棋牌游戏技术实战(一):用 Node.js 实现房间消息队列、请求幂等与断线恢复

做棋牌游戏开发时,房间同步经常是在正常网络下先跑通的。一方点一下,服务端收到消息,另一方的画面跟着变,看起来已经没问题了。等到网络慢一点、玩家连点几次,或者有人断线再回来,就会遇到重复操作、画面落后、轮到谁行动不一致这些问题。

这篇就从一个小工程开始,把这些情况逐个处理掉。操作走HTTP,服务端用SSE把状态发给客户端。最后能在两个浏览器页面里交替落子,也能主动断开其中一个页面的订阅,再连回来检查状态有没有补齐。代码保存位置、运行方法和实际测过的结果都会写清楚。

为了把注意力放在同步上,例子用十五乘十五的五子棋棋盘。Alice拿黑棋先走,Bob拿白棋,双方轮流下。横、竖或斜向连成五颗以上就结束,没有禁手。棋盘对两个人都公开,不涉及私有手牌、资金或运营功能。这个规则例子只是用来验证房间链路,不是完整游戏平台。

先说清楚一个容易混淆的地方:服务端能保证的是请求进入房间队列之后的执行顺序,不能靠两部手机上传的时间戳判断谁在现实里先点。回合规则负责检查当前是不是该这个人走,队列负责让同房间的操作一个接一个执行。客户端带来的状态序号,则用来判断它是不是拿着旧画面在操作。

还要考虑请求超时。客户端等了几秒没等到返回,并不说明服务端没有执行。可能棋子已经落下,只是成功响应没有到。此时直接生成一条新指令再发,就可能把一次操作变成两次。这里给每次操作一个稳定编号,网络不确定时继续用原编号和原数据重试。

重新连接又是另一件事。连接恢复只表示能收消息了,不能证明画面已经追上服务端。客户端需要告诉服务端自己已经处理到哪一个序号,服务端能补就补;缺失的事件太旧,已经清掉了,就发完整棋盘。这三件事合在一起,才是本篇要跑通的房间同步。

开发环境用Node.js 24.x,实际验证的是v24.21.0。HTTP、Fetch、流式读取和测试都用内置能力,不需要装第三方包,也不需要数据库或Redis。这样能先把问题定位在协议和状态处理上,等这一层跑通了,再接Cocos或其他游戏客户端。

示例工程放在系列目录的示例工程/01-room-sync。下面说的文件路径都以这个工程根目录为起点。自己照着创建时,目录保持一致,mjs后缀不要改成普通文本文件。先建好这些文件,后面再一起启动,文件之间的依赖就不会缺一半。

text 复制代码
01-room-sync/
├─ package.json
├─ .env.example
├─ src/queue.mjs
├─ src/room.mjs
├─ src/server.mjs
├─ src/main.mjs
├─ client/room-client.mjs
├─ web/index.html
└─ test/room.test.mjs

先写完整的package.json。这里只有启动和测试命令,没有依赖包。终端要开在工程根目录,如果执行node --version都找不到命令,先把Node.js安装和环境变量处理好,再重新开终端。

json 复制代码
{
  "name": "room-sync-lesson-01",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24 <25" },
  "scripts": {
    "start": "node --env-file=.env src/main.mjs",
    "test": "node --test test/*.test.mjs"
  }
}

接着写.env.example。两个令牌用于本地演示,分别对应Alice和Bob。复制成.env之后可以自行修改,但两个值要不同。它们不是完整的账号系统,只是让接口有明确的调用身份。真实登录接入时,需要换掉后面服务端里的身份识别函数。

dotenv 复制代码
PORT=3100
ALICE_TOKEN=local-alice-change-me
BOB_TOKEN=local-bob-change-me

文件都写完后,在工程根目录执行下面几行。环境文件只复制一次,已经有.env就不要再次覆盖自己的配置。服务启动后终端会一直占着,预期能看到本机监听地址,不需要等它自己退出。

powershell 复制代码
node --version
Copy-Item .env.example .env
# 修改 .env 中的两个本地演示令牌,再启动:
npm start

在写服务端之前,先确定一条落子指令长什么样。这里用requestId记住是哪一次操作,expectedSeq表示玩家是在第几个状态上作出的决定。行和列从零算起,中心格是七、七,最右下角是十四、十四。页面可以显示从一开始的行号,但发给服务端时必须统一。

还有一个expectedEpoch,它表示当前房间是哪一个实例。这个字段看起来有点多,实际很有用。本例重启后会重新创建demo房间,序号又从零开始。如果只检查房间名和序号,旧页面里的操作就可能被当成新房间的操作。实例编号变化之后,服务端可以明确拒绝旧指令。

下面只展示格式。实例编号要换成查询接口真实返回的UUID,不能把中文占位字样原样发出去。玩家身份不放在这份数据里,服务端通过Authorization请求头识别,避免有人在操作体中把自己改成另一个玩家。

json 复制代码
{
  "requestId": "request_001",
  "expectedEpoch": "替换为服务端返回的房间实例UUID",
  "expectedSeq": 0,
  "row": 7,
  "col": 7
}

状态序号只在落子成功时加一,重连和心跳都不加。请求编号也不能拿来代替序号:它负责识别同一次操作,序号负责说明操作依据的棋盘版本。服务端会保存已经成功的请求结果,重复请求拿到的是原结果,不是重新下了一颗棋之后的新结果。

顺序上要先找成功记录,再检查新操作的序号。比如第一次请求带序号零,服务端成功后变成一,客户端却丢了响应。重试仍然带零,服务端应该认出这是已经成功的那次操作。如果先拿零和当前的一比较,重试就被误拒绝了,客户端仍不知道之前到底有没有下成功。

现在写完整的src/queue.mjs。这个文件只做队列和错误对象,不碰HTTP,也不懂棋盘。调用时给它房间编号和一个任务函数,它返回这次任务的结果。后面的房间服务会通过它,把同房间的读取、检查和修改放在同一条执行顺序里。

javascript 复制代码
export function fail(code, status = 409) {
  throw Object.assign(new Error(code), { code, status });
}

export class RoomQueue {
  #tails = new Map();
  #counts = new Map();
  constructor(limit = 64) { this.limit = limit; }
  run(roomId, task) {
    const count = this.#counts.get(roomId) ?? 0;
    if (count >= this.limit) return Promise.reject(
      Object.assign(new Error('ROOM_BUSY'), { code: 'ROOM_BUSY', status: 429 })
    );
    this.#counts.set(roomId, count + 1);
    const previous = this.#tails.get(roomId) ?? Promise.resolve();
    const result = previous.then(task);
    const tail = result.catch(() => {}).finally(() => {
      const left = this.#counts.get(roomId) - 1;
      if (left) this.#counts.set(roomId, left);
      else this.#counts.delete(roomId);
      if (this.#tails.get(roomId) === tail) this.#tails.delete(roomId);
    });
    this.#tails.set(roomId, tail);
    return result;
  }
}

做法是给每个房间存一个队尾Promise,新任务接在旧队尾后面。前一个任务还在等异步操作,后一个就不能开始。不同房间各有各的队尾,所以房间甲等待时,房间乙不必一起等。不过它们还在同一个Node.js进程里,长时间的同步计算仍然会卡住其他房间,这一点不能靠队列消除。

这里分开保存result和tail,是为了让失败后的队列还能继续。调用方拿到的是result,该报错就报错;内部的tail把异常接住,下一次任务才有机会执行。要是一直把失败的Promise当作队尾,后面的任务可能一直接着失败,却根本没有运行自己的函数。

任务结束时会清理计数和队尾,但不能随便删除。旧任务完成的一刻,新任务可能已经接上去了,所以还要比较当前队尾是不是自己。默认一个房间最多接收六十四个运行中或等待中的任务,满了就拒绝新任务,已经进来的任务继续执行。这个数字是实验配置,不是测出来的并发承载量。

使用时还有一个细节:任务内部有异步工作,就必须返回或等待它对应的Promise。只是在函数里启动一个数据库调用,马上返回,队列会以为业务已经结束。函数名字叫异步、代码用了队列,都不能保证顺序,真正要看它在什么时候告诉队列"这次做完了"。

接着写完整的src/room.mjs。前面一段处理落子规则,后面一段管理房间、事件和成功结果。它依赖刚才的队列模块及Node.js内置UUID。服务启动时已经有一个demo房间,成员固定为Alice和Bob,没有建房和入房接口,这篇从双方已经在房间里开始。

javascript 复制代码
import { randomUUID } from 'node:crypto';
import { fail, RoomQueue } from './queue.mjs';
export const SIZE = 15;

export function applyMove(state, seat, row, col) {
  if (state.winner !== null || state.draw) fail('GAME_FINISHED');
  if (state.turn !== seat) fail('NOT_YOUR_TURN');
  if (![row, col].every(n => Number.isInteger(n) && n >= 0 && n < SIZE)) {
    fail('INVALID_COORDINATE', 400);
  }
  const index = row * SIZE + col;
  if (state.board[index]) fail('CELL_OCCUPIED');
  const board = [...state.board];
  const stone = seat + 1;
  board[index] = stone;
  const count = (dr, dc) => {
    let n = 0, r = row + dr, c = col + dc;
    while (r >= 0 && r < SIZE && c >= 0 && c < SIZE && board[r * SIZE + c] === stone) {
      n++; r += dr; c += dc;
    }
    return n;
  };
  const won = [[1, 0], [0, 1], [1, 1], [1, -1]].some(
    ([dr, dc]) => 1 + count(dr, dc) + count(-dr, -dc) >= 5
  );
  const moves = state.moves + 1;
  return { board, turn: 1 - seat, moves, winner: won ? seat : null,
    draw: !won && moves === SIZE * SIZE };
}

export class RoomService {
  constructor({ historyLimit = 64, receiptLimit = 512 } = {}) {
    this.historyLimit = historyLimit;
    this.receiptLimit = receiptLimit;
    this.queue = new RoomQueue();
    this.rooms = new Map();
    this.rooms.set('demo', this.newRoom());
  }
  newRoom() {
    return { epoch: randomUUID(), seq: 0, members: ['alice', 'bob'],
      state: { board: Array(SIZE * SIZE).fill(0), turn: 0, moves: 0,
        winner: null, draw: false }, receipts: new Map(), events: [], peers: new Set() };
  }
  room(roomId, user) {
    const room = this.rooms.get(roomId);
    if (!room) fail('ROOM_NOT_FOUND', 404);
    if (!room.members.includes(user)) fail('NOT_A_MEMBER', 403);
    return room;
  }
  snapshot(room) {
    return structuredClone({ mode: 'snapshot', epoch: room.epoch,
      seq: room.seq, state: room.state });
  }
  resume(room, epoch, since) {
    const first = room.events[0]?.seq ?? room.seq + 1;
    if (epoch !== room.epoch || since > room.seq || since < first - 1) {
      return this.snapshot(room);
    }
    return { mode: 'delta', epoch: room.epoch, seq: room.seq,
      events: structuredClone(room.events.filter(e => e.seq > since)) };
  }
  command(roomId, user, input) {
    return this.queue.run(roomId, () => {
      const room = this.room(roomId, user);
      if (!input || typeof input !== 'object' || Array.isArray(input)) fail('BAD_COMMAND', 400);
      const { requestId, expectedEpoch, expectedSeq, row, col } = input;
      if (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{8,80}$/.test(requestId)) {
        fail('INVALID_REQUEST_ID', 400);
      }
      if (typeof expectedEpoch !== 'string' || expectedEpoch.length !== 36 ||
          !Number.isSafeInteger(expectedSeq) || expectedSeq < 0 ||
          !Number.isInteger(row) || !Number.isInteger(col)) fail('BAD_COMMAND', 400);
      const key = `${user}:${requestId}`;
      const fingerprint = JSON.stringify([expectedEpoch, expectedSeq, row, col]);
      const saved = room.receipts.get(key);
      if (saved) {
        if (saved.fingerprint !== fingerprint) fail('REQUEST_ID_REUSED');
        return structuredClone(saved.result);
      }
      if (expectedEpoch !== room.epoch) fail('ROOM_REPLACED');
      if (room.receipts.size >= this.receiptLimit) fail('ROOM_RECEIPTS_FULL', 503);
      if (expectedSeq !== room.seq) fail('STALE_SEQ');
      const seat = room.members.indexOf(user);
      const next = applyMove(room.state, seat, row, col);
      const seq = room.seq + 1;
      const event = { seq, row, col, stone: seat + 1,
        turn: next.turn, moves: next.moves, winner: next.winner, draw: next.draw };
      const result = { accepted: true, requestId, epoch: room.epoch, seq };
      // No await between validation and this in-memory commit.
      room.state = next;
      room.seq = seq;
      room.events.push(event);
      if (room.events.length > this.historyLimit) room.events.shift();
      room.receipts.set(key, { fingerprint, result });
      const update = { mode: 'delta', epoch: room.epoch, seq, events: [event] };
      for (const peer of [...room.peers]) {
        try { peer(structuredClone(update)); }
        catch { room.peers.delete(peer); }
      }
      return structuredClone(result);
    });
  }
  watch(roomId, user, epoch, since, send) {
    return this.queue.run(roomId, () => {
      const room = this.room(roomId, user);
      const initial = this.resume(room, epoch, since);
      send(initial);
      room.peers.add(send);
      return () => room.peers.delete(send);
    });
  }
}

落子先检查是否已经结束、是不是轮到这个座位、坐标是否在范围内,以及位置有没有棋子。全部通过后才复制棋盘,生成下一份状态。不能先往旧棋盘上写,再发现不合法后想办法擦掉,那样容易留下落子数、行动方或其他字段没恢复的情况。

获胜座位可能是零,因此判断终局要写winner !== null,不能写成if (winner)。四个方向分别往正反两边数连续棋子,再加当前这颗。规则只检查经过新棋子的连线,不需要每次遍历整张棋盘。这个实现有明确规则边界,没有禁手,也没有把它描述成所有五子棋比赛都通用的判定。

每条操作都先进入房间队列,再检查成员和数据。成功结果的键由账号加请求编号组成,两个账号碰巧用了相同编号,不会读到彼此的记录。服务端身份来自认证过程,不能从落子数据里的自报账号拿。后面即使改成正式登录,这个关系也应该保持。

记录里还保存了操作数据的指纹,用固定顺序的数组转换成JSON。这样同一条数据只是对象字段顺序不同,不会被当成不同操作。实例、序号和坐标都在指纹里,因此保留请求编号却偷偷改目标位置,会得到REQUEST_ID_REUSED,不会借旧编号绕过检查。

确认合法后,新棋盘、序号、事件和成功结果在没有异步等待的一段代码里更新,然后才推送给订阅者。这样正常业务失败不会留下更新一半的状态,其他房间任务也不会插到这段中间。不过这还是内存操作,进程崩溃会丢数据,不能把它叫成已经落盘的事务。

最近事件默认保留六十四条,旧事件可以清掉,因为客户端补不全时还能要快照。成功结果的处理不一样,不能随便清掉之后再把迟到重试当成新操作。本例最多留五百一十二条成功结果,满了拒绝新操作,但已知请求仍可拿原结果。十五乘十五棋盘最多二百二十五次落子,默认容量够这次实验用。

这里缓存的是成功操作,不缓存规则拒绝。某个请求今天因为条件不满足失败,之后条件变了再发,结果可能不同。因此客户端遇到明确拒绝会清掉待确认操作,不把失败请求一直自动重试。要连失败结果也固定保存,需要另外确定保存范围和有效期,这份代码没有实现那种语义。

房间同步返回两种消息。第一次进入时没有实例编号,直接拿完整快照,里面有当前序号和整个棋盘。再次连接时带上已处理序号,服务端看看后面的事件还在不在。如果都在,就补缺少的部分;中间缺了一条,就重新发快照,不让客户端猜发生过什么。

比如当前序号三,只保留事件二和三。从一开始恢复,缺的正好是二和三,可以补;从零开始恢复,事件一已经没有了,就得发快照。边界判断用最早保留序号减一,刚好处在这个位置也能恢复。写严一条会白传快照,放宽一条则可能发出不完整增量。

客户端报的序号比服务端大,也不能直接回一个空列表说已经一致。可能房间重启过,或者客户端存错了,这时仍然以服务端状态为准,返回快照。实例不一致也走快照。客户端带来的数字只用来说明自己看到哪儿,不会反过来修改服务端状态。

订阅初始化和操作用同一个房间队列。任务里先生成初始化消息,再登记订阅函数,中间不等待其他异步工作。这样一颗棋要么在初始化前已经下好,被初始化消息带回来;要么在登记之后下好,被实时推送带回来。先查状态,过一会儿再订阅,两个步骤之间就容易漏掉操作。

下面写完整的src/server.mjs。它依赖内置HTTP、文件读取和房间模块。提供健康检查、提交操作、查询同步状态和订阅事件几个入口,也把后面要用的网页与客户端模块从固定路径送出去。静态文件只有两个明确地址,没有让用户URL直接拼成磁盘路径。

javascript 复制代码
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fail } from './queue.mjs';
import { RoomService } from './room.mjs';

async function readJson(req) {
  if ((req.headers['content-type'] ?? '').split(';')[0] !== 'application/json') {
    fail('JSON_REQUIRED', 415);
  }
  const chunks = [];
  let bytes = 0, tooLarge = false;
  for await (const chunk of req) {
    bytes += chunk.length;
    if (bytes > 4096) { tooLarge = true; continue; }
    if (!tooLarge) chunks.push(chunk);
  }
  if (tooLarge) fail('BODY_TOO_LARGE', 413);
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
  catch { fail('INVALID_JSON', 400); }
}
const json = (res, status, data) => {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store' });
  res.end(JSON.stringify(data));
};

export function buildServer(tokens, service = new RoomService()) {
  if (!tokens.alice || !tokens.bob || tokens.alice === tokens.bob) {
    throw new Error('Two distinct local demo tokens are required');
  }
  const identity = req => {
    const header = req.headers.authorization;
    const user = Object.keys(tokens).find(u => header === `Bearer ${tokens[u]}`);
    if (!user) fail('UNAUTHORIZED', 401);
    return user;
  };
  const streams = new Set();
  const server = createServer(async (req, res) => {
    try {
      const url = new URL(req.url, 'http://127.0.0.1');
      if (req.method === 'GET' && ['/', '/client/room-client.mjs'].includes(url.pathname)) {
        const isPage = url.pathname === '/';
        const file = new URL(isPage ? '../web/index.html' : '../client/room-client.mjs', import.meta.url);
        const content = await readFile(file);
        res.writeHead(200, { 'Content-Type': isPage ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8',
          'Cache-Control': 'no-store' });
        res.end(content); return;
      }
      if (req.method === 'GET' && url.pathname === '/health') {
        return json(res, 200, { status: 'ready', storage: 'memory' });
      }
      const match = /^\/api\/rooms\/([a-zA-Z0-9_-]+)\/(commands|sync|events)$/.exec(url.pathname);
      if (!match) fail('NOT_FOUND', 404);
      const [, roomId, action] = match;
      const user = identity(req);
      if (req.method === 'POST' && action === 'commands') {
        const result = await service.command(roomId, user, await readJson(req));
        return json(res, 200, result);
      }
      if (req.method !== 'GET' || !['sync', 'events'].includes(action)) fail('METHOD_NOT_ALLOWED', 405);
      const rawSince = url.searchParams.get('since') ?? '0';
      if (!/^\d{1,16}$/.test(rawSince)) fail('INVALID_SINCE', 400);
      const since = Number(rawSince);
      if (!Number.isSafeInteger(since)) fail('INVALID_SINCE', 400);
      const epoch = url.searchParams.get('epoch') ?? '';
      if (action === 'sync') {
        const data = await service.queue.run(roomId, () => {
          return service.resume(service.room(roomId, user), epoch, since);
        });
        return json(res, 200, data);
      }
      // Authorize before starting an SSE response.
      service.room(roomId, user);
      res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8',
        'Cache-Control': 'no-cache, no-transform', 'X-Accel-Buffering': 'no' });
      res.flushHeaders();
      streams.add(res);
      let unsubscribe, timer, closed = false;
      const cleanup = () => {
        closed = true;
        clearInterval(timer);
        unsubscribe?.();
        streams.delete(res);
      };
      res.on('close', cleanup);
      res.on('error', cleanup);
      const send = data => {
        if (closed || res.destroyed) throw new Error('STREAM_CLOSED');
        if (!res.write(`event: room\ndata: ${JSON.stringify(data)}\n\n`)) {
          res.destroy();
          throw new Error('SLOW_CONSUMER');
        }
      };
      unsubscribe = await service.watch(roomId, user, epoch, since, send);
      if (closed) { unsubscribe(); return; }
      timer = setInterval(() => {
        if (!res.write(': heartbeat\n\n')) res.destroy();
      }, 15000);
      timer.unref();
    } catch (error) {
      if (res.headersSent) { res.destroy(); return; }
      const status = error.status ?? 500;
      if (status === 500) console.error(error);
      json(res, status, { code: error.code ?? 'INTERNAL_ERROR' });
    }
  });
  server.requestTimeout = 10000;
  server.headersTimeout = 5000;
  return { server, service, close: async () => {
    for (const res of streams) res.destroy();
    await new Promise(resolve => server.close(resolve));
    server.closeAllConnections();
  } };
}

操作接口要求JSON,正文上限四千零九十六字节。这里算收到的Buffer长度,不按字符串字符数算,中文在UTF-8里一个字可能占几个字节。超过上限后不再积累正文,读完本次请求再拒绝,接收时间还有超时限制。这是本机实验的简单做法,公开入口还需要代理层限制和请求频率控制。

健康检查不需要身份,但房间查询和订阅需要。服务端先找令牌对应账号,再检查是不是房间成员,然后才打开SSE响应。否则身份还没确认就先发成功响应头,后面再发现没权限,已经没法把同一条响应改成普通的错误状态,只能关连接。

SSE的业务消息用一行event: room,再用一行data:跟JSON,最后空一行表示结束。服务端十五秒发一次注释心跳,客户端不把它当作棋盘事件。令牌放在Authorization头里,地址里只带实例和序号。本例为了带这个头,使用Fetch读取响应流,没有使用原生EventSource的自动恢复功能。

如果res.write返回假,说明发送缓冲已经开始积压,不是落子没成功。这里把这个慢订阅关掉,让它重新走恢复流程,不把它的等待加进房间操作队列。这样正常玩家的操作不会被一个慢连接拖住,也不为每个慢连接无限存消息。具体多少慢连接能承受,还要在部署环境里测,本文没有给这个数字。

连接结束要把订阅函数、心跳定时器和活动流引用一起清掉。清理监听响应的关闭和错误事件,不能把请求读完直接当成持续响应也结束了。还有一种情况是初始化任务还在队列里,客户端先关了。拿到取消订阅函数后再次检查关闭状态,就是为了避免这种时候留下无效订阅。

完整启动文件是src/main.mjs。它读取.env里的令牌和端口,只监听本机地址。缺少令牌、两个值相同或者端口不合法都会启动失败,先改配置再重启。退出时先关闭活动订阅,再关闭服务,避免长连接一直挂着让程序退不出来。

javascript 复制代码
import { buildServer } from './server.mjs';
const app = buildServer({ alice: process.env.ALICE_TOKEN, bob: process.env.BOB_TOKEN });
const port = Number(process.env.PORT ?? 3100);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('INVALID_PORT');
app.server.listen(port, '127.0.0.1', () => console.log(`Listening on http://127.0.0.1:${port}`));
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.once(signal, () => app.close().then(() => process.exit(0)));
}

服务跑起来后,另开一个终端检查。预期返回status=ready和storage=memory,后者明确告诉你这是内存状态。连不上就看服务终端的错误;端口占用可以改.env,但浏览器地址和下面命令里的端口也要跟着改,不能只改一处。

powershell 复制代码
Invoke-RestMethod http://127.0.0.1:3100/health

服务端能发消息之后,再接客户端。完整文件放在client/room-client.mjs,它既能作为浏览器原生模块使用,也能由Node.js测试导入。consume把消息应用到视图,RoomClient管连接和操作。渲染函数从外面传进来,所以没有把某个引擎的节点操作写死在同步模块里。

javascript 复制代码
export function consume(current, msg) {
  if (msg.mode === 'snapshot') {
    return structuredClone({ epoch: msg.epoch, seq: msg.seq, state: msg.state });
  }
  if (!current || current.epoch !== msg.epoch) throw new Error('NEED_SNAPSHOT');
  const next = structuredClone(current);
  for (const event of msg.events) {
    if (event.seq <= next.seq) continue;
    if (event.seq !== next.seq + 1) throw new Error('SEQUENCE_GAP');
    const index = event.row * 15 + event.col;
    if (next.state.board[index]) throw new Error('STATE_CONFLICT');
    next.state.board[index] = event.stone;
    for (const key of ['turn', 'moves', 'winner', 'draw']) next.state[key] = event[key];
    next.seq = event.seq;
  }
  if (next.seq < msg.seq) throw new Error('SEQUENCE_GAP');
  return next;
}

export class RoomClient {
  constructor(base, token, render = () => {}, report = console.log) {
    this.base = base;
    this.token = token;
    this.render = render;
    this.report = report;
    this.view = null;
    this.pending = null;
    this.generation = 0;
  }
  stop() {
    this.generation++;
    this.controller?.abort();
  }
  async watch() {
    this.stop();
    const generation = this.generation;
    let delay = 500;
    while (generation === this.generation) {
      this.controller = new AbortController();
      try {
        const query = new URLSearchParams({ epoch: this.view?.epoch ?? '',
          since: String(this.view?.seq ?? 0) });
        const response = await fetch(`${this.base}/api/rooms/demo/events?${query}`, {
          headers: { Authorization: `Bearer ${this.token}` }, signal: this.controller.signal
        });
        if ([401, 403, 404].includes(response.status)) {
          this.report(`WATCH_DENIED_${response.status}`); return;
        }
        if (!response.ok) throw new Error(`HTTP_${response.status}`);
        const reader = response.body.getReader();
        const controller = this.controller;
        const decoder = new TextDecoder();
        let buffer = '';
        try {
          while (generation === this.generation) {
            const idle = setTimeout(() => controller.abort(), 45000);
            let chunk;
            try { chunk = await reader.read(); }
            finally { clearTimeout(idle); }
            const { value, done } = chunk;
            if (done) throw new Error('STREAM_ENDED');
            buffer += decoder.decode(value, { stream: true });
            let end;
            while ((end = buffer.indexOf('\n\n')) !== -1) {
              const block = buffer.slice(0, end);
              buffer = buffer.slice(end + 2);
              const data = block.split('\n').find(line => line.startsWith('data: '));
              if (!data || generation !== this.generation) continue;
              this.view = consume(this.view, JSON.parse(data.slice(6)));
              delay = 500;
              this.render(structuredClone(this.view));
            }
            if (buffer.length > 65536) throw new Error('FRAME_TOO_LARGE');
          }
        } finally { await reader.cancel().catch(() => {}); }
      } catch (error) {
        if (generation !== this.generation) return;
        if (['NEED_SNAPSHOT', 'SEQUENCE_GAP', 'STATE_CONFLICT'].includes(error.message)) {
          this.view = null;
        }
        this.report(error.message);
      }
      if (generation !== this.generation) return;
      await new Promise(resolve => setTimeout(resolve, delay + Math.random() * 200));
      delay = Math.min(delay * 2, 8000);
    }
  }
  async move(row, col) {
    if (this.pending) throw new Error('PENDING_COMMAND_EXISTS');
    if (!this.view) throw new Error('WAIT_FOR_SNAPSHOT');
    this.pending = { requestId: crypto.randomUUID(), expectedEpoch: this.view.epoch,
      expectedSeq: this.view.seq, row, col };
    return this.retry();
  }
  async retry() {
    if (!this.pending) throw new Error('NO_PENDING_COMMAND');
    const response = await fetch(`${this.base}/api/rooms/demo/commands`, {
      method: 'POST', headers: { Authorization: `Bearer ${this.token}`,
        'Content-Type': 'application/json' }, body: JSON.stringify(this.pending),
      signal: AbortSignal.timeout(3000)
    });
    const result = await response.json();
    // A transport error or server failure retains the exact original request.
    if (response.status >= 500 || response.status === 429) throw new Error(result.code);
    this.pending = null;
    if (!response.ok) throw new Error(result.code);
    return result;
  }
}

快照收到后整份替换。增量则先核对实例,再按序号应用。已经处理过的事件跳过,下一条不是当前序号加一就报缺口,不假装中间什么也没发生。处理前复制当前视图,即使一批消息应用到一半发现问题,外面的旧状态也不会变成只更新了半批的棋盘。

还要看消息说要恢复到哪个序号。假如目标是五,数组实际只给到三,即使二和三本身连续,这一包也不完整。尾部检查会把这种情况识别出来。发现实例、缺口或棋盘冲突,就清掉协议视图,下次让服务端重新给快照。画面可以留着看,但不能把旧画面当成现在能操作的状态。

解析时不能假定每次网络读取刚好是一整条消息。有可能读到半行,也有可能一次读到好几帧,所以先存进缓冲,找完整空行分隔再解析。文本解码也按流处理,避免字符在两个分块之间被拆坏。这里仅实现本服务的单行JSON格式,不是能处理所有第三方SSE字段的通用库。

每次停止或重建连接会改变一个代次编号。旧请求晚一点结束,或者旧重连循环还在等待,只要代次不匹配就退出。单纯Abort当前请求还不够,旧函数可能已经走到错误处理里,稍后又拉起一条新连接。代次检查把这些旧循环一起挡住,页面就不会越重连越多条订阅。

重试等待从五百毫秒开始,逐步到八秒,再加一点随机间隔,避免大量客户端同时回来。收到有效业务帧会恢复短间隔。未授权、非成员、房间不存在这些明确错误会停止,不一直盲连。网络恢复和权限恢复不是一回事,错误原因不处理,连一百次也没用。

注释心跳不会触发棋盘更新,但会让流读取收到字节。一次读取四十五秒都没有任何字节,客户端会取消当前流再恢复。这个阈值在示例里已经实现,不等于手机弱网保证;设备进后台之后定时器可能受限制,仍然要去实际设备上测。连通、能收到心跳和业务状态已经追平,也不能混成一个"在线"标记。

提交操作时等三秒。网络异常后把pending留下,里面的编号、实例、序号和坐标都不改;重试继续发同一份。明确成功或者业务拒绝后清掉。服务端五百类错误和队列过载则保留,交给上层决定怎么处理。有待确认操作时不再接受新点击,防止结果未知时又产生一串新请求。

这个待确认对象存在当前页面内存,刷新页面会丢,重新订阅也不会自动替你再次落子。要跨刷新保存操作,需要另外做本地缓存和请求结果查询,还要说明什么时候失效。恢复后看见一颗棋,并不能在所有玩法里证明它就是自己那条请求造成的,确认操作仍应该看对应请求编号的结果。

为了能亲手检查,写完整的web/index.html。它通过同源路径加载刚才的客户端模块,不需要前端打包。页面有令牌输入、连接、断开订阅、重试和棋盘,令牌来自自己的.env,不会写到地址栏或者持久化存储。页面关闭会停止订阅,这仍是实验页,不是正式登录页面。

html 复制代码
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>房间同步技术实验</title>
<style>
  body{font:16px system-ui;max-width:760px;margin:32px auto;padding:16px;background:#f6f8fc;color:#18334e}
  input,button{padding:8px;margin:4px;border:1px solid #cad6e2;border-radius:6px}
  #board{display:grid;grid-template-columns:repeat(15,1fr);max-width:600px;margin:20px 0}
  #board button{aspect-ratio:1;margin:0;padding:0;border-radius:0;background:#e8cf9e;font-size:22px}
  pre{white-space:pre-wrap;background:white;padding:16px;border-radius:8px}
</style>
<h1>房间同步技术实验</h1>
<p>本机演示:Alice 先行,Bob 后行。令牌来自本机 .env 文件。</p>
<label>演示令牌 <input id="token" type="password" autocomplete="off"></label>
<button id="connect">连接 / 重连</button><button id="disconnect">断开订阅</button>
<button id="retry">重试待确认操作</button>
<pre id="status">等待连接</pre><div id="board"></div>
<script type="module">
  import { RoomClient } from '/client/room-client.mjs';
  const board = document.querySelector('#board');
  const status = document.querySelector('#status');
  const token = document.querySelector('#token');
  let client;
  const cells = Array.from({ length: 225 }, (_, index) => {
    const button = document.createElement('button');
    button.setAttribute('aria-label', `第${Math.floor(index / 15) + 1}行第${index % 15 + 1}列`);
    button.addEventListener('click', async () => {
      try {
        if (!client) throw new Error('请先连接');
        const result = await client.move(Math.floor(index / 15), index % 15);
        status.textContent += `\n操作已确认:seq=${result.seq}`;
      } catch (error) { status.textContent += `\n${error.message}`; }
    });
    board.append(button); return button;
  });
  const render = view => {
    for (let i = 0; i < cells.length; i++) cells[i].textContent = ['', '●', '○'][view.state.board[i]];
    status.textContent = JSON.stringify({ seq: view.seq, turn: view.state.turn,
      moves: view.state.moves, winner: view.state.winner, draw: view.state.draw }, null, 2);
  };
  document.querySelector('#connect').onclick = () => {
    if (!client || client.token !== token.value) {
      client?.stop();
      client = new RoomClient(location.origin, token.value, render,
        message => { status.textContent += `\n${message}`; });
    }
    void client.watch();
  };
  document.querySelector('#disconnect').onclick = () => {
    client?.stop(); status.textContent += '\n订阅已断开,旧画面仅供查看';
  };
  document.querySelector('#retry').onclick = async () => {
    try {
      if (!client) throw new Error('请先连接');
      const result = await client.retry(); status.textContent += `\n重试已确认:seq=${result.seq}`;
    } catch (error) { status.textContent += `\n${error.message}`; }
  };
  window.addEventListener('pagehide', () => client?.stop());
</script>
</html>

服务启动后,在两个浏览器页面打开http://127.0.0.1:3100/。一个输入Alice令牌,一个输入Bob令牌,点连接。正常应都是序号零、棋盘空白、行动座位零。Alice点中心格,两边最终看到黑子,序号一,轮到座位一;Bob再下一颗,两边最终黑白各一颗,序号二。

HTTP返回和SSE消息没有固定谁先到。HTTP只确认这次操作的结果,页面棋盘由同步消息更新,所以不要收到HTTP成功后再追加一次落子。否则推送也到的时候,就可能把同一个动作表现成两遍。实验的状态文本出现顺序偶尔不同没关系,最终棋盘和序号一致才重要。

"断开订阅"只停接收消息,HTTP操作入口并没有一起关。这是刻意用来构造旧画面:把Bob断开,再让Alice下黑子,Bob保持零;Bob重连带着旧序号补回来,达到一。然后Bob下白子,两边达到二。恢复过程中不该多出第二颗黑子,也不该清空房间或者重置回合。

正式界面一般还需要在恢复期间限制操作,并区分断线、连接中、恢复中和可以行动。这份小页面没有实现完整的产品交互,它主要让协议结果容易检查。先确认到底是哪一层出问题,再补界面行为,比在很多动画和弹窗里猜同步是否正确要直接。

除了浏览器,再用PowerShell检查重复请求。以下几段是联调片段,不是新文件,开在工程根目录即可。先保持服务运行,把令牌换成自己的.env值。如果之前已经下过棋,最好重启服务从零开始,否则中心格可能已经有棋,或者当前轮到Bob,这时拒绝是正常规则结果。

powershell 复制代码
$base = 'http://127.0.0.1:3100'
$auth = @{ Authorization = 'Bearer local-alice-change-me' }
$initial = Invoke-RestMethod "$base/api/rooms/demo/sync" -Headers $auth
$initial | ConvertTo-Json -Depth 5

第一次查询拿到快照,用里面的实例和序号构造操作,不手写一个假的UUID。下一段在同一个PowerShell窗口运行,依赖前面的$base、$auth和$initial。它先下中心格,再原样发一次相同请求,第二次应该返回第一次结果。

powershell 复制代码
$body = @{
  requestId = 'powershell_move_001'
  expectedEpoch = $initial.epoch
  expectedSeq = $initial.seq
  row = 7
  col = 7
} | ConvertTo-Json -Compress
$first = Invoke-RestMethod "$base/api/rooms/demo/commands" -Method Post `
  -Headers $auth -ContentType 'application/json' -Body $body
$again = Invoke-RestMethod "$base/api/rooms/demo/commands" -Method Post `
  -Headers $auth -ContentType 'application/json' -Body $body
$first
$again

两次的请求编号、实例和成功序号应相同。只看两个accepted=true还不够,再查棋盘的落子数和序号,都只增加一次。服务端取原回执时不再广播一次落子,所以另一个页面也不应重复播放操作,或者出现新的状态变化。

下面仍在同一个窗口运行,先带原序号查询增量,再不带实例查询快照。首次落子实验的预期是增量里只有一条事件,快照里moves=1、seq=1。如果中间做过别的操作,就按实际次数判断,不要把预期数字当成固定接口返回。

powershell 复制代码
$delta = Invoke-RestMethod `
  "$base/api/rooms/demo/sync?epoch=$($initial.epoch)&since=$($initial.seq)" -Headers $auth
$snapshot = Invoke-RestMethod "$base/api/rooms/demo/sync" -Headers $auth
$delta | ConvertTo-Json -Depth 6
$snapshot.state.moves
$snapshot.seq

接着可以把第一次的列改成八,但保持请求编号不变,再提交。预期是编号复用冲突,棋盘和序号不变。不要为这个实验换新编号,否则测的成了另一条操作。PowerShell遇到四百类响应会抛异常,错误码要看响应正文,或者看接下来自动化测试的断言。

能手工走一遍以后,再把容易出错的情况固定成测试。完整文件放在test/room.test.mjs,依赖内置测试运行器、断言和前面模块。前八项主要检查队列和内存状态,后两项自己监听随机本机端口,实际发HTTP并读取SSE,结束后关闭服务,不需要数据库和常驻服务配合。

javascript 复制代码
import test from 'node:test';
import assert from 'node:assert/strict';
import { RoomQueue } from '../src/queue.mjs';
import { RoomService, applyMove } from '../src/room.mjs';
import { consume, RoomClient } from '../client/room-client.mjs';
import { buildServer } from '../src/server.mjs';

const command = (service, id, seq, row, col) => ({ requestId: id,
  expectedEpoch: service.rooms.get('demo').epoch, expectedSeq: seq, row, col });
const hasCode = code => error => error.code === code;

test('same room awaits predecessor; other room proceeds; rejection does not poison queue', async () => {
  const queue = new RoomQueue(3), order = [];
  let release;
  const gate = new Promise(resolve => { release = resolve; });
  const first = queue.run('A', async () => { order.push('A1-start'); await gate; order.push('A1-end'); });
  const second = queue.run('A', () => order.push('A2'));
  await queue.run('B', () => order.push('B1'));
  assert.deepEqual(order, ['A1-start', 'B1']);
  release(); await Promise.all([first, second]);
  assert.deepEqual(order, ['A1-start', 'B1', 'A1-end', 'A2']);
  await assert.rejects(queue.run('A', () => { throw new Error('failure'); }));
  assert.equal(await queue.run('A', () => 7), 7);
});

test('queue applies admission limit without cancelling accepted work', async () => {
  const queue = new RoomQueue(1);
  let release;
  const gate = new Promise(resolve => { release = resolve; });
  const first = queue.run('A', () => gate);
  await assert.rejects(queue.run('A', () => 2), hasCode('ROOM_BUSY'));
  release(1); assert.equal(await first, 1);
});

test('concurrent duplicate is committed once; changed payload is refused', async () => {
  const service = new RoomService();
  const input = command(service, 'request_001', 0, 7, 7);
  const [a, b] = await Promise.all([service.command('demo', 'alice', input),
    service.command('demo', 'alice', input)]);
  assert.deepEqual(a, b);
  const room = service.rooms.get('demo');
  assert.equal(room.seq, 1); assert.equal(room.state.moves, 1);
  assert.equal(room.events.length, 1); assert.equal(room.receipts.size, 1);
  await assert.rejects(service.command('demo', 'alice', { ...input, col: 8 }), hasCode('REQUEST_ID_REUSED'));
});

test('failure leaves state, log and receipts unchanged; next valid command succeeds', async () => {
  const service = new RoomService(), room = service.rooms.get('demo');
  const before = service.snapshot(room);
  await assert.rejects(service.command('demo', 'bob', command(service, 'wrong_turn', 0, 0, 0)), hasCode('NOT_YOUR_TURN'));
  await assert.rejects(service.command('demo', 'alice', command(service, 'wrong_cell', 0, 15, 0)), hasCode('INVALID_COORDINATE'));
  await assert.rejects(service.command('demo', 'mallory', command(service, 'wrong_user', 0, 0, 0)), hasCode('NOT_A_MEMBER'));
  assert.deepEqual(service.snapshot(room), before);
  assert.equal(room.events.length, 0); assert.equal(room.receipts.size, 0);
  await service.command('demo', 'alice', command(service, 'valid_move', 0, 0, 0));
  const after = service.snapshot(room);
  await assert.rejects(service.command('demo', 'bob', command(service, 'stale_move', 0, 0, 1)), hasCode('STALE_SEQ'));
  await assert.rejects(service.command('demo', 'bob', command(service, 'taken_cell', 1, 0, 0)), hasCode('CELL_OCCUPIED'));
  assert.deepEqual(service.snapshot(room), after);
});

test('bounded history falls back to snapshot; future sequence and old incarnation do too', async () => {
  const service = new RoomService({ historyLimit: 2 }), room = service.rooms.get('demo');
  let view = consume(null, service.snapshot(room));
  for (let i = 0; i < 3; i++) await service.command('demo', i % 2 ? 'bob' : 'alice', command(service, `history_${i}`, i, 0, i));
  const delta = service.resume(room, room.epoch, 1);
  assert.equal(delta.mode, 'delta'); assert.deepEqual(delta.events.map(e => e.seq), [2, 3]);
  assert.equal(service.resume(room, room.epoch, 0).mode, 'snapshot');
  assert.equal(service.resume(room, room.epoch, 4).mode, 'snapshot');
  view = consume(view, service.resume(room, '', 0));
  assert.deepEqual(view.state, room.state);
  const input = command(service, 'old_epoch_', 3, 0, 3);
  service.rooms.set('demo', service.newRoom());
  await assert.rejects(service.command('demo', 'bob', input), hasCode('ROOM_REPLACED'));
});

test('receipt saturation refuses new commands but still returns known results', async () => {
  const service = new RoomService({ receiptLimit: 1 });
  const first = command(service, 'first_move', 0, 0, 0);
  const result = await service.command('demo', 'alice', first);
  await assert.rejects(service.command('demo', 'bob', command(service, 'second_move', 1, 0, 1)), hasCode('ROOM_RECEIPTS_FULL'));
  assert.deepEqual(await service.command('demo', 'alice', first), result);
  assert.equal(service.rooms.get('demo').seq, 1);
});

test('client applies duplicates once and rejects a gap without mutating its previous state', () => {
  const service = new RoomService();
  const view = consume(null, service.snapshot(service.rooms.get('demo')));
  const event = { seq: 1, row: 0, col: 0, stone: 1, turn: 1, moves: 1, winner: null, draw: false };
  const message = { mode: 'delta', epoch: view.epoch, seq: 1, events: [event] };
  const next = consume(view, message);
  assert.deepEqual(consume(next, message), next);
  assert.throws(() => consume(view, { ...message, seq: 2, events: [{ ...event, seq: 2 }] }), /SEQUENCE_GAP/);
  assert.equal(view.seq, 0); assert.equal(view.state.moves, 0);
});

test('rule checks a full winning line and rejects moves after game end', () => {
  let state = new RoomService().rooms.get('demo').state;
  for (let i = 0; i < 4; i++) {
    state = applyMove(state, 0, 0, i); state = applyMove(state, 1, 2, i);
  }
  state = applyMove(state, 0, 0, 4);
  assert.equal(state.winner, 0); assert.equal(state.moves, 9);
  assert.throws(() => applyMove(state, 1, 2, 4), hasCode('GAME_FINISHED'));
});

test('real HTTP authentication, duplicate submission and SSE catch-up agree with memory state', { timeout: 10000 }, async () => {
  const app = buildServer({ alice: 'test-a', bob: 'test-b' });
  await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
  const base = `http://127.0.0.1:${app.server.address().port}`;
  let stream;
  const post = async input => {
    const response = await fetch(`${base}/api/rooms/demo/commands`, { method: 'POST',
      headers: { Authorization: 'Bearer test-a', 'Content-Type': 'application/json' }, body: JSON.stringify(input) });
    return { status: response.status, body: await response.json() };
  };
  try {
    const denied = await fetch(`${base}/api/rooms/demo/sync`);
    assert.equal(denied.status, 401); await denied.arrayBuffer();
    for (const [type, body, status, code] of [
      ['text/plain', '{}', 415, 'JSON_REQUIRED'],
      ['application/json', '{', 400, 'INVALID_JSON'],
      ['application/json', JSON.stringify({ padding: 'x'.repeat(5000) }), 413, 'BODY_TOO_LARGE']
    ]) {
      const bad = await fetch(`${base}/api/rooms/demo/commands`, { method: 'POST',
        headers: { Authorization: 'Bearer test-a', 'Content-Type': type }, body });
      assert.equal(bad.status, status); assert.equal((await bad.json()).code, code);
    }
    const input = command(app.service, 'http_move_', 0, 7, 7);
    const [first, again] = await Promise.all([post(input), post(input)]);
    assert.equal(first.status, 200); assert.deepEqual(first, again);
    const room = app.service.rooms.get('demo');
    stream = await fetch(`${base}/api/rooms/demo/events?epoch=${room.epoch}&since=0`, {
      headers: { Authorization: 'Bearer test-b' }
    });
    assert.equal(stream.status, 200);
    const reader = stream.body.getReader();
    const decoder = new TextDecoder(); let text = '';
    while (!text.includes('\n\n')) { const chunk = await reader.read(); text += decoder.decode(chunk.value, { stream: true }); }
    const frame = JSON.parse(text.split('\n').find(line => line.startsWith('data: ')).slice(6));
    assert.equal(frame.mode, 'delta'); assert.equal(frame.seq, 1);
    assert.equal(frame.events[0].row, 7); assert.equal(frame.events[0].col, 7);
    assert.equal(room.state.moves, 1);
    await reader.cancel();
  } finally { await app.close(); }
});

test('client stops and reconnects over real SSE; command retry does not duplicate a move', { timeout: 10000 }, async () => {
  const app = buildServer({ alice: 'test-a', bob: 'test-b' });
  await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
  const base = `http://127.0.0.1:${app.server.address().port}`;
  const until = async check => {
    const deadline = Date.now() + 3000;
    while (!check()) { if (Date.now() > deadline) throw new Error('WAIT_TIMEOUT'); await new Promise(r => setTimeout(r, 10)); }
  };
  const alice = new RoomClient(base, 'test-a', () => {}, () => {});
  let task = alice.watch();
  try {
    await until(() => alice.view?.seq === 0);
    alice.stop(); await task;
    await alice.move(7, 7);
    const room = app.service.rooms.get('demo');
    const saved = [...room.receipts.values()][0];
    // Model a committed request whose response was lost, using the original payload.
    const [epoch, seq, row, col] = JSON.parse(saved.fingerprint);
    alice.pending = { requestId: saved.result.requestId, expectedEpoch: epoch, expectedSeq: seq, row, col };
    await alice.retry(); assert.equal(room.state.moves, 1);
    task = alice.watch();
    await until(() => alice.view?.seq === 1);
    assert.deepEqual(alice.view.state, room.state);
    assert.equal(alice.pending, null);
  } finally { alice.stop(); await task; await app.close(); }
});

队列测试让房间甲的第一个任务卡在一个可控等待门上,再检查甲的第二项没提前执行,房间乙却可以完成。释放等待门后再核对实际顺序。只连着调用两个同步函数,很容易看起来"有顺序",但它并没有真的考验异步等待期间是否会交叉。

重复提交测试同时发两份相同操作,除了回执一致,还检查序号一、落子数一、事件一条、回执一条。失败测试则在错误回合、越界、非成员、旧序号和已占格子之后对比快照,并确认下一条合法操作还能成功。要同时看状态和后续行为,不能只有一条"预期会报错"的断言。

恢复测试临时把历史窗口设为两条,做三次操作,就能检查从一补二和三、从零回快照的边界。不必为了这个条件真下六十五步。新建同名房间再提交旧实例指令,预期明确拒绝,也检查了旧请求不能串到新棋局。

真实HTTP测试检查没有认证、正文类型错误、JSON损坏和正文超限,再做并发防重,读取真实SSE帧。最后一项用客户端类断开订阅,在操作已经成功后保留原负载模拟响应丢失,原样重试,再连回去对比客户端与服务端状态。这测的是不确定结果的处理,没有通过网络设备实际注入丢包。

在工程根目录执行:

powershell 复制代码
npm test

实际结果是十项通过,零失败,没有跳过。所有工程mjs文件也通过了语法检查。测试确认的是这些场景中的业务断言,不是所有网络和所有规则都穷尽了。终局用例验证了一次完整横向五连,其他方向、满盘平局和更多设备条件还可以继续补测试。

我也用两个浏览器页面走了断订阅恢复:Bob先停在序号零,Alice落子变成一,Bob连回来补上黑子,再由Bob下白子,双方最后都是序号二、落子数二。这个结果和服务端状态一致。它是实际页面联调,不是把设计图当作运行截图;封面仍然只是插画。

如果过程中出错,先看令牌、实例、请求编号和序号这几样,日志不要打原始令牌。未授权要核对.env和Authorization头;非成员要看账号与成员表。健康检查能返回,不代表当前账号就有权限进入房间,不能为了赶紧看到画面删掉成员检查。

STALE_SEQ说明新操作用的是旧画面,先同步,再让玩家决定下一步。不要在错误处理里偷偷把序号换成最新值,保留旧编号重新发,这把操作依据和数据指纹都变了。REQUEST_ID_REUSED则要查编号生成和待确认缓存,看看是不是整个页面一直在用一个固定编号。

ROOM_REPLACED一般表示本实验已经重启,新房间取代了旧实例。获取新快照,清理旧房间待确认操作,再开始新实验。位置被占用或者没轮到当前玩家,属于规则拒绝,不是重连就能解决的网络故障。错误都叫"网络不好",反而容易让人一直做无效重试。

长时间没有更新,也要分开看HTTP操作是否成功、SSE是否持续打开、消息是否解析,以及视图序号是否有缺口。操作成功但推送暂时没到,可以恢复补齐;流连接正常但事件被客户端拒绝,就查实例与顺序。网络面板里一条订阅长期等待,本身可能就是持续响应的正常样子。

目前整个工程的状态都在内存。进程重启会清空棋盘、事件和成功结果,实例编号变化只是帮助识别,不会恢复旧棋局。连接断开后补消息已经实现,进程崩溃后找回数据还没有实现,这两件事需要分开讲,不能因为有"恢复"两个字就当成所有情况都能恢复。

以后接数据库时,需要在同一个事务中读取房间、检查实例与序号、保存新状态、追加事件并保存成功请求结果,提交之后再通知客户端。提交成功但推送失败,客户端应从已经保存的状态恢复。不能把现在几行内存赋值随手换成四个独立异步写入,否则可能出现棋盘改了、回执没存的半截结果。

扩展多个进程也需要处理房间归属。每个进程各有自己的队列,把状态放进Redis,并不会让这些队列自动合成一个。要明确谁负责这个房间,谁可以接管,旧负责人失效后如何阻止它继续写。共享存储、路由和执行顺序是不同问题,增加一层网关也不能替代这些约束。

如果以后换成有私有手牌的玩法,快照和增量要按账号能看见的内容生成,重连补发也一样。不能先把整份私有状态发出去,再让前端藏起来。本例公共棋盘允许双方看同一份数据,这个做法不能直接套到所有棋牌游戏技术场景。

这一篇实际做完的是同房间顺序执行、成功请求防重、序号恢复和本机页面联调,没有做真实数据库、多进程部署、代理压测、Cocos或手机弱网验收。照着运行时,重点看重复操作有没有只执行一次,失败后状态有没有保持,重连后双方棋盘有没有一致。先把这些结果做实,再往后扩展存储和客户端,会更容易判断每一层到底解决了什么。

相关推荐
小天源2 小时前
GEO 品牌监测系统实战:基于 Node.js + Playwright 实现多平台采集与报告导出
人工智能·node.js·geo·品牌检测·ai诊断
damoluomu2 小时前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(一)
node.js
郝学胜_神的一滴17 小时前
游戏引擎原理与实践 04:拆解引擎基础系统与内存管理
c++·游戏
guslegend21 小时前
脚手架入门:必要性、核心功能与执行原理
前端·架构·node.js·脚手架·前端工程化
Hello_Pyhx1 天前
DLSS5-Swapper 配置教程:核对游戏API、选择路线与排查F8无响应
游戏
Martina_03211 天前
山谷地形下雨后湿痕总往坡上爬?用6步检查高度场、流向遮罩与分区加载
人工智能·游戏·数学建模·3d·自然语言处理·aigc·关卡设计
特立独行的猫a1 天前
用仓颉语言给娃写了个打字练习游戏:cj-tauri 项目实战
游戏·ui·框架·tauri·仓颉·cangjie·cj-tauri
HaipengYu2 天前
深度解密 OpenCode 2 插件加载机制:Dual-Version (V1/V2) 适配实战与官方文档的“隐形陷阱”
node.js
前端之虎陈随易2 天前
bm2,Node.js 与 Bun 项目部署新选择
node.js