Files
ark-stronghold-protocol/server/sim/targeting.js
T
sganggs 4bd154e326 feedback7 R22-GHSIM: a 速射手 (优先攻击空中单位) blocking a ground enemy shoots a flyer in its range first; every other attacker keeps its blocked enemy first (GitHub #220, the owner's decision of 2026-10-08); golden: roster-026, roster-037 move
The owner's decision (relayed 2026-10-08): only the 速射手 air priority outranks the attacker's own blocked enemy — PRTS
索敌的概念 「阻挡(近战限定)→特殊优先级→…」 — while the user's playtest #6 rule 「阻挡了就一定要能打到」 keeps 'blocked first'
for every other attacker and priority. PR #220 by TsangAsuna (for #205) moved every special priority ahead of the block;
re-implemented for priority 'fly' only (targeting.js sortEnemyTargets: the fly key, then blocked, then the rest as
before) — the trait's priority and a skill's targeting priority 'fly' (新约能天使) alike. Seen (repro): 能天使 on a melee
tile blocking a walker kept shooting it with a flyer in range; now the flyer, and 远牙 (神射手, lowest DEF first) still hits
the enemy it blocks over a softer or a flying one. Docs: SIM.md (blocking), design/engine.md, PLAYING.md 阻挡.

Golden: roster-026 (流星, 速射手) and roster-037 (新约能天使's priority 'fly') now shoot flyers in range while blocking.
2026-10-08 20:29:45 +08:00

249 lines
17 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/targeting.js — range tests, target filters and priorities (DESIGN §3, §5.5).
//
// Grid ranges: `[dRow, dCol]` offsets relative to facing RIGHT, rotated by the unit's direction (sim/dir.js: RIGHT
// (dr,dc), UP (dc,−dr), LEFT (−dr,−dc), DOWN (−dc,dr); row 0 = bottom). An enemy is in range when the tile containing
// it (round(y), round(x)) is one of the absolute range tiles — a huge enemy (巨型单位) when any tile its hit rectangle
// occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
// Operator priority: (1) enemies it blocks (every blocker, ranged ones on melee tiles included — user playtest #6
// follow-up "阻挡了就一定要能打到"; Battle.blockedTargets; a healer's heal attack picks injured allies instead, blocking
// or not — ai.js acquireTargets), (2) profile priority (fly/lowDef/…),
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
// priority: sortAllyTargets. 起飞 (an ally's flag `liftoff`) = 对地规避: no ground enemy selects it (evadesGround).
// An enemy's area effects select allies with areaSelectable, its buff auras with auraSelectable (no 隐匿 ally, the one
// blocking the source included — GitHub #97; 迷彩 is not checked — DESIGN §22.12).
import { COLS, ROWS } from './constants.js';
import { normDir, rotateOffset } from './dir.js';
import { bodyDist } from './body.js';
/**
* Build the absolute tile-key list of a grid for a unit standing at (r,c) facing `dir` ('UP'|'RIGHT'|'DOWN'|'LEFT';
* a legacy facing sign ±1 is read as RIGHT / LEFT).
*/
export function absoluteRangeKeys(grid, r, c, dir, extend = 0) {
const keys = [];
const seen = new Set();
const d = normDir(dir);
const add = (dr, dc) => {
const [ar, ac] = rotateOffset(dr, dc, d);
const rr = r + ar, cc = c + ac;
if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) return;
const k = rr * COLS + cc;
if (!seen.has(k)) { seen.add(k); keys.push(k); }
};
// kits may hand over junk (a number, a string, [[1,'a']]): ignore anything that is not an integer [dr, dc] pair
// instead of throwing inside the attack loop every tick
if (!Array.isArray(grid) || !Number.isInteger(r) || !Number.isInteger(c)) return keys;
const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1]));
for (const [dr, dc] of cells) add(dr, dc);
if (extend > 0 && Number.isFinite(extend)) {
const maxByRow = new Map();
for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc));
for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, COLS); k++) add(dr, mx + k);
}
return keys;
}
// extendedGrid(grid, extend): the relative form of absoluteRangeKeys' rangeExtend — one implementation with the card's
// record range (shared/loadoutRecord.js attackRangeGrid), re-exported here for the battle (Battle._refreshRange).
export { extendedGrid } from '../../shared/loadoutRecord.js';
/** Tile key of a unit's current position. */
export function tileKeyOf(u) {
const r = Math.round(u.y), c = Math.round(u.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return -1;
return r * COLS + c;
}
/** Key of the buff that keeps an enemy's 隐匿 source `sourceKey` switched off after a block (Battle._stealthSwitch). */
export const stealthOffKey = (sourceKey) => `stealthOff:${sourceKey}`;
/**
* Is enemy `e`'s 隐匿 in effect — no ally selection picks it, operator splash skips it (Battle.foesInRadius), the b.snap
* stealth bit is set? Not while it is revealed (反隐, flag `reveal`), not while blocked, and not while every 隐匿 source
* it holds is still switched off after its last block (flag `stealthOff`: PRTS 作战机制 §隐匿 "在被我方单位阻挡后会解除隐匿,
* 不被阻挡的3秒后重新进入隐匿" — constants.js STEALTH_RESTORE, or the source's own "(解除阻挡N秒后恢复)").
*/
export function enemyStealthed(e) {
const f = e.s.flags;
if (!f.stealth || f.reveal || e.blockedBy) return false;
if (!f.stealthOff) return true;
for (const b of e.buffs) if (b.flags && b.flags.stealth && !e.findBuff(stealthOffKey(b.key))) return true;
return false;
}
/**
* Can `attacker` (ally) target enemy `e` at all (ignoring range)? A profile's own exclusion `skipEnemy(e)` (a kit trait:
* 嵯峨 劝善 "嵯峨不攻击重伤单位") keeps such an enemy out of the unit's normal targets, the enemies it blocks
* (blockedTargets) and the target condition of its skill triggers (they pass `unit.profile`).
*/
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
const f = e.s.flags;
if (f.untargetable || (f.sleep && !(profile && profile.hitSleep))) return false;
if (f.stealth && enemyStealthed(e)) return false;
if (e.isFlying && !(profile && profile.canHitFly)) return false;
if (profile && profile.groundOnly && e.isFlying) return false;
if (profile && typeof profile.skipEnemy === 'function' && profile.skipEnemy(e)) return false;
return true;
}
/**
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿, the
* 排气格栅 tile) is a target only for the enemy it blocks — PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿" and 索敌的
* 概念 "敌人会在自身被干员阻挡情况下强行无视对方可选性发动攻击" (the term text "不阻挡时…" is the short form; 异常效果:
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
* off the enemy paths — PRTS). 迷彩 (flag `camou`, term ba.camou "不阻挡时不成为敌方普通攻击的目标") works the same way:
* PRTS 异常效果 gives both anomalies the note "与'阻挡时解除'没有直接关系" — for this target selection (an attack, a skill
* pick, a cast condition, a normal attack on every ally in range: PRTS 选择器 "所有触发选择器通常不无视迷彩"); an area
* effect selects with areaSelectable / auraSelectable, which do not check 迷彩. An airborne ally (起飞, flag `liftoff`) is
* never a target of a ground enemy (evadesGround). An enemy whose 索敌不受阻挡影响 (profile `blockFree`: 自制投石机, PRTS 天赋)
* has no blocker exception: a 隐匿 / 迷彩 ally that blocks it is no target either — so, blocked by an operator on the
* 排气格栅 with nobody else in range, it does not attack (the official game, community report of 2026-10-06, item 24).
*/
export function canTargetAlly(e, a, ranged) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
if (f.untargetable || f.sleep) return false;
if (ranged && (f.stealth || f.camou) && (e.blockedBy !== a || (e.profile && e.profile.blockFree))) return false;
if (f.liftoff && evadesGround(e, a)) return false;
return true;
}
/**
* May an AREA effect of enemy-side `src` select ally `a` — a splash, a blast, an area skill or status, a pulse, a zone it
* leaves, a chain / bounce jump, a 周围四格 addition (content/enemies/helpers.js areaAllies / areaAlliesInTiles / fieldAllies)?
* PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; §隐匿 "隐匿效果使得获得该效果的单位无法被
* 任何敌方的能力索敌选中"; PRTS 异常效果 §无法选择: with 隐匿, 不可选中, 无敌, 塔不可选中 or 对地规避 "常见的、来自不同阵营的
* “选择”行为将无视这些单位进行(如同范围内不存在这个单位)", and the abilities PRTS marks "无视可选性" are those that skip
* that check. So not:
* - a 隐匿 ally (flag `stealth`: 伪装服, 叙拉古 6, 伊内丝 S2, the 排气格栅 tile), even when it blocks `src`. The blocked
* enemy still attacks that blocker (canTargetAlly; PRTS 作战机制 "因为“阻挡优先级最高”的效果,敌人会无视一切可选性对该干员
* 进行攻击"). Its splash, blast, zone and aura do not (GitHub #97, owner 2026-10-04; the 0.1.2 [ASSUMED] that the
* blocker's area also hit is withdrawn). A dead `src` (death blasts) blocks nobody;
* - an untargetable (不可选中) or sleeping (沉睡 = 无敌 + 无法行动) ally;
* - an airborne 起飞 ally when `src` walks (evadesGround — the same refusal the damage pipeline makes).
* An invulnerable (`invulnerable`, 无敌) ally is still selected, as by canTargetAlly — a known deviation from 无法选择
* [ASSUMED, DESIGN §21.22] (its damage is 0 anyway). 迷彩 (flag `camou`) is not checked: the area effects that call this
* are splash-type, 中点判定 / 格子判定, auras or carry a PRTS "无视迷彩" note (gamedata_const ba.camou "(无法躲避溅射类
* 攻击)"; PRTS 异常效果 迷彩 "所有光环类能力、以及涉及中点判定/格子判定的效果均不受迷彩制约"; PRTS 选择器 "非弹道类型的溅射
* 攻击会自动无视迷彩") — the few without any of these are listed [ASSUMED] in DESIGN §22.12. `src` null (an effect with no
* selecting enemy): 隐匿 still applies, 对地规避 not. Abilities PRTS marks "无视无法选择 / 无视(目标)可选性" never call
* this (【污染秽蚀】, 【盲信之誓】, 萨卡兹悖谬暴虐兵长's 暴击, 假想敌:淤困's burst spread, 远眺's 暴露 — DamageInfo
* `ignoreSelect`); neither do map / terrain effects nor 寒霜's aura. Enemy buff auras use auraSelectable.
*/
export function areaSelectable(src, a) {
return auraSelectable(src, a) && !(a.s.flags.liftoff && evadesGround(src, a));
}
/**
* May a BUFF AURA of enemy-side `src` (a 光环 refreshed on whoever stands in it — 深池伙友卫队's force field, 扎罗's
* 远古威慑; content/enemies/helpers.js auraAllies) take ally `a`? PRTS 作战机制 §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的
* 索敌机制和Buff选择器选中为目标", "目前明日方舟中使用能选中隐匿状态单位的Buff效果一定是无视隐匿状态起作用的" (its example:
* 寒霜's 攻速下降 Debuff — content/enemies/archetypes.js allyAura keeps that one on every ally): no 隐匿 ally, the one blocking `src`
* included (GitHub #97), no untargetable or sleeping one; 迷彩 does not protect ("所有光环类能力…均不受迷彩制约"). Unlike areaSelectable it does not
* apply 对地规避: a ground enemy's aura still reaches an airborne 起飞 ally [ASSUMED, DESIGN §21.20 / §21.22].
*/
export function auraSelectable(src, a) {
if (!a || !a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
if (f.untargetable || f.sleep || f.stealth) return false;
return true;
}
/**
* 对地规避 of an airborne ally (起飞, buff flag `liftoff` — 蒂比's skills): true when `src` is an enemy whose 行动方式 is
* ground, which then cannot select `a` (gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,可以阻挡飞行敌人";
* PRTS 术语释义 起飞 "包含对地规避(无法被不同阵营行动方式为地面的单位选中)"; PRTS 异常效果 MOTION_TARGET_FREE, a 无法选择
* effect that a selector whose 行动方式 is not ground ignores). Flyers, 近地悬浮 and 浮空 enemies (Unit.isFlying — PRTS
* 行动方式: a hovering unit "是真正的飞行单位", a levitated one is seen as a flyer) still select it; it stays a ground unit
* itself ("起飞的干员仍然是地面单位"), so they need no 对空 check. Used by every enemy selection (canTargetAlly), the damage
* pipeline and Battle.applyStatus (a ground enemy's area damage, statuses and hits under way skip it — PRTS 作战机制 "AOE
* 的判定是对攻击范围内的每个可以被选中的敌人进行判定"), and every enemy area selection (areaSelectable: splash, blasts,
* chain / bounce jumps, 周围四格 additions, barrages, 沙狱, death blasts). Not selections, so they still reach it:
* sourceless damage and DamageInfo / applyStatus `ignoreSelect` — abilities that "无视无法选择" (【污染秽蚀】, 【盲信之誓】,
* 萨卡兹悖谬暴虐兵长's 暴击 splash), direct picks (碎铳之簧's counter on its attacker: PRTS 异常效果 "'直接选中'的能力…不受这些
* 仅在选择时生效的异常效果制约"), the blasts of flying units credited to a ground leader (刺胄之弹, 斩胄之剑 / 破胄之锤) and
* the ticks of a debuff a ground enemy put on it before it took off (出血, 沙狱, burning DoTs, 【自然涌动】: a tick selects
* nobody). A shot already in flight is refused [ASSUMED] (damage.js).
*/
export function evadesGround(src, a) {
return !!(a && a.s && a.s.flags.liftoff && src && src.side === 'enemy' && !src.isFlying);
}
const PRIORITY_FNS = {
fly: (e) => (e.isFlying ? 0 : 1),
lowDef: (e) => e.s.def,
highDef: (e) => -e.s.def,
ranged: (e) => (e.base.rangeRadius > 0 && e.def?.applyWay !== 'MELEE' ? 0 : 1),
lowestHp: (e) => e.hp,
highestHp: (e) => -e.hp,
lowestHpRatio: (e) => e.hpRatio,
highestAtk: (e) => -e.s.atk,
boss: (e) => (e.isBoss ? 0 : 1),
// "优先攻击精英或领袖敌人" (薇薇安娜 S3: kits/ops/op-vvana.js): an ELITE / BOSS rank enemy or a leader first
elite: (e) => (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS' ? 0 : 1),
notBurst: (e) => (e.s.flags.burstLock ? 1 : 0),
ground: (e) => (e.isFlying ? 1 : 0),
// 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级
// (Unit.weight: massLevel with 失重 etc.) first, the usual order after that
heaviest: (e) => -e.weight,
};
/**
* Sort candidate enemies for an attacker (in place) and return them: the enemies it blocks first (the user's rule after
* playtest #6, "阻挡了就一定要能打到", for every blocker — DESIGN §20.3), then the special priority, taunt, the remaining
* route, the spawn order. One exception, the owner's decision of 2026-10-08 (GitHub #220 by TsangAsuna, #205): the 速射手 air priority
* (trait 优先攻击空中单位 — priority 'fly', a skill's too) comes before its own blocked enemy, so a 速狙 on a melee tile
* blocking a ground enemy shoots the flyer in its range (PRTS 索敌的概念 「阻挡(近战限定)→特殊优先级→…」); every other
* priority keeps the blocked enemy first.
* @param {object} attacker ally unit
* @param {object[]} cands enemies
* @param {string|null} priority profile/skill priority key
*/
export function sortEnemyTargets(battle, attacker, cands, priority) {
if (cands.length <= 1) return cands;
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
const airFirst = priority === 'fly';
const ax = attacker.x, ay = attacker.y;
const keyed = cands.map((e) => ({
e,
b: e.blockedBy === attacker ? 0 : 1,
p: pf ? pf(e) : priority === 'nearest' ? bodyDist(e, ax, ay) : priority === 'farthest' ? -bodyDist(e, ax, ay) : 0,
t: -(e.s.taunt || 0),
d: battle.remainingDistance(e),
s: e.spawnSeq,
}));
keyed.sort((a, b) => (airFirst ? a.p - b.p : 0) || a.b - b.b || a.p - b.p || a.t - b.t || a.d - b.d || a.s - b.s);
for (let i = 0; i < keyed.length; i++) cands[i] = keyed[i].e;
return cands;
}
/**
* Enemy target selection among allies (PRTS 作战机制 索敌, 敌方: "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→
* 不容易被攻击)→最早出现"): its blocker first → highest taunt level → latest deployed (`aggroSeq`: the deploy order —
* Battle.start deploys the operators first and ranks the start-of-battle summons after them). Special priorities
* (优先攻击…) are the enemies' own content: `e.profile.canTarget` filters, key sorts broken by `aggroCmp`.
*/
export function sortAllyTargets(enemy, cands) {
if (cands.length <= 1) return cands;
const bl = enemy.profile && enemy.profile.blockFree ? null : enemy.blockedBy; // 索敌不受阻挡影响: no blocker first
cands.sort((a, b) => {
const ba = bl === a ? 0 : 1, bb = bl === b ? 0 : 1;
return ba - bb || aggroCmp(a, b);
});
return cands;
}
/**
* 仇恨值 order of two allies for an enemy (negative ⇒ `a` first): higher taunt level, then the latest deployed
* (`aggroSeq`). The tie-break after an enemy's special priority (优先攻击防御力最高的… — PRTS 索敌: 特殊优先级 → 仇恨值).
*/
export function aggroCmp(a, b) {
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
if (ta !== tb) return tb - ta;
return (b.aggroSeq || b.deploySeq) - (a.aggroSeq || a.deploySeq);
}