5348 lines
269 KiB
C#
5348 lines
269 KiB
C#
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<String> args) — methodId 66042045。
|
||
/// 客户端原生层按名字反射解析并调用(L50Game.dll RunGmUtils_Public_Static_String_String_List_1_String_0
|
||
/// / RunGmUtilsCommand_Private_Static_String_MethodInfo_List_1_String_0),执行全程发生在客户端进程内,
|
||
/// 服务端只发一次 Notify。ALT+F1 面板"召唤NPC"就是这类本地调用(实证:包日志 0 条 GM 请求但单位带完整
|
||
/// AI/受击/踉跄),这是把同等效果搬进 17888 的通道。
|
||
/// 注意:有效命令名集合未知(GmAddEnemyByPlayer 是网络命令名,客户端本地不存在),需实测。
|
||
/// </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 -> 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 循环,只在「进世界」和「移动 >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> "特摄战原型-科斯",隐藏可控原型 <=>
|
||
/// 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);
|
||
}
|
||
}
|