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

+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);