全域匹配机制更新

This commit is contained in:
PengYiZhen
2026-04-24 15:56:13 +08:00
parent 8557b1b30c
commit cfce18be7e
20 changed files with 2102 additions and 18 deletions

View File

@@ -37,7 +37,7 @@ DB_USERNAME=root
# 数据库密码
DB_PASSWORD=123456
# 数据库名称
DB_DATABASE=xymj-server
DB_DATABASE=xymj-db
# 是否自动同步数据库结构(生产环境建议设为 false
DB_SYNCHRONIZE=true
# 是否启用 SQL 日志(开发环境建议设为 true

View File

@@ -17,7 +17,7 @@
"copy-package": "node scripts/copy-package.mjs",
"generate-controllers-once": "node scripts/generate-controllers.mjs",
"clean": "rimraf dist",
"test": "mocha -r tsx test/**_test.ts --exit --timeout 15000"
"test": "set NODE_ENV=test&& mocha -r tsx test/**_test.ts --exit --timeout 15000"
},
"author": "",
"license": "UNLICENSED",

View File

@@ -21,6 +21,7 @@ import { NearbyChatRoom } from "./rooms/chat/NearbyChatRoom";
import { TeamChatRoom } from "./rooms/chat/TeamChatRoom";
import { ChatRoomName } from "./rooms/chat/ChatRoomName";
import { LoadTestRoom } from "./rooms/LoadTestRoom";
import { MatchmakerRoom } from "./rooms/MatchmakerRoom";
/**
* Import database and cache
@@ -69,6 +70,11 @@ export default config({
recordFrames: false, // 是否记录帧数据
});
/**
* 匹配入口(主动匹配/被动开房)
*/
gameServer.define("matchmaker_room", MatchmakerRoom);
/**
* 聊天房间(世界/工会/附近/队伍)
*/

View File

@@ -68,6 +68,18 @@ export const swaggerSchemas: Record<string, any> = {
nullable: true,
example: 'https://example.com/avatar.jpg',
},
openid: {
type: 'string',
nullable: true,
example: 'oWZ9m5XXXXXX',
description: '第三方 openid例如微信/抖音)',
},
guildId: {
type: 'string',
nullable: true,
example: '10001',
description: '工会ID',
},
status: {
type: 'integer',
example: 1,

View File

@@ -33,10 +33,10 @@ export async function createConnection(): Promise<DataSource> {
try {
await dataSource.initialize();
console.log('✅ 数据库连接成功');
console.log('[数据库]✅ 数据库连接成功');
return dataSource;
} catch (error) {
console.error('❌ 数据库连接失败:', error);
console.error('[数据库]❌ 数据库连接失败:', error);
throw error;
}
}
@@ -58,7 +58,7 @@ export async function closeConnection(): Promise<void> {
if (dataSource && dataSource.isInitialized) {
await dataSource.destroy();
dataSource = null;
console.log('✅ 数据库连接已关闭');
console.log('[数据库]✅ 数据库连接已关闭');
}
}

View File

@@ -12,10 +12,14 @@
// 必须在最顶部导入 reflect-metadataTypeORM 需要它
import "reflect-metadata";
import { installConsolePrefix } from "./utils/log";
import { listen } from "@colyseus/tools";
// Import Colyseus config
import app from "./app.config";
// 统一控制台输出格式:[xymj][类别],并用蓝色前缀显示
installConsolePrefix("系统");
// Create and listen on 2567 (or PORT environment variable.)
listen(app);

View File

@@ -7,6 +7,7 @@ import { BaseEntity } from './BaseEntity';
@Entity('users')
@Index(['username'], { unique: true })
@Index(['email'], { unique: true })
@Index(['openid'], { unique: true })
export class User extends BaseEntity {
@Column({
type: 'varchar',
@@ -46,6 +47,22 @@ export class User extends BaseEntity {
})
avatar?: string;
@Column({
type: 'varchar',
length: 64,
nullable: true,
comment: '第三方 openid例如微信',
})
openid?: string;
@Column({
type: 'varchar',
length: 64,
nullable: true,
comment: '工会ID',
})
guildId?: string;
@Column({
type: 'tinyint',
default: 1,

View File

@@ -0,0 +1,714 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>匹配赛演示 - 小游码匠</title>
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #111827 0%, #4c1d95 55%, #2563eb 100%);
min-height: 100vh;
padding: 20px;
color: #111827;
margin: 0;
}
.container { max-width: 1200px; margin: 0 auto; }
.back-link {
position: absolute;
top: 18px;
left: 18px;
color: #fff;
text-decoration: none;
padding: 10px 14px;
background: rgba(255,255,255,0.14);
border-radius: 10px;
transition: all 0.2s ease;
}
.back-link:hover { background: rgba(255,255,255,0.22); }
.header {
text-align: center;
color: #fff;
padding: 18px 0 26px;
}
.header h1 {
margin: 0 0 8px;
font-size: 2.2rem;
text-shadow: 0 2px 10px rgba(0,0,0,0.25);
}
.header p { margin: 0; opacity: 0.92; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 1000px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: rgba(255,255,255,0.96);
border-radius: 14px;
padding: 18px;
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
border: 1px solid rgba(255,255,255,0.45);
}
.card h2 {
margin: 0 0 12px;
font-size: 1.25rem;
color: #4f46e5;
padding-bottom: 10px;
border-bottom: 2px solid rgba(79,70,229,0.25);
}
label { display: block; font-size: 12px; color: #4b5563; margin-bottom: 6px; }
input, select, textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
font-size: 14px;
outline: none;
background: #fff;
}
input:focus, select:focus, textarea:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
textarea { min-height: 78px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12.5px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.row > * { flex: 1; min-width: 180px; }
.row.tight > * { min-width: 140px; }
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
font-weight: 700;
font-size: 14px;
background: #4f46e5;
color: #fff;
transition: transform 0.12s ease, filter 0.12s ease, opacity 0.12s ease;
}
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
.btn.gray { background: #64748b; }
.btn.danger { background: #dc2626; }
.btn.success { background: #16a34a; }
.pill {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: rgba(79,70,229,0.10);
color: #4338ca;
font-weight: 800;
font-size: 12px;
border: 1px solid rgba(79,70,229,0.18);
}
.status {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
margin-top: 10px;
}
.kv {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 10px 12px;
}
.kv b { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }
.kv span { display: block; font-weight: 800; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log {
height: 340px;
overflow: auto;
background: #0b1020;
color: #e5e7eb;
border-radius: 14px;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid rgba(255,255,255,0.10);
}
code { background: rgba(79,70,229,0.08); padding: 2px 6px; border-radius: 8px; }
.hint { font-size: 12px; color: #6b7280; line-height: 1.65; margin-top: 10px; }
</style>
</head>
<body>
<a class="back-link" href="index.html">← 返回首页</a>
<div class="container">
<div class="header">
<h1>🎯 匹配赛演示(主动匹配 / 被动开房)</h1>
<p>连接 <code>matchmaker_room</code>,收到 <code>match:found</code> 后自动加入 <code>game_room</code>,支持模拟断线与重连。</p>
</div>
<div class="grid">
<div>
<div class="card">
<h2>🔐 连接与参数</h2>
<div class="row">
<div>
<label>服务器地址HTTP / WS 均可)</label>
<input id="serverUrl" value="http://localhost:2567" />
</div>
<div>
<label>JWT token必填</label>
<input id="token" placeholder="从 /api/auth/login 拿到 accessToken" />
</div>
</div>
<div class="row tight" style="margin-top: 10px;">
<div>
<label>demoUserId演示用同 token 多开请填不同)</label>
<input id="demoUserId" placeholder="例如 u1 / u2留空则用 token 的 userId" />
</div>
</div>
<div class="row tight" style="margin-top: 10px;">
<div>
<label>modeId</label>
<input id="modeId" value="ranked" />
</div>
<div>
<label>playersPerMatch动态人数</label>
<input id="playersPerMatch" type="number" min="2" max="100" value="2" />
</div>
<div>
<label>region可选</label>
<input id="region" placeholder="例如 cn1 / global" />
</div>
</div>
<div class="row" style="margin-top: 12px;">
<button class="btn success" id="btnConnectMm" onclick="connectMatchmaker()">连接 matchmaker_room</button>
<button class="btn danger" id="btnLeaveMm" onclick="leaveMatchmaker()" disabled>离开 matchmaker_room</button>
</div>
<div class="status">
<div class="kv"><b>Matchmaker 状态</b><span id="mmStatus">未连接</span></div>
<div class="kv"><b>matchmaker_roomId</b><span id="mmRoomId">-</span></div>
<div class="kv"><b>matchmaker_sessionId</b><span id="mmSessionId">-</span></div>
<div class="kv"><b>queueKey</b><span id="queueKey">-</span></div>
</div>
<div class="hint">
- 需要后端能验证 JWT`RequireAuth`)。<br/>
- “动态人数”由接入方传入 `playersPerMatch`,框架只保证同队列内人数一致(`queueKey` 包含它)。
</div>
</div>
<div class="card">
<h2>⚔️ 主动匹配(大厅点开始)</h2>
<div class="row">
<button class="btn" id="btnFind" onclick="startActiveMatch()" disabled>match:find开始匹配</button>
<button class="btn gray" id="btnCancel" onclick="cancelActiveMatch()" disabled>match:cancel取消</button>
</div>
<div class="hint">
匹配成功会收到 <code>match:found</code>,页面会自动 <code>joinById(roomId)</code> 进入对局。
</div>
</div>
<div class="card">
<h2>🏠 被动开房(房间码 / 组队)</h2>
<div class="row">
<button class="btn" id="btnPartyCreate" onclick="partyCreate()" disabled>party:create创建房间码</button>
<button class="btn success" id="btnPartyStart" onclick="partyStart()" disabled>party:start房主进入游戏</button>
<button class="btn gray" id="btnPartyLeave" onclick="partyLeave()" disabled>party:leave离开</button>
</div>
<div class="row" style="margin-top: 10px;">
<div>
<label>partyCode</label>
<input id="partyCode" placeholder="例如 ABC123" />
</div>
<div style="flex:0.6; min-width: 160px;">
<label>&nbsp;</label>
<button class="btn success" id="btnPartyJoin" onclick="partyJoin()" disabled>party:join</button>
</div>
</div>
<div class="status">
<div class="kv"><b>partyId</b><span id="partyId">-</span></div>
<div class="kv"><b>当前人数</b><span id="partyCount">-</span></div>
<div class="kv"><b>目标人数</b><span id="partyTarget">-</span></div>
</div>
<div class="hint">
被动模式下:成员先加入 party只有房主点击 <code>party:start</code> 后才会统一推送 <code>match:found</code> 并切入游戏页。
</div>
</div>
</div>
<div>
<div class="card">
<h2>🎮 对局game_room与重连</h2>
<div class="status">
<div class="kv"><b>game_roomId</b><span id="gameRoomId">-</span></div>
<div class="kv"><b>game_sessionId</b><span id="gameSessionId">-</span></div>
<div class="kv"><b>matchId</b><span id="matchId">-</span></div>
<div class="kv"><b>seatIndex</b><span id="seatIndex">-</span></div>
</div>
<div style="margin-top: 12px;" class="row">
<button class="btn gray" id="btnLeaveGame" onclick="leaveGameRoom()" disabled>离开对局(正常)</button>
<button class="btn danger" id="btnSimDrop" onclick="simulateDrop()" disabled>模拟断线(非正常)</button>
<button class="btn success" id="btnReconnect" onclick="reconnectGameRoom()" disabled>重连(带 reconnectKey</button>
</div>
<div class="row" style="margin-top: 10px;">
<div>
<label>reconnectKey收到 match:found 后保存)</label>
<input id="reconnectKey" placeholder="自动填充" />
</div>
</div>
<div class="row" style="margin-top: 10px;">
<div>
<label>对局聊天game_room 内 onMessage('chat')</label>
<input id="gameChatText" placeholder="输入并发送 chat" />
</div>
<div style="flex:0.5; min-width: 140px;">
<label>&nbsp;</label>
<button class="btn" id="btnGameChat" onclick="sendGameChat()" disabled>发送 chat</button>
</div>
</div>
<div class="hint">
- 掉线后服务器会广播 <code>playerOffline</code>,重连成功会收到 <code>reconnect:ok</code><br/>
- 重连窗口由服务端环境变量 <code>MATCH_RECONNECT_WINDOW_MS</code> 控制(默认 15000ms
</div>
</div>
<div class="card">
<div class="row" style="justify-content: space-between;">
<h2 style="margin:0;">🧾 日志</h2>
<span class="pill" id="pillConn">未连接</span>
</div>
<div style="margin-top: 10px;" class="row">
<button class="btn gray" onclick="clearLog()">清空日志</button>
</div>
<div class="log" id="logBox" style="margin-top: 10px;"></div>
</div>
</div>
</div>
</div>
<script>
// 注意localStorage 在同一浏览器的所有窗口/标签页共享
// 为避免“两个窗口填不同账号 token 却互相覆盖”token/demoUserId 用 sessionStorage每标签页独立
const LS = {
serverUrl: "mmDemo_serverUrl",
modeId: "mmDemo_modeId",
playersPerMatch: "mmDemo_playersPerMatch",
region: "mmDemo_region",
};
const SS = {
token: "mmDemo_token",
demoUserId: "mmDemo_demoUserId",
};
let client = null;
let mmRoom = null;
let gameRoom = null;
let partyState = {
partyId: "",
count: 0,
playersPerMatch: 0,
leaderUserId: "",
isLeader: false,
};
// 记住最近一次 match found用于重连
let lastFound = {
roomId: "",
matchId: "",
seatIndex: -1,
reconnectKey: "",
};
function $(id) { return document.getElementById(id); }
function now() { return new Date().toLocaleTimeString(); }
function log(msg) {
const el = $("logBox");
el.textContent = `[${now()}] ${msg}\n` + el.textContent;
}
function clearLog() { $("logBox").textContent = ""; log("日志已清空"); }
function persistInputs() {
localStorage.setItem(LS.serverUrl, $("serverUrl").value.trim());
sessionStorage.setItem(SS.token, $("token").value.trim());
sessionStorage.setItem(SS.demoUserId, ($("demoUserId") ? $("demoUserId").value.trim() : ""));
localStorage.setItem(LS.modeId, $("modeId").value.trim());
localStorage.setItem(LS.playersPerMatch, $("playersPerMatch").value.trim());
localStorage.setItem(LS.region, $("region").value.trim());
}
function restoreInputs() {
const v1 = localStorage.getItem(LS.serverUrl); if (v1) $("serverUrl").value = v1;
const v2 = sessionStorage.getItem(SS.token); if (v2) $("token").value = v2;
const v2b = sessionStorage.getItem(SS.demoUserId); if (v2b && $("demoUserId")) $("demoUserId").value = v2b;
const v3 = localStorage.getItem(LS.modeId); if (v3) $("modeId").value = v3;
const v4 = localStorage.getItem(LS.playersPerMatch); if (v4) $("playersPerMatch").value = v4;
const v5 = localStorage.getItem(LS.region); if (v5) $("region").value = v5;
}
function setMmStatus(text) {
$("mmStatus").textContent = text;
$("pillConn").textContent = text;
}
function setEnabled(id, enabled) { $(id).disabled = !enabled; }
function getJoinBaseOptions() {
const token = $("token").value.trim();
if (!token) throw new Error("请先填写 token");
const demoUserId = ($("demoUserId") ? $("demoUserId").value.trim() : "");
return demoUserId ? { token, demoUserId } : { token };
}
async function connectMatchmaker() {
if (typeof Colyseus === "undefined") {
alert("未加载 colyseus.js");
return;
}
persistInputs();
const serverUrl = $("serverUrl").value.trim();
if (!serverUrl) return alert("请输入 serverUrl");
try {
if (!client) client = new Colyseus.Client(serverUrl);
setMmStatus("连接中...");
log(`连接 matchmaker_room -> ${serverUrl}`);
mmRoom = await client.joinOrCreate("matchmaker_room", getJoinBaseOptions());
$("mmRoomId").textContent = mmRoom.roomId || "-";
$("mmSessionId").textContent = mmRoom.sessionId || "-";
setMmStatus("已连接 matchmaker_room");
setEnabled("btnConnectMm", false);
setEnabled("btnLeaveMm", true);
setEnabled("btnFind", true);
setEnabled("btnCancel", true);
setEnabled("btnPartyCreate", true);
setEnabled("btnPartyJoin", true);
setEnabled("btnPartyLeave", true);
setEnabled("btnPartyStart", false);
mmRoom.onLeave(() => {
log("已离开 matchmaker_room");
mmRoom = null;
$("mmRoomId").textContent = "-";
$("mmSessionId").textContent = "-";
$("queueKey").textContent = "-";
setMmStatus("未连接");
setEnabled("btnConnectMm", true);
setEnabled("btnLeaveMm", false);
setEnabled("btnFind", false);
setEnabled("btnCancel", false);
setEnabled("btnPartyCreate", false);
setEnabled("btnPartyJoin", false);
setEnabled("btnPartyLeave", false);
setEnabled("btnPartyStart", false);
});
mmRoom.onError((code, message) => {
log(`matchmaker_room error [${code}]: ${message}`);
});
mmRoom.onMessage("mm:ready", (msg) => log(`mm:ready sessionId=${msg.sessionId}`));
mmRoom.onMessage("match:queued", (msg) => {
$("queueKey").textContent = msg.queueKey || "-";
log(`match:queued queueKey=${msg.queueKey}`);
});
mmRoom.onMessage("match:cancelled", (msg) => log(`match:cancelled ok=${!!msg.ok}`));
mmRoom.onMessage("match:error", (msg) => log(`match:error ${JSON.stringify(msg)}`));
mmRoom.onMessage("party:created", (msg) => {
$("partyId").textContent = msg.partyId || "-";
$("partyCode").value = msg.partyCode || "";
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
$("partyCount").textContent = "1";
partyState.partyId = String(msg.partyId || "");
partyState.count = 1;
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
partyState.leaderUserId = String(msg.leaderUserId || "");
partyState.isLeader = !!msg.isLeader;
setEnabled("btnPartyStart", partyState.isLeader);
log(`party:created code=${msg.partyCode} target=${msg.playersPerMatch}`);
});
mmRoom.onMessage("party:joined", (msg) => {
$("partyId").textContent = msg.partyId || "-";
$("partyCode").value = msg.partyCode || $("partyCode").value;
$("partyCount").textContent = String(msg.count ?? "-");
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
partyState.partyId = String(msg.partyId || "");
partyState.count = Number(msg.count || 0);
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
partyState.leaderUserId = String(msg.leaderUserId || "");
partyState.isLeader = !!msg.isLeader;
setEnabled("btnPartyStart", partyState.isLeader);
log(`party:joined count=${msg.count}/${msg.playersPerMatch}`);
});
mmRoom.onMessage("party:update", (msg) => {
$("partyId").textContent = msg.partyId || "-";
$("partyCount").textContent = String(msg.count ?? "-");
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
partyState.partyId = String(msg.partyId || "");
partyState.count = Number(msg.count || 0);
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
partyState.leaderUserId = String(msg.leaderUserId || "");
partyState.isLeader = !!msg.isLeader;
setEnabled("btnPartyStart", partyState.isLeader);
log(`party:update count=${msg.count}/${msg.playersPerMatch} isLeader=${partyState.isLeader}`);
});
mmRoom.onMessage("party:left", (msg) => log(`party:left ok=${!!msg.ok}`));
mmRoom.onMessage("party:error", (msg) => log(`party:error ${msg.message || JSON.stringify(msg)}`));
mmRoom.onMessage("match:found", async (msg) => {
log(`match:found roomId=${msg.roomId} seat=${msg.seatIndex} matchId=${msg.matchId}`);
lastFound.roomId = msg.roomId || "";
lastFound.matchId = msg.matchId || "";
lastFound.seatIndex = typeof msg.seatIndex === "number" ? msg.seatIndex : -1;
lastFound.reconnectKey = msg.reconnectKey || "";
$("reconnectKey").value = lastFound.reconnectKey;
$("matchId").textContent = lastFound.matchId || "-";
$("seatIndex").textContent = String(lastFound.seatIndex);
// 匹配成功后统一切入新页面(所有匹配到的人都会跳转)
try {
const payload = {
serverUrl: $("serverUrl").value.trim(),
token: $("token").value.trim(),
demoUserId: ($("demoUserId") ? $("demoUserId").value.trim() : ""),
roomId: lastFound.roomId,
matchId: lastFound.matchId,
seatIndex: lastFound.seatIndex,
reconnectKey: lastFound.reconnectKey,
};
sessionStorage.setItem("xymj_match_found", JSON.stringify(payload));
} catch (e) {}
window.location.href = "MatchmakingGame.html";
});
log("matchmaker_room 连接成功");
} catch (e) {
console.error(e);
setMmStatus("连接失败");
log("连接失败: " + (e.message || e));
alert("连接失败: " + (e.message || e));
}
}
async function leaveMatchmaker() {
if (!mmRoom) return;
try { await mmRoom.leave(); } catch (e) {}
}
function buildMatchParams() {
const modeId = ($("modeId").value || "default").trim();
const playersPerMatch = parseInt($("playersPerMatch").value, 10) || 2;
const regionRaw = ($("region").value || "").trim();
const payload = { modeId, playersPerMatch };
if (regionRaw) payload.region = regionRaw;
return payload;
}
function startActiveMatch() {
if (!mmRoom) return alert("请先连接 matchmaker_room");
const payload = buildMatchParams();
mmRoom.send("match:find", payload);
log("send match:find " + JSON.stringify(payload));
}
function cancelActiveMatch() {
if (!mmRoom) return;
mmRoom.send("match:cancel", {});
log("send match:cancel");
}
function partyCreate() {
if (!mmRoom) return alert("请先连接 matchmaker_room");
const payload = buildMatchParams();
mmRoom.send("party:create", payload);
log("send party:create " + JSON.stringify(payload));
}
function partyJoin() {
if (!mmRoom) return alert("请先连接 matchmaker_room");
const partyCode = ($("partyCode").value || "").trim();
if (!partyCode) return alert("请输入 partyCode");
mmRoom.send("party:join", { partyCode });
log("send party:join " + partyCode);
}
function partyLeave() {
if (!mmRoom) return;
mmRoom.send("party:leave", {});
log("send party:leave");
$("partyId").textContent = "-";
$("partyCount").textContent = "-";
$("partyTarget").textContent = "-";
partyState = { partyId: "", count: 0, playersPerMatch: 0, leaderUserId: "", isLeader: false };
setEnabled("btnPartyStart", false);
}
function partyStart() {
if (!mmRoom) return alert("请先连接 matchmaker_room");
if (!partyState.isLeader) return alert("只有房主可以开始游戏");
mmRoom.send("party:start", {});
log("send party:start");
}
async function joinGameRoomByFound(foundMsg) {
try {
if (!client) throw new Error("client not ready");
const roomId = foundMsg.roomId;
if (!roomId) throw new Error("match:found missing roomId");
// 若已在房间,先离开
if (gameRoom) {
try { await gameRoom.leave(); } catch (e) {}
gameRoom = null;
}
const token = $("token").value.trim();
const joinOpt = {
token,
matchId: foundMsg.matchId,
seatIndex: foundMsg.seatIndex,
reconnectKey: foundMsg.reconnectKey,
};
log("joinById(game_room) -> " + roomId);
gameRoom = await client.joinById(roomId, joinOpt);
$("gameRoomId").textContent = gameRoom.roomId || "-";
$("gameSessionId").textContent = gameRoom.sessionId || "-";
setEnabled("btnLeaveGame", true);
setEnabled("btnSimDrop", true);
setEnabled("btnReconnect", true);
setEnabled("btnGameChat", true);
gameRoom.onLeave((code) => {
log("game_room onLeave code=" + code);
$("gameRoomId").textContent = "-";
$("gameSessionId").textContent = "-";
setEnabled("btnLeaveGame", false);
setEnabled("btnSimDrop", false);
setEnabled("btnGameChat", false);
// 允许重连(如果我们有 lastFound
setEnabled("btnReconnect", !!lastFound.roomId);
gameRoom = null;
});
gameRoom.onError((code, message) => log(`game_room error [${code}]: ${message}`));
gameRoom.onMessage("reconnect:ok", (msg) => log("reconnect:ok " + JSON.stringify(msg)));
gameRoom.onMessage("playerOffline", (msg) => log("playerOffline " + JSON.stringify(msg)));
gameRoom.onMessage("playerOnline", (msg) => log("playerOnline " + JSON.stringify(msg)));
gameRoom.onMessage("chat", (msg) => log("game chat " + JSON.stringify(msg)));
// 简单展示 state 变化(玩家在线/座位)
gameRoom.onStateChange((state) => {
try {
const ps = [];
if (state.players && state.players.forEach) {
state.players.forEach((p, sid) => {
ps.push({
sid: String(sid).slice(0, 8),
userId: p.userId,
seat: p.seatIndex,
online: p.online,
});
});
}
if (ps.length) log("state players=" + JSON.stringify(ps));
} catch (e) {}
});
log("✅ 已加入 game_room");
} catch (e) {
console.error(e);
log("join game_room 失败: " + (e.message || e));
}
}
async function leaveGameRoom() {
if (!gameRoom) return;
try { await gameRoom.leave(); } catch (e) {}
}
function simulateDrop() {
if (!gameRoom) return;
try {
// 非正常断线:关闭底层连接
if (gameRoom.connection && gameRoom.connection.transport && gameRoom.connection.transport.close) {
gameRoom.connection.transport.close();
} else if (gameRoom.connection && gameRoom.connection.close) {
gameRoom.connection.close();
} else {
// fallback快速 leave不等于真实掉线但保底
gameRoom.leave();
}
log("已触发模拟断线");
} catch (e) {
log("模拟断线失败: " + (e.message || e));
}
}
async function reconnectGameRoom() {
if (!client) return alert("client 未初始化");
if (!lastFound.roomId) return alert("没有可重连的 roomId需要先 match:found");
const token = $("token").value.trim();
const reconnectKey = ($("reconnectKey").value || "").trim();
if (!reconnectKey) return alert("没有 reconnectKey");
try {
log("尝试重连 joinById -> " + lastFound.roomId);
gameRoom = await client.joinById(lastFound.roomId, {
token,
matchId: lastFound.matchId,
reconnectKey,
seatIndex: lastFound.seatIndex,
});
$("gameRoomId").textContent = gameRoom.roomId || "-";
$("gameSessionId").textContent = gameRoom.sessionId || "-";
setEnabled("btnLeaveGame", true);
setEnabled("btnSimDrop", true);
setEnabled("btnGameChat", true);
log("✅ 重连 joinById 成功(等服务端下发 reconnect:ok");
} catch (e) {
console.error(e);
log("重连失败: " + (e.message || e));
alert("重连失败: " + (e.message || e));
}
}
function sendGameChat() {
if (!gameRoom) return;
const text = ($("gameChatText").value || "").trim();
if (!text) return;
gameRoom.send("chat", text);
$("gameChatText").value = "";
}
document.addEventListener("DOMContentLoaded", () => {
restoreInputs();
["serverUrl","token","demoUserId","modeId","playersPerMatch","region"].forEach((id) => {
$(id).addEventListener("input", persistInputs);
});
// 初始禁用
setEnabled("btnLeaveMm", false);
setEnabled("btnFind", false);
setEnabled("btnCancel", false);
setEnabled("btnPartyCreate", false);
setEnabled("btnPartyJoin", false);
setEnabled("btnPartyLeave", false);
setEnabled("btnPartyStart", false);
setEnabled("btnLeaveGame", false);
setEnabled("btnSimDrop", false);
setEnabled("btnReconnect", false);
setEnabled("btnGameChat", false);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对局准备 - 小游码匠</title>
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
padding: 20px;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: radial-gradient(1200px 600px at 20% 10%, rgba(99,102,241,0.45), transparent 60%),
radial-gradient(1000px 500px at 80% 20%, rgba(34,197,94,0.32), transparent 55%),
linear-gradient(135deg, #0b1020 0%, #1f2a44 50%, #0b1020 100%);
color: #e5e7eb;
}
.container { max-width: 1100px; margin: 0 auto; }
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
font-weight: 800;
font-size: 14px;
background: #6366f1;
color: #fff;
transition: transform 0.12s ease, filter 0.12s ease, opacity 0.12s ease;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
.btn.gray { background: #64748b; }
.btn.danger { background: #dc2626; }
.btn.success { background: #16a34a; }
.title h1 { margin: 0; font-size: 1.8rem; }
.title p { margin: 6px 0 0; opacity: 0.9; font-size: 13px; }
.card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
padding: 16px;
box-shadow: 0 16px 40px rgba(0,0,0,0.35);
backdrop-filter: blur(8px);
}
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 14px;
align-items: start;
}
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
.kv {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 10px;
}
.kv .item {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
padding: 10px 12px;
}
.kv .item b { display:block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }
.kv .item span { display:block; font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: rgba(34,197,94,0.14);
border: 1px solid rgba(34,197,94,0.25);
color: #bbf7d0;
font-weight: 900;
font-size: 12px;
}
.pill.warn {
background: rgba(245,158,11,0.14);
border-color: rgba(245,158,11,0.25);
color: #fde68a;
}
.log {
height: 360px;
overflow: auto;
background: rgba(0,0,0,0.45);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 16px;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.players {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-top: 12px;
}
.player {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
padding: 10px 12px;
}
.player .name { font-weight: 900; }
.player .meta { opacity: 0.85; font-size: 12px; margin-top: 4px; }
code { background: rgba(99,102,241,0.14); padding: 2px 6px; border-radius: 10px; }
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<div class="title">
<h1>🎮 对局准备</h1>
<p>来自匹配结果的统一入口页:自动加入 <code>game_room</code>,等待所有人到齐后开始。</p>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end;">
<span class="pill" id="statusPill">准备中</span>
<button class="btn gray" onclick="backToMatch()">返回匹配页</button>
<button class="btn danger" id="btnLeave" onclick="leaveRoom()" disabled>离开对局</button>
<button class="btn success" id="btnReconnect" onclick="reconnectRoom()" disabled>重连</button>
</div>
</div>
<div class="grid">
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
<h2 style="margin:0;">📌 房间信息</h2>
<button class="btn" id="btnJoin" onclick="joinRoom()">加入对局</button>
</div>
<div class="kv">
<div class="item"><b>serverUrl</b><span id="serverUrl">-</span></div>
<div class="item"><b>roomId</b><span id="roomId">-</span></div>
<div class="item"><b>matchId</b><span id="matchId">-</span></div>
<div class="item"><b>seatIndex</b><span id="seatIndex">-</span></div>
<div class="item"><b>reconnectKey</b><span id="reconnectKey">-</span></div>
<div class="item"><b>sessionId</b><span id="sessionId">-</span></div>
</div>
<div style="margin-top:12px;">
<h3 style="margin:0 0 10px;">👥 玩家列表(来自 state.players</h3>
<div class="players" id="playersBox"></div>
</div>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
<h2 style="margin:0;">🧾 日志</h2>
<button class="btn gray" onclick="clearLog()">清空</button>
</div>
<div class="log" id="logBox" style="margin-top:10px;"></div>
</div>
</div>
</div>
<script>
let client = null;
let room = null;
let payload = null;
function $(id) { return document.getElementById(id); }
function now() { return new Date().toLocaleTimeString(); }
function log(msg) { $("logBox").textContent = `[${now()}] ${msg}\n` + $("logBox").textContent; }
function clearLog() { $("logBox").textContent = ""; log("日志已清空"); }
function setPill(text, kind) {
const el = $("statusPill");
el.textContent = text;
el.className = kind === "warn" ? "pill warn" : "pill";
}
function loadPayload() {
try {
const raw = sessionStorage.getItem("xymj_match_found");
payload = raw ? JSON.parse(raw) : null;
} catch (e) { payload = null; }
if (!payload || !payload.serverUrl || !payload.token || !payload.roomId) {
setPill("缺少匹配信息", "warn");
log("未找到 sessionStorage[xymj_match_found],请从 MatchmakingDemo.html 重新进入");
$("btnJoin").disabled = true;
$("btnReconnect").disabled = true;
return;
}
$("serverUrl").textContent = payload.serverUrl;
$("roomId").textContent = payload.roomId;
$("matchId").textContent = payload.matchId || "-";
$("seatIndex").textContent = String(payload.seatIndex ?? "-");
$("reconnectKey").textContent = payload.reconnectKey || "-";
setPill("待加入对局");
}
function renderPlayers(state) {
const box = $("playersBox");
box.innerHTML = "";
const list = [];
try {
if (state && state.players && state.players.forEach) {
state.players.forEach((p, sid) => {
list.push({
sid: String(sid),
userId: p.userId,
seatIndex: p.seatIndex,
online: p.online,
});
});
}
} catch (e) {}
if (!list.length) {
const div = document.createElement("div");
div.className = "player";
div.textContent = "暂无玩家信息(等待 state 同步)";
box.appendChild(div);
return;
}
list.sort((a,b) => (a.seatIndex - b.seatIndex));
for (const p of list) {
const div = document.createElement("div");
div.className = "player";
div.innerHTML =
`<div class="name">seat ${p.seatIndex} · ${String(p.userId || "unknown")}</div>` +
`<div class="meta">online=${p.online} · sid=${p.sid.slice(0,8)}</div>`;
box.appendChild(div);
}
}
async function joinRoom() {
if (!payload) return;
if (typeof Colyseus === "undefined") return alert("未加载 colyseus.js");
try {
setPill("加入中...");
log(`joinById -> ${payload.roomId}`);
client = new Colyseus.Client(payload.serverUrl);
room = await client.joinById(payload.roomId, {
token: payload.token,
demoUserId: payload.demoUserId || undefined,
matchId: payload.matchId,
seatIndex: payload.seatIndex,
reconnectKey: payload.reconnectKey,
});
$("sessionId").textContent = room.sessionId || "-";
$("btnLeave").disabled = false;
$("btnJoin").disabled = true;
$("btnReconnect").disabled = false;
setPill("已进入房间,等待开始");
log("✅ 已加入 game_room准备开始游戏");
room.onMessage("reconnect:ok", (msg) => log("reconnect:ok " + JSON.stringify(msg)));
room.onMessage("playerOffline", (msg) => log("playerOffline " + JSON.stringify(msg)));
room.onMessage("playerOnline", (msg) => log("playerOnline " + JSON.stringify(msg)));
room.onLeave((code) => {
log("onLeave code=" + code);
setPill("已离开,等待重连", "warn");
$("btnJoin").disabled = true;
$("btnLeave").disabled = true;
$("btnReconnect").disabled = false;
room = null;
});
room.onError((code, message) => log(`error [${code}] ${message}`));
// state
room.onStateChange((state) => {
renderPlayers(state);
});
} catch (e) {
console.error(e);
setPill("加入失败", "warn");
log("加入失败: " + (e.message || e));
alert("加入失败: " + (e.message || e));
}
}
async function leaveRoom() {
if (!room) return;
try { await room.leave(); } catch (e) {}
}
async function reconnectRoom() {
if (!payload) return;
if (typeof Colyseus === "undefined") return alert("未加载 colyseus.js");
try {
setPill("重连中...");
log(`reconnect joinById -> ${payload.roomId}`);
client = new Colyseus.Client(payload.serverUrl);
room = await client.joinById(payload.roomId, {
token: payload.token,
demoUserId: payload.demoUserId || undefined,
matchId: payload.matchId,
seatIndex: payload.seatIndex,
reconnectKey: payload.reconnectKey,
});
$("sessionId").textContent = room.sessionId || "-";
$("btnLeave").disabled = false;
$("btnReconnect").disabled = false;
setPill("已重连,等待开始");
log("✅ 重连成功");
room.onStateChange((state) => renderPlayers(state));
} catch (e) {
console.error(e);
setPill("重连失败", "warn");
log("重连失败: " + (e.message || e));
alert("重连失败: " + (e.message || e));
}
}
function backToMatch() {
window.location.href = "MatchmakingDemo.html";
}
document.addEventListener("DOMContentLoaded", () => {
loadPayload();
// 自动加入(像“切入新页面准备开始游戏”)
if ($("btnJoin").disabled !== true) {
joinRoom();
}
});
</script>
</body>
</html>

View File

@@ -546,6 +546,28 @@
</div>
</div>
<div class="card">
<h2>🎯 匹配赛演示</h2>
<div class="api-endpoint" style="background: #f5f3ff; border-left-color: #7c3aed;">
<div>
<span class="method get" style="background: #7c3aed;">DEMO</span>
<span class="endpoint-path">MatchmakingDemo.html</span>
<span class="status-badge status-info">主动匹配 / 被动开房 / 重连</span>
</div>
<div class="description">
类王者荣耀:大厅点开始匹配(动态人数)与房间码开房加入;匹配成功自动进入 <code>game_room</code>,支持模拟断线与重连
</div>
<div style="margin-top: 15px;">
<a href="MatchmakingDemo.html" class="swagger-link" style="color: #7c3aed; border-bottom-color: #7c3aed;">
🎯 打开匹配赛演示程序 →
</a>
</div>
<div class="description" style="margin-top: 10px; color:#666;">
<strong>提示:</strong>匹配成功后会自动跳转到 <code>MatchmakingGame.html</code> 进行“对局准备/开始游戏”。
</div>
</div>
</div>
<div class="card">
<h2>📈 并发压测</h2>
<div class="api-endpoint" style="background: #ecfdf5; border-left-color: #0d9488;">

View File

@@ -2,6 +2,8 @@ import { Client } from "@colyseus/core";
import { FrameSyncRoom, ClientInput } from "../utils/FrameSync";
import { MyRoomState, Player } from "./schema/MyRoomState";
import { RequireAuth } from "../utils/decorators/RequireAuth";
import crypto from "crypto";
import RedisClient from "../utils/redis";
/**
* 游戏房间示例 - 使用帧同步
@@ -9,10 +11,25 @@ import { RequireAuth } from "../utils/decorators/RequireAuth";
export class GameRoom extends FrameSyncRoom<MyRoomState> {
maxClients = 4;
state = new MyRoomState();
private reconnectWindowMs = Number.parseInt(
process.env.MATCH_RECONNECT_WINDOW_MS || "15000",
10
);
private pendingReconnect = new Map<
string,
{ oldSessionId: string; expireAtMs: number; reconnectKey: string; matchId: string }
>(); // userId -> info
@RequireAuth()
onCreate(options: any) {
console.log("[GameRoom] 房间创建:", this.roomId);
// 对接匹配赛:接入方可以在创建房间时传 playersPerMatch平台框架做兜底
if (options?.playersPerMatch) {
const n = Number.parseInt(String(options.playersPerMatch), 10);
if (!Number.isNaN(n) && n > 0) {
this.maxClients = n;
}
}
// 初始化帧同步20 FPS
this.initFrameSync({
@@ -31,13 +48,63 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
});
}
// @RequireAuth()
@RequireAuth()
onJoin(client: Client, options: any) {
console.log(`[GameRoom] ${client.sessionId} 加入房间`);
const userId = String(options.demoUserId ? String(options.demoUserId).trim() : options.userId);
const matchId = options.matchId ? String(options.matchId) : "";
const seatIndex =
options.seatIndex !== undefined ? Number.parseInt(String(options.seatIndex), 10) : -1;
const reconnectKey = options.reconnectKey ? String(options.reconnectKey) : "";
// 断线重连:若存在等待重连记录,则校验 reconnectKey 并把旧 session 迁移到新 session
const migrated = this.tryConsumeReconnect(userId, matchId, reconnectKey);
if (migrated) {
const existing = this.state.players.get(migrated.oldSessionId);
if (existing) {
this.state.players.delete(migrated.oldSessionId);
existing.sessionId = client.sessionId;
existing.online = true;
this.state.players.set(client.sessionId, existing);
client.send("reconnect:ok", { matchId });
this.broadcast(
"playerOnline",
{ sessionId: client.sessionId, userId },
{ except: client }
);
return;
}
}
// 兜底去重:即使没有命中 pendingReconnect也保证同一 userId 在房间中只保留一个 player
// 场景:页面刷新/重复连接时旧 session 仍在重连窗口,导致状态里出现同 userId 多条记录
const duplicateSessionId = this.findSessionIdByUserId(userId);
if (duplicateSessionId && duplicateSessionId !== client.sessionId) {
const duplicate = this.state.players.get(duplicateSessionId);
if (duplicate) {
this.state.players.delete(duplicateSessionId);
duplicate.sessionId = client.sessionId;
duplicate.online = true;
if (!Number.isNaN(seatIndex) && seatIndex >= 0) {
duplicate.seatIndex = seatIndex;
}
this.state.players.set(client.sessionId, duplicate);
// 清理可能残留的重连记录,避免后续超时任务误删
this.pendingReconnect.delete(userId);
client.send("reconnect:ok", { matchId, dedup: true });
return;
}
}
// 创建玩家并添加到状态
const player = new Player();
player.sessionId = client.sessionId;
player.userId = userId;
player.seatIndex = Number.isNaN(seatIndex) ? -1 : seatIndex;
player.online = true;
player.x = Math.random() * 800; // 随机初始位置
player.y = Math.random() * 500;
this.state.players.set(client.sessionId, player);
@@ -59,12 +126,59 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
onLeave(client: Client, consented: boolean) {
console.log(`[GameRoom] ${client.sessionId} 离开房间`);
// 从状态中移除玩家
const player = this.state.players.get(client.sessionId);
const userId = player?.userId ? String(player.userId) : "";
const matchId = ""; // 可后续从 options/metadata 注入
if (!consented && player && userId) {
// 标记离线并给予重连窗口
player.online = false;
const reconnectKey = crypto.randomUUID();
const expireAtMs = Date.now() + this.reconnectWindowMs;
this.pendingReconnect.set(userId, {
oldSessionId: client.sessionId,
expireAtMs,
reconnectKey,
matchId,
});
void this.persistReconnect(
userId,
matchId,
reconnectKey,
Math.ceil(this.reconnectWindowMs / 1000)
);
// 延迟真正移除
this.clock.setTimeout(() => {
const info = this.pendingReconnect.get(userId);
if (!info) return;
if (info.oldSessionId !== client.sessionId) return;
if (Date.now() < info.expireAtMs) return;
this.pendingReconnect.delete(userId);
this.state.players.delete(client.sessionId);
this.broadcast("playerLeft", {
sessionId: client.sessionId,
playerCount: this.state.players.size,
});
}, this.reconnectWindowMs);
this.broadcast("playerOffline", {
sessionId: client.sessionId,
userId,
reconnectWindowMs: this.reconnectWindowMs,
});
return;
}
// 主动离开或无重连信息:直接移除
this.state.players.delete(client.sessionId);
console.log(`[GameRoom] 当前玩家数量: ${this.state.players.size}`);
// 广播玩家离开消息
this.broadcast("playerLeft", {
sessionId: client.sessionId,
@@ -77,6 +191,43 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
this.stopFrameSync();
}
private tryConsumeReconnect(userId: string, matchId: string, reconnectKey: string) {
if (!userId || !reconnectKey) return null;
const info = this.pendingReconnect.get(userId);
if (!info) return null;
if (Date.now() > info.expireAtMs) {
this.pendingReconnect.delete(userId);
return null;
}
if (info.reconnectKey !== reconnectKey) return null;
if (matchId && info.matchId && matchId !== info.matchId) return null;
this.pendingReconnect.delete(userId);
return info;
}
private async persistReconnect(
userId: string,
matchId: string,
reconnectKey: string,
ttlSeconds: number
) {
try {
const redis = RedisClient.getInstance();
await redis.connect();
const key = `mm:reconnect:${matchId || "none"}:${userId}`;
await redis.set(key, reconnectKey, ttlSeconds);
} catch {
// ignore if redis not available
}
}
private findSessionIdByUserId(userId: string): string | null {
for (const [sessionId, player] of this.state.players.entries()) {
if (player.userId === userId) return sessionId;
}
return null;
}
/**
* 帧更新 - 处理游戏逻辑
*/

447
src/rooms/MatchmakerRoom.ts Normal file
View File

@@ -0,0 +1,447 @@
import type Redis from "ioredis";
import { Room, Client, matchMaker } from "colyseus";
import crypto from "crypto";
import { RequireAuth } from "../utils/decorators/RequireAuth";
import { MatchmakingService } from "../services/matchmaking/MatchmakingService";
import type { MatchFindRequest, PartyCreateRequest, PartyJoinRequest } from "../services/matchmaking/types";
import RedisClient from "../utils/redis";
type SessionUser = {
userId: string;
/** 演示用:允许同一 token 多开时区分不同“玩家” */
demoUserId?: string;
username?: string;
ticketId?: string;
queueKey?: string;
playersPerMatch?: number;
partyId?: string;
};
function randomCode(len: number) {
// 去掉容易混淆的字符
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < len; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
function clampInt(n: any, min: number, max: number) {
const v = Number.parseInt(String(n), 10);
if (Number.isNaN(v)) return min;
return Math.max(min, Math.min(max, v));
}
/**
* 匹配入口房间
* - 主动模式:排队凑人 -> 创建对局房
* - 被动模式:创建 party(房间码) -> 其他人加入 -> 满人后创建对局房
*/
export class MatchmakerRoom extends Room {
maxClients = 5000;
private mm = new MatchmakingService();
private users = new Map<string, SessionUser>(); // sessionId -> user
private tick?: NodeJS.Timeout;
private redisSub: Redis | null = null;
private notifyChannel = "colyseus:mm:notify";
@RequireAuth()
onCreate(options: any) {
// 主动匹配
this.onMessage("match:find", async (client, message: MatchFindRequest) => {
const user = this.getUser(client);
const modeId = String(message?.modeId || "default");
const playersPerMatch = clampInt(message?.playersPerMatch, 2, 100);
const region = message?.region ? String(message.region) : undefined;
const { queueKey, ticketId } = await this.mm.enqueue({
userId: user.userId,
sessionId: client.sessionId,
username: user.username,
modeId,
playersPerMatch,
region,
skill: message?.skill,
tags: message?.tags,
enqueueAtMs: Date.now(),
});
user.ticketId = ticketId;
user.queueKey = queueKey;
user.playersPerMatch = playersPerMatch;
client.send("match:queued", { queueKey });
// 尝试立刻组局(跨实例安全)
await this.tryMatch(queueKey, playersPerMatch);
});
this.onMessage("match:cancel", async (client) => {
const user = this.getUser(client);
if (!user.queueKey || !user.ticketId) {
client.send("match:cancelled", { ok: true });
return;
}
const ok = await this.mm.cancelEnqueue(user.queueKey, user.ticketId);
user.queueKey = undefined;
user.ticketId = undefined;
client.send("match:cancelled", { ok });
});
// 被动开房party
this.onMessage("party:create", async (client, message: PartyCreateRequest) => {
const user = this.getUser(client);
const modeId = String(message?.modeId || "default");
const playersPerMatch = clampInt(message?.playersPerMatch, 2, 100);
const region = message?.region ? String(message.region) : undefined;
const partyId = crypto.randomUUID();
const partyCode = randomCode(6);
await this.mm.createParty({
partyId,
partyCode,
modeId,
playersPerMatch,
region,
leaderUserId: user.userId,
createdAtMs: Date.now(),
});
user.partyId = partyId;
client.send("party:created", {
partyId,
partyCode,
modeId,
playersPerMatch,
region,
leaderUserId: user.userId,
isLeader: true,
});
});
this.onMessage("party:join", async (client, message: PartyJoinRequest) => {
const user = this.getUser(client);
const partyCode = String(message?.partyCode || "").trim().toUpperCase();
if (!partyCode) {
client.send("party:error", { message: "partyCode 不能为空" });
return;
}
const partyId = await this.mm.getPartyIdByCode(partyCode);
if (!partyId) {
client.send("party:error", { message: "房间码不存在或已过期" });
return;
}
const party = await this.mm.getParty(partyId);
if (!party) {
client.send("party:error", { message: "房间已关闭" });
return;
}
const count = await this.mm.addPartyMember(partyId, {
userId: user.userId,
username: user.username,
joinedAtMs: Date.now(),
});
user.partyId = partyId;
const isLeader = party.leaderUserId === user.userId;
client.send("party:joined", {
partyId,
partyCode,
count,
playersPerMatch: party.playersPerMatch,
leaderUserId: party.leaderUserId,
isLeader,
});
// 广播 party 状态给当前在线成员(用于前端更新人数/房主标识)
await this.broadcastPartyUpdate(partyId);
});
this.onMessage("party:leave", async (client) => {
const user = this.getUser(client);
if (!user.partyId) {
client.send("party:left", { ok: true });
return;
}
const partyId = user.partyId;
user.partyId = undefined;
const remaining = await this.mm.removePartyMember(partyId, user.userId);
if (remaining <= 0) {
await this.mm.closeParty(partyId);
} else {
await this.broadcastPartyUpdate(partyId);
}
client.send("party:left", { ok: true });
});
this.onMessage("party:start", async (client) => {
const user = this.getUser(client);
if (!user.partyId) {
client.send("party:error", { message: "你当前不在房间队伍中" });
return;
}
const party = await this.mm.getParty(user.partyId);
if (!party) {
client.send("party:error", { message: "房间已关闭或过期" });
return;
}
if (party.leaderUserId !== user.userId) {
client.send("party:error", { message: "只有房主可以开始游戏" });
return;
}
const members = await this.mm.getPartyMemberInfos(user.partyId);
if (members.length < party.playersPerMatch) {
client.send("party:error", {
message: `人数不足,当前 ${members.length}/${party.playersPerMatch}`,
});
return;
}
await this.startMatchFromParty(user.partyId);
});
// 定时扫:避免“只入队但没人触发 tryMatch”
this.tick = setInterval(async () => {
const seen = new Set<string>();
for (const u of this.users.values()) {
if (!u.queueKey || !u.playersPerMatch) continue;
if (seen.has(u.queueKey)) continue;
seen.add(u.queueKey);
// 低频尝试,避免频繁抢锁
await this.tryMatch(u.queueKey, u.playersPerMatch);
}
}, 1000);
void this.setupNotifyBridge();
}
onJoin(client: Client, options: any) {
// RequireAuth 会把 userId/username 写入 options
const demoUserIdRaw = options?.demoUserId ? String(options.demoUserId).trim() : "";
const effectiveUserId = demoUserIdRaw || String(options.userId);
this.users.set(client.sessionId, {
userId: effectiveUserId,
demoUserId: demoUserIdRaw || undefined,
username: options.username ? String(options.username) : undefined,
});
client.send("mm:ready", { sessionId: client.sessionId });
}
async onLeave(client: Client) {
const user = this.users.get(client.sessionId);
if (user?.queueKey && user.ticketId) {
await this.mm.cancelEnqueue(user.queueKey, user.ticketId);
}
if (user?.partyId) {
const remaining = await this.mm.removePartyMember(user.partyId, user.userId);
if (remaining > 0) {
await this.broadcastPartyUpdate(user.partyId);
} else {
await this.mm.closeParty(user.partyId);
}
}
this.users.delete(client.sessionId);
}
onDispose() {
if (this.tick) clearInterval(this.tick);
void this.teardownNotifyBridge();
}
private getUser(client: Client): SessionUser {
const user = this.users.get(client.sessionId);
if (!user) {
throw new Error("user not found (did you join before sending messages?)");
}
return user;
}
private async tryMatch(queueKey: string, playersPerMatch: number) {
const match = await this.mm.tryFormMatch(queueKey, playersPerMatch);
if (!match) return;
// 创建对局房(复用 game_room
const roomName = "game_room";
const room = await matchMaker.createRoom(roomName, {
fps: 20,
recordFrames: false,
matchId: match.matchId,
playersPerMatch,
queueKey,
});
await this.mm.updateMatchRoom(match.matchId, roomName, room.roomId);
const notifications = match.players.map((p) => ({
userId: p.userId,
sessionId: p.sessionId,
payload: {
roomName,
roomId: room.roomId,
matchId: match.matchId,
seatIndex: p.seatIndex,
reconnectKey: p.reconnectKey,
joinOptions: {
matchId: match.matchId,
seatIndex: p.seatIndex,
reconnectKey: p.reconnectKey,
},
},
}));
// 本实例直发 + Redis Pub/Sub 广播(跨实例转发到在线用户)
this.deliverNotifications(notifications);
await this.publishNotify({ originRoomId: this.roomId, type: "match:found", notifications });
}
private async startMatchFromParty(partyId: string) {
const party = await this.mm.getParty(partyId);
if (!party) return;
const members = await this.mm.getPartyMemberInfos(partyId);
if (members.length < party.playersPerMatch) return;
const matchId = crypto.randomUUID();
const roomName = "game_room";
const room = await matchMaker.createRoom(roomName, {
fps: 20,
recordFrames: false,
matchId,
playersPerMatch: party.playersPerMatch,
partyId,
});
// 通知 party 内在线用户加入
const notifications = members.slice(0, party.playersPerMatch).map((m, i) => ({
userId: m.userId,
sessionId: this.findSessionIdByUserId(m.userId) || "",
payload: {
roomName,
roomId: room.roomId,
matchId,
seatIndex: i,
reconnectKey: crypto.randomUUID(),
joinOptions: { matchId, seatIndex: i, partyId },
},
}));
this.deliverNotifications(notifications);
await this.publishNotify({ originRoomId: this.roomId, type: "match:found", notifications });
await this.mm.closeParty(partyId);
}
private async broadcastPartyUpdate(partyId: string) {
const party = await this.mm.getParty(partyId);
if (!party) return;
const members = await this.mm.getPartyMembers(partyId);
const count = members.length;
for (const userId of members) {
const targetClient = this.findClientByUserId(userId);
if (!targetClient) continue;
targetClient.send("party:update", {
partyId,
count,
playersPerMatch: party.playersPerMatch,
leaderUserId: party.leaderUserId,
isLeader: party.leaderUserId === userId,
});
}
}
private findClientByUserId(userId: string): Client | null {
for (const [sessionId, u] of this.users.entries()) {
if (u.userId === userId) {
return this.clients.find((c) => c.sessionId === sessionId) || null;
}
}
return null;
}
private deliverNotifications(
notifications: Array<{ userId: string; sessionId?: string; payload: any }>
) {
for (const n of notifications) {
const targetClient =
(n.sessionId ? this.clients.find((c) => c.sessionId === n.sessionId) || null : null) ||
this.findClientByUserId(n.userId);
if (!targetClient) continue;
targetClient.send("match:found", n.payload);
}
}
private async publishNotify(envelope: any) {
try {
const redis = RedisClient.getInstance();
await redis.connect();
const client = redis.getClient();
await client.publish(this.notifyChannel, JSON.stringify(envelope));
} catch (e: any) {
console.warn("[MatchmakerRoom] Redis notify publish 失败:", e?.message || e);
}
}
private async setupNotifyBridge() {
try {
const redis = RedisClient.getInstance();
await redis.connect();
const dup = redis.getClient().duplicate();
this.redisSub = dup;
dup.on("error", (err: Error) => {
console.error("[MatchmakerRoom] Redis notify 订阅连接错误:", err.message);
});
await dup.subscribe(this.notifyChannel);
dup.on("message", (_channel: string, message: string) => {
try {
const data = JSON.parse(message) as any;
if (data?.originRoomId && data.originRoomId === this.roomId) return;
if (data?.type !== "match:found") return;
const notifications = Array.isArray(data.notifications) ? data.notifications : [];
this.deliverNotifications(
notifications.map((n: any) => ({
userId: String(n.userId),
sessionId: n.sessionId ? String(n.sessionId) : undefined,
payload: n.payload,
}))
);
} catch {
/* ignore malformed */
}
});
} catch (e: any) {
console.warn(
"[MatchmakerRoom] Redis notify 桥接未启用(仅同实例可收 match:found:",
e?.message || e
);
}
}
private async teardownNotifyBridge() {
if (!this.redisSub) return;
const sub = this.redisSub;
this.redisSub = null;
try {
await sub.unsubscribe(this.notifyChannel);
await sub.quit();
} catch {
try {
sub.disconnect();
} catch {
/* ignore */
}
}
}
private findSessionIdByUserId(userId: string): string | null {
for (const [sessionId, u] of this.users.entries()) {
if (u.userId === userId) return sessionId;
}
return null;
}
}

View File

@@ -2,6 +2,9 @@ import { Schema, type, MapSchema } from "@colyseus/schema";
export class Player extends Schema {
@type("string") sessionId: string = "";
@type("string") userId: string = "";
@type("number") seatIndex: number = -1;
@type("boolean") online: boolean = true;
@type("number") x: number = 0;
@type("number") y: number = 0;
@type("boolean") attacking: boolean = false;

View File

@@ -110,7 +110,17 @@ export class AuthService {
// 查找用户(包含密码字段)
const user = await this.userRepository.findOne({
where: [{ username: dto.username }, { email: dto.username }],
select: ['id', 'username', 'email', 'password', 'nickname', 'avatar', 'status'],
select: [
'id',
'username',
'email',
'password',
'nickname',
'avatar',
'openid',
'guildId',
'status',
],
});
if (!user) {

View File

@@ -0,0 +1,265 @@
import crypto from "crypto";
import RedisClient from "../../utils/redis";
export interface QueueTicket {
userId: string;
sessionId: string;
username?: string;
modeId: string;
playersPerMatch: number;
region?: string;
skill?: number;
tags?: string[];
enqueueAtMs: number;
}
export interface PartyInfo {
partyId: string;
partyCode: string;
modeId: string;
playersPerMatch: number;
region?: string;
leaderUserId: string;
createdAtMs: number;
}
export interface PartyMemberInfo {
userId: string;
username?: string;
joinedAtMs: number;
}
export interface MatchInfo {
matchId: string;
queueKey: string;
createdAtMs: number;
players: Array<{
userId: string;
sessionId: string;
username?: string;
seatIndex: number;
reconnectKey: string;
}>;
roomName?: string;
roomId?: string;
status: "created" | "room_created" | "closed";
}
function clampInt(n: any, min: number, max: number) {
const v = Number.parseInt(String(n), 10);
if (Number.isNaN(v)) return min;
return Math.max(min, Math.min(max, v));
}
export class MatchmakingService {
private redis = RedisClient.getInstance();
/** queueKey 必须包含 playersPerMatch确保同队列人数一致 */
getQueueKey(params: { modeId: string; playersPerMatch: number; region?: string }) {
const playersPerMatch = clampInt(params.playersPerMatch, 2, 100);
const region = params.region ? String(params.region) : "global";
return `queue:${params.modeId}:${playersPerMatch}:${region}`;
}
private async getRawRedis() {
const client = this.redis.getClient();
return client;
}
async enqueue(ticket: QueueTicket): Promise<{ queueKey: string; ticketId: string }> {
const client = await this.getRawRedis();
const queueKey = this.getQueueKey(ticket);
const ticketId = crypto.randomUUID();
const ticketKey = `mm:ticket:${ticketId}`;
const payload = {
...ticket,
ticketId,
};
// ticket 保存 10 分钟,便于查找/取消/调试
await client
.multi()
.set(ticketKey, JSON.stringify(payload), "EX", 60 * 10)
// ZSET: score 使用时间戳,后续可改为匹配分数
.zadd(queueKey, String(ticket.enqueueAtMs), ticketId)
.exec();
return { queueKey, ticketId };
}
async cancelEnqueue(queueKey: string, ticketId: string): Promise<boolean> {
const client = await this.getRawRedis();
const ticketKey = `mm:ticket:${ticketId}`;
const res = await client.multi().zrem(queueKey, ticketId).del(ticketKey).exec();
const zrem = Number(res?.[0]?.[1] ?? 0);
return zrem > 0;
}
async tryFormMatch(queueKey: string, playersPerMatch: number): Promise<MatchInfo | null> {
const client = await this.getRawRedis();
const lockKey = `mm:lock:${queueKey}`;
const lockId = crypto.randomUUID();
// 短锁,避免多实例重复出队组局
const lockOk = await client.set(lockKey, lockId, "PX", 2000, "NX");
if (!lockOk) return null;
try {
const n = clampInt(playersPerMatch, 2, 100);
const lua = `
local queueKey = KEYS[1]
local n = tonumber(ARGV[1])
local ids = redis.call('ZRANGE', queueKey, 0, n - 1)
if (#ids < n) then
return {}
end
redis.call('ZREM', queueKey, unpack(ids))
return ids
`;
const ids = (await client.eval(lua, 1, queueKey, String(n))) as string[];
if (!ids || ids.length !== n) return null;
const tickets = await Promise.all(ids.map((id) => client.get(`mm:ticket:${id}`)));
const players = tickets
.map((t) => {
if (!t) return null;
try {
return JSON.parse(t) as QueueTicket & { ticketId: string };
} catch {
return null;
}
})
.filter(Boolean) as Array<QueueTicket & { ticketId: string }>;
if (players.length !== n) {
// 有坏数据就放弃本次组局(简单起见);可扩展为回滚
return null;
}
const matchId = crypto.randomUUID();
const createdAtMs = Date.now();
const match: MatchInfo = {
matchId,
queueKey,
createdAtMs,
status: "created",
players: players.map((p, idx) => ({
userId: p.userId,
sessionId: p.sessionId,
username: p.username,
seatIndex: idx,
reconnectKey: crypto.randomUUID(),
})),
};
await client.set(`mm:match:${matchId}`, JSON.stringify(match), "EX", 60 * 10);
// 清理 ticket避免重复用
await Promise.all(ids.map((id) => client.del(`mm:ticket:${id}`)));
return match;
} finally {
// 只在锁仍属于自己时释放(简单校验)
const val = await client.get(lockKey);
if (val === lockId) {
await client.del(lockKey);
}
}
}
async updateMatchRoom(matchId: string, roomName: string, roomId: string) {
const client = await this.getRawRedis();
const raw = await client.get(`mm:match:${matchId}`);
if (!raw) return;
let match: MatchInfo;
try {
match = JSON.parse(raw) as MatchInfo;
} catch {
return;
}
match.roomName = roomName;
match.roomId = roomId;
match.status = "room_created";
await client.set(`mm:match:${matchId}`, JSON.stringify(match), "EX", 60 * 10);
}
async createParty(params: PartyInfo): Promise<void> {
const client = await this.getRawRedis();
await client.set(`mm:party:${params.partyId}`, JSON.stringify(params), "EX", 60 * 30);
await client.sadd(`mm:party:${params.partyId}:members`, params.leaderUserId);
await client.hset(
`mm:party:${params.partyId}:memberInfo`,
params.leaderUserId,
JSON.stringify({
userId: params.leaderUserId,
joinedAtMs: Date.now(),
})
);
await client.expire(`mm:party:${params.partyId}:members`, 60 * 30);
await client.expire(`mm:party:${params.partyId}:memberInfo`, 60 * 30);
await client.set(`mm:partyCode:${params.partyCode}`, params.partyId, "EX", 60 * 30);
}
async getPartyIdByCode(partyCode: string): Promise<string | null> {
const client = await this.getRawRedis();
return await client.get(`mm:partyCode:${partyCode}`);
}
async getParty(partyId: string): Promise<PartyInfo | null> {
const client = await this.getRawRedis();
const raw = await client.get(`mm:party:${partyId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as PartyInfo;
} catch {
return null;
}
}
async addPartyMember(partyId: string, member: PartyMemberInfo): Promise<number> {
const client = await this.getRawRedis();
await client.sadd(`mm:party:${partyId}:members`, member.userId);
await client.hset(`mm:party:${partyId}:memberInfo`, member.userId, JSON.stringify(member));
return await client.scard(`mm:party:${partyId}:members`);
}
async getPartyMemberInfos(partyId: string): Promise<PartyMemberInfo[]> {
const client = await this.getRawRedis();
const raw = await client.hgetall(`mm:party:${partyId}:memberInfo`);
const out: PartyMemberInfo[] = [];
for (const v of Object.values(raw)) {
try {
out.push(JSON.parse(v) as PartyMemberInfo);
} catch {
// ignore
}
}
// 稳定排序:先 joinedAtMs再 userId
out.sort((a, b) => (a.joinedAtMs - b.joinedAtMs) || a.userId.localeCompare(b.userId));
return out;
}
async removePartyMember(partyId: string, userId: string): Promise<number> {
const client = await this.getRawRedis();
await client.srem(`mm:party:${partyId}:members`, userId);
await client.hdel(`mm:party:${partyId}:memberInfo`, userId);
return await client.scard(`mm:party:${partyId}:members`);
}
async getPartyMembers(partyId: string): Promise<string[]> {
const client = await this.getRawRedis();
return await client.smembers(`mm:party:${partyId}:members`);
}
async closeParty(partyId: string) {
const client = await this.getRawRedis();
const party = await this.getParty(partyId);
await client.del(`mm:party:${partyId}`);
await client.del(`mm:party:${partyId}:members`);
await client.del(`mm:party:${partyId}:memberInfo`);
if (party?.partyCode) {
await client.del(`mm:partyCode:${party.partyCode}`);
}
}
}

View File

@@ -0,0 +1,33 @@
export type MatchMode = "active" | "passive";
export interface MatchFindRequest {
modeId: string;
/** 接入方决定一局人数 */
playersPerMatch: number;
/** 可选:段位/分数,用于后续扩展分段匹配 */
skill?: number;
/** 可选:大区 */
region?: string;
/** 可选:附加过滤维度 */
tags?: string[];
}
export interface PartyCreateRequest {
modeId: string;
playersPerMatch: number;
region?: string;
}
export interface PartyJoinRequest {
partyCode: string;
}
export interface MatchFoundPayload {
roomName: string;
roomId: string;
matchId: string;
seatIndex: number;
reconnectKey: string;
joinOptions: Record<string, any>;
}

View File

@@ -32,6 +32,11 @@ export function RequireAuth() {
const redis = RedisClient.getInstance();
descriptor.value = async function (...args: any[]) {
// 单元测试环境跳过鉴权(测试通常不依赖真实 JWT/Redis
if (process.env.NODE_ENV === "test") {
return await originalMethod.apply(this, args);
}
// 对于onJoin第一个参数是client第二个是options
// 对于onCreate第一个参数是options
let options: any;

52
src/utils/log.ts Normal file
View File

@@ -0,0 +1,52 @@
type LogLevel = "log" | "info" | "warn" | "error";
const BLUE = "\x1b[34m";
const RESET = "\x1b[0m";
function prefix(category: string) {
const cat = category && String(category).trim() ? String(category).trim() : "系统";
return `${BLUE}[xymj][${cat}]${RESET}`;
}
export function formatLogArgs(category: string, args: any[]) {
// 保证 prefix 独立一个参数,避免影响原本对象打印
return [prefix(category), ...args];
}
export function installConsolePrefix(defaultCategory = "系统") {
const orig: Record<LogLevel, (...args: any[]) => void> = {
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
const wrap =
(level: LogLevel) =>
(...args: any[]) => {
// 允许调用方用 console.log("[数据库]", "xxx") 这种方式指定类别
let category = defaultCategory;
if (typeof args[0] === "string") {
const m = args[0].match(/^\s*\[([^\]]+)\]\s*/);
if (m) {
category = m[1];
args = [args[0].replace(m[0], ""), ...args.slice(1)];
if (args[0] === "") args = args.slice(1);
}
}
orig[level](...formatLogArgs(category, args));
};
console.log = wrap("log") as any;
console.info = wrap("info") as any;
console.warn = wrap("warn") as any;
console.error = wrap("error") as any;
return () => {
console.log = orig.log as any;
console.info = orig.info as any;
console.warn = orig.warn as any;
console.error = orig.error as any;
};
}

View File

@@ -47,16 +47,16 @@ class RedisClient {
});
this.client.on('connect', () => {
console.log('✅ Redis 连接成功');
console.log('[数据库]✅ Redis 连接成功');
});
this.client.on('error', (err: any) => {
const errorMsg = err?.message || err?.code || '未知错误';
console.error(`❌ Redis 连接错误: ${errorMsg}`);
console.error(`[数据库]❌ Redis 连接错误: ${errorMsg}`);
});
this.client.on('close', () => {
console.log('✅ Redis 连接已关闭');
console.log('[数据库]✅ Redis 连接已关闭');
});
}

View File

@@ -23,9 +23,14 @@ describe("testing your Colyseus app", () => {
// make your assertions
assert.strictEqual(client1.sessionId, room.clients[0].sessionId);
// wait for state sync
await room.waitForNextPatch();
// wait for state sync (client-side)
// (initial patch may arrive before we start awaiting)
await new Promise<void>((resolve) => setTimeout(resolve, 50));
assert.deepStrictEqual({ mySynchronizedProperty: "Hello world" }, client1.state.toJSON());
assert.deepStrictEqual(client1.state.toJSON(), {
mySynchronizedProperty: "Hello world",
frame: 0,
players: {},
});
});
});