Files
ark-stronghold-protocol/shared/protocol.js
T

361 lines
22 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';
// ---- 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 } : {}),
// 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), $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/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;
}