using Ananta.SDK.Network; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; // ★ 刮刮乐:ScratchWire.SubTypeLevelOne namespace Ananta.Server.Handlers.Game; /// /// 龙栖村(新祈村)庙会摊位「布景」—— 服务端主动把摊位机关摆到官方世界坐标上。 /// /// ★ 用户的原话:「现在能像别人服务那样把龙栖村所有的摊位布景都一起刷出来吗」 /// /// ── 与对照包的关系(第 89 轮已核实,别再多花时间)──────────────────────────── /// 对照包 `DRMK-PS_08.05_Lyco-v0.2.1` 里**没有任何摊位摆位代码**: /// grep SyncGadgetAdd|GadgetEntityInfo|MiniGameStall 只命中它自己的 /// GameRouter.cs / GameRouter.SceneDeviceState.cs / MethodId.cs。 /// 它只做了**打气球一个**小游戏的完整链路,其余摊位(套圈/刮刮乐/五子棋…) /// 连 wire 契约都没有(它的 Ananta.RpcTypes/Methods/Game/ 只有 BalloonZone + RacingZone)。 /// ⇒ 「像别人那样把摊位刷出来」这件事,**我方是走在前面的一方**,不是补课。 /// 对照包它自己也只有一句「入口空 ACK」,摊位本体照样看不见。 /// /// ── 为什么必须服务端摆(与健身房器材 / 派对家具 / 补货物件完全同因)──────────── /// 这批摊位机关(Gadget_DaQiQiu 912387408 / Gadget_RingToss 1937674505 / /// ScratchGame 165071345 / Gadget_WuZiQi 2066229997 …)的真身分两半: /// ① 客户端 Spoon 图里有一个 GADGET 节点(StartBalloonNode / StartRingTossNode …) /// ② 关卡块(vfc_1479 / vfc_859)里有一份**官方世界摆位** /// 而它**不在**客户端会自己实例化的清单里 —— grid-AOI 下发(68956797)**不驱动** /// LX6.Item.GadgetBlob.OnAoiAddBlobCfgByGridIndex ⇒ 一件都不会自己出现。 /// 唯一可行路径 = 服务端拿 pathId + 官方坐标显式走 SyncGadgetAdd。 /// /// ── 数据来源(别再搞混两套 id 空间)──────────────────────────────────────── /// pathId = ClientData/4229938/Configs/GadgetCatalog.json(2935 条,服务端用) /// ⛔ 不是客户端 GadgetConfig.json 的 1367 条(另一套; /// 同一件 Gadget_DaQiQiu 在那边是 79304020,在服务端 catalog 是 912387408) /// 世界坐标 = Configs/MiniGameStalls.json(解自关卡块,工具 /// gym_tools/_r87_minigame_gadget.py,记录结构 = 命中点-28 起头, /// +0/+4/+8 = f32 x/y/z,+16 = yaw,+28 = pathId) /// /// ── 判据(回答「是不是要按 F 才开始摆」)────────────────────────────────── /// **不是。** 摆位是「玩家走进锚点半径」触发的常驻世界装点,一次会话每片区只摆一次。 /// 与玩家有没有交互、摊位开没开、Lua 有没有跑到那里**都无关** —— 这是纯服务端行为。 /// 反过来说:**看得见摊位 ≠ 摊位能玩**。能玩要各自的小游戏入口 RPC /// (打气球 EnterBalloonZone 已实现;套圈 EnterRingTossZone 67108425 见下)。 /// /// ⛔ PackedGadgetInfo.iScale 必须是 **0**(100 ⇒ transform scale (0,0,1) 零尺寸空壳, /// 看得见实体、看不见模型)。与气球的 DestructibleInfo.IScale = 1000 是 /// **同名不同尺度的两个字段**(铁律 39;本条在 R89 上真踩过一次,别再来)。 /// /// Rollback: world.content.miniGameStalls.enabled = false(config 热重载,不需重编译)。 /// internal sealed partial class GameRouter { private const string MiniGameStallKey = "client4229938-minigame-stall"; /// /// ★ 刮刮乐桌的 pathId —— 走**服务端** catalog 空间 /// (ClientData/4229938/Configs/GadgetCatalog.json),不是客户端 /// GadgetConfig.json 的 1367 条那套(铁律:两个 id 空间别混)。 /// 数据侧:Configs/MiniGameStalls.json 的 { "pathId": 165071345, "name": "ScratchGame" }。 /// private const int ScratchTablePathId = 165071345; internal sealed class MiniGameStallSessionState { internal object SyncRoot { get; } = new(); /// 摊位片区本轮已摆过的名字。一次会话每片区一次,和健身房 / 营地一样。 internal HashSet Placed { get; } = new(StringComparer.Ordinal); internal int PlacedCount; /// 空表只抱怨一次,不每 tick 刷屏。 internal bool WarnedEmpty; /// 只记一次「跳过了几件」的明细,避免每片区重复。 internal HashSet SkipLogged { get; } = new(); // ── ★ 刮刮乐「走近自动叫面板」的状态 ────────────────────────────── /// 服务端自己 mint 出来的 ScratchGame 桌 instanceId(`[GADGET] add id=…`)。 internal ulong ScratchGadgetUid; internal float ScratchX = float.NaN; internal float ScratchZ = float.NaN; /// 上一刻玩家是否在圈内(迟滞用)。 internal bool ScratchNear; /// 本轮是否已推过(进圈只推一次)。 internal bool ScratchOffered; } private static MiniGameStallSessionState MiniGameStallStateOf(TcpSession session) { if (session.Items.TryGetValue(MiniGameStallKey, out var existing) && existing is MiniGameStallSessionState typed) return typed; var created = new MiniGameStallSessionState(); session.Items[MiniGameStallKey] = created; return created; } /// 重连 / 切场景:忘掉已摆记录,下次进图重摆(客户端那边实例已随会话消失)。 internal static void ResetMiniGameStall(TcpSession session) { var owner = MiniGameStallStateOf(session); lock (owner.SyncRoot) { owner.Placed.Clear(); owner.PlacedCount = 0; owner.WarnedEmpty = false; owner.SkipLogged.Clear(); owner.ScratchGadgetUid = 0; owner.ScratchX = float.NaN; owner.ScratchZ = float.NaN; owner.ScratchNear = false; owner.ScratchOffered = false; } } /// /// 由网格 AOI tick 调用(它已经知道玩家位置)。把玩家走进的每个摊位片区的机关摆一次。 /// internal static async Task TickMiniGameStallAsync( TcpSession session, WorldEntryState state, float px, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls; if (!cfg.Enabled) return; var owner = MiniGameStallStateOf(session); if (MiniGameStallCatalog4229938.Count == 0) { // 每会话一行即可 —— 缺数据文件是「还没抓点位」这个准备步骤,不是运行时故障。 lock (owner.SyncRoot) { if (owner.WarnedEmpty) return; owner.WarnedEmpty = true; } session.Log.Info($"[STALL] 没有可用摊位:{MiniGameStallCatalog4229938.LoadError}"); return; } var channel = cfg.Channel switch { 1 => GadgetSendChannel.Aoi, 2 => GadgetSendChannel.Both, 3 => GadgetSendChannel.AddList, _ => GadgetSendChannel.Add, }; foreach (var spot in MiniGameStallCatalog4229938.WithinRadius(px, pz, 0)) { var radius = spot.Radius + cfg.RadiusBonus; if (radius > 0f) { var dx = spot.X - px; var dz = spot.Z - pz; if (dx * dx + dz * dz > radius * radius) continue; } lock (owner.SyncRoot) { if (!owner.Placed.Add(spot.Name)) continue; } if (spot.Items.Count == 0) { session.Log.Info($"[STALL] 片区 {spot.Name} 还没有摊位坐标(先跑 _r87_minigame_gadget.py)"); continue; } session.Log.Info($"[STALL] 进入 {spot.Name}(raid={spot.Raid} 锚点 {spot.X:F1},{spot.Z:F1} " + $"半径 {radius:F0}m),放置 {spot.Items.Count} 件摊位 channel={channel} " + $"logOnly={cfg.LogOnly}"); if (cfg.LogOnly) continue; var placed = 0; if (channel == GadgetSendChannel.AddList) { // 批量:一次推完整个片区。uniqueId=0 ⇒ 服务端自己 mint。 var specs = spot.Items .Select(i => new GadgetSpawnSpec(i.PathId, i.X, i.Y, i.Z, i.Yaw, i.UniqueId, i.Name)) .ToList(); var batch = await SpawnGadgetListAsync(session, state, specs, cfg.IScale); placed = batch.Ok ? batch.Count : 0; if (!batch.Ok) session.Log.Warn($"[STALL] {spot.Name} 批量放置失败:{batch.Message}"); } else { foreach (var item in spot.Items) { // iScale 传 0 —— ⛔ 这是 PackedGadgetInfo.iScale 的语义, // 与气球的 DestructibleInfo.IScale=1000 不是一个字段(铁律 39)。 var result = await SpawnGadgetAsync( session, state, item.PathId, item.X, item.Y, item.Z, item.Yaw, channel, item.UniqueId, 0); if (result.Ok) { placed++; // ★ 刮刮乐桌:记住服务端 mint 出来的 instanceId —— 推面板要用它 // (配表里 UniqueId = 0 ⇒ 只有 SpawnGadgetAsync 的返回值可信)。 if (item.PathId == ScratchTablePathId) { lock (owner.SyncRoot) { owner.ScratchGadgetUid = result.Id; owner.ScratchX = item.X; owner.ScratchZ = item.Z; } } session.Log.Info($"[STALL] + {item.Name}({item.PathId}) " + $"@({item.X:F2},{item.Y:F2},{item.Z:F2}) yaw={item.Yaw:F1} " + $"uid={item.UniqueId}"); } else { session.Log.Warn($"[STALL] {spot.Name} {item.Name}({item.PathId}) 放置失败:{result.Message}"); } } } lock (owner.SyncRoot) owner.PlacedCount += placed; session.Log.Info($"[STALL] {spot.Name} 放置完成 {placed}/{spot.Items.Count}" + (placed == spot.Items.Count ? "" : $"({spot.Items.Count - placed} 件没发出去)")); // ★ 摆完之后看一眼玩家是不是正站在刮刮乐桌前(见 MaybeOfferScratchAsync)。 await MaybeOfferScratchAsync(session, owner, px, pz); } } /// /// ★★★ 玩家走近刮刮乐桌 ⇒ 推一条 SyncStartScratchGameZone(68649573),把面板叫出来。 /// /// ── 为什么必须服务端「主动」推 ──────────────────────────────────────── /// 客户端**不发** AskStartScratchGame:它在 2349 个 Lua 文件里 0 命中, /// 9 份 packets-*.log 里也 0 命中(服务端 handler 已登记白名单、从未被调用过)。 /// 而刮刮乐面板的**唯一**入口是服务端推 SyncStartScratchGameZone: /// GameSceneToClientImpl.lua:1131 → gScratchGameManager:Show(gadgetUid, subType, result) /// ScratchGameManager.lua:7:result == nil ⇒ return;否则 /// GetEntitySearchByInstanceId(gadgetUid) → RegisterWaitLoad → /// Find("ItemViewNode/UIPose") → CheckShow(SCRATCHCARD_PANEL, …)。 /// ⇒ 服务端不推,「通用交互提示」永远只是提示、没有选项。 /// 详细 wire 见 GameRouter.MiniGameEntry.PushScratchStartAsync。 /// /// ── 迟滞(别在边界上连推)──────────────────────────────────────────── /// 进圈(dist ≤ ScratchOfferRadius)推**一次**;出圈超过 ScratchRearmRadius /// 才复位,允许再进来再推。玩家一直站在圈里 ⇒ 只推一条。 /// /// ── 前提 ───────────────────────────────────────────────────────── /// 桌子的 gadgetUid 必须是**服务端自己 mint 的那个**([GADGET] add id=…)—— /// 配表里 UniqueId = 0,所以只有 SpawnGadgetAsync 的返回值可信。 /// 没摆到(uid==0)或走 AddList 批量通道(那条路目前不回收 id)⇒ 直接返回,什么都不做。 /// /// Rollback: world.content.miniGameStalls.scratchAutoOpen = false(配置热重载,不用重编译)。 /// private static async Task MaybeOfferScratchAsync( TcpSession session, MiniGameStallSessionState owner, float px, float pz) { var cfg = PrivateServerConfigStore.Current.World.Content.MiniGameStalls; if (!cfg.ScratchAutoOpen) return; ulong gadgetUid; float tx, tz; lock (owner.SyncRoot) { gadgetUid = owner.ScratchGadgetUid; tx = owner.ScratchX; tz = owner.ScratchZ; } if (gadgetUid == 0 || float.IsNaN(tx) || float.IsNaN(tz)) return; var dx = tx - px; var dz = tz - pz; var dist2 = dx * dx + dz * dz; var offerR = MathF.Max(0.5f, cfg.ScratchOfferRadius); var rearmR = MathF.Max(offerR + 0.5f, cfg.ScratchRearmRadius); // 迟滞必须 > 触发半径 var shouldPush = false; lock (owner.SyncRoot) { if (dist2 <= offerR * offerR) { if (!owner.ScratchNear) { owner.ScratchNear = true; if (!owner.ScratchOffered) { owner.ScratchOffered = true; shouldPush = true; } } } else if (dist2 > rearmR * rearmR) { owner.ScratchNear = false; owner.ScratchOffered = false; } } if (!shouldPush) return; await PushScratchStartAsync(session, gadgetUid, ScratchWire.SubTypeLevelOne); } }