feedback3 WC: the room's host can remove a human before the match (room.kick), the player is told and may join again

This commit is contained in:
sganggs committed 2026-10-04 17:15:21 +08:00
1 parent db57c39c7a
commit a308317041
5 files changed
+175 -6

No files matched your search

+2 -2
View File
@@ -407,10 +407,10 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
+11 -2
View File
@@ -76,7 +76,7 @@ export function inviteLink(code) {
*/
export { copyText };
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot, onKick }) {
const coop = room.mode !== 'solo';
if (!seat) {
const canAdd = coop && facts.isHost;
@@ -126,6 +126,9 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
<//>` : null}
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟">
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name)} aria-label="移出该博士" />
<//>` : null}
</footer>
</article>`;
}
@@ -194,6 +197,12 @@ export function RoomScreen() {
const start = () => run('start', () => net.request('room.start', {}));
const addBot = () => run('add', () => net.request('room.addBot', {}));
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
// the host removes a human before the match (community report #17): asked first; the player may join again
const kick = async (seat, name) => {
if (inFlight.current) return;
const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const leave = async () => {
if (inFlight.current) return;
@@ -251,7 +260,7 @@ export function RoomScreen() {
<main class=${`seats${coop ? '' : ' seats--solo'}`}>
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts}
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} />`)}
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
+31 -2
View File
@@ -8,9 +8,14 @@
// Humans and bots take the lowest free seat index; seat indexes never compact.
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.start. ▸ Changing the difficulty
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.kick, room.start. ▸ Changing the difficulty
// un-readies the other humans. ▸ room.start requires every other human to be connected and ready;
// the host's start counts as the host's ready (the host may still toggle room.ready for display).
// * room.kick {seat} (community report #17, owner approved): before the match only, the host removes another human
// like an AI seat (an AI seat stays room.removeBot's; never the host itself). The seat is freed at once and the
// player gets `room.closed {reason:'kicked'}` — now, or on the next resume when offline (with the result replay,
// as the grace timeout) —, so the reconnect token no longer leads back to the seat (it stays the player's
// identity: net.js sessions belong to players, not seats). ▸ No ban: the player may join again with the code.
// * Host migration: when the host leaves (or is removed), the lowest-seat remaining human (connected
// ones first) becomes host. A room without humans is disposed (bots never keep a room alive).
// * Disconnect in LOBBY: the seat shows connected=false and is freed after `lobbyGraceMs` (60 s); a
@@ -47,7 +52,7 @@
// matches, so a late b.progress / b.result of the previous match is ignored by the next one (DESIGN §14).
// onEnd(summary) → room back to LOBBY (departed seats freed, humans un-readied, disconnected humans
// get the lobby grace), dispose() on the next macrotask. Players can start again.
// * room.closed reasons: 'timeout' (removed after lobby grace), 'shutdown' (server stopping).
// * room.closed reasons: 'timeout' (removed after lobby grace), 'kicked' (room.kick), 'shutdown' (server stopping).
// * Operator loadout (DESIGN §16): room.loadout { entries } is checked strictly against the game data
// (shared/protocol.js checkLoadout: known visible chess, a skill index legal for the normal AND the elite status, a
// module of the elite or 'none'; any bad entry rejects the whole message, nothing is stored). ▸ It is stored on the
@@ -257,6 +262,7 @@ export class Lobby {
case 'room.setDifficulty': return this.setDifficulty(session, msg);
case 'room.addBot': return this.addBot(session);
case 'room.removeBot': return this.removeBot(session, msg);
case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session);
case 'room.loadout': return this.loadout(session, msg);
default:
@@ -421,6 +427,29 @@ export class Lobby {
return OK;
}
/** Host removes another human before the match (header: room.kick). */
kick(session, { seat }) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
if (room.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId);
const target = room.seats[seat];
if (!target || target.left) return fail(ERR.BAD_TARGET, 'seat holds no player');
if (target.isBot) return fail(ERR.BAD_TARGET, 'seat holds an AI (room.removeBot)');
if (target.playerId === session.playerId) return fail(ERR.BAD_TARGET, 'cannot kick yourself');
const kicked = this.registry.byId(target.playerId);
const wasHere = !!kicked && kicked.roomCode === room.code;
const replay = this.replayFor(room, target.playerId);
this.removeMember(room, target.playerId);
if (wasHere) {
if (kicked.connected) sendSession(kicked, { t: 'room.closed', reason: 'kicked' });
else { kicked.notice = 'kicked'; kicked.pendingResult = replay; }
}
this.log.info(`[lobby] ${room.code} ${target.name} removed by the host`);
return OK;
}
start(session) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
+2
View File
@@ -245,6 +245,8 @@ export const C2S = {
'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)
'room.kick': { 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 },
+129
View File
@@ -0,0 +1,129 @@
// test/lobby-kick.test.js — room.kick (community report #17, owner approved): before the match the host removes another
// human like an AI seat (server/lobby.js kick). The player gets room.closed {kicked} (now, or on the next resume when
// offline), the reconnect token no longer leads back to the seat, and the player may join again (no ban) [ASSUMED].
import { describe, test, before, after, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../server/index.js';
import { StubMatch } from '../server/match/StubMatch.js';
import { TestClient } from './helpers/wsClient.js';
import { ERR } from '../shared/constants.js';
import { validateC2S } from '../shared/protocol.js';
function clientPool(getUrl) {
const open = new Set();
return {
async connect() { const c = await TestClient.connect(getUrl()); open.add(c); return c; },
async player(name, token) {
const c = await this.connect();
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
},
async closeAll() {
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
open.clear();
},
};
}
const quietLog = () => {
const errors = [];
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
};
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null;
async function createRoom(c) {
await ok(c, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
return c.waitFor('room.state', (s) => s.hostId === c.id);
}
async function joinRoom(c, code) {
await ok(c, { t: 'room.join', code });
return c.waitFor('room.state', (s) => s.code === code && !!seatOf(s, c.id));
}
describe('room.kick (lobby)', () => {
let srv;
let pool;
const cap = quietLog();
before(async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: StubMatch, lobbyGraceMs: 60_000 });
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
});
afterEach(async () => { await pool.closeAll(); });
after(async () => {
await srv?.close();
assert.deepEqual(cap.errors, [], 'no server errors logged');
});
test('protocol: room.kick {seat} with a seat index', () => {
assert.equal(validateC2S({ t: 'room.kick', seat: 1 }), null);
assert.notEqual(validateC2S({ t: 'room.kick', seat: 9 }), null);
assert.notEqual(validateC2S({ t: 'room.kick' }), null);
});
test('the host kicks a connected human: room.closed {kicked}, the seat is freed, the player may join again', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const guest = await pool.player('Guest');
await joinRoom(guest, st.code);
await ok(guest, { t: 'room.ready', ready: true });
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
guest.clearInbox();
await ok(host, { t: 'room.kick', seat: 1 });
const closed = await guest.waitFor('room.closed');
assert.equal(closed.reason, 'kicked');
const after = await host.waitFor('room.state', (s) => s.seats[1] === null);
assert.equal(after.hostId, host.id);
await guest.expectNone('room.state');
await err(guest, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
// no ban: the same player joins again (lowest free seat)
const back = await joinRoom(guest, st.code);
assert.equal(seatOf(back, guest.id).seat, 1);
assert.equal(seatOf(back, guest.id).ready, false);
});
test('an offline human is removed at once; the old token resumes outside the room with room.closed {kicked}', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const guest = await pool.player('Guest');
await joinRoom(guest, st.code);
await guest.terminate();
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false);
await ok(host, { t: 'room.kick', seat: 1 });
await host.waitFor('room.state', (s) => s.seats[1] === null);
// the host can start alone right away (the offline seat no longer blocks it with NOT_READY)
await ok(host, { t: 'room.start' });
await host.waitFor('room.state', (s) => s.inMatch === true);
const again = await pool.connect();
const w = await again.hello('Guest', guest.token);
assert.equal(w.resumed, true, 'the identity survives');
assert.equal(w.playerId, guest.id);
const closed = await again.waitFor('room.closed');
assert.equal(closed.reason, 'kicked');
await again.expectNone('room.state');
await err(again, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
});
test('refusals: not the host, yourself, an AI seat, an empty seat, during a match', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const guest = await pool.player('Guest');
await joinRoom(guest, st.code);
await err(guest, { t: 'room.kick', seat: 0 }, ERR.NOT_HOST);
const self = await err(host, { t: 'room.kick', seat: 0 }, ERR.BAD_TARGET);
assert.match(self.detail || self.msg, /yourself/);
await ok(host, { t: 'room.addBot' });
await host.waitFor('room.state', (s) => s.seats[2]?.isBot);
await err(host, { t: 'room.kick', seat: 2 }, ERR.BAD_TARGET);
await err(host, { t: 'room.kick', seat: 3 }, ERR.BAD_TARGET);
const stranger = await pool.player('Stranger');
await err(stranger, { t: 'room.kick', seat: 1 }, ERR.NOT_IN_ROOM);
await ok(guest, { t: 'room.ready', ready: true });
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
await ok(host, { t: 'room.start' });
await host.waitFor('room.state', (s) => s.inMatch === true);
await err(host, { t: 'room.kick', seat: 1 }, ERR.ROOM_STARTED);
});
});