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).
275 lines
16 KiB
JavaScript
275 lines
16 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 };
|
|
}
|
|
|
|
/** 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) },
|
|
'room.start': {},
|
|
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
|
'room.loadout': { entries: isLoadoutEntries },
|
|
|
|
// match
|
|
'g.infoReady': {},
|
|
'g.band': { bandId: isId },
|
|
'g.bandSkip': {},
|
|
'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 },
|
|
'g.leave': {},
|
|
|
|
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle
|
|
'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,
|
|
$optional: ['leaks', 'bossDmg', 'by', 'done'],
|
|
},
|
|
'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',
|
|
// 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',
|
|
});
|