Files
ark-stronghold-protocol/server/sim/content/devices.js
T
sganggs 227ab9e7ca feedback7 R23-TICK: an attack interval or a time-SP cost of a whole number of frames takes exactly that many — 1 s is 30 frames, not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2); golden: every family moves (roster, bonds, fields, matches, standins, diy — regenerated in the next commit)
Seen (PR #402, measured again on 0.2.2): TICK = 1/30 is not a binary fraction. An attack cooldown counted down from 1 s
stops at 2.1e-16 after thirty steps and the fire check `atkCd > 0` waited a 31st frame; 300 time gains of 1/30 SP add up
to 9.999999999999975 and `sp >= cost` charged a 10-SP skill a frame late. Over the 265 golden battles 30.85 % of the
consecutive attacks on a whole-frame interval came a frame late (control, other intervals: 0.09 %), and 4 157
end-of-tick states sat within 1e-9 under a cost. The engine already took timers within 1e-9 as done (skill timeLeft,
buffs, every() / after(), enemy abilities, nextCastAt, the 失衡 end); these two did not.

Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」 — one frame = 1/30 s; since 2019-12-24 a non-integer frame count
is rounded (四舍五入); an exact 1 s is 30 frames under any rule.

Now: ai.js attackCountdown(cd, dt) (left within 1e-9 of 0 is 0) for the operators' cooldown (the canAct guard kept) and
the enemies' (not while stunned or hidden; the 0.2.2 失衡 swing cut untouched) — an enemy's whole-frame wind-up is exact
too (0.4 s: 12 frames, 13 before). The same countdown for the two strict `cd -= dt; if (cd > 0)` attack clocks of the
same shape: the 双眼皮 turrets (content/devices.js; BAT 4 s: 121 → 120 frames) and 寒檀's S2 icicles (0.5 s: 16 → 15).
SkillRuntime.gainSp takes SP within 1e-9 of the cost as full for every charge (a tiny negative remainder → 0);
_normalize compares the same way. Left as they are: a non-integer count still ends on the frame that crosses 0
(⌈30 × interval⌉ — the official 四舍五入 is an older, separate difference); the count-up content clocks that restart from
0 (bosses.js self-timers, kit auras — PR #402's list); attack / hurt SP (+1 on an integer cost) unchanged.

Tests: test/sim/tick-tolerance.test.js (new, 7: the PR's four adapted — residue, attackCountdown over bat 0.1–10 s ×
14 ASPD = ⌈30 × interval⌉, 1 s operator every 30 / 3 s ranged enemy every 90, time SP 4/10/16/20/30/50 at exactly 30 × N
gains for one and two charges — plus the 1.2 s / 1.25 s control (36 / 38), the turret at 0 layers (120) and 寒檀's
icicles (15); the four engine tests fail on the parent: 31 / 91, cost 4 at 121, 121, 16). The PR's seven test edits,
re-run: the same 8 assertions failed on the new timing and each edit passes on the parent and on the fix —
feedback2-surtr-stealth (count once no arrow is in flight), kits_alt_t2 拉普兰德 S1 (dummy ATK 300 → 250: 120 × 15 = her
1791 HP by 59.5 s), kits_alt_t6 (medic smoke at half HP; 余 S1 up to the first 燃烧 burst), op_jesca2 (the T6 shield
returns a tick after the cast), op_lmlee (hits before the burst), op_logos (count after the clear), op_narant (the first
attack thrown after the new enemies). node --test (with the golden of the next commit): 5703 tests, 5701 pass, 0 fail,
2 skipped; eslint 0 errors (135 warnings, the parent's 135); typecheck, check:imports, smoke ok.

feedback7 R23-TICK: golden for whole-frame attack intervals and SP costs (PR #402) — 281 of 283 scenarios move: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, standins 10/10, diy 136/138; solo-NORMAL-1 now loses its Final Assault (below)

npm run golden:update on the previous commit. Every family moves because both sides attack on time (more enemy kinds
than operator forms sit on whole-frame intervals: 3 s, 4 s, 1 s …) and time SP charges a frame sooner. Measured over
the 265 battles: whole-frame consecutive attacks a frame late 30.85 % → 0.04 % (control 0.09 % → 0.13 %; 1 s gaps at
30 frames 256 → 3 776 of ~3 800, 3 s at 90 frames 0 → 6 657 of 6 657), SP float-short states 4 157 → 0. Moved battles'
totals: kills 10 647 → 10 625, leaks 12 398 → 12 445, deaths 2 913 → 2 944. Unchanged: diy-040, diy-069.

End results that flip (one frame cascades; the causes as measured with the matches' battles instrumented):
- solo-NORMAL-1: won → lost in round 14 (passed 14 → 13, team LP 8 → 0): the Final Assault on 卢西恩 (pool 390 000) runs
  as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails — 884 left
  when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, it emptied at 318 s, 8 LP left.
- solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends
  at 300.6 s, before the overtime drain (304.8 s and 2 LP before).
- solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round
  6 and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before).
- coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防).
- coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s
  (397.8): 47 LP less of overtime drain.
- coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22).
- roster-016: cleared at 220.3 s → time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22).
- roster-041: time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s).
- bond-suntShip-high: cleared at 164.2 s → time-out at 190 s (kills 11 → 13, deaths 4 → 2).
- standin-04: perfect → 10 leaks (deaths 8 → 10; 萨卡兹大剑组长 / 萨卡兹穿刺手 / 澪 get through).
- diy-005 / diy-008 (艾丽妮 / 赫拉格): cleared at 225.3 s → time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 walking).
- diy-127 (白铁 / 酒神): time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72).
- diy-131 (死芒): perfect → 1 leak (a 掠海漂移体).
The other matches keep their ends (solo-FUNNY-1/2, solo-HARD-2, solo-ABYSS-1/2, coop2-HARD-4, coop3-ABYSS-5,
coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy). PR #402's own
0.2.1 table (coop2-NORMAL-14-standins loss → win, solo-HARD-2 / solo-ABYSS-2 earlier exits) was measured before §27 and
does not repeat here.
2026-10-09 01:37:32 +08:00

586 lines
30 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/devices.js — stage devices & special terrain (data/stages.json, research 05 §2.3–2.4).
//
// The engine already spawns active 阻隔工事 crates (obstacle devices, 100 HP) and marks active 射击台/mounds as
// obstacles that elevate operators. This module adds, per battle:
// 阻隔工事 an enemy blocked by a crate destroys it (after its attack interval even if it cannot attack: harmless
// walkers no longer stall on a crate); map cards (`deviceOverrides`) remove crates / add or remove
// platforms (obstacle + elevation) / activate hidden devices
// 气流 act2 m01 blowers (`trap_013_blower`, rangeTiles along `dir`): operators whose direction (`u.dir`, UP|
// RIGHT|DOWN|LEFT) equals the blower's dir / is its opposite / is perpendicular get ATK +
// blower_s_character[equal|opposite|vertical].atk (the m01 blowers blow DOWN); enemies moving with /
// against the flow get move speed × (1 + blower_s_enemy[equal|opposite].move_speed)
// 沼泽 m a unit on it (allies and ground enemies) triggers 【陷入沼泽】 on entering and every intervalSec (1) s
// there: an enemy gains 1 layer — 2 at 重量 ≥ heavyWeight (the device's `value`, 3) — of ASPD aspdPerStack
// (fractions are ×100 ASPD) and move speed × (1 + moveMulPerStack × layers), an operator 1 layer of the
// ASPD part only; at most maxStacks layers; cleared on leaving
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them —
// not 自制投石机, whose 索敌不受阻挡影响: blocked by one with nobody else in range it does not attack)
// 深水 d ground enemies on it: sea_drown[enemy].damage dmg/s (无来源 true 持续伤害, not 环境伤害: tags dot /
// periodic / deepsea), ASPD attack_speed (×100), move × move_speed
// 活性源石 i a unit on it (allies and ground enemies) gets a timed effect: damage true dmg/s, ATK + atk, ASPD +
// attack_speed for `duration` s from its last contact — an enemy keeps it after walking off; one effect
// per unit, its time starts again while the unit is on the tile; the tiles never switch off
// “双眼皮” turrets (`trap_1104_aclasert`, band 机械援助 / `deviceOverrides` alias on): ranged arts shooter on its data
// range; ASPD + attack_speed_per_stack × L (≤ max_attack_speed), hits apply fragile 1 + damage_scale_per_stack
// × L (≤ max_damage_scale) for the text's duration; L = the owner's highest bond layers (live)
// 盟约寒风 `startColdWind` / `kjeragColdWind` helpers for the 谢拉格 bond (every interval s all enemies cold)
// Device overrides: a player's battle input `deviceOverrides` ({ alias: bool }) and `alias` keys in its playerEffects
// params (auto_chess_change_map effects: band 机械援助, 机变 map cards) switch the devices on that player's half of
// the field only (unite / boss fields hold both halves); Battle opts.deviceOverrides switch every half.
// `deviceOverridesOf(battle)` is the union (informational).
// Terrain numbers come from `stage.special` (fallback: the device's skill blackboard, then research 05 values).
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season. The
// canoe (特制水上平台) makes its 深水区 deployable in the prep's deploy map only (server/match/board.js); the sim's Grid
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack, attackCountdown } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
import { hypot } from '../detmath.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */
const aspdOf = (v) => (Math.abs(v) < 1 ? v * 100 : v);
export const TERRAIN = Object.freeze({ mire: 1, smog: 2, deepsea: 3, infection: 4 });
const TERRAIN_NAME = ['', 'mire', 'smog', 'deepsea', 'infection'];
const BUFF = { 1: 'terrain:mire', 2: 'terrain:smog', 3: 'terrain:deepsea', 4: 'terrain:infection' };
const AIRFLOW = 'terrain:airflow';
const CRATE_KEY = 'trap_1105_accrate';
const TURRET_KEY = 'trap_1104_aclasert';
/** Research 05 §2.3 values, used only when a stage has no `special` block / device blackboard. */
const RESEARCH = Object.freeze({
mire: { intervalSec: 1, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10, heavyWeight: 3 },
deepsea: { damage: 40, attack_speed: -0.6, move_speed: 0.6 },
infection: { damage: 70, atk: 0.2, attack_speed: 20, duration: 300 },
});
const STATE = new WeakMap();
const stateOf = (battle) => STATE.get(battle) ?? null;
// ---------------------------------------------------------------------------------------------------------------
// device overrides
const truthy = (v) => (typeof v === 'number' ? v !== 0 : typeof v === 'string' ? v !== '0' && v !== '' && v !== 'false' : !!v);
function addOverrides(m, obj) {
if (!obj || typeof obj !== 'object') return m;
for (const [k, v] of Object.entries(obj)) if (k.includes('#')) m.set(k, truthy(v));
return m;
}
/** Device overrides of one player (battle input `deviceOverrides` + playerEffects params): Map alias → active. */
export function playerDeviceOverrides(ps) {
const m = new Map();
if (!ps) return m;
addOverrides(m, ps.input?.deviceOverrides);
for (const e of ps.playerEffects || []) addOverrides(m, e?.params);
return m;
}
/** Union of device overrides for this battle: Map alias → active. */
export function deviceOverridesOf(battle) {
const m = new Map();
for (const ps of battle.players || []) for (const [k, v] of playerDeviceOverrides(ps)) m.set(k, v);
addOverrides(m, battle.opts?.deviceOverrides);
return m;
}
const aliasOf = (d) => d?.raw?.alias ?? d?.alias ?? null;
function dataActive(d) {
return d.raw && typeof d.raw.active === 'boolean' ? d.raw.active : !d.hidden && d.active !== false;
}
/**
* Override of a device (true / false, undefined = none). A device belongs to the player whose half of the field
* holds it (the stage has one alias group per half: #0xx own board, #1xx partner half, #2xx/#3xx boss halves); map
* cards (“移除场地上全部阻隔工事”) and band 机械援助 (“所有场地中出现1个”) change the owner's own half only — a
* partner's card never removes this player's crates, a partner's band never adds a turret scaling with this player's
* layers. Battle-level overrides (opts.deviceOverrides) apply to every half.
*/
function overrideOf(battle, st, d) {
const a = aliasOf(d);
if (a == null) return undefined;
const owner = playerForCol(battle, d.col);
const mine = owner != null ? st.byPlayer.get(owner) : null;
if (mine && mine.has(a)) return mine.get(a);
if (st.global.has(a)) return st.global.get(a);
return undefined;
}
function isActive(battle, st, d) {
const o = overrideOf(battle, st, d);
return o === undefined ? dataActive(d) : o;
}
// ---------------------------------------------------------------------------------------------------------------
// cold wind (谢拉格 bond)
/**
* Every `interval` s (first gust after `first` s, default = interval) all enemies on the field become cold for
* `duration` s (a number or `() => number`, evaluated per gust — layers can change mid-battle). A cold enemy that
* gets cold again freezes (engine rule). `playerId` only tags the fx; `ownerOnly` limits the gust to enemies
* attributed to that player. Returns { cancel() } or null for bad numbers.
*/
export function startColdWind(battle, { playerId = null, interval, duration, first, ownerOnly = false } = {}) {
const iv = num(interval, 0);
if (!(iv > 0)) return null;
const durOf = () => num(typeof duration === 'function' ? duration() : duration, 0);
const handle = { cancelled: false, gusts: 0, timer: null, cancel() { this.cancelled = true; this.timer?.cancel(); } };
const gust = () => {
if (handle.cancelled || battle.finished) return;
const dur = durOf();
if (!(dur > 0)) return;
let n = 0;
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
if (ownerOnly && playerId != null && e.ownerId !== playerId) continue;
if (battle.applyStatus(e, 'cold', { duration: dur, source: null })) n++;
}
handle.gusts++;
const R = battle.rect;
battle.fx('coldWind', { x: (R.c0 + R.c1) / 2, y: (R.r0 + R.r1) / 2, playerId, n, duration: dur });
};
const firstDelay = Math.max(0, num(first, iv));
handle.timer = battle.after(firstDelay, () => {
if (handle.cancelled) return;
gust();
handle.timer = battle.every(iv, gust);
});
return handle;
}
/**
* 谢拉格 6-member wind from the bond effect blackboard (effects.json bondeffect_kjerag `env_gbuff_new` bb):
* interval `bond_eff_kjerag[storm].interval`, duration `base_time + time_per_stack × layers` (live 谢拉格 layers).
*/
export function kjeragColdWind(battle, playerId, bb = {}, { bondId = 'kjeragShip' } = {}) {
const interval = num(bb['bond_eff_kjerag[storm].interval'], 0);
const base = num(bb['bond_eff_kjerag[storm].base_time'], 0);
const per = num(bb['bond_eff_kjerag[storm].time_per_stack'], 0);
const layers = () => num(battle.getPlayer(playerId)?.bonds?.[bondId]?.layers, 0);
return startColdWind(battle, { playerId, interval, duration: () => base + per * layers() });
}
// ---------------------------------------------------------------------------------------------------------------
// turrets (“双眼皮”)
/** Highest bond layer count of a player (live copy: in-battle gains included). */
function topLayers(battle, playerId) {
const bonds = battle.getPlayer(playerId)?.bonds;
let L = 0;
if (bonds && typeof bonds === 'object') for (const b of Object.values(bonds)) L = Math.max(L, num(b?.layers, 0));
return L;
}
function playerForCol(battle, c) {
if (!battle.players.length) return null;
if (battle.players.length === 1) return battle.players[0].playerId;
const half = c >= 11 ? 'R' : 'L';
return (battle.players.find((p) => p.half === half) ?? battle.players[0]).playerId;
}
/**
* Tile for a turret: its own position when it is inside the field and free, else the nearest free in-rect tile
* nobody can deploy on — off the enemy paths first (void before a lane / gate: act2 m01's turrets stand on row 8,
* outside the normal field, and would otherwise land on the red gate).
*/
function deviceTile(battle, r0, c0) {
const R = battle.rect;
let best = null, bs = null;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
if (battle.isReservedTile(r, c)) continue;
const exact = r === r0 && c === c0;
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0;
const s = [exact ? 0 : 1, walk, hypot(r - r0, c - c0), r * COLS + c];
let less = !bs;
if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
if (less) { best = [r, c]; bs = s; }
}
}
return best;
}
function spawnTurret(battle, st, d) {
const alias = aliasOf(d) ?? `${d.key}@${d.row},${d.col}`;
if (st.turrets.has(alias)) return st.turrets.get(alias);
const tiles = (d.raw?.rangeTiles ?? []).filter((p) => Array.isArray(p) && battle.grid.inRect(p[0], p[1]));
if (!tiles.length) return null; // shoots at nothing on this field
const tile = deviceTile(battle, d.row, d.col);
if (!tile) return null;
const stats = d.raw?.stats ?? d.stats ?? {};
const u = battle.spawnDevice(d.key, tile[0], tile[1], {
hp: num(stats.maxHp, 100), blockCnt: 0, name: d.name ?? d.raw?.name ?? d.key,
atk: num(stats.atk, 0), def: num(stats.def, 0), res: num(stats.res, 0), bat: num(stats.bat, 1), aspd: num(stats.aspd, 100),
});
if (!u) return null;
const skill = d.raw?.skill ?? {};
const bb = skill.bb ?? {};
const durM = String(skill.desc ?? skill.description ?? '').match(/持续(\d+(?:\.\d+)?)秒/);
u.ownerId = playerForCol(battle, d.col);
u.dir = u.ownerId != null ? (battle.getPlayer(u.ownerId)?.dir ?? 'RIGHT') : 'RIGHT';
u.mem.turret = {
alias,
keys: tiles.map(([r, c]) => r * COLS + c),
baseAspd: u.base.aspd,
aspdPer: num(bb.attack_speed_per_stack, 0),
aspdMax: num(bb.max_attack_speed, Infinity),
fragPer: num(bb.damage_scale_per_stack, 0),
fragMax: num(bb.max_damage_scale, Infinity),
fragDur: durM ? +durM[1] : 0,
cd: 0,
};
st.turrets.set(alias, u);
battle.fx('turretOnline', { x: u.x, y: u.y, id: u.id, alias });
return u;
}
const TURRET_PROFILE = Object.freeze({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets: 1, atkScale: 1 });
function tickTurret(battle, u, dt) {
const T = u.mem.turret;
if (!u.alive || !T) return;
T.cd = attackCountdown(T.cd, dt); // (the engine's attack countdown: a whole number of ticks takes exactly that many)
if (T.cd > 0 || u.s.flags.stun) return;
const L = u.ownerId != null ? topLayers(battle, u.ownerId) : 0;
const bonus = Math.min(L * T.aspdPer, T.aspdMax);
if (Math.abs(u.base.aspd - (T.baseAspd + bonus)) > 1e-9) { u.base.aspd = T.baseAspd + bonus; u.markDirty(); }
const cands = battle.enemiesInKeys(T.keys, u, TURRET_PROFILE);
if (!cands.length) { T.cd = 0; return; }
sortEnemyTargets(battle, u, cands, null);
const frag = Math.min(1 + L * T.fragPer, T.fragMax) - 1;
const prof = {
...TURRET_PROFILE,
afterHit: (b, src, target) => {
if (frag > 0 && T.fragDur > 0 && target && target.alive) b.applyStatus(target, 'fragile', { duration: T.fragDur, value: frag, source: src });
},
};
performAttack(battle, u, prof, [cands[0]]);
T.cd = u.s.interval;
}
/** Activate “双眼皮” turrets on this field (all known aliases by default; idempotent). Returns the turret units. */
export function activateTurrets(battle, { aliases = null } = {}) {
const st = stateOf(battle);
if (!st) return [];
const out = [];
for (const d of battle.stage?.devices || []) {
if (d.key !== TURRET_KEY && d.role !== 'turret') continue;
if (aliases && !aliases.includes(aliasOf(d))) continue;
const u = spawnTurret(battle, st, d);
if (u) out.push(u);
}
ensureTick(battle, st);
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// terrain
function buildTerrain(battle, st) {
const R = battle.rect;
let any = false;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const code = TERRAIN[battle.grid.tile(r, c).terrain] ?? 0;
if (code) { st.terrain[r * COLS + c] = code; any = true; }
}
}
st.hasTerrain = any;
const sp = battle.stage?.special || {};
const devSkill = (role) => (battle.stage?.devices || []).find((d) => d.role === role)?.raw?.skill?.bb ?? null;
// mire
const m = sp.mire || {};
const mdev = devSkill('mireController') || {};
// the device skill's `value` (3) is the 重量 from which an enemy gains 2 layers, not an interval (PRTS 沼泽控制)
const mInterval = num(m.intervalSec, RESEARCH.mire.intervalSec);
st.mire = {
interval: mInterval > 0 ? mInterval : RESEARCH.mire.intervalSec,
aspdPer: aspdOf(num(m.aspdPerStack ?? mdev.attack_speed, RESEARCH.mire.aspdPerStack)),
movePer: num(m.moveMulPerStack ?? mdev.move_speed, RESEARCH.mire.moveMulPerStack),
max: Math.max(1, Math.floor(num(m.maxStacks ?? mdev.max_stack_cnt, RESEARCH.mire.maxStacks))),
heavyWeight: num(m.heavyWeight ?? mdev.value, RESEARCH.mire.heavyWeight),
};
// deep sea
const ds = sp.deepsea?.bb || devSkill('tideController') || {};
st.deepsea = {
damage: num(ds['sea_drown[enemy].damage'], RESEARCH.deepsea.damage),
aspd: aspdOf(num(ds['sea_drown[enemy].attack_speed'], RESEARCH.deepsea.attack_speed)),
moveMul: num(ds['sea_drown[enemy].move_speed'], RESEARCH.deepsea.move_speed),
};
// active originium: `duration` is how long the effect lasts on a unit (PRTS tile template "部署于其上的我军和经过的
// 敌军在{duration}s内…"), not a lifetime of the tiles
const inf = sp.infection?.bb || {};
const infDur = num(inf.duration, RESEARCH.infection.duration);
st.infection = {
damage: num(inf.damage, RESEARCH.infection.damage),
atk: num(inf.atk, RESEARCH.infection.atk),
aspd: aspdOf(num(inf.attack_speed, RESEARCH.infection.attack_speed)),
duration: infDur > 0 ? infDur : RESEARCH.infection.duration,
};
// blowers
for (const d of battle.stage?.devices || []) {
if (d.role !== 'blower' || !isActive(battle, st, d)) continue;
const raw = String(d.dir ?? d.raw?.dir ?? 'UP').toUpperCase();
const f = DIR_VEC[raw];
if (!f) continue;
const dir = normDir(raw);
const bb = d.raw?.skill?.bb ?? sp.blower?.bb ?? {};
const tiles = d.raw?.rangeTiles ?? [];
for (const p of tiles) {
if (!Array.isArray(p) || !battle.grid.inRect(p[0], p[1])) continue;
// world vector: x = col, y = row
st.flow.set(p[0] * COLS + p[1], { fx: f[1], fy: f[0], dir, bb });
}
}
}
/** 活性源石's tick: true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 ("受到来自自然环境的伤害" content reads it). */
const infectionDamage = (battle, amount) => (ctx) => {
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
};
/**
* 深水区's 【水蚀】 tick (PRTS 涨潮控制 技能3 深水: "每秒受到40点无来源真实持续伤害(不属于环境伤害,不会触发受击回复)"; PRTS 伤害分类
* lists 深水区/涨潮水蚀 as BUFF damage): 无来源 true 持续伤害 — tags 'dot' (锡人's 凋敝魂灵 raises it), 'periodic' and
* 'deepsea' (the 免疫水蚀 swimmers cancel it), no 'terrain' (not 环境伤害), no 受击回复 (noSp).
*/
const deepWaterDamage = (battle, amount) => (ctx) => {
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, sourceless: true, noSp: true, tags: ['dot', 'periodic', 'deepsea'] });
};
function enterTerrain(battle, st, u, code) {
const m = u.mem;
if (m.terrain === code) return;
// leaving a tile ends its effect — except 活性源石's, which runs out on its own (touchInfection)
if (m.terrain && m.terrain !== TERRAIN.infection) battle.removeBuff(u, BUFF[m.terrain]);
m.terrain = code;
m.terrainSince = battle.time;
m.mireStacks = 0;
m.mireTriggers = 0;
if (!code) return;
if (code === TERRAIN.smog) battle.addBuff(u, { key: BUFF[code], flags: { stealth: true } });
else if (code === TERRAIN.deepsea) {
const D = st.deepsea;
battle.addBuff(u, { key: BUFF[code], mods: { aspd: D.aspd, moveMul: D.moveMul }, interval: 1, onTick: deepWaterDamage(battle, D.damage) });
}
// mire layers are applied by tickMire, 活性源石 by touchInfection (every tick on the tile)
}
/**
* 活性源石 contact (every tick on the tile). The tile gives a timed effect — damage true dmg/s, ATK + atk, ASPD +
* attack_speed for `duration` s (PRTS 特殊地形 tile template: "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}
* 真实伤害,攻击力提升…,攻击速度增加…") — that stays on an enemy after it walks off: PRTS 危机合约 tag
* global_tile_infection_1 「目标:可控感染」 "踏过活性源石地块的敌人不再持续损失生命值" switches the lasting HP loss off,
* so without it the loss continues. One effect per unit (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"): a
* unit that already carries it gets its full `duration` back and keeps its per-second rhythm — no second effect, no
* extra tick [ASSUMED: the time counts from the last contact — so an operator deployed on it, always in contact, drains
* past `duration`]. An operator moved off the tile (Battle.relocate: 夕's 小自在 …; Battle.moveRedeploy: 乌尔比安 S3)
* keeps it for its time; leaving the field drops it with every buff; a 重生 clears it (enemies/archetypes.js rebirthCleanse: PRTS
* 特殊机制 §重生 "清空自身身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The
* tick (infectionDamage) is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石),
* not 'dot' [ASSUMED: PRTS 伤害分类's list of BUFF damage does not name it].
*/
function touchInfection(battle, st, u) {
const I = st.infection;
const key = BUFF[TERRAIN.infection];
const b = u.findBuff(key);
if (b) { if (b.timeLeft < I.duration) b.timeLeft = I.duration; return; }
battle.addBuff(u, { key, duration: I.duration, mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: infectionDamage(battle, I.damage) });
}
/** Layers an enemy gains per 【陷入沼泽】 trigger at 重量 ≥ heavyWeight (PRTS 沼泽控制: "若其重量大于等于3,改为获得2层"). */
const MIRE_HEAVY_LAYERS = 2;
/**
* 沼泽 (PRTS 沼泽控制 备注): a unit in the mire triggers its 【陷入沼泽】 "每秒…一次" (the device skill charges in 1 s) — an
* enemy gains 1 layer of ASPD −5 % and move speed −5 % (2 layers at 重量 ≥ heavyWeight, the skill's `value` 3), any other
* unit 1 layer of ASPD −5 % only; at most 10 layers; "上述减益于单位不再位于沼泽之中时解除" (enterTerrain).
* [ASSUMED] the first trigger comes on entering, then one every second.
*/
function tickMire(battle, st, u) {
const M = st.mire, m = u.mem;
const due = 1 + Math.floor((battle.time - m.terrainSince + 1e-9) / M.interval);
if (due <= m.mireTriggers) return;
const enemy = u.side === 'enemy';
const per = enemy && num(u.s.massLevel, 0) >= M.heavyWeight ? MIRE_HEAVY_LAYERS : 1;
const n = Math.min(M.max, m.mireStacks + per * (due - m.mireTriggers));
m.mireTriggers = due;
if (n === m.mireStacks) return;
m.mireStacks = n;
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: enemy ? { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } : { aspd: M.aspdPer * n } });
}
/**
* Terrain code under a unit (0 = none). Air units (Unit.isFlying: flyers, hovering 近地悬浮 and levitated enemies) and
* hidden units ignore ground terrain; smog only helps allies.
*/
function terrainFor(battle, st, u) {
if (u.hidden || u.isFlying) return 0;
let k;
if (u.side === 'ally') k = u.tileR * COLS + u.tileC;
else {
const r = Math.round(u.y), c = Math.round(u.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return 0;
k = r * COLS + c;
}
let code = st.terrain[k];
if (code === TERRAIN.smog && u.side !== 'ally') code = 0;
if (code === TERRAIN.deepsea && u.side === 'ally') code = 0;
return code;
}
function airflowRelation(dot) { return dot > 0.7 ? 'equal' : dot < -0.7 ? 'opposite' : 'vertical'; }
/** Operator ↔ blower: the same direction ⇒ 'equal', the reverse ⇒ 'opposite', perpendicular ⇒ 'vertical'. */
export function airflowRelationOf(unitDir, flowDir) {
const u = normDir(unitDir), f = normDir(flowDir);
return u === f ? 'equal' : u === oppositeDir(f) ? 'opposite' : 'vertical';
}
function tickAirflowAlly(battle, st, u) {
const f = st.flow.get(u.tileR * COLS + u.tileC);
let atk = 0;
if (f && u.motion !== 'FLY') atk = num(f.bb[`blower_s_character[${airflowRelationOf(u.dir, f.dir)}].atk`], 0);
if (atk === (u.mem.airAtk ?? 0)) return;
u.mem.airAtk = atk;
if (atk) battle.addBuff(u, { key: AIRFLOW, refresh: 'replace', mods: { atkPct: atk } });
else battle.removeBuff(u, AIRFLOW);
}
function tickAirflowEnemy(battle, st, e) {
const px = e.mem.airPx, py = e.mem.airPy;
e.mem.airPx = e.x; e.mem.airPy = e.y;
let mul = 1;
if (!e.hidden && px != null) {
const r = Math.round(e.y), c = Math.round(e.x);
const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null;
const vx = e.x - px, vy = e.y - py;
const v = hypot(vx, vy);
if (f && v > 1e-6) {
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v);
if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
} else if (f && v <= 1e-6) mul = e.mem.airMul ?? 1; // paused this tick (attack pause / blocked): keep the last push
}
if (!(mul >= 0)) mul = 0;
if (mul === (e.mem.airMul ?? 1)) return;
e.mem.airMul = mul;
if (mul !== 1) battle.addBuff(e, { key: AIRFLOW, refresh: 'replace', mods: { moveMul: mul } });
else battle.removeBuff(e, AIRFLOW);
}
/** Apply terrain / airflow state to one unit now (deploy, spawn, every tick). */
function refreshUnit(battle, st, u) {
if (!u.alive || !u.deployed || u.kind === 'device') return;
if (st.hasTerrain) {
enterTerrain(battle, st, u, terrainFor(battle, st, u));
if (u.mem.terrain === TERRAIN.mire) tickMire(battle, st, u);
else if (u.mem.terrain === TERRAIN.infection) touchInfection(battle, st, u);
}
if (st.flow.size) { if (u.side === 'ally') tickAirflowAlly(battle, st, u); else tickAirflowEnemy(battle, st, u); }
}
function ensureTick(battle, st) {
if (st.ticking) return;
if (!st.hasTerrain && !st.flow.size && !st.turrets.size) return;
st.ticking = true;
battle.on('tick', ({ dt }) => {
if (st.hasTerrain || st.flow.size) {
for (const u of battle.allyUnits) refreshUnit(battle, st, u);
for (const e of battle.enemies) refreshUnit(battle, st, e);
}
for (const u of st.turrets.values()) tickTurret(battle, u, dt);
});
// a unit that (re)deploys / spawns starts fresh (death drops its buffs) and gets its tile's terrain at once: smog
// must already hide an operator from the first enemy shot of the battle. A 【移动】 (ctx.move: 乌尔比安 S3,
// Battle.moveRedeploy) keeps its buffs, so it changes tiles like a walk instead: starting from nothing would strand
// the old tile's smog / airflow buff on it
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || !u.mem || u.kind === 'device') return;
if (!ctx.move) { u.mem.terrain = 0; u.mem.airAtk = 0; u.mem.airMul = 1; u.mem.airPx = null; }
refreshUnit(battle, st, u);
}, { priority: 90 });
}
/** Terrain name under (r, c) for other content ('mire' | 'smog' | 'deepsea' | 'infection' | null). */
export function terrainAt(battle, r, c) {
const st = stateOf(battle);
const code = st && r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.terrain[r * COLS + c] : 0;
return TERRAIN_NAME[code] || null;
}
// ---------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
const st = {
byPlayer: new Map((battle.players || []).map((ps) => [ps.playerId, playerDeviceOverrides(ps)])),
global: addOverrides(new Map(), battle.opts?.deviceOverrides),
terrain: new Uint8Array(ROWS * COLS),
hasTerrain: false,
flow: new Map(),
turrets: new Map(),
platformOn: new Set(),
platformOff: new Set(),
crateOff: new Set(),
ticking: false,
};
STATE.set(battle, st);
const devices = battle.stage?.devices || [];
buildTerrain(battle, st); // grid, stage `special` and overrides are known at construction
ensureTick(battle, st);
// overrides that differ from the data (per half owner): crates removed, platforms added/removed
for (const d of devices) {
if (!battle.grid.inRect(d.row, d.col)) continue;
const on = overrideOf(battle, st, d);
if (on === undefined || on === dataActive(d)) continue;
const k = d.row * COLS + d.col;
if (d.role === 'crate' && !on) st.crateOff.add(k);
if (d.role === 'platform' || d.role === 'mound') (on ? st.platformOn : st.platformOff).add(k);
}
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || u.side !== 'ally') return;
const k = u.tileR * COLS + u.tileC;
if (u.kind === 'device') {
// stage crates switched off by a map card vanish before anybody deploys on their tile
if (u.defId === CRATE_KEY && st.crateOff.has(k) && !st.started) { u.mem.removedByOverride = true; battle.retreat(u, { reason: 'removed', permanent: true }); }
return;
}
if (st.platformOn.has(k)) u.ground = false;
else if (st.platformOff.has(k)) u.ground = battle.grid.isLow(u.tileR, u.tileC);
}, { priority: 100 });
// an enemy stopped by a crate breaks it (the engine lets attackers chop it; this also covers harmless walkers)
battle.on('blocked', ({ blocker, enemy }) => {
if (!blocker || blocker.kind !== 'device' || blocker.defId !== CRATE_KEY) return;
const delay = Math.max(0.1, Math.min(10, num(enemy.s.interval, 1)));
battle.after(delay, () => {
if (blocker.alive && enemy.alive && enemy.blockedBy === blocker) {
battle.fx('crateBreak', { x: blocker.x, y: blocker.y, id: blocker.id });
battle.kill(blocker, enemy);
}
});
});
battle.on('battleStart', () => {
st.started = true;
// platforms switched by map cards (the engine applied the data state)
for (const k of st.platformOn) battle.setObstacle((k / COLS) | 0, k % COLS, true);
for (const k of st.platformOff) battle.setObstacle((k / COLS) | 0, k % COLS, false);
// hidden crates switched on by an override
for (const d of devices) {
if (d.role !== 'crate' || dataActive(d) || !isActive(battle, st, d) || !battle.grid.inRect(d.row, d.col) || battle.unitAt(d.row, d.col)) continue;
battle.spawnDevice(d.key, d.row, d.col, { hp: num(d.stats?.maxHp ?? d.raw?.stats?.maxHp, 100), obstacle: true, name: d.name });
}
// “双眼皮” turrets switched on (band 机械援助 / overrides)
for (const d of devices) if ((d.key === TURRET_KEY || d.role === 'turret') && isActive(battle, st, d)) spawnTurret(battle, st, d);
ensureTick(battle, st);
}, { priority: 50 });
}
export function registerMeta(registry) {} // map cards / band 机械援助 reach the battle through deviceOverrides