Files
ark-stronghold-protocol/server/sim/Battle.js
T
sganggs bbe59b9a5f feedback4: a sleeping enemy cannot be blocked and takes no block slot — 沉睡 = 无法行动+无敌+不可阻挡 (GitHub #140; the owner's decision of 2026-10-06)
- GitHub #140: a player tested 瑕光's S2 in the official mode — she slept 4 enemies and still blocked the next ones,
  after the skill no more were (「被沉睡的敌人是不占用阻挡数的」). PRTS 异常效果: SLEEPING 沉睡 = 「无法行动+无敌+不可阻挡」,
  BLOCK_FREE 不可阻挡 = 「无法阻挡/被阻挡,自动解除阻挡」. Until now an enemy that fell asleep while blocked stayed blocked
  and kept its blocker's slot.
- Battle.applyStatus releases an enemy that falls asleep (as for 浮空 / 恐惧 / 不可阻挡), Battle._checkBlock never blocks a
  sleeper, and ai.js updateEnemy lets a plain-buff sleeper go on its next update. The sleeper stays put; awake, it is
  blocked again only by a blocker with a free slot, else it walks on. A stun, a freeze or a refused sleep keeps the
  block; sleeping operators are unchanged.
- Readers of the block follow: Battle.blockedTargets (blocked-first targeting, the DEFAULT trigger, hitAllBlocked)
  drops the sleeper — of the hitSleep attackers only 瑕光 blocks, and her 仁慈 reads her range; a 要塞 holding only a
  sleeper no longer stalls; “庞贝”'s self-blast clock restarts when a sleep ends its block [ASSUMED]; the snapshot's
  blocked bit follows.
- Tests: new test/sim/feedback4-sleep-unblock.test.js (5 cases, each fails without the change: the slot frees and the
  next enemy is blocked, the sleeper stays put; awake with no room it walks on; never blocked while asleep, re-blocked
  on waking beside a free blocker; a plain-buff sleep; the real 瑕光 S2). 1274 targeted tests (blocking, statuses,
  小满 / 瑕光 / 缇缇 kits, garrisons, professions, enemies, kits t1–t6), docs-consistency and GOLDEN_FULL pass.
- Docs: DESIGN §24.9 (and the §24 intro, §5.5 Blocking, §22.8's release list), SIM.md (Blocking, the `sleep` row,
  plain buffs), the buffs.js / Battle.js / ai.js / 瑕光 kit comments.
- Golden (every moved scenario sleeps blocked enemies; DESIGN §24.9 gives each move): roster-003 / roster-005 (小满 S2:
  82 kills instead of 83 / cleared at 136.73 s instead of the 140 s timeout), roster-013 (缇缇 S3: only the blocked
  count and the snapshot hashes), roster-032 (缇缇 S1: 7 knock-outs instead of 5), roster-038 (elite 缇缇 S2: ends at
  197.43 s), roster-039 (瑕光 S2 normal + elite: 41 of 52 killed instead of 47 — the sleepers walk on when they wake),
  roster-042 (缇缇 S2: ends at 161.17 s instead of 166.97 s), bond-maniShip-high (小满: only the blocked count and the
  snapshot hashes), coop4-FUNNY-6 (ai_0's 缇缇: layers and boss damage from round 9), coop4-ABYSS-7 (ai_2's 小满:
  rounds 11–12), coop2-NORMAL-8-serverrun (ai_1's 缇缇: one more leak in round 10, the team eliminated in round 13
  instead of winning). fields unchanged.
2026-10-06 16:57:55 +08:00

190 lines
12 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.js — one field simulation (normal / unite / boss / hidden). Public API: DESIGN §5.1.
//
// const b = new Battle({ seed, kind, modeId, round, stage|stageId, rect, timeLimit, players, spawns, routes,
// sharedBoss, flags, fieldId?, data?, content?, recordEvents?, logger? })
// b.step(); b.finished; b.forceEnd(reason); b.time; b.result(); b.snapshot(); b.drainEvents(); b.on/off(...)
//
// Construction creates every ally unit (undeployed) and installs content (kits + domain modules). The first
// step() (or an explicit start()) deploys everything (operators by column from the left, top to bottom within a
// column — mirrored for the right boss side —, then the summon pieces the same way — except a piece content flags
// `deferDeploy`, which waits on its tile; the players of a shared field side by side), fires `deploy` (initial) for
// each unit, forces out the operators that enter already knocked out (`carryState.down`, 联防: constants.js
// FORCED_EXIT — down on their tile, redeploy timer running) and then fires `battleStart`.
// An operator that left the field — knocked out, or forced out by its own effects (史尔特尔's 余烬 …, GitHub #60) — lies
// on its `body` tile — where it fell, or its own home when it fell on another board piece's home — and redeploys there;
// no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `isDown`, `_layBody`, `downOn`,
// `restTile`, `isReservedTile`; docs/SIM.md §1).
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
// Board → field coordinates: units are given in board coordinates (rows 9–12, cols 2–10). Normal/unite:
// (row, col + colOffset). Boss/hidden: row − 7 when row ≥ 7 (board rows → boss rows 2–5); side 'R' mirrors
// the column (col → 20 − col) and mirrors the direction (RIGHT ↔ LEFT, UP / DOWN kept). Every ally has a direction
// `dir` (UP|RIGHT|DOWN|LEFT, default RIGHT — PlayerBattleInput unit `dir`, sim/dir.js) that rotates its range grid.
// `abs: true` on a unit (or player `coords: 'field'`) = field tiles (and field directions).
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
//
// Code layout: this file keeps the constructor (fields, players, spawn queue, content install); the methods live in
// server/sim/battle/, one module per concern, and are installed on Battle.prototype below in a fixed order, with the
// descriptors of class methods (non-enumerable; `this` is the battle, subclasses and `super` calls work as before):
// players.js board → field tiles / directions, ally creation, kit / profile / skill setup
// lifecycle.js start (the battle-start deployment), step (the tick order above), end checks, BattleResult
// hooks.js hook bus (on / off / emit), timers (after / every), content-callback isolation and errors
// spawns.js spawn queue, routes, spawnEnemy; hidden enemies, boss pool sync, field clamp, list compaction
// deploy.js deploy, kill, retreat, removal bookkeeping, redeploys, leaks
// blocking.js contact blocking, block release, the 隐匿 switch-off after a block
// status.js buffs, catalogue statuses (抵抗, 寒冷 → 冻结, 诱导, 恐惧 …), "同名效果取最高", the buff tick
// combat.js damage / heal / HP loss (damage.js pipeline), projectiles, forced attacks
// queries.js enemy tile index, unit queries (tiles, grid, radius, selectability), players, range rebuilds
// summons.js tokens (summons), devices, stage devices
// tiles.js obstacles, relocation / 【移动】, knocked-out bodies, reserved tiles, tactical points, ground paths
// displacement.js push / pull / displace (受力等级 = force − weight); pushTiles
// economy.js DP, IN_BATTLE bond layers, bounty coins
// events.js client events, snapshot, field meta, fx, the sim log
// util.js fin / clone
import { TICK, ROWS, COLS, DP_DEFAULTS, MAX_HOOK_DEPTH, AUTO_OP_COOLDOWN } from './constants.js';
import { GEO } from '../../shared/constants.js';
import { createRng } from './rng.js';
import { Grid } from './grid.js';
import { ProjectileSystem } from './projectiles.js';
import { remainingDistance } from './ai.js';
import { toDataSource, normalizeRoute, normalizeStage } from './simdata.js';
import { installContent } from './content/index.js';
import { BattlePlayers } from './battle/players.js';
import { BattleLifecycle } from './battle/lifecycle.js';
import { BattleHooks } from './battle/hooks.js';
import { BattleSpawns } from './battle/spawns.js';
import { BattleDeploy } from './battle/deploy.js';
import { BattleBlocking } from './battle/blocking.js';
import { BattleStatus } from './battle/status.js';
import { BattleCombat } from './battle/combat.js';
import { BattleQueries } from './battle/queries.js';
import { BattleSummons } from './battle/summons.js';
import { BattleTiles } from './battle/tiles.js';
import { BattleDisplacement } from './battle/displacement.js';
import { BattleEconomy } from './battle/economy.js';
import { BattleEvents } from './battle/events.js';
export { pushTiles } from './battle/displacement.js';
const DEFAULT_RECTS = { normal: GEO.NORMAL_RECT, unite: GEO.UNITE_RECT, boss: GEO.BOSS_RECT, hidden: GEO.BOSS_RECT };
export class Battle {
constructor(opts = {}) {
this.opts = opts;
this.seed = (Number(opts.seed) >>> 0) || 1;
this.rng = createRng(this.seed);
this.kind = opts.kind ?? 'normal';
this.modeId = opts.modeId ?? null;
this.round = opts.round ?? 0;
this.fieldId = opts.fieldId ?? null;
this.logger = opts.logger ?? console;
this.recordEvents = opts.recordEvents !== false;
/** When false the battle only ends by time limit / boss pool / forceEnd (tests, sandboxes). */
this.autoFinish = opts.autoFinish !== false;
this.data = toDataSource(opts.data);
let stage = opts.stage ?? (opts.stageId ? this.data.getStage(opts.stageId) : null);
if (stage && !stage._norm) stage = normalizeStage(stage.id ?? opts.stageId, stage);
this.stage = stage || { id: 'empty', rows: [], devices: [] };
this.stageId = this.stage.id ?? opts.stageId ?? null;
this.rect = { ...(opts.rect ?? DEFAULT_RECTS[this.kind] ?? GEO.NORMAL_RECT) };
{ // a malformed rect (non-integers, inverted, off the stage) falls back to the kind's default field
const R = this.rect;
const ok = [R.r0, R.r1, R.c0, R.c1].every(Number.isInteger) && R.r0 >= 0 && R.c0 >= 0 && R.r1 < ROWS && R.c1 < COLS && R.r0 <= R.r1 && R.c0 <= R.c1;
if (!ok) this.rect = { ...(DEFAULT_RECTS[this.kind] ?? GEO.NORMAL_RECT) };
}
this.grid = new Grid(this.stage, this.rect);
const bossLike = this.kind === 'boss' || this.kind === 'hidden';
// A bad limit (0, negative, NaN — e.g. a missing config row) must not turn a normal round into an endless one:
// non-boss kinds fall back to 60 s; only boss/hidden (or an explicit Infinity) run without a limit.
const tl = Number(opts.timeLimit);
this.timeLimit = opts.timeLimit == null || !(tl > 0) ? (bossLike ? Infinity : 60) : tl;
// startOpCooldown: the operation cooldown of the battle-start deployment (skills.js AUTO_OP_COOLDOWN)
this.flags = { layerGainsEnabled: this.kind === 'normal', ...DP_DEFAULTS, startOpCooldown: AUTO_OP_COOLDOWN, ...(opts.flags || {}) };
const soc = Number(this.flags.startOpCooldown);
this.flags.startOpCooldown = this.flags.startOpCooldown != null && Number.isFinite(soc) && soc >= 0 ? soc : AUTO_OP_COOLDOWN;
// DP knobs may arrive as undefined/null/strings (e.g. a template without `dp`): never let them poison DP with NaN.
for (const k of ['dpInit', 'dpPerSec', 'dpMax']) {
const v = Number(this.flags[k]);
this.flags[k] = this.flags[k] != null && Number.isFinite(v) && v >= 0 ? v : DP_DEFAULTS[k];
}
this.sharedBoss = opts.sharedBoss ?? null;
this.routes = (opts.routes ?? []).map((r) => normalizeRoute(r));
/** Per-template enemy overrides `{ [enemyKey]: { stats: {…partial} } }` (waves.json `overrides`). */
this.enemyOverrides = opts.enemyOverrides ?? {};
this.dt = TICK;
this.time = 0;
this.tickCount = 0;
this.finished = false;
this.reason = null;
this.started = false;
this._startDeploying = false;
/** @type {import('./units.js').Unit[]} every unit ever created */
this.units = [];
/** @type {import('./units.js').Unit[]} alive enemies (compacted each tick) */
this.enemies = [];
/** @type {import('./units.js').Unit[]} every ally unit (ops, tokens, devices; dead included) */
this.allyUnits = [];
this._hooks = Object.create(null);
this._emitDepth = 0;
this._frameName = new Array(MAX_HOOK_DEPTH).fill(null); // open hook/callback frames (diagnostics, _chain)
this._frameOwner = new Array(MAX_HOOK_DEPTH).fill(null);
this._frameCtx = new Array(MAX_HOOK_DEPTH).fill(null);
this._toRelease = []; // permanently removed units whose hooks / periodic timers are dropped at step end
this._sched = [];
this._evq = [];
this.projectiles = new ProjectileSystem(this);
this._occ = new Array(ROWS * COLS).fill(null);
this._eb = new Array(ROWS * COLS);
for (let i = 0; i < this._eb.length; i++) this._eb[i] = [];
this._ebUsed = [];
this._idSeq = 0;
this._attackSeq = 0; // DamageInfo.attackId of normal attacks (one id per attack, all its damage instances)
this._deploySeq = 0;
this._spawnSeq = 0;
this._enemiesDirty = false;
this.killed = 0;
this.total = 0;
this.leakedCount = 0;
this.errors = [];
this.errorCount = 0;
this._errKeys = new Set();
this._result = null;
this._skills = { onDamaged: (u) => { if (u.skill) u.skill.onDamaged(); } };
this.remainingDistance = (e) => remainingDistance(this, e);
// ---- players
this.players = [];
this._perPlayer = Object.create(null);
for (const p of opts.players ?? []) if (p && typeof p === 'object') this._addPlayer(p);
// ---- spawns
this._pending = [];
for (const s of opts.spawns ?? []) if (s && typeof s === 'object') this._safe(() => this._queueSpawn(s, true), 'queueSpawn');
this._pending.sort((a, b) => a.time - b.time || a.seq - b.seq);
// ---- content
this.contentMode = opts.content ?? 'full';
this._safe(() => installContent(this, { mode: this.contentMode, extra: opts.extraContent }), 'installContent');
for (const u of this.allyUnits) if (!u.kit) this._setupUnit(u);
if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup');
}
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`;
}
}
// the method modules, in this order (a name defined twice is an error, never a silent override)
for (const part of [BattlePlayers, BattleLifecycle, BattleHooks, BattleSpawns, BattleDeploy, BattleBlocking, BattleStatus, BattleCombat, BattleQueries, BattleSummons, BattleTiles, BattleDisplacement, BattleEconomy, BattleEvents]) {
for (const key of Reflect.ownKeys(part.prototype)) {
if (key === 'constructor') continue;
if (Object.prototype.hasOwnProperty.call(Battle.prototype, key)) throw new Error(`Battle.${String(key)} is defined twice`);
Object.defineProperty(Battle.prototype, key, Object.getOwnPropertyDescriptor(part.prototype, key));
}
}
export default Battle;