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

+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,
};