Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
449 lines
28 KiB
JavaScript
449 lines
28 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';
|
||
import { isDroppableChess } from './standIn.js';
|
||
import { diySlotIds, validateDiyPicks } from './diy.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 };
|
||
}
|
||
|
||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||
|
||
/**
|
||
* `room.ownership { notOwned }`: the base chess ids the player marked as not owned on the 干员持有 screen (the
|
||
* per-browser setting next to 干员调配; default: every operator owned ⇒ []). Such a chess keeps its identity (name,
|
||
* bonds, 特质, tier, price, merge) and fights as its official stand-in (shared/standIn.js standInRecord). Structural
|
||
* limit below; the semantic check (`checkNotOwned`) is LENIENT, unlike checkLoadout: an id that is not a droppable chess
|
||
* (unknown, elite, PRESET / 自选, a stale id of another build) is dropped, never the whole list.
|
||
*/
|
||
export const OWNERSHIP_LIMITS = Object.freeze({ notOwned: 160 });
|
||
/** Structural check of `room.ownership.notOwned`: an array of ≤ 160 ids. */
|
||
export const isNotOwnedList = (v) => isList(v, OWNERSHIP_LIMITS.notOwned, isId);
|
||
|
||
/**
|
||
* Semantic check + normalisation of a not-owned list against the game data: keeps the ids of droppable chess
|
||
* (shared/standIn.js isDroppableChess — NORMAL base chess with a stand-in), deduplicated and sorted; drops the rest.
|
||
* Used by the server (lobby, match) and by the client before it sends or imports a list.
|
||
* @param {any} list `room.ownership.notOwned`
|
||
* @param {(id: string) => any} getChess chess record lookup
|
||
* @returns {{ ok: true, notOwned: string[], dropped: number } | { error: 'BAD_MSG', detail: string }}
|
||
*/
|
||
export function checkNotOwned(list, getChess) {
|
||
if (!isNotOwnedList(list)) return { error: 'BAD_MSG', detail: 'bad notOwned list' };
|
||
const keep = new Set();
|
||
for (const id of list) {
|
||
const c = typeof getChess === 'function' ? getChess(id) : null;
|
||
if (c && c.chessId === id && isDroppableChess(c)) keep.add(id);
|
||
}
|
||
const notOwned = [...keep].sort();
|
||
return { ok: true, notOwned, dropped: list.length - notOwned.length };
|
||
}
|
||
|
||
// ---- 自选编队 (0.2.0 DIY): room.diy { picks } ------------------------------------------------------------------------
|
||
|
||
/**
|
||
* `room.diy { picks }`: the player's 自选编队 — `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the
|
||
* four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6; shared/diy.js). An out-of-match setting
|
||
* like 干员持有: stored per session / seat, a match takes the picks its seat had at its start. Structural limit below
|
||
* (room for more slots in a later season); the semantic check (`checkDiyPicks`) is LENIENT, like checkNotOwned: an
|
||
* illegal pick — not a pick of the slot's tier, an operator without a kit, a prototype off its locked skill, an unknown
|
||
* skill / module, the same operator twice in a tier, an owned operator in a second slot, an unknown slot — is dropped,
|
||
* never the whole roster; only malformed input is BAD_MSG.
|
||
*/
|
||
export const DIY_LIMITS = Object.freeze({ slots: 8 });
|
||
const isDiyPickWire = (p) => p === null || (isPlain(p) && isId(p.charId)
|
||
&& nullable((v) => isInt(v, 0, 9))(p.skillIndex) && nullable(isId)(p.uniEquipId));
|
||
/** Structural check of `room.diy.picks`: a map of ≤ 8 slot ids → a pick `{ charId, skillIndex?, uniEquipId? }` or null. */
|
||
export const isDiyPicks = (v) => isMap(v, DIY_LIMITS.slots, isId, isDiyPickWire);
|
||
|
||
/**
|
||
* Semantic check + normalisation of a 自选 roster against the game data (`{ chess, backups }` or a sim DataSource) and
|
||
* the kit registry (`kitted`: server/sim/content/kits/index.js KITTED_CHARS — an operator without a kit is never fielded):
|
||
* the slots are taken in data order (tier 5, then tier 6), and each pick is kept when the roster so far plus it still
|
||
* passes shared/diy.js validateDiyPicks — so the result always passes it, and of two picks that clash (one owned operator
|
||
* in two slots, one operator twice in a tier) the first slot keeps it. Kept picks are complete
|
||
* (`{ charId, skillIndex, uniEquipId }`: a prototype's locked selection, uniEquipId null = no module).
|
||
* @param {any} picks `room.diy.picks`
|
||
* @param {{ data: any, kitted?: Iterable<string>|((id: string) => boolean)|null }} opts
|
||
* @returns {{ ok: true, picks: Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>, dropped: number }
|
||
* | { error: 'BAD_MSG', detail: string }}
|
||
*/
|
||
export function checkDiyPicks(picks, { data, kitted = null } = { data: null }) {
|
||
if (!isDiyPicks(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
|
||
const slots = diySlotIds(data);
|
||
/** @type {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>} */
|
||
const kept = {};
|
||
let dropped = 0;
|
||
for (const id of Object.keys(picks)) if (picks[id] != null && !slots.includes(id)) dropped++;
|
||
for (const slotId of slots) {
|
||
const pick = Object.hasOwn(picks, slotId) ? picks[slotId] : null;
|
||
if (pick == null) continue;
|
||
const res = validateDiyPicks({ ...kept, [slotId]: pick }, { data, kitted });
|
||
if ('ok' in res) kept[slotId] = res.picks[slotId];
|
||
else dropped++;
|
||
}
|
||
return { ok: true, picks: kept, dropped };
|
||
}
|
||
|
||
// ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats ---------------------
|
||
|
||
const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||
const round1 = (v) => Math.round(v * 10) / 10;
|
||
const round2 = (v) => Math.round(v * 100) / 100;
|
||
/** Attack interval (s) of a stats object: its own `interval`, else bat × 100 / aspd (null without an attack time). */
|
||
const intervalOf = (x) => {
|
||
if (Number.isFinite(x.interval) && x.interval > 0) return round2(x.interval);
|
||
const bat = fin(x.bat, 0);
|
||
const aspd = fin(x.aspd, 100) > 0 ? fin(x.aspd, 100) : 100;
|
||
return bat > 0 ? round2((bat * 100) / aspd) : null;
|
||
};
|
||
const statView = (x) => ({
|
||
maxHp: Math.round(fin(x.maxHp)), atk: Math.round(fin(x.atk)), def: Math.round(fin(x.def)), res: round1(fin(x.res)),
|
||
interval: intervalOf(x), blockCnt: Math.max(0, Math.round(fin(x.blockCnt))), moveSpeed: round2(fin(x.moveSpeed)),
|
||
});
|
||
|
||
/**
|
||
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
|
||
* or the last ones the sim computed) next to its own `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 },
|
||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||
'room.ownership': { notOwned: isNotOwnedList },
|
||
// 自选编队 (0.2.0 DIY): the player's DIY slot picks; stored per session / seat like room.ownership (a match takes the
|
||
// picks its seat had when it started; during a match they are stored for the next one: ROOM_STARTED)
|
||
'room.diy': { picks: isDiyPicks },
|
||
// spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats —
|
||
// in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets
|
||
// room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat.
|
||
'room.spectate': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) },
|
||
'room.removeSpectator': { playerId: isId },
|
||
|
||
// match
|
||
'g.infoReady': {},
|
||
'g.band': { bandId: isId },
|
||
'g.bandSkip': {},
|
||
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
|
||
// free (Match.timeoutBand); absent / null clears it
|
||
'g.bandFocus': { bandId: nullable(isId), $optional: ['bandId'] },
|
||
'g.buy': { slot: (v) => isInt(v, 0, 15) },
|
||
'g.refresh': {},
|
||
'g.freeze': {},
|
||
'g.levelUp': {},
|
||
'g.sell': { uid: isUid },
|
||
// dir: the facing chosen on the deploy wheel for a board target (absent ⇒ RIGHT; the piece's own tile ⇒ re-orient)
|
||
'g.move': { uid: isUid, to: target, dir: isDir, $optional: ['dir'] },
|
||
// replaceUid: with both of the target's slots used, the equipped item the replace dialog picked (research 09 §1.2
|
||
// UseEquipUp.unloadInstId; absent ⇒ the oldest; not one of the target's items ⇒ BAD_TARGET)
|
||
'g.equip': { itemUid: isUid, targetUid: isUid, replaceUid: nullable(isUid), $optional: ['replaceUid'] },
|
||
'g.art': { itemUid: isUid, row: (v) => isInt(v, 0, GEO.ROWS - 1), col: (v) => isInt(v, 0, GEO.COLS - 1), dir: isDir, $optional: ['dir'] },
|
||
'g.destroy': { uid: isUid },
|
||
'g.reward': { idx: (v) => isInt(v, 0, 5) },
|
||
'g.choice': { idx: (v) => isInt(v, 0, 5), choiceId: isId, $optional: ['choiceId'] },
|
||
'g.ready': { ready: isBool },
|
||
'g.emote': { id: (v) => EMOTES.includes(v) },
|
||
// playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
|
||
// spectator seat follows from then on (Match.watchPref; community report of 2026-10-06, item 56)
|
||
'g.watch': { fieldId: (v) => isStr(v, 32), playerId: isId, $optional: ['playerId'] },
|
||
'g.autoplay': { on: isBool },
|
||
// solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the
|
||
// browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused
|
||
'g.pause': { on: isBool },
|
||
// the stats the own board's units start their next battle with (user playtest #4 item 7; prep phases): answered by
|
||
// the push m.unitStats { seq, round, units: [unitStatsEntry] }; `seq` is echoed so the client keeps the newest answer
|
||
'g.unitStats': { seq: (v) => isInt(v, 0, 2 ** 31), $optional: ['seq'] },
|
||
'g.leave': {},
|
||
|
||
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds
|
||
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
|
||
// uniteLeft; user playtest #6 item 7 — the leakers' live counter)
|
||
'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', ENGAGE: 'engage', DIE: 'die', LEAK: 'leak',
|
||
STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy',
|
||
});
|
||
|
||
/**
|
||
* The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies/helpers.js
|
||
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a 傀儡师's 替身,
|
||
* sim professions.js; a string is that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
|
||
* the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab
|
||
* backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples.
|
||
*/
|
||
export function fxForm(ev) {
|
||
if (!Array.isArray(ev) || ev[0] !== EV.FX) return undefined;
|
||
const x = ev[4];
|
||
if (!x || typeof x !== 'object' || x.id == null || !Object.hasOwn(x, 'form')) return undefined;
|
||
return typeof x.form === 'string' ? x.form : null;
|
||
}
|