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 && ( - - + + )} + )} 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')); +});