using System.Collections.Concurrent; using System.Globalization; using System.Text.Json; using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; using AgentCatalog = Ananta.Server.ClientData.Client4229938.NpcSummonCatalog4229938; using ClientConfigRepository = Ananta.Server.ClientData.Client4229938.ClientConfigRepository; using GadgetCatalog4229938 = Ananta.Server.ClientData.Client4229938.GadgetCatalog4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; namespace Ananta.Server.Handlers.Game; /// Public snapshot of the tracked game session for the admin panel (no internal types leak). public sealed class AdminPlayerSnapshot { public bool Connected { get; init; } public string SessionId { get; init; } = string.Empty; public string? LastSeenUtc { get; init; } public ulong UnitId { get; init; } public uint TemplateId { get; init; } public float X { get; init; } public float Y { get; init; } public float Z { get; init; } public float Facing { get; init; } public bool HasTransform { get; init; } public bool Ready { get; init; } public uint ActiveRaidId { get; init; } public ulong ActiveInstanceId { get; init; } public uint ActiveUniverseId { get; init; } public ulong PendingTeleportId { get; init; } public uint LastAdminVehicleId { get; init; } public ulong LastSpawnedVehicleUid { get; init; } public ulong CurrentVehicleId { get; init; } public int CurrentVehicleSeat { get; init; } public byte LastVehicleDriveState { get; init; } public bool HasVehicleDriveState { get; init; } public uint TimeHour { get; init; } public uint TimeMinute { get; init; } public bool TimeFixed { get; init; } public bool HasExplicitTime { get; init; } public uint WeatherId { get; init; } public bool HasExplicitWeather { get; init; } } /// Public weapon catalog entry for the admin panel. public sealed class AdminWeaponEntry { public uint TemplateId { get; init; } public string Name { get; init; } = string.Empty; public ulong InstanceId { get; init; } } /// Public vehicle catalog entry for the admin panel (flying/water spawner). public sealed class AdminVehicleEntry { public uint TemplateId { get; init; } public string Name { get; init; } = string.Empty; public int VehicleType { get; init; } public int SeatCount { get; init; } public string Category { get; init; } = string.Empty; } /// Public scene preset (raid/instance/universe + arrival) for the admin panel. public sealed class AdminScenePreset { public string Key { get; init; } = string.Empty; public string Label { get; init; } = string.Empty; public uint RaidId { get; init; } public ulong InstanceId { get; init; } public uint UniverseId { get; init; } public float X { get; init; } public float Y { get; init; } public float Z { get; init; } public float Facing { get; init; } } /// Public NPC (character) catalog entry for the admin panel. public sealed class AdminNpcEntry { public uint NpcCultivationId { get; init; } public string Name { get; init; } = string.Empty; public uint SpiritId { get; init; } public int VariantCount { get; init; } public IReadOnlyList Menus { get; init; } = Array.Empty(); } /// One row of the 生成敌人 / 生成NPC catalog (raw AgentConfig agent). public sealed class AdminAgentEntry { public uint AgentId { get; init; } public string Name { get; init; } = string.Empty; public uint EnemyClassType { get; init; } public uint AiSetting { get; init; } /// /// Number of AgentConfig rows sharing this display name inside the same panel. AgentConfig /// holds hundreds of near-identical variants per character (same model, different AI / /// spawn-group), which used to flood the dropdown. Only the first variant is listed. /// public int VariantCount { get; init; } = 1; /// /// Every AgentConfig id that collapsed into this entry, ascending. Kept so the search box can /// still resolve an id that is no longer shown, and so any variant stays reachable. /// public uint[] VariantIds { get; init; } = []; /// /// Whether the client binds an enemy HP bar to this template. The bar is a property of /// AgentConfig (ShowHPBar, plus a non-zero InitWeaponId), not of anything the server pushes: /// no amount of SyncUnitHp / SyncUnitAttacked conjures one onto a template that has none. /// False means the enemy still fights, takes damage and dies — it just shows no bar, which /// reads as "this thing cannot be killed". /// public bool HasHpBar { get; init; } } /// /// Bridge between the game session and the embedded admin web server. /// Fed by the per-frame hook in PrivateServerApplication (every login+game frame); /// only sessions carrying a 4229938 world state are treated as the player session. /// All pushes use the same DTOs + method ids as the in-game handlers. /// public static class GameAdminBridge { private static readonly ConcurrentDictionary Sessions = new(); private static long s_adminTeleportCounter = 950_000; public static void Touch(TcpSession session) => Sessions[session.Id] = (session, DateTimeOffset.UtcNow.Ticks); public static AdminPlayerSnapshot GetSnapshot() { var best = PickPlayerSession(); if (best is null) return new AdminPlayerSnapshot { Connected = false }; var (session, ticks) = best.Value; var state = GameRouter.GetStateIfExists(session); if (state is null) return new AdminPlayerSnapshot { Connected = false, SessionId = session.Id, LastSeenUtc = new DateTimeOffset(ticks, TimeSpan.Zero).ToString("o"), }; lock (state.SyncRoot) { return new AdminPlayerSnapshot { Connected = true, SessionId = session.Id, LastSeenUtc = new DateTimeOffset(ticks, TimeSpan.Zero).ToString("o"), UnitId = state.ActiveSpiritUnitId, TemplateId = state.ActiveSpiritTemplateId, X = state.LastReportedPlayerPosition.X, Y = state.LastReportedPlayerPosition.Y, Z = state.LastReportedPlayerPosition.Z, Facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : state.WorldEntryCreateHeroFacing, HasTransform = state.HasLastReportedPlayerTransform, Ready = state.Ready, ActiveRaidId = state.ActiveRaidId, ActiveInstanceId = state.ActiveInstanceId, ActiveUniverseId = state.ActiveUniverseId, PendingTeleportId = state.PendingTeleportId, LastAdminVehicleId = state.LastAdminVehicleId, LastSpawnedVehicleUid = state.LastSpawnedVehicleUid, CurrentVehicleId = GameRouter.GetVehicleRuntimeState(session) is { } vehicles ? CurrentVehicleOf(vehicles) : 0, CurrentVehicleSeat = GameRouter.GetVehicleRuntimeState(session) is { } vehicles2 ? CurrentSeatOf(vehicles2) : -1, LastVehicleDriveState = state.LastVehicleDriveState, HasVehicleDriveState = state.HasVehicleDriveState, TimeHour = state.TimeHour, TimeMinute = state.TimeMinute, TimeFixed = state.TimeFixed, HasExplicitTime = state.HasExplicitTime, WeatherId = state.WeatherId, HasExplicitWeather = state.HasExplicitWeather, }; } } private static ulong CurrentVehicleOf(GameRouter.VehicleRuntimeState runtime) { lock (runtime.SyncRoot) return runtime.CurrentVehicleId; } private static int CurrentSeatOf(GameRouter.VehicleRuntimeState runtime) { lock (runtime.SyncRoot) return runtime.CurrentVehicleSeat; } public static uint[] FleetIds() { try { return PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds ?? []; } catch { return []; } } /// /// Flying (Type 35) and water (Type 33/34) vehicles from the catalog. These are /// excluded from the regular fleet by default; the admin panel offers a separate /// spawner with a force flag so they can be materialized without editing config. /// public static IReadOnlyList FlyingWaterVehicles() { try { return ClientData.Client4229938.VehicleCatalog4229938.AllSummonable .Where(v => v.VehicleType is 33 or 34 or 35) .Select(v => new AdminVehicleEntry { TemplateId = v.Id, Name = string.IsNullOrWhiteSpace(v.Name) ? $"(type {v.VehicleType})" : v.Name, VehicleType = v.VehicleType, SeatCount = v.SeatCount, Category = v.VehicleType == 35 ? "Flying" : "Water", }) .OrderBy(v => v.Category) .ThenBy(v => v.TemplateId) .ToList(); } catch { return []; } } /// /// Scene presets: curated world spawns first, then every map entrance from the /// game dump (SceneCatalog4229938: RaidConfig SceneId = instance id). Falls back /// to the two curated entries if the catalog fails to load. /// public static IReadOnlyList ScenePresets() { uint universe; try { universe = PrivateServerConfigStore.Current.World.UniverseId; } catch { universe = 76000888; } var result = new List { new() { Key = "nova-spawn", Label = "Nova spawn (WorldMap_Release)", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 3142.669922f, Y = 0f, Z = 2522.780029f, Facing = 0f, }, new() { // 凌云 = RaidConfig Name of 23300999; 定澜北路 = RailStationConfig StationChineseName. Key = "lingyun-dinglan", Label = "定澜北路 — 凌云 (23300999)", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -1638f, Y = 18.025f, Z = 1540.5f, Facing = -6.39965f, }, // Facing is Unity Euler Y in degrees — SwitchSceneAsync stores it verbatim in // state.LastReportedPlayerRotation, and every consumer reads that as degrees. // Drop point re-measured in-game 2026-09-20 (was 1608.66 / 0.01 / 1585.06 @ 200.23, // which landed on the wrong floor — Y 22.07 puts the player inside the apartment). new() { Key = "tafei-apartment", Label = "塔菲公寓", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1607.51f, Y = 22.07f, Z = 1552.87f, Facing = 55.13f, }, // Drop points supplied by the user 2026-09-20. Both live in the open world, so they // reuse Nova City (23300888 / SceneId 20001222) exactly like nova-spawn above. new() { Key = "zhangwang-zhengba", Label = "掌王争霸", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 611.77f, Y = -59.63f, Z = 2208.70f, Facing = 280.18f, }, new() { Key = "heilingjie-restaurant", Label = "黑领结餐厅", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2133.92f, Y = 0.12f, Z = 2011.21f, Facing = 180.47f, }, // Hand-picked drop points supplied by the user (Desk/坐标.txt). SceneId comes from // RaidConfig: 23301277 → 20001319, 23301285 → 20001327 (both Multiverse 76000888). new() { Key = "bushou-dont-look-up", Label = "不要抬头(搜打撤)(23301277)", RaidId = 23301277, InstanceId = 20001319, UniverseId = universe, X = 1505.0f, Y = 15.0f, Z = 1330.0f, Facing = 0f, }, // 2026-09-24 补:这两张和上面那条同为 RaidType 19(搜打撤),SceneId 取自 // Configs/RaidConfig.json 的 SceneId 字段(真源,与捐赠端文档 §5.6 一致)。 // ⛑ 落点是捐赠端文档里那组未经实测的 (0, 10, 0) —— 进去以后如果掉出地形, // 用「传送」卡片落到有地形的地方,再把这里的 Y 改对。 new() { Key = "extraction-final-battle", Label = "搜打撤 · 终局之战 (23301215)", RaidId = 23301215, InstanceId = 20001272, UniverseId = universe, X = 0f, Y = 10f, Z = 0f, Facing = 0f, }, new() { Key = "extraction-tomb-raid", Label = "搜打撤 · 古墓摸金 (23301311)", RaidId = 23301311, InstanceId = 20001352, UniverseId = universe, X = 0f, Y = 10f, Z = 0f, Facing = 0f, }, new() { Key = "tomb-of-terror", Label = "恐怖墓穴(踩空重新传送)(23301285)", RaidId = 23301285, InstanceId = 20001327, UniverseId = universe, X = 569.9f, Y = 267.5f, Z = 971.6f, Facing = 0f, }, // Drop points supplied by the user. All live in the open world // (SceneId 20001222 = Nova City / raid 23300888), same instance as nova-spawn. new() { Key = "gold-hunt-entry", Label = "猎金游戏入口", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1499.48f, Y = -9.98f, Z = 1698.85f, Facing = 169.85f, }, new() { Key = "dianchi-house", Label = "典斥屋", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1139.94f, Y = 0.04f, Z = 2053.36f, Facing = 115.10f, }, new() { Key = "gym", Label = "健身房", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 3925.78f, Y = 0.05f, Z = 1832.75f, Facing = 155.72f, }, new() { Key = "yuesheng", Label = "乐升", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1996.71f, Y = 0.01f, Z = 2322.18f, Facing = 42.44f, }, // The next four arrived as raw coordinates without a facing, so they land facing // north (0). Re-measure in game if the arrival angle matters. new() { Key = "lvyu-mansion", Label = "绿屿公馆", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2260.0f, Y = 160.7f, Z = 1399.6f, Facing = 0f, }, new() { Key = "guannan-beian", Label = "观澜北岸", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2622.9f, Y = 188.5f, Z = 2615.9f, Facing = 0f, }, new() { Key = "suguang-cottage", Label = "素光小筑", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1100.8f, Y = 9.0f, Z = 2375.7f, Facing = 0f, }, new() { Key = "old-goods-warehouse", Label = "旧物仓库公寓", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1655.9f, Y = -8.0f, Z = 2336.2f, Facing = 0f, }, // Mini-game / landmark drops supplied by the user 2026-09-25, all Nova City again. // 钓鱼点和摩天轮 arrived without a heading, so they land facing north (0). new() { Key = "fishing-spot", Label = "钓鱼点", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -4238.2f, Y = 124.3f, Z = -3455.8f, Facing = 0f, }, new() { Key = "bowling-alley", Label = "保龄球", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2688.94f, Y = 0.00f, Z = 1594.57f, Facing = 176.93f, }, new() { // 「飞镖乐队」是误称(2026-09-30 用户纠正)= Sonic Boom,音爆。 // 对得上 NpcShopCatalog.json Shops[0] Id 94100017 "Sonic Boom Front Desk"。 // ⛑ Key 仍保留 dart-band(前端/脚本若引用过就不能改),只改显示名。 Key = "dart-band", Label = "音爆", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1874.88f, Y = -4.74f, Z = 2413.81f, Facing = 99.53f, }, new() { Key = "ferris-wheel", Label = "摩天轮", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1662.44f, Y = 35.3f, Z = 2000.28f, Facing = 0f, }, // 胡闹厨房 arrived without a heading, so it lands facing north (0). new() { Key = "kitchen-chaos", Label = "胡闹厨房", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = 1330f, Y = 19f, Z = 927f, Facing = 0f, }, // ── Smokey Drift(疾速烟尘改装厂)的两个地下工位 ─────────────────────────── // 坐标直取 GameRouter.Teleport.ShopInteriors 的 Interior(=ParkingWaypoint), // 与客户端 CarShopConfig Id=1(西门,indoorID 4) / Id=2(海边,indoorID 47) 一一对应。 // ⛑ Interior 与主城开放世界同为 instance 20001222,只是 y<0 的室内层, // NpcShopCatalog 里两间店的 Shop.Id 恰为 94100069 / 94100070(都叫 Smokey Drift)。 new() { Key = "carshop-west-gate", Label = "西门改装店(94100069)", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1443.23f, Y = -19.992f, Z = 2351.428f, Facing = 180f, }, new() { Key = "carshop-seaside", Label = "海边改装店(94100070)", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2783.912f, Y = -11.92f, Z = 1262.419f, Facing = 0f, }, // 午夜回想没给 raid:坐标 (2868.60, 0.02, 2199.17) 与舞池 (2873.41, 0.03, 2198.43) // 相距不到 5m,同一片区域 ⇒ 按主城开放世界处理。 new() { Key = "midnight-reverie", Label = "午夜回想", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2868.60f, Y = 0.02f, Z = 2199.17f, Facing = 359f, }, new() { Key = "shake-win", Label = "摇摇乐", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -2213.05f, Y = 31.67f, Z = 1559.78f, Facing = 0f, }, new() { Key = "barber-shop", Label = "理发厅", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -1221.87f, Y = 18.05f, Z = 1025.74f, Facing = 92.35f, }, // 露营地没给 raid / 朝向:坐标 (1290.16, 0.97, 2377.57) 与典斥屋、乐升、旧物仓库公寓、 // 西门改装店同属主城西南片区,Y≈地面 ⇒ 按主城开放世界处理,朝向缺省北 (0)。 new() { Key = "campsite", Label = "露营地", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1290.16f, Y = 0.97f, Z = 2377.57f, Facing = 0f, }, // 重霄游戏机没给 raid / 朝向。归属判据(MapentranceConfig.json,93 行入口的高度带): // raid 23300888(Nova)的入口 Y 全在 0 / -1 附近(DK9 HQ 0.0、Freshville -1.095…); // raid 23300999(凌云)的入口 Y 恒在 17.4~18.3(Yaojin Road 17.49、Yanli Lane 18.03…)。 // 本点 Y=19.26 落在凌云这一带,且最近的入口正是 Yaojin Road(Id 229, 23300999, 359m)。 new() { Key = "chongxiao-arcade", Label = "重霄游戏机", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = 0.17f, Y = 19.26f, Z = 1132.14f, Facing = 0f, }, // 气球摊没给 raid。Y=168.5 超出规则 232 的高度带(>30 失效),改用最近入口法: // MapentranceConfig Id 239「Longqi Village」(raid 23300999) 在 (-4506.7, 180.17, -2588.9), // 距本点 342m 且 Y 同带(180 vs 168.5)⇒ 凌云 23300999 / 20001223。 // 同类对照:钓鱼点 (-4238.2, 124.3, -3455.8) 最近的也是 Longqi Village(907m),同 raid。 new() { Key = "balloon-stall", Label = "气球摊", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -4749.00f, Y = 168.50f, Z = -2830.30f, Facing = 313.3f, }, // 街机大全没给 raid。Y=13.08 落在规则 232 两条高度带的缝里(主城 0 / 凌云 17~19), // 高度带判不出 ⇒ 用最近入口法:MapentranceConfig 里最近的 4 个入口**全是 raid 23300888** // (250 新启机场岛屿站 142m / 335 凌云市-重霄 230m / 213 Nova City Airport 277m / 336 277m), // 而凌云 23300999 最近的入口要 649m ⇒ 判 Nova City 23300888 / 20001222。 // ⛑ 待实测:本点 Y=13.08 比主城入口(Y≈0)高出 13m,像室内二层; // IndoorConfig 里街机厅两种 ParentRaid 都有(Id 3/57/67 → 23300888,Id 250/285 → 23300999), // 所以「街机」这个名字本身不足以定 raid,只能靠坐标。 new() { Key = "arcade-hall", Label = "街机大全", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = -685.59f, Y = 13.08f, Z = -1119.94f, Facing = 220.94f, }, // 2026-09-30 用户改名:「舞会」→「斯拜科酒店」(TimelineConfig.json 里作 // 「11-猎金游戏·斯拜科酒店」,与「猎金游戏入口」同一条线)。坐标/raid 未动。 // 当初没给 raid / 朝向,Y=80 超出规则 232 的高度带(>30 失效),两条路判定: // ① 最近入口法:MapentranceConfig 最近的 8 个入口**全是 raid 23300888**, // 最近 Id 334 (2473.2, 1.0, 1169.0) 仅 166m;同带已成实例 > Id 325 (2782.6, -11.958, 1255.8) // ≈ 海边改装店 Interior、Id 343/344 (2277.5, 160.595, 1412.5) ≈ 绿屿公馆 —— 全是 Nova City。 // ② 玩法侧印证:GameRouter.PartyCamp.cs:19 —— 露营/派对的 PartyConfig.RaidId 就是 23300888 // (派对就地举行、不切副本)。 // ⛑ Key 仍保留 dance-party(改名只动显示名,避免引用断裂)。 new() { Key = "dance-party", Label = "斯拜科酒店", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2605.33f, Y = 80f, Z = 1269.74f, Facing = 0f, }, // 足球场没给 raid / 朝向。规则 232 直接命中:Y=0.03 ≈ 主城地面带(凌云带是 17~19) // ⇒ 23300888 / 20001222。最近入口法同向:Freshville Station(Id 201, 23300888) 仅 285m, // 前五名全是 23300888。朝向缺省北 (0)。 new() { Key = "football-field", Label = "足球场", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 1183.34f, Y = 0.03f, Z = 1551.63f, Facing = 0f, }, // 踢毽子没给 raid。Y=170.4 > 30,规则 232 失效 ⇒ 最近入口法: // Longqi Village(Id 239, raid 23300999) 356m,前三名全是 23300999。 // 且本点距既有「气球摊」(-4749.0, 168.5, -2830.3) 仅 26.8m —— 同一片野区、同一 raid。 new() { Key = "kick-jianzi", Label = "踢毽子", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -4775.00f, Y = 170.40f, Z = -2823.70f, Facing = 333.6f, }, // 警卫署没给 raid。规则 232 直接命中:Y=0.56 ≈ 主城地面带(凌云带 17~19) // ⇒ 23300888 / 20001222。最近入口法同向:Coastal Station(Id 206, 23300888) 250m, // 前五名全是 23300888(绿屿公馆 215 / 337 / 340 / 345)。 new() { Key = "guard-station", Label = "警卫署", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2379.94f, Y = 0.56f, Z = 1826.46f, Facing = 35.73f, }, // 五子棋没给 raid。Y=168.4 > 30,规则 232 失效 ⇒ 最近入口法: // Longqi Village(Id 239, raid 23300999) 347m,前三名全是 23300999。 // 与既有「气球摊」(93m) / 「踢毽子」(117.7m) 同一片野区、同一 raid。 new() { Key = "gomoku", Label = "五子棋", RaidId = 23300999, InstanceId = 20001223, UniverseId = universe, X = -4678.20f, Y = 168.40f, Z = -2890.60f, Facing = 317.4f, }, // 王牌车神没给 raid。规则 232 直接命中:Y=-0.11 ≈ 主城地面带(含 -1 那档) // ⇒ 23300888 / 20001222。最近入口法同向:Rixxy Wonderland(Id 200, 23300888) 仅 101m。 // ⛑ 用户给的 yaw = -61.54° 是负数,这里归一化到 [0,360) ⇒ 298.46(-61.54+360)。 // Unity euler y 上两者视觉等价,但既有预设全部是 0~360,保持一致避免下游意外。 new() { Key = "ace-driver", Label = "王牌车神", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 3014.73f, Y = -0.11f, Z = 954.66f, Facing = 298.46f, }, // 电影院没给 raid / 朝向。规则 232 直接命中:Y=1.35 ≈ 主城地面带(凌云带 17~19) // ⇒ 23300888 / 20001222。最近入口法同向:Id 325 (2782.6, -11.958, 1255.8) 144m、 // Rixxy Wonderland(Id 200) 196m,前五名全是 23300888。朝向缺省北 (0)。 new() { Key = "cinema", Label = "电影院", RaidId = 23300888, InstanceId = 20001222, UniverseId = universe, X = 2769.06f, Y = 1.35f, Z = 1111.99f, Facing = 0f, }, }; try { foreach (var p in ClientData.Client4229938.SceneCatalog4229938.Presets) result.Add(new AdminScenePreset { Key = p.Key, Label = p.Label, RaidId = p.RaidId, InstanceId = p.InstanceId, UniverseId = p.UniverseId, X = p.X, Y = p.Y, Z = p.Z, Facing = p.Facing, }); } catch { /* curated defaults above still work */ } return result; } /// /// Resolve a client-supplied raid id to one of the curated arrivals. /// /// Several presets deliberately share a raid id (nova-spawn and tafei-apartment are both the /// main city 23300888), and the client only sends the raid id, so ties resolve to the first /// entry listed. Put the arrival you want the in-game map to use first in /// — the admin panel is unaffected because it picks by Key. /// internal static bool TryGetScenePreset(uint raidId, out AdminScenePreset? preset) { foreach (var p in ScenePresets()) { if (p.RaidId != raidId) continue; preset = p; return true; } preset = null; return false; } /// /// 按 pathId + 世界坐标刷一个物件,走 SyncGadgetAddList(68925813) /// 的单件批量形式(与 PropRestock / 小游戏摊位同一条已验证通道)。 /// /// ⛑⛑ iScale 必须 0:0 ⇒ 客户端原生 1:1;写 100 会建成 /// localScale=(0,0,1) 的空壳(无模型、交互范围缩到原点)。09-23 健身房器材 /// 就是栽在这上面,GymEquipmentSettings.IScale 已从 100 改 0。 /// /// 用途:给街头篮球 QTE 补篮筐。PoiGameConfig.Basket_BallStand /// (BasketballSceneNode.prefab)里没有篮筐模型 —— 只有 /// PivotNode/LanWangCenterPoint(空 Transform,进球判定点)和场边 5 个球架 /// (sc_fd_ty_wjz_lanqiujia01a_pc),玩家站在圆心投篮时目标点是个"虚空点"。 /// 专用篮筐件 Gadgets_Common_BasketballRim = 1086190276 在 /// GadgetCatalog.json 里(客户端认得 ⇒ 不会像 gym 那次变空壳), /// 世界摆位为 0,需要时由本方法刷出来。 /// public static async Task<(bool Ok, string Message, int Count)> SpawnGadgetAtAsync( int pathId, float x, float y, float z, float yaw, int iScale = 0) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0); if (pathId == 0) return (false, "pathId is required", 0); var (session, state) = target.Value; var specs = new[] { new GameRouter.GadgetSpawnSpec(pathId, x, y, z, yaw, 0UL, "") }; var r = await GameRouter.SpawnGadgetListAsync(session, state, specs, iScale); if (!r.Ok) return (false, r.Message, 0); session.Log.Info($"[ADMIN] gadget spawn pathId={pathId} pos=({x:F2},{y:F2},{z:F2}) " + $"yaw={yaw:F2} iScale={iScale} count={r.Count}"); return (true, $"spawned {r.Count} gadget(s) pathId={pathId} at ({x:F2},{y:F2},{z:F2})", r.Count); } /// /// ★★ 街头篮球:把对手(红铁桶 42071029)的身体网格重新点亮(2026-10-03 23:4x)。 /// /// ⛑⛑ 根因(实测,别再猜"没加载"):BasketballNpcCharacter.LoadCharacterModel /// 走 gCS.LuaUtils.GetDialogModelByAgentId(args),模型确实加载出来了—— /// 节点 monster_A321001_p003_gm(Clone) 在 baseUnit.ModelSlot.transform 下, /// active=true、scale=1、离玩家约 8.5m、animator 已挂 basketball_tin_mirror; /// 但 body / body01 两个 SkinnedMeshRenderer.enabled = false, /// 只有辅助体 TouchBendProxy(碰撞代理)亮着 ⇒ 玩家看不见对手。 /// 怀疑是 args.TargetUnit = gCS.LocalUnitMgr:GetNpcByTemplateId(42071029) 为 nil /// (凭空开局 ⇒ 场景里没有该模板 NPC)导致 dialog 模型停在"未上场"态。 /// /// ⛑ 修法:只开 SkinnedMeshRenderer。不能开 Capsule AO Body / /// TouchBendProxy 这类 MeshRenderer 辅助体(AO / 弯曲代理,开了会出怪块)。 /// 实测开启 40s 后仍为 true,引擎不会自动关回去。 /// /// ⛑ 由面板在开局脚本之后延时调用(Lua 环境里 wait / UpdateBeat 都是 nil, /// 延迟只能在浏览器 JS 的 setTimeout 里做)。 /// public static async Task<(bool Ok, string Message)> FixBasketballNpcModelAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); const string script = @" local ok, err = pcall(function() local function P(s) print_error('[DRMK-NPCFIX] ' .. s) end local g = nil pcall(function() g = gBasketballGameManager.currentGame end) if not g then P('no currentGame (game not running?)'); return end local nc = nil pcall(function() nc = g.npcCharacter end) if not nc then P('no npcCharacter'); return end local ms = nil pcall(function() ms = nc.baseUnit and nc.baseUnit.ModelSlot end) local tr = nil if ms then pcall(function() tr = ms.transform end) end if not tr then P('no ModelSlot.transform'); return end local rs = nil pcall(function() rs = tr:GetComponentsInChildren(typeof(UnityEngine.SkinnedMeshRenderer)) end) if not rs then P('no SkinnedMeshRenderer found'); return end local n, turned = rs.Length, 0 for i = 0, rs.Length - 1 do local okR, r = pcall(function() return rs[i] end) if okR and r then local okE, e = pcall(function() return r.enabled end) if okE and e == false then local nm = '?' pcall(function() nm = r.gameObject.name end) pcall(function() r.enabled = true end) turned = turned + 1 P('enabled: ' .. tostring(nm)) end end end local po = nil pcall(function() po = tr.position end) if po then P(string.format('npc at (%.2f,%.2f,%.2f)', po.x, po.y, po.z)) end P(string.format('SMR=%d turnedOn=%d', n, turned)) end) if not ok then print_error('[DRMK-NPCFIX] fatal: ' .. tostring(err)) end "; var r = await GameRouter.RunClientLuaAsync(target.Value.Session, script); return r; } /// Instant move: SyncUnitPositionAndFacing (see GameRouter.TeleportPlayerInstantAsync). public static async Task<(bool Ok, string Message)> TeleportInstantAsync(float x, float y, float z, float? facing) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; ulong unitId; float face; lock (state.SyncRoot) { unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing); state.LastReportedPlayerPosition = new Vec3(x, y, z); state.LastReportedPlayerRotation = new Vec3(0f, face, 0f); state.HasLastReportedPlayerTransform = true; } try { // ⛑ 不是 SyncUnitPosition_P(68233969):本地角色是 NetworkRoleType.None, // NetworkGhostModule.ForceSetPositionAndFacing 会直接拒(Player.log: "Role(None)不可强设位置!!")。 // 正确通道 = SyncUnitPositionAndFacing(68030312),与 GameRouter.Teleport.TeleportPlayerInstantAsync 同源。 var body = UxSerializer.Serialize(WorldCodec.PositionAndFacing(unitId, new Vec3(x, y, z), face)); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, body, CancellationToken.None); session.Log.Info($"[ADMIN] instant teleport unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}"); return (true, $"instant teleport sent: unit={unitId} ({x:F2},{y:F2},{z:F2}) facing={face:F2}"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } /// Loading-flow move: SyncPreTeleportOption + SyncTeleport with a fresh teleport id. public static async Task<(bool Ok, string Message)> TeleportWithLoadingAsync(float x, float y, float z, float? facing) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; float face; ulong teleportId; lock (state.SyncRoot) { face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing); teleportId = (ulong)Interlocked.Increment(ref s_adminTeleportCounter); state.PendingTeleportId = teleportId; state.PendingTeleportPreFinished = false; state.PendingTeleportSyncSent = true; state.LastReportedPlayerPosition = new Vec3(x, y, z); state.LastReportedPlayerRotation = new Vec3(0f, face, 0f); state.HasLastReportedPlayerTransform = true; } try { var pre = new GameMethods.PreTeleportOption { configId = 0, teleportId = teleportId, ForceClear = false, TimeLineDration = 0, position = new GameMethods.TeleportVec3(x, y, z), facing = face, beforeResName = string.Empty, customBeforeTrans = false, beforePosition = new GameMethods.TeleportVec3(x, y, z), beforeRot = new GameMethods.TeleportVec3(0f, face, 0f), loadingResName = string.Empty, afterResName = string.Empty, customAfterTrans = false, afterPosition = new GameMethods.TeleportVec3(x, y, z), afterRot = new GameMethods.TeleportVec3(0f, face, 0f), extParams = string.Empty, }; await session.NotifyAsync(MethodId.SyncPreTeleportOption, UxSerializer.Serialize(pre), CancellationToken.None); var sync = new GameMethods.TeleportOption { teleportId = teleportId, Position = new GameMethods.TeleportVec3(x, y, z), Facing = face, IsSwitchScene = false, WaitTaskResource = false, MapEntranceId = 0, }; await session.NotifyAsync(MethodId.SyncTeleport, UxSerializer.Serialize(sync), CancellationToken.None); session.Log.Info($"[ADMIN] loading-flow teleport teleportId={teleportId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}"); return (true, $"loading-flow teleport sent: teleportId={teleportId} ({x:F2},{y:F2},{z:F2}) facing={face:F2}; watch ReportPre/PostTeleportFinish in logs"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } /// /// Admin vehicle spawn: validates against the catalog/fleet, then runs the same /// direct-scene-spawn push as client summon (SyncLogicVehicleEnter + SyncSpawnVehicle, /// interactable). The car appears beside the player; walk up and press F to drive. /// Set force=true to bypass the fleetIds whitelist (used for flying/water vehicles). /// public static async Task<(bool Ok, string Message, ulong Uid)> SpawnVehicleAsync(uint vehicleId, bool force = false) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0UL); var (session, state) = target.Value; if (!force) { var fleet = FleetIds(); if (fleet.Length != 0 && !fleet.Contains(vehicleId)) return (false, $"vehicleId {vehicleId} is not in gameplay.vehicles.fleetIds ({fleet.Length} entries) — use force=true for flying/water vehicles", 0UL); } Vec3 near; float facing; lock (state.SyncRoot) { near = state.LastReportedPlayerPosition; facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : state.WorldEntryCreateHeroFacing; } try { var result = await GameRouter.SpawnDirectAsync(session, vehicleId, near, facing, "admin-panel"); return (result.Ok, result.Message, result.EntityId); } catch (Exception ex) { return (false, $"spawn failed: {ex.Message}", 0UL); } } /// /// Admin gadget spawn: places one interactive prop from GadgetCatalog.json a few metres in /// front of the player. The client owns the prefab and loads it itself — the server only /// supplies the prefab id and a transform, which is the whole reason this channel works at all /// (unlike DynamicGo, whose ids live inside the client's scene resources). /// /// 0 = SyncGadgetAdd, 1 = SyncGadgetAOIAddAndRemove, 2 = both. public static async Task<(bool Ok, string Message, ulong Id)> SpawnGadgetAsync( int pathId, float distance = 2.5f, int channel = 0) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0UL); var (session, state) = target.Value; Vec3 near; float facing; lock (state.SyncRoot) { near = state.LastReportedPlayerPosition; facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : state.WorldEntryCreateHeroFacing; } // Facing is a compass heading in degrees; +Z is north in this game's coordinate system. var radians = facing * MathF.PI / 180f; var x = near.X + MathF.Sin(radians) * distance; var z = near.Z + MathF.Cos(radians) * distance; try { return await GameRouter.SpawnGadgetAsync( session, state, pathId, x, near.Y, z, facing, (GameRouter.GadgetSendChannel)channel); } catch (Exception ex) { return (false, $"spawn failed: {ex.Message}", 0UL); } } /// Admin gadget clear: removes the props this panel placed. One id, or all of them. public static async Task<(bool Ok, string Message, int Count)> ClearGadgetsAsync(ulong? onlyId = null) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0); var (session, state) = target.Value; try { return await GameRouter.RemoveGadgetsAsync(session, state, onlyId); } catch (Exception ex) { return (false, $"clear failed: {ex.Message}", 0); } } /// /// T2-31 Seymour 大停电 — 把控制器 Gadgets_Hacker_Tetris(1166913245) 放进世界,然后停手。 /// /// 与 的区别不是"换个 pathId"那么简单:那个对象是 /// IsTask=true、带着 startTaskId/endTaskId、用 packed iScale 的任务图物件, /// 普通放置物件那条路径的参数一律不适合它。 /// /// 成功后返回 ok waiting-for-interact:服务端什么都不用再做,接下来要看的是 /// 客户端自己有没有因为这个物件而弹黑客交互(详见 GameRouter.Blackout.cs 头部说明)。 /// /// Panel-triggered blackout. Pass all three coordinates to override the landing spot /// (otherwise Gameplay.BlackoutControllerAtBillboard decides). public static async Task<(bool Ok, string Message, ulong Id)> StartBlackoutAsync( float? x = null, float? y = null, float? z = null) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0UL); var (session, state) = target.Value; try { Vec3? at = x.HasValue && y.HasValue && z.HasValue ? new Vec3(x.Value, y.Value, z.Value) : null; return await GameRouter.StartBlackoutAsync(session, state, at); } catch (Exception ex) { return (false, $"blackout start failed: {ex.Message}", 0UL); } } /// Only tears down our own controller and resets the round bookkeeping. This does not /// restore the city lighting on the client — the client's own graph owns that. public static async Task<(bool Ok, string Message)> StopBlackoutAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; try { GameRouter.ResetBlackoutState(session); return (true, "state reset (controller left to the client's graph to clean up)"); } catch (Exception ex) { return (false, $"blackout stop failed: {ex.Message}"); } } /// Diagnostics: what the server currently believes about the running round. public static BlackoutStatusSnapshot BlackoutStatus() { var target = RequirePlayerSession(); if (target is null) return new BlackoutStatusSnapshot(false, false, 0UL, 0, null); var (session, _) = target.Value; var (id, running, ends) = GameRouter.ProbeBlackout(session); var remaining = running ? (int)Math.Max(0, (ends - DateTime.UtcNow).TotalSeconds) : 0; return new BlackoutStatusSnapshot(true, running, id, remaining, running ? ends.ToString("O") : null); } /// Panel-facing projection (rule 12: public methods cannot return internal types). /// Named "...Snapshot" because a nested type cannot share its parent's member name (CS0102). public sealed record BlackoutStatusSnapshot(bool Online, bool Running, ulong ControllerId, int RemainingSeconds, string? EndsAt); /// Admin catalog: the Gadget prefab groups shown in the panel picker. public static IReadOnlyList<(string Key, string Label, IReadOnlyList<(int PathId, string Name)> Items)> GadgetGroups() => GadgetCatalog4229938.Groups .Select(g => (g.Key, g.Label, g.Items.Select(i => (i.PathId, i.Name)).ToList() as IReadOnlyList<(int PathId, string Name)>)) .ToList(); /// Admin enter: seats the player in the last spawned vehicle (no F-press needed). public static async Task<(bool Ok, string Message)> EnterVehicleAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); try { return await GameRouter.ForceEnterVehicleAsync(target.Value.Session); } catch (Exception ex) { return (false, $"enter failed: {ex.Message}"); } } /// Admin exit: ejects the player from the current vehicle. public static async Task<(bool Ok, string Message)> ExitVehicleAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); try { return await GameRouter.ForceExitVehicleAsync(target.Value.Session); } catch (Exception ex) { return (false, $"exit failed: {ex.Message}"); } } /// /// 自动驾驶巡航速度的面板投影(2026-09-30)。 /// SavedKmh 为 null = config/autonomous-driving.json 还不存在, /// 也就是「没在 GM 里调过速」,此时生效值就是主配置 cruiseSpeed × 3.6。 /// ActiveAutoDrive = 当前有没有正在自驾的车(决定保存时能不能热应用)。 /// public sealed record AutonomousDriveSpeedStatus( float? SavedKmh, float EffectiveKmh, bool ActiveAutoDrive, float MinKmh, float MaxKmh); public static AutonomousDriveSpeedStatus AutonomousDriveSpeed() { float? saved = null; try { if (File.Exists(AutonomousDriveSpeedSettings.FilePath)) saved = AutonomousDriveSpeedSettings.Read(); } catch { // 文件被写坏:按「没调过」显示,面板提示重新保存即可,不能让整个面板 500。 } var fallback = PrivateServerConfigStore.Current.Gameplay.VehicleNavigation.CruiseSpeed * 3.6f; var active = false; var target = RequirePlayerSession(); if (target is not null) { var runtime = GameRouter.GetVehicleRuntimeState(target.Value.Session); lock (runtime.SyncRoot) active = runtime.AutoDriving && runtime.AutoDrivingVehicleId != 0; } return new AutonomousDriveSpeedStatus( saved, saved ?? fallback, active, AutonomousDriveSpeedSettings.MinKmh, AutonomousDriveSpeedSettings.MaxKmh); } /// /// 保存自动驾驶巡航速度(km/h)。有车正在自动驾驶时顺带热应用: /// 撤回旧 Cruise 任务 → 用新速度重建 → 再确认一次,路线不变。 /// ⛑ 没有会话 / 没在开车也算成功 —— 速度已经落盘,下次开自动驾驶生效。 /// public static async Task<(bool Ok, string Message, bool HotApplied)> SetAutonomousDriveSpeedAsync(float kmh) { var min = AutonomousDriveSpeedSettings.MinKmh; var max = AutonomousDriveSpeedSettings.MaxKmh; if (!AutonomousDriveSpeedSettings.Valid(kmh)) return (false, $"速度必须在 {min:0.#}–{max:0.#} km/h 之间(收到 {kmh:0.#})。", false); var target = RequirePlayerSession(); if (target is null) { try { var v = AutonomousDriveSpeedSettings.Save(kmh); return (true, $"已保存 {v:0.#} km/h(当前没有客户端会话,下次开启自动驾驶生效)", false); } catch (Exception ex) { return (false, $"save failed: {ex.Message}", false); } } try { var hot = await GameRouter.ApplyAutonomousDriveSpeedAsync(target.Value.Session, kmh); return (true, hot ? $"已保存并热应用 {kmh:0.#} km/h:当前这辆车立刻按新速度跑(路线不变)" : $"已保存 {kmh:0.#} km/h(当前没有正在自动驾驶的车,下次开启生效)", hot); } catch (Exception ex) { return (false, $"apply failed: {ex.Message}", false); } } /// Admin time set: records + pushes SyncPlayerCurrentTime. public static async Task<(bool Ok, string Message)> SetTimeAsync(int hour, int minute, bool fix, int transition) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (hour is < 0 or > 23 || minute is < 0 or > 59) return (false, "hour must be 0..23 and minute 0..59"); if (transition < 0) transition = 0; var (session, state) = target.Value; lock (state.SyncRoot) { state.TimeHour = (uint)hour; state.TimeMinute = (uint)minute; state.TimeFixed = fix; state.TimeTransitionSeconds = (uint)transition; state.HasExplicitTime = true; // ★ 面板设时间 = 玩家主动改时间,记一次(与 GameRouter.SetSessionTime 同一套判据)。 state.TimeEpoch++; } try { await GameRouter.PushSessionTimeAsync(session); return (true, $"time set to {hour:D2}:{minute:D2} (fix={fix})"); } catch (Exception ex) { return (false, $"time push failed: {ex.Message}"); } } /// Admin weather set: records + pushes SyncPlayerWeather. public static async Task<(bool Ok, string Message)> SetWeatherAsync(int weatherId, int transition) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (weatherId < 0 || transition < 0) return (false, "weatherId and transition must be >= 0"); var (session, state) = target.Value; lock (state.SyncRoot) { state.WeatherId = (uint)weatherId; state.WeatherTransitionSeconds = (uint)transition; state.HasExplicitWeather = weatherId != 0; } if (weatherId == 0) return (true, "weather override cleared (client default resumes)"); try { await GameRouter.PushSessionWeatherAsync(session); return (true, $"weather set to {weatherId}"); } catch (Exception ex) { return (false, $"weather push failed: {ex.Message}"); } } public static object TimeWeatherStatus() { var snap = GetSnapshot(); return new { connected = snap.Connected, hour = snap.TimeHour, minute = snap.TimeMinute, fix = snap.TimeFixed, hasTime = snap.HasExplicitTime, weatherId = snap.WeatherId, hasWeather = snap.HasExplicitWeather, weathers = ClientData.Client4229938.WeatherCatalog4229938.All.Select(w => new { id = w.Id, name = w.Name }).ToList(), }; } /// /// GM 面板「角色日程」卡片:每个可玩角色此刻在干什么、在哪、以及一个已经算好偏移的传送落点。 /// /// 数据全来自官方三张表(养成 AgentTag → 时刻表 → 活动表),与场上真正放人的 /// GameRouter.RosterNpc.cs 走同一个 NpcActivityCatalog4229938.Resolve, /// 所以面板显示的坐标就是服务端实际会放人的坐标。 /// /// ⛑ goto* 故意不落在角色身上:挪到身旁约 3 米,并算出朝向角色的 facing, /// 免得传送完贴在脸上(用户明确要求)。facing 约定与全项目一致: /// atan2(dx, dz) * 180 / π(见 GameRouter.EnemyAiActions.cs:454)。 /// ⛑⛑ 落点必须踩在 CivilianDistricts 的官方人行道上 —— 按固定方向偏移会卡进墙里 /// 或掉进海里(09-25 玩家实测)。 /// public static object RosterSchedule() { var target = RequirePlayerSession(); var cfg = PrivateServerConfigStore.Current.Gameplay.RosterNpc; int daySecond; uint raid; float px, py, pz; uint activeSpirit = 0; Dictionary placed; if (target is null) { daySecond = (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds; raid = Profile.RaidId; px = py = pz = 0f; placed = new Dictionary(); } else { var (session, state) = target.Value; lock (state.SyncRoot) { daySecond = state.CurrentDaySecond(); raid = state.ActiveRaidId; px = state.LastReportedPlayerPosition.X; py = state.LastReportedPlayerPosition.Y; pz = state.LastReportedPlayerPosition.Z; activeSpirit = state.ActiveSpiritTemplateId; } placed = GameRouter.RosterNpcPlacements(session); } var rows = new List(); foreach (var character in ClientConfigRepository.Characters()) { var spiritId = character.TemplateId; if (spiritId == 0) continue; var agentId = AgentIdForSpirit(spiritId); var tag = NpcCultivationCatalog4229938.AgentTagForSpirit(spiritId); // ★ 只登记**官方明确有日程**的角色:NpcDailySchedule 里得有这个 tag 的时刻表。 // OC 占位行没有 AgentTag(培养表里没这一行),辰宿/昭昭/Bernard 有 tag 但没有时刻表 —— // 他们既不会被摆进城里,也不在这张卡片里出现(09-25 用户要求)。 if (tag == 0 || NpcScheduleCatalog4229938.For(tag).Count == 0) continue; var display = agentId != 0 ? SpiritDisplayName(spiritId, character.Name) : character.Name; // 与真正放人走同一个 Resolve:官方活动坐标优先,查不到才退到手工常驻点。 var choice = cfg.DailyLife ? NpcActivityCatalog4229938.Resolve( spiritId, daySecond, raid, cfg.RequirePosition, px, py, pz) : null; var activity = choice?.Entry; var spot = choice?.Spot; var mode = choice?.Mode ?? "off"; float x = choice?.PlaceX ?? 0f, y = choice?.PlaceY ?? 0f, z = choice?.PlaceZ ?? 0f; var placeable = choice?.Placeable == true; // 落点:在角色**身旁约 3 米**,而且必须踩在官方人行道上 —— 直接按固定方向挪会卡进墙里 // 或掉进海里(09-25 玩家实测)。CivilianDistricts.json 里那 13786 个 spot 是服务端 // 自己生成市民用的,全部可站立,所以取「离角色 3 米最近的那个官方 spot」即可。 (float X, float Y, float Z, float Facing) landing = placeable ? TeleportLandingNear(x, y, z) : (x, y, z, 0f); var gx = landing.X; var gy = landing.Y; var gz = landing.Z; var facing = landing.Facing; var distance = placeable ? MathF.Sqrt((x - px) * (x - px) + (z - pz) * (z - pz)) : 0d; rows.Add(new { spiritId, name = display, agentId, tag, isCurrent = spiritId == activeSpirit, mode, activityId = activity?.Id ?? 0, performance = activity?.Performance ?? spot?.Note ?? "", raid = activity?.Raid ?? spot?.Raid ?? 0, placeable, x, y, z, gotoX = gx, gotoY = gy, gotoZ = gz, gotoFacing = facing, distance = placeable ? (int)distance : -1, onField = placed.ContainsKey(spiritId), }); } return new { connected = target is not null, daySecond, clock = $"{daySecond / 3600:D2}:{daySecond % 3600 / 60:D2}", explicitTime = target is not null && target.Value.State.HasExplicitTime, raid, enabled = cfg.Enabled, playerX = px, playerY = py, playerZ = pz, rows, }; } /// /// 「传送过去」的落点:角色身旁约 米,且必须踩在 /// CivilianDistricts 的官方人行道 spot 上。 /// /// ⛑⛑ 为什么不能再按固定方向偏移:7.07m 的斜向挪动把玩家送进过墙里和海里(09-25 玩家实测)。 /// 那 13786 个 spot 是服务端生成满城市民用的、全部可站立,所以取「离角色 3 米最近的那个」 /// 既满足「身旁 3 米」又保证脚下是实地。一个都找不到时才退回固定偏移(并沿用角色自己的 Y)。 /// private const float LandingMeters = 3f; private const float LandingSearchMeters = 8f; private const float LandingMinMeters = 1.2f; private static (float X, float Y, float Z, float Facing) TeleportLandingNear(float x, float y, float z) { Vec3 best = default; var bestScore = float.MaxValue; foreach (var (_, spots) in CivilianDistrictCatalog4229938.WithinRadius(x, z, LandingSearchMeters)) { foreach (var s in spots) { var dx = s.X - x; var dz = s.Z - z; var d = MathF.Sqrt(dx * dx + dz * dz); if (d < LandingMinMeters) // 别贴在角色脸上 continue; var score = MathF.Abs(d - LandingMeters); if (score < bestScore) { bestScore = score; best = s; } } } float gx, gy, gz; if (bestScore < float.MaxValue) { gx = best.X; gy = best.Y; // ⛑ 用人行道自己的高度,别拿角色的(可能差一整层楼) gz = best.Z; } else { // 附近没有官方人行道(室内点位等)⇒ 退回 3m 斜向偏移,Y 用角色自己的。 var step = LandingMeters * 0.7071f; gx = x + step; gy = y; gz = z + step; } var facing = MathF.Atan2(x - gx, z - gz) * (180f / MathF.PI); return (gx, gy, gz, facing); } /// Admin weapon grant: pushes SyncArmoryAddWeapon for a catalog template. public static async Task<(bool Ok, string Message)> GrantWeaponAsync(uint templateId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (templateId == 0) return (false, "templateId must be non-zero"); try { return await GameRouter.GrantWeaponAsync(target.Value.Session, templateId); } catch (Exception ex) { return (false, $"grant failed: {ex.Message}"); } } /// Admin bulk weapon grant: pushes SyncArmoryAddWeapon for every catalog weapon. public static async Task<(bool Ok, string Message)> GrantAllWeaponsAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var session = target.Value.Session; try { // ⛑️ 点按钮时玩家一定在浏览器里 ⇒ 客户端处于 hangup ⇒ 此刻往 socket 灌 671 个包, // 与 2026-09-23 的断线重连风暴是同一形态(客户端不消费消息,TCP 缓冲灌满, // 回到前台时判定断开 ⇒ Reconnect Module 12s 超时)。所以分两种走法: // · hangup 中:立刻返回,后台排队,等玩家切回游戏窗口再发 // · 已在前台:照旧同步发完,面板能拿到真实结果(约 4 秒) if (GameRouter.IsBackgroundPushPaused(session)) { _ = Task.Run(async () => { try { await GameRouter.GrantAllWeaponsWhenReadyAsync( session, 0, GameRouter.GrantAllHangupWaitMs); } catch (Exception ex) { session.Log.Warn($"[WEAPON] admin grant-all (deferred) failed: {ex.Message}"); } }); return (true, "客户端在后台 —— 已排队,切回游戏窗口后自动发放" + "(服务端日志里看 [WEAPON] grant-all complete)"); } return await GameRouter.GrantAllWeaponsWhenReadyAsync(session, 0, 0); } catch (Exception ex) { return (false, $"grant-all failed: {ex.Message}"); } } public static IReadOnlyList WeaponCatalog() => GameRouter.WeaponCatalog().Select(w => new AdminWeaponEntry { TemplateId = w.TemplateId, Name = ClientData.Client4229938.WeaponNameTranslator4229938.EnglishFor(w.TemplateId, w.Name), InstanceId = w.InstanceId, }).ToList(); /// Admin scene ("raid") switch: re-push SyncEnterScene for the destination. public static async Task<(bool Ok, string Message)> SwitchSceneAsync( uint raidId, ulong instanceId, uint universeId, float x, float y, float z, float facing) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (raidId == 0 || instanceId == 0 || universeId == 0) return (false, "raidId/instanceId/universeId must all be non-zero"); if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(facing)) return (false, "coordinates must be finite numbers"); try { await GameRouter.SwitchSceneAsync(target.Value.Session, raidId, instanceId, universeId, x, y, z, facing, "admin-panel"); return (true, $"scene switch sent: raid={raidId} instance={instanceId} universe={universeId} ({x:F2},{y:F2},{z:F2}) — loading screen expected, then arrival"); } catch (Exception ex) { return (false, $"switch failed: {ex.Message}"); } } /// Public timeline/cutscene entry for the admin panel. public sealed class AdminTimelineEntry { public uint TimelineId { get; init; } public string TimelineName { get; init; } = string.Empty; public string Group { get; init; } = string.Empty; public int PGOType { get; init; } public int PlayerControlType { get; init; } public bool BanSkip { get; init; } public bool BanPause { get; init; } public bool NeedPreload { get; init; } public bool PauseAI { get; init; } public bool ClosePanel { get; init; } } /// Timeline catalog: all named cutscenes from TimelineConfig.json. public static IReadOnlyList TimelineCatalog() { try { return ClientData.Client4229938.TimelineCatalog4229938.Named .Select(t => new AdminTimelineEntry { TimelineId = t.Id, TimelineName = t.TimelineName, Group = t.Group, PGOType = t.PGOType, PlayerControlType = t.PlayerControlType, BanSkip = t.BanSkip, BanPause = t.BanPause, NeedPreload = t.NeedPreload, PauseAI = t.PauseAI, ClosePanel = t.ClosePanel, }) .ToList(); } catch (Exception ex) { return new List { new() { TimelineId = 0, TimelineName = $"catalog error: {ex.Message}" } }; } } /// 一条台词,供面板「剧情对话」子页列表渲染。 public sealed class AdminDialogEntry { public uint DialogId { get; init; } public string Speaker { get; init; } = string.Empty; public string Message { get; init; } = string.Empty; public bool Cutscene { get; init; } public int Type { get; init; } } /// /// 台詞索引(DialogIndex.json,58932 条)。支持关键词(文案 / 说话人 / id 片段)与 /// 「只看过场」过滤。客户端播不播不依赖这张表 —— 它只是给面板挑 id 用的清单。 /// public static IReadOnlyList StoryDialogCatalog(string? keyword, bool? cutsceneOnly, int limit) { try { return ClientData.Client4229938.StoryDialogue4229938 .Search(keyword, cutsceneOnly, limit <= 0 ? 200 : limit) .Select(d => new AdminDialogEntry { DialogId = d.Id, Speaker = d.Speaker, Message = d.Message, Cutscene = d.IsCutscene, Type = d.Type, }) .ToList(); } catch (Exception ex) { return new List { new() { Message = $"catalog error: {ex.Message}" } }; } } /// 面板用的统计(总数 / 过场数),给卡片标题显示。 public static (int Total, int Cutscene) StoryDialogStats() => (ClientData.Client4229938.StoryDialogue4229938.Count, ClientData.Client4229938.StoryDialogue4229938.CutsceneCount); /// 推一条台词(含过场镜头)。 public static Task<(bool Ok, string Message)> PlayStoryDialogAsync(uint dialogId) => GameRouter.PushStoryDialogAsync(dialogId); /// 只推语音。 public static Task<(bool Ok, string Message)> PlayStoryDialogVoiceAsync(uint dialogId) => GameRouter.PushStoryDialogVoiceAsync(dialogId); /// /// Play a timeline/cutscene by name. Sends a raw trigger payload to the client; /// the fastpatch-injected __DRMK_PlayTimeline handler picks it up and calls /// gTimelineManager:Timeline_LoadAndPlay(name, data). The client handles NPC /// spawning, camera work and video playback locally. /// public static async Task<(bool Ok, string Message)> PlayTimelineAsync(string timelineName) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (string.IsNullOrWhiteSpace(timelineName)) return (false, "timelineName must be non-empty"); try { var session = target.Value.Session; session.Log.Info($"[TIMELINE] play name={timelineName}"); // Hook point: GameSceneToClientImpl.SyncPrepareSwitchSceneTimeline // (methodId 68380635) is an empty no-op in the stock client. The // fastpatch overrides it to call gTimelineManager:Timeline_LoadAndPlay // with the replaceTimelineName argument. Payload: uint8 withoutDefault // (1) + UTF-8 timelineName (length-prefixed as the client expects a // string argument). We encode it as: 1 byte flag + UTF-8 bytes. var nameBytes = System.Text.Encoding.UTF8.GetBytes(timelineName); // The client deserializer expects: bool withoutDefaultTimeline, string replaceTimelineName // bool = 1 byte, string = 4-byte length prefix + UTF-8 bytes var payload = new byte[1 + 4 + nameBytes.Length]; payload[0] = 1; // withoutDefaultTimeline = true BitConverter.TryWriteBytes(payload.AsSpan(1, 4), (uint)nameBytes.Length); Array.Copy(nameBytes, 0, payload, 5, nameBytes.Length); await session.NotifyAsync(68380635, payload, CancellationToken.None); return (true, $"timeline playback triggered: {timelineName} — check the game client for the cutscene"); } catch (Exception ex) { return (false, $"play failed: {ex.Message}"); } } // ================= 切人演出:按**动画**分(2026-10-03 纠正)================= // 捐赠端 GM 面板的「切人动画演出」子页主下拉是 SwitchSpiritConfig 的 201 行(一条 = 一出演出), // 第二个下拉才是「切成哪个角色」。我 10-02 第一版按角色做是错的: // 同一角色有十几条演出,按角色只能触发服务端替他挑的那一条(受 DisperseAnchorsByCharacter / // 距离 / 权重影响),玩家想看「送人下车」那条根本选不到。 /// 一条切人演出,供面板「切人演出」子页列表渲染。 public sealed class AdminSwitchShowEntry { public uint SwitchShowId { get; init; } public string Timeline { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; public string TimelineDescription { get; init; } = string.Empty; public string Label { get; init; } = string.Empty; public int SwitchType { get; init; } public uint RaidId { get; init; } public float Weight { get; init; } public bool Invalid { get; init; } /// timeline 命中 CommonTimelinePrefix —— 这类行允许「FightSpiritId 空 = 人人可用」。 public bool IsCommon { get; init; } public bool Playable { get; init; } public bool HasCompanionAgents { get; init; } public float[] Position { get; init; } = []; /// 能演这条演出的角色(已按 IdsFor 判据算好,并扣掉 Forbid)。 public IReadOnlyList FightSpiritIds { get; init; } = []; /// 与 一一对应的显示名。 public IReadOnlyList SpiritNames { get; init; } = []; /// 默认选中的目标角色(清单第一个)。 public uint DefaultSpiritId { get; init; } public IReadOnlyList AgentIds { get; init; } = []; } /// 面板「切人演出」清单(一行 SwitchSpiritConfig = 一出演出)。 public static IReadOnlyList SwitchShowCatalog() { try { // roster 由调用方传给 EligibleSpiritIds —— 201 行里有一部分没写 FightSpiritId, // 那不代表「谁都演不了」,而是「通用行,全 roster 可用」(判据见 SwitchShowRow)。 var roster = ClientConfigRepository.Characters().Select(c => c.TemplateId).ToArray(); var named = ClientConfigRepository.Characters() .ToDictionary(c => c.TemplateId, c => c.Name); return ClientConfigRepository.SwitchShows() .Select(x => { var eligible = x.EligibleSpiritIds(roster, []); return new AdminSwitchShowEntry { SwitchShowId = x.Id, Timeline = x.Timeline, Description = x.Description, TimelineDescription = x.TimelineDescription, Label = x.Label, SwitchType = x.SwitchType, RaidId = x.RaidId, Weight = x.Weight, Invalid = x.Invalid, IsCommon = x.IsCommon, Playable = x.Playable && eligible.Count > 0, HasCompanionAgents = x.AgentIds.Count > 0, Position = new[] { x.Position.X, x.Position.Y, x.Position.Z }, FightSpiritIds = eligible, SpiritNames = eligible.Select(id => named.TryGetValue(id, out var n) ? n : string.Empty).ToArray(), DefaultSpiritId = eligible.FirstOrDefault(), AgentIds = x.AgentIds, }; }) .ToList(); } catch (Exception ex) { return new List { new() { Description = $"catalog error: {ex.Message}" } }; } } /// /// 当前受控角色(0 = 还没有玩家会话)。前端靠它把目标角色下拉的默认值避开当前角色 —— /// 否则「选一条演出 → 默认目标正好是当前角色」⇒ 服务端静默 return ⇒ 点了没反应。 /// public static uint ActiveSpiritId() { var target = RequirePlayerSession(); if (target is null) return 0; lock (target.Value.State.SyncRoot) return target.Value.State.ActiveSpiritTemplateId; } /// 指定某条演出来播。 public static async Task<(bool Ok, string Message)> PlaySwitchShowAsync(uint spiritId, uint switchShowId) { var target = RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端进世界)"); if (switchShowId == 0) return (false, "请先选一条切人演出"); if (spiritId == 0) return (false, "请先选择要切成哪个角色"); return await GameRouter.SwitchSpiritWithShowAsync(target.Value.Session, spiritId, switchShowId); } /// /// Switch the controlled character from the admin panel (server-driven, no client wheel). /// /// The Z wheel can only ever offer the 15 FightSpiritConfig rows with CanJoin=true, and the /// server cannot widen that list (any non-empty SyncTaskRoleTeam greys out everything it does /// not name). This drives GameRouter.OnSwitchSpirit straight from the panel instead, so every /// roster row is reachable — including CanJoin=false story characters and rows rescued from the /// placeholder-icon filter via content.roster.additionalTemplateIds. /// public static async Task<(bool Ok, string Message)> SwitchSpiritAsync(uint spiritId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点"); if (spiritId == 0) return (false, "请先选择角色,或在「或输入 ID」里填战斗灵 ID(如 15021029 弥弥古)"); var entry = ClientConfigRepository.Characters().FirstOrDefault(c => c.TemplateId == spiritId); if (entry is null) { var roster = ClientConfigRepository.Characters(); var sample = string.Join(", ", roster.Take(6).Select(c => $"{c.TemplateId} {SpiritDisplayName(c.TemplateId, c.Name)}")); return (false, $"战斗灵 {spiritId} 不在可切换名单(roster,共 {roster.Count} 个)里。" + $"想加人请把它写进 content.roster.additionalTemplateIds。可用示例:{sample} …"); } try { var result = await GameRouter.SwitchSpiritAsync(target.Value.Session, spiritId); return result; } catch (Exception ex) { return (false, $"switch failed: {ex.Message}"); } } /// /// Stuck weapon state recovery for the admin panel. See GameRouter.WeaponDrop.cs for why this /// exists: the client drop is entirely local, so long-press-Q can wedge the unit in its absorb /// loop with nothing for the server to answer. Re-publishing the weapon state is the server-side /// equivalent of the character switch that currently clears it. /// public static async Task<(bool Ok, string Message)> ResetWeaponStateAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点"); try { return await GameRouter.ResetWeaponStateAsync(target.Value.Session); } catch (Exception ex) { return (false, $"reset failed: {ex.Message}"); } } /// /// Metro / light rail — read-only status snapshot for the admin panel. /// /// Ported from the donor build (GameRouter.Metro.cs) unchanged in behaviour: the client owns the /// whole carriage simulation, so this is nothing but "which lines does the dispatcher push and /// where does the timetable put every carriage right now". See GameRouter.Metro.cs for why no /// client patch is involved (AskGetAllMetroInfos + SyncRunningMetroInfos carry no coordinates). /// public static object MetroStatus() => GameRouter.MetroStatus(RequirePlayerSession()?.Session); // ── 通缉系统(Wanted · 警车版,2026-09-23 自捐赠端 miyuki_fix 移植)──────────────────── /// Body of POST /api/wanted/action。action 为空串时只回状态。 public sealed class WantedActionRequest { public string Action { get; set; } = string.Empty; public uint Level { get; set; } public uint CrimeValue { get; set; } public uint Points { get; set; } = 25; public int Seconds { get; set; } = 10; public double? Amount { get; set; } public bool? Enabled { get; set; } public uint NpcHitPoints { get; set; } public uint NpcKillPoints { get; set; } public uint VehicleHitPoints { get; set; } public uint VehicleDestroyPoints { get; set; } public bool? AutoEscapeEnabled { get; set; } public bool? ArrestOnPlayerDown { get; set; } public int PursuitTimeoutSeconds { get; set; } public int VehiclesPerLevel { get; set; } public double RewardPerLevel { get; set; } public double PenaltyPerLevel { get; set; } } /// 通缉系统只读状态(犯罪值 / 星级 / 已派警车 / 结算统计)。 public static object WantedStatus() => GameRouter.WantedReport(RequirePlayerSession()?.Session); /// /// 通缉系统动作:addviolation / setvalue / setlevel / escape / completeescape / arrest / clear / /// configure。需要玩家已进世界(否则返回 world state not ready)。 /// public static Task WantedActionAsync(WantedActionRequest? cmd) { var session = RequirePlayerSession()?.Session; if (cmd is null) return Task.FromResult(new { ok = false, error = "empty body" }); var options = new GameRouter.WantedActionOptions { Action = cmd.Action ?? string.Empty, Level = cmd.Level, CrimeValue = cmd.CrimeValue, Points = cmd.Points, Seconds = cmd.Seconds, Amount = cmd.Amount, Enabled = cmd.Enabled, NpcHitPoints = cmd.NpcHitPoints, NpcKillPoints = cmd.NpcKillPoints, VehicleHitPoints = cmd.VehicleHitPoints, VehicleDestroyPoints = cmd.VehicleDestroyPoints, AutoEscapeEnabled = cmd.AutoEscapeEnabled, ArrestOnPlayerDown = cmd.ArrestOnPlayerDown, PursuitTimeoutSeconds = cmd.PursuitTimeoutSeconds, VehiclesPerLevel = cmd.VehiclesPerLevel, RewardPerLevel = cmd.RewardPerLevel, PenaltyPerLevel = cmd.PenaltyPerLevel, }; return GameRouter.WantedActionAsync(session, options, CancellationToken.None); } /// Master switch of the metro dispatcher. public static object SetMetroEnabled(bool enabled) => GameRouter.SetMetroEnabled(RequirePlayerSession()?.Session, enabled); /// /// Retune the dispatcher. null keeps the current selection (that is how the /// panel changes only the push period or the time factor); an empty list turns every line off. /// Passing = true switches the selection back to "follow the current raid" /// (RaidConfig.LightRailLineIds) - that is also the state a fresh session starts in; sending an /// explicit list turns raid-following off until auto is sent again. /// public static object ConfigureMetroLines(IReadOnlyList? lines, int? pushMs, double? timeFactor, bool? auto = null) => GameRouter.ConfigureMetroLines(RequirePlayerSession()?.Session, lines, pushMs, timeFactor, auto); /// Stop pushing. The client keeps whatever carriages it already built. public static object StopMetro() => GameRouter.StopMetro(RequirePlayerSession()?.Session); /// /// 远程 GM 工具命令:把一条命令推给客户端"本地执行"。 /// IGameGMToClient.SyncRunGmUtils(String methodName, List<String> args) — methodId 66042045。 /// 客户端原生层按名字反射解析并调用(L50Game.dll RunGmUtils_Public_Static_String_String_List_1_String_0 /// / RunGmUtilsCommand_Private_Static_String_MethodInfo_List_1_String_0),执行全程发生在客户端进程内, /// 服务端只发一次 Notify。ALT+F1 面板"召唤NPC"就是这类本地调用(实证:包日志 0 条 GM 请求但单位带完整 /// AI/受击/踉跄),这是把同等效果搬进 17888 的通道。 /// 注意:有效命令名集合未知(GmAddEnemyByPlayer 是网络命令名,客户端本地不存在),需实测。 /// public static async Task<(bool Ok, string Message)> RunGmUtilsAsync(string methodName, IReadOnlyList args) { var argList = (args ?? Array.Empty()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Select(a => a.Trim()) .ToList(); var target = RequirePlayerSession(); if (target is null) { Console.Error.WriteLine($"[GMUTILS] DROP (no player session / world state): name={methodName} args=[{string.Join(", ", argList)}] — 进入世界后再试"); return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点"); } if (string.IsNullOrWhiteSpace(methodName)) return (false, "methodName must be non-empty"); try { var session = target.Value.Session; session.Log.Info($"[GMUTILS] run name={methodName} args=[{string.Join(", ", argList)}]"); NoteGmCommandIntent(methodName, argList); var dto = new SceneMethods.SyncRunGmUtils7 { methodName = methodName.Trim(), args = argList }; await session.NotifyAsync(66042045, UxSerializer.Serialize(dto), CancellationToken.None); return (true, $"SyncRunGmUtils(66042045) sent: {methodName.Trim()} [{string.Join(", ", argList)}] — watch the game client"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } /// /// 第二条 GM 通道:IGameSceneToClient.SyncDoGmCommand(String command, String[] args) — 68373443。 /// 客户端由 LX6.Service.GameSceneToClientImpl 实现(ParseSpoonLines 解析命令行后走本地 GM 系统)。 /// command 可带参数整体填入(如 "AddNpc 40100010"),args 留空;或 command 只填命令名、参数放 args。 /// public static async Task<(bool Ok, string Message)> DoGmCommandAsync(string command, IReadOnlyList args) { var argList = (args ?? Array.Empty()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Select(a => a.Trim()) .ToList(); var target = RequirePlayerSession(); if (target is null) { Console.Error.WriteLine($"[GMCMD] DROP (no player session / world state): cmd={command} args=[{string.Join(", ", argList)}] — 进入世界后再试"); return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点"); } if (string.IsNullOrWhiteSpace(command)) return (false, "command must be non-empty"); try { var session = target.Value.Session; session.Log.Info($"[GMCMD] run cmd={command} args=[{string.Join(", ", argList)}]"); NoteGmCommandIntent(command, argList); var dto = new SceneMethods.SyncDoGmCommand7 { command = command.Trim(), args = argList }; await session.NotifyAsync(68373443, UxSerializer.Serialize(dto), CancellationToken.None); return (true, $"SyncDoGmCommand(68373443) sent: {command.Trim()} [{string.Join(", ", argList)}] — watch the game client"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } /// /// 诊断用:同一条 GM 命令用 4 种候选 wire 格式依次发送(间隔 1.2s),用来定位客户端到底认哪一种。 /// C = RunGmUtils(methodName=方法名, args=[参数]) ← 语义最明确,优先怀疑对象 /// B = DoGmCommand(command="方法名 参数1 参数2", args=[]) ← 命令行整行模式 /// A = DoGmCommand(command=方法名, args=[参数]) ← 当前面板默认,实测无效 /// D = RunGmUtils(methodName="方法名 参数", args=[]) /// 现象判读:AddNpc 无视 ID 永远出同一个 NPC + 需要参数的命令无反应 == 客户端没收到 args(整条命令被当无参执行)。 /// public static async Task<(bool Ok, string Message)> GmProbeAsync(string method, IReadOnlyList args) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点"); var argList = (args ?? Array.Empty()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Select(a => a.Trim()) .ToList(); if (string.IsNullOrWhiteSpace(method)) return (false, "method must be non-empty"); var name = method.Trim(); var full = string.Join(' ', new[] { name }.Concat(argList)); var steps = new (string Label, uint MethodId, byte[] Payload)[] { ("C RunGmUtils(方法名+args)", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils7 { methodName = name, args = argList })), ("B DoGmCommand(整行+空args)", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand7 { command = full, args = new List() })), ("A DoGmCommand(方法名+args)", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand7 { command = name, args = argList })), ("D RunGmUtils(整行+空args)", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils7 { methodName = full, args = new List() })), }; try { var session = target.Value.Session; var sent = new List(); for (var i = 0; i < steps.Length; i++) { var (label, id, payload) = steps[i]; session.Log.Info($"[GMPROBE] {i + 1}/4 {label} payload={payload.Length}B"); await session.NotifyAsync(id, payload, CancellationToken.None); sent.Add($"{i + 1}. {label}"); if (i < steps.Length - 1) await Task.Delay(1200); } return (true, "已依次发送(间隔1.2秒):\n" + string.Join("\n", sent) + "\n\n看游戏里第几个冒出来:第1个=C,第2个=B,第3个=A,第4个=D。"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } // ------------------------------------------------------------------ // 本地创建扫描:业务 Lua(NpcInviteManager / DartsGame / MaJiang)生成可交互单位 // 用的是 gCS.LuaUtils.CreateClientAgentByCfg(cfgId, pos, angle, cb, suit), // 读的就是 AgentConfig —— 纯客户端本地实例化,自带全套 AI / 受击 / 交互, // 不产生任何 C2S(区别于 AddEnemy*:那些会转发给服务端 RMPC 由服务器生成)。 // GmUtils 暴露了同一批本地入口,反射调用它们即可复刻 ALT+F1 面板的效果。 // ------------------------------------------------------------------ private static readonly (string Method, string[] Args)[] LocalCreateCandidates = { ("CreateClientAgent", new[] { "{ID}" }), ("CreateAgentEntity", new[] { "{ID}" }), ("CreateAgentUnit", new[] { "{ID}", "0" }), ("CreateNPC", new[] { "{ID}" }), ("AddNpc", new[] { "{ID}" }), }; public static async Task<(bool Ok, string Message)> GmScanLocalCreateAsync(string cfgId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet — 请先登录并进入世界后再点"); var id = (cfgId ?? string.Empty).Trim(); if (id.Length == 0) return (false, "请先选择或输入 ID"); var session = target.Value.Session; var sent = new List(); var total = LocalCreateCandidates.Length * 2; var step = 0; try { foreach (var cand in LocalCreateCandidates) { var args = cand.Args.Select(a => a.Replace("{ID}", id)).ToList(); // 同一方法用两条通道各发一次:C=反射调用 GmUtils 方法,A=命令行 GM 系统 var shots = new (string Label, uint MethodId, byte[] Payload)[] { ($"C·RunGmUtils {cand.Method}", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils7 { methodName = cand.Method, args = args })), ($"A·DoGmCommand {cand.Method}", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand7 { command = cand.Method, args = args })), }; foreach (var (label, mid, payload) in shots) { step++; session.Log.Info($"[GMSCAN] {step}/{total} {label}({string.Join(",", args)}) payload={payload.Length}B"); await session.NotifyAsync(mid, payload, CancellationToken.None); sent.Add($"{step,2}. {label} {string.Join(' ', args)}"); if (step < total) await Task.Delay(1500); } } return (true, $"本地创建扫描已发送 {total} 组(间隔 1.5 秒,约 {total * 15 / 10} 秒)\n" + "盯着屏幕,看第几个单位冒出来 —— 那个序号对应的方法就是可用的本地入口。\n" + "这些方法是纯客户端本地生成(和 ALT+F1 面板同源),成功的那个应该自带完整受击反馈。\n\n" + string.Join("\n", sent)); } catch (Exception ex) { return (false, $"scan failed: {ex.Message}"); } } /// /// Encoding probe: the same command sent six ways — 7-bit vs Int32 count prefix, /// across both S2C channels, plus a full-command-line variant of each. /// Whichever one produces the *requested* unit (rather than the client's fallback /// NPC) is the correct wire format. /// public static async Task<(bool Ok, string Message)> GmEncodeProbeAsync(string method, string cfgId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet — 请先登录并进入世界后再点"); var name = (method ?? string.Empty).Trim(); var id = (cfgId ?? string.Empty).Trim(); if (name.Length == 0 || id.Length == 0) return (false, "请填写方法与 ID"); var args = new List { id }; var full = $"{name} {id}"; var empty = new List(); var steps = new (string Label, uint MethodId, byte[] Payload)[] { ("A·DoGmCommand + 7bit args", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand7 { command = name, args = args })), ("C·RunGmUtils + 7bit args", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils7 { methodName = name, args = args })), ("A·DoGmCommand + Int32 args", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand { command = name, args = args })), ("C·RunGmUtils + Int32 args", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils { methodName = name, args = args })), ("A·DoGmCommand 整行 + 空args(7bit)", 68373443, UxSerializer.Serialize(new SceneMethods.SyncDoGmCommand7 { command = full, args = empty })), ("C·RunGmUtils 整行 + 空args(7bit)", 66042045, UxSerializer.Serialize(new SceneMethods.SyncRunGmUtils7 { methodName = full, args = empty })), }; var session = target.Value.Session; var sent = new List(); try { for (var i = 0; i < steps.Length; i++) { var (label, mid, payload) = steps[i]; session.Log.Info($"[GMENC] {i + 1}/{steps.Length} {label} ({name} {id}) payload={payload.Length}B"); await session.NotifyAsync(mid, payload, CancellationToken.None); sent.Add($"{i + 1}. {label}"); if (i < steps.Length - 1) await Task.Delay(2000); } return (true, $"已发送 {steps.Length} 种 wire 编码(间隔 2 秒)。\n" + "判断方法:看第几个冒出来的单位是【你选的目标】,而不是摩天轮售票员。\n" + "售票员 = 客户端没读到 ID(走了 fallback 默认值)。\n\n" + string.Join("\n", sent)); } catch (Exception ex) { return (false, $"encode probe failed: {ex.Message}"); } } /// /// NPC / character catalog: the distinct NpcCultivationRelatedId values /// (9105xxxx — the "character codes") from FightSpiritConfig.json, each linked /// to its 1502xxxx fight-spirit (侠客/战斗灵) template id, the number of spirit /// rows sharing the id, and the menu functions that reference it /// (MobileMenuSGuiConfig.json NpcCultivationIdList). /// public static IReadOnlyList NpcCatalog() { try { return NpcCatalogCache.Value; } catch (Exception ex) { return new List { new() { NpcCultivationId = 0, Name = $"catalog error: {ex.Message}" }, }; } } private static readonly Lazy> NpcCatalogCache = new(BuildNpcCatalog); /// /// Agent/spirit resolver: maps any of the three id forms to its 1502xxxx FightSpirit template. /// - 1502xxxx : the FightSpirit template id itself (direct) /// - 9105xxxx : the NPC cultivation id (NpcCultivationRelatedId) /// - 40xxxxx : an Agent id that has a FightSpirit (AgentId field) /// Enemy/BOSS agents that have NO FightSpirit (e.g. 40140183 = 柠檬/Lemon) are absent and /// must be reported as non-spawnable through this character-spirit panel. /// private static readonly Lazy> SpawnResolver = new(BuildSpawnResolver); private static Dictionary BuildSpawnResolver() { var map = new Dictionary(); try { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "FightSpiritConfig.json"))); foreach (var r in doc.RootElement.GetProperty("records").EnumerateArray()) { if (!r.TryGetProperty("Id", out var idEl) || !idEl.TryGetUInt32(out var id)) continue; var name = r.TryGetProperty("Name", out var nEl) && nEl.ValueKind == JsonValueKind.String ? nEl.GetString() ?? string.Empty : string.Empty; // direct spirit id (1502xxxx) if (!map.ContainsKey(id)) map[id] = (id, name); // cultivation id (9105xxxx) if (r.TryGetProperty("NpcCultivationRelatedId", out var cEl) && cEl.ValueKind == JsonValueKind.Number) { var c = cEl.GetUInt32(); if (c != 0 && !map.ContainsKey(c)) map[c] = (id, name); } // agent id (40xxxxx) that owns a FightSpirit if (r.TryGetProperty("AgentId", out var aEl) && aEl.ValueKind == JsonValueKind.Number) { var a = aEl.GetUInt32(); if (a != 0 && !map.ContainsKey(a)) map[a] = (id, name); } } } catch (Exception ex) { Console.Error.WriteLine($"[NPC] BuildSpawnResolver failed: {ex.Message}"); } return map; } private static List BuildNpcCatalog() { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); // FightSpiritConfig: spiritId -> (name, npcCultivationId), record order preserved. var rows = new List<(uint SpiritId, string Name, uint NpcCultivationId)>(); using (var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "FightSpiritConfig.json")))) { foreach (var r in doc.RootElement.GetProperty("records").EnumerateArray()) { if (!r.TryGetProperty("Id", out var idEl) || !idEl.TryGetUInt32(out var id)) continue; if (!r.TryGetProperty("NpcCultivationRelatedId", out var npcEl) || !npcEl.TryGetUInt32(out var npc) || npc == 0) continue; var name = r.TryGetProperty("Name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String ? nameEl.GetString() ?? string.Empty : string.Empty; rows.Add((id, name, npc)); } } // MobileMenuSGuiConfig: menu name -> the NpcCultivationIdList it references. var menusByNpc = new Dictionary>(); using (var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "MobileMenuSGuiConfig.json")))) { foreach (var r in doc.RootElement.GetProperty("records").EnumerateArray()) { if (!r.TryGetProperty("NpcCultivationIdList", out var listEl) || listEl.ValueKind != JsonValueKind.Array) continue; var ids = listEl.EnumerateArray() .Where(x => x.TryGetUInt32(out _)) .Select(x => x.GetUInt32()) .Distinct() .ToArray(); if (ids.Length == 0) continue; var name = r.TryGetProperty("Name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String ? nameEl.GetString() ?? string.Empty : string.Empty; foreach (var id in ids) { if (!menusByNpc.TryGetValue(id, out var list)) menusByNpc[id] = list = new List(); if (!string.IsNullOrWhiteSpace(name) && !list.Contains(name)) list.Add(name); } } } return rows .GroupBy(x => x.NpcCultivationId) .OrderBy(g => g.Key) .Select(g => new AdminNpcEntry { NpcCultivationId = g.Key, SpiritId = g.First().SpiritId, Name = g.Select(x => x.Name).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n) && n != "" && !n.Contains("文件夹", StringComparison.Ordinal) && !n.Contains("验证", StringComparison.Ordinal)) ?? g.First().Name, VariantCount = g.Count(), Menus = menusByNpc.TryGetValue(g.Key, out var menus) ? menus : Array.Empty(), }) .ToList(); } /// /// Admin "spawn NPC": materializes the character through the game's PROVEN spirit-entity /// channel (the same RaidBattleUnitSpirit projection the world-entry and spirit-switch /// handoffs use — no fastpatch Lua involved, so it works even though the client never /// executes fastpatch Lua). The NPC's 1502xxxx spirit template is a playable roster /// character (FightSpiritConfig.json); we project it as a remote (ManagedPid=0) entity /// so it stands in the world without hijacking the player's controlled spirit. /// public static async Task<(bool Ok, string Message)> SpawnNpcAsync(uint npcCultivationId, float x, float y, float z, float facing) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var known = NpcCatalog().FirstOrDefault(n => n.NpcCultivationId == npcCultivationId); // The built-in catalog only knows the 23 cultivation NPC ids (9105xxxx -> spirit 1502xxxx). // Resolve any of: a direct 1502xxxx spirit id, a 9105xxxx cultivation id, or a 40xxxxx Agent id // that owns a FightSpirit. Enemy/BOSS agents with no FightSpirit (e.g. 40140183 = 柠檬/Lemon) // cannot be projected as a character spirit and are reported clearly instead of crashing. uint spiritId; string name; var resolver = SpawnResolver.Value; if (resolver.TryGetValue(npcCultivationId, out var resolved) && resolved.SpiritId != 0) { spiritId = resolved.SpiritId; name = resolved.Name; } else if (known is not null) { spiritId = known.SpiritId; name = known.Name; } else { return (false, $"ID {npcCultivationId} 没有对应的战斗灵(FightSpirit)。" + "它看起来是敌人/BOSS实体(如 柠檬/Lemon=40140183 这类纯敌人 Agent 没有战斗灵)," + "无法用「生成NPC(角色/侠客)」面板生成——该面板只投影可玩角色精灵(1502xxxx)。\n" + "可生成的是有战斗灵的角色:例如agent 40140076(柠檬味/Lemon Sour)→ 15021042," + "或直接输入 1502xxxx 战斗灵ID / 9105xxxx 培养ID。"); } if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(facing)) return (false, "coordinates must be finite numbers"); // Only spirits on the playable roster can be projected. Everything else (placeholder-icon // rows such as 15021026 Daisy Bell, and the 150220xx test/prototype rows) makes // CharacterUnitProjection throw, which used to surface as an opaque "send failed". var rosterEntry = ClientConfigRepository.Characters().FirstOrDefault(c => c.TemplateId == spiritId); if (rosterEntry is null) { var sample = string.Join(", ", ClientConfigRepository.Characters() .Take(6).Select(c => $"{c.TemplateId} {SpiritDisplayName(c.TemplateId, c.Name)}")); return (false, $"战斗灵 {spiritId}({name})不在可召唤名单(roster)里,无法投影为角色。" + $"roster 由 FightSpiritConfig 按 [15020967,15022000) + SHeadIconID≠28000032 筛出,共 " + $"{ClientConfigRepository.Characters().Count} 个。可用示例:{sample} …"); } try { var (session, state) = target.Value; int slot; lock (state.SyncRoot) { slot = state.SummonedNpcEntityIds.Count; } // Same convenience as SpawnAgentAsync: an all-zero pose means "drop it in front of the // player". The panel's coordinate inputs default to 0, so without this the character // is projected at the world origin and looks like nothing happened. // // Summoned characters are fanned out sideways as well. These units survive // SyncLogicAgentLeave (see the uid note below), so consecutive summons used to stack // on the exact same spot — the "they all stand on one coordinate" report. if (x == 0f && y == 0f && z == 0f && facing == 0f && state.HasLastReportedPlayerTransform) { var p = state.LastReportedPlayerPosition; facing = state.LastReportedPlayerRotation.Y; // ⛑ facing 是度数,必须先转弧度(2026-09-26 修,同 SpawnAgentAsync 那处)。 var yaw = facing * (MathF.PI / 180f); x = p.X + MathF.Sin(yaw) * 3.5f; z = p.Z + MathF.Cos(yaw) * 3.5f; y = p.Y; // Wrapped: there is no despawn path any more, so the running count only ever // grows and an unwrapped offset would march further and further to the side. // 12 slots is a ±9.6 m band, after which placement simply starts over. var ring = slot % 12; if (ring > 0) { // forward = (sin f, cos f); right = (cos f, -sin f). var lateral = ((ring % 2 == 1) ? 1f : -1f) * ((ring + 1) / 2) * 1.6f; x += MathF.Cos(yaw) * lateral; z += -MathF.Sin(yaw) * lateral; } } GameRouter.ResetInteractionState(); var pos = new Vec3(x, y, z); // A summoned character gets its OWN uid instead of the roster's canonical unit id. // Those roster ids double as the spirit-switch identities the client pre-allocates, so // reusing them made SyncLogicAgentLeave a no-op (the client never retired the copy) and // made repeat summons of the same character collide. A dedicated segment fixes both. var ordinal = IdCounterStore.Next(SummonOrdinalCounter, 0); var unitId = SummonedNpcUidBase + (ulong)ordinal; // ManagedPid=0 -> projected as a remote/non-controlled entity (an NPC standing // beside the player) instead of the local player's current spirit. var remote = RuntimePayloadFactory.ExistingUnitProjection(unitId, spiritId, pos, facing); // Proven entity sequence: agent enter -> managed authority -> spirit projection // -> frozen transform. SyncPlayerCurrentSpirit is deliberately NOT sent (that // would transfer control to the NPC / switch the player's body). await session.NotifyAsync(MethodId.SyncLogicAgentEnter, UxSerializer.Serialize(WorldCodec.LogicAgentEnter(unitId)), CancellationToken.None); await session.NotifyAsync(MethodId.SyncManagedLogicAgent, UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(unitId, PrivateServerConfigStore.Current.Player.Pid, 0)), CancellationToken.None); await session.NotifyAsync(MethodId.SyncRaidBattleUnitSpirit, UxSerializer.Serialize(remote), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, UxSerializer.Serialize(WorldCodec.PositionAndFacing(unitId, pos, facing)), CancellationToken.None); int standing; lock (state.SyncRoot) { state.LastSummonedNpcEntityId = unitId; state.SummonedNpcEntityIds.Add(unitId); standing = state.SummonedNpcEntityIds.Count; } session.Log.Info($"[NPC] spirit-spawn npc={npcCultivationId} ({name}) spirit={spiritId} unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={facing:F2} standing={standing}"); return (true, $"spawned {name} ({npcCultivationId} → spirit {spiritId}) as unit {unitId} at ({x:F2},{y:F2},{z:F2}) facing={facing:F2} — 场上共 {standing} 个召唤角色"); } catch (Exception ex) { return (false, $"send failed: {ex.Message}"); } } /// Instance-id sequence for items granted from the panel. The GM console path keeps its /// own counter inside GameRouter (starting at 920000000000); this one starts well above it so the /// two can never hand out the same PlayerPackItem.UniqueId. private const long PanelItemSeqFloor = 950000000000L; private const string PanelItemSeqCounter = "GameAdminBridge.PanelItemSeq"; /// /// Admin "道具": pushes every backpack item in ItemCatalog.json into the player's pack. /// /// The wallet currencies (36920001 RewardGold / 36920002 RewardMoney / 36920003 RewardBindingGold) /// live inside the same id range but are wallet state, not pack stacks — granting them here would /// make the HUD wallet and the pack disagree, so they are skipped. /// /// The whole catalogue is several thousand rows, so the push is chunked: one /// SyncBackpackItemChanged per batch keeps any single packet to a sane size. /// public static async Task<(bool Ok, string Message)> GrantAllItemsAsync(uint count = 999u) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var catalog = Ananta.Server.ClientData.Client4229938.ItemCatalog4229938.All; if (catalog.Count == 0) return (false, "ItemCatalog.json 缺失或为空(应位于 ClientData/4229938/Configs/)"); var (session, state) = target.Value; var batch = new List(); int granted = 0, skippedWallet = 0, toppedUp = 0; lock (state.SyncRoot) { foreach (var item in catalog) { if (item.Id == 36920001u || item.Id == 36920002u || item.Id == 36920003u) { skippedWallet++; continue; } if (state.GmBackpackItems.TryGetValue(item.Id, out var existing)) { existing.Count = Math.Max(existing.Count, count); toppedUp++; continue; } var stack = RuntimePayloadFactory.GmPackItem4229938( item.Id, count, false, (ulong)IdCounterStore.Next(PanelItemSeqCounter, PanelItemSeqFloor)); state.GmBackpackItems[item.Id] = stack; batch.Add(stack); granted++; } } PlayerProgressStore.Save(state, session.Log); // ⛑️ 与「发放全武器」同款风险:点按钮时客户端一定在浏览器里 ⇒ 处于 hangup, // 十几个几百 KB 级的大包灌进一个不消费消息的 socket,回到前台就会被判定断线。 // 所以 hangup 中改为后台排队,等玩家切回游戏窗口再推。 if (GameRouter.IsBackgroundPushPaused(session)) { var deferred = batch.ToList(); _ = Task.Run(async () => { try { await GameRouter.WaitForClientInteractiveAsync(session, GameRouter.GrantAllHangupWaitMs); await PushItemChunksAsync(session, deferred); } catch (Exception ex) { session.Log.Warn($"[ITEM] grant-all (deferred) failed: {ex.Message}"); } }); return (true, $"客户端在后台 —— 已排队 {deferred.Count} 个道具,切回游戏窗口后自动发放" + "(服务端日志里看 [ITEM] grant-all packets=)"); } var pushed = await PushItemChunksAsync(session, batch); session.Log.Info($"[ITEM] grant-all: new={granted} toppedUp={toppedUp} skippedWallet={skippedWallet} packets={pushed} count/item={count}"); return (true, $"已发放 {granted} 个新道具(另有 {toppedUp} 个已有道具补足到 {count},跳过 {skippedWallet} 个钱包货币),分 {pushed} 个包下发(每个 {count} 个)"); } /// /// 把整批道具分包推给客户端。摊平 + 断线即停,理由同武器发放: /// 3783 个道具 / 300 = 13 个几百 KB 级的大包,一口气发完会把客户端主线程砸死 /// (Player.log: [Davinci] type=TimeScaleAnomaly 紧跟「与服务器的网络连接已断开」)。 /// private static async Task PushItemChunksAsync( Ananta.SDK.Network.TcpSession session, List batch) { const int chunkSize = 300; const int chunkDelayMs = 120; const int maxConsecutiveFailures = 3; int pushed = 0; var consecutiveFailures = 0; for (int i = 0; i < batch.Count; i += chunkSize) { var chunk = batch.Skip(i).Take(chunkSize).ToList(); var packet = new GameMethods.SyncBackpackItemChanged4229938 { addItemList = chunk, updateItemList = [], deleteItemList = [], }; try { await session.NotifyAsync(MethodId.SyncBackpackItemChanged, UxSerializer.Serialize(packet), CancellationToken.None); pushed++; consecutiveFailures = 0; } catch (Exception ex) { consecutiveFailures++; session.Log.Warn($"[ITEM] grant-all chunk {i / chunkSize} failed: {ex.Message}"); if (consecutiveFailures >= maxConsecutiveFailures) { session.Log.Warn($"[ITEM] grant-all aborted after {consecutiveFailures} consecutive failures (pushed={pushed})"); break; } } if (i + chunkSize < batch.Count) await Task.Delay(chunkDelayMs, CancellationToken.None); } return pushed; } /// GM 面板「发放全货币」:三种钱包货币各自补到 。 public static async Task<(bool Ok, string Message)> GrantAllCurrencyAsync(double amount = 9_990_000d) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; try { var summary = await GameRouter.GrantAllCurrencies4229938Async( session, state, amount, CancellationToken.None); session.Log.Info($"[CURRENCY] grant-all target={amount} -> {summary}"); lock (state.SyncRoot) { var reached = $"金币/Money={state.WalletMoney:0.##} · 钻石/Gold={state.WalletGold:0.##} · 绑定钻/BindingGold={state.WalletBindingGold:0.##}"; var unchanged = summary.Contains("already at or above"); return (true, unchanged ? $"三种货币均已 ≥ {amount:0.##},未重复发放 —— 当前 {reached}" : $"已发放 —— 当前 {reached}"); } } catch (Exception ex) { session.Log.Warn($"[CURRENCY] grant-all failed: {ex.Message}"); return (false, "发放失败: " + ex.Message); } } /// Base for summoned-character uids. This is its own segment on purpose: the roster's /// canonical unit ids (100000000000+) are the identities the spirit-switch flow pre-allocates, /// and reusing them for summoned copies made SyncLogicAgentLeave a no-op. internal const ulong SummonedNpcUidBase = 0x300000000000UL; /// Ordinal behind . 高水位落盘(IdCounterStore): /// 重启后若从 0 重发,客户端还认得上一轮那个 unitId,SyncLogicAgentLeave 会变成 no-op。 private const string SummonOrdinalCounter = "GameAdminBridge.SummonOrdinal"; /// /// ★ 分配一个**能被 SyncLogicAgentLeave 真正回收**的 NPC unitId(硬规则 165)。 /// /// ⛑⛑ 为什么必须走这一段:100000000000+ 是客户端预分配的切人身份 /// (玩家单位 100000000000、各角色 1000000000xx), /// `SyncLogicAgentLeave` 对这一段是**静默 no-op** —— 客户端根本不回收那一具身体。 /// 09-25 21:5x 实机:每次切走里希都新分配一个 `100000000103 / 100000000105 …` /// 的留场身体,切回时发的 Leave 全部落空 ⇒ **跑步机上每切一回就多一个里希**。 /// `0x300000000000` 这一段已在 GM 召唤上验证过「建得出、也收得掉」。 /// /// ⛑ 序数必须走 IdCounterStore(高水位落盘):重启后若从 0 重发, /// 客户端还认得上一轮那个 unitId ⇒ Leave 又变 no-op。 /// internal static ulong NextRetirableNpcUid() => SummonedNpcUidBase + (ulong)IdCounterStore.Next(SummonOrdinalCounter, 0); /// FightSpiritConfig.Id -> AgentConfig.Id. FightSpiritConfig carries the English /// internal name ("Rin", "Taffy"); the readable Chinese name lives on the AgentConfig row the /// spirit points at ("凛", "塔菲"), so the panel has to hop through this map. private static readonly Lazy> SpiritAgentMap = new(BuildSpiritAgentMap); private static Dictionary BuildSpiritAgentMap() { var map = new Dictionary(); try { var root = PrivateServerConfigStore.ResolveProjectPath(PrivateServerConfigStore.Current.Paths.ClientConfigs); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "FightSpiritConfig.json"))); foreach (var r in doc.RootElement.GetProperty("records").EnumerateArray()) { if (!r.TryGetProperty("Id", out var idEl) || !idEl.TryGetUInt32(out var id)) continue; if (!r.TryGetProperty("AgentId", out var aEl) || !aEl.TryGetUInt32(out var agent) || agent == 0) continue; map[id] = agent; } } catch (Exception ex) { Console.Error.WriteLine($"[NPC] BuildSpiritAgentMap failed: {ex.Message}"); } return map; } /// /// FightSpiritConfig.Id -> AgentConfig.Id(FightSpiritConfig.AgentId)。拿不到返回 0。 /// /// ★ 这个 id 就是「这个可玩角色在 NPC 世界里的那一面」: /// 15020967 辰宿 → 40140030,15021021 里希 → 40140040,15021044 李扶风 → 40155013。 /// 它就是 AgentConfig 表里的行 id(NpcCatalog.json 9345 行,15/15 CanJoin 全部命中, /// 带 AgentPersonaId / AiSettingId),所以可以直接当 NpcFormworkId 走市民通道刷 NPC —— /// 这就是官方「切人后老角色留在大世界里过自己的日子」的原生实现。 /// internal static uint AgentIdForSpirit(uint spiritId) => SpiritAgentMap.Value.TryGetValue(spiritId, out var agentId) ? agentId : 0u; /// Readable display name for a spirit: the Chinese AgentConfig name when the spirit /// maps to one, otherwise FightSpiritConfig's own name. private static string SpiritDisplayName(uint spiritId, string spiritName) { if (SpiritAgentMap.Value.TryGetValue(spiritId, out var agentId) && agentId != 0) { var entry = AgentCatalog.Find(agentId); if (entry is not null && !string.IsNullOrWhiteSpace(entry.Name)) return entry.Name; } return spiritName; } /// The playable roster — the only characters the spirit-projection channel can /// materialise. Exposed so the panel can render the exact same list instead of a hand-written /// one that drifts out of sync with FightSpiritConfig. /// /// GM 面板 / POST /api/police/arrest:对指定单位执行一次正向「逮捕」演出 /// (SyncPoliceBeginArrest 68839124)。 /// /// ⛑ 这是验证通道,不是玩法入口 —— 官方的逮捕挂在盘查面板(C# 侧 /// gPoliceJobManager.cs)上,客户端什么时候发 AskPoliceExamInteract(63233964) /// 我们还没拿到实据。先用 GM 这一发确认"帧能不能出演出",再决定要不要投玩法。 /// /// 目标单位 uid;传 0 ⇒ 用最近一次 GM 召唤的 NPC。 /// /// /// 里希**牢房收押**(2026-09-30)。把 移进牢房内、停 AI、改写账本。 /// ⛑ 玩家必须站在牢房门口 gameplay.policePrison.nearMeters(默认 4 m)内,且在主城。 /// 详见 GameRouter.PolicePrison.cs。 /// public static async Task<(bool Ok, string Message)> PolicePrisonCommitAsync(ulong uid) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); // 2026-10-02 对齐捐赠端:牢房改走捐赠端的 ControlPolicePrison4229938 // (捐赠端 GameAdminBridge.cs:234-236 的 prison-commit / prison-return)。 // ⛑ 捐赠端这一支**不接 uid 参数** —— 它只收押"当前已押送/已暂离的那一个", // 主线旧实现可以任意指定 uid,属于偏离,已矫正。 return await GameRouter.ControlPolicePrison4229938(target.Value.Session, "prison-commit"); } /// 里希**牢房带出**:把当前收押对象移回门口并恢复 AI。 public static async Task<(bool Ok, string Message)> PolicePrisonReleaseAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); return await GameRouter.ControlPolicePrison4229938(target.Value.Session, "prison-return"); } /// /// 里希**押送:把犯人押上警车**(2026-09-30)。 /// 直接发 SyncUnitVehicleStatus(Status=4) + SyncUnitBoardingInfo 绑定座位, /// 没有原生押送过场(那个要 GATE Lua 通道)。详见 GameRouter.PoliceEscort.cs。 /// public static async Task<(bool Ok, string Message)> PoliceEscortBoardAsync(ulong uid, ulong vehicle, int seat) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); return await GameRouter.EscortBoardAsync(target.Value.Session, uid, vehicle, seat); } /// 里希**押送:把犯人从车上押下来**(落在车旁 2 m)。 public static async Task<(bool Ok, string Message)> PoliceEscortAlightAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); return await GameRouter.EscortAlightAsync(target.Value.Session); } /// /// ★★ GATE Lua 通道:把一段 Lua 下发到客户端进程里执行 /// (SyncDoToLuaMaster(45437825) → 客户端内置 gCsToLuaHandler.GmRunLua)。 /// 详见 GameRouter.GateLua.cs。 /// public static async Task<(bool Ok, string Message)> RunClientLuaAsync(string script) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); return await GameRouter.RunClientLuaAsync(target.Value.Session, script); } /// /// ★ 回执探针:下发一段「执行了就往服务端发 DrmkLuaRx」的 Lua。 /// 服务端日志出现 [VEHICLE-STORY] cmd … name=DrmkLuaRx … ⇒ 客户端真的执行了。 /// public static async Task<(bool Ok, string Message)> GateLuaProbeAsync(string tag) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var stamp = string.IsNullOrWhiteSpace(tag) ? DateTime.Now.ToString("HHmmss") : tag; return await GameRouter.RunClientLuaAsync(target.Value.Session, GameRouter.BuildReceiptScript4229938(stamp)); } /// ★ 给账本里的一个 NPC 挂 / 摘客户端 F 按钮(自动把 uid 拆成 low32/high32 再生成 Lua)。 public static async Task<(bool Ok, string Message)> GateLuaUnitButtonAsync(string uidText, string text, bool add) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); if (!ulong.TryParse(uidText, out var uid) || uid == 0) return (false, "uid 无效 —— 请从下面的下拉里选一个(街上随机路人是客户端本地实体,服务端没有 id)。"); return await GameRouter.RunClientLuaAsync(target.Value.Session, GameRouter.BuildUnitButtonScript4229938(uid, string.IsNullOrWhiteSpace(text) ? "里希盘查" : text, add)); } /// 服务端账本里可操作的 NPC(供 F 按钮下拉)。 public static object GateLuaUnits() { var target = RequirePlayerSession(); if (target is null) return new { ok = false, message = "no player session tracked yet", units = Array.Empty() }; var units = GameRouter.EnumLedgerUnits4229938(target.Value.Session); return new { ok = true, count = units.Length, units = units.Take(60).Select(u => new { uid = u.Uid.ToString(), ledger = u.Ledger, x = u.Position.X, y = u.Position.Y, z = u.Position.Z, distance = float.IsFinite(u.Distance) ? (float?)MathF.Round(u.Distance, 1) : null, }).ToArray(), }; } /// ★ 一步到位:在玩家面前刷一个市民 + 给它挂「盘查」F 按钮(点了才开盘查)。 public static async Task<(bool Ok, string Message)> ExamSpawnWithFButtonAsync(uint agentId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); // 2026-10-02 对齐捐赠端:捐赠端没有"刷 NPC 并挂 F"这一步 —— 它的 F 按钮由 // SendPoliceButtons4229938 在 NPC 登记时统一下发(LixiPolice.cs:143/178/238)。 // 这一步退化成"只生成测试 NPC 4065053",与捐赠端 'npc' action 一致。 return await SpawnPoliceTestNpcAsync(target.Value.Session); } /// /// ★ 捐赠端里希总入口(2026-10-02 逐字移植捐赠端 GameAdminBridge.cs:227-255)。 /// 面板按钮直接打这个;/api/lixi/police 的 action 与捐赠端完全一致。 /// ⛑ 机械狗(dog)与武装支援(armed)本次没搬(依赖 HackerDevices / 友方随从子系统), /// 故这两个 action 返回"未接通"而不是伪造成功。 /// /// /// 生成测试动作 NPC 并把它登记进警察账本。 /// ⛑ 逐字对齐捐赠端 GameAdminBridge.cs:249-252: /// SpawnStaticNpcsAsync([new AdminNpcSpawnItem(4065053, 0)], 2, 2, 1)。 /// ⛑⛑ 测试模板不要改,必须是 4065053(2026-10-02 事故复盘)。 /// 客户端原生 SNpcBtnQuery.GetButtons / PoliceJobUtils 按 /// AgentConfig.json → ExamType 判定一个模板是否可被巡卫盘查, /// 只有 ExamType=2 才行(捐赠端 GameRouter.Npc.cs:200-203 的 /// PoliceExamType != 2 判据,提示语原文就是「可用模板 4065053 测试 F 互动」)。 /// 实测数据:4065053(藤原一马 Fujiwara Kazuma)ExamType=2; /// 42031099(摩天轮售票员 Ferris Wheel Ticket Seller)ExamType=0。 /// 10-02 曾误换成 42031099(理由「4065053 没有原生交互位、按 F 不弹菜单」), /// 该理由是错的:ExamType=0 时面板能被服务端硬推 S002 打开, /// 但客户端不授予盘查资格 ⇒ 按键点了恒无反应,且菜单里压根不给 /// 「押送上车」。捐赠端 v0.6 全部日志里测试模板 100% 只有 4065053 /// (ACTION_NPC ... template=4065053 examType=2),从未用过 42031099。 /// ★ 2026-10-02 修正:此前主线走自己的 SpawnAgentAsync(战斗 agent 管线), /// 与捐赠端的静态 NPC 管线不是同一条 —— 静态管线才会发 /// SyncBattleAiS=false(原生 PoliceJobUtils.CanUnitInteract 拒绝 IsBattleAiS /// 的目标 ⇒ 不发就永远盘查不了),并自带 /// RegisterPoliceNpc4229938 + RefreshSpawnedPoliceNpcs4229938 /// (已补进 GameRouter.DonorNpc.cs,对应捐赠端 Npc.cs:141/199)。 /// 主线等价物 = SpawnDonorStaticNpcsAsync(GameRouter.Npc.cs 的逐字移植版)。 /// ⇒ 因此这里不再手动补登记 / 刷新(捐赠端也没有),交给生成管线内部做。 /// public static async Task<(bool Ok, string Message)> SpawnPoliceTestNpcAsync(Ananta.SDK.Network.TcpSession session) { var spawned = await GameRouter.SpawnDonorStaticNpcsAsync( session, [new DonorNpcSpawnItem(4065053, 0)], 2f, 2f, 1); if (!spawned.Ok) return (false, spawned.Message); // 诊断:F 按钮是经 GATE 会话下发 Lua 挂的(PoliceButtons.cs:18 读 "lixi-police-gate")。 // 任何一个前置不满足都会**静默失败**,所以把三项状态直接报回面板,省得再猜。 var gate = GameRouter.TryGetGateSession4229938(session, out var g) && g is not null ? $"GATE 已绑定(gate={g.Id})" : "⚠️ GATE 未绑定 —— 完全退出客户端重进一次(要让它重新走 Login)"; var state = GameRouter.GetStateIfExistsPublic(session); var root = GameRouter.PoliceRootSentForGm4229938(session) ? "S002 root 已发布" : "⚠️ S002 root 未发布 —— 切一次角色(切走再切回里希)再生成"; var who = state is null ? "无世界状态" : (state.ActiveSpiritTemplateId == 15021021 ? "当前角色=里希 ✅" : $"⚠️ 当前角色={state.ActiveSpiritTemplateId}(不是里希 15021021,F 不会挂)"); return (true, $"已发送测试动作 NPC 4065053(uid={string.Join(',', spawned.Ids)}),静态 NPC 管线。" + $"F 按钮会在 0.75s / 1.25s 后各刷一次。状态:{gate};{root};{who}。" + "等待模型加载后,用里希靠近 6 米内点击“启动原生盘查”。"); } public static async Task<(bool Ok, string Message)> LixiPoliceAsync(string action) { var target = RequirePlayerSession(); if (target is null) return (false, "没有已连接的游戏角色,请先进入游戏。"); var session = target.Value.Session; switch (action) { case "prison-enable": case "prison-commit": case "prison-return": return await GameRouter.ControlPolicePrison4229938(session, action); case "prison-go": if (!await GameRouter.CanVisitPolicePrison4229938(session)) return (false, "请在主城下车、退出遥控并结束盘查/押送,再传送;不会携带 NPC。"); var entrance = GameRouter.PolicePrisonEntrance4229938; return await TeleportWithLoadingAsync(entrance.X, entrance.Y, entrance.Z, 180); case "examine": return await GameRouter.ControlPoliceExam4229938(session, true); case "end-examine": return await GameRouter.ControlPoliceExam4229938(session, false); case "dog": case "armed": return (false, "机械巡卫犬 / 武装支援本次未移植(依赖 HackerDevices 与友方随从子系统)。"); case "dismiss": await GameRouter.StopLixiPolice4229938(session, supportOnly: true); return (true, "支援已召回,冷却保留。"); case "npc": return await SpawnPoliceTestNpcAsync(session); default: return (false, "无效的里希操作。"); } } /// GATE 会话是否已绑定(面板用来提示「要不要重登」)。 public static object GateLuaStatus() { var target = RequirePlayerSession(); if (target is null) return new { ok = false, bound = false, message = "no player session tracked yet" }; var bound = GameRouter.TryGetGateSession4229938(target.Value.Session, out var gate); return new { ok = true, bound, gate = bound && gate is not null ? gate.Id.ToString() : "0", message = bound ? "GATE 会话已绑定,可以下发 Lua。" : "GATE 会话未绑定 —— 完全退出客户端重进一次。", }; } /// 牢房 + 押送 当前状态(收押了谁、车上押着谁、玩家离门口多远、坐标、场景是否 OK)。 public static object PolicePrisonStatus() { var target = RequirePlayerSession(); if (target is null) return new { ok = false, message = "no player session tracked yet", detained = "0" }; // ⛑ 2026-10-02:捐赠端**没有**牢房状态查询(它的 PoliceRuntime 是私有嵌套类, // 对外只暴露 ControlPolicePrison4229938 的 ok/message)。旧 `PrisonStatus` 随 // 主线版 PolicePrison.cs 一起被捐赠版替换掉了 ⇒ 这里只保留押送状态。 var e = GameRouter.EscortStatus(target.Value.Session); // 2026-10-02:把牢房两个坐标 + 场景判据 + 实测距离真的填回去(原来是空串/硬编码 true, // 「刷新」出来的全是假的,没法判断自己到没到门口 4 m 内)。取值全部走警察运行时自己的常量。 var snap = GameRouter.PolicePrisonSnapshotForGm4229938(target.Value.Session); static string F(Vec3 v) => $"{v.X:0.##},{v.Y:0.##},{v.Z:0.##}"; return new { ok = true, enabled = true, detained = "0", ledger = "", entranceMeters = snap.EntranceMeters is null ? null : (float?)MathF.Round(snap.EntranceMeters.Value, 2), sceneOk = snap.SceneOk, entrance = F(snap.Entrance), inside = F(snap.Inside), escortAgent = e.Agent.ToString(), escortVehicle = e.Vehicle.ToString(), escortSeat = e.Seat, }; } public static async Task<(bool Ok, string Message)> ArrestNpcAsync( ulong uid, bool teleport = false, bool pull = false) { if (!PrivateServerConfigStore.Current.Gameplay.PoliceArrestEnabled) return (false, "gameplay.policeArrestEnabled=false(回滚开关开着,改 true 重启)"); var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; ulong actorUnit; var targetUid = uid; lock (state.SyncRoot) { actorUnit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; if (targetUid == 0) targetUid = state.LastSummonedNpcEntityId; } if (targetUid == 0) { // 既没给 uid 也没召唤过 ⇒ 从服务端账本里挑最近的一个活单位。 // ⛑ 优先级:有真实坐标的(行人/地盘敌人/功能 NPC)> GM 召唤(位置不明)> // 客户端瞄准的本地实体(服务端没账本,抓不动的概率最大)—— 所以放最后。 var pick = GameRouter.CollectPoliceTargets(session) .Where(t => t.Uid != actorUnit) .OrderBy(t => t.Distance < 0 ? 1 : 0) .ThenBy(t => t.Distance < 0 ? -t.Distance : t.Distance) .FirstOrDefault(); if (pick.Uid == 0) return (false, "没有可抓目标:服务端账本里一个活单位都没有(行人 / 地盘敌人 / 功能 NPC 都没刷出来)"); targetUid = pick.Uid; } if (actorUnit == targetUid) return (false, $"target uid={targetUid} 就是玩家自己的单位 — 不能自己抓自己"); // ⛑ teleport 为什么必须在服务端做:行人是走动的,**只有服务端账本里的坐标是当前值**。 // 09-25 试过从客户端上报包(AskReportLogicAgentSyncData 的 DECODED)反解坐标再传送, // 结果落点差 70 m —— 日志里的坐标是几十秒前的。所以要抓谁、就把玩家挪到谁跟前, // 必须让服务端自己算。 string? where = null; // ⛑⛑ 推荐路径:把 NPC 拽到玩家面前(挪玩家的两条路都证伪了,见 PoliceArrest.cs ⓪b)。 if (pull) { var pr2 = await GameRouter.PullTargetToPlayerAsync(session, targetUid); where = " 拽人=" + pr2.Message; if (!pr2.Ok) return (false, "拽人失败:" + pr2.Message); await Task.Delay(250).ConfigureAwait(false); } else if (teleport) { var pt = GameRouter.CollectPoliceTargets(session).FirstOrDefault(t => t.Uid == targetUid); if (pt.Uid == 0 || pt.Distance < 0) { session.Log.Warn($"[POLICE-ARREST] teleport 跳过:target={targetUid} 在服务端账本里没有坐标(Distance<0)"); } else { var px = pt.X + 2.5f; var py = pt.Y + 0.6f; var pz = pt.Z + 2.5f; var tp = await TeleportInstantAsync(px, py, pz, null); where = $" 落点=({px:F1},{py:F1},{pz:F1}) 距目标 {pt.Distance:F1}m 传送={tp.Message}"; if (!tp.Ok) return (false, $"传送失败:{tp.Message}"); // 给客户端一点时间把人放下来,否则逮捕帧可能打在还没进场的单位上。 await Task.Delay(150).ConfigureAwait(false); } } var type = (byte)PrivateServerConfigStore.Current.Gameplay.PoliceArrestType; await GameRouter.ArrestBySessionAsync(session, actorUnit, targetUid, "GM"); session.Log.Info($"[POLICE-ARREST] GM arrest actor={actorUnit} target={targetUid} type={type} teleport={teleport} — SyncPoliceBeginArrest(68839124) sent"); return (true, $"SyncPoliceBeginArrest(68839124) sent: actor={actorUnit} target={targetUid} type={type}{(where is null ? "" : " |" + where)}"); } /// /// GET /api/police/targets:当前会话里服务端能点名的全部可抓单位,按距离升序。 /// /// 存在的理由见 GameRouter.CollectPoliceTargets 的注释 —— 城里大多数路人是客户端本地 /// 实体(id 形如 8300000000000000188),服务端抓不到;所以必须先把「抓得到的」摊开, /// 否则面板上一个 uid 都填不出来。 /// public static IReadOnlyList<(ulong Uid, string Kind, string Label, float Distance, float X, float Y, float Z)> PoliceTargets() { var target = RequirePlayerSession(); if (target is null) return Array.Empty<(ulong, string, string, float, float, float, float)>(); return GameRouter.CollectPoliceTargets(target.Value.Session) .Select(t => (t.Uid, t.Kind, t.Label, t.Distance, t.X, t.Y, t.Z)) .ToArray(); } /// /// POST /api/plate/push 的可选覆盖项。null ⇒ 沿用 gameplay.plate 配置里的值。 /// 存在的意义:线格式还没试出来,改配置再推一次比重新编译快得多。 /// public sealed class PlatePushOptions { /// 手动推时默认无视总开关(配置 enabled=false 也能推)。 public bool Force { get; set; } = true; public ulong? UniqueId { get; set; } public int? GraphId { get; set; } public float? X { get; set; } public float? Y { get; set; } public float? Z { get; set; } public float? RotX { get; set; } public float? RotY { get; set; } public float? RotZ { get; set; } public uint? FavorNpcActivityId { get; set; } public uint? TaskId { get; set; } public string? NpcBind { get; set; } public int? SlotIndex { get; set; } public uint? SpiritId { get; set; } // 线格式旋钮(对应 PlatePilotSettings 同名项) public bool? RootMarker { get; set; } public bool? ListMarker { get; set; } public string? ListCountEncoding { get; set; } public bool? ItemMarker { get; set; } public bool? DictMarker { get; set; } public string? DictCountEncoding { get; set; } public bool? VectorMarker { get; set; } } /// /// GM 面板 / POST /api/plate/push:手动推一个 Plate(盘)下去。 /// /// 用途:验证第五条管线(硬规则 157)。客户端收到盘才会建 Spoon 图并把 NPC 绑上去播表演, /// 否则角色只会站在跑步机旁边发呆。线格式目前是猜的(68554224 的 reader 在 C# 侧), /// 所以所有「有没有 0xFF / count 怎么编码」都做成了可覆盖项。 /// 判据:服务端日志出现 [PLATE] ★★★ AskPlateLoadComplete / /// AskPlateResourceReady,或客户端 Player.log 报 68554224 反序列化失败。 /// public static async Task<(bool Ok, string Message)> PushPlateAsync(PlatePushOptions? opt) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var cfg = PrivateServerConfigStore.Current.Gameplay.Plate; var merged = new PlatePilotSettings { Enabled = true, // 手动推 ⇒ 不看总开关 PushOnWorldReady = cfg.PushOnWorldReady, UniqueId = opt?.UniqueId?.ToString() ?? cfg.UniqueId, GraphId = opt?.GraphId ?? cfg.GraphId, X = opt?.X ?? cfg.X, Y = opt?.Y ?? cfg.Y, Z = opt?.Z ?? cfg.Z, RotX = opt?.RotX ?? cfg.RotX, RotY = opt?.RotY ?? cfg.RotY, RotZ = opt?.RotZ ?? cfg.RotZ, FavorNpcActivityId = opt?.FavorNpcActivityId ?? cfg.FavorNpcActivityId, TaskId = opt?.TaskId ?? cfg.TaskId, NpcBind = opt?.NpcBind ?? cfg.NpcBind, SlotIndex = opt?.SlotIndex ?? cfg.SlotIndex, SpiritId = opt?.SpiritId ?? cfg.SpiritId, RootMarker = opt?.RootMarker ?? cfg.RootMarker, ListMarker = opt?.ListMarker ?? cfg.ListMarker, ListCountEncoding = opt?.ListCountEncoding ?? cfg.ListCountEncoding, ItemMarker = opt?.ItemMarker ?? cfg.ItemMarker, DictMarker = opt?.DictMarker ?? cfg.DictMarker, DictCountEncoding = opt?.DictCountEncoding ?? cfg.DictCountEncoding, VectorMarker = opt?.VectorMarker ?? cfg.VectorMarker, }; var bytes = await GameRouter.PushPlateAsync(session, merged); return bytes > 0 ? (true, $"pushed SyncPlateAddList graph={merged.GraphId} uid={merged.UniqueId} " + $"bind={merged.NpcBind} bytes={bytes}") : (false, "plate disabled (gameplay.plate.enabled=false)"); } /// POST /api/npclife/spoon 的可选覆盖项。 public sealed class NpcSpoonOptions { /// 手动推时无视总开关。 public bool Force { get; set; } = true; /// 好感 NPC 的 AgentTag(里希 = 12)。0 ⇒ 用 反查。 public uint? AgentTag { get; set; } /// 由哪个角色反查 AgentTag / AgentId(默认里希 15021021)。 public uint? SpiritId { get; set; } /// 直接指定 spoon 位号;不填就按 gameplay.npcLife.spoonAgentIdMode 取。 public int? SpoonAgentId { get; set; } public float? X { get; set; } public float? Y { get; set; } public float? Z { get; set; } /// 线格式旋钮:包尾可空的 busyInfo 写哪个字节(0 / 255 / -1 = 不写)。默认沿用配置。 public int? BusyInfoMarkerByte { get; set; } } /// /// GM 面板 / POST /api/npclife/spoon:手动推一条 /// SyncFavorNpcSpoonAgentId(64003213)。 /// /// 这是「让角色真的去用器材」的正式通道(硬规则 160):客户端 /// LifeScheduleNpcModule.AttachNet(activityId, spoonAgentId, hasAmbientLoopPose, /// ambientLoopPos, ambientLoopFacing) 吃的正是这一条,而我们以前一次都没发过 /// —— 出场包里只有活动 id,所以角色只会播 ambient(站着玩手机)。 /// /// 判据:客户端 Player.log 里出现 [日程表NPC] /// (LifeScheduleNpcModule.LOG_PREFIX)开头的行,或角色真的上跑步机; /// 若报 64003213 反序列化失败 ⇒ 把 busyInfoNullMarker 改成 false 再推。 /// public static async Task<(bool Ok, string Message)> PushNpcSpoonAsync(NpcSpoonOptions? opt) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var cfg = PrivateServerConfigStore.Current.Gameplay.NpcLife; var spiritId = opt?.SpiritId ?? 15021021u; var agentId = AgentIdForSpirit(spiritId); var agentTag = opt?.AgentTag ?? NpcCultivationCatalog4229938.AgentTagForSpirit(spiritId); if (agentTag == 0) return (false, $"spirit={spiritId} 没有 AgentTag(NpcCultivationConfig 里没有这一行)"); var merged = new NpcLifeSettings { Enabled = true, PushOnPlace = cfg.PushOnPlace, SpoonAgentIdMode = opt?.SpoonAgentId is not null ? "fixed" : cfg.SpoonAgentIdMode, SpoonAgentIdFixed = opt?.SpoonAgentId ?? cfg.SpoonAgentIdFixed, BusyInfoMarkerByte = opt?.BusyInfoMarkerByte ?? cfg.BusyInfoMarkerByte, }; var spoon = GameRouter.ResolveSpoonAgentId(merged, agentId); var x = opt?.X ?? 1191.037f; var y = opt?.Y ?? 0.053f; var z = opt?.Z ?? 2052.112f; var bytes = await GameRouter.PushNpcSpoonAgentAsync(session, agentTag, spoon, x, y, z, merged); return bytes > 0 ? (true, $"pushed SyncFavorNpcSpoonAgentId agentTag={agentTag} spoon={spoon} " + $"at=({x:0.##},{y:0.##},{z:0.##}) bytes={bytes}") : (false, "push failed"); } /// POST /api/npclife/timetable 的可选覆盖项。 public sealed class NpcTimeTableOptions { /// 手动推时无视总开关。 public bool Force { get; set; } = true; /// 好感 NPC 的 AgentTag(里希 = 12)。0 ⇒ 用 反查。 public uint? AgentTag { get; set; } /// 由哪个角色反查 AgentTag / AgentId(默认里希 15021021)。 public uint? SpiritId { get; set; } /// 直接指定 spoon 位号;不填就按 gameplay.npcLife.spoonAgentIdMode 取。 public int? SpoonAgentId { get; set; } /// true ⇒ 走单条 SyncAgentTagTimeTableInfo(64400736);false ⇒ 全量字典 64219939。 public bool? SingleTag { get; set; } } /// /// GM 面板 / POST /api/npclife/timetable:手动推时刻表。 /// /// ★★ 这是 SyncFavorNpcSpoonAgentId 的硬前提:客户端 /// NpcDaliyManager.lua:143 里 OnSyncFavorNpcSpoonAgentId 一进门就判 /// table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag]),空则整帧丢弃 —— /// 09-25 19:36 那次「推了没反应」就是这个原因。 /// /// 登录帧虽然也带了一份 FavorNpcDailyScheduleInfos,但那一份的 /// SpoonAgentId / CurrentSpoonAgentId / SpoonPosition 全是 0, /// 等于「有日程、没人占坑」。这里重推一份带 spoon 号的覆盖上去。 /// /// 判据:推完再推 /api/npclife/spoon,角色是否开始播那条活动的表演; /// 客户端报 64219939 / 64400736 反序列化失败 ⇒ 改 single 再试(排掉字典线格式的变量)。 /// public static async Task<(bool Ok, string Message)> PushNpcTimeTableAsync(NpcTimeTableOptions? opt) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var cfg = PrivateServerConfigStore.Current.Gameplay.NpcLife; var spiritId = opt?.SpiritId ?? 15021021u; var agentId = AgentIdForSpirit(spiritId); var agentTag = opt?.AgentTag ?? NpcCultivationCatalog4229938.AgentTagForSpirit(spiritId); if (agentTag == 0) return (false, $"spirit={spiritId} 没有 AgentTag(NpcCultivationConfig 里没有这一行)"); var merged = new NpcLifeSettings { Enabled = true, PushOnPlace = cfg.PushOnPlace, SpoonAgentIdMode = opt?.SpoonAgentId is not null ? "fixed" : cfg.SpoonAgentIdMode, SpoonAgentIdFixed = opt?.SpoonAgentId ?? cfg.SpoonAgentIdFixed, BusyInfoMarkerByte = cfg.BusyInfoMarkerByte, PushTimeTable = true, TimeTableSingleTag = opt?.SingleTag ?? cfg.TimeTableSingleTag, TimeTableSpoonInSlice = cfg.TimeTableSpoonInSlice, }; var spoon = GameRouter.ResolveSpoonAgentId(merged, agentId); var bytes = await GameRouter.PushNpcTimeTableAsync(session, agentTag, spoon, merged); var which = merged.TimeTableSingleTag ? "SyncAgentTagTimeTableInfo(64400736)" : "SyncFavorNpcTimeTableInfos(64219939)"; return bytes > 0 ? (true, $"pushed {which} agentTag={agentTag} spoon={spoon} bytes={bytes}") : (false, "push failed(NpcDailySchedule.json 为空?)"); } /// /// GM 面板 / GET /api/player/pos:读玩家此刻的坐标。 /// /// 为什么需要它:朝向没有表可查 —— 跑步机那台 gadget 在 dump 里只在 prefab /// 注册表出现(有 GraphId/pathId,没有世界摆放与旋转),`GadgetCatalog.json` 同样只有 /// prefab 条目,`MapentranceConfig` 里离跑步机最近的入口也在 56m 外(未必是健身房那扇门)。 /// 于是最靠谱的取法是实测:**站到目标点(比如健身房大门)上,读玩家坐标,反算朝向**。 /// /// 用法:走到想要 NPC 面向的那个点 → 打这个接口拿到 (x,z) → 用 /// atan2(dx, dz) 换成度填进 gameplay.rosterNpc.poiOverrides[].facing。 /// public static (bool Ok, string Message, float X, float Y, float Z, uint Raid, int DaySecond) PlayerPosition() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0, 0, 0, 0, 0); var (_, state) = target.Value; float x, y, z; lock (state.SyncRoot) { x = state.LastReportedPlayerPosition.X; y = state.LastReportedPlayerPosition.Y; z = state.LastReportedPlayerPosition.Z; } return (true, $"player at ({x:0.##},{y:0.##},{z:0.##})", x, y, z, state.ActiveRaidId, state.CurrentDaySecond()); } /// POST /api/npclife/poi 的可选覆盖项。 public sealed class NpcPoiOptions { /// 目标单位 id。0 ⇒ 从常驻 NPC 账本里按 查它此刻的实体。 public ulong? UnitId { get; set; } /// 由哪个角色反查单位(默认里希 15021021)。 public uint? SpiritId { get; set; } /// POI 动作 id。不填就用 gameplay.npcLife.poiActionId,再不行 198(跑步机跑步)。 public uint? PoiActionId { get; set; } } /// /// GM 面板 / POST /api/npclife/poi:给某个单位直接下发 /// SyncAgentPlayPOIAction(68459398)。 /// /// 这是已验证过的原生通道(GameRouter.Ambient.cs:892 同款), /// 跑步机那条是 UrbanDiversityPOIActionConfig 的 /// 198「站姿_运动_跑步机跑步」(MainActionGroup 400 / MainActions [86190])。 /// /// ⛑ 生活日程开着(AgentDataSetsActivityCfgId 非 0)时客户端走生活日程状态, /// 这一帧会被盖掉 ⇒ 想看效果先把 gameplay.rosterNpc.dailyLife 设 false。 /// public static async Task<(bool Ok, string Message)> PushNpcPoiActionAsync(NpcPoiOptions? opt) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var cfg = PrivateServerConfigStore.Current.Gameplay.NpcLife; var unitId = opt?.UnitId ?? 0; if (unitId == 0) { var spiritId = opt?.SpiritId ?? 15021021u; if (!GameRouter.RosterNpcPlacements(session).TryGetValue(spiritId, out unitId)) return (false, $"spirit={spiritId} 此刻不在场上(记得先走近到 260m 内让它落地)"); } var poi = opt?.PoiActionId ?? cfg.PoiActionId; if (poi == 0) poi = 198; // 站姿_运动_跑步机跑步 var n = await GameRouter.PushNpcPoiActionAsync(session, unitId, poi); return n > 0 ? (true, $"pushed SyncAgentPlayPOIAction unit={unitId} poi={poi}") : (false, "push failed"); } /// /// GM 面板 / POST /api/police/examine:给指定 NPC 推一次「案底」 /// (SyncAgentPoliceExamData 68635920,LuaOnly)。 /// /// ⛑ 这是服务端唯一能把盘查 UI 叫出来的通道。盘查面板本体要 NPC 挂 /// MultiInteractTypeConfig.Examine 双人交互位才开得出来(规则 153),按 F 永远出不来; /// 但这一帧是 LuaOnly(midToExportOption[68635920])⇒ 客户端 lua /// PoliceJobManager.OnSyncAgentPoliceExamData 一定收得到, /// 我们注入的 tools/police_examine.lua 就钩在那里,收到即开盘查面板。 /// /// 目标 uid;0 ⇒ 自动挑最近(同 )。 /// 塞几条罪名(从 PoliceFineCatalog 的 IsShow 行里取)。 /// 先把玩家挪到目标跟前,免得面板开了但人不在视野里。 /// public static async Task<(bool Ok, string Message)> ExamineNpcAsync( ulong uid, uint fineCount, bool teleport = false, bool close = false, bool pull = false, string crimesWire = "a") { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; // ⛑⛑ 关面板通道(2026-09-25 首次实机被卡住才补的): // 盘查面板是模态的,ESC / 长按 F 都退不出,重开客户端才能脱身。 // 所以留一帧“退出”信号:FineTimes=0 + Fines 空 ⇒ 注入 lua 那边判成 close。 // ⛑ 正常开盘查时 FineTimes 恒 =1,所以不会误判。 if (close) { var closePayload = new GameMethods.SyncAgentPoliceExamData4229938 { agentEntityId = 0, data = new GameMethods.PoliceExamDataBody4229938 { FineTimes = 0, Fines = new Dictionary(), }, }; await session.NotifyAsync(MethodId.SyncAgentPoliceExamData, UxSerializer.Serialize(closePayload), CancellationToken.None); session.Log.Info("[POLICE-EXAM] close frame sent (FineTimes=0, Fines empty) — 客户端应关掉 POLICE_INQUIRY_PANEL"); return (true, "收盘查面板帧已发(客户端 lua 收到 FineTimes=0 + 空 Fines ⇒ ClosePanel_Story)"); } ulong actorUnit; lock (state.SyncRoot) actorUnit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; var targetUid = uid; if (targetUid == 0) { var pick = GameRouter.CollectPoliceTargets(session) .Where(t => t.Uid != actorUnit) .OrderBy(t => t.Distance < 0 ? 1 : 0) .ThenBy(t => t.Distance < 0 ? -t.Distance : t.Distance) .FirstOrDefault(); if (pick.Uid == 0) return (false, "没有可盘查目标:服务端账本里一个活单位都没有"); targetUid = pick.Uid; } if (actorUnit == targetUid) return (false, $"target uid={targetUid} 就是玩家自己的单位 — 不能自己查自己"); string? where = null; // ⛑⛑ 推荐路径:把 NPC 拽到玩家面前(挪玩家的两条路都证伪了,见 PoliceArrest.cs ⓪b)。 if (pull) { var pr = await GameRouter.PullTargetToPlayerAsync(session, targetUid); where = " 拽人=" + pr.Message; if (!pr.Ok) return (false, "拽人失败:" + pr.Message); await Task.Delay(250).ConfigureAwait(false); } else if (teleport) { var pt = GameRouter.CollectPoliceTargets(session).FirstOrDefault(t => t.Uid == targetUid); if (pt.Uid == 0 || pt.Distance < 0) { session.Log.Warn($"[POLICE-EXAM] teleport 跳过:target={targetUid} 在服务端账本里没有坐标"); } else { // ⛑⛑ 用 loading(SyncTeleport)而不是 instant(SyncUnitPositionAndFacing): // 2026-09-25 17:30 实机:instant 帧发了(`unit=100000000000`),客户端日志里 // [DRMK-EXAM] 也开了面板,但玩家**没动** —— 传送后到目标仍是 35 m, // `LastReportedPlayerPosition` 被客户端后续上报覆盖回原位 ⇒ 那一帧没被吃。 // ⇒ 改走官方传送流程(面板上的 mode="loading" 那条,SyncTeleport)。 var tp = await TeleportWithLoadingAsync(pt.X + 2.5f, pt.Y + 0.6f, pt.Z + 2.5f, null); where = $" 落点=({pt.X + 2.5f:F1},{pt.Y + 0.6f:F1},{pt.Z + 2.5f:F1}) 距目标 {pt.Distance:F1}m 传送(loading)={tp.Message}"; if (!tp.Ok) return (false, $"传送失败:{tp.Message}"); // loading 传送有加载流程,等久一点再开盘查面板。 await Task.Delay(1200).ConfigureAwait(false); } } // 罪名:IsShow 行里按 uid 取偏移,让不同 NPC 的案底不一样(同一个人每次都一样)。 var fines = PoliceCatalog4229938.RenderableFines; var dict = new Dictionary(); if (fines.Count > 0) { // 2026-10-03:fineCount == 0 ⇒ 全发(RenderableFines 的全部 IsShow 行)。 // 之前 0 会被 Math.Min 压成 0 条,等于不塞罪名;现在 0 是「全部」。 var want = fineCount == 0 ? fines.Count : (int)Math.Min(fineCount, (uint)fines.Count); var offset = (int)(targetUid % (ulong)fines.Count); for (var i = 0; i < want; i++) dict[fines[(offset + i) % fines.Count].Id] = false; } // ⛑⛑ 先把「这个 NPC 有什么罪」告诉客户端 —— C# 侧 SyncAgentCrimes(68251759)。 // 不发这一帧的话 module.Crimes 是空的 ⇒ TryAddFine / 搜身 / 酒测 / 执法建议 // 全部命中不了 ⇒ 盘查面板点任何选项都是空转(2026-09-25 定案)。 var crimeIds = dict.Keys.ToArray(); var cr = await SetAgentCrimesAsync(session, targetUid, crimeIds, crimesWire); if (!cr.Ok) return (false, "下发罪名失败:" + cr.Message); await Task.Delay(120).ConfigureAwait(false); var payload = new GameMethods.SyncAgentPoliceExamData4229938 { agentEntityId = targetUid, data = new GameMethods.PoliceExamDataBody4229938 { FineTimes = 1, Fines = dict, }, }; await session.NotifyAsync(MethodId.SyncAgentPoliceExamData, UxSerializer.Serialize(payload), CancellationToken.None); session.Log.Info($"[POLICE-EXAM] examine target={targetUid} fines={dict.Count} teleport={teleport} — SyncAgentPoliceExamData(68635920) sent"); return (true, $"SyncAgentPoliceExamData(68635920) sent: target={targetUid} fines=[{string.Join(",", dict.Keys)}]{(where is null ? "" : " |" + where)}"); } /// /// 给一个 NPC 下发「案底」—— SyncAgentCrimes(68251759, CSharpOnly)。 /// /// ⛑⛑ 这条是盘查链路的开关:PoliceExamineManager 的 /// TryAddFine / 搜身 / 酒测 / 右上「执法建议」全部读 /// module.Crimes,而 Crimes 只有这一帧能填。 /// 不发 ⇒ 面板开得出来但点什么都是空转。 /// internal static async Task<(bool Ok, string Message)> SetAgentCrimesAsync( TcpSession session, ulong uid, IReadOnlyList? fineIds, string wire = "a") { // 2026-10-03:留空 ⇒ RenderableFines 全部(之前是 Take(3))。 var list = fineIds is { Count: > 0 } ? fineIds.ToArray() : PoliceCatalog4229938.RenderableFines.Select(f => f.Id).ToArray(); if (list.Length == 0) return (false, "PoliceCatalog 里没有可渲染的罪名(RenderableFines 为空)"); var bytes = BuildAgentCrimesWire(uid, list, wire); await session.NotifyAsync(MethodId.SyncAgentCrimes, bytes, CancellationToken.None); session.Log.Info($"[POLICE-CRIMES] SyncAgentCrimes(68251759) wire={wire} uid={uid} " + $"crimes=[{string.Join(",", list)}] bytes={bytes.Length} hex={Convert.ToHexString(bytes)}"); return (true, $"SyncAgentCrimes(68251759) wire={wire} sent: uid={uid} " + $"crimes=[{string.Join(",", list)}] bytes={bytes.Length}"); } /// /// 手搓 SyncAgentCrimes 的字节, 用来现场换线格式。 /// /// ⛑ 为什么不用 DTO 序列化:这一帧极性是 CSharpOnly,客户端 lua 没有 reader 可抄, /// count 到底怎么编码只能靠 GmCreatePedData.Crimes 那个 writer 类比推断 —— /// 猜错一次就要重启一次服务端。做成旋钮后,POST /api/police/crimes {"wire":"b"} /// 当场换格式重发,不重启。 /// a = [FF][Int7 count+1] (默认,照 WriteList7Bit 的推断) /// b = [FF][Raw7 count] (gadget 系:peek byte + 原始 count,不 +1) /// c = [Int7 count+1] (无 marker) /// d = [FF][u32 count] (定长 count) /// internal static byte[] BuildAgentCrimesWire(ulong id, IReadOnlyList fines, string? wire) { var w = new UxWriter(); w.U64(id); switch ((wire ?? "a").Trim().ToLowerInvariant()) { case "b": w.U8(0xFF); w.Raw7(fines.Count); break; case "c": w.Int7(fines.Count); break; case "d": w.U8(0xFF); w.U32((uint)fines.Count); break; default: w.U8(0xFF); w.Int7(fines.Count); break; } foreach (var f in fines) w.U32(f); return w.ToArray(); } /// GM 面板 / POST /api/police/crimes 的入口(uid=0 ⇒ 挑最近的)。 public static async Task<(bool Ok, string Message)> PushAgentCrimesAsync( ulong uid, IReadOnlyList? fineIds, string wire = "a") { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; ulong actorUnit; lock (state.SyncRoot) actorUnit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId; var targetUid = uid; if (targetUid == 0) { var pick = GameRouter.CollectPoliceTargets(session) .Where(t => t.Uid != actorUnit) .OrderBy(t => t.Distance < 0 ? 1 : 0) .ThenBy(t => t.Distance < 0 ? -t.Distance : t.Distance) .FirstOrDefault(); if (pick.Uid == 0) return (false, "没有可下发目标:服务端账本里一个活单位都没有"); targetUid = pick.Uid; } return await SetAgentCrimesAsync(session, targetUid, fineIds, wire); } /// /// 推一次 SyncPoliceExamReaction(64480222, LuaOnly) —— 「你刚才那个盘查动作,对方的反应」。 /// /// ⛑⛑ 这是盘查里**唯一能让面板"有反应"的服务端帧**,2026-09-25 才挖出来。 /// 客户端链路:PoliceJobManager.OnReceiveReaction_Story(reactionId) /// → PoliceExamineManager.OnReceiveReaction /// → 用 self.PreOptionId 取 optionCfg + PoliceExamReactionConfig.GetConfig(reactionId) /// → 两者都在 ⇒ OnSuccessClickOption(加罪名、弹 AI 对话、可能发 AskPoliceEffectiveExam)。 /// ⇒ 所以**必须先让玩家点一下选项**(PreOptionId 才有值),再推这一帧。 /// ⛑ 载荷就是一个 u32 reactionId(RPCDeserializeAuto.lua:3281),极性 LuaOnly ⇒ 只有 lua 收。 /// ⛑ 为什么需要它:官方链路里客户端点选项后 C# 会发 C2S,服务端回这一帧。 /// 但我们这边 C# 那条 story 跑不起来(街上的人没有 Examine 多人交互位)⇒ C2S 根本没发 /// (unknown-methods.log 里一条都没有)⇒ 只能由 GM/服务端主动推。 /// public static async Task<(bool Ok, string Message)> PushPoliceExamReactionAsync(uint reactionId) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var bytes = new UxWriter().U32(reactionId).ToArray(); await session.NotifyAsync(MethodId.SyncPoliceExamReaction, bytes, CancellationToken.None); session.Log.Info($"[POLICE-REACT] SyncPoliceExamReaction(64480222) reactionId={reactionId} sent"); return (true, $"SyncPoliceExamReaction(64480222) reactionId={reactionId} sent — " + "判据:客户端面板有变化,或服务端日志出现 [POLICE-ARREST] AskPoliceEffectiveExam"); } /// /// GM 面板 / POST /api/police/fine 的入口:手动推一次罚款结果 /// (ShowReceiveRewardDetail 64145515,Reason = PoliceFine 180001)。 /// /// 正常链路不需要它 —— 玩家点「开罚单」⇒ C2S AskPoliceFineNpc(63548238) /// ⇒ GameRouter.PoliceFine.cs 自动回。留着是为了调线格式不用重启。 /// public static async Task<(bool Ok, string Message)> PushPoliceFineRewardAsync(double money, uint jobExp) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, _) = target.Value; var r = await GameRouter.PushFineRewardAsync(session, money, jobExp, CancellationToken.None); session.Log.Info($"[POLICE-FINE] manual {r.Message}"); return r; } public static IReadOnlyList<(uint SpiritId, string Name, ulong UnitId)> SummonRoster() { var rows = ClientConfigRepository.Characters() .Select(c => (SpiritId: c.TemplateId, Display: SpiritDisplayName(c.TemplateId, c.Name), Raw: c.Name, c.UnitId)) .ToArray(); // Several spirits share an AgentConfig row (15020967 and 15021037 both point at 40140030), // so the Chinese name alone is ambiguous. Disambiguate only the collisions. var collisions = rows.GroupBy(x => x.Display).Where(g => g.Count() > 1) .Select(g => g.Key).ToHashSet(StringComparer.Ordinal); return rows.Select(x => { if (!collisions.Contains(x.Display)) return (x.SpiritId, x.Display, x.UnitId); var usable = !string.IsNullOrWhiteSpace(x.Raw) && x.Raw != x.Display && x.Raw != ""; return (x.SpiritId, $"{x.Display}({(usable ? x.Raw : x.SpiritId.ToString())})", x.UnitId); }).ToArray(); } /// Monotonic ordinal behind the synthetic agent uid/pid pair. private static int s_nextAgentOrdinal; /// /// uid space for agents this server synthesises. /// /// The client allocates its OWN local units from the same 0x200000000000 base with a small /// index — measured in packets-latest.log, the player's own entity is 0x200000000001 /// (AskUnitMoveActionSimple.UnitId = 35184372088833). Starting our ordinals at 1 therefore /// handed the first spawned enemy the player's entity id, and every HP settlement of that /// "enemy" (SyncUnitHp 1000 -> 900 -> ... -> 0, then down/prepareDie/LogicAgentLeave) landed /// on the player. With EnableFallingDownOnDeath2 on, HP=0 switches the HUD to the knocked-out /// bar, which is the permanently stuck "昏迷 100%". /// /// The offset reserves the first 100000 indices for client-owned units. Keep /// in sync: GameRouter.LocalEnemyCombat uses it to decide /// whether a hit target belongs to this server or to a client-owned local unit. /// public const ulong ServerSpawnUidBase = 0x200000000000UL; public const ulong ServerSpawnUidOrdinalOffset = 100000UL; public const ulong ServerSpawnUidFloor = ServerSpawnUidBase + ServerSpawnUidOrdinalOffset; /// /// Admin "生成敌人" / "生成NPC": materialises a raw AgentConfig agent through the /// SyncRaidBattleUnitAgent channel (the channel the dbnb-0.2 branch was verified with), /// which is independent of the 生成NPC(角色)spirit-projection path above. /// /// Sequence and why each packet is required (all established in-game on dbnb-0.2): /// SyncLogicAgentEnter — registers the logic agent id. /// SyncManagedLogicAgent — hands the agent to a pid so the client keeps it alive. /// SyncRaidBattleUnitAgent — the unit itself; carries AgentSyncClientInfo.treeName and /// .AISetting. An empty tree name or AISetting 0 leaves the /// client unable to resolve a behaviour tree -> no animation. /// SyncAgentCampInfo — 4 = UnitCamp.AllEnemy (hostile), 0 = Unknown (civilian). /// SyncAttachBattleModule — binds the skeleton; without it the agent renders in T-pose. /// SyncUnitAttrs / SyncUnitHp — attribute + HP tables so the unit data set is complete. /// /// Deliberately omitted: hurt feedback, chase/movement, death and removal packets. This port /// only has to spawn a correctly-posed agent; dbnb-0.2 proved the rest unusable for /// server-spawned agents and they are out of scope here. /// /// /// false = 只把实体放进世界,不把它登记进敌人账本、也不下令开战。黑客召唤物(蜘蛛机器人 / /// 送货无人机)走这条路:它们要被玩家接管操控,而不是反过来打玩家。模板仍在 Hostile 桶里 /// (entry.Hostile 校验拦着,必须 hostile:true 才让生成),所以 camp 依旧是 4,只是没有 /// 行为树下达攻击指令。客户端 SyncSwitchControl 不看 camp,只看 ClientData.AgentId。 /// /// /// 必须与 AgentSummonCatalog 的 EnemyClassType 一致(1/2/4/6/7 ⇒ true,9 ⇒ false), /// 否则 entry.Hostile != hostile 直接拒绝生成。 /// /// /// false = 只建单位、不注册进敌人账本(打不死、也不会被服务端拉去战斗)。召唤物一律 false。 /// /// /// 是否推 SyncForceEnemyRelation(把玩家和这个单位显式标成敌对)。 /// 默认 true = 保持 2026-09-25 之前的现状,所有既有调用点行为不变。 /// 2026-09-25 战术支援新增:友方支援 NPC 必须传 false,否则玩家能锁定并"打"自己人 /// (又因为 engage=false 打不死,表现就是「对着友军挥刀,血条不掉」)。 /// /// /// 覆盖 SyncAgentCampInfo.camp。默认 null = 按 hostile 取 4(敌人)/ 0(平民)。 /// 友方支援 NPC 传 0:玩家的本地战斗 AI 按阵营挑目标,camp=4 会把他们当成可攻击对象。 /// /// /// 覆盖 AgentSyncClientInfo.treeName。默认 null = 沿用模板解析出来的那棵 /// (EnemyAiCatalog 有行就用它的 Tree,否则 hostile ? Main_Common : Main_Common_NPC)。 /// ⛑ 2026-09-27 为梅卡羊加的:40980002 在 EnemyAiCatalog.json 里没有行, /// 只能退化成 Main_Common;而它原生那棵是 MeikaShip。 /// /// /// 覆盖 SyncUnitOccur.occurId。默认 null = aiProfile?.Occur ?? 0。 /// ⛑ 同上:40980002 的 AgentConfig.EnemyOccurId = 70000302,但没行 ⇒ 只能填 0, /// 实机结果是「单位在、能出攻击特效、模型透明」⇒ 出场演出没跑。 /// public static async Task<(bool Ok, string Message, ulong Uid)> SpawnAgentAsync( uint agentId, float x, float y, float z, float facing, bool hostile, bool engage = true, bool forceEnemyRelation = true, byte? campOverride = null, string? treeNameOverride = null, uint? occurOverride = null) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0UL); var entry = AgentCatalog.Find(agentId); if (entry is null) return (false, $"agent {agentId} 不在召唤目录 AgentSummonCatalog.json 中", 0UL); if (entry.Hostile != hostile) return (false, $"agent {agentId}({entry.Name})是{(entry.Hostile ? "敌对" : "非敌对")}单位,请改用另一个面板生成", 0UL); // ★ 2026-09-30 马桶鸵鸟走「坐骑」而不是「敌人」:它要被玩家骑上去玩厕所 QTE // (客户端 ToiletOstrichGamePanelStore.lua 的音游式三键),带攻击 AI 的话玩家会先跟它打起来。 // 只对 5 个马桶鸵鸟模板生效,其它 agent 一个字节都不变。 // ⛔ 它仍在 AgentConfig 的 EnemyClassType=1 桶里 ⇒ 面板必须按「生成敌人」调(hostile 传 true), // 这里只改「要不要 engage / 要不要标敌对」,camp 仍是 4。 // 回滚:gameplay.toiletQte.passiveSpawn=false ⇒ 与普通敌人逐字节同路。 var toiletPassive = PrivateServerConfigStore.Current.Gameplay.ToiletQte.PassiveSpawn && GameRouter.IsToiletOstrichAgent(agentId); if (toiletPassive) { engage = false; forceEnemyRelation = false; } if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(facing)) return (false, "coordinates must be finite numbers", 0UL); try { var (session, state) = target.Value; // Convenience: an all-zero pose means "drop it in front of the player". Callers such as // the admin panel's 服务端直生成 button have no coordinate UI. if (x == 0f && y == 0f && z == 0f && facing == 0f && state.HasLastReportedPlayerTransform) { var p = state.LastReportedPlayerPosition; facing = state.LastReportedPlayerRotation.Y; // ⛑ facing 是度数,必须先转弧度再进三角函数。2026-09-26 修:以前直接 // Math.Sin(facing)/Math.Cos(facing),把度数当弧度用 ⇒ 落点虽然还在 3.5 m 圆上 // (所以"生成在身旁"看不出问题),但方向随 facing 数值乱飘,不是正前方。 // 约定与全项目其余各处一致:前方 = (sin yaw, 0, cos yaw),右方 = (cos yaw, 0, -sin yaw) // (对照 GameRouter.Vehicle.cs:591 "5.5m to the right"、GameRouter.Wanted.cs:843-846 // 警车刷背后的那套 forward/right,两处都是实机验证过的)。 var yaw = facing * (MathF.PI / 180f); x = p.X + MathF.Sin(yaw) * 3.5f; z = p.Z + MathF.Cos(yaw) * 3.5f; // Y stays at the player's own height: the client uses the server-supplied // position verbatim (no ground snapping), so any +1 offset makes it hover. y = p.Y; } var ordinal = Interlocked.Increment(ref s_nextAgentOrdinal); // See ServerSpawnUidOrdinalOffset: ordinal 1 used to be 0x200000000001, the player's // own entity id, and the whole enemy HP/death settlement then ran on the player. var uid = unchecked((ulong)(ServerSpawnUidBase + ServerSpawnUidOrdinalOffset + (ulong)ordinal)); // Simulation OWNERSHIP must be the real account pid. This used to be a synthetic // 0x00400000 + ordinal, which is not a pid the client knows anything about: the // client's LogicAgentModule.ApplyMasterPolicy switches the unit module on/off // according to whether the agent belongs to the simulated master, so a made-up // pid leaves the module disabled — no animation resolution, no hit reaction, no // death. The donor build (7k-v0.4, ENEMY_PRESENTATION_20260920.md) calls this the // key difference between a broken and a working spawn. // // Note that ownership and entity id are two different things here: // ownerPid -> SyncManagedLogicAgent.pid and RaidBattleUnitAgent.ManagedPid // uid -> SyncAgentCampInfo.pid, which resolves through // SceneDataMgr.GetUnit(pid) and therefore needs the ENTITY id. var ownerPid = Profile.PlayerPid; // Per-template combat profile: the behaviour tree, the authored weapon and the // occur id as AgentConfig defines them, instead of a hostile/civilian guess. var aiProfile = EnemyAiCatalog4229938.Find(agentId); // 2026-09-26 Wave 2:战斗模式解析(off 时 = aiProfile 本体,旧行为逐字节不变; // melee/ranged 时攻击集 / 武器 / 动画集按 7K SelectCombatProfile 换)。 // 武器、技能槽、姿态握手三处共用这一份,保证一致。 var combatProfile = EnemyAiCatalog4229938.ResolveSpawnProfile(agentId); // Fallback: Main_Common = combat agents, Main_Common_NPC = civilians / friendly NPCs. var treeName = !string.IsNullOrWhiteSpace(treeNameOverride) ? treeNameOverride : !string.IsNullOrWhiteSpace(aiProfile?.Tree) ? aiProfile.Tree : (hostile ? "Main_Common" : "Main_Common_NPC"); // Weapon: the template's own when it has one. A unit with no weapon bound has // nothing to swing, so the last resort is 98005506 (黑金节奏手's weapon) rather // than 0. AgentConfig only authors a weapon for a minority of templates, so a // weaponless enemy here usually means the template itself is unarmed. var weaponId = entry.InitWeaponId != 0 ? entry.InitWeaponId : (aiProfile?.Weapon ?? 0); if (weaponId == 0) weaponId = 98005506u; // 2026-09-26 Wave 2:战斗模式开启时武器以模式解析为准 —— melee 空手 = 0 是 // **有意的**(7K SelectCombatProfile 就是这么卸的),不走 98005506 兜底; // ranged / 持武器近战 = 模板武器原样。off 时上面三行旧逻辑一个字不变。 if (combatProfile is not null && EnemyAiCatalog4229938.SpawnMode != "off") weaponId = combatProfile.Weapon; // 2026-10-01 探针二阶:SourceWeaponId 的取值。 // 手动模式 = 直接用 spawnSourceWeaponId;自动模式 = 取本次会话最近一次掉落的 // 武器实体(9e12 起的 id,编号最大即最近)。⛑ 见 PrivateServerConfig 的注释: // 手填旧 uid 在重启后实体早已不存在,结果不可解释,所以探针要用自动模式。 var sourceWeaponId = PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.SpawnSourceWeaponId; if (PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.SpawnSourceWeaponAuto) { var drops = RequirePlayerSession()?.State.WeaponDrops.Values; if (drops is not null) { ulong newest = 0; foreach (var drop in drops) if (drop.DestructibleId > newest) newest = drop.DestructibleId; if (newest != 0) sourceWeaponId = newest; } } var agentSyncClientInfo = new SceneMethods.AgentSyncClientInfo { isApproachNpc = true, // Combat-enable switches. These are the difference between an agent that merely // stands there and one that reacts to hits (values taken from the reference // GameRouter.Enemy.cs): // beHitType = 2 PlotAgentBeHitType.CanBeHitWithDamage // isAttackInSafeMode allowed to attack even inside a safe zone // forbidAetherAI run the local behaviour tree instead of a server-fed one // agentStimType = 0 PlotAgentStimType.AllStim // forbidStimulateType = 0 AgentForbidStimulateType.None // // beHitType is read from gameplay.combat.enemies.beHitType rather than hard-coded: // 1 (CanBeHitNoDamage, what the left-behind companions use) hands the whole hurt // reaction to the client, which looks more native but is also why companions // cannot be killed — the unit never lands in our damage ledger. 2 keeps damage // server-settled, which is what the HP / poise / death chain depends on. beHitType = PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.BeHitType, isAttackInSafeMode = true, forbidAetherAI = true, agentStimType = 0, forbidStimulateType = 0, // 2026-09-26 Wave 1:骇入标记①(对齐捐赠端 7k-v0.5 GameRouter.Enemy.cs:308, // 原注释 "EnemyRebel.CanUseInternal also requires SpawnInfo.hackerBetray")。 // 只对 hostile 生效;开关 gameplay.combat.enemies.pushHackableState(默认开)。 HackerBetray = hostile && PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PushHackableState, // 2026-09-26 Wave 1b:暗杀标记。AgentSyncClientInfo.FeiSuo(dump.cs:1745249) // 是整个 spawn 契约里唯一从未赋值的敌对布尔(恒 false)。修完蹲伏被踢出的问题后 // 蹲伏能撑 9 秒(够跑 22 轮 0.4s 检测),ReportCanAssassinateEnemies 仍为 0 // ⇒ 候选集本身是空的。开关 gameplay.combat.enemies.pushFeiSuoSpawnFlag(默认开)。 FeiSuo = hostile && PrivateServerConfigStore.Current.Gameplay.Combat.Enemies.PushFeiSuoSpawnFlag, TriggerLeaveEvent = true, approachDistance = 300, LeaveDistance = 600, stimIDList = [], spawnEffectId = [], returnPoiActionIds = [], sitIndex = -1, indoorList = [], roomIds = [], treeName = treeName, SpoonAgentId = -1, // Template's AI behaviour set (AgentConfig.AI). 0 leaves the client with no // behaviour tree to resolve, which is one of the two T-pose causes. AISetting = aiProfile?.Ai ?? entry.AiSetting, AIAgentInfo = new SceneMethods.LinkAIAgentInfo { Uid = uid, FightSpiritId = 0, Fashion = null, Nickname = entry.Name, }, }; await session.NotifyAsync(MethodId.SyncLogicAgentEnter, UxSerializer.Serialize(new SceneMethods.SyncLogicAgentEnter { agentId = uid }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncManagedLogicAgent, UxSerializer.Serialize(new SceneMethods.SyncManagedLogicAgent { agentId = uid, pid = ownerPid, moveId = 0 }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncRaidBattleUnitAgent, UxSerializer.Serialize(new SceneMethods.RaidBattleUnitAgent { SkillId = -1, HSummonIndex = -1, SpoonAgentId = -1, FashionIdList = [], VehicleIndex = -1, IsBorn = true, BattleAiS = true, agentSyncClientInfo = agentSyncClientInfo, // Template weapon. With no weapon bound the unit has nothing to swing and the // combat state machine never engages. AgentConfig.InitWeaponId, falling back to // 98005506 (the reference server's hardcoded default). WeaponId = weaponId, // 2026-10-01 探针:WeaponId(uint 配置 id) 发非 0 实测不挂武器,而玩家侧 // 武器全按实体(WeaponUid/destructibleId)工作 ⇒ 试把实体 id 填进这里。 // 取值 = gameplay.combat.enemies.spawnSourceWeaponId(0 = 保持历史行为)。 SourceWeaponId = sourceWeaponId, SpawnType = 1, // AgentSpawnType.Gm AIAgentInfo = new SceneMethods.LinkAIAgentInfo { Uid = uid, FightSpiritId = 0, Fashion = null, Nickname = entry.Name, }, Id = uid, TemplateId = agentId, Position = new Auto.UXVector3 { X = x, Y = y, Z = z }, FacingDirection = facing, ManagedPid = ownerPid, GroundData = new Auto.MoveActionGroundData { MoveGroundType = 0, MoveGroundId = 0, MetaInfo = 0, LocalPos = new Auto.UXVector3(), }, }), CancellationToken.None); // Occurrence announcement: tells the client the unit has "occurred" in the world and // carries its spawn transform. The reference server sends this immediately after // SyncRaidBattleUnitAgent. await session.NotifyAsync(MethodId.SyncUnitOccur, UxSerializer.Serialize(new SceneMethods.SyncUnitOccur { enemyId = uid, occurId = occurOverride ?? aiProfile?.Occur ?? 0u, dullSecs = 0f, position = new Auto.UXVector3 { X = x, Y = y, Z = z }, rotateY = facing, }), CancellationToken.None); var gameplay = PrivateServerConfigStore.Current.Gameplay; // 4 = UnitCamp.AllEnemy for hostile agents, 0 for civilians. // pid is the ENTITY id here, not the owner pid: the client resolves this packet // through SceneDataMgr.GetUnit(pid), which is the battle-unit registry. // ⛑ 2026-09-23 血条专项:kimika 捐赠端的 spawn 序列里没有这个包,所以它是 // "血条不显示"的负向候选。开关 Gameplay.EnemySpawnCampInfo,默认 true = 保持现状。 if (gameplay.EnemySpawnCampInfo) { await session.NotifyAsync(MethodId.SyncAgentCampInfo, UxSerializer.Serialize(new SceneMethods.SyncAgentCampInfo { pid = uid, camp = campOverride ?? (hostile ? (byte)4 : (byte)0) }), CancellationToken.None); } // Skeleton binding — without this the agent renders in T-pose. // ⛑ 同上:kimika 也没有这个包(它的 Gameplay.Enemies.AttachBattleModule 默认 false)。 // 开关 Gameplay.EnemySpawnAttachBattleModule,默认 true = 保持现状。 if (gameplay.EnemySpawnAttachBattleModule) { await session.NotifyAsync(MethodId.SyncAttachBattleModule, UxSerializer.Serialize(new SceneMethods.SyncAttachBattleModule { unitId = uid }), CancellationToken.None); } // Attribute table must precede SyncUnitHp (AttributeNameConfig Id=1 is Max HP), // and SyncUnitHp carries the ABSOLUTE value, not a ratio — 1.0 read against a // 1000 MaxHp is an empty bar, which is why these used to render nothing. // // ⛑⛑ 2026-09-23 血条专项:原来是"先推 [1]=MaxHp,再单独推一次架势块"两次 SyncUnitAttrs。 // 若客户端的 SyncUnitAttrs 是替换语义(而不是合并),第二次推送会把 attr 1 抹掉 // ⇒ ClientData.MaxHp = 0 ⇒ EnemyHUDCtrl 拿 0 去 sizing,血条永远不会出现。这是"敌人 // 没有血条"的头号候选,所以两批合并成一次推送(下面的架势块注释原文保留)。 // // 架势 (poise) 属性块 — 2026-09-23 补,来自 kimika 捐赠端 spawn 的第 5a 步。 // 属性 58 = 架势条上限:不推它,客户端的 ClientData.MaxPoiseValue 就是 0, // 所有架势 / 虚弱的读取端全死(EnemyHUDCtrl.CheckShowDisarmBar 还额外卡 // maxDisarmValue <= 0)。其余按 AttributeNameConfig 的 id 对齐捐赠端默认值。 await session.NotifyAsync(MethodId.SyncUnitAttrs, UxSerializer.Serialize(new SceneMethods.SyncUnitAttrs { unitId = uid, attrs = new Dictionary { [1] = GameRouter.LocalEnemyMaxHp, // Max HP —— 血条尺寸 [56] = 1f, // 基础霸体系数 [58] = GameRouter.LocalEnemyMaxPoise, // 架势条上限 [76] = 0f, // 被打架势条百分比提升 [77] = 0f, // 被打架势条固定值提升 [78] = 5f, // 架势条恢复时间 (s) [79] = 2f, // 架势条恢复速度 [80] = 1f, // 虚弱基础易伤倍率 [81] = 20f, // 虚弱期架势条下降速度 [82] = 0f, // 虚弱起身需要架势条 [97] = 30f, [213] = 5f, // 虚弱期时间 (s) }, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitHp, UxSerializer.Serialize(new SceneMethods.SyncUnitHp { unitId = uid, hp = GameRouter.LocalEnemyMaxHp }), CancellationToken.None); // 满条开局 + 未破防,HUD 不会在陈旧 / 零值上打开。 // ⛑ 2026-09-26 Wave 4:这一帧随 GameRouter.PushPoiseProbe 一起关(补偿 UI 清理, // 捐赠端 7k-v0.5 spawn 全程不发 SyncEnemyPoiseRate)。翻回 true 即恢复。 if (GameRouter.PushPoiseProbe) { await session.NotifyAsync(MethodId.SyncEnemyPoiseRate, UxSerializer.Serialize(new Ananta.Server.RpcTypes.Client4229938.Methods.Game.SyncEnemyPoiseRateData4229938 { Id = uid, Ratio = 1f, }), CancellationToken.None); } // 骇入标记②(2026-09-26 Wave 1,对齐捐赠端 7k-v0.5 GameRouter.Enemy.cs:357-362: // 位置就在 SyncUnitHp 之后)。这是 Q4「7K 敌人头顶 HUD 比我们多什么」里的 // 骇入图标,也是原生 spawn 握手的一部分。只对 hostile 生效 —— 市民 / // 友方召唤物路径不变(开关 pushHackableState,默认开)。 if (hostile && gameplay.Combat.Enemies.PushHackableState) { await GameRouter.PublishNpcHackableAsync(session, uid, true, CancellationToken.None); session.Log.Info($"[AGENT] hackable uid={uid} isHackable=true"); } // 战斗姿态握手(2026-09-26 Wave 2,对齐 7K FinishEnemyAttackAiSpawnAsync 的 // occur → stance 顺序;7K 的 stance 就在 SyncUnitHackableState 之后)。 // 内部判据与 7K 一致:off / template / 无动画集可换时**一个包都不发**。 if (hostile) await GameRouter.SendEnemyCombatStanceAsync(session, uid, combatProfile); // 技能槽绑定(2026-09-23 补,来自 kimika 的 GameRouter.EnemySkills.cs): // 把模板自己的攻击技能按 Common/Heavy/Active/Unique/Dodge/Control 顺序绑进单位自己的 // 槽位。kimika 的原话是"不是让敌人出手的开关,但 some client builds read the actor's // slot table for the HUD" —— 也就是说敌人 HUD(含血条)可能读这张槽位表。我们此前 // 一个槽都没发过,这是和捐赠端最实的一处差异。开关 Gameplay.EnemySpawnSkillSlots。 if (gameplay.EnemySpawnSkillSlots && hostile && combatProfile is not null && combatProfile.Attacks.Count > 0) { var slots = new uint[] { MethodId.SyncChangeCommonSkill, MethodId.SyncChangeHeavyAttack, MethodId.SyncChangeActiveSkill, MethodId.SyncChangeUniqueSkill, MethodId.SyncChangeDodgeSkill, MethodId.SyncChangeControlSkill }; var bound = 0; for (var i = 0; i < slots.Length && i < combatProfile.Attacks.Count; i++) { await session.NotifyAsync(slots[i], UxSerializer.Serialize(WorldCodec.SkillBinding(uid, combatProfile.Attacks[i])), CancellationToken.None); bound++; } session.Log.Info($"[AGENT] skill slots uid={uid} bound={bound} kit=[{string.Join(",", combatProfile.Attacks)}] mode={combatProfile.CombatMode}"); } // 强制敌对关系(donor spawn 第 6 步):让锁定 / HUD 把这个单位当敌人对待。 // 此前只有 SyncAgentCampInfo(camp=4),缺这一步是 HUD 不绑血条的另一个候选原因。 var relationPlayerUnit = state.WorldEntryControlUnit != 0 ? state.WorldEntryControlUnit : state.ActiveSpiritUnitId; // ⛑ forceEnemyRelation=false(2026-09-25 战术支援):友方支援 NPC 不能被标成玩家的敌人。 // 默认 true,既有敌人 / 市民生成路径一个字节都不变。 if (forceEnemyRelation && relationPlayerUnit != 0) { await session.NotifyAsync(MethodId.SyncForceEnemyRelation, UxSerializer.Serialize(new Ananta.Server.RpcTypes.Client4229938.Methods.Game.SyncForceEnemyRelationData4229938 { Me = relationPlayerUnit, Other = uid, }), CancellationToken.None); } // 手里的道具(默认手机 + usage 6「右手玩手机」)。必须排在 SyncRaidBattleUnitAgent // 与 SyncManagedLogicAgent 之后 —— 单位还没在客户端落地时这两个包会被静默丢掉。 await GameRouter.PublishEnemyPropAsync(session, uid, agentId, CancellationToken.None); // Make a hostile agent defeatable. Two things are missing without this block: // - the HP ledger entry, so AskMultipleSkillHit2 reports naming this uid are // settled instead of ignored (GameRouter.LocalEnemyCombat); // - the hostile activation batch, so the client's behaviour tree leaves idle // and the agent actually fights back. // Civilians are deliberately left unregistered: they are not meant to be // damaged, and putting them in the ledger would make them killable. // (engage=false 时跳过,见方法注释:黑客召唤物不当敌人打) if (hostile && engage) { var playerUnit = state.WorldEntryControlUnit != 0 ? state.WorldEntryControlUnit : state.ActiveSpiritUnitId; GameRouter.RegisterSpawnedEnemy(session, uid, agentId, new Vec3(x, y, z), playerUnit, facing); await GameRouter.EngageSpawnedEnemyAsync(session, uid, playerUnit); _ = GameRouter.ReEngageSpawnedEnemyAsync(session, uid); } if (hostile && !EnemyHpBarCatalog4229938.HasHpBar(agentId)) session.Log.Warn($"[AGENT] no-hp-bar agent={agentId} name={entry.Name} — this AgentConfig template has no ShowHPBar/InitWeaponId, so the client binds no enemy HP bar. The enemy still takes damage and dies, but with no bar and no stance bar that is invisible; pick an id from EnemyCatalog.json to see one."); session.Log.Info($"[AGENT] spawned agent={agentId} name={entry.Name} {(hostile ? "hostile" : "other")} uid={uid} ownerPid={ownerPid} tree={treeName} ai={aiProfile?.Ai ?? entry.AiSetting} camp={campOverride ?? (hostile ? (byte)4 : (byte)0)} relation={(forceEnemyRelation ? "enemy" : "none")} weapon={weaponId}{(weaponId == 98005506u ? "(fallback)" : "")} weaponItem={aiProfile?.WeaponSceneItem ?? 0} srcWeapon={sourceWeaponId} occur={occurOverride ?? aiProfile?.Occur ?? 0} mode={EnemyAiCatalog4229938.SpawnMode}/{(combatProfile?.CombatMode ?? "-")} animSet={combatProfile?.BattleAnimSetTag ?? 0} attacks={(combatProfile is null ? "none" : string.Join('/', combatProfile.Attacks))} at=({x:F2},{y:F2},{z:F2}) facing={facing:F2}"); // 登记 uid → 模板:坐骑模式不 engage ⇒ 不进敌人账本,而骑乘 / 逃跑两条上报只带 uid。 if (GameRouter.IsToiletOstrichAgent(agentId)) GameRouter.NoteToiletMountSpawned(session, uid, agentId); if (toiletPassive) session.Log.Info($"[TOILET] spawned as ride mount (passive, no engage / no enemy-relation) agent={agentId} uid={uid}"); return (true, $"spawned {entry.Name} (agent {agentId}) uid={uid} at ({x:F2}, {y:F2}, {z:F2}) — verify it appears in-game with a normal pose (not T-pose)", uid); } catch (Exception ex) { return (false, $"spawn failed: {ex.Message}", 0UL); } } // --------------------------------------------------------------------------------- // Pending GM spawn intent. // // The client mangles the id when it forwards a GM command back to the server: the panel // asks for AddEnemy 40100010 (黑金节奏手) and the client's C2S GmAddEnemy arrives with // EnemyId=40900579 (verified in packets-latest.log). The id the operator actually wants is // therefore remembered here when the S2C command is sent, and the C2S forward reuses it. // --------------------------------------------------------------------------------- private static uint s_pendingGmEnemyId; private static long s_pendingGmEnemyAtMs; private const long PendingGmEnemyTtlMs = 20_000; /// Remembers the enemy id the admin panel just asked for. public static void SetPendingGmEnemyId(uint id) { if (id == 0) return; s_pendingGmEnemyId = id; s_pendingGmEnemyAtMs = Environment.TickCount64; } /// Consumes the remembered id, or 0 when none is fresh enough. public static uint TakePendingGmEnemyId() { if (s_pendingGmEnemyId == 0) return 0; if (Environment.TickCount64 - s_pendingGmEnemyAtMs > PendingGmEnemyTtlMs) { s_pendingGmEnemyId = 0; return 0; } var id = s_pendingGmEnemyId; s_pendingGmEnemyId = 0; return id; } /// /// Records the intent when the panel issues an AddEnemy* GM command. The client forwards /// these to the server as C2S GmAddEnemy* invokes with a mangled id, so the server keeps /// the authoritative one. /// internal static void NoteGmCommandIntent(string method, IReadOnlyList args) { if (!method.StartsWith("AddEnemy", StringComparison.OrdinalIgnoreCase)) return; if (args.Count == 0) return; if (uint.TryParse(args[0], out var id)) SetPendingGmEnemyId(id); } /// /// Catalog for the 生成敌人 / 生成NPC panel sections: raw AgentConfig agents split by /// EnemyClassType (1/2/4/6/7 hostile, 9 civilian). /// /// /// Agent dropdown rows for one panel. AgentConfig carries many same-name rows per character — /// same model, different AI / spawn-group — which used to flood the list (one enemy had 552 /// rows). Each name is therefore listed once, using its lowest id; the remaining ids stay /// reachable through and the free-id input. /// public static IReadOnlyList AgentSummonCatalog(bool hostile) { var result = new List(); foreach (var group in AgentCatalog.All .Where(e => e.Hostile == hostile) .GroupBy(e => e.Name, StringComparer.Ordinal) .OrderBy(g => PinnedRank(g.Key)) // 置顶项先出(未命中 = int.MaxValue) .ThenBy(g => g.First().Id)) // 其余仍按最小 id 升序 ⇒ 顺序与置顶前逐项一致 { // 置顶项改用客户端常量里的那个 id,其余组仍取组内最小 id。 var first = PinnedFirstIds.TryGetValue(group.Key, out var pinnedId) && group.Any(e => e.Id == pinnedId) ? group.First(e => e.Id == pinnedId) : group.First(); // Any variant with a bar makes the entry bar-capable: the panel lists only the first // id, so judging the entry on that id alone would mark a whole family as bar-less. var hasHpBar = group.Any(e => EnemyHpBarCatalog4229938.HasHpBar(e.Id)); result.Add(new AdminAgentEntry { AgentId = first.Id, Name = first.Name, EnemyClassType = first.EnemyClassType, AiSetting = first.AiSetting, VariantCount = group.Count(), VariantIds = group.Select(e => e.Id).ToArray(), HasHpBar = hasHpBar, }); } return result; } /// /// 面板下拉里**置顶**的名字(按 Name 精确匹配,按数组顺序排在最前)。 /// ⛑ 2026-09-30 用户定:厕所 QTE 用的「马桶鸵鸟」要排在「生成敌人」下拉框第一位。 /// private static readonly string[] PinnedFirstNames = ["马桶鸵鸟"]; /// /// 置顶项用的**代表 id**。AgentConfig 里有 5 个同名同模「马桶鸵鸟」 /// (40122028 / 40652007 / 40922011 / 42046401 / 45000446,同一具 GeneralModelId=86963029), /// 按「组内最小 id」会取到 40122028 —— 那只是 id 最小的偶然结果,不是游戏自己引用的那个。 /// 客户端常量是 AgentConfig.ToiletOstrich = 42046401,骑乘 AI 走它的 /// AISetting 40213146 + BehaviorActionConfigId 40214321 ⇒ 置顶项必须用它。 /// private static readonly Dictionary PinnedFirstIds = new(StringComparer.Ordinal) { ["马桶鸵鸟"] = 42046401, }; /// 置顶次序;未命中的名字返回 ⇒ 排到置顶项之后。 private static int PinnedRank(string name) { for (var i = 0; i < PinnedFirstNames.Length; i++) { if (string.Equals(PinnedFirstNames[i], name, StringComparison.Ordinal)) return i; } return int.MaxValue; } public sealed class AdminSpawnIdEntry { public uint Id { get; init; } public string Name { get; init; } = string.Empty; public string FactionName { get; init; } = string.Empty; } /// /// 面板「生成单位 / 切换角色(客户端 GM 通道)」的下拉数据源 —— SpawnIds.json。 /// 该文件由工作区脚本 gen_id_tables.py 从客户端导出的配置表预生成,分三类: /// enemy → AgentConfig 中 Faction 属于 11 个敌对阵营(PlayerLockTargetType=1)的记录 /// npc → AgentConfig 中非敌对且 EnemyClassType=9 的市民/物件 /// spirit → FightSpiritConfig,即 AskSwitchPlayerSpirit / GMSwitchSpirit 用的可控角色 /// 注意三类 ID 空间不同:同一个数字在 AddNpc 与 AddEnemy 里指向不同实体。 /// private static readonly Lazy>> SpawnIdTable = new(BuildSpawnIdTable); private static Dictionary> BuildSpawnIdTable() { var table = new Dictionary>(StringComparer.OrdinalIgnoreCase); try { var cfg = PrivateServerConfigStore.Current; var root = PrivateServerConfigStore.ResolveProjectPath(cfg.Paths.ClientConfigs); var file = Path.Combine(root, "SpawnIds.json"); if (!File.Exists(file)) { Console.Error.WriteLine($"[SPAWNIDS] missing {file}"); return table; } using var doc = JsonDocument.Parse(File.ReadAllText(file)); foreach (var (key, kind) in new[] { ("enemies", "enemies"), ("npcs", "npcs"), ("spirits", "spirits") }) { var list = new List(); if (doc.RootElement.TryGetProperty(key, out var arr) && arr.ValueKind == JsonValueKind.Array) { foreach (var r in arr.EnumerateArray()) { if (!r.TryGetProperty("id", out var idEl) || !idEl.TryGetUInt32(out var id)) continue; var name = r.TryGetProperty("name", out var nEl) && nEl.ValueKind == JsonValueKind.String ? nEl.GetString() ?? string.Empty : string.Empty; var faction = r.TryGetProperty("factionName", out var fEl) && fEl.ValueKind == JsonValueKind.String ? fEl.GetString() ?? string.Empty : string.Empty; list.Add(new AdminSpawnIdEntry { Id = id, Name = name, FactionName = faction }); } } table[kind] = list; } Console.Error.WriteLine($"[SPAWNIDS] loaded enemies={table.GetValueOrDefault("enemies")?.Count ?? 0} " + $"npcs={table.GetValueOrDefault("npcs")?.Count ?? 0} " + $"spirits={table.GetValueOrDefault("spirits")?.Count ?? 0}"); } catch (Exception ex) { Console.Error.WriteLine($"[SPAWNIDS] load failed: {ex.Message}"); } return table; } public static IReadOnlyList SpawnIdCatalog(string kind) { var key = kind?.ToLowerInvariant() switch { "npc" => "npcs", "spirit" => "spirits", _ => "enemies", }; return SpawnIdTable.Value.TryGetValue(key, out var list) ? list : (IReadOnlyList)Array.Empty(); } // --- World placement ------------------------------------------------------------ // Feed the proven friend-NPC actor path (SpawnAgentAsync) a list of explicit world // coordinates, so "put NPCs into the city at fixed spots and go look" is testable. /// Encodes one SyncAetherAIInitDatas (68704070) framing variant. null = unknown name. /// /// 给带 -isec-(不含 -real)的变体用:发多少条**合成探测**路口(0 = 空表)。 /// -real 变体忽略它,改由 aether.initIntersectionsReal / initRealIntersectionCount 控制。 /// public static byte[]? BuildAetherInitWire(string variant, uint raidId, bool hasZoneGraph, int handle, int intersectionCount = 0) => variant switch { // --- ★ 捐赠端相位表(171 行,带周期真值)—— 让红绿灯亮起来的那条变体 ---------- "top-int7-isec-donor" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecDerived4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildDonorIntersectionsDerived(), }), "top-int7-isec-donor-base" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecBaseFirst4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildDonorIntersectionsBaseFirst(), }), // --- 真路口(数据来自 DUMPS 世界转储,见 IntersectionCatalog4229938) --------------- // 元素 wire 顺序已由客户端 lua 确认:RPCSerializeAuto.lua:2506 // InstanceId(u64) → ZoneIndex(i32) → CurrentState(byte) → Period×3 // 列表编码:WriteList7Bit + 每元素 0xFF(markCommon) —— 与 top-int7 完全同构。 "top-int7-isec-real" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecDerived4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildRealIntersectionsDerived(), }), "top-int7-isec-real-base" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecBaseFirst4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildRealIntersectionsBaseFirst(), }), // --- 探测表:任意 (InstanceId,ZoneIndex) 组合,交给客户端判定 ---------------- "top-int7-isec-probe" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecDerived4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildIntersectionsFrom(IntersectionCatalog4229938.ProbeRows), }), // --- 合成探测路口(InstanceId/ZoneIndex 递增,仅用于看客户端有没有反应) ------------ "top-int7-isec-derived" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecDerived4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildProbeIntersectionsDerived(intersectionCount), }), "top-int7-isec-base" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7BitIsecBaseFirst4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = BuildProbeIntersectionsBaseFirst(intersectionCount), }), "top-int32" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = [], }), "top-int7" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataTop7Bit4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = [], }), "inline-int32" => UxSerializer.Serialize(new GameMethods.AetherAIInitData4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = [], }), "boolfirst-int32" => UxSerializer.Serialize(new GameMethods.AetherAIInitDataBoolFirst4229938 { RaidId = raidId, HasZoneGraph = hasZoneGraph, ZoneStorageDataHandle = handle, Intersections = [], }), _ => null, }; /// /// 探测用路口表(派生先):InstanceId / ZoneIndex 递增,状态取默认相位。 /// 真实 InstanceId 语义未知,这里只是「让客户端有东西可解析」的最小样本。 /// private static List BuildProbeIntersectionsDerived(int count) { var list = new List(Math.Max(0, count)); for (var i = 0; i < count; i++) list.Add(new GameMethods.TrafficIntersectionInitInfoDerived4229938 { InstanceId = (ulong)(i + 1), ZoneIndex = i, CurrentState = 0, // MassTrafficIntersectionState.Start CurrentPeriodIndex = 0, NextPeriodIndex = 1, RailPeriodIndex = 2, }); return list; } /// 同 ,基类字段在前。 private static List BuildProbeIntersectionsBaseFirst(int count) { var list = new List(Math.Max(0, count)); for (var i = 0; i < count; i++) list.Add(new GameMethods.TrafficIntersectionInitInfoBaseFirst4229938 { InstanceId = (ulong)(i + 1), ZoneIndex = i, CurrentState = 0, CurrentPeriodIndex = 0, NextPeriodIndex = 1, RailPeriodIndex = 2, }); return list; } // --- 真路口(捐赠端相位表 zonegraph_intersections.csv,171 行)--------------------- /// /// ★★ 2026-10-02:捐赠端 PrivateServerPor 那张路口相位表()。 /// /// ⛑⛑ InstanceId 填 CSV 第一列(= 主线 intersections.csv 的第一列,59/105/…); /// ZoneIndex **默认填真 zone index**(查 data/intersection_zone_map.csv, /// 59 ⇒ 184),这是 2026-10-02 21:2x 修的 —— 捐赠端原写法把两个字段都填成第一列, /// 客户端拿它查 _zoneIndexToBlobIndex 查不到 ⇒ 灯柱没人接管、**永远不变色**。 /// 开关 aether.intersectionUseTrueZoneIndex=false 可回到捐赠端原填法。 /// ⛑ 周期三 byte 取表里**真值**,不是旧探测变体那样写死 0/1/2。 /// ⛑ 一键回滚:aether.intersectionsEnabled=false ⇒ 这里返回空表(与 top-int7 等价)。 /// private static List BuildDonorIntersectionsDerived() { if (!PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionsEnabled) return []; var useTrueZone = PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionUseTrueZoneIndex; var all = IntersectionTable.All; var list = new List(all.Count); foreach (var r in all) list.Add(new GameMethods.TrafficIntersectionInitInfoDerived4229938 { InstanceId = (ulong)r.ZoneIndex, // CSV 第一列 = InstanceId ZoneIndex = ZoneIndexFor(r.ZoneIndex, useTrueZone), CurrentState = 0, // MassTrafficIntersectionState.Start CurrentPeriodIndex = r.CurrentPeriodIndex, NextPeriodIndex = r.NextPeriodIndex, RailPeriodIndex = r.RailPeriodIndex, }); return list; } /// /// 取 init 里该填的 ZoneIndex:useTrueZone 时查映射表,查不到(0)就回退成 id 本身。 /// private static int ZoneIndexFor(int instanceId, bool useTrueZone) { if (!useTrueZone) return instanceId; var z = IntersectionTable.TrueZoneOf(instanceId); return z > 0 ? z : instanceId; } /// 同 ,基类字段在前(对照组)。 private static List BuildDonorIntersectionsBaseFirst() { if (!PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionsEnabled) return []; var useTrueZone = PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionUseTrueZoneIndex; var all = IntersectionTable.All; var list = new List(all.Count); foreach (var r in all) list.Add(new GameMethods.TrafficIntersectionInitInfoBaseFirst4229938 { CurrentPeriodIndex = r.CurrentPeriodIndex, NextPeriodIndex = r.NextPeriodIndex, RailPeriodIndex = r.RailPeriodIndex, InstanceId = (ulong)r.ZoneIndex, ZoneIndex = ZoneIndexFor(r.ZoneIndex, useTrueZone), CurrentState = 0, }); return list; } // --- 真路口(world dump) ------------------------------------------------------- /// /// -real 变体实际会发出去的路口行(已应用开关与条数上限)。 /// 关掉 aether.initIntersectionsReal 即退化为「空表」—— 与现状 top-int7 完全等价, /// 这是本次改动的一键回滚点。 /// internal static IntersectionCatalog4229938.IntersectionRow[] RealIntersectionRows() { var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; if (!cfg.InitIntersectionsReal) return []; var rows = IntersectionCatalog4229938.Rows; var max = cfg.InitRealIntersectionCount; if (max > 0 && rows.Length > max) rows = rows[..max]; return rows; } /// 真路口(派生字段先 —— lua 确认的 wire 顺序)。 private static List BuildRealIntersectionsDerived() => BuildIntersectionsFrom(RealIntersectionRows()); /// 把任意 (InstanceId,ZoneIndex) 行转成 init 元素(派生字段先)。 private static List BuildIntersectionsFrom( IReadOnlyList rows) { var list = new List(rows.Count); foreach (var r in rows) list.Add(new GameMethods.TrafficIntersectionInitInfoDerived4229938 { InstanceId = r.InstanceId, ZoneIndex = r.ZoneIndex, CurrentState = 0, // MassTrafficIntersectionState.Start CurrentPeriodIndex = 0, NextPeriodIndex = 1, RailPeriodIndex = 2, }); return list; } /// 真路口(基类字段先 —— 万一 lua 那侧的 Autogen 与实际 C++ reader 不一致时的对照组)。 private static List BuildRealIntersectionsBaseFirst() { var rows = RealIntersectionRows(); var list = new List(rows.Length); foreach (var r in rows) list.Add(new GameMethods.TrafficIntersectionInitInfoBaseFirst4229938 { InstanceId = r.InstanceId, ZoneIndex = r.ZoneIndex, CurrentState = 0, CurrentPeriodIndex = 0, NextPeriodIndex = 1, RailPeriodIndex = 2, }); return list; } public static readonly string[] AllAetherInitVariants = ["top-int32", "inline-int32", "top-int7", "boolfirst-int32"]; /// Replays every Aether init framing (800 ms apart) so one client run identifies the /// one whose RaidId the client reads back as the real raid id in Player.log. public static async Task<(bool Ok, string Message)> AetherInitVariantsAsync(IEnumerable? variants = null) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var list = (variants ?? AllAetherInitVariants).Where(v => !string.IsNullOrWhiteSpace(v)).ToList(); if (list.Count == 0) list = [.. AllAetherInitVariants]; var (session, state) = target.Value; var raidId = state.ActiveRaidId; var world = PrivateServerConfigStore.Current.World.RaidId; var hasZoneGraph = raidId == world; var handle = hasZoneGraph ? PrivateServerConfigStore.Current.Gameplay.Vehicles.ZoneStorageDataHandle : 0; var sb = new System.Text.StringBuilder(); sb.AppendLine($"raid={raidId} zoneGraph={hasZoneGraph} handle={handle} — each variant 800ms apart, " + "then read Ananta_mainleakflow_260805\\log\\Player.log: the winning variant prints RaidId=" + raidId); foreach (var variant in list) { var wire = BuildAetherInitWire(variant, raidId, hasZoneGraph, handle); if (wire is null) { sb.AppendLine($" {variant,-16} UNKNOWN"); continue; } await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAetherAIInitDatas, wire, CancellationToken.None); sb.AppendLine($" {variant,-16} {wire.Length,2}B {Convert.ToHexString(wire)}"); session.Log.Info($"[AETHER] probe variant={variant} raid={raidId} bytes={wire.Length} hex={Convert.ToHexString(wire)}"); await Task.Delay(800); } return (true, sb.ToString()); } /// /// 重发一次 Aether init,内容 = data/intersections_probe.csv 里的 /// (InstanceId, ZoneIndex) 组合(允许同一个 InstanceId 出现多行 = 同时试多个候选 zone)。 /// /// ★ 用途:客户端对每个「在 blob 里找不到」的 zone 打一行 /// cannot find static zone info for zone index: Z, instanceId: I, /// 没打错的就是有效组合 ⇒ 反查每个路口真正属于哪个 zone(世界转储里推不出来)。 /// 走 HTTP POST /api/aether/isec-probe,不用重启客户端。 /// public static async Task<(bool Ok, string Message, int Rows)> AetherIsecProbeAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0); var (session, state) = target.Value; var raidId = state.ActiveRaidId; var hasZoneGraph = raidId == PrivateServerConfigStore.Current.World.RaidId; var handle = hasZoneGraph ? PrivateServerConfigStore.Current.Gameplay.Vehicles.ZoneStorageDataHandle : 0; var rows = IntersectionCatalog4229938.ProbeRows; var wire = BuildAetherInitWire("top-int7-isec-probe", raidId, hasZoneGraph, handle); if (wire is null) return (false, "probe variant 'top-int7-isec-probe' missing", rows.Length); await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAetherAIInitDatas, wire, CancellationToken.None); session.Log.Info($"[AETHER] isec-probe rows={rows.Length} bytes={wire.Length} raid={raidId}"); return (true, $"sent {rows.Length} rows ({wire.Length}B) — grep Player.log for " + "'cannot find static zone info'; the pairs WITHOUT an error are the valid ones", rows.Length); } public sealed record SpawnPoint(float X, float Y, float Z, ulong Uid, bool Ok, string Message); /// /// Places NPCs at explicit world coordinates and returns every point (including its final /// coordinates) so the result can be walked/teleported to and inspected. /// origin=null → the tracked player's last reported position. /// public static async Task<(bool Ok, string Message, List Points)> SpawnAgentGridAsync( uint agentId, bool hostile, float? originX, float? originY, float? originZ, float distance, float facingDeg, int rows, int cols, float spacing) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", []); var (_, state) = target.Value; rows = Math.Clamp(rows, 1, 40); cols = Math.Clamp(cols, 1, 40); spacing = spacing <= 0 ? 3f : spacing; var origin = new Vec3(originX ?? 0, originY ?? 0, originZ ?? 0); var usePlayerOrigin = originX is null && originY is null && originZ is null; float facing = facingDeg; if (usePlayerOrigin) { if (!state.HasLastReportedPlayerTransform) return (false, "no player transform reported yet — move once in game, or type explicit coordinates", []); origin = state.LastReportedPlayerPosition; facing = state.LastReportedPlayerRotation.Y; } // Lay the grid out along the facing direction so it reads as a street block. var rad = facing * (float)(Math.PI / 180.0); var fx = (float)Math.Sin(rad); var fz = (float)Math.Cos(rad); var rx = fz; // right vector = forward rotated -90° in XZ var rz = -fx; var bx = origin.X + fx * distance; var bz = origin.Z + fz * distance; var points = new List(); for (var r = 0; r < rows; r++) { for (var c = 0; c < cols; c++) { var offF = r * spacing; var offR = (c - (cols - 1) / 2.0f) * spacing; var x = bx + fx * offF + rx * offR; var z = bz + fz * offF + rz * offR; var y = origin.Y + 1.0f; var res = await SpawnAgentAsync(agentId, x, y, z, facing, hostile); points.Add(new SpawnPoint(x, y, z, res.Uid, res.Ok, res.Message)); if (!res.Ok && !res.Message.Contains("目录")) return (false, $"row {r} col {c} failed: {res.Message}", points); await Task.Delay(60); } } var okCount = points.Count(p => p.Ok); return (okCount == points.Count, $"placed {okCount}/{points.Count} at base ({bx:0.0}, {origin.Y + 1.0f:0.0}, {bz:0.0}) facing {facing:0.0}°", points); } // ---- Citizens (Aether crowd simulation) ------------------------------------------------- // These entities belong to the client's crowd manager: the server announces them once and the // client owns them from then on — no roster, no persistence. tier 0 means "every preset", // 1 = street service (vendors / clerks / couriers), 2 = pure background pedestrians. /// Group list only — 199 groups × 20 items would be a megabyte of JSON on every /// panel refresh, so the items are fetched per group on demand. public static object CivilianPanelState() { var target = RequirePlayerSession(); // A broken CivilianGroups.json must not take the whole response down with it — otherwise // the card silently reads "无玩家会话" with an empty dropdown, which is how the first // PascalCase/lowercase key mismatch presented itself. object[] groups = []; string? error = null; try { groups = GameRouter.CivilianGroups.Select(g => new { id = g.Id, label = g.Label, category = g.Category, systemOnly = g.SystemOnly, count = g.Items.Count, }).Cast().ToArray(); } catch (Exception ex) { error = ex.Message; } return new { online = target is not null, groupCount = groups.Length, groups, error, }; } public static object CivilianGroupDetail(int id) { var group = GameRouter.CivilianGroup(id); if (group is null) return new { ok = false, message = $"没有分组 {id}" }; return new { ok = true, id = group.Id, label = group.Label, items = group.Items.Select(i => new { npcFormworkId = i.NpcFormworkId, poiActionId = i.PoiActionId, npcName = i.NpcName, npcDesc = i.NpcDesc, action = i.Action, }).ToArray(), }; } public static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnCivilianGroupAsync( int groupId, float spacing = 2.6f) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", []); var group = GameRouter.CivilianGroup(groupId); if (group is null) return (false, $"没有分组 {groupId}", []); var items = group.Items.Select(i => new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId)).ToArray(); if (items.Length == 0) return (false, $"分组 {groupId} 是空的", []); var result = await GameRouter.SpawnCiviliansNearPlayerAsync( target.Value.Session, items, spacing <= 0f ? 2.6f : spacing); return (result.Ok, result.Message, result.Ids); } /// /// The land-plot seeding card (T0-8). Returns what is loaded plus the clock the seeding will /// use, so the panel can show both without a second round trip. /// public static object CivilianDistrictState() { var target = RequirePlayerSession(); object[] types = []; int districts = 0, spots = 0; string? error = null; try { districts = CivilianDistrictCatalog4229938.DistrictCount; spots = CivilianDistrictCatalog4229938.SpotCount; types = CivilianDistrictCatalog4229938.SummaryByType() .Select(t => new { type = t.LandPlotType, districts = t.Districts, spots = t.Spots }) .Cast().ToArray(); } catch (Exception ex) { error = ex.Message; } return new { online = target is not null, hour = target is not null ? (int)Math.Clamp(target.Value.State.TimeHour, 0u, 23u) : -1, districtCount = districts, spotCount = spots, types, error, }; } /// /// 面板下拉用:列出有坐标的可入侵点。 /// /// 官方数据里有几条 type=1(大本营)的 CenterPos 全是 0 —— 它们不能当传送目标, /// 所以这里只回 。 /// public static object ListFactionInvasionSpots() { var error = FactionInvasionSpotCatalog4229938.LoadError; if (error is not null) return new { ok = false, message = error, spots = System.Array.Empty() }; var all = FactionInvasionSpotCatalog4229938.Placeable(); var spots = new List(all.Count); foreach (var s in all) { spots.Add(new { eventId = s.EventId, areaId = s.AreaId, label = $"[{s.AreaId}] {s.AreaName} · {s.EventName} · {s.TypeName} @({s.X:F0},{s.Y:F0},{s.Z:F0})", }); } var missing = FactionInvasionSpotCatalog4229938.Count - all.Count; return new { ok = true, message = missing > 0 ? $"{all.Count} 个可入侵点(另有 {missing} 条官方没给坐标,没列出来)" : $"{all.Count} 个可入侵点", spots = spots.ToArray(), }; } /// /// 一次嘉姆入侵:传送 → 放两个随从 → 推占领包。三步各自独立开关。 /// ⛑ 不代切角色:玩家要先在角色界面切到嘉姆(spiritId 15020989)。 /// public static async Task<(bool Ok, string Message, string Detail)> RunFactionInvasionAsync( int eventId, bool teleport, bool summon, bool occupy) => await GameRouter.RunFactionInvasionAsync(eventId, teleport, summon, occupy); public static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnCiviliansByDistrictAsync( float radius, int maxTotal, int hour) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", [], ""); return await GameRouter.SpawnCiviliansByDistrictAsync( target.Value.Session, radius <= 0f ? 150f : radius, maxTotal <= 0 ? 120 : maxTotal, hour); } /// /// The functional-NPC card. Reports how many fixed spots exist for the player's current scene /// so the panel can show "this scene has 18" before anything is spawned. /// public static object FunctionalNpcState() { var target = RequirePlayerSession(); var raidId = target?.State.ActiveRaidId ?? Profile.RaidId; string? error = FunctionalNpcSpotCatalog4229938.LoadError; var total = FunctionalNpcSpotCatalog4229938.Count; var here = error is null ? FunctionalNpcSpotCatalog4229938.ForRaid(raidId, true).Count : 0; // Distance to the nearest spot matters more than the count: the client only ever shows // entities near the player, so "27 placed" means nothing if the closest one is 700 m away. // Surfacing it here saves a round of "I spawned them but nothing appeared". string? nearestName = null; double nearestDistance = -1; string? playerPos = null; if (target is not null && error is null) { Vec3 pos; lock (target.Value.State.SyncRoot) pos = target.Value.State.LastReportedPlayerPosition; playerPos = $"({pos.X:F0}, {pos.Y:F0}, {pos.Z:F0})"; foreach (var spot in FunctionalNpcSpotCatalog4229938.ForRaid(raidId, true)) { var d = Math.Sqrt((spot.X - pos.X) * (spot.X - pos.X) + (spot.Z - pos.Z) * (spot.Z - pos.Z)); if (nearestDistance < 0 || d < nearestDistance) { nearestDistance = d; nearestName = spot.Name; } } } return new { online = target is not null, raidId, total, here, auto = PrivateServerConfigStore.Current.Gameplay.Aether.FunctionalNpcsEnabled, active = target is not null ? GameRouter.FunctionalNpcActiveCount(target.Value.Session) : 0, playerPos, nearestName, nearestDistance = nearestDistance < 0 ? -1 : Math.Round(nearestDistance, 1), error, }; } public static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnFunctionalNpcsAsync( float radius, bool includeSemantic) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", [], ""); return await GameRouter.SpawnFunctionalNpcsAsync(target.Value.Session, radius, includeSemantic); } /// /// The gang-territory card. Same shape as : how many authored /// turf corners the current scene has, how far the nearest one is, and whether the follow loop /// is running — "85 spots" means nothing when the closest one is half a map away. /// public static object EnemyTerritoryState() { var target = RequirePlayerSession(); var raidId = target?.State.ActiveRaidId ?? Profile.RaidId; var cfg = PrivateServerConfigStore.Current.Gameplay.Aether; string? error = EnemyTerritorySpotCatalog4229938.LoadError; var total = EnemyTerritorySpotCatalog4229938.Count; var here = error is null ? EnemyTerritorySpotCatalog4229938.ForRaid(raidId).Count : 0; string? nearestName = null; double nearestDistance = -1; string? playerPos = null; if (target is not null && error is null) { Vec3 pos; lock (target.Value.State.SyncRoot) pos = target.Value.State.LastReportedPlayerPosition; playerPos = $"({pos.X:F0}, {pos.Y:F0}, {pos.Z:F0})"; foreach (var spot in EnemyTerritorySpotCatalog4229938.ForRaid(raidId)) { var d = Math.Sqrt((spot.X - pos.X) * (spot.X - pos.X) + (spot.Z - pos.Z) * (spot.Z - pos.Z)); if (nearestDistance < 0 || d < nearestDistance) { nearestDistance = d; nearestName = spot.AreaName; } } } return new { online = target is not null, raidId, total, here, auto = cfg.EnemyTerritoryEnabled, enter = cfg.EnemyTerritoryEnterMeters, exit = cfg.EnemyTerritoryExitMeters, perPoint = cfg.EnemyTerritoryPerPoint, maxActive = cfg.EnemyTerritoryMaxActive, engage = cfg.EnemyTerritoryEngage, active = target is not null ? GameRouter.EnemyTerritoryActiveCount(target.Value.Session) : 0, playerPos, nearestName, nearestDistance = nearestDistance < 0 ? -1 : Math.Round(nearestDistance, 1), error, }; } public static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnEnemyTerritoryAsync( float radius) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", [], ""); return await GameRouter.SpawnEnemyTerritoryAsync(target.Value.Session, radius); } /// /// Server-driven walk test for the citizens spawned this session. See GameRouter.WalkCiviliansAsync: /// the point is to compare moveId=0 (what every call site used to send) with a real gait on the /// same trajectory, because only the screen can settle which one animates. /// public static async Task<(bool Ok, string Message, int Count)> WalkCiviliansAsync( byte moveId, float distance, float durationSeconds) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0); return await GameRouter.WalkCiviliansAsync( target.Value.Session, moveId, distance <= 0f ? 6f : distance, durationSeconds <= 0f ? 3f : durationSeconds); } // ---- Donor-build static NPC spawn (verbatim port) ---------------------------------------- // Kept separate from the civilian card above: this is the donor's exact sequence, including // the per-spawn Aether init, used to prove the fourth entity channel works before layering // persistence / presets on top. public static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnDonorStaticNpcsAsync( IReadOnlyList items, float xSpacing, float zSpacing, int maxPerRow) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", []); return await GameRouter.SpawnDonorStaticNpcsAsync(target.Value.Session, items, xSpacing, zSpacing, maxPerRow); } /// /// Unit states that stop the player from moving or acting, taken from UnitStateConfig /// (NoSkill=1) and hand-picked down to the ones that are clearly a negative control /// effect rather than a neutral/beneficial flag. /// /// Why this exists: enemy casts are faked by us, but the hit and the resulting stiffness /// are resolved entirely client-side (we never send damage). On a real server the same /// settlement pass that applies the hit also releases it; here nobody does, so the state /// sticks forever. Pressing E is the client's own break-out, which clears movement but /// not the cast lock — exactly the "can move again but only the first combo step fires" /// symptom. /// /// Deliberately NOT included: 313/314/315 (免疫硬直 — those are the cure, not the /// disease), 316 悬浮, 317 骑机车, 362-364 燃烧/减速/中毒, and the rest of the 321 /// NoSkill rows that are ordinary gameplay flags. /// internal static readonly uint[] PlayerControlStates = [ 5, // 传送 9, // Stun 12, // 定身 26, // 眩晕 27, // 致盲 34, // 技能施法 58, // 不能普攻 61, // Frozen 143, // 传送 145, // 对话 158, // 睡眠 191, // 技能供打断状态 324, // 被击飞过程中 351, // 静止不可移动 360, // 麻痹 361, // 短路 367, // 晕阙 368, 369, 370, 371, // 技能不可自我打断 1-4 373, // 倒地 375, // BVB束缚 ]; /// /// Releases control states on the player unit. mode "curated" sends the hand-picked list /// above; mode "sweep" brute-forces every state id in 1..500, which is the fallback for /// when the stuck state is one we did not guess. /// public static async Task<(bool Ok, string Message, int Count)> ClearPlayerControlStatesAsync(string mode) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)", 0); var (session, state) = target.Value; var unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : 100000000000UL; var sweep = string.Equals(mode, "sweep", StringComparison.OrdinalIgnoreCase); IEnumerable ids = sweep ? Enumerable.Range(1, 500).Select(i => (uint)i) : PlayerControlStates; return await ClearControlStatesAsync(session, unit, ids, sweep ? "sweep" : "curated"); } /// /// Unit states that keep the knocked-out HUD ("昏迷") up, from UnitStateConfig: /// 373 倒地, 10564 FallingDownKeepAlive, 10583 FallingDownRecoverHealthState. /// internal static readonly uint[] PlayerDownStates = [373, 10564, 10583]; /// /// Clears a stuck "昏迷 100%" HUD: re-publishes the player's HP table and explicitly exits /// the FallingDown state. /// /// Why this is needed: a spawned agent once shared the player's own entity id /// (see ServerSpawnUidOrdinalOffset), so the enemy HP settlement ran on the player and /// drove its HP to zero. With EnableFallingDownOnDeath2 on, HP=0 switches the HUD to the /// knocked-out bar and nothing writes a full HP value back afterwards — the bar stays up /// even though control is unaffected. /// /// unitId defaults to the tracked player unit. Pass the client-side entity id explicitly /// when that is the one driving the bar (packets log: AskUnitMoveActionSimple.UnitId). /// public static async Task<(bool Ok, string Message)> RestorePlayerHealthAsync(ulong unitId = 0) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; // The ledger is authoritative, so it has to be healed too — otherwise the next hit would // settle from the stale pre-revive value and re-kill the player. lock (state.SyncRoot) { state.PlayerHp = CombatCodec.MaxHp; state.PlayerDowned = false; } // Investigation 2026-09-21: there is only ONE player id space. Every packet the client // originates for the player — AskUseSkill.Releaser, AskMultipleSkillHit2.ReleaserId, // AskReportLogicAgentSyncData.AgentId — carries Profile.InitialUnitId (100000000000). // So this one id is all we can heal; anything else was a wrong guess. Callers may still // pin an explicit id to experiment with a candidate entity. var units = new List(); if (unitId != 0) units.Add(unitId); else units.Add(state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId); var distinctUnits = units.Distinct().ToList(); foreach (var unit in distinctUnits) { // Hard rule 52: attribute table FIRST, then the ABSOLUTE hp value. await session.NotifyAsync(MethodId.SyncUnitAttrs, UxSerializer.Serialize(CombatCodec.UnitAttrs(unit, CombatCodec.MaxHp)), CancellationToken.None); await session.NotifyAsync(MethodId.SyncUnitHp, UxSerializer.Serialize(CombatCodec.UnitHp(unit, CombatCodec.MaxHp)), CancellationToken.None); foreach (var id in PlayerDownStates) await session.NotifyAsync(MethodId.SyncRemoveUnitState, UxSerializer.Serialize(new SceneMethods.SyncRemoveUnitState { unitId = unit, state = id, reason = 1, sourceId = 0, }), CancellationToken.None); await session.NotifyAsync(MethodId.IGameSceneToClient_SyncPlayerFallingDownState, UxSerializer.Serialize(new SceneMethods.SyncPlayerFallingDownState { uid = unit, isEnter = false }), CancellationToken.None); } session.Log.Info($"[REVIVE] units=[{string.Join(",", distinctUnits)}] hp={CombatCodec.MaxHp:F0} downStates={PlayerDownStates.Length} fallingDown=false"); return (true, $"restored hp on {string.Join(", ", distinctUnits)} (attrs->hp, cleared {PlayerDownStates.Length} down states, fallingDown=false)"); } /// /// Locks or releases the player's life. /// /// The bar always drains — HP is server-authoritative now, so a hit always shows. What the lock /// decides is only whether it can reach zero: /// * locked (default): the drain stops at , /// the player gets hurt but is never knocked out; /// * released: zero is lethal — knock-out, then an automatic revive after /// . /// public static (bool Ok, string Message) SetPlayerLifeLock(bool locked) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; lock (state.SyncRoot) { state.PlayerLifeLocked = locked; state.PlayerDowned = false; // Releasing the lock should not start from the 1 HP floor the lock leaves behind. if (!locked || state.PlayerHp < CombatCodec.MaxHp) state.PlayerHp = CombatCodec.MaxHp; } session.Log.Info($"[LIFE-LOCK] player life {(locked ? "locked (never dies)" : "released (can be killed)")}"); return (true, $"生命值锁定:{(locked ? "开(会掉血但不会死)" : "关(会被打死)")}"); } /// Current life-lock state plus the authoritative HP, for the panel toggle. public static (bool Ok, bool Locked, float Hp, string Message) GetPlayerLifeLock() { var target = RequirePlayerSession(); if (target is null) return (false, true, 0f, "no player session tracked yet (log in with the game client first)"); var (_, state) = target.Value; lock (state.SyncRoot) return (true, state.PlayerLifeLocked, state.PlayerHp, "ok"); } /// Turns the attack AI's automatic control-state release on or off. public static (bool Ok, string Message) SetAutoUnlock(bool enabled) { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); GameRouter.GetLocalEnemyRuntimeState(target.Value.Session).AutoUnlockEnabled = enabled; target.Value.Session.Log.Info($"[UNLOCK] auto-release {(enabled ? "enabled" : "disabled")}"); return (true, $"auto-release {(enabled ? "enabled" : "disabled")}"); } /// Session-scoped core, so the attack AI can schedule its own releases. internal static async Task<(bool Ok, string Message, int Count)> ClearControlStatesAsync( TcpSession session, ulong unit, IEnumerable ids, string label) { var count = 0; foreach (var id in ids) { await session.NotifyAsync(MethodId.SyncRemoveUnitState, UxSerializer.Serialize(new SceneMethods.SyncRemoveUnitState { unitId = unit, state = id, reason = 1, sourceId = 0, }), CancellationToken.None); count++; } session.Log.Info($"[UNLOCK] cleared player unit={unit} mode={label} states={count}"); return (true, $"sent SyncRemoveUnitState x{count} to unit {unit}", count); } internal static (TcpSession Session, WorldEntryState State)? RequirePlayerSession() { var best = PickPlayerSession(); if (best is null) return null; var state = GameRouter.GetStateIfExists(best.Value.Session); return state is null ? null : (best.Value.Session, state); } private static (TcpSession Session, long Ticks)? PickPlayerSession() { (TcpSession Session, long Ticks)? best = null; foreach (var entry in Sessions.Values) { try { if (!entry.Session.Client.Connected) continue; } catch { continue; } if (GameRouter.GetStateIfExists(entry.Session) is null) continue; if (best is null || entry.Ticks > best.Value.Ticks) best = entry; } return best; } /// /// Retires every character summoned through SpawnNpcAsync, for the wheel's "disband all" button /// (AskDestroyAllGangMember, 63243016). /// /// This only works because summoned characters are given their own uid segment /// (SummonedNpcUidBase). The earlier build reused the roster's canonical unit ids, which double /// as the spirit-switch identities the client pre-allocates, and SyncLogicAgentLeave was a /// silent no-op on those — the client never retired the copy. A dedicated segment retires fine. /// public static async Task<(bool Ok, string Message)> DismissSummonedNpcsAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "no player session tracked yet (log in with the game client first)"); var (session, state) = target.Value; ulong[] ids; lock (state.SyncRoot) { ids = state.SummonedNpcEntityIds.ToArray(); state.SummonedNpcEntityIds.Clear(); } if (ids.Length == 0) return (true, "场上没有召唤角色"); foreach (var id in ids) { try { await session.NotifyAsync(MethodId.SyncLogicAgentLeave, UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)), CancellationToken.None); } catch (Exception ex) { return (false, $"清除 unit {id} 失败: {ex.Message}"); } } return (true, $"已清除 {ids.Length} 个召唤角色"); } // ---------- 人流密度(T0-8 调参卡) ---------- /// /// 立刻按当前所在地块重算并推一次人群密度。 /// 为什么必须要有这个入口:人群没有 tick 循环,只在「进世界」和「移动 >35m」推包, /// 原地改配置是不会生效的 — 必须手动推一次。 /// public static async Task<(bool Ok, string Message)> RefreshCrowdAsync(float? overrideDensity = null) { var target = RequirePlayerSession(); if (target is null) return (false, "无玩家会话(请先用游戏客户端登录)"); var crowdOn = PrivateServerConfigStore.Current.Gameplay.Aether.CrowdEnabled; try { var ok = await GameRouter.RefreshAmbientCrowdAsync(target.Value.Session, overrideDensity); if (!crowdOn) return ok ? (true, "人群已关闭:密度压到 0,已回收服务端行人 NPC") : (false, "人群已关闭,但清零推送没发成功(看服务端日志 [CROWD])"); var probe = GameRouter.ProbeCrowdDensity(target.Value.Session); var shown = overrideDensity ?? (probe.Ok ? probe.Density : 0f); return ok ? (true, $"已推送 density={shown:F3} plot={probe.PlotName}") : (false, $"未推送(1500ms 合并窗口内刚推过)density={shown:F3}"); } catch (Exception ex) { return (false, $"推送失败: {ex.Message}"); } } /// /// 只算不推,给面板显示「玩家脚下当前会算出什么密度」。 /// 改完参数不用真的走 35 米,页面上就能看到目标值的变化。 /// public static (bool Online, float Density, string PlotName, string LandPlotType, double HourFactor, int Hour, bool ExplicitTime) CrowdProbe() { var target = RequirePlayerSession(); if (target is null) return (false, 0f, "", "", 0.0, DateTime.Now.Hour, false); var (_, state) = target.Value; var p = GameRouter.ProbeCrowdDensity(target.Value.Session); if (!p.Ok) return (false, 0f, "", "", 0.0, DateTime.Now.Hour, false); int hour; bool explicitTime; lock (state.SyncRoot) { hour = state.HasExplicitTime ? (int)state.TimeHour : DateTime.Now.Hour; explicitTime = state.HasExplicitTime; } return (true, p.Density, p.PlotName, p.LandPlotType, p.HourFactor, hour, explicitTime); } // ============================================================ 搜打撤(Extraction Shooter) /// 面板「🎒 搜打撤」的状态体。。 public static object ExtractionState() => GameRouter.ExtractionStateForPanel(); /// /// 面板开关的总入口。所有参数都是「null = 不动」,前端可以每次只提交改过的那一个。 /// /// ⛑ 面板改动只在内存里:刻意不落盘 —— 这是排障工具,重启就该回到配置文件的默认值, /// 免得「昨天为了测试开的开关今天还在生效」这种幽灵行为。 /// public static (bool Ok, string Message) SetExtractionOptions( bool? fakeMatch, uint? preferredRaid, bool? exitToMainCity, bool? exitSettlement, string? wheelSync, bool? wheelWeaponData) { var target = RequirePlayerSession(); if (fakeMatch is not null) GameRouter.ExtractionFakeMatch = fakeMatch.Value; if (preferredRaid is not null) GameRouter.ExtractionPreferredRaid = preferredRaid.Value; // 这两个没有运行时 override(不需要热切),面板上只能看不能改 ⇒ 明确告诉用户去改配置, // 免得前端整包提交时这两个字段被静默丢掉、用户以为改成功了。 var hint = ""; if (exitToMainCity is not null || exitSettlement is not null) hint = "(这两个要在 config/private-server.json 的 world.content.extraction 里改,改动需要重启)"; // 武器轮盘:下发的三种模式(off / incremental / full)。认不出的名字保持原值不动。 if (!string.IsNullOrWhiteSpace(wheelSync) && Enum.TryParse(wheelSync, true, out var mode)) { GameRouter.ExtractionWheelSync = mode; // 从 off 切回来时,客户端手里那张表已经不可信(期间可能被城镇 16 槽配装覆盖过), // 强制丢一次基线,下一次同步必走整表。 if (mode != GameRouter.ExtractionWheelSyncMode4229938.Off && target is not null) { lock (target.Value.State.SyncRoot) target.Value.State.ExtractionWheelPublished.Clear(); } } if (wheelWeaponData is not null) ExtractionShooterRuntime4229938.WheelWeaponDataEnabled = wheelWeaponData.Value; return (true, $"伪造匹配={GameRouter.ExtractionFakeMatch} · 目标 raid={GameRouter.ExtractionPreferredRaid} " + $"· 轮盘同步={GameRouter.ExtractionWheelSync} " + $"· 轮盘带 WeaponData={ExtractionShooterRuntime4229938.WheelWeaponDataEnabled} {hint}"); } /// 面板「进图」。 public static async Task<(bool Ok, string Message)> StartExtractionAsync(uint raidId) { var target = RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var (session, state) = target.Value; return await GameRouter.EnterExtractionRaidAsync(session, state, raidId); } /// 面板「退出副本回主城」—— 与游戏里点退出走同一条 ExitExtractionAsync。 public static async Task<(bool Ok, string Message)> ExitExtractionAsync() { var target = RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var (session, state) = target.Value; uint activeRaid; lock (state.SyncRoot) activeRaid = state.ActiveRaidId; if (!GameRouter.IsExtractionRaid(activeRaid)) return (false, $"当前 raid={activeRaid} 不是搜打撤图,用「场景切换」回主城"); await GameRouter.ExitExtractionAsync(session, state); return (true, $"已退出 raid {activeRaid}(含结算帧)"); } /// 面板「⟳ 重推武器轮盘」:强制整表下发一次 SyncSpiritWeaponDetail。现场排障用。 public static async Task<(bool Ok, string Message)> RepublishExtractionWheel() { var target = RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var (session, state) = target.Value; await GameRouter.PushExtractionWheelSnapshotAsync(session, state, "panel:republish"); return (true, "已重推武器轮盘整表(SyncSpiritWeaponDetail)"); } /// 面板「⟳ 重推登录帧」:把含 PlayerExtractionShooterInfo 的那一帧再发一次。 public static Task<(bool Ok, string Message)> RepublishExtractionLoginFrame() => GameRouter.RepublishExtractionLoginFrameAsync(); /// /// 面板「任意 notify 探针」:把一条裸包按指定 methodId 推给客户端(取证用,见 /// GameRouter.PushExtractionNotifyAsync 的说明)。 /// public static Task<(bool Ok, string Message)> PushExtractionNotify(uint methodId, string? hex) => GameRouter.PushExtractionNotifyAsync(methodId, hex); public sealed class KesiControlOptions { /// true = 接管科斯机甲;false = 切回进入机甲前的角色。 public bool Enter { get; set; } = true; } /// /// GM 面板 / POST /api/kesi/control:科斯机甲的原生控制切换(2026-09-26, /// 移植 7k-v0.5 GameRouter.Kesi.cs,实现在 GameRouter.Kesi.cs)。 /// /// 科斯 = FightSpiritConfig 15022030 "特摄战原型-科斯",隐藏可控原型 <=> /// IsPublish=false / CanJoin=false / Invisible=true ⇒ 不在 roster 里,GM 面板换人下拉里也选不到。 /// /// 怎么用:进游戏 → 下车 → 站在室外空地 → 点「控制科斯机甲」→ 看模板有无出现。 /// 判据:服务端日志 [KESI] enter=True ok=True;失败则看 [SWITCH-MIN]。 /// ⛑ 服务端协议侧齐了 ≠ 画面上能动:模型异步加载、镜头、机甲技能面板都要实机确认 /// (捐赠端自己也标了"尚需游戏内验证")。 /// public static async Task<(bool Ok, string Message)> KesiControlAsync(KesiControlOptions? opt) { var target = RequirePlayerSession(); if (target is null) return (false, "还没有玩家会话(先用游戏客户端登录)"); var (session, _) = target.Value; return await GameRouter.ControlKesi4229938(session, opt?.Enter ?? true); } }