using System.Buffers.Binary; using System.Collections.Generic; // List using System.Threading; // CancellationToken(与五子棋同款留白) using Ananta.Handlers.Game; // ★ R183:ChineseChessBoard / ChessMove(象棋规则引擎) using Ananta.SDK.Logging; using Ananta.SDK.Network; // TcpSession using Ananta.SDK.Rpc; using Ananta.Server.ClientData.Client4229938; // DonorNpcCatalog4229938 / PropRestockCatalog4229938 using Ananta.Server.Configuration; // IdCounterStore using Ananta.Server.Protocol.Client4229938; // WorldCodec using Ananta.Server.RpcTypes.Client4229938; // ChineseChessZoneInfo / ChineseChessWire namespace Ananta.Server.Handlers.Game; /// /// ★★★ R182(2026-09-29)—— 象棋「能交互、开局后黑屏」的修复 ★★★ /// /// ── 病灶(R181 定案,这一轮修)──────────────────────────────────────────── /// 黑屏由 ChineseChessMgr.EnterGame_Async 一进来就开,只在 /// OnEnterZoneSuccess 那条协程跑到**最后一行** CloseTransition 时才关。 /// 那条协程的第一个门是: /// /// while true do /// if self.Player1 and self.Player2 and self.Player1.IsModelLoaded and self.Player2.IsModelLoaded then break end /// coroutine.yield(nil) /// end /// /// 而 Player2(对手)的加载卡在 ChineseChessPlayer.lua:224: /// /// if not gChineseChessMgr.OpponentAgentInstanceId or not ulong.Greater(..., 0) then /// coroutine.yield(gChineseChessMgr.WaitOpponentAgentInstanceIdToken) -- ★ 无超时 /// end /// /// ⇒ 服务端必须做两件事,缺一不可: /// ① **spawn 一个世界里真实存在的对手单位**(客户端 CheckAiAgent 会 /// gCS.SceneDataMgr.TryGetOrConvertToUnit(instanceId, 256) 去查它); /// ② 推 SyncGameGroundZoneInfo,且 ParticipantInfos 里那一条的 /// AgentInstanceId = ①的那个 uid —— 这是 /// gChineseChessMgr.OpponentAgentInstanceId 的**唯一**来源。 /// /// ── 为什么不能靠 SyncGameGroundZonePlayerInfo(那条推送)──────────────────── /// GameSceneToClientImpl.lua:1162 对它加了闸: /// /// if participantInfo.Pid == gPlayerManager.infoLogin.bindData.pid then /// ... gChineseChessMgr:OnSyncGameGroundZonePlayerInfo(...) /// end /// /// 对手的 Pid 不是我的 pid ⇒ **那条推送永远送不到象棋管理器**。 /// 唯一能遍历全部参与方的是 OnSyncGameGroundZoneInfo(它直接 ipairs(zoneInfo.ParticipantInfos))。 /// /// ── 与五子棋(R153/R155/R156/R157)的异同 ──────────────────────────────── /// · 同:都靠「注册 logic-agent uid + 用客户端给的 agentId 走 Aether 静态 NPC 通道把身份送过去」, /// 座位都取自官方摆位,且**一个会话一张桌只 spawn 一次**(R157 的复用法)。 /// · 异:五子棋的入口返回**裸 u64**(客户端 TryGetLogicAgent 要用),象棋三个入口 /// midToReturnMessageReader 全是 function(reader) end ⇒ **void**,空 ok 即可。 /// ★ 也正因为回执本来就没错(unknown-methods.log 里那两个包是 typed-default 但 err=0, /// 客户端 .Callback 只判 err)—— 所以本轮的修法**只加推送,不改回执形状**。 /// · 异:象棋的参与方是**多态列表元素**(tag 7,字段比 base 多一个 IsRed); /// 五子棋那次 ParticipantInfos 一直是空的 ⇒ 那条路从没被走过。见 ChineseChessWireContracts.cs。 /// /// ── 本轮的边界(说清楚,别把它当「象棋能玩了」)────────────────────────── /// 本轮只打通到 **「棋盘可见、黑屏关掉」**。走子还需要第二批: /// 收 AskChineseChessMovePiece → **服务端算应手** → 推 SyncChineseChessMove + /// SyncGameGroundZoneTurnChange。已确认 **AI 不在客户端** /// (ChineseChessSearchChart.lua 在本仓 2349 个 Lua 里只被 require、零调用点)。 /// /// ⛑ 回滚:把 GameRouter.cs 的 Enabled4229938MethodIds 里那 4 行象棋 mid 删掉即可 /// (handler 不注册 = 走 typed-default 兜底),不用删本文件。 /// internal sealed partial class GameRouter { /// 象棋棋盘的 pathId(PropRestock.json 里的 Gadget_XiangQi,全库唯一)。 private const int ChineseChessBoardPathId = 495093791; /// 会话级记忆键:本会话这张桌的对手 uid(R157 的「只建一次」)。 private const string ChineseChessAiUidKey = "ChineseChess.AiUid"; /// 记录那个 uid 是给**哪张桌**的(凌云/龙栖各有多张棋盘)。 private const string ChineseChessAiGadgetKey = "ChineseChess.AiGadgetUid"; /// zone 会话序号(进程内自增,只用来拼一个非空的 ZoneSessionId)。 private const string ChineseChessZoneSeqCounter = "ChineseChess.ZoneSeq"; /// /// 对手站位:沿棋盘 forward 的距离(米)。实测玩家站在棋盘 −forward×1.9 处 /// (棋盘 (-4563.78,188.85,-2628.81) yaw 127.3,玩家 (-4565.29,188.86,-2627.80)), /// 所以对手放 +forward×1.5 = 棋盘另一侧。 /// ⚠ 站位只是为了「这个 NPC 不在棋盘里/不和玩家重叠」——客户端拿到单位后会把模型 /// modelTransform:SetParent(seatGo) 挂到自己的座位上(ChineseChessPlayer.lua:288)。 /// private const float ChineseChessAiSeatOffset = 1.5f; // ───────────────────────────────────────────────────────────────────── // ① 标准对局(人机)—— 本轮实测命中的就是这一条 // ───────────────────────────────────────────────────────────────────── /// /// AskChineseChessEnterZoneDoubleAI (67307903) /// C2S = u64 gadgetuid + u32 agentid + bool useredpiece + byte difficulty;RET = **void**。 /// /// 契约第一现场:ClientToGameSceneDelegate.lua:1790 AskChineseChessEnterZoneDoubleAI_Serializer /// WritePrimitive(gadgetuid, WriteUInt64) / (agentid, WriteUInt32) / /// (useredpiece, WriteBoolean) / (CheckEnum(difficulty,53,0), WriteByte) /// 实测 body=14b:25 00 48 B1 7E CB 92 74 | BB 5B 64 02 | 01 | 00 /// ⇒ gadgetUid 8400000000000000037(= R179 补摆进凌云的那张棋盘,已核对到 u64) /// agentId 40131515(NpcCatalog「Xian Yougen」)/ useRedPiece true / difficulty 0。 /// /// 返回读取器 midToReturnMessageReader[67307903] = function(reader) end ⇒ 空体 = void。 /// [Handler(MethodId.AskChineseChessEnterZoneDoubleAI)] private async Task AskChineseChessEnterZoneDoubleAIAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; ulong gadgetUid = 0; uint agentId = 0; bool useRedPiece = false; byte difficulty = 0; if (body is { Length: >= 14 }) { gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); agentId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); useRedPiece = body[12] != 0; difficulty = body[13]; } else { conn.Log.Warn($"[CHESS] AskChineseChessEnterZoneDoubleAI short body {body?.Length ?? 0}b -> ok"); } // ① 先把对手单位建出来(回执之前)—— 客户端拿到回执就会去 TryGetOrConvertToUnit。 var (aiUid, note) = await SpawnChineseChessAiAsync( conn, gadgetUid, agentId, playerUsesRed: useRedPiece, tag: "AskChineseChessEnterZoneDoubleAI", difficulty: difficulty); // ② void 回执。★ 顺序不能反:客户端要先过 enterToken 才进 OnEnterZoneSuccess/CreatePlayers。 await conn.ReturnEmptyOkAsync(msg); // ③ 唯一能把 OpponentAgentInstanceId 送进客户端的推送。 await PushChineseChessZoneInfoAsync(conn, gadgetUid, ChineseChessWire.GameTypeDoubleAI, aiUid, playerUsesRed: useRedPiece, endGameId: 0); // ④ ★ R183:新开一局 ⇒ 重置服务端盘面(重进同一个 zone 必须从零开始, // 否则会拿上一局的残盘校验玩家的第一手 → 全部被拒)。 ResetChineseChessGame(conn, gadgetUid, playerUsesRed: useRedPiece, difficulty: difficulty, aiUid: aiUid); // ⑤ ★ R183:玩家执黑 ⇒ 红先手是 AI 的,服务端必须替它开局, // 否则客户端 `IsMyAction`(我的颜色 == Chart.IsRedPlayChess)永远 false ⇒ 「能选不能走」。 var opening = await PushChineseChessAiOpeningIfNeededAsync(conn, gadgetUid, useRedPiece); conn.Log.Info($"[CHESS] AskChineseChessEnterZoneDoubleAI gadgetUId={gadgetUid} " + $"agentId={agentId} useRedPiece={useRedPiece} difficulty={difficulty} " + $"-> aiUid={aiUid} {note}(已推 zone info,等客户端出棋盘){opening}"); } // ───────────────────────────────────────────────────────────────────── // ② 残局挑战 —— 同族同形,只是多一个 endGameId // ───────────────────────────────────────────────────────────────────── /// /// AskChineseChessEnterZoneEndGame (67616104) /// C2S = u64 gadgetuid + u32 agentid + u32 endgameid;RET = **void** /// (midToReturnMessageReader[67616104] = function(reader) end)。 /// /// 客户端调用点 ChineseChessMgr.lua:195:self.IsRed = endGameCfg.IsRedFirst /// —— 残局里执红与否由 PoiGameChineseChessEndGameConfig 决定(20 条,已确认存在), /// 所以「玩家执红」这件事只能靠请求里的 agentid 之外的信息;本实现按**请求缺省 = 玩家执红** /// 处理(与五子棋残局同策略),并把 AI 放黑位。 /// [Handler(MethodId.AskChineseChessEnterZoneEndGame)] private async Task AskChineseChessEnterZoneEndGameAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; ulong gadgetUid = 0; uint agentId = 0; uint endGameId = 0; if (body is { Length: >= 16 }) { gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); agentId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); endGameId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12, 4)); } else { conn.Log.Warn($"[CHESS] AskChineseChessEnterZoneEndGame short body {body?.Length ?? 0}b -> ok"); } var (aiUid, note) = await SpawnChineseChessAiAsync( conn, gadgetUid, agentId, playerUsesRed: true, tag: "AskChineseChessEnterZoneEndGame", difficulty: 0); await conn.ReturnEmptyOkAsync(msg); await PushChineseChessZoneInfoAsync(conn, gadgetUid, ChineseChessWire.GameTypeEndGame, aiUid, playerUsesRed: true, endGameId: endGameId); // ★ R183:残局本实现固定「玩家执红」(AI 执黑),所以没有 AI 开局那一步。 // ⚠ 真正照 `PoiGameChineseChessEndGameConfig[EndGameId].RedPos/BlackPos` 摆盘还没做 // (本版服务端仍按**标准开局**校验)—— 留到下一批,见 R183 报告「未做」一节。 ResetChineseChessGame(conn, gadgetUid, playerUsesRed: true, difficulty: 0, aiUid: aiUid); conn.Log.Info($"[CHESS] AskChineseChessEnterZoneEndGame gadgetUId={gadgetUid} " + $"agentId={agentId} endGameId={endGameId} -> aiUid={aiUid} {note}"); } // ───────────────────────────────────────────────────────────────────── // ③ 离开 —— 只回执;**故意不清理对手**(同五子棋 R157 的理由) // ───────────────────────────────────────────────────────────────────── /// /// AskChineseChessLeaveZone (67667163) —— C2S = u64 gadgetuid;RET = **void**。 /// /// ★ 与五子棋 LeaveGomoku 完全同源:本版客户端**没有可靠的静态 NPC 删除通道** /// (SyncAetherAIStaticNpcRemove* 在 MethodId 里不存在;SyncRemoveNpc 装不下 /// 830… 段的 u64;SyncRemoveManagedAgent 全 Lua 0 命中)。既然删不掉,就**只建一次**: /// 清缓存只会让下次进来 Next() 出第二个 uid,旧的那个照样留在场上。 /// [Handler(MethodId.AskChineseChessLeaveZone)] private Task AskChineseChessLeaveZoneAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; var gadgetUid = body is { Length: >= 8 } ? BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)) : 0UL; var kept = conn.Session.Items.TryGetValue(ChineseChessAiUidKey, out var o) && o is ulong u && u != 0 ? u.ToString() : "(无)"; conn.Log.Info($"[CHESS] AskChineseChessLeaveZone gadgetUId={gadgetUid} -> ok" + $"(AI 对手 uid={kept} 保留,下次进入复用 —— R157 同策)"); return conn.ReturnEmptyOkAsync(msg); } // ───────────────────────────────────────────────────────────────────── // ④ 玩家棋力/残局进度查询 —— 返回真类型,别让开始面板的字典悬空 // ───────────────────────────────────────────────────────────────────── /// /// QueryChineseChessPlayerInfo (67492072) —— C2S = 空;RET = complex /// (midToReturnMessageReader[67492072] = Base.ReadComplex(reader, Auto.Reader[681]))。 /// /// 消费者 ChineseChessStartPanelStore.lua:61: /// QueryChineseChessPlayerInfo().Callback = function (err, info) /// if err == Ok and info then /// self.passedEndGameIds = info.DroppedEndGameIdSet /// if self.selectedMode ~= Late then self:InitEndGameLevelSelector() end /// end /// end /// 走 typed-default 时字典字段是 nil ⇒「已通关残局」判定悬空。这里补一个**真空表**。 /// ⚠ 两个字典是 Base.WriteDict(Int32 计数),不是 7bit —— 见 ChineseChessWireContracts.cs。 /// [Handler(MethodId.QueryChineseChessPlayerInfo)] private async Task QueryChineseChessPlayerInfoAccept(Connection conn, UxRpcMessage msg) { var info = new ChineseChessPlayerInfo { DroppedEndGameIdSet = new Dictionary(), DroppedDoubleAIDifficultySet = new Dictionary(), DoubleAINormalWinCount = 0, DoubleAINormalTotalCount = 0, }; await conn.ReturnAsync(msg, info); conn.Log.Info("[CHESS] QueryChineseChessPlayerInfo -> 空进度(残局 0/20 已通关," + "AI 难度奖励 0/0)"); } // ───────────────────────────────────────────────────────────────────── // ★★★ R183 —— ⑤ 落子:这一切的核心 // // 病灶(实测,`logs/console-20260929-101956.log:1194+`): // -> rpc AskChineseChessMovePiece [0x040568ED] #128430095 24b // ! [RPC4229938] unimplemented AskChineseChessMovePiece -> typed-default void 0b // <- ret AskChineseChessMovePiece [0x040568ED] #128430095 e=0 0b // `e=0` 是**假象**(unimplemented 走 typed-default 也回 0),真正要看的是 `! unimplemented`。 // // 为什么「必须服务端来动子」——三条独立事实: // ① `ChineseChessMgr.MoveChess`(:979) 本地**不乐观应用**:只跑 `IsMyAction` / // `CheckMoveCheck` / `IsBlockedMove` 三个校验就发包,然后把 `IsWaitingServerMove = true`。 // ② 真正落子的 `ChineseChessBoardBase.MoveChessTo`(:94) 最后一行只调 // `self.Chart:MoveChess(chessId, point)`,**不翻回合**。 // ③ 回合翻转的唯一来源是 `SyncGameGroundZoneTurnChange`(见 ChineseChessWireContracts.cs)。 // ⇒ 「能选中、点目标格没反应」= 服务端一个字都没回。要让棋子动起来,服务端必须: // 玩家那一手 → `SyncChineseChessMove` 回显 → 推回合给对手 // AI 应手 → `SyncChineseChessMove` → 推回合还给玩家 // 两条推送**缺一不可**:只推 move 不动回合(`Chart.IsRedPlayChess` 不变 ⇒ // `IsMyAction` 一直 true ⇒ 玩家能连走),只推回合不动子则棋盘永远停在开局。 // // 为什么 AI 在服务端:`ChineseChessSearchChart.lua` 在本仓 2349 个 Lua 里**零调用点** // (R182 已查),客户端没有可用的搜索器 ⇒ 应手只能服务端算。 // 规则引擎见 `Ananta.Server/Ananta.Handlers/Game/ChineseChessBoard.cs` // (独立实现 + `gym_tools/_chess_rules_check` 离线断言,开局红/黑各 44 着)。 // // ⛑ 回滚:把 GameRouter.cs 白名单里 `AskChineseChessMovePiece` / `AskChineseChessSurrender` // 两行删掉即可(handler 不注册 = 走 typed-default 兜底),不用删本文件。 // ───────────────────────────────────────────────────────────────────── /// 一局象棋的服务端状态(一个 gadgetUid 一份,停在 Session.Items 里)。 private const string ChineseChessGameKey = "ChineseChess.Game"; /// 玩家执红?(进入时从请求的 useRedPiece 记下来,供懒建盘面时回填)。 private const string ChineseChessPlayerRedKey = "ChineseChess.PlayerUsesRed"; /// UX.Game.ChineseChessAIDifficulty(0..4),客户端在进入请求里给的。 private const string ChineseChessDifficultyKey = "ChineseChess.Difficulty"; /// /// 一局棋的全部可变状态。★ 所有读写都进 —— 同一条连接上的 /// 多个 handler 是异步交错的(`await` 出点),而盘面是纯可变结构。 /// private sealed class ChineseChessGameSession { internal readonly object SyncRoot = new(); /// 规则引擎(9×10 盘面 + 走法生成 + 将军/绝杀 + AI)。 internal readonly ChineseChessBoard Board = new(); internal ulong GadgetUid; internal ulong AiUid; /// 玩家执红?(AI = 另一方) internal bool PlayerUsesRed = true; /// 客户端在进入请求里给的 AI 难度(0 = Beginner 实测值)。 internal byte Difficulty; /// 下一步棋的序号(写进 ChineseChessMove.MoveIndex)。 internal uint NextMoveIndex = 1; /// 已分胜负?是则后续落子一律拒绝。 internal bool Over; internal bool AiIsRed => !PlayerUsesRed; } /// /// 「这一步算完之后要往客户端推什么」的清单 —— 锁内产出、锁外消费。 /// ★ 这么拆是因为推送是 await,而 `await` **不能**放在锁里(会把整条连接的其它 handler 一起堵住)。 /// private sealed class ChineseChessMovePlan { /// 非空 = 这一步不合法,只回 ok 不推任何东西(客户端可重选)。 internal string? Reject; internal ChineseChessMove? PlayerMove; internal GameGroundZoneTurnChangePush? TurnAfterPlayer; internal ChineseChessMove? AiMove; internal GameGroundZoneTurnChangePush? TurnAfterAi; /// null = 未分胜负;0 = 红胜 / 1 = 黑胜(颜色,见 ChineseChessWireContracts)。 internal int? Winner; internal byte OverReason = ChineseChessWire.GameOverNormal; } /// /// AskChineseChessMovePiece (67463405) /// C2S = u64 gadgetuid + u32 fromX + u32 fromY + u32 toX + u32 toY = **24 B**;RET = void /// (midToReturnMessageReader[67463405] 是空体)。 /// /// 逐字节对照实测报文(`logs/packets-20260929-101956.log:19087+`): /// 25 00 48 B1 7E CB 92 74 | 0A 00 00 00 | 05 00 00 00 | 07 00 00 00 | 05 00 00 00 /// ⇒ gadgetUid 8400000000000000037 / from (10,5) / to (7,5) /// (红炮在 (10,5) —— 与 `ChineseChessChart.InitChartBoard` 的 `GetPointByPosition(10,5)` 对上, /// 这条一致性就是「坐标 = (x,y)」这个约定的锚点)。 /// 签名权威 ClientToGameSceneDelegate.lua:2608 AskChineseChessMovePiece_Serializer; /// 调用点 ChineseChessMgr.lua:1016。 /// /// ★ 回执策略:**任何**路径都回 Ok(连非法着法也回)—— /// 客户端的 .Callback 对 Ok 是「什么都不做」(真值形态 if err ~= Ok then 显示错误), /// 而**不回执**会让 IsWaitingServerMove 永远停在 true ⇒ 此后一发不可收拾。 /// 非法着法的表现 = 「推回一个 move」这件事没发生 ⇒ 棋盘不动、可以重选,正是我们想要的。 /// [Handler(MethodId.AskChineseChessMovePiece)] private async Task AskChineseChessMovePieceAccept(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is not { Length: >= 24 }) { conn.Log.Warn($"[CHESS] AskChineseChessMovePiece short body {body?.Length ?? 0}b -> ok"); await conn.ReturnEmptyOkAsync(msg); return; } var gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); var fromX = (int)BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); var fromY = (int)BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12, 4)); var toX = (int)BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16, 4)); var toY = (int)BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20, 4)); var game = GetOrCreateChineseChessGame(conn, gadgetUid); // 先回执:越早清掉 IsWaitingServerMove 越好(推不推子由后面的锁内判定决定)。 await conn.ReturnEmptyOkAsync(msg); var plan = new ChineseChessMovePlan(); lock (game.SyncRoot) BuildChineseChessMovePlan(game, fromX, fromY, toX, toY, plan); if (plan.Reject is not null) { conn.Log.Warn($"[CHESS] AskChineseChessMovePiece gadgetUId={gadgetUid} " + $"({fromX},{fromY})->({toX},{toY}) **拒绝**:{plan.Reject}(已回 ok,客户端可重选)"); return; } if (plan.PlayerMove is { } pm) { conn.Log.Info($"[CHESS] AskChineseChessMovePiece gadgetUId={gadgetUid} " + $"玩家[{(game.PlayerUsesRed ? "红" : "黑")}] #{pm.MoveIndex} " + $"({fromX},{fromY})->({toX},{toY}) 吃={pm.CapturedChessId} 将={pm.IsCheck}" + $"{game.Board.Render()}"); await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncChineseChessMove, new ChineseChessMovePush { UId = gadgetUid, Move = pm }); } if (plan.TurnAfterPlayer is { } tp) await conn.NotifyAsync(MethodId.SyncGameGroundZoneTurnChange, tp); if (plan.AiMove is { } am) { conn.Log.Info($"[CHESS] AI 应手[{(game.AiIsRed ? "红" : "黑")}] #{am.MoveIndex} " + $"({am.FromX},{am.FromY})->({am.ToX},{am.ToY}) 吃={am.CapturedChessId} " + $"将={am.IsCheck}"); await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncChineseChessMove, new ChineseChessMovePush { UId = gadgetUid, Move = am }); } if (plan.TurnAfterAi is { } ta) await conn.NotifyAsync(MethodId.SyncGameGroundZoneTurnChange, ta); if (plan.Winner is { } winner) { conn.Log.Info($"[CHESS] 本局结束 winner={winner}(0 红 / 1 黑)reason={plan.OverReason} " + $"rounds={game.Board.MoveCount}"); await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncChineseChessGameOver, new ChineseChessGameOverPush { UId = gadgetUid, Winner = winner, Reason = plan.OverReason }); } } /// /// AskChineseChessSurrender (67452562) —— C2S = u64 gadgetuid;RET = **void** /// (midToReturnMessageReader[67452562] 空体)。 /// /// ★ R183 顺手补的第三条(可单独回滚):不做它 ⇒ 玩家点「认输」什么都没发生。 /// 客户端 GiveUp 先置 _isGiveUp = true 再发包;收尾在服务端推的 /// SyncChineseChessGameOver 里(TryFinalizeGame 读到 _isGiveUp 后才走 /// 「黑屏退出」那一支)。 /// ★ 认输 = **对手胜** ⇒ winner 取对手的颜色(0 红 / 1 黑)。 /// [Handler(MethodId.AskChineseChessSurrender)] private async Task AskChineseChessSurrenderAccept(Connection conn, UxRpcMessage msg) { var gadgetUid = msg.Body is { Length: >= 8 } ? BinaryPrimitives.ReadUInt64LittleEndian(msg.Body.AsSpan(0, 8)) : 0UL; var game = GetOrCreateChineseChessGame(conn, gadgetUid); int winner; lock (game.SyncRoot) { game.Over = true; // 认输 = 对手胜 ⇒ winner 取 **AI 的颜色**(0 红 / 1 黑)。 winner = ChineseChessWire.WinnerOf(game.AiIsRed); } await conn.ReturnEmptyOkAsync(msg); await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncChineseChessGameOver, new ChineseChessGameOverPush { UId = gadgetUid, Winner = winner, Reason = ChineseChessWire.GameOverSurrender, }); conn.Log.Info($"[CHESS] AskChineseChessSurrender gadgetUId={gadgetUid} -> winner={winner} " + $"(玩家[{(game.PlayerUsesRed ? "红" : "黑")}]认输 ⇒ 对手胜)"); } // ───────────────────────────────────────────────────────────────────── // 盘面状态:建 / 重置 / 取 // ───────────────────────────────────────────────────────────────────── /// 新开一局 ⇒ 把盘面重置成标准开局,并记下这一局的参数。 private static void ResetChineseChessGame( Connection conn, ulong gadgetUid, bool playerUsesRed, byte difficulty, ulong aiUid) { conn.Session.Items[ChineseChessPlayerRedKey] = playerUsesRed; conn.Session.Items[ChineseChessDifficultyKey] = difficulty; var game = new ChineseChessGameSession { GadgetUid = gadgetUid, AiUid = aiUid, PlayerUsesRed = playerUsesRed, Difficulty = difficulty, }; conn.Session.Items[ChineseChessGameKey] = game; conn.Log.Info($"[CHESS] 新开一局:玩家执{(playerUsesRed ? "红" : "黑")}(AI 执" + $"{(playerUsesRed ? "黑" : "红")})难度={difficulty} gadgetUId={gadgetUid} " + $"aiUid={aiUid} 盘面=标准开局"); } /// /// 取这一局的盘面;没有就**懒建**一个。 /// 懒建存在的理由:玩家可能不经我们记录的入口就发出落子包(客户端重启、快速重进), /// 这时服务端仍要有盘面才算得出应手 —— 拿标准开局 + 会话里缓存的颜色/难度是最合理的近似。 /// private static ChineseChessGameSession GetOrCreateChineseChessGame(Connection conn, ulong gadgetUid) { if (conn.Session.Items.TryGetValue(ChineseChessGameKey, out var raw) && raw is ChineseChessGameSession existing && existing.GadgetUid == gadgetUid) { return existing; } var playerRed = true; if (conn.Session.Items.TryGetValue(ChineseChessPlayerRedKey, out var pr) && pr is bool b) playerRed = b; var difficulty = (byte)0; if (conn.Session.Items.TryGetValue(ChineseChessDifficultyKey, out var d) && d is byte db) difficulty = db; var aiUid = conn.Session.Items.TryGetValue(ChineseChessAiUidKey, out var ao) && ao is ulong au ? au : 0UL; var created = new ChineseChessGameSession { GadgetUid = gadgetUid, AiUid = aiUid, PlayerUsesRed = playerRed, Difficulty = difficulty, }; conn.Session.Items[ChineseChessGameKey] = created; conn.Log.Warn($"[CHESS] 落子时才发现本会话没有这局的盘面 ⇒ 懒建一个(gadgetUId={gadgetUid} " + $"玩家执{(playerRed ? "红" : "黑")} 难度={difficulty} aiUid={aiUid} 标准开局)"); return created; } /// /// ★ 锁内:校验玩家的着法、落子、算 AI 应手、判是否终局。**只算不推**(推送在锁外)。 /// /// 拒绝清单(任一命中就只回 ok、什么都不推)—— 每一项都能在日志里看到确切原因, /// 这就是「服务端不认这一步」与「服务端根本没收到这一步」的分界线。 /// private static void BuildChineseChessMovePlan( ChineseChessGameSession game, int fromX, int fromY, int toX, int toY, ChineseChessMovePlan plan) { var board = game.Board; var playerRed = game.PlayerUsesRed; var aiRed = game.AiIsRed; if (game.Over) { plan.Reject = "本局已结束"; return; } var id = board.At(fromX, fromY); if (id == ChineseChessBoard.Empty) { plan.Reject = $"起点 ({fromX},{fromY}) 无子"; return; } if (!ChineseChessBoard.InBoard(toX, toY)) { plan.Reject = $"目标 ({toX},{toY}) 不在盘内(x∈[3,11] y∈[3,12])"; return; } if (ChineseChessBoard.IsRedId(id) != playerRed) { plan.Reject = $"({fromX},{fromY}) 上是{(ChineseChessBoard.IsRedId(id) ? "红" : "黑")}子," + $"玩家执{(playerRed ? "红" : "黑")}"; return; } if (board.RedToMove != playerRed) { plan.Reject = "还没轮到玩家走(服务端镜像 = " + (board.RedToMove ? "红" : "黑") + ")"; return; } if (!board.PseudoTargets(id).Contains((toX, toY))) { plan.Reject = $"#{id} 不成走法 ({fromX},{fromY})->({toX},{toY})"; return; } if (board.LeavesOwnKingInCheck(id, toX, toY)) { plan.Reject = $"#{id} 走完自将(送将)"; return; } // ── ① 玩家这一手 ─────────────────────────────────────────────────── var captured = board.ApplyMove(id, toX, toY); board.RedToMove = aiRed; plan.PlayerMove = new ChineseChessMove { MoveIndex = game.NextMoveIndex++, // ★ 客户端这条路径**不读** PlayerPid(`ON_CHINESE_CHESS_MOVE` 只取 4 个坐标), // 与 zone info 里 Pid=0 同一个理由:人机局没有真实玩家 pid 可用。 PlayerPid = 0, FromX = (uint)fromX, FromY = (uint)fromY, ToX = (uint)toX, ToY = (uint)toY, CapturedChessId = captured == ChineseChessBoard.Empty ? 0u : (uint)captured, IsCheck = board.IsInCheck(aiRed), }; // 回合交给对手。★ 先把回合推出去再推 AI 的 move —— 与「谁在什么时候能动手」一致。 plan.TurnAfterPlayer = MakeTurnChange(game.GadgetUid, (uint)board.MoveCount, aiRed); // ── ② 对手已无着可走 ⇒ 玩家胜(绝杀 / 困毙)──────────────────────── if (!board.HasAnyLegalMove(aiRed)) { game.Over = true; plan.Winner = ChineseChessWire.WinnerOf(playerRed); plan.OverReason = ChineseChessWire.GameOverNormal; plan.AiMove = null; plan.TurnAfterAi = null; return; } // ── ③ AI 应手 ────────────────────────────────────────────────────── var aiMove = board.PickMove(aiRed, game.Difficulty); if (aiMove is not { } mv) { // 理论不可达(上面刚判过 HasAnyLegalMove)。保守当成玩家胜,避免整局卡死。 game.Over = true; plan.Winner = ChineseChessWire.WinnerOf(playerRed); plan.TurnAfterAi = null; return; } var aiCaptured = board.ApplyMove(mv.FromId, mv.ToX, mv.ToY); board.RedToMove = playerRed; plan.AiMove = new ChineseChessMove { MoveIndex = game.NextMoveIndex++, PlayerPid = 0, FromX = (uint)mv.FromX, FromY = (uint)mv.FromY, ToX = (uint)mv.ToX, ToY = (uint)mv.ToY, CapturedChessId = aiCaptured == ChineseChessBoard.Empty ? 0u : (uint)aiCaptured, IsCheck = board.IsInCheck(playerRed), }; plan.TurnAfterAi = MakeTurnChange(game.GadgetUid, (uint)board.MoveCount, playerRed); // ── ④ 玩家已无着可走 ⇒ AI 胜 ─────────────────────────────────────── if (!board.HasAnyLegalMove(playerRed)) { game.Over = true; plan.Winner = ChineseChessWire.WinnerOf(aiRed); plan.OverReason = ChineseChessWire.GameOverNormal; } } /// 拼一条 SyncGameGroundZoneTurnChange(21 B,Inline,无标记)。 private static GameGroundZoneTurnChangePush MakeTurnChange(ulong gadgetUid, uint round, bool redToMove) => new() { ZoneIdentifier = new GameGroundZoneIdentifier { GadgetUId = gadgetUid, TaskId = 0, // ★ 与 zone info 的 tag 9 是两回事:这里必须填 GameGroundZoneType.ChineseChess = 12, // 否则 `GameSceneToClientImpl.lua:1213` 那条 else-if 链会把这个包投给别的玩法。 ZoneType = ChineseChessWire.ZoneTypeChineseChess, }, CurrentRound = round, CurrentTurn = ChineseChessWire.TurnOf(redToMove), }; /// /// 玩家执黑时替 AI 走红方开局(红先手)。 /// /// 为什么必须有:客户端 `IsMyAction()` = 「我的颜色 == `Chart.IsRedPlayChess`」, /// 而 `Chart.IsRedPlayChess` 开局恒为 true(红先)⇒ 玩家执黑时它一直是 false /// ⇒ `Board.ClickPoint` 第一个门就 `return` ⇒ **连棋子都选不了**。 /// 所以玩家执黑时,服务端必须先把红方那一手推过去,客户端才轮到玩家。 /// /// ⚠ 这两条推送会落在客户端的 `_pendingInGameMessages` 里(此刻 `IsPlaying` 还是 false), /// 由 `OnEnterZoneSuccess` 里的 `FlushPendingMessages()` 在 `IsPlaying = true` 之后补放 —— /// 那一步在 `Board` 已建成之后,所以 `board.Board` 非空、不会丢。 /// private static async Task PushChineseChessAiOpeningIfNeededAsync( Connection conn, ulong gadgetUid, bool playerUsesRed) { if (playerUsesRed) return "(玩家执红 ⇒ 红先手归玩家,无 AI 开局)"; var game = GetOrCreateChineseChessGame(conn, gadgetUid); ChineseChessMove opening; uint round; lock (game.SyncRoot) { var m = game.Board.PickMove(red: true, game.Difficulty); if (m is not { } mv) return "(**玩家执黑但 AI 红方无着可走** —— 开局不可能,检查盘面初始化)"; var captured = game.Board.ApplyMove(mv.FromId, mv.ToX, mv.ToY); game.Board.RedToMove = false; round = (uint)game.Board.MoveCount; opening = new ChineseChessMove { MoveIndex = game.NextMoveIndex++, PlayerPid = 0, FromX = (uint)mv.FromX, FromY = (uint)mv.FromY, ToX = (uint)mv.ToX, ToY = (uint)mv.ToY, CapturedChessId = captured == ChineseChessBoard.Empty ? 0u : (uint)captured, IsCheck = game.Board.IsInCheck(false), }; } await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncChineseChessMove, new ChineseChessMovePush { UId = gadgetUid, Move = opening }); await conn.NotifyAsync(MethodId.SyncGameGroundZoneTurnChange, MakeTurnChange(gadgetUid, round, redToMove: false)); conn.Log.Info($"[CHESS] 玩家执黑 ⇒ 服务端替 AI(红) 开局 #{opening.MoveIndex} " + $"({opening.FromX},{opening.FromY})->({opening.ToX},{opening.ToY}) 并推回合=黑"); return "(玩家执黑,已推 AI 红方开局 + 回合=黑)"; } // ───────────────────────────────────────────────────────────────────── // 核心:推 zone info(唯一能送达 OpponentAgentInstanceId 的通道) // ───────────────────────────────────────────────────────────────────── /// /// ★★★ R182 核心:把「这局开了、对手是这个单位」推给客户端。 /// /// 三条独立证据要求它必须存在: /// ① ChineseChessMgr.lua:1203 是 OpponentAgentInstanceId 的**唯一**赋值点, /// 而它只在 OnSyncGameGroundZonePlayerInfo 里,唯一能送达对手的调用者是 /// OnSyncGameGroundZoneInfo 的 ipairs(zoneInfo.ParticipantInfos)。 /// ② 按 pid 过滤的那条专用推送(GameSceneToClientImpl.lua:1162)对手永远进不去。 /// ③ 实测 packets:SyncGameGroundZoneInfo 在全部历史日志里 **0 命中** —— 客户端从没收到过。 /// /// 字段取值依据(值不是猜的): /// ZoneType = 12(GameGroundZoneType.ChineseChess,来自 interop /// Auto.Client.dll 的 Constant 表:Dart0/Chef1/Bowling2/Gomoku3/Racing4/Basketball5/ /// Balloon6/Scratch7/FryChestnut8/Glue9/Washer10/BBQ11/ChineseChess12/PartyDance13/ /// RingToss14/Dice15) /// ZoneState = GameStart(2)、SyncReason = Enter(0)、StartReason = Single(0) /// CurrentRound = 1、CurrentTurn = 0(本条推送的这两个字段客户端不读 —— /// 回合走的是另一条 SyncGameGroundZoneTurnChange) /// ScoreInfo 非空但**全空集合**(随 Client4229938 契约文件里同一块名字)。 /// /// ★ ParticipantInfos 是本轮唯一真正的新东西:多态列表、元素 tag = 7、 /// 字段 = IsRed | Pid | NpcCultivationId | AIAgentInfo | AgentInstanceId | ...(Reader[880])。 /// AgentInstanceId 必须 = 世界里真实存在的单位 uid,否则客户端 /// TryGetOrConvertToUnit 查不到 → 10 秒后打 /// [ChineseChess] 等待服务器对手 NPC 单位超时(它有超时的那一支会打日志, /// 没超时的那一支不会 —— 这正是 R181 用来区分两个等待点的指纹)。 /// /// 回滚 = 删两个 handler 里对本方法的调用(两行),白名单那几行留着无害。 /// private static async Task PushChineseChessZoneInfoAsync( Connection conn, ulong gadgetUid, byte gameType, ulong aiUid, bool playerUsesRed, uint endGameId) { // AI 执红 ⇔ 玩家不执红。SeatIndex 与 IsRed 保持一致(本推送里客户端不读,但自洽)。 var aiIsRed = !playerUsesRed; var zoneInfo = new ChineseChessZoneInfo { GameType = gameType, CurrentRound = 1, CurrentTurn = 0, EndGameId = endGameId, // 非空:客户端自己的 writer 把这个成员声明成 nullable=false,官方服务端总会写一段。 ScoreInfo = new ChineseChessScoreInfo(), ZoneSessionId = $"{ChineseChessWire.ZoneSessionPrefix}-{conn.Session.Id}-" + $"{IdCounterStore.Next(ChineseChessZoneSeqCounter, 1)}", GadgetUId = gadgetUid, TaskId = 0, StartReason = ChineseChessWire.ZoneStartReasonSingle, GameTypeRandom = false, SyncReason = ChineseChessWire.ZoneSyncReasonEnter, ZoneType = ChineseChessWire.ZoneTypeChineseChess, ZoneState = ChineseChessWire.ZoneStateGameStart, ParticipantInfos = new List { new() { IsRed = aiIsRed, Pid = 0, NpcCultivationId = 0, AIAgentInfo = null, // ★ 留 null:非空会连带序列化还没 dump 的 Fashion AgentInstanceId = aiUid, // ★★ 客户端 OpponentAgentInstanceId 的唯一来源 VehicleInstanceId = 0, SeatIndex = aiIsRed ? 0 : 1, IsReady = true, IsPlayAgain = false, }, }, CountDownInfo = new GameGroundZoneCountDownInfo(), }; await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo); conn.Log.Info($"[CHESS] pushed SyncGameGroundZoneInfo tag={ChineseChessWire.ZoneTagChineseChess} " + $"participantTag={ChineseChessWire.ParticipantTagChineseChess} " + $"zoneType={ChineseChessWire.ZoneTypeChineseChess} state=GameStart " + $"gameType={gameType} endGameId={endGameId} " + $"aiAgentInstanceId={aiUid} aiIsRed={aiIsRed} gadgetUId={gadgetUid} " + $"zone={zoneInfo.ZoneSessionId}"); } // ───────────────────────────────────────────────────────────────────── // 对手单位:与五子棋 R153/R155/R157 三步完全同形 // ───────────────────────────────────────────────────────────────────── /// /// 建一个「客户端查得到的 AI 对手」—— 三步(与 SpawnGomokuAiAsync 同源): /// ① 分配 logic-agent uid 并 SyncLogicAgentEnter 注册; /// ② 用**客户端给的 agentId** 走 Aether 静态 NPC 通道 /// (NpcFormworkId 就是 AgentConfig id;不送客户端建 Unit 时报 /// 创建AgentUnit失败!AgentConfigId为0! —— R155 的血泪); /// ③ 把同一个 uid 交回调用方,由调用方写进 ParticipantInfos[].AgentInstanceId。 /// /// ★ R157 的复用:同一会话同一张桌**只 spawn 一次**。缓存键带 gadgetUid,因为凌云有 2 张棋盘 /// (8400000000000000036 @(-4617.82,189.51,-2605.60) / /// 8400000000000000037 @(-4563.78,188.85,-2628.81)),要各有一个对手。 /// private async Task<(ulong AiUid, string Note)> SpawnChineseChessAiAsync( Connection conn, ulong gadgetUid, uint agentId, bool playerUsesRed, string tag, byte difficulty) { if (conn.Session.Items.TryGetValue(ChineseChessAiUidKey, out var cachedObj) && cachedObj is ulong cachedUid && cachedUid != 0 && conn.Session.Items.TryGetValue(ChineseChessAiGadgetKey, out var cachedGadgetObj) && cachedGadgetObj is ulong cachedGadget && cachedGadget == gadgetUid) { conn.Log.Info($"[CHESS] {tag} gadgetUId={gadgetUid} agentId={agentId} " + $"difficulty={difficulty} playerUsesRed={playerUsesRed} " + $"-> aiUid={cachedUid} reuse(本会话这张盘已有对手,跳过重复 spawn —— R157)"); return (cachedUid, "reuse"); } // 与场景物/道具同段(8300000000000000000 段),避免撞号。 var aiUid = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); // ① 注册(客户端 TryGetLogicAgent 查的就是这张表)。 await conn.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(aiUid)); // ② 身份 + 站位。 var seat = ResolveChineseChessAiSeat(conn, gadgetUid, playerUsesRed); string note; if (seat is null) { note = "skip: 座位查不到(棋盘不在 SpawnedGadgets 且摆位表里也没有)"; } else if (agentId == 0) { note = "skip: 客户端没给 agentId"; } else if (!DonorNpcCatalog4229938.TryGet(agentId, out _)) { note = $"skip: AgentConfig {agentId} 不在 NpcCatalog"; } else { var s = seat.Value; await SendCivilianAsync(conn.Session, new CivilianSpawnItem(agentId, 0), aiUid, s.X, s.Y, s.Z, s.Facing, skipPhone: true, skipHackPhone: true); note = $"spawned agentConfig={agentId} at=({s.X:F2},{s.Y:F2},{s.Z:F2}) facing={s.Facing:F1}"; } // ★ 无条件缓存(哪怕 spawn 没真发出去)—— 只在成功时缓存会让下次进来 Next() 出第二个 uid, // 而旧的那个删不掉(本版客户端无可靠删除通道)⇒ 场上越来越多。 conn.Session.Items[ChineseChessAiUidKey] = aiUid; conn.Session.Items[ChineseChessAiGadgetKey] = gadgetUid; conn.Log.Info($"[CHESS] {tag} gadgetUId={gadgetUid} agentId={agentId} difficulty={difficulty} " + $"playerUsesRed={playerUsesRed} -> aiUid={aiUid} {note}"); return (aiUid, note); } /// /// 对手该站哪:棋盘根位姿取 (服务端 spawn 时登记了 /// X/Y/Z + PathId),yaw 再从 PropRestock.json 按 (pathId, x, z) 精确匹配 /// (棋盘是 R179 经 PropRestock 摆进来的 ⇒ 权威表就是它,不是 MiniGameStalls)。 /// /// AI 放在棋盘 **+forward×1.5**、朝向 yaw+180(面朝棋盘);实测玩家站在 −forward 侧, /// 所以两者不会重叠。查不到返回 null(调用方退回「只注册 uid」的老行为,不硬造坐标)。 /// private static (float X, float Y, float Z, float Facing)? ResolveChineseChessAiSeat( Connection conn, ulong gadgetUid, bool playerUsesRed) { float bx = 0f, by = 0f, bz = 0f, yaw = 0f; var hasRoot = false; var state = GetStateIfExists(conn.Session); if (state is not null) { lock (state.SyncRoot) { if (state.SpawnedGadgets.TryGetValue(gadgetUid, out var rec)) { bx = rec.X; by = rec.Y; bz = rec.Z; yaw = LookupChineseChessBoardYaw(rec.PathId, rec.X, rec.Z); hasRoot = true; } } } if (!hasRoot) { // 兜底:补货表里第一张棋盘(凌云共 2 张,都在同一片;偏一点也好过不发)。 foreach (var cell in PropRestockCatalog4229938.Cells) { foreach (var item in cell.Items) { if (item.PathId != ChineseChessBoardPathId) continue; bx = item.X; by = item.Y; bz = item.Z; yaw = item.Yaw; hasRoot = true; break; } if (hasRoot) break; } } if (!hasRoot) return null; var rad = yaw * MathF.PI / 180f; // AI 总在 +forward 侧(玩家站在 −forward 侧,实测)。playerUsesRed 只影响朝向语义, // 不影响落点 —— 客户端会把模型挂到自己的座位上(ChineseChessPlayer.lua:288)。 _ = playerUsesRed; var facing = yaw + 180f; return (bx + MathF.Sin(rad) * ChineseChessAiSeatOffset, by, bz + MathF.Cos(rad) * ChineseChessAiSeatOffset, MathF.IEEERemainder(facing, 360f)); } /// 从补货表按 (pathId, x, z) 反查棋盘的 yaw;找不到给 0(只会让 AI 朝向偏一点)。 private static float LookupChineseChessBoardYaw(int pathId, float x, float z) { foreach (var cell in PropRestockCatalog4229938.Cells) { foreach (var item in cell.Items) { if (item.PathId == pathId && MathF.Abs(item.X - x) < 0.5f && MathF.Abs(item.Z - z) < 0.5f) { return item.Yaw; } } } return 0f; } }