为什么采用全局管理缓存及状态:麻将客户端状态管理

为什么采用全局管理缓存及状态:麻将客户端状态管理

本文写麻将客户端为什么把游戏数据放在一个全局表里统一管理、缓存为什么集中注册与清理。只写原理,不写实现标识。

一、为什么必须全局管理

麻将客户端面对四个麻烦:服务端多个节点异步下发状态(对局服、匹配服、场景服走不同路径,到达顺序不保证);热更新会重载代码但不能丢对局数据;玩家会掉线重连、最小化恢复、跨局连打;同一状态会被多个事件重复推送(状态同步、房间信息、动作广播、重连、界面显示都可能带同一状态进来)。四个麻烦指向同一个解法:内存里只有一个权威数据源,所有读写走它;所有缓存集中登记,清理时一次清干净。这是本方案的起点,后面几节都是它的展开。为什么是这个解法而不是别的,逐个排除:双数据源(界面一份、逻辑一份)需要持续对账,对账本身就是新缺陷来源,不如不分;每次推送全量重建界面最省事,但玩家选中的牌会被清空,手牌选中反复掉落,体验不可接受;黑名单式清理(默认全清、逐个豁免)漏一个就是一次残留串局,白名单(默认不清、逐个声明)漏一个最多是旧数据多留一会儿,前者致命后者轻微;动画锁强制清零最干脆,但会误释放别的动画的锁,渲染与动画竞争,牌面错乱。只有全局唯一数据源加集中登记清理,同时过掉四个麻烦,所以采用它。采用的不是最聪明的方案,是四个麻烦共同逼出来的唯一解。再往深说一层:麻将的状态是相互依存的,手牌、弃牌、副露、剩余牌数、游戏状态、座位没有一个能独立存在。出牌合法性依赖定缺(缺门不能留),碰杠胡依赖弃牌与手牌,摸牌依赖剩余牌数,终局结算依赖全部累计,箭头指向依赖弃牌堆现状。牵一发而动全身:改一个状态,多个读取方跟着变。

而架构是异步的:多个节点下发状态,到达顺序不保证;事件广播先到后到不确定;定时器回调随时插入。如果采用事件分离(每个事件自带上下文、各模块各自维护),每个处理器收到的都是局部滞后的上下文,还要自己重建与其他状态的依赖关系。同一时刻内存里存在同一状态的多个版本:事件甲里的手牌是旧的,事件乙里的弃牌是新的,合并规则要写在每个处理器里。上下文极其混乱,难以理解:排查一个显示问题,先要确定读的是哪个版本的上下文,再确定合并顺序对不对。

全局状态机把收敛点收到一处:所有写入进同一张表,依赖关系只实现一次(读表即读到全量依赖);异步到达的事件只负责喊变了、触发重读,不携带决策上下文;重复推送由去重整数消化,残留由统一清理兜底。维护复杂度从"事件数乘状态数"降为"状态数":加一个状态,只需管它在表里的读写与清理,不用管它经过哪些事件。收敛复杂度从"每条路径各自收敛"降为"表收敛即全部收敛"。这就是采用全局管理的根本原因:状态互依加异步架构下,分离是发散之源,集中是收敛之器。这可能很反直觉:通常认为解耦和事件分离更先进,全局表像是落后的做法。但事实就是如此------先进与落后不由形式决定,由约束决定。状态互依加异步,就是全局表的主场;换成状态独立加同步调用,事件分离立刻反超。方案没有贵贱,只有约束配不配。对照一下什么时候不该用全局表:纯配置读取(读一次用一次,无状态、无互依、无异步),用全局表就是杀鸡用牛刀,直接读配置即可;单次请求响应(登录、下单,发完等回,无他人并发改同一份数据),用事件传参最干净,不需要权威表。麻将两条全占(互依加异步),所以用全局表。判断方法只有一条:数一数有多少个写者、多少个读者、到达顺序保不保证。单写者单读者保序,不用全局表;多写者多读者乱序,必须全局表。对上号再动手,不要一上来就分层分表。还要说透竞态:分布式异步天然存在竞态,快照与动作谁先到、通知与代答谁先跑、重连补发与本地旧值谁新谁旧,没有一种顺序是保证的。竞态不是异常,是常态;设计时就要按"任何顺序到达结果都一致"来写,而不是先按一种顺序写对、再逐个修其他顺序的 bug。去重整数管重复到达,快照替换管新旧覆盖,收窗清理管残留,每一个机制背后都是一种竞态。把竞态当常态写代码,缺陷少一半;把竞态当异常修代码,修完一个冒出三个。集中登记的好处用气泡定时器举例:四座气泡各有隐藏定时器,分散管理时重建要找四个地方关,漏一个就串局;集中登记后重建只管串行表,一次全关。但前提是登记齐全:漏登记的定时器重建时照样杀不掉,所以创建紧跟登记是必须遵守的约定,漏登记等于没登记。
#mermaid-svg-YEz7ROlUULNzzjfR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YEz7ROlUULNzzjfR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YEz7ROlUULNzzjfR .error-icon{fill:#552222;}#mermaid-svg-YEz7ROlUULNzzjfR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YEz7ROlUULNzzjfR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YEz7ROlUULNzzjfR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .marker.cross{stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YEz7ROlUULNzzjfR p{margin:0;}#mermaid-svg-YEz7ROlUULNzzjfR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label text{fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label span{color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster-label span p{background-color:transparent;}#mermaid-svg-YEz7ROlUULNzzjfR .label text,#mermaid-svg-YEz7ROlUULNzzjfR span{fill:#333;color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .node rect,#mermaid-svg-YEz7ROlUULNzzjfR .node circle,#mermaid-svg-YEz7ROlUULNzzjfR .node ellipse,#mermaid-svg-YEz7ROlUULNzzjfR .node polygon,#mermaid-svg-YEz7ROlUULNzzjfR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .rough-node .label text,#mermaid-svg-YEz7ROlUULNzzjfR .node .label text,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label,#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label{text-anchor:middle;}#mermaid-svg-YEz7ROlUULNzzjfR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .rough-node .label,#mermaid-svg-YEz7ROlUULNzzjfR .node .label,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label,#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label{text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .node.clickable{cursor:pointer;}#mermaid-svg-YEz7ROlUULNzzjfR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .arrowheadPath{fill:#333333;}#mermaid-svg-YEz7ROlUULNzzjfR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YEz7ROlUULNzzjfR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YEz7ROlUULNzzjfR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster text{fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR .cluster span{color:#333;}#mermaid-svg-YEz7ROlUULNzzjfR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YEz7ROlUULNzzjfR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YEz7ROlUULNzzjfR rect.text{fill:none;stroke-width:0;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape p,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YEz7ROlUULNzzjfR .icon-shape .label rect,#mermaid-svg-YEz7ROlUULNzzjfR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YEz7ROlUULNzzjfR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YEz7ROlUULNzzjfR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YEz7ROlUULNzzjfR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务端多节点
对局状态快照
匹配通知
重连补发
全局唯一数据源

服务端字段+本地字段分离
渲染: 只读数据源
决策: 状态变迁去重
定时器串行化

二、唯一数据源与热更新保留

全局数据表是唯一的游戏数据源:房间、状态、座位、手牌、弃牌、副露、倒计时全部存在里面,界面、推荐、自动托管都从同一个读取入口取数。不存在第二份对局数据,所以不存在两份数据打架。读取入口只返回表引用,不拷贝:调用方读到永远是最新值,但也不能私自改表结构,写操作集中在数据层的几个函数里。

走查例子:假设推荐模块与界面同时读手牌。推荐算出听牌集合,界面画出手牌,两边读的是同一张表,不会出现推荐按新牌算、界面按旧牌画的分裂。若各模块自建缓存,同步时机不同必然分裂,排查时先要确定谁的数据过期,成本极高。统一数据源把这类问题消灭在结构上。

热更新保留旧值:重载时全局表沿用旧表,再与默认模板合并补全新增字段。规则有三条:旧值优先(对局中数据不丢);新增字段由模板补默认值(新版本加字段不崩);协议映射表同样保留(不断连,数字协议与名字映射不断)。走查例子:假设热更新新增连胜字段并新增一条协议。重载后连胜补零,协议映射保留旧二十条并加入新映射,对局中请求不断连。若映射表不保留,重载后所有协议数字对不上名字,请求发错协议,整局协议错乱。本家手牌独立存放:手牌表恒为不含摸牌的张数,摸到的牌独立存一个字段,逐张模拟时先合并再判听。这个约定被验胡、推荐、自动出牌三处共用,改一处即改全部,所以必须写成全局契约而不是各模块自悟。违反契约的代价是注释里写明的坑:逐张模拟打出后若不把摸牌补回,张数检测恒不通过,建议恒空,整个推荐面板空白。契约的价值就在于把这类坑从三处合并为一处:修一次,全好。

走查例子:假设对局中热更新,玩家手牌 13 张加摸牌 1 张。重载后全局表保留,手牌与摸牌都在,新增字段补默认值,倒计时定时器按剩余时间继续跑,玩家无感知。若没有保留机制,重载即回到默认空表,界面清空、倒计时归零、托管状态丢失,对局直接中断。再假设新版本给玩家加了一个新字段(如连胜次数),旧表没有该字段。合并模板后该字段补默认值 0,老数据照用,新功能照跑,不需要写数据迁移脚本。这是模板合并相对整表替换的优点:字段级兼容,不用版本号分支。

三、服务端字段与本地字段分离

快照替换是数据不同步的根源,分离是解法。字段分两类:服务端字段(房间号、游戏状态、庄家座位、当前座位、最后出牌、骰子、剩余牌数、剩余毫秒、回合数、底分、响应座位表)每次随快照全量重建;本地字段(匹配类型、各类等待时长、本家身份、托管状态、待响应表、换牌选择、换入牌、定缺、结算残留、箭头备份、房主标识、分享码)快照替换时原样保留。

快照替换分三步,每步缺一不可:先保存本地字段与摸牌(不存则清零),再清空全部服务端字段(不清则新旧混杂,玩家列表出现重影),最后从快照重建(跳过本地字段名,玩家逐个按白名单过滤)。被踢出的玩家跳过恢复:快照是过去的全量,踢出是现在的决定,用过去的全量覆盖现在的决定就是复活,必须用踢出名单挡一道。显式清理表同理:服务器有意置空的字段,遍历是看不见的(空值不出现),必须走独立通道声明,否则旧值永久残留,表现为改了没生效。重建时跳过本地字段名,玩家逐个按服务端玩家字段白名单过滤重建,被踢出的玩家跳过恢复(快照旧数据不复活已踢出者,否则被踢者换个快照又坐回来)。显式清理表先剥离再处理:空值在遍历中传不透,必须用独立通道声明哪些玩家字段要清零,否则服务器有意置空的字段永远清不掉,旧值残留。增量合并走另一条路:只合新增变化,本地字段跳过不碰,适合高频小更新;全量替换适合重连与状态跳变,两条路按场景选用,不混用。增量合并的座位级合并保留旧玩家已有游戏状态:匹配与开局下发的玩家表仅含基础资料,直接覆盖会清掉对局状态,所以逐座位合并,非服务端字段从旧数据继承,显式声明要清的才清。清玩家指令必须在循环前处理:遍历顺序不定,晚于玩家设置会清掉刚设的数据。私有手牌子字段合并:设置提供的收下,没提供的不动,表深拷贝、值直接赋。流水幽灵项过滤:付款方与收款方为零的项剔除,否则结算面板渲染出付款给空白玩家的行。其他字段直接赋值,表深拷贝隔离引用。合并后逐座位补齐缺失字段,手牌表保底空表。入口先校验:房间号匹配类型状态为数字、座位一到四、玩家为表、编号为数字,不合即拒收并记日志。校验是合并的前门:脏数据进表后排查成本高得多,拒收在前最便宜。首次进房的四个入口(建房、匹配、分享码进房、开局)是例外:此时本地还没有值,服务端下发的本地字段必须完整保存。入口初始化合并只收非空值,空值不覆盖,避免把"没下发"当成"清零"。终局残留三保险中的数据层兜底也在此:新局进入换三张或定缺,待渲染标记无条件清,不管之前是谁置的。

为什么必须分离:换牌选择、定缺、托管状态、箭头备份都是客户端在对局推进中产生的,快照里没有。若不分离,每次快照到达都会把这些清零:换三张选到一半被清空、托管中被快照打回未托管、箭头备份丢失导致终局箭头指错。分离后快照只管服务端权威部分,本地推进不受跨服延迟影响。

走查例子:假设玩家换三张已选 2 张,此时快照到达。替换流程先保存换牌选择,清空服务端字段,重建后再把换牌选择放回,玩家看到的已选牌不动。若没有分离,换牌选择被清空,玩家得重选;若玩家没发现,倒计时结束按空选择提交,换牌失败。再假设玩家托管中快照到达:托管值在本地字段里,快照重建不碰它,托管门禁继续生效;若托管值被清空,玩家一点按钮就发出请求,与服务端托管状态对不上,出现能操作却被服务端拒绝的怪象。第三个例子是箭头备份:碰后箭头回退点存在本地字段,快照到达不清除,终局箭头恢复有据可查;若被清空,终局箭头指向已被碰走的牌。

白名单的另一面是约束:服务端字段新增必须同步进白名单,否则重建时漏掉;本地字段新增必须想清楚生命周期(何时清、谁来清),否则永久残留。每次加字段都回答这两个问题,清单不会腐烂。

快照重建的玩家级细节决定显示对错,走查四个事故:第一,防空表保护。假设对局中快照到达,对手弃牌表为空表(非终局广播可能为空,本家手牌走私有通道不受影响)。若直接覆盖,累积弃牌清零,出牌统计显示对手一张没出过;正确做法是空表不覆盖,已有累积保留。第二,定缺误遮盖。假设上局定缺为万,新局尚未定缺,快照无定缺字段。若从旧数据继承万,新局定缺阶段万花色被遮盖,玩家以为万是缺门,实际还没定;正确做法是快照缺失即置空,绝不继承。第三,旧座位恢复:快照少发座位,从旧数据补回,被踢出者除外,防止快照少发导致玩家消失。第四,私有手牌取服务端值,杠列表与自摸标记跨同步保留(服务端快照不含这两样);摸牌优先从快照取,快照与旧值都没有才兜底------此前无条件用旧值覆盖,把重连首帧服务端下发的摸牌丢弃,自摸按钮消失。第五,待响应状态新局非等待则清空,回合等待列表非终局则清空。第六,世代计数每次替换加一,供调试追踪数据新旧。走查例子:假设玩家报出牌统计不对,查日志世代计数发现快照替换了五次而界面只刷了三次,定位到动画推迟吞了两次渲染;没有世代计数,只能逐帧对日志,排查成本数倍。每一条背后都是一次显示事故,清单即教训。

四、去重整数:同一状态只执行一次

同一状态会被多个事件重复推送(状态同步、房间信息、动作广播、重连、界面显示),阶段变迁函数必须对重复免疫。解法是一个整数:记录上次执行变迁时的状态,本次相同直接返回,不同才执行并更新记录。渲染侧另有一个独立整数,职责相同。

分支规则:出牌与响应阶段同值不重复进出(避免清掉手牌选中);换三张与定缺各走各的进入函数(没进过换三张的重连直接定缺);其他状态只在离开特殊阶段时清理:终局与回合终局清,等待准备加载态不清(无特殊界面可清)。分支用白名单而不用黑名单:新状态加入时默认不清理,必须显式声明才清。出牌与响应阶段同值不重复进出是白名单精神的另一面:已在出牌阶段就别再进一次,重复进入的清理动作会清掉手牌选中。玩家正在选牌,推送一到选中全掉,这种缺陷不崩溃但极伤体验,去重整数专治此类问题。退出房间时两个整数置零,下次进房强制完整重走一遍,不跳过任何分支。

走查例子:假设出牌阶段连续收到三次同一状态推送。第一次执行进入出牌阶段,记录更新;第二三次读到相同记录,直接返回,手牌选中保留。若没有去重整数,每次推送都重进一次出牌阶段,手牌选中被清空三次,玩家选中的牌反复掉落。假设退出房间后重进,记录已在退出时置零,完整重走进入分支,界面重建正确。再假设没进过换三张的重连(如断线时正在定缺):分支直接定缺,不经过换三张进入函数,避免换三张界面弹出来挡住定缺。分支的完备性(每个状态都有明确去向)与去重整数配合,一个管走哪条路,一个管走几次。

五、渲染与决策分离

渲染函数只读数据源画界面,不做状态决策:面板不可见跳过渲染(显示时完整重建);动画进行中推迟渲染(置推迟标志,动画结束补画);房间数据重置先清桌防旧房间残留;终局补偿渲染只对终局相关阶段执行(白名单制,出牌阶段移出白名单,防止旧终局串入新局手牌渲染)。

决策函数只做阶段变迁,不画界面:按状态进入换三张、定缺、出牌分支,终局只清理。渲染先于决策调用:先画出当前状态,再基于当前状态做变迁。若顺序反过来,变迁的清理动作会隐藏刚画好的界面。两者的副作用必须对齐:渲染画什么,决策不能隐藏什么;对不上的改决策,不改渲染。听牌推荐是副作用对齐的实例:此前任何刷新都隐藏推荐面板,玩家点开不足一秒就被下一次刷新杀掉,面板永远显示不出来;现仅在离开出牌与响应阶段时隐藏,手牌变化由动作处理器隐藏,阶段刷新不再误杀。换三张分支渲染完直接返回:牌墙按剩余与骰子算四家张数,玩家信息含空位清理,托管刷新,按钮隐藏,一个分支管一摊事,不与其他阶段共享代码。

换三张进入走动画时间线三分支:时间已过直接进选派;时间未过且动画没跑启动动画链(重连跳过已完成子阶段);动画运行中跳过。三分支覆盖正常、重连、进行中,不多不少。定缺用假阶段锚定消除空窗:状态一到达即置换牌显示标记,闩锁保证换牌切定缺只切换一次,绝不来回;服务端推进到定缺时若换三张动画还在跑立即取消,骰子隐藏;没进过换三张的重建走独立飞入标记,不复用选派标记(复用会走错恢复路径)。每个标记只表达一件事,一个标记两种含义就是一次串局。

动画锁推迟机制:动画进行中到达的渲染请求不丢弃,置推迟标志后返回;动画结束时检查标志补画一次。没有推迟机制的替代方案是动画中直接画,结果是动画与渲染竞争同一批控件,牌面闪烁或位置错乱;另一个替代方案是丢弃请求,结果是动画结束界面停在旧状态,玩家看到过期牌面。推迟是第三条路:不争也不丢,等动画让路。

终局补偿渲染同理:终局到达时若在动画中,先记待渲染标记,任意阶段进来先消费标记再正常渲染;新局开始时丢弃旧终局标记,防止旧终局串入新局。白名单限定补偿只在终局相关阶段执行:出牌阶段移出白名单后,胡牌瞬间先到的终局不再阻塞新局手牌渲染(此前第二局手牌永不渲染)。终局渲染前先缓存当前牌面(副露弃牌手牌与张数),防止清空后服务器数据未到时空渲染,白屏一闪。缓存的是引用快照不是深拷贝:终局瞬间数据不再变化,引用足够;若此时数据还会变,引用缓存会跟着变,就必须深拷贝。用引用还是用拷贝,取决于数据在缓存有效期内变不变,这是缓存设计的第一问。终局渲染后补回缺门标识(渲染清桌逻辑会隐藏它),锚定最后弃牌箭头三选一:终局结果里的权威弃牌优先,其次当前最后出牌,最后是碰前备份。终局补全对齐:操作按钮骰子隐藏、倒计时清理、赢家结果渲染、准备按钮恢复,缺一项终局界面就缺一块;渲染后再强制重锚一次箭头,后续任何界面操作都不得动箭头。已胡玩家徽章与胡牌常驻显示,直到下个回合清桌才重置:重连回来没有动作回放,快照里一旦有赢家就恢复显示,否则已胡玩家到终局才现身。流局效果仅加载态显示,新局清桌后不再重显。

六、定时器串行化与重建

所有可能冲突的异步操作(定时器回调)集中登记在一个串行表里。进入重建临界区时,取消所有已登记的旧定时器,保证上一个任务不干扰下一个。登记动作紧跟创建动作,每个创建都有登记配对,漏登记的定时器重建时杀不掉,会在新局到期触发旧局回调。

被杀定时器的善后分两类:动画锁释放责任单独登记补偿表,被杀时精确执行补偿,保证锁的加解锁严格配对(替代强制清零,强制清零曾误释放其他动画的锁);其余定时器直接取消。最小化恢复传保留参数不杀定时器:杀后重建永远有遗漏(部分重建了、部分没重建),导致倒计时与超时自动决策偶发死掉;保留则剩余时间天然正确(定时器隐藏期间持续运行,归零事件照发),恢复后直接可用,回调带纪元守卫保证隐藏期执行安全。

倒计时时钟单独单杀(不注册串行表):此前只置空引用不关句柄,残留句柄在换局后于新局到期触发旧局回调(自动换牌与自动定缺串局);现先关句柄再置空。开局流程几个计时器变量同样只清引用:因为句柄已由串行表关闭,这里再关一次会重复关闭,清引用防重复,串行表关句柄,两道配合。最小化恢复的回调带纪元守卫与动态查找窗口:隐藏期间回调触发时先对纪元,纪元不对直接返回;找窗口动态找,不缓存旧引用。守卫保证保留下来的定时器在隐藏期执行安全,这是保留模式敢用的前提。走查例子:假设最小化期间换三张倒计时到期,回调触发时界面隐藏。纪元对上,执行自动提交,状态推进;玩家恢复时直接看到定缺界面,倒计时位置显示新阶段倒计时。若无纪元守卫,回调操作隐藏中的旧窗口引用,轻则界面错乱,重则访问销毁对象出错。动态查找保证每次找到的都是当前窗口,不是缓存的上局引用。焦点、气泡、胡牌视图、动画定时器表各自清理,防止重建后操作已销毁的窗口引用或隐藏新创建的气泡。气泡是重灾区:旧气泡隐藏定时器若残留,新局同座位的新气泡一创建就被旧定时器隐藏,玩家看到气泡一闪而过,还以为是显示缺陷,实际是定时器串局。四座气泡列表逐个清空加定时器全关,双保险。胡牌视图两个定时器(隐藏与置顶)同样单杀:重建后操作已销毁的胡牌视图会出错,不止是显示问题。

走查例子:假设换三张倒计时剩 5 秒时玩家最小化。保留模式下定时器继续跑,5 秒后回调触发自动提交,玩家恢复时已进定缺。若采用杀掉模式,恢复时需重建倒计时:重建逻辑必须精确恢复剩余 5 秒,任一分支遗漏则倒计时不工作或自动提交丢失。保留模式把正确性交还给计时器本身,重建逻辑只需管界面。杀后重建遗漏是实测教训:部分重建了、部分没重建,倒计时与超时自动决策偶发死掉,只能等服务器推进后恢复。从那以后最小化恢复一律保留定时器,真重建才杀。
#mermaid-svg-Tzh7oFnnmxnBpkXV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tzh7oFnnmxnBpkXV .error-icon{fill:#552222;}#mermaid-svg-Tzh7oFnnmxnBpkXV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tzh7oFnnmxnBpkXV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .marker.cross{stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tzh7oFnnmxnBpkXV p{margin:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label text{fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label span{color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster-label span p{background-color:transparent;}#mermaid-svg-Tzh7oFnnmxnBpkXV .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV span{fill:#333;color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node rect,#mermaid-svg-Tzh7oFnnmxnBpkXV .node circle,#mermaid-svg-Tzh7oFnnmxnBpkXV .node ellipse,#mermaid-svg-Tzh7oFnnmxnBpkXV .node polygon,#mermaid-svg-Tzh7oFnnmxnBpkXV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .rough-node .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label text,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .rough-node .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label,#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label{text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node.clickable{cursor:pointer;}#mermaid-svg-Tzh7oFnnmxnBpkXV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .arrowheadPath{fill:#333333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster text{fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV .cluster span{color:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Tzh7oFnnmxnBpkXV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tzh7oFnnmxnBpkXV rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape p,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tzh7oFnnmxnBpkXV .icon-shape .label rect,#mermaid-svg-Tzh7oFnnmxnBpkXV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tzh7oFnnmxnBpkXV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tzh7oFnnmxnBpkXV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tzh7oFnnmxnBpkXV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 真重建


最小化恢复
创建定时器
紧跟登记入串行表
进入重建?
取消全部已登记定时器
有锁释放责任?
精确执行补偿
直接取消
保留定时器

剩余时间天然正确

七、分级重置:身份、骨架与全清

重置分三档,按场景选用,不多清不少清:

  1. 身份保留(局间):保留房间号、匹配类型、本家身份、托管值、房主标识、来源,用于终局后准备与取消托管。房主标识必须先存后恢,否则新局后房主标记消失、无法踢人。
  2. 骨架保留(局间):保留玩家身份字段白名单(编号、名字、座位、状态、准备、托管、房主、段位分等),不保留手牌副露弃牌。此前整表深拷贝把上局终局公开手牌带进下局,而新局非终局只发空手牌,空表保护不覆盖已有手牌,导致对手上局手牌跨局残留。骨架白名单修掉了整类问题。
  3. 全清(退出踢出无房间):房间号与匹配类型清零,避免房间信息条件误触重开面板;终局残留同步清理。

离开加载态清桌走查:假设上局终局后面板停在加载态显示终局牌面,新局开始状态离开加载态。清理条件成立:清桌、清上局结算(有待渲染标记则保留给渲染消费)、清终局缓存。渲染记录更新为当前状态与回合。若清理条件依赖回合号递增,单局制跨局服务端把回合号重置为零,递增永不成立,清理被跳过,旧终局残留。所以清理条件只看是否离开加载态,不看回合号。掉线重连不经过重置:数据保留,重进恢复依赖服务端补发。走查例子:假设玩家出牌阶段掉线 30 秒,期间错过三次状态推送。重连后服务端补发整包,本地在旧数据上合并,玩家看到牌面从断线前平滑过渡到最新,没有闪白没有重进房间。若重连走重置,本地先清空再等整包,中间有几秒白屏;整包万一延迟,白屏更久。保留的代价是合并逻辑必须处理乱序(补发与本地旧值的先后),这部分由快照替换与增量合并两条路分担。终局残留三保险走查:假设上局终局后面板已弹结算,新局进入换三张,数据层无条件清待渲染标记,界面侧离开加载态再清一次,玩家点退出重进则全清。三道分别堵数据层、界面层、房间层,旧终局三条串局的路全断。

事件发布拷贝隔离:发布时先把回调表抄一份再逐个调用,每个回调独立捕获异常,单个崩溃记日志不影响其他订阅者,无订阅者也记一次。没有隔离的替代方案是一个回调崩溃整条广播链中断,后面订阅者收不到事件,排查时先要确定是谁崩的。请求回调取即清:取回调的同时清除登记,防止重复调用;无回调有错则发错误事件,有回调则错误码与响应一起交付,调用方已自行发布的传参防双发。超时定时器在回调处理时取消,超时与回调谁先到谁赢,赢了的把对方登记清掉,输了的找不到登记直接返回。重置的定时器清理分先后:先关记录在案的全部句柄,再清本地记录,最后清倒计时动画推迟回合换牌请求超时回调与类型索引。顺序不能反:先清记录后关句柄,回调在记录已空时可能误触发。请求回调逐个通知超时错误码,不静默丢弃,等待中的请求方收到明确失败而不是无限挂起。防重入、频控、退出锁、跨局累计(胡牌计数、座位回合信息、踢出名单)同步归零。走查例子:假设玩家退出房间秒进新房,退出锁未释放,新房入口响应到达时发现锁还在,直接跳过面板打开,玩家卡在房间外;频控未重置,进房第一次出牌被判操作过频,直接拒绝;踢出名单未清空,新房同桌玩家被当成踢出者跳过恢复,座位空着。三个锁三种死法,归零一次全消。胡牌计数不清则新局连胜显示串台,座位回合信息不清则新局回合归属错乱。推荐缓存强制清空已在前文,自动动作定时器同样单杀。重置函数是全客户端状态的最大公约数清理点,漏一项就是一类串局 bug。推荐模块缓存在重置时强制清空,防止推荐持有旧手牌:重置不清推荐缓存的后果是新局推荐面板显示上局听牌,玩家照打,句句不沾边。换房跨局清桌时同步重置语音防重标记:否则新局放一遍上局放过的语音,玩家以为出牌了,实际只是旧标记没清。清桌、语音标记、牌桌三者同一次清理,不分开。

走查例子:假设第一局结束同房再来一局。重置保留身份与骨架,清掉手牌副露弃牌与终局残留;新局发牌后快照重建服务端字段,骨架合并为完整玩家,准备按钮可用,房主可踢人:终局、回合终局及加载态恢复准备按钮显示。若骨架带入手牌,第二局换三张阶段对手手牌显示的是上局终局公开牌,且不会被空快照覆盖,玩家看到错牌。自动动作定时器同样单杀:重置不清它,新局到期触发旧局自动动作,牌还没发就自动出牌。若终局残留未清,新局出牌阶段可能弹出上局结算面板。若房主标识未保留,新局房主踢人按钮消失,房间管理瘫痪一半;三个反例对应三类保留字段,缺一不可。

掉线重连走另一条路:不经过重置,数据原样保留,等服务端补发。重置与补发互斥:重置过的等快照重建,没重置的等补发合并。若重连也走重置,本地手牌清空后只能等整包,重连期间界面全空;保留本地数据则重连瞬间仍显示旧牌面,补发到达后无缝更新,体验连续。
#mermaid-svg-CgyTS9XNUqLZw4w1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CgyTS9XNUqLZw4w1 .error-icon{fill:#552222;}#mermaid-svg-CgyTS9XNUqLZw4w1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CgyTS9XNUqLZw4w1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .marker.cross{stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CgyTS9XNUqLZw4w1 p{margin:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label text{fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label span{color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster-label span p{background-color:transparent;}#mermaid-svg-CgyTS9XNUqLZw4w1 .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 span{fill:#333;color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node rect,#mermaid-svg-CgyTS9XNUqLZw4w1 .node circle,#mermaid-svg-CgyTS9XNUqLZw4w1 .node ellipse,#mermaid-svg-CgyTS9XNUqLZw4w1 .node polygon,#mermaid-svg-CgyTS9XNUqLZw4w1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .rough-node .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label text,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .rough-node .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label,#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label{text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node.clickable{cursor:pointer;}#mermaid-svg-CgyTS9XNUqLZw4w1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .arrowheadPath{fill:#333333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster text{fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 .cluster span{color:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CgyTS9XNUqLZw4w1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CgyTS9XNUqLZw4w1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape p,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CgyTS9XNUqLZw4w1 .icon-shape .label rect,#mermaid-svg-CgyTS9XNUqLZw4w1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CgyTS9XNUqLZw4w1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CgyTS9XNUqLZw4w1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CgyTS9XNUqLZw4w1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 同房再来
退出踢出
掉线重连
对局结束/退出/重连
场景?
保留身份+骨架

清手牌副露终局残留
全清

房间号匹配类型归零
不重置

等服务端补发
新局快照重建

八、为什么不用事件传参而用全局表

一个自然的问题:模块间为什么不直接传参,非要经过全局表?答案是事件是多对多广播,参数是一对一传递。对局状态有多个读取方(界面各控件、推荐、托管、音效、记牌器),若用事件传参,每次状态变更要构造包含全部字段的大包发给所有人,丢一个字段就有一个模块读空;新增读取方要改发送方,发送方与接收方互相耦合。全局表反过来:发送方只管写表,读取方按需自取,新增读取方零改动发送方。代价是写操作必须集中(防止乱写),读到旧值的责任在读取时机(配合去重整数与推迟渲染解决)。事件通知负责喊"变了",全局表负责回答"变成什么样",两者分工。走查例子:假设新增一个记牌器面板,需要弃牌、副露、剩余牌数。用全局表方案,记牌器直接读表,发送方一行不改;用事件传参方案,发送方要在每次变更构造大包并找到记牌器发过去,漏一个字段记牌器少一项,新增发送路径还要处理重连补发。读取方越多,全局表的优势越大;只有一个读取方时,两者差不多。

九、私有手牌契约:13加1

本家手牌表恒为不含摸牌的张数,摸到的牌独立存一个字段,这是全局契约。快照重建时私有手牌取服务端值,杠列表与自摸标记跨同步保留(服务端快照不含这两样,丢了则杠按钮与自摸按钮消失)。摸牌优先从快照取,快照与旧值都没有才用旧值兜底:此前无条件用旧值覆盖,把重连首帧服务端下发的摸牌丢弃。逐张模拟打出后必须把摸牌补回再判听,否则张数检测恒不通过。三个地方(验胡、推荐、自动出牌)共用同一约定,改约定即改三处,所以约定写死不动。走查例子:假设重连首帧服务端下发摸牌为七万,旧值无摸牌。正确流程取快照七万,推荐按十四张算听牌;错误流程用旧值覆盖,快照七万被丢,推荐按十三张算,张数检测不过,建议空白。

十、为什么这套方案成立

六个为什么串起来就是采用理由:为什么唯一数据源------多读取方下事件传参的耦合与漏字段成本高于集中写的约束成本;为什么字段分离------快照不同步是常态,本地推进不能等跨服延迟,分离让两边各走各的时钟;为什么整数去重------重复推送是常态,去重保证同一状态只执行一次,选中不掉;为什么渲染决策分离------渲染管画,决策管变迁,副作用不对齐的锅由决策背,不碰渲染;为什么串行化------异步回调天然并发,集中登记加统一清理是唯一不遗漏的办法,保留模式是杀后重建遗漏教训换来的;为什么分级重置------身份骨架全清三档对应三种场景,多清丢体验(白屏),少清串数据(错牌),分级是两者之间的刻度。每个为什么背后都有一个排除掉的替代方案和一次实测教训,方案是教训的形状。反过来也要说清代价与边界:全局表的代价是写操作必须集中,改表要过评审,随手加字段的时代结束了;读旧值的责任在读取时机,读早了读到旧值不能怪表,要怪自己的调用点没对时机。走查例子:假设某模块在快照到达前读状态做决策,读到的是旧状态,决策错了不能怪表脏,只能怪自己没等快照;正确做法是决策点订阅状态变更事件,快照到达触发重读,或者决策前先读世代计数,世代不对直接返回;表越大合并越贵,所以白名单只放必要字段,流水统计这类只增不改的数据另找地方存,不进全局表。全局管理管的是对局状态,不是所有数据;什么都往全局表塞,表就变成垃圾场,清理时一样遗漏。边界与方案同等重要。

十一、测试建议

以下为建议做法,不是代码事实:改动快照替换后,用换三张选牌中、托管中、箭头备份三个场景验证本地字段不丢,再加被踢玩家不复活一条;改动去重整数后,用同一状态连推三次验证只执行一次,用重连验证完整重走,用没进过换三张的重连验证直定缺分支;改动渲染决策顺序后,验证准备标记不被终局清理链误清,验证动画中渲染推迟后补画;改动定时器后,验证最小化恢复倒计时继续、换局无旧回调串入、动画锁加解锁配对;改动重置后,用同房再来验证房主可踢人、用退出验证面板不重开、用重连验证数据恢复、用终局后换局验证无旧结算弹出;改动私有手牌后,用重连首帧验证摸牌不丢、杠按钮与自摸按钮都在;新增字段后,回答白名单与生命周期两个问题再合入:进服务端表还是本地表,何时清、谁来清,答不上来不准加;合入后补一条走查,证明该字段在快照、重连、换局三条路径下都不丢不串。改动事件发布后,用一个崩溃回调加一个正常回调验证隔离:崩溃的记日志,正常的照常收到。改动请求回调后,验证超时与回调同时到达只处理一次,回调取后登记清空,重复到达直接返回。改动校验后,用座位号五、玩家非表、编号非数字三条脏数据验证拒收;改动推荐缓存后,用重置验证缓存清空,用高频事件验证防抖有效且结果正确。

相关推荐
酣大智3 小时前
OSPF引入的直连路由在 ospf 路由表中看不到
网络·ospf
2601_966377133 小时前
等保2.0最新标准新增安全数据处理要求后怎么办?等保一体机数据安全模块来助力
运维·网络·安全·等保一体机
BullSmall3 小时前
window系统上报Cannot assign requested address
网络·功能测试
杨云龙UP4 小时前
MySQL Host is blocked because of many connection errors 导致 JDBC 连接失败排查与解决
linux·运维·网络·数据库·sql·mysql·登录失败
执念WRD4 小时前
Fastjson 1.2.83 RCE 漏洞利用
网络·安全·网络安全
xixiaoyunya4 小时前
局域网多电脑文件互备:从传统痛点到自动化方案的技术路径
网络·安全
Wang's Blog4 小时前
Java框架快速入门: Spring Security+OAuth2之搭建授权服务器(依赖与表结构)
java·服务器·spring
Lynne3095 小时前
为什么工业数据越来越需要在现场处理?边缘计算正在解决什么问题?
网络·数据分析·数据
m0_734571765 小时前
深入理解5g <十二> pucch
网络·5g