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.
225 lines
14 KiB
JavaScript
225 lines
14 KiB
JavaScript
// 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,
|
||
};
|