- The S2 ward ("期间持续使自身与该目标周围的敌人陷入沉睡") re-applies a 0.5 s sleep every 0.25 s, so an enemy held asleep
was one entry for garrison_125 (「每当范围内有敌人或干员进入沉睡或晕眩时」 萨尔贡 / 精准 +1, elite +2, at most 24 / 48 per
battle). The reporter's first-hand memory of the official mode: 「攻击范围内有陷入沉睡就开始迅速增加直至上限」.
- The pulse now passes applyStatus `reenter` (a new opt-in: statusApplied reports `entered`; the buff refreshes as
before) — once per enemy and pulse, not while a longer sleep from elsewhere holds it. With one enemy in her range the
trait reaches its cap 5.63 s into S2; an enemy outside her range still adds nothing; a refresh elsewhere is still no
entry (the garrison rule is unchanged). No enemy wakes between pulses. The pulse timing is [ASSUMED] (not in the data).
- Tests: garrisons_battle — new 缇缇 125 + S2 test (normal and elite: an enemy at her back adds nothing over 8 s of
pulses; an enemy in range beside both wards, one entry per pulse, takes the trait to 24 / 48 within 6.5 s).
- Docs: DESIGN §24.8 (and the §24 intro), the Battle.applyStatus / garrison / kit comments.
- Golden: roster-042 (缇缇 S2): her trait's 24 layers come within her first S2 (26–37 s) instead of over four casts up
to 177 s; same totals (capped), and the earlier layers end the fight at 166.97 s instead of 182.03 s, 5 knock-outs
instead of 7.
190 lines
12 KiB
JavaScript
190 lines
12 KiB
JavaScript
// 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;
|