944 lines
47 KiB
C#
944 lines
47 KiB
C#
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);
|
||
}
|
||
}
|