Files
ark-stronghold-protocol/server/sim/spec.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

390 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import { BOSS_POOL_MIN_HP } from './constants.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; the hit that
* would leave less than BOSS_POOL_MIN_HP (1) takes the rest)
* 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)
* `hp` below 1 reads 0 (the leader is down; user playtest #6 item 5): the difference of the server's float hp and the
* local counters can leave dust (3.6e-12) smaller than half an ulp of `cum`, which no hit could remove — `cum += dust`
* changes nothing — so the leader stood at "0 HP" and the field never ended.
*/
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() {
const h = this.serverHp - Math.max(0, this.cum - this.acked);
return h < BOSS_POOL_MIN_HP ? 0 : h;
}
/** 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 = left - a < BOSS_POOL_MIN_HP ? 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;
}
/**
* 联防 (user playtest #6 item 7): each source player's enemies still standing on a unite field — not spawned yet,
* alive, or already through the objective again — i.e. what settlement would charge them if the battle ended now
* (server/match/unite.js uniteSurvivors: the unite result's counted leaks + unspawned entries by `sourcePlayerId`;
* alive enemies become leaks on a timeout, keyed like Battle._recordLeak). It falls as the helpers strike them down and
* rises when one splits or summons (content-spawned children inherit the parent's sourcePlayerId).
* `{ [playerId]: n }` (a player with none left is absent), or null for a battle without the sim's state (a stand-in).
* @returns {Record<string, number> | null}
*/
export function uniteLeft(battle) {
if (!battle) return null;
const out = {};
const add = (pid) => { if (typeof pid === 'string' && pid) out[pid] = (out[pid] || 0) + 1; };
if (battle.finished && typeof battle.result === 'function') {
const r = battle.result();
if (!r || !r.perPlayer) return null;
for (const pp of Object.values(r.perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
for (const u of r.unspawned || []) if (u) add(u.sourcePlayerId);
return out;
}
if (!battle._perPlayer || !Array.isArray(battle._pending) || !Array.isArray(battle.enemies)) return null;
for (const p of battle._pending) if (p) add(p.sourcePlayerId);
for (const e of battle.enemies) if (e && e.alive && e.counted) add(e.sourcePlayerId ?? e.ownerId);
for (const pp of Object.values(battle._perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
return out;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* still standing).
*/
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;
const out = {
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),
};
if (battle && battle.kind === 'unite') {
const left = uniteLeft(battle);
if (left) out.left = left;
}
return out;
}
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;
}