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:
sganggs committed 2026-10-05 23:35:01 +08:00
1 parent 742f478526
commit 4bee542e61
26 files changed
+1026 -105

No files matched your search

+50 -4
View File
@@ -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,
};
}