Review of the E3 fix (阵法术师 / 轰击术师 strike every enemy in range): - 卡涅利安 S1 沙暴守卫 charged: PRTS 备注 "应用蓄力时:应用技能未开启时的特性" keeps the whole skill-off trait in force, 不攻击 included. A kit trait canAttack now stops her attacks while a charged S1 runs; the guard stays. The E3 test casts S1 uncharged and a new test pins both casts. - 卡涅利安 S3 食噬之印 charged: the mark stacks before the attack's damage (PRTS 备注 "于攻击造成伤害前生效"): x1.2 on the first hit, x2.0 from the 5th (was one attack late). One mark per enemy: another 卡涅利安 only adds stacks, the bonus stays the setter's (PRTS 备注 popup). - rangeAoe is applied after every override (kit trait included) and sets allInRange itself; SUB.phalanx / blastcaster carry rangeAoe only. - 流形 copies (tokens.js and the 缪尔赛思 kit) turn a copied 'beam' into a plain bolt: the copy takes no attack shape. - fx.js: the dead blastcaster shell colour is gone (and its test). - SIM.md / comments cite PRTS 溅射半径一览 (no splash row for these branches), note that stealthed enemies are not struck unless revealed or blocked, and list the table's other radii as open deviations.
508 lines
27 KiB
JavaScript
508 lines
27 KiB
JavaScript
// server/sim/professions.js — default combat behaviour per profession / subProfessionId (DESIGN §5.6 traits).
|
||
//
|
||
// A *profile* describes how a unit attacks. It is resolved once per unit:
|
||
// PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait-text / trait-blackboard tunables (TUNE[sub]) →
|
||
// data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides.
|
||
// Profile fields:
|
||
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none'
|
||
// projectile 'none'|'beam'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('beam': an instant hit drawn as a
|
||
// line; 'boomerang': out to the target and back to the thrower, ai.js throwBoomerang)
|
||
// boomerang bool (回环射手: keeps 'boomerang')
|
||
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
|
||
// allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target)
|
||
// rangeAoe bool (a 锁定攻击范围 AoE without a projectile — SUB table or kit trait, applied by resolveProfile after
|
||
// every override: allInRange + instant 'beam' hits on a ranged profile; only selectable enemies are
|
||
// struck — a stealthed one is not, unless revealed or blocked; PRTS 作战机制 §AOE伤害判定)
|
||
// splashScale (× damage for splash victims)
|
||
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
|
||
// chain {count, falloff, radius, sluggish} heal {mode:'single'|'multi'|'chain', count, falloff, farMul, elementHealRatio}
|
||
// priority 'fly'|'lowDef'|'ranged'|'lowestHp'|'highestHp'|'nearest'|'farthest'|'notBurst'|null
|
||
// noAttack bool (never attacks) noAttackUnlessSkill bool (attacks only while its skill is active)
|
||
// noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value}
|
||
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
|
||
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
|
||
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
|
||
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
|
||
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
|
||
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
|
||
|
||
import { toLocal, frontOf } from './dir.js';
|
||
import { absoluteRangeKeys } from './targeting.js';
|
||
import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js';
|
||
import { COLS } from './constants.js';
|
||
|
||
const P = (o) => Object.freeze(o);
|
||
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||
|
||
export const PROFESSION_DEFAULTS = Object.freeze({
|
||
SNIPER: P({ attack: 'ranged', dmgType: 'phys', projectile: 'arrow', canHitFly: true }),
|
||
CASTER: P({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true }),
|
||
MEDIC: P({ attack: 'ranged', dmgType: 'heal', projectile: 'orb', canHitFly: true, heal: { mode: 'single' } }),
|
||
SUPPORT: P({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true }),
|
||
TANK: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
WARRIOR: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
PIONEER: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
SPECIAL: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
TOKEN: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
});
|
||
|
||
// --------------------------------------------------------------------------------------------------------------
|
||
// install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile.
|
||
|
||
const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
|
||
battle.on('attack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.alive) return;
|
||
let n = ctx.targets.length;
|
||
if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt));
|
||
if (n > 0) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true });
|
||
}, { owner: unit, priority: -10 });
|
||
};
|
||
|
||
const installHpDrain = (battle, unit) => {
|
||
battle.every(1, () => {
|
||
if (!unit.alive || !unit.deployed) return;
|
||
const loss = unit.s.maxHp * (unit.profile.hpDrain ?? 0.03);
|
||
// non-lethal: the drain never knocks the operator out on its own
|
||
if (unit.hp - loss < 1) unit.hp = Math.min(unit.hp, 1);
|
||
else battle.loseHp(unit, loss, { source: unit, silent: true });
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installMerchant = (battle, unit) => {
|
||
const iv = unit.profile.merchantInterval ?? 3;
|
||
const cost = unit.profile.merchantCost ?? 3;
|
||
battle.every(iv, () => {
|
||
if (!unit.alive || !unit.deployed) return;
|
||
const pl = battle.getPlayer(unit.ownerId);
|
||
if (!pl) return;
|
||
// `merchantPay` { unit, cost, cancel }: content may change the payment or waive it (cancel)
|
||
const ctx = battle.hasHook('merchantPay') ? battle.emit('merchantPay', { unit, cost, cancel: false }) : null;
|
||
if (!unit.alive || !unit.deployed || (ctx && ctx.cancel)) return;
|
||
const c = ctx && Number.isFinite(ctx.cost) ? Math.max(0, ctx.cost) : cost;
|
||
if (pl.dp >= c) battle.addDp(unit.ownerId, -c);
|
||
else battle.retreat(unit, { reason: 'merchant' });
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installCharger = (battle, unit) => {
|
||
battle.on('kill', (ctx) => {
|
||
if (ctx.killer === unit && ctx.victim.side === 'enemy') battle.addDp(unit.ownerId, unit.profile.dpOnKill ?? 1);
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installDollkeeper = (battle, unit) => {
|
||
// the substitute's HP comes from the unit's own substitute token (风丸 纸偶) for its selected skill / module
|
||
// (DESIGN §16); a dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats → 50 % of its
|
||
// own max HP
|
||
const dollHp = () => {
|
||
const tokId = (unit.def.tokens || []).map((t) => (typeof t === 'string' ? t : t?.tokenId)).find((t) => t && /shadow|doll/.test(t));
|
||
const tok = tokId ? battle.data.getToken?.(tokId, unit.defId, unit.def?.loadout ?? null) : null;
|
||
return tok && tok.stats.maxHp > 0 ? tok.stats.maxHp : unit.base.maxHp * 0.5;
|
||
};
|
||
battle.on('fatal', (ctx) => {
|
||
if (ctx.unit !== unit || ctx.prevented || unit.trait.doll) return;
|
||
ctx.prevented = true;
|
||
unit.trait.doll = true;
|
||
battle.addBuff(unit, {
|
||
key: 'trait:substitute', duration: unit.profile.dollDuration ?? 20, visible: true,
|
||
mods: { blockCnt: -99, hpMul: Math.max(0.05, dollHp() / Math.max(1, unit.base.maxHp)) },
|
||
onExpire: () => {
|
||
unit.trait.doll = false;
|
||
if (unit.alive) { unit.markDirty(); unit.hp = unit.s.maxHp; battle.fx('swap', { x: unit.x, y: unit.y, id: unit.id }); }
|
||
},
|
||
});
|
||
battle.releaseBlocked(unit);
|
||
unit.markDirty();
|
||
unit.hp = unit.s.maxHp;
|
||
battle.fx('substitute', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit, priority: -100 });
|
||
battle.on('death', (ctx) => { if (ctx.unit === unit) unit.trait.doll = false; }, { owner: unit });
|
||
};
|
||
|
||
const installLibrator = (battle, unit) => {
|
||
// ATK ramps linearly to +rampMax (bb atk, default +200 %) over rampTime s (bb max_stack_cnt, default 40) while
|
||
// the skill is inactive; blocks nothing while inactive; everything resets when the skill ends.
|
||
const rampMax = unit.profile.rampMax ?? 2;
|
||
const rampTime = unit.profile.rampTime ?? 40;
|
||
const rampInit = Math.min(rampMax, unit.profile.rampInit ?? 0); // golden module: "部署后获得+100%加成" (init_atk)
|
||
const applyBlock = () => {
|
||
if (unit.skill?.active) battle.removeBuff(unit, 'trait:libratorBlock');
|
||
else { battle.addBuff(unit, { key: 'trait:libratorBlock', mods: { blockCnt: -99 } }); battle.releaseBlocked(unit); }
|
||
};
|
||
unit.trait.ramp = 0;
|
||
battle.every(1, () => {
|
||
if (!unit.alive || !unit.deployed || unit.skill?.active) return;
|
||
unit.trait.ramp = Math.min(rampMax, unit.trait.ramp + rampMax / rampTime);
|
||
battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
|
||
}, { owner: unit });
|
||
const applyRamp = () => {
|
||
if (unit.trait.ramp > 0) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
|
||
else battle.removeBuff(unit, 'trait:libratorRamp');
|
||
};
|
||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { unit.trait.ramp = rampInit; applyRamp(); applyBlock(); } }, { owner: unit });
|
||
battle.on('skillStart', (ctx) => { if (ctx.unit === unit) applyBlock(); }, { owner: unit });
|
||
battle.on('skillEnd', (ctx) => {
|
||
if (ctx.unit !== unit) return;
|
||
unit.trait.ramp = 0;
|
||
applyRamp();
|
||
applyBlock();
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installPhalanx = (battle, unit) => {
|
||
const apply = () => {
|
||
if (unit.skill?.active) battle.removeBuff(unit, 'trait:phalanxGuard');
|
||
else battle.addBuff(unit, { key: 'trait:phalanxGuard', mods: { defPct: unit.profile.guardDef ?? 2, resFlat: unit.profile.guardRes ?? 20 } });
|
||
};
|
||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
|
||
battle.on('skillStart', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
|
||
battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
|
||
};
|
||
|
||
const installBearer = (battle, unit) => {
|
||
battle.on('skillStart', (ctx) => {
|
||
if (ctx.unit === unit) { battle.addBuff(unit, { key: 'trait:bearer', mods: { blockCnt: -99 } }); battle.releaseBlocked(unit); }
|
||
}, { owner: unit });
|
||
battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) battle.removeBuff(unit, 'trait:bearer'); }, { owner: unit });
|
||
};
|
||
|
||
const installStalker = (battle, unit) => {
|
||
const p = unit.profile.dodge ?? 0.5;
|
||
battle.addBuff(unit, { key: 'trait:stalker', mods: { dodgePhys: p, dodgeArts: p, taunt: -1 }, persist: true, allowDead: true });
|
||
};
|
||
|
||
const installHunter = (battle, unit) => {
|
||
const max = unit.profile.ammoMax ?? 8;
|
||
unit.trait.ammo = max;
|
||
unit.trait.reloadAcc = 0;
|
||
battle.on('tick', (ctx) => {
|
||
if (!unit.alive || !unit.deployed) return;
|
||
if (battle.time - unit.lastAttackAt >= 1 && unit.trait.ammo < max) {
|
||
unit.trait.reloadAcc += ctx.dt;
|
||
if (unit.trait.reloadAcc >= 1) { unit.trait.reloadAcc -= 1; unit.trait.ammo++; }
|
||
} else unit.trait.reloadAcc = 0;
|
||
}, { owner: unit });
|
||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.ammo = max; }, { owner: unit });
|
||
};
|
||
|
||
/**
|
||
* 回环射手 (loopshooter) trait "持有回旋投射物时才能够攻击(投射物需要时间回收)": its projectile is a boomerang that
|
||
* flies to the target, hits on arrival and flies back to the thrower (ai.js throwBoomerang, speeds in constants.js);
|
||
* the thrower attacks only while it holds its boomerang — every one it threw must be caught first (PRTS 跃跃 S2 note
|
||
* "必须回收全部回旋投掷物才可以进行下一次攻击") — and once its attack cooldown is ready, so the real interval is the longer
|
||
* of the two ("实际攻击间隔会受投掷物的实际飞行时间影响产生浮动"). A (re)deployed thrower holds a fresh one.
|
||
*/
|
||
const installLoopshooter = (battle, unit) => {
|
||
unit.trait.boomerangsOut = 0;
|
||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.boomerangsOut = 0; }, { owner: unit });
|
||
};
|
||
|
||
const installTactician = (battle, unit) => {
|
||
const spawn = () => {
|
||
if (!unit.alive || !unit.deployed) return;
|
||
if (unit.trait.reinforcement && unit.trait.reinforcement.alive) return;
|
||
const tile = battle.findTacticalPoint(unit);
|
||
if (!tile) return;
|
||
unit.trait.reinforcement = battle.spawnToken(unit, 'token_tactician_reinforce', tile[0], tile[1], {
|
||
def: {
|
||
name: '援军', stats: {
|
||
maxHp: unit.base.maxHp * 0.7, atk: unit.base.atk * 0.5, def: unit.base.def, magicResistance: 0,
|
||
blockCnt: 1, baseAttackTime: 1.2, attackSpeed: 100, respawnTime: 999, cost: 0,
|
||
}, rangeGrid: [[0, 0]], dmgType: 'phys', attackKind: 'melee', profession: 'TOKEN',
|
||
},
|
||
});
|
||
};
|
||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) spawn(); }, { owner: unit });
|
||
};
|
||
|
||
const installFunnel = (battle, unit) => {
|
||
unit.trait.funnelTarget = null;
|
||
unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2;
|
||
};
|
||
|
||
const installMystic = (battle, unit) => {
|
||
unit.trait.stored = 0;
|
||
const max = unit.profile.storeMax ?? 3;
|
||
battle.on('tick', () => {
|
||
if (!unit.canAct) return;
|
||
if (unit.atkCd <= 0 && !unit.trait.hadTarget && unit.trait.stored < max) {
|
||
unit.trait.storeAcc = (unit.trait.storeAcc ?? 0) + battle.dt;
|
||
if (unit.trait.storeAcc >= unit.s.interval) { unit.trait.storeAcc = 0; unit.trait.stored++; }
|
||
}
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installBard = (battle, unit) => {
|
||
battle.every(1, () => {
|
||
if (!unit.canAct) return;
|
||
const amount = unit.s.atk * (unit.profile.auraRatio ?? 0.1);
|
||
for (const ally of battle.alliesInGrid(unit)) {
|
||
if (ally.hp < ally.s.maxHp) battle.heal(unit, ally, amount, { aura: true });
|
||
}
|
||
}, { owner: unit });
|
||
};
|
||
|
||
const installSkywalker = (battle, unit) => {
|
||
battle.addBuff(unit, { key: 'trait:skywalker', flags: { blockFly: true }, persist: true, allowDead: true });
|
||
};
|
||
|
||
/** Target's tile offset from the unit in the unit's facing-RIGHT frame ([dRow, dCol], sim/dir.js toLocal). */
|
||
/** The target (its body: a huge enemy's every tile — body.js) on a tile of `grid` around the unit (its facing frame). */
|
||
const inTraitGrid = (unit, target, grid) => bodyInKeys(target, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0));
|
||
|
||
/**
|
||
* 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing — a
|
||
* huge enemy when any tile of its body does (body.js).
|
||
*/
|
||
const onFrontLine = (unit, target) => {
|
||
const [fr, fc] = unit.fwd;
|
||
const lateral = (r, c) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir)[0];
|
||
if (!target.hitArea) {
|
||
return lateral(Math.round(target.y), Math.round(target.x)) === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
|
||
}
|
||
for (const k of bodyKeys(target)) {
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
if (lateral(r, c) === 0 && (c - unit.tileC) * fc + (r - unit.tileR) * fr >= 0) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// --------------------------------------------------------------------------------------------------------------
|
||
// subprofession table
|
||
|
||
export const SUB = Object.freeze({
|
||
// --- SNIPER
|
||
fastshot: P({ priority: 'fly', dmgMul: (b, u, t) => (t.isFlying ? (u.profile.flyScale ?? 1) : 1) }),
|
||
closerange: P({}),
|
||
longrange: P({ priority: 'lowDef' }),
|
||
aoesniper: P({ splashRadius: 1.1, projectile: 'bomb' }),
|
||
bombarder: P({ splashRadius: 1.0, projectile: 'bomb', groundOnly: true, canHitFly: false,
|
||
afterHit: (battle, unit, target, info) => {
|
||
// aftershocks: (times − 1) extra hits at append_atk_scale × ATK (default one hit at 50 %)
|
||
const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1);
|
||
for (let i = 1; i <= n; i++) {
|
||
battle.after(0.3 * i, () => {
|
||
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定
|
||
if (e.isFlying) continue;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] });
|
||
}
|
||
});
|
||
}
|
||
} }),
|
||
hunter: P({ install: installHunter,
|
||
canAttack: (battle, unit) => (unit.trait.ammo ?? 8) > 0,
|
||
dmgMul: (b, u) => u.profile.ammoScale ?? 1.2,
|
||
afterAttack: (battle, unit) => { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 8) - 1); } }),
|
||
loopshooter: P({ projectile: 'boomerang', boomerang: true, install: installLoopshooter,
|
||
canAttack: (battle, unit) => !(unit.trait.boomerangsOut > 0) }),
|
||
reaperrange: P({ allInRange: true,
|
||
dmgMul: (battle, unit, target) => {
|
||
const grid = unit.profile.frontGrid;
|
||
const front = grid ? inTraitGrid(unit, target, grid) : onFrontLine(unit, target);
|
||
return front ? (unit.profile.frontScale ?? 1.5) : 1;
|
||
} }),
|
||
// --- CASTER
|
||
// "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (PRTS 溅射半径一览 — which lists
|
||
// no splash for the 阵法术师 / 轰击术师 — and Arknights Terra Wiki, Splash Caster), while the 轰击术师 ("超远距离的群体法术
|
||
// 伤害") and the 阵法术师 strike every enemy inside the attack range at once, the same damage near and far (Terra Wiki,
|
||
// Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE) — community report E3 after 0.1.0
|
||
splashcaster: P({ splashRadius: 1.1 }),
|
||
blastcaster: P({ rangeAoe: true }),
|
||
chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }),
|
||
funnel: P({ projectile: 'drone', install: installFunnel,
|
||
dmgMul: (battle, unit, target) => {
|
||
const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
|
||
if (unit.trait.funnelTarget === target.id) unit.trait.funnelScale = Math.min(f.max, (unit.trait.funnelScale ?? f.init) + f.delta);
|
||
else { unit.trait.funnelTarget = target.id; unit.trait.funnelScale = f.init; }
|
||
return unit.trait.funnelScale;
|
||
} }),
|
||
mystic: P({ install: installMystic,
|
||
hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }),
|
||
phalanx: P({ noAttackUnlessSkill: true, rangeAoe: true, install: installPhalanx }),
|
||
primcaster: P({}),
|
||
corecaster: P({}),
|
||
// --- MEDIC
|
||
physician: P({ heal: { mode: 'single' } }),
|
||
ringhealer: P({ heal: { mode: 'multi', count: 3 } }),
|
||
chainhealer: P({ heal: { mode: 'chain', count: 3, falloff: 0.25 } }),
|
||
healer: P({ heal: { mode: 'single', farMul: 0.8, nearDist: 2 } }),
|
||
wandermedic: P({ heal: { mode: 'single', elementHealRatio: 0.5 } }),
|
||
incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null,
|
||
afterHit: (battle, unit, target, info) => {
|
||
if (!(info.dealt > 0)) return;
|
||
const ally = battle.lowestHpAllyInRange(unit);
|
||
if (ally) battle.heal(unit, ally, info.dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] });
|
||
} }),
|
||
// --- SUPPORT
|
||
slower: P({ onHitStatus: { key: 'sluggish', duration: 0.8 } }),
|
||
underminer: P({}),
|
||
bard: P({ noAttack: true, dmgType: 'heal', heal: null, install: installBard }),
|
||
ritualist: P({}),
|
||
craftsman: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
|
||
summoner: P({}),
|
||
// --- TANK
|
||
protector: P({}),
|
||
guardian: P({}),
|
||
shotprotector: P({ attack: 'ranged', canHitFly: true, projectile: 'arrow' }),
|
||
primprotector: P({}),
|
||
unyield: P({ noHeal: true }),
|
||
duelist: P({}),
|
||
fortress: P({ fortress: true, splashRadius: 1.0, projectile: 'bomb' }),
|
||
// --- WARRIOR
|
||
centurion: P({ hitAllBlocked: true }),
|
||
crusher: P({ hitAllBlocked: true }),
|
||
fearless: P({}),
|
||
fighter: P({}),
|
||
hammer: P({ splashRadius: 1.0, splashScale: 0.5, splashOthersOnly: true }),
|
||
instructor: P({ dmgMul: (battle, unit, target) => (target.blockedBy === unit ? 1 : (unit.profile.unblockedScale ?? 1.2)) }),
|
||
librator: P({ noAttackUnlessSkill: true, install: installLibrator }),
|
||
lord: P({ canHitFly: true,
|
||
dmgMul: (battle, unit, target) => {
|
||
if (target.blockedBy === unit) return 1;
|
||
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
|
||
return bodyOnTile(target, unit.tileR, unit.tileC) || bodyOnTile(target, fr, fc) ? 1 : (unit.profile.rangedScale ?? 0.8);
|
||
} }),
|
||
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
|
||
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
|
||
sword: P({ hits: 2 }),
|
||
artsfghter: P({ dmgType: 'arts' }),
|
||
swordmaster: P({ hits: 2 }),
|
||
// --- PIONEER
|
||
pioneer: P({}),
|
||
charger: P({ install: installCharger }),
|
||
tactician: P({ install: installTactician,
|
||
dmgMul: (battle, unit, target) => (unit.trait.reinforcement && target.blockedBy === unit.trait.reinforcement ? (unit.profile.reinforceScale ?? 1.5) : 1) }),
|
||
agent: P({ canHitFly: true }),
|
||
counsellor: P({}),
|
||
bearer: P({ install: installBearer }),
|
||
// --- SPECIAL
|
||
alchemist: P({ attack: 'ranged', canHitFly: true, projectile: 'lob' }),
|
||
dollkeeper: P({ install: installDollkeeper }),
|
||
executor: P({}),
|
||
geek: P({ install: installHpDrain }),
|
||
hookmaster: P({ canHitFly: true }),
|
||
merchant: P({ install: installMerchant }),
|
||
pusher: P({ hitAllBlocked: true }),
|
||
skywalker: P({ canHitFly: true, blockFly: true, install: installSkywalker }),
|
||
stalker: P({ allInRange: true, install: installStalker }),
|
||
traper: P({ attack: 'ranged', canHitFly: false, projectile: 'none' }),
|
||
});
|
||
|
||
/**
|
||
* Trait tunables per subprofession, read from the trait text and the trait blackboard (`def.traitBb`, data
|
||
* `trait.bb`; elites include their module upgrade). Returns profile overrides.
|
||
*/
|
||
const TUNE = {
|
||
fastshot: (tb) => ({ flyScale: num(tb.atk_scale, 1) }),
|
||
bombarder: (tb) => ({ shockScale: num(tb['attack@append_atk_scale'], 0.5), shockTimes: num(tb['attack@times'], 2) }),
|
||
hunter: (tb) => ({ ammoMax: num(tb.value, 8), ammoScale: num(tb.atk_scale, 1.2) }),
|
||
reaperrange: (tb, def) => ({ frontScale: num(tb.atk_scale, 1.5), frontGrid: def.raw?.trait?.rangeGrid ?? null }),
|
||
funnel: (tb) => ({ funnel: { init: num(tb.init_atk_scale, 0.2), delta: num(tb.delta_atk_scale, 0.15), max: num(tb.max_atk_scale, 1.1) } }),
|
||
mystic: (tb) => ({ storeMax: num(tb.times, 3) }),
|
||
phalanx: (tb) => ({ guardDef: num(tb.def, 2), guardRes: num(tb.magic_resistance, 20) }),
|
||
healer: (tb, def, p) => ({ heal: { ...p.heal, farMul: num(tb.heal_scale, 0.8) } }),
|
||
wandermedic: (tb, def, p) => ({ heal: { ...p.heal, elementHealRatio: num(tb.ep_heal_ratio, 0.5) } }),
|
||
incantationmedic: (tb) => ({ healRatio: num(tb.scale, 0.5) }),
|
||
chainhealer: (tb, def, p) => {
|
||
const o = { ...p.heal };
|
||
const m = String(def.trait || '').match(/在(\d+)个友方单位间跳跃/);
|
||
if (m) o.count = +m[1];
|
||
const f = String(def.trait || '').match(/治疗量降低(\d+)%/);
|
||
if (f) o.falloff = +f[1] / 100;
|
||
if (tb['attack@chain.max_target'] != null) o.count = num(tb['attack@chain.max_target'], o.count);
|
||
if (tb['attack@chain.atk_scale'] != null) o.falloff = 1 - num(tb['attack@chain.atk_scale'], 1 - o.falloff);
|
||
return { heal: o };
|
||
},
|
||
ringhealer: (tb, def, p) => ({ heal: { ...p.heal, count: /同时恢复三个|同时恢复3个/.test(String(def.trait || '')) ? 3 : (p.heal.count ?? 3) } }),
|
||
chain: (tb, def, p) => {
|
||
const o = { ...p.chain };
|
||
const m = String(def.trait || '').match(/在(\d+)个敌人间跳跃/);
|
||
if (m) o.count = +m[1];
|
||
const f = String(def.trait || '').match(/伤害降低(\d+)%/);
|
||
if (f) o.falloff = +f[1] / 100;
|
||
if (tb['attack@max_target'] != null) o.count = num(tb['attack@max_target'], o.count);
|
||
if (tb['attack@sluggish'] != null) o.sluggish = num(tb['attack@sluggish'], o.sluggish);
|
||
if (tb['attack@chain.atk_scale'] != null) o.falloff = 1 - num(tb['attack@chain.atk_scale'], 1 - o.falloff);
|
||
return { chain: o };
|
||
},
|
||
slower: (tb) => ({ onHitStatus: { key: 'sluggish', duration: num(tb.sluggish, 0.8) } }),
|
||
underminer: (tb) => (tb.atk != null && tb.duration != null ? { onHitStatus: { key: 'weaken', duration: num(tb.duration, 2), value: Math.abs(num(tb.atk, 0.1)) } } : {}),
|
||
bard: (tb) => ({ auraRatio: num(tb['attack@atk_to_hp_recovery_ratio'], 0.1) }),
|
||
hammer: (tb) => ({ splashScale: num(tb['attack@atk_scale_2'], 0.5), splashRadius: num(tb['attack@ability_range_radius'], 1) }),
|
||
instructor: (tb) => ({ unblockedScale: num(tb.atk_scale, 1.2) }),
|
||
librator: (tb) => ({ rampMax: num(tb.atk, 2), rampTime: num(tb.max_stack_cnt, 40), rampInit: num(tb.init_atk, 0) }),
|
||
lord: (tb) => ({ rangedScale: num(tb.atk_scale, 0.8) }),
|
||
musha: (tb) => ({ selfHeal: num(tb.value, 50) }),
|
||
reaper: (tb) => ({ selfHeal: num(tb.value, 50) }),
|
||
charger: (tb) => ({ dpOnKill: num(tb.cost, 1) }),
|
||
tactician: (tb) => ({ reinforceScale: num(tb.atk_scale, 1.5) }),
|
||
dollkeeper: (tb) => ({ dollDuration: num(tb.duration, 20) }),
|
||
geek: (tb) => ({ hpDrain: num(tb.hp_ratio, 0.03) }),
|
||
merchant: (tb) => ({ merchantInterval: num(tb.interval, 3), merchantCost: Math.abs(num(tb.cost, -3)) }),
|
||
stalker: (tb) => ({ dodge: num(tb.prob, 0.5) }),
|
||
};
|
||
|
||
const PRIORITY_ALIASES = {
|
||
FLY_FIRST: 'fly', fly: 'fly', LOW_DEF: 'lowDef', lowDef: 'lowDef', lowestDef: 'lowDef', RANGED: 'ranged', ranged: 'ranged',
|
||
lowestHp: 'lowestHp', highestHp: 'highestHp', nearest: 'nearest', farthest: 'farthest',
|
||
};
|
||
|
||
/**
|
||
* Resolve the attack profile of a unit def. `kitTrait` (optional) overrides everything.
|
||
* @returns {object} mutable profile
|
||
*/
|
||
export function resolveProfile(def, kitTrait = null) {
|
||
const prof = PROFESSION_DEFAULTS[def.profession] || PROFESSION_DEFAULTS.WARRIOR;
|
||
const sub = (def.subProf && SUB[def.subProf]) || {};
|
||
const tb = def.traitBb || {};
|
||
const p = {
|
||
sub: def.subProf || null,
|
||
attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false, maxTargets: 1,
|
||
hitAllBlocked: false, allInRange: false, splashRadius: 0, splashScale: 1, splashOthersOnly: false,
|
||
groundOnly: false, hits: 1, chain: null, heal: null, priority: null, noAttack: false,
|
||
noAttackUnlessSkill: false, noHeal: false, blockFly: false,
|
||
...prof, ...sub, tb,
|
||
};
|
||
const tune = def.subProf && TUNE[def.subProf];
|
||
if (tune) Object.assign(p, tune(tb, def, p));
|
||
// data-provided fields (build-data parsed them from trait text) take precedence over table defaults,
|
||
// except for 'none' (no normal attack: phalanx/librator/bard) and 'heal' kinds whose attack shape stays table-defined.
|
||
const ak = def.attackKind ? String(def.attackKind).toLowerCase() : null;
|
||
if (def.dmgType) p.dmgType = def.dmgType;
|
||
if (ak === 'ranged' || ak === 'melee') {
|
||
p.attack = ak;
|
||
if (def.projectile) p.projectile = def.projectile;
|
||
if (def.canHitFly != null) p.canHitFly = !!def.canHitFly;
|
||
else p.canHitFly = p.attack === 'ranged' || p.canHitFly;
|
||
} else if (ak === 'heal') {
|
||
p.attack = 'ranged';
|
||
p.dmgType = 'heal';
|
||
} else if (ak === 'none') {
|
||
if (!p.noAttack && !p.noAttackUnlessSkill) p.noAttackUnlessSkill = true;
|
||
}
|
||
if (def.targetPriority) p.priority = PRIORITY_ALIASES[def.targetPriority] ?? def.targetPriority;
|
||
if (def.splashRadius != null && def.splashRadius > 0) p.splashRadius = def.splashRadius;
|
||
// a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a
|
||
// build-data default) would turn the out-and-back flight into a plain shot
|
||
if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang';
|
||
if (def.type === 'token') {
|
||
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
|
||
if (def.stats.atk <= 0) p.noAttack = true;
|
||
}
|
||
if (p.dmgType === 'heal' && !p.heal && !p.noAttack) p.heal = { mode: 'single' };
|
||
if (p.dmgType !== 'heal' && p.heal) p.heal = null;
|
||
if (p.dmgType === 'none') p.noAttack = true;
|
||
if (kitTrait) Object.assign(p, kitTrait);
|
||
// a 锁定攻击范围 AoE (阵法术师, 轰击术师) strikes every enemy on its range and has no projectile: they are struck at the
|
||
// same moment ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体
|
||
// 目标,同时造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one
|
||
if (p.rangeAoe) {
|
||
p.allInRange = true;
|
||
if (p.attack === 'ranged') p.projectile = 'beam';
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/** Every subProfessionId with a specific behaviour (used by tests to check coverage). */
|
||
export const KNOWN_SUBPROFESSIONS = Object.freeze(Object.keys(SUB));
|