Merge upstream 0.2.2 and resolve reroll conflicts (#408)
This commit is contained in:
commit
be095d8638
447 files changed
+33376
-8843
No files matched your search
+86
-16
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
import { cultivatedStats, isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from './potential.js';
|
||||
|
||||
// ---- tiny validators -------------------------------------------------------
|
||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
@@ -40,7 +41,12 @@ const isUnitEnd = (u) => isPlain(u) && nullable(isUid)(u.uid) && isNum(u.hpPct,
|
||||
&& 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
|
||||
const isPerPlayer = (p) => isPlain(p) && isInt(p.killed, 0, 1e5) && isInt(p.total, 0, 1e5)
|
||||
// `resolved` = the HUD capsule's numerator of this player's own field (the round's own scheduled enemies knocked out
|
||||
// or leaked: server/sim/battle/deploy.js killedInTotal / leakedInTotal, Battle.resolved). `killed` counts every counted
|
||||
// knock-out — a runtime split child / summon too — and may therefore exceed `total`, which counts only the round's own
|
||||
// scheduled enemies (server/match/fields.js validateClientResult bounds it against maxTotal instead).
|
||||
&& optional((x) => isInt(x, 0, 1e5))(p.resolved)
|
||||
&& 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)
|
||||
@@ -56,6 +62,7 @@ const isUnspawned = (u) => isPlain(u) && isId(u.enemyKey) && nullable(isId)(u.so
|
||||
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)
|
||||
&& optional((x) => isInt(x, 0, 1e5))(v.resolved)
|
||||
&& 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);
|
||||
@@ -64,12 +71,15 @@ export function isBattleResult(v) {
|
||||
// ---- 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.
|
||||
* `room.loadout { entries, ops? }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||
* (the per-browser loadout of the 干员调配 screen); `ops` (0.2.2) = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }`,
|
||||
* the player's per-operator 潜能 and 练度 (shared/potential.js; absent / missing = 潜能 6, 精英2 Lv.60 — the owner's
|
||||
* decision of 2026-10-08). Structural limits below; the semantic checks against the game data — known visible chess,
|
||||
* legal skill index for the normal AND the elite status, legal module of the elite (`checkLoadout`); an operator of the
|
||||
* 干员调配 roster or the 自选 owned pool (`checkLoadoutOps`) — are 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 });
|
||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9, ops: 256 });
|
||||
/** 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')
|
||||
@@ -173,6 +183,52 @@ export function resolveLoadout(loadout, chess, getChess) {
|
||||
return { skillIndex, moduleId };
|
||||
}
|
||||
|
||||
/** One `room.loadout.ops` entry: `{ potential?: 1–6, cultivate?: 0–3 }`, at least one of them. */
|
||||
const isOpsEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'potential' || k === 'cultivate')
|
||||
&& optional(isPotential)(e.potential) && optional(isCultivate)(e.cultivate);
|
||||
/** Structural check of `room.loadout.ops` (0.2.2): a map of ≤ 256 charIds → `{ potential?, cultivate? }`. */
|
||||
export const isLoadoutOps = (v) => isMap(v, LOADOUT_LIMITS.ops, isId, isOpsEntry);
|
||||
|
||||
/**
|
||||
* The operators a player sets a potential / 练度 for (0.2.2): the charIds of the 干员调配 roster — the visible normal chess
|
||||
* (checkLoadout's targets; a hidden chess shares its visible twin's charId: 锡人, 耶拉 …) — and the owned 6★ 自选 picks
|
||||
* (data/backups.json `diy.ownedPool`). A PRESET (特许) operator the player does not own is set by hand (潜能 1, 未精英化:
|
||||
* the official 「未持有的特许按1潜」 and +0 %); a 补位 stand-in or a prototype pick takes neither.
|
||||
* @param {Record<string, any>|null|undefined} chess data/chess.json
|
||||
* @param {any} [backups] data/backups.json
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function cultivationCharIds(chess, backups = null) {
|
||||
const out = new Set();
|
||||
for (const c of Object.values(chess && typeof chess === 'object' ? chess : {})) {
|
||||
if (c && !c.isGolden && !c.isDiy && c.visible !== false && !c.isHidden && (!c.baseId || c.baseId === c.chessId) && isId(c.charId)) out.add(c.charId);
|
||||
}
|
||||
for (const id of Array.isArray(backups?.diy?.ownedPool) ? backups.diy.ownedPool : []) if (isId(id)) out.add(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic check + normalisation of `room.loadout.ops` (0.2.2) — strict like checkLoadout: an operator `isOperator` does
|
||||
* not know (cultivationCharIds) rejects the whole message. Entries equal to the defaults (潜能 6, 练度 3) are dropped, the
|
||||
* rest stored complete `{ potential, cultivate }`. `undefined` / `null` = no settings (`{}`).
|
||||
* @param {any} ops
|
||||
* @param {(charId: string) => boolean} isOperator
|
||||
* @returns {{ ok: true, ops: Record<string, { potential: number, cultivate: number }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
|
||||
*/
|
||||
export function checkLoadoutOps(ops, isOperator) {
|
||||
if (ops == null) return { ok: true, ops: {} };
|
||||
if (!isLoadoutOps(ops)) return { error: 'BAD_MSG', detail: 'bad operator settings' };
|
||||
const out = {};
|
||||
for (const id of Object.keys(ops)) {
|
||||
if (typeof isOperator !== 'function' || !isOperator(id)) return { error: 'BAD_TARGET', detail: `unknown operator ${id}` };
|
||||
const potential = ops[id].potential ?? POTENTIAL_DEFAULT;
|
||||
const cultivate = ops[id].cultivate ?? CULTIVATE_DEFAULT;
|
||||
if (potential === POTENTIAL_DEFAULT && cultivate === CULTIVATE_DEFAULT) continue;
|
||||
out[id] = { potential, cultivate };
|
||||
}
|
||||
return { ok: true, ops: out };
|
||||
}
|
||||
|
||||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -270,22 +326,25 @@ const statView = (x) => ({
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or the last ones the sim computed) next to its own numbers — `unit.base` with its 练度 multiplier (`unit.cultMul`,
|
||||
* 0.2.2: part of the operator's own numbers, as on the record cards; 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
|
||||
* S3's 4-11 never reached the card). `dir`: the unit's facing now (UP / RIGHT / DOWN / LEFT; UnitInfo.dir is the facing
|
||||
* at send time and the snapshot tuples carry none) — the detail card's range overlay rotates by it (GitHub PR #281).
|
||||
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any, dir?: string } | 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]> }}
|
||||
* range?: Array<[number, number]>, dir?: string }}
|
||||
*/
|
||||
export function unitStatsEntry(u, s = null) {
|
||||
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
|
||||
const own = u && u.base && typeof u.base === 'object' ? u.base : {};
|
||||
const base = u && u.cultMul ? cultivatedStats(own, u.cultMul) : own;
|
||||
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]])
|
||||
@@ -299,6 +358,7 @@ export function unitStatsEntry(u, s = null) {
|
||||
...statView(cur),
|
||||
base: statView(base),
|
||||
...(range ? { range } : {}),
|
||||
...(isDir(u?.dir) ? { dir: u.dir } : {}),
|
||||
// the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced
|
||||
silenced: !!(cur.flags && cur.flags.silence),
|
||||
};
|
||||
@@ -325,6 +385,9 @@ export const C2S = {
|
||||
'room.leave': {},
|
||||
'room.ready': { ready: isBool },
|
||||
'room.setDifficulty': { difficulty: (v) => DIFFICULTIES.includes(v) },
|
||||
// the co-op room option 「AI 队友最后选择」 (GitHub #338; host, before the match): the strategy and 机变 drafts order every
|
||||
// human seat before every AI seat (server/match/match/phases.js humansFirst); room.state.aiPicksLast
|
||||
'room.setAiPicksLast': { on: isBool },
|
||||
'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
|
||||
@@ -334,8 +397,9 @@ export const C2S = {
|
||||
// INFO_CHECK: the host proposes a full setup reroll; all human seats must consent.
|
||||
'room.rerollSetup': { setupRevision: (v) => isInt(v, 0, 2 ** 31) },
|
||||
'room.cancelReroll': { voteId: (v) => isInt(v, 1, 2 ** 31) },
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||||
'room.loadout': { entries: isLoadoutEntries },
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
|
||||
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
|
||||
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
|
||||
// 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 },
|
||||
@@ -369,7 +433,7 @@ export const C2S = {
|
||||
'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.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
|
||||
@@ -386,13 +450,19 @@ export const C2S = {
|
||||
|
||||
// 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)
|
||||
// uniteLeft; user playtest #6 item 7 — the leakers' live counter). `resolved` = the HUD capsule's numerator: the
|
||||
// field's own scheduled enemies that were knocked out **or leaked** (official: 漏一个 1/3, 打死一个 2/3,
|
||||
// 打死会分裂的 3/3 — Battle.leakedInTotal; runtime splits / summons stay out of both parts of the capsule). A boss / hidden field adds
|
||||
// `leaksBy` = { [playerId]: the cumulative LP the enemies that reached that player's goal cost } — `leaks` minus its
|
||||
// sum is the leader's own "扣除目标生命" effects; the server holds the b.result's leaked lists to it (Match._bossLeaksAgree)
|
||||
'b.progress': {
|
||||
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
|
||||
resolved: (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'],
|
||||
leaksBy: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, 1e6)),
|
||||
$optional: ['resolved', 'leaks', 'bossDmg', 'by', 'done', 'left', 'leaksBy'],
|
||||
},
|
||||
'b.result': { battleId: isId, result: isBattleResult },
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user