卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+522
View File
@@ -0,0 +1,522 @@
// 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 +1 stack on entering and every intervalSec s on the tile (max maxStacks): ASPD aspdPerStack (fractions
// are ×100 ASPD), move speed × (1 + moveMulPerStack × stacks); cleared on leaving (allies and ground enemies)
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them)
// 深水 d ground enemies on it: sea_drown[enemy].damage true dmg/s, ASPD attack_speed (×100), move × move_speed
// 活性源石 i units on it (allies and ground enemies): damage true dmg/s, ATK + atk, ASPD + attack_speed, for
// `duration` s of battle time
// “双眼皮” 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.
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.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: 3, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10 },
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, Math.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 -= dt;
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') || {};
st.mire = {
interval: num(m.intervalSec ?? mdev.value, 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))),
};
// 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
const inf = sp.infection?.bb || {};
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)),
until: num(inf.duration, 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 });
}
}
}
const terrainDamage = (battle, amount) => (ctx) => {
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
};
function enterTerrain(battle, st, u, code) {
const m = u.mem;
if (m.terrain === code) return;
if (m.terrain) battle.removeBuff(u, BUFF[m.terrain]);
m.terrain = code;
m.terrainSince = battle.time;
m.mireStacks = 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: terrainDamage(battle, D.damage) });
} else if (code === TERRAIN.infection) {
const I = st.infection;
battle.addBuff(u, { key: BUFF[code], mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: terrainDamage(battle, I.damage) });
}
// mire stacks are applied by tickMire
}
function tickMire(battle, st, u) {
const M = st.mire;
const n = Math.min(M.max, 1 + Math.floor((battle.time - u.mem.terrainSince + 1e-9) / M.interval));
if (n === u.mem.mireStacks) return;
u.mem.mireStacks = n;
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
}
/** Terrain code under a unit (0 = none). Flyers and hidden units ignore ground terrain; smog only helps allies. */
function terrainFor(battle, st, u) {
if (u.hidden || u.motion === 'FLY') 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;
if (code === TERRAIN.infection && battle.time >= st.infection.until) 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 = Math.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);
}
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
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || !u.mem || u.kind === 'device') return;
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