feedback5 R4: one file per operator kit — kits/tier1..6.js split into kits/ops/<chessId>-<codename>.js (129 kits), kits/shared/tier1..6.js (the helpers two or more kits of a tier use, and each tier's header notes) and kits/index.js (the registry, same keys in the same order); the tier files become re-export shims

Pure move: every line of a kit, of a helper and of the tier headers is copied verbatim (only the headers' first
line names the new file); a helper used by one kit moved into that kit's file. Glue only: the generated imports,
`export default { … }` around each registry entry, `export { … }` lists in shared/tier2..6.js, and tier4's
`(bb, chess, def)` default-argument wrapper as shared/tier4.js `withDefaults` (applied in each tier-4 kit file).
kits/index.js loads every kit file with a guarded dynamic import (browser-safe, no directory listing); tierN.js
re-export the tier registry (TIER_KITS) and their old named exports (tier1 helpers + tinmanKit, tier6
PITHST_ELEMENTS). The merged registry, every tier default and named export have the same keys, order, frozenness
and function sources as before. test/docs-consistency.test.js reads the moved code at its new paths.
Golden: GOLDEN_FULL=1 node --test test/golden.test.js unchanged.
This commit is contained in:
sganggs committed 2026-10-05 16:13:56 +08:00
1 parent 6b489b1776
commit 1169102d46
143 files changed
+12924 -11784

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
// 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) lives in tier6.js.
// 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 tier4/tier5), 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;
+26
View File
@@ -0,0 +1,26 @@
// server/sim/content/kits/shared/tier2.js — helpers and notes of the Tier 2 operator kits (formerly tier2.js; the kits
// live one per file in ../ops/).
//
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named
// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's
// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header.
//
// Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡
// 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skill in `skills`
// ({ [skillId]: SkillSpec }, official Lv4 / Lv7 blackboards — the kit function receives the SELECTED skill's `bb`, so
// every spec is built from `bb`; the trigger rule comes from that skill's data). Talent / install hooks that belong to
// the default skill only (target locks, counters, hit triggers) check onDefaultSkill(chess). Module choices need no
// code here: `chess` is the loadout-resolved record (trait.bb / talents of the selected module, or none).
/**
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
*/
function onDefaultSkill(chess) {
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
}
export { onDefaultSkill };
+224
View File
@@ -0,0 +1,224 @@
// server/sim/content/kits/shared/tier3.js — helpers and notes of the hand-authored kits for the 21 tier-3 chess (19
// visible + 见行者/巫恋 hidden; formerly tier3.js — the kits live one per file in ../ops/).
//
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). `bb` is the skill blackboard at the
// chess's level (normal Lv4 `_a` / elite Lv7 `_b`), `chess` the data/chess.json record (talents incl. module upgrades,
// trait incl. module trait bb), `def` the normalised def. Every number comes from a blackboard; the few values that
// only exist in the text ("三连击", "至多5个", "初始两只", "至多3只") are parsed from the description with fallbacks.
// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange,
// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken.
// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits);
// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile
// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet
// (freeTile). Offensive skills whose own range differs from the attack
// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4).
//
// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single
// hit), 'volley' (multi-target shot), 'push', 'pull', 'summon' (token), 'coin' (n), 'dp' (n), 'revive', 'buff',
// 'shield', 'shieldBreak', 'camouflage', 'explode'.
//
// Operator loadouts (DESIGN §16): the kit is built for the SELECTED skill (`bb`, `chess.skill`, `def.skill`) and module
// (talents / trait bb). `skill` is the default skill's spec; `skills: { [skillId]: SkillSpec }` holds every other
// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal /
// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the
// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id.
// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official
// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except
// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every
// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1),
// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial
// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply).
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { bodyInKeys } from '../../../body.js';
import { normalizeChess, normalizeSkill } from '../../../simdata.js';
import { tacticalPoint as sharedTacticalPoint } from '../../tokens.js';
// ---------------------------------------------------------------------------------------------------------------
// helpers
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const defOf = (chess, def) => def ?? normalizeChess(chess);
const talentBb = (d, i) => d?.talents?.[i]?.bb ?? {};
const traitBb = (d) => d?.traitBb ?? {};
/** Module-only talent (data: `hidden && fromModule`, e.g. 空弦 精锐 "范围内存在地面敌人时攻击速度+8"). */
const moduleTalentBb = (chess) => (chess?.talents ?? []).find((t) => t && t.hidden && t.fromModule)?.bb ?? null;
/** Id of the SELECTED skill the kit is built for (DESIGN §16: `bb`, `chess.skill` and `def.skill` belong to it). */
const selectedId = (chess, d) => d?.skill?.id ?? chess?.skill?.skillId ?? null;
/**
* Normalised record (simdata normalizeSkill shape: bb, rangeGrid, maxCharges, duration, description, trigger) of the
* selectable skill `id` at this chess's level: the selected one is `def.skill` with the kit's `bb`, any other comes
* from `chess.skills[]` (DATA §2.2) — every spec of `skills` is built from its own numbers whichever skill is selected.
*/
function skillData(chess, d, bb, id) {
if (id != null && selectedId(chess, d) === id && d?.skill) return { ...d.skill, bb: bb ?? d.skill.bb ?? {} };
const rec = (chess?.skills ?? []).find((s) => s && s.skillId === id);
return (rec && normalizeSkill({ skill: rec })) || { id, bb: {}, rangeGrid: null, maxCharges: 1, duration: 0, description: '' };
}
/** `{ [skillId]: SkillSpec }` of the non-default selectable skills: `builders[id](skillData(id))` each. */
function altSkills(chess, d, bb, builders) {
const out = {};
for (const [id, build] of Object.entries(builders)) out[id] = build(skillData(chess, d, bb, id));
return out;
}
/** instant, or charges when the skill stores several (可充能N次). */
const instantKindOf = (s) => ((s?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
/** Timed stat skill (迅捷打击 / 攻击力强化 / 防御力强化 …): bb atk / def / max_hp / attack_speed on the operator. */
const statSkill = (s) => {
const b = s.bb || {};
const mods = {};
if (num(b.atk)) mods.atkPct = num(b.atk);
if (num(b.def)) mods.defPct = num(b.def);
if (num(b.max_hp)) mods.hpPct = num(b.max_hp);
if (num(b.attack_speed)) mods.aspd = num(b.attack_speed);
return { kind: 'duration', mods };
};
/**
* "范围内存在地面敌人时攻击速度+N" (能天使 MAR-Y trait, 空弦 MAR-Y talent): ASPD buff `key` while at least `cnt` ground
* enemies stand in the current range.
*/
function groundAspd(key, aspd, cnt = 1) {
return (battle, unit) => {
let on = false;
battle.every(0.1, () => {
const n = alive(unit) ? battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).filter((e) => !e.isFlying).length : 0;
const want = n >= Math.max(1, cnt);
if (want === on && (!want || unit.findBuff(key))) return;
on = want;
if (want) battle.addBuff(unit, { key, mods: { aspd } });
else battle.removeBuff(unit, key);
}, { owner: unit });
};
}
const alive = (u) => !!(u && u.alive && u.deployed);
const tileKeyOf = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
/** On a tile of `set`: an ally by its tile, an enemy by its body (every tile a huge enemy occupies — sim/body.js). */
const onTiles = (x, set) => (x.side === 'ally' ? set.has(tileKeyOf(x)) : bodyInKeys(x, set));
const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.tileR, u.tileC, u.dir, ext);
const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra });
const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null);
const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */
function textNum(text, re, fallback) {
const m = String(text ?? '').match(re);
if (!m) return fallback;
return /^\d+(\.\d+)?$/.test(m[1]) ? +m[1] : (CN_NUM[m[1]] ?? fallback);
}
/**
* 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 an ally that has not deployed yet / waits to redeploy — the initial
* deployment runs one unit after another: a summon placed while it runs must not steal a later board unit's tile).
*/
function freeTile(battle, r, c) {
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** Walkable ground tile a melee summon can stand on. */
const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c);
/**
* Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable —
* free, standable ground (never 深水区: grid.canStand) inside her initial range ("只能部署在召唤者攻击范围内"; the prep
* already keeps the piece there, PlayerState._legal) —, else the shared tactical point (tokens.js tacticalPoint =
* Battle.findTacticalPoint: a free walkable tile of its initial range on an enemy ground path first, then the nearest).
*/
function tacticalPoint(battle, unit, prefer = null) {
const inRange = (r, c) => (unit.baseRangeKeys || unit.rangeKeys || []).includes(r * COLS + c);
if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1]) && inRange(prefer[0], prefer[1])) return prefer;
return sharedTacticalPoint(battle, unit);
}
/** Targetable enemies on `keys`, sorted by the unit's priority (at most `n` when n > 0). */
function enemiesOn(battle, unit, keys, n = 0, profile = null) {
const list = battle.enemiesInKeys(keys, unit, profile ?? { canHitFly: true });
sortEnemyTargets(battle, unit, list, (profile ?? unit.profile)?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/**
* Tile aura: every `interval` s (0 ⇒ call the returned update() yourself, e.g. from a skill onTick) buffs the
* units of `side` standing on `tiles()` (filter/mods per unit, mods null ⇒ skipped) and drops the buff from units
* that left. update.clear() removes everything this aura applied. Same-key auras of several sources do not stack.
*/
function aura(battle, unit, o) {
let cur = new Set();
const iv = o.interval ?? 0.2;
const dur = Math.max(iv * 2, 0.1) + 0.05;
const drop = (x) => { const b = x.findBuff(o.key); if (b && b.source === unit) battle.removeBuff(x, b); };
const update = () => {
const next = new Set();
if (alive(unit) && (!o.active || o.active())) {
const keys = o.tiles();
const set = keys instanceof Set ? keys : new Set(keys || []);
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑)
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
if (!mods) continue;
battle.addBuff(x, { key: o.key, duration: dur, refresh: 'extend', mods, source: unit, tags: ['aura'] });
next.add(x);
}
}
for (const x of cur) if (!next.has(x)) drop(x);
cur = next;
};
update.clear = () => { for (const x of cur) drop(x); cur = new Set(); };
// immediate: installed at construction ⇒ first runs right after the initial deployment (t = 0), not 1 interval later
if (iv > 0) battle.every(iv, update, { owner: unit, immediate: true });
return update;
}
/** Buff present exactly while `cond()` holds (checked every tick). */
function whileTrue(battle, unit, key, cond, mods) {
battle.on('tick', () => {
const on = alive(unit) && cond();
const has = !!unit.findBuff(key);
if (on && !has) battle.addBuff(unit, { key, mods: typeof mods === 'function' ? mods() : mods });
else if (!on && has) battle.removeBuff(unit, key);
}, { owner: unit });
}
/** Team-wide talents never stack: only the lowest-id living carrier of `tag` of this player applies them. */
function isLeader(battle, unit, tag) {
let best = null;
for (const a of battle.allyUnits) if (alive(a) && a.ownerId === unit.ownerId && a.mem[tag] && (!best || a.id < best.id)) best = a;
return best === unit;
}
/** SP gain that respects "no SP while a timed skill runs". */
function giveSp(u, n, reason = 'talent') {
const sk = u?.skill;
if (!sk || sk.noSkill || !(n > 0) || (sk.active && sk.isTimed)) return 0;
return sk.gainSp(n, reason);
}
/**
* Funnel ramp tracked per target (耶拉's 2 drones each ramp on their own lock; 1 drone = profession behaviour). Two
* drones locked on the same enemy land in the same tick: they ramp in parallel (one step per volley, same scale).
*/
function funnelMap(battle, unit, target) {
const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
const m = unit.trait.funnelMap || (unit.trait.funnelMap = new Map());
const prev = m.get(target.id);
if (prev && prev.t === battle.time) return prev.s;
const s = prev == null ? f.init : Math.min(f.max, prev.s + f.delta);
m.set(target.id, { s, t: battle.time });
return s;
}
/** Drones return when their lock is dropped: forget ramps of enemies no longer attacked. */
function installFunnelPrune(battle, unit) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.trait.funnelMap) return;
const ids = new Set(ctx.targets.map((t) => t.id));
for (const k of [...unit.trait.funnelMap.keys()]) if (!ids.has(k)) unit.trait.funnelMap.delete(k);
}, { owner: unit, priority: -100 });
}
export {
num, defOf, talentBb, traitBb, moduleTalentBb, selectedId, altSkills, instantKindOf, statSkill, groundAspd, alive,
onTiles, gridKeys, fx, copyGrid, NINE, textNum, freeTile, groundTile, tacticalPoint, enemiesOn, aura, whileTrue,
isLeader, giveSp, funnelMap, installFunnelPrune,
};
+162
View File
@@ -0,0 +1,162 @@
// server/sim/content/kits/shared/tier4.js — helpers and notes of the hand-authored kits for every tier-4 chess (26: 22
// visible + 4 hidden; formerly tier4.js — the kits live one per file in ../ops/).
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). The same kit serves the elite
// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and
// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs.
// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …).
//
// Notes shared by several kits:
// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s
// ⇒ 1.05 s; 水月 −0.7 on 3.5 s) and is converted to the engine's `batPct` (÷ base BAT). 焰尾's +0.7 (text: 攻击间隔
// 缩短) is a multiplier (×0.7) and 信仰搅拌机's 0.6 is the counter interval ratio — both handled in their kits.
// - "技力光环 (同类效果取最高)" (莫斯提马, 白面鸮) share the buff key `aura:spRecovery`; a unit keeps the highest one.
// - Auras refresh short buffs every AURA s so they lapse within a few ticks after the source leaves.
// - Talents that name a faction use data fields: nationId (拉特兰 laterano, 卡西米尔 kazimierz, 谢拉格 kjerag),
// profession (术师 CASTER, 重装 TANK, 先锋 PIONEER), enemy tags (萨卡兹 sarkaz, 海怪 seamonster); 深海猎人 = research
// groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json).
// - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs
// (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking).
// - On-hit effects of 卡涅利安's attacks land on every enemy the attack strikes — the 阵法术师 trait strikes every enemy
// on her range at once (professions.js `allInRange` / `rangeAoe`, community report E3): S2's 停顿 / 束缚 via a
// `damaged` hook, the charged S3 mark via a `hit` hook (it applies before the damage, PRTS 备注).
// - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the
// sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods).
// - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's
// `skills` map (see alt() below); logic of one skill (counters, deferrals, auras…) only runs when it is selected,
// and module effects check the selected module (moduleIs / the module-merged trait & talent blackboards).
// `node tools/kit-coverage.mjs --tier 4 --strict`; tests: test/content/kits_alt_t4.test.js.
import { bodyInKeys } from '../../../body.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { normalizeChess } from '../../../simdata.js';
const AURA = 0.2; // aura refresh period (s)
const AURA_DUR = 0.25; // aura buff lifetime (s): lapses ~1 tick after the source stops refreshing it
// ---------------------------------------------------------------------------------------------------------------
// helpers
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : d));
const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {};
/** Hidden module talent blackboard (name null, e.g. 水月 move_speed, 魔王 cnt/atk). */
const moduleBb = (def) => (def?.talents || []).filter((t) => !t.name && t.bb && Object.keys(t.bb).length).reduce((o, t) => Object.assign(o, t.bb), {});
const grid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null);
/** Flat BAT change (s) → batPct. */
const batFlat = (def, v) => { const b = num(def?.stats?.bat, 1) || 1; return Math.max(-0.9, num(v, 0) / b); };
const nationOf = (u) => u?.def?.raw?.nationId ?? null;
const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag);
const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []);
/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */
function enemiesOnRange(battle, unit, keys = null) {
const set = keys || keySet(unit);
const out = [];
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, set)) out.push(e);
return out;
}
/** Targetable enemies in the unit's current range (flyers included), best targets first. */
function targetsInRange(battle, unit, n = 0, keys = null) {
const list = battle.enemiesInKeys(keys || unit.rangeKeys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Targetable enemies on the tiles of `g` (offsets) around the unit. */
function targetsInGrid(battle, unit, g) {
return battle.unitsInGrid(unit, g, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, { canHitFly: true }));
}
/** Run `fn` every `iv` s while the unit is alive and deployed. */
const whileDeployed = (battle, unit, iv, fn) => battle.every(iv, () => { if (unit.alive && unit.deployed) fn(); }, { owner: unit });
/** Short-lived aura buff. */
const pulse = (battle, target, key, mods, extra = {}) => battle.addBuff(target, { key, mods, duration: AURA_DUR, ...extra });
/** Keep (or remove) a permanent self buff. */
function toggleBuff(battle, unit, key, on, mods) {
const cur = unit.findBuff(key);
if (on) {
if (!cur || JSON.stringify(cur.mods) !== JSON.stringify(mods)) battle.addBuff(unit, { key, mods });
} else if (cur) battle.removeBuff(unit, key);
}
/** "技力光环(同类效果取最高)": shared key, highest value wins. */
function spAura(battle, source, value, filter) {
whileDeployed(battle, source, AURA, () => {
for (const a of battle.alliesFor(source, source.ownerId)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue;
battle.addBuff(a, { key: 'aura:spRecovery', mods: { spRecoveryFlat: value }, duration: AURA_DUR, source, data: { v: value } });
}
});
}
/** Reveal stealthed enemies on the given tiles. */
/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
const skillActive = (u) => !!(u.skill && u.skill.active && u.skill.kind !== 'passive');
// ---- operator loadouts (DESIGN §16) ----------------------------------------------------------------------------
// The kit function receives the def of the SELECTED skill / module: `bb` = that skill's blackboard (normal Lv4 /
// elite Lv7), `def.skill` its record, talents / trait already carry the selected module's changes. `skills` holds the
// hand-authored spec of every selectable non-default skill (only the selected one is built, with its own `bb`);
// install / talent logic that belongs to ONE skill is gated on that skill being the selected one.
/** Id of the selected skill. */
const selId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null;
/** Is `id` the selected skill? */
const isSel = (def, id) => selId(def) === id;
/** `skills` map of a kit: the spec of the selected skill when a builder exists for it (DESIGN §16 kit contract). */
function alt(def, builders) {
const id = selId(def);
return id && typeof builders[id] === 'function' ? { [id]: builders[id]() } : {};
}
/** Instant or charges kind from the data (maxChargeTime). */
const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
/** No other ally operator / summon on the 4 (or 8) tiles around the unit. */
function lonely(battle, unit, diag = false) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if ((!dr && !dc) || (!diag && dr && dc)) continue;
const r = unit.tileR + dr, c = unit.tileC + dc;
if (!battle.grid.inBounds(r, c)) continue;
const o = battle.unitAt(r, c);
if (o && o !== unit && o.side === 'ally' && o.kind !== 'device') return false;
}
}
return true;
}
/**
* Module "攻击范围扩大" given as a new range (the selected module's data-only talent `rangeGrid`, e.g. SPC-X / RIN-X:
* the 3×3 caster range + ONE centre tile [0,3]). It replaces the unit's own range — so also the initial range of the
* DEFAULT trigger — while a skill with its own range (莫斯提马 S3, 白面鸮 S2…) keeps that range, as in AK. Null when the
* selected module (or 'none') changes no range. (The rangeGrid-only talent is dropped from `def.talents`: read the
* module record.)
*/
function moduleRangeGrid(def) {
const m = def?.raw?.module;
if (!m || !m.active || !m.id) return null;
const rec = (def.raw.modules || []).find((x) => x && x.uniEquipId === m.id);
for (const t of rec?.talentChanges || []) { const g = grid(t?.rangeGrid); if (g) return g; }
return null;
}
const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } };
/**
* Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708
* around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved.
*/
function pullToFront(battle, unit, e, force) {
return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0;
}
/** Physician module trait (录武官, 华法琳 elites): heals on allies below hp_ratio are ×heal_scale. */
function installLowHpHealBonus(battle, unit, tb) {
const s = num(tb.heal_scale, 0), r = num(tb.hp_ratio, 0);
if (!(s > 0) || !(r > 0)) return;
battle.on('heal', (c) => { if (c.source === unit && c.target !== unit && c.target.hpRatio < r) c.amount *= s; }, { owner: unit });
}
// DESIGN §5.6 documents `(bb, chess)`; content/index.js also passes the normalised def — rebuild it when absent.
const withDefaults = (kits) => Object.fromEntries(Object.entries(kits).map(([id, f]) => [id, (bb, chess, def) => f(bb || {}, chess, def || normalizeChess(chess))]));
export {
AURA, AURA_DUR, num, tbb, moduleBb, grid, batFlat, nationOf, enemyHasTag, keySet, enemiesOnRange, targetsInRange,
targetsInGrid, whileDeployed, pulse, toggleBuff, spAura, resCut, reveal, skillActive, isSel, alt, instantKind, lonely,
applyModuleRange, pullToFront, installLowHpHealBonus, withDefaults,
};
+204
View File
@@ -0,0 +1,204 @@
// server/sim/content/kits/shared/tier5.js — helpers and notes of the hand-authored kits for every tier-5 chess (DESIGN
// §7, docs/SIM.md §7.2; formerly tier5.js — the kits live one per file in ../ops/).
//
// export default { [baseChessId]: (bb, chess, def) => Kit }. `bb` = skill blackboard at the chess's level (normal Lv4 /
// elite Lv7), `chess` = data/chess.json record (talents with their own bb — elite records already carry the module talent
// upgrades — trait.bb incl. the module trait upgrade, stats, tokens), `def` = normalised def (skill.rangeGrid, …).
// Every number comes from those blackboards; the few constants below exist only in the skill TEXT (no blackboard key).
//
// Conventions shared by the kits of this file:
// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s,
// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT.
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge,
// × elementalTakenMul = 元素脆弱), with `element` set for the client colour.
// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload
// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun
// radius 1.7, S3 splash 1.7 and its refilled ammo capped at the skill's ammo; 寒檀 icicles splash 1.5 and cycle left
// row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit at 1.15 (30°/s, hit radius 0.4); 铃兰 T2 is an
// aura (sluggish enemies in range are 脆弱 while sluggish); 缇缇's chain sleep picks the highest-aggro enemy within
// 1.5; 乌尔比安 lands on the anchor tile > the tile beyond > his own tile; 引星棘刺 throws at the farthest forward tile
// when no enemy is in range; 夕 T2 summons only on a deployable target tile.
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
// the heal replaces that attack). Every other
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
// every MANUAL skill; AUTO skills never take a class row).
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'crit',
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
import { bodyInKeys } from '../../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
const HALF_HP = 0.5;
/**
* Abyssal Hunters (【深海猎人】 = character_table groupId `abyssal`; data/chess.json has no groupId, the charIds come from
* docs/research/03-operators.json): 幽灵鲨, 斯卡蒂, 歌蕾蒂娅, 乌尔比安, 归溟幽灵鲨. 浊心斯卡蒂 (char_1012_skadi2) has groupId
* null in the official data — she is NOT an Abyssal Hunter for these talents.
*/
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
/** Never auto-fires (CUSTOM_RANGE with an empty trigger grid): the kit activates the skill itself. */
const NEVER = Object.freeze({ rule: 'CUSTOM_RANGE', grid: Object.freeze([]) });
const AURA_IV = 0.25;
const AURA_DUR = 0.5;
// alternate skills (loadouts) — text-only numbers
/** "周围" (8-neighbourhood) as a radius: 隐德来希 S2 血镰, 缇缇 S2 sleep ward, 乌尔比安 S1 anchor, 引星棘刺 S3 area. */
const RING1 = 1.5;
// ---- helpers ------------------------------------------------------------------------------------------------------
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const on = (u) => !!u && u.alive && u.deployed && !u.removed;
/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */
const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet);
const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {};
const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null;
const traitBb = (chess) => chess?.trait?.bb ?? {};
const skillGrid = (chess, def) => def?.skill?.rangeGrid ?? chess?.skill?.rangeGrid ?? null;
const maxCharges = (chess, def) => num(def?.skill?.maxCharges, num(chess?.skill?.maxChargeTime, 1));
/** Blackboard `base_attack_time` (seconds) → batPct of this chess's base attack time. */
const batPct = (sec, chess) => { const b = num(chess?.stats?.bat, 1) || 1; const v = num(sec) / b; return v ? Math.max(-0.9, v) : 0; };
/** Drop zero / non-finite entries (a zero mod is noise in the buff list). */
const mods = (m) => { const o = {}; for (const k of Object.keys(m)) { const v = m[k]; if (typeof v === 'number' && Number.isFinite(v) && v !== 0) o[k] = v; } return o; };
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const nationOf = (u) => u?.def?.raw?.nationId ?? null;
const inFaction = (u, bond, nations) => !!u?.def && ((u.def.bonds || []).includes(bond) || nations.includes(nationOf(u)));
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId);
const isOp = (u) => u && u.kind === 'op';
// ---- operator loadouts (DESIGN §16) -------------------------------------------------------------------------------
// A kit function is resolved per SELECTED skill: `bb` / `chess.skill` / `def.skill` are the selected skill's. The default
// skill keeps its `skill` spec; every other selectable skill is a `skills[skillId]` entry. Kit `install` / talents run
// under every skill, so default-skill machinery in them is guarded by the selected skill id (`sid`).
/** Id of the selected skill of a chess record / def. */
const selectedId = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
/** `skills` map whose entries are built only when read (each spec is a function of the SELECTED skill's bb). */
function lazySkills(builders) {
const o = {};
for (const [id, build] of Object.entries(builders)) Object.defineProperty(o, id, { enumerable: true, get: build });
return o;
}
/** Spec kind of an instant skill (charges when the selected skill can charge). */
const instantKind = (chess, def) => (maxCharges(chess, def) > 1 ? 'charges' : 'instant');
/** Skill-range targeting override of the selected skill (undefined without a skill range). */
const skillRange = (chess, def, extra = null) => {
const g = skillGrid(chess, def);
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
};
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
function crowdAspd(battle, unit, key, aspd, cnt) {
if (!aspd || !(cnt > 0)) return;
whileOn(battle, unit, 0.2, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt) battle.addBuff(unit, { key, duration: 0.3, mods: { aspd } });
});
}
/** "攻击范围内存在元素损伤爆发的敌人时,技力自然恢复速度+X/秒" (modules PRI-Y). */
function burstSpUp(battle, unit, key, sp) {
if (!(sp > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
if (battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).some((e) => e.s.flags.burstLock)) battle.addBuff(unit, { key, duration: AURA_DUR, mods: { spRecoveryFlat: sp } });
});
}
/** First living deployed ally with the same operator (charId) — used to keep same-name talents from stacking. */
function leaderOf(battle, unit) {
const cid = unit.def?.charId;
for (const a of battle.allyUnits) if (on(a) && a.def?.charId === cid) return a;
return null;
}
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */
function elementHit(battle, src, tgt, amount, tag, element = null) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] });
}
/** Periodic check while the unit is on the field. */
function whileOn(battle, unit, sec, fn) {
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
}
/** "同类效果取最高" SP-recovery aura: refresh `aura:spRecovery` on matching allies unless a higher one is present. */
function spAura(battle, unit, value, filter) {
if (!(value > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.alliesFor(unit)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
battle.addBuff(a, { key: 'aura:spRecovery', duration: AURA_DUR, mods: { spRecoveryFlat: value } });
}
});
}
/** Module PRI-X (本源术师): damage vs enemies in an element burst ×damage_scale. */
function burstDamageUp(battle, unit, mul) {
if (!(mul > 1)) return;
battle.on('hit', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
if (c.target.s.flags.burstLock) c.dmg.mul *= mul;
}, { owner: unit });
}
/** Modules PHY-X / GUA-X: heals on allies below hp_ratio ×heal_scale. */
function lowHpHealUp(battle, unit, tb) {
const mul = num(tb.heal_scale), thr = num(tb.hp_ratio);
if (!(mul > 1) || !(thr > 0)) return;
battle.on('heal', (c) => {
if (c.source !== unit || c.opts?.regen || !c.target || c.target.side !== 'ally') return;
if (c.target.hpRatio < thr) c.amount *= mul;
}, { owner: unit });
}
/**
* Modules "攻击范围扩大" (夕 SPC-X, 白面鸮 RIN-X): the range becomes the SELECTED module's own grid — the data's
* range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + ONE centre tile [0,3] — like tier4's
* 莫斯提马 / 莱恩哈特 / 白面鸮 (integration review: a flat forward +1 added a whole column, 3 tiles, and widened the skill
* ranges too). It replaces the unit's own range, so the DEFAULT trigger's initial range follows; skills with their own
* range keep it. Without such a grid in the data: one extra forward tile [ASSUMED].
*/
function moduleRangeUp(battle, unit, chess) {
if (!chess?.isGolden || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return;
const m = chess.module;
const rec = m && m.active && m.id ? (chess.modules || []).find((x) => x && x.uniEquipId === m.id) : null;
const g = (rec?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (g) {
unit.rangeGrid = g.map((p) => [p[0], p[1]]);
battle.refreshRange(unit);
return;
}
// a permanent (persist, never-expiring) rangeExtend: the engine counts it in the initial range of the DEFAULT skill
// trigger ("敌人进入初始攻击范围") as well
battle.addBuff(unit, { key: 't5:moduleRange', mods: { rangeExtend: 1 }, persist: true, allowDead: true });
}
/** Permanent (talent) stat buff that survives death/redeploy. */
function permBuff(battle, unit, key, m) {
const mm = mods(m);
if (Object.keys(mm).length) battle.addBuff(unit, { key, mods: mm, persist: true, allowDead: true });
}
/** Enemies inside an absolute-key grid of `grid` offsets around `unit` (best targets first). */
function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}) {
const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0);
let list;
if (ignoreStealth) {
const set = new Set(keys);
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set));
} else list = battle.enemiesInKeys(keys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
export {
HALF_HP, NEVER, AURA_IV, AURA_DUR, RING1, num, on, inRange, talent, talentRec, traitBb, skillGrid, maxCharges, batPct,
mods, dist, inFaction, isAbyssal, isOp, selectedId, lazySkills, instantKind, skillRange, crowdAspd, burstSpUp,
leaderOf, elementHit, whileOn, spAura, burstDamageUp, lowHpHealUp, moduleRangeUp, permBuff, enemiesInGrid,
};
+282
View File
@@ -0,0 +1,282 @@
// server/sim/content/kits/shared/tier6.js — helpers and notes of the hand-authored kits (formerly tier6.js; the kits
// live one per file in ../ops/) for every tier-6 chess (阶 VI, DIY slots excluded) plus the
// hidden chess granted by effects (盟约·辅助干员 chess_char_1_15, band Pith "优等生"; 妮芙 chess_char_6_10 is the hidden
// tier-6 entry). `export default { [baseChessId]: (bb, chess, def) => Kit }` (docs/SIM.md §7.2).
//
// Every number comes from the blackboards (skill bb at the chess level: normal Lv4 / elite Lv7; talents from
// `def.talents[i].bb`, elite module upgrades included; module-only talent parts from `chess.talents[index −1]`; trait
// module upgrades from `def.traitBb`). A few numbers exist only in the official text; they are parsed from it
// (e.g. 锏 "总计10次斩击", 艾雅法拉 "5连发", 仇白 "额外攻击2个目标", 海嗣 duration). The profession defaults of
// professions.js (bard aura, phalanx guard, chain bounces, funnel ramp, bombarder aftershocks, geek drain, tactician
// reinforcement …) are kept and built upon.
//
// Simplifications (one line per id; see also the report of the content phase):
// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst).
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
// snow lives on the ground tiles of her current range; the frozen "保护目标" token is not used (she just
// freezes herself).
// 6_03 余 the fire wall is her column; "crossing" = source and target on opposite sides of it; bullet clearing =
// cancelling a ranged enemy hit that crosses it; talent 2 given to all ops = each op regenerates itself
// (elite: and gets the ×1.14 arts vs burning targets); 庇护 = phys/arts taken ×(1−0.25).
// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers;
// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its
// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation).
// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff);
// the storm stops when she leaves the field.
// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area.
// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range;
// S3 puts a 黄金盟誓 on every free deployable melee tile of her talent-1 area (fences too), each for
// the skill duration (the token's maxDeployCount 1 is its hand limit — tokens.js).
// 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the
// skill ends (duration = remaining skill time).
// 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on
// enemies in her range (`elementHit`; several 塑心 do not stack — the strongest applies).
// 6_10 妮芙 失魂 = 元素伤害 ('elemental') per second while the target's apoptosis burst lasts.
// 6_11 缪尔赛思 流形 copies the nearest allied operator of its player on the field (no hand/bench in battle; same pick
// as content/tokens.js) when its own skill starts; it copies the listed stats + range + damage type (not
// splash/chain/multi-hit shapes); clones do not split again. MLYSS_WTRMAN trigger = DEFAULT or an enemy
// in a (copied) 流形's range. An uncopied 流形 never attacks and its copy skill waits (ready) until someone
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
// 6_12 迷迭香 "溅射范围扩大": S2 radius 1.5 (PRTS 溅射半径一览; ×1.3 [ASSUMED] until 0.1.1); 感知稳定 picks among the
// owner's deployed casters (none ⇒ no buff).
// 6_13 新约能天使 bombardment radius 1 tile (not in data).
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
// 脆弱 status (同名效果取最高).
// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order).
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0
// tiles/s; [ASSUMED] the turn rate is not modelled: straight at the target); every drone is out, so she
// makes no normal attack herself, while each drone on its target attacks like a normal drone (her attack
// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2
// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone,
// mid-skill too). Base drone count 1.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit
// but stays put: Battle._displaceable).
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
// ({ [skillId]: SkillSpec }, built from the SELECTED skill's `bb`: normal Lv4 / elite Lv7; triggers from that skill's
// data). Talent / install hooks written for the default skill check `onDefaultSkill(chess)` (余 闲云隐市 for all, 维娜
// S3 targets, 焰影苇草 灼痕 until the skill ends, 流明 S3 bullets, 溯光星源 S3 locks, 耀骑士临光 S3 true damage, 缪尔赛思
// S3 bind, 浊心斯卡蒂 tide). Module choices read the loadout-resolved record (trait.bb, talents, hidden module parts);
// the module-specific code: 浊心斯卡蒂 新生代, 维娜 秩序圣“球”, 焰影苇草 “独属自己的一隅”, 塑心 音乐家的旅程, 缪尔赛思
// 落叶四季, 仇白 欲雪时, 耀骑士临光 “骑士家族”, 锏 新合同, 纯烬艾雅法拉 想要留下的生命 (生息演算 / 集成战略-only parts of
// the RA / IS modules are not modelled). Alternate-skill simplifications:
// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first).
// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH =
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching
// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the
// gate); spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's
// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚.
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts).
// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check).
// 6_07 S2 passive counts the OTHER allies of the talent-1 area.
// 6_08 S2 carriers = operators of her range (ground first, then lowest HP); a fireball shoots an enemy of the carrier's
// range (else hers).
// 6_09 S2 partner = the highest-ATK other operator of her range (re-picked every 0.5 s); S3 picks by base stats.
// 6_11 S1 one DP every interval, the remainder when it ends; module 落叶四季 "援军阻挡的敌人更容易受到我方的攻击" = the
// 流形 variant's taunt_level (+1) on the enemies it blocks.
// 6_05 S2 / 6_12 S3 base_attack_time scales the base attack time (akdata "乘算负数"); see batOf.
// 6_12 S1 the extra arts hit lands on the main + splash victims (not the aftershocks).
// 6_13 S1 "主动关闭" = skill.stop(); S3 the 投递坐标 (the player's pick) goes next to the first enemy entering her range
// (at the latest when S3 starts); delivered = the knocked-out non-ranged operator of hers with the latest
// respawnAt; the bombardment radius is AIRSTRIKE_RADIUS.
// 6_14 S1 the HoT covers the allies within 1.5 tiles of the healed target; S2 "蓄力" = cast with every charge full.
// 6_15 S1 the bind-end explosion radius is 1.2 tiles [ASSUMED].
// 6_16 S1 bounces are full hits within attack@projectile_range, never on the same enemy twice in a row.
// 6_18 S1 "非移动敌人" = blocked / not walking / immobilised enemies; S2 every drone ramps on its own target.
// 6_20 S2 the barrier is one pool over her range at the cast, absorbing gauge fills (before 元素抗性).
//
// Summons: skill summons (黄金盟誓, “耀阳”) are spawned without a kit so content/tokens.js's token kit applies (true
// damage / appear burst / lifetime); the fallbacks here only run when no token kit exists (`kit.fromTokens`).
// Owner-coupled behaviour of placeable summons (海嗣 aura/lifetime/redeploy, 流形 copy/steal/split/respawn) lives in
// these kits — tokens.js skips it for summoners with a hand-authored kit (its `managed()` rule).
// Engine notes: "extra targets" (蕾缪安 wanted, 维娜 S3) use the engine's extra range keys (`battle.setExtraRange`,
// merged into every range rebuild); 海嗣 / 流形 extend their summoner's DEFAULT trigger through
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js';
import { hasHp } from '../../../damage.js';
// ------------------------------------------------------------------------------------------------------------------
// helpers
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
/** Blackboard value by exact key, else the first key ending with `.key` / `]key` (prefixed official keys). */
function bv(bb, key, d = 0) {
if (!bb) return d;
const v = bb[key];
if (typeof v === 'number' && Number.isFinite(v)) return v;
for (const k of Object.keys(bb)) {
if (k.endsWith('.' + key) || k.endsWith(']' + key)) {
const x = bb[k];
if (typeof x === 'number' && Number.isFinite(x)) return x;
}
}
return d;
}
const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {};
const tdesc = (def, i) => String((def && def.talents && def.talents[i] && def.talents[i].description) || '');
/** Module-only (hidden, index −1) talent parts of a chess record, merged (first occurrence wins). */
function moduleBb(chess) {
const out = {};
for (const t of (chess && chess.talents) || []) {
if (!t || t.index !== -1 || !t.bb) continue;
for (const [k, v] of Object.entries(t.bb)) if (!(k in out)) out[k] = v;
}
return out;
}
const parseN = (text, re, d) => { const m = String(text ?? '').match(re); return m ? +m[1] : d; };
const live = (u) => !!u && u.alive && u.deployed && !u.removed && !u.hidden;
const isElite = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const hasBond = (u, id) => !!(u && u.def && Array.isArray(u.def.bonds) && u.def.bonds.includes(id));
const keyOf = (u) => Math.round(u.y) * COLS + Math.round(u.x);
const opsOf = (battle, ownerId) => battle.allies(ownerId).filter((a) => a.kind === 'op');
const ANY = Object.freeze({ canHitFly: true });
const enemiesIn = (battle, unit, keys) => battle.enemiesInKeys(keys || unit.rangeKeys || [], unit, ANY);
const isTok = (t, id, owner) => !!t && t.kind === 'token' && t.defId === id && t.ownerUnit === owner;
/**
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
* Talent / install hooks written for the default skill check it (they run under every selected skill).
*/
function onDefaultSkill(chess) {
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
}
/** Id of the selected skill (loadout-resolved record, else the def). */
const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
/**
* Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js,
* checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on
* 2.3 s ⇒ 1.8 s, the tier1 `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒
* ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5).
*/
function batOf(v, def, mul = false) {
const x = num(v);
if (!x) return 0;
if (x > 0 || mul) return Math.max(-0.9, x);
const bat = num(def?.stats?.bat, 1) || 1;
return Math.max(-0.9, x / bat);
}
/** Spec kind of an instant skill (charges when the data gives it several). */
const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
/** The selected skill's own range grid (null: the unit's range). */
const skillGridOf = (def) => (def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null);
/**
* 异常状态 (ba.debuff "包括晕眩、寒冷、冻结等") — the control statuses cleansed by 流明 (抵抗 itself is the engine `resist`
* status: RESIST_STATUSES). Stat debuffs (虚弱/脆弱/…) are not 异常状态.
*/
const ABNORMAL = new Set(['stun', 'freeze', 'cold', 'sleep', 'silence', 'fear', 'attract', 'tremble', 'bind', 'levitate',
'disarm', 'palsy', 'sluggish', 'slow']);
const hasAbnormal = (u) => u.buffs.some((b) => b.status && ABNORMAL.has(b.status));
/** Remove every 异常状态 of `u`; returns how many were removed. */
function cleanseAbnormal(battle, u) {
let n = 0;
for (const b of u.buffs.slice()) if (b.status && ABNORMAL.has(b.status)) { battle.removeStatus(u, b.status); n++; }
if (n) battle.fx('cleanse', { x: u.x, y: u.y, id: u.id });
return n;
}
const AROUND8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const N4 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
/** Every offset of the 19×21 field: "攻击范围扩大至整个战场". */
const WHOLE_FIELD = (() => {
const g = [];
for (let dr = -(ROWS - 1); dr <= ROWS - 1; dr++) for (let dc = -(COLS - 1); dc <= COLS - 1; dc++) g.push([dr, dc]);
return Object.freeze(g);
})();
const STATE = new WeakMap();
/** Per-battle shared state for kits (trackers registered once per battle). */
function bstate(battle) {
let s = STATE.get(battle);
if (!s) { s = {}; STATE.set(battle, s); }
return s;
}
/**
* Free deployable tiles of `grid` around `unit` (melee ground tiles unless `ranged`). Skips `battle.isReservedTile`
* (a living unit, or the home tile of a piece that has not deployed yet / waits to redeploy: a summon parked there
* would keep that operator off the field).
*/
function freeTiles(battle, unit, grid, { ranged = false, ground = true } = {}) {
const out = [];
const own = unit.tileR * COLS + unit.tileC;
for (const k of absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) {
if (k === own) continue;
const r = (k / COLS) | 0, c = k % COLS;
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged })) continue;
if (ground && !battle.grid.groundPassable(r, c, true)) continue;
out.push([r, c]);
}
return out;
}
/** Tile closest to any living enemy (first tile when there is no enemy). */
function bestTile(battle, tiles) {
if (!tiles.length) return null;
const en = battle.enemies.filter((e) => e.alive && !e.hidden);
if (!en.length) return tiles[0];
let best = tiles[0], bd = Infinity;
for (const t of tiles) {
let d = Infinity;
for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
if (d < bd - 1e-9) { bd = d; best = t; }
}
return best;
}
/**
* Element (损伤) amplification: element gauge fills skip the `hit` hook but fire `elementHit` { source, target, dmg }
* before the fill — multiply `dmg.mul` there (one instance, exact burst threshold). `fn(ctx)` returns the factor.
*/
function onElementHit(battle, unit, fn) {
battle.on('elementHit', (ctx) => {
if (!ctx.dmg || ctx.dmg.type !== 'element' || !ctx.target) return;
const f = fn(ctx);
if (Number.isFinite(f) && f > 0 && f !== 1) ctx.dmg.mul *= f;
}, { owner: unit });
}
/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */
const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) =>
(amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
/**
* Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles
* from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the
* 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself
* stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it.
*/
function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) {
if (!e || !e.alive || !unit) return 0;
return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop });
}
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
function aura(battle, unit, period, fn) {
battle.every(period, () => { if (live(unit)) fn(); }, { owner: unit });
}
export {
num, bv, tbb, tdesc, moduleBb, parseN, live, isElite, hasBond, keyOf, opsOf, ANY, enemiesIn, isTok, onDefaultSkill,
selectedSkill, batOf, instantKind, skillGridOf, ABNORMAL, hasAbnormal, cleanseAbnormal, AROUND8, N4, WHOLE_FIELD,
bstate, freeTiles, bestTile, onElementHit, elementDmg, pullToward, aura,
};