This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

406 lines
22 KiB
C#
Raw Permalink 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 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;
/// <summary>
/// 五子棋**单机**(人机 / 残局)—— <c>EnterGomokuZoneDoubleAI</c>(67614971) 与
/// <c>EnterGomokuZoneEndGame</c>(67341006)。
///
/// <para>★ 现象与根因链(四刀,每一刀都有本机取证)</para>
/// <list type="number">
/// <item><b>R153 入口未登记</b>:这两条从未进白名单 ⇒ 走 <c>DefaultReturnCatalog4229938</c>
/// 兜底(:1611 / :1710)回 8 个 0x00 = **裸 u64 = 0**。客户端
/// <c>[warning]StartGomoke AI: TryGetLogicAgent fail, uid=0</c> /
/// <c>StartGomoke EndGame: TryGetLogicAgent fail, uid=0</c>。
/// ⇒ 必须登记并回一个**真实存在的**实体 uid。</item>
/// <item><b>R155 只有 uid 装不下身份</b>:<c>SyncLogicAgentEnter</c> 的 interop 签名只有一个 ulong,
/// 而 <c>LogicAgent.AgentConfigId</c> 是 uint ⇒ 只注册 uid 会让客户端报
/// <c>创建AgentUnit失败!AgentConfigId为0!</c>。
/// 唯一能送 AgentConfig 的通道是 **Aether 静态 NPC**(<c>NpcFormworkId</c> 就是 AgentConfig id),
/// 而客户端在请求体第 2 个字段 <c>agentid</c> 里**自己把该填哪个告诉我们了**。</item>
/// <item><b>R156 缺棋局状态推送(本轮真正卡住落子的那一刀)</b>:
/// 客户端点子链是 <c>TryRayIntersectBoard</c> → <c>PlaceGomokuPiece</c> →
/// <c>AskPlaceGomokuPiece</c>,而闸门是 <c>GomokuManager.get_NowMyTurn()</c>;
/// 唯一能置起"我的回合"的输入就是 <c>SyncGameGroundZoneInfo</c>(68004609)。
/// 实测 <c>AskPlaceGomokuPiece</c> / <c>PlaceGomokuPiece</c> / <c>SyncGameGroundZoneInfo</c>
/// 在全部历史日志里 **0 命中** ⇒ 客户端从没收到过 ⇒ 闸门恒关 ⇒ **一条落子包都不发**。</item>
/// <item><b>R185 参与方列表不能为空</b>:客户端靠 <c>ipairs(zoneInfo.ParticipantInfos)</c> 拿到
/// <c>pendingOpponentAgentId</c>。推空列表 ⇒ 它永远是 0 ⇒ 闸门还是不开。
/// 且列表元素是**多态**的,每个元素自带 1B tag = <c>GomokuWire.ParticipantTagGomoku</c>(12);
/// 若用裸 <c>[UxContract]</c>(默认 0xFF)会被 Dispatch 判成 undefined 分支全部丢弃。</item>
/// </list>
///
/// <para>★ 三步法与主线自己的 <c>GameRouter.ChineseChess.cs</c>(<c>SpawnChineseChessAiAsync</c>)
/// **完全同形**(那里注释原话:"与五子棋 R153/R155/R157 三步完全同形"):
/// ① <c>SyncLogicAgentEnter</c> 注册 uid → ② <c>SendCivilianAsync</c> 送 AgentConfig + 站位
/// → ③ 把同一个 uid 回给客户端 / 写进 <c>ParticipantInfos[].AgentInstanceId</c>。</para>
///
/// <para>⛑⛑ <b>不要加"等 NPC 落地"的延迟</b>:象棋的实测证据是客户端
/// <c>TryGetOrConvertToUnit</c> 查不到时**会等待**(10 秒后才打"等待服务器对手 NPC 单位超时"),
/// 不是立刻失败 ⇒ 时序不是问题,缺的是状态推送。曾按"时序"假设加过延迟,方向是错的。</para>
///
/// <para>契约 DTO 见 <c>Ananta.RpcTypes/Client4229938/GomokuWireContracts.cs</c>(文件头有逐字段的本机复核)。</para>
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>
/// 对手站位:摊位锚点沿 yaw **背侧**外推多少米(玩家在 yaw 正方向约 2.2m 的顾客位,
/// 对手隔棋盘坐在摊位另一侧)。
/// ⛑ yaw→forward 的约定与骰子/象棋一致:<c>forward = (sin(yaw°), cos(yaw°))</c>
/// (<c>GameRouter.Dice.cs:603</c> 的 <c>atan2(dx, dz)</c> 反解即此约定)。
/// </summary>
private const float GomokuOpponentBackOffsetMeters = 1.6f;
/// <summary>摊位表里五子棋机关的名字(<c>Configs/MiniGameStalls.json</c>)。</summary>
private const string GomokuStallItemName = "Gadget_WuZiQi";
/// <summary>
/// 客户端在 <c>EnterGomokuZoneDoubleAI</c> 里带上来的对手模板(实测 = 40131483「廖永强」,
/// 普通市民 class 9)。服务端优先用它;若它不在 NpcCatalog 里就退回通用市民模板。
/// </summary>
private const uint GomokuDefaultOpponentFormwork = 40131483;
/// <summary>POI 动作缺省值(与 <c>FunctionalNpcSpots.json</c> 的 poiActionIdDefault 同值)。</summary>
private const uint GomokuDefaultPoiActionId = 17;
/// <summary>五子棋 zone 会话序号(进程内自增,只用来拼一个非空的 ZoneSessionId)。</summary>
private const string GomokuZoneSeqCounter = "Gomoku.ZoneSeq";
/// <summary>对手 uid 的会话级缓存键(见 <see cref="SpawnGomokuOpponentAsync"/> 的 R157 注释)。</summary>
private const string GomokuAiUidKey = "Gomoku.AiUid";
/// <summary>记录那个 uid 是给**哪张桌**的(庙会区有多张 WuZiQi 桌,要各有一个对手)。</summary>
private const string GomokuAiGadgetKey = "Gomoku.AiGadgetUid";
// ─────────────────────────────────────────────────────────────────────
// 两条单机入口
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// EnterGomokuZoneDoubleAI(67614971) 人机对战。
/// C2S = u64 gadgetuid + u32 agentid + bool useblackpiece + byte difficulty + bool canuseskill。
/// RET = **裸 u64**(对手实体的真实 uid)。
/// </summary>
[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);
}
/// <summary>
/// EnterGomokuZoneEndGame(67341006) 残局。
/// C2S = u64 gadgetuid + u32 agentid + u32 endgameid。RET = **裸 u64**(同上)。
/// ⛑ 与 DoubleAI 的差别只有:请求里**没有** useBlack 字段 ⇒ 按残局约定「玩家先手 = 黑」。
/// </summary>
[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);
}
/// <summary>把对手 uid 按「裸 u64」拼成 8 字节小端回给客户端。</summary>
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 核心:把「棋局已开始、现在轮到谁、对手是谁」推给客户端
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 推 <c>SyncGameGroundZoneInfo</c>(68004609) + <c>GomokuZoneInfo</c>(多态 tag 13)。
/// 这是「点棋盘没反应」与「能落子」之间缺的那一刀 —— 客户端 <c>get_NowMyTurn()</c>
/// 的置位只有这一个输入来源。
///
/// <para>★ 座位/先手(别与象棋的「颜色」混淆):五子棋 <c>CurrentTurn</c> 是 **SeatIndex 座位号**。
/// <c>GomokuWire.SeatIndexBlack = 0</c>;客户端两个单机入口都 <c>useBlack=true</c>
/// ⇒ 玩家 <c>mySeatIndex = 0</c>、黑棋先手 ⇒ <c>CurrentTurn</c> 必须是 0。
/// 玩家 SeatIndex=0 / AI SeatIndex=1 / CurrentTurn=玩家座位 —— **三者必须自洽**,
/// 写错任一个都是「点不动」。</para>
///
/// <para>回滚 = 删两个 handler 里对本方法的调用(两行)。</para>
/// </summary>
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<GomokuParticipantInfo>
{
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 同形
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 建一个「客户端查得到的 AI 对手」,返回它的**真实 uid**。三步:
/// ① <c>SyncLogicAgentEnter</c> 注册 uid(客户端 <c>TryGetLogicAgent</c> 查的就是这张表);
/// ② 用**客户端给的 agentId** 走 Aether 静态 NPC 通道(<c>NpcFormworkId</c> = AgentConfig id,
/// 不送的话客户端建 Unit 时报 <c>创建AgentUnit失败!AgentConfigId为0!</c>);
/// ③ 把同一个 uid 交回调用方(回包 + 写进 ParticipantInfos)。
///
/// <para>★ R157 复用:**同一会话同一张桌只 spawn 一次**。理由(象棋同源注释原话):
/// 「旧的那个删不掉(本版客户端无可靠删除通道)⇒ 场上越来越多」。
/// 实测本仓 <c>MethodId</c> 里没有 <c>SyncAetherAIStaticNpcRemove*</c>;
/// <c>SyncRemoveNpc</c>(68446556) 的 reader 是 Int32 ⇒ 装不下 830… 段的 u64。
/// ⇒ 与其猜"怎么删",不如**根本不重复建**。</para>
///
/// <para>缓存键带 gadgetUid(庙会区有多张 WuZiQi 桌,要各有一个对手,不能互相顶掉)。
/// 作用域用 <c>TcpSession.Items</c> —— 断线即弃,不会泄漏到别的会话。</para>
/// </summary>
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);
}
}
/// <summary>
/// 挑对手模板:优先用客户端请求里带来的 agentId(实测 40131483「廖永强」),
/// 它不在 NpcCatalog 里时才退回通用市民(骰子 <c>PickDiceOpponentItem</c> 同款挑法)。
/// </summary>
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;
}
/// <summary>摊位表里离玩家最近的那个五子棋机关;表为空时返回 null。</summary>
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;
/// <summary>
/// 造 NPC 失败时的兜底 uid(骰子 <c>FallbackUid</c> 同款)。刻意不是 0 ——
/// 0 会被客户端当成「进场失败」(套圈注释的判据),至少让它走到
/// 「等待 Agent 实体同步」而不是当场判死,日志里也好区分。
/// </summary>
private static ulong GomokuFallbackUid()
=> GameAdminBridge.ServerSpawnUidFloor + 0x910000UL
+ (ulong)Interlocked.Increment(ref s_gomokuFallbackOrdinal);
}