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).
161 lines
7.7 KiB
JavaScript
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: [] };
|
|
}
|
|
}
|