feedback5 WK: 自选编队 on the server — room.diy { picks } (lenient: illegal picks dropped, only malformed input is BAD_MSG; stored per session / seat; a match takes the picks its seat had at the start, a change during a match is for the next one; welcome lists diyKitted), PlayerState.diy with the player's data view (a slotted DIY slot is its operator for every rule: bonds from its factions, no 特质, the pick's skill and module, the slot's tier / price / merge), a per-player stock per slotted piece (8 at tier 5, 5 at tier 6 [ASSUMED]; never in the shared pool; none when every bond of it is banned), DIY pieces in that player's shop rolls and reward offers once the 调度中心 reaches the slot's level, battleInput diy, m.private.diy / diyBanned, prep scouting and results name the operator, 信标 never sends a 自选 piece to a teammate [ASSUMED]
The owner's decisions of 2026-10-05 (picks, slots, shop per player, bans); research 0.2.0 §2. Golden unchanged (no scenario slots a DIY pick). Tests: test/match/diy-shop.test.js (9), test/lobby-diy.test.js (4).
This commit is contained in:
1 parent
742f478526
commit
4bee542e61
26 files changed
+1026
-105
No files matched your search
+50
-4
@@ -71,6 +71,16 @@
|
||||
// every operator) and keeps it for its whole length: the setting is out of match ("局外设置,下一局生效"), so while
|
||||
// the room's match runs a new list is only stored for the next match (ROOM_STARTED 'stored for the next match',
|
||||
// never handed to the match). A spectator's list stays on its session.
|
||||
// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05): room.diy { picks } — the player's picks for the four DIY
|
||||
// slots ({ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }) — is checked leniently (shared/protocol.js
|
||||
// checkDiyPicks against the game data and the kit registry, server/sim/content/kits/index.js KITTED_CHARS: an
|
||||
// illegal pick — an operator without a kit, another tier's prototype, a prototype off its locked skill, a second slot
|
||||
// of one owned operator, the same operator twice in a tier, an unknown slot / skill / module — is dropped, never the
|
||||
// whole roster; only malformed picks are BAD_MSG) and stored on the session and the seat exactly like the
|
||||
// not-owned list: the match receives seats[].diy when it starts (bots: none — they field no 自选 piece [ASSUMED]),
|
||||
// and a change while it runs is stored for the next match (ROOM_STARTED 'stored for the next match'). Every
|
||||
// `welcome` carries `diyKitted` (welcomeInfo): the operators a DIY slot may field, so the client's picker offers
|
||||
// exactly what the server accepts.
|
||||
// * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none):
|
||||
// room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its
|
||||
// match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players
|
||||
@@ -78,18 +88,19 @@
|
||||
// {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match
|
||||
// broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators /
|
||||
// addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may
|
||||
// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership (stored
|
||||
// for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any
|
||||
// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership /
|
||||
// room.diy (stored for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any
|
||||
// time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same
|
||||
// code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for
|
||||
// a player seat (the seat is kept and given back on resume).
|
||||
|
||||
import { randomBytes, randomInt } from 'node:crypto';
|
||||
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
|
||||
import { checkLoadout, checkNotOwned } from '../shared/protocol.js';
|
||||
import { checkLoadout, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
|
||||
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
|
||||
import { getData as defaultGetData, lookup } from './data.js';
|
||||
import { Match as DefaultMatch } from './match/Match.js';
|
||||
import { KITTED_CHARS } from './sim/content/kits/index.js';
|
||||
|
||||
/** Room code alphabet: uppercase letters without I and O (and no digits, so no 0/1). */
|
||||
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
@@ -117,7 +128,8 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
/**
|
||||
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
|
||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
|
||||
* notOwned?: readonly string[] | null }} Seat
|
||||
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
|
||||
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
|
||||
*/
|
||||
|
||||
/** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */
|
||||
@@ -127,6 +139,13 @@ function freezeLoadout(loadout) {
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
|
||||
function freezeDiy(picks) {
|
||||
const out = {};
|
||||
for (const [id, p] of Object.entries(picks || {})) out[id] = Object.freeze({ charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId ?? null });
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/** One room: 4 seat slots, host, difficulty, optional running match. */
|
||||
export class Room {
|
||||
/** @param {string} code @param {'solo'|'coop'} mode @param {string} difficulty @param {number} now */
|
||||
@@ -298,6 +317,7 @@ export class Lobby {
|
||||
case 'room.start': return this.start(session);
|
||||
case 'room.loadout': return this.loadout(session, msg);
|
||||
case 'room.ownership': return this.ownership(session, msg);
|
||||
case 'room.diy': return this.diy(session, msg);
|
||||
case 'room.spectate': return this.spectate(session, msg);
|
||||
case 'room.removeSpectator': return this.removeSpectator(session, msg);
|
||||
default:
|
||||
@@ -602,6 +622,29 @@ export class Lobby {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* room.diy (0.2.0 自选编队): keep the legal picks (checkDiyPicks against the data and KITTED_CHARS), store them on the
|
||||
* session and the seat (see the header). A running match never takes them: it keeps the picks its seat had at its
|
||||
* start.
|
||||
*/
|
||||
diy(session, { picks }) {
|
||||
const res = checkDiyPicks(picks, { data: this.safeData(), kitted: KITTED_CHARS });
|
||||
if (!res || !('ok' in res)) return fail(ERR.BAD_MSG, res && res.detail);
|
||||
const kept = freezeDiy(res.picks);
|
||||
session.diy = kept;
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return OK;
|
||||
const seat = room.seatOf(session.playerId);
|
||||
if (seat) seat.diy = kept;
|
||||
if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match');
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Extra fields of every `welcome` (net.js): the operators a 自选 slot may field (shared/diy.js `kitted`). */
|
||||
welcomeInfo() {
|
||||
return { diyKitted: KITTED_CHARS };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Match wiring
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -616,6 +659,8 @@ export class Lobby {
|
||||
loadout: s.isBot ? null : s.loadout || null,
|
||||
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
|
||||
notOwned: s.isBot ? null : s.notOwned || null,
|
||||
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
|
||||
diy: s.isBot ? null : s.diy || null,
|
||||
}));
|
||||
// lastPublic / results: the latest m.public broadcast and the m.result frames (encoded), kept for the replay.
|
||||
const ctx = { live: true, ended: false, disposed: false, match: null, lastPublic: null, sharedResult: null, results: new Map() };
|
||||
@@ -875,6 +920,7 @@ export class Lobby {
|
||||
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
|
||||
loadout: session.loadout || null,
|
||||
notOwned: session.notOwned || null,
|
||||
diy: session.diy || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user