fix: align scenario 2400 battle replay parity

This commit is contained in:
2026-08-05 03:08:12 +00:00
parent bbb6de6971
commit 0dc860feee
12 changed files with 668 additions and 51 deletions
@@ -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,
+33 -4
View File
@@ -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'));