Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameAdminBridge.cs
T

5348 lines
269 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.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;
/// <summary>Public snapshot of the tracked game session for the admin panel (no internal types leak).</summary>
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; }
}
/// <summary>Public weapon catalog entry for the admin panel.</summary>
public sealed class AdminWeaponEntry
{
public uint TemplateId { get; init; }
public string Name { get; init; } = string.Empty;
public ulong InstanceId { get; init; }
}
/// <summary>Public vehicle catalog entry for the admin panel (flying/water spawner).</summary>
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;
}
/// <summary>Public scene preset (raid/instance/universe + arrival) for the admin panel.</summary>
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; }
}
/// <summary>Public NPC (character) catalog entry for the admin panel.</summary>
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<string> Menus { get; init; } = Array.Empty<string>();
}
/// <summary>One row of the 生成敌人 / 生成NPC catalog (raw AgentConfig agent).</summary>
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; }
/// <summary>
/// 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.
/// </summary>
public int VariantCount { get; init; } = 1;
/// <summary>
/// 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.
/// </summary>
public uint[] VariantIds { get; init; } = [];
/// <summary>
/// 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".
/// </summary>
public bool HasHpBar { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public static class GameAdminBridge
{
private static readonly ConcurrentDictionary<string, (TcpSession Session, long Ticks)> 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 []; }
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<AdminVehicleEntry> 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 []; }
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<AdminScenePreset> ScenePresets()
{
uint universe;
try { universe = PrivateServerConfigStore.Current.World.UniverseId; }
catch { universe = 76000888; }
var result = new List<AdminScenePreset>
{
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;
}
/// <summary>
/// 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
/// <see cref="ScenePresets"/> — the admin panel is unaffected because it picks by Key.
/// </summary>
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;
}
/// <summary>
/// 按 <c>pathId</c> + 世界坐标刷<b>一个</b>物件,走 <c>SyncGadgetAddList</c>(68925813)
/// 的单件批量形式(与 PropRestock / 小游戏摊位同一条已验证通道)。
///
/// <para>⛑⛑ <b><c>iScale</c> 必须 0</b>:0 ⇒ 客户端原生 1:1;写 100 会建成
/// <c>localScale=(0,0,1)</c> 的<b>空壳</b>(无模型、交互范围缩到原点)。09-23 健身房器材
/// 就是栽在这上面,<c>GymEquipmentSettings.IScale</c> 已从 100 改 0。</para>
///
/// <para>用途:给街头篮球 QTE 补篮筐。<c>PoiGameConfig.Basket_BallStand</c>
/// (<c>BasketballSceneNode.prefab</c>)里<b>没有篮筐模型</b> —— 只有
/// <c>PivotNode/LanWangCenterPoint</c>(空 Transform,进球判定点)和场边 5 个球架
/// (<c>sc_fd_ty_wjz_lanqiujia01a_pc</c>),玩家站在圆心投篮时目标点是个"虚空点"。
/// 专用篮筐件 <c>Gadgets_Common_BasketballRim = 1086190276</c> 在
/// <c>GadgetCatalog.json</c> 里(客户端认得 ⇒ 不会像 gym 那次变空壳),
/// 世界摆位为 0,需要时由本方法刷出来。</para>
/// </summary>
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);
}
/// <summary>
/// ★★ <b>街头篮球:把对手(红铁桶 42071029)的身体网格重新点亮</b>(2026-10-03 23:4x)。
///
/// <para>⛑⛑ <b>根因(实测,别再猜"没加载")</b>:<c>BasketballNpcCharacter.LoadCharacterModel</c>
/// 走 <c>gCS.LuaUtils.GetDialogModelByAgentId(args)</c>,模型<b>确实加载出来了</b>——
/// 节点 <c>monster_A321001_p003_gm(Clone)</c> 在 <c>baseUnit.ModelSlot.transform</c> 下,
/// active=true、scale=1、离玩家约 8.5m、animator 已挂 <c>basketball_tin_mirror</c>;
/// 但 <c>body</c> / <c>body01</c> 两个 <b>SkinnedMeshRenderer.enabled = false</b>,
/// 只有辅助体 <c>TouchBendProxy</c>(碰撞代理)亮着 ⇒ 玩家看不见对手。
/// 怀疑是 <c>args.TargetUnit = gCS.LocalUnitMgr:GetNpcByTemplateId(42071029)</c> 为 nil
/// (凭空开局 ⇒ 场景里没有该模板 NPC)导致 dialog 模型停在"未上场"态。</para>
///
/// <para>⛑ 修法:<b>只开 SkinnedMeshRenderer</b>。不能开 <c>Capsule AO Body</c> /
/// <c>TouchBendProxy</c> 这类 MeshRenderer 辅助体(AO / 弯曲代理,开了会出怪块)。
/// 实测开启 40s 后仍为 true,引擎不会自动关回去。</para>
///
/// <para>⛑ 由面板在开局脚本之后延时调用(Lua 环境里 <c>wait</c> / <c>UpdateBeat</c> 都是 nil,
/// 延迟只能在浏览器 JS 的 setTimeout 里做)。</para>
/// </summary>
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;
}
/// <summary>Instant move: SyncUnitPositionAndFacing (see GameRouter.TeleportPlayerInstantAsync).</summary>
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}");
}
}
/// <summary>Loading-flow move: SyncPreTeleportOption + SyncTeleport with a fresh teleport id.</summary>
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}");
}
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>
/// 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).
/// </summary>
/// <param name="channel">0 = SyncGadgetAdd, 1 = SyncGadgetAOIAddAndRemove, 2 = both.</param>
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);
}
}
/// <summary>Admin gadget clear: removes the props this panel placed. One id, or all of them.</summary>
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);
}
}
/// <summary>
/// T2-31 Seymour 大停电 — 把控制器 Gadgets_Hacker_Tetris(1166913245) 放进世界,然后<b>停手</b>。
///
/// 与 <see cref="SpawnGadgetAsync"/> 的区别不是"换个 pathId"那么简单:那个对象是
/// IsTask=true、带着 startTaskId/endTaskId、用 packed iScale 的<b>任务图</b>物件,
/// 普通放置物件那条路径的参数一律不适合它。
///
/// 成功后返回 <c>ok waiting-for-interact</c>:服务端什么都不用再做,接下来要看的是
/// <b>客户端自己有没有因为这个物件而弹黑客交互</b>(详见 GameRouter.Blackout.cs 头部说明)。
/// </summary>
/// <summary>Panel-triggered blackout. Pass all three coordinates to override the landing spot
/// (otherwise <c>Gameplay.BlackoutControllerAtBillboard</c> decides).</summary>
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);
}
}
/// <summary>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.</summary>
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}");
}
}
/// <summary>Diagnostics: what the server currently believes about the running round.</summary>
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);
}
/// <summary>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).</summary>
public sealed record BlackoutStatusSnapshot(bool Online, bool Running, ulong ControllerId, int RemainingSeconds, string? EndsAt);
/// <summary>Admin catalog: the Gadget prefab groups shown in the panel picker.</summary>
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();
/// <summary>Admin enter: seats the player in the last spawned vehicle (no F-press needed).</summary>
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}");
}
}
/// <summary>Admin exit: ejects the player from the current vehicle.</summary>
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}");
}
}
/// <summary>
/// 自动驾驶巡航速度的面板投影(2026-09-30)。
/// <c>SavedKmh</c> 为 null = <c>config/autonomous-driving.json</c> 还不存在,
/// 也就是「没在 GM 里调过速」,此时生效值就是主配置 <c>cruiseSpeed × 3.6</c>。
/// <c>ActiveAutoDrive</c> = 当前有没有正在自驾的车(决定保存时能不能热应用)。
/// </summary>
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);
}
/// <summary>
/// 保存自动驾驶巡航速度(km/h)。<b>有车正在自动驾驶时顺带热应用</b>:
/// 撤回旧 Cruise 任务 → 用新速度重建 → 再确认一次,路线不变。
/// ⛑ 没有会话 / 没在开车也算成功 —— 速度已经落盘,下次开自动驾驶生效。
/// </summary>
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);
}
}
/// <summary>Admin time set: records + pushes SyncPlayerCurrentTime.</summary>
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}");
}
}
/// <summary>Admin weather set: records + pushes SyncPlayerWeather.</summary>
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(),
};
}
/// <summary>
/// GM 面板「角色日程」卡片:每个可玩角色此刻在干什么、在哪、以及一个已经算好偏移的传送落点。
///
/// 数据全来自官方三张表(养成 AgentTag → 时刻表 → 活动表),与场上真正放人的
/// <c>GameRouter.RosterNpc.cs</c> 走同一个 <c>NpcActivityCatalog4229938.Resolve</c>,
/// 所以面板显示的坐标就是服务端实际会放人的坐标。
///
/// ⛑ <c>goto*</c> 故意<b>不</b>落在角色身上:挪到身旁约 3 米,并算出朝向角色的 facing,
/// 免得传送完贴在脸上(用户明确要求)。facing 约定与全项目一致:
/// <c>atan2(dx, dz) * 180 / π</c>(见 GameRouter.EnemyAiActions.cs:454)。
/// ⛑⛑ 落点必须踩在 <c>CivilianDistricts</c> 的官方人行道上 —— 按固定方向偏移会卡进墙里
/// 或掉进海里(09-25 玩家实测)。
/// </summary>
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<uint, ulong> placed;
if (target is null)
{
daySecond = (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds;
raid = Profile.RaidId;
px = py = pz = 0f;
placed = new Dictionary<uint, ulong>();
}
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<object>();
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,
};
}
/// <summary>
/// 「传送过去」的落点:角色身旁约 <see cref="LandingMeters"/> 米,且必须踩在
/// <c>CivilianDistricts</c> 的官方人行道 spot 上。
///
/// ⛑⛑ 为什么不能再按固定方向偏移:7.07m 的斜向挪动把玩家送进过墙里和海里(09-25 玩家实测)。
/// 那 13786 个 spot 是服务端生成满城市民用的、全部可站立,所以取「离角色 3 米最近的那个」
/// 既满足「身旁 3 米」又保证脚下是实地。一个都找不到时才退回固定偏移(并沿用角色自己的 Y)。
/// </summary>
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);
}
/// <summary>Admin weapon grant: pushes SyncArmoryAddWeapon for a catalog template.</summary>
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}");
}
}
/// <summary>Admin bulk weapon grant: pushes SyncArmoryAddWeapon for every catalog weapon.</summary>
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<AdminWeaponEntry> WeaponCatalog()
=> GameRouter.WeaponCatalog().Select(w => new AdminWeaponEntry
{
TemplateId = w.TemplateId,
Name = ClientData.Client4229938.WeaponNameTranslator4229938.EnglishFor(w.TemplateId, w.Name),
InstanceId = w.InstanceId,
}).ToList();
/// <summary>Admin scene ("raid") switch: re-push SyncEnterScene for the destination.</summary>
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}");
}
}
/// <summary>Public timeline/cutscene entry for the admin panel.</summary>
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; }
}
/// <summary>Timeline catalog: all named cutscenes from TimelineConfig.json.</summary>
public static IReadOnlyList<AdminTimelineEntry> 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<AdminTimelineEntry> { new() { TimelineId = 0, TimelineName = $"catalog error: {ex.Message}" } };
}
}
/// <summary>一条台词,供面板「剧情对话」子页列表渲染。</summary>
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; }
}
/// <summary>
/// 台詞索引(<c>DialogIndex.json</c>,58932 条)。支持关键词(文案 / 说话人 / id 片段)与
/// 「只看过场」过滤。客户端播不播不依赖这张表 —— 它只是给面板挑 id 用的清单。
/// </summary>
public static IReadOnlyList<AdminDialogEntry> 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<AdminDialogEntry> { new() { Message = $"catalog error: {ex.Message}" } };
}
}
/// <summary>面板用的统计(总数 / 过场数),给卡片标题显示。</summary>
public static (int Total, int Cutscene) StoryDialogStats()
=> (ClientData.Client4229938.StoryDialogue4229938.Count,
ClientData.Client4229938.StoryDialogue4229938.CutsceneCount);
/// <summary>推一条台词(含过场镜头)。</summary>
public static Task<(bool Ok, string Message)> PlayStoryDialogAsync(uint dialogId)
=> GameRouter.PushStoryDialogAsync(dialogId);
/// <summary>只推语音。</summary>
public static Task<(bool Ok, string Message)> PlayStoryDialogVoiceAsync(uint dialogId)
=> GameRouter.PushStoryDialogVoiceAsync(dialogId);
/// <summary>
/// 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.
/// </summary>
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 /
// 距离 / 权重影响),玩家想看「送人下车」那条根本选不到。
/// <summary>一条切人演出,供面板「切人演出」子页列表渲染。</summary>
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; }
/// <summary>timeline 命中 CommonTimelinePrefix —— 这类行允许「FightSpiritId 空 = 人人可用」。</summary>
public bool IsCommon { get; init; }
public bool Playable { get; init; }
public bool HasCompanionAgents { get; init; }
public float[] Position { get; init; } = [];
/// <summary><b>能演这条演出的角色</b>(已按 IdsFor 判据算好,并扣掉 Forbid)。</summary>
public IReadOnlyList<uint> FightSpiritIds { get; init; } = [];
/// <summary>与 <see cref="FightSpiritIds"/> 一一对应的显示名。</summary>
public IReadOnlyList<string> SpiritNames { get; init; } = [];
/// <summary>默认选中的目标角色(清单第一个)。</summary>
public uint DefaultSpiritId { get; init; }
public IReadOnlyList<uint> AgentIds { get; init; } = [];
}
/// <summary>面板「切人演出」清单(一行 SwitchSpiritConfig = 一出演出)。</summary>
public static IReadOnlyList<AdminSwitchShowEntry> 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<AdminSwitchShowEntry> { new() { Description = $"catalog error: {ex.Message}" } };
}
}
/// <summary>
/// 当前受控角色(0 = 还没有玩家会话)。前端靠它把目标角色下拉的默认值避开当前角色 ——
/// 否则「选一条演出 → 默认目标正好是当前角色」⇒ 服务端静默 return ⇒ 点了没反应。
/// </summary>
public static uint ActiveSpiritId()
{
var target = RequirePlayerSession();
if (target is null) return 0;
lock (target.Value.State.SyncRoot)
return target.Value.State.ActiveSpiritTemplateId;
}
/// <summary>指定某条演出来播。</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// 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).
/// </summary>
public static object MetroStatus()
=> GameRouter.MetroStatus(RequirePlayerSession()?.Session);
// ── 通缉系统(Wanted · 警车版,2026-09-23 自捐赠端 miyuki_fix 移植)────────────────────
/// <summary>Body of <c>POST /api/wanted/action</c>。action 为空串时只回状态。</summary>
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; }
}
/// <summary>通缉系统只读状态(犯罪值 / 星级 / 已派警车 / 结算统计)。</summary>
public static object WantedStatus()
=> GameRouter.WantedReport(RequirePlayerSession()?.Session);
/// <summary>
/// 通缉系统动作:addviolation / setvalue / setlevel / escape / completeescape / arrest / clear /
/// configure。需要玩家已进世界(否则返回 world state not ready)。
/// </summary>
public static Task<object> WantedActionAsync(WantedActionRequest? cmd)
{
var session = RequirePlayerSession()?.Session;
if (cmd is null)
return Task.FromResult<object>(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);
}
/// <summary>Master switch of the metro dispatcher.</summary>
public static object SetMetroEnabled(bool enabled)
=> GameRouter.SetMetroEnabled(RequirePlayerSession()?.Session, enabled);
/// <summary>
/// Retune the dispatcher. <paramref name="lines"/> 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 <paramref name="auto"/> = 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 <paramref name="lines"/> list turns raid-following off until auto is sent again.
/// </summary>
public static object ConfigureMetroLines(IReadOnlyList<uint>? lines, int? pushMs, double? timeFactor, bool? auto = null)
=> GameRouter.ConfigureMetroLines(RequirePlayerSession()?.Session, lines, pushMs, timeFactor, auto);
/// <summary>Stop pushing. The client keeps whatever carriages it already built.</summary>
public static object StopMetro()
=> GameRouter.StopMetro(RequirePlayerSession()?.Session);
/// <summary>
/// 远程 GM 工具命令:把一条命令推给客户端"本地执行"。
/// IGameGMToClient.SyncRunGmUtils(String methodName, List&lt;String&gt; 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 是网络命令名,客户端本地不存在),需实测。
/// </summary>
public static async Task<(bool Ok, string Message)> RunGmUtilsAsync(string methodName, IReadOnlyList<string> args)
{
var argList = (args ?? Array.Empty<string>())
.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}");
}
}
/// <summary>
/// 第二条 GM 通道:IGameSceneToClient.SyncDoGmCommand(String command, String[] args) — 68373443。
/// 客户端由 LX6.Service.GameSceneToClientImpl 实现(ParseSpoonLines 解析命令行后走本地 GM 系统)。
/// command 可带参数整体填入(如 "AddNpc 40100010"),args 留空;或 command 只填命令名、参数放 args。
/// </summary>
public static async Task<(bool Ok, string Message)> DoGmCommandAsync(string command, IReadOnlyList<string> args)
{
var argList = (args ?? Array.Empty<string>())
.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}");
}
}
/// <summary>
/// 诊断用:同一条 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(整条命令被当无参执行)。
/// </summary>
public static async Task<(bool Ok, string Message)> GmProbeAsync(string method, IReadOnlyList<string> args)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet — 请先登录并进入世界(角色出现在游戏里)后再点");
var argList = (args ?? Array.Empty<string>())
.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<string>() })),
("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<string>() })),
};
try
{
var session = target.Value.Session;
var sent = new List<string>();
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<string>();
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}");
}
}
/// <summary>
/// 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.
/// </summary>
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<string> { id };
var full = $"{name} {id}";
var empty = new List<string>();
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<string>();
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}");
}
}
/// <summary>
/// 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).
/// </summary>
public static IReadOnlyList<AdminNpcEntry> NpcCatalog()
{
try
{
return NpcCatalogCache.Value;
}
catch (Exception ex)
{
return new List<AdminNpcEntry>
{
new() { NpcCultivationId = 0, Name = $"catalog error: {ex.Message}" },
};
}
}
private static readonly Lazy<List<AdminNpcEntry>> NpcCatalogCache = new(BuildNpcCatalog);
/// <summary>
/// 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.
/// </summary>
private static readonly Lazy<Dictionary<uint, (uint SpiritId, string Name)>> SpawnResolver =
new(BuildSpawnResolver);
private static Dictionary<uint, (uint SpiritId, string Name)> BuildSpawnResolver()
{
var map = new Dictionary<uint, (uint SpiritId, string Name)>();
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<AdminNpcEntry> 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<uint, List<string>>();
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<string>();
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 != "<player>"
&& !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<string>(),
})
.ToList();
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>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.</summary>
private const long PanelItemSeqFloor = 950000000000L;
private const string PanelItemSeqCounter = "GameAdminBridge.PanelItemSeq";
/// <summary>
/// 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.
/// </summary>
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<Auto.PlayerPackItem>();
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} 个)");
}
/// <summary>
/// 把整批道具分包推给客户端。摊平 + 断线即停,理由同武器发放:
/// 3783 个道具 / 300 = 13 个几百 KB 级的大包,一口气发完会把客户端主线程砸死
/// (Player.log: [Davinci] type=TimeScaleAnomaly 紧跟「与服务器的网络连接已断开」)。
/// </summary>
private static async Task<int> PushItemChunksAsync(
Ananta.SDK.Network.TcpSession session, List<Auto.PlayerPackItem> 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;
}
/// <summary>GM 面板「发放全货币」:三种钱包货币各自补到 <paramref name="amount"/>。</summary>
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);
}
}
/// <summary>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.</summary>
internal const ulong SummonedNpcUidBase = 0x300000000000UL;
/// <summary>Ordinal behind <see cref="SummonedNpcUidBase"/>. 高水位落盘(IdCounterStore):
/// 重启后若从 0 重发,客户端还认得上一轮那个 unitId,SyncLogicAgentLeave 会变成 no-op。</summary>
private const string SummonOrdinalCounter = "GameAdminBridge.SummonOrdinal";
/// <summary>
/// ★ 分配一个**能被 <c>SyncLogicAgentLeave</c> 真正回收**的 NPC unitId(硬规则 165)。
///
/// ⛑⛑ 为什么必须走这一段:<c>100000000000+</c> 是客户端<b>预分配的切人身份</b>
/// (玩家单位 100000000000、各角色 1000000000xx),
/// `SyncLogicAgentLeave` 对这一段是**静默 no-op** —— 客户端根本不回收那一具身体。
/// 09-25 21:5x 实机:每次切走里希都新分配一个 `100000000103 / 100000000105 …`
/// 的留场身体,切回时发的 Leave 全部落空 ⇒ **跑步机上每切一回就多一个里希**。
/// `0x300000000000` 这一段已在 GM 召唤上验证过「建得出、也收得掉」。
///
/// ⛑ 序数必须走 <c>IdCounterStore</c>(高水位落盘):重启后若从 0 重发,
/// 客户端还认得上一轮那个 unitId ⇒ Leave 又变 no-op。
/// </summary>
internal static ulong NextRetirableNpcUid()
=> SummonedNpcUidBase + (ulong)IdCounterStore.Next(SummonOrdinalCounter, 0);
/// <summary>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.</summary>
private static readonly Lazy<Dictionary<uint, uint>> SpiritAgentMap = new(BuildSpiritAgentMap);
private static Dictionary<uint, uint> BuildSpiritAgentMap()
{
var map = new Dictionary<uint, uint>();
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;
}
/// <summary>
/// FightSpiritConfig.Id -&gt; AgentConfig.Id(<c>FightSpiritConfig.AgentId</c>)。拿不到返回 0。
///
/// ★ 这个 id 就是「这个可玩角色在 NPC 世界里的那一面」:
/// 15020967 辰宿 → 40140030,15021021 里希 → 40140040,15021044 李扶风 → 40155013。
/// 它<b>就是 AgentConfig 表里的行 id</b>(<c>NpcCatalog.json</c> 9345 行,15/15 CanJoin 全部命中,
/// 带 AgentPersonaId / AiSettingId),所以可以直接当 <c>NpcFormworkId</c> 走市民通道刷 NPC ——
/// 这就是官方「切人后老角色留在大世界里过自己的日子」的原生实现。
/// </summary>
internal static uint AgentIdForSpirit(uint spiritId)
=> SpiritAgentMap.Value.TryGetValue(spiritId, out var agentId) ? agentId : 0u;
/// <summary>Readable display name for a spirit: the Chinese AgentConfig name when the spirit
/// maps to one, otherwise FightSpiritConfig's own name.</summary>
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;
}
/// <summary>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.</summary>
/// <summary>
/// GM 面板 / <c>POST /api/police/arrest</c>:对指定单位执行一次正向「逮捕」演出
/// (<c>SyncPoliceBeginArrest</c> 68839124)。
///
/// ⛑ 这是<b>验证通道</b>,不是玩法入口 —— 官方的逮捕挂在盘查面板(C# 侧
/// <c>gPoliceJobManager.cs</c>)上,客户端什么时候发 <c>AskPoliceExamInteract</c>(63233964)
/// 我们还没拿到实据。先用 GM 这一发确认"帧能不能出演出",再决定要不要投玩法。
///
/// <param name="uid">目标单位 uid;传 0 ⇒ 用最近一次 GM 召唤的 NPC。</param>
/// </summary>
/// <summary>
/// 里希**牢房收押**(2026-09-30)。把 <paramref name="uid"/> 移进牢房内、停 AI、改写账本。
/// ⛑ 玩家必须站在牢房门口 <c>gameplay.policePrison.nearMeters</c>(默认 4 m)内,且在主城。
/// 详见 <c>GameRouter.PolicePrison.cs</c>。
/// </summary>
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");
}
/// <summary>里希**牢房带出**:把当前收押对象移回门口并恢复 AI。</summary>
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");
}
/// <summary>
/// 里希**押送:把犯人押上警车**(2026-09-30)。
/// 直接发 <c>SyncUnitVehicleStatus</c>(Status=4) + <c>SyncUnitBoardingInfo</c> 绑定座位,
/// 没有原生押送过场(那个要 GATE Lua 通道)。详见 <c>GameRouter.PoliceEscort.cs</c>。
/// </summary>
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);
}
/// <summary>里希**押送:把犯人从车上押下来**(落在车旁 2 m)。</summary>
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);
}
/// <summary>
/// ★★ <b>GATE Lua 通道</b>:把一段 Lua 下发到客户端进程里执行
/// (<c>SyncDoToLuaMaster</c>(45437825) → 客户端内置 <c>gCsToLuaHandler.GmRunLua</c>)。
/// 详见 <c>GameRouter.GateLua.cs</c>。
/// </summary>
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);
}
/// <summary>
/// ★ <b>回执探针</b>:下发一段「执行了就往服务端发 DrmkLuaRx」的 Lua。
/// 服务端日志出现 <c>[VEHICLE-STORY] cmd … name=DrmkLuaRx …</c> ⇒ 客户端真的执行了。
/// </summary>
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));
}
/// <summary>★ 给账本里的一个 NPC 挂 / 摘客户端 F 按钮(自动把 uid 拆成 low32/high32 再生成 Lua)。</summary>
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));
}
/// <summary>服务端账本里可操作的 NPC(供 F 按钮下拉)。</summary>
public static object GateLuaUnits()
{
var target = RequirePlayerSession();
if (target is null)
return new { ok = false, message = "no player session tracked yet", units = Array.Empty<object>() };
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(),
};
}
/// <summary>★ 一步到位:在玩家面前刷一个市民 + 给它挂「盘查」F 按钮(点了才开盘查)。</summary>
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);
}
/// <summary>
/// ★ 捐赠端里希总入口(2026-10-02 逐字移植捐赠端 <c>GameAdminBridge.cs:227-255</c>)。
/// 面板按钮直接打这个;<c>/api/lixi/police</c> 的 action 与捐赠端完全一致。
/// ⛑ 机械狗(dog)与武装支援(armed)本次<b>没搬</b>(依赖 HackerDevices / 友方随从子系统),
/// 故这两个 action 返回"未接通"而不是伪造成功。
/// </summary>
/// <summary>
/// 生成测试动作 NPC 并把它登记进警察账本。
/// ⛑ 逐字对齐捐赠端 <c>GameAdminBridge.cs:249-252</c>:
/// <c>SpawnStaticNpcsAsync([new AdminNpcSpawnItem(4065053, 0)], 2, 2, 1)</c>。
/// ⛑⛑ <b>测试模板不要改,必须是 4065053</b>(2026-10-02 事故复盘)。
/// 客户端原生 <c>SNpcBtnQuery.GetButtons</c> / <c>PoliceJobUtils</c> 按
/// <c>AgentConfig.json → ExamType</c> 判定一个模板是否可被巡卫盘查,
/// <b>只有 ExamType=2 才行</b>(捐赠端 <c>GameRouter.Npc.cs:200-203</c> 的
/// <c>PoliceExamType != 2</c> 判据,提示语原文就是「可用模板 4065053 测试 F 互动」)。
/// 实测数据:4065053(藤原一马 Fujiwara Kazuma)ExamType=<b>2</b>;
/// 42031099(摩天轮售票员 Ferris Wheel Ticket Seller)ExamType=<b>0</b>。
/// 10-02 曾误换成 42031099(理由「4065053 没有原生交互位、按 F 不弹菜单」),
/// <b>该理由是错的</b>:ExamType=0 时面板能被服务端硬推 S002 打开,
/// 但客户端不授予盘查资格 ⇒ 按键点了恒无反应,且菜单里压根不给
/// 「押送上车」。捐赠端 v0.6 全部日志里测试模板 100% 只有 4065053
/// (<c>ACTION_NPC ... template=4065053 examType=2</c>),从未用过 42031099。
/// ★ 2026-10-02 修正:此前主线走自己的 <c>SpawnAgentAsync</c>(<b>战斗 agent 管线</b>),
/// 与捐赠端的<b>静态 NPC 管线</b>不是同一条 —— 静态管线才会发
/// <c>SyncBattleAiS=false</c>(原生 <c>PoliceJobUtils.CanUnitInteract</c> 拒绝 IsBattleAiS
/// 的目标 ⇒ 不发就永远盘查不了),并自带
/// <c>RegisterPoliceNpc4229938</c> + <c>RefreshSpawnedPoliceNpcs4229938</c>
/// (已补进 <c>GameRouter.DonorNpc.cs</c>,对应捐赠端 <c>Npc.cs:141/199</c>)。
/// 主线等价物 = <c>SpawnDonorStaticNpcsAsync</c>(<c>GameRouter.Npc.cs</c> 的逐字移植版)。
/// ⇒ 因此这里<b>不再</b>手动补登记 / 刷新(捐赠端也没有),交给生成管线内部做。
/// </summary>
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, "无效的里希操作。");
}
}
/// <summary>GATE 会话是否已绑定(面板用来提示「要不要重登」)。</summary>
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 会话未绑定 —— 完全退出客户端重进一次。",
};
}
/// <summary>牢房 + 押送 当前状态(收押了谁、车上押着谁、玩家离门口多远、坐标、场景是否 OK)。</summary>
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)}");
}
/// <summary>
/// <c>GET /api/police/targets</c>:当前会话里<b>服务端能点名</b>的全部可抓单位,按距离升序。
///
/// 存在的理由见 <c>GameRouter.CollectPoliceTargets</c> 的注释 —— 城里大多数路人是客户端本地
/// 实体(id 形如 <c>8300000000000000188</c>),服务端抓不到;所以必须先把「抓得到的」摊开,
/// 否则面板上一个 uid 都填不出来。
/// </summary>
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();
}
/// <summary>
/// <c>POST /api/plate/push</c> 的可选覆盖项。<c>null</c> ⇒ 沿用 <c>gameplay.plate</c> 配置里的值。
/// 存在的意义:线格式还没试出来,改配置再推一次比重新编译快得多。
/// </summary>
public sealed class PlatePushOptions
{
/// <summary>手动推时默认无视总开关(配置 enabled=false 也能推)。</summary>
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; }
}
/// <summary>
/// GM 面板 / <c>POST /api/plate/push</c>:手动推一个 Plate(盘)下去。
///
/// 用途:验证第五条管线(硬规则 157)。客户端收到盘才会建 Spoon 图并把 NPC 绑上去播表演,
/// 否则角色只会站在跑步机旁边发呆。线格式目前是猜的(68554224 的 reader 在 C# 侧),
/// 所以所有「有没有 0xFF / count 怎么编码」都做成了可覆盖项。
/// 判据:服务端日志出现 <c>[PLATE] ★★★ AskPlateLoadComplete</c> /
/// <c>AskPlateResourceReady</c>,或客户端 <c>Player.log</c> 报 68554224 反序列化失败。
/// </summary>
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)");
}
/// <summary><c>POST /api/npclife/spoon</c> 的可选覆盖项。</summary>
public sealed class NpcSpoonOptions
{
/// <summary>手动推时无视总开关。</summary>
public bool Force { get; set; } = true;
/// <summary>好感 NPC 的 AgentTag(里希 = 12)。0 ⇒ 用 <see cref="SpiritId"/> 反查。</summary>
public uint? AgentTag { get; set; }
/// <summary>由哪个角色反查 AgentTag / AgentId(默认里希 15021021)。</summary>
public uint? SpiritId { get; set; }
/// <summary>直接指定 spoon 位号;不填就按 <c>gameplay.npcLife.spoonAgentIdMode</c> 取。</summary>
public int? SpoonAgentId { get; set; }
public float? X { get; set; }
public float? Y { get; set; }
public float? Z { get; set; }
/// <summary>线格式旋钮:包尾可空的 busyInfo 写哪个字节(0 / 255 / -1 = 不写)。默认沿用配置。</summary>
public int? BusyInfoMarkerByte { get; set; }
}
/// <summary>
/// GM 面板 / <c>POST /api/npclife/spoon</c>:手动推一条
/// <c>SyncFavorNpcSpoonAgentId</c>(64003213)。
///
/// 这是「让角色真的去用器材」的<b>正式通道</b>(硬规则 160):客户端
/// <c>LifeScheduleNpcModule.AttachNet(activityId, spoonAgentId, hasAmbientLoopPose,
/// ambientLoopPos, ambientLoopFacing)</c> 吃的正是这一条,而我们以前<b>一次都没发过</b>
/// —— 出场包里只有活动 id,所以角色只会播 ambient(站着玩手机)。
///
/// 判据:客户端 <c>Player.log</c> 里出现 <c>[日程表NPC]</c>
/// (<c>LifeScheduleNpcModule.LOG_PREFIX</c>)开头的行,或角色真的上跑步机;
/// 若报 64003213 反序列化失败 ⇒ 把 <c>busyInfoNullMarker</c> 改成 false 再推。
/// </summary>
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");
}
/// <summary><c>POST /api/npclife/timetable</c> 的可选覆盖项。</summary>
public sealed class NpcTimeTableOptions
{
/// <summary>手动推时无视总开关。</summary>
public bool Force { get; set; } = true;
/// <summary>好感 NPC 的 AgentTag(里希 = 12)。0 ⇒ 用 <see cref="SpiritId"/> 反查。</summary>
public uint? AgentTag { get; set; }
/// <summary>由哪个角色反查 AgentTag / AgentId(默认里希 15021021)。</summary>
public uint? SpiritId { get; set; }
/// <summary>直接指定 spoon 位号;不填就按 <c>gameplay.npcLife.spoonAgentIdMode</c> 取。</summary>
public int? SpoonAgentId { get; set; }
/// <summary>true ⇒ 走单条 <c>SyncAgentTagTimeTableInfo</c>(64400736);false ⇒ 全量字典 64219939。</summary>
public bool? SingleTag { get; set; }
}
/// <summary>
/// GM 面板 / <c>POST /api/npclife/timetable</c>:手动推时刻表。
///
/// ★★ 这是 <c>SyncFavorNpcSpoonAgentId</c> 的<b>硬前提</b>:客户端
/// <c>NpcDaliyManager.lua:143</c> 里 <c>OnSyncFavorNpcSpoonAgentId</c> 一进门就判
/// <c>table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag])</c>,空则整帧丢弃 ——
/// 09-25 19:36 那次「推了没反应」就是这个原因。
///
/// 登录帧虽然也带了一份 <c>FavorNpcDailyScheduleInfos</c>,但那一份的
/// <c>SpoonAgentId</c> / <c>CurrentSpoonAgentId</c> / <c>SpoonPosition</c> 全是 0,
/// 等于「有日程、没人占坑」。这里重推一份带 spoon 号的覆盖上去。
///
/// 判据:推完再推 <c>/api/npclife/spoon</c>,角色是否开始播那条活动的表演;
/// 客户端报 64219939 / 64400736 反序列化失败 ⇒ 改 <c>single</c> 再试(排掉字典线格式的变量)。
/// </summary>
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 为空?)");
}
/// <summary>
/// GM 面板 / <c>GET /api/player/pos</c>:读玩家此刻的坐标。
///
/// 为什么需要它:<b>朝向没有表可查</b> —— 跑步机那台 gadget 在 dump 里只在 prefab
/// 注册表出现(有 GraphId/pathId,没有世界摆放与旋转),`GadgetCatalog.json` 同样只有
/// prefab 条目,`MapentranceConfig` 里离跑步机最近的入口也在 56m 外(未必是健身房那扇门)。
/// 于是最靠谱的取法是实测:**站到目标点(比如健身房大门)上,读玩家坐标,反算朝向**。
///
/// 用法:走到想要 NPC 面向的那个点 → 打这个接口拿到 (x,z) → 用
/// <c>atan2(dx, dz)</c> 换成度填进 <c>gameplay.rosterNpc.poiOverrides[].facing</c>。
/// </summary>
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());
}
/// <summary><c>POST /api/npclife/poi</c> 的可选覆盖项。</summary>
public sealed class NpcPoiOptions
{
/// <summary>目标单位 id。0 ⇒ 从常驻 NPC 账本里按 <see cref="SpiritId"/> 查它此刻的实体。</summary>
public ulong? UnitId { get; set; }
/// <summary>由哪个角色反查单位(默认里希 15021021)。</summary>
public uint? SpiritId { get; set; }
/// <summary>POI 动作 id。不填就用 <c>gameplay.npcLife.poiActionId</c>,再不行 198(跑步机跑步)。</summary>
public uint? PoiActionId { get; set; }
}
/// <summary>
/// GM 面板 / <c>POST /api/npclife/poi</c>:给某个单位直接下发
/// <c>SyncAgentPlayPOIAction</c>(68459398)。
///
/// 这是<b>已验证过的原生通道</b>(<c>GameRouter.Ambient.cs:892</c> 同款),
/// 跑步机那条是 <c>UrbanDiversityPOIActionConfig</c> 的
/// <b>198「站姿_运动_跑步机跑步」</b>(MainActionGroup 400 / MainActions [86190])。
///
/// ⛑ 生活日程开着(<c>AgentDataSetsActivityCfgId</c> 非 0)时客户端走生活日程状态,
/// 这一帧会被盖掉 ⇒ 想看效果先把 <c>gameplay.rosterNpc.dailyLife</c> 设 false。
/// </summary>
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");
}
/// <summary>
/// GM 面板 / <c>POST /api/police/examine</c>:给指定 NPC 推一次「案底」
/// (<c>SyncAgentPoliceExamData</c> 68635920,LuaOnly)。
///
/// ⛑ 这是<b>服务端唯一能把盘查 UI 叫出来的通道</b>。盘查面板本体要 NPC 挂
/// <c>MultiInteractTypeConfig.Examine</c> 双人交互位才开得出来(规则 153),按 F 永远出不来;
/// 但这一帧是 <b>LuaOnly</b>(<c>midToExportOption[68635920]</c>)⇒ 客户端 lua
/// <c>PoliceJobManager.OnSyncAgentPoliceExamData</c> 一定收得到,
/// 我们注入的 <c>tools/police_examine.lua</c> 就钩在那里,收到即开盘查面板。
///
/// <param name="uid">目标 uid;0 ⇒ 自动挑最近(同 <see cref="ArrestNpcAsync"/>)。</param>
/// <param name="fineCount">塞几条罪名(从 <c>PoliceFineCatalog</c> 的 IsShow 行里取)。</param>
/// <param name="teleport">先把玩家挪到目标跟前,免得面板开了但人不在视野里。</param>
/// </summary>
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<uint, bool>(),
},
};
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<uint, bool>();
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)}");
}
/// <summary>
/// 给一个 NPC 下发「案底」—— <c>SyncAgentCrimes</c>(68251759, CSharpOnly)。
///
/// ⛑⛑ 这条是盘查链路的开关:<c>PoliceExamineManager</c> 的
/// <c>TryAddFine</c> / 搜身 / 酒测 / 右上「执法建议」全部读
/// <c>module.Crimes</c>,而 <c>Crimes</c> 只有这一帧能填。
/// 不发 ⇒ 面板开得出来但点什么都是空转。
/// </summary>
internal static async Task<(bool Ok, string Message)> SetAgentCrimesAsync(
TcpSession session, ulong uid, IReadOnlyList<uint>? 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}");
}
/// <summary>
/// 手搓 <c>SyncAgentCrimes</c> 的字节,<paramref name="wire"/> 用来现场换线格式。
///
/// ⛑ 为什么不用 DTO 序列化:这一帧极性是 <b>CSharpOnly</b>,客户端 lua 没有 reader 可抄,
/// count 到底怎么编码只能靠 <c>GmCreatePedData.Crimes</c> 那个 writer 类比推断 ——
/// 猜错一次就要重启一次服务端。做成旋钮后,<c>POST /api/police/crimes {"wire":"b"}</c>
/// 当场换格式重发,不重启。
/// 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)
/// </summary>
internal static byte[] BuildAgentCrimesWire(ulong id, IReadOnlyList<uint> 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();
}
/// <summary>GM 面板 / <c>POST /api/police/crimes</c> 的入口(uid=0 ⇒ 挑最近的)。</summary>
public static async Task<(bool Ok, string Message)> PushAgentCrimesAsync(
ulong uid, IReadOnlyList<uint>? 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);
}
/// <summary>
/// 推一次 <c>SyncPoliceExamReaction</c>(64480222, LuaOnly) —— 「你刚才那个盘查动作,对方的反应」。
///
/// ⛑⛑ 这是盘查里**唯一能让面板"有反应"的服务端帧**,2026-09-25 才挖出来。
/// 客户端链路:<c>PoliceJobManager.OnReceiveReaction_Story(reactionId)</c>
/// → <c>PoliceExamineManager.OnReceiveReaction</c>
/// → 用 <c>self.PreOptionId</c> 取 optionCfg + <c>PoliceExamReactionConfig.GetConfig(reactionId)</c>
/// → 两者都在 ⇒ <c>OnSuccessClickOption</c>(加罪名、弹 AI 对话、可能发 AskPoliceEffectiveExam)。
/// ⇒ 所以**必须先让玩家点一下选项**(PreOptionId 才有值),再推这一帧。
/// ⛑ 载荷就是一个 <c>u32 reactionId</c>(<c>RPCDeserializeAuto.lua:3281</c>),极性 LuaOnly ⇒ 只有 lua 收。
/// ⛑ 为什么需要它:官方链路里客户端点选项后 C# 会发 C2S,服务端回这一帧。
/// 但我们这边 C# 那条 story 跑不起来(街上的人没有 Examine 多人交互位)⇒ C2S 根本没发
/// (unknown-methods.log 里一条都没有)⇒ 只能由 GM/服务端主动推。
/// </summary>
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");
}
/// <summary>
/// GM 面板 / <c>POST /api/police/fine</c> 的入口:手动推一次罚款结果
/// (<c>ShowReceiveRewardDetail</c> 64145515,Reason = PoliceFine 180001)。
///
/// 正常链路不需要它 —— 玩家点「开罚单」⇒ C2S <c>AskPoliceFineNpc</c>(63548238)
/// ⇒ <c>GameRouter.PoliceFine.cs</c> 自动回。留着是为了<b>调线格式不用重启</b>。
/// </summary>
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 != "<player>";
return (x.SpiritId, $"{x.Display}({(usable ? x.Raw : x.SpiritId.ToString())})", x.UnitId);
}).ToArray();
}
/// <summary>Monotonic ordinal behind the synthetic agent uid/pid pair.</summary>
private static int s_nextAgentOrdinal;
/// <summary>
/// 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
/// <see cref="ServerSpawnUidFloor"/> in sync: GameRouter.LocalEnemyCombat uses it to decide
/// whether a hit target belongs to this server or to a client-owned local unit.
/// </summary>
public const ulong ServerSpawnUidBase = 0x200000000000UL;
public const ulong ServerSpawnUidOrdinalOffset = 100000UL;
public const ulong ServerSpawnUidFloor = ServerSpawnUidBase + ServerSpawnUidOrdinalOffset;
/// <summary>
/// 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.
/// </summary>
/// <param name="engage">
/// false = 只把实体放进世界,不把它登记进敌人账本、也不下令开战。黑客召唤物(蜘蛛机器人 /
/// 送货无人机)走这条路:它们要被玩家接管操控,而不是反过来打玩家。模板仍在 Hostile 桶里
/// (entry.Hostile 校验拦着,必须 hostile:true 才让生成),所以 camp 依旧是 4,只是没有
/// 行为树下达攻击指令。客户端 SyncSwitchControl 不看 camp,只看 ClientData.AgentId。
/// </param>
/// <param name="hostile">
/// 必须与 AgentSummonCatalog 的 <c>EnemyClassType</c> 一致(1/2/4/6/7 ⇒ true,9 ⇒ false),
/// 否则 <c>entry.Hostile != hostile</c> 直接拒绝生成。
/// </param>
/// <param name="engage">
/// false = 只建单位、不注册进敌人账本(打不死、也不会被服务端拉去战斗)。召唤物一律 false。
/// </param>
/// <param name="forceEnemyRelation">
/// 是否推 <c>SyncForceEnemyRelation</c>(把玩家和这个单位显式标成敌对)。
/// <b>默认 true = 保持 2026-09-25 之前的现状</b>,所有既有调用点行为不变。
/// 2026-09-25 战术支援新增:友方支援 NPC 必须传 <c>false</c>,否则玩家能锁定并"打"自己人
/// (又因为 engage=false 打不死,表现就是「对着友军挥刀,血条不掉」)。
/// </param>
/// <param name="campOverride">
/// 覆盖 <c>SyncAgentCampInfo.camp</c>。默认 null = 按 hostile 取 4(敌人)/ 0(平民)。
/// 友方支援 NPC 传 0:玩家的本地战斗 AI 按阵营挑目标,camp=4 会把他们当成可攻击对象。
/// </param>
/// <param name="treeNameOverride">
/// 覆盖 <c>AgentSyncClientInfo.treeName</c>。默认 null = 沿用模板解析出来的那棵
/// (<c>EnemyAiCatalog</c> 有行就用它的 Tree,否则 hostile ? Main_Common : Main_Common_NPC)。
/// ⛑ 2026-09-27 为梅卡羊加的:40980002 在 <c>EnemyAiCatalog.json</c> 里<b>没有</b>行,
/// 只能退化成 <c>Main_Common</c>;而它原生那棵是 <c>MeikaShip</c>。
/// </param>
/// <param name="occurOverride">
/// 覆盖 <c>SyncUnitOccur.occurId</c>。默认 null = <c>aiProfile?.Occur ?? 0</c>。
/// ⛑ 同上:40980002 的 <c>AgentConfig.EnemyOccurId = 70000302</c>,但没行 ⇒ 只能填 0,
/// 实机结果是「单位在、能出攻击特效、模型透明」⇒ 出场演出没跑。
/// </param>
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 是<b>度数</b>,必须先转弧度再进三角函数。2026-09-26 修:以前直接
// Math.Sin(facing)/Math.Cos(facing),把度数当弧度用 ⇒ 落点虽然还在 3.5 m 圆上
// (所以"生成在身旁"看不出问题),但<b>方向随 facing 数值乱飘</b>,不是正前方。
// 约定与全项目其余各处一致:前方 = (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 序列里<b>没有</b>这个包,所以它是
// "血条不显示"的负向候选。开关 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 是<b>替换</b>语义(而不是合并),第二次推送会把 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<uint, float>
{
[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;
/// <summary>Remembers the enemy id the admin panel just asked for.</summary>
public static void SetPendingGmEnemyId(uint id)
{
if (id == 0) return;
s_pendingGmEnemyId = id;
s_pendingGmEnemyAtMs = Environment.TickCount64;
}
/// <summary>Consumes the remembered id, or 0 when none is fresh enough.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
internal static void NoteGmCommandIntent(string method, IReadOnlyList<string> args)
{
if (!method.StartsWith("AddEnemy", StringComparison.OrdinalIgnoreCase)) return;
if (args.Count == 0) return;
if (uint.TryParse(args[0], out var id)) SetPendingGmEnemyId(id);
}
/// <summary>
/// Catalog for the 生成敌人 / 生成NPC panel sections: raw AgentConfig agents split by
/// EnemyClassType (1/2/4/6/7 hostile, 9 civilian).
/// </summary>
/// <summary>
/// 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 <see cref="AdminAgentEntry.VariantIds"/> and the free-id input.
/// </summary>
public static IReadOnlyList<AdminAgentEntry> AgentSummonCatalog(bool hostile)
{
var result = new List<AdminAgentEntry>();
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;
}
/// <summary>
/// 面板下拉里**置顶**的名字(按 <c>Name</c> 精确匹配,按数组顺序排在最前)。
/// ⛑ 2026-09-30 用户定:厕所 QTE 用的「马桶鸵鸟」要排在「生成敌人」下拉框第一位。
/// </summary>
private static readonly string[] PinnedFirstNames = ["马桶鸵鸟"];
/// <summary>
/// 置顶项用的**代表 id**。<c>AgentConfig</c> 里有 5 个同名同模「马桶鸵鸟」
/// (40122028 / 40652007 / 40922011 / 42046401 / 45000446,同一具 <c>GeneralModelId=86963029</c>),
/// 按「组内最小 id」会取到 40122028 —— 那只是 id 最小的偶然结果,不是游戏自己引用的那个。
/// 客户端常量是 <c>AgentConfig.ToiletOstrich = 42046401</c>,骑乘 AI 走它的
/// <c>AISetting 40213146</c> + <c>BehaviorActionConfigId 40214321</c> ⇒ 置顶项必须用它。
/// </summary>
private static readonly Dictionary<string, uint> PinnedFirstIds = new(StringComparer.Ordinal)
{
["马桶鸵鸟"] = 42046401,
};
/// <summary>置顶次序;未命中的名字返回 <see cref="int.MaxValue"/> ⇒ 排到置顶项之后。</summary>
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;
}
/// <summary>
/// 面板「生成单位 / 切换角色(客户端 GM 通道)」的下拉数据源 —— SpawnIds.json。
/// 该文件由工作区脚本 gen_id_tables.py 从客户端导出的配置表预生成,分三类:
/// enemy → AgentConfig 中 Faction 属于 11 个敌对阵营(PlayerLockTargetType=1)的记录
/// npc → AgentConfig 中非敌对且 EnemyClassType=9 的市民/物件
/// spirit → FightSpiritConfig,即 AskSwitchPlayerSpirit / GMSwitchSpirit 用的可控角色
/// 注意三类 ID 空间不同:同一个数字在 AddNpc 与 AddEnemy 里指向不同实体。
/// </summary>
private static readonly Lazy<Dictionary<string, List<AdminSpawnIdEntry>>> SpawnIdTable =
new(BuildSpawnIdTable);
private static Dictionary<string, List<AdminSpawnIdEntry>> BuildSpawnIdTable()
{
var table = new Dictionary<string, List<AdminSpawnIdEntry>>(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<AdminSpawnIdEntry>();
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<AdminSpawnIdEntry> SpawnIdCatalog(string kind)
{
var key = kind?.ToLowerInvariant() switch
{
"npc" => "npcs",
"spirit" => "spirits",
_ => "enemies",
};
return SpawnIdTable.Value.TryGetValue(key, out var list)
? list
: (IReadOnlyList<AdminSpawnIdEntry>)Array.Empty<AdminSpawnIdEntry>();
}
// --- 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.
/// <summary>Encodes one SyncAetherAIInitDatas (68704070) framing variant. null = unknown name.</summary>
/// <param name="intersectionCount">
/// 给带 <c>-isec-</c>(不含 <c>-real</c>)的变体用:发多少条**合成探测**路口(0 = 空表)。
/// <c>-real</c> 变体忽略它,改由 <c>aether.initIntersectionsReal</c> / <c>initRealIntersectionCount</c> 控制。
/// </param>
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,
};
/// <summary>
/// 探测用路口表(派生先):InstanceId / ZoneIndex 递增,状态取默认相位。
/// 真实 InstanceId 语义未知,这里只是「让客户端有东西可解析」的最小样本。
/// </summary>
private static List<GameMethods.TrafficIntersectionInitInfoDerived4229938> BuildProbeIntersectionsDerived(int count)
{
var list = new List<GameMethods.TrafficIntersectionInitInfoDerived4229938>(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;
}
/// <summary>同 <see cref="BuildProbeIntersectionsDerived"/>,基类字段在前。</summary>
private static List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938> BuildProbeIntersectionsBaseFirst(int count)
{
var list = new List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938>(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 行)---------------------
/// <summary>
/// ★★ 2026-10-02:捐赠端 PrivateServerPor 那张路口相位表(<see cref="IntersectionTable"/>)。
///
/// ⛑⛑ <c>InstanceId</c> 填 CSV 第一列(= 主线 <c>intersections.csv</c> 的第一列,59/105/…);
/// <c>ZoneIndex</c> **默认填真 zone index**(查 <c>data/intersection_zone_map.csv</c>,
/// 59 ⇒ 184),这是 2026-10-02 21:2x 修的 —— 捐赠端原写法把两个字段都填成第一列,
/// 客户端拿它查 <c>_zoneIndexToBlobIndex</c> 查不到 ⇒ 灯柱没人接管、**永远不变色**。
/// 开关 <c>aether.intersectionUseTrueZoneIndex=false</c> 可回到捐赠端原填法。
/// ⛑ 周期三 byte 取表里**真值**,不是旧探测变体那样写死 0/1/2。
/// ⛑ 一键回滚:<c>aether.intersectionsEnabled=false</c> ⇒ 这里返回空表(与 <c>top-int7</c> 等价)。
/// </summary>
private static List<GameMethods.TrafficIntersectionInitInfoDerived4229938> BuildDonorIntersectionsDerived()
{
if (!PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionsEnabled)
return [];
var useTrueZone = PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionUseTrueZoneIndex;
var all = IntersectionTable.All;
var list = new List<GameMethods.TrafficIntersectionInitInfoDerived4229938>(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;
}
/// <summary>
/// 取 init 里该填的 <c>ZoneIndex</c>:<c>useTrueZone</c> 时查映射表,查不到(0)就回退成 id 本身。
/// </summary>
private static int ZoneIndexFor(int instanceId, bool useTrueZone)
{
if (!useTrueZone)
return instanceId;
var z = IntersectionTable.TrueZoneOf(instanceId);
return z > 0 ? z : instanceId;
}
/// <summary>同 <see cref="BuildDonorIntersectionsDerived"/>,基类字段在前(对照组)。</summary>
private static List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938> BuildDonorIntersectionsBaseFirst()
{
if (!PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionsEnabled)
return [];
var useTrueZone = PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionUseTrueZoneIndex;
var all = IntersectionTable.All;
var list = new List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938>(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) -------------------------------------------------------
/// <summary>
/// <c>-real</c> 变体实际会发出去的路口行(已应用开关与条数上限)。
/// 关掉 <c>aether.initIntersectionsReal</c> 即退化为「空表」—— 与现状 <c>top-int7</c> 完全等价,
/// 这是本次改动的一键回滚点。
/// </summary>
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;
}
/// <summary>真路口(派生字段先 —— lua 确认的 wire 顺序)。</summary>
private static List<GameMethods.TrafficIntersectionInitInfoDerived4229938> BuildRealIntersectionsDerived()
=> BuildIntersectionsFrom(RealIntersectionRows());
/// <summary>把任意 (InstanceId,ZoneIndex) 行转成 init 元素(派生字段先)。</summary>
private static List<GameMethods.TrafficIntersectionInitInfoDerived4229938> BuildIntersectionsFrom(
IReadOnlyList<IntersectionCatalog4229938.IntersectionRow> rows)
{
var list = new List<GameMethods.TrafficIntersectionInitInfoDerived4229938>(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;
}
/// <summary>真路口(基类字段先 —— 万一 lua 那侧的 Autogen 与实际 C++ reader 不一致时的对照组)。</summary>
private static List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938> BuildRealIntersectionsBaseFirst()
{
var rows = RealIntersectionRows();
var list = new List<GameMethods.TrafficIntersectionInitInfoBaseFirst4229938>(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"];
/// <summary>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.</summary>
public static async Task<(bool Ok, string Message)> AetherInitVariantsAsync(IEnumerable<string>? 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());
}
/// <summary>
/// 重发一次 Aether init,内容 = <c>data/intersections_probe.csv</c> 里的
/// (InstanceId, ZoneIndex) 组合(允许同一个 InstanceId 出现多行 = 同时试多个候选 zone)。
///
/// ★ 用途:客户端对每个「在 blob 里找不到」的 zone 打一行
/// <c>cannot find static zone info for zone index: Z, instanceId: I</c>,
/// 没打错的就是有效组合 ⇒ 反查每个路口真正属于哪个 zone(世界转储里推不出来)。
/// 走 HTTP <c>POST /api/aether/isec-probe</c>,不用重启客户端。
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
public static async Task<(bool Ok, string Message, List<SpawnPoint> 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<SpawnPoint>();
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.
/// <summary>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.</summary>
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<object>().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);
}
/// <summary>
/// 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.
/// </summary>
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<object>().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,
};
}
/// <summary>
/// 面板下拉用:列出有坐标的可入侵点。
///
/// 官方数据里有几条 type=1(大本营)的 <c>CenterPos</c> 全是 0 —— 它们不能当传送目标,
/// 所以这里只回 <see cref="FactionInvasionSpotCatalog4229938.Placeable"/>。
/// </summary>
public static object ListFactionInvasionSpots()
{
var error = FactionInvasionSpotCatalog4229938.LoadError;
if (error is not null)
return new { ok = false, message = error, spots = System.Array.Empty<object>() };
var all = FactionInvasionSpotCatalog4229938.Placeable();
var spots = new List<object>(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(),
};
}
/// <summary>
/// 一次嘉姆入侵:传送 → 放两个随从 → 推占领包。三步各自独立开关。
/// ⛑ 不代切角色:玩家要先在角色界面切到嘉姆(spiritId 15020989)。
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// The gang-territory card. Same shape as <see cref="FunctionalNpcState"/>: 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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<DonorNpcSpawnItem> 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);
}
/// <summary>
/// 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.
/// </summary>
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束缚
];
/// <summary>
/// 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.
/// </summary>
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<uint> ids = sweep ? Enumerable.Range(1, 500).Select(i => (uint)i) : PlayerControlStates;
return await ClearControlStatesAsync(session, unit, ids, sweep ? "sweep" : "curated");
}
/// <summary>
/// Unit states that keep the knocked-out HUD ("昏迷") up, from UnitStateConfig:
/// 373 倒地, 10564 FallingDownKeepAlive, 10583 FallingDownRecoverHealthState.
/// </summary>
internal static readonly uint[] PlayerDownStates = [373, 10564, 10583];
/// <summary>
/// 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).
/// </summary>
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<ulong>();
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)");
}
/// <summary>
/// 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 <see cref="GameRouter.PlayerVitalsLockFloorHp"/>,
/// the player gets hurt but is never knocked out;
/// * released: zero is lethal — knock-out, then an automatic revive after
/// <see cref="GameRouter.PlayerDownRespawnDelayMs"/>.
/// </summary>
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 ? "开(会掉血但不会死)" : "关(会被打死)")}");
}
/// <summary>Current life-lock state plus the authoritative HP, for the panel toggle.</summary>
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");
}
/// <summary>Turns the attack AI's automatic control-state release on or off.</summary>
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")}");
}
/// <summary>Session-scoped core, so the attack AI can schedule its own releases.</summary>
internal static async Task<(bool Ok, string Message, int Count)> ClearControlStatesAsync(
TcpSession session, ulong unit, IEnumerable<uint> 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;
}
/// <summary>
/// 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.
/// </summary>
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 调参卡) ----------
/// <summary>
/// 立刻按当前所在地块重算并推一次人群密度。
/// 为什么必须要有这个入口:人群没有 tick 循环,只在「进世界」和「移动 &gt;35m」推包,
/// 原地改配置是不会生效的 — 必须手动推一次。
/// </summary>
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}");
}
}
/// <summary>
/// 只算不推,给面板显示「玩家脚下当前会算出什么密度」。
/// 改完参数不用真的走 35 米,页面上就能看到目标值的变化。
/// </summary>
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)
/// <summary>面板「🎒 搜打撤」的状态体。<see cref="GameRouter.ExtractionStateForPanel"/>。</summary>
public static object ExtractionState()
=> GameRouter.ExtractionStateForPanel();
/// <summary>
/// 面板开关的总入口。所有参数都是「null = 不动」,前端可以每次只提交改过的那一个。
///
/// ⛑ <b>面板改动只在内存里</b>:刻意不落盘 —— 这是排障工具,重启就该回到配置文件的默认值,
/// 免得「昨天为了测试开的开关今天还在生效」这种幽灵行为。
/// </summary>
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<GameRouter.ExtractionWheelSyncMode4229938>(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}");
}
/// <summary>面板「进图」。</summary>
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);
}
/// <summary>面板「退出副本回主城」—— 与游戏里点退出走同一条 ExitExtractionAsync。</summary>
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}(含结算帧)");
}
/// <summary>面板「⟳ 重推武器轮盘」:强制整表下发一次 SyncSpiritWeaponDetail。现场排障用。</summary>
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)");
}
/// <summary>面板「⟳ 重推登录帧」:把含 PlayerExtractionShooterInfo 的那一帧再发一次。</summary>
public static Task<(bool Ok, string Message)> RepublishExtractionLoginFrame()
=> GameRouter.RepublishExtractionLoginFrameAsync();
/// <summary>
/// 面板「任意 notify 探针」:把一条裸包按指定 methodId 推给客户端(取证用,见
/// <c>GameRouter.PushExtractionNotifyAsync</c> 的说明)。
/// </summary>
public static Task<(bool Ok, string Message)> PushExtractionNotify(uint methodId, string? hex)
=> GameRouter.PushExtractionNotifyAsync(methodId, hex);
public sealed class KesiControlOptions
{
/// <summary>true = 接管科斯机甲;false = 切回进入机甲前的角色。</summary>
public bool Enter { get; set; } = true;
}
/// <summary>
/// GM 面板 / <c>POST /api/kesi/control</c>:<b>科斯机甲</b>的原生控制切换(2026-09-26,
/// 移植 7k-v0.5 <c>GameRouter.Kesi.cs</c>,实现在 <c>GameRouter.Kesi.cs</c>)。
///
/// 科斯 = FightSpiritConfig <c>15022030</c> "特摄战原型-科斯",隐藏可控原型 &lt;=&gt;
/// IsPublish=false / CanJoin=false / Invisible=true ⇒ 不在 roster 里,GM 面板换人下拉里也选不到。
///
/// <b>怎么用</b>:进游戏 → 下车 → 站在室外空地 → 点「控制科斯机甲」→ 看模板有无出现。
/// <b>判据</b>:服务端日志 <c>[KESI] enter=True ok=True</c>;失败则看 <c>[SWITCH-MIN]</c>。
/// ⛑ 服务端协议侧齐了 ≠ 画面上能动:模型异步加载、镜头、机甲技能面板都要实机确认
/// (捐赠端自己也标了"尚需游戏内验证")。
/// </summary>
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);
}
}