966 lines
52 KiB
C#
966 lines
52 KiB
C#
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;
|
||
}
|
||
}
|