diff --git a/tractor-game-simulator/client/src/App.jsx b/tractor-game-simulator/client/src/App.jsx
index 3660f72..2f348ed 100644
--- a/tractor-game-simulator/client/src/App.jsx
+++ b/tractor-game-simulator/client/src/App.jsx
@@ -188,6 +188,7 @@ function App() {
playerName: values.playerName,
config: {
dealInterval: values.dealInterval,
+ normalModeOnly: values.normalModeOnly === true,
testMode: values.testMode === true,
testRuleId: values.testMode ? values.testRuleId : null
}
@@ -322,7 +323,9 @@ function App() {
发牌间隔: {currentRoom.config.dealInterval} 毫秒
规则模式: {currentRoom.config.testMode
? `测试模式(${RULE_SELECT_OPTIONS.find(option => option.value === currentRoom.config.testRuleId)?.label || currentRoom.config.testRuleId})`
- : '正常随机二选一'}
+ : currentRoom.config.normalModeOnly
+ ? '普通对局(无特殊规则)'
+ : '正常随机二选一'}
{/* Bot管理区域 - 仅房主可见 */}
diff --git a/tractor-game-simulator/client/src/components/Room/CreateRoomModal.jsx b/tractor-game-simulator/client/src/components/Room/CreateRoomModal.jsx
index 58dd1b7..a12c0f2 100644
--- a/tractor-game-simulator/client/src/components/Room/CreateRoomModal.jsx
+++ b/tractor-game-simulator/client/src/components/Room/CreateRoomModal.jsx
@@ -4,6 +4,7 @@ import { RULE_SELECT_OPTIONS } from '../../utils/ruleCatalog';
export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
const [form] = Form.useForm();
const testMode = Form.useWatch('testMode', form);
+ const normalModeOnly = Form.useWatch('normalModeOnly', form);
const handleSubmit = () => {
form.validateFields().then(values => {
@@ -28,6 +29,7 @@ export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
roomName: '我的房间',
playerName: '玩家1',
dealInterval: 500,
+ normalModeOnly: false,
testMode: false,
testRuleId: 'normal_game'
}}
@@ -56,21 +58,40 @@ export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
-
- 规则测试模式
+
+ {
+ if (e.target.checked) {
+ form.setFieldsValue({ testMode: false });
+ }
+ }}
+ >
+ 无特殊规则(普通对局)
+
- 开启后跳过随机二选一,每局直接使用指定规则。
+ 开启后每局直接使用「世事无常」,跳过随机二选一。
- {testMode && (
-
-
-
+
+ {!normalModeOnly && (
+ <>
+
+ 规则测试模式
+
+
+ 开启后跳过随机二选一,每局直接使用指定规则。
+
+ {testMode && (
+
+
+
+ )}
+ >
)}
diff --git a/tractor-game-simulator/server/src/models/Room.js b/tractor-game-simulator/server/src/models/Room.js
index 695c537..1c6a7c5 100644
--- a/tractor-game-simulator/server/src/models/Room.js
+++ b/tractor-game-simulator/server/src/models/Room.js
@@ -69,6 +69,9 @@ export class Room {
if (testMode && !testRule) {
throw new Error('测试模式必须选择一条已实现规则');
}
+ // 「无特殊规则」模式:每局直接使用 normal_game,跳过随机二选一。
+ // 与 testMode 互斥;同时开启时以 testMode 为准,避免歧义。
+ const normalModeOnly = !testMode && config.normalModeOnly === true;
if (!Number.isInteger(config.bottomCardsCount) ||
config.bottomCardsCount < 1 ||
config.bottomCardsCount > 20) {
@@ -100,7 +103,8 @@ export class Room {
return {
...config,
testMode,
- testRuleId: testRule?.id || null
+ testRuleId: testRule?.id || null,
+ normalModeOnly
};
}
diff --git a/tractor-game-simulator/server/src/services/GameEngine.js b/tractor-game-simulator/server/src/services/GameEngine.js
index 8c80e3b..0441925 100644
--- a/tractor-game-simulator/server/src/services/GameEngine.js
+++ b/tractor-game-simulator/server/src/services/GameEngine.js
@@ -405,6 +405,28 @@ export class GameEngine {
return chooser;
}
+ // 「无特殊规则」模式:等同于强制使用 normal_game,跳过二选一。
+ if (this.room.config.normalModeOnly) {
+ const normalRule = getRuleById(RuleIds.NORMAL_GAME);
+ const ruleSetup = getRuleSetup(normalRule);
+ gameState.selectedRule = normalRule;
+ gameState.ruleOptions = [];
+ gameState.ruleChooserPlayerId = null;
+ gameState.isRuleSelectionPending = false;
+ gameState.ruleSelectionMode = null;
+ gameState.bottomCardsCount = ruleSetup.bottomCardsCount;
+ gameState.attackerScore = ruleSetup.attackerStartingScore;
+ this.initializeFocusFigureCandidates();
+ this.io.to(this.room.id).emit('rule_selected', {
+ playerId: null,
+ playerName: '普通对局',
+ rule: normalRule,
+ normalModeOnly: true
+ });
+ logger.info(`房间 ${this.room.id} 普通对局模式,直接使用规则: ${normalRule.name}`);
+ return null;
+ }
+
const rememberedIndex = gameState.nextRuleChooserIndex;
const chooserIndex = Number.isInteger(rememberedIndex) && players[rememberedIndex]
? rememberedIndex
diff --git a/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js b/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
index 38757ce..77a405d 100644
--- a/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
+++ b/tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
@@ -433,24 +433,29 @@ function removePlayerPermanently(io, roomManager, room, player, socket) {
playerName: player.name
});
- // 如果房主离开,转移房主权限或删除房间
+ // 如果房间内已无真人玩家(只剩 bot 或完全为空),直接清理房间,
+ // 避免仅剩 bot 时房间残留、房间列表不断堆积的问题。
+ const hasHumanPlayer = room.players.some(p => !p.isBot);
+ if (!hasHumanPlayer) {
+ const gameEngines = getGameEngines();
+ const botServices = getBotServices();
+ gameEngines.delete(room.id);
+ botServices.delete(room.id);
+ roomManager.deleteRoom(room.id);
+ logger.info(`玩家 ${player.name} 离开房间: ${room.id},房间内已无真人玩家,房间已删除`);
+ return; // 房间已删除,不需要再广播
+ }
+
+ // 如果房主离开,将房主转移给下一位真人玩家(跳过 bot)
if (room.hostId === player.socketId) {
- if (room.players.length > 0) {
- room.hostId = room.players[0].socketId;
+ const newHost = room.players.find(p => !p.isBot);
+ if (newHost) {
+ room.hostId = newHost.socketId;
io.to(room.id).emit('host_changed', {
- newHostId: room.players[0].id,
- newHostName: room.players[0].name
+ newHostId: newHost.id,
+ newHostName: newHost.name
});
- logger.info(`房间 ${room.id} 房主转移给: ${room.players[0].name}`);
- } else {
- // 房间被删除时也清理游戏引擎和bot服务
- const gameEngines = getGameEngines();
- const botServices = getBotServices();
- gameEngines.delete(room.id);
- botServices.delete(room.id);
- roomManager.deleteRoom(room.id);
- logger.info(`玩家 ${player.name} 离开房间: ${room.id},房间已删除`);
- return; // 房间已删除,不需要再广播
+ logger.info(`房间 ${room.id} 房主转移给: ${newHost.name}`);
}
}
diff --git a/tractor-game-simulator/server/test/roomBotCleanup.test.mjs b/tractor-game-simulator/server/test/roomBotCleanup.test.mjs
new file mode 100644
index 0000000..ccbc3ee
--- /dev/null
+++ b/tractor-game-simulator/server/test/roomBotCleanup.test.mjs
@@ -0,0 +1,103 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { RoomManager } from '../src/services/RoomManager.js';
+import { registerRoomHandlers } from '../src/socket/handlers/roomHandlers.js';
+
+class FakeSocket {
+ constructor(id) {
+ this.id = id;
+ this.handlers = new Map();
+ this.emitted = [];
+ this.joinedRooms = new Set();
+ }
+ on(event, handler) { this.handlers.set(event, handler); }
+ emit(event, payload) { this.emitted.push({ event, payload }); }
+ join(roomId) { this.joinedRooms.add(roomId); }
+ leave(roomId) { this.joinedRooms.delete(roomId); }
+ to() { return { emit: () => {} }; }
+ trigger(event, payload) { return this.handlers.get(event)?.(payload); }
+ last(event) { return this.emitted.findLast(e => e.event === event)?.payload; }
+}
+
+function createIo() {
+ return {
+ broadcasts: [],
+ to(roomId) {
+ return {
+ emit: (event, payload) => {
+ this.broadcasts.push({ roomId, event, payload });
+ }
+ };
+ }
+ };
+}
+
+function setupRoomWithBots({ humanCount = 1, botCount = 2 } = {}) {
+ const io = createIo();
+ const roomManager = new RoomManager();
+
+ // 房主(真人)
+ const hostSocket = new FakeSocket('socket-host');
+ registerRoomHandlers(io, hostSocket, roomManager);
+ hostSocket.trigger('create_room', { name: 'bot清理测试', playerName: '房主', config: {} });
+ const created = hostSocket.last('room_created');
+ const room = roomManager.getRoom(created.room.id);
+
+ // 追加真人
+ const extraHumans = [];
+ for (let i = 1; i < humanCount; i++) {
+ const s = new FakeSocket(`socket-human-${i}`);
+ registerRoomHandlers(io, s, roomManager);
+ s.trigger('join_room', { roomId: room.id, playerName: `真人${i}` });
+ extraHumans.push(s);
+ }
+
+ // 追加 bot(通过房主)
+ for (let i = 0; i < botCount; i++) {
+ hostSocket.trigger('add_bot', { roomId: room.id, botName: `Bot${i + 1}` });
+ }
+
+ return { io, roomManager, room, hostSocket, extraHumans };
+}
+
+test('唯一真人(房主)离开后,房间内只剩 bot,房间应被删除', () => {
+ const { roomManager, room, hostSocket } = setupRoomWithBots({ humanCount: 1, botCount: 2 });
+ assert.equal(room.players.length, 3, '开始应有 1 真人 + 2 bot');
+
+ hostSocket.trigger('leave_room', { roomId: room.id });
+
+ assert.equal(roomManager.getRoom(room.id), undefined, '只剩 bot 时房间应被删除');
+});
+
+test('非房主真人最后一个离开时也应删除房间', () => {
+ // 房主真人 + 1 真人 + 1 bot
+ const { roomManager, room, hostSocket, extraHumans } = setupRoomWithBots({ humanCount: 2, botCount: 1 });
+ assert.equal(room.players.length, 3);
+
+ // 先让房主离开:应转给另一位真人,房间保留
+ hostSocket.trigger('leave_room', { roomId: room.id });
+ assert.ok(roomManager.getRoom(room.id), '仍有真人在,房间应保留');
+ const remaining = roomManager.getRoom(room.id);
+ assert.equal(
+ remaining.hostId,
+ extraHumans[0].id,
+ '房主应转给真人而不是 bot'
+ );
+
+ // 最后一名真人离开 → 房间应被删除
+ extraHumans[0].trigger('leave_room', { roomId: room.id });
+ assert.equal(roomManager.getRoom(room.id), undefined, '最后真人离开后房间应被删除');
+});
+
+test('房主离开但仍有其它真人时,房主转给真人(跳过 bot)', () => {
+ const { roomManager, room, hostSocket, extraHumans } = setupRoomWithBots({ humanCount: 2, botCount: 2 });
+ // 房间人员顺序:房主(0) 真人1(1) Bot1(2) Bot2(3)
+ // 注意 add_bot 是房主主动加的,因此加入顺序中 bot 在真人后面
+
+ hostSocket.trigger('leave_room', { roomId: room.id });
+ const remaining = roomManager.getRoom(room.id);
+ assert.ok(remaining, '还有真人,房间应保留');
+ assert.equal(remaining.hostId, extraHumans[0].id, '新房主必须是真人');
+ // 确认房间里仍然保留 bot
+ assert.ok(remaining.players.some(p => p.isBot), 'bot 应仍然在房间');
+});
diff --git a/tractor-game-simulator/server/test/roomNormalMode.test.mjs b/tractor-game-simulator/server/test/roomNormalMode.test.mjs
new file mode 100644
index 0000000..654db8d
--- /dev/null
+++ b/tractor-game-simulator/server/test/roomNormalMode.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { Room } from '../src/models/Room.js';
+import { Player } from '../src/models/Player.js';
+import { GameEngine } from '../src/services/GameEngine.js';
+import { RuleIds } from '../src/rules/ruleRegistry.js';
+
+function createIo() {
+ const events = [];
+ return {
+ events,
+ to() {
+ return {
+ emit(event, payload) {
+ events.push({ event, payload });
+ }
+ };
+ }
+ };
+}
+
+function createRoom(config = {}) {
+ const room = new Room('普通对局房', 'socket-host', { dealInterval: 10, ...config });
+ for (let i = 0; i < 4; i++) {
+ room.addPlayer(new Player(`socket-${i}`, `玩家${i}`, i));
+ }
+ return room;
+}
+
+test('validateConfig 正确保存 normalModeOnly=true', () => {
+ const room = createRoom({ normalModeOnly: true });
+ assert.equal(room.config.normalModeOnly, true);
+ assert.equal(room.config.testMode, false);
+});
+
+test('normalModeOnly 与 testMode 冲突时以 testMode 为准', () => {
+ const room = createRoom({
+ normalModeOnly: true,
+ testMode: true,
+ testRuleId: RuleIds.NORMAL_GAME
+ });
+ assert.equal(room.config.testMode, true);
+ assert.equal(room.config.normalModeOnly, false);
+});
+
+test('normalModeOnly 房间:startRuleSelection 直接选定 normal_game 并跳过二选一', () => {
+ const room = createRoom({ normalModeOnly: true });
+ const io = createIo();
+ const engine = new GameEngine(room, io);
+
+ // 避免副作用(发牌等)
+ engine.initializeFocusFigureCandidates = () => {};
+
+ const result = engine.startRuleSelection();
+ assert.equal(result, null, 'normalModeOnly 时不应返回 chooser');
+
+ const gs = room.gameState;
+ assert.equal(gs.selectedRule?.id, RuleIds.NORMAL_GAME);
+ assert.equal(gs.isRuleSelectionPending, false);
+ assert.equal(gs.ruleOptions.length, 0);
+ assert.equal(gs.ruleChooserPlayerId, null);
+ assert.equal(gs.ruleSelectionMode, null);
+
+ const selectionStarted = io.events.find(e => e.event === 'rule_selection_started');
+ assert.equal(selectionStarted, undefined, '不应触发 rule_selection_started');
+
+ const selected = io.events.find(e => e.event === 'rule_selected');
+ assert.ok(selected, '应触发 rule_selected');
+ assert.equal(selected.payload.rule.id, RuleIds.NORMAL_GAME);
+ assert.equal(selected.payload.normalModeOnly, true);
+});
+
+test('非 normalModeOnly 房间:仍走随机二选一(保留原行为)', () => {
+ const room = createRoom(); // 默认 normalModeOnly=false
+ const io = createIo();
+ const engine = new GameEngine(room, io, () => 0.1);
+ engine.initializeFocusFigureCandidates = () => {};
+
+ engine.startRuleSelection();
+
+ const gs = room.gameState;
+ assert.equal(gs.isRuleSelectionPending, true, '默认模式下仍需选择规则');
+ assert.ok(gs.ruleOptions.length >= 2, '应生成至少两条候选');
+ assert.ok(io.events.some(e => e.event === 'rule_selection_started'));
+});