Files
ark-stronghold-protocol/server/sim/battle/queries.js
T
sganggs 5ef47b0cf1 feedback5 O23: 自选 kits for 白铁, 娜斯提 (with their 支援装置) and 死芒 (with her 悲叹的仆役); ally targets for 白铁's 铁钳号·原型机
- kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn): S1 / S2 / S3, 战地工程师 (device stock 3, CRA-X +1, refilled at each
  deployment; a device that left comes back on its tile after its redeploy time, paying its cost, one stock, while he stands;
  his devices leave with him), 节约经费 (prob 0.7 / CRA-X stage 3 0.9), CRA-X (stock, costs, stage 3 SP +0.2/s), CRA-Y
  (redeploy times, stage 3 ASPD +6 on device-buffed operators); the device kits 白铁™多功能平台 (S1 ATK +12 % ×2.5 / 3 in S1;
  S2 1 SP / 3.5 s, 2.5 / 2 s in S2, 0.4 % / s 流失 ×2) and 铁钳号·原型机 (an ally target: damage cancelled, 1 SP per hit,
  a 工匠's hit heals 1 %; its skill −2.5 % then 230 / 260 % + 110 / 120 % within 1.1 of 白铁's ATK; 团结的力量 ×0.8
  physical behind it). Exports the shared 工匠 device helpers.
- kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti): S1 / S2 / S3, 前方施工 (stock; CRA-X stage 3 first device −3, combined
  ranges), 注意安全 (10 % 庇护, 15 % + 1 SP / 6 s for her and our ranged operators once a ranged enemy appeared), CRA-X; the
  device kits “质检专员”, “监工专员” (3 / 8 HP, its S2 pulses of SP and her barrier) and “应急承重小组” (小工程师 + the 高台 it
  builds and levels).
- kits/ops/op-necras.js — 死芒 (char_450_necras): S1 / S2 / S3, 复燃 (summon on the kill tile or a random tile of her range,
  upgrade at 3), 回光黯淡, the trait (the enemies her summons block are her targets; SOC-X ×1.15), SOC-X stage 3; the
  悲叹的仆役 kit and its special form (S3).
- Engine (additive): Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget (battle/queries.js); ai.js acquireTargets puts a
  registered ally target of the range after every enemy, performAttack flies ranged attacks to it. Nothing changes while
  none is registered: every golden family but diy unchanged. docs/SIM.md §6 documents it.
- kits/index.js: the three files appended to OPERATOR_KIT_FILES.
- Tests: test/content/op_ironmn.test.js, op_nasti.test.js, op_necras.test.js, test/sim/feedback5-o23-engine.test.js.
- Golden: only diy moved — diy-106 … diy-109 re-chunked (the new operators' configurations come before the prototypes),
  diy-110 … diy-114 new.
2026-10-06 05:28:54 +08:00

283 lines
14 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/battle/queries.js — Battle methods: queries: the per-tick enemy tile index, enemies / allies by tile keys,
// grid, radius and selectability, the ally targets (allies our attacks select like enemies), players, and the range
// rebuild of an ally (current, base and extra range keys).
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { ROWS, COLS } from '../constants.js';
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, enemyStealthed } from '../targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from '../body.js';
export class BattleQueries {
/**
* Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile
* it occupies.
*/
_buildEnemyIndex() {
for (const k of this._ebUsed) this._eb[k].length = 0;
this._ebUsed.length = 0;
for (const e of this.enemies) {
if (!e.alive || e.hidden) continue;
if (e.hitArea) {
for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); }
continue;
}
const r = Math.round(e.y), c = Math.round(e.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
const k = r * COLS + c;
const b = this._eb[k];
if (!b.length) this._ebUsed.push(k);
b.push(e);
}
}
/** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */
enemiesInKeys(keys, attacker, profile) {
const out = [];
if (!keys) return out;
for (let i = 0; i < keys.length; i++) {
const b = this._eb[keys[i]];
if (!b || !b.length) continue;
for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e);
}
return out;
}
/**
* Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's
* 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方";
* kits/ops/op-ironmn.js). Those standing on one of `keys`, alive and deployed — never `attacker` itself, and an ally target
* selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 −2 puts them last). [] at once
* while none is registered, so every other battle runs exactly as before.
*/
allyTargetsInKeys(keys, attacker) {
const set = this._allyTargets;
if (!set || !set.size || !keys || !attacker || set.has(attacker)) return [];
const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys);
const out = [];
for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a);
return out;
}
/** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */
setAllyTarget(unit, on = true) {
if (!unit || unit.side !== 'ally') return false;
if (on) (this._allyTargets ??= new Set()).add(unit);
else if (this._allyTargets) this._allyTargets.delete(unit);
return true;
}
/** Is `unit` a registered ally target (setAllyTarget)? */
isAllyTarget(unit) {
return !!(unit && this._allyTargets && this._allyTargets.has(unit));
}
/**
* A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the
* 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE).
*/
anyEnemyInKeys(keys) {
if (!keys) return false;
for (let i = 0; i < keys.length; i++) {
const b = this._eb[keys[i]];
if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true;
}
return false;
}
/**
* Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally
* is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr —
* damage.js heal).
*/
injuredAlliesInKeys(keys, healer, includeElement = false) {
const out = [];
if (!keys) return out;
const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys));
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
for (const a of this.allyUnits) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
if (!set.has(a.tileR * COLS + a.tileC)) continue;
if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
const injured = a.hp < a.s.maxHp - 1e-6;
const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0;
if (injured || elem) out.push(a);
}
out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
return out;
}
lowestHpAllyInRange(unit) {
const l = this.injuredAlliesInKeys(unit.rangeKeys, unit);
return l[0] ?? null;
}
/** Units of the opposite side (or `side`) whose tile is inside `grid` offsets relative to `unit`. */
unitsInGrid(unit, grid, { side = null, extend = 0 } = {}) {
const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], Math.round(unit.y), Math.round(unit.x), unit.dir, extend);
const want = side ?? (unit.side === 'ally' ? 'enemy' : 'ally');
const set = new Set(keys);
const out = [];
const list = want === 'enemy' ? this.enemies : this.allyUnits;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden) continue;
if (bodyInKeys(x, set)) out.push(x);
}
return out;
}
/** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */
alliesInGrid(unit) {
const set = unit.rangeKeySet || new Set(unit.rangeKeys || []);
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit));
}
/**
* May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by`
* itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定
* rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6
* item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys).
*/
allySelectable(a, by = null) {
return !!a && (a === by || !a.s.flags.isolated);
}
/** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */
alliesFor(by, ownerId = null) {
return this.allies(ownerId).filter((a) => this.allySelectable(a, by));
}
/**
* Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y).
* `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3
* 溅射半径"): every enemy counts by its position (判定中心), a huge one too.
*/
enemiesInRadius(x, y, r, centre = false) {
const out = [];
const r2 = r * r + 1e-9;
for (const e of this.enemies) {
if (!e.alive || e.hidden) continue;
if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; }
const dx = e.x - x, dy = e.y - y;
if (dx * dx + dy * dy <= r2) out.push(e);
}
return out;
}
/**
* The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是
* 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no
* untargetable enemy and no 隐匿 one unless revealed, blocked or not hidden again yet after a block (targeting.js
* enemyStealthed; tile selectors — enemiesInKeys / canTargetEnemy — already skip them). Flying and asleep enemies stay
* the caller's choice. Enemy-side effects on other enemies (auras, heals) and physical collisions keep enemiesInRadius.
* Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash and skill circles still reached an
* unblocked 隐匿 enemy.
*/
foesInRadius(x, y, r, centre = false) {
const out = this.enemiesInRadius(x, y, r, centre);
let n = 0;
for (const e of out) {
const f = e.s.flags;
if (f.untargetable || (f.stealth && enemyStealthed(e))) continue;
out[n++] = e;
}
out.length = n;
return out;
}
/**
* Every deployed ally within `r` of (x, y) — no selection rule: an enemy's area effect selects among them with
* targeting.js areaSelectable (content/enemies/helpers.js areaAllies: no 隐匿 ally, the blocker included, GitHub #97; DESIGN §22.12).
*/
alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) {
const out = [];
const r2 = r * r + 1e-9;
for (const a of this.allyUnits) {
if (!a.alive || !a.deployed || a.hidden) continue;
if (!includeDevices && a.kind === 'device') continue;
if (ownerId != null && a.ownerId !== ownerId) continue;
const dx = a.x - x, dy = a.y - y;
if (dx * dx + dy * dy <= r2) out.push(a);
}
return out;
}
/** Alive deployed allies (ops + tokens), optionally of one player. */
allies(ownerId = null) {
return this.allyUnits.filter((a) => a.alive && a.deployed && a.kind !== 'device' && (ownerId == null || a.ownerId === ownerId));
}
aliveEnemies() { return this.enemies.filter((e) => e.alive); }
unitAt(r, c) { const u = this._occ[r * COLS + c]; return u && u.alive ? u : null; }
unitById(id) { return this.units.find((u) => u.id === id) ?? null; }
tileInfo(r, c) { return this.grid.tile(r, c); }
getPlayer(playerId) { return this.players.find((p) => p.playerId === playerId) ?? null; }
_pp(playerId) { return playerId == null ? null : this._perPlayer[playerId] ?? null; }
// =============================================================================================================
// ranges
/**
* Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override +
* rangeExtend — the unit's own 攻击距离 unless the running skill's range ignores it, `targeting.noRangeExtend`: 信仰搅拌机
* S3, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响" — plus the skill's own `targeting.rangeExtend`) ∪
* `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range of the DEFAULT
* skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, never-expiring
* buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; `liveRangeGrid` = the
* relative grid (facing RIGHT) of the 攻击范围 the detail card shows (shared/protocol.js unitStatsEntry `range`;
* community report E1 after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11): the grid behind
* `rangeKeys` without the extra keys (targeting.js extendedGrid; the grid itself when nothing extends it), or the
* unit's own range while a skill's grid only selects targets (`targeting.showOwnRange`: 荒芜拉普兰德 S1, no official
* range change). Rebuilt on deploy / relocate / skill range switches and whenever either extend changes (rangeChanged).
*/
_refreshRange(u) {
const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null;
const grid = (tg && tg.rangeGrid) || u.rangeGrid || [[0, 0]];
const ext = (tg && tg.noRangeExtend ? 0 : u.s.rangeExtend) + ((tg && tg.rangeExtend) || 0);
u._rangeExtend = u.s.rangeExtend;
u._baseExtend = u.s.baseRangeExtend;
const keys = absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext);
const set = new Set(keys);
const extra = u.extraRangeKeys;
if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); }
u.rangeKeys = keys;
u.rangeKeySet = set;
const own = !!(tg && tg.showOwnRange);
const lg = own ? u.rangeGrid || [[0, 0]] : grid, le = own ? u.s.rangeExtend : ext;
u.liveRangeGrid = le > 0 ? extendedGrid(lg, le) : lg;
if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend);
if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile
}
/** True when a buff changed the unit's rangeExtend (total or permanent part) since its range was last built. */
rangeChanged(u) {
return u._rangeExtend !== undefined && (u._rangeExtend !== u.s.rangeExtend || u._baseExtend !== u.s.baseRangeExtend);
}
/** Public: rebuild a unit's range after content changed `unit.rangeGrid` (流形 copies) or its tile keys. */
refreshRange(unit) {
if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false;
this._refreshRange(unit);
return true;
}
/**
* Extra targetable tiles merged into the unit's range (蕾缪安 wanted targets, 维娜 S3, …): absolute tile keys
* (row × COLS + col), kept across every later range rebuild until content sets them again (null / [] clears).
* Not part of `baseRangeKeys` (the DEFAULT trigger range) — see SkillRuntime.addTriggerRange for that.
*/
setExtraRange(unit, keys) {
if (!unit || unit.side !== 'ally') return false;
const out = [];
const seen = new Set();
for (const k of keys || []) if (Number.isInteger(k) && k >= 0 && k < ROWS * COLS && !seen.has(k)) { seen.add(k); out.push(k); }
unit.extraRangeKeys = out.length ? out : null;
if (unit.alive && unit.deployed) this._refreshRange(unit);
return true;
}
}