Files
ark-stronghold-protocol/server/sim/battle/events.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

128 lines
6.1 KiB
JavaScript

// server/sim/battle/events.js — Battle methods: client events (DESIGN §8.2 tuples, bounded buffer), the snapshot, field
// meta, fx and the sim log.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { EVENT_BUFFER_CAP } from '../constants.js';
import { elementView } from '../damage.js';
import { unitInfo, snapshotUnits, ammoView, wolfView, negView, coinView } from '../snapshot.js';
export class BattleEvents {
fx(kind, params = {}) {
const { x = 0, y = 0, ...extra } = params || {};
this._ev(['fx', kind, Math.round(x * 100) / 100, Math.round(y * 100) / 100, extra]);
}
log(msg) {
const k = 'log:' + msg;
if (this._errKeys.has(k)) return;
this._errKeys.add(k);
if (this.opts.verbose) this.logger.warn?.(`[sim] ${msg}`);
}
_ev(tuple) {
if (!this.recordEvents) return;
this._evq.push(tuple);
if (this._evq.length > EVENT_BUFFER_CAP) this._evq.splice(0, this._evq.length - EVENT_BUFFER_CAP / 2);
}
/** Client-facing events since the last drain (DESIGN §8.2 tuples). */
drainEvents() {
const ev = this._evq;
this._evq = [];
return ev;
}
/**
* Record that an enemy's attack recovery (atkStandUntil) is cut or ignored now — snapshot `standCut`, display metadata
* (render/interp.js); never changes the attack's timing or state.
*/
_cutAttackStand(unit) {
if (unit.side === 'enemy' && Number.isFinite(unit.atkStandUntil) && unit.atkStandUntil > this.time) {
unit.atkStandCutAt = this.time;
}
}
/**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
* come back on: _layBody — where they fell, or their home);
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView);
* ammo: [[id, rounds left, rounds in the magazine]] — a running ammo skill, whole rounds (snapshot.js ammoView): the segmented bar;
* wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (snapshot.js wolfView): the pips under the HP bar;
* neg: [[id, fill]] — the share of its cap a negative-HP pool holds (snapshot.js negView; 斩业星熊's 我执): the red bar;
* stand: [[id, until]] — when each enemy's attack recovery ends (atkStandUntil; alive, deployed, visible, not
* feared or stunned) — display metadata (render/interp.js holds the position until then);
* standCut: [[id, at]] — the latest time each listed enemy's recovery was cut or ignored (_cutAttackStand), the
* dying ones in their death window included.
* ammo / wolves / neg / stand / standCut are display only: no sim state reads them, and the nine-field unit tuples are
* unchanged.
*/
snapshot() {
const snap = {
fieldId: this.fieldId,
t: Math.round(this.time * 1000) / 1000,
units: snapshotUnits(this.units, this.time),
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
killed: this.killed,
total: this.total,
// the HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies that are 已解决 (down or leaked).
// `killed` above counts every counted knock-out (runtime splits / summons too) and may exceed `total`.
resolved: this.resolved,
};
if (this.players.length > 1) {
snap.dps = {};
for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp);
}
if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) };
const r2 = (v) => Math.round(v * 100) / 100;
let down = null;
for (const u of this.allyUnits) {
if (!this.isDown(u)) continue;
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]);
}
if (down) snap.down = down;
let elem = null, ammo = null, wolves = null, neg = null, stand = null, standCut = null;
let listed = null; // the ids in snap.units, built for the first enemy with a cut to report
for (const u of this.units) {
if (u.side === 'enemy' && u.atkStandCutAt >= 0) {
const cutAt = Math.round(u.atkStandCutAt * 1000) / 1000;
if (cutAt <= snap.t && (listed || (listed = new Set(snap.units.map((x) => x[0])))).has(u.id)) (standCut || (standCut = [])).push([u.id, cutAt]);
}
if (!u.alive || !u.deployed || u.hidden) continue;
const until = Math.round(u.atkStandUntil * 1000) / 1000;
if (u.side === 'enemy' && !u.s.flags.fear && !u.s.flags.stun && Number.isFinite(until) && until > snap.t) {
(stand || (stand = [])).push([u.id, until]);
}
const v = elementView(u, this.time);
if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]);
const am = ammoView(u);
if (am) (ammo || (ammo = [])).push([u.id, am[0], am[1]]);
const wv = wolfView(u);
if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]);
const ng = negView(u);
if (ng) (neg || (neg = [])).push([u.id, ng]);
const purse = coinView(u);
if (purse) (snap.coins || (snap.coins = [])).push([u.id, ...purse]);
}
if (elem) snap.elem = elem;
if (ammo) snap.ammo = ammo;
if (wolves) snap.wolves = wolves;
if (neg) snap.neg = neg;
if (stand) snap.stand = stand;
if (standCut) snap.standCut = standCut;
return snap;
}
/**
* Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out
* operators waiting to redeploy included (a client joining mid-battle shows them down).
*/
fieldMeta() {
return {
fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId,
units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo),
};
}
}