using System.Collections.Concurrent; using Ananta.SDK.Logging; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 骰子(DiceGame)—— 酒吧「午夜回响」吧台的玩法, /// C# 侧 L50.Gameplay.DiceGame.DiceGameManager,UI 侧 /// Lua\LuaFiles\LX6\SGUI\StoreDefine\BarGameStartPanelStore.lua。 /// /// ★ 根因链(2026-09-26 实机取证,逐条实锤) /// /// 第一版:AskEnterDiceZoneDoubleAI 没注册 ⇒ 框架 typed-default 兜底返回 0 /// ⇒ 客户端 BarGameStartPanelStore.lua:162「AgentId为0,无法继续游戏」。已修(注册 handler)。 /// 第二版(frida 实锤):返回编造的 uid 也不行。客户端 /// lastReadyDiag = "等待 Agent 实体同步 agentId=35184381626017",必须返回 /// 世界里真实存在的实体 uid。用 frida 把真实 NPC 的 uid 直写进 /// diceOpponentAgentId 后,客户端立刻跑出 /// SetPlayerPrepareMotionFinish → TransitionTo → DecidePlayerActionOrder → /// PlaceDices → GenDice×5,状态从 prepareState 进到 beforeShakeState。 /// /// 所以这一版:服务端真的造一个 NPC 站到玩家旁边,把它的 uid 交回去。 /// /// ★ 为什么不是「抄扇巴掌」 /// 扇巴掌(L18.Gameplay.SlapSceneNode)的 NPC 对手是场景里预设好的 GameObject /// (字段 palmKingSlapSceneNpcList),整个类只有 SetCameraActive / .ctor /// 两个方法,纯本地、不经过服务端、也不是真实 Agent 实体 —— 没有可移植的服务端实现。 /// 但它印证了同一件事:这类 POI 小游戏的对手就是「场景里一个真实存在的 NPC」。 /// 我们这边没有现成的吧台 NPC 可复用,所以走服务端造 NPC 的通道(SendCivilianAsync), /// 造出来的实体跟官方 static NPC 同段 uid,客户端一样认。 /// /// ★ 迭代方式(重要) /// 服务端→客户端那六条 Sync* 的载荷布局没有离线权威来源(MethodId 表 / RpcSurface / /// 代理 lua 都是旧版,没有骰子)。字段顺序来自 IL2CPP dump,但「枚举线宽」「complex vs struct」 /// 两处只能靠实机收敛。为此加了 /api/dice/raw(见 AdminWebServer): /// 可以直接把任意 methodId + hex 推给客户端,不用重启服务端就能反复试格式。 /// /// ⛑ 回滚:把 GameRouter.cs 的 Enabled4229938MethodIds 里这四行删掉即可。 /// internal sealed partial class GameRouter { /// 每条连接一份骰子对局状态,挂在 Session.Items 上。 private const string DiceStateKey = "dice.session"; // ------------------------------------------------------------------------------------------- // ★ 2026-09-27 15:1x:对手改为**钉死的世界坐标**,不再「相对玩家现算」。 // 之前每局落点都不一样(玩家站位/朝向一变它就跟着跑),用户要求固定座位。 // 下面两个常量就是用户实测后指定的座位(吧台对面)。 // ⛑ Y 不钉:沿用玩家当前的 Y(贴地),避免楼层/台阶高度不对。 // ------------------------------------------------------------------------------------------- private const float FixedOpponentX = 2868.50f; private const float FixedOpponentZ = 2201.36f; /// 对局结束后等多久再撤对手(毫秒)—— 留给结算/收摊动画,撤太早客户端会闪。 private const int OpponentCleanupDelayMs = 2500; // (下面三个是「相对玩家摆位」时期的老常量,随固定坐标一并停用,仅保留注释备查: // OpponentDistanceMeters=2.1 / OpponentRightOffsetMeters=1.1 / OpponentFacingOffsetDegrees=40) /// /// 对手朝向在「正对玩家」的基础上再转多少度。 /// ⛑ 符号约定:Unity 绕 Y 轴 **yaw 增大 = 向右转**(forward 从 +Z 转向 +X,即自身右手边), /// 所以这里 **正数 = 向左转**(公式里做减法)。40 = 对手朝自己的左手边转 40°。 /// 想让它往右转就填 -40。 /// private const float OpponentFacingOffsetDegrees = 40f; private sealed class DiceSession { public ulong GadgetUId; public ulong OpponentUid; /// SyncDiceGameStart 的四个可变参数。默认 gameAIId=100(与客户端一致)、3 回合、不设目标分、玩家先手。 public uint OpponentModelId = 100; public int TotalRounds = 3; public int TargetScore = 0; // 0 = 不设目标分(原版由 PoiGameDiceAIConfig.TargetScore 给,我们按回合数收尾) public int FirstPlayer = 0; // 0 = 玩家先手 /// /// 客户端下发的模式:UX.Game.DiceGameType — 1 = Simple(5 颗,比牌型)、0 = Classic(6 颗,比分数)。 /// 实机(2026-09-27 进局包)客户端恒发 u8@12 = 1,即 Simple。 /// public int Mode = 1; /// 客户端下发的难度(PoiGameDiceAIConfig 100..103),目前只用于日志。 public int Level = 100; /// 本局骰子数:Simple 5 颗 / Classic 6 颗(客户端 SimpleDiceNum=5 / DefaultDiceNum=6)。 public int DiceCount => Mode == 1 ? 5 : 6; /// Simple = 比牌型(一回合一个胜场),Classic = 比分数值(累加)。 public bool SimpleMode => Mode == 1; public bool GameStartSent; /// 主机循环是否已启动(用 Interlocked 保证一次点开始只起一个)。 public int HostStarted; public int Round = 1; /// ScoreA = 玩家(seat 0,mainPlayer),ScoreB = 对手(seat 1)。 public int ScoreA; public int ScoreB; /// 本回合内玩家/对手各掷出的分,用于判定「本回合赢家」。 public int RoundScoreA; public int RoundScoreB; public long StartedUtcTicks; // ---- 玩家回合协作 ------------------------------------------------------------- /// 玩家离开玩法区后置 true,主机循环会尽快退出。 public volatile bool LeaveRequested; /// 主机正在等的「玩家操作」信号。非 null 表示正在等玩家。 public TaskCompletionSource? PlayerSignal; /// 正在等玩家的这一步的名字(只用于日志)。 public string WaitingFor = string.Empty; /// 玩家最近一次出手上报的命令(0 = 还没有)。 public int LastPlayerCmd; /// 玩家最近一次出手上报的参数。 public int[]? LastPlayerParams; /// 玩家这一局是否真的出过手(用于「玩家是否在挂机」的自适应)。 public bool PlayerEverActed; } private static DiceSession GetDiceSession(RpcContext ctx) { if (ctx.Session.Items.TryGetValue(DiceStateKey, out var raw) && raw is DiceSession s) return s; var fresh = new DiceSession(); ctx.Session.Items[DiceStateKey] = fresh; return fresh; } // --------------------------------------------------------------------------------------- // AskEnterDiceZoneDoubleAI (67898934) —— 「开始游戏」按钮打过来的那条 // --------------------------------------------------------------------------------------- [Handler(MethodId.AskEnterDiceZoneDoubleAI)] private async Task AskEnterDiceZoneDoubleAI4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; var hex = body is { Length: > 0 } ? System.Convert.ToHexString(body.AsSpan(0, Math.Min(body.Length, 32))) : string.Empty; // 请求体:u64 @0 = 吧台/玩法区实体 uid(gadgetUId);u32 @8 = 100(客户端 gameAIId 也是 100); // u8 @12 = 1;@13..14 = FF 01(空 list 终止标记)。只取前两个,其余不解析语义。 ulong gadgetUId = body is { Length: >= 8 } ? BitConverter.ToUInt64(body, 0) : 0UL; uint arg8 = body is { Length: >= 12 } ? BitConverter.ToUInt32(body, 8) : 0u; // @12 = mode(UX.Game.DiceGameType:0 = Complex/Classic、1 = Simple)。客户端实机恒发 1。 byte mode = body is { Length: >= 13 } ? body[12] : (byte)1; var session = GetDiceSession(msg.Context); // 新开一局:清掉上一局的残留,否则分数会累加、主机会因为 HostStarted 还挂着而不重启。 session.LeaveRequested = true; // 让上一局(若有)尽快退出 session.PlayerSignal?.TrySetResult(true); session.PlayerSignal = null; Volatile.Write(ref session.HostStarted, 0); session.ScoreA = 0; session.ScoreB = 0; session.Round = 1; session.GameStartSent = false; session.PlayerEverActed = false; session.LastPlayerCmd = 0; session.LastPlayerParams = null; session.GadgetUId = gadgetUId; session.Mode = mode == 1 ? 1 : 0; session.Level = (int)arg8; session.StartedUtcTicks = DateTime.UtcNow.Ticks; // ★ 造一个真实 NPC 当对手。失败就退回编造 uid(至少不让客户端卡在 agentId==0)。 // ⛑ 不要「复用」上一局的 NPC(试过,已回退):复用时客户端会卡在 // lastReadyDiag = "等待 Agent 实体加载资源 agentId=...",因为它需要重新加载实体、 // 而我们已经不再下发这个实体了。代价是每局多一个市民,但正确性优先。 var (uid, how) = await SpawnDiceOpponentAsync(conn); session.OpponentUid = uid; conn.Log.Info($"[DICE] enter zone bytes={body.Length} hex={hex} gadgetUId={gadgetUId} arg8={arg8}" + $" mode={session.Mode}({(session.SimpleMode ? "Simple/比牌型" : "Classic/比分")}) dice={session.DiceCount}" + $" -> opponentUid={uid} ({how})"); await conn.ReturnAsync(msg, uid); // 只发 GameStart,命令循环**不在这里起**。 // ⛑ 血泪教训:试过在进局后 1.2s 就开始发 StartShake,客户端直接卡死在 // prepareState,之后再发多少条命令都推不动(实测 30 秒不动)。 // 客户端要先把准备流程走完才会就绪,就绪的标志是它主动上报 // AskDicePlayerAction —— 所以驱动循环改到那条 handler 里起。 // 上一局已通知退出,新局开始 —— 复位,否则主机循环一进来就 return。 session.LeaveRequested = false; var tcp0 = conn.Session; var snap0 = session; _ = Task.Run(async () => { try { await Task.Delay(1500); await PushDiceGameStartAsync(tcp0, snap0.GadgetUId, snap0.OpponentModelId, snap0.TotalRounds, snap0.TargetScore, snap0.FirstPlayer); snap0.GameStartSent = true; } catch (Exception ex) { tcp0.Log.Warn($"[DICE] GameStart 下发失败:{ex.Message}"); } }); } /// /// 服务端当主机,驱动整局骰子对战。 /// /// ★ 2026-09-26 实机实锤的命令序列(frida 逐个验证,看客户端状态机推进) /// /// SyncDiceCommand(gadgetUId, StartShake=1) -> DoTransitionToShakeState -> shakeState /// SyncDiceCommand(gadgetUId, OpenBox=2) -> DoTransitionToAfterShakeState -> afterShakeState /// SyncDiceCommand(gadgetUId, SkipToSettle=6) -> -> roundSettleState /// 再发 StartShake -> 自动换手(roundPlayer 从 DicePlayer 变 RemotePlayer) /// /// /// ★★ 最关键的一条:命令的 targetUid 是 gadgetUId(玩法区实体), /// 不是玩家 uid、也不是对手 uid。发给 uid=0 或对手 uid 时客户端只收包不推进状态, /// 只有发 gadgetUId 才会真的推进状态机。这个坑排查了很久。 /// /// /// ⛑ 另外别信 lastReadyDiag:那是某一时刻的一次性快照,对局中不会再刷新。 /// 实测 main.isMotionInit 已经是 true、op.isSkillInit 手动置 true 之后, /// 这条 diag 字符串一个字都没变。真正的实时状态看 nowState / roundPlayer。 /// /// private static async Task RunDiceHostAsync(TcpSession session, DiceSession st) { // ⛑ 先把「本局自己造的那个对手」记下来:DiceSession 是整条连接共用的, // 玩家中途重开一局时 st.OpponentUid 会被新 NPC 顶掉。收尾时若直接读 // st.OpponentUid,旧主机的收尾就会把新对手撤了(新局直接卡在 // 「等待 Agent 实体同步」)。所以收尾只认自己这一局的 uid。 var myOpponentUid = st.OpponentUid; try { // 由 AskDicePlayerAction(客户端就绪信号)唤起,这里只留一点点缓冲。 await Task.Delay(800); var rnd = new Random(); var rounds = Math.Max(1, st.TotalRounds); for (var round = 1; round <= rounds && !st.LeaveRequested; round++) { st.Round = round; st.RoundScoreA = 0; st.RoundScoreB = 0; // side 0 = 玩家(seat 0 / mainPlayer),side 1 = 对手(seat 1 / RemotePlayer)。 // 先手由 FirstPlayer 决定:0 = 玩家先。 for (var k = 0; k < 2; k++) { if (st.LeaveRequested) return; var side = (st.FirstPlayer == 0 ? k : 1 - k); var isPlayer = side == 0; // ★ 真实计分:服务端掷骰、算分、下发。客户端 remote 模式下只是播放器, // 它的 nowRoundScore 实测恒为 0(没有服务端下发的分数就不会自己算), // 所以分数必须由我们当权威给出。 var dice = RollDice(rnd, st.DiceCount); var turnScore = ScoreOf(dice, st.SimpleMode); if (isPlayer) { // ★★ 玩家回合:**完全由玩家的操作上报驱动**。 // // 2026-09-26 23:46:43 实证了客户端的协作模式 —— 「逐个上报、等服务端确认」: // player action who=0 cmd=2 params=[1] bytes=19 (OpenBox) // player action who=0 cmd=3 params=[] bytes=15 (SelectDices) // 我们每转发一条,客户端才放行下一个操作。所以这里不能自己排好序硬发, // 而要:等玩家报一个 → 原样转发 → 再等下一个 → …… 直到这一手结算。 // // 兜底:玩家不出手(超时)就按预设序列代打 // (StartShake → OpenBox → SkipToSettle),保证这一手一定能收尾。 var fallbacks = new[] { DiceCommand.StartShake, DiceCommand.OpenBox, DiceCommand.SkipToSettle }; var names = new[] { "摇骰", "开盅", "结算" }; // 玩家整局都没出过手就只留 3 秒窗口,免得一局被拖成好几分钟。 var allowWait = st.PlayerEverActed || round == 1; var settled = false; // 上限 12 步:够容纳「选骰 → 重摇」的多轮操作,又不会死循环。 for (var step = 0; step < 12 && !settled; step++) { if (st.LeaveRequested) return; st.LastPlayerCmd = 0; st.LastPlayerParams = null; var name = step < names.Length ? names[step] : "继续"; var timeout = allowWait ? (step == 0 ? 18000 : 12000) : 3000; var got = await WaitPlayerOrTimeout(session, st, timeout, name); if (st.LeaveRequested) return; int cmd; int[]? ps; if (got && st.LastPlayerCmd is > DiceCommand.UseSkill and <= DiceCommand.InitSkills) { // 玩家真出手了:原样转发玩家的命令。 cmd = st.LastPlayerCmd; ps = st.LastPlayerParams; st.PlayerEverActed = true; session.Log.Info($"[DICE] 采用玩家上报的命令 cmd={cmd}" + (ps is { Length: > 0 } ? $" params=[{string.Join(",", ps)}]" : "")); } else { cmd = step < fallbacks.Length ? fallbacks[step] : DiceCommand.SkipToSettle; ps = null; } // 开盅前把点数下发,让桌上骰子跟服务端算的分一致 // (实测 DiceResult 不会打断状态机)。 if (cmd == DiceCommand.OpenBox) await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.DiceResult, dice)); await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, cmd, ps)); if (cmd is DiceCommand.Settle or DiceCommand.SkipToSettle) { settled = true; } else { await Task.Delay(1200); } } // 兜底:玩家没走完结算,补一条保证这一手能收尾(否则状态机会停住)。 if (!settled) { await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.SkipToSettle, null)); await Task.Delay(3000); } // ⛑ Winner 不能传 -1:那是「未定」的哨兵值,客户端 UI 很可能 // 因此不渲染胜负。这里传当前领先者(seat 0 = 玩家 / 1 = 对手)。 await SafePush(session, PushDiceScoreInfoAsync(session, st.GadgetUId, round, 0, turnScore, LeaderOf(st, isPlayerTurn: true, turnScore), dice, false, DicePhaseAfterShake, DiceScores(st, isPlayerTurn: true, turnScore))); st.RoundScoreA = turnScore; // Classic 直接累加分数;Simple 要等对手也掷完再比牌型(见对手回合)。 if (!st.SimpleMode) st.ScoreA += turnScore; } else { // 对手回合:全自动,节奏比动画略慢,发太快会打乱状态机。 await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.StartShake, null)); await Task.Delay(4000); await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.DiceResult, dice)); await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.OpenBox, null)); await Task.Delay(3500); await SafePush(session, PushDiceScoreInfoAsync(session, st.GadgetUId, round, 1, turnScore, LeaderOf(st, isPlayerTurn: false, turnScore), dice, false, DicePhaseAfterShake, DiceScores(st, isPlayerTurn: false, turnScore))); await SafePush(session, PushDiceCommandAsync(session, st.GadgetUId, DiceCommand.SkipToSettle, null)); await Task.Delay(3500); st.RoundScoreB = turnScore; if (st.SimpleMode) { // Simple:本回合双方比牌型,牌型高的一方 +1 个胜场(平局都不得分)。 if (st.RoundScoreA > st.RoundScoreB) st.ScoreA++; else if (st.RoundScoreA < st.RoundScoreB) st.ScoreB++; } else st.ScoreB += turnScore; } // 每手结束下发一次回合结算(带双方累计分)。 // ⛑ 玩家侧是 seat 0、对手是 seat 1,别写反。 // ⛑ Winner 传本回合实际赢家(0=玩家 / 1=对手),不要传 -1。 var roundWinner = st.RoundScoreA >= st.RoundScoreB ? 0 : 1; var isEnd = round >= rounds && k >= 1; await SafePush(session, PushDiceRoundSettleAsync(session, st.GadgetUId, round, st.ScoreA, st.ScoreB, turnScore, isEnd, roundWinner)); session.Log.Info($"[DICE] 第{round}回合 {(isPlayer ? "玩家" : "对手")} 掷出 " + $"[{string.Join(",", dice)}] = {turnScore}" + (st.SimpleMode ? $"(牌型等级 {turnScore})" : " 分") + $" 累计 玩家{st.ScoreA} : 对手{st.ScoreB}"); } } // 终局:分高者胜,平局给玩家(0)。 var winner = st.ScoreA >= st.ScoreB ? 0 : 1; await SafePush(session, PushDiceGameEndAsync(session, st.GadgetUId, winner, st.ScoreA, st.ScoreB)); session.Log.Info($"[DICE] 对局结束 玩家{st.ScoreA} : 对手{st.ScoreB} " + $"胜者={(winner == 0 ? "玩家" : "对手")}"); // ★ 收摊:结算动画播完就把对手 NPC 撤掉,不在场上留人(否则每局攒一个)。 try { await Task.Delay(OpponentCleanupDelayMs, CancellationToken.None); await RemoveDiceOpponentAsync(session, st, "对局结束", myOpponentUid); } catch (Exception ex) { session.Log.Warn($"[DICE] 对局结束后回收对手失败:{ex.Message}"); } } catch (Exception ex) { // 玩家掉线/离开时 NotifyAsync 会抛,属正常退出,记一条日志即可。 session.Log.Warn($"[DICE] 对局主机结束:{ex.Message}"); } finally { st.PlayerSignal = null; } } /// /// 组装 SyncDiceScoreInfo 里的 seat -> 分数表。 /// ⛑ seat 0 是玩家、seat 1 是对手。传的是「含本手的累计分」, /// 因为这条分数字是在本手加分之前下发的。 /// private static Dictionary DiceScores(DiceSession st, bool isPlayerTurn, int turnScore) { // Simple:总分是「赢下的回合数」,本手的牌型等级不进总分(否则会显示成 0+8 这种怪值)。 if (st.SimpleMode) return new() { [0] = st.ScoreA, [1] = st.ScoreB }; return new() { [0] = st.ScoreA + (isPlayerTurn ? turnScore : 0), [1] = st.ScoreB + (isPlayerTurn ? 0 : turnScore), }; } /// 目前领先的一方:0 = 玩家(seat 0),1 = 对手(seat 1)。平局算玩家。 private static int LeaderOf(DiceSession st, bool isPlayerTurn, int turnScore) { var s = DiceScores(st, isPlayerTurn, turnScore); return s[0] >= s[1] ? 0 : 1; } /// UX.Game.DicePhase.AfterShake —— ScoreInfo 是在开盅之后发的。 private const int DicePhaseAfterShake = 3; /// 掷 n 颗骰子(1..6)。 private static int[] RollDice(Random rnd, int n) { var d = new int[n]; for (var i = 0; i < n; i++) d[i] = rnd.Next(1, 7); return d; } /// /// 一手得分。 /// /// Simple(mode=1):返回**牌型等级** DiceHandRank(0..8),本回合双方比大小。 /// Classic(mode=0):返回**原版计分** DiceBasicScore(点数分 × 组合倍数),累加进总分。 /// /// /// ★ 2026-09-27 从 7k-v0.5 移植:那一版的 DiceBasicScore / DiceHandRank 与 /// DiceGame_Rules.md(frida 挂客户端读 DiceHandTypeCalculator 的实测值)逐项吻合, /// 比原先的「五颗点数之和」占位规则更接近原版。客户端 BaseDicePlayer 的 /// nowRoundScore 等字段在服务端不下发时恒为 0 ⇒ 分数仍由我们当权威,改算法不影响链路。 /// /// private static int ScoreOf(int[] dice, bool simpleMode) => simpleMode ? DiceHandRank(dice) : DiceBasicScore(dice); /// /// 原版「牌型」判定(DiceHandTypeCalculator.GetHandType / ClassifyHand 的等价实现)。 /// 0 高牌 / 1 一对 / 2 两对 / 3 三条 / 4 四连顺 / 5 葫芦 / 6 四条 / 7 五连顺 / 8 五条。 /// private static int DiceHandRank(int[] dice) { var groups = dice.GroupBy(x => x).Select(x => x.Count()).OrderDescending().ToArray(); int longest = 0, run = 0; for (int face = 1; face <= 6; face++) { run = dice.Contains(face) ? run + 1 : 0; longest = Math.Max(longest, run); } if (groups[0] == 5) return 8; if (longest == 5) return 7; if (groups[0] == 4) return 6; if (groups.SequenceEqual(new[] { 3, 2 })) return 5; if (longest == 4) return 4; if (groups[0] == 3) return 3; return groups.Count(x => x == 2); // 两对 / 一对 / 高牌 } /// /// 原版一手得分 = Σ 点数分(literal)× 命中组合的倍数。 /// literal:1→10, 2→2, 3→3, 4→4, 5→5, 6→8(1 和 6 是特殊值)。 /// 组合倍数:对子 4 / 三条 10 / 四条 30 / 五条 80 / 六条 150;顺子 4 连 14 / 5 连 24 / 6 连 45。 /// ⛑ 简化:最低 ×1,不实现徽章改倍数与爆点惩罚(我们也不下发徽章)。 /// private static int DiceBasicScore(int[] dice) { int[] literals = [0, 10, 2, 3, 4, 5, 8], stacks = [0, 0, 4, 10, 30, 80, 150]; int multiple = dice.GroupBy(x => x).Sum(g => stacks[Math.Min(g.Count(), 6)]); int longest = 0, run = 0; for (int face = 1; face <= 6; face++) { run = dice.Contains(face) ? run + 1 : 0; longest = Math.Max(longest, run); } multiple += longest switch { 6 => 45, 5 => 24, 4 => 14, _ => 0 }; return dice.Sum(x => literals[x]) * Math.Max(1, multiple); } /// /// 等玩家操作;超时则记一条日志并返回 false(自动代打)。 /// 玩家操作的信号是客户端上报 AskDicePlayerAction,在它的 handler 里 SetResult。 /// private static async Task WaitPlayerOrTimeout( TcpSession session, DiceSession st, int timeoutMs, string stepName) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); st.PlayerSignal = tcs; st.WaitingFor = stepName; session.Log.Info($"[DICE] 等待玩家「{stepName}」({timeoutMs / 1000}s 超时自动代打)"); var done = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); st.PlayerSignal = null; st.WaitingFor = string.Empty; if (done == tcs.Task) { session.Log.Info($"[DICE] 玩家操作「{stepName}」-> 继续"); return true; } session.Log.Info($"[DICE] 玩家「{stepName}」超时 -> 服务端代打"); return false; } /// /// 下发包一层:Sync* 里除了 SyncDiceCommand 之外都没实机验证过载荷, /// 万一客户端解析报错也不该把整个对局主机带崩。 /// private static async Task SafePush(TcpSession session, Task push) { try { await push; } catch (Exception ex) { session.Log.Warn($"[DICE] 下发失败(已忽略):{ex.Message}"); } } /// UX.Game.DiceRemoteCommand 的线上取值(1 字节)。 private static class DiceCommand { public const int UseSkill = 0; public const int StartShake = 1; public const int OpenBox = 2; public const int SelectDices = 3; public const int Rethrow = 4; public const int Settle = 5; public const int SkipToSettle = 6; public const int BustRethrow = 7; public const int BustCancel = 8; public const int DiceResult = 9; public const int InitSkills = 10; } /// /// 在玩家身前造一个市民 NPC,返回它的真实 uid。 /// 走的是 /api/civilians/spawn-group 那条已验证可见的通道(SendCivilianAsync), /// uid 与官方 static NPC 同段(StaticNpcIdFloor),客户端能找到这个实体。 /// private static async Task<(ulong Uid, string How)> SpawnDiceOpponentAsync(Connection conn) { var session = conn.Session; try { var state = GetStateIfExists(session); if (state is null) return (FallbackUid(), "no-state"); var near = state.LastReportedPlayerPosition; var item = PickDiceOpponentItem(); if (item is null) return (FallbackUid(), "no-civilian-item"); // Aether 初始化:市民没见过它就不会实体化(见 SpawnCiviliansNearPlayerAsync 注释)。 await SendDonorAetherInitAsync(session); // ★ 固定座位:X/Z 用常量,Y 跟玩家当前高度(贴地)。 // 朝向不钉死 —— 始终转向玩家(atan2 直接给出「forward=(sin,cos)」的那个 yaw), // 这样玩家绕着吧台走,对手会转头看你,但**人不会挪窝**。 var x = FixedOpponentX; var z = FixedOpponentZ; var facing = (float)(Math.Atan2(near.X - x, near.Z - z) * 180.0 / Math.PI); var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); // ⛑ 必传 skipPhone:true —— 见 SendCivilianAsync 末尾:POI 的 UsageId 为 0 时 // (我们挑的 701「站姿_任务_看一下指指点点_少女」就是 0)它会走 // MarkNpcHackableOnSpawnAsync 的兜底,给这个 NPC 补一部手机 + 玩手机 usage, // 结果就是对手站在吧台边一直打电话。骰子对手只需要一个安静的实体 uid。 await SendCivilianAsync(session, item, id, x, near.Y, z, facing, skipPhone: true); lock (state.SyncRoot) { state.StaticNpcPreparedPlotEvents[id] = 0; state.StaticNpcPoses[id] = new StaticNpcPose(x, near.Y, z, facing); } return (id, $"civilian formwork={item.NpcFormworkId} poi={item.PoiActionId}"); } catch (Exception ex) { conn.Log.Warn($"[DICE] 造对手 NPC 失败:{ex.Message}"); return (FallbackUid(), "exception:" + ex.Message); } } /// /// 把这一局造出来的对手 NPC 撤掉(2026-09-27 新增,用户要求「游戏结束后清除」)。 /// /// ★ 两条通道都发:static NPC 没有专属的 *StaticNpcRemove /// (MethodId 表里只有 AddData / ConvertToPe / VehicleRemove), /// 而 SyncAetherAINpcRemove(68332284) 管的是 ambient peds / functional NPC 那本账, /// 我们对不对得上无法离线判定 ⇒ 两条都发,哪条生效算哪条(多发一条无效包客户端是忽略的)。 /// /// ⛑⛑ 服务端账本必须一起清:StaticNpcPoses / StaticNpcPreparedPlotEvents /// 留着的话,玩家走远再回来触发重扫(每 120s 全量重扫)会把它再摆回来。 /// /// ⛑ :传了就只撤这一个 uid(对局主机用,防止旧局收尾 /// 误删新局 NPC);不传就撤「当前对手」(玩家主动离开时用)。 /// 只有「要撤的正好还是当前对手」时才清 st.OpponentUid 指针。 /// private static async Task RemoveDiceOpponentAsync(TcpSession session, DiceSession st, string reason, ulong expectedUid = 0) { var uid = expectedUid != 0 ? expectedUid : st.OpponentUid; if (uid == 0) return; if (st.OpponentUid == uid) st.OpponentUid = 0; try { await session.NotifyAsync(MethodId.SyncAetherAINpcRemove, UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = uid }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncLogicAgentLeave, UxSerializer.Serialize(WorldCodec.LogicAgentLeave(uid)), CancellationToken.None); } catch (Exception ex) { session.Log.Warn($"[DICE] 撤销包发送失败 uid={uid}:{ex.Message}"); } var state = GetStateIfExists(session); if (state is not null) lock (state.SyncRoot) { state.StaticNpcPoses.Remove(uid); state.StaticNpcPreparedPlotEvents.Remove(uid); } session.Log.Info($"[DICE] 回收对手 uid={uid}({reason})"); } /// 挑一个市民模板;优先「非系统行为」组里第一个可用的。 private static CivilianSpawnItem? PickDiceOpponentItem() { foreach (var g in CivilianGroups) { if (g.SystemOnly) continue; foreach (var i in g.Items) return new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId); } return null; } /// 把角度收进 (-180, 180]。 private static float WrapDegrees(float deg) { var wrapped = deg % 360f; if (wrapped > 180f) wrapped -= 360f; else if (wrapped <= -180f) wrapped += 360f; return wrapped; } private static long s_diceFallbackOrdinal; /// /// 造 NPC 失败时的兜底 uid。刻意落在 ServerSpawnUidFloor 之上再偏 0x900000, /// 避免与玩家/敌人账本串味。客户端会卡在「等待 Agent 实体同步」,但至少不是 0。 /// private static ulong FallbackUid() => GameAdminBridge.ServerSpawnUidFloor + 0x900000UL + (ulong)Interlocked.Increment(ref s_diceFallbackOrdinal); // --------------------------------------------------------------------------------------- // 其余三条:都是 void 返回,空包 + err=0 就是客户端要的 OK 回执。 // --------------------------------------------------------------------------------------- /// /// AskDicePlayerAction (67747541) —— 玩家操作上报。单独拆出来是为了看清 /// 「按空格」时客户端到底有没有上报、上报的命令是什么: /// 线上布局与 SyncDiceCommand 同源,u64 + 1 字节命令 + FF + Int7(n) + n×Int32 + u32。 /// [Handler(MethodId.AskDicePlayerAction)] private Task AskDicePlayerAction4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; var (who, cmd, ps, badge) = ParsePlayerAction(body); var hex = body is { Length: > 0 } ? System.Convert.ToHexString(body) : string.Empty; var session = GetDiceSession(msg.Context); conn.Log.Info($"[DICE] player action who={who} cmd={cmd} badge={badge} " + $"params=[{string.Join(",", ps)}] bytes={body.Length} hex={hex}"); // ★ 第一次收到 = 客户端「就绪」信号:只在准备流程走完后才发(进局后约 6 秒)。 // 收到它才开始驱动,否则会把客户端卡在 prepareState。 if (Interlocked.Exchange(ref session.HostStarted, 1) == 0) { var tcp = conn.Session; var snap = session; _ = Task.Run(() => RunDiceHostAsync(tcp, snap)); return conn.ReturnEmptyOkAsync(msg); } // ★ 之后的每一次上报都当作「玩家出手」:记下命令,唤醒正在等待的主机步骤。 // ⛑ 就绪那种空上报(cmd=0)也走这里,但主机会按 UseSkill 过滤掉、用预设序列。 session.LastPlayerCmd = cmd; session.LastPlayerParams = ps; var sig = session.PlayerSignal; if (sig is not null) { conn.Log.Info($"[DICE] 玩家出手 -> 放行「{session.WaitingFor}」"); sig.TrySetResult(true); } else { conn.Log.Info($"[DICE] player action(主机未在等待,仅记录)cmd={cmd}"); } return conn.ReturnEmptyOkAsync(msg); } /// /// 解析 AskDicePlayerAction 的载荷。 /// 线上布局(与 SyncDiceCommand 同源,2026-09-26 实证): /// u64 who | 1字节 cmd | FF | Int7(n) | n×Int32 | u32 badge。 /// 实测空参数时是 00*8 05 FF 01 00000000 = 15 字节。 /// private static (ulong Who, int Cmd, int[] Params, uint Badge) ParsePlayerAction(byte[] body) { ulong who = body is { Length: >= 8 } ? BitConverter.ToUInt64(body, 0) : 0UL; int cmd = body is { Length: >= 9 } ? body[8] : 0; var i = 9; // 空列表标记 FF + 计数(Int7 存的是 n+1),n 为 0 时这两个字节就是 FF 01。 if (i + 1 >= body.Length || body[i] != 0xFF) return (who, cmd, Array.Empty(), 0); i++; var n = body[i] - 1; i++; if (n < 0) n = 0; var ps = new List(n); for (var k = 0; k < n && i + 4 <= body.Length; k++, i += 4) ps.Add(BitConverter.ToInt32(body, i)); var badge = i + 4 <= body.Length ? BitConverter.ToUInt32(body, i) : 0u; return (who, cmd, ps.ToArray(), badge); } /// AskLeaveDiceZone —— 玩家退出玩法区,通知主机尽快收摊,并顺手把对手撤掉。 [Handler(MethodId.AskLeaveDiceZone)] private async Task AskLeaveDiceZone4229938(Connection conn, UxRpcMessage msg) { var session = GetDiceSession(msg.Context); session.LeaveRequested = true; session.PlayerSignal?.TrySetResult(true); conn.Log.Info($"[DICE] leave zone -> 主机停止"); // 中途退出也别把 NPC 留在场上(不 await 太久,回执要紧)。 try { await RemoveDiceOpponentAsync(conn.Session, session, "玩家离开玩法区"); } catch (Exception ex) { conn.Log.Warn($"[DICE] 离开时回收对手失败:{ex.Message}"); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskReportDiceBehavior)] private Task DiceAck(Connection conn, UxRpcMessage msg) { var body = msg.Body; var hex = body is { Length: > 0 } ? System.Convert.ToHexString(body.AsSpan(0, Math.Min(body.Length, 48))) : string.Empty; conn.Log.Info($"[DICE] accepted {RpcMethodNames.Display(msg.MethodId)} bytes={body.Length} hex={hex}"); return conn.ReturnEmptyOkAsync(msg); } // --------------------------------------------------------------------------------------- // 服务端→客户端下发(给 /api/dice/* 用的公共入口,方便不重启地试格式) // --------------------------------------------------------------------------------------- /// SyncDiceGameStart (68696343)。OnSyncDiceGameStart(u64,u32,i32,i32,i32) = 24 字节平铺。 internal static Task PushDiceGameStartAsync( TcpSession session, ulong gadgetUId, uint opponentModelId, int totalRounds, int targetScore, int firstPlayer) { var w = new UxWriter(); w.U64(gadgetUId).U32(opponentModelId).I32(totalRounds).I32(targetScore).I32(firstPlayer); var bytes = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceGameStart {bytes.Length}b {System.Convert.ToHexString(bytes)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceGameStart, bytes, CancellationToken.None); } /// /// SyncDiceCommand (68028865) —— 唯一**实机验证过**的下发通道。 /// 线上布局:u64 + 1 字节命令 + FF + Int7(数量) + 数量×Int32 + u32。 /// ★ 2026-09-26 21:24 实证:命令按 1 字节写时客户端 OnSyncDiceCommand → /// ReceiveRemoteCommand 都会触发;按 4 字节写则完全不触发。 /// internal static Task PushDiceCommandAsync( TcpSession session, ulong targetUid, int command, IReadOnlyList? intParams, uint badgeId = 0, int enumWidth = 1) { var w = new UxWriter(); w.U64(targetUid); if (enumWidth == 1) w.U8((byte)command); else w.I32(command); var ps = intParams ?? Array.Empty(); w.U8(0xFF).Int7(ps.Count); foreach (var p in ps) w.I32(p); w.U32(badgeId); var bytes = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceCommand {bytes.Length}b {System.Convert.ToHexString(bytes)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceCommand, bytes, CancellationToken.None); } /// SyncDiceRoundActions (68060649)。OnSyncDiceRoundActions(u64, DiceRoundActions)。 internal static Task PushDiceRoundActionsAsync(TcpSession session, ulong targetUid, int targetPlayer, IEnumerable<(int Command, int[] IntParams, uint BadgeId)>? actions = null) { var list = new List(); foreach (var a in actions ?? Array.Empty<(int, int[], uint)>()) list.Add(new DiceAction { Command = (byte)a.Command, IntParams = new List(a.IntParams ?? Array.Empty()), BadgeId = a.BadgeId, }); var payload = new DiceRoundActions { TargetPlayer = targetPlayer, Actions = list }; var bytes = UxSerializer.Serialize(payload); var w = new UxWriter(); w.U64(targetUid).Raw(bytes); var all = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceRoundActions {all.Length}b {System.Convert.ToHexString(all)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceRoundActions, all, CancellationToken.None); } /// SyncDiceRoundSettle (68568673)。OnSyncDiceRoundSettle(u64, DiceRoundSettleInfo)。 internal static Task PushDiceRoundSettleAsync(TcpSession session, ulong targetUid, int round, int scoreA, int scoreB, int roundScore, bool isGameEnd, int winner) { var info = new DiceRoundSettleInfo { Round = round, PlayerAScore = scoreA, PlayerBScore = scoreB, RoundScore = roundScore, IsGameEnd = isGameEnd, Winner = winner, }; var bytes = UxSerializer.Serialize(info); var w = new UxWriter(); w.U64(targetUid).Raw(bytes); var all = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceRoundSettle {all.Length}b {System.Convert.ToHexString(all)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceRoundSettle, all, CancellationToken.None); } /// SyncDiceScoreInfo (68500287)。OnSyncDiceScoreInfo(u64, DiceScoreInfo)。 internal static Task PushDiceScoreInfoAsync(TcpSession session, ulong targetUid, int currentRound, int currentTurn, int turnScore, int winner, int[]? dice, bool isBust, int phase, IReadOnlyDictionary? scores = null) { var info = new DiceScoreInfo { CurrentRound = currentRound, CurrentTurn = currentTurn, // ⛑ 空字典很可能是客户端不显示分数的原因之一 —— 这里带上真实的 seat->分数。 ParticipantScoreDic = scores is null ? new Dictionary() : new Dictionary(scores), CurrentTurnScore = turnScore, Winner = winner, CurrentDiceValues = new List(dice ?? Array.Empty()), SettledDiceValues = new List(), IsBust = isBust, CurrentThrowTimes = 0, HasExtraRound = false, OnExtraRound = false, Phase = (byte)phase, }; var bytes = UxSerializer.Serialize(info); var w = new UxWriter(); w.U64(targetUid).Raw(bytes); var all = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceScoreInfo {all.Length}b {System.Convert.ToHexString(all)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceScoreInfo, all, CancellationToken.None); } /// SyncDiceGameEnd (68018652)。OnSyncDiceGameEnd(u64, DiceGameEndInfo)。 internal static Task PushDiceGameEndAsync(TcpSession session, ulong targetUid, int winner, int scoreA, int scoreB, uint[]? drops = null) { var info = new DiceGameEndInfo { Winner = winner, Drops = new List(drops ?? Array.Empty()), PlayerAFinalScore = scoreA, PlayerBFinalScore = scoreB, }; var bytes = UxSerializer.Serialize(info); var w = new UxWriter(); w.U64(targetUid).Raw(bytes); var all = w.ToArray(); session.Log.Info($"[DICE] -> SyncDiceGameEnd {all.Length}b {System.Convert.ToHexString(all)}"); return session.NotifyAsync(MethodId.IGameSceneToClient_SyncDiceGameEnd, all, CancellationToken.None); } /// 把任意字节推给客户端(/api/dice/raw 用),用于不重启地试载荷格式。 internal static Task PushDiceRawAsync(TcpSession session, uint methodId, byte[] bytes) { session.Log.Info($"[DICE] -> raw mid={methodId} {bytes.Length}b {System.Convert.ToHexString(bytes)}"); return session.NotifyAsync(methodId, bytes, CancellationToken.None); } }