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);
}