359 lines
22 KiB
JavaScript
359 lines
22 KiB
JavaScript
// 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';
|
||
|
||
// ---- 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) && p.killed <= p.total
|
||
&& 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)
|
||
&& 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 }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||
* (the per-browser loadout of the 干员调配 screen). Structural limits below; the semantic check against the game data
|
||
* (known visible chess, legal skill index for the normal AND the elite status, legal module of the elite) is
|
||
* `checkLoadout` — 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 });
|
||
/** 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 };
|
||
}
|
||
|
||
// ---- 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 `unit.base` (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).
|
||
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | 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]> }}
|
||
*/
|
||
export function unitStatsEntry(u, s = null) {
|
||
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
|
||
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 } : {}),
|
||
};
|
||
}
|
||
|
||
/** 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), $optional: ['token', 'version'] },
|
||
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) },
|
||
'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': {},
|
||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||
'room.loadout': { entries: isLoadoutEntries },
|
||
// 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': {},
|
||
'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) },
|
||
'g.ready': { ready: isBool },
|
||
'g.emote': { id: (v) => EMOTES.includes(v) },
|
||
'g.watch': { fieldId: (v) => isStr(v, 32) },
|
||
'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)
|
||
'b.progress': {
|
||
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (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)),
|
||
$optional: ['leaks', 'bossDmg', 'by', 'done', 'left'],
|
||
},
|
||
'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.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', 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.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;
|
||
}
|