fix: 전투 이펙트와 차등 하네스 호환성을 복원한다

Ref 기준의 판정 순서, 난수 소비, 아이템·특기 이펙트와 로그 형식을 맞춘다.

전체 전투 결과와 이벤트·상태·난수 trace를 엄격 비교하고 fixture 및 출병 예약 회귀를 보강한다.
This commit is contained in:
2026-08-24 03:37:56 +00:00
parent 32bab36320
commit 95cf68dffd
21 changed files with 1499 additions and 319 deletions
+15 -3
View File
@@ -1,6 +1,6 @@
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js';
@@ -110,6 +110,17 @@ const isSupplyCity = (city: City): boolean => {
return city.supplyState > 0;
};
const resolveLegacyTurnHourMinute = (general: General): string => {
const raw = general.meta['turnTime'];
if (typeof raw === 'string' && raw.length >= 16) {
return raw.slice(11, 16);
}
if (general.turnTime instanceof Date && Number.isFinite(general.turnTime.getTime())) {
return general.turnTime.toISOString().slice(11, 16);
}
return '00:00';
};
export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
defender: WarUnit<TriggerState>,
attacker: WarUnitGeneral<TriggerState>
@@ -390,7 +401,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
const attackerName = attackerUnit.getName();
const cityName = cityUnit.getName();
const seedText = input.seed ? `<span class="hidden_but_copyable">(전투시드: ${input.seed})</span>` : '';
const seedText = input.seed ? `<span class='hidden_but_copyable'>(전투시드: ${input.seed})</span>` : '';
const turnHourMinute = resolveLegacyTurnHourMinute(attackerUnit.getGeneral());
const josaRo = JosaUtil.pick(cityName, '로');
const josaYi = JosaUtil.pick(attackerName, '이');
@@ -400,7 +412,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
LogFormat.MONTH
);
attackerLogger.pushGeneralActionLog(
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText}`,
`<G><b>${cityName}</b></>${josaRo} <M>진격</>합니다.${seedText} <1>${turnHourMinute}</>`,
LogFormat.MONTH
);
+15 -9
View File
@@ -1,9 +1,10 @@
import type { RandUtil } from '@sammo-ts/common';
import { JosaUtil, type RandUtil } from '@sammo-ts/common';
import type { General } from '@sammo-ts/logic/domain/entities.js';
import { getLegacyBattleItemIdentity, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js';
import type { WarUnit } from './units.js';
import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js';
export interface WarTriggerContext {
rng: RandUtil;
@@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
return false;
}
this.unit.activateSkill('아이템사용');
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
return false;
}
if (this.unit.hasActivatedSkill('아이템소모')) {
return false;
}
const unit = this.unit as WarUnit & {
getGeneral?: () => General;
};
@@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger {
if (!general) {
return false;
}
const item = getLegacyBattleItemIdentity(general);
this.unit.activateSkill(item.name);
if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) {
return false;
}
if (this.unit.hasActivatedSkill('아이템소모')) {
return false;
}
this.unit.activateSkill('아이템소모');
return removeEquippedItem(general, 'item') !== null;
const josaUl = JosaUtil.pick(item.rawName, '');
this.unit.getLogger().pushGeneralActionLog(`<C>${item.name}</>${josaUl} 사용!`, LogFormat.PLAIN);
removeEquippedItem(general, 'item');
return true;
}
}
@@ -1,13 +1,16 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '../triggers.js';
import type { WarUnit } from '../units.js';
export class che_부적 extends BaseWarUnitTrigger {
constructor(unit: WarUnit, raiseType: number = 0) {
super(unit, 0, raiseType);
super(unit, TriggerPriority.Begin, raiseType);
}
protected actionWar(self: WarUnit): boolean {
self.activateSkill('저격불가', '부상무효');
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
// Ref WarActivateSkills(..., isSelf=false): the talisman's owner is
// injury-proof, while the opposing unit is prevented from sniping.
oppose.activateSkill('저격불가');
return true;
}
}
@@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js';
// 의술: 치료 시도
export class che_의술시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre + 350);
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
super(unit, TriggerPriority.Pre + 350, raiseType);
}
protected actionWar(
@@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger {
// 의술: 치료 발동
export class che_의술발동 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 550);
constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) {
super(unit, TriggerPriority.Post + 550, raiseType);
}
protected actionWar(
@@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger {
self.addDex(self.getCrewType(), calcDamage);
self.addLevelExp(calcDamage / 50);
let rice = self.calcRiceConsumption(calcDamage);
// Ref calcRiceConsumption() declares an int parameter, so the
// fractional 90% counter-damage is truncated before rice cost.
let rice = self.calcRiceConsumption(Math.trunc(calcDamage));
rice *= 0.25;
const general = self.getGeneral();
general.rice = Math.max(0, general.rice - rice);
@@ -1,13 +1,15 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '../triggers.js';
import type { WarUnit } from '../units.js';
export class che_진압 extends BaseWarUnitTrigger {
constructor(unit: WarUnit, raiseType: number = 0) {
super(unit, 0, raiseType);
super(unit, TriggerPriority.Begin, raiseType);
}
protected actionWar(self: WarUnit): boolean {
self.activateSkill('반계불가', '격노불가');
protected actionWar(_self: WarUnit, oppose: WarUnit): boolean {
// Ref's 진압 is an opposing-unit restriction, not a self debuff.
oppose.activateSkill('반계불가', '격노불가');
return true;
}
}
+14 -5
View File
@@ -192,10 +192,16 @@ export class WarUnitGeneral<
return truncate ? Math.trunc(clamped) : clamped;
}
private resolveMainStat(armType: number, withInjury = true): number {
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury });
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury });
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury });
private resolveMainStat(armType: number, withInjury = true, truncate = true): number {
const leadership = this.getComputedStat('leadership', this.general.stats.leadership, {
withInjury,
truncate,
});
const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury, truncate });
const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, {
withInjury,
truncate,
});
if (armType === this.config.armTypes.wizard) {
return intelligence;
@@ -279,7 +285,10 @@ export class WarUnitGeneral<
return 0;
}
const mainStat = this.resolveMainStat(armType, false);
// GameUnitDetail::getCriticalRatio requests each Ref stat with
// useFloor=false, so action bonuses such as 징병's +25% leadership must
// retain their fractional part until after the probability is formed.
const mainStat = this.resolveMainStat(armType, false, false);
const coef =
armType === this.config.armTypes.wizard ||
armType === this.config.armTypes.siege ||