Files

944 lines
47 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 骰子(DiceGame)—— 酒吧「午夜回响」吧台的玩法,
/// C# 侧 <c>L50.Gameplay.DiceGame.DiceGameManager</c>,UI 侧
/// <c>Lua\LuaFiles\LX6\SGUI\StoreDefine\BarGameStartPanelStore.lua</c>。
///
/// <para>★ 根因链(2026-09-26 实机取证,逐条实锤)</para>
/// <list type="number">
/// <item>第一版:<c>AskEnterDiceZoneDoubleAI</c> 没注册 ⇒ 框架 typed-default 兜底返回 <b>0</b>
/// ⇒ 客户端 <c>BarGameStartPanelStore.lua:162</c>「AgentId为0,无法继续游戏」。已修(注册 handler)。</item>
/// <item>第二版(frida 实锤):返回<b>编造</b>的 uid 也不行。客户端
/// <c>lastReadyDiag = "等待 Agent 实体同步 agentId=35184381626017"</c>,必须返回
/// <b>世界里真实存在的实体 uid</b>。用 frida 把真实 NPC 的 uid 直写进
/// <c>diceOpponentAgentId</c> 后,客户端立刻跑出
/// <c>SetPlayerPrepareMotionFinish → TransitionTo → DecidePlayerActionOrder →
/// PlaceDices → GenDice×5</c>,状态从 <c>prepareState</c> 进到 <c>beforeShakeState</c>。</item>
/// </list>
/// 所以这一版:<b>服务端真的造一个 NPC 站到玩家旁边,把它的 uid 交回去</b>。
///
/// <para>★ 为什么不是「抄扇巴掌」</para>
/// 扇巴掌(<c>L18.Gameplay.SlapSceneNode</c>)的 NPC 对手是<b>场景里预设好的 GameObject</b>
/// (字段 <c>palmKingSlapSceneNpcList</c>),整个类只有 <c>SetCameraActive</c> / <c>.ctor</c>
/// 两个方法,纯本地、不经过服务端、也不是真实 Agent 实体 —— 没有可移植的服务端实现。
/// 但它印证了同一件事:这类 POI 小游戏的对手就是「场景里一个真实存在的 NPC」。
/// 我们这边没有现成的吧台 NPC 可复用,所以走服务端造 NPC 的通道(<c>SendCivilianAsync</c>),
/// 造出来的实体跟官方 static NPC 同段 uid,客户端一样认。
///
/// <para>★ 迭代方式(重要)</para>
/// 服务端→客户端那六条 Sync* 的载荷布局<b>没有</b>离线权威来源(MethodId 表 / RpcSurface /
/// 代理 lua 都是旧版,没有骰子)。字段顺序来自 IL2CPP dump,但「枚举线宽」「complex vs struct」
/// 两处只能靠实机收敛。为此加了 <c>/api/dice/raw</c>(见 AdminWebServer):
/// 可以直接把任意 methodId + hex 推给客户端,<b>不用重启服务端</b>就能反复试格式。
///
/// <para>⛑ 回滚:把 GameRouter.cs 的 Enabled4229938MethodIds 里这四行删掉即可。</para>
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>每条连接一份骰子对局状态,挂在 Session.Items 上。</summary>
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;
/// <summary>对局结束后等多久再撤对手(毫秒)—— 留给结算/收摊动画,撤太早客户端会闪。</summary>
private const int OpponentCleanupDelayMs = 2500;
// (下面三个是「相对玩家摆位」时期的老常量,随固定坐标一并停用,仅保留注释备查:
// OpponentDistanceMeters=2.1 / OpponentRightOffsetMeters=1.1 / OpponentFacingOffsetDegrees=40)
/// <summary>
/// 对手朝向在「正对玩家」的基础上再转多少度。
/// ⛑ 符号约定:Unity 绕 Y 轴 **yaw 增大 = 向右转**(forward 从 +Z 转向 +X,即自身右手边),
/// 所以这里 **正数 = 向左转**(公式里做减法)。40 = 对手朝自己的左手边转 40°。
/// 想让它往右转就填 -40。
/// </summary>
private const float OpponentFacingOffsetDegrees = 40f;
private sealed class DiceSession
{
public ulong GadgetUId;
public ulong OpponentUid;
/// <summary>SyncDiceGameStart 的四个可变参数。默认 gameAIId=100(与客户端一致)、3 回合、不设目标分、玩家先手。</summary>
public uint OpponentModelId = 100;
public int TotalRounds = 3;
public int TargetScore = 0; // 0 = 不设目标分(原版由 PoiGameDiceAIConfig.TargetScore 给,我们按回合数收尾)
public int FirstPlayer = 0; // 0 = 玩家先手
/// <summary>
/// 客户端下发的模式:<c>UX.Game.DiceGameType</c> — 1 = Simple(5 颗,比牌型)、0 = Classic(6 颗,比分数)。
/// 实机(2026-09-27 进局包)客户端恒发 <c>u8@12 = 1</c>,即 Simple。
/// </summary>
public int Mode = 1;
/// <summary>客户端下发的难度(PoiGameDiceAIConfig 100..103),目前只用于日志。</summary>
public int Level = 100;
/// <summary>本局骰子数:Simple 5 颗 / Classic 6 颗(客户端 <c>SimpleDiceNum=5 / DefaultDiceNum=6</c>)。</summary>
public int DiceCount => Mode == 1 ? 5 : 6;
/// <summary>Simple = 比牌型(一回合一个胜场),Classic = 比分数值(累加)。</summary>
public bool SimpleMode => Mode == 1;
public bool GameStartSent;
/// <summary>主机循环是否已启动(用 Interlocked 保证一次点开始只起一个)。</summary>
public int HostStarted;
public int Round = 1;
/// <summary>ScoreA = 玩家(seat 0,mainPlayer),ScoreB = 对手(seat 1)。</summary>
public int ScoreA;
public int ScoreB;
/// <summary>本回合内玩家/对手各掷出的分,用于判定「本回合赢家」。</summary>
public int RoundScoreA;
public int RoundScoreB;
public long StartedUtcTicks;
// ---- 玩家回合协作 -------------------------------------------------------------
/// <summary>玩家离开玩法区后置 true,主机循环会尽快退出。</summary>
public volatile bool LeaveRequested;
/// <summary>主机正在等的「玩家操作」信号。非 null 表示正在等玩家。</summary>
public TaskCompletionSource<bool>? PlayerSignal;
/// <summary>正在等玩家的这一步的名字(只用于日志)。</summary>
public string WaitingFor = string.Empty;
/// <summary>玩家最近一次出手上报的命令(0 = 还没有)。</summary>
public int LastPlayerCmd;
/// <summary>玩家最近一次出手上报的参数。</summary>
public int[]? LastPlayerParams;
/// <summary>玩家这一局是否真的出过手(用于「玩家是否在挂机」的自适应)。</summary>
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}");
}
});
}
/// <summary>
/// 服务端当主机,驱动整局骰子对战。
///
/// <para>★ 2026-09-26 实机实锤的命令序列(frida 逐个验证,看客户端状态机推进)</para>
/// <code>
/// SyncDiceCommand(gadgetUId, StartShake=1) -> DoTransitionToShakeState -> shakeState
/// SyncDiceCommand(gadgetUId, OpenBox=2) -> DoTransitionToAfterShakeState -> afterShakeState
/// SyncDiceCommand(gadgetUId, SkipToSettle=6) -> -> roundSettleState
/// 再发 StartShake -> 自动换手(roundPlayer 从 DicePlayer 变 RemotePlayer)
/// </code>
/// <para>
/// ★★ 最关键的一条:命令的 targetUid 是 <b>gadgetUId(玩法区实体)</b>,
/// 不是玩家 uid、也不是对手 uid。发给 uid=0 或对手 uid 时客户端只收包不推进状态,
/// 只有发 gadgetUId 才会真的推进状态机。这个坑排查了很久。
/// </para>
/// <para>
/// ⛑ 另外别信 <c>lastReadyDiag</c>:那是某一时刻的一次性快照,对局中不会再刷新。
/// 实测 main.isMotionInit 已经是 true、op.isSkillInit 手动置 true 之后,
/// 这条 diag 字符串一个字都没变。真正的实时状态看 nowState / roundPlayer。
/// </para>
/// </summary>
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;
}
}
/// <summary>
/// 组装 SyncDiceScoreInfo 里的 seat -> 分数表。
/// ⛑ seat 0 是玩家、seat 1 是对手。传的是「含本手的累计分」,
/// 因为这条分数字是在本手加分之前下发的。
/// </summary>
private static Dictionary<int, int> 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),
};
}
/// <summary>目前领先的一方:0 = 玩家(seat 0),1 = 对手(seat 1)。平局算玩家。</summary>
private static int LeaderOf(DiceSession st, bool isPlayerTurn, int turnScore)
{
var s = DiceScores(st, isPlayerTurn, turnScore);
return s[0] >= s[1] ? 0 : 1;
}
/// <summary>UX.Game.DicePhase.AfterShake —— ScoreInfo 是在开盅之后发的。</summary>
private const int DicePhaseAfterShake = 3;
/// <summary>掷 n 颗骰子(1..6)。</summary>
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;
}
/// <summary>
/// 一手得分。
/// <list type="bullet">
/// <item>Simple(mode=1):返回**牌型等级** <c>DiceHandRank</c>(0..8),本回合双方比大小。</item>
/// <item>Classic(mode=0):返回**原版计分** <c>DiceBasicScore</c>(点数分 × 组合倍数),累加进总分。</item>
/// </list>
/// <para>
/// ★ 2026-09-27 从 7k-v0.5 移植:那一版的 <c>DiceBasicScore</c> / <c>DiceHandRank</c> 与
/// <c>DiceGame_Rules.md</c>(frida 挂客户端读 <c>DiceHandTypeCalculator</c> 的实测值)逐项吻合,
/// 比原先的「五颗点数之和」占位规则更接近原版。客户端 <c>BaseDicePlayer</c> 的
/// nowRoundScore 等字段在服务端不下发时恒为 0 ⇒ 分数仍由我们当权威,改算法不影响链路。
/// </para>
/// </summary>
private static int ScoreOf(int[] dice, bool simpleMode)
=> simpleMode ? DiceHandRank(dice) : DiceBasicScore(dice);
/// <summary>
/// 原版「牌型」判定(<c>DiceHandTypeCalculator.GetHandType</c> / <c>ClassifyHand</c> 的等价实现)。
/// 0 高牌 / 1 一对 / 2 两对 / 3 三条 / 4 四连顺 / 5 葫芦 / 6 四条 / 7 五连顺 / 8 五条。
/// </summary>
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); // 两对 / 一对 / 高牌
}
/// <summary>
/// 原版一手得分 = Σ 点数分(literal)× 命中组合的倍数。
/// literal:<c>1→10, 2→2, 3→3, 4→4, 5→5, 6→8</c>(1 和 6 是特殊值)。
/// 组合倍数:对子 4 / 三条 10 / 四条 30 / 五条 80 / 六条 150;顺子 4 连 14 / 5 连 24 / 6 连 45。
/// ⛑ 简化:最低 ×1,不实现徽章改倍数与爆点惩罚(我们也不下发徽章)。
/// </summary>
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);
}
/// <summary>
/// 等玩家操作;超时则记一条日志并返回 false(自动代打)。
/// 玩家操作的信号是客户端上报 <c>AskDicePlayerAction</c>,在它的 handler 里 SetResult。
/// </summary>
private static async Task<bool> WaitPlayerOrTimeout(
TcpSession session, DiceSession st, int timeoutMs, string stepName)
{
var tcs = new TaskCompletionSource<bool>(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;
}
/// <summary>
/// 下发包一层:Sync* 里除了 SyncDiceCommand 之外都没实机验证过载荷,
/// 万一客户端解析报错也不该把整个对局主机带崩。
/// </summary>
private static async Task SafePush(TcpSession session, Task push)
{
try { await push; }
catch (Exception ex) { session.Log.Warn($"[DICE] 下发失败(已忽略):{ex.Message}"); }
}
/// <summary>UX.Game.DiceRemoteCommand 的线上取值(1 字节)。</summary>
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;
}
/// <summary>
/// 在玩家身前造一个市民 NPC,返回它的真实 uid。
/// 走的是 <c>/api/civilians/spawn-group</c> 那条已验证可见的通道(SendCivilianAsync),
/// uid 与官方 static NPC 同段(StaticNpcIdFloor),客户端能找到这个实体。
/// </summary>
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);
}
}
/// <summary>
/// 把这一局造出来的对手 NPC 撤掉(2026-09-27 新增,用户要求「游戏结束后清除」)。
///
/// <para>★ 两条通道都发:static NPC 没有专属的 <c>*StaticNpcRemove</c>
/// (MethodId 表里只有 AddData / ConvertToPe / VehicleRemove),
/// 而 <c>SyncAetherAINpcRemove</c>(68332284) 管的是 ambient peds / functional NPC 那本账,
/// 我们对不对得上无法离线判定 ⇒ 两条都发,哪条生效算哪条(多发一条无效包客户端是忽略的)。</para>
///
/// <para>⛑⛑ 服务端账本必须一起清:<c>StaticNpcPoses</c> / <c>StaticNpcPreparedPlotEvents</c>
/// 留着的话,玩家走远再回来触发重扫(每 120s 全量重扫)会把它<b>再摆回来</b>。</para>
///
/// <para>⛑ <paramref name="expectedUid"/>:传了就只撤这一个 uid(对局主机用,防止旧局收尾
/// 误删新局 NPC);不传就撤「当前对手」(玩家主动离开时用)。
/// 只有「要撤的正好还是当前对手」时才清 <c>st.OpponentUid</c> 指针。</para>
/// </summary>
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})");
}
/// <summary>挑一个市民模板;优先「非系统行为」组里第一个可用的。</summary>
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;
}
/// <summary>把角度收进 (-180, 180]。</summary>
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;
/// <summary>
/// 造 NPC 失败时的兜底 uid。刻意落在 ServerSpawnUidFloor 之上再偏 0x900000,
/// 避免与玩家/敌人账本串味。客户端会卡在「等待 Agent 实体同步」,但至少不是 0。
/// </summary>
private static ulong FallbackUid()
=> GameAdminBridge.ServerSpawnUidFloor + 0x900000UL
+ (ulong)Interlocked.Increment(ref s_diceFallbackOrdinal);
// ---------------------------------------------------------------------------------------
// 其余三条:都是 void 返回,空包 + err=0 就是客户端要的 OK 回执。
// ---------------------------------------------------------------------------------------
/// <summary>
/// AskDicePlayerAction (67747541) —— 玩家操作上报。单独拆出来是为了看清
/// 「按空格」时客户端到底有没有上报、上报的命令是什么:
/// 线上布局与 SyncDiceCommand 同源,u64 + 1 字节命令 + FF + Int7(n) + n×Int32 + u32。
/// </summary>
[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);
}
/// <summary>
/// 解析 AskDicePlayerAction 的载荷。
/// 线上布局(与 SyncDiceCommand 同源,2026-09-26 实证):
/// <c>u64 who | 1字节 cmd | FF | Int7(n) | n×Int32 | u32 badge</c>。
/// 实测空参数时是 <c>00*8 05 FF 01 00000000</c> = 15 字节。
/// </summary>
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<int>(), 0);
i++;
var n = body[i] - 1;
i++;
if (n < 0) n = 0;
var ps = new List<int>(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);
}
/// <summary>AskLeaveDiceZone —— 玩家退出玩法区,通知主机尽快收摊,并顺手把对手撤掉。</summary>
[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/* 用的公共入口,方便不重启地试格式)
// ---------------------------------------------------------------------------------------
/// <summary>SyncDiceGameStart (68696343)。OnSyncDiceGameStart(u64,u32,i32,i32,i32) = 24 字节平铺。</summary>
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);
}
/// <summary>
/// SyncDiceCommand (68028865) —— 唯一**实机验证过**的下发通道。
/// 线上布局:u64 + <b>1 字节</b>命令 + FF + Int7(数量) + 数量×Int32 + u32。
/// ★ 2026-09-26 21:24 实证:命令按 1 字节写时客户端 OnSyncDiceCommand →
/// ReceiveRemoteCommand 都会触发;按 4 字节写则完全不触发。
/// </summary>
internal static Task PushDiceCommandAsync(
TcpSession session, ulong targetUid, int command, IReadOnlyList<int>? 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<int>();
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);
}
/// <summary>SyncDiceRoundActions (68060649)。OnSyncDiceRoundActions(u64, DiceRoundActions)。</summary>
internal static Task PushDiceRoundActionsAsync(TcpSession session, ulong targetUid, int targetPlayer,
IEnumerable<(int Command, int[] IntParams, uint BadgeId)>? actions = null)
{
var list = new List<DiceAction>();
foreach (var a in actions ?? Array.Empty<(int, int[], uint)>())
list.Add(new DiceAction
{
Command = (byte)a.Command,
IntParams = new List<int>(a.IntParams ?? Array.Empty<int>()),
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);
}
/// <summary>SyncDiceRoundSettle (68568673)。OnSyncDiceRoundSettle(u64, DiceRoundSettleInfo)。</summary>
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);
}
/// <summary>SyncDiceScoreInfo (68500287)。OnSyncDiceScoreInfo(u64, DiceScoreInfo)。</summary>
internal static Task PushDiceScoreInfoAsync(TcpSession session, ulong targetUid, int currentRound,
int currentTurn, int turnScore, int winner, int[]? dice, bool isBust, int phase,
IReadOnlyDictionary<int, int>? scores = null)
{
var info = new DiceScoreInfo
{
CurrentRound = currentRound,
CurrentTurn = currentTurn,
// ⛑ 空字典很可能是客户端不显示分数的原因之一 —— 这里带上真实的 seat->分数。
ParticipantScoreDic = scores is null
? new Dictionary<int, int>()
: new Dictionary<int, int>(scores),
CurrentTurnScore = turnScore,
Winner = winner,
CurrentDiceValues = new List<int>(dice ?? Array.Empty<int>()),
SettledDiceValues = new List<int>(),
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);
}
/// <summary>SyncDiceGameEnd (68018652)。OnSyncDiceGameEnd(u64, DiceGameEndInfo)。</summary>
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<uint>(drops ?? Array.Empty<uint>()),
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);
}
/// <summary>把任意字节推给客户端(/api/dice/raw 用),用于不重启地试载荷格式。</summary>
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);
}
}