using Ananta.SDK.Network; 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; /// /// 搜打撤的 GM / 面板面 —— 手动进出图、出场结算、状态输出、轮盘内容列举。 /// /// 玩法逻辑本身在 GameRouter.Extraction.cs(背包/容器/搜索)与 /// GameRouter.ExtractionWeapons.cs(轮盘实体化);这里只放「人要看 / 人要点」的那一层。 /// /// ⛑ 三条排障常识(面板帮不上忙的那些): /// 1. 改了没生效 → 先比对源码 mtime 与 logs/console-latest.log 里的服务端启动时间, /// 确认这一版真的被跑过(同一天里踩过两次:跑的是改之前的 dll)。 /// 2. [Handler] ≠ 已注册 → 还要进 GameRouter.cs 的 Enabled4229938MethodIds, /// 漏了的判据是日志那行 unimplemented … typed-default void 0b。 /// 3. 某一片 UI 整体空 → 客户端日志搜 attempt to perform arithmetic / /// BindingWidgetShow failing,一个 nil 算术会崩掉整棵 store 树,不是只坏一个面板。 /// internal sealed partial class GameRouter { // ------------------------------------------------------ runtime-overridable switches // 面板上的勾选项改内存里这四份,不必改 json、不必重启;重启之后回落到配置文件的值。 private static bool? s_extractionFakeMatchOverride; private static uint? s_extractionPreferredRaidOverride; /// 「伪造匹配」开关 —— 见 GameRouter.ExtractionMatch.cs 顶部说明。 internal static bool ExtractionFakeMatch { get => s_extractionFakeMatchOverride ?? ExtractionCfg().FakeMatch; set { s_extractionFakeMatchOverride = value; Console.WriteLine($"[EXTRACT] fake match = {value}"); } } /// 伪造匹配要去的 raid。 internal static uint ExtractionPreferredRaid { get => s_extractionPreferredRaidOverride ?? ExtractionCfg().PreferredRaid; set { s_extractionPreferredRaidOverride = value; Console.WriteLine($"[EXTRACT] preferred raid = {value}"); } } // ------------------------------------------------------------------ enter / exit /// /// 把人切进一张搜打撤图(面板「进图」按钮与伪造匹配共用这一条)。 /// /// 为什么不能直接改 state.ActiveRaidId:只有 SwitchSceneAsync 会重新起一次 /// 进图事务(推新的 world-entry generation、重推 SyncEnterScene、重建角色控制), /// 手动改字段的结果是「服务端认为进图了、客户端还站在老图上」。 /// 进图之后的搜打撤专属包(SyncPlayerEnterExtractionShooter 空体 + 各包容量)由 /// GameRouter.World.cs 在事务收尾时调 PushExtractionEnterAsync 补, /// 和真实客户端自己走 EnterRaid 那条路完全同路径 —— 这里不再重复推一次。 /// internal static async Task<(bool Ok, string Message)> EnterExtractionRaidAsync( TcpSession session, WorldEntryState state, uint raidId) { var cfg = ExtractionCfg(); if (!cfg.Enabled) return (false, "搜打撤整套已关(world.content.extraction.enabled=false)"); if (!IsExtractionRaid(raidId)) return (false, $"raid {raidId} 不在搜打撤名单里(world.content.extraction.raidIds)"); if (!GameAdminBridge.TryGetScenePreset(raidId, out var preset) || preset is null) return (false, $"raid {raidId} 没有收录进场点位 —— 先在「场景切换」里确认这条 preset 存在"); var owner = EnsureExtractionSeeded(session); await SwitchSceneAsync(session, preset.RaidId, preset.InstanceId, preset.UniverseId, preset.X, preset.Y, preset.Z, preset.Facing, "extraction-enter"); session.Log.Info($"{ExtractionTag} enter raid={preset.RaidId} scene={preset.InstanceId} " + $"@ ({preset.X}, {preset.Y}, {preset.Z}) bags={owner.Bags.Count}"); return (true, $"已切进 raid {preset.RaidId}(scene {preset.InstanceId}," + $"落点 {preset.X}/{preset.Y}/{preset.Z})"); } /// /// 出场结算 SyncRaidSettlement(68995324):退图之前推,让客户端走完整的结算流程。 /// /// ⛑ 线格式见 的注释 —— /// 零战斗数据恒 32 字节,这是唯一能肉眼核对的东西;字节数一变,说明 complex 被写成 Inline 了。 /// 我们不填战斗数据(SpiritBattleDatas 恒空):既没抓到过样本, /// 也不值得为一段 UI 动画去碰 Auto.Reader[1334] 那套未知布局。 /// internal static async Task PushExtractionSettlementAsync(TcpSession session, bool isWin) { var owner = ExtractionStateOf(session); uint enterRaidTime; lock (owner.SyncRoot) enterRaidTime = owner.EnterRaidSeconds; var state = GetStateIfExists(session); ulong instanceId = 0; if (state is not null) lock (state.SyncRoot) instanceId = state.ActiveInstanceId; var now = NowSeconds(); await session.NotifyAsync(MethodId.SyncRaidSettlement, UxSerializer.Serialize(new SceneMethods.SyncRaidSettlementArgs4229938 { InstanceId = instanceId, IsWin = isWin, Settlement = new SceneMethods.ExtractionShooterSettlementData { EnterRaidTime = enterRaidTime, BattleTime = enterRaidTime == 0 ? 0d : Math.Max(0, now - enterRaidTime), ElementEffectCount = 0, }, CloseTime = now, }), CancellationToken.None); session.Log.Info($"{ExtractionTag} settlement pushed instance={instanceId} win={isWin} " + $"battleseconds={(enterRaidTime == 0 ? 0 : Math.Max(0, now - enterRaidTime))}"); } // ------------------------------------------------------------------ panel state /// /// GET /api/extraction/status 的返回体。 /// /// 字段命名用小写驼峰 ⇒ 与前端 JS 一一对应;这里的形状改动时记得同步 /// AdminWebServer.cs 里 exStatus() 那段,它是唯一消费者。 /// internal static object ExtractionStateForPanel() { var cfg = ExtractionCfg(); var target = GameAdminBridge.RequirePlayerSession(); ExtractionShooterSessionState owner; WorldEntryState? state = null; uint activeRaid = 0; if (target is null) { owner = new ExtractionShooterSessionState(); } else { owner = ExtractionStateOf(target.Value.Session); state = target.Value.State; lock (state.SyncRoot) activeRaid = state.ActiveRaidId; } var inRaid = IsExtractionRaid(activeRaid); var content = ExtractionWheelContent(owner); return new { enabled = cfg.Enabled, raidIds = cfg.RaidIds.Select(id => id.ToString()).ToArray(), activeRaid = activeRaid.ToString(), inExtractionRaid = inRaid, fakeMatch = ExtractionFakeMatch, preferredRaid = ExtractionPreferredRaid.ToString(), exitToMainCity = cfg.ExitToMainCity, exitSettlement = cfg.ExitSettlement, catalogError = ExtractionShooterCatalog4229938.LoadError, online = target is not null, bags = BagRows(owner), containers = owner.Containers.Count, tokens = owner.TokenCount, wheel = new { mode = ExtractionWheelSync.ToString(), weaponData = ExtractionShooterRuntime4229938.WheelWeaponDataEnabled, bagId = ExtractionShooterCatalog4229938.WheelBagId, slotCells = ExtractionShooterRuntime4229938.WheelSlotCells4229938, homeOffset = ExtractionShooterRuntime4229938.WheelHomeOffset4229938, weapons = content.Select(w => new { index = w.Index, cellX = w.CellX, itemId = w.WireItemId, templateId = w.SceneItemTemplateId, instanceId = w.InstanceId.ToString(), }).ToArray(), inert = ExtractionWheelInertItems(owner) .Select(x => new { cellX = x.CellX, itemId = x.WireItemId }).ToArray(), }, }; } /// 每个包一行:id + 当前格数 + 物品数(面板用来确认「到底有没有东西」)。 private static object[] BagRows(ExtractionShooterSessionState owner) { var rows = new List(); lock (owner.SyncRoot) { foreach (var bag in owner.Bags.Values) { rows.Add(new { bagId = bag.BagId, size = $"{bag.Meta.CapacityX}x{bag.Meta.CapacityY}", items = bag.Items.Count, slotGroup = bag.IsSlotGroup, }); } } return rows.ToArray(); } /// /// 轮盘里会被实体化的那些武器(index = CellX + 2)。 /// internal static IReadOnlyList<(int Index, uint CellX, uint WireItemId, uint SceneItemTemplateId, ulong InstanceId)> ExtractionWheelContent(ExtractionShooterSessionState owner) => ExtractionShooterRuntime4229938.WheelWeapons(owner) .Select(w => ((int)(w.CellX + ExtractionShooterRuntime4229938.WheelHomeOffset4229938), w.CellX, w.WireItemId, w.SceneItemTemplateId, w.InstanceId)) .ToArray(); /// /// 轮盘上但不参与 Tab 切枪的物品(牛奶 / 药剂这类 —— 有格子、没有军械实例)。 /// internal static IReadOnlyList<(uint CellX, uint WireItemId)> ExtractionWheelInertItems( ExtractionShooterSessionState owner) { var armed = ExtractionShooterRuntime4229938.WheelWeapons(owner) .Select(w => w.CellX).ToHashSet(); var result = new List<(uint, uint)>(); lock (owner.SyncRoot) { if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag)) return result; foreach (var item in bag.Items) { var cellX = (uint)item.CellX; if (armed.Contains(cellX)) continue; result.Add((cellX, ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId))); } } return result; } /// /// 面板「重推登录帧」:SyncPlayerInfo(64682159) 再发一次。 /// /// 登录帧里的 InfoMinor.PlayerExtractionShooterInfo 是唯一把包柜塞给客户端的通道 /// (),后期再改那些 bag 状态 /// 客户端不会自己来问 ⇒ 排障时想"把整套包结构重来一遍",按这个按钮比重新登录快得多。 /// internal static async Task<(bool Ok, string Message)> RepublishExtractionLoginFrameAsync() { var target = GameAdminBridge.RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var (session, state) = target.Value; await session.NotifyAsync(MethodId.SyncPlayerInfo, UxSerializer.Serialize(RuntimePayloadFactory.MinimalPlayerInfo4229938(state)), CancellationToken.None); return (true, "已重推 SyncPlayerInfo(含 PlayerExtractionShooterInfo)"); } /// /// 面板「任意 notify 探针」:把一条裸包按指定 methodId 直接推给客户端。 /// /// 用途:搜打撤还剩的最大空白是撤离点 SyncExtractionShooterEvacuationRooms 这类 /// 「不知道客户端到底认什么形状」的包。靠猜契约再编译再进游戏太慢,用这个探针可以直接 /// 往客户端打任意 methodId + 任意 hex,从 Player.log 的 /// deserialize failed / attempt to perform arithmetic 反推它要什么(硬规则 15)。 /// /// ⛑️ 纯调试通道,不写任何状态、不进白名单(S2C 方向本就不需要)。 /// ⛑️ hex 为空 = 推空体,用来试探「客户端有没有这一条的 impl」(空体最不容易崩)。 /// 移植自捐赠端 v1.7(GameRouter.PushExtractionNotifyAsync)。 /// internal static async Task<(bool Ok, string Message)> PushExtractionNotifyAsync(uint methodId, string? hex) { var target = GameAdminBridge.RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var payload = Array.Empty(); if (!string.IsNullOrWhiteSpace(hex)) { try { payload = Convert.FromHexString(hex.Trim()); } catch (Exception ex) { return (false, $"hex 解析失败:{ex.Message}"); } } var session = target.Value.Session; await session.NotifyAsync(methodId, payload, CancellationToken.None); Console.WriteLine($"[EXTRACT] notify probe methodId={methodId} bytes={payload.Length}"); return (true, $"已推 {methodId}({payload.Length}b)"); } }