fix: 전투 시뮬레이터 기본 특기 계약 복원
Ref와 같이 시나리오 defaultSpecialDomestic을 공격자와 수비자 payload에 적용한다. 기본 5병종 5x5 고정 seed Ref 차등 회귀와 API·Chromium 회귀를 추가한다.
This commit is contained in:
@@ -31,6 +31,16 @@ const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback
|
|||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveOptionalString = (record: Record<string, unknown>, keys: string[]): string | null => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveUnitSetName = (worldState: WorldStateRow, fallback: string): string => {
|
const resolveUnitSetName = (worldState: WorldStateRow, fallback: string): string => {
|
||||||
const config = asRecord(worldState.config);
|
const config = asRecord(worldState.config);
|
||||||
const environment = asRecord(config.environment ?? config.map);
|
const environment = asRecord(config.environment ?? config.map);
|
||||||
@@ -88,6 +98,7 @@ export interface BattleSimEnvironment {
|
|||||||
config: WarEngineConfig;
|
config: WarEngineConfig;
|
||||||
startYear: number;
|
startYear: number;
|
||||||
scenarioEffect: ScenarioEffectKey | null;
|
scenarioEffect: ScenarioEffectKey | null;
|
||||||
|
defaultSpecialDomestic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildBattleSimSeedBase = (request: Pick<BattleSimRequestPayload, 'seed'>): string | null =>
|
export const buildBattleSimSeedBase = (request: Pick<BattleSimRequestPayload, 'seed'>): string | null =>
|
||||||
@@ -133,6 +144,7 @@ export const buildBattleSimEnvironment = async (
|
|||||||
config,
|
config,
|
||||||
startYear: resolveStartYear(worldState),
|
startYear: resolveStartYear(worldState),
|
||||||
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
|
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
|
||||||
|
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']) ?? 'None',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export const battleRouter = router({
|
|||||||
config: environment.config,
|
config: environment.config,
|
||||||
unitSet: environment.unitSet,
|
unitSet: environment.unitSet,
|
||||||
scenarioEffect: environment.scenarioEffect,
|
scenarioEffect: environment.scenarioEffect,
|
||||||
|
defaultSpecialDomestic: environment.defaultSpecialDomestic,
|
||||||
nationTypes: traits.nationTypes,
|
nationTypes: traits.nationTypes,
|
||||||
eventDomesticTraits: traits.eventDomesticTraits,
|
eventDomesticTraits: traits.eventDomesticTraits,
|
||||||
warTraits: traits.warTraits,
|
warTraits: traits.warTraits,
|
||||||
|
|||||||
@@ -319,7 +319,10 @@ describe('battle router orchestration', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
|
config: {
|
||||||
|
environment: { scenarioEffect: 'event_MoreEffect' },
|
||||||
|
const: { defaultSpecialDomestic: 'che_event_신산' },
|
||||||
|
},
|
||||||
meta: { scenarioMeta: { startYear: 180 } },
|
meta: { scenarioMeta: { startYear: 180 } },
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
};
|
};
|
||||||
@@ -339,6 +342,7 @@ describe('battle router orchestration', () => {
|
|||||||
expect(context).toMatchObject({
|
expect(context).toMatchObject({
|
||||||
world: { startYear: 180, currentYear: 200, currentMonth: 1 },
|
world: { startYear: 180, currentYear: 200, currentMonth: 1 },
|
||||||
scenarioEffect: 'event_MoreEffect',
|
scenarioEffect: 'event_MoreEffect',
|
||||||
|
defaultSpecialDomestic: 'che_event_신산',
|
||||||
config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 },
|
config: { armPerPhase: 500, maxTrainByWar: 110, maxAtmosByWar: 150 },
|
||||||
});
|
});
|
||||||
expect(context.unitSet.crewTypes?.[0]).toMatchObject({
|
expect(context.unitSet.crewTypes?.[0]).toMatchObject({
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const readImage = async (relative: string): Promise<Buffer> => {
|
|||||||
|
|
||||||
const simulatorFormOptions = {
|
const simulatorFormOptions = {
|
||||||
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
|
world: { startYear: 190, currentYear: 205, currentMonth: 8 },
|
||||||
|
defaultSpecialDomestic: 'None',
|
||||||
config: {
|
config: {
|
||||||
maxTrainByWar: 120,
|
maxTrainByWar: 120,
|
||||||
maxAtmosByWar: 120,
|
maxAtmosByWar: 120,
|
||||||
@@ -416,7 +417,8 @@ test('operates independent/game presets, imports my general, and renders battle
|
|||||||
expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]);
|
expect(fixture.prepareResponseBytes).toEqual([expect.any(Number)]);
|
||||||
expect(fixture.prepareResponseBytes?.every((bytes) => bytes < 128)).toBe(true);
|
expect(fixture.prepareResponseBytes?.every((bytes) => bytes < 128)).toBe(true);
|
||||||
expect(fixture.preparedPayloads[0]).toMatchObject({
|
expect(fixture.preparedPayloads[0]).toMatchObject({
|
||||||
attackerGeneral: { special: 'che_event_신산' },
|
attackerGeneral: { special: 'None' },
|
||||||
|
defenderGenerals: [{ special: 'None' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadPromise = page.waitForEvent('download');
|
const downloadPromise = page.waitForEvent('download');
|
||||||
@@ -437,7 +439,8 @@ test('operates independent/game presets, imports my general, and renders battle
|
|||||||
await expect.poll(() => fixture.preparedPayloads.length).toBe(2);
|
await expect.poll(() => fixture.preparedPayloads.length).toBe(2);
|
||||||
expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]);
|
expect(await readBrowserWorkerResult(page, 1)).toEqual(fixture.serverResults[1]);
|
||||||
expect(fixture.preparedPayloads[1]).toMatchObject({
|
expect(fixture.preparedPayloads[1]).toMatchObject({
|
||||||
attackerGeneral: { special: 'che_event_신산' },
|
attackerGeneral: { special: 'None' },
|
||||||
|
defenderGenerals: [{ special: 'None' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (artifactRoot) {
|
if (artifactRoot) {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type BattleSimOptions = {
|
|||||||
config: WarEngineConfig;
|
config: WarEngineConfig;
|
||||||
unitSet: UnitSetDefinition;
|
unitSet: UnitSetDefinition;
|
||||||
scenarioEffect: string | null;
|
scenarioEffect: string | null;
|
||||||
|
defaultSpecialDomestic: string;
|
||||||
nationTypes: Array<{ key: string; name: string; info: string }>;
|
nationTypes: Array<{ key: string; name: string; info: string }>;
|
||||||
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
|
eventDomesticTraits: Array<{ key: string; name: string; info: string }>;
|
||||||
warTraits: Array<{ key: string; name: string; info: string }>;
|
warTraits: Array<{ key: string; name: string; info: string }>;
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ const buildGeneralPayload = (
|
|||||||
personal: general.personal,
|
personal: general.personal,
|
||||||
// Ref does not expose the domestic speciality in this form. Battle requests
|
// Ref does not expose the domestic speciality in this form. Battle requests
|
||||||
// always use the scenario's default domestic speciality instead.
|
// always use the scenario's default domestic speciality instead.
|
||||||
special: options.value?.eventDomesticTraits[0]?.key ?? general.special,
|
special: options.value?.defaultSpecialDomestic ?? 'None',
|
||||||
special2: general.special2,
|
special2: general.special2,
|
||||||
crew: general.crew,
|
crew: general.crew,
|
||||||
crewtype: general.crewtype,
|
crewtype: general.crewtype,
|
||||||
|
|||||||
@@ -1929,6 +1929,125 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'matches the five basic arm-type matrix and wizard probabilities with Ref defaults',
|
||||||
|
{ timeout: 180_000 },
|
||||||
|
() => {
|
||||||
|
const unitSet = readJson<UnitSetDefinition>(
|
||||||
|
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||||
|
);
|
||||||
|
const config: WarEngineConfig = {
|
||||||
|
armPerPhase: 500,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
maxTrainByWar: 110,
|
||||||
|
maxAtmosByWar: 150,
|
||||||
|
castleCrewTypeId: 1000,
|
||||||
|
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
|
||||||
|
};
|
||||||
|
const basicCrews = [
|
||||||
|
{ id: 1100, armType: 1, label: 'footman' },
|
||||||
|
{ id: 1200, armType: 2, label: 'archer' },
|
||||||
|
{ id: 1300, armType: 3, label: 'cavalry' },
|
||||||
|
{ id: 1400, armType: 4, label: 'wizard' },
|
||||||
|
{ id: 1501, armType: 5, label: 'siege' },
|
||||||
|
] as const;
|
||||||
|
for (const expected of basicCrews) {
|
||||||
|
expect(
|
||||||
|
unitSet.crewTypes?.find(({ id }) => id === expected.id)?.armType,
|
||||||
|
`${expected.label} arm type`
|
||||||
|
).toBe(expected.armType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
attacker: (typeof basicCrews)[number];
|
||||||
|
defender: (typeof basicCrews)[number];
|
||||||
|
fixture: BattleSimRequestPayload & { startYear: number };
|
||||||
|
}> = [];
|
||||||
|
for (const attacker of basicCrews) {
|
||||||
|
for (const defender of basicCrews) {
|
||||||
|
const fixture = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
);
|
||||||
|
fixture.seed = `battle-differential-basic-matrix-${attacker.armType}-${defender.armType}`;
|
||||||
|
fixture.attackerGeneral.crewtype = attacker.id;
|
||||||
|
fixture.attackerGeneral.crew = 50000;
|
||||||
|
fixture.attackerGeneral.rice = 1000000;
|
||||||
|
fixture.attackerGeneral.leadership = 90;
|
||||||
|
fixture.attackerGeneral.strength = 90;
|
||||||
|
fixture.attackerGeneral.intel = 90;
|
||||||
|
fixture.attackerGeneral.personal = 'None';
|
||||||
|
fixture.attackerGeneral.special = 'None';
|
||||||
|
fixture.attackerGeneral.special2 = 'None';
|
||||||
|
fixture.attackerGeneral.dex1 = 12000;
|
||||||
|
fixture.attackerGeneral.dex2 = 12000;
|
||||||
|
fixture.attackerGeneral.dex3 = 12000;
|
||||||
|
fixture.attackerGeneral.dex4 = 12000;
|
||||||
|
fixture.attackerGeneral.dex5 = 12000;
|
||||||
|
|
||||||
|
const defenderGeneral = fixture.defenderGenerals[0]!;
|
||||||
|
defenderGeneral.crewtype = defender.id;
|
||||||
|
defenderGeneral.crew = 50000;
|
||||||
|
defenderGeneral.rice = 1000000;
|
||||||
|
defenderGeneral.leadership = 85;
|
||||||
|
defenderGeneral.strength = 85;
|
||||||
|
defenderGeneral.intel = 85;
|
||||||
|
defenderGeneral.personal = 'None';
|
||||||
|
defenderGeneral.special = 'None';
|
||||||
|
defenderGeneral.special2 = 'None';
|
||||||
|
defenderGeneral.dex1 = 12000;
|
||||||
|
defenderGeneral.dex2 = 12000;
|
||||||
|
defenderGeneral.dex3 = 12000;
|
||||||
|
defenderGeneral.dex4 = 12000;
|
||||||
|
defenderGeneral.dex5 = 12000;
|
||||||
|
cases.push({ attacker, defender, fixture });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cases).toHaveLength(25);
|
||||||
|
const references = runReferenceTraceBatch(
|
||||||
|
workspaceRoot!,
|
||||||
|
cases.map(({ fixture }) => JSON.stringify(fixture))
|
||||||
|
);
|
||||||
|
cases.forEach(({ attacker, defender, fixture }, index) => {
|
||||||
|
const coreEvents: WarBattleTraceEvent[] = [];
|
||||||
|
const coreLogs = createCoreLogCapture();
|
||||||
|
let coreRng: TracingRng | null = null;
|
||||||
|
let coreOutcome: WarBattleOutcome | null = null;
|
||||||
|
processBattleSimJob(
|
||||||
|
{
|
||||||
|
...fixture,
|
||||||
|
unitSet,
|
||||||
|
config,
|
||||||
|
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trace: (event) => coreEvents.push(event),
|
||||||
|
loggerFactory: coreLogs.loggerFactory,
|
||||||
|
onBattleResolved: (outcome) => {
|
||||||
|
coreOutcome = outcome;
|
||||||
|
},
|
||||||
|
rngFactory: (seed) => {
|
||||||
|
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||||
|
return coreRng.createRandUtil();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reference = references[index]!;
|
||||||
|
const label = `basic-matrix.${attacker.label}.${defender.label}`;
|
||||||
|
try {
|
||||||
|
assertTraceParity(coreEvents, reference, coreRng, coreOutcome);
|
||||||
|
assertAllLogBucketsParity(coreLogs, reference, fixture, label);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
it('matches wizard strategy attempts, outcomes, and RNG consumption', () => {
|
||||||
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
const base = readJson<BattleSimRequestPayload & { startYear: number }>(
|
||||||
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
|
||||||
|
|||||||
Reference in New Issue
Block a user