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