feat: align battle simulator with legacy handlers

This commit is contained in:
2026-07-25 05:57:05 +00:00
parent 5040691d7c
commit 4b13c6d4f5
11 changed files with 977 additions and 21 deletions
+1
View File
@@ -48,6 +48,7 @@ export type WarStatName =
| 'killRice'
| 'criticalDamageRange'
| 'warMagicSuccessDamage'
| 'warMagicFailDamage'
| 'warMagicTrialProb'
| 'warMagicSuccessProb'
| 'addDex'
+11 -1
View File
@@ -1,9 +1,12 @@
import type { WarTriggerRegistry } from './triggers.js';
import { WarTriggerCaller, type WarTriggerRegistry } from './triggers.js';
import { che_기병병종전투 } from './triggers/che_기병병종전투.js';
import { che_방어력증가5p } from './triggers/che_방어력증가5p.js';
import { che_선제사격발동, che_선제사격시도 } from './triggers/che_선제사격.js';
import { che_성벽부상무효 } from './triggers/che_성벽부상무효.js';
import { che_저지, che_저지_시도 } from './triggers/che_저지.js';
import { che_필살발동, che_필살시도 } from './triggers/che_필살.js';
import { che_회피발동, che_회피시도 } from './triggers/che_회피.js';
import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js';
export const CREW_TYPE_WAR_TRIGGER_KEYS = [
'che_성벽부상무효',
@@ -13,6 +16,9 @@ export const CREW_TYPE_WAR_TRIGGER_KEYS = [
'che_선제사격발동',
'che_저지시도',
'che_저지발동',
'che_필살',
'che_회피',
'che_계략',
] as const;
export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
@@ -23,4 +29,8 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({
che_선제사격발동: (unit) => new che_선제사격발동(unit),
che_저지시도: (unit) => new che_저지_시도(unit),
che_저지발동: (unit) => new che_저지(unit),
che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)),
che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
che_계략: (unit) =>
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
});
+105 -6
View File
@@ -9,7 +9,13 @@ import { WarActionPipeline, type WarActionModule } from './actions.js';
import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js';
import { WarCrewType } from './crewType.js';
import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js';
import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js';
import type {
WarBattleInput,
WarBattleOutcome,
WarBattleTraceUnitSnapshot,
WarGeneralInput,
WarUnitReport,
} from './types.js';
import { getMetaNumber } from './utils.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
@@ -77,7 +83,7 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W
if (unit instanceof WarUnitGeneral) {
const context = unit.getActionContext();
caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context));
} else {
} else if (!(unit instanceof WarUnitCity && unit.isSiege())) {
appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry);
}
return caller;
@@ -86,12 +92,10 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W
const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => {
const caller = new WarTriggerCaller();
if (unit instanceof WarUnitGeneral) {
if (registry['che_필살']) {
appendCrewTypeTriggers(caller, unit, ['che_필살'], registry);
}
appendCrewTypeTriggers(caller, unit, ['che_필살', 'che_회피', 'che_계략'], registry);
const context = unit.getActionContext();
caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context));
} else {
} else if (!(unit instanceof WarUnitCity && unit.isSiege())) {
appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry);
}
return caller;
@@ -192,6 +196,57 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => {
};
};
const buildTraceUnitSnapshot = (
unit: WarUnit,
defenderCity: City
): WarBattleTraceUnitSnapshot => {
const common = {
kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const),
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(),
name: unit.getName(),
isAttacker: unit.isAttacker(),
crewTypeId: unit.getCrewType().id,
phase: unit.getPhase(),
realPhase: unit.getRealPhase(),
maxPhase: unit.getMaxPhase(),
hp: unit.getHP(),
rawWarPower: unit.getRawWarPower(),
warPower: unit.getWarPower(),
warPowerMultiplier: unit.getWarPowerMultiply(),
killed: unit.getKilled(),
dead: unit.getDead(),
activatedSkills: unit.getActivatedSkillLog(),
};
if (unit instanceof WarUnitGeneral) {
const general = unit.getGeneral();
return {
...common,
general: {
crew: general.crew,
rice: general.rice,
train: general.train,
atmos: general.atmos,
injury: general.injury,
experience: general.experience,
dedication: general.dedication,
dex1: getMetaNumber(general.meta, 'dex1'),
dex2: getMetaNumber(general.meta, 'dex2'),
dex3: getMetaNumber(general.meta, 'dex3'),
dex4: getMetaNumber(general.meta, 'dex4'),
dex5: getMetaNumber(general.meta, 'dex5'),
},
};
}
return {
...common,
cityState: {
defence: defenderCity.defence,
wall: defenderCity.wall,
population: defenderCity.population,
},
};
};
const flushLoggers = (loggers: ActionLogger[]): Array<ReturnType<ActionLogger['flush']>[number]> => {
const logs: Array<ReturnType<ActionLogger['flush']>[number]> = [];
for (const logger of loggers) {
@@ -307,6 +362,22 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
defender = getNextDefender(null, true);
let conquerCity = false;
let logWritten = false;
let traceSeq = 0;
const emitTrace = (
event: string,
currentDefender: WarUnit<TriggerState> | null,
details: Record<string, unknown> = {}
): void => {
input.trace?.({
seq: traceSeq++,
event,
attacker: buildTraceUnitSnapshot(attackerUnit, input.defenderCity),
defender: currentDefender ? buildTraceUnitSnapshot(currentDefender, input.defenderCity) : null,
city: buildTraceUnitSnapshot(cityUnit, input.defenderCity),
details,
});
};
emitTrace('battle_start', defender, { seed: input.seed ?? '' });
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
const attackerName = attackerUnit.getName();
@@ -349,6 +420,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
);
conquerCity = true;
emitTrace('supply_retreat', defender);
break;
}
}
@@ -409,17 +481,31 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
const initCaller = buildBattleInitTriggers(attackerUnit, triggerRegistry);
initCaller.merge(buildBattleInitTriggers(defender, triggerRegistry));
initCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv());
emitTrace('opponent_initialized', defender);
}
attackerUnit.beginPhase();
defender.beginPhase();
emitTrace('phase_power', defender, {
attackerAttack: attackerUnit.getComputedAttack(),
attackerDefence: attackerUnit.getComputedDefence(),
attackerTrain: attackerUnit.getComputedTrain(),
attackerAtmos: attackerUnit.getComputedAtmos(),
defenderAttack: defender.getComputedAttack(),
defenderDefence: defender.getComputedDefence(),
defenderTrain: defender.getComputedTrain(),
defenderAtmos: defender.getComputedAtmos(),
});
const battleCaller = buildBattlePhaseTriggers(attackerUnit, triggerRegistry);
battleCaller.merge(buildBattlePhaseTriggers(defender, triggerRegistry));
battleCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv());
emitTrace('phase_triggered', defender);
let deadDefender = attackerUnit.calcDamage();
let deadAttacker = defender.calcDamage();
const rawDeadAttacker = deadAttacker;
const rawDeadDefender = deadDefender;
const attackerHP = attackerUnit.getHP();
const defenderHP = defender.getHP();
@@ -445,6 +531,14 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
attackerUnit.increaseKilled(deadDefender);
defender.increaseKilled(deadAttacker);
emitTrace('phase_damage', defender, {
rawDeadAttacker,
rawDeadDefender,
deadAttacker,
deadDefender,
attackerHpBefore: attackerHP,
defenderHpBefore: defenderHP,
});
const phaseNickname = defender.getPhase() < 0 ? '先' : `${attackerUnit.getPhase() + 1} `;
@@ -464,9 +558,11 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
attackerUnit.addPhase();
defender.addPhase();
emitTrace('phase_end', defender);
const attackerState = attackerUnit.continueWar();
if (!attackerState.canContinue) {
emitTrace('attacker_stopped', defender, { noRice: attackerState.noRice });
logWritten = true;
attackerUnit.logBattleResult();
@@ -500,6 +596,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
const defenderState = defender.continueWar();
if (!defenderState.canContinue) {
emitTrace('defender_stopped', defender, { noRice: defenderState.noRice });
logWritten = true;
attackerUnit.logBattleResult();
@@ -555,6 +652,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
defender.finishBattle();
defender = getNextDefender(defender, true);
emitTrace('opponent_switched', defender);
}
}
@@ -586,6 +684,7 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
);
}
}
emitTrace('battle_end', defender, { conquered: conquerCity });
const logs = flushLoggers([attackerLogger, ...defenderGenerals.map((unit) => unit.getLogger())]);
@@ -0,0 +1,169 @@
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, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
import type { WarTriggerModule } from './types.js';
const MAGIC_TO_GENERAL = {
: [1.2, 1.1],
: [1.4, 1.2],
: [1.6, 1.3],
: [1.8, 1.4],
: [2.0, 1.5],
} as const;
const MAGIC_TO_CITY = {
: [1.2, 1.1],
: [1.4, 1.2],
: [1.6, 1.3],
} as const;
type MagicState = [name: string, damage: number];
const applyMagicDamageModifiers = (
self: WarUnitGeneral,
oppose: WarUnit,
statName: 'warMagicSuccessDamage' | 'warMagicFailDamage',
damage: number,
magic: string
): number => {
let result = self.getActionPipeline().onCalcStat(self.getActionContext(), statName, damage, magic);
if (oppose instanceof WarUnitGeneral) {
result = oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, result, magic);
}
return result;
};
export class che_계략시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre + 300);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!(self instanceof WarUnitGeneral) || self.hasActivatedSkill('계략불가')) return true;
const general = self.getGeneral();
let trialProbability =
(self.getComputedStat('intelligence', general.stats.intelligence) / 100) *
self.getCrewType().magicCoef;
trialProbability = self
.getActionPipeline()
.onCalcStat(self.getActionContext(), 'warMagicTrialProb', trialProbability);
if (oppose instanceof WarUnitGeneral) {
trialProbability = oppose
.getActionPipeline()
.onCalcOpposeStat(oppose.getActionContext(), 'warMagicTrialProb', trialProbability);
}
if (trialProbability <= 0) return true;
const rawIntelligence = general.stats.intelligence;
const allRawStats = general.stats.leadership + general.stats.strength + rawIntelligence;
if (self.getPhase() === 0 && rawIntelligence * 3 >= allRawStats) {
trialProbability *= 3;
}
if (!self.rng.nextBool(trialProbability)) return true;
let successProbability = 0.7;
successProbability = self
.getActionPipeline()
.onCalcStat(self.getActionContext(), 'warMagicSuccessProb', successProbability);
if (oppose instanceof WarUnitGeneral) {
successProbability = oppose
.getActionPipeline()
.onCalcOpposeStat(oppose.getActionContext(), 'warMagicSuccessProb', successProbability);
}
const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL;
const magic = self.rng.choice(Object.keys(table));
const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table];
const successDamage = applyMagicDamageModifiers(
self,
oppose,
'warMagicSuccessDamage',
rawSuccessDamage,
magic
);
self.activateSkill('계략시도', magic);
if (self.rng.nextBool(successProbability)) {
self.activateSkill('계략');
selfEnv['magic'] = [magic, successDamage] satisfies MagicState;
} else {
self.activateSkill('계략실패');
selfEnv['magic'] = [magic, failDamage] satisfies MagicState;
}
return true;
}
}
const readMagic = (env: Record<string, unknown>): MagicState | null => {
const value = env['magic'];
if (!Array.isArray(value) || typeof value[0] !== 'string' || typeof value[1] !== 'number') return null;
return [value[0], value[1]];
};
export class che_계략발동 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 300);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략') || selfEnv['계략발동']) return true;
const magicState = readMagic(selfEnv);
if (!magicState) return true;
selfEnv['계략발동'] = true;
const [magic, rawDamage] = magicState;
const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic);
const particle = JosaUtil.pick(magic, '을');
self.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} <C>성공</>했다!`, LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>에 당했다!`, LogFormat.PLAIN);
self.multiplyWarPowerMultiply(damage);
return true;
}
}
export class che_계략실패 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 300);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true;
const magicState = readMagic(selfEnv);
if (!magicState) return true;
selfEnv['계략실패'] = true;
const [magic, rawDamage] = magicState;
const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic);
const particle = JosaUtil.pick(magic, '을');
self.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} <R>실패</>했다!`, LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog(`<D>${magic}</>${particle} 간파했다!`, LogFormat.PLAIN);
self.multiplyWarPowerMultiply(1 / damage);
oppose.multiplyWarPowerMultiply(damage);
return true;
}
}
export const triggerModule: WarTriggerModule = {
key: 'che_계략',
name: '계략',
info: '[전투] 귀병의 계략 시도/성공/실패',
createTriggerList: (unit) =>
new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)),
};
@@ -0,0 +1,50 @@
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js';
import type { WarTriggerModule } from './types.js';
export class che_회피시도 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre + 200);
}
protected actionWar(
self: WarUnit,
_oppose: WarUnit,
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!(self instanceof WarUnitGeneral)) return true;
if (self.hasActivatedSkill('특수') || self.hasActivatedSkill('회피불가')) return true;
if (!self.rng.nextBool(self.getComputedAvoidRatio())) return true;
self.activateSkill('회피시도', '회피');
return true;
}
}
export class che_회피발동 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 500);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (!self.hasActivatedSkill('회피')) return true;
oppose.getLogger().pushGeneralBattleDetailLog('상대가 <R>회피</>했다!</>', LogFormat.PLAIN);
self.getLogger().pushGeneralBattleDetailLog('<C>회피</>했다!</>', LogFormat.PLAIN);
oppose.multiplyWarPowerMultiply(1 / 6);
return true;
}
}
export const triggerModule: WarTriggerModule = {
key: 'che_회피',
name: '회피',
info: '[전투] 페이즈마다 확률로 회피 발동',
createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)),
};
+48
View File
@@ -23,6 +23,7 @@ export interface WarEngineConfig {
maxAtmosByCommand: number;
maxTrainByWar: number;
maxAtmosByWar: number;
maxGeneralStat?: number;
castleCrewTypeId: number;
armTypes: WarArmTypes;
}
@@ -53,6 +54,53 @@ export interface WarBattleInput<TriggerState extends GeneralTriggerState = Gener
defenderNation: Nation | null;
triggerRegistry?: WarTriggerRegistry;
loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger;
trace?: (event: WarBattleTraceEvent) => void;
}
export interface WarBattleTraceUnitSnapshot {
kind: 'general' | 'city';
id: number;
name: string;
isAttacker: boolean;
crewTypeId: number;
phase: number;
realPhase: number;
maxPhase: number;
hp: number;
rawWarPower: number;
warPower: number;
warPowerMultiplier: number;
killed: number;
dead: number;
activatedSkills: Record<string, number>;
general?: {
crew: number;
rice: number;
train: number;
atmos: number;
injury: number;
experience: number;
dedication: number;
dex1: number;
dex2: number;
dex3: number;
dex4: number;
dex5: number;
};
cityState?: {
defence: number;
wall: number;
population: number;
};
}
export interface WarBattleTraceEvent {
seq: number;
event: string;
attacker: WarBattleTraceUnitSnapshot;
defender: WarBattleTraceUnitSnapshot | null;
city: WarBattleTraceUnitSnapshot;
details: Record<string, unknown>;
}
export interface WarUnitReport {
+36 -12
View File
@@ -147,14 +147,33 @@ export class WarUnitGeneral<
return phase + this.bonusPhase;
}
private resolveStatValue(statName: keyof StatBlock, base: number): number {
return this.actionPipeline.onCalcStat(this.getActionContext(), statName, base);
public getComputedStat(
statName: keyof StatBlock,
base: number,
options: { withInjury?: boolean; withStatAdjust?: boolean; withActions?: boolean } = {}
): number {
const withInjury = options.withInjury ?? true;
const withStatAdjust = options.withStatAdjust ?? true;
const withActions = options.withActions ?? true;
const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1;
let value = base * injuryRatio;
if (withStatAdjust && statName === 'strength') {
value += round((this.general.stats.intelligence * injuryRatio) / 4);
} else if (withStatAdjust && statName === 'intelligence') {
value += round((this.general.stats.strength * injuryRatio) / 4);
}
const maxGeneralStat = this.config.maxGeneralStat ?? 255;
value = clamp(value, 0, maxGeneralStat);
if (withActions) {
value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value);
}
return clamp(value, 0, maxGeneralStat);
}
private resolveMainStat(armType: number): number {
const leadership = this.resolveStatValue('leadership', this.general.stats.leadership);
const strength = this.resolveStatValue('strength', this.general.stats.strength);
const intelligence = this.resolveStatValue('intelligence', this.general.stats.intelligence);
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 });
if (armType === this.config.armTypes.wizard) {
return intelligence;
@@ -173,20 +192,22 @@ export class WarUnitGeneral<
if (!(oppose instanceof WarUnitGeneral)) {
return value;
}
return oppose.getActionPipeline().onCalcOpposeStat(this.getActionContext(), statName, value, aux);
return oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, value, aux);
}
public override getDex(crewType: WarCrewType): number {
const armType =
const storedArmType =
crewType.armType === this.config.armTypes.castle
? (this.config.armTypes.siege ?? crewType.armType)
: crewType.armType;
const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${armType}`);
const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${storedArmType}`);
const aux = {
isAttacker: this.isAttacker(),
opposeType: this.oppose?.getCrewType() ?? null,
};
const statName = toDexStatName(armType);
// Legacy stores castle dexterity in the siege column, but still exposes
// the requested castle arm type to trait handlers as `dex0`.
const statName = toDexStatName(crewType.armType);
let dex = this.actionPipeline.onCalcStat(this.getActionContext(), statName, base, aux);
dex = this.resolveOpposeStatValue(statName, dex, aux);
return dex;
@@ -236,7 +257,7 @@ export class WarUnitGeneral<
return 0;
}
const mainStat = this.resolveMainStat(armType);
const mainStat = this.resolveMainStat(armType, false);
const coef =
armType === this.config.armTypes.wizard ||
armType === this.config.armTypes.siege ||
@@ -318,7 +339,10 @@ export class WarUnitGeneral<
}
public addDex(crewType: WarCrewType, exp: number): void {
const armType = crewType.armType;
const armType =
crewType.armType === this.config.armTypes.castle
? (this.config.armTypes.siege ?? crewType.armType)
: crewType.armType;
const key = `${META_DEX_PREFIX}${armType}`;
const base = getMetaNumber(this.general.meta, key);
this.general.meta[key] = round(base + exp);