Files
ark-stronghold-protocol/server/sim/content/kits/shared/tier1.js
T

244 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/kits/shared/tier1.js — the general kit helpers (formerly tier1.js's named exports; the tier-1
// and tier-2 kits use them) and the notes of the Tier 1 operator kits, which live one per file in ../ops/.
//
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` = skill blackboard at the chess's
// level (normal Lv4 / elite Lv7), `chess` = raw data/chess.json record (talents with their own bb, trait.bb incl. the
// elite module upgrade, module{active}), `def` = normalised def (def.skill.rangeGrid, def.skill.description …).
// Every number comes from a blackboard; the few constants below exist nowhere in data and are documented.
// Profession defaults (professions.js) are reused: kits only add what the trait text / module says beyond them.
//
// Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯
// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛
// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) is registered
// with the tier-6 kits.
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE —
// but the six of the owner's deliberate deviation, DESIGN §21.29 / tools/build-data.mjs TRIGGER_DEVIATIONS, which are
// DEFAULT —, a MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec rules are
// documented at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT; the two 哨戒铁卫 S2s 深巡 行动能力剥夺 and
// 雷蛇 反击电弧 state the DEFAULT their data carries since that deviation — GitHub issue #4, PR #12).
// Tests: test/content/kits_alt_t1.test.js.
//
// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} ·
// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} ·
// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind).
import { COLS } from '../../../constants.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { offsetTile } from '../../../dir.js';
import { bodyInKeys, bodyTileReach } from '../../../body.js';
// =================================================================================================================
// shared helpers (named exports; content/index.js only merges the default export)
/** Finite number or `d` (blackboard values may be strings). */
export const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/** Named talent `i` (data index ≥ 0, in order) of a raw chess record. */
function talentAt(chess, i) {
const list = (chess?.talents ?? []).filter((t) => t && t.index !== -1);
return list[i] ?? null;
}
/** Blackboard of named talent `i` ({} when absent). */
export const talentBb = (chess, i = 0) => talentAt(chess, i)?.bb ?? {};
/** Range grid of named talent `i` (or null). */
export const talentGrid = (chess, i = 0) => talentAt(chess, i)?.rangeGrid ?? null;
/** Merged blackboard of the hidden module talents (data index −1, elite only). */
export function moduleBb(chess) {
const o = {};
for (const t of chess?.talents ?? []) if (t && t.index === -1 && t.bb) Object.assign(o, t.bb);
return o;
}
/** Trait blackboard (elites include their module's trait upgrade). */
export const traitBb = (chess) => chess?.trait?.bb ?? {};
/** Elite module active (golden chess). */
export const moduleOn = (chess) => !!(chess?.module && chess.module.active);
export const up = (u) => !!u && u.alive && u.deployed;
export const posKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
/** Chebyshev tile distance between two units (a huge enemy: from the nearest tile it occupies — sim/body.js). */
export const cheb = (a, b) => (b.hitArea ? bodyTileReach(b, Math.round(a.y), Math.round(a.x))
: a.hitArea ? bodyTileReach(a, Math.round(b.y), Math.round(b.x))
: Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x))));
/** Normal attack hit on its primary target (no splash, no chain jump). */
export const isMainHit = (dmg) => !!dmg && dmg.isAttack && !dmg.isSplash && !(dmg.tags && dmg.tags.includes('chain'));
/** Damage ctx caused by an enemy's attack. */
export const byEnemyAttack = (ctx) => !!ctx.source && ctx.source.side === 'enemy' && !!ctx.dmg && ctx.dmg.isAttack;
/**
* `damaged` ctx of a damage that removed HP and can give 受击回复 SP — the engine's rule (damage.js applyHpLoss: not a 流失
* (`noSp`, Battle.loseHp), not an element 损伤), whatever its source: an attack, a zone, the 无来源 源石溶剂 tick.
*/
export const hurtSpDamage = (ctx) => !!ctx.dmg && !ctx.dmg.noSp && ctx.type !== 'element' && ctx.amount > 0;
/** A timed skill is running (no SP may be gained). */
export const skillBusy = (u) => !!(u.skill && u.skill.active && u.skill.isTimed);
/** Give SP unless a timed skill is running (AK: no SP gain during a skill). */
export function giveSp(u, n, reason = 'talent') {
if (!u || !u.skill || u.skill.noSkill || skillBusy(u) || !(n > 0)) return 0;
return u.skill.gainSp(n, reason);
}
/** 'hit' handler for damage dealt BY `unit` (pre-mitigation; mutate ctx.dmg). */
export function onHitBy(battle, unit, fn, priority = 0) {
return battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target && ctx.target.side === 'enemy') fn(ctx); }, { owner: unit, priority });
}
/** 'hit' handler for damage dealt TO `unit`. */
export function onHitOn(battle, unit, fn, priority = 0) {
return battle.on('hit', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority });
}
/** 'damaged' handler for damage dealt TO `unit`. */
export function onDamagedOn(battle, unit, fn, priority = 0) {
return battle.on('damaged', (ctx) => { if (ctx.target === unit) fn(ctx); }, { owner: unit, priority });
}
/** Targetable enemies inside `grid` (relative to the unit; null ⇒ current range), best targets first. */
export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canHitFly = true, groundOnly = false } = {}) {
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
const list = battle.enemiesInKeys(keys, unit, { canHitFly, groundOnly });
sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/**
* Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit
* (炎佑: Battle.allySelectable), as Battle.alliesInGrid.
*/
export function alliesInGridOf(battle, unit, grid = null) {
const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || []));
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit));
}
/** Any targetable enemy in the unit's current range. */
export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0;
/** Run `fn` once per battle for `key` (battle-wide handlers shared by several units). */
const ONCE = new WeakMap();
export function once(battle, key, fn) {
let s = ONCE.get(battle);
if (!s) ONCE.set(battle, (s = new Set()));
if (s.has(key)) return;
s.add(key);
fn();
}
/** Permanent talent stat buff (survives death / redeploy). */
export function statBuff(battle, unit, key, mods) {
const m = {};
for (const [k, v] of Object.entries(mods)) if (Number.isFinite(v) && v !== 0 && !(k.endsWith('Mul') && v === 1)) m[k] = v;
if (Object.keys(m).length) battle.addBuff(unit, { key, mods: m, persist: true, allowDead: true, tags: ['talent'] });
}
/** Keep buff `key` on `unit` exactly while cond() holds (checked every tick). `mods` may be a function. */
export function toggleBuff(battle, unit, key, cond, mods, extra = {}) {
const check = () => {
const want = up(unit) && !!cond();
const has = unit.findBuff(key);
if (want && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods, tags: ['talent'], ...extra });
else if (!want && has) battle.removeBuff(unit, key);
};
battle.on('tick', check, { owner: unit });
battle.on('battleStart', check, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) check(); }, { owner: unit });
}
/**
* Aura while `unit` is on the field: every `interval` s, allies passing `select(a)` get buff `key` for slightly longer
* than the interval. Several sources of the same aura never stack: the strongest `value` wins.
*/
export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) {
battle.every(interval, () => {
if (!up(unit)) return;
for (const a of battle.alliesFor(unit)) {
if (!select(a)) continue;
const cur = a.findBuff(key);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key, duration: interval + 0.1, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
}
/** Extra natural SP recovery (+perSec) while cond() holds (hooks the per-tick 'time' SP gain). */
export function spTimeBonus(battle, unit, perSec, cond) {
if (!(perSec > 0)) return;
battle.on('spGain', (ctx) => {
if (ctx.unit === unit && ctx.reason === 'time' && cond()) ctx.amount += perSec * battle.dt;
}, { owner: unit });
}
/** Module "攻击范围内敌人的隐匿效果失效": stealthed enemies inside the current range are revealed. */
export function installReveal(battle, unit, interval = 0.2) {
battle.every(interval, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !e.s.flags.stealth || !bodyInKeys(e, unit.rangeKeySet)) continue;
const was = !!e.s.flags.reveal;
if (battle.applyStatus(e, 'reveal', { duration: interval + 0.1, source: unit }) && !was) battle.fx('reveal', { x: e.x, y: e.y, id: e.id });
}
}, { owner: unit });
}
/**
* Lingering area (spores, alchemy units): `onPulse(battle)` runs immediately and then every `interval` s,
* `round(duration / interval)` times in total. Not owned by the caster: it keeps working after the caster falls.
*/
export function makeZone(battle, caster, { x, y, radius, duration, interval = 1, skill, onPulse, onEnd }) {
const total = Math.max(1, Math.round(duration / interval));
let n = 0;
battle.fx('zone', { x, y, radius, dur: duration, id: caster.id, skill });
return battle.every(interval, (b, sc) => {
onPulse(b);
if (++n >= total) { sc.cancel(); if (onEnd) onEnd(b); }
}, { immediate: true });
}
/**
* A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out
* operator lying there, not the home tile of a board unit that has not deployed yet / waits to redeploy — a summon
* there would stop that operator from redeploying until it leaves).
*/
export function summonTileFree(battle, r, c) {
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** First free tile around `unit` (Chebyshev ring 1, front first — offsets rotated by its direction) where `ok(r, c)` holds. */
export function freeTileAround(battle, unit, ok) {
const order = [[0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0], [0, -1], [1, -1], [-1, -1]];
for (const [dr, dc] of order) {
const [r, c] = offsetTile(unit.tileR, unit.tileC, dr, dc, unit.dir);
if (!summonTileFree(battle, r, c)) continue;
if (ok(r, c)) return [r, c];
}
return null;
}
/** Skill spec kind for an instant skill with (possible) charges. */
export const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
/**
* SkillRecord `skillId` of a raw chess record (DESIGN §16 operator loadouts): the selected skill (`chess.skill`) or one of
* `chess.skills[]` (every skill of the chess at its level — bb, duration, rangeGrid, trigger). Null when absent.
* A kit's `skills` map builds every entry from its own record, so each spec is right whichever skill is selected.
*/
export function skillRec(chess, skillId) {
if (chess?.skill && (chess.skill.skillId ?? chess.skill.id) === skillId) return chess.skill;
return (chess?.skills ?? []).find((s) => s && s.skillId === skillId) ?? null;
}
/** Blackboard of skill `skillId` of a raw chess record ({} when absent). */
export const skillBbOf = (chess, skillId) => skillRec(chess, skillId)?.bb ?? {};
/**
* Skill blackboard `base_attack_time` → engine batPct of this chess's base attack time. The value is a FLAT change of
* the base attack time in seconds (AK attribute ADDITION: 送葬人 −0.5 on 2.3 s "少量缩短(-0.5)" ⇒ 1.8 s, 红豆 +0.5
* "略微增大(+0.5)" — the same convention as the tier-4 / tier-5 kits), except a positive value described as a shortening, which is
* the new interval ratio (深靛 0.7 "攻击间隔略微缩短" ⇒ ×0.7). The convention is per skill in AK: skills PRTS lists as
* a ratio (雷蛇 反击电弧 "+70%", 古米 食粮烹制 "+130%", 深靛 灯塔守卫者 "-80%") use batPct = value directly, not this.
*/
export function batMod(v, chess = null, desc = '') {
const x = num(v, 0);
if (!x) return 0;
if (x > 0 && x < 1 && /间隔[^,。;]*缩短/.test(desc)) return x - 1;
const bat = num(chess?.stats?.bat, 1) || 1;
return Math.max(-0.9, x / bat);
}
/** "周围8格" = Chebyshev ring 1 ⇒ Euclidean radius covering the 8 neighbours. */
export const RING1 = 1.5;