fix: align item battle progression with legacy

This commit is contained in:
2026-07-25 07:04:58 +00:00
parent 7ed1b5df7f
commit 828dceae13
5 changed files with 112 additions and 15 deletions
@@ -25,14 +25,20 @@ export const itemModule: ItemModule = {
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: number | [number, number],
_aux?: unknown
aux?: unknown
): number | [number, number] {
let newValue: number | [number, number] = value;
if (statName === 'leadership' && typeof value === 'number') {
newValue = value + STAT_VALUE;
}
if (statName === 'warAvoidRatio' && typeof newValue === 'number') {
const { leadership } = context.general.stats;
const leadership =
typeof aux === 'object' &&
aux !== null &&
'leadership' in aux &&
typeof aux.leadership === 'number'
? aux.leadership
: context.general.stats.leadership + STAT_VALUE;
const crewL = context.general.crew / 100;
const boost = (1 - crewL / leadership) * 0.5;
return newValue + Math.min(Math.max(boost, 0), 0.5);
@@ -1,9 +1,22 @@
import { che_저지, che_저지_시도 } from '@sammo-ts/logic/war/triggers/che_저지.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { che_저지 } from '@sammo-ts/logic/war/triggers/che_저지.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_저지_삼황내문';
class ActivateItemHalt extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre);
}
protected actionWar(self: WarUnit): boolean {
self.activateSkill('특수', '저지');
return true;
}
}
export const itemModule: ItemModule = {
key: ITEM_KEY,
rawName: '삼황내문',
@@ -28,10 +41,10 @@ export const itemModule: ItemModule = {
if (haltCount >= 2) {
return null;
}
if (haltCount === 1 && unit.getPhase() === 0 && !unit.rng.nextBool(0.5)) {
if (haltCount === 1 && unit.getPhase() === 0 && unit.rng.nextBool(0.5)) {
return null;
}
return new WarTriggerCaller(new che_저지_시도(unit), new che_저지(unit));
return new WarTriggerCaller(new ActivateItemHalt(unit), new che_저지(unit));
},
};
+18 -4
View File
@@ -30,6 +30,7 @@ const META_RANK_PREFIX = 'rank_';
const META_INTEL_EXP = 'intelExp';
const META_STRENGTH_EXP = 'strengthExp';
const META_LEADERSHIP_EXP = 'leadershipExp';
const MAX_EXP_LEVEL = 255;
const RANK_WARNUM = `${META_RANK_PREFIX}warnum`;
const RANK_KILLNUM = `${META_RANK_PREFIX}killnum`;
@@ -295,7 +296,10 @@ export class WarUnitGeneral<
let avoidRatio = this.getCrewType().avoid / 100;
avoidRatio *= this.getComputedTrain() / 100;
const aux = { isAttacker: this.isAttacker() };
const aux = {
isAttacker: this.isAttacker(),
leadership: this.getComputedStat('leadership', this.general.stats.leadership),
};
avoidRatio = this.actionPipeline.onCalcStat(this.getActionContext(), 'warAvoidRatio', avoidRatio, aux);
avoidRatio = this.resolveOpposeStatValue('warAvoidRatio', avoidRatio, aux);
@@ -346,8 +350,14 @@ export class WarUnitGeneral<
}
public addLevelExp(value: number): void {
const adjust = this.isAttacker() ? value : value * 0.8;
this.general.experience += adjust;
const sideAdjusted = this.isAttacker() ? value : value * 0.8;
const adjusted = this.actionPipeline.onCalcStat(this.getActionContext(), 'experience', sideAdjusted);
this.general.experience += adjusted;
const nextExpLevel =
this.general.experience < 1000
? Math.trunc(this.general.experience / 100)
: Math.trunc(Math.sqrt(this.general.experience / 10));
this.general.meta[META_EXP_LEVEL] = clamp(nextExpLevel, 0, MAX_EXP_LEVEL);
}
public addDedication(value: number): void {
@@ -361,7 +371,11 @@ export class WarUnitGeneral<
: crewType.armType;
const key = `${META_DEX_PREFIX}${armType}`;
const base = getMetaNumber(this.general.meta, key);
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', exp, { armType });
let nextExp = exp;
if (armType === this.config.armTypes.wizard || armType === this.config.armTypes.siege) {
nextExp *= 0.9;
}
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
this.general.meta[key] = base + adjustedExp;
}
+52
View File
@@ -166,6 +166,58 @@ const buildGeneral = (strength: number): General => ({
});
describe('war triggers', () => {
it('updates the legacy experience level and applies item experience modifiers immediately', () => {
const general = buildGeneral(80);
general.experience = 90;
general.meta.explevel = 0;
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
const unit = new WarUnitGeneral(
new RandUtil(new ConstantRNG(0)),
buildConfig(),
general,
buildCity(),
buildNation(),
true,
crewType,
new ActionLogger({ generalId: 1, nationId: 1 }),
new WarActionPipeline([
{
onCalcStat: (_context, statName, value) =>
statName === 'experience' ? (value as number) * 1.2 : value,
},
])
);
unit.addLevelExp(10);
expect(general.experience).toBe(102);
expect(general.meta.explevel).toBe(1);
});
it('applies the legacy 90% dexterity gain for wizard and siege arms', () => {
const general = buildGeneral(80);
general.meta.dex5 = 5000;
const siegeCrew = new WarCrewType({
...buildUnitSet().crewTypes![0]!,
id: 101,
armType: 5,
name: '공성병',
});
const unit = new WarUnitGeneral(
new RandUtil(new ConstantRNG(0)),
buildConfig(),
general,
buildCity(),
buildNation(),
true,
siegeCrew,
new ActionLogger({ generalId: 1, nationId: 1 }),
new WarActionPipeline([])
);
unit.addDex(siegeCrew, 100);
expect(general.meta.dex5).toBe(5090);
});
it('activates and applies critical damage', async () => {
const rng = new RandUtil(new ConstantRNG(0));
const config = buildConfig();
@@ -691,12 +691,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
process.env['ITEM_PARITY_DEBUG'] === '1'
? `\ncore=${JSON.stringify(
coreEvents
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_damage')
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
)}\nref=${JSON.stringify(
reference.events
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_damage')
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
)}`
: '';
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
@@ -791,7 +791,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
try {
assertTraceParity(coreEvents, reference, coreRng);
} catch (error) {
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}`);
const debug =
process.env['ITEM_PARITY_DEBUG'] === '1'
? `\ncore=${JSON.stringify(
coreEvents
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
)}\nref=${JSON.stringify(
reference.events
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
)}`
: '';
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
}
}
expect(failures, failures.join('\n')).toEqual([]);