merge: scenario 2400 battle replay parity
This commit is contained in:
@@ -134,11 +134,14 @@ const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => (
|
||||
},
|
||||
});
|
||||
|
||||
const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): General => ({
|
||||
const mapGeneralPayload = (
|
||||
payload: BattleSimJobPayload['attackerGeneral'],
|
||||
currentCityId: number
|
||||
): General => ({
|
||||
id: payload.no,
|
||||
name: payload.name,
|
||||
nationId: payload.nation,
|
||||
cityId: payload.officer_city,
|
||||
cityId: payload.city ?? currentCityId,
|
||||
troopId: 0,
|
||||
stats: {
|
||||
leadership: payload.leadership,
|
||||
@@ -184,10 +187,12 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen
|
||||
dex3: payload.dex3,
|
||||
dex4: payload.dex4,
|
||||
dex5: payload.dex5,
|
||||
intelExp: payload.intel_exp,
|
||||
strengthExp: payload.strength_exp,
|
||||
leadershipExp: payload.leadership_exp,
|
||||
defenceTrain: payload.defence_train,
|
||||
intel_exp: payload.intel_exp,
|
||||
strength_exp: payload.strength_exp,
|
||||
leadership_exp: payload.leadership_exp,
|
||||
defence_train: payload.defence_train,
|
||||
officerCity: payload.officer_city,
|
||||
officer_city: payload.officer_city,
|
||||
rank_warnum: payload.warnum,
|
||||
rank_killnum: payload.killnum,
|
||||
rank_killcrew: payload.killcrew,
|
||||
@@ -304,8 +309,8 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
||||
const defenderNation = mapNationPayload(payload.defenderNation);
|
||||
const attackerCity = mapCityPayload(payload.attackerCity);
|
||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
||||
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
||||
const attacker = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
|
||||
const defenders = payload.defenderGenerals.map((general) => mapGeneralPayload(general, defenderCity.id));
|
||||
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||
|
||||
return resolveDefenderOrder({
|
||||
@@ -376,8 +381,10 @@ export const processBattleSimJob = (
|
||||
const defenderNation = mapNationPayload(payload.defenderNation);
|
||||
const attackerCity = mapCityPayload(payload.attackerCity);
|
||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||
const attackerGeneral = mapGeneralPayload(payload.attackerGeneral);
|
||||
const defenderGenerals = payload.defenderGenerals.map(mapGeneralPayload);
|
||||
const attackerGeneral = mapGeneralPayload(payload.attackerGeneral, attackerCity.id);
|
||||
const defenderGenerals = payload.defenderGenerals.map((general) =>
|
||||
mapGeneralPayload(general, defenderCity.id)
|
||||
);
|
||||
|
||||
const initialRice = new Map<number, number>();
|
||||
initialRice.set(attackerGeneral.id, attackerGeneral.rice);
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zBattleSimGeneral = z.object({
|
||||
no: z.number().int().positive(),
|
||||
name: z.string().min(1),
|
||||
nation: z.number().int().positive(),
|
||||
city: z.number().int().min(0).optional(),
|
||||
turntime: z.string().min(1),
|
||||
personal: z.string().nullable(),
|
||||
special: z.string().nullable().optional(),
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface BattleSimGeneralPayload {
|
||||
no: number;
|
||||
name: string;
|
||||
nation: number;
|
||||
/** Current city. Older clients omit this; the surrounding city payload is authoritative then. */
|
||||
city?: number;
|
||||
turntime: string;
|
||||
personal: string | null;
|
||||
special?: string | null;
|
||||
|
||||
@@ -237,9 +237,9 @@ describe('battle sim processor', () => {
|
||||
datetime: '2026-01-01 00:00:00',
|
||||
avgWar: 1,
|
||||
phase: 2,
|
||||
killed: 625,
|
||||
maxKilled: 625,
|
||||
minKilled: 625,
|
||||
killed: 626,
|
||||
maxKilled: 626,
|
||||
minKilled: 626,
|
||||
dead: 1000,
|
||||
maxDead: 1000,
|
||||
minDead: 1000,
|
||||
@@ -267,6 +267,30 @@ describe('battle sim processor', () => {
|
||||
expect(result.order).toEqual([2]);
|
||||
});
|
||||
|
||||
it('uses the legacy defence training threshold when ordering defenders', () => {
|
||||
const payload = buildPayload('reorder');
|
||||
payload.defenderGenerals[0]!.defence_train = 101;
|
||||
|
||||
const result = processBattleSimJob(payload);
|
||||
|
||||
expect(result.result).toBe(true);
|
||||
expect(result.order).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps current city separate from the officer assignment city', () => {
|
||||
const localOfficer = buildPayload('battle');
|
||||
localOfficer.attackerGeneral.city = localOfficer.attackerCity.city;
|
||||
const remoteOfficer = buildPayload('battle');
|
||||
remoteOfficer.attackerGeneral.city = remoteOfficer.attackerCity.city;
|
||||
remoteOfficer.attackerGeneral.officer_city = 99;
|
||||
|
||||
const local = processBattleSimJob(localOfficer);
|
||||
const remote = processBattleSimJob(remoteOfficer);
|
||||
|
||||
expect(local.killed).toBeGreaterThan(remote.killed ?? 0);
|
||||
expect(local).not.toEqual(remote);
|
||||
});
|
||||
|
||||
it('executes crew trigger handlers in simulator battles', () => {
|
||||
const payload = buildPayload('battle');
|
||||
payload.unitSet.crewTypes![0]!.phaseSkillTrigger = ['che_선제사격시도', 'che_선제사격발동'];
|
||||
|
||||
@@ -82,6 +82,119 @@ const toHex = (bytes: Uint8Array): string =>
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const fixtureNumber = (value: unknown, fallback = 0): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const formatFixtureDate = (value: Date | undefined): string =>
|
||||
value ? value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, '') : '1970-01-01 00:00:00';
|
||||
|
||||
const buildBattleGeneralFixture = <TriggerState extends GeneralTriggerState>(general: General<TriggerState>) => {
|
||||
const meta = general.meta;
|
||||
const rawInheritBuff = general.triggerState.meta.inheritBuff;
|
||||
let inheritBuff: Record<string, number> | number[] | undefined;
|
||||
if (Array.isArray(rawInheritBuff) && rawInheritBuff.every((value) => typeof value === 'number')) {
|
||||
inheritBuff = rawInheritBuff;
|
||||
} else if (typeof rawInheritBuff === 'object' && rawInheritBuff !== null) {
|
||||
inheritBuff = Object.fromEntries(
|
||||
Object.entries(rawInheritBuff).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
|
||||
);
|
||||
} else if (typeof rawInheritBuff === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawInheritBuff);
|
||||
if (Array.isArray(parsed) && parsed.every((value) => typeof value === 'number')) {
|
||||
inheritBuff = parsed;
|
||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||
inheritBuff = Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, number] => typeof entry[1] === 'number'
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A malformed comparison-only projection must not affect the battle.
|
||||
}
|
||||
}
|
||||
return {
|
||||
no: general.id,
|
||||
name: general.name,
|
||||
nation: general.nationId,
|
||||
city: general.cityId,
|
||||
turntime: formatFixtureDate(general.turnTime),
|
||||
personal: general.role.personality,
|
||||
special: general.role.specialDomestic,
|
||||
special2: general.role.specialWar,
|
||||
crew: general.crew,
|
||||
crewtype: general.crewTypeId,
|
||||
atmos: general.atmos,
|
||||
train: general.train,
|
||||
intel: general.stats.intelligence,
|
||||
intel_exp: fixtureNumber(meta.intel_exp),
|
||||
book: general.role.items.book,
|
||||
strength: general.stats.strength,
|
||||
strength_exp: fixtureNumber(meta.strength_exp),
|
||||
weapon: general.role.items.weapon,
|
||||
injury: general.injury,
|
||||
leadership: general.stats.leadership,
|
||||
leadership_exp: fixtureNumber(meta.leadership_exp),
|
||||
horse: general.role.items.horse,
|
||||
item: general.role.items.item,
|
||||
explevel: fixtureNumber(meta.explevel),
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
officer_level: general.officerLevel,
|
||||
officer_city: fixtureNumber(meta.officer_city ?? meta.officerCity),
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
dex1: fixtureNumber(meta.dex1),
|
||||
dex2: fixtureNumber(meta.dex2),
|
||||
dex3: fixtureNumber(meta.dex3),
|
||||
dex4: fixtureNumber(meta.dex4),
|
||||
dex5: fixtureNumber(meta.dex5),
|
||||
defence_train: fixtureNumber(meta.defence_train),
|
||||
recent_war: general.recentWarTime ? formatFixtureDate(general.recentWarTime) : null,
|
||||
warnum: fixtureNumber(meta.rank_warnum),
|
||||
killnum: fixtureNumber(meta.rank_killnum),
|
||||
killcrew: fixtureNumber(meta.rank_killcrew),
|
||||
...(inheritBuff ? { inheritBuff } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildBattleCityFixture = (city: City) => ({
|
||||
city: city.id,
|
||||
nation: city.nationId,
|
||||
supply: city.supplyState,
|
||||
name: city.name,
|
||||
pop: city.population,
|
||||
agri: city.agriculture,
|
||||
comm: city.commerce,
|
||||
secu: city.security,
|
||||
def: city.defence,
|
||||
wall: city.wall,
|
||||
trust: fixtureNumber(city.meta.trust),
|
||||
level: city.level,
|
||||
pop_max: city.populationMax,
|
||||
agri_max: city.agricultureMax,
|
||||
comm_max: city.commerceMax,
|
||||
secu_max: city.securityMax,
|
||||
def_max: city.defenceMax,
|
||||
wall_max: city.wallMax,
|
||||
dead: fixtureNumber(city.meta.dead),
|
||||
state: city.state,
|
||||
conflict: JSON.stringify(city.conflict ?? {}),
|
||||
});
|
||||
|
||||
const buildBattleNationFixture = (nation: Nation | null) => ({
|
||||
type: nation?.typeCode ?? 'None',
|
||||
tech: fixtureNumber(nation?.meta.tech),
|
||||
level: nation?.level ?? 0,
|
||||
capital: nation?.capitalCityId ?? 0,
|
||||
nation: nation?.id ?? 0,
|
||||
name: nation?.name ?? '재야',
|
||||
gold: nation?.gold ?? 0,
|
||||
rice: nation?.rice ?? 10000,
|
||||
gennum: fixtureNumber(nation?.meta.gennum, 1),
|
||||
});
|
||||
|
||||
const buildAllowedNationIds = (
|
||||
attackerNationId: number,
|
||||
diplomacy: Array<{ fromNationId: number; toNationId: number; state: number }>
|
||||
@@ -455,6 +568,26 @@ export class ActionDefinition<
|
||||
traceGeneralIds.has(String(context.general.id)) ||
|
||||
defenderGenerals.some((general) => traceGeneralIds.has(String(general.id)));
|
||||
|
||||
if (process.env.CORE_BATTLE_FIXTURE_TRACE === '1') {
|
||||
process.stdout.write(
|
||||
`AI_WAR_FIXTURE_CORE ${JSON.stringify({
|
||||
action: 'battle',
|
||||
seed,
|
||||
repeatCnt: 1,
|
||||
year: time.year,
|
||||
month: time.month,
|
||||
startYear: time.startYear,
|
||||
scenarioEffect: null,
|
||||
attackerGeneral: buildBattleGeneralFixture(context.general),
|
||||
attackerCity: buildBattleCityFixture(attackerCity),
|
||||
attackerNation: buildBattleNationFixture(attackerNation),
|
||||
defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture),
|
||||
defenderCity: buildBattleCityFixture(defenderCity),
|
||||
defenderNation: buildBattleNationFixture(defenderNation),
|
||||
})}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const battle = resolveWarBattle({
|
||||
seed,
|
||||
unitSet,
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import type { CrewTypeDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
|
||||
const isWideCodePoint = (codePoint: number): boolean =>
|
||||
codePoint >= 0x1100 &&
|
||||
(codePoint <= 0x115f ||
|
||||
codePoint === 0x2329 ||
|
||||
codePoint === 0x232a ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||
(codePoint >= 0x1b000 && codePoint <= 0x1b001) ||
|
||||
(codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
|
||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
||||
|
||||
const substringForDisplayWidth = (value: string, width: number): string => {
|
||||
let currentWidth = 0;
|
||||
let result = '';
|
||||
for (const character of value) {
|
||||
const characterWidth = isWideCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
if (currentWidth + characterWidth > width) {
|
||||
break;
|
||||
}
|
||||
result += character;
|
||||
currentWidth += characterWidth;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 전투 계산에 필요한 병종 정보 래퍼.
|
||||
export class WarCrewType {
|
||||
constructor(private readonly definition: CrewTypeDefinition) {}
|
||||
@@ -61,10 +91,9 @@ export class WarCrewType {
|
||||
}
|
||||
|
||||
getShortName(): string {
|
||||
if (this.definition.name.length <= 4) {
|
||||
return this.definition.name;
|
||||
}
|
||||
return this.definition.name.slice(0, 4);
|
||||
// Ref uses mb_strwidth(..., 'UTF-8') and truncates at display width 4.
|
||||
// Korean/CJK characters consume two columns, unlike JS string length.
|
||||
return substringForDisplayWidth(this.definition.name, 4);
|
||||
}
|
||||
|
||||
getAttackCoef(oppose: WarCrewType): number {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { WarUnit } from '@sammo-ts/logic/war/units.js';
|
||||
|
||||
export class che_격노시도 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||
super(unit, TriggerPriority.Pre + 300, raiseType);
|
||||
super(unit, TriggerPriority.Body + 400, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
@@ -40,7 +40,7 @@ export class che_격노시도 extends BaseWarUnitTrigger {
|
||||
|
||||
export class che_격노발동 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit, raiseType: number = 0) {
|
||||
super(unit, TriggerPriority.Post + 450, raiseType);
|
||||
super(unit, TriggerPriority.Post + 600, raiseType);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
|
||||
@@ -51,7 +51,7 @@ export class che_계략시도 extends BaseWarUnitTrigger {
|
||||
|
||||
const general = self.getGeneral();
|
||||
let trialProbability =
|
||||
(self.getComputedStat('intelligence', general.stats.intelligence) / 100) *
|
||||
(self.getComputedStat('intelligence', general.stats.intelligence, { truncate: false }) / 100) *
|
||||
self.getCrewType().magicCoef;
|
||||
trialProbability = self
|
||||
.getActionPipeline()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { JosaUtil } from '@sammo-ts/common';
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
|
||||
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
|
||||
@@ -7,7 +8,7 @@ export class che_반계시도 extends BaseWarUnitTrigger {
|
||||
private readonly prob: number;
|
||||
|
||||
constructor(unit: WarUnit, prob = 0.4) {
|
||||
super(unit, TriggerPriority.Pre + 200);
|
||||
super(unit, TriggerPriority.Body + 300);
|
||||
this.prob = prob;
|
||||
}
|
||||
|
||||
@@ -37,7 +38,7 @@ export class che_반계시도 extends BaseWarUnitTrigger {
|
||||
|
||||
export class che_반계발동 extends BaseWarUnitTrigger {
|
||||
constructor(unit: WarUnit) {
|
||||
super(unit, TriggerPriority.Post + 150);
|
||||
super(unit, TriggerPriority.Post + 250);
|
||||
}
|
||||
|
||||
protected actionWar(
|
||||
@@ -56,12 +57,15 @@ export class che_반계발동 extends BaseWarUnitTrigger {
|
||||
}
|
||||
|
||||
const [opposeMagic, damage] = magicData;
|
||||
const particle = JosaUtil.pick(opposeMagic, '을');
|
||||
|
||||
self.getLogger().pushGeneralBattleDetailLog(
|
||||
`<C>반계</>로 상대의 <D>${opposeMagic}</>을 되돌렸다!`,
|
||||
`<C>반계</>로 상대의 <D>${opposeMagic}</>${particle} 되돌렸다!`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
oppose.getLogger().pushGeneralBattleDetailLog(`<D>${opposeMagic}</>을 <R>역으로</> 당했다!`, LogFormat.PLAIN);
|
||||
oppose
|
||||
.getLogger()
|
||||
.pushGeneralBattleDetailLog(`<D>${opposeMagic}</>${particle} <R>역으로</> 당했다!`, LogFormat.PLAIN);
|
||||
|
||||
self.multiplyWarPowerMultiply(damage);
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@ export class che_위압발동 extends BaseWarUnitTrigger {
|
||||
return true;
|
||||
}
|
||||
|
||||
oppose.getLogger().pushGeneralBattleDetailLog('상대에게 <R>위압</>받았다!', LogFormat.PLAIN);
|
||||
self.getLogger().pushGeneralBattleDetailLog('상대에게 <C>위압</>을 줬다!', LogFormat.PLAIN);
|
||||
// Preserve Ref's historical extra closing marker in the rendered log.
|
||||
oppose.getLogger().pushGeneralBattleDetailLog('상대에게 <R>위압</>받았다!</>', LogFormat.PLAIN);
|
||||
self.getLogger().pushGeneralBattleDetailLog('상대에게 <C>위압</>을 줬다!</>', LogFormat.PLAIN);
|
||||
oppose.setWarPowerMultiply(0);
|
||||
if (canAddAtmos(oppose)) {
|
||||
oppose.addAtmos(-5);
|
||||
|
||||
@@ -163,6 +163,11 @@ const fireTriggers = (keys: string[], self: WarUnit, attacker: WarUnit, defender
|
||||
};
|
||||
|
||||
describe('crew type catalog', () => {
|
||||
it('uses the legacy display-width limit for battle log crew names', () => {
|
||||
expect(new WarCrewType(crewType(1405, 4, '남귀병')).getShortName()).toBe('남귀');
|
||||
expect(new WarCrewType(crewType(1, 1, 'AB한C')).getShortName()).toBe('AB한');
|
||||
});
|
||||
|
||||
it('compiles every shipped unit set and resolves all crew handlers', async () => {
|
||||
const unitSetDirectory = new URL('../../../resources/unitset/', import.meta.url);
|
||||
const fileNames = (await readdir(unitSetDirectory)).filter((fileName) => fileName.endsWith('.json'));
|
||||
|
||||
@@ -23,11 +23,20 @@ import {
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js';
|
||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from '../../../app/game-api/src/battleSim/types.js';
|
||||
import { convertLog } from '../../../app/game-api/src/battleSim/logFormatter.js';
|
||||
import type {
|
||||
BattleSimGeneralPayload,
|
||||
BattleSimJobPayload,
|
||||
BattleSimRequestPayload,
|
||||
} from '../../../app/game-api/src/battleSim/types.js';
|
||||
|
||||
interface ReferenceTrace {
|
||||
engine: 'ref';
|
||||
conquered: boolean;
|
||||
defenderOrder?: {
|
||||
before: Array<{ id: number; order: number }>;
|
||||
after: Array<{ id: number; order: number }>;
|
||||
};
|
||||
events: WarBattleTraceEvent[];
|
||||
rng: RandomCall[];
|
||||
logs: {
|
||||
@@ -127,6 +136,28 @@ const findWorkspaceRoot = (start: string): string | null => {
|
||||
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
|
||||
|
||||
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||
if (compareContainer) {
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'exec',
|
||||
'-i',
|
||||
compareContainer,
|
||||
'php',
|
||||
'-d',
|
||||
'error_reporting=8191',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'-',
|
||||
],
|
||||
{
|
||||
input: fixtureJson,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
}
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (compareSourceRoot) {
|
||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
||||
@@ -194,6 +225,117 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc
|
||||
return JSON.parse(stdout) as ReferenceTrace;
|
||||
};
|
||||
|
||||
const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => {
|
||||
const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH;
|
||||
if (precomputedTracePath) {
|
||||
const traces = fs
|
||||
.readFileSync(path.resolve(precomputedTracePath), 'utf8')
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
if (traces.length < fixtureLines.length) {
|
||||
throw new Error(`precomputed ref corpus has ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
return traces.slice(0, fixtureLines.length);
|
||||
}
|
||||
const compareContainer = process.env.REF_COMPARE_CONTAINER;
|
||||
if (compareContainer) {
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'exec',
|
||||
'-i',
|
||||
compareContainer,
|
||||
'php',
|
||||
'-d',
|
||||
'error_reporting=8191',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'--jsonl',
|
||||
],
|
||||
{
|
||||
input: `${fixtureLines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
return stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace);
|
||||
}
|
||||
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
|
||||
if (!compareSourceRoot) {
|
||||
throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.');
|
||||
}
|
||||
const resolvedCompareRoot = path.resolve(compareSourceRoot);
|
||||
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-'));
|
||||
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const relative = path.relative(resolvedCompareRoot, source);
|
||||
return !(
|
||||
relative === '.git' ||
|
||||
relative.startsWith(`.git${path.sep}`) ||
|
||||
relative === 'vendor' ||
|
||||
relative.startsWith(`vendor${path.sep}`) ||
|
||||
relative === 'd_log' ||
|
||||
relative.startsWith(`d_log${path.sep}`) ||
|
||||
relative === path.join('hwe', 'd_setting') ||
|
||||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
|
||||
);
|
||||
},
|
||||
});
|
||||
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
|
||||
try {
|
||||
const traces: ReferenceTrace[] = [];
|
||||
const chunkSize = 200;
|
||||
for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) {
|
||||
const chunk = fixtureLines.slice(offset, offset + chunkSize);
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-i',
|
||||
'-v',
|
||||
`${runtimeRoot}:/var/www/html`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
|
||||
'-v',
|
||||
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
|
||||
'sam-rebuild-ref-php:8.3',
|
||||
'php',
|
||||
'-d',
|
||||
'display_errors=0',
|
||||
'-d',
|
||||
'log_errors=0',
|
||||
'/var/www/html/hwe/compare/battle_trace.php',
|
||||
'--jsonl',
|
||||
],
|
||||
{
|
||||
input: `${chunk.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
traces.push(
|
||||
...stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ReferenceTrace)
|
||||
);
|
||||
}
|
||||
if (traces.length !== fixtureLines.length) {
|
||||
throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`);
|
||||
}
|
||||
return traces;
|
||||
} finally {
|
||||
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record<string, ReferenceItemMetadata> => {
|
||||
const stdout = execFileSync(
|
||||
'docker',
|
||||
@@ -229,7 +371,16 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v
|
||||
return;
|
||||
}
|
||||
const reference = expected as number;
|
||||
const tolerance = Math.max(1, Math.abs(reference) * 0.01);
|
||||
const configuredRelativeTolerance = Number.parseFloat(
|
||||
process.env['BATTLE_TRACE_RELATIVE_TOLERANCE'] ?? '0.01'
|
||||
);
|
||||
if (!Number.isFinite(configuredRelativeTolerance) || configuredRelativeTolerance < 0) {
|
||||
throw new Error('BATTLE_TRACE_RELATIVE_TOLERANCE must be a non-negative finite number');
|
||||
}
|
||||
const tolerance = Math.max(
|
||||
Number.EPSILON * Math.max(1, Math.abs(reference)) * 8,
|
||||
Math.abs(reference) * configuredRelativeTolerance
|
||||
);
|
||||
expect(
|
||||
Math.abs((actual as number) - reference),
|
||||
`${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}`
|
||||
@@ -239,35 +390,112 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v
|
||||
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
|
||||
Array.isArray(value) && value.length === 0 ? {} : value;
|
||||
|
||||
const assertTraceParity = (
|
||||
coreEvents: WarBattleTraceEvent[],
|
||||
reference: ReferenceTrace,
|
||||
coreRng: TracingRng | null
|
||||
): void => {
|
||||
const coreEventNames = coreEvents.map((event) => event.event);
|
||||
const referenceEventNames = reference.events.map((event) => event.event);
|
||||
expect(
|
||||
coreEventNames,
|
||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
||||
).toEqual(referenceEventNames);
|
||||
expect(
|
||||
const describeSequenceDifference = (label: string, actual: unknown[], expected: unknown[]): string | null => {
|
||||
const commonLength = Math.min(actual.length, expected.length);
|
||||
for (let index = 0; index < commonLength; index += 1) {
|
||||
if (JSON.stringify(actual[index]) !== JSON.stringify(expected[index])) {
|
||||
const start = Math.max(0, index - 2);
|
||||
const end = index + 3;
|
||||
return `${label}[${index}]: core=${JSON.stringify(actual.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`;
|
||||
}
|
||||
}
|
||||
if (actual.length !== expected.length) {
|
||||
return `${label} length: core=${actual.length} ref=${expected.length}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const describeTextDifference = (actual: string | undefined, expected: string): string => {
|
||||
const actualText = actual ?? '';
|
||||
let index = 0;
|
||||
while (index < actualText.length && index < expected.length && actualText[index] === expected[index]) {
|
||||
index += 1;
|
||||
}
|
||||
const start = Math.max(0, index - 80);
|
||||
const end = index + 160;
|
||||
return `offset=${index} core=${JSON.stringify(actualText.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`;
|
||||
};
|
||||
|
||||
const normalizeCapturedDefenders = (
|
||||
fixtureLine: string,
|
||||
defenders: BattleSimGeneralPayload[] | Record<string, BattleSimGeneralPayload>
|
||||
): BattleSimGeneralPayload[] => {
|
||||
if (Array.isArray(defenders)) {
|
||||
return defenders;
|
||||
}
|
||||
// JSON.parse enumerates integer-like object keys numerically, but PHP's
|
||||
// associative array preserves their source order. Recover that order for
|
||||
// old Ref corpus lines so stable sort ties replay the same defenders.
|
||||
const source = /"defenderGenerals":\{([\s\S]*?)\},"defenderCity":/u.exec(fixtureLine)?.[1];
|
||||
if (!source) {
|
||||
return Object.values(defenders);
|
||||
}
|
||||
const ids = [...source.matchAll(/"(\d+)":\{/gu)].map((match) => match[1]!);
|
||||
return ids.map((id) => defenders[id]).filter((general): general is BattleSimGeneralPayload => Boolean(general));
|
||||
};
|
||||
|
||||
const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null): void => {
|
||||
const normalizedCoreRng =
|
||||
coreRng?.calls.map(({ seq, operation, arguments: args, result }) => ({
|
||||
seq,
|
||||
operation,
|
||||
arguments: normalizeRandomArguments(args),
|
||||
result,
|
||||
}))
|
||||
).toEqual(
|
||||
reference.rng.map(({ seq, operation, arguments: args, result }) => ({
|
||||
seq,
|
||||
operation,
|
||||
arguments: normalizeRandomArguments(args),
|
||||
result,
|
||||
}))
|
||||
);
|
||||
})) ?? [];
|
||||
const normalizedReferenceRng = reference.rng.map(({ seq, operation, arguments: args, result }) => ({
|
||||
seq,
|
||||
operation,
|
||||
arguments: normalizeRandomArguments(args),
|
||||
result,
|
||||
}));
|
||||
const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng);
|
||||
if (rngDifference) {
|
||||
throw new Error(rngDifference);
|
||||
}
|
||||
};
|
||||
|
||||
const assertTraceParity = (
|
||||
coreEvents: WarBattleTraceEvent[],
|
||||
reference: ReferenceTrace,
|
||||
coreRng: TracingRng | null
|
||||
): void => {
|
||||
const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null;
|
||||
const comparableCoreEvents = defenderOrderEvent ? coreEvents.slice(1) : coreEvents;
|
||||
if (reference.defenderOrder) {
|
||||
// Ref retains non-participating (order <= 0) defenders at the tail and
|
||||
// stops when it reaches them. Core discards them before sorting. The
|
||||
// effective ordered defender sequence is otherwise the same.
|
||||
const effectiveReferenceOrder = {
|
||||
before: reference.defenderOrder.before.filter(({ order }) => order > 0),
|
||||
after: reference.defenderOrder.after.filter(({ order }) => order > 0),
|
||||
};
|
||||
const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined;
|
||||
expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual(
|
||||
effectiveReferenceOrder.before.map(({ id }) => id)
|
||||
);
|
||||
expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual(
|
||||
effectiveReferenceOrder.after.map(({ id }) => id)
|
||||
);
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) {
|
||||
expectNearlyEqual(
|
||||
coreOrder?.[side][index]?.order,
|
||||
effectiveReferenceOrder[side][index]?.order,
|
||||
`defender order ${side}[${index}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertRngParity(reference, coreRng);
|
||||
const coreEventNames = comparableCoreEvents.map((event) => event.event);
|
||||
const referenceEventNames = reference.events.map((event) => event.event);
|
||||
expect(
|
||||
coreEventNames,
|
||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
||||
).toEqual(referenceEventNames);
|
||||
|
||||
for (let index = 0; index < reference.events.length; index += 1) {
|
||||
const core = coreEvents[index]!;
|
||||
const core = comparableCoreEvents[index]!;
|
||||
const ref = reference.events[index]!;
|
||||
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
|
||||
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
|
||||
@@ -301,7 +529,157 @@ if (process.env.TURN_DIFFERENTIAL_REFERENCE === '1' && !workspaceRoot) {
|
||||
}
|
||||
const describeWithReference = workspaceRoot ? describe : describe.skip;
|
||||
|
||||
const battleCorpusPath = process.env.BATTLE_CORPUS_PATH;
|
||||
const itWithBattleCorpus = battleCorpusPath ? it : it.skip;
|
||||
|
||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
itWithBattleCorpus(
|
||||
'replays a captured battle corpus with matching trace, RNG, skills, outcome, and attacker logs',
|
||||
{ timeout: 600_000 },
|
||||
() => {
|
||||
const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10);
|
||||
const fixtureLines = fs
|
||||
.readFileSync(path.resolve(battleCorpusPath!), 'utf8')
|
||||
.split(/\r?\n/u)
|
||||
.filter(Boolean)
|
||||
.slice(0, Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : undefined);
|
||||
expect(fixtureLines.length, 'captured fixture count').toBeGreaterThan(0);
|
||||
|
||||
const referenceTraces = runReferenceTraceBatch(workspaceRoot!, fixtureLines);
|
||||
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 failures: string[] = [];
|
||||
const categoryCounts = new Map<string, number>();
|
||||
const recordFailure = (category: string, index: number, fixture: BattleSimRequestPayload, detail: string) => {
|
||||
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
||||
if (failures.length < 40) {
|
||||
failures.push(
|
||||
`${category} fixture=${index + 1} ${fixture.year}-${String(fixture.month).padStart(2, '0')} attacker=${fixture.attackerGeneral.no} city=${fixture.defenderCity.city}: ${detail}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (let index = 0; index < fixtureLines.length; index += 1) {
|
||||
const capturedFixture = JSON.parse(fixtureLines[index]!) as Omit<
|
||||
BattleSimRequestPayload,
|
||||
'defenderGenerals'
|
||||
> & {
|
||||
defenderGenerals: BattleSimGeneralPayload[] | Record<string, BattleSimGeneralPayload>;
|
||||
startYear: number;
|
||||
scenarioEffect?: string | null;
|
||||
};
|
||||
// Older captured Ref lines preserved numeric general IDs as
|
||||
// JSON object keys. New captures are arrays, but normalize the
|
||||
// historical corpus without changing its iteration order.
|
||||
const fixture: BattleSimRequestPayload & {
|
||||
startYear: number;
|
||||
scenarioEffect?: string | null;
|
||||
} = {
|
||||
...capturedFixture,
|
||||
defenderGenerals: normalizeCapturedDefenders(
|
||||
fixtureLines[index]!,
|
||||
capturedFixture.defenderGenerals
|
||||
),
|
||||
};
|
||||
const reference = referenceTraces[index]!;
|
||||
const coreEvents: WarBattleTraceEvent[] = [];
|
||||
let coreRng: TracingRng | null = null;
|
||||
const coreResult = processBattleSimJob(
|
||||
{
|
||||
...fixture,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear },
|
||||
scenarioEffect: fixture.scenarioEffect,
|
||||
},
|
||||
{
|
||||
trace: (event) => coreEvents.push(event),
|
||||
rngFactory: (seed) => {
|
||||
coreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(coreRng);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
} catch (error) {
|
||||
recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
const finalReference = reference.events.at(-1);
|
||||
if (finalReference) {
|
||||
const outcome = {
|
||||
phase: coreResult.phase,
|
||||
killed: coreResult.killed,
|
||||
dead: coreResult.dead,
|
||||
};
|
||||
const expectedOutcome = {
|
||||
phase: finalReference.attacker.phase,
|
||||
killed: finalReference.attacker.killed,
|
||||
dead: finalReference.attacker.dead,
|
||||
};
|
||||
if (JSON.stringify(outcome) !== JSON.stringify(expectedOutcome)) {
|
||||
recordFailure(
|
||||
'outcome',
|
||||
index,
|
||||
fixture,
|
||||
`core=${JSON.stringify(outcome)} ref=${JSON.stringify(expectedOutcome)}`
|
||||
);
|
||||
}
|
||||
const coreSkills = coreResult.attackerSkills ?? {};
|
||||
const rawReferenceSkills = finalReference.attacker.activatedSkills;
|
||||
const referenceSkills = Array.isArray(rawReferenceSkills) ? {} : (rawReferenceSkills ?? {});
|
||||
if (JSON.stringify(coreSkills) !== JSON.stringify(referenceSkills)) {
|
||||
recordFailure(
|
||||
'skills',
|
||||
index,
|
||||
fixture,
|
||||
`core=${JSON.stringify(coreSkills)} ref=${JSON.stringify(referenceSkills)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('<br>'));
|
||||
const expectedDetail = convertLog(reference.logs.attacker.generalBattleDetailLog.join('<br>'));
|
||||
if (coreResult.lastWarLog?.generalBattleResultLog !== expectedBrief) {
|
||||
recordFailure(
|
||||
'brief-log',
|
||||
index,
|
||||
fixture,
|
||||
describeTextDifference(coreResult.lastWarLog?.generalBattleResultLog, expectedBrief)
|
||||
);
|
||||
}
|
||||
if (coreResult.lastWarLog?.generalBattleDetailLog !== expectedDetail) {
|
||||
recordFailure(
|
||||
'detail-log',
|
||||
index,
|
||||
fixture,
|
||||
describeTextDifference(coreResult.lastWarLog?.generalBattleDetailLog, expectedDetail)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
fixtures: fixtureLines.length,
|
||||
failuresByCategory: Object.fromEntries([...categoryCounts.entries()].sort()),
|
||||
sampledFailures: failures,
|
||||
};
|
||||
process.stdout.write(`BATTLE_CORPUS_SUMMARY ${JSON.stringify(summary)}\n`);
|
||||
expect(failures, JSON.stringify(summary, null, 2)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('matches all scenario effects across general, direct-city, and fresh-defender combat', () => {
|
||||
const unitSet = readJson<UnitSetDefinition>(
|
||||
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
|
||||
@@ -779,7 +1157,40 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
||||
Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill))
|
||||
)
|
||||
).toBe(true);
|
||||
assertTraceParity(coreEvents, reference, coreRng);
|
||||
assertRngParity(reference, coreRng);
|
||||
const finalReference = reference.events.at(-1)!;
|
||||
expect({ phase: result.phase, killed: result.killed }).toEqual({
|
||||
phase: finalReference.attacker.phase,
|
||||
killed: finalReference.attacker.killed,
|
||||
});
|
||||
|
||||
// Ref keeps the injury-adjusted intelligence fraction here:
|
||||
// (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2
|
||||
// = 0.50015. Truncating the computed stat first turns this into 0.5
|
||||
// and switches RandUtil from nextFloat1() to nextBits().
|
||||
base.seed = 'battle-differential-magic-fractional-stat-v1';
|
||||
base.attackerGeneral.leadership = 120;
|
||||
base.attackerGeneral.strength = 58;
|
||||
base.attackerGeneral.intel = 81;
|
||||
base.attackerGeneral.injury = 37;
|
||||
base.attackerGeneral.personal = 'None';
|
||||
let fractionalCoreRng: TracingRng | null = null;
|
||||
processBattleSimJob(
|
||||
{
|
||||
...base,
|
||||
unitSet,
|
||||
config,
|
||||
time: { year: base.year, month: base.month, startYear: base.startYear },
|
||||
},
|
||||
{
|
||||
rngFactory: (seed) => {
|
||||
fractionalCoreRng = new TracingRng(LiteHashDRBG.build(seed));
|
||||
return new RandUtil(fractionalCoreRng);
|
||||
},
|
||||
}
|
||||
);
|
||||
const fractionalReference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
|
||||
assertRngParity(fractionalReference, fractionalCoreRng);
|
||||
});
|
||||
|
||||
it('matches cavalry, item, inherit-buff, and multiple-defender handling', () => {
|
||||
|
||||
Reference in New Issue
Block a user