Files
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

161 lines
7.7 KiB
JavaScript

// test/match/fakeBattle.js — a scriptable stand-in for server/sim/Battle.js implementing the DESIGN §5.1 surface the
// match uses: constructor(opts), step(), finished, time, tickCount, forceEnd(reason), result(), snapshot(),
// drainEvents(), fieldMeta(), on(name, fn, opts) / off(), errorCount.
//
// Behaviour is set per construction by FakeBattle.script(battle) → plan (or the default plan):
// {
// duration: game seconds until the battle finishes by itself (default: min(timeLimit, 8); boss: never — the
// shared pool or the match ends it),
// leaks: { [playerId]: n } counted leaks recorded for that player (normal fields; taken from its spawns),
// survivors: { [sourcePlayerId]: n } unite: re-entered enemies of that source still alive at the end,
// coins: { [playerId]: n }, layerGains: { [playerId]: { bondId: n } }, damage: { [playerId]: n },
// bossDps: shared-pool damage per game second (split evenly between the field's players; default 0),
// leakEvents: [{ at: seconds, lpr }] boss fields: 'enemyLeak' hook calls at those times,
// lpLossEvents: [{ at: seconds, amount }] 'lpLoss' hook calls (leader "扣除目标生命" effects) at those times,
// deadUids: [uid] operators reported dead in unitsEnd (alive false, hpPct 0),
// throwAt: tick number at which step() throws (error-isolation tests),
// throwInCtor: true → the constructor throws,
// }
// Every constructed instance is recorded in FakeBattle.instances (tests inspect the inputs the match built).
import { TICK } from '../../server/sim/constants.js';
export class FakeBattle {
static script = null;
static instances = [];
static reset() { FakeBattle.script = null; FakeBattle.instances = []; }
constructor(opts = {}) {
this.opts = opts;
this.kind = opts.kind ?? 'normal';
this.fieldId = opts.fieldId ?? null;
this.round = opts.round ?? 0;
this.players = (opts.players ?? []).map((p) => p.playerId);
this.spawns = opts.spawns ?? [];
this.sharedBoss = opts.sharedBoss ?? null;
this.timeLimit = Number.isFinite(opts.timeLimit) && opts.timeLimit > 0 ? opts.timeLimit : Infinity;
this.time = 0;
this.tickCount = 0;
this.finished = false;
this.reason = null;
this.errorCount = 0;
this._hooks = {};
this._events = [];
const plan = typeof FakeBattle.script === 'function' ? FakeBattle.script(this) : null;
const bossLike = this.kind === 'boss' || this.kind === 'hidden';
this.plan = { duration: bossLike ? Infinity : Math.min(this.timeLimit, 8), leaks: {}, survivors: {}, coins: {}, layerGains: {}, damage: {}, bossDps: 0, leakEvents: [], lpLossEvents: [], deadUids: [], ...(plan || {}) };
if (this.plan.throwInCtor) throw new Error('FakeBattle: constructor failure (scripted)');
this._leakIdx = 0;
this._lpLossIdx = 0;
FakeBattle.instances.push(this);
}
on(name, fn) {
(this._hooks[name] ||= []).push(fn);
return { name, fn };
}
off() {}
_emit(name, ctx) { for (const fn of this._hooks[name] || []) fn(ctx, this); }
step() {
if (this.finished) return;
if (this.plan.throwAt != null && this.tickCount + 1 >= this.plan.throwAt) throw new Error('FakeBattle: step failure (scripted)');
this.tickCount++;
this.time = this.tickCount * TICK;
if (this.tickCount % 3 === 0) this._events.push(['fx', 'tick', 0, 0, { t: this.time }]);
if (this.sharedBoss && this.plan.bossDps > 0) {
const per = (this.plan.bossDps * TICK) / Math.max(1, this.players.length);
for (const pid of this.players) this.sharedBoss.damage(pid, per);
}
while (this._leakIdx < this.plan.leakEvents.length && this.plan.leakEvents[this._leakIdx].at <= this.time) {
const le = this.plan.leakEvents[this._leakIdx++];
this._emit('enemyLeak', { enemy: { lpr: le.lpr ?? 1, defId: le.enemyKey ?? 'enemy_fake', isBoss: !!le.boss } });
}
while (this._lpLossIdx < this.plan.lpLossEvents.length && this.plan.lpLossEvents[this._lpLossIdx].at <= this.time) {
const le = this.plan.lpLossEvents[this._lpLossIdx++];
this._emit('lpLoss', { amount: le.amount, reason: 'fake', source: null });
}
if (this.sharedBoss && this.sharedBoss.hp <= 0) { this._finish('cleared'); return; }
if (this.time >= this.plan.duration - 1e-9) this._finish(this._leakTotal() > 0 ? 'timeout' : 'cleared');
else if (this.time >= this.timeLimit - 1e-9) this._finish('timeout');
}
_leakTotal() {
let n = 0;
for (const v of Object.values(this.plan.leaks || {})) n += v;
for (const v of Object.values(this.plan.survivors || {})) n += v;
return n;
}
forceEnd(reason = 'forced') {
if (this.finished) return;
this._finish(reason === 'timeout' ? 'timeout' : 'forced');
}
_finish(reason) {
this.finished = true;
this.reason = reason;
}
result() {
const perPlayer = {};
for (const pid of this.players) {
const spawnsOf = this.spawns.filter((s) => (s.ownerPlayerId ?? pid) === pid);
let total = 0;
for (const s of this.kind === 'unite' ? this.spawns : spawnsOf) total += Math.max(1, s.count || 1);
if (this.kind === 'unite' && this.players.indexOf(pid) > 0) total = 0;
const leaked = [];
const n = this.plan.leaks[pid] || 0;
for (let i = 0; i < n; i++) {
const s = spawnsOf[i % Math.max(1, spawnsOf.length)] || { enemyKey: 'enemy_1007_slime', mods: { slot: 'N' } };
leaked.push({ enemyKey: s.enemyKey, mods: s.mods ?? null, lpr: 1, sourcePlayerId: pid, tag: s.tag ?? null, counted: true, spawned: true });
}
if (this.kind === 'unite' && this.players.indexOf(pid) === 0) {
for (const [src, k] of Object.entries(this.plan.survivors || {})) {
const pool = this.spawns.filter((s) => s.sourcePlayerId === src);
for (let i = 0; i < k; i++) {
const s = pool[i % Math.max(1, pool.length)] || { enemyKey: 'enemy_1007_slime', mods: null };
leaked.push({ enemyKey: s.enemyKey, mods: s.mods ?? null, lpr: 1, sourcePlayerId: src, tag: null, counted: true, spawned: true });
}
}
}
const units = (this.opts.players.find((p) => p.playerId === pid)?.units ?? []).filter((u) => u.kind === 'chess');
perPlayer[pid] = {
killed: Math.max(0, total - leaked.length),
total,
leaked,
perfect: !leaked.some((l) => l.counted !== false),
layerGains: { ...(this.plan.layerGains[pid] || {}) },
coins: this.plan.coins[pid] || 0,
damageDealt: this.plan.damage[pid] || 1000,
bossDamage: 0,
healingDone: 0,
deaths: 0,
unitsEnd: units.map((u) => {
const dead = this.plan.deadUids.includes(u.uid);
return { uid: u.uid, id: u.uid, defId: u.chessId, hpPct: dead ? 0 : 0.5, sp: dead ? 0 : 3, skillActive: false, alive: !dead };
}),
unitStats: units.map((u) => ({ id: u.uid, uid: u.uid, defId: u.chessId, name: u.chessId, kind: 'op', dmg: 100, kills: 1, heal: 0, taken: 10, attacks: 5 })),
};
}
const res = { time: this.time, reason: this.reason ?? 'forced', perPlayer, killed: 0, total: 0, errors: this.errorCount };
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
return res;
}
snapshot() {
const snap = { fieldId: this.fieldId, t: this.time, units: [[1, 5, 10, 100, 100, 0, 10, 0, 0]], dp: 10, killed: 0, total: this.spawns.length };
if (this.sharedBoss) snap.boss = { hp: Math.round(this.sharedBoss.hp), max: Math.round(this.sharedBoss.maxHp) };
return snap;
}
drainEvents() { const e = this._events; this._events = []; return e; }
fieldMeta() {
return { fieldId: this.fieldId, kind: this.kind, rect: this.opts.rect ?? null, stageId: this.opts.stageId ?? null, units: [] };
}
}