406 lines
22 KiB
C#
406 lines
22 KiB
C#
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);
|
||
}
|