using System.Buffers.Binary; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 五子棋**单机**(人机 / 残局)—— EnterGomokuZoneDoubleAI(67614971) 与 /// EnterGomokuZoneEndGame(67341006)。 /// /// ★ 现象与根因链(四刀,每一刀都有本机取证) /// /// R153 入口未登记:这两条从未进白名单 ⇒ 走 DefaultReturnCatalog4229938 /// 兜底(:1611 / :1710)回 8 个 0x00 = **裸 u64 = 0**。客户端 /// [warning]StartGomoke AI: TryGetLogicAgent fail, uid=0 / /// StartGomoke EndGame: TryGetLogicAgent fail, uid=0。 /// ⇒ 必须登记并回一个**真实存在的**实体 uid。 /// R155 只有 uid 装不下身份:SyncLogicAgentEnter 的 interop 签名只有一个 ulong, /// 而 LogicAgent.AgentConfigId 是 uint ⇒ 只注册 uid 会让客户端报 /// 创建AgentUnit失败!AgentConfigId为0!。 /// 唯一能送 AgentConfig 的通道是 **Aether 静态 NPC**(NpcFormworkId 就是 AgentConfig id), /// 而客户端在请求体第 2 个字段 agentid 里**自己把该填哪个告诉我们了**。 /// R156 缺棋局状态推送(本轮真正卡住落子的那一刀): /// 客户端点子链是 TryRayIntersectBoard → PlaceGomokuPiece → /// AskPlaceGomokuPiece,而闸门是 GomokuManager.get_NowMyTurn(); /// 唯一能置起"我的回合"的输入就是 SyncGameGroundZoneInfo(68004609)。 /// 实测 AskPlaceGomokuPiece / PlaceGomokuPiece / SyncGameGroundZoneInfo /// 在全部历史日志里 **0 命中** ⇒ 客户端从没收到过 ⇒ 闸门恒关 ⇒ **一条落子包都不发**。 /// R185 参与方列表不能为空:客户端靠 ipairs(zoneInfo.ParticipantInfos) 拿到 /// pendingOpponentAgentId。推空列表 ⇒ 它永远是 0 ⇒ 闸门还是不开。 /// 且列表元素是**多态**的,每个元素自带 1B tag = GomokuWire.ParticipantTagGomoku(12); /// 若用裸 [UxContract](默认 0xFF)会被 Dispatch 判成 undefined 分支全部丢弃。 /// /// /// ★ 三步法与主线自己的 GameRouter.ChineseChess.cs(SpawnChineseChessAiAsync) /// **完全同形**(那里注释原话:"与五子棋 R153/R155/R157 三步完全同形"): /// ① SyncLogicAgentEnter 注册 uid → ② SendCivilianAsync 送 AgentConfig + 站位 /// → ③ 把同一个 uid 回给客户端 / 写进 ParticipantInfos[].AgentInstanceId。 /// /// ⛑⛑ 不要加"等 NPC 落地"的延迟:象棋的实测证据是客户端 /// TryGetOrConvertToUnit 查不到时**会等待**(10 秒后才打"等待服务器对手 NPC 单位超时"), /// 不是立刻失败 ⇒ 时序不是问题,缺的是状态推送。曾按"时序"假设加过延迟,方向是错的。 /// /// 契约 DTO 见 Ananta.RpcTypes/Client4229938/GomokuWireContracts.cs(文件头有逐字段的本机复核)。 /// internal sealed partial class GameRouter { /// /// 对手站位:摊位锚点沿 yaw **背侧**外推多少米(玩家在 yaw 正方向约 2.2m 的顾客位, /// 对手隔棋盘坐在摊位另一侧)。 /// ⛑ yaw→forward 的约定与骰子/象棋一致:forward = (sin(yaw°), cos(yaw°)) /// (GameRouter.Dice.cs:603 的 atan2(dx, dz) 反解即此约定)。 /// private const float GomokuOpponentBackOffsetMeters = 1.6f; /// 摊位表里五子棋机关的名字(Configs/MiniGameStalls.json)。 private const string GomokuStallItemName = "Gadget_WuZiQi"; /// /// 客户端在 EnterGomokuZoneDoubleAI 里带上来的对手模板(实测 = 40131483「廖永强」, /// 普通市民 class 9)。服务端优先用它;若它不在 NpcCatalog 里就退回通用市民模板。 /// private const uint GomokuDefaultOpponentFormwork = 40131483; /// POI 动作缺省值(与 FunctionalNpcSpots.json 的 poiActionIdDefault 同值)。 private const uint GomokuDefaultPoiActionId = 17; /// 五子棋 zone 会话序号(进程内自增,只用来拼一个非空的 ZoneSessionId)。 private const string GomokuZoneSeqCounter = "Gomoku.ZoneSeq"; /// 对手 uid 的会话级缓存键(见 的 R157 注释)。 private const string GomokuAiUidKey = "Gomoku.AiUid"; /// 记录那个 uid 是给**哪张桌**的(庙会区有多张 WuZiQi 桌,要各有一个对手)。 private const string GomokuAiGadgetKey = "Gomoku.AiGadgetUid"; // ───────────────────────────────────────────────────────────────────── // 两条单机入口 // ───────────────────────────────────────────────────────────────────── /// /// EnterGomokuZoneDoubleAI(67614971) 人机对战。 /// C2S = u64 gadgetuid + u32 agentid + bool useblackpiece + byte difficulty + bool canuseskill。 /// RET = **裸 u64**(对手实体的真实 uid)。 /// [Handler(MethodId.EnterGomokuZoneDoubleAI)] private async Task EnterGomokuZoneDoubleAI4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; ulong gadgetUid = 0; uint agentId = 0; var useBlack = false; var difficulty = 0; var canUseSkill = false; if (body is { Length: >= 13 }) { gadgetUid = BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(0, 8)); agentId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4)); useBlack = body[12] != 0; if (body.Length >= 14) difficulty = body[13]; if (body.Length >= 15) canUseSkill = body[14] != 0; } var (uid, how) = await SpawnGomokuOpponentAsync(conn, gadgetUid, agentId, useBlack, $"EnterGomokuZoneDoubleAI difficulty={difficulty} canUseSkill={canUseSkill}"); conn.Log.Info($"[GOMOKU] EnterGomokuZoneDoubleAI gadgetUId={gadgetUid} agentId={agentId} " + $"useBlack={useBlack} difficulty={difficulty} canUseSkill={canUseSkill} " + $"-> opponentUid={uid} ({how})"); // ⛑ 必须 ReturnRawAsync 手拼裸 8 字节:客户端 reader 就是 reader:ReadUInt64(), // 泛型 ReturnAsync 会多套 FF + Int7(len) 两字节前缀 ⇒ 走位(套圈同一个坑)。 await ReturnGomokuUidAsync(conn, msg, uid); // ★★★ R156:**回包之后**再推棋局状态。顺序不能反 —— 客户端要先拿到 uid 建出 // AgentUnit,才接得住「棋局已开始 / 现在轮到谁」。这是"能落子"的闸门。 await PushGomokuZoneInfoAsync(conn, gadgetUid, GomokuWire.GameTypeDoubleAI, uid, useBlack); } /// /// EnterGomokuZoneEndGame(67341006) 残局。 /// C2S = u64 gadgetuid + u32 agentid + u32 endgameid。RET = **裸 u64**(同上)。 /// ⛑ 与 DoubleAI 的差别只有:请求里**没有** useBlack 字段 ⇒ 按残局约定「玩家先手 = 黑」。 /// [Handler(MethodId.EnterGomokuZoneEndGame)] private async Task EnterGomokuZoneEndGame4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; ulong gadgetUid = 0; uint agentId = 0; var endGameId = 0u; 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)); } // 残局入口没有 useBlack ⇒ 玩家执黑(先手)。 var (uid, how) = await SpawnGomokuOpponentAsync(conn, gadgetUid, agentId, playerUsesBlack: true, "EnterGomokuZoneEndGame", endGameId); conn.Log.Info($"[GOMOKU] EnterGomokuZoneEndGame gadgetUId={gadgetUid} agentId={agentId} " + $"endGameId={endGameId} -> opponentUid={uid} ({how})"); await ReturnGomokuUidAsync(conn, msg, uid); await PushGomokuZoneInfoAsync(conn, gadgetUid, GomokuWire.GameTypeEndGame, uid, playerUsesBlack: true); } /// 把对手 uid 按「裸 u64」拼成 8 字节小端回给客户端。 private static Task ReturnGomokuUidAsync(Connection conn, UxRpcMessage msg, ulong uid) { var payload = new byte[8]; BinaryPrimitives.WriteUInt64LittleEndian(payload, uid); return conn.ReturnRawAsync(msg, payload); } // ───────────────────────────────────────────────────────────────────── // ★★★ R156 核心:把「棋局已开始、现在轮到谁、对手是谁」推给客户端 // ───────────────────────────────────────────────────────────────────── /// /// 推 SyncGameGroundZoneInfo(68004609) + GomokuZoneInfo(多态 tag 13)。 /// 这是「点棋盘没反应」与「能落子」之间缺的那一刀 —— 客户端 get_NowMyTurn() /// 的置位只有这一个输入来源。 /// /// ★ 座位/先手(别与象棋的「颜色」混淆):五子棋 CurrentTurn 是 **SeatIndex 座位号**。 /// GomokuWire.SeatIndexBlack = 0;客户端两个单机入口都 useBlack=true /// ⇒ 玩家 mySeatIndex = 0、黑棋先手 ⇒ CurrentTurn 必须是 0。 /// 玩家 SeatIndex=0 / AI SeatIndex=1 / CurrentTurn=玩家座位 —— **三者必须自洽**, /// 写错任一个都是「点不动」。 /// /// 回滚 = 删两个 handler 里对本方法的调用(两行)。 /// private static async Task PushGomokuZoneInfoAsync( Connection conn, ulong gadgetUid, byte gameType, ulong aiUid, bool playerUsesBlack) { var playerSeat = playerUsesBlack ? GomokuWire.SeatIndexBlack : GomokuWire.SeatIndexWhite; var aiSeat = playerUsesBlack ? GomokuWire.SeatIndexWhite : GomokuWire.SeatIndexBlack; var zoneInfo = new GomokuZoneInfo { GameType = gameType, CurrentRound = 1, // ★ 玩家座位号 —— 黑先手且玩家执黑 ⇒ 0,客户端 NowMyTurn 才会成立。 CurrentTurn = playerSeat, // 非空:客户端自己的 writer 把这个成员声明成 nullable=false,官方服务端总会写一段。 ScoreInfo = new GomokuScoreInfo(), ZoneSessionId = $"{GomokuWire.ZoneSessionPrefix}-{conn.Session.Id}-" + $"{IdCounterStore.Next(GomokuZoneSeqCounter, 1)}", GadgetUId = gadgetUid, TaskId = 0, StartReason = GomokuWire.ZoneStartReasonSingle, GameTypeRandom = false, SyncReason = GomokuWire.ZoneSyncReasonEnter, ZoneType = GomokuWire.ZoneTypeGomoku, ZoneState = GomokuWire.ZoneStateGameStart, // ★★ R185:两条真实参与方(玩家 + AI)。**绝不能推空列表** —— // 客户端 pendingOpponentAgentId 的唯一来源就是这里。 ParticipantInfos = new List { new() { Pid = 0, // 真人玩家:官方在单人局这里也是 0 NpcCultivationId = 0, AIAgentInfo = null, // ★ 留 null:非空会连带序列化还没 dump 的 Fashion AgentInstanceId = 0, // 玩家不是 agent ⇒ 0 VehicleInstanceId = 0, SeatIndex = playerSeat, IsReady = true, IsPlayAgain = false, }, new() { Pid = 0, NpcCultivationId = 0, AIAgentInfo = null, AgentInstanceId = aiUid, // ★★ 客户端 pendingOpponentAgentId 的唯一来源 VehicleInstanceId = 0, SeatIndex = aiSeat, IsReady = true, IsPlayAgain = false, }, }, CountDownInfo = new GameGroundZoneCountDownInfo(), }; await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo); conn.Log.Info($"[GOMOKU] pushed SyncGameGroundZoneInfo tag={GomokuWire.ZoneTagGomoku} " + $"participantTag={GomokuWire.ParticipantTagGomoku} " + $"zoneType={GomokuWire.ZoneTypeGomoku} state=GameStart gameType={gameType} " + $"turn={zoneInfo.CurrentTurn} playerSeat={playerSeat} aiSeat={aiSeat} " + $"aiAgentInstanceId={aiUid} playerUsesBlack={playerUsesBlack} " + $"participants={zoneInfo.ParticipantInfos.Count} " + $"gadgetUId={gadgetUid} zone={zoneInfo.ZoneSessionId}"); } // ───────────────────────────────────────────────────────────────────── // 造对手 —— 三步法,与 GameRouter.ChineseChess.cs 的 SpawnChineseChessAiAsync 同形 // ───────────────────────────────────────────────────────────────────── /// /// 建一个「客户端查得到的 AI 对手」,返回它的**真实 uid**。三步: /// ① SyncLogicAgentEnter 注册 uid(客户端 TryGetLogicAgent 查的就是这张表); /// ② 用**客户端给的 agentId** 走 Aether 静态 NPC 通道(NpcFormworkId = AgentConfig id, /// 不送的话客户端建 Unit 时报 创建AgentUnit失败!AgentConfigId为0!); /// ③ 把同一个 uid 交回调用方(回包 + 写进 ParticipantInfos)。 /// /// ★ R157 复用:**同一会话同一张桌只 spawn 一次**。理由(象棋同源注释原话): /// 「旧的那个删不掉(本版客户端无可靠删除通道)⇒ 场上越来越多」。 /// 实测本仓 MethodId 里没有 SyncAetherAIStaticNpcRemove*; /// SyncRemoveNpc(68446556) 的 reader 是 Int32 ⇒ 装不下 830… 段的 u64。 /// ⇒ 与其猜"怎么删",不如**根本不重复建**。 /// /// 缓存键带 gadgetUid(庙会区有多张 WuZiQi 桌,要各有一个对手,不能互相顶掉)。 /// 作用域用 TcpSession.Items —— 断线即弃,不会泄漏到别的会话。 /// private static async Task<(ulong Uid, string How)> SpawnGomokuOpponentAsync( Connection conn, ulong gadgetUid, uint agentId, bool playerUsesBlack, string tag, uint endGameId = 0) { var session = conn.Session; // ── R157 复用:本会话这张桌已经有对手就直接拿它的 uid。 if (session.Items.TryGetValue(GomokuAiUidKey, out var cachedObj) && cachedObj is ulong cachedUid && cachedUid != 0 && session.Items.TryGetValue(GomokuAiGadgetKey, out var cachedGadgetObj) && cachedGadgetObj is ulong cachedGadget && cachedGadget == gadgetUid) { conn.Log.Info($"[GOMOKU] {tag} gadgetUId={gadgetUid} endGameId={endGameId} " + $"-> aiUid={cachedUid} reuse(本会话这张桌已有对手,跳过重复 spawn —— R157)"); return (cachedUid, "reuse"); } try { var state = GetStateIfExists(session); if (state is null) return (GomokuFallbackUid(), "no-state"); var item = PickGomokuOpponentItem(agentId); if (item is null) return (GomokuFallbackUid(), "no-civilian-item"); // Aether 初始化:市民没见过它就不会实体化(见 SpawnCiviliansNearPlayerAsync 注释)。 await SendDonorAetherInitAsync(session); // 与场景物/道具同段(8300000000000000000 段),避免撞号。 var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor)); // ① 注册(客户端 TryGetLogicAgent 查的就是这张表)。 await conn.NotifyAsync(MethodId.SyncLogicAgentEnter, WorldCodec.LogicAgentEnter(id)); // 站位:优先用摊位表里的 Gadget_WuZiQi 坐算(沿 yaw 背侧外推), // 摊位表缺失时退回「玩家附近」(骰子的兜底思路)。 var near = state.LastReportedPlayerPosition; float x, y, z; string where; var stall = FindGomokuStall(near.X, near.Z); if (stall is not null) { var yaw = stall.Yaw * MathF.PI / 180f; // forward = (sin(yaw), cos(yaw));背侧 = 减 forward。 x = stall.X - MathF.Sin(yaw) * GomokuOpponentBackOffsetMeters; z = stall.Z - MathF.Cos(yaw) * GomokuOpponentBackOffsetMeters; y = stall.Y; where = $"stall({stall.X:F2},{stall.Y:F2},{stall.Z:F2}) yaw={stall.Yaw:F1}"; } else { x = near.X - 1.6f; z = near.Z - 1.6f; y = near.Y; where = "player-near(fallback)"; } // 朝向:始终转向玩家(atan2 直接给出「forward=(sin,cos)」的那个 yaw)。 var facing = (float)(Math.Atan2(near.X - x, near.Z - z) * 180.0 / Math.PI); // ② 身份 + 站位(SendCivilianAsync 内部会发 SyncAetherAIStaticNpcAddData + // SyncManagedLogicAgent,所以这里不再重复发那一条)。 // ⛑ skipPhone / skipHackPhone:棋手不需要手机,也不该被「给手机」那一步覆盖掉棋位。 await SendCivilianAsync(session, item, id, x, y, z, facing, skipPhone: true, skipHackPhone: true); // 服务端账本:留着它 120s 全量重扫才不会把人挪走/重复摆。 lock (state.SyncRoot) { state.StaticNpcPreparedPlotEvents[id] = 0; state.StaticNpcPoses[id] = new StaticNpcPose(x, y, z, facing); } // ★ 无条件缓存(哪怕上面某一步没真发出去)—— 只在成功时缓存会让下次进来 Next() // 出**另一个** uid,而旧的那个删不掉 ⇒ 场上越来越多(R157 的血泪)。 session.Items[GomokuAiUidKey] = id; session.Items[GomokuAiGadgetKey] = gadgetUid; conn.Log.Info($"[GOMOKU] {tag} 对手 NPC 落位 at=({x:F2},{y:F2},{z:F2}) facing={facing:F0} " + $"from {where} entity={id} formwork={item.NpcFormworkId} poi={item.PoiActionId} " + $"playerUsesBlack={playerUsesBlack}"); return (id, $"civilian formwork={item.NpcFormworkId} poi={item.PoiActionId}"); } catch (Exception ex) { conn.Log.Warn($"[GOMOKU] 造对手 NPC 失败:{ex.Message}"); return (GomokuFallbackUid(), "exception:" + ex.Message); } } /// /// 挑对手模板:优先用客户端请求里带来的 agentId(实测 40131483「廖永强」), /// 它不在 NpcCatalog 里时才退回通用市民(骰子 PickDiceOpponentItem 同款挑法)。 /// private static CivilianSpawnItem? PickGomokuOpponentItem(uint agentId) { var formwork = agentId != 0 ? agentId : GomokuDefaultOpponentFormwork; if (DonorNpcCatalog4229938.TryGet(formwork, out _)) return new CivilianSpawnItem(formwork, GomokuDefaultPoiActionId); foreach (var g in CivilianGroups) { if (g.SystemOnly) continue; foreach (var i in g.Items) return new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId); } return null; } /// 摊位表里离玩家最近的那个五子棋机关;表为空时返回 null。 private static MiniGameStallItem4229938? FindGomokuStall(float px, float pz) { MiniGameStallItem4229938? best = null; var bestD2 = double.MaxValue; foreach (var spot in MiniGameStallCatalog4229938.Spots) foreach (var it in spot.Items) { if (!string.Equals(it.Name, GomokuStallItemName, StringComparison.OrdinalIgnoreCase)) continue; var dx = it.X - px; var dz = it.Z - pz; var d2 = dx * dx + dz * dz; if (d2 < bestD2) { bestD2 = d2; best = it; } } return best; } private static long s_gomokuFallbackOrdinal; /// /// 造 NPC 失败时的兜底 uid(骰子 FallbackUid 同款)。刻意不是 0 —— /// 0 会被客户端当成「进场失败」(套圈注释的判据),至少让它走到 /// 「等待 Agent 实体同步」而不是当场判死,日志里也好区分。 /// private static ulong GomokuFallbackUid() => GameAdminBridge.ServerSpawnUidFloor + 0x910000UL + (ulong)Interlocked.Increment(ref s_gomokuFallbackOrdinal); }