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:
1 parent
6b489b1776
commit
1169102d46
143 files changed
+12924
-11784
No files matched your search
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user