棋牌游戏技术实战(五):从对局事件重新执行规则,核对棋盘和最终状态

数据库里有事件表,不代表历史已经可靠。记录可能少一步,也可能字段之间不一致,最终棋盘还可能被其他代码直接改过。本篇把每一条保存的事件拿出来,从初始状态重新执行规则,再与数据库里的最终状态比较。这样才能判断历史与当前结果是否描述同一局。

工程仍在 示例工程/02-07-room-platform。本篇回到demo公共五子棋,继续用gomoku-v1规则版本,不在这里混入第四篇的私有手牌演练。公共棋盘历史可以由当前有效成员读取;私有玩法的历史不能因此照搬本接口对所有成员开放。

先确认迁移和seed已经执行,服务能够登录并读取demo。数据库里保存了initial_state、state、seq、rule_version和逐条事件。回放不是把state复制出来逐格画一遍,而是从initial_state开始,依次对每条事件应用规则,生成每一步的新状态。

本例存的是"已接受操作及其结果",不是用户所有点击记录。越界、轮次错误、旧序号等拒绝请求没有成为lab_events事件。它们需要另外的错误日志定位,不能为了重演方便把所有失败输入也当成成功落子插进事件流。

下面SQL在实验数据库只读执行,先看完整记录。ORDER BY seq不是装饰;数据库查询结果没有因为主键递增就天然保证你当前得到的顺序。重演必须显式定义事件排序,发现缺口不能擅自把后面的编号挪前。

sql 复制代码
SELECT id, epoch, seq, rule_version, initial_state, state
FROM lab_rooms WHERE id='demo';
SELECT seq, actor, request_id, event
FROM lab_events WHERE room_id='demo' ORDER BY seq;
SELECT request_id, result
FROM lab_receipts WHERE room_id='demo' ORDER BY request_id;

事件保存seq、row、col、stone及更新后的turn、moves、winner、draw。前几个字段表示本次落子,后几个描述规则执行后的摘要。重演不是无条件相信摘要,把它直接复制进状态;应根据前几个输入重新执行规则,再检查得到的摘要与保存值相同。

例如事件声称Bob下白子,但turn字段仍然说下一步是Bob,棋盘里确实有这颗白子也不能证明事件完全正确。重演会算出下一轮应该是谁,并发现摘要不同。若只把棋子依次写到数组,这一类错误很容易被略过。

actor是认证得到的账号,stone是座位加一。demo固定映射alice为黑棋、bob为白棋,所以模块会检查两者是否一致。这个约束只适用于当前固定演示,真实多房间、换座位或动态成员玩法应把开局座位关系作为历史元数据保存,不能继续硬编码这两个名字。

完整重演模块保存为 src/replay.mjs。它依赖Node.js的深比较、前面规则函数和problem函数,没有修改数据库的代码。成功返回包含初始帧与各个事件后的状态帧,失败则抛出明确错误,不返回一份仅供界面勉强展示的半截历史。

javascript 复制代码
import { isDeepStrictEqual } from 'node:util';
import { applyMove } from './rules.mjs';
import { problem } from './store.mjs';
export function replay(history) {
  if(history.ruleVersion!=='gomoku-v1')problem('RULE_VERSION_UNSUPPORTED');
  let state=structuredClone(history.initial),seq=0;
  const frames=[{seq,state:structuredClone(state)}];
  for(const row of history.events) {
    const event=row.event;
    if(row.seq!==seq+1 || event.seq!==row.seq)problem('EVENT_GAP');
    if(![1,2].includes(event.stone))problem('BAD_STONE');
    const actorSeat=event.stone-1;
    if(row.actor!==['alice','bob'][actorSeat])problem('ACTOR_MISMATCH');
    const next=applyMove(state,actorSeat,event.row,event.col);
    const derived={seq:row.seq,row:event.row,col:event.col,stone:event.stone,
      turn:next.turn,moves:next.moves,winner:next.winner,draw:next.draw};
    if(!isDeepStrictEqual(derived,event))problem('EVENT_CONTENT_MISMATCH');
    state=next;seq=row.seq;frames.push({seq,state:structuredClone(state)});
  }
  if(seq!==history.seq || !isDeepStrictEqual(state,history.final))problem('FINAL_STATE_MISMATCH');
  return frames;
}

ruleVersion先检查,否则同一批坐标可能被另一版规则解释。例如新版加入禁手、缩小棋盘或改变终局条件,原来合法的一步可能在新版被拒绝。历史需要知道当时使用哪一版规则,而不是永远导入当前最新函数并把差异叫成数据损坏。

本例只实现gomoku-v1,有其他版本就报RULE_VERSION_UNSUPPORTED。没有从互联网上下载一个同名文件来执行,也不会无声地把未知版本替换成v1。未来增加规则注册表时,应明确支持的版本、模块和测试数据,保持历史规则可复现。

每一条记录的外层seq和event.seq都要与下一步预期相同。外层字段用于数据库主键排序,内层字段用于协议事件,二者如果不同,也属于损坏而不是无关的小误差。事件序号从一开始,初始帧是零;这让界面回放和业务提交数量有清楚对应关系。

重演逐步调用applyMove,因此同样检查终局、轮次、坐标和格子占用。一个不合法的历史不能靠"它已经在数据库里"就被直接接受。数据库唯一约束保护序号和主键,不会自动检查五子棋当前是否轮到正确的人落子。

每次生成derived摘要,再用深比较对比整条event。这里要求当前协议字段严格一致,额外字段也会导致比较不同。新增事件字段时,要先决定它是不是规则结果的一部分,并相应升级规则或协议,而不是让历史核验忽略所有未知内容。

frames保存每一步的独立复制。若所有帧都引用同一个数组,再往后下一步可能把前面的帧也改掉,回放拖到开局却仍然看到终局棋盘。本例用新规则状态和structuredClone让各帧独立,减少这类引用共享错误。

所有事件执行完后,seq必须等于保存的最终序号,状态必须与history.final深比较相同。只验证最后一帧的moves等于seq不够:棋子位置可能错,winner可能错,turn也可能错。完整状态比较把这些差异都包括进来。

这种校验能够发现已知规则和保存数据之间的不一致,但它不证明所有业务都是合法的,也不提供防篡改签名。具有修改数据库权限的人可以同时改历史、初始状态和最终状态。需要审计可信度时还要设计追加写权限、日志签名和外部留存,不能只靠一次重演相等。

RoomStore.history先锁定房间共享读取边界,再读取初始、最终状态和有序事件。否则一个查询读到序号十,事件查询期间又多一步,响应里的终局状态可能还是十而事件已经到了十一。这类混合结果会导致正常数据被误报成无法重演。

网络响应生成完成后事务就结束,不需要让数据库锁一直等到客户端拖完回放。frames也不保存到lab_rooms里作为新的权威棋盘。它们是一次验证与展示的产物,当前游戏仍由RoomStore.command修改真实状态。

完整校验脚本保存为 scripts/replay-check.mjs。它在工程根目录运行,从.env读取地址和Alice实验密码,先登录再读取history,调用replay,最后只输出版本、序号和帧数,不打印令牌与全部棋盘。

javascript 复制代码
import assert from 'node:assert/strict';
import { replay } from '../src/replay.mjs';
const base=process.env.BASE_URL??'http://127.0.0.1:3101';
const login=await fetch(base+'/api/login',{method:'POST',headers:{'Content-Type':'application/json'},
  body:JSON.stringify({user:'alice',password:process.env.ALICE_PASSWORD}),signal:AbortSignal.timeout(5000)});
assert.equal(login.status,200);const {token}=await login.json();
const response=await fetch(base+'/api/rooms/demo/history',{headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(5000)});
assert.equal(response.status,200);const history=await response.json();const frames=replay(history);
console.log(JSON.stringify({ok:true,ruleVersion:history.ruleVersion,seq:history.seq,frames:frames.length}));
powershell 复制代码
node --env-file=.env scripts/replay-check.mjs

真实服务已启动且历史完整时,预期输出ok为true,frames为seq加一。空房间seq零也能验证,帧数应为一。若脚本返回登录错误,先核对密码和初始化状态;若规则版本错误,核对历史版本与代码;若FINAL_STATE_MISMATCH,应保存问题数据再调查,不能直接UPDATE最终状态让脚本变绿。

下面给出共用工程 test/platform.test.mjs 的完整内容。这个文件同时覆盖第二、第三和本篇的数据库业务,因为它们使用同一套fixture与表结构。完整夹具在工程 test/fixture.mjs,会新建PGlite数据库、执行迁移、生成alice/bob/eve账号并建立demo,所有测试结束都会关闭实例。

javascript 复制代码
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture,input } from './fixture.mjs';
import { RoomStore } from '../src/store.mjs';
import { Auth,passwordHash,passwordMatches } from '../src/auth.mjs';
import { replay } from '../src/replay.mjs';
const code=name=>error=>error.code===name;
test('migration repeat, persisted duplicate and replacement service agree',async()=>{
  const f=await fixture();try{
    await f.engine.exec(f.schema);
    const view=await f.store.sync('demo','alice');
    const command=input(view);
    const first=await f.store.command('demo','alice',command);
    const fresh=new RoomStore(f.db);
    assert.deepEqual(await fresh.command('demo','alice',command),first);
    await assert.rejects(()=>fresh.command('demo','alice',{...command,col:8}),code('REQUEST_ID_REUSED'));
    const snapshot=await fresh.sync('demo','bob');assert.equal(snapshot.seq,1);assert.equal(snapshot.state.moves,1);
    assert.equal((await f.db.query('SELECT * FROM lab_events')).rows.length,1);
    assert.equal((await f.db.query('SELECT * FROM lab_receipts')).rows.length,1);
  }finally{await f.close();}
});
test('failure during receipt write rolls state and event back',async()=>{
  const f=await fixture();try{
    await f.db.query("ALTER TABLE lab_receipts ADD CONSTRAINT reject_test CHECK (request_id <> 'fail_receipt')");
    const view=await f.store.sync('demo','alice');
    await assert.rejects(()=>f.store.command('demo','alice',input(view,'fail_receipt')));
    assert.deepEqual(await f.store.sync('demo','alice'),view);
    assert.equal((await f.db.query('SELECT * FROM lab_events')).rows.length,0);
    await f.store.command('demo','alice',input(view,'valid_request'));
    assert.equal((await f.store.sync('demo','alice')).seq,1);
  }finally{await f.close();}
});
test('member denial, stale command and epoch mismatch keep state unchanged',async()=>{
  const f=await fixture();try{
    const view=await f.store.sync('demo','alice');
    await assert.rejects(()=>f.store.command('demo','eve',input(view)),code('NOT_A_MEMBER'));
    await assert.rejects(()=>f.store.command('demo','bob',input(view)),code('NOT_YOUR_TURN'));
    await assert.rejects(()=>f.store.command('demo','alice',{...input(view),expectedSeq:2}),code('STALE_SEQ'));
    assert.deepEqual(await f.store.sync('demo','alice'),view);
    await f.store.command('demo','alice',input(view));
    const delta=await f.store.sync('demo','bob',view.epoch,0);assert.equal(delta.events.length,1);
    assert.equal((await f.store.sync('demo','bob','wrong',0)).mode,'snapshot');
    await f.db.query("UPDATE lab_members SET active=false WHERE user_id='bob'");
    await assert.rejects(()=>f.store.sync('demo','bob'),code('NOT_A_MEMBER'));
  }finally{await f.close();}
});
test('password salt, session expiry, disable and revoke are enforced',async()=>{
  const f=await fixture();try{
    const a=await passwordHash('valid-password'),b=await passwordHash('valid-password');
    assert.notEqual(a,b);assert.equal(await passwordMatches('valid-password',a),true);
    assert.equal(await passwordMatches('bad',a),false);
    await assert.rejects(()=>f.auth.login('alice','wrong'),code('LOGIN_FAILED'));
    const login=await f.auth.login('alice','local-test-password'),header='Bearer '+login.token;
    assert.equal(await f.auth.user(header),'alice');
    assert.equal((await f.db.query('SELECT token_hash FROM lab_sessions')).rows.some(r=>r.token_hash===login.token),false);
    await f.auth.logout(header);await assert.rejects(()=>f.auth.user(header),code('UNAUTHORIZED'));
    let now=Date.now();const auth=new Auth(f.db,{ttlMs:10,now:()=>now});
    const short=await auth.login('alice','local-test-password');now+=11;
    await assert.rejects(()=>auth.user('Bearer '+short.token),code('UNAUTHORIZED'));
    const other=await f.auth.login('bob','local-test-password');await f.db.query("UPDATE lab_users SET disabled=true WHERE id='bob'");
    await assert.rejects(()=>f.auth.user('Bearer '+other.token),code('UNAUTHORIZED'));
  }finally{await f.close();}
});
test('event replay checks actual derived states and corruption',async()=>{
  const f=await fixture();try{
    for(const [n,user,row,col] of [[0,'alice',7,7],[1,'bob',7,8],[2,'alice',8,7]]) {
      const view=await f.store.sync('demo',user);await f.store.command('demo',user,input(view,`replay_${n}`,row,col));
    }
    const history=await f.store.history('demo','alice');assert.equal(replay(history).length,4);
    const bad=structuredClone(history);bad.events[1].event.turn=1;
    assert.throws(()=>replay(bad),code('EVENT_CONTENT_MISMATCH'));
    const gap=structuredClone(history);gap.events.splice(1,1);assert.throws(()=>replay(gap),code('EVENT_GAP'));
    const final=structuredClone(history);final.final.board[0]=1;assert.throws(()=>replay(final),code('FINAL_STATE_MISMATCH'));
    await assert.rejects(()=>f.store.history('demo','eve'),code('NOT_A_MEMBER'));
  }finally{await f.close();}
});
test('real localhost HTTP authenticates, rejects outsiders and preserves one commit',async()=>{
  const f=await fixture();try{
    await new Promise(r=>f.server.listen(0,'127.0.0.1',r));const base=`http://127.0.0.1:${f.server.address().port}`;
    const preflight=await fetch(base+'/api/login',{method:'OPTIONS',headers:{Origin:'http://localhost:7456'}});
    assert.equal(preflight.status,204);assert.equal(preflight.headers.get('Access-Control-Allow-Origin'),'http://localhost:7456');
    assert.equal((await fetch(base+'/api/login',{method:'OPTIONS',headers:{Origin:'http://untrusted.local'}})).status,403);
    const login=await fetch(base+'/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:'alice',password:'local-test-password'})});
    assert.equal(login.status,200);const {token}=await login.json();const headers={Authorization:'Bearer '+token,'Content-Type':'application/json'};
    assert.equal((await fetch(base+'/ready')).status,200);
    assert.equal((await fetch(base+'/api/rooms/demo/sync')).status,401);
    const view=await (await fetch(base+'/api/rooms/demo/sync',{headers})).json();const command=input(view,'http_request');
    const post=()=>fetch(base+'/api/rooms/demo/commands',{method:'POST',headers,body:JSON.stringify(command)});
    const a=await post(),b=await post();assert.equal(a.status,200);assert.deepEqual(await a.json(),await b.json());
    const history=await (await fetch(base+'/api/rooms/demo/history',{headers})).json();assert.equal(history.seq,1);replay(history);
    const stream=await fetch(base+`/api/rooms/demo/events?epoch=${view.epoch}&since=0`,{headers,signal:AbortSignal.timeout(4000)});
    assert.equal(stream.status,200);const reader=stream.body.getReader();
    const chunk=await reader.read();const frame=new TextDecoder().decode(chunk.value);
    assert.equal(JSON.parse(frame.trim().slice(6)).seq,1);
    await fetch(base+'/api/logout',{method:'POST',headers});assert.equal((await fetch(base+'/api/rooms/demo/sync',{headers})).status,401);
    assert.equal((await reader.read()).done,true);
  }finally{await f.close();}
});

前面的保存测试检查同一成功请求再次提交返回原结果,事件和回执都只有一条。回执写失败的测试刻意让第三次写出错,再检查状态和事件全部回滚。这些条件保证重演拿到的是完整业务提交,而不是半截操作留下的碎片。

成员和轮次测试证明失败请求没有进入历史。测试对比整个快照,而不只看HTTP状态码。否则一个拒绝请求可能已经改变board却没有改变seq,后面回放才发现最终棋盘多了一颗无法解释的棋子。

重演测试依次由Alice在七、七下黑子,Bob在七、八下白子,Alice在八、七再下黑子。三步生成四帧,最后一帧必须与保存结果相同。坐标是零基,界面显示行号时可能加一,但测试和事件保存没有混用两套编号。

随后测试人为修改第二条事件的turn,预期EVENT_CONTENT_MISMATCH。再删除中间事件,预期EVENT_GAP。最后改最终棋盘的一个空格,预期FINAL_STATE_MISMATCH。这三项分别验证字段内容、顺序完整性和最终状态,不是同一个断言换三个名字。

修改的是测试获得的结构副本,不是直接破坏一份真实业务数据库。这样能够稳定复现错误并检查校验器,而不会让后续其他测试依赖前面的污染。需要调查线上异常时也应先导出有权限的证据,再在隔离环境复现。

真实本机HTTP测试会登录、读取快照、重复提交同一落子,再读取history并调用重演。它核对接口读取到的seq与实际事件一致,不只是在内存里构造一个自认为正确的history对象。底层仍是隔离PGlite,不能将它标成真实PostgreSQL网络部署。

本轮实际执行十四项测试全部通过,其中包含上述正常重演、内容损坏、缺口和最终差异场景。没有做大量历史性能压测,也没有覆盖所有可能的五子棋终局。规则本身沿用第一篇已测试的基本实现,更细的规则验收仍应单独补充。

回放界面应该使用已通过校验的frames,再选择某一seq展示,而不是每次拖进度条都修改当前房间对象。下面是接入片段,不是完整页面。frames来自replay的返回值,paintBoard是现有画棋盘函数,label是现有文字节点,调用者需提供这两个界面依赖。

javascript 复制代码
// Integration snippet; frames is the verified return value of replay(history).
function showFrame(frames, wantedSeq, paintBoard, label) {
  if (!Number.isInteger(wantedSeq) || wantedSeq < 0 || wantedSeq >= frames.length) {
    throw new Error('FRAME_OUT_OF_RANGE');
  }
  const frame = frames[wantedSeq];
  paintBoard(structuredClone(frame.state.board));
  label.textContent = `第 ${frame.seq} 步,落子 ${frame.state.moves}`;
}

这个片段不接收"第几秒"来估计棋盘。事件序号是确定顺序,时间戳只描述观察时间,两者不能相互替代。多个事件时间相同或时钟不一致时,仍应按seq重演,不按浏览器记录时间排序。

回放时可以加动画,但动画不能改变校验顺序。选到某一帧直接读取该帧状态,动画只是从前一帧过渡到后一帧。跳到较远一帧时,也不要让前面的未结束动画继续回写当前画面,界面应有独立播放代次或取消机制。

如果历史非常长,保存所有完整帧会占用较多内存。本例十五乘十五棋盘且最大落子数有限,因此全帧复制便于演示。更大的事件流可以增加定期检查点,先验证检查点对应的序号和规则版本,再从它恢复后面的事件;不能为了优化直接信任任意中间快照。

检查点与initial_state应区别对待。初始状态代表这局最开始的确定条件,检查点只是某个已知序号的派生结果。如果检查点错误但后面事件都合法,重演仍可能得到另一个错误终局,所以需要保存来源和核验方式,不能只给它一个文件名就算可靠。

历史接口的权限也要持续判断。当前active成员可以读取公共demo历史,已移除成员不能靠保存URL继续查询。真实产品可能允许离开后的历史查看,那应另建明确历史授权关系,而不是无意间绕过成员校验让所有人都能看到。

本篇没有积分、结算和资金流程。校验目标是棋盘与规则状态相等,不能因为函数名字叫replay就推断它核对了账务。每项业务若需要重演,必须保存它所需的输入、版本和结果,不能在事件不够的情况下从最后数字倒推过程。

排错时先看最早失败序号。EVENT_GAP先检查是否少事件或查错房间,EVENT_CONTENT_MISMATCH检查该步规则和记录字段,FINAL_STATE_MISMATCH则重点查有没有绕过统一入口改state。保存第一处差异比只截图最后一个不一致棋盘更容易定位原因。

先明确回放校验的输入边界:history.initial属于这局开局,events属于同一个房间实例,history.final和seq属于同一次读取边界。只要其中一个来自另一局,即使单独看每份对象都合法,也不应拼在一起。接口返回epoch,就是帮助调用方识别这个归属,不能在展示层随意去掉。

当前事件表按room_id保留同一demo的操作,demo初始化不会重建。如果将来支持同名房间的新局,历史键也要升级到具体对局实例。否则重置seq以后旧事件和新事件争同一主键,重演可能读到混合历史。不能仅把epoch换了却保持其他存储逻辑不变。

初始棋盘不是从"最后状态减去所有棋子"推出来的。未来开局可能有特殊摆放、不同先手和预置条件,逆向删除不一定能恢复真实起点。保存明确initial_state是为了让重演有确定输入,减少对历史外条件的依赖。

重演函数必须保持确定性。相同初始状态、版本和事件应得到相同结果。如果applyMove在执行时读当前日期、随机值或外部接口,同样历史可能每次重放不同。当前规则只使用传入状态、座位和坐标,没有这类外部变化因素。

需要随机结果的玩法,应保存合法随机结果或可验证的确定输入,再明确对应规则如何解释,不应在回放时重新随机生成。记录随机种子是否足够,还取决于随机算法版本、调用顺序和其他状态;不能只存一个数字就认定以后所有实现都能得到原结果。

规则版本不仅是展示文案。它应与实际执行模块和测试样例绑定。历史标记gomoku-v1而代码已经偷偷改了禁手条件,仍然可能产生不兼容。修改影响历史解释的规则时应有新版本,并保留旧版重演路径,不在原版本名下不断改变行为。

协议版本也有自己的边界。比如新增一个不影响规则的显示字段,当前derived严格比较可能报错。可以设计规则结果和显示元数据的明确分区,按各自语义校验,避免混成一个对象以后不知道额外字段该忽略还是拒绝。本例协议小,所以选择严格对比,边界更容易说明。

数据库外层seq与内部event.seq分别存在是为了两个使用位置:数据库范围检索与网络事件消费。保留重复字段时应明确校验一致,不能一个更新了另一个忘了。若想删除其中一个,也需同时修改查询、输出和客户端,不能只改事件写入代码。

石色映射同样必须稳定。当前黑为一、白为二,座位为零和一,所以stone等于seat加一。用零同时表示黑方座位和空格值时,容易把"黑方座位零"误写成"棋子值零",结果棋盘上看起来没有落子。重演和接口测试都要核对这层转换。

规则执行先返回next,再生成摘要,确保turn、moves和winner描述新状态。若有人先存旧turn再应用规则,单纯渲染坐标可能仍正确,但业务轮次会错。重演比较derived可以尽早暴露这种字段时点不一致,不必等客户端出现更复杂的后续问题。

全状态相等也包括draw。终局规则如果把满盘判定写错,回放会按同一错误规则得到相同结果,因此重演成功不是规则本身正确的数学证明。规则测试仍然需要独立期望,如横向五连、边缘连线、终局后拒绝等。重演与规则验收互相补充,不互相替代。

本轮历史校验重点测试三步常规落子和损坏数据,完整方向规则仍沿用第一篇范围。未覆盖的终局情况应列为待补,不能因为十四项测试里有一项名称带replay,就把所有方向、平局和各种规则都写成验证完成。

缺口不能靠seq重新编号修复。第三条原本代表第三个合法提交,它引用的轮次、棋盘和请求结果都基于前两条。把它改叫第二条可能让数组长度整齐,却丢失了真实业务顺序。缺失事件应从可信备份或提交记录调查,不凭最终棋盘编造一条事件。

最终棋盘多一颗棋子时也不能简单补一条猜测事件。需要知道谁在什么状态下合法提交,使用哪个requestId以及是否有回执。现有状态只能说明结果,不完整证明过程。自动生成一份"能重演成功"的伪历史,会破坏后续排错可信度。

比较失败时,保存最早差异附近的必要材料。完整历史可以很大,但定位通常需要初始条件、该步之前的状态、该步事件和版本。避免将令牌或无关账户信息一起放进问题报告,授权读取历史的范围并不等于授权公开全部记录。

校验器当前返回完整frames,错误时不返回半截结果。排错工具若需要部分帧,也应明确标记它停在哪一步以及没有通过最终核验。界面不应把一份失败前的局部回放标为"已验证完整对局",用户看到的结论必须对应实际检查范围。

业务历史展示与当前棋局操作应使用不同控制器。拖回放进度不会发送commands,也不修改实时view;回到当前游戏时重新同步当前房间状态。否则一份旧帧可能被误当成最新依据,玩家在旧seq上操作,连续收到STALE_SEQ。

下面增加完整 client/timeline.mjs,它只接受已经通过replay的frames和绘制回调。seek选择明确帧,play按顺序推进,stop取消后续推进;构造和每次绘制都复制数据,避免界面回调修改内部历史。它可以接浏览器或Cocos绘制函数,不依赖具体UI框架。

javascript 复制代码
// Receives already verified frames, never the live room object.
export class Timeline {
  constructor(frames,paint) {
    if(!Array.isArray(frames)||!frames.length)throw new Error('EMPTY_HISTORY');
    this.frames=structuredClone(frames);this.paint=paint;this.position=0;this.generation=0;this.timer=null;
  }
  stop(){this.generation++;clearTimeout(this.timer);this.timer=null;}
  seek(index){
    this.stop();
    if(!Number.isInteger(index)||index<0||index>=this.frames.length)throw new Error('FRAME_OUT_OF_RANGE');
    this.position=index;this.paint(structuredClone(this.frames[index]));
  }
  play(interval=500){
    if(!Number.isFinite(interval)||interval<16)throw new Error('INTERVAL_TOO_SHORT');
    this.stop();const generation=this.generation;
    const step=()=>{
      if(generation!==this.generation)return;
      this.paint(structuredClone(this.frames[this.position]));
      if(this.position<this.frames.length-1){this.position++;this.timer=setTimeout(step,interval);}
      else this.timer=null;
    };
    step();
  }
}

它的generation与第六篇连接代次类似,但用途是取消旧播放循环。用户拖到开局以后,之前排好的自动播放回调不能又把画面推进到下一步。所有异步展示任务都需要明确当前代次,不能只在按钮上显示"暂停"却让定时器继续运行。

seek先取消播放,再检查索引范围。无效索引报FRAME_OUT_OF_RANGE,不用最后一帧代替输入。play限制间隔不能短于十六毫秒,避免一个错误零值不断创建极密定时器。这个下限用于本演练,不是证明实际渲染已稳定到某个帧率。

timeline的position对应frames数组索引,而本例帧序号连续且从零开始,所以二者相同。未来使用检查点和只保存部分帧时,不能继续假设wantedSeq就是数组下标。应建立序号到帧的映射,或按已保存检查点重新推导目标状态。

完整测试 test/timeline.test.mjs 保存如下。测试播放起点后立即seek回零,等待原定时器可能执行的时间,实际seen仍只有两次零;同时绘制回调修改复制对象,内部与原输入都保持不变。

javascript 复制代码
import test from 'node:test';
import assert from 'node:assert/strict';
import { Timeline } from '../client/timeline.mjs';
test('timeline isolates frames, seek cancels autoplay, and boundaries reject',async()=>{
  const frames=[{seq:0,state:{board:[0]}},{seq:1,state:{board:[1]}}];const seen=[];
  const timeline=new Timeline(frames,frame=>{seen.push(frame.seq);frame.state.board[0]=99;});
  timeline.play(16);timeline.seek(0);await new Promise(r=>setTimeout(r,30));
  assert.deepEqual(seen,[0,0]);assert.equal(timeline.frames[0].state.board[0],0);
  assert.equal(frames[0].state.board[0],0);assert.throws(()=>timeline.seek(2),/FRAME_OUT_OF_RANGE/);
  assert.throws(()=>timeline.play(0),/INTERVAL_TOO_SHORT/);timeline.stop();
});

这项展示测试与脚本验收加入以后,共用测试一共十四项。它验证展示控制器,不替代前面规则重演。把两种测试分清,可以在失败时判断是历史内容不合法,还是合法帧在界面播放时被旧回调覆盖。

若历史有大量事件,预先生成全部完整帧消耗内存,可以按需求生成范围。对当前棋盘最大二百二十五次落子,复制完整棋盘便于理解和检查。做性能优化之前先记录历史长度、单帧结构与内存,再选择检查点间隔,避免凭"代码看起来复制多"就放弃可靠边界。

检查点必须标记规则版本与对应seq。读取时确认后续第一条是seq加一,并检查最终结果。信任一个未经验证的检查点可能让后续全部正常事件都在错误底板上运行。检查点缓存与权威历史应该分清,缓存坏了应能重新从初始状态计算。

并行校验多局时,每局有自己的初始状态和frames,不能把state放在模块级共享变量里。当前replay的state是函数局部,适合多次独立调用。修改成一个全局缓存以后,就要重新检查并发和清理策略,不能仅因为单局测试仍然通过而认为行为不变。

历史接口当前没有分页。教学棋盘事件数有限,返回全部有序事件可以直接核对。长时间玩法应增加分页或范围,但第一页、第二页必须属于同一读取边界或带稳定最终序号,不能边翻页边无声混入新事件。最后仍需要确认自己得到完整连续范围。

分页返回的最终seq要是本次回放目标,不必总是实时最新值。想回放到序号一百,可以固定targetSeq一百,再按范围读取;实时房间后来来到一百零五,不应导致前面的分页目标不断变化。接口需要把这种边界设计清楚,客户端不能仅依靠列表当前长度判断是否结束。

数据库事件不做UPDATE是一个重要约定,但当前schema没有通过数据库触发器禁止更新。应用和账号权限需要共同维护追加写行为。若日后增加管理修正功能,应明确是追加纠正事件还是另建经过审计的迁移,不能为了一个显示错误直接覆盖已保存历史。

回执与事件通过request_id关联,但当前历史输出不会把每份回执也带给界面。回放只需要规则输入与结果,业务防重由回执表负责。恢复和审计时可以额外核对两者数量与关联,展示接口不必为了"资料齐全"把全部会话或其他无关表一起打包。

一个操作已成功但响应丢失,重试返回旧回执,不追加事件,所以回放序号不会因为网络次数增加。若历史中出现同一成功请求的多次落子,说明写入口或回执主键没有按预期工作。用户的重复点击与网络重试应分别记录为不同意图或同一编号,而不是事后根据时间猜测。

事件数量等于seq仍可能有缺口加重复编号,只数count不能完全证明连续。数据库主键限制了同一seq重复,但若人为维护删除或修改编号,就要检查排序后的每一步。replay逐条要求加一,比只看max(seq)和count更可靠。

深比较按完整状态语义核对,不靠JSON对象键顺序字符串相等。数组顺序仍然有意义,因为board的位置映射不能乱。复制或序列化时若有人为了"排序统一"把棋盘数组排序,会让棋子位置全丢失,即使颜色数量相同也不是同一盘棋。

回放显示需要明确它是历史视图。当前RoomPanel用于实时棋盘,若加回放按钮,可以进入独立timeline模式并停止实时交互。回放模式下点击棋盘不应继续提交commands,退出后先同步再启用落子,否则玩家可能误以为自己在真实当前局面行动。

界面保存帧不必保存原始登录密码。历史读取拿到结果后,展示数据和认证凭据生命周期分别处理。退出账号时清理显示缓存,尤其私有玩法的历史视图;公共demo也不应因为简单就把账号切换后的旧数据归属隐藏起来。

排错脚本失败应返回非零进程状态,当前assert或replay异常会让Node退出失败。部署流水线不能只搜日志里有没有ok,而忽略进程退出码。若脚本因为连接不上根本没校验历史,也不能显示"校验无差异",需要区分未执行和执行失败。

保存验证结果时记录版本、目标seq、帧数和最早错误编号,足够说明校验到了哪里。当前脚本成功输出这些摘要,不输出完整敏感资料。需要完整复现数据时在有权限的隔离环境保存,公开文章只展示演示代码与虚构测试内容。

真实业务异常修复后,先在原问题历史副本重演,再跑全部正常与坏数据测试。把校验器改得更宽松让问题通过并不等于修复规则。应该能说明哪条逻辑错、改变后为何符合那一版规则,以及历史版本是否需要迁移。

这一套流程的作用是让状态变更能够解释和复现。它不是游戏运营分析,也没有推断玩家行为动机。每一条有效操作都有序号、账号和规则结果,重演检查它们是否一致;做到这个范围以后,其他业务分析才有可靠数据基础。

数据库恢复以后,可以先运行replay-check再打开回放界面。脚本负责内容校验,Timeline负责播放已经通过的帧,paint回调负责实际棋子节点。三处职责明确以后,问题可以分别归到历史、异步展示或节点绘制,不需要一出现画面错误就改规则函数。

例如脚本输出seq三、frames四,Timeline选择第二步,绘制应看到两颗棋子;若只看到一颗,先查paint是否正确映射全部board,不要去修改events让它配合错误画面。若Timeline自身position错误,检查seek与旧播放代次。只有replay本身报错,才优先查规则和保存内容。

回放速度也不会改变业务事实。五百毫秒间隔和一秒间隔应显示同样的帧,只是推进时间不同;暂停以后当前帧不变,seek后旧定时器失效。可以用同一组frames验证不同播放参数,检查它们的最终状态完全相同,而不是把动画时间当成事件时间。

测试绘制回调故意改写frame.state.board,是为了观察复制边界。真实绘制函数不应改输入,但控制器仍然把自己的帧保护起来。这个行为有助于降低界面误修改历史的影响,不能据此允许规则模块共享可变状态。不同层保护的是各自数据所有权。

完整历史与界面进度条数字也要核对。进度条上限是最后一帧索引,即最终seq;frames.length多一个初始帧,不应该直接拿长度当最大seq。三步事件有四帧,最大可选仍然是三。这个偏一问题很常见,边界测试明确要求seek到四应失败。

回放读取失败时,界面可以显示无法读取,并保留用户明确知道是旧缓存的内容;不能把旧frames套上新房间标题让人以为这是刚取得的历史。缓存需要有房间、实例、版本与目标seq标记,尤其账号切换后要清理归属不同的历史。

如果规则升级计划影响已有记录,先收集受影响版本的一组历史,在新规则下比较差异,再决定保留旧版重演还是迁移数据。无法说明差异来源时,不应批量重写事件内容。这类工作需要具体兼容策略,当前gomoku-v1不会自动选择替代版本。

当前脚本验收已经通过本机HTTP读取一份真实提交历史,并在独立Node进程中执行重演。底层隔离数据库范围明确,真实PostgreSQL恢复库和大量事件仍待验收。把这两层记录分开,后续维护者可以准确补上环境测试,不必重复猜测现有"通过"究竟指哪一条路径。

修复完成后,把最小问题历史加入测试,保留触发差异的那一步和必要初始条件。不要只写一个"可以重演"的大样例而删掉原来的坏数据,否则下次重构可能又把同一个缺口或字段错误放过去。第五篇实际完成的是可执行的历史校验链路,下一篇再把当前状态接到Cocos画面和连接生命周期。

相关推荐
白露与泡影1 小时前
Oracle AI Database 26ai RAC 部署步骤、关键命令与一键脚本
数据库·人工智能·oracle
天天喝旺仔2 小时前
MySQL 索引原理与慢查询优化实战:从 B+ 树到执行计划调优
数据库·sql·mysql·性能优化
liulilittle2 小时前
Linux 下 select 测试函数
linux·服务器·网络·数据库·c++·select·c
DongQiShanRen2 小时前
玄龙(上):TICK 主循环——意识心跳怎么跳
linux·jvm·数据库·人工智能·数据挖掘·rust
数据库小学妹3 小时前
存算分离到底分离了什么?四条架构变化与选型判断
数据库·数据库架构·云原生数据库·缓存一致性·存算分离
FPGA小徐3 小时前
【一生一芯 / PA】异常响应机制:RISC-V 中 ecall → mtvec → mret 的完整代
开发语言·数据库·c#
m0_646429973 小时前
MySQL 初始化 SQL 中文乱码问题总结
数据库·sql·mysql
实战派K8S&DB3 小时前
TDSQL 核心模块与进程体系
运维·数据库·分布式·sql·mysql
FfHUCisI3 小时前
Go 性能分析工具 pprof:CPU、Heap 与 Goroutine 实战
数据库·golang