Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tractor-game-simulator/client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -322,7 +323,9 @@ function App() {
<p>发牌间隔: {currentRoom.config.dealInterval} 毫秒</p>
<p>规则模式: {currentRoom.config.testMode
? `测试模式(${RULE_SELECT_OPTIONS.find(option => option.value === currentRoom.config.testRuleId)?.label || currentRoom.config.testRuleId})`
: '正常随机二选一'}</p>
: currentRoom.config.normalModeOnly
? '普通对局(无特殊规则)'
: '正常随机二选一'}</p>
</div>

{/* Bot管理区域 - 仅房主可见 */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -28,6 +29,7 @@ export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
roomName: '我的房间',
playerName: '玩家1',
dealInterval: 500,
normalModeOnly: false,
testMode: false,
testRuleId: 'normal_game'
}}
Expand Down Expand Up @@ -56,21 +58,40 @@ export default function CreateRoomModal({ visible, onClose, onCreateRoom }) {
<InputNumber min={10} max={5000} step={100} style={{ width: '100%' }} />
</Form.Item>

<Form.Item name="testMode" valuePropName="checked" style={{ marginBottom: 8 }}>
<Checkbox>规则测试模式</Checkbox>
<Form.Item name="normalModeOnly" valuePropName="checked" style={{ marginBottom: 8 }}>
<Checkbox
onChange={(e) => {
if (e.target.checked) {
form.setFieldsValue({ testMode: false });
}
}}
>
无特殊规则(普通对局)
</Checkbox>
</Form.Item>
<Typography.Text type="secondary">
开启后跳过随机二选一,每局直接使用指定规则
开启后每局直接使用「世事无常」,跳过随机二选一
</Typography.Text>
{testMode && (
<Form.Item
label="测试规则"
name="testRuleId"
rules={[{ required: true, message: '请选择测试规则' }]}
style={{ marginTop: 12 }}
>
<Select options={RULE_SELECT_OPTIONS} showSearch optionFilterProp="label" />
</Form.Item>

{!normalModeOnly && (
<>
<Form.Item name="testMode" valuePropName="checked" style={{ marginBottom: 8, marginTop: 12 }}>
<Checkbox>规则测试模式</Checkbox>
</Form.Item>
<Typography.Text type="secondary">
开启后跳过随机二选一,每局直接使用指定规则。
</Typography.Text>
{testMode && (
<Form.Item
label="测试规则"
name="testRuleId"
rules={[{ required: true, message: '请选择测试规则' }]}
style={{ marginTop: 12 }}
>
<Select options={RULE_SELECT_OPTIONS} showSearch optionFilterProp="label" />
</Form.Item>
)}
</>
)}
</Form>
</Modal>
Expand Down
6 changes: 5 additions & 1 deletion tractor-game-simulator/server/src/models/Room.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -100,7 +103,8 @@ export class Room {
return {
...config,
testMode,
testRuleId: testRule?.id || null
testRuleId: testRule?.id || null,
normalModeOnly
};
}

Expand Down
22 changes: 22 additions & 0 deletions tractor-game-simulator/server/src/services/GameEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 20 additions & 15 deletions tractor-game-simulator/server/src/socket/handlers/roomHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}

Expand Down
103 changes: 103 additions & 0 deletions tractor-game-simulator/server/test/roomBotCleanup.test.mjs
Original file line number Diff line number Diff line change
@@ -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 应仍然在房间');
});
86 changes: 86 additions & 0 deletions tractor-game-simulator/server/test/roomNormalMode.test.mjs
Original file line number Diff line number Diff line change
@@ -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'));
});