Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.ChineseChess.cs
T

966 lines
52 KiB
C#
Raw 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.Buffers.Binary;
using System.Collections.Generic; // List<ChineseChessParticipantInfo>
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;
/// <summary>
/// ★★★ R182(2026-09-29)—— 象棋「能交互、开局后黑屏」的修复 ★★★
///
/// ── 病灶(R181 定案,这一轮修)────────────────────────────────────────────
/// 黑屏由 <c>ChineseChessMgr.EnterGame_Async</c> 一进来就开,只在
/// <c>OnEnterZoneSuccess</c> 那条协程跑到**最后一行** <c>CloseTransition</c> 时才关。
/// 那条协程的第一个门是:
/// <code>
/// while true do
/// if self.Player1 and self.Player2 and self.Player1.IsModelLoaded and self.Player2.IsModelLoaded then break end
/// coroutine.yield(nil)
/// end
/// </code>
/// 而 <c>Player2</c>(对手)的加载卡在 <c>ChineseChessPlayer.lua:224</c>:
/// <code>
/// if not gChineseChessMgr.OpponentAgentInstanceId or not ulong.Greater(..., 0) then
/// coroutine.yield(gChineseChessMgr.WaitOpponentAgentInstanceIdToken) -- ★ 无超时
/// end
/// </code>
/// ⇒ <b>服务端必须做两件事,缺一不可</b>:
/// ① **spawn 一个世界里真实存在的对手单位**(客户端 <c>CheckAiAgent</c> 会
/// <c>gCS.SceneDataMgr.TryGetOrConvertToUnit(instanceId, 256)</c> 去查它);
/// ② 推 <c>SyncGameGroundZoneInfo</c>,且 <c>ParticipantInfos</c> 里那一条的
/// <c>AgentInstanceId</c> = ①的那个 uid —— 这是
/// <c>gChineseChessMgr.OpponentAgentInstanceId</c> 的**唯一**来源。
///
/// ── 为什么不能靠 SyncGameGroundZonePlayerInfo(那条推送)────────────────────
/// <c>GameSceneToClientImpl.lua:1162</c> 对它加了闸:
/// <code>
/// if participantInfo.Pid == gPlayerManager.infoLogin.bindData.pid then
/// ... gChineseChessMgr:OnSyncGameGroundZonePlayerInfo(...)
/// end
/// </code>
/// 对手的 Pid 不是我的 pid ⇒ **那条推送永远送不到象棋管理器**。
/// 唯一能遍历全部参与方的是 <c>OnSyncGameGroundZoneInfo</c>(它直接 <c>ipairs(zoneInfo.ParticipantInfos)</c>)。
///
/// ── 与五子棋(R153/R155/R156/R157)的异同 ────────────────────────────────
/// · 同:都靠「注册 logic-agent uid + 用客户端给的 agentId 走 Aether 静态 NPC 通道把身份送过去」,
/// 座位都取自官方摆位,且**一个会话一张桌只 spawn 一次**(R157 的复用法)。
/// · 异:五子棋的入口返回**裸 u64**(客户端 <c>TryGetLogicAgent</c> 要用),象棋三个入口
/// <c>midToReturnMessageReader</c> 全是 <c>function(reader) end</c> ⇒ **void**,空 ok 即可。
/// ★ 也正因为回执本来就没错(<c>unknown-methods.log</c> 里那两个包是 typed-default 但 err=0,
/// 客户端 <c>.Callback</c> 只判 err)—— 所以本轮的修法**只加推送,不改回执形状**。
/// · 异:象棋的参与方是**多态列表元素**(tag 7,字段比 base 多一个 IsRed);
/// 五子棋那次 ParticipantInfos 一直是空的 ⇒ 那条路从没被走过。见 ChineseChessWireContracts.cs。
///
/// ── 本轮的边界(说清楚,别把它当「象棋能玩了」)──────────────────────────
/// 本轮只打通到 **「棋盘可见、黑屏关掉」**。走子还需要第二批:
/// 收 <c>AskChineseChessMovePiece</c> → **服务端算应手** → 推 <c>SyncChineseChessMove</c> +
/// <c>SyncGameGroundZoneTurnChange</c>。已确认 **AI 不在客户端**
/// (<c>ChineseChessSearchChart.lua</c> 在本仓 2349 个 Lua 里只被 require、零调用点)。
///
/// ⛑ 回滚:把 GameRouter.cs 的 Enabled4229938MethodIds 里那 4 行象棋 mid 删掉即可
/// (handler 不注册 = 走 typed-default 兜底),不用删本文件。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>象棋棋盘的 pathId(<c>PropRestock.json</c> 里的 <c>Gadget_XiangQi</c>,全库唯一)。</summary>
private const int ChineseChessBoardPathId = 495093791;
/// <summary>会话级记忆键:本会话这张桌的对手 uid(R157 的「只建一次」)。</summary>
private const string ChineseChessAiUidKey = "ChineseChess.AiUid";
/// <summary>记录那个 uid 是给**哪张桌**的(凌云/龙栖各有多张棋盘)。</summary>
private const string ChineseChessAiGadgetKey = "ChineseChess.AiGadgetUid";
/// <summary>zone 会话序号(进程内自增,只用来拼一个非空的 ZoneSessionId)。</summary>
private const string ChineseChessZoneSeqCounter = "ChineseChess.ZoneSeq";
/// <summary>
/// 对手站位:沿棋盘 forward 的距离(米)。实测玩家站在棋盘 −forward×1.9 处
/// (棋盘 <c>(-4563.78,188.85,-2628.81)</c> yaw 127.3,玩家 <c>(-4565.29,188.86,-2627.80)</c>),
/// 所以对手放 +forward×1.5 = 棋盘另一侧。
/// ⚠ 站位只是为了「这个 NPC 不在棋盘里/不和玩家重叠」——客户端拿到单位后会把模型
/// <c>modelTransform:SetParent(seatGo)</c> 挂到自己的座位上(<c>ChineseChessPlayer.lua:288</c>)。
/// </summary>
private const float ChineseChessAiSeatOffset = 1.5f;
// ─────────────────────────────────────────────────────────────────────
// ① 标准对局(人机)—— 本轮实测命中的就是这一条
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskChineseChessEnterZoneDoubleAI (67307903)
/// C2S = u64 gadgetuid + u32 agentid + bool useredpiece + byte difficulty;RET = **void**。
///
/// 契约第一现场:<c>ClientToGameSceneDelegate.lua:1790 AskChineseChessEnterZoneDoubleAI_Serializer</c>
/// <c>WritePrimitive(gadgetuid, WriteUInt64)</c> / <c>(agentid, WriteUInt32)</c> /
/// <c>(useredpiece, WriteBoolean)</c> / <c>(CheckEnum(difficulty,53,0), WriteByte)</c>
/// 实测 body=14b:<c>25 00 48 B1 7E CB 92 74 | BB 5B 64 02 | 01 | 00</c>
/// ⇒ gadgetUid <b>8400000000000000037</b>(= R179 补摆进凌云的那张棋盘,已核对到 u64)
/// agentId <b>40131515</b>(<c>NpcCatalog</c>「Xian Yougen」)/ useRedPiece true / difficulty 0。
///
/// 返回读取器 <c>midToReturnMessageReader[67307903] = function(reader) end</c> ⇒ 空体 = void。
/// </summary>
[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
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskChineseChessEnterZoneEndGame (67616104)
/// C2S = u64 gadgetuid + u32 agentid + u32 endgameid;RET = **void**
/// (<c>midToReturnMessageReader[67616104] = function(reader) end</c>)。
///
/// 客户端调用点 <c>ChineseChessMgr.lua:195</c>:<c>self.IsRed = endGameCfg.IsRedFirst</c>
/// —— 残局里执红与否由 <c>PoiGameChineseChessEndGameConfig</c> 决定(20 条,已确认存在),
/// 所以「玩家执红」这件事只能靠请求里的 agentid 之外的信息;本实现按**请求缺省 = 玩家执红**
/// 处理(与五子棋残局同策略),并把 AI 放黑位。
/// </summary>
[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 的理由)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// AskChineseChessLeaveZone (67667163) —— C2S = u64 gadgetuid;RET = **void**。
///
/// ★ 与五子棋 <c>LeaveGomoku</c> 完全同源:本版客户端**没有可靠的静态 NPC 删除通道**
/// (<c>SyncAetherAIStaticNpcRemove*</c> 在 MethodId 里不存在;<c>SyncRemoveNpc</c> 装不下
/// 830… 段的 u64;<c>SyncRemoveManagedAgent</c> 全 Lua 0 命中)。既然删不掉,就**只建一次**:
/// 清缓存只会让下次进来 <c>Next()</c> 出第二个 uid,旧的那个照样留在场上。
/// </summary>
[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);
}
// ─────────────────────────────────────────────────────────────────────
// ④ 玩家棋力/残局进度查询 —— 返回真类型,别让开始面板的字典悬空
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// QueryChineseChessPlayerInfo (67492072) —— C2S = 空;RET = <b>complex</b>
/// (<c>midToReturnMessageReader[67492072] = Base.ReadComplex(reader, Auto.Reader[681])</c>)。
///
/// 消费者 <c>ChineseChessStartPanelStore.lua:61</c>:
/// <code>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</code>
/// 走 typed-default 时字典字段是 nil ⇒「已通关残局」判定悬空。这里补一个**真空表**。
/// ⚠ 两个字典是 <c>Base.WriteDict</c>(Int32 计数),不是 7bit —— 见 ChineseChessWireContracts.cs。
/// </summary>
[Handler(MethodId.QueryChineseChessPlayerInfo)]
private async Task QueryChineseChessPlayerInfoAccept(Connection conn, UxRpcMessage msg)
{
var info = new ChineseChessPlayerInfo
{
DroppedEndGameIdSet = new Dictionary<uint, bool>(),
DroppedDoubleAIDifficultySet = new Dictionary<uint, bool>(),
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 兜底),不用删本文件。
// ─────────────────────────────────────────────────────────────────────
/// <summary>一局象棋的服务端状态(一个 gadgetUid 一份,停在 <c>Session.Items</c> 里)。</summary>
private const string ChineseChessGameKey = "ChineseChess.Game";
/// <summary>玩家执红?(进入时从请求的 <c>useRedPiece</c> 记下来,供懒建盘面时回填)。</summary>
private const string ChineseChessPlayerRedKey = "ChineseChess.PlayerUsesRed";
/// <summary>UX.Game.ChineseChessAIDifficulty(0..4),客户端在进入请求里给的。</summary>
private const string ChineseChessDifficultyKey = "ChineseChess.Difficulty";
/// <summary>
/// 一局棋的全部可变状态。★ 所有读写都进 <see cref="SyncRoot"/> —— 同一条连接上的
/// 多个 handler 是异步交错的(`await` 出点),而盘面是纯可变结构。
/// </summary>
private sealed class ChineseChessGameSession
{
internal readonly object SyncRoot = new();
/// <summary>规则引擎(9×10 盘面 + 走法生成 + 将军/绝杀 + AI)。</summary>
internal readonly ChineseChessBoard Board = new();
internal ulong GadgetUid;
internal ulong AiUid;
/// <summary>玩家执红?(AI = 另一方)</summary>
internal bool PlayerUsesRed = true;
/// <summary>客户端在进入请求里给的 AI 难度(0 = Beginner 实测值)。</summary>
internal byte Difficulty;
/// <summary>下一步棋的序号(写进 <c>ChineseChessMove.MoveIndex</c>)。</summary>
internal uint NextMoveIndex = 1;
/// <summary>已分胜负?是则后续落子一律拒绝。</summary>
internal bool Over;
internal bool AiIsRed => !PlayerUsesRed;
}
/// <summary>
/// 「这一步算完之后要往客户端推什么」的清单 —— 锁内产出、锁外消费。
/// ★ 这么拆是因为推送是 <c>await</c>,而 `await` **不能**放在锁里(会把整条连接的其它 handler 一起堵住)。
/// </summary>
private sealed class ChineseChessMovePlan
{
/// <summary>非空 = 这一步不合法,只回 ok 不推任何东西(客户端可重选)。</summary>
internal string? Reject;
internal ChineseChessMove? PlayerMove;
internal GameGroundZoneTurnChangePush? TurnAfterPlayer;
internal ChineseChessMove? AiMove;
internal GameGroundZoneTurnChangePush? TurnAfterAi;
/// <summary>null = 未分胜负;0 = 红胜 / 1 = 黑胜(颜色,见 ChineseChessWireContracts)。</summary>
internal int? Winner;
internal byte OverReason = ChineseChessWire.GameOverNormal;
}
/// <summary>
/// AskChineseChessMovePiece (67463405)
/// C2S = u64 gadgetuid + u32 fromX + u32 fromY + u32 toX + u32 toY = **24 B**;RET = <b>void</b>
/// (<c>midToReturnMessageReader[67463405]</c> 是空体)。
///
/// 逐字节对照实测报文(`logs/packets-20260929-101956.log:19087+`):
/// <c>25 00 48 B1 7E CB 92 74 | 0A 00 00 00 | 05 00 00 00 | 07 00 00 00 | 05 00 00 00</c>
/// ⇒ gadgetUid 8400000000000000037 / from (10,5) / to (7,5)
/// (红炮在 (10,5) —— 与 `ChineseChessChart.InitChartBoard` 的 `GetPointByPosition(10,5)` 对上,
/// 这条一致性就是「坐标 = (x,y)」这个约定的锚点)。
/// 签名权威 <c>ClientToGameSceneDelegate.lua:2608 AskChineseChessMovePiece_Serializer</c>;
/// 调用点 <c>ChineseChessMgr.lua:1016</c>。
///
/// ★ 回执策略:**任何**路径都回 Ok(连非法着法也回)——
/// 客户端的 <c>.Callback</c> 对 Ok 是「什么都不做」(真值形态 <c>if err ~= Ok then 显示错误</c>),
/// 而**不回执**会让 <c>IsWaitingServerMove</c> 永远停在 true ⇒ 此后一发不可收拾。
/// 非法着法的表现 = 「推回一个 move」这件事没发生 ⇒ 棋盘不动、可以重选,正是我们想要的。
/// </summary>
[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 });
}
}
/// <summary>
/// AskChineseChessSurrender (67452562) —— C2S = u64 gadgetuid;RET = **void**
/// (<c>midToReturnMessageReader[67452562]</c> 空体)。
///
/// ★ R183 顺手补的第三条(可单独回滚):不做它 ⇒ 玩家点「认输」什么都没发生。
/// 客户端 <c>GiveUp</c> 先置 <c>_isGiveUp = true</c> 再发包;收尾在服务端推的
/// <c>SyncChineseChessGameOver</c> 里(<c>TryFinalizeGame</c> 读到 <c>_isGiveUp</c> 后才走
/// 「黑屏退出」那一支)。
/// ★ 认输 = **对手胜** ⇒ winner 取对手的颜色(0 红 / 1 黑)。
/// </summary>
[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 ? "红" : "黑")}]认输 ⇒ 对手胜)");
}
// ─────────────────────────────────────────────────────────────────────
// 盘面状态:建 / 重置 / 取
// ─────────────────────────────────────────────────────────────────────
/// <summary>新开一局 ⇒ 把盘面重置成标准开局,并记下这一局的参数。</summary>
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} 盘面=标准开局");
}
/// <summary>
/// 取这一局的盘面;没有就**懒建**一个。
/// 懒建存在的理由:玩家可能不经我们记录的入口就发出落子包(客户端重启、快速重进),
/// 这时服务端仍要有盘面才算得出应手 —— 拿标准开局 + 会话里缓存的颜色/难度是最合理的近似。
/// </summary>
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;
}
/// <summary>
/// ★ 锁内:校验玩家的着法、落子、算 AI 应手、判是否终局。**只算不推**(推送在锁外)。
///
/// 拒绝清单(任一命中就只回 ok、什么都不推)—— 每一项都能在日志里看到确切原因,
/// 这就是「服务端不认这一步」与「服务端根本没收到这一步」的分界线。
/// </summary>
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;
}
}
/// <summary>拼一条 <c>SyncGameGroundZoneTurnChange</c>(21 B,Inline,无标记)。</summary>
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),
};
/// <summary>
/// 玩家执黑时替 AI 走红方开局(红先手)。
///
/// 为什么必须有:客户端 `IsMyAction()` = 「我的颜色 == `Chart.IsRedPlayChess`」,
/// 而 `Chart.IsRedPlayChess` 开局恒为 true(红先)⇒ 玩家执黑时它一直是 false
/// ⇒ `Board.ClickPoint` 第一个门就 `return` ⇒ **连棋子都选不了**。
/// 所以玩家执黑时,服务端必须先把红方那一手推过去,客户端才轮到玩家。
///
/// ⚠ 这两条推送会落在客户端的 `_pendingInGameMessages` 里(此刻 `IsPlaying` 还是 false),
/// 由 `OnEnterZoneSuccess` 里的 `FlushPendingMessages()` 在 `IsPlaying = true` 之后补放 ——
/// 那一步在 `Board` 已建成之后,所以 `board.Board` 非空、不会丢。
/// </summary>
private static async Task<string> 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 的通道)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// ★★★ R182 核心:把「这局开了、对手是这个单位」推给客户端。
///
/// 三条独立证据要求它必须存在:
/// ① <c>ChineseChessMgr.lua:1203</c> 是 <c>OpponentAgentInstanceId</c> 的**唯一**赋值点,
/// 而它只在 <c>OnSyncGameGroundZonePlayerInfo</c> 里,唯一能送达对手的调用者是
/// <c>OnSyncGameGroundZoneInfo</c> 的 <c>ipairs(zoneInfo.ParticipantInfos)</c>。
/// ② 按 pid 过滤的那条专用推送(<c>GameSceneToClientImpl.lua:1162</c>)对手永远进不去。
/// ③ 实测 packets:<c>SyncGameGroundZoneInfo</c> 在全部历史日志里 **0 命中** —— 客户端从没收到过。
///
/// 字段取值依据(值不是猜的):
/// <c>ZoneType = 12</c>(<c>GameGroundZoneType.ChineseChess</c>,来自 interop
/// <c>Auto.Client.dll</c> 的 Constant 表:Dart0/Chef1/Bowling2/Gomoku3/Racing4/Basketball5/
/// Balloon6/Scratch7/FryChestnut8/Glue9/Washer10/BBQ11/<b>ChineseChess12</b>/PartyDance13/
/// RingToss14/Dice15)
/// <c>ZoneState = GameStart(2)</c>、<c>SyncReason = Enter(0)</c>、<c>StartReason = Single(0)</c>
/// <c>CurrentRound = 1</c>、<c>CurrentTurn = 0</c>(本条推送的这两个字段客户端不读 ——
/// 回合走的是另一条 <c>SyncGameGroundZoneTurnChange</c>)
/// <c>ScoreInfo</c> 非空但**全空集合**(随 Client4229938 契约文件里同一块名字)。
///
/// ★ <c>ParticipantInfos</c> 是本轮唯一真正的新东西:多态列表、元素 tag = 7、
/// 字段 = <c>IsRed | Pid | NpcCultivationId | AIAgentInfo | AgentInstanceId | ...</c>(Reader[880])。
/// <c>AgentInstanceId</c> 必须 = 世界里真实存在的单位 uid,否则客户端
/// <c>TryGetOrConvertToUnit</c> 查不到 → 10 秒后打
/// <c>[ChineseChess] 等待服务器对手 NPC 单位超时</c>(它有超时的那一支会打日志,
/// 没超时的那一支不会 —— 这正是 R181 用来区分两个等待点的指纹)。
///
/// 回滚 = 删两个 handler 里对本方法的调用(两行),白名单那几行留着无害。
/// </summary>
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<ChineseChessParticipantInfo>
{
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 三步完全同形
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 建一个「客户端查得到的 AI 对手」—— 三步(与 SpawnGomokuAiAsync 同源):
/// ① 分配 logic-agent uid 并 <c>SyncLogicAgentEnter</c> 注册;
/// ② 用**客户端给的 agentId** 走 Aether 静态 NPC 通道
/// (<c>NpcFormworkId</c> 就是 AgentConfig id;不送客户端建 Unit 时报
/// <c>创建AgentUnit失败!AgentConfigId为0!</c> —— R155 的血泪);
/// ③ 把同一个 uid 交回调用方,由调用方写进 <c>ParticipantInfos[].AgentInstanceId</c>。
///
/// ★ R157 的复用:同一会话同一张桌**只 spawn 一次**。缓存键带 gadgetUid,因为凌云有 2 张棋盘
/// (<c>8400000000000000036 @(-4617.82,189.51,-2605.60)</c> /
/// <c>8400000000000000037 @(-4563.78,188.85,-2628.81)</c>),要各有一个对手。
/// </summary>
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);
}
/// <summary>
/// 对手该站哪:棋盘根位姿取 <see cref="WorldEntryState.SpawnedGadgets"/>(服务端 spawn 时登记了
/// X/Y/Z + PathId),yaw 再从 <c>PropRestock.json</c> 按 (pathId, x, z) 精确匹配
/// (棋盘是 R179 经 PropRestock 摆进来的 ⇒ 权威表就是它,不是 MiniGameStalls)。
///
/// AI 放在棋盘 **+forward×1.5**、朝向 yaw+180(面朝棋盘);实测玩家站在 −forward 侧,
/// 所以两者不会重叠。查不到返回 <c>null</c>(调用方退回「只注册 uid」的老行为,不硬造坐标)。
/// </summary>
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));
}
/// <summary>从补货表按 (pathId, x, z) 反查棋盘的 yaw;找不到给 0(只会让 AI 朝向偏一点)。</summary>
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;
}
}