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
@@ -1,11 +1,13 @@
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_부적 } from '@sammo-ts/logic/war/triggers/che_부적.js';
import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_부적_태현청생부';
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303;
export const itemModule: ItemModule = {
key: ITEM_KEY,
@@ -28,8 +30,12 @@ export const itemModule: ItemModule = {
}
return value;
} as NonNullable<ItemModule['onCalcStat']>,
getBattleInitTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_부상무효(context.unit, RAISE_TYPE), new che_부적(context.unit, RAISE_TYPE));
},
getBattlePhaseTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_부적(context.unit));
return new WarTriggerCaller(new che_부적(context.unit, RAISE_TYPE));
},
};
@@ -1,9 +1,10 @@
import { che_진압 } from '@sammo-ts/logic/war/triggers/che_진압.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_진압_박혁론';
const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE;
export const itemModule: ItemModule = {
key: ITEM_KEY,
@@ -18,6 +19,6 @@ export const itemModule: ItemModule = {
unique: false,
getBattlePhaseTriggerList: (context) => {
if (!context.unit) return null;
return new WarTriggerCaller(new che_진압(context.unit));
return new WarTriggerCaller(new che_진압(context.unit, RAISE_TYPE));
},
};
+20 -3
View File
@@ -1,11 +1,19 @@
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js';
import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
import type { ItemModule } from './types.js';
const INFO =
'[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복<br>[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)';
const ATTEMPT_DEDUP_TYPE: Record<string, number> = {
che_의술_상한잡병론: 301,
che_의술_정력견혈산: 302,
che_의술_청낭서: 302,
che_의술_태평청령: 303,
};
export const createMedicalItem = (key: string, rawName: string): ItemModule => ({
key,
rawName,
@@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => (
reqSecu: 0,
unique: true,
getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)),
getBattlePhaseTriggerList: (context) =>
context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null,
getBattlePhaseTriggerList: (context) => {
if (!context.unit) {
return null;
}
const attemptRaiseType =
BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * (ATTEMPT_DEDUP_TYPE[key] ?? 0);
return new WarTriggerCaller(
new che_의술시도(context.unit, attemptRaiseType),
new che_의술발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
);
},
});
+22 -1
View File
@@ -1,4 +1,7 @@
import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
import type { ItemModule } from './types.js';
export const createEventBattleTraitItemModule = (
@@ -45,7 +48,25 @@ export const createEventBattleTraitItemModule = (
itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList;
}
if (traitModule.getBattlePhaseTriggerList) {
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
if (traitModule.key === 'che_저격') {
itemModule.getBattlePhaseTriggerList = (context) =>
context.unit
? new WarTriggerCaller(
new che_저격시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.5, 20, 40),
new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM)
)
: null;
} else if (traitModule.key === 'che_의술') {
itemModule.getBattlePhaseTriggerList = (context) =>
context.unit
? new WarTriggerCaller(
new che_의술시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
new che_의술발동(context.unit)
)
: null;
} else {
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
}
}
if (traitModule.getWarPowerMultiplier) {
itemModule.getWarPowerMultiplier =
+18 -3
View File
@@ -1,5 +1,5 @@
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js';
import { getEquippedItemInstance } from './inventory.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
import type { ItemModule } from './types.js';
@@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (self.hasActivatedSkillOnLog('충차공격') > 0 && self.getPhase() === self.getMaxPhase() - 1) {
if (self instanceof WarUnitGeneral) {
const equipped = getEquippedItemInstance(self.getGeneral(), 'item');
if (equipped?.itemKey === ITEM_KEY && (equipped.state.charges ?? 0) <= 0) {
this.processConsumableItem();
}
}
return true;
}
if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) {
return true;
}
@@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger {
return true;
}
self.activateSkill('충차공격', '아이템사용');
self.getLogger().pushGeneralBattleDetailLog('<C>충차</>로 성벽을 공격합니다.');
consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2);
self.activateSkill('충차공격');
const equipped = getEquippedItemInstance(general, 'item');
if (equipped?.itemKey === ITEM_KEY) {
const remaining = equipped.state.charges ?? 2;
// Ref decrements the purchase-time remain값 at first city contact,
// but only deletes the item in the last battle phase.
equipped.state.charges = remaining - 1;
}
return true;
}
}
+6 -1
View File
@@ -22,7 +22,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
import type { ItemModule, ItemModuleExport } from './types.js';
import { listEquippedItemKeys } from './utils.js';
import { removeEquippedItem } from './inventory.js';
import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js';
export const ITEM_KEYS = [
'che_간파_노군입산부',
@@ -664,12 +664,17 @@ class ItemWarActionRouter<
private resolveModules(context: WarActionContext<TriggerState>): Array<ItemModule<TriggerState>> {
const keys = listEquippedItemKeys(context.general);
const modules: Array<ItemModule<TriggerState>> = [];
let itemIdentity = { name: '-', rawName: '-' };
for (const key of keys) {
const module = this.registry.get(key);
if (module) {
modules.push(module);
if (module.slot === 'item') {
itemIdentity = { name: module.name, rawName: module.rawName };
}
}
}
registerLegacyBattleItemIdentity(context.general, itemIdentity);
return modules;
}
+22 -3
View File
@@ -12,6 +12,27 @@ import type {
const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item'];
const INVENTORY_META_KEY = 'itemInventory';
export interface LegacyBattleItemIdentity {
name: string;
rawName: string;
}
// Ref's BaseWarUnitTrigger::processConsumableItem() asks General::getItem()
// for the *item-slot* display name even when a weapon trigger raised the
// shared item flag. Keep that transient lookup out of persisted General meta.
const legacyBattleItemIdentities = new WeakMap<object, LegacyBattleItemIdentity>();
export const registerLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
identity: LegacyBattleItemIdentity
): void => {
legacyBattleItemIdentities.set(general, identity);
};
export const getLegacyBattleItemIdentity = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): LegacyBattleItemIdentity => legacyBattleItemIdentities.get(general) ?? { name: '-', rawName: '-' };
const emptyState = (): GeneralItemInstanceState => ({ values: {} });
const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({
@@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => {
return null;
}
const charges =
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0
? record['charges']
: undefined;
typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined;
const valuesRecord = asRecord(record['values']) ?? {};
const values: Record<string, TriggerValue> = {};
for (const [key, entry] of Object.entries(valuesRecord)) {