using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
using AgentCatalog = Ananta.Server.ClientData.Client4229938.NpcSummonCatalog4229938;
using ClientConfigRepository = Ananta.Server.ClientData.Client4229938.ClientConfigRepository;
using GadgetCatalog4229938 = Ananta.Server.ClientData.Client4229938.GadgetCatalog4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
namespace Ananta.Server.Handlers.Game;
/// Public snapshot of the tracked game session for the admin panel (no internal types leak).
public sealed class AdminPlayerSnapshot
{
public bool Connected { get; init; }
public string SessionId { get; init; } = string.Empty;
public string? LastSeenUtc { get; init; }
public ulong UnitId { get; init; }
public uint TemplateId { get; init; }
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
public float Facing { get; init; }
public bool HasTransform { get; init; }
public bool Ready { get; init; }
public uint ActiveRaidId { get; init; }
public ulong ActiveInstanceId { get; init; }
public uint ActiveUniverseId { get; init; }
public ulong PendingTeleportId { get; init; }
public uint LastAdminVehicleId { get; init; }
public ulong LastSpawnedVehicleUid { get; init; }
public ulong CurrentVehicleId { get; init; }
public int CurrentVehicleSeat { get; init; }
public byte LastVehicleDriveState { get; init; }
public bool HasVehicleDriveState { get; init; }
public uint TimeHour { get; init; }
public uint TimeMinute { get; init; }
public bool TimeFixed { get; init; }
public bool HasExplicitTime { get; init; }
public uint WeatherId { get; init; }
public bool HasExplicitWeather { get; init; }
}
/// Public weapon catalog entry for the admin panel.
public sealed class AdminWeaponEntry
{
public uint TemplateId { get; init; }
public string Name { get; init; } = string.Empty;
public ulong InstanceId { get; init; }
}
/// Public vehicle catalog entry for the admin panel (flying/water spawner).
public sealed class AdminVehicleEntry
{
public uint TemplateId { get; init; }
public string Name { get; init; } = string.Empty;
public int VehicleType { get; init; }
public int SeatCount { get; init; }
public string Category { get; init; } = string.Empty;
}
/// Public scene preset (raid/instance/universe + arrival) for the admin panel.
public sealed class AdminScenePreset
{
public string Key { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public uint RaidId { get; init; }
public ulong InstanceId { get; init; }
public uint UniverseId { get; init; }
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
public float Facing { get; init; }
}
/// Public NPC (character) catalog entry for the admin panel.
public sealed class AdminNpcEntry
{
public uint NpcCultivationId { get; init; }
public string Name { get; init; } = string.Empty;
public uint SpiritId { get; init; }
public int VariantCount { get; init; }
public IReadOnlyList Menus { get; init; } = Array.Empty();
}
/// One row of the 生成敌人 / 生成NPC catalog (raw AgentConfig agent).
public sealed class AdminAgentEntry
{
public uint AgentId { get; init; }
public string Name { get; init; } = string.Empty;
public uint EnemyClassType { get; init; }
public uint AiSetting { get; init; }
///
/// Number of AgentConfig rows sharing this display name inside the same panel. AgentConfig
/// holds hundreds of near-identical variants per character (same model, different AI /
/// spawn-group), which used to flood the dropdown. Only the first variant is listed.
///
public int VariantCount { get; init; } = 1;
///
/// Every AgentConfig id that collapsed into this entry, ascending. Kept so the search box can
/// still resolve an id that is no longer shown, and so any variant stays reachable.
///
public uint[] VariantIds { get; init; } = [];
///
/// Whether the client binds an enemy HP bar to this template. The bar is a property of
/// AgentConfig (ShowHPBar, plus a non-zero InitWeaponId), not of anything the server pushes:
/// no amount of SyncUnitHp / SyncUnitAttacked conjures one onto a template that has none.
/// False means the enemy still fights, takes damage and dies — it just shows no bar, which
/// reads as "this thing cannot be killed".
///
public bool HasHpBar { get; init; }
}
///
/// Bridge between the game session and the embedded admin web server.
/// Fed by the per-frame hook in PrivateServerApplication (every login+game frame);
/// only sessions carrying a 4229938 world state are treated as the player session.
/// All pushes use the same DTOs + method ids as the in-game handlers.
///
public static class GameAdminBridge
{
private static readonly ConcurrentDictionary Sessions = new();
private static long s_adminTeleportCounter = 950_000;
public static void Touch(TcpSession session)
=> Sessions[session.Id] = (session, DateTimeOffset.UtcNow.Ticks);
public static AdminPlayerSnapshot GetSnapshot()
{
var best = PickPlayerSession();
if (best is null)
return new AdminPlayerSnapshot { Connected = false };
var (session, ticks) = best.Value;
var state = GameRouter.GetStateIfExists(session);
if (state is null)
return new AdminPlayerSnapshot
{
Connected = false,
SessionId = session.Id,
LastSeenUtc = new DateTimeOffset(ticks, TimeSpan.Zero).ToString("o"),
};
lock (state.SyncRoot)
{
return new AdminPlayerSnapshot
{
Connected = true,
SessionId = session.Id,
LastSeenUtc = new DateTimeOffset(ticks, TimeSpan.Zero).ToString("o"),
UnitId = state.ActiveSpiritUnitId,
TemplateId = state.ActiveSpiritTemplateId,
X = state.LastReportedPlayerPosition.X,
Y = state.LastReportedPlayerPosition.Y,
Z = state.LastReportedPlayerPosition.Z,
Facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : state.WorldEntryCreateHeroFacing,
HasTransform = state.HasLastReportedPlayerTransform,
Ready = state.Ready,
ActiveRaidId = state.ActiveRaidId,
ActiveInstanceId = state.ActiveInstanceId,
ActiveUniverseId = state.ActiveUniverseId,
PendingTeleportId = state.PendingTeleportId,
LastAdminVehicleId = state.LastAdminVehicleId,
LastSpawnedVehicleUid = state.LastSpawnedVehicleUid,
CurrentVehicleId = GameRouter.GetVehicleRuntimeState(session) is { } vehicles
? CurrentVehicleOf(vehicles) : 0,
CurrentVehicleSeat = GameRouter.GetVehicleRuntimeState(session) is { } vehicles2
? CurrentSeatOf(vehicles2) : -1,
LastVehicleDriveState = state.LastVehicleDriveState,
HasVehicleDriveState = state.HasVehicleDriveState,
TimeHour = state.TimeHour,
TimeMinute = state.TimeMinute,
TimeFixed = state.TimeFixed,
HasExplicitTime = state.HasExplicitTime,
WeatherId = state.WeatherId,
HasExplicitWeather = state.HasExplicitWeather,
};
}
}
private static ulong CurrentVehicleOf(GameRouter.VehicleRuntimeState runtime)
{
lock (runtime.SyncRoot)
return runtime.CurrentVehicleId;
}
private static int CurrentSeatOf(GameRouter.VehicleRuntimeState runtime)
{
lock (runtime.SyncRoot)
return runtime.CurrentVehicleSeat;
}
public static uint[] FleetIds()
{
try { return PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds ?? []; }
catch { return []; }
}
///
/// Flying (Type 35) and water (Type 33/34) vehicles from the catalog. These are
/// excluded from the regular fleet by default; the admin panel offers a separate
/// spawner with a force flag so they can be materialized without editing config.
///
public static IReadOnlyList FlyingWaterVehicles()
{
try
{
return ClientData.Client4229938.VehicleCatalog4229938.AllSummonable
.Where(v => v.VehicleType is 33 or 34 or 35)
.Select(v => new AdminVehicleEntry
{
TemplateId = v.Id,
Name = string.IsNullOrWhiteSpace(v.Name) ? $"(type {v.VehicleType})" : v.Name,
VehicleType = v.VehicleType,
SeatCount = v.SeatCount,
Category = v.VehicleType == 35 ? "Flying" : "Water",
})
.OrderBy(v => v.Category)
.ThenBy(v => v.TemplateId)
.ToList();
}
catch { return []; }
}
///
/// Scene presets: curated world spawns first, then every map entrance from the
/// game dump (SceneCatalog4229938: RaidConfig SceneId = instance id). Falls back
/// to the two curated entries if the catalog fails to load.
///
public static IReadOnlyList ScenePresets()
{
uint universe;
try { universe = PrivateServerConfigStore.Current.World.UniverseId; }
catch { universe = 76000888; }
var result = new List
{
new()
{
Key = "nova-spawn", Label = "Nova spawn (WorldMap_Release)",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 3142.669922f, Y = 0f, Z = 2522.780029f, Facing = 0f,
},
new()
{
// 凌云 = RaidConfig Name of 23300999; 定澜北路 = RailStationConfig StationChineseName.
Key = "lingyun-dinglan", Label = "定澜北路 — 凌云 (23300999)",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -1638f, Y = 18.025f, Z = 1540.5f, Facing = -6.39965f,
},
// Facing is Unity Euler Y in degrees — SwitchSceneAsync stores it verbatim in
// state.LastReportedPlayerRotation, and every consumer reads that as degrees.
// Drop point re-measured in-game 2026-09-20 (was 1608.66 / 0.01 / 1585.06 @ 200.23,
// which landed on the wrong floor — Y 22.07 puts the player inside the apartment).
new()
{
Key = "tafei-apartment", Label = "塔菲公寓",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1607.51f, Y = 22.07f, Z = 1552.87f, Facing = 55.13f,
},
// Drop points supplied by the user 2026-09-20. Both live in the open world, so they
// reuse Nova City (23300888 / SceneId 20001222) exactly like nova-spawn above.
new()
{
Key = "zhangwang-zhengba", Label = "掌王争霸",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 611.77f, Y = -59.63f, Z = 2208.70f, Facing = 280.18f,
},
new()
{
Key = "heilingjie-restaurant", Label = "黑领结餐厅",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2133.92f, Y = 0.12f, Z = 2011.21f, Facing = 180.47f,
},
// Hand-picked drop points supplied by the user (Desk/坐标.txt). SceneId comes from
// RaidConfig: 23301277 → 20001319, 23301285 → 20001327 (both Multiverse 76000888).
new()
{
Key = "bushou-dont-look-up", Label = "不要抬头(搜打撤)(23301277)",
RaidId = 23301277, InstanceId = 20001319, UniverseId = universe,
X = 1505.0f, Y = 15.0f, Z = 1330.0f, Facing = 0f,
},
// 2026-09-24 补:这两张和上面那条同为 RaidType 19(搜打撤),SceneId 取自
// Configs/RaidConfig.json 的 SceneId 字段(真源,与捐赠端文档 §5.6 一致)。
// ⛑ 落点是捐赠端文档里那组未经实测的 (0, 10, 0) —— 进去以后如果掉出地形,
// 用「传送」卡片落到有地形的地方,再把这里的 Y 改对。
new()
{
Key = "extraction-final-battle", Label = "搜打撤 · 终局之战 (23301215)",
RaidId = 23301215, InstanceId = 20001272, UniverseId = universe,
X = 0f, Y = 10f, Z = 0f, Facing = 0f,
},
new()
{
Key = "extraction-tomb-raid", Label = "搜打撤 · 古墓摸金 (23301311)",
RaidId = 23301311, InstanceId = 20001352, UniverseId = universe,
X = 0f, Y = 10f, Z = 0f, Facing = 0f,
},
new()
{
Key = "tomb-of-terror", Label = "恐怖墓穴(踩空重新传送)(23301285)",
RaidId = 23301285, InstanceId = 20001327, UniverseId = universe,
X = 569.9f, Y = 267.5f, Z = 971.6f, Facing = 0f,
},
// Drop points supplied by the user. All live in the open world
// (SceneId 20001222 = Nova City / raid 23300888), same instance as nova-spawn.
new()
{
Key = "gold-hunt-entry", Label = "猎金游戏入口",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1499.48f, Y = -9.98f, Z = 1698.85f, Facing = 169.85f,
},
new()
{
Key = "dianchi-house", Label = "典斥屋",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1139.94f, Y = 0.04f, Z = 2053.36f, Facing = 115.10f,
},
new()
{
Key = "gym", Label = "健身房",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 3925.78f, Y = 0.05f, Z = 1832.75f, Facing = 155.72f,
},
new()
{
Key = "yuesheng", Label = "乐升",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1996.71f, Y = 0.01f, Z = 2322.18f, Facing = 42.44f,
},
// The next four arrived as raw coordinates without a facing, so they land facing
// north (0). Re-measure in game if the arrival angle matters.
new()
{
Key = "lvyu-mansion", Label = "绿屿公馆",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2260.0f, Y = 160.7f, Z = 1399.6f, Facing = 0f,
},
new()
{
Key = "guannan-beian", Label = "观澜北岸",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2622.9f, Y = 188.5f, Z = 2615.9f, Facing = 0f,
},
new()
{
Key = "suguang-cottage", Label = "素光小筑",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1100.8f, Y = 9.0f, Z = 2375.7f, Facing = 0f,
},
new()
{
Key = "old-goods-warehouse", Label = "旧物仓库公寓",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1655.9f, Y = -8.0f, Z = 2336.2f, Facing = 0f,
},
// Mini-game / landmark drops supplied by the user 2026-09-25, all Nova City again.
// 钓鱼点和摩天轮 arrived without a heading, so they land facing north (0).
new()
{
Key = "fishing-spot", Label = "钓鱼点",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -4238.2f, Y = 124.3f, Z = -3455.8f, Facing = 0f,
},
new()
{
Key = "bowling-alley", Label = "保龄球",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2688.94f, Y = 0.00f, Z = 1594.57f, Facing = 176.93f,
},
new()
{
// 「飞镖乐队」是误称(2026-09-30 用户纠正)= Sonic Boom,音爆。
// 对得上 NpcShopCatalog.json Shops[0] Id 94100017 "Sonic Boom Front Desk"。
// ⛑ Key 仍保留 dart-band(前端/脚本若引用过就不能改),只改显示名。
Key = "dart-band", Label = "音爆",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1874.88f, Y = -4.74f, Z = 2413.81f, Facing = 99.53f,
},
new()
{
Key = "ferris-wheel", Label = "摩天轮",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1662.44f, Y = 35.3f, Z = 2000.28f, Facing = 0f,
},
// 胡闹厨房 arrived without a heading, so it lands facing north (0).
new()
{
Key = "kitchen-chaos", Label = "胡闹厨房",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = 1330f, Y = 19f, Z = 927f, Facing = 0f,
},
// ── Smokey Drift(疾速烟尘改装厂)的两个地下工位 ───────────────────────────
// 坐标直取 GameRouter.Teleport.ShopInteriors 的 Interior(=ParkingWaypoint),
// 与客户端 CarShopConfig Id=1(西门,indoorID 4) / Id=2(海边,indoorID 47) 一一对应。
// ⛑ Interior 与主城开放世界同为 instance 20001222,只是 y<0 的室内层,
// NpcShopCatalog 里两间店的 Shop.Id 恰为 94100069 / 94100070(都叫 Smokey Drift)。
new()
{
Key = "carshop-west-gate", Label = "西门改装店(94100069)",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1443.23f, Y = -19.992f, Z = 2351.428f, Facing = 180f,
},
new()
{
Key = "carshop-seaside", Label = "海边改装店(94100070)",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2783.912f, Y = -11.92f, Z = 1262.419f, Facing = 0f,
},
// 午夜回想没给 raid:坐标 (2868.60, 0.02, 2199.17) 与舞池 (2873.41, 0.03, 2198.43)
// 相距不到 5m,同一片区域 ⇒ 按主城开放世界处理。
new()
{
Key = "midnight-reverie", Label = "午夜回想",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2868.60f, Y = 0.02f, Z = 2199.17f, Facing = 359f,
},
new()
{
Key = "shake-win", Label = "摇摇乐",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -2213.05f, Y = 31.67f, Z = 1559.78f, Facing = 0f,
},
new()
{
Key = "barber-shop", Label = "理发厅",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -1221.87f, Y = 18.05f, Z = 1025.74f, Facing = 92.35f,
},
// 露营地没给 raid / 朝向:坐标 (1290.16, 0.97, 2377.57) 与典斥屋、乐升、旧物仓库公寓、
// 西门改装店同属主城西南片区,Y≈地面 ⇒ 按主城开放世界处理,朝向缺省北 (0)。
new()
{
Key = "campsite", Label = "露营地",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1290.16f, Y = 0.97f, Z = 2377.57f, Facing = 0f,
},
// 重霄游戏机没给 raid / 朝向。归属判据(MapentranceConfig.json,93 行入口的高度带):
// raid 23300888(Nova)的入口 Y 全在 0 / -1 附近(DK9 HQ 0.0、Freshville -1.095…);
// raid 23300999(凌云)的入口 Y 恒在 17.4~18.3(Yaojin Road 17.49、Yanli Lane 18.03…)。
// 本点 Y=19.26 落在凌云这一带,且最近的入口正是 Yaojin Road(Id 229, 23300999, 359m)。
new()
{
Key = "chongxiao-arcade", Label = "重霄游戏机",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = 0.17f, Y = 19.26f, Z = 1132.14f, Facing = 0f,
},
// 气球摊没给 raid。Y=168.5 超出规则 232 的高度带(>30 失效),改用最近入口法:
// MapentranceConfig Id 239「Longqi Village」(raid 23300999) 在 (-4506.7, 180.17, -2588.9),
// 距本点 342m 且 Y 同带(180 vs 168.5)⇒ 凌云 23300999 / 20001223。
// 同类对照:钓鱼点 (-4238.2, 124.3, -3455.8) 最近的也是 Longqi Village(907m),同 raid。
new()
{
Key = "balloon-stall", Label = "气球摊",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -4749.00f, Y = 168.50f, Z = -2830.30f, Facing = 313.3f,
},
// 街机大全没给 raid。Y=13.08 落在规则 232 两条高度带的缝里(主城 0 / 凌云 17~19),
// 高度带判不出 ⇒ 用最近入口法:MapentranceConfig 里最近的 4 个入口**全是 raid 23300888**
// (250 新启机场岛屿站 142m / 335 凌云市-重霄 230m / 213 Nova City Airport 277m / 336 277m),
// 而凌云 23300999 最近的入口要 649m ⇒ 判 Nova City 23300888 / 20001222。
// ⛑ 待实测:本点 Y=13.08 比主城入口(Y≈0)高出 13m,像室内二层;
// IndoorConfig 里街机厅两种 ParentRaid 都有(Id 3/57/67 → 23300888,Id 250/285 → 23300999),
// 所以「街机」这个名字本身不足以定 raid,只能靠坐标。
new()
{
Key = "arcade-hall", Label = "街机大全",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = -685.59f, Y = 13.08f, Z = -1119.94f, Facing = 220.94f,
},
// 2026-09-30 用户改名:「舞会」→「斯拜科酒店」(TimelineConfig.json 里作
// 「11-猎金游戏·斯拜科酒店」,与「猎金游戏入口」同一条线)。坐标/raid 未动。
// 当初没给 raid / 朝向,Y=80 超出规则 232 的高度带(>30 失效),两条路判定:
// ① 最近入口法:MapentranceConfig 最近的 8 个入口**全是 raid 23300888**,
// 最近 Id 334 (2473.2, 1.0, 1169.0) 仅 166m;同带已成实例 > Id 325 (2782.6, -11.958, 1255.8)
// ≈ 海边改装店 Interior、Id 343/344 (2277.5, 160.595, 1412.5) ≈ 绿屿公馆 —— 全是 Nova City。
// ② 玩法侧印证:GameRouter.PartyCamp.cs:19 —— 露营/派对的 PartyConfig.RaidId 就是 23300888
// (派对就地举行、不切副本)。
// ⛑ Key 仍保留 dance-party(改名只动显示名,避免引用断裂)。
new()
{
Key = "dance-party", Label = "斯拜科酒店",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2605.33f, Y = 80f, Z = 1269.74f, Facing = 0f,
},
// 足球场没给 raid / 朝向。规则 232 直接命中:Y=0.03 ≈ 主城地面带(凌云带是 17~19)
// ⇒ 23300888 / 20001222。最近入口法同向:Freshville Station(Id 201, 23300888) 仅 285m,
// 前五名全是 23300888。朝向缺省北 (0)。
new()
{
Key = "football-field", Label = "足球场",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 1183.34f, Y = 0.03f, Z = 1551.63f, Facing = 0f,
},
// 踢毽子没给 raid。Y=170.4 > 30,规则 232 失效 ⇒ 最近入口法:
// Longqi Village(Id 239, raid 23300999) 356m,前三名全是 23300999。
// 且本点距既有「气球摊」(-4749.0, 168.5, -2830.3) 仅 26.8m —— 同一片野区、同一 raid。
new()
{
Key = "kick-jianzi", Label = "踢毽子",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -4775.00f, Y = 170.40f, Z = -2823.70f, Facing = 333.6f,
},
// 警卫署没给 raid。规则 232 直接命中:Y=0.56 ≈ 主城地面带(凌云带 17~19)
// ⇒ 23300888 / 20001222。最近入口法同向:Coastal Station(Id 206, 23300888) 250m,
// 前五名全是 23300888(绿屿公馆 215 / 337 / 340 / 345)。
new()
{
Key = "guard-station", Label = "警卫署",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2379.94f, Y = 0.56f, Z = 1826.46f, Facing = 35.73f,
},
// 五子棋没给 raid。Y=168.4 > 30,规则 232 失效 ⇒ 最近入口法:
// Longqi Village(Id 239, raid 23300999) 347m,前三名全是 23300999。
// 与既有「气球摊」(93m) / 「踢毽子」(117.7m) 同一片野区、同一 raid。
new()
{
Key = "gomoku", Label = "五子棋",
RaidId = 23300999, InstanceId = 20001223, UniverseId = universe,
X = -4678.20f, Y = 168.40f, Z = -2890.60f, Facing = 317.4f,
},
// 王牌车神没给 raid。规则 232 直接命中:Y=-0.11 ≈ 主城地面带(含 -1 那档)
// ⇒ 23300888 / 20001222。最近入口法同向:Rixxy Wonderland(Id 200, 23300888) 仅 101m。
// ⛑ 用户给的 yaw = -61.54° 是负数,这里归一化到 [0,360) ⇒ 298.46(-61.54+360)。
// Unity euler y 上两者视觉等价,但既有预设全部是 0~360,保持一致避免下游意外。
new()
{
Key = "ace-driver", Label = "王牌车神",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 3014.73f, Y = -0.11f, Z = 954.66f, Facing = 298.46f,
},
// 电影院没给 raid / 朝向。规则 232 直接命中:Y=1.35 ≈ 主城地面带(凌云带 17~19)
// ⇒ 23300888 / 20001222。最近入口法同向:Id 325 (2782.6, -11.958, 1255.8) 144m、
// Rixxy Wonderland(Id 200) 196m,前五名全是 23300888。朝向缺省北 (0)。
new()
{
Key = "cinema", Label = "电影院",
RaidId = 23300888, InstanceId = 20001222, UniverseId = universe,
X = 2769.06f, Y = 1.35f, Z = 1111.99f, Facing = 0f,
},
};
try
{
foreach (var p in ClientData.Client4229938.SceneCatalog4229938.Presets)
result.Add(new AdminScenePreset
{
Key = p.Key, Label = p.Label, RaidId = p.RaidId, InstanceId = p.InstanceId,
UniverseId = p.UniverseId, X = p.X, Y = p.Y, Z = p.Z, Facing = p.Facing,
});
}
catch { /* curated defaults above still work */ }
return result;
}
///
/// Resolve a client-supplied raid id to one of the curated arrivals.
///
/// Several presets deliberately share a raid id (nova-spawn and tafei-apartment are both the
/// main city 23300888), and the client only sends the raid id, so ties resolve to the first
/// entry listed. Put the arrival you want the in-game map to use first in
/// — the admin panel is unaffected because it picks by Key.
///
internal static bool TryGetScenePreset(uint raidId, out AdminScenePreset? preset)
{
foreach (var p in ScenePresets())
{
if (p.RaidId != raidId)
continue;
preset = p;
return true;
}
preset = null;
return false;
}
///
/// 按 pathId + 世界坐标刷一个物件,走 SyncGadgetAddList(68925813)
/// 的单件批量形式(与 PropRestock / 小游戏摊位同一条已验证通道)。
///
/// ⛑⛑ iScale 必须 0:0 ⇒ 客户端原生 1:1;写 100 会建成
/// localScale=(0,0,1) 的空壳(无模型、交互范围缩到原点)。09-23 健身房器材
/// 就是栽在这上面,GymEquipmentSettings.IScale 已从 100 改 0。
///
/// 用途:给街头篮球 QTE 补篮筐。PoiGameConfig.Basket_BallStand
/// (BasketballSceneNode.prefab)里没有篮筐模型 —— 只有
/// PivotNode/LanWangCenterPoint(空 Transform,进球判定点)和场边 5 个球架
/// (sc_fd_ty_wjz_lanqiujia01a_pc),玩家站在圆心投篮时目标点是个"虚空点"。
/// 专用篮筐件 Gadgets_Common_BasketballRim = 1086190276 在
/// GadgetCatalog.json 里(客户端认得 ⇒ 不会像 gym 那次变空壳),
/// 世界摆位为 0,需要时由本方法刷出来。
///
public static async Task<(bool Ok, string Message, int Count)> SpawnGadgetAtAsync(
int pathId, float x, float y, float z, float yaw, int iScale = 0)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)", 0);
if (pathId == 0)
return (false, "pathId is required", 0);
var (session, state) = target.Value;
var specs = new[] { new GameRouter.GadgetSpawnSpec(pathId, x, y, z, yaw, 0UL, "") };
var r = await GameRouter.SpawnGadgetListAsync(session, state, specs, iScale);
if (!r.Ok)
return (false, r.Message, 0);
session.Log.Info($"[ADMIN] gadget spawn pathId={pathId} pos=({x:F2},{y:F2},{z:F2}) " +
$"yaw={yaw:F2} iScale={iScale} count={r.Count}");
return (true, $"spawned {r.Count} gadget(s) pathId={pathId} at ({x:F2},{y:F2},{z:F2})", r.Count);
}
///
/// ★★ 街头篮球:把对手(红铁桶 42071029)的身体网格重新点亮(2026-10-03 23:4x)。
///
/// ⛑⛑ 根因(实测,别再猜"没加载"):BasketballNpcCharacter.LoadCharacterModel
/// 走 gCS.LuaUtils.GetDialogModelByAgentId(args),模型确实加载出来了——
/// 节点 monster_A321001_p003_gm(Clone) 在 baseUnit.ModelSlot.transform 下,
/// active=true、scale=1、离玩家约 8.5m、animator 已挂 basketball_tin_mirror;
/// 但 body / body01 两个 SkinnedMeshRenderer.enabled = false,
/// 只有辅助体 TouchBendProxy(碰撞代理)亮着 ⇒ 玩家看不见对手。
/// 怀疑是 args.TargetUnit = gCS.LocalUnitMgr:GetNpcByTemplateId(42071029) 为 nil
/// (凭空开局 ⇒ 场景里没有该模板 NPC)导致 dialog 模型停在"未上场"态。
///
/// ⛑ 修法:只开 SkinnedMeshRenderer。不能开 Capsule AO Body /
/// TouchBendProxy 这类 MeshRenderer 辅助体(AO / 弯曲代理,开了会出怪块)。
/// 实测开启 40s 后仍为 true,引擎不会自动关回去。
///
/// ⛑ 由面板在开局脚本之后延时调用(Lua 环境里 wait / UpdateBeat 都是 nil,
/// 延迟只能在浏览器 JS 的 setTimeout 里做)。
///
public static async Task<(bool Ok, string Message)> FixBasketballNpcModelAsync()
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
const string script = @"
local ok, err = pcall(function()
local function P(s) print_error('[DRMK-NPCFIX] ' .. s) end
local g = nil
pcall(function() g = gBasketballGameManager.currentGame end)
if not g then P('no currentGame (game not running?)'); return end
local nc = nil
pcall(function() nc = g.npcCharacter end)
if not nc then P('no npcCharacter'); return end
local ms = nil
pcall(function() ms = nc.baseUnit and nc.baseUnit.ModelSlot end)
local tr = nil
if ms then pcall(function() tr = ms.transform end) end
if not tr then P('no ModelSlot.transform'); return end
local rs = nil
pcall(function() rs = tr:GetComponentsInChildren(typeof(UnityEngine.SkinnedMeshRenderer)) end)
if not rs then P('no SkinnedMeshRenderer found'); return end
local n, turned = rs.Length, 0
for i = 0, rs.Length - 1 do
local okR, r = pcall(function() return rs[i] end)
if okR and r then
local okE, e = pcall(function() return r.enabled end)
if okE and e == false then
local nm = '?'
pcall(function() nm = r.gameObject.name end)
pcall(function() r.enabled = true end)
turned = turned + 1
P('enabled: ' .. tostring(nm))
end
end
end
local po = nil
pcall(function() po = tr.position end)
if po then P(string.format('npc at (%.2f,%.2f,%.2f)', po.x, po.y, po.z)) end
P(string.format('SMR=%d turnedOn=%d', n, turned))
end)
if not ok then print_error('[DRMK-NPCFIX] fatal: ' .. tostring(err)) end
";
var r = await GameRouter.RunClientLuaAsync(target.Value.Session, script);
return r;
}
/// Instant move: SyncUnitPositionAndFacing (see GameRouter.TeleportPlayerInstantAsync).
public static async Task<(bool Ok, string Message)> TeleportInstantAsync(float x, float y, float z, float? facing)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
var (session, state) = target.Value;
ulong unitId;
float face;
lock (state.SyncRoot)
{
unitId = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : Profile.InitialUnitId;
face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing);
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, face, 0f);
state.HasLastReportedPlayerTransform = true;
}
try
{
// ⛑ 不是 SyncUnitPosition_P(68233969):本地角色是 NetworkRoleType.None,
// NetworkGhostModule.ForceSetPositionAndFacing 会直接拒(Player.log: "Role(None)不可强设位置!!")。
// 正确通道 = SyncUnitPositionAndFacing(68030312),与 GameRouter.Teleport.TeleportPlayerInstantAsync 同源。
var body = UxSerializer.Serialize(WorldCodec.PositionAndFacing(unitId, new Vec3(x, y, z), face));
await session.NotifyAsync(MethodId.SyncUnitPositionAndFacing, body, CancellationToken.None);
session.Log.Info($"[ADMIN] instant teleport unit={unitId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}");
return (true, $"instant teleport sent: unit={unitId} ({x:F2},{y:F2},{z:F2}) facing={face:F2}");
}
catch (Exception ex)
{
return (false, $"send failed: {ex.Message}");
}
}
/// Loading-flow move: SyncPreTeleportOption + SyncTeleport with a fresh teleport id.
public static async Task<(bool Ok, string Message)> TeleportWithLoadingAsync(float x, float y, float z, float? facing)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
var (session, state) = target.Value;
float face;
ulong teleportId;
lock (state.SyncRoot)
{
face = facing ?? (state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : Profile.WorldFacing);
teleportId = (ulong)Interlocked.Increment(ref s_adminTeleportCounter);
state.PendingTeleportId = teleportId;
state.PendingTeleportPreFinished = false;
state.PendingTeleportSyncSent = true;
state.LastReportedPlayerPosition = new Vec3(x, y, z);
state.LastReportedPlayerRotation = new Vec3(0f, face, 0f);
state.HasLastReportedPlayerTransform = true;
}
try
{
var pre = new GameMethods.PreTeleportOption
{
configId = 0,
teleportId = teleportId,
ForceClear = false,
TimeLineDration = 0,
position = new GameMethods.TeleportVec3(x, y, z),
facing = face,
beforeResName = string.Empty,
customBeforeTrans = false,
beforePosition = new GameMethods.TeleportVec3(x, y, z),
beforeRot = new GameMethods.TeleportVec3(0f, face, 0f),
loadingResName = string.Empty,
afterResName = string.Empty,
customAfterTrans = false,
afterPosition = new GameMethods.TeleportVec3(x, y, z),
afterRot = new GameMethods.TeleportVec3(0f, face, 0f),
extParams = string.Empty,
};
await session.NotifyAsync(MethodId.SyncPreTeleportOption, UxSerializer.Serialize(pre), CancellationToken.None);
var sync = new GameMethods.TeleportOption
{
teleportId = teleportId,
Position = new GameMethods.TeleportVec3(x, y, z),
Facing = face,
IsSwitchScene = false,
WaitTaskResource = false,
MapEntranceId = 0,
};
await session.NotifyAsync(MethodId.SyncTeleport, UxSerializer.Serialize(sync), CancellationToken.None);
session.Log.Info($"[ADMIN] loading-flow teleport teleportId={teleportId} pos=({x:F2},{y:F2},{z:F2}) facing={face:F2}");
return (true, $"loading-flow teleport sent: teleportId={teleportId} ({x:F2},{y:F2},{z:F2}) facing={face:F2}; watch ReportPre/PostTeleportFinish in logs");
}
catch (Exception ex)
{
return (false, $"send failed: {ex.Message}");
}
}
///
/// Admin vehicle spawn: validates against the catalog/fleet, then runs the same
/// direct-scene-spawn push as client summon (SyncLogicVehicleEnter + SyncSpawnVehicle,
/// interactable). The car appears beside the player; walk up and press F to drive.
/// Set force=true to bypass the fleetIds whitelist (used for flying/water vehicles).
///
public static async Task<(bool Ok, string Message, ulong Uid)> SpawnVehicleAsync(uint vehicleId, bool force = false)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)", 0UL);
var (session, state) = target.Value;
if (!force)
{
var fleet = FleetIds();
if (fleet.Length != 0 && !fleet.Contains(vehicleId))
return (false, $"vehicleId {vehicleId} is not in gameplay.vehicles.fleetIds ({fleet.Length} entries) — use force=true for flying/water vehicles", 0UL);
}
Vec3 near;
float facing;
lock (state.SyncRoot)
{
near = state.LastReportedPlayerPosition;
facing = state.HasLastReportedPlayerTransform ? state.LastReportedPlayerRotation.Y : state.WorldEntryCreateHeroFacing;
}
try
{
var result = await GameRouter.SpawnDirectAsync(session, vehicleId, near, facing, "admin-panel");
return (result.Ok, result.Message, result.EntityId);
}
catch (Exception ex)
{
return (false, $"spawn failed: {ex.Message}", 0UL);
}
}
///
/// Admin gadget spawn: places one interactive prop from GadgetCatalog.json a few metres in
/// front of the player. The client owns the prefab and loads it itself — the server only
/// supplies the prefab id and a transform, which is the whole reason this channel works at all
/// (unlike DynamicGo, whose ids live inside the client's scene resources).
///
/// 0 = SyncGadgetAdd, 1 = SyncGadgetAOIAddAndRemove, 2 = both.
public static async Task<(bool Ok, string Message, ulong Id)> SpawnGadgetAsync(
int pathId, float distance = 2.5f, int channel = 0)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)", 0UL);
var (session, state) = target.Value;
Vec3 near;
float facing;
lock (state.SyncRoot)
{
near = state.LastReportedPlayerPosition;
facing = state.HasLastReportedPlayerTransform
? state.LastReportedPlayerRotation.Y
: state.WorldEntryCreateHeroFacing;
}
// Facing is a compass heading in degrees; +Z is north in this game's coordinate system.
var radians = facing * MathF.PI / 180f;
var x = near.X + MathF.Sin(radians) * distance;
var z = near.Z + MathF.Cos(radians) * distance;
try
{
return await GameRouter.SpawnGadgetAsync(
session, state, pathId, x, near.Y, z, facing, (GameRouter.GadgetSendChannel)channel);
}
catch (Exception ex)
{
return (false, $"spawn failed: {ex.Message}", 0UL);
}
}
/// Admin gadget clear: removes the props this panel placed. One id, or all of them.
public static async Task<(bool Ok, string Message, int Count)> ClearGadgetsAsync(ulong? onlyId = null)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)", 0);
var (session, state) = target.Value;
try
{
return await GameRouter.RemoveGadgetsAsync(session, state, onlyId);
}
catch (Exception ex)
{
return (false, $"clear failed: {ex.Message}", 0);
}
}
///
/// T2-31 Seymour 大停电 — 把控制器 Gadgets_Hacker_Tetris(1166913245) 放进世界,然后停手。
///
/// 与 的区别不是"换个 pathId"那么简单:那个对象是
/// IsTask=true、带着 startTaskId/endTaskId、用 packed iScale 的任务图物件,
/// 普通放置物件那条路径的参数一律不适合它。
///
/// 成功后返回 ok waiting-for-interact:服务端什么都不用再做,接下来要看的是
/// 客户端自己有没有因为这个物件而弹黑客交互(详见 GameRouter.Blackout.cs 头部说明)。
///
/// Panel-triggered blackout. Pass all three coordinates to override the landing spot
/// (otherwise Gameplay.BlackoutControllerAtBillboard decides).
public static async Task<(bool Ok, string Message, ulong Id)> StartBlackoutAsync(
float? x = null, float? y = null, float? z = null)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)", 0UL);
var (session, state) = target.Value;
try
{
Vec3? at = x.HasValue && y.HasValue && z.HasValue
? new Vec3(x.Value, y.Value, z.Value)
: null;
return await GameRouter.StartBlackoutAsync(session, state, at);
}
catch (Exception ex)
{
return (false, $"blackout start failed: {ex.Message}", 0UL);
}
}
/// Only tears down our own controller and resets the round bookkeeping. This does not
/// restore the city lighting on the client — the client's own graph owns that.
public static async Task<(bool Ok, string Message)> StopBlackoutAsync()
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
var (session, _) = target.Value;
try
{
GameRouter.ResetBlackoutState(session);
return (true, "state reset (controller left to the client's graph to clean up)");
}
catch (Exception ex)
{
return (false, $"blackout stop failed: {ex.Message}");
}
}
/// Diagnostics: what the server currently believes about the running round.
public static BlackoutStatusSnapshot BlackoutStatus()
{
var target = RequirePlayerSession();
if (target is null)
return new BlackoutStatusSnapshot(false, false, 0UL, 0, null);
var (session, _) = target.Value;
var (id, running, ends) = GameRouter.ProbeBlackout(session);
var remaining = running ? (int)Math.Max(0, (ends - DateTime.UtcNow).TotalSeconds) : 0;
return new BlackoutStatusSnapshot(true, running, id, remaining, running ? ends.ToString("O") : null);
}
/// Panel-facing projection (rule 12: public methods cannot return internal types).
/// Named "...Snapshot" because a nested type cannot share its parent's member name (CS0102).
public sealed record BlackoutStatusSnapshot(bool Online, bool Running, ulong ControllerId, int RemainingSeconds, string? EndsAt);
/// Admin catalog: the Gadget prefab groups shown in the panel picker.
public static IReadOnlyList<(string Key, string Label, IReadOnlyList<(int PathId, string Name)> Items)> GadgetGroups() =>
GadgetCatalog4229938.Groups
.Select(g => (g.Key, g.Label, g.Items.Select(i => (i.PathId, i.Name)).ToList() as IReadOnlyList<(int PathId, string Name)>))
.ToList();
/// Admin enter: seats the player in the last spawned vehicle (no F-press needed).
public static async Task<(bool Ok, string Message)> EnterVehicleAsync()
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
try
{
return await GameRouter.ForceEnterVehicleAsync(target.Value.Session);
}
catch (Exception ex)
{
return (false, $"enter failed: {ex.Message}");
}
}
/// Admin exit: ejects the player from the current vehicle.
public static async Task<(bool Ok, string Message)> ExitVehicleAsync()
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
try
{
return await GameRouter.ForceExitVehicleAsync(target.Value.Session);
}
catch (Exception ex)
{
return (false, $"exit failed: {ex.Message}");
}
}
///
/// 自动驾驶巡航速度的面板投影(2026-09-30)。
/// SavedKmh 为 null = config/autonomous-driving.json 还不存在,
/// 也就是「没在 GM 里调过速」,此时生效值就是主配置 cruiseSpeed × 3.6。
/// ActiveAutoDrive = 当前有没有正在自驾的车(决定保存时能不能热应用)。
///
public sealed record AutonomousDriveSpeedStatus(
float? SavedKmh, float EffectiveKmh, bool ActiveAutoDrive, float MinKmh, float MaxKmh);
public static AutonomousDriveSpeedStatus AutonomousDriveSpeed()
{
float? saved = null;
try
{
if (File.Exists(AutonomousDriveSpeedSettings.FilePath))
saved = AutonomousDriveSpeedSettings.Read();
}
catch
{
// 文件被写坏:按「没调过」显示,面板提示重新保存即可,不能让整个面板 500。
}
var fallback = PrivateServerConfigStore.Current.Gameplay.VehicleNavigation.CruiseSpeed * 3.6f;
var active = false;
var target = RequirePlayerSession();
if (target is not null)
{
var runtime = GameRouter.GetVehicleRuntimeState(target.Value.Session);
lock (runtime.SyncRoot)
active = runtime.AutoDriving && runtime.AutoDrivingVehicleId != 0;
}
return new AutonomousDriveSpeedStatus(
saved, saved ?? fallback, active,
AutonomousDriveSpeedSettings.MinKmh, AutonomousDriveSpeedSettings.MaxKmh);
}
///
/// 保存自动驾驶巡航速度(km/h)。有车正在自动驾驶时顺带热应用:
/// 撤回旧 Cruise 任务 → 用新速度重建 → 再确认一次,路线不变。
/// ⛑ 没有会话 / 没在开车也算成功 —— 速度已经落盘,下次开自动驾驶生效。
///
public static async Task<(bool Ok, string Message, bool HotApplied)> SetAutonomousDriveSpeedAsync(float kmh)
{
var min = AutonomousDriveSpeedSettings.MinKmh;
var max = AutonomousDriveSpeedSettings.MaxKmh;
if (!AutonomousDriveSpeedSettings.Valid(kmh))
return (false, $"速度必须在 {min:0.#}–{max:0.#} km/h 之间(收到 {kmh:0.#})。", false);
var target = RequirePlayerSession();
if (target is null)
{
try
{
var v = AutonomousDriveSpeedSettings.Save(kmh);
return (true, $"已保存 {v:0.#} km/h(当前没有客户端会话,下次开启自动驾驶生效)", false);
}
catch (Exception ex)
{
return (false, $"save failed: {ex.Message}", false);
}
}
try
{
var hot = await GameRouter.ApplyAutonomousDriveSpeedAsync(target.Value.Session, kmh);
return (true, hot
? $"已保存并热应用 {kmh:0.#} km/h:当前这辆车立刻按新速度跑(路线不变)"
: $"已保存 {kmh:0.#} km/h(当前没有正在自动驾驶的车,下次开启生效)", hot);
}
catch (Exception ex)
{
return (false, $"apply failed: {ex.Message}", false);
}
}
/// Admin time set: records + pushes SyncPlayerCurrentTime.
public static async Task<(bool Ok, string Message)> SetTimeAsync(int hour, int minute, bool fix, int transition)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
if (hour is < 0 or > 23 || minute is < 0 or > 59)
return (false, "hour must be 0..23 and minute 0..59");
if (transition < 0)
transition = 0;
var (session, state) = target.Value;
lock (state.SyncRoot)
{
state.TimeHour = (uint)hour;
state.TimeMinute = (uint)minute;
state.TimeFixed = fix;
state.TimeTransitionSeconds = (uint)transition;
state.HasExplicitTime = true;
// ★ 面板设时间 = 玩家主动改时间,记一次(与 GameRouter.SetSessionTime 同一套判据)。
state.TimeEpoch++;
}
try
{
await GameRouter.PushSessionTimeAsync(session);
return (true, $"time set to {hour:D2}:{minute:D2} (fix={fix})");
}
catch (Exception ex)
{
return (false, $"time push failed: {ex.Message}");
}
}
/// Admin weather set: records + pushes SyncPlayerWeather.
public static async Task<(bool Ok, string Message)> SetWeatherAsync(int weatherId, int transition)
{
var target = RequirePlayerSession();
if (target is null)
return (false, "no player session tracked yet (log in with the game client first)");
if (weatherId < 0 || transition < 0)
return (false, "weatherId and transition must be >= 0");
var (session, state) = target.Value;
lock (state.SyncRoot)
{
state.WeatherId = (uint)weatherId;
state.WeatherTransitionSeconds = (uint)transition;
state.HasExplicitWeather = weatherId != 0;
}
if (weatherId == 0)
return (true, "weather override cleared (client default resumes)");
try
{
await GameRouter.PushSessionWeatherAsync(session);
return (true, $"weather set to {weatherId}");
}
catch (Exception ex)
{
return (false, $"weather push failed: {ex.Message}");
}
}
public static object TimeWeatherStatus()
{
var snap = GetSnapshot();
return new
{
connected = snap.Connected,
hour = snap.TimeHour,
minute = snap.TimeMinute,
fix = snap.TimeFixed,
hasTime = snap.HasExplicitTime,
weatherId = snap.WeatherId,
hasWeather = snap.HasExplicitWeather,
weathers = ClientData.Client4229938.WeatherCatalog4229938.All.Select(w => new { id = w.Id, name = w.Name }).ToList(),
};
}
///
/// GM 面板「角色日程」卡片:每个可玩角色此刻在干什么、在哪、以及一个已经算好偏移的传送落点。
///
/// 数据全来自官方三张表(养成 AgentTag → 时刻表 → 活动表),与场上真正放人的
/// GameRouter.RosterNpc.cs 走同一个 NpcActivityCatalog4229938.Resolve,
/// 所以面板显示的坐标就是服务端实际会放人的坐标。
///
/// ⛑ goto* 故意不落在角色身上:挪到身旁约 3 米,并算出朝向角色的 facing,
/// 免得传送完贴在脸上(用户明确要求)。facing 约定与全项目一致:
/// atan2(dx, dz) * 180 / π(见 GameRouter.EnemyAiActions.cs:454)。
/// ⛑⛑ 落点必须踩在 CivilianDistricts 的官方人行道上 —— 按固定方向偏移会卡进墙里
/// 或掉进海里(09-25 玩家实测)。
///
public static object RosterSchedule()
{
var target = RequirePlayerSession();
var cfg = PrivateServerConfigStore.Current.Gameplay.RosterNpc;
int daySecond;
uint raid;
float px, py, pz;
uint activeSpirit = 0;
Dictionary placed;
if (target is null)
{
daySecond = (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds;
raid = Profile.RaidId;
px = py = pz = 0f;
placed = new Dictionary();
}
else
{
var (session, state) = target.Value;
lock (state.SyncRoot)
{
daySecond = state.CurrentDaySecond();
raid = state.ActiveRaidId;
px = state.LastReportedPlayerPosition.X;
py = state.LastReportedPlayerPosition.Y;
pz = state.LastReportedPlayerPosition.Z;
activeSpirit = state.ActiveSpiritTemplateId;
}
placed = GameRouter.RosterNpcPlacements(session);
}
var rows = new List