Files
ark-stronghold-protocol/shared/protocol.js
T
sganggs 221e6b8dd2 0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
2026-10-12 14:07:08 +08:00

525 lines
34 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.
// Normative message catalogue (DESIGN §8). Used by server (validation) and client (building requests).
// Every client→server message is `{ t, rid?, ...fields }`. Unknown `t` or invalid fields ⇒ ERR.BAD_MSG.
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
import { isDroppableChess } from './standIn.js';
import { diySlotIds, validateDiyPicks } from './diy.js';
import { cultivatedStats, isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from './potential.js';
// ---- tiny validators -------------------------------------------------------
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
const isStr = (v, max = 64) => typeof v === 'string' && v.length <= max;
const isBool = (v) => typeof v === 'boolean';
const isId = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const isUid = (v) => isInt(v, 1, 2 ** 31);
const isNum = (v, lo = -Infinity, hi = Infinity) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
const optional = (check) => (v) => v === undefined || check(v);
const nullable = (check) => (v) => v === null || v === undefined || check(v);
/** A plain object with at most `max` own keys, every key passing `key` and every value passing `val`. */
const isMap = (v, max, key, val) => {
if (!isPlain(v)) return false;
const keys = Object.keys(v);
if (keys.length > max) return false;
for (const k of keys) if (!key(k) || !val(v[k])) return false;
return true;
};
const isList = (v, max, item) => Array.isArray(v) && v.length <= max && v.every(item);
// ---- client-side combat (DESIGN §14): b.progress / b.result payloads -------------------------------------------
/** Size limits of a b.result payload (the whole frame also obeys the 64 KB inbound limit). */
export const RESULT_LIMITS = Object.freeze({ players: 4, leaked: 400, unitsEnd: 64, unitStats: 160, layerGains: 40, mods: 16, unspawned: 400 });
const BIG = 1e13;
const isStat = (v) => v === undefined || isNum(v, 0, BIG);
const isModVal = (v) => v === null || isNum(v, -BIG, BIG) || isStr(v, 64) || isBool(v);
const isLeak = (l) => isPlain(l) && isId(l.enemyKey)
&& (l.mods === undefined || l.mods === null || isMap(l.mods, RESULT_LIMITS.mods, (k) => isStr(k, 32), isModVal))
&& optional((v) => isNum(v, 0, 1000))(l.lpr) && nullable(isId)(l.sourcePlayerId) && nullable((v) => isStr(v, 16))(l.tag)
&& optional(isBool)(l.counted) && optional(isBool)(l.boss) && optional(isBool)(l.spawned);
const isUnitEnd = (u) => isPlain(u) && nullable(isUid)(u.uid) && isNum(u.hpPct, 0, 1) && isNum(u.sp, 0, 1e5) && isBool(u.alive)
&& optional(isBool)(u.skillActive) && nullable(isId)(u.defId);
const isUnitStat = (u) => isPlain(u) && nullable(isUid)(u.uid) && nullable(isId)(u.defId) && optional((v) => isStr(v, 16))(u.kind)
&& isStat(u.dmg) && isStat(u.kills) && isStat(u.heal) && isStat(u.taken) && isStat(u.attacks);
const isPerPlayer = (p) => isPlain(p) && isInt(p.killed, 0, 1e5) && isInt(p.total, 0, 1e5)
// `resolved` = the HUD capsule's numerator of this player's own field (the round's own scheduled enemies knocked out
// or leaked: server/sim/battle/deploy.js killedInTotal / leakedInTotal, Battle.resolved). `killed` counts every counted
// knock-out — a runtime split child / summon too — and may therefore exceed `total`, which counts only the round's own
// scheduled enemies (server/match/fields.js validateClientResult bounds it against maxTotal instead).
&& optional((x) => isInt(x, 0, 1e5))(p.resolved)
&& isList(p.leaked, RESULT_LIMITS.leaked, isLeak) && isBool(p.perfect)
&& isMap(p.layerGains, RESULT_LIMITS.layerGains, isId, (v) => isNum(v, 0, 1e4))
&& isStat(p.coins) && isStat(p.damageDealt) && isStat(p.bossDamage) && isStat(p.healingDone) && isStat(p.deaths)
&& isList(p.unitsEnd, RESULT_LIMITS.unitsEnd, isUnitEnd)
&& (p.unitStats === undefined || isList(p.unitStats, RESULT_LIMITS.unitStats, isUnitStat));
const isUnspawned = (u) => isPlain(u) && isId(u.enemyKey) && nullable(isId)(u.sourcePlayerId) && nullable((v) => isStr(v, 16))(u.tag)
&& optional((v) => isNum(v, 0, 1e6))(u.time);
/**
* Structural check of a client BattleResult (DESIGN §5.1 / §14 `b.result`): types, ranges and size limits only —
* the semantic checks against the battle's spec are the server's (server/match/fields.js validateClientResult).
*/
export function isBattleResult(v) {
return isPlain(v) && ['cleared', 'timeout', 'forced'].includes(v.reason) && isNum(v.time, 0, 1e5)
&& optional((x) => isInt(x, 0, 1e5))(v.killed) && optional((x) => isInt(x, 0, 1e5))(v.total)
&& optional((x) => isInt(x, 0, 1e5))(v.resolved)
&& isMap(v.perPlayer, RESULT_LIMITS.players, isId, isPerPlayer) && Object.keys(v.perPlayer).length > 0
&& (v.unspawned === undefined || isList(v.unspawned, RESULT_LIMITS.unspawned, isUnspawned))
&& optional((x) => isInt(x, 0, 1e9))(v.errors) && optional((x) => isNum(x, 0, BIG))(v.bossHpLeft);
}
// ---- operator loadout (DESIGN §16): room.loadout { entries } -------------------------------------------------
/**
* `room.loadout { entries, ops? }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
* (the per-browser loadout of the 干员调配 screen); `ops` (0.2.2) = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }`,
* the player's per-operator 潜能 and 练度 (shared/potential.js; absent / missing = 潜能 6, 精英2 Lv.60 — the owner's
* decision of 2026-10-08). Structural limits below; the semantic checks against the game data — known visible chess,
* legal skill index for the normal AND the elite status, legal module of the elite (`checkLoadout`); an operator of the
* 干员调配 roster or the 自选 owned pool (`checkLoadoutOps`) — are used by the server (lobby, match) and by the client to
* sanitise a stored loadout before sending.
*/
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9, ops: 256 });
/** The "no module" choice of an elite (模组: 不装备). */
export const MODULE_NONE = 'none';
const isLoadoutEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'skill' || k === 'module')
&& optional((v) => isInt(v, 0, LOADOUT_LIMITS.skillIndex))(e.skill) && optional(isId)(e.module);
/** Structural check of `room.loadout.entries`. */
export const isLoadoutEntries = (v) => isMap(v, LOADOUT_LIMITS.entries, isId, isLoadoutEntry);
/** Skill indexes a chess record offers (data `skills[]`, DESIGN §16; the default skill alone while data lacks it). */
function skillIndexesOf(c) {
if (!c || typeof c !== 'object') return [];
if (Array.isArray(c.skills) && c.skills.length) {
return [...new Set(c.skills.map((s) => s && s.index).filter((i) => isInt(i, 0, LOADOUT_LIMITS.skillIndex)))].sort((a, b) => a - b);
}
return isInt(c.skill?.index, 0, LOADOUT_LIMITS.skillIndex) ? [c.skill.index] : [];
}
/**
* What the 干员调配 screen may choose for one chess (DESIGN §16).
* skills: skill indexes unlocked at BOTH the normal and the elite status (identical sets in the official data)
* defaultSkill: `defaultSkillIndex` (data: the skills[] entry flagged isDefault, else `skill.index`)
* modules: the elite's modules (uniEquipId…) + 'none'; [] when the chess has no elite record
* defaultModule: the elite's default module (`defaultUniEquipId`), 'none' for module-less elites, null without elite
* @param {any} base normal chess record
* @param {any} [golden] its elite record (null when absent)
* @returns {{ skills: number[], defaultSkill: number|null, modules: string[], defaultModule: string|null }}
*/
export function loadoutOptions(base, golden = null) {
const n = skillIndexesOf(base);
const g = golden ? skillIndexesOf(golden) : null;
const skills = g && g.length ? n.filter((i) => g.includes(i)) : n;
const flagged = Array.isArray(base?.skills) ? base.skills.find((s) => s && s.isDefault && isInt(s.index, 0, LOADOUT_LIMITS.skillIndex)) : null;
let defaultSkill = flagged ? flagged.index : isInt(base?.skill?.index, 0, LOADOUT_LIMITS.skillIndex) ? base.skill.index : null;
if (defaultSkill == null || !skills.includes(defaultSkill)) defaultSkill = skills.length ? skills[0] : defaultSkill;
let modules = [];
let defaultModule = null;
if (golden) {
let def = null;
if (Array.isArray(golden.modules)) {
modules = [...new Set(golden.modules.map((m) => m && m.uniEquipId).filter((id) => isId(id) && id !== MODULE_NONE))];
const d = golden.modules.find((m) => m && m.isDefault && isId(m.uniEquipId));
def = d ? d.uniEquipId : null;
} else if (golden.module && golden.module.active && isId(golden.module.id)) {
modules = [golden.module.id];
}
if (def == null && golden.module && golden.module.active && modules.includes(golden.module.id)) def = golden.module.id;
modules.push(MODULE_NONE);
defaultModule = def || MODULE_NONE;
}
return { skills, defaultSkill, modules, defaultModule };
}
/**
* Semantic check + normalisation of a loadout against the game data (DESIGN §16). Strict: any unknown / hidden / elite
* chess id, illegal skill index or module rejects the whole loadout. Entries equal to the defaults are dropped, the
* rest are stored complete: `{ skill, module }` (module null for a chess without an elite record).
* @param {any} entries `room.loadout.entries`
* @param {(id: string) => any} getChess chess record lookup (normal and golden ids)
* @returns {{ ok: true, loadout: Record<string, { skill: number, module: string|null }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
*/
export function checkLoadout(entries, getChess) {
if (!isLoadoutEntries(entries)) return { error: 'BAD_MSG', detail: 'bad loadout entries' };
const out = {};
for (const id of Object.keys(entries)) {
const e = entries[id];
const base = typeof getChess === 'function' ? getChess(id) : null;
if (!base || base.isGolden || base.visible === false || base.isHidden || base.isDiy || (base.baseId && base.baseId !== id)) {
return { error: 'BAD_TARGET', detail: `unknown chess ${id}` };
}
const golden = base.goldenId ? getChess(base.goldenId) || null : null;
const opt = loadoutOptions(base, golden);
const skill = e.skill ?? opt.defaultSkill;
if (!opt.skills.includes(skill)) return { error: 'BAD_TARGET', detail: `skill ${e.skill} not available for ${id}` };
if (e.module !== undefined && !golden) return { error: 'BAD_TARGET', detail: `${id} has no elite module` };
const module = golden ? (e.module ?? opt.defaultModule) : null;
if (golden && !opt.modules.includes(module)) return { error: 'BAD_TARGET', detail: `module ${e.module} not available for ${id}` };
if (skill === opt.defaultSkill && module === opt.defaultModule) continue;
out[id] = { skill, module };
}
return { ok: true, loadout: out };
}
/**
* The skill index / module a board chess fights with under a (checked) loadout (DESIGN §16 PlayerBattleInput units):
* normal chess → `{ skillIndex, moduleId: null }` (normal chess have no module); elite → `moduleId` = uniEquipId or
* 'none'. Chess the loadout does not mention use their defaults.
* @param {Record<string, { skill: number, module: string|null }> | null | undefined} loadout
* @param {any} chess the piece's chess record (normal or golden)
* @param {(id: string) => any} getChess
* @returns {{ skillIndex: number|null, moduleId: string|null }}
*/
export function resolveLoadout(loadout, chess, getChess) {
if (!chess || typeof chess !== 'object') return { skillIndex: null, moduleId: null };
const baseId = chess.baseId || chess.chessId;
const base = chess.isGolden ? (getChess(baseId) || chess) : chess;
const golden = chess.isGolden ? chess : null;
const opt = loadoutOptions(base, chess.isGolden ? chess : (base.goldenId ? getChess(base.goldenId) || null : null));
const e = loadout && Object.hasOwn(loadout, baseId) ? loadout[baseId] : null;
const skillIndex = e && opt.skills.includes(e.skill) ? e.skill : opt.defaultSkill;
let moduleId = null;
if (golden) moduleId = e && opt.modules.includes(e.module) ? e.module : opt.defaultModule;
return { skillIndex, moduleId };
}
/** One `room.loadout.ops` entry: `{ potential?: 1–6, cultivate?: 0–3 }`, at least one of them. */
const isOpsEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'potential' || k === 'cultivate')
&& optional(isPotential)(e.potential) && optional(isCultivate)(e.cultivate);
/** Structural check of `room.loadout.ops` (0.2.2): a map of ≤ 256 charIds → `{ potential?, cultivate? }`. */
export const isLoadoutOps = (v) => isMap(v, LOADOUT_LIMITS.ops, isId, isOpsEntry);
/**
* The operators a player sets a potential / 练度 for (0.2.2): the charIds of the 干员调配 roster — the visible normal chess
* (checkLoadout's targets; a hidden chess shares its visible twin's charId: 锡人, 耶拉 …) — and the owned 6★ 自选 picks
* (data/backups.json `diy.ownedPool`). A PRESET (特许) operator the player does not own is set by hand (潜能 1, 未精英化:
* the official 「未持有的特许按1潜」 and +0 %); a 补位 stand-in or a prototype pick takes neither.
* @param {Record<string, any>|null|undefined} chess data/chess.json
* @param {any} [backups] data/backups.json
* @returns {Set<string>}
*/
export function cultivationCharIds(chess, backups = null) {
const out = new Set();
for (const c of Object.values(chess && typeof chess === 'object' ? chess : {})) {
if (c && !c.isGolden && !c.isDiy && c.visible !== false && !c.isHidden && (!c.baseId || c.baseId === c.chessId) && isId(c.charId)) out.add(c.charId);
}
for (const id of Array.isArray(backups?.diy?.ownedPool) ? backups.diy.ownedPool : []) if (isId(id)) out.add(id);
return out;
}
/**
* Semantic check + normalisation of `room.loadout.ops` (0.2.2) — strict like checkLoadout: an operator `isOperator` does
* not know (cultivationCharIds) rejects the whole message. Entries equal to the defaults (潜能 6, 练度 3) are dropped, the
* rest stored complete `{ potential, cultivate }`. `undefined` / `null` = no settings (`{}`).
* @param {any} ops
* @param {(charId: string) => boolean} isOperator
* @returns {{ ok: true, ops: Record<string, { potential: number, cultivate: number }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
*/
export function checkLoadoutOps(ops, isOperator) {
if (ops == null) return { ok: true, ops: {} };
if (!isLoadoutOps(ops)) return { error: 'BAD_MSG', detail: 'bad operator settings' };
const out = {};
for (const id of Object.keys(ops)) {
if (typeof isOperator !== 'function' || !isOperator(id)) return { error: 'BAD_TARGET', detail: `unknown operator ${id}` };
const potential = ops[id].potential ?? POTENTIAL_DEFAULT;
const cultivate = ops[id].cultivate ?? CULTIVATE_DEFAULT;
if (potential === POTENTIAL_DEFAULT && cultivate === CULTIVATE_DEFAULT) continue;
out[id] = { potential, cultivate };
}
return { ok: true, ops: out };
}
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
/**
* `room.ownership { notOwned }`: the base chess ids the player marked as not owned on the 干员持有 screen (the
* per-browser setting next to 干员调配; default: every operator owned ⇒ []). Such a chess keeps its identity (name,
* bonds, 特质, tier, price, merge) and fights as its official stand-in (shared/standIn.js standInRecord). Structural
* limit below; the semantic check (`checkNotOwned`) is LENIENT, unlike checkLoadout: an id that is not a droppable chess
* (unknown, elite, PRESET / 自选, a stale id of another build) is dropped, never the whole list.
*/
export const OWNERSHIP_LIMITS = Object.freeze({ notOwned: 160 });
/** Structural check of `room.ownership.notOwned`: an array of ≤ 160 ids. */
export const isNotOwnedList = (v) => isList(v, OWNERSHIP_LIMITS.notOwned, isId);
/**
* Semantic check + normalisation of a not-owned list against the game data: keeps the ids of droppable chess
* (shared/standIn.js isDroppableChess — NORMAL base chess with a stand-in), deduplicated and sorted; drops the rest.
* Used by the server (lobby, match) and by the client before it sends or imports a list.
* @param {any} list `room.ownership.notOwned`
* @param {(id: string) => any} getChess chess record lookup
* @returns {{ ok: true, notOwned: string[], dropped: number } | { error: 'BAD_MSG', detail: string }}
*/
export function checkNotOwned(list, getChess) {
if (!isNotOwnedList(list)) return { error: 'BAD_MSG', detail: 'bad notOwned list' };
const keep = new Set();
for (const id of list) {
const c = typeof getChess === 'function' ? getChess(id) : null;
if (c && c.chessId === id && isDroppableChess(c)) keep.add(id);
}
const notOwned = [...keep].sort();
return { ok: true, notOwned, dropped: list.length - notOwned.length };
}
// ---- 自选编队 (0.2.0 DIY): room.diy { picks } ------------------------------------------------------------------------
/**
* `room.diy { picks }`: the player's 自选编队 — `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the
* four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6; shared/diy.js). An out-of-match setting
* like 干员持有: stored per session / seat, a match takes the picks its seat had at its start. Structural limit below
* (room for more slots in a later season); the semantic check (`checkDiyPicks`) is LENIENT, like checkNotOwned: an
* illegal pick — not a pick of the slot's tier, an operator without a kit, a prototype off its locked skill, an unknown
* skill / module, the same operator twice in a tier, an owned operator in a second slot, an unknown slot — is dropped,
* never the whole roster; only malformed input is BAD_MSG.
*/
export const DIY_LIMITS = Object.freeze({ slots: 8 });
const isDiyPickWire = (p) => p === null || (isPlain(p) && isId(p.charId)
&& nullable((v) => isInt(v, 0, 9))(p.skillIndex) && nullable(isId)(p.uniEquipId));
/** Structural check of `room.diy.picks`: a map of ≤ 8 slot ids → a pick `{ charId, skillIndex?, uniEquipId? }` or null. */
export const isDiyPicks = (v) => isMap(v, DIY_LIMITS.slots, isId, isDiyPickWire);
/**
* Semantic check + normalisation of a 自选 roster against the game data (`{ chess, backups }` or a sim DataSource) and
* the kit registry (`kitted`: server/sim/content/kits/index.js KITTED_CHARS — an operator without a kit is never fielded):
* the slots are taken in data order (tier 5, then tier 6), and each pick is kept when the roster so far plus it still
* passes shared/diy.js validateDiyPicks — so the result always passes it, and of two picks that clash (one owned operator
* in two slots, one operator twice in a tier) the first slot keeps it. Kept picks are complete
* (`{ charId, skillIndex, uniEquipId }`: a prototype's locked selection, uniEquipId null = no module).
* @param {any} picks `room.diy.picks`
* @param {{ data: any, kitted?: Iterable<string>|((id: string) => boolean)|null }} opts
* @returns {{ ok: true, picks: Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>, dropped: number }
* | { error: 'BAD_MSG', detail: string }}
*/
export function checkDiyPicks(picks, { data, kitted = null } = { data: null }) {
if (!isDiyPicks(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
const slots = diySlotIds(data);
/** @type {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>} */
const kept = {};
let dropped = 0;
for (const id of Object.keys(picks)) if (picks[id] != null && !slots.includes(id)) dropped++;
for (const slotId of slots) {
const pick = Object.hasOwn(picks, slotId) ? picks[slotId] : null;
if (pick == null) continue;
const res = validateDiyPicks({ ...kept, [slotId]: pick }, { data, kitted });
if ('ok' in res) kept[slotId] = res.picks[slotId];
else dropped++;
}
return { ok: true, picks: kept, dropped };
}
// ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats ---------------------
const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const round1 = (v) => Math.round(v * 10) / 10;
const round2 = (v) => Math.round(v * 100) / 100;
/** Attack interval (s) of a stats object: its own `interval`, else bat × 100 / aspd (null without an attack time). */
const intervalOf = (x) => {
if (Number.isFinite(x.interval) && x.interval > 0) return round2(x.interval);
const bat = fin(x.bat, 0);
const aspd = fin(x.aspd, 100) > 0 ? fin(x.aspd, 100) : 100;
return bat > 0 ? round2((bat * 100) / aspd) : null;
};
const statView = (x) => ({
maxHp: Math.round(fin(x.maxHp)), atk: Math.round(fin(x.atk)), def: Math.round(fin(x.def)), res: round1(fin(x.res)),
interval: intervalOf(x), blockCnt: Math.max(0, Math.round(fin(x.blockCnt))), moveSpeed: round2(fin(x.moveSpeed)),
});
/**
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
* or the last ones the sim computed) next to its own numbers — `unit.base` with its 练度 multiplier (`unit.cultMul`,
* 0.2.2: part of the operator's own numbers, as on the record cards; no buffs) — max HP, ATK, DEF, RES, attack interval
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
* (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included, not a kit's
* target-selection grid (the sim's `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌
* S3's 4-11 never reached the card). `dir`: the unit's facing now (UP / RIGHT / DOWN / LEFT; UnitInfo.dir is the facing
* at send time and the snapshot tuples carry none) — the detail card's range overlay rotates by it (GitHub PR #281).
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any, dir?: string } | null} u
* @param {any} [s] aggregated stats (missing ⇒ the base)
* @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number,
* def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number,
* base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number },
* range?: Array<[number, number]>, dir?: string }}
*/
export function unitStatsEntry(u, s = null) {
const own = u && u.base && typeof u.base === 'object' ? u.base : {};
const base = u && u.cultMul ? cultivatedStats(own, u.cultMul) : own;
const cur = s && typeof s === 'object' ? s : base;
const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid)
? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]])
: null;
return {
id: Number.isInteger(u?.id) ? u.id : null,
uid: Number.isInteger(u?.uid) ? u.uid : null,
defId: typeof u?.defId === 'string' ? u.defId : null,
hp: Math.max(0, Math.round(fin(u?.hp))),
alive: u?.alive !== false,
...statView(cur),
base: statView(base),
...(range ? { range } : {}),
...(isDir(u?.dir) ? { dir: u.dir } : {}),
// the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced
silenced: !!(cur.flags && cur.flags.silence),
};
}
/** Deploy directions (DESIGN §3, research 09 §1.2; the same list as server/sim/dir.js DIRS). */
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
const isDir = (v) => DIRS.includes(v);
const target = (v) => {
if (!v || typeof v !== 'object') return false;
if (v.area === 'board') return isInt(v.row, 0, GEO.ROWS - 1) && isInt(v.col, 0, GEO.COLS - 1);
if (v.area === 'hand') return isInt(v.idx, 0, GEO.HAND_SIZE - 1);
return false;
};
/** @type {Record<string, Record<string, (v:any)=>boolean> & { $optional?: string[] }>} */
export const C2S = {
// session & lobby
hello: { name: (v) => isStr(v, NAME_MAX_LEN) && v.trim().length > 0, token: (v) => v == null || isStr(v, 64), version: (v) => v == null || isInt(v, 0, 1e6),
noReplace: isBool, claimAt: (v) => isNum(v, 0, Number.MAX_SAFE_INTEGER), $optional: ['token', 'version', 'noReplace', 'claimAt'] },
ping: { c: (v) => typeof v === 'number' && Number.isFinite(v) },
'room.create': { mode: (v) => v === 'solo' || v === 'coop', difficulty: (v) => DIFFICULTIES.includes(v) },
'room.join': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) },
'room.leave': {},
'room.ready': { ready: isBool },
'room.setDifficulty': { difficulty: (v) => DIFFICULTIES.includes(v) },
// the co-op room option 「AI 队友最后选择」 (GitHub #338; host, before the match): the strategy and 机变 drafts order every
// human seat before every AI seat (server/match/match/phases.js humansFirst); room.state.aiPicksLast
'room.setAiPicksLast': { on: isBool },
'room.addBot': {},
'room.removeBot': { seat: (v) => isInt(v, 0, MAX_SEATS - 1) },
// the host removes another human before the match (server/lobby.js kick; community report #17); playerId = the one the
// host confirmed — a seat that changed hands meanwhile is refused
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
'room.start': {},
'room.rerollSetup': { setupRevision: (v) => isInt(v, 0, 2 ** 31) },
'room.cancelReroll': { voteId: (v) => isInt(v, 1, 2 ** 31) },
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
'room.ownership': { notOwned: isNotOwnedList },
// 自选编队 (0.2.0 DIY): the player's DIY slot picks; stored per session / seat like room.ownership (a match takes the
// picks its seat had when it started; during a match they are stored for the next one: ROOM_STARTED)
'room.diy': { picks: isDiyPicks },
// spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats —
// in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets
// room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat.
'room.spectate': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) },
'room.removeSpectator': { playerId: isId },
// match
'g.infoReady': { setupRevision: (v) => isInt(v, 0, 2 ** 31), $optional: ['setupRevision'] },
'g.rerollVote': { voteId: (v) => isInt(v, 1, 2 ** 31), agree: isBool },
'g.band': { bandId: isId },
'g.bandSkip': {},
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
// free (Match.timeoutBand); absent / null clears it
'g.bandFocus': { bandId: nullable(isId), $optional: ['bandId'] },
'g.buy': { slot: (v) => isInt(v, 0, 15) },
'g.refresh': {},
'g.freeze': {},
'g.levelUp': {},
'g.sell': { uid: isUid },
// dir: the facing chosen on the deploy wheel for a board target (absent ⇒ RIGHT; the piece's own tile ⇒ re-orient)
'g.move': { uid: isUid, to: target, dir: isDir, $optional: ['dir'] },
// replaceUid: with both of the target's slots used, the equipped item the replace dialog picked (research 09 §1.2
// UseEquipUp.unloadInstId; absent ⇒ the oldest; not one of the target's items ⇒ BAD_TARGET)
'g.equip': { itemUid: isUid, targetUid: isUid, replaceUid: nullable(isUid), $optional: ['replaceUid'] },
'g.art': { itemUid: isUid, row: (v) => isInt(v, 0, GEO.ROWS - 1), col: (v) => isInt(v, 0, GEO.COLS - 1), dir: isDir, $optional: ['dir'] },
'g.destroy': { uid: isUid },
'g.reward': { idx: (v) => isInt(v, 0, 5) },
'g.choice': { idx: (v) => isInt(v, 0, 5), choiceId: isId, $optional: ['choiceId'] },
'g.ready': { ready: isBool },
'g.emote': { id: (v) => EMOTES.includes(v) },
// playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
// spectator seat follows from then on (Match.watchPref; community report of 2026-10-06, item 56)
'g.watch': { fieldId: (v) => isStr(v, 32), playerId: isId, $optional: ['playerId'] },
'g.autoplay': { on: isBool },
// solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the
// browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused
'g.pause': { on: isBool },
// the stats the own board's units start their next battle with (user playtest #4 item 7; prep phases): answered by
// the push m.unitStats { seq, round, units: [unitStatsEntry] }; `seq` is echoed so the client keeps the newest answer
'g.unitStats': { seq: (v) => isInt(v, 0, 2 ** 31), $optional: ['seq'] },
'g.leave': {},
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
// uniteLeft; user playtest #6 item 7 — the leakers' live counter). `resolved` = the HUD capsule's numerator: the
// field's own scheduled enemies that were knocked out **or leaked** (official: 漏一个 1/3, 打死一个 2/3,
// 打死会分裂的 3/3 — Battle.leakedInTotal; runtime splits / summons stay out of both parts of the capsule). A boss / hidden field adds
// `leaksBy` = { [playerId]: the cumulative LP the enemies that reached that player's goal cost } — `leaks` minus its
// sum is the leader's own "扣除目标生命" effects; the server holds the b.result's leaked lists to it (Match._bossLeaksAgree)
'b.progress': {
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
resolved: (v) => isInt(v, 0, 1e5),
leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG),
by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool,
left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)),
leaksBy: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, 1e6)),
$optional: ['resolved', 'leaks', 'bossDmg', 'by', 'done', 'left', 'leaksBy'],
},
'b.result': { battleId: isId, result: isBattleResult },
};
// Server → client message types (documentation + client dispatch table keys).
export const S2C = [
'welcome', 'ok', 'error', 'pong',
'room.state', 'room.closed',
'm.public', 'm.private', 'm.field', 'm.toast', 'm.ticker', 'm.emote', 'm.result',
// m.layer { playerId, bondId, add } — own live prep layer gain; ephemeral, never replayed on resync.
'm.layer',
// m.unitStats { seq, round, units: [unitStatsEntry] } — the answer to g.unitStats (the requester only)
'm.unitStats',
// client-side combat (DESIGN §14): b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed,
// speed, watch? } · b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative boss damage counted } } ·
// b.end { battleId, fieldId, reason }
'b.start', 'b.pool', 'b.end',
// server-run combat streaming (legacy / SP_COMBAT=server only)
'b.snap', 'b.ev',
];
/**
* Validate a decoded client message. Returns `null` when valid, otherwise a short reason string.
* Extra unknown fields are ignored (not copied by handlers).
*/
export function validateC2S(msg) {
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return 'not an object';
const spec = typeof msg.t === 'string' && Object.hasOwn(C2S, msg.t) ? C2S[msg.t] : null;
if (!spec) return `unknown type ${String(msg.t).slice(0, 32)}`;
if (msg.rid != null && !isInt(msg.rid, 0, 2 ** 31)) return 'bad rid';
const optional = spec.$optional || [];
for (const [k, check] of Object.entries(spec)) {
if (k === '$optional') continue;
const v = msg[k];
if (v === undefined && optional.includes(k)) continue;
if (!check(v)) return `bad field ${k}`;
}
return null;
}
// Event tuple kinds inside `b.ev` (DESIGN §8.2).
export const EV = Object.freeze({
SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', ENGAGE: 'engage', DIE: 'die', LEAK: 'leak',
STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy',
});
/**
* The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies/helpers.js
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a 傀儡师's 替身,
* sim professions.js; a string is that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
* the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab
* backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples.
*/
export function fxForm(ev) {
if (!Array.isArray(ev) || ev[0] !== EV.FX) return undefined;
const x = ev[4];
if (!x || typeof x !== 'object' || x.id == null || !Object.hasOwn(x, 'form')) return undefined;
return typeof x.form === 'string' ? x.form : null;
}