Files
ark-stronghold-protocol/server/sim/content/kits/shared/tier1.js
T
sganggs cd180bc752 feedback5 WE1: 庇护 is one effect per unit — 宴, 余, 缪尔赛思, 赫拉格, 左乐 and 赫德雷 join the shared key (follow-up 18)
gamedata_const ba.protect / PRTS 术语 庇护 "受到的物理和法术伤害降低相应比例(同名效果取最高)": the strongest source holds; the
自选 kits already used battle.applyStrongest('protect'), these six multiplied private cuts with it.

- kits/shared/tier1.js: PROTECT, protectMods, PROTECT_TICK_HOLD and holdProtect (applyStrongest on the shared key).
- 宴 / 赫拉格 / 左乐 (below their HP threshold): refreshed every tick and at each hit on them; 余 (blocking) and 缪尔赛思's
  melee copies at their old cadence; 赫德雷's 庇护 joins the key, his CRU-X 物理伤害 + becomes its own instance. 左乐 keeps the
  PRTS S2 ordering (his own 庇护 newer than his barrier cuts behind it); 行险 drops his own held 庇护 for its 0.1 s
  re-judgement [ASSUMED: another source's returns with its next refresh]. 遥's bubbles stay separate (the client's
  damage_resistance[bonus] multiplies with the common damage_resistance[inf]).

Golden: no scenario moved — no unit of the corpus holds two 庇护 at once, and one source alone cuts exactly as before.

Tests: test/content/feedback5-protect.test.js (new, 2); kits_t6 (余), kits_alt_t6 (缪尔赛思), op_hodrer updated to the
shared key; kits_t1t2, op_helage, op_zuole pass. Docs: SIM.md §3, kits/README.md checklist 9.
2026-10-06 15:43:24 +08:00

261 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, TICK } 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 });
}
/**
* 庇护 (gamedata_const ba.protect "受到的物理和法术伤害降低相应比例(同名效果取最高)"; PRTS 术语释义 庇护): ONE effect per unit
* whoever grants it — battle.applyStrongest under this key, the strongest value holds and a weaker one resumes when it
* outlasts it. (遥's bubbles are a separate key, the client's damage_resistance[bonus] — they multiply with it.)
*/
export const PROTECT = 'protect';
/** The mods of a 庇护 value: physical and arts damage taken ×(1 − v). */
export const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
/** A hold that outlives one tick and lapses in the next: refreshed every tick while its condition lasts. */
export const PROTECT_TICK_HOLD = 1.5 * TICK;
/** Hold 庇护 `value` on `target` for `duration` s (the shared PROTECT effect). */
export function holdProtect(battle, target, value, duration, source = null) {
if (value > 0) battle.applyStrongest(target, PROTECT, { duration, value, mods: protectMods, source });
}
/** 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;