麻将系统架构


修订记录

版本 日期 说明
v1.0 2026-07-16 首次发布

一、系统概述

麻将(Mahjong)是某MMO项目中的一个子游戏系统,属于畅玩阁(SmallGame)体系。采用分布式跨服架构,由五个服务器角色、一个客户端和一个通用小游戏框架组成。

1.1 系统边界

畅玩阁 SmallGame

麻将 Mahjong(本文档)

其他玩法(SmallGame 管理)

共享基础设施

货币 attr68/69

配置加载 smallgame_config_loader

段位系统

畅玩阁 UI changwange/

1.2 Little Game Framework

畅玩阁(SmallGame)提供通用基础设施:

能力 接口 说明
货币操作 AwardCurrency / GetCurrencyBalance / GetFrozenCurrencyBalance 属性68/69
配置加载 smallgame_config_loader.* 委托式访问
子游戏注册 RegisterGameModule(nMatchType, tGameModule) 按匹配类型注册
自走棋回调 GameServer_OnSettlement / GameServer_QueryRewardType / GameServer_QueryConsumeType 外游戏扩展
畅玩阁日志 LogFunGame(player, szStepNumid, nResults, tData) 对局生命周期日志
玩家数据 GetRole(dwID) / GetPlayingRole(dwID) 跨服/本服

1.3 模块加载顺序

modules/modulelist.xml:
  modules/chat          ← 第31行
  modules/smallgame     ← 第243行(先于麻将加载)
  modules/mahjong       ← 第244行

ui/modulelist_game.xml:
  ui/chat               ← 第10行(最早加载的UI之一)
  ui/changwange         ← 第161行(畅玩阁UI)
  ui/mahjong            ← 第162行(麻将UI)

结论:chat 最早加载,smallgame 次之,mahjong 最后。smallgame Include chat 时 chat 的全局变量已存在。

1.4 模块配置

modules/mahjong/config.xml 中每个 <节点> 子目录是引擎框架定义的运行环境。麻将按节点分别加载不同的脚本:

<GameClient>  ← 客户端运行时
    加载:mahjong_dump, config_loader, define, voice_define, map, timer,
          verifier, fan, voice, client_core, client_ai, client_recommend,
          client_autoplay, client_profile (14个)
    用途:客户端内置的麻将逻辑。包含 g_tData 数据源(client_core)、
          胡牌检测(verifier)、番型计算(fan)、推荐引擎(recommend)、
          AI托管(client_ai)、自动出牌(autoplay)。UI 通过 Include 引入

<GameServer>  ← 服务器运行时
    加载:mahjong_dump, config_loader, define, voice_define, verifier, map,
          timer, play_server, game_server (9个)
    用途:GameServer(玩家C2S入口+货币操作)和 PlayServer(对局逻辑)
          同进程运行。verifier 用于服务端校验胡牌合法性

<GameCenter>  ← 消息路由
    加载:mahjong_dump, config_loader, define, voice_define, map, timer,
          game_center, match_center (8个)
    用途:game_center 负责消息转发,match_center 负责 PlayServer 分配和路由

<SceneServer>  ← 房间管理
    加载:mahjong_dump, config_loader, define, voice_define, map, timer,
          snowflake, scene (8个)
    用途:房间生命周期管理、匹配、结算重试。snowflake 生成唯一房间ID

不加载麻将脚本的节点:LogServer、ServerMergeTool

1.5 文件统计

目录 文件数 总行数 说明
modules/mahjong/ 15 ~23500 服务器 + 客户端逻辑
ui/mahjong/ 8 ~14500 麻将UI
modules/smallgame/ 5 ~1500 小游戏框架(被麻将 Include)
ui/changwange/ 12 畅玩阁面板
modules/mahjong/docs/ 4 文档

二、服务器架构

2.1 服务器身份

身份 进程 权威域 职责
GameServer 同进程 玩家数据权威 C2S 唯一入口。SavedData 唯一读写端。货币唯一操作者(Award/Consume)。结算 Token 唯一维护者。段位分写入
PlayServer 同进程 对局逻辑权威 牌墙唯一拥有者。碰杠胡唯一检测者。分数计算。AI 决策。AutoTakeOut 结算发布
GameCenter 同进程 消息路由 无状态转发。nConnIndex → GameServer 路由。跨中心转发。SceneServer/MatchCenter 可用性检查
MatchCenter 同进程 PlayServer 调度 PlayServer 分配(加权选服)。路由表 g_MAHJONG_rooms_by_id。健康检查 OnTickHealthCheck。跨中心匹配 g_tCrossMatches
SceneServer 独立 房间管理权威 房间创建/销毁/状态迁移。匹配池管理。玩家在线/离线跟踪。结算重试引擎。雪花ID生成

边界规则

  • GameServer 不碰牌墙、不检测胡牌、不创建房间
  • PlayServer 不操作货币、不读写 SavedData
  • SceneServer 不操作货币、不进行游戏逻辑
  • GameCenter 不缓存任何数据、不做业务决策
  • MatchCenter 只记录路由、不干预游戏逻辑

核心约束

  • GameServer = PlayServer 同进程(同地址空间),相同 ID
  • GameCenter = MatchCenter 同进程(同地址空间),相同 ID
  • 同进程内调用走直接函数调用(g_tCrossCallTargets
  • SceneServer 是唯一独立进程

2.2 切图导致断线

玩家在 GS 之间切换(切图)时,客户端主动断开与当前 GS 的连接,连接到目标 GS。数秒内玩家处于离线状态:

GameServer B(新) GameServer A(旧) Client GameServer B(新) GameServer A(旧) Client 切图触发 nSrcCenterID 判断玩家离线 数秒断线窗口 玩家以新连接上线 主动断开连接 设置 MAHJONG_PLAYER_STATUS_OFFLINE AI 托管接管出牌 连接到新 GS 触发 OnPlayerEnterScene 检查孤儿冻结 → RollbackMahjongPendingEntry

诚实标注——半开闭状态同步问题

切图期间玩家与旧 GS 的连接已断开,但新 GS 的连接尚未建立。这构成一个半开闭状态

  • GameServer 侧:玩家标记为 OFFLINEnLeaveSceneTime 记录断线时刻。player.mahjong 清空(=nil),等待重连后 Guardians 重放再恢复
  • PlayServer 侧:无法确定玩家是在切图还是真断线。只能通过超时被动感知——玩家必须在超时前(MAHJONG_RESPONSE_TIMEOUT 客户端倒计时)做出出牌/碰杠响应,否则 AI 托管接管。PlayServer 不反向通知 SceneServer(见 §3.7),因为两个平等状态(PlayerOnline vs PlayerOffline)通过网络传输存在乱序到达风险,会导致 SceneServer 状态振荡无法收敛
  • SceneServer 侧:仅通过 GS 的单向通知(PlayerOffline / PlayerOnline)推算玩家状态。自身不做状态仲裁
  • 实际后果
    • 玩家的出牌决策在断线窗口期由 AI 代为决策(可能不符合玩家意图)
    • 若切图发生在接牌/弃牌等关键交互节点前,AI 可能错过该操作导致本轮空过
    • 结算/退出/崩溃等资金操作不受影响(Token 流水线在 GS 端原子化执行,不依赖玩家状态)
    • 不可消除:半开闭状态是分布式架构的固有问题。系统已通过三类防护最小化影响:AI 超时代管、Guardians 持久化保障资金安全、结算重试兜底

防护

  • 结算重试有 per-session 4次上限(约20秒,覆盖最坏10秒断线窗口),跨 session 12次上限(约1分钟,覆盖极端重连)
  • AutoTakeOut 由 Guardians 持久化,不因断线丢失
  • 断线时间不计入响应超时(Room_HandleResponseTimeout 对 AI 不做惩罚)

畅玩阁系统

SceneServer 独立进程

GameCenter + MatchCenter 同进程

GameServer + PlayServer 同进程

CallServer

CallCenter

module_award / module_consume

CallScene

CallCenter

CallServer

CallCenter

C2S协议

Include

客户端 - game*.exe

麻将UI层
mahjong_ui.lua + mahjong_main_panel.lua

Bridge层
mahjong_ui_common.lua

Core层
mahjong_client_core.lua

胡牌检测
mahjong_verifier.lua

番型计算
mahjong_fan.lua

推荐引擎
mahjong_client_recommend.lua

GameServer
C2S入口 / SavedData / 货币操作

PlayServer
对局状态机 / 算分 / AI

GameCenter
消息转发器

MatchCenter
PlayServer分配 / 动作路由 / 健康检查

SceneServer
房间管理 / 匹配池 / 结算重试 / 在线状态

SmallGame框架
AwardCurrency / GetCurrency
RegisterGameModule

畅玩阁UI
changwange_main_panel

2.3 服务器间通信

跨服调用规则

同进程内(ID 相同):直接函数调用
跨服务器:指定目标 ID,接受方第一个参数为来源 ID

Center → 其它 Center:传目标 CenterID,接受方 srcCenterID
Center → Scene:传目标 SceneID,接受方 srcCenterID
Scene → Center/其它 Scene:传目标 ID,接受方 srcSceneID
GameServer → 同进程直调(GameServer=PlayServer 同地址空间同 ID)

GameServer ↔ GameCenter:GameServer 只能调自己的 Center(不指定 ID),Center 通过 nConnIndex(玩家连接索引)路由回对应 GameServer。

转发协议命名

SceneServer2MatchCenter_CreateRoom    源_目标_功能名
GameCenter2SceneServer_ExitRoom       同上

Forward_ToGameServer

local function Forward_ToGameServer(dwID, szProtocol, ...)
    -- 1. 玩家在本 Center 在线 → 直接调本服 GameServer
    local tPlaying = GetPlayingRole(dwID)
    if tPlaying then
        module:CallServer(tPlaying.nConnIndex, szProtocol, ...)
        return true
    end
    -- 2. 玩家在远程 Center → 跨中心转发
    if IsRemotePlayer(dwID) then
        local tBase = GetRole(dwID)
        if tBase then
            if not IsCenterConnected(tBase.nCenterID) then return false end
            module:CallCenter(tBase.nCenterID,
                "GameCenter2GameCenter_ForwardToGameServer",
                dwID, szProtocol, ...)
            return true
        end
    end
    -- 3. 本 Center 离线
    return false
end

nCenterID > 0 的处理

  • GetRole(dwID).nCenterID > 0 在正常情况下表示玩家隶属于某个远程 Center
  • 但在跨服降级时,nCenterID 可能为 0(数据过时)
  • 因此不能用 nCenterID > 0 判断"玩家是否在远程"
  • IsRemotePlayer(dwID) 更可靠(框架提供,处理了边界情况)
  • 定位优先级:GetPlayingRole(本服在线)> IsRemotePlayer(远程)> 离线

Forward_ToSceneServer

Forward_ToSceneServer = function(nSceneServerID, szProtocol, ...)
    if not nSceneServerID or not IsSceneServerConnected(nSceneServerID) then
        return false   -- TOCTOU:检查与调用间可能断开,调用方用超时补偿
    end
    module:CallSceneServer(nSceneServerID, szProtocol, ...)
    return true
end

Forward_ToMatchCenter

Forward_ToMatchCenter = function(nMatchCenterID, szProtocol, ...)
    if nMatchCenterID == GetCenterID() then   -- 同进程直接调
        local fn = g_tCrossCallTargets[szProtocol]
        if fn then fn(...); return true end
    end
    if IsCenterConnected(nMatchCenterID) then
        module:CallCenter(nMatchCenterID, szProtocol, ...)
        return true
    end
    return false
end

2.4 异步调用与重试策略

所有 CallCenter / CallScene / CallServer 调用是异步的,且不保证一定成功。重试取决于数据类型和重要性,分三类:

类型 触发方 示例 重试策略 携带数据
玩家主动重试 客户端 C2S 请求超时重发 5 秒超时,客户端自行决定 全量请求参数
服务器主动重试 服务器 EndGame 转发失败重试 Settlement_RetryTick 每 5 秒,上限 3 次/局,10 次/总 完整结算数据(含 tResultData)
不重试 房间状态同步、Online/Offline 通知 不重试,丢失不补

重试携带完整数据

以 EndGame 为例:重试时携带的是完整的 tResultData,包含所有玩家的结算结果、手牌、番型等。GameServer 收到后:

收到完整 tResultData
  → 读取自身的 nLastProcessedSettleToken
  → 计算 nExpected = nLastProcessedSettleToken + 1
  → 比较 nSettleKey(来自 tResultData.nRound)vs nExpected
  → 理解:这次是全量结算还是已处理过的重复
  → 不需要额外状态查询

这意味着即使 GameServer 在之前的处理中崩溃丢失了内存状态,仅凭自身的 Token + PlayServer 传递的完整数据就能正确判定当前操作。这是幂等设计的核心。

不重试的场景

  • 玩家 Online / Offline 通知:状态最终由 SceneServer 通过游戏内轮询收敛
  • 房间状态同步 SyncRoom:下一帧状态会覆盖前一帧
  • 非真实数据(临时标记):丢失不影响资金安全

2.5 分布式事务与一致性

SAGA(流程编排)

AutoTakeOut 发布结算

GameServer 接收处理

Guardians 重试补偿

TCC(货币操作)

Try:ConsumeRes 冻结 attr69

Confirm:AwardCurrency 结算

Cancel:RefundFrozenOnAbnormal 回滚

整个系统没有跨服务器的分布式事务。每个服务器只在自己的数据域内做原子操作。跨服务器一致性靠两种模式混合保证:

TCC(Try-Confirm-Cancel):货币操作。

  • Try:入场冻结 ConsumeRes(attr68) → attr69,资金已转移
  • Confirm:结算成功 AwardCurrency,资金最终发放
  • Cancel:Crash/退款 RefundFrozenOnAbnormal,资金退回
  • Try 阶段已占用资源(attr69),Confirm/Cancel 二选一

SAGA(事件驱动补偿):流程编排。

  • 正向操作:AutoTakeOut 发布结算 → ApplyMahjongSettlement
  • 补偿操作:RollbackMahjongPendingEntry / Settlement_RetryTick
  • 每个正向操作有对应的补偿操作,失败时通过补偿回到一致状态

事后补偿一致性

  • 保证 AP(可用性+分区容忍),不实时保证 C
  • 真实数据(货币/段位分):AP + C,靠事后补偿闭环
  • 非真实数据(房间状态):AP only,丢失不重试
  • 补偿引擎:Guardians / AutoTakeOut,持久化重试保证送达

写前日志(Write-Ahead Log):结算操作前先持久化 Token,再执行 Award。服务器崩溃后重启时通过 Token 判断执行进度。这是 TCC 的 Try 阶段的实现基础。

2.7 帧同步模型

-- 帧驱动:16帧 = 1秒(62.5ms/TICK)
module:StartActivate("OnTickTimer", 4)  -- 每种服务器类型只能注册一个

TCP/IP 传输,帧尾按顺序发出数据包。非协程、非同步,标准异步编程结构(Actor 单调模型)。

Actor 模型规则

  • 调用方发出消息后不等待返回,继续执行
  • 不保证消息送达,不保证处理顺序
  • 调用方自行处理超时、重试、降级
  • 调用其他服务器后不可立即访问可能被修改的数据(无同步读)
  • 同进程直接函数调用不改变这些规则——调用的仍是另一个角色的函数

三、协议流转

3.1 匹配/创建/加入

SceneServer GameCenter GameServer SceneServer GameCenter GameServer Room → MATCHING/WAITING Event_Publish(ROOM_INFO) Client ApplyMatch / CreateRoom / JoinByShareCode CheckMatchEntryAccess(等级+货币区间) 限流 MAHJONG_ENTRY_THROTTLE_MS=500ms CallCenter("GameServer2GameCenter_ApplyMatch", ...) Forward_ToSceneServer(sceneID, ...) Match_Apply / Room_CreateManual SceneServer2GameCenter_xxx(tResponse) Forward_ToGameServer(playerID, ...) Data_Update(tResponse) S2C_MAHJONG_ApplyMatch Client

3.2 准备与开局

PlayServer MatchCenter SceneServer GameCenter GameServer PlayServer MatchCenter SceneServer GameCenter GameServer ConsumeRes attr68→attr69 nPendingFreezeToken 生成 alt [已准备(幂等)] [全部准备] Client C2S_MAHJONG_Ready(dwRoomID) CheckPlayerInRoom FreezeMahjongBalanceForEntry CallCenter("GameServer2GameCenter_PlayerReady", ...) GameCenter2SceneServer_PlayerReady SUCCESS SceneServer2MatchCenter_CreateRoom Gs_SelectWithProbability(加权选服) MatchCenter2PlayServer_CreateRoom Room_Create + Game_Start(发牌/定庄) PlayServer2MatchCenter_SyncGameState MatchCenter2GameCenter_SyncGameState Forward_ToGameServer S2C_MAHJONG_SyncGameState Client

3.3 游戏动作(出牌)

PlayServer GameServer PlayServer GameServer alt [同进程] [跨进程] Event_Publish(GAME_ACTION) Client C2S_Discard/Peng/Gang/Hu/Pass 直接函数调用 GameCenter → MatchCenter → PlayServer 游戏逻辑处理 GameAction 广播 S2C_MAHJONG_GameAction Client

3.4 胡牌与结算

SceneServer GameServer Guardians PlayServer SceneServer GameServer Guardians PlayServer Game_PlayerHu nSettleKey == nExpected → Award nSettleKey < nExpected → SKIP nSettleKey > nExpected → RETRY alt [标准通道] Game_End(全量结算) AutoTakeOut 兜底(bExtraSettle=true) tCurrentRoom 已清除时走 ExtraSettlement ExtraSettlement 后发送 Ach → SceneServer 清除 pending nSettleKey = nSettleSeq + nCurrentRound AutoTakeOut(AO_SETTLE, nSettleKey) Mahjong_GuardiansSettle bSameRoom(roomID + matchType 均匹配?) ApplyMahjongSettlement OK nDelta = 全量(所有玩家均适用) PlayServer2MatchCenter_EndGame Settlement_AddPending GameCenter2GameServer_EndGame ApplyMahjongSettlement(全量) AchSettlement Settlement_RemovePending

3.5 退出房间

SceneServer GameCenter GameServer SceneServer GameCenter GameServer SUCCESS → RollbackMahjongPendingEntry PLAYER_NOT_EXISTS → 清理过期 tCurrentRoom alt [tCurrentRoom 不存在] [tCurrentRoom 存在] Client ExitRoom(dwRoomID) CheckPlayerInRoom nFrozen>0 → RefundFrozenOnAbnormal 兜底 ROOM_NOT_EXISTS CallCenter GameCenter2SceneServer_ExitRoom Room_Exit nError + bShouldRefund S2C_MAHJONG_ExitRoom Client

3.6 异常处理(Crash)

GameServer GameCenter 断开源(Scene/MC/PS) GameServer GameCenter 断开源(Scene/MC/PS) 跳过 alt [== nExpected] [≠ nExpected] SceneServer2GameCenter_Crash GameCenter2GameServer_Crash nCrashSettleKey = nLPT + 1 RollbackMahjongPendingEntry RefundFrozenOnAbnormal AchSettlement(当前token)

3.7 PlayServer 不应反向通知玩家断线

PlayServer 采用线性串化(standing serialization)处理事件序列,通过超时被动感知玩家状态。它无法确定当前玩家是在线还是掉线,所以只能采用被动方式。

核心原则:不过度相信网络

分布式系统中,TCP/IP 及服务器间通信受限于:

  • 流控重传:丢包重发导致延迟不确定
  • 重排:IP 层可能改变包顺序
  • PCB 控制块状态:不同服务器上的连接状态不一定同步
  • 乱序:A 先发出,B 后发出,B 可能先于 A 到达

这是分布式系统的天然竞态条件。设计上不能假设网络可靠、有序。

原因:正反馈振荡 + 状态二义

先发出但可能晚到

后发出但可能先到

玩家切图断线

PlayServer 超时

若反向通知 SceneServer '离线'

SceneServer 标记离线

玩家重新连接

SceneServer 通知 '在线'

PlayServer 恢复

状态冲突

无优先级依据

持续振荡

时序矛盾

  1. 玩家切图断线 → GS 通知 SceneServer 离线
  2. PlayServer 超时 → 若反向通知 SceneServer “离线”
  3. 同一时刻玩家重连成功 → GS 通知 SceneServer “上线”
  4. 两个通知通过网络传输,由于重排/重传/PCB 差异,谁先到无法确定
  5. SceneServer 同时持有两个平等状态 → 无法收敛 → 振荡

解决方案一:全局原子流水线

每个操作附带全局单调递增序号,接收方按序号排序,后到的覆盖先到的。需要全局统一时钟源和分布式事务协调器。代价极高,不适用。

解决方案二:接受二义,不做反向通知(本系统采用)

PlayServer 只做:
  → 超时 → AI 托管(本地决策)
  → 有响应 → 取消 AI(本地决策)
  → 不判断玩家在线/离线

玩家状态的唯一权威路径:
  → 断线 → GS → SceneServer 离线
  → 上线 → GS → SceneServer 上线
  → PlayServer 不参与状态判断

闭环系统基本要素:
  状态变更只能从一个方向流入:GS → SceneServer
  不过度相信网络:每个服务器只对自己域内的状态负责

错误开环

PlayServer(超时)

SceneServer

GS(判定上线)

振荡

正确闭环

GS 判定断线

SceneServer(状态权威)

GS 判定上线

PlayServer(执行 AI/恢复)

3.8 一致性模型

本系统采用弱一致性(最终一致性),不保证强一致性。

最终一致性(本系统采用)

操作完成

事后补偿

AutoTakeOut 重试

Settlement_RetryTick

Guardians 持久化

最终一致

CAP 权衡:保证 AP(可用性+分区容忍),不实时保证 C。一致性通过事后补偿拟合(Write-Ahead Log、AutoTakeOut、结算重试)。真实数据 AP+事后补偿,非真实数据 AP only。

3.9 ForwardFailure 流程

GameCenter 转发到 SceneServer 失败时触发 GameCenter2SceneServer_ForwardFailed

GameServer GameCenter SceneServer GameServer GameCenter SceneServer Forward_ToGameServer(dwID, "GameCenter2GameServer_EndGame", ...) 创建待确认结算 alt [转发成功] [转发失败(GameServer 不可达)] SceneServer2GameCenter_EndGame(dwID, tResultData) 正常送达 AchSettlement GameCenter2SceneServer_AchSettlement Settlement_RemovePending GameCenter2SceneServer_ForwardFailed Settlement_BuildFromForwardFailed Settlement_RetryTick 定时重试

触发 ForwardFailure 的场景

协议 说明
MAHJONG_PROTO_APPLY_MATCH 匹配请求转发失败
MAHJONG_PROTO_CREATE_ROOM 创建房间转发失败
MAHJONG_PROTO_JOIN_BY_SHARE_CODE 分享码加入转发失败
MAHJONG_PROTO_START_GAME 开局通知转发失败
MAHJONG_PROTO_READY 准备通知转发失败
MAHJONG_PROTO_END_GAME 结算转发失败(最重要的场景)
MAHJONG_PROTO_CRASH Crash通知转发失败
MAHJONG_PROTO_PLAYER_AFK 暂离通知转发失败
MAHJONG_PROTO_PULL_RESULT 拉取结果转发失败
MAHJONG_PROTO_PLAYER_SYNC 玩家同步转发失败
MAHJONG_PROTO_PLAYER_ONLINE_RESULT 上线结果转发失败

ForwardFailure 的最终落地

成功

失败

超上限(3次/局)

超总上限(10次)

ForwardFailure 触发

Settlement_BuildFromForwardFailed

协议是 EndGame
且参数有效?

记录日志,丢弃

Settlement_AddPending

Settlement_RetryTick
每 5 秒重试

AchSettlement 到达

重试计数++

标记 bRetryExhausted

Settlement_RetryPlayer
玩家重连时恢复

永久放弃

CRITICAL 日志

EndGame 是 ForwardFailure 最重要的应用场景:
  1. SceneServer 发送 EndGame 到 GameCenter
  2. GameCenter 的 Forward_ToGameServer 失败(玩家所在 GS 不可达)
  3. GameCenter 调 GameCenter2SceneServer_ForwardFailed
  4. SceneServer 执行 Settlement_BuildFromForwardFailed
  5. 创建待确认结算(Settlement_AddPending)
  6. Settlement_RetryTick 每 5 秒尝试重新发送
  7. 玩家重连时 Settlement_RetryPlayer 立即补发

其他协议(匹配/创建/准备等)的 ForwardFailure 不创建待确认结算,因为这些操作不涉及资金。玩家重连后通过 PlayerOnline 重新同步房间状态。

3.10 登录/EnterScene 重排到 AutoTakeOut 之后

错误时序:EnterScene 先于 AutoTakeOut

晚于 EnterScene 到达

玩家登录/重连

OnPlayerEnterScene

检查 tCurrentRoom = nil

检查 nFrozen > 0

误判为孤儿冻结

RefundFrozenOnAbnormal(退款)

AutoTakeOut 队列

Mahjong_GuardiansSettle

AwardCurrency(发奖)

资金错误:退款 + 发奖同时发生

账户余额不匹配

问题:AutoTakeOut 可能包含结算处理。如果 EnterScene 在 AutoTakeOut 之前触发,GameServer 看到玩家 tCurrentRoom = nilnFrozen > 0,会判定为孤儿冻结并执行退款(RollbackMahjongPendingEntry)。随后 AutoTakeOut 到达并执行结算发奖。

结果是:玩家同一笔资金被退款一次、发奖一次,造成双重支付。

正确时序

正确时序:AutoTakeOut 先于 EnterScene

是(有正常冻结)

否(已处理完毕)

玩家登录/重连

Guardians 重防线

重放所有待处理的 AutoTakeOut

Mahjong_GuardiansSettle

结算处理完毕

tCurrentRoom 正确恢复或清除

nFrozen 正确扣除

OnPlayerEnterScene

nFrozen > 0 ?

走重连恢复路径

不做任何退款

这就是登录重排:玩家上线时,Guardians 系统必须先重放所有待处理的 AutoTakeOut 事件,然后再执行 EnterScene 逻辑。

重排顺序:
  1. Guardians 重放 AutoTakeOut 队列(结算处理)
  2. 重放完成后的回调(更新玩家状态)
  3. OnPlayerEnterScene(检查房间状态、孤儿冻结)

如果不重排:
  EnterScene 在 AutoTakeOut 前执行
  → 看到无房间+有冻结 → 退款
  → AutoTakeOut 到达 → 发奖
  → 双重支付

重排后:
  AutoTakeOut 先执行 → 结算处理完毕
  → EnterScene 执行时状态已正确
  → 不会误触发退款

代码位置mahjong_game_server.lua_MahjongPlayerOnlineImpl中通过 OnPlayerEnterScene 检查孤儿冻结。该函数必须在 Guardians 重放回调完成后才被触发。

系统重排约束

必须先于

必须先于

必须先于

Guardians

EnterScene

Room 恢复

Pull

四、玩家登录与状态恢复

4.1 GS 触发的事件(ON_PLAYER_LEAVE_SCENE / ON_PLAYER_LEAVEGS / PLAYER_ENTER_SCENE_COMPLETED / EVT_GUARDIANS_TAKEOUT_DONE)

GameServer 场景状态转移

GameServer 触发上线

GameServer 触发下线

离线通知

ON_PLAYER_LEAVE_SCENE
玩家离开场景(切图/断线)

ON_PLAYER_LEAVEGS
玩家离开 GS(保险兜底)

tCurrentRoom
且 PLAYING?

saved.nLeaveSceneTime = now

通知 SceneServer 离线
GameServer2GameCenter_PlayerOffline

nFrozen > 0?

RollbackMahjongPendingEntry(孤儿冻结)

跳过

tCurrentRoom
且 PLAYING?

saved.nLogoutTime = now

跳过

PLAYER_ENTER_SCENE_COMPLETED
玩家进入场景(重连/切图完成)

EVT_GUARDIANS_TAKEOUT_DONE
Guardians 重放完成

player.mahjong.bAutoTakeoutDone = true

_MahjongPlayerOnlineImpl

bAutoTakeoutDone?

等待 Guardians

_MahjongPlayerOnlineImpl

SS

ON

LeaveScene 与 Logout 的区别

事件 触发条件 记录字段 宽限期 处罚
LEAVE_SCENE 切图/断线/强退 saved.nLeaveSceneTime 有(MAHJONG_DISCONNECT_GRACE_SECONDS) 宽限期内重连赦免
LEAVEGS 离开GS/杀进程 saved.nLogoutTime 结算时直接处罚
-- mahjong_game_server.lua L1705-1761
-- 受保场次:PLAYING 状态的房间才记录

-- OnPlayerLeaveScene(可靠覆盖切图、断线、杀进程)
if tRoom.nStatus == MAHJONG_STATUS_PLAYING then
    saved.nLeaveSceneTime = GetCurrentTime()
end
-- 不管有无房间,都通知 SceneServer
module:CallCenter("GameServer2GameCenter_PlayerOffline", ...)

-- OnPlayerLogout(保险兜底,LEAVE_SCENE 可能漏掉强退/杀进程)
if tRoom and tRoom.nStatus == MAHJONG_STATUS_PLAYING then
    saved.nLogoutTime = GetCurrentTime()
end
-- 注意:不在此处立即处罚。真正的处罚在 EndGame 中统一判断。

4.2 _MahjongPlayerOnlineImpl 完整流程

SceneServer侧

nil

PLAYING

WAITING_LOAD

其他

_MahjongPlayerOnlineImpl(player)

读取 SavedData

nLeaveSceneTime 在
宽限期内?

清除 nLeaveSceneTime
(赦免逃跑处罚)

保留 nLeaveSceneTime

GetPlayerCurrentRoom

tCurrentRoom?

通知 GameCenter 玩家在线
CallCenter(GameServer2GameCenter_PlayerOnline)

nFrozen > 0
且 nLastMatchType > 0?

RollbackMahjongPendingEntry(孤儿冻结)

DONE1

GameCenter2SceneServer_PlayerOnline
(SceneServer 侧处理)

Settlement_RetryPlayer

玩家在房间?

恢复 nStatus = ONLINE

nAfk == AFK_OFFLINE?

清除 AFK → AFK_NONE

广播给同房间玩家

通知 PlayServer 玩家在线(→取消AI)

无需清除

根据 nGameStatus

通知 PlayServer 在线

通知 PlayServer 同步终局

更新 GameCenterID

PlayerOnlineResult(hasRoom=true)

PlayerOnlineResult(hasRoom=false)

GameServer 接收

有房间?

恢复牌桌 UI + _RefreshAll

清理 tCurrentRoom

Client_Pull(拉取状态)

DONE

4.3 断线重连处罚逻辑

EndGame 结算时判断是否处罚(mahjong_game_server.lua ~L3945):

字段 含义 处罚条件
nLogoutTime 离开GS时间(LEAVEGS) 存在 > 0 → 直接处罚(无宽限期)
nLeaveSceneTime 离开场景时间(LEAVE_SCENE) 存在且超过宽限期 → 处罚
处罚触发:Hu 结算后(GuardiansSettle 中),输家(nDelta <= 0)且断线超过宽限期
处罚动作:AccumulateAbnormalExitBan(累计强制退出次数 → 封禁)

4.4 玩家属性与江湖令资金流

属性定义
属性ID 名称 容器 操作
68 江湖令(可用余额) rtPlayerAttr2 可用余额,可通过购买/结算增加
69 江湖令(冻结余额) rtPlayerAttr2 入场时从 attr68 转入,退出时转回 attr68
-- smallgame_define.lua
SMALLGAME_CURRENCY_ATTR_ID   = 68   -- 可用
SMALLGAME_CURRENCY_FROZEN_ID = 69   -- 冻结

RES_TYPE.rtPlayerAttr2               -- 辅助属性容器
资金流完整状态机

初始状态

C2S_Ready\nConsumeRes(attr68, N)

赢家结算(nDelta>0)\nAwardCurrency(nDelta)

输家结算(nDelta<0)\nnInitialFrozen += nDelta

退出退款\nAward(N) + ConsumeRes(attr69, N)

退出退款\nAward(N+delta) + ConsumeRes(attr69, N)

Crash/孤儿退款\nRollbackMahjongPendingEntry

可用 attr68 = X

可用 attr68 = X - N\n冻结 attr69 = N

可用 attr68 += delta\n冻结 attr69 = N(保持不变)

可用 attr68 不变\n冻结镜像下调 N+delta

可用 attr68 += N\n冻结 attr69 = 0

可用 attr68 += N+delta\n冻结 attr69 = 0

入场冻结(FreezeMahjongBalanceForEntry)
-- ~L914 流程:
saved.nPendingFreezeToken = 当前毫秒(单调递增)
FreezeMahjongBalance(player, tContext)
  └─ ConsumeRes(attr68, nFullBalance)   -- attr68 -= 全额
  └─ BuildMahjongCurrentRoom(player, saved, tContext)
  └─ nLastProcessedSettleToken = math_max(nLPT, nFreezeToken)  -- 对齐种子

成功 → saved.nHandledFreezeToken = saved.nPendingFreezeToken
失败 → saved.nPendingFreezeToken 保留(允许重试)
结算发奖(ApplyMahjongSettlement)
nDelta > 0(赢家):
  AwardCurrency(nDelta) → attr68 += nDelta
  nInitialFrozen 不变(退出时全额释放)

nDelta < 0(输家):
  nNewFrozen = math_max(0, nFrozen + nDelta)    -- 冻结镜像下调
  attr68 不变(退出时释放较少金额,自然扣损)

nDelta = 0:
  无资金操作,仅更新段位分
退出退款(RefundFrozenOnAbnormal)
nAwardAmount = math_min(nFrozen, nInitialFrozen)
AwardCurrency(nAwardAmount) → attr68 += nAwardAmount
CreateAndCostRes(attr69, nFrozen) → attr69 -= nFrozen

赢家示例(初始500万,赢160万):
  attr68 += 500万(退款)
  attr69 -= 500万(清除冻结)
  净结果:可用 +160万 + 取回500万冻结 = +160万(正确)

输家示例(初始500万,输160万):
  nInitialFrozen = 340万(已下调)
  nAwardAmount = min(500万, 340万) = 340万
  attr68 += 340万
  attr69 -= 500万
  净结果:取回340万冻结 / 实际扣160万(正确)
镜像字段(SavedData)
字段 类型 说明
nJianghuLingAvailable number 麻将内部记录的可用余额镜像(用于对局日志验算,非权威)
nJianghuLingFrozen number [已废弃] 冻结余额镜像
nCurrencyBeforeGame number 入场时可用余额快照(对局日志验算)
nPendingFreezeToken number 入场冻结 Token(Guardians 重试标记)
nHandledFreezeToken number 已处理/回滚的冻结 Token
资金安全边界

AwardCurrency 与 ConsumeRes 是独立系统调用,不在同一事务内。
Award 成功后 ConsumeRes 失败 → attr69 残留 → CRITICAL 日志标记人工核查
Token 不回退(防止双重发奖),下次从钱包重读 nJianghuLingAvailable

五、数据流详解

5.1 客户端三层架构

Control · mahjong_main_panel.lua

Bridge · mahjong_ui_common.lua

Core · mahjong_client_core.lua

S2C

C2S

View · mahjong_ui.lua

纯渲染

g_tData(数据源)

S2C 处理

Request_Send

Event_Publish

SubscribeEvent

FireEvent

Request 包装

_RefreshAll

_CheckPhaseChange

GAME_ACTION 分发

服务器

5.2 数据更新决策

SyncGameState

GameAction

NotifyAction

ApplyMatch 等

EndGame

S2C 消息到达

消息类型?

Data_ReplaceSnapshot(全量)

直接修改 g_tData

Data_Update(增量)

Data_Update(增量)

Data_Update + 缓存终局数据

保存本地字段

清除服务端字段

从快照重建

恢复本地字段

Event_Publish

六、Core 层数据源:g_tData

g_tData 是单一权威数据源(Single Source of Truth)。所有 UI 渲染只读从此表读取。

6.1 数据结构

g_tData = {
    -- 房间信息
    dwRoomID        = 0,        -- 房间ID,0表示不在房间
    nMatchType      = 0,        -- 匹配类型
    nGameStatus     = 0,        -- 游戏状态常量
    nDealerSeat     = 0,        -- 庄家座位
    nCurrentSeat    = 0,        -- 当前操作玩家
    nMyAfk          = 0,        -- 本家暂离状态
    nReadyDelay     = 0,        -- 准备倒计时
    nResponseDelay  = 0,        -- 响应倒计时
    nRemainingMs    = 0,        -- 服务端广播的倒计时间

    -- 玩家表(座位索引 1-4)
    tPlayers = {
        [1] = {
            dwID, szName, nSeat, nHandCount,
            tDiscardTiles = {},    -- 弃牌数组
            tMelds = {},           -- 碰杠数组 { nType, tTiles, nGangType? }
            nScore, nRoundScore,
            bIsDealer, bIsWinner, bActive, bBankrupt,
            nStatus, bReady, nHuOrder,
            nQueSuit,            -- 定缺花色
            huTile,              -- 胡牌牌值
            bIsHuaZhu, bIsTing,
            bIsOwner,            -- 房主
            nVoiceType, nCardEffectID, nBubbleID, nTableSkinID,
            nRankLevel, nRankScore,
            tHandTiles = {},     -- 服务端同步的手牌
        }
    },

    -- 私有数据
    tPrivate = {
        tHandTiles = {},         -- 本家手牌
        nGetCard = 0,            -- 摸牌牌值
        tAnGang = {},            -- 暗杠(保留跨同步)
        tBuGang = {},            -- 补杠(保留跨同步)
    },

    -- 响应状态
    nPendingTile = 0,            -- 待响应的牌
    tPendingResponses = {},      -- 可选响应 { bCanPeng, bCanGang, bCanHu, bCanPass }

    -- 游戏状态
    tLastDiscard = { nSeat, nTile },  -- 最后出牌
    nRemainingCount = 0,              -- 牌墙剩余
    tDice = {},                       -- 骰子 { d1, d2 }
    tCurrentFlowItems = {},           -- 实时流水
    tDiscardCount = {},               -- 出牌统计 { wan, tiao, tong }
    tExchangeSelected = {},           -- 换三张选择
    tExchangeReceived = {},           -- 换三张收到

    -- EndGame 数据
    _tEndGameResult = nil,           -- 服务端下发的终局数据
    _tSettleHuData = nil,            -- 实时结算数据
    _bPendingEndGameDisplay = false, -- 待渲染终局标记
    _bRoomDataReset = false,         -- 房间数据重置标记
}

6.2 数据更新模式

全量快照替换(Data_ReplaceSnapshot)

Data_ReplaceSnapshot = function(tSnapshot)
    -- 1. 保存本地字段(LOCAL_ROOT_FIELDS)
    -- 2. 清除所有服务端字段(SERVER_SYNC_FIELDS)
    -- 3. 从 tSnapshot 重建
    --    └─ tPlayers:仅取 SERVER_PLAYER_FIELDS 列出的字段
    --    └─ 未提供的字段从旧数据继承
    --    └─ tPrivate:tHandTiles 覆盖,保留 tAnGang/tBuGang
    -- 4. 恢复本地字段
end

增量合并(Data_Update)

Data_Update = function(tNewData)
    -- tPlayers:逐座位合并(新数据覆盖,旧数据保留未覆盖字段)
    -- tPrivate:逐子字段合并
    -- 其他字段:直接赋值
end

6.3 SERVER_PLAYER_FIELDS(44个字段)

-- 这些是服务端同步的玩家字段,Data_ReplaceSnapshot 只保留这些
{
    "dwID", "szName", "nVipLevel", "nLevel", "nJob", "nGender",
    "nAvatarID", "nAvatarFrameID", "nSeat", "nHandCount",
    "tDiscardTiles", "tMelds", "tFlowerTiles",
    "nScore", "nRoundScore", "bIsDealer", "bIsWinner",
    "bActive", "bBankrupt", "nStatus", "bReady", "nAfk", "nQueSuit",
    "huTile", "tHandTiles", "bIsHuaZhu", "bIsTing",
    "bIsOwner",
    "nVoiceType", "nCardEffectID", "nBubbleID", "nTableSkinID",
    "nRankLevel", "nRankScore",
}

七、事件系统

7.1 为什么不使用 FireEvent

麻将系统有三套事件体系:

体系 函数 范围 说明
项目全局 FireEvent / RegisterEvent 跨模块 畅玩阁、任务系统使用
麻将 Core Event_Publish / Client_SubscribeEvent 麻将 Core 内部 client_core 的 local 函数
麻将 UI MahjongUI_FireEvent / MahjongUI_SubscribeEvent 麻将 UI ui_common 提供

Client 层(mahjong_client_core.lua)不使用 FireEvent 的原因

FireEvent 是项目全局事件总线,任何模块都可以注册监听。麻将 Core 层发布的事件(如 MAHJONG_EVENT_GAME_ACTION)如果走 FireEvent,其他模块(畅玩阁、任务等)也能收到,但这些事件的数据格式是麻将内部结构的(g_tData.tPlayerstActionData 等),外部模块无法理解,造成不必要的耦合和潜在的类型错误。

麻将 Core 采用自己的 Event_Publish 体系,事件只在麻将系统内部传播:

MahjongUI_FireEvent 桥接

_RefreshAll / _CheckPhaseChange

Event_Publish 麻将域

MAHJONG_EVENT_GAME_STATE

MAHJONG_EVENT_GAME_ACTION

MAHJONG_EVENT_EXIT_ROOM

MAHJONG_EVENT_SETTLE_HU

FireEvent 全局总线

SMALLGAME_EVENT_GAME_COMPLETE

SMALLGAME_EVENT_HU

Event_Publish

MahjongUI_SubscribeEvent

main_panel.lua

ui.lua

当需要通知外部模块时(如每局结束触发任务进度):

  • 麻将 Core 不直接 FireEvent,而是在 S2C 处理函数末尾调用 tSmallgameGameServer.FireModuleEvent
  • 由 SmallGame 框架代为触发全局事件
  • 麻将 UI 层通过 MahjongUI_SubscribeEvent 内部处理,不污染全局总线

七、UI 系统架构

7.1 主面板控件树

mahjong_main_panel (Window)
  ├── wnd_tishi(提示区域)
  │     ├── wnd_huanpai(换牌提示)
  │     └── txt_tishi(文字提示)
  ├── wnd_opt(操作按钮)
  │     ├── btn_peng(碰)
  │     ├── btn_gang(杠)
  │     ├── btn_hu(胡)
  │     └── btn_guo(过)
  ├── wnd_discard(弃牌区)
  │     ├── img_arrow(弃牌箭头,锚到最后一张)
  │     ├── wnd_dianpao / mov_dianpao(点炮描点,锚到被胡的牌)
  │     └── list_discard1~4(四方弃牌列表)
  ├── wnd_player1~4(四个玩家区域)
  │     ├── wnd_playerinfo(头像/名字/段位/货币)
  │     ├── list_mahjong(手牌区)
  │     ├── list_get_card(摸牌区)
  │     ├── list_view(碰杠展示区)
  │     ├── list_final(终局倒牌区)
  │     ├── list_hu / card_hu(胡牌显示)
  │     ├── wnd_result(番型倍率常驻显示)
  │     ├── wnd_optanimate / mov_optanimate(webp方位动画)
  │     └── wnd_animate(旧式纹理动画,仅保留 HU badge)
  ├── wnd_zhuang(中央区域)
  │     ├── wnd_state(当前玩家指示器)
  │     ├── wnd_view(胡牌类型纹理)
  │     └── wnd_result(番型信息)
  └── wnd_table(牌桌区域)
        └── list_mapai1~4(四方牌墙)

7.2 事件系统

Core 事件发布(Event_Publish in mahjong_client_core.lua):

-- Core 层内部事件(client_core 的 local 函数)
-- 订阅:Client_SubscribeEvent(nEventType, fnCallback)
-- 发布:Event_Publish(nEventType, tData)
-- 27 个数值事件(MAHJONG_EVENT_* 常量)

UI 桥接(MahjongUI_FireEvent in mahjong_ui_common.lua):

-- UI 层订阅
MahjongUI_SubscribeEvent(MAHJONG_EVENT_GAME_STATE, function(tData)
    -- UI 回调
end)

-- 内部实现:为每个事件类型创建一个 Core 订阅
-- Core 回调 → 遍历 g_tUIEventSubs[nEventType] → 调用所有 UI 回调

UI 内部事件(MahjongUI_FireEvent):

-- 在 mahjong_ui_common.lua 内部使用
-- 已订阅 Core 事件的回调中调用
-- 也用于 UI 组件间通信

UI 事件订阅表与处理

事件 处理函数 说明
GAME_STATE _RefreshAll + _CheckPhaseChange 全量刷新+阶段转换
NOTIFY_ACTION → 显示碰/杠/胡/过按钮 响应选项
GAME_ACTION → 出牌/碰/杠/胡/过处理 动作分发
TIMER → 超时自动操作 自动出牌/胡牌
END_GAME → 显示终局牌面 战绩渲染
EXIT_ROOM → 隐藏面板 退出清理
PREPARE_GAME ClearTable + _RefreshAll 新局准备
ERROR → 显示错误提示 错误码→字串
SETTLE_HU → 打开结算面板 胡牌即时结算

7.3 全量刷新:_RefreshAll

_RefreshAll 是统一的 UI 全重建入口。按 g_tData.nGameStatus 分派:

nGameStatus 渲染内容 重建范围
EXCHANGE 牌墙、玩家信息、换三张UI 全部
QUE_SUIT 手牌、定缺窗口 全部
WAITING 玩家信息、等待界面 全部
WAITING_LOAD 终局牌面(碰杠+弃牌+手牌) 弃牌重建、碰杠重建、箭头重锚
PLAYING 手牌、牌墙、碰杠、弃牌、箭头 全部重建
WAIT_RESPONSE 同上 + 响应按钮 全部重建

WAITING_LOAD 刷新(终局牌面):

if nStatus == WAITING_LOAD then
    MahjongUI_ClearAllDiscards()      -- 清空弃牌
    MahjongUI_ClearAllViews()          -- 清空碰杠
    self:_RenderEndGameTable(tData, nMySeat)  -- 重建终局牌面
      └─ 渲染手牌(ShowFinalCard)
      └─ 渲染碰杠(AddMeld)
      └─ 渲染弃牌(AddDiscard)
      └─ 重建箭头(UpdateDiscardArrow)
end

阶段切换检测

g_nLastRefreshStatus = g_nLastRefreshStatus or 0
if nStatus ~= g_nLastRefreshStatus then
    -- 检测到阶段转换
    if g_nLastRefreshStatus == EXCHANGE and nStatus == QUE_SUIT then
        -- 换三张结束,进入定缺
        _RestoreAfterExchange()
    elseif g_nLastRefreshStatus == WAITING_LOAD and nStatus ~= WAITING_LOAD then
        -- 离开终局状态,新局开始
        MahjongUI_ClearTable(nMySeat)
    end
end

7.4 UI ↔ Core 约束

硬性约束(不可违反):

Modules 与 UI 之间只允许两种通信方式:
  1. 事件方案:modules → FireEvent → UI(RegisterEvent 接收)
  2. 回调方案:UI 传 function 引用给 modules,modules 在适当时机调用

禁止:
  - modules 调 UI 函数(FindWindow、Show、SetText、GetStringEx 等)
  - modules 通过任何间接方式调 UI 函数

八、动画系统

8.1 动画类型

动画类型 实现方式 资源格式 控件 说明
动作动画(碰/杠/胡/自摸) MahjongUI_PlayOptAnimate webp mov_optanimate 方位动画,在对应玩家座位播放
特殊番型(天胡/地胡/杠上花等) MahjongUI_GetActionWebp + PlayOptAnimate webp mov_optanimate 通过 tFanTypes 自动选择 webp 文件
点炮描点 MahjongUI_ShowDianpao webp mov_dianpao 锚定到弃牌区最后一张牌
弃牌箭头 MahjongUI_UpdateDiscardArrow img_arrow 锚定到最后弃牌
弃牌飞牌 MahjongUI_PlayDiscardAnimate 动画控件 手牌控件 弃牌从手牌飞向弃牌区
摸牌滑落 MahjongUI_UpdateGetCard 动画控件 list_get_card 摸牌从顶部滑落
番型展示 MahjongUI_ShowResult 纹理 wnd_result 常驻显示,不自动隐藏
骰子动画 MahjongUI_ShowDice webp 骰子控件 随机转动后定格到服务器值
开局动画 MahjongUI_PlayStartFlow 纹理+webp wnd_start+骰子 对局开始→骰子→定庄

8.2 动画资源选择

-- 基础动作:碰/杠/胡/自摸
MahjongUI_GetActionWebp(nil, MAHJONG_ACTION_PENG)
  → MAHJONG_ACTION_WEBP_MAP[PENG] = "W1351_2Dtx_peng""W1351_2Dtx_peng.webp"

-- 特殊番型:天胡/地胡/杠上花/杠上炮/抢杠胡/海底捞月
MahjongUI_GetActionWebp({ MAHJONG_FAN_TYPE_TIAN_HU }, nil)
  → MAHJONG_HU_WEBP_PRIORITY[TIAN_HU] = "W1351_2Dtx_tianhu""W1351_2Dtx_tianhu.webp"

-- 自摸特殊番型(自摸 + 特殊胡)
MahjongUI_GetActionWebp({ MAHJONG_FAN_TYPE_GANG_SHANG_HUA }, MAHJONG_ACTION_ZIMO)
  → MAHJONG_ZIMO_WEBP_EXTRA 匹配 → 替代动画
  → ("W1351_2Dtx_zimo.webp", "W1351_2Dtx_gskh.webp")
  → 仅使用第二返回值(特殊胡动画)

8.3 webp 资源映射

-- 方位动画映射表(MAHJONG_ACTION_WEBP_MAP)
MAHJONG_ACTION_PENG             = "W1351_2Dtx_peng"
MAHJONG_ACTION_GANG             = "W1351_2Dtx_gang"
MAHJONG_ACTION_HU               = "W1351_2Dtx_hu"
MAHJONG_ACTION_ZIMO             = "W1351_2Dtx_zimo"

-- 特殊番型映射(MAHJONG_HU_WEBP_PRIORITY)
MAHJONG_FAN_TYPE_TIAN_HU        = "W1351_2Dtx_tianhu"
MAHJONG_FAN_TYPE_DI_HU          = "W1351_2Dtx_dihu"
MAHJONG_FAN_TYPE_GANG_SHANG_HUA = "W1351_2Dtx_gskh"
MAHJONG_FAN_TYPE_GANG_SHANG_PAO = "W1351_2Dtx_gsp"
MAHJONG_FAN_TYPE_QIANG_GANG_HU  = "W1351_2Dtx_qgh"
MAHJONG_FAN_TYPE_HAI_DI_LAO_YUE = "W1351_2Dtx_hdly"

所有 webp 文件路径:ui/texture/movie/ + 文件名。文件格式为 .webp,通过 mov 控件播放。

8.4 Strand 定时器管理

-- 每次 ClearTable / 新局开始时 取消所有旧定时器
MahjongUI_BeginRebuild()
  └─ 取消所有 g_tMahjongStrandTimers
  └─ 释放动画锁:g_bMahjongAnimating = false
  └─ 清除推迟标记:g_bMahjongDeferRefresh = false
  └─ 清除 g_nLastGetCardTile
  └─ 清除气泡定时器

-- 创建定时器后注册到 strand
local timer_id = module_mahjong.mahjong_timer.CreateTimer(delay, 0, callback)
Mahjong_StrandRegisterTimer(timer_id)

-- 动画锁机制
g_bMahjongAnimating: 动画播放中 → _RefreshAll 推迟
g_nMahjongAnimatingCount: 动画计数(支持嵌套动画)
g_bMahjongDeferRefresh: 推迟的 _RefreshAll
g_bMahjongDeferPhaseChange: 推迟的 _CheckPhaseChange

九、畅玩阁系统(SmallGame)

麻将依附于畅玩阁(SmallGame)框架。

9.1 小游戏框架

-- modules/smallgame/config.xml
-- GameServer: smallgame_config_loader + smallgame_define + smallgame_game_server
-- GameClient: smallgame_config_loader + smallgame_define + smallgame_client_data + smallgame_client_driver
-- GameCenter: smallgame_config_loader + smallgame_define
-- SceneServer: smallgame_config_loader + smallgame_define

9.2 游戏模块注册

SmallgameGameServer.RegisterGameModule(nMatchType, tGameModule)

麻将通过 g_RegisteredGameModules[nMatchType] 注册回调接口:

-- 麻将初始化时
tSmallgameGameServer.RegisterGameModule(nMatchType, {
    OnSettlement  = GameServer_OnSettlement,       -- 结算回调
    QueryRewardType = GameServer_QueryRewardType,   -- 产出日志类型
    QueryConsumeType = GameServer_QueryConsumeType, -- 消耗日志类型
    OnActions = { ... },                             -- 操作定义
})

9.3 货币系统

SMALLGAME_CURRENCY_ATTR_ID   = 68  -- 可用江湖令(rtPlayerAttr2)
SMALLGAME_CURRENCY_FROZEN_ID = 69  -- 冻结江湖令(rtPlayerAttr2)
操作 函数 说明
冻结 ConsumeRes(attr68) → attr69 入场冻结
发放 AwardCurrency(player, amount, reason, logType, bSkipDailyCap) 结算赢奖
消耗 CreateAndCostRes 扣除冻结
读可用 GetCurrencyBalance(player) 获取 attr68
读冻结 GetFrozenCurrencyBalance(player) 获取 attr69

9.4 畅玩阁日志

SmallgameGameServer.LogFunGame(player, szStepNumid, nResults, tData)
步骤号 results 触发时机
C4101(进入) 0 SuccessMatch — PLAYING 状态
C4102(结束) 1-7 EndGame — 对局完整结算
C4103(退出) 3 ExitRoom — PLAYING 中途离开
C4102(崩溃) 4 Crash — 补偿
C4102(胡牌) 0 GuardiansSettle — 胡牌即时结算

十、基础设施模块

10.1 配置加载(mahjong_config_loader.lua,28行)

纯委托到 smallgame_config_loader

mahjong_config_loader.OpenTable(filename)
mahjong_config_loader.GetIndexedTable(...)
mahjong_config_loader.GetGlobalTable(...)
mahjong_config_loader.GetRuleTable(...)
mahjong_config_loader.GetParamTable(...)
mahjong_config_loader.GetGroupIDByMatchType(nMatchType)

配置表

  • Mahjong_Global.tab — 全局配置
  • Mahjong_Rule.tab — 规则表(双键:matchType + ruleName)
  • Mahjong_Rank.tab — 段位配置
  • Mahjong_Param.tab — 参数配置
-- 读取规则
GetRule(nMatchType, MAHJONG_RULE_ROUND_BASE_SCORE, 1)
-- 读取参数
GetMahjongParam(ID, DEFAULT)

10.2 定时器系统(mahjong_timer.lua,338行)

基于 mahjong_map(跳表)实现的通用一次性定时器。

函数 说明
CreateTimer(nDelayMs, dwRoomID, fnCallback) 创建定时器,返回ID
CloseTimer(nTimerID) 按ID取消
RemoveTimersByRoom(dwRoomID) 按房间批量取消
DoEvents(nNow) 每帧执行到期定时器
GetRemainingTime(nTimerID, nNow) 获取剩余时间

定时器数据结构(热更新安全,g_ 前缀):

g_tTimerMap = { [nTimerID] = { nTimerID, nExpire, dwRoomID, fnCallback } }
g_tTimerMapByExpire = mahjong_map.new(TimeComparator_Asc)  -- 跳表按到期时间排序
g_tRoomTimerMap = { [dwRoomID] = { [nTimerID] = true } }   -- 房间索引

10.3 跳表(mahjong_map.lua,272行)

有序映射(Skip List)。

方法 复杂度 说明
new(cmp) O(1) 创建实例,支持自定义比较器
set(k, v) O(log n) 插入/更新
get(k) O(log n) 查找
delete(k) O(log n) 删除
pairs() O(n) 有序迭代

10.4 雪花ID(mahjong_snowflake.lua,234行)

仅 SceneServer 加载,用于生成唯一房间ID。

ID 布局(默认53位内):

| Timestamp (28 bits) | ServerID (7 bits) | TypeID (6 bits) | Sequence (12 bits) |

关键特性

  • 53位内兼容 Lua double-number 精度
  • 时钟回拨:重用上次时间戳+递增序列号
  • 序列耗尽:借用下一秒
  • 崩溃恢复:持久化 (server_id, type_id) → last_sec,重启后推进1秒避免碰撞

10.5 调试转储(mahjong_dump.lua,330行)

mahjong_dump.dump(value, name, indent)     -- 多行格式化输出
mahjong_dump.dump_null(value)               -- 安全处理 nil
mahjong_dump.to_string(value)               -- 单行紧凑序列化
mahjong_dump.capture_stack_trace()          -- 堆栈跟踪

十一、常量与定义

核心常量定义在 mahjong_define.lua

11.1 错误码

范围 用途
MAHJONG_ERROR_SUCCESS (1) 成功
3300001-3300010 房间/玩家/匹配错误
3300011-3300012 经济错误(破产/不足)
3300015-3300017 服务器连接错误
3300018 封禁
3300019-3300020 准入错误(等级/货币区间)
3300021-3300027 扩展错误
3300101+ 客户端错误(超时等)

11.2 游戏常量

类别 范围
牌值编码 万1-9, 条11-19, 筒21-29, 风31-34, 箭35-37, 花41-48
房间状态 MATCHING(1), WAITING(2), PLAYING(3)
游戏状态 WAITING(1) → READY(2) → DEALING(3) → PLAYING(4) → WAIT_RESPONSE(5) → END(6) → …
番型 16种基础番型(1-16) + 6种叠加番型(21-26) + 2种特殊番型(31-32)
协议 1001-1029

11.3 日志类型

消耗/产出基数为 23045+/13045+,公式:

-- 产出(赢奖)
自摸 = Res13045 + (fanType - 1)
点炮 = Res13061 + (fanType - 1)
天胡 = Res13077(固定)
地胡 = Res13078(固定)

-- 消耗(输分)
被自摸 = Cost23045 + (fanType - 1)
被点炮 = Cost23061 + (fanType - 1)

-- 冻结/退还
入场冻结 = Cost23079
退出退款 = Res13082
stale清理 = Res13085

十二、结算系统

完整结算系统文档见 麻将结算系统.md

12.1 核心公式

nSettleKey = nSettleSeq + nCurrentRound
nExpected = nLastProcessedSettleToken + 1
nSettleKey == nExpected → 执行
nSettleKey < nExpected → SKIP
nSettleKey > nExpected → RETRY

12.2 幂等机制

路径 幂等方法
结算 Token 比较(三路分支)
Crash 与结算共享流水线
退款 状态守卫(tCurrentRoom + nFrozen + RoomID + FreezeToken)
退出 HasMahjongPendingEntry 检查

12.3 三通道并行结算

通道 触发 执行函数 Key 公式
Hu AutoTakeOut(一次) 胡牌事件 ApplyMahjongSettlement nSettleSeq + nRound
EndGame 直发(主) Game_End ApplyMahjongSettlement nSettleSeq + nRound
AutoTakeOut 兜底(bExtraSettle) 冷路径重试 Mahjong_ExtraSettlement nSettleSeq + nRound(同 Key,不推进 Token)

注:bExtraSettle 使用与 Hu/EndGame 相同的 Key,但不推进 nLastProcessedSettleToken。原因是玩家在胡牌结算后可能已退出房间并进入其他游戏(畅玩阁内其他玩法),此时玩家的 nLastProcessedSettleToken 被新游戏占用作结算流水线。如果 bExtraSettle 推进 Token,新游戏的结算会因流水线序号不匹配而失败。因此 bExtraSettle 只能走 Mahjong_ExtraSettlement 特殊通道直接 Award,不参与 Token 流水线。幂等性由 AutoTakeOut 本身保证。


十三、振荡控制

13.1 正反馈与负反馈

正反馈:系统的输出加剧系统的输入,导致状态发散、无法收敛。

错误 → 继续尝试 → 继续错误 → 无限循环

在分布式系统中,正反馈表现为:A 失败 → A 重试 → B 也失败 → B 也重试 → A 再失败 → 互相放大。

负反馈:系统的输出抑制系统的输入,导致状态收敛到稳定点。

错误 → 限速 → 降低频率 → 错误减少 → 稳定

在分布式系统中,负反馈表现为:重试计数 → 达到上限 → 停止重试 → 等待恢复 → 重连时恢复。

麻将系统的所有防护机制都是负反馈设计。

13.2 振荡来源

麻将系统中存在三类正反馈振荡:

类型 A:结算重试振荡

重发 EndGame

返回 failure(GS 不可达)

nRetryCount++ nNextRetryTime+=5s

AchSettlement(可能延迟)

Ach 在间歇到达

下个 RetryTick 发现无 pending

SceneServer RetryTick

GameServer

Settlement_RemovePending

Pending 已清除

终止

震荡条件:GS 短暂不可达时 RetryTick 每5秒重发。GS 恢复后可能同时收到多个重复 EndGame。

类型 B:AutoTakeOut 振荡

玩家离线期间

玩家重连触发

GameServer 处理

EnterScene 先于 AutoTakeOut 到达

资金二义

AutoTakeOut 未处理

Guardians 保留队列

重放 AutoTakeOut

正常完成

误判孤儿冻结 → 错误退款

资金安全风险

AutoTakeOut 不是在服务器之间指数退避重试的。它由 Guardians 系统持久化保留,在玩家重新登录/连接时一次性重放到 GameServer。重放时机必须在 EnterScene 之前,否则会导致资金错误。

类型 C:ForwardFailed 重入

ForwardFailed 回调

Settlement_BuildFromForwardFailed

RetryTick

Ach 已同时到达

Pending 还在

Token < Expected

不发 Ach

继续 RetryTick

GameCenter 转发失败

SceneServer

Settlement_AddPending

重发 EndGame

Pending 已清除

重复 EndGame

GameServer SKIP

13.2 三层防护

Layer 3:降级恢复

Layer 2:熔断

Layer 1:限频

Settlement_RetryTick
128 pending/帧

AutoTakeOut 玩家重连触发

per-session nRetryCount 上限 5(≈25s,覆盖最坏10s掉线)

cross-session nTotalRetryCount 上限 20(≈1min,极端重连)

nGameCenterID<=0 直接放弃

bRetryExhausted

Settlement_RetryPlayer 重连恢复

Guardians 持久化不丢失

防护 机制 上限 超限行为
结算重试频率 per-session nRetryCount 4次/局(≈20秒) 标记 bRetryExhausted,暂停
全局重试总数 cross-session nTotalRetryCount 12次/总(≈1分钟) 永久放弃 + CRITICAL
放弃说明 4次×5秒=20秒覆盖单会话掉线;12次×5秒=60秒≈1分钟覆盖极端重连。 最坏情况下玩家断线约10秒可恢复重连,4次×5秒=20秒足够覆盖正常重连窗口。连续12次失败(1分钟)仍不成功→玩家已彻底断线或服务器致命崩溃,继续重试无意义
AutoTakeOut 玩家重连时触发放置
无效目标 nGameCenterID ≤ 0 1次 直接放弃 pending
ForwardFailed 竞态 已有 pending 跳过 跳过
单帧风暴 MAHJONG_SETTLEMENT_RETRY_MAX_PER_TICK 128/帧 推迟到下一帧

恢复:Settlement_RetryPlayer(玩家重连时恢复),Guardians 持久化不丢失。

十四、日志规范

级别 函数 说明
调试 MJLOGT / LogRichDebug if MAHJONG_DEBUG then 控制
警告 MJLOGW / LogRichWarning 产品模式开启
错误 MJLOGE / LogRichErr 产品模式开启

日志位置

  • 客户端:game*.log
  • 服务端:../Server/logs/{类型}/{日期}/{类型}_{日期}.log

对局日志(latMahjongRound,28字段):

  • settle_type: 1=胡牌即时, 2=对局结束
  • 包含手牌/碰杠/番型/段位等

畅玩阁日志(C4101-C4103):

  • 对局生命周期事件(进入/结束/退出/崩溃/胡牌)

十五、已知边界与限制

边界 说明
Award/ConsumeRes 跨系统事务 AwardCurrency 成功后 ConsumeRes 失败导致 attr69 残留。两者是独立系统调用,不在同一事务内。代码做了3次重试 + CRITICAL 日志标记人工核查
少量发奖 AwardCurrency 成功后 SetMahjongSavedData 持久化失败导致账号余额快照未更新。Token 不回退,下次从钱包重读恢复
结算重试 SceneServer 的结算重试:per-session 4次+跨 session 12次(≈1分钟),超限后永久放弃。
牌桌重绘 全量刷新时所有子控件销毁重建,持久化锚点(箭头、描点)需要重新锚定
退出响应 C2S 直回的错误响应(无 tResponse 包装)通过 tPacket 根层读取 nError
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐