卫戍协议:盟约 web remake — initial import

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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
// Shared enums & constants (server + browser). Pure ESM, no Node APIs.
export const PROTOCOL_VERSION = 1;
export const MAX_SEATS = 4;
export const ROOM_CODE_LEN = 4;
export const NAME_MAX_LEN = 12;
export const DIFFICULTIES = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
export const DIFFICULTY_NAMES = { FUNNY: '标准模拟', NORMAL: '险境模拟', HARD: '绝境模拟', ABYSS: '终极模拟' };
export const DIFFICULTY_COLORS = { FUNNY: '#f6a329', NORMAL: '#e85a1a', HARD: '#e73118', ABYSS: '#ff0024' };
// modeId in data/config.json = `mode_${type}_${difficulty.toLowerCase()}` with type single|multi
export const modeIdFor = (roomMode, difficulty) =>
`mode_${roomMode === 'solo' ? 'single' : 'multi'}_${difficulty.toLowerCase()}`;
export const PHASE = Object.freeze({
LOBBY: 'LOBBY',
INFO_CHECK: 'INFO_CHECK',
BAND_DRAFT: 'BAND_DRAFT',
BATTLE_CHECK: 'BATTLE_CHECK',
ROUND_START: 'ROUND_START',
SP_DRAFT: 'SP_DRAFT',
PREP: 'PREP',
COMBAT: 'COMBAT',
UNITE: 'UNITE',
SETTLE: 'SETTLE',
FINAL_ASSAULT: 'FINAL_ASSAULT',
HIDDEN_CORE: 'HIDDEN_CORE',
RESULT: 'RESULT',
});
export const PHASE_NAMES = {
LOBBY: '等待中', INFO_CHECK: '确认本局信息', BAND_DRAFT: '选择策略', BATTLE_CHECK: '协议启动',
ROUND_START: '回合开始', SP_DRAFT: '机变阶段', PREP: '休整期', COMBAT: '作战中', UNITE: '联防阶段',
SETTLE: '结算', FINAL_ASSAULT: '最终攻势', HIDDEN_CORE: '隐秘核心', RESULT: '模拟结束',
};
// Board geometry on the 19x21 stage grid (row 0 = bottom). See DESIGN §3.
export const GEO = Object.freeze({
ROWS: 19, COLS: 21,
FIELD: { r0: 9, r1: 12, c0: 2, c1: 10 }, // own deployable board region
NORMAL_RECT: { r0: 9, r1: 12, c0: 0, c1: 10 }, // simulation rect for a normal battle
UNITE_RECT: { r0: 9, r1: 12, c0: 0, c1: 20 },
BOSS_RECT: { r0: 0, r1: 5, c0: 0, c1: 20 },
HAND_ROW: 7, HAND_SIZE: 10, // hand slot idx = col 0..9
TEMP_ROW: 8, TEMP_C0: 4, TEMP_SIZE: 5, // temp slot idx 0..4 = cols 4..8
PARTNER_COL_OFFSET: 8,
});
export const AREA = Object.freeze({ BOARD: 'board', HAND: 'hand', TEMP: 'temp', OUTSIDE: 'outside' });
export const PIECE_KIND = Object.freeze({ CHESS: 'chess', ITEM: 'item', TOKEN: 'token' });
// Snapshot unit flag bits (DESIGN §8.2)
export const UF = Object.freeze({
BLOCKED: 1, STUNNED: 2, FROZEN: 4, STEALTH: 8, SKILL: 16, SHIELD: 32, INVULN: 64, COLD: 128, SLEEP: 256, FLYING: 512,
});
export const ANIM = Object.freeze({ IDLE: 0, MOVE: 1, ATTACK: 2, SKILL: 3, DIE: 4, STUN: 5, DEPLOY: 6 });
export const ERR = Object.freeze({
BAD_MSG: 'BAD_MSG', // malformed / unknown message
RATE: 'RATE', // rate limited
NOT_IN_ROOM: 'NOT_IN_ROOM',
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
ROOM_FULL: 'ROOM_FULL',
ROOM_STARTED: 'ROOM_STARTED',
NOT_HOST: 'NOT_HOST',
NOT_READY: 'NOT_READY',
WRONG_PHASE: 'WRONG_PHASE',
NO_FUNDS: 'NO_FUNDS',
HAND_FULL: 'HAND_FULL',
BOARD_FULL: 'BOARD_FULL',
BAD_TILE: 'BAD_TILE',
BAD_TARGET: 'BAD_TARGET',
SOLD_OUT: 'SOLD_OUT',
MAX_LEVEL: 'MAX_LEVEL',
NOT_YOUR_TURN: 'NOT_YOUR_TURN',
ALREADY: 'ALREADY',
TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY',
ELIMINATED: 'ELIMINATED',
INTERNAL: 'INTERNAL',
});
export const ERR_TEXT = {
BAD_MSG: '无效的请求', RATE: '操作过于频繁', NOT_IN_ROOM: '你不在房间中', ROOM_NOT_FOUND: '未找到该同盟密钥对应的房间',
ROOM_FULL: '房间已满', ROOM_STARTED: '模拟已开始', NOT_HOST: '只有房主可以操作', NOT_READY: '仍有玩家未就绪',
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限',
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级',
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰',
INTERNAL: '服务器内部错误',
};
// ---- Emotes (交流, research 09 §4) -----------------------------------------------------------------------------
// The 36 official in-match emotes: display_meta_table emoticonData, scene AUTOCHESS_BATTLE, 6 themes × 6, one wheel
// page per theme in activity_table autoChessData.enabledEmoticonThemeIdList order, emotes by sortId. `g.emote { id }`
// / `m.emote { playerId, id }` carry the official emoji id. Generated reference: data/emotes.json
// (tools/build-emotes.mjs); test/ui/emotes.test.js keeps this table identical to it.
// Official emotes have no text (desc is null): `label` is ours and only ever an aria-label, never displayed.
// Art: extracted from a local client (tools/local-extract) to /assets/local/emoticon/<dir>/<picId>.png and listed in
// data/local-assets.json group `emoticon/<dir>`; the UI shows a neutral glyph when it is absent. The picId is not
// derived from the id (autochess_battle_fooldoctor_03…06 → pic_fooldoctor_04/05/06/08_battle).
const emo = (id, sortId, picId, label) => Object.freeze({ id, sortId, picId, label });
export const EMOTE_THEMES = Object.freeze([
{ themeId: 'emoticon_autochess_basic', dir: 'basic', sortId: 100000, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_happy', 1001, 'pic_happy_battle', '开心'),
emo('autochess_battle_scared', 1002, 'pic_scared_battle', '害怕'),
emo('autochess_battle_sorry', 1003, 'pic_sorry_battle', '对不起'),
emo('autochess_battle_thanks', 1004, 'pic_thanks_battle', '谢谢'),
emo('autochess_battle_thinking', 1005, 'pic_thinking_battle', '思考'),
emo('autochess_battle_nice_cooperate', 1006, 'pic_cooperate_battle', '合作愉快'),
] },
{ themeId: 'emoticon_originium_slug', dir: 'slug', sortId: 1001, isBasic: false, name: '表情套组:虫动', emotes: [
emo('slug_autochess_battle_nice_work', 2001, 'pic_nice_work_battle', '合作愉快!'),
emo('slug_autochess_battle_thanks', 2002, 'pic_thanks_battle', '谢谢!'),
emo('slug_autochess_battle_sorry', 2003, 'pic_sorry_battle', '对不起!'),
emo('slug_autochess_battle_bye', 2004, 'pic_bye_battle', '再见!'),
emo('slug_autochess_battle_distrust', 2005, 'pic_distrust_battle', '???'),
emo('slug_autochess_battle_very_soon', 2006, 'pic_very_soon_battle', '很快就好!'),
] },
{ themeId: 'emoticon_autochess_basic_2', dir: 'basic_2', sortId: 100001, isBasic: true, name: '表情套组:卫戍协议', emotes: [
emo('autochess_battle_noproblem', 1007, 'pic_noproblem_battle', '没问题!'),
emo('autochess_battle_respect', 1008, 'pic_respect_battle', '敬礼!'),
emo('autochess_battle_call', 1009, 'pic_call_battle', '欢呼!'),
emo('autochess_battle_playingcool', 1010, 'pic_playingcool_battle', '酷!'),
emo('autochess_battle_sad', 1011, 'pic_sad_battle', '伤心'),
emo('autochess_battle_dying', 1012, 'pic_dying_battle', '快死了'),
] },
{ themeId: 'emoticon_foolsday_doctor', dir: 'fooldoctor', sortId: 1002, isBasic: false, name: '表情套组:博士士', emotes: [
emo('autochess_battle_fooldoctor_01', 1020, 'pic_fooldoctor_01_battle', '博士士 1'),
emo('autochess_battle_fooldoctor_02', 1021, 'pic_fooldoctor_02_battle', '博士士 2'),
emo('autochess_battle_fooldoctor_03', 1022, 'pic_fooldoctor_04_battle', '博士士 3'),
emo('autochess_battle_fooldoctor_04', 1023, 'pic_fooldoctor_05_battle', '博士士 4'),
emo('autochess_battle_fooldoctor_05', 1024, 'pic_fooldoctor_06_battle', '博士士 5'),
emo('autochess_battle_fooldoctor_06', 1025, 'pic_fooldoctor_08_battle', '博士士 6'),
] },
{ themeId: 'emoticon_foolsday_amiya', dir: 'foolamiya', sortId: 1003, isBasic: false, name: '表情套组:米米子', emotes: [
emo('autochess_battle_foolamiya_01', 1040, 'pic_foolamiya_01_battle', '米米子 1'),
emo('autochess_battle_foolamiya_02', 1041, 'pic_foolamiya_02_battle', '米米子 2'),
emo('autochess_battle_foolamiya_03', 1042, 'pic_foolamiya_03_battle', '米米子 3'),
emo('autochess_battle_foolamiya_04', 1043, 'pic_foolamiya_04_battle', '米米子 4'),
emo('autochess_battle_foolamiya_05', 1044, 'pic_foolamiya_05_battle', '米米子 5'),
emo('autochess_battle_foolamiya_06', 1045, 'pic_foolamiya_06_battle', '米米子 6'),
] },
{ themeId: 'emoticon_foolsday_wisdel', dir: 'foolwisdel', sortId: 1004, isBasic: false, name: '表情套组:维维美', emotes: [
emo('autochess_battle_foolwisdel_01', 1060, 'pic_foolwisdel_01_battle', '维维美 1'),
emo('autochess_battle_foolwisdel_02', 1061, 'pic_foolwisdel_02_battle', '维维美 2'),
emo('autochess_battle_foolwisdel_03', 1062, 'pic_foolwisdel_03_battle', '维维美 3'),
emo('autochess_battle_foolwisdel_04', 1063, 'pic_foolwisdel_04_battle', '维维美 4'),
emo('autochess_battle_foolwisdel_05', 1064, 'pic_foolwisdel_05_battle', '维维美 5'),
emo('autochess_battle_foolwisdel_06', 1065, 'pic_foolwisdel_06_battle', '维维美 6'),
] },
].map((t) => Object.freeze({ ...t, emotes: Object.freeze(t.emotes) })));
/** Every emote with its theme: `{ id, sortId, picId, label, themeId, dir }`, in wheel order. */
export const EMOTE_CATALOG = Object.freeze(EMOTE_THEMES.flatMap((t) => t.emotes.map((e) => Object.freeze({ ...e, themeId: t.themeId, dir: t.dir }))));
/** The 36 official emote ids (protocol whitelist: `EMOTES.includes(id)`). */
export const EMOTES = Object.freeze(EMOTE_CATALOG.map((e) => e.id));
const EMOTE_INDEX = new Map(EMOTE_CATALOG.map((e) => [e.id, e]));
/** Catalog record of an emote id, or null (safe for any input, including '__proto__'). */
export const emoteInfo = (id) => (typeof id === 'string' && EMOTE_INDEX.get(id)) || null;
// id-keyed maps without a prototype: a wire id like '__proto__' / 'toString' looks up undefined, never an Object method
const emoteMap = (pick) => Object.freeze(Object.assign(Object.create(null), Object.fromEntries(EMOTE_CATALOG.map((e) => [e.id, pick(e)]))));
/** Emote id → theme id. */
export const EMOTE_THEME = emoteMap((e) => e.themeId);
/** Emote id → our aria-label (never displayed). */
export const EMOTE_LABEL = emoteMap((e) => e.label);
/** data/local-assets.json group of an emote's art (`emoticon/<dir>`), or null. */
export const emoteArtGroup = (id) => { const e = emoteInfo(id); return e ? `emoticon/${e.dir}` : null; };
/** Canonical URL of an emote's extracted art (`/assets/local/emoticon/<dir>/<picId>.png`), or null for unknown ids. */
export const emoteArtPath = (id) => { const e = emoteInfo(id); return e ? `/assets/local/emoticon/${e.dir}/${e.picId}.png` : null; };
export const EMOTE_COOLDOWN_MS = 1000; // activity_table autoChessData.constData.chatCD (s)
export const EMOTE_BUBBLE_MS = 3000; // constData.chatTime (s): how long a bubble stays up
+117
View File
@@ -0,0 +1,117 @@
// shared/loadoutRecord.js — operator loadouts (DESIGN §16, DATA.md §2.2): a data/chess.json record as the selected
// skill / module make it. Pure ESM shared by the simulation (server/sim/simdata.js re-exports it: getChess(id, loadout)
// builds unit defs from it) and the client UI (the detail card shows the stats / 特性 / talents the unit fights with —
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait). One implementation, so
// the card and the battle never disagree. (Which choices a player may make: shared/protocol.js loadoutOptions.)
/**
* Resolve a loadout against a chess record.
* @param {object|null} rec data/chess.json record
* @param {{ skillIndex?: number, moduleId?: string, skill?: number, module?: string }|null} [loadout]
* @returns {{ skillIndex: number|null, moduleId: string|null, skillIsDefault: boolean, moduleIsDefault: boolean,
* isDefault: boolean }|null} null without a record; `moduleId` null for chess without module choices
*/
export function resolveRecordLoadout(rec, loadout = null) {
if (!rec || typeof rec !== 'object') return null;
const skills = Array.isArray(rec.skills) ? rec.skills : null;
const defSkill = rec.skill && Number.isInteger(rec.skill.index) ? rec.skill.index : (skills?.find((s) => s && s.isDefault)?.index ?? null);
const lo = loadout && typeof loadout === 'object' ? loadout : {};
const wantSkill = lo.skillIndex ?? lo.skill;
const skillIndex = skills && Number.isInteger(wantSkill) && skills.some((s) => s && s.index === wantSkill) ? wantSkill : defSkill;
const mods = Array.isArray(rec.modules) ? rec.modules : null;
const defMod = mods ? (mods.find((m) => m && m.isDefault)?.uniEquipId ?? 'none') : null;
const wantMod = lo.moduleId ?? lo.module;
const moduleId = mods && (wantMod === 'none' || (typeof wantMod === 'string' && mods.some((m) => m && m.uniEquipId === wantMod))) ? wantMod : defMod;
const skillIsDefault = skillIndex === defSkill;
const moduleIsDefault = moduleId === defMod;
return { skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault: skillIsDefault && moduleIsDefault };
}
const clean6 = (v) => (typeof v !== 'number' || !Number.isFinite(v) || Number.isInteger(v) || Math.abs(v) >= 1e6 ? v : Math.round(v * 1e6) / 1e6);
/** Stats with a module: the no-module `statsBase` + the module's flat `attr` (same arithmetic as tools/build-data.mjs). */
export function composeStats(statsBase, attr) {
const s = { ...(statsBase || {}) };
for (const [f, v] of Object.entries(attr || {})) s[f] = clean6((s[f] || 0) + v);
return s;
}
/**
* Talents with a module: apply ModuleRecord.talentChanges to the no-module talents — the merge rule of
* tools/build-data.mjs mergeTalentChanges (override of an existing index: module values win, base keys the module does
* not restate are kept; otherwise appended; empty placeholders dropped).
*/
export function composeTalents(base, changes) {
const talents = (base || []).map((t) => ({ ...t }));
for (const ch of changes || []) {
const { talentIndex, ...rest } = ch;
const rec = { index: talentIndex, ...rest, fromModule: true };
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
if (at >= 0) {
const old = talents[at];
talents[at] = {
...rec,
name: rec.name || old.name, desc: rec.desc ?? old.desc, descRaw: rec.descRaw ?? old.descRaw,
bb: { ...old.bb, ...rec.bb }, bbStr: { ...old.bbStr, ...rec.bbStr },
rangeGrid: rec.rangeGrid || old.rangeGrid, tokenKey: rec.tokenKey || old.tokenKey,
hidden: old.hidden && rec.hidden,
};
} else {
talents.push(rec);
}
}
return talents.filter((t) => t.name || t.desc || Object.keys(t.bb || {}).length || t.tokenKey);
}
/**
* The chess record as the selected loadout makes it (a new object; the input is never mutated): `skill` = the selected
* SkillRecord; golden chess with a non-default module choice: `stats` = statsBase + module attr, `trait` = the module's
* traitOverride or traitBase, `talents` = talentsBase + talentChanges, `module` = the chosen module (`active:false`,
* id null for 'none'). A talent that summons through a container token (凛御银灰) follows the selected skill's token.
* The default loadout returns `rec` itself.
* @param {object} rec data/chess.json record
* @param {object} lo resolveRecordLoadout(rec, …)
*/
export function loadoutRecord(rec, lo) {
if (!rec || !lo || lo.isDefault) return rec;
const out = { ...rec };
if (!lo.moduleIsDefault && Array.isArray(rec.modules)) {
const m = lo.moduleId === 'none' ? null : rec.modules.find((x) => x.uniEquipId === lo.moduleId) ?? null;
out.stats = composeStats(rec.statsBase ?? rec.stats, m ? m.attr : null);
out.trait = (m && m.traitOverride) || rec.traitBase || rec.trait;
out.talents = composeTalents(rec.talentsBase ?? rec.talents, m ? m.talentChanges : null);
out.module = m
? { id: m.uniEquipId, name: m.name ?? null, type: m.typeName ?? null, level: m.level ?? rec.module?.level ?? 0, active: true }
: { id: null, name: null, type: null, level: rec.module?.level ?? 0, active: false };
}
if (!lo.skillIsDefault && Array.isArray(rec.skills)) {
const s = rec.skills.find((x) => x.index === lo.skillIndex);
if (s) {
out.skill = s;
if (rec.assets) out.assets = { ...rec.assets, skillIcon: s.iconId ?? rec.assets.skillIcon };
const tok = s.overrideTokenKey;
if (tok && (rec.tokens || []).includes(tok) && (out.talents || []).some((t) => t && t.containerTokenKey)) {
out.talents = out.talents.map((t) => (t && t.containerTokenKey ? { ...t, tokenKey: tok } : t));
}
}
}
return out;
}
/**
* The attack range a (loadout-resolved) chess record fights with: an elite whose equipped module reads "攻击范围扩大"
* uses that module's own grid — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the
* centre tile [0,3] — as the kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`.
* @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module)
* @returns {number[][]|null}
*/
export function attackRangeGrid(rec) {
if (!rec || typeof rec !== 'object') return null;
const m = rec.module;
if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) {
const mod = (Array.isArray(rec.modules) ? rec.modules : []).find((x) => x && x.uniEquipId === m.id);
const g = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (g) return g;
}
return Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null;
}
+274
View File
@@ -0,0 +1,274 @@
// 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',
});