卫戍协议:盟约 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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+348
View File
@@ -0,0 +1,348 @@
// server/sim/spec.js — BattleSpec: the JSON description of one battle, built by the server and simulated identically
// by the server (headless / takeover / verification) and by browsers (the authoritative client and display replicas).
// DESIGN §14. Pure ESM (served at /sim/spec.js): no Node API.
//
// const spec = buildBattleSpec({ battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit, players,
// spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss })
// const battle = createBattleFromSpec(spec, dataSource, { sharedBoss?, BattleClass?, logger?, recordEvents?, quiet? })
//
// A spec is JSON-safe by construction (it IS the JSON round trip of its inputs: `undefined` / functions vanish,
// Infinity / NaN become null), so the server builds its own battles from exactly what the clients receive. Object
// references (stage records, the shared boss pool, the data source) never travel: `stageId` names the stage, `boss`
// carries the pool numbers at battle start and the side that runs the battle attaches a pool object:
// * server: the match's SharedBossPool (or a crediting wrapper on takeover, server/match/finalAssault.js),
// * client: a LocalBossPool (below) reconciled with the server's `b.pool` broadcasts.
// Determinism: the sim uses no wall clock and no Math.random; the same spec + the same data give the same result on
// every machine (test/match/clientCombat.test.js compares result digests).
//
// Operator loadouts (DESIGN §16): every operator entry of `players[].units[]` may carry `skillIndex` (character skill
// slot, 0-based) and `moduleId` (uniEquipId or 'none'); buildBattleSpec keeps them when well-formed (else drops them =
// the default). createBattleFromSpec hands the Battle a per-battle data view (withUnitLoadouts) that resolves each
// operator def — and its summons — for the loadout of its chess (simdata getChess(id, loadout) / getToken(id, owner,
// ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup.
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
export const SPEC_VERSION = 1;
/** Deep JSON round trip (exactly what a spec becomes on the wire). */
export function jsonClone(v) {
if (v === undefined) return undefined;
return JSON.parse(JSON.stringify(v));
}
// JSON has no Infinity: ±Infinity inputs become ±1e308 (still "larger than anything" for the sim's comparisons and
// caps) instead of null (which the sim would read as 0); NaN becomes null (the sim treats both as "missing").
const INF = 1e308;
const specReplacer = (k, v) => (typeof v === 'number' && !Number.isFinite(v) ? (Number.isNaN(v) ? null : v > 0 ? INF : -INF) : v);
/**
* Build the JSON BattleSpec of one field (DESIGN §14). Inputs are the Battle options the match computed (DESIGN §5.1)
* minus object references: `stageId` instead of a stage object, `boss: { poolHp, poolMax }` instead of a pool.
* @returns {object} a fresh JSON-safe object
*/
export function buildBattleSpec(o = {}) {
const bossLike = o.kind === 'boss' || o.kind === 'hidden';
const tl = Number(o.timeLimit);
const spec = {
v: SPEC_VERSION,
battleId: o.battleId ?? null,
fieldId: o.fieldId ?? null,
kind: o.kind ?? 'normal',
seed: (Number(o.seed) >>> 0) || 1,
modeId: o.modeId ?? null,
round: Number.isInteger(o.round) ? o.round : 0,
stageId: o.stageId ?? null,
rect: o.rect ?? null,
// boss / hidden fields end by the shared pool or the match (null = no limit)
timeLimit: !bossLike && tl > 0 && Number.isFinite(tl) ? tl : null,
players: Array.isArray(o.players) ? o.players : [],
// a spawn at time Infinity never happens and is not counted (Battle._queueSpawn): leave it out
spawns: (Array.isArray(o.spawns) ? o.spawns : []).filter((x) => !(x && Number(x.time) === Infinity)),
routes: Array.isArray(o.routes) ? o.routes : [],
flags: o.flags ?? {},
enemyOverrides: o.enemyOverrides ?? {},
waveId: o.waveId ?? null,
bossId: o.bossId ?? null,
content: o.content ?? 'full',
boss: bossLike && o.boss ? { poolHp: Number(o.boss.poolHp) || 0, poolMax: Number(o.boss.poolMax) || 1 } : null,
};
const out = JSON.parse(JSON.stringify(spec, specReplacer));
for (const p of out.players) for (const u of (p && Array.isArray(p.units) ? p.units : [])) if (u && typeof u === 'object') sanitizeUnitLoadout(u);
return out;
}
const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/;
/** Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality). Mutates `u`. */
export function sanitizeUnitLoadout(u) {
if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex;
if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; }
return u;
}
// withUnitLoadouts(ds, players) — the per-battle loadout data view — lives in simdata.js (content/index.js
// installContent applies it to every Battle, however constructed); re-exported here for spec users.
export { withUnitLoadouts };
/**
* Construct the Battle a spec describes. The spec is deep-copied (one spec may build several battles: a display
* replica, a takeover re-simulation, a verification run).
* @param {object} spec BattleSpec
* @param {any} dataSource DataSource (or raw data maps; default: the sim's default source)
* @param {{ sharedBoss?: object|null, BattleClass?: Function, logger?: object, recordEvents?: boolean, quiet?: boolean,
* content?: string }} [opts]
*/
export function createBattleFromSpec(spec, dataSource, opts = {}) {
if (!spec || typeof spec !== 'object') throw new TypeError('createBattleFromSpec: spec required');
const s = jsonClone(spec);
const bossLike = s.kind === 'boss' || s.kind === 'hidden';
let sharedBoss = opts.sharedBoss ?? null;
if (!sharedBoss && bossLike && s.boss) sharedBoss = new LocalBossPool(s.boss.poolMax, s.boss.poolHp);
const BattleClass = typeof opts.BattleClass === 'function' ? opts.BattleClass : Battle;
const battleOpts = {
seed: s.seed,
kind: s.kind,
modeId: s.modeId,
round: s.round,
stageId: s.stageId,
rect: s.rect ?? undefined,
timeLimit: s.timeLimit == null ? (bossLike ? Infinity : undefined) : s.timeLimit,
players: s.players ?? [],
spawns: s.spawns ?? [],
routes: s.routes ?? [],
sharedBoss,
flags: s.flags ?? {},
fieldId: s.fieldId,
enemyOverrides: s.enemyOverrides ?? {},
waveId: s.waveId ?? null,
data: BattleClass === Battle ? withUnitLoadouts(toDataSource(dataSource), s.players) : dataSource,
content: opts.content ?? s.content ?? 'full',
};
if (s.bossId != null) battleOpts.bossId = s.bossId;
if (opts.logger) battleOpts.logger = opts.logger;
if (opts.recordEvents === false) battleOpts.recordEvents = false;
if (opts.quiet) battleOpts.quiet = true;
const b = new BattleClass(battleOpts);
b.battleId = s.battleId ?? null;
return b;
}
/**
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp)
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
* (`cum`) it has already counted
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
*/
export class LocalBossPool {
constructor(maxHp, hp = maxHp) {
this.maxHp = Math.max(1, Number(maxHp) || 1);
const h = Number(hp);
this.serverHp = Number.isFinite(h) ? Math.max(0, Math.min(this.maxHp, h)) : this.maxHp;
this.cum = 0;
this.acked = 0;
/** @type {Record<string, number>} */
this.byPlayer = {};
}
get hp() { return Math.max(0, this.serverHp - Math.max(0, this.cum - this.acked)); }
/** Only used by the sim's fallback path when damage() throws. */
set hp(v) {
const n = Number(v);
if (Number.isFinite(n)) this.serverHp = Math.max(0, n) + Math.max(0, this.cum - this.acked);
}
damage(playerId, amount) {
const a = Number(amount);
const left = this.hp;
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
const dealt = Math.min(left, a);
this.cum += dealt;
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
return dealt;
}
sync(serverHp, ackedCum) {
const h = Number(serverHp);
if (Number.isFinite(h)) this.serverHp = Math.max(0, Math.min(this.maxHp, h));
const a = Number(ackedCum);
if (Number.isFinite(a) && a >= 0) this.acked = a;
}
}
/**
* LP meter of a battle: cumulative LP the field cost its team so far — enemy leaks weighted by `lpr` (data
* lifePointReduce; 0 for harmless units, 1 when absent) plus leader "扣除目标生命" effects (the sim's 'lpLoss' hook).
* Boss / hidden fields report it as b.progress.leaks; the server charges the team LP with the deltas.
* @returns {{ lp: number, detach: () => void }}
*/
export function attachLpMeter(battle) {
const meter = { lp: 0, detach() {} };
if (!battle || typeof battle.on !== 'function') return meter;
const h1 = battle.on('enemyLeak', (ctx) => {
const e = ctx && ctx.enemy;
if (!e) return;
const lpr = Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1;
meter.lp += lpr;
}, { priority: -1000, owner: 'lpMeter' });
const h2 = battle.on('lpLoss', (ctx) => {
const n = Number(ctx && ctx.amount);
if (Number.isFinite(n) && n > 0) meter.lp += n;
}, { priority: -1000, owner: 'lpMeter' });
meter.detach = () => { try { battle.off?.(h1); battle.off?.(h2); } catch { /* ignore */ } };
return meter;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite) and the boss pool damage of this field.
*/
export function battleProgress(battle) {
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
let leaks = 0;
const pp = battle && battle._perPlayer ? battle._perPlayer : (r && r.perPlayer) || {};
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
return {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
leaks,
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
};
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
/**
* Canonical summary of a BattleResult (everything the match consumes): used to compare a client's result with the
* server's re-simulation (SP_VERIFY) and by the determinism tests. Returns `{ json, hash }`.
*/
export function resultDigest(result) {
const res = result && typeof result === 'object' ? result : {};
const per = res.perPlayer && typeof res.perPlayer === 'object' ? res.perPlayer : {};
const players = Object.keys(per).sort().map((pid) => {
const p = per[pid] || {};
const lg = p.layerGains && typeof p.layerGains === 'object' ? p.layerGains : {};
return [
pid,
p.killed | 0, p.total | 0, !!p.perfect, r4(p.coins),
Object.keys(lg).sort().map((k) => [k, r4(lg[k])]),
// mods included: a leak re-enters the 联防 with them (round multipliers, bounty id)
(Array.isArray(p.leaked) ? p.leaked : []).map((l) => `${l && l.enemyKey}|${l && l.counted === false ? 0 : 1}|${(l && l.sourcePlayerId) || ''}|${modsKey(l && l.mods)}`).sort(),
Math.round(Number(p.damageDealt) || 0), Math.round(Number(p.bossDamage) || 0),
(Array.isArray(p.unitsEnd) ? p.unitsEnd : []).map((u) => [u && u.uid, r4(u && u.hpPct), r4(u && u.sp), !!(u && u.alive)]),
];
});
const json = JSON.stringify([res.reason ?? null, r4(res.time), res.killed | 0, res.total | 0, players]);
let h = 2166136261;
for (let i = 0; i < json.length; i++) { h ^= json.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
return { json, hash: h.toString(16).padStart(8, '0') };
}
const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 2 ** 31 ? v : null);
const keyOr = (v) => (isKey(v) ? v : null);
function compactMods(m) {
if (!m || typeof m !== 'object') return null;
const out = {};
let n = 0;
for (const k of Object.keys(m)) {
if (n >= 16 || k.length > 32) break;
const v = m[k];
if ((typeof v === 'number' && Number.isFinite(v)) || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 64) || v === null) { out[k] = v; n++; }
}
return out;
}
/**
* The part of a BattleResult the server consumes, in the b.result wire shape (shared/protocol.js isBattleResult):
* numbers only where the match reads them, list sizes capped, names dropped (the server names units from its data).
*/
export function compactResult(res) {
const r = res && typeof res === 'object' ? res : {};
const perPlayer = {};
for (const pid of Object.keys(r.perPlayer || {}).slice(0, 4)) {
const p = r.perPlayer[pid] || {};
const layerGains = {};
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
const total = Math.max(0, Math.trunc(fnum(p.total)));
perPlayer[pid] = {
killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))),
total,
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
if (l.boss) o.boss = true;
if (l.spawned) o.spawned = true;
return o;
}),
perfect: !!p.perfect,
layerGains,
coins: Math.max(0, fnum(p.coins)),
damageDealt: Math.max(0, Math.round(fnum(p.damageDealt))),
bossDamage: Math.max(0, Math.round(fnum(p.bossDamage))),
healingDone: Math.max(0, Math.round(fnum(p.healingDone))),
deaths: Math.max(0, Math.trunc(fnum(p.deaths))),
unitsEnd: cap(p.unitsEnd, 64).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), hpPct: Math.max(0, Math.min(1, fnum(u.hpPct))), sp: Math.max(0, Math.min(1e5, fnum(u.sp))),
skillActive: !!u.skillActive, alive: !!u.alive,
})),
unitStats: cap(p.unitStats, 160).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), kind: typeof u.kind === 'string' && u.kind.length <= 16 ? u.kind : 'op',
dmg: Math.max(0, Math.round(fnum(u.dmg))), kills: Math.max(0, Math.trunc(fnum(u.kills))), heal: Math.max(0, Math.round(fnum(u.heal))),
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
})),
};
}
const out = {
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
time: Math.max(0, Math.min(1e5, fnum(r.time))),
killed: Math.max(0, Math.trunc(fnum(r.killed))),
total: Math.max(0, Math.trunc(fnum(r.total))),
perPlayer,
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
};
if (out.killed > out.total) out.killed = out.total;
if (Array.isArray(r.unspawned) && r.unspawned.length) {
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
time: Math.max(0, Math.min(1e6, fnum(u.time))),
}));
}
return out;
}
/** Bytes a b.result frame may use (the socket's inbound limit is 64 KB; headroom for the envelope). */
export const RESULT_FRAME_BUDGET = 60 * 1024;
/**
* Keep a compact result under the frame budget (a larger frame closes the socket: the server would take the field over
* at the very end). Never touches what settles LP / funds / layers of a normal field. In order: drop the per-unit
* statistics, drop the leaks' `mods` (the server rebuilds them from the spec), and — boss fields only, whose leaks cost
* team LP through b.progress — drop leak entries. Returns the (possibly) trimmed copy.
* @param {object} result compactResult(...) output
* @param {{ bossLike?: boolean, budget?: number, battleId?: string }} [o]
*/
export function fitResult(result, { bossLike = false, budget = RESULT_FRAME_BUDGET, battleId = '' } = {}) {
const size = (r) => JSON.stringify({ t: 'b.result', battleId, result: r, rid: 2147483647 }).length;
if (!result || typeof result !== 'object' || size(result) <= budget) return result;
const r = { ...result, perPlayer: {} };
for (const [pid, p] of Object.entries(result.perPlayer || {})) r.perPlayer[pid] = { ...p, unitStats: [] };
if (size(r) <= budget) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = (p.leaked || []).map((l) => ({ ...l, mods: null }));
if (size(r) <= budget || !bossLike) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = [];
if (Array.isArray(r.unspawned)) r.unspawned = [];
return r;
}