This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

7996 lines
491 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
namespace Ananta.Server.Configuration;
/// <summary>
/// Human-editable private-server settings.
///
/// Protocol constants (RPC ids, serializer layouts, client wire types) deliberately do NOT live
/// here. Those belong to Protocol/Client4229938 and should only change when supporting a new client.
/// </summary>
public sealed class PrivateServerConfig
{
public ClientSettings Client { get; init; } = new();
public NetworkSettings Network { get; init; } = new();
public AdminSettings Admin { get; init; } = new();
public LoginSettings Login { get; init; } = new();
public PlayerSettings Player { get; init; } = new();
public WorldSettings World { get; init; } = new();
public ContentSettings Content { get; init; } = new();
public GameplaySettings Gameplay { get; init; } = new();
public UiSettings Ui { get; init; } = new();
public LoggingSettings Logging { get; init; } = new();
public PathSettings Paths { get; init; } = new();
public DebugSettings Debug { get; init; } = new();
internal void Validate()
{
static void Port(int value, string name)
{
if (value is < 1 or > 65535) throw new InvalidDataException($"{name} must be 1..65535, got {value}.");
}
if (Debug.Enabled) Port(Debug.Port, "debug.port");
if (Client.Version != ClientBuild.Version)
throw new InvalidDataException($"client.version={Client.Version} is not supported by this source tree; expected {ClientBuild.Version}.");
if (Client.ServerId <= 0) throw new InvalidDataException("client.serverId must be positive.");
if (string.IsNullOrWhiteSpace(Client.RpcMd5)) throw new InvalidDataException("client.rpcMd5 must not be empty.");
if (string.IsNullOrWhiteSpace(Network.BindHost) || string.IsNullOrWhiteSpace(Network.AdvertisedHost))
throw new InvalidDataException("network.bindHost and network.advertisedHost must not be empty.");
if (Player.Pid == 0) throw new InvalidDataException("player.pid must not be zero.");
if (Player.InitialUnitId == 0) throw new InvalidDataException("player.initialUnitId must not be zero.");
if (Player.InitialSpiritTemplateId == 0) throw new InvalidDataException("player.initialSpiritTemplateId must not be zero.");
if (string.IsNullOrWhiteSpace(Player.AccountId) || string.IsNullOrWhiteSpace(Player.UserName) || string.IsNullOrWhiteSpace(Player.DisplayName))
throw new InvalidDataException("player.accountId, player.userName and player.displayName must not be empty.");
if (string.IsNullOrWhiteSpace(Player.LoginToken) || string.IsNullOrWhiteSpace(Player.GameToken) ||
string.IsNullOrWhiteSpace(Player.ShareToken) || string.IsNullOrWhiteSpace(Player.FpPassToken) || string.IsNullOrWhiteSpace(Player.Skey))
throw new InvalidDataException("player compatibility tokens must not be empty.");
if (World.RaidId == 0 || World.SceneInstanceId == 0 || World.UniverseId == 0)
throw new InvalidDataException("world.raidId, sceneInstanceId and universeId must not be zero.");
if (World.Content.Enabled && string.IsNullOrWhiteSpace(Paths.WorldData))
throw new InvalidDataException("paths.worldData must not be empty while world.content.enabled.");
if (World.Content.Enabled && string.IsNullOrWhiteSpace(World.Content.Scene))
throw new InvalidDataException("world.content.scene must not be empty.");
ValidateStreamWindow(World.Content.SceneItems, "world.content.sceneItems");
ValidateStreamWindow(World.Content.Gadgets, "world.content.gadgets");
if (World.Content.DynamicGo.SeedAttempts is < 1 or > 60)
throw new InvalidDataException($"world.content.dynamicGo.seedAttempts must be 1..60, got {World.Content.DynamicGo.SeedAttempts}.");
// ⚠ 2026-10-01 订正:下面这句「值不会被用上」已随 donor《content-streaming》落地失效。
// 现在 unloadRadiusCells(卸载边界)与 retainVisitedCells(保留环)都被流式代码真的读取,
// removeGridInfo 也会带内容下发 —— 硬规则 212「从不发真移除」废弃。
// ⛑ 仍然成立的那半:<c>loadBudgetCells</c> 至今仍是纯校验键(真正控量的是 maxCellsPerPush)。
// —— 历史原文(2026-09-28 实测):unloadRadiusCells 与 loadBudgetCells <b>只在这里被读一次</b>,
// 两个网格通道的流式代码既不按 unloadRadiusCells 卸载(removeGridInfo 恒为空),
// 也不按 loadBudgetCells 控量(用的是 maxCellsPerPush)。
static void ValidateStreamWindow<T>(T settings, string name) where T : class
{
var radius = (int)(typeof(T).GetProperty("RadiusCells")?.GetValue(settings) ?? 0);
var unload = (int)(typeof(T).GetProperty("UnloadRadiusCells")?.GetValue(settings) ?? 0);
var budget = (int)(typeof(T).GetProperty("LoadBudgetCells")?.GetValue(settings) ?? 0);
if (radius is < 1 or > 8)
throw new InvalidDataException($"{name}.radiusCells must be 1..8, got {radius}.");
if (unload < radius || unload > radius + 8)
throw new InvalidDataException($"{name}.unloadRadiusCells must be within radiusCells..radiusCells+8, got {unload}.");
if (budget is < 1 or > 200)
throw new InvalidDataException($"{name}.loadBudgetCells must be 1..200, got {budget}.");
}
if (Network.LoginPorts is null || Network.LoginPorts.Length != 2)
throw new InvalidDataException("network.loginPorts must contain exactly two ports.");
Port(Network.LoginPorts[0], "network.loginPorts[0]");
Port(Network.LoginPorts[1], "network.loginPorts[1]");
Port(Network.GamePort, "network.gamePort");
Port(Network.Proxy.HttpsPort, "network.proxy.httpsPort");
Port(Network.Proxy.HttpPort, "network.proxy.httpPort");
Port(Network.Proxy.LoginListPort, "network.proxy.loginListPort");
Port(Network.Proxy.LoginTcpPort, "network.proxy.loginTcpPort");
Port(Network.Proxy.GameTcpPort, "network.proxy.gameTcpPort");
Port(Network.Proxy.SceneSubPort, "network.proxy.sceneSubPort");
if (Admin.Enabled)
{
if (string.IsNullOrWhiteSpace(Admin.Host))
throw new InvalidDataException("admin.host must not be empty when admin.enabled=true.");
Port(Admin.Port, "admin.port");
}
if (string.IsNullOrWhiteSpace(Network.Proxy.UpdateHost) || string.IsNullOrWhiteSpace(Network.Proxy.CertificatePassphrase))
throw new InvalidDataException("network.proxy.updateHost and certificatePassphrase must not be empty.");
if (Content.Roster.UnitIdBase == 0) throw new InvalidDataException("content.roster.unitIdBase must not be zero.");
if (Content.Roster.TemplateIdMin >= Content.Roster.TemplateIdMaxExclusive)
throw new InvalidDataException("content.roster.templateIdMin must be less than templateIdMaxExclusive.");
if (Gameplay.Combat.MaxHp <= 0) throw new InvalidDataException("gameplay.combat.maxHp must be positive.");
if (Gameplay.Combat.Skills.All().Any(x => x == 0)) throw new InvalidDataException("gameplay.combat.skills contains id 0.");
if (Gameplay.Combat.Charges.Length == 0) throw new InvalidDataException("gameplay.combat.charges must not be empty.");
foreach (var charge in Gameplay.Combat.Charges)
{
_ = Gameplay.Combat.Skills.ByName(charge.Skill);
if (charge.Max == 0 || charge.Current > charge.Max || charge.Period < 0)
throw new InvalidDataException($"Invalid gameplay.combat.charges entry for '{charge.Skill}'.");
}
if (Gameplay.WebTraversal.SharedBuffIds.Length == 0)
throw new InvalidDataException("gameplay.webTraversal.sharedBuffIds must not be empty.");
if (!Gameplay.WebTraversal.SharedBuffIds.Contains(Gameplay.WebTraversal.PersistentGrappleBuffId))
throw new InvalidDataException("persistentGrappleBuffId must also be present in sharedBuffIds.");
if (Gameplay.WebTraversal.GrappleRearmDelayMs < 0)
throw new InvalidDataException("gameplay.webTraversal.grappleRearmDelayMs must be >= 0.");
if (Gameplay.Vehicles.Enabled)
{
if (Gameplay.Vehicles.FleetIds.Length == 0) throw new InvalidDataException("gameplay.vehicles.fleetIds must not be empty when vehicles are enabled.");
if (Gameplay.Vehicles.MinimumApproachMeters <= 0 || Gameplay.Vehicles.DesiredApproachMeters < Gameplay.Vehicles.MinimumApproachMeters)
throw new InvalidDataException("gameplay.vehicles approach distances are invalid.");
if (Gameplay.Vehicles.SearchRadiusMeters < Gameplay.Vehicles.DesiredApproachMeters)
throw new InvalidDataException("gameplay.vehicles.searchRadiusMeters must cover desiredApproachMeters.");
if (string.IsNullOrWhiteSpace(Paths.ZoneGraphLanes)) throw new InvalidDataException("paths.zoneGraphLanes must not be empty while vehicles are enabled.");
}
if (Ui.UidLabel.Enabled && string.IsNullOrWhiteSpace(Ui.UidLabel.Text))
throw new InvalidDataException("ui.uidLabel.text must not be empty when ui.uidLabel.enabled=true.");
if (Ui.CutscenePanel.Enabled)
{
if (string.IsNullOrWhiteSpace(Ui.CutscenePanel.Host))
throw new InvalidDataException("ui.cutscenePanel.host must not be empty when enabled.");
Port(Ui.CutscenePanel.Port, "ui.cutscenePanel.port");
}
if (string.IsNullOrWhiteSpace(Logging.Directory))
throw new InvalidDataException("logging.directory must not be empty.");
if (Logging.Packets.MaxBodyBytes < 0)
throw new InvalidDataException("logging.packets.maxBodyBytes must be >= 0 (0 means unlimited).");
if (string.IsNullOrWhiteSpace(Paths.ClientConfigs))
throw new InvalidDataException("paths.clientConfigs must not be empty.");
if (string.IsNullOrWhiteSpace(Paths.RuntimeFastpatch))
throw new InvalidDataException("paths.runtimeFastpatch must not be empty.");
}
}
public sealed class ClientSettings
{
public int Version { get; init; }
public string ArtifactVersion { get; init; } = string.Empty;
public int ServerId { get; init; }
public int Aid { get; init; }
public string RpcMd5 { get; init; } = string.Empty;
public int NoMoreHotfixPatchError { get; init; }
}
public sealed class LoginSettings
{
/// <summary>
/// Shape of the unsolicited <c>SyncRoleList</c> notify sent after <c>TryLogin</c>.
/// <para>
/// <c>list</c> sends an empty <c>List&lt;ulong&gt;</c> (7 bytes on the wire: 5-byte
/// notification header + <c>FF 01</c>). That is the only shape that works on a cold
/// login.
/// </para>
/// <para>
/// The legacy shape was a bare <c>ulong</c> pid (8 bytes). It looks plausible and is
/// wrong: a notify named "RoleList" is expected to carry a collection. With the bare
/// pid the client stalls inside its login state machine — heartbeats keep flowing on
/// the same socket and <c>RequestEnterGame</c> simply never arrives, so the failure
/// looks like a network hang with no error anywhere.
/// </para>
/// <para>
/// <c>pid</c> restores the legacy shape for bisecting against an older capture;
/// <c>none</c> suppresses the notify entirely.
/// </para>
/// </summary>
public string RoleListMode { get; init; } = "list";
}
public sealed class NetworkSettings
{
public string BindHost { get; init; } = string.Empty;
public string AdvertisedHost { get; init; } = string.Empty;
public int[] LoginPorts { get; init; } = [];
public int GamePort { get; init; }
/// <summary>
/// 服务端 → 客户端的传输层 keepalive 间隔(毫秒),帧 mode=0x03。
/// <b>默认 0 = 关闭</b>(和 2026-10-01 之前的行为完全一致)。
/// 联机/多会话场景才需要:一个卡死的客户端不该把别人的会话一起拖死。
/// 🛑 回滚 = 设回 0,不用重新编译。修改后立即生效的是**新连接**,已建立的会话沿用旧值。
/// </summary>
public int SocketHeartbeatMs { get; init; }
/// <summary>
/// 单次加密写的上限(毫秒)。超过就把这条连接退役,避免慢/卡死的接收方把写锁占死。
/// <b>0 = 不设上限</b>(旧行为)。
/// ⛑ 本地单机跑的话给个宽松值就行;15000ms 足够容纳最大的那一批 SceneItem / PropRestock 推送。
/// </summary>
public int WriteTimeoutMs { get; init; } = 15000;
public ProxyNetworkSettings Proxy { get; init; } = new();
}
public sealed class ProxyNetworkSettings
{
public int HttpsPort { get; init; }
public int HttpPort { get; init; }
public int LoginListPort { get; init; }
public int LoginTcpPort { get; init; }
public int GameTcpPort { get; init; }
public int SceneSubPort { get; init; }
public string UpdateHost { get; init; } = string.Empty;
public string CertificatePassphrase { get; init; } = string.Empty;
}
public sealed class AdminSettings
{
/// <summary>Embedded localhost admin panel (no auth — bind 127.0.0.1 only).</summary>
public bool Enabled { get; init; } = true;
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 17888;
}
public sealed class PlayerSettings
{
public ulong Pid { get; init; }
public ulong InitialUnitId { get; init; }
public uint InitialSpiritTemplateId { get; init; }
public string AccountId { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string LoginToken { get; init; } = string.Empty;
public string GameToken { get; init; } = string.Empty;
public string ShareToken { get; init; } = string.Empty;
public string FpPassToken { get; init; } = string.Empty;
public string Skey { get; init; } = string.Empty;
public uint LoginUniverseId { get; init; }
}
public sealed class WorldSettings
{
public uint RaidId { get; init; }
public ulong SceneInstanceId { get; init; }
public uint UniverseId { get; init; }
public Vector3Settings Spawn { get; init; } = new();
public float Facing { get; init; }
public bool EntryOpeningEnabled { get; init; }
public WorldContentSettings Content { get; init; } = new();
}
/// <summary>
/// World content streaming. The client renders lampposts and street props from
/// its baked SceneItemBlob once the server pushes the destructible grid window;
/// gadgets (lifts, doors, vending machines) use the client build's own baked
/// GadgetBlob through SyncGadgetGridAOIDecrease; DynamicGOs are re-seeded while
/// the client finishes loading its world headers. All data comes from paths.worldData.
/// </summary>
public sealed class WorldContentSettings
{
/// <summary>Master switch for all world-content streaming.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Legacy primary scene name retained for config compatibility.</summary>
public string Scene { get; init; } = "WorldMap_Release";
/// <summary>
/// Build-local scene data allowed to supplement the current free-roam scene.
/// FIX23V uses only WorldMap_Release for this server/raid; SceneItems themselves are
/// resolved from the client SceneItemBlob and do not use this list as a placement whitelist.
/// </summary>
public string[] ProductionScenes { get; init; } = ["WorldMap_Release"];
public SceneItemStreamSettings SceneItems { get; init; } = new();
public GadgetStreamSettings Gadgets { get; init; } = new();
public ElevatorSettings Elevators { get; init; } = new();
public SceneDeviceSettings SceneDevices { get; init; } = new();
public DynamicGoStreamSettings DynamicGo { get; init; } = new();
public DoorSettings Doors { get; init; } = new();
/// <summary>赛车检查点门(2026-10-03 移植自捐赠分支)。见 <see cref="RacingGateSettings"/>。</summary>
public RacingGateSettings RacingGates { get; init; } = new();
/// <summary>Gym equipment the server places itself (squat / bench / treadmill / sit-up).</summary>
public GymEquipmentSettings GymEquipment { get; init; } = new();
/// <summary>
/// 街头篮球 QTE 的篮筐(2026-10-03)。MiniGame prefab 里<b>没有篮筐模型</b>,
/// 得由服务端刷一个 <c>Gadgets_Common_BasketballRim</c> 到判定点位置。
/// ⛑ 偏移/角度放配置里是为了<b>免编译微调</b>:改 json + 面板 reload-config 即可,
/// 不用再因为挪 2 米就停服重新编译。
/// </summary>
/// <summary>
/// 搜打撤(ExtractionShooter,「不要抬头!」)。默认关闭 —— 改 true 且 raid 名单命中才会走
/// 进图 / 容器 / 背包那套结构。回滚不需要重新编译。
/// </summary>
public ExtractionShooterSettings Extraction { get; init; } = new();
/// <summary>Every other client-blob prop the server restocks by world cell.</summary>
public PropRestockSettings PropRestock { get; init; } = new();
/// <summary>家具摆放探针(T1-22 排障)。默认全关,开了才会在玩家脚下摆物件。</summary>
public DebugPlaceSettings DebugPlace { get; init; } = new();
/// <summary>Answer to the client's AskMetroGadgetIds(67809129) — carriage gadget ids.</summary>
public MetroGadgetSettings MetroGadgets { get; init; } = new();
/// <summary>
/// Per-prefab <c>PackedGadgetInfo.spoonSpecialList</c> overrides (2026-09-23). Some props stay
/// inert unless the placement carries special params — the mini-game cabinets are the proven
/// case, see <see cref="GadgetSpoonParamSettings"/>.
/// </summary>
public GadgetSpoonParamSettings GadgetSpoonParams { get; init; } = new();
/// <summary>
/// 室内扇区(<c>sector_high_indoor_&lt;IndoorId&gt;</c>)—— 服务端按 IndoorConfig 的最近条目
/// 推出玩家所在室内,然后推 SyncLoadIndoorSector(68095220) / SyncIndoorSectorControl(68971505)。
/// 默认关闭:线格式是静态推导出来的,先 logOnly 验一轮再开。
/// </summary>
public IndoorSectorSettings IndoorSectors { get; init; } = new();
/// <summary>
/// 派对场地家具(露营的烤炉 / 帐篷 / 篝火 / 野餐垫 / 灯 / 野餐车)。
/// 与健身房器材同因同解:这批 Gadgets_Party_* 只活在客户端关卡 blob 里,
/// grid-AOI 下发不驱动 GadgetBlob ⇒ 必须服务端按官方场地坐标显式摆一次。
/// 见 GameRouter.PartyCamp.cs。回滚:world.content.partyCamp.enabled = false。
/// </summary>
public PartyCampSettings PartyCamp { get; init; } = new();
/// <summary>
/// 龙栖村(raid 23300999)互动摊位机关 + 打气球玩法。
/// 见 GameRouter.MiniGameStall.cs / GameRouter.Balloon.cs。
/// 回滚:world.content.miniGameStalls.enabled = false。
/// </summary>
public MiniGameStallSettings MiniGameStalls { get; init; } = new();
}
/// <summary>
/// Indoor sector streaming (2026-09-27). The client's <c>sector_high_indoor_&lt;N&gt;</c> roots are
/// pure scene-subtree visibility switches — lights, mirrors, LOD decoration groups. They are a
/// <b>fifth</b> channel, unrelated to the gadget / scene-item grids, and until now nothing on the
/// server pushed them, which is one reason indoor official layouts never appeared.
/// </summary>
public sealed class IndoorSectorSettings
{
/// <summary>False = never push. Rollback lever, no rebuild needed.</summary>
public bool Enabled { get; init; } = false;
/// <summary>True = only log what would be pushed (safe way to validate the id derivation).</summary>
public bool LogOnly { get; init; } = true;
/// <summary>
/// 进入判定半径(米)。玩家离某个 IndoorConfig 条目比这更近就算「在室内」。
/// 新启 54 个有坐标条目的最近邻距离中位数 ≈ 100 m、最小 33 m ⇒ 默认 60 m 是折中。
/// </summary>
public float RadiusMeters { get; init; } = 60f;
/// <summary>
/// 离开判定半径(米)。0 = 用「进入半径 + 15」的迟滞,避免在门口反复 Load/Unload。
/// </summary>
public float ExitRadiusMeters { get; init; } = 0f;
/// <summary>
/// 是否附带推 SyncIndoorSectorControl(68971505)(显式 true/false 开关)。
/// 注入端的 <c>sectorstate</c> 是两条一起喂的(SyncIndoorSectorControl + 引擎层
/// SetIndoorSectorState),所以默认开。
/// </summary>
public bool PushControl { get; init; } = true;
/// <summary>
/// 状态没变时的重声明间隔(毫秒)。0 = 只在状态变化时推一次。
/// 与 gadget / sceneitem 网格同症状:不持续声明,客户端会把窗口丢掉。
/// </summary>
public int HeartbeatMs { get; init; } = 5000;
/// <summary>
/// 除扇区 Id(176000xx)外,是否<b>也</b>把裸 IndoorId 推一遍。
/// ★ <b>2026-09-27 20:1x 实机 A/B 已定案:默认关。</b>
/// 同一轮里推 9 个扇区 Id + 1 个裸 IndoorId,客户端日志:
/// <c>SetIndoorSectorState SectorControlId:0, indoorSectorStates:(17600101,True)…(17600109,True),(76,True)</c>
/// + <c>config not found for id 76</c> —— <b>只有裸 IndoorId 查不到,9 个扇区 Id 全被接受</b>。
/// ⇒ 这个字典的 key 就是 <c>WorldStateIndoorSectorConfig.Id</c>,不是 IndoorId。
/// (另一佐证:无扇区配置的 Bowtie Lounge indoorId=44 只推裸 44,客户端同样报 not found。)
/// 留着这个开关只是给以后万一要重做 A/B 用。
/// </summary>
public bool AlsoPushRawIndoorId { get; init; } = false;
/// <summary>
/// 是否优先采信客户端 <c>SyncChangeIndoor</c> 上报的 <c>indoorConfigId</c>(<b>权威值</b>),
/// 而不是服务端的「几何最近邻」推算。
/// ★ <b>2026-09-27 20:3x 实机</b>:ACD Nova Branch(76)与 ACD快乐房(129)的
/// <c>IndoorConfig.MiniMapPos</c> 矩形<b>互相重叠</b>,纯几何最近邻在重叠区每几秒就在
/// 76 / 129 之间跳一次 ⇒ 客户端每跳一次就把 9 个扇区的子树建了又删、删了又建。
/// 客户端自己上报的 <c>Enter GpsBound: IndoorId: 76</c> 才是不抖的那个(对得上 76)。
/// </summary>
public bool PreferClientReport { get; init; } = true;
/// <summary>
/// 客户端上报值的保鲜期(毫秒)。超过这个时间没再上报就退回几何判定
/// (客户端只在进出 bound 时才发 <c>SyncChangeIndoor</c>,站着不动不会持续发)。
/// </summary>
public int ClientReportGraceMs { get; init; } = 15000;
/// <summary>
/// 换室内前的<b>确认延时</b>(毫秒):候选 id 必须连续稳定这么久才真正切换。
/// 两个来源(客户端上报 / 几何)各自都会瞬时抖到 0 或隔壁场馆,光靠「优先采信」
/// 治不了 —— 必须再叠一层防抖。0 = 不防抖(回到 20:3x 抖动的旧行为)。
/// </summary>
public int SwitchDelayMs { get; init; } = 3000;
}
/// <summary>
/// Doors as gadgets (T2-11, donor rounds 16C-16I). AskPlayerEnterOrLeaveDoor arrives as a NOTIFY
/// and the server answers by restating the entity through SyncGadgetRefresh(68765074) with the
/// DoorModuleSyncInfo filled in - there is no dedicated door RPC in the client surface.
/// </summary>
public sealed class DoorSettings
{
/// <summary>False = log only, never push. Rollback lever, no rebuild needed.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Also answer the client's own LEAVE reports (ProximityState = NoPlayer). Keep true: the
/// client does not always emit LEAVE, which is why the donor added the timer below.
/// </summary>
public bool PushOnLeave { get; init; } = true;
/// <summary>
/// Degrees the leaf swings to while a player is inside it. Panel-owned semantics (round
/// 16I-5): 90 is the hinge idiom and is safe for sliders too. 0 behaves like "no animation".
/// </summary>
public float OpenAngle { get; init; } = 90f;
/// <summary>
/// Also restate the door through SyncGadgetRefresh(68765074) / DoorModuleSyncInfo. False by
/// default: that path is accepted but its leaf behaviour tops out at "the leaf disappears",
/// which donor round 16H counted as success. Left here as a comparison lever only.
/// </summary>
public bool PushGadgetRefresh { get; init; } = false;
/// <summary>
/// Safety net from donor round 16I-4: N seconds after an ENTER with no LEAVE, push NoPlayer.
/// Without it the leaf never closes because the client alone does not restate the module.
/// 0 = disable the timer.
/// </summary>
public int AutoCloseSeconds { get; init; } = 10;
}
/// <summary>
/// Scene devices — doors, vending machines, fridges, arcades. An Ask* device INVOKE is only a
/// REQUEST: the client's device-state manager waits for the server to ECHO the transition before
/// it plays the interaction, so replying "ok" without the echo leaves the device stuck.
/// Every switch here is a rollback lever — see GameRouter.SceneDevices.cs.
/// </summary>
public sealed class SceneDeviceSettings
{
/// <summary>Master switch. Off = fall back to the generated neutral replies (no echo).</summary>
public bool Enabled { get; init; } = true;
/// <summary>Echo SyncSceneDeviceStateChange(68165561) on an accepted change-state.</summary>
public bool EchoState { get; init; } = true;
/// <summary>Echo SyncSceneDeviceValueChange(68332775) on an accepted record-value.</summary>
public bool EchoValue { get; init; } = true;
/// <summary>
/// Reply to <c>AskCheckSpoonConditionByType</c>(67709054) — "may I run this device script?".
///
/// ⛔⛔ **2026-09-28 实机订正(用户验证):这里必须是 false。**
/// 旧注释写「生成的中性默认 0x00=false 会让客户端拒绝跑设备脚本,donor round 13 答 0x01」
/// —— 对**衣柜这一族是反的**,而且 donor 那条从来没有被实测过,只是照抄的推断。
/// 实测经过(塔菲公寓衣柜 uid 14013633775178039662,
/// prefab `SpoonSlot_WardrobeDoor_FashionChange_tafeiapartment`,pathId 371305360):
/// · 回 **0x01(true)**:F 提示一闪即没、换装打不开。
/// `logs/packets-20260928-204201.log` 21:03:18.961 S2C RETURN body=1b hex=**01**
/// (同一条 60b C2S payload 尾部就是衣柜 uid 6E41E7CDAB6D7AC2),
/// `[SPOON] action` 只发 2 次(21:03:59 / 21:04:05)就停。
/// · 回 **0x00(false)**:F 常驻;`logs/console-20260928-224215.log` 22:46:37 `condition -> False`、
/// 22:46:44 `[SPOON] action` 触发,其 60b payload 与对照组 v1.5 成功换装时**逐字节相同**。
/// 对照组 v1.5 根本没有本 handler,走 `DefaultReturnCatalog4229938` 兜底 0x00 ⇒ 衣柜一直正常。
/// ⇒ 结论:**false = 客户端走本地表现、脚本照跑;true = 客户端把交互交给服务端脚本流程,
/// 而我们给不出脚本输出(AskDoSpoonServerActionByType 只回空 OK)⇒ 交互挂死。**
/// 回滚:本值改回 true 即复现「衣柜 F 不显示」。配置键 =
/// <c>world.content.sceneDevices.spoonConditionTrue</c>(配置里已显式写 false)。
/// ⛑ 默认值同步改成 false(旧默认 true 是坑:一旦配置里没有这个键就会复现 bug)。
/// </summary>
public bool SpoonConditionTrue { get; init; } = false;
/// <summary>Device ids that must never be echoed (client-local flows that regress). Decimal.</summary>
public string[] EchoOffIds { get; init; } = [];
}
/// <summary>
/// Lifts. The cab never moves on its own — the client asks, the server has to echo the movement
/// back with SyncMobilePlatformInfo(68705244) or nothing happens at all.
/// </summary>
public sealed class ElevatorSettings
{
public bool Enabled { get; init; } = true;
/// <summary>How long the ride takes before the arrival push lands (ms).</summary>
public int TravelMs { get; init; } = 2500;
/// <summary>
/// LiftDoorMode sent with the ARRIVAL push (dump.cs:1750057 — Normal=0, StayOpen=1, Closed=2).
/// The hall call (F) only produces <c>AskMoveMobilePlatform</c>; there is no "open the door"
/// RPC on the wire, so if the cab parks with Normal the doors stay shut and the player can
/// never step in. StayOpen is the only value that asks the client to hold the doors.
/// Set to 0 to restore the old (doors stay shut) behaviour.
/// </summary>
public byte ArrivalDoorMode { get; init; } = 1;
/// <summary>
/// Push <c>SyncLiftDoorMode(68799106)</c> with <see cref="ArrivalDoorMode"/> after the cab
/// parks. Without it the doors stay shut: LiftMgr keeps its own per-lift LiftDoorMode and
/// nothing in the hall call asks it to open.
/// </summary>
public bool OpenDoorsOnArrival { get; init; } = true;
}
public sealed class GymEquipmentSettings
{
/// <summary>False = never place anything. Rollback lever, no rebuild needed.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Which S2C channel places the prop:
/// 0 = SyncGadgetAdd(68843189) one entity per push
/// 1 = SyncGadgetAOIAddAndRemove(68169646)
/// 2 = both of the above
/// 3 = SyncGadgetAddList(68925813) one push for the whole venue
///
/// 0 is the one proven to build the entity (name / position / interact point all correct, and
/// the minigame is playable). ⚠️ It is NOT the channel AnantaGymLoader.exe uses — the loader
/// calls SyncGadgetAddList(68925813) with iScale=0, see the IScale note. Try 3 (with IScale 0)
/// if props still come out invisible on channel 0. Its wire shape is inferred
/// (single-arg RPC ⇒ inline), not yet proven live.
/// </summary>
public int Channel { get; init; } = 0;
/// <summary>
/// PackedGadgetInfo.iScale sent with every gym prop.
///
/// 0 is the value AnantaGymLoader.exe writes (agent.js gymspawn2: pk.field("iScale").value = 0)
/// and the value carried by every one of the 400 blob records read out of
/// GadgetBlob.tmpPackedGadgetExtendInfo.basePackedInfo. The client renders it 1:1, which is what
/// self-streamed props (vending machines, cat cafe) end up with.
/// 100 (the GM-panel default) makes gym entities come out with transform scale (0,0,1) —
/// a zero-scale shell with no prefab.
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>
/// Extra metres added to every venue's own radius before its equipment is placed. Raise it if
/// the venue's anchor is outside the building; 0 keeps each spot's own radius.
/// </summary>
public float RadiusBonus { get; init; } = 0f;
/// <summary>
/// ★ 2026-10-01 千泊健身房实测:摆放撞在客户端「切室内」的那一瞬,19 条 SyncGadgetAdd 全部被吞
/// (服务端 19/19 下发成功、客户端零痕迹,场馆当场空着);而场馆<b>每会话只铺一次</b>
/// (<c>GymEquipmentSessionState.Placed</c>)⇒ 一次丢 = 整场会话都空。
/// 同一场馆、同样 19 条包,客户端切室内完成 <b>12 秒后</b>再摆就全部正常出现(用户实机确认)。
///
/// <para>判据 = 客户端 <c>SyncChangeIndoor</c> 的上报时刻(<c>WorldEntryState.ClientIndoorReportMs</c>):
/// 失败那次上报与摆放<b>同一秒</b>,成功那次相隔 12 秒。0 = 关掉这个守卫(回到旧行为)。</para>
/// ⛑ 别改用 <c>IndoorSectorSessionState.LastPushMs</c> —— 它每次心跳都刷新(连"该场馆无扇区 ⇒
/// 什么都不推"也算),拿它当守卫会让器材<b>永远摆不出来</b>。
/// </summary>
public int SettleAfterIndoorChangeMs { get; init; } = 3000;
/// <summary>传送流程未结束(<c>state.PendingTeleportId != 0</c>)时不摆放 —— 同一类「客户端正在换场景」的窗口。</summary>
public bool SkipDuringTeleport { get; init; } = true;
/// <summary>Log the placements but push nothing. Use it to dry-run a fresh capture.</summary>
public bool LogOnly { get; init; } = false;
}
/// <summary>
/// 搜打撤(ExtractionShooter,本季 Sanhok-free 的「不要抬头!」)的服务端侧总开关。
///
/// 这套内容与 AccountInstanceId / world-content 三条管线无关,是<b>纯服务端状态</b>驱动的:
/// 服务端自己记住包里有什么、容器里有什么,客户端只有 Ctrl 出来的 view。
/// 所以关掉它不会有任何残留 —— 全部状态都在 session.Items 里。
/// </summary>
public sealed class ExtractionShooterSettings
{
/// <summary>False = 所有搜打撤 handler 直接放行 / 跳过,零结构性改动。</summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// 被当作搜打撤图的 raid 名单。
///
/// 为什么是名单而不是 RaidType:配置表里**没有** raid ↔ 搜打撤图的外键
/// (<c>ExtractionShooterConfig</c> 只给「图 → BirthPoints」),官方文档用落点包围盒判图,
/// 而服务端这里只需要「当前 raid 要不要挂搜打撤 UI」⇒ 名单最直白。
/// 23301277 = 不要抬头!(scene 20001319);23301215 终局之战;23301311 古墓摸金。
/// </summary>
public uint[] RaidIds { get; init; } = [23301277];
/// <summary>
/// 退出请求(67504722)是否真的把人扔回主城。
///
/// ⛑ 关掉它 ≠ 关掉整套:等待房和主城<b>也会</b>发这条请求,必须有
/// <c>IsExtractionRaid</c> 守卫(handler 里已有),这个开关只是多一层。
/// </summary>
public bool ExitToMainCity { get; init; } = true;
/// <summary>进图时给每个包下发的初始 token(代币)数。0 = 不下发。</summary>
public uint InitialTokenCount { get; init; } = 0;
/// <summary>
/// 容器一次刷出来的物件数上限。<c>ExtractionShooterItemContainerConfig</c> 自己有
/// QuantityMin/Max,这里是第二次夹紧(用于防止吃满带宽的大箱)。
/// </summary>
public int MaxRollPer { get; init; } = 64;
/// <summary>
/// 伪造匹配:客户端点「开始」的那四条请求(<c>AskStartSingleMatch</c> 63347492 /
/// <c>StartBotMatch</c> 63474603 / <c>StartMatchInTeam</c> 63563208 /
/// <c>StartGameInTeam</c> 63121362)直接切进 <see cref="PreferredRaid"/>。
///
/// 为什么要有这个:官方的开局要过真实匹配服 / 队伍服,服务端补不出那条链路,
/// 而客户端点开始之后就在等切图 —— 不接住就是卡在开始界面。
/// ⛑ 默认关:这四条是<b>通用</b>请求(别的玩法也走),开着会把所有开局都截到搜打撤去。
/// 真要玩的时候在面板勾上(面板改动只在内存里,重启回落到这里)。
/// </summary>
public bool FakeMatch { get; init; } = false;
/// <summary>伪造匹配要去的 raid。默认 23301277(不要抬头!)。</summary>
public uint PreferredRaid { get; init; } = 23301277;
/// <summary>
/// 退图之前先推一条 <c>SyncRaidSettlement</c>(68995324)。
///
/// 「帧全对但人卡住」里有一整类是缺出场:每伪造一次进场,都要反查客户端有没有对应的
/// 出场请求被空 OK 吞掉。结算帧是副本流程的收尾信号 —— 直接拔线回主城的客户端
/// 有时会把结算 UI 挂在屏幕上下不来。
/// 零战斗数据时这条包恒 32 字节(见 handshake 文档 §6.7)。
/// </summary>
public bool ExitSettlement { get; init; } = true;
/// <summary>
/// 武器轮盘的实体化下发方式:<c>full</c>(默认,每次变动重发整表)/ <c>incremental</c>(只发变化的格)
/// / <c>off</c>(不下发 = 修复前的行为,轮盘只显示物品、Tab 切不到)。
/// 写错的值按 <c>full</c> 处理。
/// </summary>
public string WheelSync { get; init; } = "full";
/// <summary>
/// 轮盘物品是否随 itemInfo 一起带 <c>WeaponData</c>。带上客户端才能把「背包那一格」和
/// 「那件实体化武器」连起来(否则枪的 tooltip 退化成普通物品描述)。关掉用于回退验证。
/// </summary>
public bool WheelWeaponData { get; init; } = true;
}
/// <summary>
/// Restocks client-blob props by world cell: vending machines, indoor chairs/sofas, claw machines,
/// mini-game cabinets, basketball courts, bouncy beds, market stalls — everything the client keeps
/// in <c>LX6.Item.GadgetBlob</c> but never instantiates from our grid-AOI push alone.
/// Data comes from Configs/PropRestock.json (_gen_proprestock.py builds it out of a blob dump).
/// </summary>
public sealed class PropRestockSettings
{
/// <summary>False = never place anything. Rollback lever.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// How many cells around the player's own cell get stocked. 1 = the 3x3 neighbourhood
/// (120 m across at a 40 m cell), which is roughly the client's own streaming window.
/// </summary>
public int RadiusCells { get; init; } = 1;
/// <summary>
/// Which S2C channel places the props: 0 = SyncGadgetAdd(68843189), 1 = AOI
/// SyncGadgetAOIAddAndRemove(68169646), 3 = SyncGadgetAddList(68925813).
/// 0 is the proven one.
/// </summary>
public int Channel { get; init; } = 0;
/// <summary>
/// PackedGadgetInfo.iScale sent with every prop. Must stay 0: 100 produces an entity with the
/// right name and a working interact point but transform scale (0,0,1) — no prefab.
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>Log the placements but push nothing. Use it to dry-run a fresh capture.</summary>
public bool LogOnly { get; init; } = false;
}
/// <summary>
/// 家具摆放探针(T1-22 排障)。走已经验证过的 <c>SpawnGadgetAsync</c> 显式摆放通道,
/// 在玩家脚下周围摆一圈指定 pathId 的真物件,用来判断「显式摆放到底通不通」。
/// 全部参数热重载;想再摆一次把 <c>Gen</c> +1。见 <c>GameRouter.DebugPlace.cs</c>。
/// </summary>
public sealed class DebugPlaceSettings
{
/// <summary>False = 什么都不摆。回滚开关。</summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// 要摆的 prefab pathId(来自 <c>Configs/GadgetCatalog.json</c>)。
/// 常用:2012164699 = <c>Bench_chairs_p_common_prop_roadsystem_yiht01</c>(公交站长椅)、
/// 192285565 = <c>Bench_chairs_p_map01_indoor_prop_mcdonalds_seat001a</c>(麦当劳椅)。
/// </summary>
public int PathId { get; init; } = 0;
/// <summary>摆几个(沿圆周均匀分布)。</summary>
public int Count { get; init; } = 4;
/// <summary>离玩家多远(米)。</summary>
public float Radius { get; init; } = 3.5f;
/// <summary>相对玩家脚底的高度偏移(米)。</summary>
public float OffsetY { get; init; } = 0.05f;
/// <summary>通道:0 = SyncGadgetAdd(已验证) / 1 = AOI / 3 = SyncGadgetAddList。</summary>
public int Channel { get; init; } = 0;
/// <summary>
/// <c>PackedGadgetInfo.iScale</c>。必须是 0:100 会摆出一个名字对、交互点也有、
/// 但缩放是 (0,0,1) 的实体 —— 看不见模型。
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>只打日志不发包。</summary>
public bool LogOnly { get; init; } = false;
/// <summary>每次 +1 就重新摆一轮(热重载生效,不用重启、不用重编译)。</summary>
public int Gen { get; init; } = 0;
}
/// <summary>
/// <c>PackedGadgetInfo.spoonSpecialList</c> — per-instance spoon parameters carried on a placement.
///
/// ★ Proof this matters (frida dump of 161 live client instances, 2026-09-23): the ONLY props whose
/// spoonSpecialList is non-null are the <c>SpoonSlot_*</c> doors and <c>MiniGameMachine_Bee</c>
/// (pathId 223752202) carrying <b>markId 31511937 = "True"</b>. Furniture/seats carry none, which
/// is why "sit down" never depended on this field. The Bee cabinet is different: without the
/// param the client builds the entity but the mini-game never arms.
///
/// Keyed by pathId so every channel picks it up automatically — GM spawn, gym equipment and
/// PropRestock all funnel through <c>GameRouter.BuildGadgetEntityInfo</c>.
/// Rollback: <c>enabled = false</c> (props go back to a null spoonSpecialList).
/// </summary>
public sealed class GadgetSpoonParamSettings
{
public bool Enabled { get; init; } = true;
/// <summary>pathId → params. JSON keys are strings (e.g. "223752202").</summary>
public Dictionary<string, List<GadgetSpoonParamEntry>> ByPathId { get; init; } = new();
}
public sealed class GadgetSpoonParamEntry
{
/// <summary><c>PackedGadgetSpecialParam.markId</c> — 31511937 for the Bee cabinets.</summary>
public int MarkId { get; init; }
/// <summary><c>PackedGadgetSpecialParam.value</c> — the string "True", not a boolean.</summary>
public string Value { get; init; } = "";
}
/// <summary>
/// AskMetroGadgetIds(67809129) answer. The client builds the carriage prefab itself and then asks
/// the server which gadget instance ids belong to it; before this existed the reply was the
/// typed-default empty list, so the SpoonSlotComponent seats in the carriages never got a hold and
/// no "sit down" button ever appeared.
///
/// The ids here are invented (option A): the client only needs an id to bind its local seat slot
/// to. If seats still stay dead, switch to really spawning the chairs through SyncGadgetAdd with
/// the LightRailChair pathIds (843740076 / 1058671153 / 267309514 / 1181678445) and hand those
/// instance ids back instead.
/// </summary>
public sealed class MetroGadgetSettings
{
/// <summary>False = keep answering the empty list (pre-2026-09-23 behaviour). Rollback lever.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Fallback only: how many carriages to answer for when RailTrainConfig/RailLineConfig cannot
/// resolve the metroId. With those configs present the real composition is used instead.
/// </summary>
public int Carriages { get; init; } = 3;
/// <summary>Fallback only: seat gadget ids handed back per carriage (InnerGadgetIds).</summary>
public int SeatsPerCarriage { get; init; } = 6;
/// <summary>
/// Seats per carriage template id (RailTrainConfig CarriageIds value -> seat slot count).
/// These counts are NOT in any config table — they live in the carriage prefab, so the only way
/// to fill them is to dump a live train. Verified 2026-09-23 on LightRail_4_405 (line 4,
/// template 3, CarriageIds [4,5,9]): template 4 (head) has 4 seats, template 5 has 6.
/// Anything missing falls back to <see cref="DefaultCarriageSeats"/>.
/// ⛑ <b>2026-09-27 21:3x 已知矛盾</b>:<c>Vehicle03/End</c> 这一布局类里同时配了
/// tpl4=4 与 tpl9=6,而两者的 <c>TemplateName</c>/<c>ViewModelName</c> 完全相同(只差
/// <c>IsRevert</c> 镜像)⇒ 座位数本该一样,<b>必有一个是实测数错的</b>。
/// 代码里取用时按模板 id 升序取第一个并打一条 warn(<c>GameRouter.MetroGadget.cs</c>)。
/// ⛑ 仍缺实测值的模板:10/11(Vehicle01 头)、2/7(中)、3/8(尾)、12/14(CX01 端)、
/// 13(CX01 中)、15(游船)—— 目前全靠「借同车型 / 退化成 6」,座位数不对时客户端会把
/// <b>整节车厢静默丢弃</b>(座位按不出「坐下」)。补法见 <c>GameRouter.MetroGadget.cs</c> 的 warn 文案。
/// ★ The client matches InnerGadgetIds index-for-index against the carriage's own seat slots, so
/// a wrong count here means that whole carriage silently gets no "sit down" button at all.
/// </summary>
public Dictionary<int, int> SeatsByCarriageTemplate { get; init; } = new() { [4] = 4, [5] = 6 };
/// <summary>Seat count used for carriage templates not listed in <see cref="SeatsByCarriageTemplate"/>.</summary>
public int DefaultCarriageSeats { get; init; } = 6;
/// <summary>True = derive one answer entry per carriage from RailTrainConfig. False = flat Carriages x SeatsPerCarriage.</summary>
public bool UseTrainConfig { get; init; } = true;
/// <summary>
/// Base of the invented id range. Gadget instance ids live in the 9.5e12 band (hard rule 23),
/// and the formula below keeps every train/carriage/seat combination unique and stable:
/// id = IdBase + metroId * 10000 + carriage * 100 + seat.
/// </summary>
public ulong IdBase { get; init; } = 9_500_000_000_000UL;
/// <summary>Also fill OuterGadgetIds (things mounted outside the carriage). Off by default.</summary>
public bool IncludeOuter { get; init; } = false;
/// <summary>Log what would be answered but still return the empty list.</summary>
public bool LogOnly { get; init; } = false;
}
/// <summary>
/// Grid window for baked scene items (lampposts, props, breakables, 可破坏/可投掷物).
///
/// 2026-09-24 起这条配置真的接上了实现:服务端按 40m 网格推
/// <c>SyncDestructibleGridAOIDecrease</c>(68969593),客户端从自己的 SceneItemBlob 出货
/// —— 等价于 AnantaWorldMod 注入器,不再需要 frida。见 <c>GameRouter.SceneItemGrid.cs</c>。
/// ⛑️ 之前它只是个空壳:没有代码读它,mid 也用错成 68934309(那条只有 aoiInfo 一个字段)。
/// </summary>
public sealed class SceneItemStreamSettings
{
public bool Enabled { get; init; } = true;
/// <summary>Window half-size in 40m cells (7x7 window at 3).</summary>
public int RadiusCells { get; init; } = 3;
/// <summary>Tighter ring for cells carrying interior sectors.</summary>
public int RadiusInteriorCells { get; init; } = 4;
/// <summary>
/// ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3):
/// 窗口外的格子在离开这个半径(Chebyshev,以当前所在格为中心)时才会被显式卸载。
/// 以前它是空头支票 —— 见下面那段保留的历史注释。
///
/// <para>历史(⛔ 2026-09-28 实测):当时这个值只被 <c>Validate()</c> 的范围校验读一次,
/// 两个网格通道的 <c>removeGridInfo</c> 恒为空,改它不会有任何效果(硬规则 212)。
/// donor 文档 §1/§3/§6 给了完整语义 —— 吃这个值的叫 **卸载边界**:
/// <c>added = window − prevLoaded</c>;<c>keep = prevLoaded 中 ≤ unloadRadius 的那些</c>;
/// <c>removed = prevLoaded − keep</c>;<c>next = window ∪ keep</c>。</para>
/// ⛑ 回滚真卸载:<c>realUnload = false</c>(回到「只增不减」的旧行为)。
/// </summary>
public int UnloadRadiusCells { get; init; } = 6;
/// <summary>
/// ★ 2026-10-01 起**真的生效**:true = 走出窗口但仍留在 <c>unloadRadiusCells</c>
/// 边界内的格子<b>不下发卸载</b>,也不重新加载(donor §3「保留环」)。
/// false = 出窗口即卸载(等价于 unload = radius,物件来回重加载的风险更高)。
///
/// <para>历史(⛔ 2026-09-28 实测):这个键当年是**纯空操作** —— 唯一引用在
/// <c>GameRouter.GadgetGrid.cs</c> 的
/// <c>if (cfg.RetainVisitedCells &amp;&amp; !cfg.LoadAllSectors) budget = Math.Max(budget, 1);</c>
/// 而 budget 本来就是 <c>Math.Max(1, MaxCellsPerPush)</c>,那行恒等于原值(已删)。</para>
/// </summary>
public bool RetainVisitedCells { get; init; } = true;
/// <summary>
/// ★ donor《content-streaming-implementation》§3 —— 真正下发 <c>removeGridInfo</c>。
///
/// <para>关掉它 = 回到 2026-09-28 之前的行为:格子只增不减,走过的永不卸载
/// (客户端驻留对象数一路涨,2026-09-27 §10.9.3 内存项第 3 条)。</para>
/// ⛑ 排查顺序:怀疑卸载把身边物件删掉了 ⇒ 先改 <c>realUnloadLogOnly=true</c>
/// (只打「本来要卸哪些格子」,一条报文都不发),确认卸载集合是窗外的环形再放开。
/// </summary>
public bool RealUnload { get; init; } = true;
/// <summary>只记录将要卸载的格子,不下发报文、也不从已声明集合里摘除(完全等价于旧行为)。见 <see cref="RealUnload"/>。</summary>
public bool RealUnloadLogOnly { get; init; } = false;
/// <summary>
/// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳(两个窗口不相交)时
/// 清空已声明集合并按「首次进窗口」重铺。判据 = 格子 Chebyshev 距离 &gt; unloadRadiusCells。
/// 关掉它 = 回到「Sent 只增不减,远眺留下一堆幽灵格子」的旧行为。
/// </summary>
public bool ReseedOnFarJump { get; init; } = true;
/// <summary>When true, request every authored object in each production cell instead of applying the extracted sector filter.</summary>
public bool LoadAllSectors { get; init; } = false;
/// <summary>
/// ⛔ <b>零消费点 —— 全仓没有任何代码读它</b>(连 <c>Validate()</c> 都不看),
/// true / false 对行为没有任何影响。2026-09-28 核实。留字段只为兼容已落盘的 JSON。
/// </summary>
public bool ExcludeTestItems { get; init; } = true;
/// <summary>
/// ⛔⛔ <b>名字骗人 —— 只被 <c>Validate()</c> 校验 1..200,真正的预算是 <c>MaxCellsPerPush</c>。</b>
/// 推送代码用的是 <c>var budget = Math.Max(1, cfg.MaxCellsPerPush)</c>,
/// 所以改这个值只会改校验结果,不会改每轮发多少格。见硬规则 212。
/// </summary>
public int LoadBudgetCells { get; init; } = 49;
// ---- 以下为 2026-09-24 新增:68969593 网格流的时序参数(与 gadget 网格同构) ----
/// <summary>How often the loop looks at the player's cell (ms).</summary>
public int TickMs { get; init; } = 1000;
/// <summary>
/// Grace period after world-ready before the first Loaded push. The client needs its grid
/// data resident first — the injector waits for the same reason. Kept a little later than the
/// gadget grid (12000) so the two streams don't hit the client in the same frame.
/// </summary>
public int FirstDelayMs { get; init; } = 16000;
/// <summary>
/// Re-declare the window this often even when the player stands still (ms). 0 = off.
///
/// ⛔⛔ 2026-09-30 起本通道默认 <b>0(关)</b>,理由见 <see cref="ResendWindowOnMove"/>:
/// 站桩时整窗重推 = 客户端对已加载格子重新 AddByIndex = **重新实例化** ⇒ 玩家看到
/// 「可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位」。
/// 旧的注释「Symptom this fixes: items appear for a second and vanish」已被 AnantaWorldMod
/// 反证(它从不重推,物件也不消失)—— 那条实证其实是**重推本身造成的闪**,因果写反了。
/// 想恢复旧行为:本键 5000 + <see cref="RefreshMs"/> 120000 + ResendWindowOnMove=true。
/// </summary>
public int HeartbeatMs { get; init; } = 5000;
/// <summary>
/// Full re-send of the surrounding window after this long (ms). Safety net.
/// ⛑ <b>0 = 关闭</b>(GameRouter.SceneItemGrid.cs 里有 `cfg.RefreshMs &gt; 0` 守卫;
/// 没有这个守卫时填 0 会变成「恒真」⇒ 每个 tick 都整窗重推,比心跳还频繁)。
/// </summary>
public int RefreshMs { get; init; } = 120000;
/// <summary>Cells per 68969593 message. One message carries one sector list, never more.</summary>
public int MaxCellsPerPush { get; init; } = 64;
/// <summary>
/// Re-send the WHOLE window on a cell crossing instead of only never-seen cells. The client
/// treats addGridInfo as its current AOI snapshot, not as a delta.
///
/// ⛔⛔ 2026-09-30 起默认 <b>false</b> —— 上面那句「客户端把 addGridInfo 当 AOI 快照」的假设
/// <b>已被参考实现反证</b>:AnantaWorldMod(new-worldmod/world_mod.js:3525-3550)每 700ms 遍历
/// 窗口,`if (loaded.has(key)) continue;` —— **每格只 AddByIndex 一次,从不重推**,物件照样在。
/// 而我们 true 时每次跨格 / 每 5 s 心跳整窗重推 ⇒ 客户端对已加载格子重新实例化 ⇒
/// **物件每 5 秒闪一次、位移 / 破坏状态 / 念力 hold 全部被重置**(用户 2026-09-30 实测症状)。
/// ⇒ 本键 false + <see cref="HeartbeatMs"/>=0 + <see cref="RefreshMs"/>=0 = **严格增量**,
/// 与 AnantaWorldMod 等价:已刷出的格子永不重推。
/// ⛑ 回滚 = 三个键改回 true / 5000 / 120000,不需要动代码。
/// </summary>
public bool ResendWindowOnMove { get; init; } = true;
/// <summary>
/// Push <c>SyncDestructibleSceneAOIActive</c>(68418115) = true once per session before the
/// first grid push. It belongs to the DESTRUCTIBLE family (that is the family this channel
/// is named after), but hard rule 31 records that pushing it around interiors corrupted
/// interior state, so it stays OFF by default.
/// 排障顺序:物品一个都不出 → 先开这个;室内门/店员消失 → 立刻关回去。
/// </summary>
public bool ArmActiveSwitch { get; init; } = false;
/// <summary>
/// 念动力探针(硬规则 28):用 <c>aoiInfo.addInfos</c> 在玩家正前方凭空摆一个可抓 SceneItem。
/// 这是 T1-22 里唯一能**直接验证**服务端显式摆放链路的手段 —— 箱子出现 ⇒ addInfos 这条
/// 路通,家具不出只是缺坐标;箱子都不出 ⇒ 先修通道,别再往坐标表里填数。默认关。
/// ⛑️ 只摆一次(会话级),否则心跳 / 跨格重声明每隔几秒就叠一个箱子。
/// </summary>
public bool SpawnTestBox { get; init; } = false;
/// <summary>
/// 探针用的 SceneitemConfig id。80001877 = 官方测试盒,<c>CanQInteraction=true</c>,
/// 客户端会实例化成真的 SceneItem 并被 <c>MindPowerMgr:GetAimItem()</c> 认成 Q 目标。
/// </summary>
public int[] SpawnTestBoxCfgIds { get; init; } = [80001877];
}
/// <summary>Grid window for client-baked gadgets (lifts, doors, vending machines).</summary>
public sealed class GadgetStreamSettings
{
public bool Enabled { get; init; } = true;
public int RadiusCells { get; init; } = 3;
/// <summary>
/// ★ 2026-10-01 起**真的生效**(donor《content-streaming-implementation》§3):
/// 走出本边界(Chebyshev &gt; 本值,以当前所在格为中心)的格子会被显式卸载。
/// ⛔ 历史:见 <see cref="SceneItemStreamSettings.UnloadRadiusCells"/> —— 2026-09-28 实测时
/// 它只被 <c>Validate()</c> 读一次,本通道的 <c>removeGridInfo</c> 也恒为空(硬规则 212 作废)。
/// ⛑ 回滚真卸载:<c>realUnload = false</c>。
/// </summary>
public int UnloadRadiusCells { get; init; } = 6;
/// <summary>★ 见 <see cref="SceneItemStreamSettings.RetainVisitedCells"/> —— 2026-10-01 起真的生效:卸载边界内的保留环。</summary>
public bool RetainVisitedCells { get; init; } = true;
/// <summary>★ 见 <see cref="SceneItemStreamSettings.RealUnload"/> —— 真正下发 <c>removeGridInfo</c>。默认开;false = 只增不减的旧行为。</summary>
public bool RealUnload { get; init; } = true;
/// <summary>★ 见 <see cref="SceneItemStreamSettings.RealUnloadLogOnly"/> —— 只记不发。</summary>
public bool RealUnloadLogOnly { get; init; } = false;
/// <summary>★ 见 <see cref="SceneItemStreamSettings.ReseedOnFarJump"/> —— 传送 / 远跳重新铺窗。默认开。</summary>
public bool ReseedOnFarJump { get; init; } = true;
/// <summary>When true, request every authored gadget in each production cell instead of applying the extracted sector filter.</summary>
public bool LoadAllSectors { get; init; } = false;
/// <summary>⛔ 见 <see cref="SceneItemStreamSettings.LoadBudgetCells"/> —— 只被 <c>Validate()</c> 校验,
/// 真正的预算键是 <c>maxCellsPerPush</c>。</summary>
public int LoadBudgetCells { get; init; } = 49;
/// <summary>
/// Gadget UniqueIds the client must never instantiate — hard rule 25. Gadget <b>200448</b>
/// ships with a baked sceneItemId of 0; the 4229938 client enters an infinite
/// <c>StateMachineContainer</c> recursion the moment it <c>LoadDynamicItem(200448, 0)</c> and
/// <c>TryGetDjPathHashBySceneItemId</c> fails on "SceneCfg at id 0 is null" — which on a live
/// client looks exactly like a freeze-then-disconnect.
/// The ids are restated on every grid push as <c>addGridInfo.ExceptIds</c> plus
/// <c>aoiInfo.removeIds</c> / <c>aoiInfo.inactiveIds</c>, so the one broken gadget stays
/// dormant while everything else in the same cell still streams normally.
/// (donor reference value: <c>[200448]</c>.)
/// </summary>
public ulong[] ExcludedGadgetIds { get; init; } = [200448];
// ---- Gadget grid streaming (SyncGadgetGridAOIDecrease 68956797) ----
// Replaces the AnantaGadgetLoader exe: the server tells the client which 40 m cells to load
// and the client materializes its own authored props from its GadgetBlob.
/// <summary>How often the loop looks at the player's cell (ms).</summary>
public int TickMs { get; init; } = 1000;
/// <summary>
/// Re-declare the current window this often even when the player stands still (ms). 0 = off
/// (legacy: push only on first load, cell crossings and the periodic refresh).
/// Symptom this fixes: props showed up for a few seconds after a teleport and then vanished —
/// the client appears to drop grid cells the server stops declaring.
/// </summary>
public int HeartbeatMs { get; init; } = 5000;
/// <summary>
/// Grace period after world-ready before the first Loaded push. The client needs its grid
/// data resident first — the injector tool waits ~20 s for the same reason.
/// </summary>
public int FirstDelayMs { get; init; } = 8000;
/// <summary>Full re-send of the surrounding window after this long (ms). Safety net, not the main path.</summary>
public int RefreshMs { get; init; } = 120000;
/// <summary>
/// ★★ 2026-10-04「入世界守卫」—— 首次整窗推送后等这么久,强制做一次 <b>整窗 remove → add</b>
/// (先下发 removeGridInfo 卸掉全部已声明格子,清空 Sent,下一次 tick 以 reason=1 重新铺窗)。
/// 0 = 关闭。<c>realUnload=false</c> 或 <c>realUnloadLogOnly=true</c> 时只清服务端集合、不发卸载报文。
///
/// <para>病因(已用 console-20261003-235851.log 坐实):客户端
/// <c>SyncGadgetGridAOIAddDecrease</c> 只在「格子从不在 AOI 到在 AOI」这一次<b>跃变</b>上实例化物件;
/// 首推时客户端 GadgetBlob 还没常驻 ⇒ <c>SyncGadgetGridAOIAddByBlob</c> 静默 return,
/// 之后 <c>heartbeatMs=5000</c> 的整窗重声明补不回来(4.5 分钟 ~50 次重推,洞不愈合)。
/// 玩家「跑远再跑回来」之所以好,唯一作用就是走出 <c>unloadRadiusCells</c> 触发一次真·卸载,
/// 回来时格子变成新格 ⇒ 重新实例化。这里在服务端自己造一次同样的跃变,玩家不用跑。</para>
///
/// <para>⛑ 只在每个会话做一次(<c>SettleDone</c>);远跳 reseed 已经做过时也算完成。</para>
/// </summary>
public int SettleReseedMs { get; init; } = 15000;
/// <summary>Tighter ring for cells that carry interior sectors (rooms pop in on entry otherwise).</summary>
public int RadiusInteriorCells { get; init; } = 4;
/// <summary>Cells per 68956797 message. One message carries one sector list, never more.</summary>
public int MaxCellsPerPush { get; init; } = 64;
/// <summary>
/// Re-send the WHOLE current window on every cell crossing instead of only the cells the client
/// has never seen. The client appears to treat addGridInfo as its current AOI snapshot: an
/// AOIMove carrying only the newly-entered cells made the previously streamed props vanish one
/// frame after they appeared. Off = legacy incremental behaviour (Sent set filters the window).
/// </summary>
public bool ResendWindowOnMove { get; init; } = true;
/// <summary>
/// RESEARCH LEVER ONLY — default OFF. Pushes SyncDestructibleSceneAOIActive (68418115) once per
/// session before the first grid push.
/// 68418115 belongs to the DESTRUCTIBLE family, not the gadget grid: donor round 14C measured
/// that streaming destructible windows around interiors corrupts interior state (vanishing
/// doors, missing outfit NPC), and round 14E set the switch to disabled. Kept here so the
/// claim stays testable, but it must stay false in normal play.
/// </summary>
public bool SceneAoiSwitchEnabled { get; init; } = false;
}
/// <summary>DynamicGo re-seeding after gameplay-ready.</summary>
public sealed class DynamicGoStreamSettings
{
public bool Enabled { get; init; } = true;
/// <summary>How many times SyncDynamicGoActiveInfo is re-sent (client headers load async).</summary>
public int SeedAttempts { get; init; } = 12;
/// <summary>Activate every DynamicGo id found in the build-4229938 probe.</summary>
public bool ActivateAllKnown { get; init; } = true;
/// <summary>Force explicitly-authored test/temp/debug DynamicGo blockouts inactive.</summary>
public bool ExcludeTestPlaceholders { get; init; } = true;
}
public sealed class Vector3Settings
{
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
internal Vec3 ToVec3() => new(X, Y, Z);
}
/// <summary>
/// One entry of <c>content.roster.rowOverrides</c>. Mirrors the shape the fastpatch generator
/// consumes; the server only needs the id and the optional display label.
/// </summary>
public sealed class RosterRowOverride
{
public uint SpiritId { get; init; }
/// <summary>Panel display name for this row. Empty = keep the FightSpiritConfig name.</summary>
public string Label { get; init; } = "";
}
public sealed class ContentSettings
{
public RosterSettings Roster { get; init; } = new();
public SummonWheelSettings SummonWheel { get; init; } = new();
public uint[] ExcludedUnlockSystems { get; init; } = [];
public bool IncludePhoneBottomApps { get; init; }
}
public sealed class RosterSettings
{
public uint TemplateIdMin { get; init; }
public uint TemplateIdMaxExclusive { get; init; }
public uint[] ExcludedTemplateIds { get; init; } = [];
/// <summary>
/// Roster rows that bypass BOTH roster filters and are force-included even though the normal
/// intake would drop them.
///
/// The stock intake drops a row for either of two reasons (ClientConfigRepository.Load):
/// * it is listed in <see cref="ExcludedTemplateIds"/>;
/// * its icon equals <see cref="GenericPlaceholderIconId"/> (28000032) — the "asset not
/// authored yet" marker, meaning this filter is what really hides unfinished characters.
/// A row named here escapes both, so it joins the roster (login payload spirits, the in-game
/// character lists, and anything the admin switch panel can target) without having to weaken
/// those filters globally.
///
/// Currently used for 15021042 Lemon Sour (AgentId 40140076): a complete playable row
/// (HavePrivateWeapon, WeaponSlot, Quality 3) that shipping never published — IsPublish=false,
/// CanJoin=false, Invisible=true — and that never got a real head icon.
///
/// A listed id escapes the TemplateIdMin..TemplateIdMaxExclusive window as well, which is what
/// lets the 15022xxx "占位" (placeholder) rows join the roster without opening the window for the
/// ~68 dev rows that live up there (test robots, 武学 folders, PVP dummies).
/// </summary>
public uint[] AdditionalTemplateIds { get; init; } = [];
/// <summary>
/// Per-row config surgery, shared with the client fastpatch (the generator turns the very same
/// array into tools/spirit_row_override.lua). The server only reads two of the keys:
/// <c>spiritId</c> and <c>label</c>. <c>cloneFrom</c> / <c>set</c> are client-side only and are
/// ignored here on purpose — the client's own copy of FightSpiritConfig is what the game reads,
/// so the agent swap must happen there, not in the server projection.
///
/// <c>label</c> overrides the name the admin panel shows for that row. Without it a cloned row
/// still carries the placeholder's own name (OC FlightSpirit占位_幼女 and friends), because the
/// server projection keeps the stock AgentId and therefore resolves the stock display name.
/// </summary>
public RosterRowOverride[] RowOverrides { get; init; } = [];
/// <summary>
/// When true (default) a roster row is dropped unless it has a real head icon
/// (<c>SHeadIconID</c> &gt; 0 and not <see cref="GenericPlaceholderIconId"/>).
///
/// <para>
/// Set false to publish rows whose portrait was never authored. They render with the
/// placeholder head on the Z wheel, but the character itself is playable —
/// <c>SHeadIconID == 0</c> means "no portrait authored", not "not playable", so the
/// filter was silently hiding finished characters as if they were locked.
/// (Same knob the 08.05_fix3 "Lifesession" donation build added.)
/// </para>
/// </summary>
public bool RequireIconId { get; init; } = true;
public uint GenericPlaceholderIconId { get; init; }
public ulong UnitIdBase { get; init; }
public int DefaultUrbanJob { get; init; }
/// <summary>
/// Characters offered by the in-game switch wheel (Z), sent through SyncTaskRoleTeam (64534724).
///
/// Accepts either id space — 8-digit numbers are read as NpcCultivationIds (91050014 = Taffy,
/// the ids the player actually knows), anything shorter as a TaskRoleConfig.Id (102000 = Taffy).
/// Both translate to TaskRoleConfig.Id before they go on the wire.
///
/// Empty (default) = never send SyncTaskRoleTeam, leaving the client on its own fallback
/// (the FightSpiritConfig rows with CanJoin=true). Non-empty takes the list over entirely,
/// so anything you want to keep has to be listed.
/// </summary>
public uint[] SwitchableRoles { get; init; } = [];
}
/// <summary>
/// Tab-wheel summoning (嘉姆 / 帮派Boss wheel, client method 63745420 AskSummonGangMember).
///
/// The wheel has exactly seven slots and the client side owns that count, so slots cannot be
/// added. What the server CAN own is what each slot resolves to — and that is what makes this
/// build able to summon characters the stock wheel never offers.
/// </summary>
public sealed class SummonWheelSettings
{
/// <summary>
/// Per-slot redirection: <c>SentId</c> is whatever the client puts on the wire for that slot,
/// <c>TargetId</c> is what actually gets spawned.
///
/// This exists because the id the client sends is not stable or meaningful to the server —
/// measurements showed the same wheel sending either the gang-member row id or the display
/// row's AgentQuoteId depending on build, and neither necessarily names a spawnable character.
/// Mapping it server-side means the answer stops depending on guessing the client's shape.
///
/// TargetId accepts the same three id shapes as SpawnNpcAsync: a FightSpirit id (1502xxxx),
/// a cultivation id (9105xxxx), or an owning Agent id (40140041 = Ringo). Empty = pass the
/// id through unchanged, which is the reference branch's behaviour.
/// </summary>
/// A settable array rather than init-only so a slot list could be swapped at runtime; in
/// practice this is edited by hand in config/private-server.json (the panel card that used to
/// edit it was removed), which means a restart is required for changes to take effect.
public SummonSlotOverride[] SlotOverrides { get; set; } = [];
}
public sealed class SummonSlotOverride
{
public uint SentId { get; init; }
public uint TargetId { get; init; }
/// <summary>Optional note; only appears in console logs.</summary>
public string Label { get; init; } = string.Empty;
}
public sealed class GameplaySettings
{
/// <summary>
/// ★ 可玩角色「常驻大世界」:进世界后,名单里每个角色按<b>它自己的日程</b>站在城里对应的位置
/// (Richie 在跑步机、Garm 在娃娃机 …),不用先切走再切回。
/// 机制与 <c>Aether.FunctionalNpcsEnabled</c> 完全同款(跟随循环 + 进入/离开迟滞),
/// 差别只有坐标来源:这里取 <c>AgentDataSetsActivityConfig.MapPosition</c>。
/// </summary>
public RosterNpcSettings RosterNpc { get; init; } = new();
/// <summary>
/// ★ Plate(盘)演出管线试点 —— <c>gameplay.plate</c>。
/// 角色光站在点位上只会发呆玩手机;要让里希真的去跑跑步机,服务端必须推
/// <c>SyncPlateAddList</c>(68554224) 把「盘」交给客户端(硬规则 157)。
/// ⛑ 客户端 reader 在 C# 侧(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly),
/// 线格式只能靠试 ⇒ 这几组 <c>*Encoding/*Marker</c> 开关就是给实机 A/B 用的,
/// 改配置不用重编译(每次推送时现读)。
/// </summary>
public PlatePilotSettings Plate { get; init; } = new();
/// <summary>
/// ★ 生活日程「正式通道」—— <c>gameplay.npcLife</c>。
/// 比 Plate 更靠前的一层:官方让一个好感 NPC 真的去跑跑步机,靠的是
/// <c>SyncFavorNpcSpoonAgentId</c>(64003213)(「这个 agentTag 现在占着几号 spoon 位、站哪儿」)
/// 以及登录帧 <c>FavorNpcDailyScheduleInfos</c> 里的
/// <c>CurrentSpoonAgentId</c> / <c>SpoonPosition</c> / 每段的 <c>SpoonAgentId</c>。
/// 我们以前这三处全填 0(见 <c>RuntimePayloadFactory</c>)⇒ 客户端只有活动 id,
/// 没有「人该站到哪个 spoon 节点上」⇒ 只能播默认 ambient(站着玩手机)。
/// ⛑ spoon 位号到底是什么值还没有官方表(客户端 Spoon 资产里),所以做成旋钮逐项试。
/// </summary>
public NpcLifeSettings NpcLife { get; init; } = new();
/// <summary>
/// ★ 按地块铺市民(T0-8)的 <b>POI 二次筛选</b>(2026-10-02 新增,
/// 依赖 <c>UrbanDiversityPOIActionConfig.json</c> 全字段表)。
/// 两个开关<b>默认全 false</b> = 完全保持今天的行为,打开才会用全字段表过滤候选池。
/// </summary>
public CivilianPoiSettings CivilianPoi { get; init; } = new();
/// <summary>
/// 官方等待区站位(2026-10-02 新增,配置键 <c>gameplay.civilianWaitArea</c>)。
/// 数据来源 = <c>paths.pedSpawns</c>(<c>zonegraph_ped_spawns.csv</c>,4070 槽 / N 个等待区),
/// 消费点 <c>GameRouter.Civilians</c>。⛑ <c>Enabled</c> 默认 false ⇒ 不改现有行为。
/// </summary>
public CivilianWaitAreaSettings CivilianWaitArea { get; init; } = new();
/// <summary>
/// ★ 钓鱼播种 —— <c>gameplay.fishingSeedAllGear</c>。
/// 登录帧 <c>InfoMinor.FishingInfo.GearDict</c> 是否整套播种 34 件渔具
/// (<c>FishingGearConfig.json</c> 全表)。只在 <c>GearDict</c> 为空时才播,
/// 所以已经在官方服拿过渔具的存档不会被覆盖。
///
/// <para>为什么值得做:捐赠文档 §8 说 <c>GearDict</c> 在客户端 lua / C# 都无人读取、
/// 「往里塞数据零可见效果」—— 那条结论说的是 <b>字典内容</b>不被消费,
/// 不等于 <c>PlayerFishingInfo</c> 这个结构体空着无所谓。客户端初始化链
/// (<c>FishingGameFishMgr.container</c> / <c>FishingGameEffectMgr.objPool</c> /
/// <c>FishingGameRod.animator</c>)实测三连 nil,本文档作者的环境里播种是开着的。
/// ⇒ 这是一次性把"服务端少给了一块"这个变量消掉的开关,关掉即可回到原状。</para>
/// </summary>
public bool FishingSeedAllGear { get; init; } = true;
/// <summary>
/// ★ 第 50 轮(自 donor v2.1 移植,2026-09-29)—— **室内层判定门**(`gameplay.indoorVisibilityGate`)。
///
/// <para>作用:摆放派对场地家具(<c>PartyCamp</c>)时,若某件家具落在
/// 「<c>VisibleOutside = false</c>」的室内占地里、而玩家不在其中 ⇒ **本件不摆**。
/// 标本:舞会场地(danceOnly)的两件常驻机关(Gadgets_Party_DanceDoor 514708797 /
/// Gadgets_Party_LightControl 1341214683)在 IndoorConfig#150 "Spiko Hotel" 的占地里,
/// 我们却把它们摆到露天 y≈80 ⇒ 现场「道具在半空、地板不在」。</para>
///
/// <para>⛑ 本门**只**作用于 PartyCamp 这一条链,不碰市民 / 机关网格 / 场景物等其它刷法
/// (donor 实测:露营点 / nova-spawn / 保龄球馆 / 公寓车库入口都不在任何室内矩形内)。</para>
///
/// <para>回滚:<c>gameplay.indoorVisibilityGate = false</c>。</para>
/// </summary>
public bool IndoorVisibilityGate { get; init; } = true;
/// <summary>
/// 播种渔具的 <c>RemainingDurability</c>(<c>gameplay.fishingGearDurability</c>)。
/// ⛑ 小游戏本身用的是 <c>lineStaminaMax</c>(1000),<b>不读</b>这个字段
/// (捐赠文档 §8「渔具耐久字段无人使用」)。填它只是为了结构体完整、不留 0 值。
/// </summary>
public int FishingGearDurability { get; init; } = 100;
/// <summary>
/// Hacker kit shape for characters authored with job 401 (Seymour / 赛默迪斯科球人):
/// · "full" (default) — all 11 capability buffs (incl. 蜘蛛机器人 52606149 / 黑客无人机
/// 52606150), the 6/6 battery, and the EonBug app's BBS payload (SyncHackerJobInfo).
/// · "safe" — the proven round-1 trio (52606133/167/168), no battery, no BBS payload
/// (middle-mouse GadgetManager entry stays "visible but grey").
/// Round-1 measurements showed the wider sets can hide the middle-mouse hack entry while the
/// EonBug APP path keeps working; flip this to "safe" to restore the baseline without a rebuild.
/// </summary>
public string HackerAbilitySet { get; init; } = "full";
/// <summary>
/// Ability buffs the official server grants by spending talent points on the Hacker talent tree
/// (<c>TalentTreeTalentConfig</c>, tree 99900010 — the only tree whose nodes carry 526xxxxx
/// ability buffs). Published on top of the job-401 kit so Seymour has them without climbing the
/// tree, because the talent pipeline itself is not implemented (see T1-6).
/// Only ever handed to a character whose <c>FightSpiritConfig.DefaultUrbanJob</c> is 401.
/// Empty array disables the extra layer entirely.
/// </summary>
public uint[] HackerTalentBuffIds { get; init; } = [];
/// <summary>
/// Shape of the runtime <c>SyncSpiritJobInfo</c> grant, i.e. which phone apps a character gets:
/// · "single" — only the character's authored <c>FightSpiritConfig.DefaultUrbanJob</c> rank, and
/// Jobless characters get no record at all (the round-1 baseline). Ceiling is 3-4 apps,
/// because only 4 of the 8 authored careers map onto a class that gates an app.
/// · "multi" — additionally register one rank for every job <b>class</b> that gates a
/// phone app, so all 9 career-locked apps appear on every character.
/// <c>MainPhoneUtils.CheckJobCanShow</c> walks the whole <c>AvailableJobs</c> dictionary
/// (<c>SpiritJobManager.GetCurSpiritAvailableJobIdList</c>) and matches
/// <c>UrbanJobConfig.JobClass</c>; it never reads <c>CurrentJob</c>, Level or Exp, so the
/// extra ranks are free and unlock in parallel.
/// Since this defeats the per-character exclusivity the apps were authored for, "single" is
/// the default — flip to "multi" only to survey what the apps look like.
/// Per-character additions on top of either mode: <see cref="UrbanJobOverrides"/>.
/// </summary>
public string UrbanJobGrantMode { get; init; } = "single";
/// <summary>
/// <c>SpiritJob.Level</c> / <c>SpiritJob.Exp</c> published in <c>SyncSpiritJobInfo</c>:
/// · "max" (default) — publish the top authored level of the rank's job class, plus the exp
/// needed to stand on it, straight out of <c>UrbanJobLevelConfig</c>. The cap differs per
/// class: Cat Express 24 · gang 20 · police 16 · tea farmer 10 · hacker 3 · most others 3.
/// Classes with no level table at all (chef 11300020, 11300016, 11300018) stay at level 1,
/// because <c>SpiritJobManager.GetLevelCfg</c> would resolve an invented level to nil.
/// · "authored" — the 7k baseline, level 1 / exp 0 on every record.
/// ⛑️ This affects content panels only (farmer inventory caps, the "Lv.N" labels in the
/// cleaner/washer stores). Phone-app icons never read Level or Exp.
/// </summary>
public string UrbanJobLevel { get; init; } = "max";
/// <summary>
/// Where a character stands on its <b>promotion chain</b> (<c>UrbanJobConfig.PreJob</c>, e.g.
/// 401 -> 402 -> 403 for the Hacker career, each step gated by a <c>UrbanJobConfig.Level</c>
/// threshold — police 301/302/303 need job level 1/5/10):
/// · "top" (default) — publish every rank of the career and put the character on the highest
/// one, i.e. the shape it would reach after finishing the chain by hand.
/// · "authored" — publish only the single rank <c>FightSpiritConfig.DefaultUrbanJob</c> names.
/// ⛑️ This is the other half of "no extra ability appeared": promotion changes rank, not power.
/// Rank and career really only drive the career UI and the phone-app gate, and both key off the
/// shared <c>JobClass</c>, so no new app appears either. Capabilities come from
/// <c>CharacterCapabilities</c> / <c>UrbanBadgeConfig</c> — see T0-12.
/// </summary>
public string UrbanJobRank { get; init; } = "top";
/// <summary>
/// Where a character's <b>capability buffs</b> come from:
/// · "auto" (default) — additionally publish every buff listed on an <c>UrbanBadgeConfig</c> row
/// whose <c>JobId</c> is a career the character is published with. This is the data-driven
/// path: give any character job 501 (say, via <see cref="UrbanJobOverrides"/>) and it receives
/// Cat Express's <c>52606163</c> Acceleration Mastery without touching a line of C#.
/// ⛔ OnlyServer rows are excluded at projection time, which also keeps the two negative
/// debuffs (52606230 / 52606231) out of the set.
/// · "off" — capabilities stay exactly what <c>CharacterCapabilities.BuffIds</c> hard-codes.
/// Only careers 7 / 401 / 501 carry any buff today; jobs 301/302/303 (police) are all OnlyServer
/// or empty, so Richie gains nothing visible — that is authored data, not a bug.
/// </summary>
public string JobBadgeBuffs { get; init; } = "auto";
/// <summary>
/// 潜水(Diving)解锁 —— 让<b>所有角色</b>都能下潜,而不是只有水面游泳。
/// · "on"(默认)— 进世界后服务端自动做两件事:① 通过
/// <c>SyncRunGmUtils</c>(66042045) 让客户端反射执行 <c>GmUtils</c> 的潜水开关方法
/// (默认 <c>EnableDive</c>,可换成 <c>ForceDive</c> 走强制路径);
/// ② 把 <see cref="DivingSuitBuffId"/> 挂进该角色的 <c>SyncUnitBuffList</c>,解决氧气。
/// · "off" — 两件都不做,回到原始行为(只能游泳)。
///
/// 为什么是两件:潜水有<b>两道彼此独立的门</b>,缺一个都潜不下去。
/// ① 门控(native)— <c>DiveManager.CheckIsEnableDiving()</c> 读静态 <c>enableInGamePlay</c>,
/// 服务端<b>没有</b>任何专用 S2C 能开它;66042045 是官方的远程 GM 反射通道
/// (<c>GameGMToClientImpl.SyncRunGmUtils</c>,RVA 0x12752F10 真实现),
/// 客户端按 methodName 反射调用 <c>LX6.Utils.GmUtils</c> 的静态方法。
/// ② 氧气 — 下潜会掉氧气,耗尽被顶回水面(<c>DiveLackOxygenCfg</c>)。
/// <c>52606233 潜水服buff</c> 的官方描述就是「不会掉氧气,且会更快的恢复氧气」,
/// 挂上它就不必再维护一条氧气 tick(省掉 <c>SyncPlayerOxygenSystemState</c> 68251202
/// / <c>SyncPlayerCurrentOxygenValue</c> 68717877 的周期推包)。
/// ⛑️ 未实测:GmUtils 的有效方法名集合未知,<c>EnableDive</c> 是否被反射命中只能实机验证。
/// 若开了仍潜不下去,把 <see cref="DivingGmMethods"/> 改成 ["ForceDive"] 再试。
/// </summary>
public string DivingMode { get; init; } = "on";
/// <summary>
/// <see cref="DivingMode"/>="on" 时挂进角色 buff 列表的潜水服 buff。
/// BuffConfig 52606233「潜水服buff」= 不会掉氧气,且会更快的恢复氧气。
/// </summary>
public uint DivingSuitBuffId { get; init; } = 52_606_233;
/// <summary>
/// 白靖(15021039)时缓的<b>第二条触发</b>:把 <c>TryStartBaiJingTime4229938</c> 也挂到当前流派的
/// <c>ActiveSkill</c>(E 键)上 —— 这就是捐赠端 7k-v0.5 <c>GameRouter.Combat.cs:72-79</c> 的做法。
///
/// 默认 <b>false</b>(2026-09-27 用户拍板:走原生驾驶 HUD,不要 7K 自己加的那条)。
/// ⛑ 2026-09-27 14:2x 实机:切到白靖 → 手机叫车 → 上 <b>Veloce·Quicksilver</b>
/// (VehicleConfig 81005007,VehicleType=5,走 <c>DriveControlDriverStore</c>,
/// 也就是<b>唯一带 PROFESSION_SKILL 按钮位</b>的那个驾驶 HUD)
/// ⇒ 驾驶 HUD 上没有出现职业技能按钮,尽管 52959495 确实在 <c>SyncUnitBuffList</c> 里
/// (packets 日志 14:27:25 entityId=100000000021 那条,InstanceId 1200067)。
/// 原生判据就一行 <c>ShowProfessionSkillButton = HasBuff(Pid, BuffConfig.TimeScaleBadgeBuff)</c>,
/// 所以要么那个常量在本版本解析不出来,要么它指的不是 52959495。
/// ⇒ 若进一步排查确认原生这条在本客户端版本不通,把本开关置 <b>true</b> 即可立刻用上,
/// <b>不用重新编译</b>。
/// </summary>
public bool BaiJingTimeBindActiveSkill { get; init; } = false;
/// <summary>
/// 排查 <c>BuffConfig.TimeScaleBadgeBuff</c> 真值的应急旋钮:这里列的 buff 会<b>额外</b>挂进
/// 白靖的常驻 <c>SyncUnitBuffList</c>,用来做 A/B(改配置即可,不用重新编译)。
/// 默认空。候选(按可能性排序,都出自 <c>UberSimConfig</c> 那套网约车/货车玩法):
/// · 52980302「货车职业徽章19001323(子弹时间持续时间提高)」—— TimeStopPara 的第二条;
/// · 52980312 / 52980313 / 52980314 —— UberSimConfig.AddAcceptBuffList(接单 buff);
/// · 52959496「斯安威斯坦」(5 秒状态本体)—— 不太可能(按钮得在<b>没</b>激活时显示),
/// 但若游戏把它当"能力已解锁"标记也说得通。
/// ⛑ 前两个已经是白靖常驻的 52959495 / 52606175,别重复填。
/// </summary>
public uint[] BaiJingTimeScaleProbeBuffs { get; init; } = [];
/// <summary>
/// 梅卡羊生成帧的 <c>SyncUnitOccur.occurId</c>。<b>默认 0</b>(与 GM 面板那次成功生成一致)。
/// 70000302 是 <c>AgentConfig 40980002.EnemyOccurId</c>,留作排查"模型透明"时用。
/// ⛑ 2026-09-27 实机:GM 面板生成 40980002 时日志是 <c>occur=0</c>(<c>EnemyAiCatalog</c>
/// 没有这一行,<c>SpawnAgentAsync</c> 只能填 0),结果是「单位在、能出攻击特效,
/// 但<b>模型透明</b>」⇒ 怀疑出场演出(淡入 / 材质)没跑。改成 70000302 验证。
/// </summary>
public uint MechanikaSheepOccurId { get; init; } = 70_000_302;
/// <summary>
/// 梅卡羊生成帧的 <c>AgentSyncClientInfo.treeName</c>。
///
/// ⛑⛑ <b>默认已从 <c>MeikaShip</c> 改成 <c>Main_Common</c>(2026-09-27 15:3x)。</b>
/// 四次实机的对照表(唯一始终跟着"不出场"走的变量就是这棵树):
/// | 通道 | uid 段 | tree | occur | camp | 结果 |
/// |---|---|---|---|---|---|
/// | 手写帧 ×2 | 0x3000… | **MeikaShip** | 0 | 0 | 不出 |
/// | spawnAgent + activate | 0x2000…(35184372188833) | **MeikaShip** | 70000302 | 0 | 不出 |
/// | GM 面板 | 0x2000…(35184372188834) | Main_Common | 0 | 4 | **出场(模型透明)** |
/// ⇒ uid 段已证伪(两次 uid 段不同、一次相同,都不出);occur 两边都取过值,排除;
/// engage 批次补了 battleAiS 也不出。**剩下 <c>MeikaShip</c> 是 3/3、<c>Main_Common</c> 是 1/1。**
/// 机制上也说得通:`GameAdminBridge.SpawnAgentAsync` 那条注释写过
/// "An empty tree name or AISetting 0 leaves the client <b>unable to resolve a behaviour
/// tree</b>" ⇒ 这版客户端很可能<b>根本没有 MeikaShip 这棵树</b>(梅卡羊是被砍掉的内容,
/// 模型 86952527 也是透明的),解不出树 ⇒ 单位不初始化 ⇒ 不渲染。
/// </summary>
public string MechanikaSheepTreeName { get; init; } = "Main_Common";
/// <summary>
/// 梅卡羊走哪条生成通道:
/// · <b>"spawnAgent"</b>(默认)— <c>GameAdminBridge.SpawnAgentAsync</c>。
/// uid 落在 <c>0x200000000000+100000</c> 段(GM 面板生成 40980002 实测 35184372188833,
/// <b>能出场</b>),包序列与 AgentCatalog 校验都是已验证的那套。
/// · "handwritten" — 保留的手写帧(uid 落在 <c>0x300000000000</c> 段)。
/// ⛑ 换通道没能解决问题(uid 与 GM 完全相同仍不出),见 <see cref="MechanikaSheepTreeName"/>。
/// ⛑ 2026-09-27:手写帧服务端日志一直是 Ok=True、包也发了,但客户端<b>完全不渲染</b>;
/// 同一模板走 SpawnAgentAsync 却能出场 ⇒ 怀疑就是 uid 段。默认已切到 spawnAgent,
/// 留这个开关只为对照。
/// </summary>
public string MechanikaSheepSpawnPath { get; init; } = "spawnAgent";
/// <summary>
/// 羊用的 agent 模板。
///
/// ⛑⛑ <b>默认已从 <c>40980002</c> 改成 <c>40120651</c>(2026-09-27 15:5x)。</b>
/// 决定性取证(<c>packets-20260927-155019.log</c> 15:52:55–15:53:15,uid=35184372188833):
/// · <c>AskInteractCmd CmdType=33/34 … stringParam1="35184372188833"</c> ← 客户端在跟它交互
/// · <c>AskUnitMoveActionSimple {UnitId:35184372188833, Pos:…, Rot:…}</c> ← 客户端在给它上报移动
/// · <c>AskClientUseCommonSkill … TargetId=35184372188833</c> ← 玩家拿它当攻击目标
/// ⇒ **羊一直都在**:单位被注册、能被锁定、能被攻击、客户端还在驱动它移动。
/// 它唯一的毛病是**模型 86952527 在这版客户端里是透明的**(GM 面板生成同一模板也是
/// "只有攻击特效、看不见模型",用户实机原话)。
/// ⇒ 前面三轮查的 uid 段 / engage 批次 / 行为树**全都查错了方向** —— 那些只影响"行为",
/// 不影响"存不存在"。真正的 blocker 从头到尾就是**模型透明**。
///
/// 候选(<c>AgentConfig.json</c>):
/// · **40120651「羊」模型 86952761** ← 有模型,现默认。代价:行为树是
/// <c>Behavior_EmptyBehavior</c>(`Behavior_Battle` 空串)⇒ 不会自己走,靠我们 follow 推包。
/// · 42090155「羊」同模型 86952761。
/// · 40980002 / 40120907「梅卡羊」= 原生那个,但模型 86952527 **透明**,不要再默认。
/// ⛑ 换模板的代价:客户端 <c>IsHaveEnemyByDistance(40980002, 10)</c> 认不到这只羊,
/// 但我们服务端的 10 米判定用的是自己记的坐标,不受影响。
/// </summary>
public uint MechanikaSheepAgentId { get; init; } = 40_120_651;
/// <summary>
/// 羊的阵营(<c>SyncAgentCampInfo.camp</c>)。默认 <b>0</b>(Unknown / 市民,硬规则 132:
/// 友方 NPC 一律 0)。改成 <b>4</b> 就是敌对阵营(GM 面板生成 40120651 时就是 4,
/// 用户实机反馈"会攻击我")。排查"羊不出现"时可试着切 4 做对照。
/// </summary>
public uint MechanikaSheepCamp { get; init; } = 0;
/// <summary>
/// 生成之后要不要补"激活批次"。⛑⛑ 这是 2026-09-27 找出<b>最关键的一处差异</b>:
/// GM 面板生成 40980002 能出场,我们 E 触发的<b>同一个模板、同一个 uid(35184372188833)
/// 却不出场</b>——uid 段假设被证伪,剩下的差异里只有这个是"客户端行为"级别的:
/// · <b>"battleAiS"</b>(默认)— 只发 <c>SyncBattleAiS(uid, true)</c> + <c>SyncEnemyDetect</c>,
/// 1.5 s 后重发一次。<c>GameRouter.LocalEnemyCombat.cs</c> 注释写明:
/// "native side flips BaseUnit.set_IsBattleAiS here, and that is what moves the unit
/// off its idle/ambient tree onto CombatBehaviorTree" ⇒ 不发它单位就<b>停在 idle 树</b>。
/// ⛑ 不发 <c>SyncUnitLockTarget</c> / <c>SyncEnemyFightEdict</c> ⇒ **羊不会锁定也不会攻击玩家**。
/// · "full" — 走 <c>GameRouter.EngageSpawnedEnemyAsync</c>(= GM 面板那套,含锁玩家,
/// **羊会攻击玩家**),只在排查时用。
/// · "none" — 一个都不发(旧行为,已实机证明不出场)。
/// </summary>
public string MechanikaSheepActivate { get; init; } = "battleAiS";
/// <summary>
/// 生成之后要不要把羊头顶那根<b>血条</b>收掉(默认 true)。
///
/// ⛑ 用户 2026-09-27 实机:「羊头顶上有个血条,不像机械狗这样不显示血条,是不是羊默认不是友方」。
/// 排查结论 —— <b>跟友方/敌对无关</b>,血条第一判据是模板的 <c>AgentConfig.ShowHPBar</c>:
/// · 40120651「羊」ShowHPBar=<b>True</b>
/// · 机械狗 40120911 的 ShowHPBar 也=<b>True</b>,但它不显示血条
/// ⇒ 差在第二个判据:机械狗走 <c>GameRouter.HackerSummon.cs</c> 的
/// <c>SyncAddUnitState(state=10266 /*UnitStateConfig.HideHp*/)</c>。
/// 配置表是客户端压缩二进制池,服务端改不了 ShowHPBar ⇒ 只能写 UnitState。
/// 阵营那条路已经是对的(<see cref="MechanikaSheepCamp"/>=0,且从不发 SyncForceEnemyRelation)。
/// </summary>
public bool MechanikaSheepHideHpBar { get; init; } = true;
/// <summary>
/// <b>梅卡妮卡 E 键改成哪个技能</b>(<c>SyncChangeActiveSkill</c> 下发给客户端的那个 id)。
/// 默认 <b>51939201</b>「混厄召唤师召唤精灵1」;置 0 = 不改写(回到流派 ActiveSkill
/// 51943205 重击 —— 能出羊,但按 E 会先出一记重击)。
///
/// ⛑⛑ <b>51937721 已实机证伪(2026-09-27 16:4x),不要再试。</b>
/// 下发 51937721 后客户端<b>一个 AskUseSkill 都没发</b>(packets 里 51937721 只出现 1 次,
/// 就是我们下发的那一帧)⇒ 重击没了、羊也没了。根因:
/// · E 槽(ActiveSkill)只认 <c>SkillCastTypeTag = 6</c>。
/// · 51943205「街头格斗_重击」tag=<b>6</b>;51937721 / 51937722 / 51934941 tag 全是 <b>2</b>。
/// · 全表 5536 个技能里名字含"念力/梅卡/羊"的**没有一个 tag=6**(全是 0 或 2)
/// ⇒ 这版客户端里"念力"根本不是可释放的根技能,只能从跳转图里被替换出来。
/// ⇒ 结论:<b>梅卡妮卡的羊在这版客户端里没有任何原生 E 入口</b>,捐赠端 v0.5 的 E 绑定
/// 是他自己硬加的。想要"E 出羊且没有重击动作",只能借一个 tag=6 的召唤技能当壳子。
///
/// 现选 51939201 的理由:全表 492 个 tag=6 技能里,只有「混厄召唤师召唤精灵1~4」
/// (51939201–51939204)是<b>召唤类</b>;其余全是挥击/突进/重击。它不在任何 FightSkillConfig
/// 行里(敌人向技能),动画资源可能缺失 ⇒ 客户端可能不播动作或 T-pose,但只要它<b>肯发
/// AskUseSkill</b>,服务端就能接住并召羊。这也是"没有重击动作"的唯一可行路径。
/// ⛑ 若 E 又变哑(不发任何包)⇒ 置 0 回到"重击 + 兜底召唤"(那条 100% 能出羊)。
///
/// ⛑⛑ 为什么必须改写:客户端 E 键放的就是 <c>SyncChangeActiveSkill</c> 下发的那个技能
/// (<c>GameRouter.Profiles.cs</c> 的 PublishSkillBindings;实机客户端回的正是 51943205)。
/// 我们一直给梅卡妮卡下发 Street Brawler 的 ActiveSkill 51943205「街头格斗_重击」
/// ⇒ 按 E 就是一记重击,然后服务端兜底再召羊 ⇒ 用户看到的"先重击再出羊"。
///
/// 51937721 的价值:它的 <c>SkillReplace</c> = [97011851, 97011078],客户端 SkillJumpManager
/// 会自己判:
/// · <c>97011851</c>「切换为召唤梅卡羊」<c>!IsHaveEnemyByDistance(40980002, 10)</c> ⇒ 跳 51937722(召唤)
/// · <c>97011078</c>「原地念动」<c>return true</c> ⇒ 跳 51934941(念力砸击)
/// ⇒ 双态在<b>客户端</b>完成,重击动作根本不会出现。服务端只要认这三个 id 即可。
///
/// ⛑ 未证伪的风险:<b>没有任何 FightSkillConfig 行把 51937721 当 ActiveSkill,也没有任何
/// FightSkillConfig 行把 15021017 写进 SpiritId</b>(2026-09-27 全表 grep)。
/// 也就是说「官方就是 E 触发」这件事<b>没有配置表证据</b>,只有 SkillReplace 的语义证据。
/// 下发后客户端若不认 ⇒ E 变成空放。回滚:把这项置 0。
/// </summary>
public uint MechanikaSheepActiveSkillId { get; init; } = 51_939_201;
/// <summary>
/// <b>一次 E 放几只羊</b>。默认 <b>3</b>(左前 / 右前 / 背后)。
/// ⛑ 落点角度见 <see cref="MechanikaSheepSpawnAngles"/>;本值 &lt;=0 时取角度数组的长度。
/// </summary>
public int MechanikaSheepPerCast { get; init; } = 3;
/// <summary>
/// 落点相对玩家<b>朝向</b>的角度(<b>度</b>),默认 <c>[45, -45, 180]</c> = 左前 / 右前 / 背后。
/// 落点距离固定 2.5 m(<c>GameRouter.MechanikaSheep.cs</c> 的 <c>MechanikaSheepDropMeters4229938</c>)。
/// ⛑ 硬规则 181:这些是度数,代码里会先 × π/180 再喂 sin/cos。
/// </summary>
public int[] MechanikaSheepSpawnAngles { get; init; } = [45, -45, 180];
/// <summary>
/// 场上<b>同时存活的羊上限</b>。默认 <b>0 = 不限制</b>(2026-09-27 用户要求解除上限)。
/// ⛑ 不限制意味着连按 E 可以无限堆 —— 若发现卡顿/客户端吃不消,填一个数字即可,不用改代码。
/// 切角色 / 切场景时会整批 <c>SyncLogicAgentLeave</c> 召回(<c>StopMechanikaSheep4229938</c>)。
/// </summary>
public int MechanikaSheepMaxAlive { get; init; } = 0;
/// <summary>
/// <see cref="DivingMode"/>="on" 时依次下发给客户端执行的 <c>GmUtils</c> 方法名。
/// 默认只跑 <c>EnableDive</c>;实测无效时改 <c>ForceDive</c>(绕过条件检查),
/// 水下滤镜是 <c>SetUnderWaterFilter</c>(可与前者并列,例如 ["EnableDive","SetUnderWaterFilter"])。
/// </summary>
public string[] DivingGmMethods { get; init; } = ["EnableDive"];
/// <summary>
/// 传给 <see cref="DivingGmMethods"/> 每个方法的参数列表,默认 ["true"]。
/// ⛑⛑ 必须<b>小写</b>:客户端 <c>GmUtils.RunGmUtilsCommand</c> 用 Newtonsoft.Json 解析每个
/// 参数字符串,传 "True" 会抛 JsonReaderException(Unexpected character ... value: T)
/// 并<b>整条指令丢弃</b> —— 2026-09-22 21:52 实测踩过(日志见 Player.log 的
/// "GM指令 EnableDive 参数解析错误 arg=enable argString=True")。
/// </summary>
public string[] DivingGmArgs { get; init; } = ["true"];
// ⛔ 载具 GM(vehicleGmEnabled / vehicleGmMethods / vehicleGmArgs)已于 2026-09-26 23:5x **整块删除**。
// 起因:默认那条 `SetUnlimitedNitrogen` 实测 = 刹车失效 + 下车后车仍持续加速,且它是客户端进程内
// 状态、服务端重启清不掉(用户被困在失控的车里,只能手发 args=["false"] 或重启客户端)。
// 关开关不够(残留状态还在),所以代码、配置、json 键三处一起删干净。
// 以后真要再开:别用 `SetUnlimitedNitrogen`,走纯数值指令
// `SetVehicleTopSpeed` / `AddVehicleSpeedBuff` / `SetHelicopterSimpleMode`(不劫持输入、无常驻状态)。
/// <summary>
/// Extra urban jobs granted to individual characters, keyed by spirit template id
/// (<c>"15020967": [401]</c>). The key is a string because System.Text.Json only deserializes
/// string dictionary keys out of the box.
///
/// This is the escape hatch for "exclusive, but I want this one character to have that app":
/// it hands career-locked apps to a character with no authored job (or the wrong one) without
/// switching the whole server to "multi". Job reference:
/// 401 = EonBug · 301 = Duty Terminal <i>and</i> Mimic Database · 501 = Cat Express ·
/// 801 = Chaos Tide · 1201 = Top Cleaning · 4 = VWT · 1901 = 茶农 · 2000 = 重霄食谱.
/// </summary>
public Dictionary<string, uint[]> UrbanJobOverrides { get; init; } = new();
/// <summary>
/// 单个角色的「<b>当前</b>职业」覆盖,key 同样是 spirit template id(<c>"15021039": 4</c>)。
/// 与 <see cref="UrbanJobOverrides"/> 的区别:那个只往 <c>AvailableJobs</c> 里<b>加</b>职业
/// (<c>CurrentJob</c> 保持 <c>FightSpiritConfig.DefaultUrbanJob</c>),这个直接改 <c>CurrentJob</c>。
///
/// 为什么要单独一个键(2026-09-27,白靖):
/// · <c>FightSpiritConfig 15021039.DefaultUrbanJob = 100</c>「无业」,而
/// <c>SkillResourcesConfig Id=4</c> 写的是「赛车手职业时缓次数」、BuffConfig 52959495/52959496
/// 是斯安威斯坦 —— 原生这套能力属于<b>赛车手</b>职业(UrbanJobConfig 4/5/6,JobClass 11300002)。
/// · 捐赠端 7k-v0.5 用硬编码 <c>CharacterProgression4229938.ExclusiveClassBySpirit[15021039] = 11300002</c>
/// 把 CurrentJob 顶成 4。本服不硬编码角色 id ⇒ 走这份配置。
/// ⛑ 只填真的要改当前职业的角色;有自带职业的(里希 301 / 塔菲 501)别填,会顶掉作者设定。
/// </summary>
public Dictionary<string, uint> UrbanJobCurrentOverrides { get; init; } = new();
/// <summary>
/// T0-4: the int published into <c>PlayerClientInfoSpirit.InfoPolice.TodayCompleteMissionCnt</c>
/// (spirit data). <c>PlayerInfoSpiritData.lua:75-76</c> copies it straight into
/// <c>gPoliceJobManager.todayMissionCnt</c>, which the police phone panel renders as
/// "今日完成 N 单" (PoliceJobManager.lua:139/170/197/221) and re-broadcasts over
/// <c>POLICE_SYNC_MISSION_COUNT</c>.
///
/// ⛑️ The field was never missing: <c>PlayerClientInfoPolice</c> is a reflected
/// <c>[UxContract]</c> nested in <c>PlayerClientInfoSpirit</c>, so the login payload has always
/// carried it — with value 0. This knob only seeds a number so the panel is not stuck at zero;
/// the server has no police mission system yet (that is T4-9), so any non-zero value is cosmetic.
///
/// Runtime siblings the client also accepts: <c>SyncPoliceCompleteMissionCnt</c> (64618646, one
/// uint) for live updates, and the GM call <c>GmPoliceClearTodayCompleteMissionCnt</c> (65635352).
/// </summary>
public uint PoliceTodayCompleteMissionCnt { get; init; }
/// <summary>
/// 层 2a / T4-9: publish the dispatch slots of <c>PoliceDispatchCatalog</c> whose
/// <c>ShowInApp</c> is true (ids 2 "NCCA Dog" / 3 "Helicopter Search" / 4 "Armed Support")
/// into <c>SyncSpiritPoliceJobInfo.DispatchInfos</c>.
///
/// Without them <c>PolicePanelManager.GetPoliceDispatchItemList</c> (PolicePanelManager.lua:848)
/// returns an empty table, which is why the Duty Terminal homepage renders blank: the list is
/// built from <c>self.dispatchInfo[spiritId]</c>, not from the config table.
/// <c>NextAvailableTime</c> ships as 0 so <c>GetDispatchCD</c> yields a negative number and every
/// chip renders as clickable rather than counting down.
/// </summary>
public bool PoliceDispatchEnabled { get; init; } = true;
/// <summary>
/// 黑客 APP 召唤物(蜘蛛机器人 51938181 / 送货无人机 51938183)。
///
/// 为 true 时,服务端接到这两个 temp-skill 后会在玩家身前刷出对应 agent
/// (40120908 / 40120909),并推 SyncSwitchControl(68552741) 把玩家放进操控位;
/// 之后 DoHackEnemy → ANDROID_CONTROL_SWITCH → 蜘蛛/无人机操控面板全在客户端。
///
/// 回滚:置 false 后服务端只打一行 [SUMMON] skipped 日志,不刷实体也不接管 ——
/// 即恢复到 2026-09-23 之前的「点了按钮只有施法动作」状态。
/// </summary>
public bool HackerSummonEnabled { get; init; } = true;
/// <summary>
/// 巡卫官执勤终端的巡卫犬(NCCA Dog)召唤物(2026-09-25)。
///
/// 与黑客那两个是<b>同一套 android-control 通道</b>:里希(15021021)的
/// <c>FightSpiritConfig.TempSkill</c> 里挂着 51938184「召唤无敌机器狗」与
/// 51938186「召唤无敌机器狗(驻场状态)」,客户端先在执勤面板上按
/// <c>AskPoliceDispatch(2)</c>(我们回空 OK),再由 PolicePanelManager.CallDispatch
/// 走 UseSkillByPid 把技能打到这条路上。服务端做的事与蜘蛛/无人机逐字相同:
/// 刷 <c>40120911 机械狗</c>(AgentSummonCatalog 里就在 40120908 蜘蛛 / 40120909 无人机
/// 隔壁,EnemyClassType=1、AI=40213004 完全一致),再推 SyncSwitchControl 交操控。
/// 客户端侧对应 <c>RobotDogControlsStore.lua</c>,召回/交还用的同样是
/// AskStopControlAgent(true/false) —— 所以两个 handler 直接复用。
///
/// ⛑ 与 HackerSummonEnabled 分开的原因:一个是黑客 APP、一个是巡卫官职业 APP,
/// 出问题要能各自单独关掉,别共用开关互相拖累。
///
/// 回滚:置 false ⇒ 服务端只打 [SUMMON] skipped,回到「点了只有施法动作」。
/// </summary>
public bool PoliceDogSummonEnabled { get; init; } = true;
/// <summary>
/// 巡卫官执勤终端的战术支援(Armed Support,2026-09-25)。
///
/// <c>AskPoliceDispatch(4)</c> 到来时在玩家身前刷 <b>两个友方冲锋枪巡卫官</b>
/// (AgentSummonCatalog 40120875 / 40120983,AI=40213022、InitWeapon=98005562,
/// 对得上 PoliceDispatchCatalog Id=4 的 <c>Des="冲锋枪2人"</c> / <c>Number=2</c>)。
///
/// 与狗 / 蜘蛛 / 无人机不同:这条<b>不交接操控</b>,刷出来是给他们自己打的,所以
/// SpawnAgentAsync 走 <c>engage:false</c> + <c>forceEnemyRelation:false</c> + <c>camp=0</c>
/// —— 不注册进敌人账本(打不死),也不把玩家和他们标成敌对(否则玩家能锁定友军挥刀,
/// 又因为没进账本永远不掉血)。
///
/// ⛑ 官方的解锁条件是「通过天赋树-盘查解锁」,天赋树我们只有壳,所以这里直接默认开放;
/// 派兵本身不读 <c>NeedActive</c> / <c>UnlockDes</c>,客户端不会显示成锁定。
///
/// 回滚:置 false ⇒ 只打一行 [POLICE] skipped,回到「点了按钮回空 OK、什么也不发生」。
/// </summary>
public bool PoliceArmedSupportEnabled { get; init; } = true;
/// <summary>
/// 直升机搜查的服务端记账(2026-09-25)。
///
/// 只影响 AskHelicopterNpc(67113443) / AskHelicopterScanChange(67898923) /
/// AskPoliceStopHelicopterDispatch(63057829) 这三条的<b>记账与 S2C 收尾</b>;
/// ⛑ <b>关掉它不会让客户端卡死</b> —— 三个 handler 依然回 empty OK,只是不打标记、
/// 不推 SynExitHelicopterView。这是故意的:这三个里有两条是 INVOKE,回执必须无条件给。
///
/// 官方玩法里这些标记会喂给「盘查 / 通缉 / 案件」,那条链我们是空的,所以这里的记账目前
/// 只有日志价值;真要接那条链时从这里取数据。
///
/// ⛔ 别指望把直升机"刷出来":SyncPoliceDispatchHelicopter(68359668) 的客户端实现是空函数体,
/// 视角与面板由客户端 C# 侧自己开(lua 里 S_POLICE_HELICOPTER 没有任何 Open 调用点)。
/// 真要一台停在场景里的直升机,走载具通道 —— VehicleConfig 81006001(Reed·Ranger)/
/// 81007007(Helicopter01a_Rope,索降版),且必须用 SpawnVehicleAsync(force:true),
/// 因为它们不在 gameplay.vehicles.fleetIds 里(飞行载具)。
/// </summary>
public bool PoliceHelicopterEnabled { get; init; } = true;
/// <summary>
/// 掉落物拾取的两个 C2S 回执(<c>AskPickUpSceneItemTransferToItem</c> 67297154 /
/// <c>AskEnemyItemPickUp</c> 67453017)是否记账。见 <c>GameRouter.NpcPhoneDrop.cs</c>。
///
/// ⛑ <b>关掉它也照样回空 OK</b> —— 这两个是 INVOKE,不回执客户端会 await 到超时(硬规则 94)。
/// 这个开关只管"记不记日志/记不记账本",不管回不回执;这是故意的,回滚安全性优先于开关干净。
///
/// 顺带说明"路人手机内容"这条线:<b>内容全在客户端本地表</b>
/// (<c>RandomChatManager.lua</c> 按 npcId 的标签匹配 35 行 <c>NPCChatAtmosphereNewConfig</c>),
/// 服务端塞不进文本;要让每个人手机不一样,动的是 npcId(AgentConfig id),不是这里。
/// </summary>
public bool SceneItemPickupEnabled { get; init; } = true;
/// <summary>
/// 直升机搜查(派遣 id=3)落地时,是否真刷一台 <c>VehicleConfig</c> 81007007
/// (Helicopter01a_Rope,带索降绳)停在玩家旁边。
///
/// <b>为什么它是独立开关</b>:官方的直升机搜查面板在 build 4229938 里是半成品
/// (面板没有打开入口、SyncPoliceDispatchHelicopter 是空实现、dump.cs 里没有对应 C# 类),
/// 服务端接再多也只是在后台记账。刷这台载具是"让场景里真的出现一架直升机"的替代方案,
/// 与官方玩法不是一回事,所以要能单独关掉。
///
/// 回滚:置 false 后点直升机搜查只记账、不刷载具(三条 C2S 回执照旧,客户端不会卡死)。
/// </summary>
public bool PoliceHelicopterVehicleEnabled { get; init; } = true;
/// <summary>
/// 正向「逮捕 NPC」总开关(2026-09-25)。false ⇒ 收到盘查选项 RPC 也只回执、不推
/// <c>SyncPoliceBeginArrest</c>(68839124)。回滚用它,不用重新编译。
/// </summary>
public bool PoliceArrestEnabled { get; init; } = true;
/// <summary>
/// <c>SyncPoliceBeginArrest</c> 的 ArrestType。反向链路(玩家被捕)用的是 <b>1</b>
/// (<c>GameRouter.Wanted.cs:723</c>),正向默认照抄;演出不对就在这里试 0 / 2。
/// </summary>
public int PoliceArrestType { get; init; } = 1;
/// <summary>演出结束后是否把被捕的 NPC 用 <c>SyncLogicAgentLeave</c>(68281244) 带走。</summary>
public bool PoliceArrestRemoveEnabled { get; init; } = true;
/// <summary>
/// 逮捕演出播多久再把人撤走(毫秒)。太短会看不到演出,太长城里会一直堆人。
/// </summary>
public int PoliceArrestRemoveDelayMs { get; init; } = 4000;
/// <summary>
/// 里希**牢房收押 / 带出**(2026-09-30,<c>GameRouter.PolicePrison.cs</c>)。
/// 只做「位置管理 + 账本/AI 托管」,入口是 GM 面板 <c>/api/police/prison</c>;
/// 不做原生押送过场、不做 F 交互按钮(都要捐赠端那套 GATE Lua 通道,主线没有)。
/// </summary>
public PolicePrisonSettings PolicePrison { get; init; } = new();
/// <summary>
/// 敌人血条专项(2026-09-23)。三个都是<b>对照开关</b>,默认值 = 当前行为,改 false 就是一次 A/B。
///
/// 背景:kimika 捐赠端的 spawn 序列里<b>没有</b> SyncAgentCampInfo 和 SyncAttachBattleModule,
/// 也没有 SyncEnemyDisarmState / SyncUnitLockTarget;而我们四个都发。kimika 那边敌人血条是可
/// 以显示的,所以"我们多发"的这几个包是<b>负向候选</b>:特别怀疑 SyncAttachBattleModule 会
/// 把单位换到另一套战斗模块上,HUD 绑的是原来那套。反之 kimika 有而我们缺的是技能槽绑定。
/// 三条都能一键回滚,改完重启服务端即可(不需要重新编译之外的动作)。
/// </summary>
public bool EnemySpawnCampInfo { get; init; } = true;
/// <summary>见 <see cref="EnemySpawnCampInfo"/>。false = spawn 时不推 SyncAttachBattleModule
/// (注意:注释称它缺失会导致 T-pose,所以关掉后要同时看模型姿势)。</summary>
public bool EnemySpawnAttachBattleModule { get; init; } = true;
/// <summary>
/// spawn 时把模板自己的攻击技能绑进 6 个技能槽(SyncChangeCommonSkill / HeavyAttack /
/// ActiveSkill / UniqueSkill / DodgeSkill / ControlSkill)。
///
/// 来源 = kimika 的 <c>GameRouter.EnemySkills.cs</c>:它说技能槽"不是让敌人出手的开关,但
/// some client builds read the actor's slot table for the HUD"—— 也就是说 HUD(含血条)可能
/// 读 actor 的技能槽表。我们一份都没发过,这是和捐赠端最实的一处差异。
/// ⛑ 用敌人<b>自己的</b> entityId 当 spiritId,duration=99999(WorldCodec.SkillBinding 的约定)。
/// </summary>
public bool EnemySpawnSkillSlots { get; init; } = true;
/// <summary>
/// Seymour 大停电(T2-31)控制器 gadget 的落点。
///
/// donor 文档 §6.3 要求 <c>Position=(0,0,0)</c> 的 identity transform —— 因为
/// <c>Gadgets_Hacker_Tetris</c> 的画面是 prefab 里 authored 的 6 块广告牌
/// (<c>vx_sc_..._Hacker_Tetris</c>),坐标由 prefab 自己决定,服务端给的只是逻辑锚点。
/// 但 identity transform 带来一个实测后果:<b>交互锚点落在世界原点</b>,而玩家坐标量级是
/// (3950, 0, 1817) 这种几千米级 ⇒ 交互点在 4 km 外,玩家走到死也按不到那个 hack 按钮,
/// 表现就是「点了周围也没反应」(2026-09-23 用户实机反馈)。
///
/// 为 <b>true</b>(默认,验证优先)时把控制器刷在玩家最后上报的坐标上(没有上报就用
/// <c>Profile.WorldSpawn</c>),这样面板触发后玩家原地就能看到交互点并按 F ⇒
/// 客户端发 AskHack(63700695, hacktype=44) ⇒ 链路能走通。
/// 为 <b>false</b> 时回到 donor 原文的 identity transform(视觉/authored 绑定更"正确",
/// 但只能靠服务端 GATE Lua 代按按钮,否则玩家永远点不到)。
///
/// ⛑️ 这是为了<b>验证链路</b>才偏离 donor 原文;等 GATE Lua(45437825)那条路打通,
/// 应该把这个开关重新置 false。
/// </summary>
// ⛑️ 2026-09-26 00:xx 用户拍板「还原到 Blackout_IMPLEMENTATION_RU.md 文档那版」:donor 原文
// 明确写着「Контроллер не надо размещать у ног игрока: его identity transform нужен для
// authored-привязок」(不要把控制器摆在玩家脚下,identity transform 是给 authored 绑定用的)。
// 第一版(09-23 下午 T2-31)日志是 `controller spawned`(不带 at),就是 identity。
// ⛔ 改回 true 会重演「广告牌/菜单浮在半空」—— 那是 prefab 整体被平移的后果(规则 131)。
public bool BlackoutControllerAtPlayer { get; init; } = false;
/// <summary>
/// 为 <b>true</b> 时用 <see cref="BlackoutBillboardX"/> / <see cref="BlackoutBillboardY"/> /
/// <see cref="BlackoutBillboardZ"/> 这组广告牌坐标;面板「启动」里填了坐标则以面板为准。
/// 为 <b>false</b>(<b>2026-09-24 21:3x 用户拍板后的默认值</b>)时退回
/// <see cref="BlackoutControllerAtPlayer"/> 的<b>玩家脚下</b>逻辑。
///
/// ⛑️⛑️ 为什么默认改回 false(09-24 21:3x 用户令「不需要找什么官方广告板,和 09-23 19:23
/// 那一版一样,直接放在脚下即可」):广告牌路线是为了让小游戏相机对准屏幕而选的
/// (相机只转身不飞行,pivot 就是控制器坐标),但官方牌屏幕在 <b>Y=70</b> 高空,
/// 玩家站落脚点 (1992.47,0,2313.44) 到控制器的 3D 距离约 <b>75 m</b>,
/// 远超骇入射程 <c>data.dis=15</c> ⇒ <c>CheckHackCondition</c> 直接 false ⇒
/// AskHack 失败 ⇒ <b>小游戏从来没开过局</b>(全历史 AskStart/FinishHackerTetris 均 0 次)。
/// 刷在脚下会失去「必须走到广告牌前」的官方仪式感(距离恒为 0),但<b>能骇、能开局、能玩</b>;
/// 09-23 19:23 那一版就是这个行为,用户确认回到它。
///
/// ⛑️ 别把它当成 donor §6.3 的 identity transform —— (0,0,0) 那条路已被实测否掉
/// (交互点在 4 km 外,玩家永远按不到)。
/// </summary>
public bool BlackoutControllerAtBillboard { get; init; } = false;
/// <summary>
/// 控制器落点 X — 默认取官方牌 <c>vx_sc_sm_map01_outdoor_build_business_mall006a_45_racing</c>
/// 的<b>屏幕正中心</b>(2001.479, 70, 2287.694)。
///
/// ⭐ <b>为什么是屏幕而不是牌脚</b>(2026-09-24 用户定):小游戏相机只"转身"
/// (<c>SetFreeLookCameraDirection</c>)不会飞过去,而面板拿到的 <c>lookAtPivot</c>
/// 就是<b>控制器自己的位置</b> ⇒ 控制器刷在屏幕上 = 相机瞄屏幕,画面一定对得上;
/// 而且客户端只记住本局见过的第一个控制器坐标(实测 pivot 会陈旧到上一个 id),
/// 所有控制器坐标一致时"记错了也没关系"。
/// ⛑️ 代价:屏幕在 70 m 高空,骇入交互有射程(<c>CheckHackCondition</c> 的 <c>data.dis</c>),
/// 按不到就把这三个值改回<b>落脚点</b>(1992.47, 0, 2313.44),或让面板「退回牌子脚下」。
/// </summary>
public float BlackoutBillboardX { get; init; } = 2001.479f;
/// <summary>控制器落点 Y — 官方牌屏幕离招牌原点高 70 m。
/// ⛑️ 要在地面骇入就填 0,并配合上面的落脚点 (1992.47, 0, 2313.44)。</summary>
public float BlackoutBillboardY { get; init; } = 70f;
/// <summary>控制器落点 Z — 官方牌屏幕中心 2287.694。</summary>
public float BlackoutBillboardZ { get; init; } = 2287.694f;
/// <summary>
/// 「控制器摆了但骇入点不出现」的补发次数(0 = 关闭补发)。
///
/// 服务端每次都报 `controller acked`,但玩家走到落点看不到骇入提示 ⇒
/// 客户端把这条 <c>SyncGadgetAOIAddAndRemove</c> 丢掉了(常见于<b>按下启动时玩家还在
/// 几百米外</b>,物件被客户端自己的流式/AOI 规则丢掉,之后传送过去也不会再加载)。
/// 补发用同一个 instanceId + 同一组坐标,幂等。调到 0 即回到接入前的行为。
/// </summary>
public int BlackoutRearmMax { get; init; } = 6;
/// <summary>两次补发之间的间隔(秒)。默认 5 秒 × 6 次 = 30 秒的补救窗口。</summary>
public int BlackoutRearmSeconds { get; init; } = 5;
/// <summary>
/// 层 2a / T4-9: how many historical case rows to fabricate into
/// <c>SyncSpiritPoliceJobInfo.CaseInfos</c> (0 = none, capped by
/// <c>PoliceConfig.static.CaseUpperLimit</c> = 50).
///
/// A case row is not a config table — the client builds it from the people it actually arrests —
/// so the rows here are composed out of real tables only: <c>NpcId</c> comes from
/// <c>PoliceSuspectCatalog</c> (real <c>AgentConfig</c> rows with a portrait; the client's
/// <c>PoliceCaseListPanelStore.OnRenderCaseItem</c> silently skips any case whose NpcId is not in
/// AgentConfig), <c>Fines</c> from <c>PoliceFineCatalog</c> shifted-pick so different rows show
/// different charges, and <c>BonusDrops</c> from <c>PoliceConfig.static.ArrestRewardDrop</c>.
/// Each case is written with <c>InterrogationInfo.State = CanInterrogate</c> so the panel offers
/// the interrogation entry instead of claiming the case is closed.
/// </summary>
public uint PoliceCaseCount { get; init; } = 3;
/// <summary>
/// 层 2a: the number seeded into the homepage tally
/// (<c>DutyBasicInfo.ServiceData</c> → DispatchTimes / PatrolTimes / ArrestTimes / FineCount,
/// rendered by <c>PolicePanelManager.RenderSummaryTemplate</c>). 0 = derive from what is actually
/// published (dispatches + cases), any other value is used verbatim for all four counters.
/// Purely cosmetic — the server owns no police statistics.
/// </summary>
public uint PoliceServiceTally { get; init; }
/// <summary>
/// Escape hatch for the police payload: <c>"full"</c> (default) publishes the T4-9 content,
/// <c>"empty"</c> publishes the bare skeleton (every collection empty, every complex field
/// present with zero fields) — byte-for-byte the first version of
/// <c>SyncSpiritPoliceJobInfo</c>, which is the only one shipped before 2026-09-21 that had no
/// reported client crash.
///
/// Why this exists: a wrong police frame does not fail loudly, it <b>takes the client process
/// down</b>. On 2026-09-21 a missing trailing field in the case row
/// (<c>PoliceCaseInfo.CrimeDefaultItems</c>) desynchronised the frame, the client then read the
/// next row's unix <c>Time</c> as a list count and tried to build a ~1.7e9 entry table — the game
/// died with no Lua error and no Windows crash report, only ~4 s after the packet. If a future
/// edit ever brings that back, flip this to <c>"empty"</c> to restore a known-good frame while
/// keeping the app openable.
/// </summary>
public string PolicePayloadMode { get; init; } = "full";
/// <summary>
/// Publish the police app's Mimic Database archive (伪人档案) as unlocked. The client keeps that
/// archive where <c>SyncSpiritPoliceJobInfo</c> leaves it, so with this off all 27 rows render
/// as locked regardless of what the player does. Off = the pre-existing behaviour.
/// </summary>
public bool PoliceFakeFilesUnlock { get; init; } = true;
/// <summary>
/// Rollback switch for the NPC shop shelf pushed by AskNpcShop (63563263):
/// <c>"off"</c> answers OK but pushes nothing (the 2026-09-22 behaviour — panel opens empty),
/// <c>"pilot"</c> (default) restocks only Solution Pawn 94100024, <c>"full"</c> restocks every
/// shop in NpcShopCatalog.json (86 shops / 8754 rows).
///
/// Why the default is the narrowest setting: the shelf frame is hand-transcribed from the
/// client's own reader (RPCDeserializeAuto Reader[366]) because the IL2CPP dump declares
/// <c>NpcShopCommodityView</c> as an empty class, so a wrong field would desynchronise the
/// frame and — as with the 2026-09-21 police payload — take the client process down with no
/// Lua error. Pilot mode confines that blast radius to one shop until the layout is confirmed.
/// </summary>
public string NpcShopPayloadMode { get; init; } = "pilot";
/// <summary>
/// Rollback switch for the 趴趴 (Bubble) post list returned by AskMomentsPostSimpleInfos
/// (63452251): <c>"full"</c> (default) publishes every post in BubblePosts.json (55 rows),
/// <c>"empty"</c> answers with an empty list — the behaviour of every build before 2026-09-24,
/// and the setting to fall back to when the frame desynchronises.
///
/// Why this needs a switch at all: <c>PostSimpleClientInfo</c> is declared as an empty class by
/// the IL2CPP dump, so the field layout comes from the client's own writer
/// (RPCSerializeAuto.lua:8310). A wrong field does not fail loudly — the client logs
/// <c>deserialize failed</c> and the panel silently stays empty.
/// </summary>
public string BubblePayloadMode { get; init; } = "full";
/// <summary>
/// Gap between two consecutive posts' <c>Date</c>, newest first. The panel renders
/// "多久以前" from <c>ServerUnixTime - info.Date</c>, so the newest post is "now" and each
/// older one steps further back.
/// </summary>
public uint BubblePostDateStepSeconds { get; init; } = 900;
/// <summary>
/// Publishes every post as already read (<c>PostSimpleClientInfo.IsRead = true</c>).
///
/// This is what decides whether tapping an NPC head opens the NPC home page or jumps straight
/// into a single picture: <c>NewBubbleMgr.lua:750 OnClickNpcBubbleHead</c> asks
/// <c>CheckHasNewStory</c> first and, when that returns a post id, calls
/// <c>OpenDetailPanel(id)</c> — the "one photo, no tabs" behaviour every NPC except 凛 had.
/// <c>CheckHasNewStory</c> returns the first STORY/SHARE post whose <c>IsRead</c> is false, so
/// with the honest "unread" value every NPC has new stories forever and the three-tab home page
/// (日程 / 进展 / 快拍) is unreachable. 凛 was the exception only because her posts land in the
/// POST bucket, which <c>CheckHasNewStory</c> never looks at.
///
/// Cost of turning it on: the unread red dots disappear, which is meaningless here anyway.
/// Set it to false to get the dots back and lose the home page again.
/// </summary>
public bool BubbleMarkAllRead { get; init; } = true;
/// <summary>
/// Publishes the NPC daily schedule in the login frame
/// (<c>PlayerClientInfoMinor.FavorNpcDailyScheduleInfos</c>), which is what fills the 日程 tab
/// of the 趴卜 NPC home page.
///
/// Off by design until it is measured: the field is a nested dictionary of complex objects
/// inside the biggest payload of the whole login, and a wrong layout there can desynchronise
/// everything after it, so it needs a switch that can be flipped without a rebuild.
/// </summary>
public bool NpcDailyScheduleEnabled { get; init; } = true;
/// <summary>
/// Cat Express / 蓝猫物流 — serves the courier board (<c>AskGetTruckJobOrders</c> 63386469 and
/// friends). Off gives the pre-2026-09-24 behaviour back: an empty board, because nothing else
/// fills it.
/// </summary>
public bool DeliveryEnabled { get; init; } = true;
/// <summary>
/// Framing of the two board replies — "complex" (0xFF marker, the default) or "struct" (none).
///
/// Which one is right is a property of the client's generated reader for
/// <c>ClientTruckOrderView</c>, and that reader is not in the Lua auto-gen tables and the IL2CPP
/// dump declares the type empty, so it cannot be read offline. Defaulting to complex matches
/// every other INVOKE reply in this repo (Bubble / Extraction / Tuite's TuiteTimelineData).
/// A wrong choice shows up as a dropped callback, never as a parse error, so this switch exists
/// to test the other branch without a rebuild.
/// </summary>
public string DeliveryReplyFraming { get; init; } = "complex";
/// <summary>Rows on the take-order board. UberSimConfig authors 2 as the day's first batch.</summary>
public int DeliveryBoardSize { get; init; } = 8;
/// <summary>
/// EstimatedFinishSeconds on every row (the "约 N 分钟" text). The server has no pickup/delivery
/// coordinates to derive travel time from, so it is a flat configured number rather than a lie
/// computed from a zero-length route.
/// </summary>
public int DeliveryEstimatedFinishSeconds { get; init; } = 300;
public int DeliveryBasePointReward { get; init; } = 10;
/// <summary>
/// 蓝猫物流「部署 / Deploy」按钮(UberSimOrderSupportConfig Id=1 Drone,functionId 51938185)
/// 按下后是否真的刷出送货无人机(AgentSummonCatalog 40120909)。
///
/// 与黑客 APP 那两个是同一套 android-control 通道:<c>GameRouter.HackerSummon.cs</c> 接住
/// temp-skill 后刷 agent 再推 SyncSwitchControl 交操控,之后面板全在客户端。
/// 按钮本身要 <c>UnlockBuff 52800661</c> 才显示,那个走 CharacterCapabilities,与本开关无关。
///
/// 回滚:置 false ⇒ 只打一行 [SUMMON] skipped,回到「点了 Deploy 只有施法动作、没有东西出来」。
/// </summary>
public bool DeliveryDroneSummonEnabled { get; init; } = true;
/// <summary>
/// 配送类 RPC 回给客户端的"成功" errorId。默认 <b>1</b>。
///
/// ⛑⛑ 2026-09-25 04:35 实测定论:客户端日志打出 <c>MessageConfig.Ok=0</c>,
/// 而配送这条线的回调全是 <c>if errorId == MessageConfig.Ok then 报错; return end</c>。
/// ⇒ 回 0 = 失败。接单那处(DeliveryHomepagePanelStore:810)命中后还会
/// <c>RefreshOrderByAskTakeOrGiveUpFail()</c> 把刚接的单回滚,表现就是"点了没反应"。
/// 所以默认必须是非 0。
///
/// ⛑⛑ 但**实测两难**(2026-09-25 04:49):改成 1 之后客户端日志变成
/// <c>NO view: errorId=1 type(view)=nil</c> —— 框架对 errorId != 0 的回包**根本不反序列化
/// body**,于是所有要拿 payload 的 RPC(大厅 / 结算预览 / 送货日志)全废,app 直接打不开。
/// ⇒ 结论:需要 payload 的 RPC 只能回 0,之后再想办法绕开 `== Ok` 的误判
/// (方向:fastpatch 里把接单回调也接管掉,或者补 AskQueryTruckPosInfo 的坐标)。
/// 默认回到 0,保住"能打开"。
///
/// ⛑ 这个 build 的极性离线定不死,别再推:
/// · <c>DeliveryTaskManager.PreSettleTruckOrder:572</c> 写
/// <c>if err ~= MessageConfig.Ok then 成功</c>
/// · <c>DeliveryTaskManager.FinishDeliveryOrder:706</c> 写
/// <c>if err == MessageConfig.Ok then 报错; return end</c>
/// 两处方向相反,而 <c>MessageConfig.Ok</c> 经 CompressTable 代理取回的值实测既像 0 又不像 0
/// (OpenUberSim 把回 0 判成错误,可 Tuite/BBChat 同样回 0 却是好的)。
/// 所以留成配置:实机哪一步不推进就改这个数,改 json 重启即可,不用重编译。
/// </summary>
public int DeliverySuccessErrorId { get; init; } = 0;
/// <summary>
/// 取货点 / 送货点坐标是否用玩家最后一次上报的位置当锚点。
///
/// 背景:UberSimPickupConfig / UberSimDeliveryConfig 只有名字和标签,<b>一行坐标都没有</b>,
/// 所以服务端无从得知真正的取送货点。开 = 取货点落在玩家脚下、送货点沿 X/Z 各偏移
/// <c>DeliveryDropOffset</c>,地图标记至少落在玩家够得着的地方;关 = 回到全 0(世界原点)。
/// 用于 <c>AskQueryTruckPosInfo</c>(63763434) 与看板行的 <c>StartPos/EndPos</c>。
/// 回滚:置 false。
/// </summary>
public bool DeliverySpotsAtPlayer { get; init; } = true;
/// <summary>
/// 送货点相对取货点的偏移(世界单位,X/Z 各加这么远)。默认 120 —— 看得见、走得到,
/// 又不至于远到要开车。仅 <c>DeliverySpotsAtPlayer</c> 为 true 时生效。
/// </summary>
public float DeliveryDropOffset { get; init; } = 120f;
/// <summary>
/// 接单成功后是否补推 <c>SyncAcceptTruckJobOrder</c>(64910164)。
///
/// ⛑⛑ 接单面板**不是**靠 AskAcceptTruckJobOrder 的回包推进的:
/// <c>DeliveryHomepagePanelStore.lua:809</c> 的回调只看 errorId、别的都不碰,而面板的
/// <c>canInteract</c> 闭锁只有 <c>OnAcceptTruckJobOrder</c>(:819) 会解开 —— 它挂在
/// <c>ON_ACCEPT_TRUCK_JOB_ORDER</c> 上,而那个消息是
/// <c>GameToClientImpl.SyncAcceptTruckJobOrder</c>(GameToClientImpl.lua:1761) 发的。
/// ⇒ 只回包不推送 = 服务端日志一片 success、界面纹丝不动(2026-09-25 用户报的"点了没反应")。
/// 回滚:置 false。
/// </summary>
public bool DeliveryAcceptSyncEnabled { get; init; } = true;
// ⛔ 2026-09-25 10:4x 曾在这里加过 deliveryCargoIntegrity / deliveryEvaluationStars
// (给结算补 TruckJobOrderResult 用)。整条结算链实机失败,结算/评分/排行榜已回退,
// 后来只把"送货日志"单独做成了 —— 那段用不到这两个开关。
// 回顾见 Drmk.Proxy/proxy/tools/_disabled/delivery_settle.lua.disabled 头部注释。
/// <summary>
/// 「送货日志 / 账户」页预置几条历史已完成订单。默认 3,置 0 只剩真实结算的行。
///
/// 为什么需要预置:客户端的结算链(<c>PreSettleTruckOrder</c>:571 /
/// <c>FinishDeliveryOrder</c>:705)只被任务系统的 eventId 驱动,面板上没有任何按钮调它,
/// <c>GMFinishOrderById</c>(:1755) 也全仓零调用点 ⇒ 不预置的话日志页永远空的。
/// 这些行走的是同一套 UberSim 真实表(货物 / 取货点 / 送货点),只是**时间是被倒推的**,
/// 第 n 条标成 n 小时前完成,好让排序和「今日累计」看得出来。
/// </summary>
public int DeliveryLogSeedCount { get; init; } = 3;
public int DeliveryRewardPointSum { get; init; } = 0;
public int DeliveryTotalIncome { get; init; } = 0;
/// <summary>Returned by AskGetTruckSatisfactionAverage and used as the board's average. 5 = five stars.</summary>
public float DeliverySatisfaction { get; init; } = 5f;
/// <summary>
/// Vehicle preselected for 叫车. 0 lets the client fall back to
/// <c>UberSimOrderSupportConfig.CallCarCfg.vehicleId</c> ([81003022, 81004024]).
/// </summary>
public uint DeliveryDefaultVehicleId { get; init; } = 0;
/// <summary>
/// Buffs published on every spirit so <c>RefreshCallFunctionListView</c> renders the two support
/// buttons: 52800660 = 叫车 (CallCar), 52800661 = 无人机 (Drone). Without them the buttons are
/// filtered out of the panel entirely and neither can be tapped.
/// </summary>
public uint[] DeliverySupportBuffIds { get; init; } = [52_800_660, 52_800_661];
/// <summary>
/// Publishes the 眼界 (Tuite) post instances in the login frame
/// (<c>PlayerClientInfoMinor.PlayerTuiteInfo.TuiteList</c>).
///
/// On by default because it is the entire content of the app: every 眼界 list
/// (推荐/发现/关注/收藏/同城) is a filter over that one list —
/// <c>SocialNetWorkUtils.lua:143/400/431/445/1687/3046</c> all read
/// <c>playerTuiteInfo.TuiteList</c> and none of them has a server request of its own, so with
/// the list empty the app has nothing to render regardless of what the detail handlers answer.
///
/// Rollback: <c>false</c> restores the pre-2026-09-24 behaviour (empty app). The payload is a
/// list7 of a 12-field struct, ~8 KB for all 204 rows, so size is not the risk — a wrong field
/// layout is, and that is what this switch isolates.
/// </summary>
public bool TuiteEnabled { get; init; } = true;
/// <summary>
/// Marks every published 眼界 post as followed (<c>ClientTuiteInfo.IsFollow = true</c>).
///
/// The 关注 tab is <c>GetFollowList</c> (SocialNetWorkUtils.lua:431), which keeps only entries
/// whose <c>IsFollow</c> is true, and the only way that flag gets set for a catalogue post is
/// <c>AskTuiteFollowRole</c> — a per-role action the player has to perform 159 times. On means
/// the tab has content immediately; <c>false</c> means it starts empty and fills as the player
/// follows authors one by one (the flag still sticks for the session).
/// </summary>
public bool TuiteFollowAll { get; init; } = true;
/// <summary>
/// Publishes every NPC conversation thread from Configs/NpcChats.json into the login frame's
/// <c>InfoNpcCultivation.NpcChats</c> / <c>NpcGroupChats</c>.
///
/// This is the entire content of BBChat (AppId 27 "Message", SystemId 146 — its red dot comes
/// from <c>gRedDotUtils.CheckNpcChatHasRedDot()</c>, MainPhoneUtils.lua:688). There is no "pull
/// the list" RPC: <c>PlayerInfoMinorNpcCultivationData.lua:55</c> copies both lists and
/// <c>NpcChatManager_Story.lua:28</c> turns them into the manager's dictionaries, so leaving
/// them empty is why the app had no conversations at all.
///
/// Rollback: <c>false</c> restores the empty app. ~2.8k rows × 22 bytes ≈ 62 KB on the login
/// frame, which is the one thing to watch if <c>SyncPlayerInfo</c> ever stops arriving.
/// </summary>
public bool NpcChatEnabled { get; init; } = true;
/// <summary>
/// Also publishes the threads whose head row is Dialog (ChatType 3) or Fake (ChatType 4).
///
/// Those are world-triggered content — a cutscene hands the conversation over
/// (<c>ChatNpcsPhoneManager.OpenFakePhoneChatPanel</c>, npc-phone props) — so pre-loading them
/// fills the app with conversations the player has not reached yet and nearly doubles the
/// payload (2791 rows → 5888). Off by default; the interact handlers still answer for those rows
/// because the whole <c>NPCChatConfig</c> NextMessage map is loaded regardless.
/// </summary>
public bool NpcChatStoryThreads { get; init; } = false;
/// <summary>
/// 手机「拨号」:用 Configs/PhoneContacts.json 里的整张通讯录填充登录帧的
/// <c>PlayerPhoneInfo.SpiritPhoneInfos[spirit].ContactList</c>。
///
/// 通讯录条目只有号码 + 备注两个字段,名字/头像/台词全在客户端本地 PhoneContactConfig 里按号码回查,
/// 所以这是纯数据下发,没有 wire 风险。回滚 <c>false</c> = 只剩两个叫车快拨号(2026-09-24 之前的行为)。
/// </summary>
public bool PhoneContactsEnabled { get; init; } = true;
/// <summary>
/// 把 PhoneContactUnlockConfig 的**全部**行 id(79 条)标成已解锁(module 3 = PhoneContact 的
/// <c>FinishedTemplateIdList</c>)。
///
/// 判据在 <c>EventConditionUtils.CheckHasUnlocked</c>:列表里包含该行的 Id 即视为解锁;
/// 原版靠任务/剧情推进,我们没有任务链,所以整表标完成。未解锁时客户端播
/// <c>LockDialogId</c>(号码忙),既不报错也不打日志,是这条线最容易误判成"服务端没发"的地方。
/// 回滚 <c>false</c> = 只解锁叫车那两行(Id 1/2),等于回到 2026-09-24 之前。
/// </summary>
public bool PhoneContactsUnlockAll { get; init; } = true;
/// <summary>
/// 连 <c>AutoAddFlag = false</c> 的号码一起下发(诈骗电话 13342865 / 灵异电话 44444444 /
/// 商店外卖类 17 条)。这些是原版要靠手动拨号或特定任务才出现的彩蛋与商线,
/// 默认 <c>false</c> 只发会自动进通讯录的那 49 条。
/// </summary>
public bool PhoneContactsIncludeHidden { get; init; } = false;
/// <summary>
/// Where the 趴卜 post pictures are fetched from (<c>SyncOssBucketInfo</c> 64833344). Off by
/// default — see <see cref="OssPictureSettings"/> for what to fill in.
/// </summary>
public OssPictureSettings OssPicture { get; init; } = new();
public SwitchAnimationSettings SwitchAnimations { get; init; } = new();
public CombatSettings Combat { get; init; } = new();
public WebTraversalSettings WebTraversal { get; init; } = new();
public VehicleSettings Vehicles { get; init; } = new();
/// <summary>
/// 车辆改装店(ApplyCar / RepairShop)进场 —— 2026-09-25 从俄语上游 donor 移植。
/// 三个开关都可以在不动代码的情况下回滚:<c>enabled=false</c> 会把整套逻辑退回
/// 移植前的行为(AskTeleport 只 ack 不 echo、两个 waypoint RPC 只 ack 不推位移、
/// 不再补推 SyncStartSummonVehicle)。
/// </summary>
public RepairShopSettings RepairShop { get; init; } = new();
/// <summary>
/// 载具路线规划(AskVehicleNavigationPathPoints / …FromPos / …PathLength)——
/// 2026-09-24 从第九份捐赠端移植,但图用的是我们自己的车道表。见
/// <c>GameRouter.VehicleNav.cs</c> 头注(里面有 unknown-methods.log 的取证数字)。
/// 关掉即逐字节回到「空点集 + 长度 0」的旧行为。
/// </summary>
public VehicleNavigationSettings VehicleNavigation { get; init; } = new();
/// <summary>
/// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30)—— 面板与判定全在客户端
/// (<c>ToiletOstrichGamePanelStore.lua</c> 的音游式三键,满分 100),服务端只负责
/// 「让骑乘坐实」+「接住失败上报」。见 <c>GameRouter.ToiletQte.cs</c> 头注。
/// 马桶鸵鸟(规范 id <b>42046401</b>)是标准 Agent,用既有的服务端直生成通道即可出,
/// 它已被置顶到面板「生成敌人」下拉框第一位(<c>GameAdminBridge.AgentSummonCatalog</c>)。
/// </summary>
public ToiletQteSettings ToiletQte { get; init; } = new();
public TaxiSettings Taxi { get; init; } = new();
public AetherSettings Aether { get; init; } = new();
/// <summary>
/// 派对 NPC 是否走「预制动作」。<b>默认 true</b>。
/// 关掉 = 回到第 37 轮之前的行为(PoiActionId 恒 0,NPC 只有兜底 idle)。
/// 只影响**派对系**(91055000 / 91056001 / 91056002)的 NPC,其它玩法与城市市民不受影响。
/// </summary>
public bool PartyNpcPoiEnabled { get; init; } = true;
/// <summary>
/// 露营系(`NpcCultivationGameplayTypeConfig` Id = <b>91055000</b> 露营派对 /
/// <b>91056001</b> 露营引导)NPC 的 POI 动作 id 表,按人轮转。空数组 ⇒ 该玩法回退
/// <see cref="PartyNpcPoiDefaultActionIds"/>。
///
/// ★ 第 39 轮换成官方**互动成套族**「喝茶聚集聊天 01~05」(ActionTag4 = `场景互动;道具`)。
/// 第 38 轮用的 `[236,239,237,238,174]` 是 4 单人 + 1 互动 ⇒ 实测「各做各的」,已作废。
///
/// ★★★ 第 47 轮:换成 **5 个官方「地面坐姿」** —— 用户现场反馈「『坐姿 + 聚集聊天』
/// 成套族……**他们没有桌子椅子啊,也没有面对着**」「我要的是**直接屁股坐在地面的姿势**,
/// 也不用喝茶什么的,不然还要有桌子椅子杯子,**最简单的动作**」。
/// ⇒ 「喝茶聚集聊天」是 `BehaviorPostureGameplayTag = **35**`(**椅子坐姿**,自带茶杯
/// `BelongingSceneItems = [80003622]`),在没有任何家具的草地上播出来就是「凭空端杯」。
///
/// ⛔ 别再靠名字猜 —— **权威判据 = `BehaviorPostureGameplayTag`**:
/// 34 = 站姿、<b>35 = 坐姿(椅子/卡座)</b>、<b>39 = 地面坐姿</b>、37 = 蹲姿、38 = 躺姿。
/// `UrbanDiversityPOIActionConfig.json`(3978 条)里 `P=39` 共 **64** 条,其中
/// **`UsageId = 0`(无道具)且 `MainActions` 非空(有可下发动作)= 46 条** ⇒ 就是本次的池子。
///
/// ⚠⚠ **第 47 轮用户要求「让我挑一下,不要什么动作都塞进随机里」** ⇒ 本表默认只放
/// **一条**(`[1504]`,人人同一种坐姿,结果完全可预期);想让人人不同,就按下面的
/// 备选往数组里加 id —— **顺序 = 第 i 个人拿第 i 条**(`poiActionIds[i % Count]`),
/// 所以「加什么、加几条」全由你定,不会被自动打乱。
///
/// ★ 2026-09-26 用户**逐条筛过**的池(`P=39` / `UsageId=0` / `MainActions` 非空 / 去重 / 去道具),
/// 目前**只剩这 9 条**是「真·坐在地上、无道具、服务端能下发」:
/// 1504 坐地伸腿聊天A 1505 坐地撑地聊天B ← 官方 A/B 一对,`ActionTag2=通用`
/// 934 沙滩_地上聊天A 313 沙滩_海滩闲坐合集02 MA[86264,86266]
/// 5006 通用_坐地闲置动作组 MA[87131..87135] ← **一条=5 变体**(按 `entityId%5` 分)
/// 929 沙滩_矜持坐着_女 930 沙滩_惬意坐着_男 931 沙滩_妩媚坐着_成女 932 沙滩_极道严肃坐着_成男
/// ⚠ 929~932 名字**带性别**:邀请的角色男女混着时,建议只用不带性别的那 5 条
/// (1504 / 1505 / 934 / 313 / 5006)—— **本表默认就是这 5 条**。
/// ⚠ 另有两条形态不同、单独列出:`531 大字型坐`(`ActionTag3=躺姿`,更像**躺**)、
/// `5012 蹲姿010_通用_马路边闲置动作组`(`P=39` 但名字是**蹲**)。
///
/// ⛔ **用户 2026-09-26 明确划掉的**(别再放回来):
/// 166 冥想打坐 · 291/292 混混坐姿/混混地面坐姿 · 1112 不良混混坐地闲置 ·
/// 928 沙滩_悠哉松弛地坐着 · 421 酒吧_酒鬼坐地 · 300/301 酒吧_靠墙醉卧/靠墙抱胸醉卧 ·
/// 554 夜店_卡座醉汉B · 644 夜店_蹲着安抚倒下的人B · 1173 警察_犯人坐地上
/// ⇒ 归并成一句:**不要打坐 / 混混 / 醉态 / 蹲 / 犯人 / 躺 这类「有故事」的姿态**,
/// 只要「**单纯坐在草地上**」。
///
/// ★ 选池顺序 = `poiActionIds[i % Count]`(**固定**,不会每次重开都变):
/// 数组里放 1 条 = 5 人同一种;放 5 条 = 一人一种。
///
/// ⛔ **越界不可用(20 条)**:`MainActions` 为空、走 `ActionSetName`/`ActionName` 字符串的那些
/// (`2114 坐地面单人闲置` / `1665 盘腿坐地上合集` / `1667 1668 盘腿坐地上聊天A·B_男·女` /
/// `16663 盘腿坐地上观看` / `501036 通用13_坐地面_idle` …)。服务端
/// `DonorPoiActionCatalog4229938.SelectMainAction` 只读数值 `MainActions`,读不到 ⇒
/// `DonorNpc.cs:255` 回落 `DefaultNpcActionId = 1001`(**站姿** idle)⇒ **会站着不动**。
/// 2026-09-26 找过它们背后的动作表(`ActionSetName=310` / `ActionName=5772` 这种):
/// `ActionGroupBehaviorConfig.json` **是 0 条空表**,`AnimationSetAnimationSetNameConfig.json`
/// 只有 10 条枚举(Quest/Gameplay/Worldlife/Common/Veh…)⇒ **映射没解出来,只能用数值那批**。
///
/// 曾经试过的 5 连(`地面坐姿_沙滩_*坐着`,含已被划掉的 928)—— 保留备查,不默认启用:
/// 928 地面坐姿_沙滩_悠哉松弛地坐着_胖男女 MA[86883] U=0
/// 929 地面坐姿_沙滩_矜持坐着_女 MA[86884] U=0
/// 930 地面坐姿_沙滩_惬意坐着_男 MA[86885] U=0
/// 931 地面坐姿_沙滩_妩媚坐着_成女 MA[86886] U=0
/// 932 地面坐姿_沙滩_极道严肃坐着_成男 MA[86887] U=0
/// ⚠ 这 5 条的字段形状与**已实测跑通**的 1331~1335 完全一致(`MainActions` 单元素 +
/// `MainActionGroup = 400` + `UsageId`/`BelongingSceneItems` 无道具需求)。
///
/// ★ 备选(同样 `P=39` / `U=0` / 有动作,想换一行就改这一行):
/// `[1504]` 地面坐姿_居民_坐地伸腿聊天A MA[87657]
/// `[1505]` 地面坐姿_居民_坐地撑地聊天B MA[87658] ← 1504/1505 是官方 A/B 一对
/// `[5006]` 地面坐姿_通用_坐地闲置动作组 MA[87131..87135] ← **一条=5 个变体**,
/// 服务端按 `entityId % 5` 取值 ⇒ 单填 `[5006]` 也能让 5 个人拿到 5 种坐姿
/// `[166]` 地面坐姿_通用_冥想打坐 MA[86158]
/// `[291]`/`[292]` 地面坐姿_居民_混混坐姿 / 混混地面坐姿
/// `[313]` 地面坐姿_沙滩_海滩闲坐合集02 MA[86264,86266]
/// ⚠ 池子里还有 20 条 `MainActions` **为空**的(走 `ActionSetName`/`ActionName` 字符串,
/// 如 `1665 盘腿坐地上合集` / `2114 坐地面单人闲置`)——**本服下发不了**:服务端
/// `SelectMainAction` 只读数值 `MainActions`,读不到就回落 `DefaultNpcActionId`
/// (1001 = 通用**站**姿 idle)⇒ 会站着不动。**别选那 20 条。**
///
/// ⚠ 「按人轮转」要**保留**:5 条 ⇒ 5 个人各拿一条,这正是用户要的「随机分配 5 个地面坐姿」。
/// ⚠ 「野餐」族(236/239/237/238)**不是**互动动作(tag4=`道具`/空),别再当露营主力,
/// 只能当「单人补充」。第 38 轮把它们当主力是本次实测暴露的错误。
/// ⚠ 想回到第 38 轮行为 ⇒ 把 id 换回去;想完全不发 POI ⇒ 本表清空(回退兜底 17)。
/// </summary>
public uint[] PartyNpcPoiCampActionIds { get; init; } = [1504, 1505, 934, 313, 5006];
/// <summary>
/// 舞会(<b>91056002</b>)NPC 的 POI 动作 id 表,按人轮转。
///
/// ★ 第 39 轮换成官方**互动成套族**「站姿_夜店_开火车 01~04」(ActionTag4 = `人物互动`)。
/// 第 38 轮的 `[6,40,5,174,52]`(鼓掌/围观/交谈)同样是单人堆,已作废。
/// ⚠ 若觉得「开火车」不贴舞会,可换 `[783,784]`(跟着DJ摇摆动作双人A/B,但 tag4 为空)。
/// </summary>
public uint[] PartyNpcPoiDanceActionIds { get; init; } = [797, 798, 799, 800];
/// <summary>
/// 兜底动作表(玩法认不出来 / <see cref="PartyNpcPoiEnabled"/> 打开但上面两张表为空)。
/// <b>17 = 站姿_通用_站姿闲置00</b>,与现在的隐式兜底等价,但走正规 POI 槽位。
/// 配成空数组 ⇒ 完全不发 POI(等价于第 37 轮之前的行为)。
/// </summary>
public uint[] PartyNpcPoiDefaultActionIds { get; init; } = [17];
/// <summary>
/// ★ 派对 NPC 是否**跳过**「HackNpc 出场给手机」那一步。<b>默认 true(跳过)</b>。
///
/// WHY:<c>SendCivilianAsync</c> 的尾部会调
/// <c>MarkNpcHackableOnSpawnAsync(..., skipPhone: false)</c> ⇒ 推
/// <c>SyncAgentBelongings(手机 80003481)</c> + <c>SyncAgentUseBelongingChanged(usage 6/1 玩手机·打电话)</c>。
/// 这一步**晚于** POI 自己的 belongings/usage,所以会把
/// 「野餐吃喝(可乐)」直接改写成「玩手机」—— 预制动作等于白配。
/// (HackNpc 那边的注释原文就写着「其余情况(POI 是拍照/打字/<b>吃东西</b>…)换成手机」。)
///
/// 默认 true = 跳过给手机,让预制动作与自带道具生效;可骇标记仍然照打,
/// 只是这些 NPC 在骇入面板里只有名字、没有按钮(这是刻意的取舍)。
/// 想让派对 NPC 又能被骇(回到第 37 轮行为)就把它改成 false。
/// 只影响派对系 NPC,城市市民/店员/行人的行为不变。
/// </summary>
public bool PartyNpcPoiSkipHackPhone { get; init; } = true;
/// <summary>
/// 派对 NPC 的驻留行为通道。取值:
/// <list type="bullet">
/// <item><c>"waypoint"</c>(**默认**)—— 服务端路点巡游(<c>SyncLogicAgentCommand</c> +
/// <c>TaskWayPointMove</c>(tag 39),`Ambient.cs:24-26` 称其为
/// 「**唯一能产生走路步态的通道**」,街上行人在走的就是它)。</item>
/// <item><c>"wander"</c> —— <c>SyncLogicAgentEnter</c> + <c>SyncAgentForceGo</c>
/// + <c>SyncMoveWandering</c> 并周期续期。**2026-09-26 实测:对 Aether agent 无效**,
/// 保留只为对照(见下方 ★★ 记录)。</item>
/// <item><c>"none"</c>(**第 47 轮起默认**)—— <b>停走档</b>:只发
/// <c>SyncLogicAgentEnter</c> + <c>SyncAgentForceGo</c>(把行为树起起来),
/// <b>不发任何 locomotion 指令</b> ⇒ 角色停在各自那个半径 2.5 m 的圆周站位上、
/// 保持朝向圆心(<c>SpawnPartyNpcsAsync</c> 的 <c>angle + π</c>),并保持 POI 坐姿。
/// ⚠ 第 47 轮把 Enter/ForceGo 提到了模式判断**之前** —— 在这之前选 <c>"none"</c>
/// 会把这两步一起丢掉(行为树没起来 ⇒ 连 POI 坐姿都保不住)。</item>
/// </list>
///
/// ★★ 2026-09-26 现场实测后**默认由 `"wander"` 改成 `"waypoint"`**:
/// 第一次用 `"wander"` 跑,服务端日志/报文全部正确
/// (`[PARTYNPC] 驻留行为循环启动 mode=wander`、`SyncMoveWandering` ×10 body=50b、
/// `SyncLogicAgentEnter` ×49、`SyncAgentForceGo` ×45),
/// 客户端也确实把人生出来了(`Player.log` `[DRMK-HUD] RegisterHudCtrl id=830…41..45`),
/// 但**一动没动**(`packets-20260926-033355.log` + 客户端 `Player.log` 双向确认)。
/// ⇒ 与 `Ambient.cs:33-34` 的证伪记录一致:<c>SyncMoveWandering</c> 走
/// `WanderingActionModule` = **BaseUnit 管线**,而 Aether agent 是 **ECS 管线**
/// ⇒ 静默失败。`AmbientPedsChannel` 默认 `"crowd"` ⇒
/// `SpawnAmbientPedAsync` 里那句「static 通道必须发游荡包」的注释
/// **从没被真正跑过**,不能当依据。
///
/// ★★★ 第 47 轮再改:**默认由 `"waypoint"` 改成 `"none"`**。
/// 用户现场实测 waypoint 档:「**可以走了,不过是乱走**」,随即要求
/// 「有没有把角色**围成一圈**,**坐在地面的动作**……把他们在这个位置围成一圈」。
/// ⇒ 走路通道本身**已经跑通**(`SyncLogicAgentCommand` + `TaskWayPointMove` 有效,
/// `SyncMoveWandering` 无效),但「走起来」和「坐着围一圈」是互斥的:
/// walk 任务会把人**走散**(每 2 s 换一个随机方向的新路点),并且用行走姿态
/// **顶掉** POI 坐姿。位置与朝向**本来就对**(<c>SpawnPartyNpcsAsync</c> 圆周排布 +
/// `angle + π` 朝圆心)⇒ 关掉 locomotion 就是正解,不需要改坐标、也不需要改坐姿。
/// ⚠ 想再看走路 ⇒ 本键填 `"waypoint"`(营地半径见 `PartyNpcRoamMaxDis`,默认 10 m 偏大,
/// 建议先收到 3)。
/// </summary>
public string PartyNpcRoamMode { get; init; } = "none";
/// <summary>驻留行为是否补发 <c>SyncLogicAgentEnter</c>(68719890)。默认 true。</summary>
public bool PartyNpcLogicEnter { get; init; } = true;
/// <summary>驻留行为是否补发独立 <c>SyncAgentForceGo</c>(68698324)(启动行为树)。默认 true。</summary>
public bool PartyNpcForceGo { get; init; } = true;
/// <summary>
/// 游荡半径下限(米),<c>SyncMoveWandering.MinDis</c>。默认 <b>2</b>。
/// ⚠ 营地里有家具与碰撞体(铁律 17:落点必须实测站得住)⇒ 别调大,否则会走丢/穿模。
/// </summary>
public float PartyNpcRoamMinDis { get; init; } = 2f;
/// <summary>游荡半径上限(米),<c>SyncMoveWandering.MaxDis</c>(waypoint 档同时用作巡游半径)。默认 <b>10</b>。</summary>
public float PartyNpcRoamMaxDis { get; init; } = 10f;
/// <summary>单次游荡时长(秒),<c>MaxOnceWanderTime</c>;<c>MaxTime</c> 自动 +5s。默认 <b>25</b>。</summary>
public float PartyNpcRoamSeconds { get; init; } = 25f;
/// <summary>
/// 续期间隔(秒)。<c>MaxTime</c> 走完客户端会**永久站住**,所以必须周期性补发。默认 <b>18</b>。
/// 必须小于 <see cref="PartyNpcRoamSeconds"/>,否则中间会出现「站住」的空档。
/// </summary>
public float PartyNpcRoamRearmSeconds { get; init; } = 18f;
/// <summary>续期循环的节拍(毫秒)。默认 <b>2000</b>。</summary>
public int PartyNpcRoamTickMs { get; init; } = 2000;
/// <summary>
/// 游荡的 <c>PathTags</c>(客户端 AetherAI 的寻路标签)。默认 <b>6</b>,与
/// <c>Aether.AmbientPedsWanderPathTags</c> 同值(那条已在行人群上跑通)。
/// ⛑ 这是最容易踩的静默失败点之一(Ambient.cs 文件头)。
/// </summary>
public uint PartyNpcRoamPathTags { get; init; } = 6;
/// <summary>waypoint 档的巡游速度(m/s)。默认 <b>1.0</b>(散步)。</summary>
public float PartyNpcRoamSpeed { get; init; } = 1.0f;
/// <summary>waypoint 档每腿的额外余量(毫秒),用来吸收寻路绕行。默认 <b>1500</b>。</summary>
public int PartyNpcRoamLegSlackMs { get; init; } = 1500;
/// <summary>
/// ★★★ 第 47 轮:**覆盖派对 NPC 的落点圆心**(世界坐标,3 个 float = X / Y / Z)。
/// <b>默认空数组 = 用官方配表 <c>PartySpot</c></b>(露营 13030000 = <c>(1288.91, -0.09, 2374.47)</c>,
/// 见 <c>PartySpotCatalog4229938</c>)。
///
/// 用户现场:「他们出现的位置还是之前的,没有出现在我想要的位置」⇒ 给了「按我站的位置
/// <c>1282.93, 0.97, 2374.78</c>」。落点 = 以此点为圆心、<see cref="PartyNpcSpawnRadius"/> 为半径的圆周,
/// 人人**面朝圆心**(<c>SpawnPartyNpcsAsync</c> 的 <c>angle + π</c>)。
///
/// ⚠⚠ <b>只取 X / Z,Y 强制用官方 PartySpot 的 y(露营 = −0.09)</b> ——
/// 客户端 HUD 那行「位置」的 y 与配表/机关的 y **不是一个参考点**:同一片营地,
/// 03:44:15 与 03:54:59 两次截图都读到 <c>y ≈ 0.97~0.98</c>,而官方把整个营地摆在
/// <c>y = −0.09 ~ −0.30</c> 上(差 ≈ **1.1 m**,约等于角色眼高/胶囊中心)
/// ⇒ 直接把 0.97 用上去会让人**整队浮空 1.1 m**。
/// (想微调高度:让官方 PartySpot 查不到时才会用第 2 个数;正常情况第 2 个数被忽略。)
///
/// ★ 该键随 <c>config/*.json</c> **热重载**(改完重新进一次派对即可,不必重启服务端)。
/// 只影响派对 NPC 的落点;配表 <c>PartySpot</c> 的官方语义在别处照旧,零影响。
/// </summary>
public float[] PartyNpcAnchorOverride { get; init; } = [];
/// <summary>
/// ★ 第 48 轮:<see cref="PartyNpcAnchorOverride"/> **只对这一个 partyId 生效**
/// (默认 <b>13030000</b> = 露营·新启)。
/// WHY:那个覆盖原先对**所有**派对无条件生效 ⇒ 一开舞会(13030003),NPC 会被摆到
/// **露营**的那个圆周上,而不是舞会的 <c>PartySpot</c>。
/// 要覆盖别的场地(例如舞会)请用 <see cref="PartyNpcAnchorOverrides"/>,**不要改这个默认值**。
/// </summary>
public uint PartyNpcAnchorOverridePartyId { get; init; } = 13030000;
/// <summary>
/// ★ 第 48 轮:**按 partyId 分别覆盖落点圆心**,键 = partyId 的字符串
/// (<c>"13030003": [x, y, z]</c>;必须是字符串键 —— System.Text.Json 只原生支持字符串字典键)。
/// 优先级**高于** <see cref="PartyNpcAnchorOverride"/>。同样**只取 X/Z**,y 恒用官方
/// <c>PartySpot</c> 的 y(HUD 那个 y 是眼高,见 <see cref="PartyNpcAnchorOverride"/> 的说明)。
///
/// 例(舞会):<c>"partyNpcAnchorOverrides": { "13030003": [2605.33, 80, 1269.74] }</c>
/// ⚠ 该键随 <c>config/*.json</c> 热重载;⚠⚠ <c>private-server.json</c> 里**不能写 <c>//</c> 注释**
/// (<c>Run-All.ps1</c> 用 Windows PowerShell 5.1 的 <c>ConvertFrom-Json</c> 严格解析)。
/// </summary>
public Dictionary<string, float[]> PartyNpcAnchorOverrides { get; init; } = new();
/// <summary>
/// ★★★ 2026-09-30:**就地开局**。列在这里的 partyId,开派对时整片场地
/// (家具 + 舞伴站位)会搬到**玩家当时所在的位置**,而不是配表里的官方坐标。
///
/// WHY:舞会 13030003 的官方场地在 <c>y ≈ 80</c> 的悬空层 —— 那层<b>没有地板</b>,
/// 只有我们摆的 2 件常驻机关 + 客户端一个花坛,玩家实机只能踩道具 / 挂飞索才能不掉落
/// (见 <c>2026-09-30.md</c> §47)。「把玩家送上 y=80」这条路已被实机证伪,
/// 于是反过来把场地搬到玩家脚下。
///
/// 例:<c>"partyVenueAtPlayerPartyIds": [13030003]</c>(舞会就地开;露营不受影响)。
/// 默认 <b>空数组 = 关闭</b>,行为与改动前<b>完全一致</b>。
///
/// ⚠ 该键随 <c>config/*.json</c> <b>热重载</b>(<c>PrivateServerConfigStore</c>),
/// 改完<b>不用重启服务端</b>,下一场派对即生效。
/// ⚠⚠ <c>private-server.json</c> 里<b>不能写 <c>//</c> 注释</b>(PowerShell 5.1 严格解析)。
/// </summary>
public uint[] PartyVenueAtPlayerPartyIds { get; init; } = [];
/// <summary>
/// 就地开局时,**NPC 落点 y 相对玩家上报 y 的偏移**(米;正 = 抬高)。默认 <b>0</b>。
/// 为什么默认 0:实测(2026-09-30)就地开局把 NPC 放在「玩家 y − 1.07」时
/// **全员陷进地里只露半个身子** ⇒ NPC 的落点 y 与玩家上报 y 是<b>同一参考系</b>
/// (都是角色根),不需要再减眼高。想微调(浮空/下陷)就改这一个键,**热重载生效**。
/// ⛑ 家具是**另一套**参考系(比角色根低 ≈1.07 m),别拿这个值去套家具。
/// </summary>
public float PartyVenueNpcYOffset { get; init; } = 0f;
/// <summary>
/// 就地开局时,**以玩家为圆心把 NPC 随机散布**的半径(米)。<b>0 = 关闭</b>(保持官方摆法)。
/// 非 0 时角度与距离都随机(距离 ∈ [0.45×R, 1.0×R]),每人朝圆心(= 玩家)。
/// 例:<c>"partyVenueScatterRadius": 3.5</c> ⇒ 四个人散在你周围 1.6~3.5 m 处、都面朝你。
/// </summary>
public float PartyVenueScatterRadius { get; init; } = 0f;
/// <summary>
/// 就地开局时,**场地家具离玩家的最远距离**(米,只收距离、<b>保留官方方向</b>)。
/// <b>0 = 不限制</b>(照抄官方相对摆位)。
/// 为什么需要:官方舞会里灯光控制台在锚点外 <b>20 m</b>、舞会大门在 4.7 m ——
/// 搬到玩家脚下后控制台就在 20 m 开外,摸不到 ⇒ 设成 4~6 让它落在身边。
/// 例:<c>"partyVenuePropMaxDistance": 5</c>。
/// </summary>
public float PartyVenuePropMaxDistance { get; init; } = 0f;
/// <summary>
/// 就地开局时,把整片家具**旋转**到「最远那件落在玩家正前方」(默认 <b>true</b>)。
/// WHY:官方布局的方位是写死的世界方向(舞会灯光控制台在 +x/+z、约 76°),
/// 搬到玩家脚下后方向不变 ⇒ 玩家很可能背对着它,实测反馈「看不到控制台」。
/// 开启后按玩家朝向整体旋转(相对摆位不变,只是换个朝向)⇒ 控制台永远在你正前方。
/// 想保留官方世界方位就设 <c>false</c>。
/// </summary>
public bool PartyVenuePropFacePlayer { get; init; } = true;
/// <summary>
/// 就地开局时,**场地家具在「玩家 y − 眼高(1.07)」基础上再抬/压的高度**(米,可正可负)。
/// 默认 <b>0</b>。想微调就改这一个键,**热重载生效**(不用重启服务端)。
/// WHY:实测(2026-09-30 21:18~21:20 四次开局)家具恒定落在「玩家 y − 1.20」
/// (= 眼高 1.07 + 官方相对摆位 −0.13)。露营点这套补偿刚好,
/// 但在城里就地开局时地面起伏 / 站在台面上 ⇒ 控制台<b>埋进地里</b>
/// ⇒ 用户要求 +0.5。<c>0.5</c> ⇒ 控制台落在「玩家 y − 0.70」。
/// ⛑ 只影响<b>家具</b>,不影响 NPC 落点(那个是 <see cref="PartyVenueNpcYOffset"/>)。
/// </summary>
public float PartyVenuePropYOffset { get; init; } = 0f;
/// <summary>
/// ★ 第 49 轮:**舞会(<c>GamePlayType = 91056002</c>)的落点锚用哪张表**。取值:
/// <list type="bullet">
/// <item><c>"danceSpot"</c>(<b>默认</b>)—— 用官方<b>舞池点</b>
/// <c>PartyDanceSpotConfig.DanceSpot</c> + 朝向 <c>DanceRotation</c>
/// (13030003 新启舞会 = <c>(2605.33, 80, 1269.74)</c>,<c>DanceRotation = -84.1142578</c>)。
/// 读表实现见 <c>ClientData\Client4229938\PartyDanceSpotCatalog4229938.cs</c>。</item>
/// <item><c>"partySpot"</c> —— 退回 <c>PartyConfig.PartySpot</c>(派对<b>场地锚</b>;
/// 13030003 = <c>(2574.24, 80.135, 1271)</c>,与 <c>PartyCamp.json</c> 的
/// <c>danceOnly.pos</c> 是同一个点、也是舞会大门 <c>Gadgets_Party_DanceDoor</c>
/// (2569.63) 所在处)。</item>
/// </list>
///
/// ⚠ 两者<b>不是同一个点</b>(13030003 相距 ≈ 31 m):
/// <c>PartySpot</c> = 场地入口/「派对在这里举行」的关卡标记;
/// <c>DanceSpot</c> = 舞池(灯光控制台 2593.94 在其旁),也是客户端唯一消费的
/// 演出 timeline 位姿(<c>ChooseDanceMusicPanelStore.lua:319-332</c> ⇒ <c>PlayLiveHouseV2</c>)。
/// 默认取舞池,因为那是官方专门为「舞会」开的表。
///
/// ⚠ 无论这一项怎么设,<see cref="PartyNpcAnchorOverrides"/> 里的
/// <c>"13030003"</c> 都<b>优先</b>(它在解析链最前面)—— 这是想手工挪舞者站位时的入口。
/// ⚠ 舞会档的 y 用<b>官方舞池点的 y</b>(80),不再套用「只取 X/Z、y 用 PartySpot」的老规矩
/// —— 那条规矩是为露营那个「HUD y 是眼高」的问题设的,舞池点本身就有官方 y。
/// </summary>
public string PartyNpcDanceAnchor { get; init; } = "danceSpot";
/// <summary>
/// ★★★ 第 52 轮(B 方案):<b>室内归属声明</b>开关(<b>默认 <c>true</c></b>)。
/// <para>
/// 落点位于「<c>VisibleOutside = false</c>」的室内占地里时,<b>照常落位</b>,
/// 但把官方字段 <c>AgentSyncClientInfo.IndoorId</c>(+ <c>indoorList</c>)声明给客户端
/// ⇒ 客户端自己按<b>室内物</b>处理:室外不渲染、走进那间室内才可见。
/// </para>
/// <para>
/// <b>为什么需要它</b>:第 50 轮的 <see cref="IndoorVisibilityGate"/> 是「拦下不落位」,
/// 判据只比 x/z 投影 —— 用户当时 <c>x = 2613.6</c>、房间 <c>Spiko Hotel</c> 东边界 <c>2610</c>
/// ⇒ <b>只差 3.6 m</b>,往西一步就翻成「放行」,人又飞回空中。
/// 声明 <c>IndoorId</c> 才是官方语义:舞会的 NPC 本来就属于那间室内,只是我们以前
/// 当露天物摆(<c>IndoorId = 0</c>)。三处构造点在本次改动前<b>全部填空/0</b>。
/// </para>
/// <para>
/// <b>与 <see cref="IndoorVisibilityGate"/> 的关系</b>:本项为 <c>true</c> 时,
/// 那道门<b>自动失效</b>(不再拦截,改为声明);把本项设 <c>false</c> 即回到第 50 轮行为
/// (门按 <see cref="IndoorVisibilityGate"/> 生效)。
/// </para>
/// <para>
/// ⚠ 声明只作用于 <c>SendCivilianAsync</c> 这条静态 NPC 通道(含派对/市民/路人),
/// <b>不碰</b>机关(<c>SyncGadgetAdd</c>)与场地家具(<c>PartyCamp</c>)—— 后者仍按
/// <see cref="IndoorVisibilityGate"/> 过滤,本轮刻意不动(避免误伤已收尾的功能)。
/// </para>
/// </summary>
public bool NpcIndoorDeclaration { get; init; } = true;
/// <summary>
/// ★ 第 47 轮:派对 NPC 圆周的**半径**(米)。<b>默认 2.0</b>
/// (第 34 轮起的原硬编码值 = 2.5;第 47 轮用户现场定为 **2**)。
/// 想围得更紧就继续调小;太小会互相穿模(实测 &lt; 1.2 开始互相挤)。
/// </summary>
public float PartyNpcSpawnRadius { get; init; } = 2.0f;
/// <summary>
/// ★ 第 53 轮:派对 NPC 的**服装来源**。默认 <c>"dresscode"</c>。
///
/// <para>
/// 支持的值:
/// <list type="bullet">
/// <item><c>"dresscode"</c>(默认)—— 按官方配表给每个角色穿**它自己的**套装:
/// ① 客户端真的送来了 <c>Rents[spiritId]</c>(只有舞会那条路送得出来)就照它穿;
/// ② 否则查 <c>FashionSpiritDressCodeConfig</c>(<c>SpiritId</c> + <c>TagList ∋ PartyConfig.SuitTag</c>,
/// 取 <c>RecommendPriority</c> 最小者 = 原皮),拿不到再放宽 tag;
/// ③ 都查不到 ⇒ 不指定 ⇒ 客户端回落 <c>11190001</c>。</item>
/// <item><c>"off"</c> —— 完全不干预,保持第 53 轮之前的行为(<c>SuitId = 0</c> ⇒ 所有角色
/// 都落 <c>11190001</c>「Graffiti Set」= Bansy 的涂鸦套装)。留作对照 / 回滚。</item>
/// </list>
/// </para>
///
/// <para>
/// 为什么默认开:用户现场「派对功能可以自订邀请角色的外观服装,但是选择后开始露营显示的还是
/// 默认服装」。实测已证**露营这条路上客户端根本送不出玩家选的服装**
/// (<c>PartyConfig#13030000.FashionsCanRent</c> 是空数组 ⇒ 换装面板的租借列表恒空 ⇒
/// 三条抓包报文 30 字节全同、都是 <c>{Rents={}, UseDefaultFashion=true}</c>)
/// ⇒ 服务端**只能**自己按配表决定,否则永远是同一套默认衣服。
/// </para>
///
/// <para>
/// ⚠ 数据表在 <c>ClientData/4229938/Configs/</c> 下:<c>FashionSpiritDressCodeConfig.json</c>、
/// <c>FashionRentConfig.json</c>、<c>FashionSuitConfig.json</c>(后者只用于日志里的人名/套装名)。
/// 缺表不会崩:<c>PartyFashionCatalog4229938</c> 全程 try/catch,空表 ⇒ 回落 <c>SuitId = 0</c>。
/// </para>
/// </summary>
public string PartyNpcFashion { get; init; } = "dresscode";
/// <summary>
/// ★ 覆盖下发给客户端的 <c>AgentSyncClientInfo.AISetting</c>(行为集 id,
/// 表 <c>AgentAIBehaviorSetsBattleAISBehaviorConfig</c>)。<b>默认 0 = 不覆盖</b>,
/// 即沿用 <c>AgentConfig.AiSettingId</c>(可邀角色是 Companion 档,无游荡行为)。
///
/// 想试「用市民档让客户端自己的 AI 去游荡」就填 <b>40213001</b>(普通男性市民战斗AI)或
/// <b>40213002</b>(女性)—— 它们的 <c>Behavior_Wander = Behavior_Common_Wander</c>、
/// <c>Behavior_StandBy = Behavior_Common_Patrol</c>。
/// ⚠ 副作用未知:市民档带 Alert/Detect/Search/Goback(会对玩家起反应、可能「回家」),
/// 所以**默认不开**;只作为 <see cref="PartyNpcRoamMode"/> 两条路都失败时的第三手。
/// </summary>
public uint PartyNpcAISettingOverride { get; init; } = 0;
/// <summary>
/// ★ 覆盖下发给客户端的 <c>AgentSyncClientInfo.treeName</c>。<b>默认 "" = 不覆盖</b>
/// (沿用 <c>"PedBase"</c>,donor 逐字移植的已验证值)。
/// 仅当 <see cref="PartyNpcAISettingOverride"/> 也填了、且怀疑 treeName 必须与行为集的
/// <c>BehaviorTree</c> 名对齐时才需要(市民档对应 <c>"Main_Common_NPC"</c>)。
/// </summary>
public string PartyNpcTreeNameOverride { get; init; } = "";
public TalentTreeSettings Talents { get; init; } = new();
public HackNpcSettings HackNpc { get; init; } = new();
public TaskPanelSettings TaskPanel { get; init; } = new();
/// <summary>
/// 地图入口(大地图上的传送点 / 地铁站图标)—— 2026-09-27 从 kpect 捐赠端移植的
/// 「入口四连发 + 点击真传送」,数据用我们自己的 <c>MapentranceConfig</c>(93 行)。
/// 见 <c>GameRouter.MapEntrance.cs</c>。
/// </summary>
public MapEntranceSettings MapEntrance { get; init; } = new();
/// <summary>
/// 车载电台的<b>服务端那一半</b>:歌曲解锁在登录帧里已经做完了
/// (<c>RuntimePayloadFactory</c> 把 59 首全部塞进 <c>PlayerRadioSongsData.SongInfoDict</c>),
/// 这里只管 <c>AskGetVehicleRadioContent</c> 返回的「串词内容」(DJ 口播 / 广告 / 新闻)。
/// 见 <c>GameRouter.VehicleRadio.cs</c>。
/// </summary>
public VehicleRadioSettings VehicleRadio { get; init; } = new();
/// <summary>
/// ★ 房产「自带家具」—— <c>gameplay.houseFurniture</c>(2026-09-30 新增)。
///
/// <para><b>症状</b>:手机 app 买房成功、所有权也落地了,但走进房子是<b>毛坯房</b>,
/// 一件家具都没有(用户实测:旧物仓公寓 / 素光小筑)。</para>
///
/// <para><b>根因</b>:客户端进房子后会发 <c>AskLoadHouseData</c>(63109556) 问
/// 「这栋房子长什么样」,我们一直回的是权威兜底表那份 <b>57 字节全空</b>的
/// <c>HouseBuildingClientData</c>(<c>FloorBuildInfoDict</c> 是空表)⇒ 客户端认为
/// 这房子没摆任何家具。日志铁证:<c>logs/packets-20260930-010341.log</c> 里
/// <c>AskLoadHouseData</c> 4 次(houseId = 11200003 / 11200005 / 11200004 / 11200006),
/// 每次都是 <c>body=4b → RETURN error=0 body=57b</c>。</para>
///
/// <para>打开后 handler 会按
/// <c>houseId → HouseConfig.BuildId → HouseBuildConfig.FurnitureTemplateIds →
/// HouseFurnitureTemplateConfig</c> 把房子<b>自带的那套家具</b>生成进
/// <c>FloorBuildInfoDict</c>。回滚 = <c>false</c>(回到 57 字节空房)。</para>
/// </summary>
public HouseFurnitureSettings HouseFurniture { get; init; } = new();
/// <summary>
/// ★ 车库 / 停车 —— <c>gameplay.houseGarage</c>(2026-09-30 新增)。
///
/// <para><b>症状</b>:走到自家车位按 F「调取载具」能开选车面板(纯客户端 UI),
/// 选完车后按 F「驶离车库」<b>毫无反应</b>;车也不出现在车位;车库大门不开。</para>
///
/// <para><b>根因</b>:车库这一族 9 条 RPC 在服务端一直是空 body 兜底,且两个关键 S2C
/// <c>SyncHouseParking</c>(64277819) / <c>SyncHouseParkingEntityBinding</c>(64060781)
/// <b>服务端从来不发</b>。客户端 <c>GarageManager.LeaveGarage</c> 第一步就读
/// <c>currentParkingInfo.ParkingVehicleUid</c>(只能由 <c>SyncHouseParkingEntityBinding</c> 填)
/// ⇒ 为空 ⇒ <c>FatalLog + return</c> ⇒ 连 <c>AskTeleportFromHouseGarage</c> 都发不出去。</para>
///
/// <para>打开后:<c>AskHouseParking</c> / <c>AskHouseMoveParkingSpace</c> 会记账并回推
/// <c>SyncHouseParking</c>(客户端据此播进库演出 + 自己发 <c>AskCreateHouseParkingVehicles</c>);
/// 后者让服务端 <c>SpawnDirectAsync</c> 出一辆车并回推
/// <c>SyncHouseParkingEntityBinding</c>(填 <c>ParkingVehicleUid</c>);
/// <c>AskTeleportFromHouseGarage</c> 开门 + 结束出库流程。回滚 = <c>false</c>
/// (回到 9 条空 body 兜底,与改动前逐字节一致)。</para>
/// </summary>
public HouseGarageSettings HouseGarage { get; init; } = new();
}
/// <summary>
/// 一个「实机实测的电梯落点」覆盖项(<c>gameplay.mapEntrance.jiguanLandingOverrides[]</c>)。
///
/// <h3>为什么需要它</h3>
/// <c>JiguanElevatorCatalog4229938</c> 推导出来的落点是<b>门自己的坐标</b> —— 那是「门标记」的位置,
/// 不等于「人站上去不会掉下去」的那一格。2026-09-30 实机:337 上高楼层时人出现在
/// <c>(2277.54,160.60,1412.50)</c> 的半空中、然后往下掉;玩家实测正确落点是
/// <c>(2277.67,160.73,1411.35)</c>(Z 差 1.15m,Y 高 0.13m —— 门标记在电梯井里侧,人在外侧)。
///
/// 所以:几何推导给「哪一层 / 哪扇门」,这张表给「那一格精确坐标」。
/// 配置是热重载的,量出来一条就能立刻验证,不用重编译、不用重启。
/// </summary>
public sealed class JiguanLandingOverride
{
/// <summary>落点门 id(<c>MapentranceConfig.Id</c>,也就是 <c>SyncTeleport.MapEntranceId</c> 填的那个)。</summary>
public uint DestEntranceId { get; init; }
/// <summary>落点 X / Y / Z(世界坐标,米)。</summary>
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
/// <summary>朝向(度)。不填 / <c>null</c> ⇒ 沿用落点门自己的 <c>EntranceFacing</c>。</summary>
public float? Facing { get; init; }
}
/// <summary>
/// 车库 / 停车(<c>gameplay.houseGarage</c>)。见
/// <see cref="GameplaySettings.HouseGarage"/> 的说明。
/// </summary>
public sealed class HouseGarageSettings
{
/// <summary>总开关。<c>false</c> = 全部回落到空 body 兜底(逐字节等于本次改动前)。</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 车辆 spawn 相对玩家的前向偏移(米)。默认 <c>0</c> = 就落在玩家脚下
/// (此时玩家正站在车位上 —— 进库演出刚把玩家放到 <c>ParkingSpaceWaypoints[i]</c>)。
/// 若车和玩家/墙重叠,可调大让它往前挪一点。配置热重载,改完不用重启。
/// </summary>
public float SpawnForwardOffset { get; init; }
/// <summary>出库时把车库大门推开(复用 <c>PushDoorOpen</c> + 缓存的门 gadgetId)。</summary>
public bool OpenDoorOnExit { get; init; } = true;
/// <summary>
/// 出库落点覆盖表(热重载)。默认空 = 用 <c>HouseConfig.VisitorExit</c> 的绝对坐标。
///
/// <para>为什么需要:客户端出库事务(<c>AskTeleport config=7 beforeResName=loading_garage_out*</c>)
/// 自己算的落点是 <c>HouseConfig.GarageOutWaypoint</c>(<b>Spoon 航点 id</b>,服务端没有
/// Spoon 位置表);而且 <c>ExitTimeline</c> 只有 1 个元素的房(素光小筑)客户端连 Spoon 反查
/// 都不做,<c>afterPosition</c> 直接留全零 ⇒ 过场结束会被甩到世界原点。
/// 服务端唯一现成的绝对坐标落点就是 <c>VisitorExit</c>,实测同层且就在房门外。
/// 如果实测落点不合适,在这里按 houseId 覆盖,改完即时生效。</para>
///
/// <code>
/// "houseGarage": {
/// "exitLandingOverrides": [
/// { "houseId": 11200006, "x": 1104.8, "y": 8.53, "z": 2391.8, "facing": 0 }
/// ]
/// }
/// </code>
/// </summary>
public List<HouseExitLandingSettings> ExitLandingOverrides { get; init; } = [];
/// <summary>打印逐条车库日志。</summary>
public bool VerboseLog { get; init; } = true;
}
/// <summary>出库落点覆盖的一条(见 <see cref="HouseGarageSettings.ExitLandingOverrides"/>)。</summary>
public sealed class HouseExitLandingSettings
{
/// <summary><c>HouseConfig.Id</c>。</summary>
public uint HouseId { get; init; }
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
public float Facing { get; init; }
}
/// <summary>
/// 房产自带家具(<c>gameplay.houseFurniture</c>)。
///
/// <para>数据源两张官方表(<c>ClientData/4229938/Configs/</c>,2026-09-30 从
/// <c>E:\ConfigDump\ConfigDump_v3\</c> 原样拷入):</para>
/// <code>
/// HouseBuildConfig.json 4 行 buildId → IndoorId / Floors / FurnitureTemplateIds
/// HouseFurnitureTemplateConfig.json 322 行 家具模板树(叶子带绝对世界坐标)
/// </code>
/// <para>4 套房各自的模板:绿屿公馆 <c>14001</c>(147 件)/ 旧物仓公寓 <c>10013</c>(36 件)/
/// 观澜北岸 <c>14000</c>(86 件)/ 素光小筑 <c>13000</c>(44 件)。</para>
/// </summary>
public sealed class HouseFurnitureSettings
{
/// <summary>总开关。<c>false</c> = 回到 57 字节空房(逐字节等于本次改动前)。</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// <b>家具机关(可互动家具)</b>开关 —— 2026-10-01 新增,默认开。
///
/// <para><c>false</c> = <c>AskCreateHouseFurnitureGadgets</c>(63581446) 回到恒空映射
/// (<c>[0xFF,0x01]</c>,逐字节等于本次改动前)⇒ 椅子坐不了、衣柜开不了、电视点不亮。</para>
///
/// <para>背景:家具<b>模型</b>由 <c>FloorBuildInfoDict</c> 下发(已上线),但客户端还会再问一次
/// 服务端要「机关实例 id」(<c>HouseGadgetManager.CreateGadgetsForFurnitures</c>)。
/// 判据:请求的 id 个数逐套精确等于该房「<c>HouseFurnitureConfig.PathId != 0</c>」的家具数
/// (绿屿 33/33、旧物仓 3/3、观澜 6/6、素光 1/1)。详见
/// <c>GameRouter.HouseFurnitureGadget.cs</c>。</para>
/// </summary>
public bool Gadgets { get; init; } = true;
/// <summary>
/// 要不要真的用 <c>SyncGadgetAdd</c>(68843189) 把机关<b>实体</b>摆进世界 —— 默认开。
///
/// <para><b>这是实测分流开关,不是功能开关</b>。客户端行为有两种可能:</para>
/// <list type="number">
/// <item><b>true(默认,H1)</b>:机关实体得服务端造,客户端
/// <c>OnGadgetSlotCompLoaded</c> 的 <c>gGadgetManager:GetEntitySearchByInstanceId(gadgetId)</c>
/// 才找得到它。</item>
/// <item><b>false(H2)</b>:机关 GameObject 本来就在家具 prefab 里
/// (<c>RegisterFurnitureLuaSlotReplace</c> 用的就是 <c>houseFurniture.gadgetGameObject</c>),
/// 服务端只需发一个唯一非 0 id,客户端 <c>TrySetGadgetInstanceId</c> 自己点亮。</item>
/// </list>
///
/// <para><b>判据</b>:H1 若把椅子 / 灯渲染成两遍(模型重了一层)就是猜错了 ⇒ 改成 <c>false</c>。
/// 机关 prefab 取自 <c>HouseFurnitureConfig.PathId</c>,位置取模板节点的世界坐标 +
/// <c>GadgetOffset</c>,<c>uniqueId = placedId</c>(与返回的 <c>gadgetInstanceId</c> 同源)。</para>
///
/// <para>⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译、不用重启。</para>
/// </summary>
public bool SpawnGadgetEntities { get; init; } = true;
/// <summary>
/// 摆机关实体走哪条推送通道 —— 默认 <c>add</c>。
///
/// <para><b>为什么会有这个开关</b>:2026-10-01 上机实测,<c>add</c>(<c>SyncGadgetAdd</c>)
/// 在服务端日志上一切正常(44 条 <c>[GADGET] add</c>,<c>id</c>/<c>pathId</c>/坐标全对),
/// 但客户端 <c>Player-prev1.log</c> 里 <c>GadgetInstanceRoot</c> 下<b>只有 blob 自带的
/// <c>NewLiftTest_…</c></b>,<b>没有任何家具机关实体</b> ⇒ 家具模型不出现(非编辑态客户端会
/// 隐藏自己那份 <c>luaSlotReplaceGo</c>,专等服务端实体补位)。</para>
///
/// <para>这正是 <c>GameRouter.Gadget.cs</c> 里记过的那个场景:「Add builds the entity
/// (name, position, interact point all correct) yet the prefab never appears」——
/// 两个客户端 handler 是不同方法(<c>0x12787ED0</c> vs <c>0x1276CF20</c>),
/// 可能不共用资源路径。<c>addList</c> 就是外部工具 <b>AnantaGymLoader.exe</b> 走的那条。</para>
///
/// <para>取值:</para>
/// <list type="bullet">
/// <item><c>add</c>(默认)—— 逐件 <c>SyncGadgetAdd</c>(68843189)。</item>
/// <item><c>addList</c> —— 一次 <c>SyncGadgetAddList</c>(68925813),N 件一个包。</item>
/// <item><c>both</c> —— 两条都发(诊断用)。<b>判据</b>:若椅子/灯渲染成两遍,
/// 说明两条通道都生效、真正的问题是别的;若只出现一遍,那出现的那条就是唯一可用通道。</item>
/// </list>
///
/// <para>⛑ 热重载 ⇒ 改完<b>出房子再进去一次</b>即可,不用重编译、不用重启。</para>
/// </summary>
public string Channel { get; init; } = "add";
/// <summary>
/// ★★ 2026-10-01:每次<b>网格 AOI 推送之后</b>把家具机关实体补发一遍 —— 默认开。
///
/// <para><b>病因</b>:<c>SyncGadgetGridAOIDecrease</c> 的 <c>addGridInfo</c> 在客户端是
/// 「当前 AOI 快照」语义(<c>GameRouter.GadgetGrid.cs</c> 的注释早有记录)。世界摆件撤掉后
/// 客户端能从自己的 blob 再铺出来;<b>家具机关是纯服务端造的、blob 里没有 ⇒ 一撤就永久消失</b>。</para>
///
/// <para><b>实测</b>(<c>Player.log</c> 2026-10-01 10:23–10:24):
/// <c>10:23:26 SyncGadgetAddList</c> → <c>10:23:34 entityHits=33/33</c> →
/// <c>10:23:37 SyncGadgetGridAOIDecrease</c>(之后每 5 秒一次)→
/// <c>10:23:56 entityHits=0/33</c>。该窗口内唯一的机关类 S2C 就是它。
/// ⇒ 症状就是用户说的「沙发椅子已经生成了的没法交互」。</para>
///
/// <para><c>false</c> = 回到「建出来 20 秒后被网格快照撤掉」的行为。
/// ⛑ 热重载 ⇒ 改完出房子再进去一次即可,不用重编译。</para>
/// </summary>
public bool RespawnAfterGridPush { get; init; } = true;
/// <summary>
/// <c>FloorBuildInfoDict</c> 的<b>键</b>取什么。
///
/// <para><b>已定案(2026-09-30):键 = 楼层号,1 基,地面层 = 1,取值 <c>1..Floors</c>。</b>
/// 默认 <c>floor</c> 就是正解,正常情况下不用动。</para>
///
/// <para>证据(全部来自客户端自己的文件):</para>
/// <list type="bullet">
/// <item><c>HouseBuildConfig.FloorsName</c> = <c>[{floor:1,__RAW__name:"1F"}]</c>;
/// 素光小筑 <c>[{1,"1F"},{2,"2F"}]</c>,长度恰等于 <c>Floors</c>。</item>
/// <item><c>BuildBound</c> / <c>CameraBound</c> / <c>CameraTopViewPoint</c> 都用 <c>floor: 1,2</c>。</item>
/// <item><c>HomeBuildPanelStore_Floor.lua</c>:<c>SelectOption(initFloor - 1)</c>、
/// <c>houseCamera.floor = index + 1</c> ⇒ 游戏侧 1 基,只有 UI 选项 0 基;
/// <c>GetFloorByPlayerPosition</c> 兜底 <c>return 1</c>。</item>
/// <item><c>HouseUtils.lua</c> 的 <c>floor = floor or 0</c> 只是参数缺失兜底,0 是非法值
/// (会走 <c>"无法找到楼层[0]的家具信息"</c>)。</item>
/// <item>⚠️ <c>LuaGmUtils.lua:166</c> 的
/// <c>for floorKey, indoorBuildInfo in pairs(buildData.FloorBuildInfoDict)</c>
/// 会<b>对每个键都加载一遍家具</b>(去重表建在键循环体内)⇒ <b>多写键 = 家具被创建多遍</b>。
/// 所以不能「把所有候选键都写上碰运气」,默认必须是单一正确键。</item>
/// </list>
///
/// <para>取值:</para>
/// <list type="bullet">
/// <item><c>floor</c>(默认,正解)—— 挂 <c>1..Floors</c>。</item>
/// <item><c>indoor</c> —— 只挂 <c>IndoorId</c>。<b>诊断用</b>:<c>GmSyncHouseIndoorBuildInfo</c>
/// 那条 GM 链路确实按 indoorId 组织,若登录链路被证伪可切过去试。</item>
/// <item><c>all</c> —— 挂 <c>{1..Floors} ∪ {IndoorId}</c>。<b>诊断用,一定会重复创建家具</b>,
/// 只用于确认「客户端到底取哪个键」,别长期开。</item>
/// </list>
///
/// <para>⛑ 这个值<b>每次请求现读</b> ⇒ 改 <c>config/private-server.json</c> 后
/// <b>不用重启</b>,出房子再进去一次即可换一种试。</para>
/// </summary>
public string KeyMode { get; init; } = "floor";
/// <summary>
/// 打印每个家具节点的逐条日志(147 件 × 每件一行)。默认关 —— 只在排查线格式时才开。
/// </summary>
public bool VerboseLog { get; init; }
}
/// <summary>
/// 地图入口(<c>gameplay.mapEntrance</c>)。
///
/// 客户端的三步走:<c>SyncMapEntrance</c>(64067363) 全量清单 →
/// <c>UpdateMapEntrance</c>(64239821) 逐点开关 → <c>SyncPrepareMapEntrance</c>(64610549) 逐点准备;
/// 再加一条 <c>SyncSubwayFare</c>(64660858) 给地铁支付环节一个金额。
/// 四帧在此之前<b>一条都没发过</b>(全服 grep 零命中),所以大地图上的传送点/地铁图标
/// 根本没有可见性数据。逐项可关,回滚不用重编译。
/// </summary>
public sealed class MapEntranceSettings
{
/// <summary>总开关:<c>false</c> = 四帧都不发、点入口也不传送,逐字节回到移植前。</summary>
public bool Enabled { get; init; } = true;
/// <summary>发 <c>SyncMapEntrance</c> 全量清单(openEntrance / displayableEntrances 两个列表)。</summary>
public bool PushSync { get; init; } = true;
/// <summary>发 <c>UpdateMapEntrance</c> 逐点开关(isOpen/isShow 都设 true)。</summary>
public bool PushUpdate { get; init; } = true;
/// <summary>
/// 发 <c>SyncPrepareMapEntrance</c> 逐点准备。
/// 客户端 lua 里没有这一帧的消费点(只在 <c>RPCMethodIdToName</c> 出现),但 kpect 的判据是
/// 「少了这步,交通层(地铁 / 机场,ShowType=2)的入口不会真正注册进地图」。留开关做 A/B。
/// </summary>
public bool PushPrepare { get; init; } = true;
/// <summary>发 <c>SyncSubwayFare</c>(地铁票价,单位见 <see cref="SubwayFare"/>)。</summary>
public bool PushSubwayFare { get; init; } = true;
/// <summary><c>SyncSubwayFare</c> 的金额(u32)。0 = 免费。</summary>
public uint SubwayFare { get; init; } = 200;
/// <summary>
/// <c>AskTouchMapEntrance</c>(63284182):点入口是否真的传送。
/// 客户端是 <c>void</c> notify(不等回包),最坏情况就是「和以前一样没反应」。
/// </summary>
public bool TouchTeleport { get; init; } = true;
/// <summary>
/// 允许<b>跨 raid</b> 传送(目标入口的 RaidId ≠ 当前 ActiveRaidId 时走 <c>SwitchSceneAsync</c>)。
/// 关掉的话跨城点击只记日志不动作,同图传送仍然生效。
/// </summary>
public bool TouchCrossRaid { get; init; } = true;
/// <summary>
/// 回显带 timeline 的 <c>AskTeleport</c> 事务(<c>SyncPreTeleportOption</c>)。
///
/// ⛑⛑ 2026-09-29 实测根因:长按 F / 点「交通直达」时客户端**确实**发了
/// <c>AskTeleport config=5 after=(1878.8,-1.1,2250.5 res=loading_metro_end_1)</c>
/// —— 就是地铁乘车动画事务。但服务端只 ack 不回显 ⇒ 客户端的 loading 流程停在第 2 步
/// (<c>AskTeleport → SyncPreTeleportOption → ReportPreTeleportFinish → domain RPC</c>),
/// 回调不执行 ⇒ domain RPC 一个都不发(日志里 <c>AskPlayerChangePositionByScenePortal</c> /
/// <c>AskPublicSwitchToPublicScene</c> 全是 0 次)⇒ 表现为「长按 F 没反应」。
///
/// 判据(见 <c>GameRouter.Teleport.cs</c>):<c>beforeResName / loadingResName / afterResName</c>
/// 任一非空,即「这个事务要播 timeline」,原样回显客户端自己发来的 option。
/// 改装店(config=8)走它自己那条 <c>PrepareCarShopOption</c> 分支,优先级更高。
/// 回滚 = <c>false</c>(只 ack 不回显,逐字节回到本次改动前)。
/// </summary>
public bool EchoPreTeleport { get; init; } = true;
/// <summary>
/// 景点 / 电梯传送门(<c>MapentranceConfig.Type == 1</c>)—— 2026-09-30 新增。
///
/// ⛑⛑ 根因(2026-09-30 01:07:55 实测):客户端 <c>RpcUtils.AskEnterRaidByMapEntrance</c> 走
/// <c>AskTeleport(config=2) → SyncPreTeleportOption → ReportPreTeleportFinish →
/// AskEnterRaidByMapEntrance(337)</c>,其中 <c>afterPosition</c> 是全零、<c>customAfterTrans=false</c>
/// ⇒ <b>客户端不知道目的地,等的是服务端那一帧 <c>SyncTeleport</c></b>。
/// 而原来那个 handler 只 <c>ReturnEmptyOkAsync</c> ⇒ 客户端永远停在 loading
/// (<b>黑屏、ESC 返回不了</b>),86 秒后 <c>ReportPostTeleportFinish result=2</c>。
///
/// 打开后 handler 会推 <c>SyncTeleport</c>(同 raid)或走 <c>SwitchSceneAsync</c>(跨 raid)。
/// 目的地推导见 <c>JiguanElevatorCatalog4229938</c>(不需要 Spoon 数据)。
/// 回滚 = <c>false</c>(只 ack,逐字节回到本次改动前)。
/// </summary>
public bool JiguanElevator { get; init; } = true;
/// <summary>
/// 电梯分组半径(米):两条景点门三维距离小于它就当成同一台电梯。
///
/// 为什么需要它:绿屿公馆的竖井跨「大厅 Y=0 ↔ 高楼层 Y=160.6」共 168m,
/// 光靠距离连不起来,靠的是「同目标 id」和「同坐标」两条规则;而 317/318 这种
/// 只有两个门、跨度 38.7m 的短竖井,只能靠半径连。
/// 默认 60 已经能覆盖全表且不会误并相邻建筑(最近的跨建筑对是 318↔329 = 126m)。
/// </summary>
public float JiguanGroupRadius { get; init; } = 60f;
/// <summary>
/// <c>SyncTeleport.MapEntranceId</c> 填哪个 id(2026-09-30 二修)。
///
/// <b>为什么这是个开关而不是写死</b>:一修把「黑屏」修好了(<c>ReportPostTeleportFinish
/// result=0</c>),但玩家<b>落地还在原地</b>。对照同一会话里成功的大地图传送那一帧,
/// 唯一的差别是 <c>MapEntranceId</c> 填的是<b>目的地</b>入口,而景点链路填的是玩家按下的
/// 那扇<b>起点</b>门。客户端 <c>CommonFuncTeleportFlowInfo</c> 有
/// <c>AfterTargetMapEntranceId</c> 与不吃参数的 <c>ReParse_AfterData()</c>(只能读服务端推下来的
/// <c>teleportOption</c>),而本链路客户端自己的 <c>afterPosition</c> 是全零 ⇒ 落点只能靠
/// <c>MapEntranceId</c> 反查 ⇒ 填起点 = 原地。
///
/// <list type="bullet">
/// <item><c>"dest"</c>(<b>默认</b>)= 落点那一层的门 id。</item>
/// <item><c>"source"</c> = 起点门 id。留作 A/B / 回滚,已知症状是「原地不动」。</item>
/// <item><c>"none"</c> = 填 <c>0</c>。2026-09-30 四修加的 A/B:
/// 客户端 <c>CommonFuncTeleportFlowInfo.OnSyncTeleport() → ReParse_AfterData()</c> 在
/// <c>MapEntranceId != 0</c> 时会去 <c>MapentranceConfig</c> / Spoon 航点反查落点,
/// 而本地没有 Spoon 图(见 <c>JiguanElevatorCatalog4229938</c> 的说明)⇒ 反查可能落空。
/// 填 0 就是逼客户端只用 <c>Position</c>。改配置即时生效,用来判断「反查落空」这个猜想。</item>
/// </list>
/// </summary>
public string JiguanEntranceIdMode { get; init; } = "dest";
/// <summary>
/// 电梯落点兜底(<b>2026-09-30 四修</b>)。
///
/// <h3>为什么还需要它</h3>
/// 前三修已经把服务端这一侧做到了「逐字节正确」:14:19:11 的实测报文里
/// <c>SyncPreTeleportOption</c> 的 <c>afterPosition</c> 已改写成
/// <c>(2277.536,160.595,1412.501)</c>、<c>customAfterTrans=true</c>;
/// 14:19:16 的 <c>SyncTeleport</c> 也带着同一坐标 + <c>MapEntranceId=343</c>。
/// 但客户端 <c>[INDOOR]</c> 在 14:19:12 / :17 / :22 三次都报 <c>indoorId=332</c>(绿屿大厅)
/// —— <b>它一帧都没采纳</b>,玩家原地不动。
///
/// 所以这一层不再猜客户端的 loading 流程,直接用<b>已被实测证明有效</b>的硬设位置通道:
/// 同一会话 14:16:48 后台「瞬移」把玩家放到 <c>(2627.48,188.74,2626.95)</c>,
/// 14:17:15 客户端立刻报 <c>[INDOOR] 进入 indoorId=298</c>(观澜公馆主体)——
/// 高楼层是可达的,通道是通的。
///
/// <c>true</c>(默认)= <c>ReportPostTeleportFinish</c> 时消费
/// <c>AskEnterRaidByMapEntrance</c> 记下的落点,用 <c>SyncUnitPositionAndFacing</c>(68030312)
/// 把人放到目的地(立刻一帧 + 500ms 后再补一帧,防客户端还在收尾时吞掉第一帧)。
/// 代价:过场动画照播,但落地是「瞬移」而不是过场自己走完 —— 观感上可以接受。
///
/// <c>false</c> = 只推 <c>SyncTeleport</c>(前三修的行为),用于 A/B 与回滚。
/// </summary>
public bool JiguanHardSet { get; init; } = true;
/// <summary>
/// 实机实测的落点覆盖表(热重载)。按<b>落点门 id</b> 匹配,优先于几何推导
/// 与 <c>JiguanElevatorCatalog4229938.MeasuredLandings</c>。
///
/// 例(把绿屿高楼层的落点从「门标记」挪到玩家实测能站稳的那一格):
/// <code>
/// "mapEntrance": {
/// "jiguanLandingOverrides": [
/// { "destEntranceId": 343, "x": 2277.67, "y": 160.73, "z": 1411.35 }
/// ]
/// }
/// </code>
/// 只改 <c>X/Y/Z</c>,不填 <c>facing</c> 就沿用门自己的朝向。改完即时生效。
/// </summary>
public List<JiguanLandingOverride> JiguanLandingOverrides { get; init; } = new();
}
/// <summary>
/// 车载电台(<c>gameplay.vehicleRadio</c>)。
///
/// 服务端这一半只有一件事:<c>AskGetVehicleRadioContent</c>(67725622) 是 INVOKE,
/// 返回值是 <c>UX.Game.RadioRandomContent</c>(ContentType u8 / ContentId u32 /
/// StartContentId u32 / EndContentId u32 / MarkType u8),客户端拿它去
/// <c>RadioPlayerManager.GetVoicesByContent</c> 查 <c>RadioContentDJs / Ads / News / SpecialNews</c>
/// 四条表,播「歌与歌之间的 DJ 串词」。目前回的是全零 typed-default ⇒ 四条表全查不到
/// (客户端日志会是四行「配表找不到」)⇒ 只有歌、没有串词。
/// </summary>
public sealed class VehicleRadioSettings
{
/// <summary>总开关:<c>false</c> = 继续回全零 typed-default(移植前的行为)。</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 串词播在歌的哪个 cue 上(<c>UX.Game.RadioMarkType</c>:1 = Middle,2 = End)。
/// 客户端 <c>OnRadioMusicMark</c> 的判断是反编译出来的(<c>~=</c> 看着像被翻转过的 <c>==</c>),
/// 两个值都留着实机试,改配置不重编译。
/// </summary>
public uint MarkType { get; init; } = 1;
/// <summary>
/// 同一类串词的最小间隔秒数。官方静态表 <c>RadioContentConfig</c> 是 600 秒
/// (DJChatCd / DJCommentCd / NewsCd / AdCd 全都是 600);设 0 表示不限制。
/// </summary>
public uint ContentCooldownSeconds { get; init; } = 0;
/// <summary>
/// 允许抽到的串词类型(<c>UX.Game.RadioContentType</c>:1=Ad 2=News 3=Chat(DJ 口播)
/// 4=Comment(DJ 口播) 5=SpecialNews)。留空 = 按权重自动抽全部。
/// </summary>
public uint[] ContentTypes { get; init; } = [];
}
/// <summary>
/// Task / Mission panel publication.
///
/// The panel list is fed by <c>eventPanelInfo.EventsInfo</c> inside SyncPlayerAllTask(64260853) —
/// it renders TaskEventConfig rows (events), NOT TaskConfig rows. Every option here can be turned
/// off from config, no rebuild needed, because the whole feature was parked twice already: pushing
/// rows without events shows nothing, and pushing them as Accepted once made ESC offer "settle the
/// current challenge" instead of opening the phone.
/// </summary>
public sealed class TaskPanelSettings
{
/// <summary>
/// Master switch. False (default) ships nothing and leaves the panel to the client — the state
/// this feature was parked in.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Which TaskConfig rows go into <c>taskInfos</c>: "catalog" keeps the historical full dump
/// (all 4342 rows), "configured" publishes only <see cref="TaskIds"/> (how far smaller an
/// authored task list the reference branch uses — one task plus its event).
/// </summary>
public string TaskIdsSource { get; init; } = "catalog";
/// <summary>Explicit TaskConfig ids used when <see cref="TaskIdsSource"/> is "configured".</summary>
public uint[] TaskIds { get; init; } = [];
/// <summary>TaskState stamped on every published row. Accepted is what an in-progress challenge
/// looks like to the client, Submited is completed history — Submited is the ESC-safe one.</summary>
public string TaskState { get; init; } = "Submited";
public bool PublishRaidRows { get; init; } = true;
public bool PublishTitleUnlocks { get; init; } = true;
/// <summary>Also push the CSharpOnly twin SyncPlayerAllTaskDebug(64423039), the only packet that
/// reaches the client's native task manager (64260853 has a Lua impl that drops taskInfos).</summary>
public bool PublishDebugRows { get; init; } = true;
/// <summary>
/// R238(2026-10-03 随赛车移植)—— 进世界时**只**发布赛车挑战那几条任务行,
/// 让客户端的 task manager 在玩家点「开始挑战」之前就拥有 60000147 的运行时节点。
///
/// 因果链(捐赠分支实测):没有节点 ⇒ `gTaskManager:SetCurrentTask` 建不出运行时节点
/// ⇒ 任务状态机不产出 TASK_STATE_CHANGED ⇒ `ChallengeManager.StartChallenge` 不被调用
/// ⇒ `currentChallengeId` 恒为 -1 ⇒ `OnSyncRacingResultData` 里
/// `ChallengeConfig.GetConfig(-1)` = nil ⇒ **不弹结算面板**。
///
/// ⛑ 与 <see cref="Enabled"/> **无关**:赛车预热必须无条件先跑。
/// 回滚 = 置 false(回到「点开始挑战没反应」)。
/// </summary>
public bool PrimeRacingChallengeTasks { get; init; } = true;
/// <summary>
/// Fill <c>eventPanelInfo.EventsInfo</c>. THIS is what the list renders. Off = the historical
/// empty panel. When true every event below becomes one list row named after
/// TaskEventConfig.EventNameCN / EventName.
/// </summary>
public bool PublishEvents { get; init; } = true;
/// <summary>Always-published event ids, regardless of which tasks are in the container.</summary>
public uint[] EventIds { get; init; } = [];
/// <summary>Derive events from the published task rows (every task maps back to the events whose
/// chain contains it). With the full catalog this yields all 1688 authored events.</summary>
public bool AutoDeriveEvents { get; init; } = true;
/// <summary>
/// TaskEventStatusData flags: Visible=1 / Acceptable=2 / HasAccepted=4 / RedPoint=8 /
/// IsUnderway=16 / IsRiskControl=32 / Conflict=64 / IsRepeat=128. Without Visible(1) the client
/// renders nothing. Default 21 = Visible|HasAccepted|IsUnderway — the combination the reference
/// branch measured as rendering rows.
/// </summary>
public byte EventStatusFlags { get; init; } = 21;
/// <summary>
/// true = EventInfo.TaskId is the newest step of that event's chain that is actually in the
/// container (matches what the player is on); false = always StartTask. Taking the oldest step
/// instead is a known bug — the list then disagrees with the task details.
/// </summary>
public bool EventTaskIdFollowsContainer { get; init; } = true;
/// <summary>Event ids pushed in <c>SubmitEventList</c> (already-turned-in events).</summary>
public uint[] SubmitEventIds { get; init; } = [];
/// <summary>Safety cap on how many events go out; 0 = no cap.</summary>
public int MaxEvents { get; init; }
}
/// <summary>
/// Picture storage for the 趴卜 / Bubble wall, pushed to the client as
/// <c>SyncOssBucketInfo</c>(64833344).
///
/// The posts only ever carry a bare object key — <c>SocialMediaConfig.ImageOSS</c> is
/// <c>"Bubble/xxx.png"</c> and nothing else — and turning that into a URL is entirely the client's
/// <c>AliOssManager</c>, seeded by this packet. Nothing in GameAssembly.dll holds an OSS domain, so
/// without it every picture request has nowhere to go: text renders, images silently never arrive.
/// That is why the app looked "text only" even though the posts themselves were correct.
///
/// <b>The real endpoint is still unknown.</b> What proves itself instead: point
/// <see cref="Endpoint"/> at any plain static HTTP host (project scripts
/// <c>_fetch_bubble_images.py</c> downloads the artwork into <c>tools/bubble-images/</c>), then read
/// the request path out of that host's access log — whichever shape arrives,
/// <c>{endpoint}/{key}</c> / <c>{endpoint}/{bucket}/{key}</c> / <c>{bucket}.{endpoint}/{key}</c>, is
/// the layout the client builds, and that decides how to split the two fields.
/// </summary>
public sealed class OssPictureSettings
{
/// <summary>Master switch. Off = never push SyncOssBucketInfo (pre-2026-09-24 behaviour).</summary>
public bool Enabled { get; init; }
/// <summary>Object-storage region string; unused by a plain HTTP host.</summary>
public string Region { get; init; } = string.Empty;
/// <summary>Base URL serving the object keys, e.g. <c>http://127.0.0.1:8099</c>.</summary>
public string Endpoint { get; init; } = string.Empty;
/// <summary>Bucket name. Filled in once the access log reveals the URL layout.</summary>
public string Bucket { get; init; } = string.Empty;
}
/// <summary>
/// Gameplay talent trees (天赋, T1-6). The panel itself is client-driven — it reads the tree layout
/// from its own config tables and only asks the server to activate a node
/// (<c>AskActiveGameplayTalentLayer(gameplayId, talentId, addLayer)</c>). What the server owns is the
/// point balance and the set of activated nodes, both published through
/// <c>PlayerClientInfo.GameplayTalentInfos</c> at login.
/// </summary>
public sealed class TalentTreeSettings
{
/// <summary>Master switch. When false the login payload carries no talent block and every talent
/// request is acked without touching state, which is the pre-T1-6 behaviour.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Points granted per gameplay tree when a tree is seen for the first time. Persisted afterwards,
/// so raising this later only tops up trees the player never touched (see <c>RenewSeedOnLogin</c>).
/// </summary>
public uint PointSeed { get; init; } = 50;
/// <summary>
/// Re-grants <see cref="PointSeed"/> on every login, i.e. an infinite sandbox point pool. Off by
/// default so spending a tree dry is possible and the balance survives a restart.
/// </summary>
public bool RenewSeedOnLogin { get; init; }
/// <summary>0 = charge the node's TalentTreeTalentConfig.CostPoint; anything else overrides it.</summary>
public uint FlatCost { get; init; }
/// <summary>Accept an activation even when the tree is out of points (sandbox: nothing is ever blocked).</summary>
public bool IgnoreCost { get; init; }
/// <summary>Accept an activation for a talent id the imported table does not know. Without the row
/// the cost and the layer cap are unknown, so the default is to reject rather than to guess.</summary>
public bool AllowUnknownTalents { get; init; }
/// <summary>
/// Push a fresh SyncPlayerInfo after every accepted activation. This is how the client learns the
/// new point balance and the new node layer without a re-login; it reuses the exact login frame,
/// so it is the only talent S2C with a proven wire. Set false if the extra full payload ever
/// becomes a problem.
/// </summary>
public bool PushPlayerInfoOnChange { get; init; } = true;
/// <summary>
/// Master switch for the career talent seeding (职业天赋, 2026-09-24). Off = the pre-seeding
/// behaviour, i.e. every <c>SpiritInfo.TalentInfo</c> / <c>SpiritJob.TalentInfo</c> ships empty.
///
/// Why it exists: scene hack targets (vending machines <c>VendingMachineNew*</c>, claw machines
/// <c>ClawMachineJiGuan_game*</c>) register themselves with <c>talentId 99906204</c>, and
/// GadgetManager.lua then asks <c>gTalentTreeMgr:CheckTalentIsLocked(talentId, 11300004)</c>
/// (11300004 = the Hacker job class). A locked talent makes CheckHackCondition fail before it
/// ever reaches SetHackBtnState, which is exactly the observed symptom: the hack prompt shows up
/// greyed out everywhere, while a server-spawned BlackoutController — which has no talentId —
/// lights up immediately. 99906204 lives in TalentTreeTalentConfig tree 99900010 together with
/// 16 siblings.
/// ⛑️ Rollback: set false. The 27 grey prompts come back, nothing else changes.
/// </summary>
public bool SeedCareerTalents { get; init; } = true;
/// <summary>Talent tree whose nodes are published as activated by <see cref="SeedCareerTalents"/>.</summary>
public uint CareerTalentTreeId { get; init; } = 99_900_010;
/// <summary>Job class the tree belongs to. Only used for logging and for the per-job records.</summary>
public uint CareerTalentJobClassId { get; init; } = 11_300_004;
/// <summary>Talent points published alongside the seeded nodes, i.e. the panel balance.</summary>
public uint CareerTalentPoint { get; init; } = 50;
/// <summary>Layers published per node for the lock test. <c>0</c> = derive from each node's
/// <c>TalentTreeTalentConfig.LayerNum</c> <b>+1</b>; any positive value is used verbatim.
/// ⛑️ The client requires a strict excess: <c>TalentTreeMgr.lua:1031</c> returns
/// <c>talentInfo.Layer &lt;= talentCfg.LayerNum</c> as "locked", so seeding exactly
/// <c>LayerNum</c> keeps the node grey (99906204 has LayerNum 1 ⇒ needs Layer 2).
/// An explicit value here must itself exceed the node's <c>LayerNum</c>.</summary>
public uint CareerTalentLayers { get; init; }
/// <summary>Restrict the seeding to these node ids instead of the whole tree. Empty = whole tree.
/// Use this if the panel ever needs some nodes to stay buyable.</summary>
public uint[] CareerTalentIds { get; init; } = [];
/// <summary>Who receives the seeded nodes, kept as two independent switches so a first test can be
/// bisected if either wire misbehaves:</summary>
/// <summary> Publish them in every character's <c>SpiritInfo.TalentInfo</c> (the login frame).
/// Off leaves that struct empty — the pre-seeding baseline.
/// ⛑️ Default <b>false</b> since 2026-09-24: <c>TalentTreeMgr.lua</c> RebuildCache maps
/// <c>jobClassId → AvailableJobs[jobId].TalentInfo</c> and only <c>jobClassId == 0</c> falls back
/// to <c>SpiritInfo.TalentInfo</c>. The hack prompt asks with job class 11300004 (Hacker), so
/// this character-wide copy never answers that query — keeping it off shrinks the login frame
/// and removes an unverified write. Turn it on if something ever queries with job class 0.</summary>
public bool SeedCareerTalentsOnSpirit { get; init; }
/// <summary> Additionally publish them per urban job through <c>SpiritJob.TalentInfo</c>
/// (SyncSpiritJobInfo). This is the copy that answers
/// <c>CheckTalentIsLocked(talentId, jobClassId)</c> for a real job class, so it is the one the
/// hack prompts depend on. ⛑️ It only reaches the query if that job class actually has an
/// <c>AvailableJob</c> — the client loop skips classes without one.</summary>
public bool SeedCareerTalentsOnEveryJob { get; init; } = true;
/// <summary>
/// Publish <b>every</b> talent tree instead of the single <see cref="CareerTalentTreeId"/>, each
/// into the container the client actually reads it from (<c>TalentTreeMgr.OnStartCurrent</c>):
/// <list type="bullet">
/// <item><c>TalentTreeConfig.GameplayId</c> != 0 → <c>GameplayTalentInfos[gameplayId]</c></item>
/// <item><c>JobClassId</c> != 0 → that career's <c>SpiritJob.TalentInfo</c> (only visible to
/// characters owning the career, because the dictionary is built per AvailableJobs row)</item>
/// <item>both 0 → the character's <c>SpiritInfo.TalentInfo</c>, narrowed by <c>SpiritId</c>
/// (Kingpin lists the two protagonists; a few rows pin one spirit each)</item>
/// </list>
/// The three expressions below are the per-container switches — hard rule 105: any login-frame
/// struct it starts filling must be independently switchable, without a rebuild.
/// <c>false</c> restores the 2026-09-24 single-tree behaviour exactly.
/// </summary>
public bool SeedAllTrees { get; init; } = true;
/// <summary>Publish the whole-tree set into <c>GameplayTalentInfos</c> when <see cref="SeedAllTrees"/>.</summary>
public bool SeedTreesOnGameplay { get; init; } = true;
/// <summary>Publish the trees that are keyed by <c>SpiritId</c> at all (Kingpin for the two
/// protagonists, plus the six single-spirit rows <c>99900021..26</c>, whose dump name is the
/// placeholder ";"). Off keeps only the un-keyed rows, which then apply to every character.</summary>
public bool SeedSpiritIdTrees { get; init; } = true;
}
/// <summary>
/// T2-32 路人骇入(黑客骇路人手机看信息)。
///
/// 客户端整条链路都是现成的,服务端唯一缺的就是 <c>SyncUnitHackableState</c>(68941159):
/// 不发它,C# 的 <c>HackManager.hackUnitTarget</c> 恒为 nil,路人身上不会有骇入按钮,
/// 三个 C2S(67070057 / 67747341 / 67093669)一个都不会发。
/// ⇒ 这里只保留一个开关,关掉即回到接入前的状态。
/// </summary>
public sealed class HackNpcSettings
{
/// <summary>
/// NPC 出场时(<c>SendCivilianAsync</c> / <c>SendAmbientCrowdPedAsync</c> 尾部)
/// 推一次 <c>SyncUnitHackableState(unitId, true)</c>。
///
/// 与载具那套不同,这里<b>不做</b>距离维护:C# 的 HackTargetSelector 自己按距离挑目标,
/// 服务端把全城 NPC 标成可骇也不会多出按钮;而发 false 去取消标记反而要维护一份
/// "已标记"账本(NPC 被回收时容易漏)⇒ 一律只标 true、不取消。
/// </summary>
public bool MarkSpawnedNpcs { get; init; } = true;
/// <summary>
/// 只给"服务端自己生成"的 NPC 打标记时用的上限(0 = 不限)。
/// 留作保险:万一某个场景一次性刷出上千个 NPC,逐包推送会占住连接
/// (参照 hard rule 38 的教训 —— 登录路径禁止大批量推送)。
/// </summary>
public int MaxMarkedNpcs { get; init; }
// ---------------------------------------------------------------- 手机(按钮的来源)
/// <summary>
/// ⭐ 只推 <c>SyncUnitHackableState</c> 是不够的 —— 面板下边那排<b>按钮</b>另有来源:
/// <c>HackerInfoPanelStore.lua</c> 对路人走 <c>HackManager:GetUnitHackBtns(unit)</c>(C#),
/// 而六个 NpcHackSkill 里"看信息/听通话"这类的 <c>CanUseInternal</c> 判的是
/// <b>该单位当前 BelongingUsage 的 <c>HackType</c></b>(<c>BelongingBelongingUsageConfig</c>):
/// 只有 usage <b>1</b>(右手正常打电话,HackType=2 窃听)/<b>2</b>(耳机打电话)/ <b>6</b>(右手玩手机,
/// HackType=1 偷看短信)这三条会给出按钮,其余 122 条都是 HackType=0。
/// 我们下发的 NPC 要么<b>根本没有 belongings</b>(crowd 行人),要么用的是 POI 的 usage
/// (18 拍照 / 86 / 93 / 237 / 238 滑动 / 241 打电话阐释…… 全是 HackType=0)
/// ⇒ 面板只剩名字,正对应用户看到的"下面没有选项"。
///
/// 因此这里在打标记的同时把**手机 + 一个可骇 usage** 一并推下去。
/// 关掉即回到只有名字的状态。
/// </summary>
public bool GivePhoneToNpcs { get; init; } = true;
/// <summary>手机的 sceneitem id。<c>BelongingBelongingUsageConfig</c> 三条可骇 usage 的
/// <c>BelongingSceneItem</c> 都只写 80003481(耳机那条还要再叠加 80003544,这里不用)。</summary>
public uint PhoneSceneItemId { get; init; } = 80003481;
/// <summary>
/// 轮换的 usage id。按 unitId 取模挑一个,城里于是既有"玩手机"也有"打电话"的人,
/// 两种按钮都能见到。空数组 ⇒ 退回 usage 6(玩手机 / 偷看短信)。
/// </summary>
public uint[] PhoneUsageIds { get; init; } = [6, 1];
/// <summary>crowd 通道(<c>SendAmbientCrowdPedAsync</c>)给不给手机。默认给 —— 行人本来什么都没有。</summary>
public bool GivePhoneToCrowdPeds { get; init; } = true;
/// <summary>static / civilian 通道(<c>SendCivilianAsync</c>)给不给手机。
/// ⛑️ 会<b>覆盖</b>该 NPC 的 POI usage(例如拍照动作 18 会被换成玩手机 6),
/// POI usage <b>本身就在可骇三条之内</b>时会自动跳过(见调用点)。</summary>
public bool GivePhoneToStaticNpcs { get; init; } = true;
/// <summary>发手机的 NPC 上限(0 = 不限)。每条要推两个包,行数多的时候留意
/// hard rule 37/38(登录/进世界路径怕大批量推送)。</summary>
public int MaxPhonedNpcs { get; init; }
}
/// <summary>
/// Aether city simulation (pedestrians / static NPCs / traffic). The client owns the
/// simulation; the server only answers the handful of C2S calls it makes.
/// </summary>
public sealed class AetherSettings
{
/// <summary>
/// Raw reply bytes (hex, no spaces) returned for AskMassHideAreas (67937263).
/// Kept configurable so the list framing can be retuned without a rebuild until the
/// client-side reader shape is confirmed from Player.log.
/// Default "FF00000000" = list marker + Int32 count 0 (matches how the client read
/// AetherAIInitData.Intersections).
/// </summary>
public string MassHideAreasReplyHex { get; init; } = "FF00000000";
/// <summary>
/// Which wire framing(s) SyncAetherAIInitDatas (68704070) is emitted with when the client
/// enters a scene. One entry per notify sent. Accepted values:
/// "top-int32" 0xFF object marker + Int32 list count (recommended)
/// "inline-int32" no object marker + Int32 list count (the 09-15 default)
/// "top-int7" 0xFF object marker + 7-bit list count
/// "boolfirst-int32" HasZoneGraph first, then RaidId, no object marker
/// "top-int7-isec-derived" 同 top-int7,且 Intersections 填合成探测路口(派生字段先)
/// "top-int7-isec-base" 同 top-int7,且 Intersections 填合成探测路口(基类字段先)
/// "top-int7-isec-real" 同 top-int7,且 Intersections 填**真路口**(派生字段先,2026-09-28 主推)
/// "top-int7-isec-real-base" 同上但基类字段先(对照组)
/// 真路口开关 = <see cref="InitIntersectionsReal"/>(false 即等价现状,一键回滚)。
/// The 4229938 client reported RaidId=16868235 for "inline-int32", which is exactly the
/// bytes of RaidId=23300888 shifted by one — proof that its reader consumes one byte
/// (the object marker) before the fields. See the 09-16 memory note.
/// </summary>
public List<string> InitVariants { get; init; } = ["top-int32"];
/// <summary>
/// 给 <c>top-int7-isec-*</c> 变体用:Intersections 填多少条**探测**路口。
/// 0 = 空表(与现状完全一致)。1 = 只发一条,用来看客户端日志有没有反应。
///
/// ★ 为什么有这个键:AetherAIInitData.Intersections 是我们**唯一没填过的官方字段**,
/// 客户端的路口/信号灯系统从来没有拿到过初始化数据。而 InstanceId 的真实语义未知,
/// 所以先用最小样本探测,确认客户端会响应之后,再投入造 182 条真路口
/// (数据源在 DUMPS/Bin.rar 的 ZebraConnector.json)。
/// </summary>
public int InitIntersectionProbeCount { get; init; } = 0;
/// <summary>
/// <c>top-int7-isec-real</c> / <c>-real-base</c> 变体是否用**真路口**填 <c>Intersections</c>。
/// false = 发空表(与 <c>top-int7</c> 现状完全等价)—— 这是一键回滚开关。
///
/// ★ 真路口来源(2026-09-28):<c>补丁和工具/DUMPS/readable_world_dump_4229938_final.zip</c>
/// - InstanceId ← <c>04_WORLD_JSON/vfc_1286_e106_*.json</c> 的 <c>interInfos[].intersection</c>
/// - ZoneIndex ← <c>03_ZONEGRAPHS/vfc_1286_e108_*.json</c>(车辆图)里车道所属 Intersection zone
/// 落盘在 <c>Ananta.Server/Ananta.App/data/intersections.csv</c>(167 条),由
/// <c>IntersectionCatalog4229938</c> 惰性加载;文件缺失/损坏 → 自动退化为 0 条,不会崩。
/// </summary>
public bool InitIntersectionsReal { get; init; } = true;
/// <summary>
/// 真路口最多发几条。0 = 全发(当前 167 条)。
/// 想先只发 1 条看客户端有没有反应就设 1(比全量更容易从日志里定位)。
/// </summary>
public int InitRealIntersectionCount { get; init; } = 0;
/// <summary>
/// 街上行走的行人(AetherCity crowd)。人群是客户端自驱的,服务端只发密度包。
/// 前置:SyncAetherAIInitDatas 必须已带 HasZoneGraph=true(由 vehicles.enabled 那条路径发出,
/// vehicles.enabled=false 时人群不会初始化)。
/// </summary>
public bool CrowdEnabled { get; init; } = true;
/// <summary>人群密度倍率。1.0 = 官方默认。</summary>
public float CrowdDensity { get; init; } = 1.0f;
/// <summary>SyncCrowdSpecifiedDensityRefresh 盒子的水平半径(米)。donor 实测 250。</summary>
public float CrowdExtendsMeters { get; init; } = 250f;
/// <summary>盒子的高度(米)。donor 实测 50。</summary>
public float CrowdExtendsHeight { get; init; } = 50f;
/// <summary>玩家离开上次中心多少米就重发一次密度盒。donor 实测 35。</summary>
public float CrowdRefreshMoveMeters { get; init; } = 35f;
// ---- T0-8:让密度随地块 / 时段变 ----
// UrbanDiversityConfig 的 PopulationDensity 与 DegreeOfBusyness 在本城 681 个地块里几乎恒为 0
// (实测只有沙滩区有值),所以密度改由「LandPlotType 系数 × HourlyFlow(时段曲线)」合成。
/// <summary>总开关。关掉就退回全局固定密度(进 S2C 前的行为),用于隔空对照。</summary>
public bool CrowdUseLandPlots { get; init; } = true;
/// <summary>LandPlotType → 密度系数。未命中的类型按 1.0 处理,随时可在此增删条目。</summary>
public Dictionary<string, float> CrowdTypeFactors { get; init; } = new(StringComparer.OrdinalIgnoreCase)
{
["商业区"] = 1.00f,
["住宅区"] = 0.70f,
["沙滩区"] = 0.60f,
["公园区"] = 0.50f,
["学院区"] = 0.60f,
["高端居住"] = 0.50f,
["运动区"] = 0.40f,
["工业区"] = 0.35f,
};
/// <summary>时段系数下限。午夜 HourlyFlow 会掉到 0,不设下限街上会彻底空场。</summary>
public float CrowdHourFloor { get; init; } = 0.15f;
/// <summary>找不到地块时的回退系数。默认 1.0 = 不对无人区再打折。</summary>
public float CrowdPlotMissFactor { get; init; } = 1.0f;
/// <summary>判定「玩家站在哪个地块」时的搜索半径(米)。</summary>
public float CrowdPlotSearchMeters { get; init; } = 60f;
// ------------------------------------------------------------------
// 自动氛围行人(GameRouter.Ambient.cs)
//
// ★★ 2026-09-20 纠错:上面那组 crowd 密度包**不是**空实现。
// dump.cs 里 0x9148360 是**基类 virtual 空壳**的地址,每个 S2C 都有这么一行;
// 判据要看有没有 `public override` 那一行(RVA 0x127xxxxx = 真实现)。实测真值表:
// 有实现:InitDatas / StaticNpcAddData / CrowdAdd / NpcRemove / ManagedLogicAgent /
// MoveWandering / AgentForceGo / SetCrowdDensity / CrowdSpecifiedDensityRefresh
// 空 stub:ChangeNpcMoveDesiredSpeed(68739456) / DoAetherAgentBehaviorTaskDefine(68705721)
// ⇒ 密度包能发但客户端没出人,是**参数/前置条件**问题,不是"发了等于没发"。
// ⇒ 单独下发"期望速度"和"行为任务"这两条 donor 结论是错的(客户端没实现)。
// ------------------------------------------------------------------
/// <summary>进世界后自动在玩家周围铺行人。关掉就只剩面板手动生成。</summary>
public bool AmbientPedsEnabled { get; init; } = true;
// ------------------------------------------------------------------
// 自动功能 NPC(GameRouter.FunctionalNpc.cs,2026-09-22)
//
// 跟随式:玩家走到哪,附近的店铺 NPC 就在哪冒出来。用**滞回**而不是单一阈值 ——
// 进 130m 铺、出 230m 撤,玩家在街口来回走不会反复生成/销毁(见 §3 规则 26)。
// ------------------------------------------------------------------
/// <summary>进世界后自动在玩家周围铺设功能 NPC(店员 / 售票)。关掉就只剩面板手动生成。</summary>
public bool FunctionalNpcsEnabled { get; init; } = true;
/// <summary>玩家进入这个半径(米)就把该点位的功能 NPC 放下去。</summary>
public float FunctionalNpcEnterMeters { get; init; } = 130f;
/// <summary>玩家离开这个半径(米)就撤掉它。必须明显大于 Enter,否则街口会抖动。</summary>
public float FunctionalNpcExitMeters { get; init; } = 230f;
/// <summary>跟随循环的检查周期(毫秒)。</summary>
public int FunctionalNpcTickMs { get; init; } = 1500;
/// <summary>每次检查最多新铺几个。铺太猛客户端一次吃太多实体会丢。</summary>
public int FunctionalNpcPerTick { get; init; } = 2;
/// <summary>
/// 玩家走远后要不要发 <c>SyncAetherAINpcRemove</c> 撤人。默认 <b>false</b>。
/// ⛑⛑ 2026-09-25 实机已证这条 remove 删不掉 8300 段那具身体(跑步机上的里希一直不消失,
/// 见 <see cref="RetirableUidSegment"/> 的说明)。撤不掉、玩家折回时却又重新发一具 ⇒
/// 同一个店员在原地叠 2~3 具(2026-10-01 用户报「健身房里 3 个卢卡斯·高桥」就是这么来的:
/// 一局内 place/retire 循环了 3 次,3 具身体同一坐标)。
/// 关掉 ⇒ <b>只铺不撤</b>:一局里每个点位最多铺一次,客户端那具身体一直站着,永不重复。
/// 想回到「走远就撤」的旧行为就设 <b>true</b>(风险:仍会叠)。
/// </summary>
public bool FunctionalNpcRetireOnExit { get; init; } = false;
// ------------------------------------------------------------------
// 自动地盘敌人(GameRouter.EnemyTerritory.cs,2026-09-23)
//
// 与功能 NPC 同一套跟随循环,只是坐标来源和生成通道不同:
// 坐标 = FactionInfluenceAreaConfig × FactionAreaPointConfig(85 个帮派地盘角点,
// EnemyCatalog 里一个坐标都没有,这是 dump 里唯一现成的敌人站位来源);
// 生成 = GameAdminBridge.SpawnAgentAsync(forbidAetherAI=true,客户端行为树接管,
// 所以它们不像 ambient 行人那样沿人行道走)。
// 半径比功能 NPC 更小(90/160):敌人会主动扑上来,130m 外就出人等于隔街被围。
// ------------------------------------------------------------------
/// <summary>进世界后自动在玩家周围的地盘上铺敌人。关掉就只剩面板手动生成。</summary>
public bool EnemyTerritoryEnabled { get; init; } = true;
/// <summary>玩家进入这个半径(米)就把该地盘的敌人放下去。</summary>
public float EnemyTerritoryEnterMeters { get; init; } = 90f;
/// <summary>玩家离开这个半径(米)就撤掉它。必须明显大于 Enter。</summary>
public float EnemyTerritoryExitMeters { get; init; } = 160f;
/// <summary>跟随循环的检查周期(毫秒)。</summary>
public int EnemyTerritoryTickMs { get; init; } = 1500;
/// <summary>
/// 每个地盘点位放几个敌人(1–5)。站位由 EnemyTerritorySpots.json 的 members 提供(人行道点,
/// 组内最小间距 1.3 m),所以 3–4 人是「三三两两站在巷口」的观感,2 人显得零星。
/// 上限 5 = 生成脚本写的站位数量(maxGroup),再高也没位置可放。
/// </summary>
public int EnemyTerritoryPerPoint { get; init; } = 3;
/// <summary>场上同时存在的地盘敌人上限。兜底,防止玩家在密集区来回走把数量堆上去。</summary>
public int EnemyTerritoryMaxActive { get; init; } = 24;
/// <summary>
/// true = 生成即登记进敌人账本并下令开战(锁定玩家 + FightEdict + Detect),打得掉血;
/// false = 只把实体放进世界站桩,玩家走近才会被客户端自己发现。
/// 想让城市"有帮派分子在晃"但不被追着打,就设 false。
/// </summary>
public bool EnemyTerritoryEngage { get; init; } = true;
// ------------------------------------------------------------------
// 敌人移动的节奏(GameRouter.EnemyAiActions.cs)
//
// ⛑️ 抽帧根因(2026-09-23 实测):SyncEnemyBattleMoveTowardUnit 是「有限位移」——
// 客户端只走 MoveTime 秒就停,然后原地站着等下一条。原来 MoveTime=0.65s 而派发间隔
// 是 1000ms ⇒ 走 0.65s 停 0.35s,肉眼就是「走一步停一下」。
// ⇒ 派发间隔必须 ≥ MoveTime,且两者要贴住(留 ~50ms 余量,小于会被下一条打断)。
// ------------------------------------------------------------------
/// <summary>每条移动命令让客户端走多久(秒)。越大一步走得越远,越接近连续行走。</summary>
public float EnemyAiMoveSeconds { get; init; } = 3.0f;
/// <summary>
/// 两条移动命令之间的间隔(毫秒)。必须 &gt;= MoveSeconds*1000,否则下一条会打断上一条。
/// 留 50ms 左右余量最自然(完全连续 / 略微一顿,靠这个值调)。
/// </summary>
public int EnemyAiMoveIntervalMs { get; init; } = 3050;
/// <summary>
/// ★ 决定性实验开关(2026-09-24):false = 服务端**完全不发**战斗移动命令
/// (SyncEnemyBattleMoveTowardUnit),只发 engage(SyncBattleAiS=true)和攻击。
/// 敌人的接近动作交给客户端自己的行为树 —— 它是 forbidAetherAI=true 生成的,
/// 客户端本来就在跑战斗 AI。如果我们每 1 秒补一条有限位移命令反而是在打断它,
/// 那关掉这个开关就能看到"原生"的连续追击。
/// ⛑️ 关掉后如果敌人完全不动,说明客户端行为树不自己走路,那就得开回来。
/// </summary>
public bool EnemyAiServerMoveEnabled { get; init; } = true;
/// <summary>
/// 攻击后把单位冻多久(毫秒):CastBusy 是完全不动,CastInterval 是下次决策的时间。
/// 原来是 6000/6500 —— 打一下站 6.5 秒,这也是"停"的一大来源。
/// </summary>
public int EnemyAiCastBusyMs { get; init; } = 2200;
public int EnemyAiCastIntervalMs { get; init; } = 2600;
/// <summary>
/// 每个敌人的移动/攻击日志打多密:前 3 条必打,之后每 N 条打一次。
/// 原来只打前 3 条,导致实机看不到任何后续节奏(诊断"一步一停"时吃了大亏)。
/// </summary>
public int EnemyAiVerboseLogEvery { get; init; } = 5;
/// <summary>
/// 玩家不在仇恨范围(30m)时让敌人原地踱步:走一段 → 停一会 → 倒着走回来。
/// 走的是 WalkFront / WalkBack 交替,位移互相抵消,所以不会整体漂向玩家。
/// </summary>
public bool EnemyAiPatrolEnabled { get; init; } = true;
/// <summary>踱步两段之间的停顿区间(毫秒),在区间里随机取值。</summary>
public int EnemyAiPatrolPauseMinMs { get; init; } = 1200;
public int EnemyAiPatrolPauseMaxMs { get; init; } = 2600;
/// <summary>
/// 踱步单条命令走多久(秒)。比追击更长:一条命令内客户端是连续走的,
/// 命令越多起停越多,看起来越碎。2.5s 大概走 4~5 m。
/// </summary>
public float EnemyAiPatrolMoveSeconds { get; init; } = 2.5f;
/// <summary>踱步离出生点最远多少米(保险绳,估算位置超出就强制换向)。</summary>
public float EnemyAiPatrolLeashMeters { get; init; } = 6f;
/// <summary>
/// 仇恨脱离半径(米)。必须大于进入半径 30 —— 单阈值会让 engagement 在边界反复开关,
/// 而每次开关都要重发 LockTarget/FightEdict/BattleAiS,客户端的动作层被重置、
/// 动画被裁断,表现就是"刚迈一步就僵住"。实测日志里几秒内 True→False→True 抖了 53 次。
/// </summary>
public float EnemyAiAggroExitMeters { get; init; } = 38f;
/// <summary>engagement 状态翻转后的最短保持时间(毫秒),二次防抖。</summary>
public int EnemyAiEngageHoldMs { get; init; } = 2000;
/// <summary>
/// 仇恨<b>进入</b>半径(米)。原先是 <c>EnemyAiActions.cs</c> 里的硬编码 <c>30f</c>,
/// 2026-09-26 提成配置项。
///
/// ⛑⛑ <b>填 0 = 敌人永不主动锁定玩家</b>(<c>EnemyAiSelectTarget</c> 恒返回 0,
/// 不发 LockTarget / FightEdict / BattleAiS)⇒ 敌人一直保持未察觉 ⇒
/// <b>蹲伏暗杀才有窗口</b>:已在交战中的敌人不可暗杀,这正是
/// <c>ReportCanAssassinateEnemies</c> 恒 0 的原因之一(实测 20:23:36 敌人就 engage 了,
/// 而蹲伏在 20:23:30–54)。
/// 想恢复原行为填 30。
/// </summary>
public float EnemyAiAggroRangeMeters { get; init; } = 30f;
/// <summary>
/// 实验开关:改用 <c>SyncMoveWandering</c>(68745518) 让敌人"自己走起来"——
/// 官方唯一不带目的地的行走包(半径/角度窗口/时间预算,路径由客户端自己挑),
/// 行人路线走的就是它,所以是真正连续的行走,而不是一串有限位移命令。
/// ⛑️ 敌人 forbidAetherAI=true,能不能接这个包没验证过;默认 false。
/// 开了之后服务端拿不到真实坐标,AiPosition 仍是估算(游荡位移不计入)。
/// </summary>
public bool EnemyAiWanderEnabled { get; init; } = false;
/// <summary>
/// 出招前<b>转向</b>用什么包:
/// <c>"target"</c>(默认)= <c>SyncUnitSmoothFaceToTarget</c>(68146316),把目标单位交给
/// 客户端,转角由它用真实朝向算 ⇒ 准;
/// <c>"smooth"</c> = <c>SyncUnitSmoothFace</c>(68718685),服务端按<b>估算</b>朝向算增量 ⇒
/// 估算过期就会转歪(2026-10-01 实机:敌人跑到玩家面前再转 90° 打空气);
/// <c>"off"</c> = 不发转向包(对照用)。
/// 详见 <c>GameRouter.EnemyAiActions.cs</c> 的 <c>TurnEnemyToAsync</c>。
/// </summary>
public string EnemyAiTurnPacket { get; set; } = "target";
/// <summary>wander 单次游荡的距离区间(米)。踱步用,别设太大免得走丢。</summary>
public float EnemyAiWanderMinDis { get; init; } = 3f;
public float EnemyAiWanderMaxDis { get; init; } = 10f;
/// <summary>wander 的时间预算(秒)。走完就站住,靠巡逻 tick 续期。</summary>
public float EnemyAiWanderSeconds { get; init; } = 14f;
// ---- 随从(Companion)—— 2026-10-01 ----
//
// ⛑⛑ 取证结论:随从模板用的是**专属伴生行为树**(`AgentAIBehaviorSetsBattleAISBehaviorConfig`
// AI=40213535 → BehaviorTree=`Companion_JiaMu_Shuangmawei`(40651922 晴)
// AI=40213604 → BehaviorTree=`Companion_Guys`(40155016 盖斯))
// 两行的 Behavior_Battle 都是空串、RelatedSkillIds 都是空表 ⇒ 它们的战斗能力
// **不在 AI 行为集里**,而是写在那棵 Companion 树的 C# 实现里,服务端查不到。
// 所以别指望 `EnemyAiAttacksFor` 给它们配招(它会退回金属吉他手那两下 51900523/51900524)。
//
// ⛑⛑ 而 AgentConfig 给的 Camp:晴=1(PlayerDefault)、盖斯=1 ⇒ **随从本来就是玩家阵营**。
// 之前 `campOverride:0` 把它按 UnitCamp.MonsterDefault 发了出去 ⇒ 客户端当它是敌人
// (挂血条、可锁定、Companion 树不接管)—— 这就是"随从像敌人"的头号根因。
// UnitCamp 枚举(dump.cs:1907832):0 MonsterDefault / 1 PlayerDefault / 2 AllNeutral
// / 3 AllFriend / 4 AllEnemy。(⛔ 旧记忆说"友方=1"是对的,但说"敌人是 0/4"只在 spawn
// 默认路径上成立;真正的判定是 UnitCamp 本身。)
/// <summary>
/// 随从生成时下发的 <c>camp</c>。默认 <b>1 = UnitCamp.PlayerDefault</b>(模板自带值,
/// 与捐赠端给机械狗发的同款)。<c>SyncAgentCampInfo</c> 的包体就是这一个字节。
/// 设 4 立刻回到"随从被当敌人"的旧行为(一键回滚用)。
/// </summary>
public byte CompanionCamp { get; set; } = 1;
/// <summary>
/// 服务端是否用<b>敌人那套四包</b>(BattleAiS / LockTarget / FightEdict / BattleMoveTowardUnit)
/// 自己驱动随从跟随+协战,并在贴身时补一个 <c>SyncUseTargetSkill</c> 让它真出招。
///
/// ⛑⛑ 2026-10-01 实机:<b>false 的时候随从完全不动</b> —— 客户端那棵 Companion 树
/// 并没有因为 <c>SyncAgentCompanionAI</c>+<c>SyncCompanionMaster</c> 就接管
/// (camp 已经发成 1 了也一样)。所以服务端必须自己驱动,默认 <b>true</b>。
/// </summary>
public bool CompanionServerDrive { get; set; } = true;
/// <summary>
/// 随从出招用的技能池(贴身时按序号轮流发 <c>SyncUseTargetSkill</c>)。
///
/// ⛑⛑ 为什么不是"它本身的攻击模组":晴/盖斯的 AI 行为集里 <c>Behavior_Battle</c> 是空串、
/// <c>RelatedSkillIds</c> 是空表,招式写在客户端那棵 <c>Companion_*</c> 行为树的 C# 里,
/// <b>服务端无从得知</b>(详见 <c>CompanionAssist.cs</c> 类注释)。所以只能借通用池。
///
/// 默认取 <c>EnemyAiCatalog</c> 的 <b>UNARMED 近战池</b>(与 7k-v0.5 同源,能在多数骨架上播):
/// 51900500 / 51900501 空手普攻,51900410 / 51900411 空手连击。
/// 若实机看到它"摆姿势不出手"(骨架不对),换成 RANGED 远程池试:
/// [51984196, 51992001, 51996871]
/// 换技能<b>不用重编译</b>,改这里重启即可。
/// </summary>
public List<uint> CompanionAttacks { get; init; } = [51900500, 51900501, 51900410, 51900411];
/// <summary>
/// 跟随(身边没有敌人)时是否仍然把 <c>SyncBattleAiS</c> 保持在 true。
///
/// ⛑⛑ 2026-10-01 实机:恒 true 的后果就是随从<b>一直保持举枪的战斗姿态</b>,
/// 没敌人也端着枪慢慢挪 —— 玩家看到的"怪"就是它。主线驱动敌人时是
/// <c>value = target != 0</c>(<c>EnemyAiActions.cs:453</c>),<b>没目标就关战斗 AI</b>。
/// 默认 <b>false</b> = 跟随就收枪,正常走路。
/// ⛑ 若改成 false 后随从连路都不走了(说明它只认战斗态的移动指令),设回 true。
/// </summary>
public bool CompanionBattleAiWhenFollowing { get; set; } = false;
/// <summary>
/// 跟随模式下单条移动命令走多久(秒)。
///
/// ⛑ 为什么要单列:有限位移命令是"走 MoveTime 秒就停",而协战用的
/// <c>EnemyAiMoveSeconds</c>(默认 0.65s)是给敌人**短距离贴脸**调的。随从要跟
/// 十几二十米的话,一条命令只走 1.2 m 就要停一下 ⇒ 玩家看到的就是"一顿一顿慢慢挪"。
/// ⇒ 跟随时取 <c>max(EnemyAiMoveSeconds, 本值)</c>,本值默认 3.0s(约 5.5 m)。
/// 调大 = 一次走更远、起停更少(但转向更迟钝);调小 = 更贴身、更碎。
/// </summary>
public float CompanionFollowMoveSeconds { get; set; } = 3.0f;
/// <summary>
/// 绑定随从后清掉它的<b>上半身持枪瞄准态</b>(<c>SyncEndShootingState</c> +
/// <c>SyncEndShootGameplayTag</c>,见 <c>GameRouter.EnemyStance.cs:32</c>)。
///
/// ⛑⛑ 2026-10-01 实机:随从"举着枪慢慢挪"的真凶。主线那条清理只在 profile 判成
/// unarmed 时才发,而随从 profile 是 <c>null</c>(招在行为树里、没进 EnemyAiCatalog)
/// ⇒ 永远走不到。瞄准态的 locomotion 是慢速瞄准步 ⇒ 「举枪」和「慢慢」同源。
/// 默认 <b>true</b>。
/// </summary>
public bool CompanionClearGunAim { get; set; } = true;
/// <summary>
/// 绑定随从后收起它头顶的血条(<c>SyncAddUnitState</c> + <c>HideHp=10266</c>)。
///
/// ⛑⛑ 血条<b>不看阵营</b>:判据在 <c>EnemyHUDCtrl.lua:579</c> ——
/// <c>shouldShowHp = ... and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) and ...</c>
/// ⇒ 改 camp 去掉不了血条,只有挂 HideHp 状态才行(per-unit,不动公共 spawn 路径)。
/// 默认 <b>true</b>。
/// </summary>
public bool CompanionHideHpBar { get; set; } = true;
/// <summary>
/// 里希执勤终端的<b>战术支援</b>(dispatchid=4「冲锋枪2人」)刷出来的两名友军,
/// 是否也接入这套随从 AI(跟随 + 协战 + 收血条)。默认 <b>true</b>。
/// </summary>
public bool CompanionBindArmedSupport { get; set; } = true;
/// <summary>
/// 绑定后推的<b>战斗动画集</b>(<c>SyncBattleAnimSet</c>,AnimationSetConfig id)。
///
/// ⛑⛑ 为什么随从需要它:官方 Companion 协议<b>只有三个包</b>(<c>dump.cs</c> 全量 grep 过:
/// <c>SyncCompanionMaster</c> / <c>SyncAgentCompanionAI</c> /
/// <c>SyncAgentCompanionAIReactionEventSuccess</c>),<b>没有 follow/move</b> ——
/// 跟随本该是客户端那棵树自己的事;它没接管,我们只能用敌人 locomotion 代劳,
/// 于是"走什么步、手怎么摆"就取决于这个动画集。
/// 主线 <c>SendEnemyCombatStanceAsync</c> 只在 profile 判成 unarmed 时推,随从 profile 是
/// <c>null</c> ⇒ 一直用默认的持枪/远程集在走慢步。
///
/// 取值(<c>EnemyAiMethods.cs</c> 注释):<b>325 = 十二面空手</b>、327 = 通用胖子空手、
/// 337/338/340 = 远程、<b>0 = 不发(默认,留作对照)</b>。
/// ⇒ 想修「举枪慢步」就填 <b>325</b>(配合 <c>CompanionDisarm=true</c>,即 7K melee 三件套)。
/// </summary>
public uint CompanionAnimSetTag { get; set; } = 0;
/// <summary>
/// 额外把随从的武器卸掉(<c>SyncAgentChangeWeapon(uid, 0)</c>)。默认 <b>false</b> ——
/// 它的 <c>InitWeaponId</c> 本来就是 0,这只是个对照开关:清了瞄准态还举枪时才开。
/// </summary>
public bool CompanionDisarm { get; set; } = false;
/// <summary>
/// 跟随时用 <c>BattleMoveActionType.Run</c>(6) 而不是 <c>WalkFront</c>(2)。
///
/// ⛑⛑ 2026-10-01 实机:跟十几米全用走路,玩家看到的就是"慢慢挪过来"。
/// 枚举在 <c>dump.cs:1913359</c>(Unknown=0 / Idle=1 / WalkFront=2 / WalkBack=3 /
/// WalkLeft=4 / WalkRight=5 / **Run=6**)。协战<b>始终</b>用 WalkFront(贴脸不需要跑)。
/// 默认 <b>true</b>;若跟随时它跑得太冲/动作不对,设 false 退回走路。
/// </summary>
public bool CompanionRunWhenFollowing { get; set; } = true;
// ------------------------------------------------------------------
// ★★★★★ 服务端驱动行走 —— 移动任务步进(2026-09-20 移植 donor1 实机配方)
//
// ⛑️ 位置流路线已证伪(donor1 第 39 轮 + 我们 09-20 实测):
// `SyncLogicAgentSyncData`(68838628) 不携带任务 ⇒ 客户端产生不了步态。
// 我们 10Hz 推了 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动;
// donor1 AI_HANDOFF 明确把「服务端硬推位置」标为**已证伪路线**(路人第 16~23 轮的老路)。
//
// 正确路线 = `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData`(wire tag 39):
// 客户端的 LogicBehaviorSchedulerModule 跑任务 —— ZoneGraph 寻路、避障、locomotion 状态。
// donor1 第 51 轮用户亲测完整配方:
// wander=False logicSync=WayPoint logicEnter=True command=True
// commandPoints=3 commandActionId=400 speed=1.6 + AutoHold/ForceGo
// donor1 第 81 轮:`SyncManagedLogicAgent`(LogicHandoff)是**客户端来要路径的前提**,
// 状态机被关掉 ⇒ 客户端根本不来问 ⇒ 任务全落空。
//
// 路网数据:`Ananta.Server/Ananta.App/data/ped_lanes.csv` + `ped_lane_links.csv`
// (88361 条车道 / 244398 点 / 263448 条连接,由 `_ref/_gen_ped_lanes.py` 生成)。
// ------------------------------------------------------------------
/// <summary>服务端沿人行道路网给每个行人派移动任务(TaskWayPointMove)。关掉就回到"客户端自己走"(实测 = 不走)。</summary>
public bool AmbientPedsDriven { get; init; } = true;
/// <summary>
/// 任务步进循环的检查周期(毫秒)。默认 500ms(donor1 的 CrowdHoldTickMs)——
/// 任务只在行人"跑完当前段"时才发下一条,不是固定频率流。
/// </summary>
public int AmbientPedsSyncMs { get; init; } = 500;
/// <summary>
/// 一条移动任务覆盖的路程(米)。1.6 m/s 下 30m ≈ 19s、60m ≈ 37s 步态,
/// 下一条任务在"预计走完 + <see cref="AmbientPedsStepSlackMs"/>"之后才发。
///
/// ⛑️ 2026-09-23 30 → 60:那段余量是**每次发任务都要空等**的,路程越短、发呆越频繁
/// (30m:每 19s 发呆 5s = 21% 的时间在站着;60m:每 37s 发呆 5s = 12%)。
/// 加长路程**不改时序预估公式**,所以多等的还是那段余量本身,不会提前打断 ⇒ 不会瞬移。
/// </summary>
public float AmbientPedsStepMetres { get; init; } = 60f;
/// <summary>
/// 任务折线的采样间距(米)。路程加长后必须靠它保住贴合度 ——
/// 原来写死 `stepMetres/5`,60m 会变成 12m 一个点,lane 中位数才 3.58m,行人会切弯穿绿化。
/// 固定 6m:60m ⇒ 11 个点,和 30m 时的密度一致。
/// </summary>
public float AmbientPedsSampleMetres { get; init; } = 6f;
/// <summary>
/// 换道时"不许走回"最近走过的几条车道(0 = 只挡住紧邻的上一条,即 2026-09-23 之前的行为)。
///
/// 离线路网实测(2000 段 / 每步 30m,模拟器 `_pedwalk_sim.py`):
/// 1 条:掉头 4.8% / 净位移中位 10.4m / 直线度 0.51 / 打转 41.5%
/// 5 条:掉头 9.8% / 净位移中位 13.6m / 直线度 0.77 / 打转 35.6%
/// **8 条:掉头 9.0% / 净位移中位 13.6m / 直线度 0.85 / 打转 33.6%** ← 默认
/// ⛑️ 记忆越长走越直,但掉头率也会上去;掉头时**必须清空记忆**,否则掉头率飙到 42%。
/// </summary>
public int AmbientPedsRouteMemory { get; init; } = 8;
/// <summary>
/// 目标导向行走(默认开):每次派单前给行人挑一个几十米外的终点,用 `PedLaneGraph.PlanRoute`
/// 规划整条路径,行人沿路径穿过多个路口 —— 而不是在局部邻域随机游走。
///
/// ⛑️ 为什么必须开:2026-09-23 用户实测"行人只是从一条路的一头走到另一头,那条路很短就很奇怪"。
/// 我们的路网 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,随机游走的活动半径天然被锁在
/// 几条短车道之间。关掉就退回纯随机游走(保留作回退路径)。
/// </summary>
public bool AmbientPedsJourneyEnabled { get; init; } = true;
/// <summary>行程终点相对**玩家**的最小半径(米)。终点在环形区域内取,保证行人不会走出回收圈。</summary>
public float AmbientPedsJourneyMinMetres { get; init; } = 45f;
/// <summary>行程终点相对**玩家**的最大半径(米)。建议 ≤ `AmbientPedsRadiusMeters` * 0.9。</summary>
public float AmbientPedsJourneyMaxMetres { get; init; } = 135f;
/// <summary>
/// 横向偏移的绝对上限(米):真实偏移 = min(车道 HalfWalk, 这里) × 出生时锁定的比例。
/// 目的是不让所有人挤在车道几何中线上(对向行人才不会互相穿模)。
///
/// ⛑️ **设成 0 = 完全不偏移,全员走车道中线** —— 排查"行人卡进街边墙壁"时先拿它排除嫌疑:
/// 2026-09-23 用户实测窄路卡墙,根因是目标导向路径用了固定 0.6m 偏移(跟路宽无关)。
/// 卡墙现象若**还有其他来源**(比如车道几何本身贴着建筑),把它设 0 就能确认/排除。
/// </summary>
public float AmbientPedsLateralMaxMetres { get; init; } = 0.4f;
/// <summary>横向可走半径的安全余量(米):<c>HalfWalk = max(0, width/2 - 本值)</c>。
/// 调大 = 行人往车道中线收(越不容易被护栏 / 街边墙卡住);调小 = 横向分布更散。
/// ⛑️ 车道宽度中位只有 2.0 m,本值 ≥1.0 时大多数窄路会退化成全员中线。
/// ⛑️ 本项在车道表加载时算进 `HalfWalk`,**改完必须重启**;`AmbientPedsLateralMaxMetres` 则是每次采样现读。</summary>
public float AmbientPedsLateralMarginMetres { get; init; } = 0.9f;
/// <summary>出生点的最小车道宽度(米):窄于这个值的车道不用来出生(树池边 / 栏杆边的小径)。
/// 设 0 = 不过滤。⛑️ 车道宽度中位只有 2.0 m(p10=1.5 / min=0.5),默认 1.8 会滤掉约 17% 的车道。</summary>
public float AmbientPedsSpawnMinWidthMetres { get; init; } = 1.8f;
/// <summary>
/// 行人模板池的 **id 段白名单**(T0-28):只保留前缀命中任一项的 `NpcFormworkId`。
/// 空数组 = 不过滤(保持 2026-09-26 之前的"全段混入"行为)。
///
/// ⛑️ 取证(2026-09-26,用户实机反馈"商店店员、废弃模型大量混在人群里"):
/// `CivilianGroups.json` 去重后有 3318 个模板,横跨 31 个 id 段;拿 `NpcCatalog.json`
/// (= AgentConfig 的 ConfigDump,9345 行,带 `Name`) 逐条对名,发现只有 **4013** 段是
/// 纯市民(2358 条,72% 是英文/罗马音人名,其余是普通中文人名)。会穿帮的段:
/// 4095/4200/4285 = 店员摊主(咖啡店店员 / 手划船售票员 / 前台店员 / KTV店员)
/// 4515 = 刷怪NPC / 特殊病毒病人 / 刷怪(难度一~四)
/// 4204 = 剧情与职能(模型店店长 / 怪兽狂 / 空手道王 / 蓝猫车管处 / 礼品铺)
/// 4065/4096/4097 = 职业角色(清洁工主理人 / 保安小弟 / 天穹安保突击专员 / 厂长 / 主唱 / 鼓手 / 医生 / 护士甲)
/// 想加回多样性就往这里补段(4501 / 4202 / 4201 三段 0 个中文名,看着也是普通市民)。
/// </summary>
public string[] AmbientPedsFormworkIdPrefixes { get; init; } = ["4013"];
/// <summary>
/// 行人模板池的 **模型黑名单**(`NpcCatalog.GeneralModelId` 命中任一项就踢出池子)。
/// 这是"刷出一街general假人"的根因 filtration:一个 AgentConfig 行只是换个名字,模型还是同一个。
///
/// ⛑️ 取证(2026-09-26,用户实机"道路上很多 NPC 都是通用临时模型"):
/// NpcCatalog 里名字含「通用临时模型」的只有 **15 行**,但它们**只用 4 个 GeneralModelId**
/// —— 86160001(少女) / 86160002(成女) / 86160003(少男) / 86160004(成男)。
/// 而按 **模型** 反查,全表有 **470 + 9 + 171 + 30 = 680 个 id** 共用这 4 个假人模型,
/// 其中 **522 个在我们的行人池里**(4013 段占 373)。也就是说只按"名字"过滤是漏的:
/// 名字都叫 Blair Scott / Phoebe Beck,看着是市民,穿的却是同一具通用模特。
/// ⇒ **必须按 GeneralModelId 过滤,而不是按行名字过滤。**
/// 排掉这 4 个模型后 4013 段 2358 → **1985**(够用,且外观终于不一样了)。
///
/// ⛑️ 用户 2026-09-26 定:**只处理这 4 个通用模特**,不要再外扩到「备用 / 空模型 / 空npc /
/// 临时」那批(86000322 / 86951719 / 86962003)—— 实测它们在池内牵连数为 **0**,加到黑名单里
/// 只会让后来人看不懂这串数字是哪儿来的。范围收紧,只对确实造成满街假人的那 4 具下手。
/// 设空数组 = 不过滤。
/// </summary>
public uint[] AmbientPedsFormworkModelBlocklist { get; init; } =
[86160001, 86160002, 86160003, 86160004];
/// <summary>
/// 行人模板池的 **名字关键词黑名单**(`NpcCatalog.Name` 含任一项就踢出池子)。
/// 白名单之外的第二道保险:万一某天真往 4013 段里混进了职能/废弃配置,名字先拦一道。
/// 空数组 = 不过滤。匹配是大小写不敏感的 `Contains`。
/// </summary>
public string[] AmbientPedsFormworkNameBlocklist { get; init; } =
[
"店员", "摊主", "售票员", "老板", "店长", "Keeper", "Owner", "Stall", "委托人",
"病人", "刷怪", "受困", "后勤", "车管", "大夫", "护士", "医生", "保安", "清洁",
"环卫", "安保", "专员", "主理人", "空余", "占位", "测试", "临时", "备用", "废弃",
"Agent", "礼品铺",
];
/// <summary>
/// 位置回馈校正的下限(米):客户端上报位置与服务端账本偏差小于它就是正常误差(车道采样点有间距),不校正。
///
/// 触发源是 `AskClientNpcFindPath`(67974714) 请求里的 `From` —— 这是**唯一**能拿到散人真实
/// 位置的通道(散人客户端自驱,硬规则 19,服务端没有位置汇报包)。
/// 2026-09-23 用户实测"1 个行人卡在原地不动,打他几下位移后又正常走了" ⇒ 客户端位置与
/// 服务端账本脱节后会永久发散,必须有这条回路把它拉回来。
/// </summary>
public float AmbientPedsResyncMinMetres { get; init; } = 3f;
/// <summary>
/// 位置回馈校正的上限(米):偏差超过它就**不认**(多半不是我们的单位,或者数据异常)。
/// 没有这道闸会把行人拖着乱跳 —— 比不校正更糟。
/// </summary>
public float AmbientPedsResyncMaxMetres { get; init; } = 60f;
/// <summary>位置回馈的「软校正」容差(米):客户端真实位置离当前路线折线不超过这个值,
/// 就只把账本归位 + 跳过已走过的点,**不**清路线重派(问路不等于卡住,见 <see cref="GameRouter.TryResyncAmbientPed"/>)。
/// 设 0 = 关掉软校正,退回「一问路就重规划」(会把行人掐成走走停停)。</summary>
public float AmbientPedsResyncRouteToleranceMetres { get; init; } = 12f;
/// <summary>
/// 看门狗 ①(服务端侧):连续派单失败达到这个次数就回收重生。
///
/// 派不出任务 = 路网状态坏了(车道不存在 / 规划不出来 / 采样推进失败)。这种卡死
/// **服务端自己就能发现**,不需要客户端回馈 —— 是"行人杵着不动"最需要兜底的一路。
/// ⛑️ 必须是"连续 N 次"而不是一次:偶发一次失败是正常抖动,一次就回收会让行人在
/// 正常游走时凭空消失(比站着不动更难看)。
/// </summary>
public int AmbientPedsStepFailLimit { get; init; } = 5;
/// <summary>
/// 看门狗 ②(客户端侧):`AmbientPedsStallWindowMs` 窗口内位置回馈校正达到这个次数就回收重生。
/// 反复脱节说明这个单位贴墙 / 卡地形,校正已经救不回来,换个人换个地方。
/// </summary>
public int AmbientPedsStallResyncLimit { get; init; } = 5;
/// <summary>看门狗 ② 的滑动窗口长度(毫秒)。donor round 84 用的就是 30 s。</summary>
public int AmbientPedsStallWindowMs { get; init; } = 30000;
/// <summary>
/// 预计走完之后的额外等待(毫秒)。这是"发呆"的唯一来源:行人走完一段路后会站住,
/// 直到这里的时间过去才收到下一段任务。
/// ⛑️ 调小可以让发呆变短,但**如果客户端实际比预估慢,新任务会提前到** ⇒ 行人被拽回上一段
/// 终点再走(瞬移)。想要零瞬移就保持默认,靠加长 <see cref="AmbientPedsStepMetres"/> 降频率。
/// </summary>
public int AmbientPedsStepSlackMs { get; init; } = 5000;
/// <summary>
/// 生成后补发 `SyncLogicAgentEnter`(68719890):把 id 注册进客户端的 logic-agent
/// 同步管理器(任务从它派发)。donor1 第 37 轮加、第 51 轮成功配方里默认开。
/// </summary>
public bool AmbientPedsLogicEnter { get; init; } = true;
/// <summary>
/// `LogicAgentSyncData.MoveId` = `BattleMoveActionType`:
/// 0 Unknown / 1 Idle / **2 WalkFront** / 3 WalkBack / 4 WalkLeft / 5 WalkRight / 6 Run。
/// (旧位置流路线的参数,保留作对照;任务路线用的是 AgentMovementMethods.Walk=1。)
/// </summary>
public byte AmbientPedsMoveId { get; init; } = 2;
/// <summary>
/// 用哪条通道建人。
/// "crowd"(默认)= SyncAetherAICrowdAdd(donor_lyco082 实机验证的市民通道)。
/// ★ 这是**唯一带"去游荡"语义**的通道:ClientCrowdInitData 有
/// `TargetLocationReason`(ZoneGraphTargetLocationReason)字段,
/// 填 Wander=1 才是"让客户端 Aether AI 自己挑目标走起来"。
/// static 通道根本没有这个字段 ⇒ 人只能站着。
/// "static" = SyncAetherAIStaticNpcAddData + AgentSyncClientInfo(treeName=PedBase)
/// + SyncManagedLogicAgent + SyncMoveWandering。
/// 人一定出得来、有动画,但 SyncMoveWandering 走的是
/// `WanderingActionModule`(BaseUnit 管线),而我们的 NPC 是
/// AgentEntity(ECS 管线)⇒ 大概率静默失败(donor 证据)。
/// ⛑️ 名字里就写着"Static"——它就是给站桩 NPC 用的。
/// </summary>
public string AmbientPedsChannel { get; init; } = "crowd";
/// <summary>维持多少人。客户端 Mass 实体有上限,40 是 donor 验证过的安全值。</summary>
public int AmbientPedsTarget { get; init; } = 40;
/// <summary>在玩家多少米内布点(从真实行人路网取点)。</summary>
public float AmbientPedsRadiusMeters { get; init; } = 150f;
/// <summary>行人离玩家超过多少米就移除。必须 &gt; 布点半径,否则刚生成就被回收。</summary>
public float AmbientPedsRecycleMeters { get; init; } = 260f;
/// <summary>人与人之间最小间距(米),防止叠在一起。</summary>
public float AmbientPedsMinGapMeters { get; init; } = 6f;
/// <summary>维护循环间隔(毫秒)。</summary>
public int AmbientPedsTickMs { get; init; } = 1000;
/// <summary>每轮最多补几个(防止一次性发几十个包把客户端噎住)。</summary>
public int AmbientPedsTopUpPerTick { get; init; } = 6;
// ---- T3-7 真实车流(服务端驱动车辆沿车道跑)------------------------------------------
/// <summary>
/// 开启车流。客户端侧**没有**车流密度解锁包(人流有 SyncSetCrowdDensity,车流只有 C2S 的
/// GmChangeAetherVehicleDensity),所以车必须由服务端逐台下发 SyncAetherAIVehicleAddData
/// 并持续推 SyncAetherAIVehicleLaneDatas 才会出现。
/// </summary>
public bool TrafficEnabled { get; init; } = true;
/// <summary>维持多少台车同时在玩家周围跑。</summary>
public int TrafficTarget { get; init; } = 12;
/// <summary>在玩家多少米内的车道上生成。</summary>
public float TrafficSpawnRadiusMeters { get; init; } = 200f;
/// <summary>车离玩家超过多少米就回收。必须 &gt; 生成半径。</summary>
public float TrafficDespawnRadiusMeters { get; init; } = 340f;
/// <summary>
/// 车道推进包的下发间隔(毫秒)。
/// ★ 默认 50ms = 20Hz,对齐客户端常量
/// <c>AetherVehicleManager.DELTA_TIME_PER_SERVER_FRAME = 0.05</c>(dump.cs:3884945)。
/// 客户端是按「服务端 20Hz 发点」来设计插值的;用 10Hz 会让点间隔与它内部假设不符,
/// 表现为走走停停 + 突然冲刺补距离。缓冲 MAX_PENDING_SYNC_DATA=30 也正好是 1.5 秒。
/// </summary>
public int TrafficTickMs { get; init; } = 50;
/// <summary>补给 / 回收扫描间隔(毫秒)。</summary>
public int TrafficReseedMs { get; init; } = 1500;
/// <summary>车道限速的整体倍率(车道自带 speed 中位 9.72 m/s ≈ 35 km/h)。</summary>
public float TrafficSpeedScale { get; init; } = 1.0f;
/// <summary>
/// VehicleControlType:1 = AetherVehicle(客户端做车道插值,车会自己沿车道跑 —— 默认),
/// 2 = RaidVehicle(只挂 Aether 数据、不接管驾驶,等价于 T1-16 骇入用的模式,车会停在 AddData 的 Position)。
/// ★ 排查「街上没车」时的对照开关:改成 2 若车出来了,说明 ControlType=1 这条 Aether 车道通道有问题。
/// </summary>
public int TrafficControlType { get; init; } = 1;
/// <summary>
/// 车道衔接的接缝容差(米):在「接缝最小」的后继基础上放宽这么多再随机选。
/// 0 = 严格取接缝最小的后继(**实测最优**:&gt;12m 的衔接仅 2.55%,而绝对阈值 8m 是 15%)。
/// 调大 = 路线更多变,但更容易出现「车瞬移一段」。
/// </summary>
public float TrafficMaxLaneGapMeters { get; init; } = 0f;
/// <summary>
/// 换道接缝的**绝对硬上限**(米)。超过就直接判定"开到尽头"并回收该车,不再硬跳。
///
/// ★ 为什么单独有这个键:<c>TrafficMaxLaneGapMeters</c> 只是"相对容差",
/// 但当一条车道的所有后继都被「方向反折」过滤掉时,挑选逻辑会退回全局最小接缝 ——
/// 那条可能在几百米外。实测日志 <c>worstGap=394.1m</c>;车道数据本身
/// p99 接缝 347m、max 2965m,&gt;30m 的占 13.92%。
/// ⇒ 没有这个上限,车会定期瞬移(并因为瞬移出 despawn 半径被立刻回收,
/// 表现为「车刚生成就没了,不断 despawn+spawn」)。
/// ⛑️ 别设太大:客户端按 0.35 秒的插值窗口平滑位置,30m 的跳 = 约 85 m/s,
/// 肉眼看就是「车瞬间飞过去」(2026-09-28 实测反馈)。接缝分布是两极化的
/// (p50=0.00m、p90=53m),所以收紧到 8m 能滤掉大跳而几乎不影响正常衔接。
/// 调小 = 更少瞬移、但车更早被回收;调大 = 车跑更远、但会出现瞬移。
/// </summary>
public float TrafficMaxLaneHopMeters { get; init; } = 8f;
/// <summary>
/// 给每台车预规划多少段车道(<c>VehicleLaneGraph.PlanRoute</c>,直行优先)。
/// 0 = 关闭预规划,退回「每到一条车道随机挑后继」(旧行为)。
///
/// ★ 这是「车开很短距离就停下/消失」的结构性解:随机挑后继会让车乱拐、掉头、
/// 钻死路、很快被回收;预规划后车沿道路连续跑,观感从"随机乱窜"变成"正常行驶"。
/// 建议 8~16。太小 = 频繁重新规划(开销),太大 = 一次算太久且路径过期。
/// </summary>
public int TrafficRouteHops { get; init; } = 12;
/// <summary>
/// LaneDatas 时间戳相对当前时刻的提前量(毫秒)。
/// ★ 默认 350ms,对齐客户端常量 <c>AetherVehicleManager.TARGET_DELAY_TIME = 0.35</c>
/// (dump.cs:3884947,即 TARGET_DELAY_FRAME=7 × 50ms)。
/// 客户端的 <c>TimedSpringInterpolationMethodUnmanaged</c> 是「定时弹簧」:
/// 每个点带一个 TargetTimeStamp,客户端在到达该时刻前把 DeltaDistance 平滑排空。
/// 时间戳 = 当前时刻(lead=0)时目标时刻**已经过去**,弹簧瞬间排空 → 车冲一下 →
/// 然后 deltaDistance=0 停住等下一个包 → 正是「停一下又用很快的速度补一段」。
/// 给一个未来时间戳,弹簧才有时间区间可以平滑铺开。
/// </summary>
public float TrafficTimestampLeadMs { get; init; } = 350f;
/// <summary>
/// 起步加速度(m/s²)。默认 2.5 ≈ 家用车温和起步(0→35km/h 约 4 秒)。
/// ★ 车「凭空直接起步」就是因为没有这个:原来 spawn 时速度直接 = 限速。
/// </summary>
public float TrafficAccelMps2 { get; init; } = 2.5f;
/// <summary>刹车减速度(m/s²)。默认 5.0(比加速大,符合直觉;急刹感就调低)。</summary>
public float TrafficDecelMps2 { get; init; } = 5.0f;
/// <summary>
/// 跟车检测半径(米)。只在这个距离内找前车。
/// ★ 调**小** = 更晚发现前车 → 车开得更冲、更少无故减速,但更容易追尾。
/// 默认 18(原为 30,实测 30 会让车在路口前就莫名减速)。
/// </summary>
public float TrafficFollowRangeMeters { get; init; } = 18f;
/// <summary>跟车最小车距(米,保险杠到保险杠)。</summary>
public float TrafficFollowMinGapMeters { get; init; } = 3.0f;
/// <summary>跟车的横向半宽(米):前车偏离本车前进方向超过这个就不算挡路。</summary>
public float TrafficFollowWidthMeters { get; init; } = 2.6f;
/// <summary>交叉让行:最近接近距离小于这个值(米)判定为会撞。</summary>
public float TrafficConflictRadiusMeters { get; init; } = 3.5f;
/// <summary>
/// 交叉让行:只看未来多少秒内会撞。
/// ★ 调**小** = 更晚才判定要撞 → 车更敢走、路口更少互相让行卡死,但更容易真撞。
/// 默认 1.8(原为 3.0,实测 3.0 会让对向车流在路口互相等)。
/// </summary>
public float TrafficConflictHorizonSec { get; init; } = 1.8f;
/// <summary>
/// 交叉让行的横向门槛(米):两车在**彼此**前进方向上的横向偏移都超过这个值 → 只是并行经过,
/// 不算冲突。★ 没有这条,双向相邻车道(横向 3.5m)会被误判成要撞,对向车流互相让行卡死。
/// </summary>
public float TrafficConflictWidthMeters { get; init; } = 2.8f;
/// <summary>
/// 让行速度(m/s)。让行方降到这个速度**继续走**,而不是停下 —— 停下会让对面也进入
/// 让行判定,两边来回抖动成死锁。
/// </summary>
public float TrafficYieldSpeedMps { get; init; } = 1.5f;
/// <summary>
/// 僵局解除:一台车连续停住这么久(秒)就进入「硬冲」模式,低速强行通过。
/// ★ 没有这个,路口互相让行会形成环形等待(A 让 B、B 让 C、C 让 A),全场堵死。
/// </summary>
public float TrafficStallReleaseSec { get; init; } = 2.0f;
/// <summary>硬冲模式持续多久(秒)。</summary>
public float TrafficStallBoostSec { get; init; } = 2.0f;
/// <summary>硬冲模式的速度上限(m/s)。低速通过,视觉上比原地不动好。</summary>
public float TrafficStallBoostSpeedMps { get; init; } = 4.0f;
/// <summary>距车道末端(= 路口/转弯)多少米内开始减速。</summary>
public float TrafficJunctionSlowMeters { get; init; } = 12f;
/// <summary>路口限速倍率(乘在车道限速上)。</summary>
public float TrafficJunctionSpeedFactor { get; init; } = 0.5f;
/// <summary>
/// 服务端自建「红绿灯相位」总开关(false = 一键回到没有信号灯的行为)。
///
/// ★ 为什么必须自建:客户端 <c>AetherIntersectionManager</c> 的放行关系
/// (<c>MassIntersectionPeriodDetail.VehicleLaneMetas</c>)在**客户端专属 blob** 里,
/// 服务端只发 <c>TrafficIntersectionPeriodInfo</c> 三个 byte(当前/下一个/铁路相位号)。
/// 我们给的 <c>ZoneStorageDataHandle</c> 客户端读不出真 blob(实测 152/167 报
/// "cannot find static zone info / blobZoneCount: 171")⇒ 客户端不会替我们拦车。
/// ⇒ 只能在服务端按「车道 → 路口 → 进口道腿 → 相位组」自己算放行,红灯方向把目标速度压 0。
/// 代价:灯柱不会亮(那是客户端 GPU 蒙皮资源 <c>honglvdeng.asset</c>),但车的让行行为是原生的。
///
/// 数据表由 <c>_gen_junctions.py</c> 从 <c>vehicle_lanes.csv</c> 端点聚类生成:
/// <c>data/junctions.csv</c> = id;cx;cz;groupCount;legCount;laneCount
/// <c>data/junction_lanes.csv</c> = laneHandle;junctionId;groupIndex
/// </summary>
public bool TrafficSignalEnabled { get; init; } = true;
/// <summary>
/// 每个相位组的绿灯时长(秒)。
/// ⛑ 原为 9:相位组数 {2组:219, 3组:124, 4组:9} ⇒ 3 组周期 33s、4 组 44s,
/// 最坏要等 24~33 秒 —— 2026-09-29 实机就是「有的车一直杵在路口」。缩短到 6。
/// </summary>
public float TrafficSignalGreenSec { get; init; } = 6f;
/// <summary>相位切换之间的**全红清空**时长(秒)。没有它会对冲的车在路口里撞成一团。</summary>
public float TrafficSignalClearSec { get; init; } = 1.5f;
/// <summary>
/// 单车红灯等待上限(秒)。等超过这么久就**放行**(当作绿灯)。
///
/// ★ 为什么必须有:周期长度 = 组数 × (绿 + 清空),路口腿数越多等得越久,
/// 单靠调 green/clear 无法给所有路口一个统一的上界。
/// 有了它,任何一辆车的红灯等待都 ≤ 这个值,杜绝「杵在路口不动」的观感;
/// 交叉冲突仍由既有的 yield(<see cref="TrafficYieldSpeedMps"/>)兜底。
/// </summary>
public float TrafficSignalMaxWaitSec { get; init; } = 8f;
/// <summary>
/// 感应式放行:只有「这个路口真的有别个方向来车」时才按相位让行,否则全绿。
///
/// ⛑⛑ 2026-09-29 实机逼出来的:我们把路网 6861/14171 ≈ **48% 的车道**都装了灯,
/// 而场上只有 8 台车撒在 620m 半径 ⇒ 绝大多数路口根本没有冲突方,却被按
/// 「N 组里只有 1 组绿」的概率白拦一半时间(实测 spd avg 掉到 1.9、stop=5/8)。
/// 判据:某路口「有车所在的相位组」≥2 组才算有冲突,此时才看相位钟;否则放行。
/// 关掉它 = 回到「所有路口无脑按相位拦车」。
/// </summary>
public bool TrafficSignalYieldToTrafficOnly { get; init; } = true;
/// <summary>红灯刹停时,在「刹到 0 所需距离」上再加的安全余量(米)。</summary>
public float TrafficSignalStopMarginMeters { get; init; } = 2.5f;
/// <summary>
/// 车流出生点距玩家的**最小**距离(米)。与 <see cref="TrafficSpawnRadiusMeters"/> 组成一个环。
/// ⛑ 2026-09-29 新增(原来只有上限,没有下限):
/// ① 车会直接刷在玩家脚边,且出生点固定取车道起点(很多在路口里)⇒「不像在车道上」;
/// ② 更关键:客户端 <c>AetherVehicleViewManager</c> 在 <c>CANDIDATE_GO_DISTANCE_SQ=400</c>(20m)
/// 内会把车流车提升成 GO(完整载具实体),从此不再吃我们的 Aether 车道插值 ⇒ 冻在原地。
/// ⇒ 让玩家身边一直攒着一批不动的车。设成 60 以上可避开这一圈。
/// </summary>
public float TrafficSpawnMinPlayerDistanceMeters { get; init; } = 60f;
/// <summary>
/// 车流车出生后发一条 <c>SyncAetherAIVehicleForceGo(68832908)</c>:<c>forceGo=false</c>。
/// 期望语义 = 「这台车不要被提升成 GO(完整载具实体)」,让它一直吃服务端推的
/// Aether 车道插值 ⇒ 玩家走近时**不会冻住**。
///
/// ⛑ 为什么需要它:客户端 <c>AetherVehicleViewManager</c> 按距离自动提升 GO
/// (20m 候选 / 8m 强制 / 同屏上限 8 台),提升后车改由客户端本地接管;
/// 我们的车是服务端模拟的、不在客户端本地车流里 ⇒ 提升 = 没司机 = 停在原地。
/// 只靠 <see cref="TrafficSpawnMinPlayerDistanceMeters"/> 躲不开 —— 车会自己开过来。
///
/// ⛑ 语义未证实:`forceGo=false` 也可能被客户端当成「取消强制」而不影响自动提升。
/// 本开关就是为二分这条通道留的:开着没变化 ⇒ 通道无效 ⇒ 改走注入器
/// (hook <c>CheckShouldNotUseGo</c> 恒 true,RVA 0x0B108F00)。
/// 代价(若生效):近处的车保持简化渲染(ECS 整车,无分离的车轮/车顶件/车灯),
/// 但至少一直在动。
/// </summary>
public bool TrafficSuppressGoRender { get; init; } = true;
/// <summary>
/// 车流车出生后发一条 <c>SyncVehicleAutonomousDrivingState(68349264)</c>:<c>isStart=true</c>,
/// 让**客户端本地驾驶系统**接管这台车。
///
/// ★ 与 <see cref="TrafficSuppressGoRender"/> 的区别:那条是求「别被提升成 GO」(已证伪,10-01 实机
/// 包发出去了照样停);这条是**反过来给它一个本地司机** —— 客户端自带驾驶、避障、交通规则,
/// 且本地驾驶本来就面向 GO 完整载具 ⇒ 被提升成 GO 之后照样开。
///
/// ⛑ 客户端**有实现**(<c>GameSceneToClientImpl</c> override,dump.cs:185152 →
/// <c>BaseVehicle.SetAutonomousDrivingState</c>),且 <c>VehicleAutonomousDrivingModule</c>
/// 是每台车的模块、非玩家车专属 ⇒ 车流车也能用。
/// ⛑ **没有**给自驾设目标点的 S2C(唯一带终点的 <c>SyncVehicleStartAI</c> 68915975 客户端无 override)
/// ⇒ 开完之后车往哪开由客户端决定,**未知** —— 本开关就是为了实测这件事(2026-10-01 实验 ①)。
/// 可能结果:①车自己开起来了 ⇒ 车流可整体换管线;②原地不动(缺目标)⇒ 转注入器方案。
/// ⛑ 我们仍在照常推 Aether 车道插值 —— 若两者打架(车抽搐/瞬移),实机会看出来,
/// 届时再加「自驾开启后停止推该车」的开关。
/// </summary>
public bool TrafficAutonomousDrive { get; init; } = true;
/// <summary>
/// ★★ 2026-10-01「方案 A」:车流车改走 <b>Cruise 驱动管线</b> —— 换掉整套服务端车道模拟。
///
/// 背景(两轮证伪后改道):
/// ① 服务端通道证伪 —— <c>SyncAetherAIVehicleForceGo(68832908)</c> 与
/// <c>SyncVehicleAutonomousDrivingState(68349264)</c> 都发过了,实机照样
/// 「车一进镜头就变色停下、移出镜头又开」;
/// ② 客户端 hook 证伪 —— 注入器 8 个探针(<c>AetherVehicleViewManager</c> 的
/// Update / UpdatePossibleSwitchGoVehicles / CheckShouldNotUseGo …)24 分钟
/// <b>全 0 命中</b> ⇒ 这个类根本没在用,按它设计的 hook 方案作废。
/// ⇒ 与其继续猜客户端,不如<b>换一条我们已验证过的驱动管线</b>:
/// <c>SyncAssignVehicleAITask(68549522)</c> + <c>VehicleCruiseParameters</c>,
/// 让<b>客户端本地 AI</b> 开车(出租车 / 玩家自驾都走的这条,已实机验证)。
///
/// 开启后每台车的出生流程变成:
/// ① <c>SyncAetherAIVehicleAddData</c> 用 <c>ControlType=2(RaidVehicle)</c> +
/// <c>LaneHandle=-1</c>(与 <c>DetachTrafficVehicleFromLaneAsync</c> 同款写法)
/// ⇒ 关掉车道模拟,把它从 Aether 插值里摘出来;
/// ② 发 Cruise 任务(目标点由我们的车道图算)+ <c>SyncChangeVehicleController</c>
/// + 主动 ack Running(1)(不 ack 任务卡 Pending,车一动不动 —— 09-28 的坑);
/// ③ 该车<b>不再参与</b>服务端每 tick 的车道推进与 <c>LaneDatas</c> 批次。
///
/// ⛑ 关键未知量:<b>Cruise 能不能驱动空车</b>。出租车/自驾都是车里有人,空车没验过。
/// 若开起来车原地不动,两个方向:① 同时开 <see cref="TrafficAutonomousDrive"/>
/// (给它一个"本地司机"状态);② 关 <see cref="TrafficSuppressGoRender"/>
/// (别再跟"提升成 GO"对着干,GO 才是本地 AI 的正常工作形态)。
///
/// ⛑ 代价:服务端<b>拿不到</b>这些车的实际位置(客户端不上报 AI 车的坐标),
/// 只能用「上一个目标点」当估算位置 ⇒ 回收/避撞判定是近似的。
/// 回滚:设 false 即回到原来的服务端车道模拟。
/// </summary>
public bool TrafficCruiseDrive { get; init; } = true;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下每次规划要走多少段车道(<c>PlanRoute</c> 的 hop 数)。
/// 段数越多 ⇒ 目标点越远 ⇒ 车一口气能开越久(不用等刷新)。太大则目标点可能落在
/// 玩家看不见的远处、车提前被按估算位置回收。12 段 ≈ 数百米。
/// </summary>
public int TrafficCruiseHops { get; init; } = 12;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下重新下发目标点的间隔(秒)。
/// 车到达终点后会停住等下一次刷新;但服务端<b>不知道它到了没有</b>(无位置上报),
/// 所以只能定时重发:旧 token 先 Abort(4),再从「上一个目标点所在的车道」往后规划。
/// 车没到就重发 ⇒ 新目标仍在它前方,等价续驶;车到了 ⇒ 新目标在它脚下前方,正常续驶。
/// </summary>
public float TrafficCruiseRefreshSec { get; init; } = 30f;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下单台车的**强制寿命上限**(秒)。
/// ⛑ 为什么需要:Cruise 车的位置是估算的,按距离回收会不准;不加寿命上限的话
/// 车可能永远达不到回收条件,玩家身边渐渐只剩一批老车、新车补不进来。
/// </summary>
public float TrafficCruiseMaxLifeSec { get; init; } = 150f;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:true = 只把<b>最后一个</b>点当目标发给客户端,
/// 让客户端 C# 的 <c>NavigationHelper</c> 自己沿真实路网导航;false = 把整串车道终点都喂进去。
///
/// ⛑ 为什么默认 true:<c>TargetPointList</c> 是<b>逐点直线</b>的,而我们这份车道几何是
/// 「弦」不是样条 —— 整串喂进去会在弯道上切内弯(压马路牙子/穿绿化带)。
/// 只给终点时客户端必须自己做寻路,走的才是真实道路。
/// (与自驾的 <c>cruiseSingleTarget</c> 同一条结论,09-30 已实机验证。)
/// </summary>
public bool TrafficCruiseSingleTarget { get; init; } = true;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:**快到终点就提前续发**的提前量(米)。
///
/// ⛑ 为什么需要(2026-10-02 01:4x 用户反馈「一条路上两辆车挤在一起开」的根因链):
/// Cruise 的 <c>CruiseType=0(GoToOneTarget)</c> + <c>ConfigFlags=4</c> 会让车**到点就刹停**,
/// 然后等我们续发下一个目标 —— 哪怕「收到 status=2 立刻续发」,中间仍有百毫秒级的空窗,
/// 车是**停在大马路上的**。别的车开过来 ⇒ 顶住它 ⇒ 客户端 AI 倒车脱困 ⇒ 越堵越死。
/// 有了提前量,车在离终点还有这么远时就拿到新目标 ⇒ 全程不停。
/// ⛑ 太大 ⇒ 车还没跑到就改道(走不到目的地,观感随机);太小 ⇒ 还是会停一下。80m ≈ 3 秒车程。
/// </summary>
public float TrafficCruiseLeadMeters { get; init; } = 80f;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:新目标点与**场上其它车的目标点**的最小间距(米)。
/// 0 = 不检查。用来避免两台车被指到同一个路口 ⇒ 挤在一起。
/// </summary>
public float TrafficCruiseTargetMinGapMeters { get; init; } = 80f;
/// <summary>
/// 车流出生时,**朝着玩家开**的那批车占多少比例(0~1)。默认 0.5 = 一半迎面、一半同向。
///
/// ⛑⛑ 为什么必须显式控这个比例(2026-10-02 01:4x 用户反馈「对面车道的车很少」):
/// 实测服务端下发方向**本来是均匀的**(六个方向桶 13%~25%,各方向寿命也一样),
/// 但车流按「距玩家 <c>TrafficDespawnRadiusMeters</c> 内」存活 ——
/// · 朝玩家开的车:从 220m 外开进来、擦身而过、很快出圈 ⇒ **在场时间极短**;
/// · 背对玩家开的车:一直在圈里 ⇒ 越积越多。
/// ⇒ 玩家一旦移动(实测日志里玩家沿 +X 走了 3km),稳态下看到的几乎全是同向车。
/// 这是**生存偏差**,不是随机数偏了 ⇒ 只能靠「强制一半的车朝玩家开」来补。
/// </summary>
public float TrafficSpawnFacePlayerRatio { get; init; } = 0.5f;
/// <summary>
/// 出生时**避开已经有车的车道**(2026-10-02 01:5x)。
///
/// ⛑⛑ 为什么光靠 `TrafficSpawnMinGapMeters`(距离) 不够(用户原话):
/// 「两辆车开**一条车道**,然后**并列**在一起挤着开」。
/// 距离判定读的是 Cruise 车的**推算位置**(dead reckoning),而车实际可能比推算慢很多
/// (被挡/等灯/减速),推算值领先 ⇒ 距离判定形同虚设 ⇒ 两台车生在同一条车道、几乎同一个点
/// ⇒ 客户端 AI 各走各的,谁也不让谁 ⇒ 并排贴着开、车身互相穿模。
///
/// ⇒ 补一条**车道级**判据:候选车道只要已经有车(车的出生车道)就换一条。
/// ⛑ 车的 `Drive.Lane` 是出生车道且不再更新,所以这是「这条车道最近有车占着」的近似 ——
/// 偏保守(车道会一直占到那台车被回收),但方向上正是我们要的「错开」。
/// 找不到空车道时会自动去掉这条约束再来一轮,不会因此刷不出车。
/// </summary>
public bool TrafficSpawnAvoidSameLane { get; init; } = true;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:每台车巡航速度的<b>随机抖动比例</b>(0~0.5)。
/// 默认 0.15 = 每台车在 0.85~1.15 倍之间取一个固定倍率(出生时定下来,全程不变)。
///
/// ⛑ 为什么需要:所有车同一个速度 ⇒ 一旦两台车挨上就会**永远并排贴着开**(相对速度 0,
/// 谁也甩不开谁)。给了速度差,快车会自然拉开,慢车被落到后面 ⇒ 车距自己就出来了。
/// ⛑ 用「每台车固定倍率」而不是每次下发随机:否则同一台车忽快忽慢,观感像抽搐。
/// </summary>
public float TrafficCruiseSpeedJitter { get; init; } = 0.15f;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:巡航速度的<b>绝对上限(km/h)</b>。0 = 不限。
///
/// ⛑⛑ 为什么光有 <see cref="TrafficSpeedScale"/> 不够(2026-10-02 03:0x):
/// 车道限速来自 `vehicle_lanes.csv` 第 9 列,实测分布是
/// **9.72 m/s(35km/h) 占 95.5%**、**16.67 m/s(60km/h) 占 4.5%**(主干道/快速路)。
/// 倍率是一视同仁地乘 ⇒ 主干道那批车会跑到 60×1.15 = **69 km/h**,
/// 而城市街道那批是 40 km/h。用户反馈「车流太快」主要就是这 4.5%。
/// ⇒ 有了绝对上限,主干道被压回城市速度,快慢路仍有区分但都不会飞。
///
/// ⛑ 还有个坑:<c>MaxSpeed</c> 是**出生时**按出生车道限速算一次、之后续发一直沿用
/// (车开到限速不同的路上也不变)。上限对这个坑同样有效 —— 不管生在哪种车道都被压住。
/// </summary>
public float TrafficCruiseMaxSpeedKmh { get; init; } = 35f;
/// <summary>
/// <see cref="TrafficCruiseDrive"/> 下:一次目标至少要规划出多少段车道,少于这个数就判
/// 「这条车道是个死路口袋」⇒ 换一条重新规划(<see cref="TrafficCruiseHops"/> 之外的新判据)。
///
/// ⛑⛑ 为什么必须有它(2026-10-01 23:2x 实机根因):车开完第一次的目标后,下一次从
/// 「上次目标所在的车道」往后规划 —— 而那条车道常常是**死路**(我们路网里 286 个孤岛、
/// 大量车道没有合格后继)⇒ 日志里刷出一串 `points=1/1`,且**每台车每次都拿到同一个目标**
/// (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。
/// 而且多台车撞在同一个目标点上 ⇒ 后车顶住已停的前车、客户端 AI 做倒车脱困后又停。
/// </summary>
public int TrafficCruiseMinHops { get; init; } = 4;
/// <summary>新车出生点与已有车辆的最小间距(米),避免「刚生成就贴上」。</summary>
public float TrafficSpawnMinGapMeters { get; init; } = 25f;
// -----------------------------------------------------------------------------------------
// ★★ 路口相位 / 红绿灯(2026-10-02 从捐赠端 PrivateServerPor 移植)—— 让灯柱真的亮起来。
//
// 背景:主线以前从没给客户端喂过路口数据(init 的 Intersections 恒为 `[]`、
// 运行期相位包 `SyncAetherAIIntersectionUpdateDa`(68819947) 一次都没发过)
// ⇒ 客户端 `AetherIntersectionManager` 建不出 IntersectionDataEntity ⇒ 不 RegisterTrafficLight
// ⇒ **灯柱从来没被接管**。`trafficSignalEnabled` 那套只是服务端自己压车速,跟灯柱无关。
// 详见 `modules/红绿灯与路口相位.md`。
//
// ⛑ 这三个开关互相独立,回滚粒度:
// 只想关灯 ⇒ `intersectionUpdateEnabled=false`(init 仍带路口表);
// 全退 ⇒ 两个都 false 且 `initVariants` 回到 `["top-int7"]`。
// -----------------------------------------------------------------------------------------
/// <summary>
/// <c>SyncAetherAIInitDatas</c>(68704070) 的 <c>Intersections</c> 里<b>带上真路口表</b>(171 个)。
/// 需要配合 <c>initVariants</c> 里选一个 <c>-donor</c> 变体(见 <c>GameAdminBridge</c>)。
/// </summary>
public bool IntersectionsEnabled { get; init; } = true;
/// <summary>
/// 登录后给每个路口推一次 <c>SyncAetherAIIntersectionUpdateDa</c>(68819947),
/// 并 thereafter 每 <see cref="IntersectionPeriodSeconds"/> 切一档相位。
/// ★ 这是「灯真的会变色」的那一条;关掉只留静态初始相位。
/// </summary>
public bool IntersectionUpdateEnabled { get; init; } = true;
/// <summary>
/// 68819947 包里 <c>IntersectionIndex</c> 取什么:
/// <c>"zoneIndex"</c>(默认,= CSV 第一列,客户端用它对 IntersectionDataEntity)或
/// <c>"listIndex"</c>(路口在表里第几行,仅当 zoneIndex 对不上时作为对照试)。
/// </summary>
public string IntersectionIndexSource { get; init; } = "zoneIndex";
/// <summary>
/// ⛑⛑⛑ **默认 false —— 2026-10-03 14:0x 用客户端报错日志证伪了这个方向,别再打开。**
/// 当时以为:客户端拿 ZoneIndex 查 <c>_zoneIndexToBlobIndex</c>,而主线
/// <c>data/intersections.csv</c> 的第二列(59 ⇒ 184)才是"真 zone index",
/// 捐赠端把 <c>ZoneIndex</c> 填成 InstanceId 是错的。
/// 实测打脸(客户端 <c>AetherIntersectionManager.BuildIntersectionData</c> 的
/// <c>cannot find static zone info</c> 报错,**171 个路口报了 132 个**):
/// · 报错的 132 个 zone 值里,属于 InstanceId 集合的 **0 个**;
/// · 没报错的 38 个 zone 值里,属于 InstanceId 集合的 **38/38**。
/// ⇒ 客户端这份 blob 的"zone"就是**以 intersection instance id 为键**的 171 条
/// (<c>blobZoneCount: 171</c>,值域稀疏:111/401/1100/1631 这种都合法),
/// 所以 <c>ZoneIndex</c> **就该填 InstanceId**,捐赠端原写法是对的。
/// 主线那张表的第二列(184)确实**是** zonegraph 的 zone index(转储
/// <c>03_ZONEGRAPHS/full_json/vfc_1286_e108_*.json</c> 的 zones 下标),
/// 但那不是客户端路口 blob 用的键 —— 两个"zone index"不是一回事。
/// ⛑ 142/171 个路口有映射(29 个查不到 ⇒ 回退成 InstanceId,恰好是对的)。
/// </summary>
public bool IntersectionUseTrueZoneIndex { get; init; } = false;
/// <summary>相位切换周期(秒)。捐赠端实测值 12。太小 ⇒ 灯闪得乱、车刚起步就红灯。</summary>
public double IntersectionPeriodSeconds { get; init; } = 12.0;
/// <summary>
/// 一次最多把多少个路口纳入相位推送(0 = 全部 171 个)。
/// 调小可减少包量(全量是 171 包/周期);按距玩家远近排,**近的优先**。
/// </summary>
public int IntersectionMaxCount { get; init; } = 0;
/// <summary>
/// 只推距玩家这个半径内的路口(米,0 = 不限)。0 时行为与捐赠端一致(全量)。
/// ⛑ 玩家走远后新路口要靠下一轮补推 —— 循环每 5s 重扫一次,所以设了也不会漏。
/// </summary>
public float IntersectionRadiusMeters { get; init; } = 0f;
/// <summary>
/// 车流车型池:跳过带 <c>RoofMod</c>(车顶改装件:天窗 / 玻璃顶 / 顶灯)的车型。
/// 排除 7 个车型(81001002/003/008、81003001/004/006/008)。
///
/// ⛑⛑ 2026-09-29 用户更正:本开关的**立论是错的**,现在默认关闭,仅作历史开关保留。
/// 原文写「这 7 个车型的天窗/玻璃会飘在空中」—— 实测不成立:
/// · **没有**车顶件的车,玻璃同样会浮空;
/// · **有**车顶件的车,也有完全正常的;
/// · 而且只发生在**一部分**车上,不是每台。
/// ⇒ 车顶件与「玻璃浮空」之间**没有因果关系**,把它排除掉只是白白少 7 种车型。
/// 真正的嫌疑是 <see cref="TrafficCreateSourceType"/>(玻璃是 <c>VehicleGlassDamageModule</c>
/// 的独立可破坏挂件,只有完整装配管线才会更新它;Aether 车道模拟只更新车辆 root ⇒
/// 玻璃留在建车时的高度),以及 <see cref="TrafficSendBrokenCollision"/> /
/// <see cref="TrafficDisableNavigation"/> 这两条同源管线。
/// </summary>
public bool TrafficSkipRoofModVehicles { get; init; } = false;
/// <summary>
/// 车流车建实体时的 <c>VehicleCreateSourceType</c>(0=Invalid / 1=Task / 2=Summon / 5=AetherAIBorrowVehicle)。
/// ★★ 这是「车玻璃 / 车窗浮在车身上方」的第一嫌疑。
/// 2(Summon) 是「玩家召唤的车」:客户端对它走**完整装配**管线,包含
/// <c>VehicleDamageModule</c> → <c>VehicleGlassDamageModule</c>(玻璃是独立的可破坏挂件:
/// glassMR / brokenGlassMR / triggerTrans / rangeTrans,见 dump.cs TypeDefIndex 3995)。
/// 而车流车的位移由 Aether 车道模拟驱动 —— 那条线**只更新车辆 root**,玻璃那套挂件建好后
/// 没人再更新 ⇒ 留在建车时的高度,看起来就是「玻璃浮在车上方」。
/// 捐赠端(PrivateServerPor) 车流车恒用 **0(Invalid)**,走最简 Aether 车路径,没有这套装配,
/// 所以它没有这个现象 —— 这就是双端差异的实质。
/// ⛑ 只影响车流车;GM「生成载具」的车仍走 Vehicle.cs 自己的 sourceType。
/// </summary>
public int TrafficCreateSourceType { get; init; } = 0;
/// <summary>
/// 车流车出生后是否补发 <c>SyncVehicleBrokenCollision</c>(当前/最大 HP)。
/// ★ 第二嫌疑:这个包喂的是车辆损伤系统(<c>TotalBrokenCollisionImpulse</c> / 损伤模块 state),
/// 与玻璃挂件同一条管线。当初是为「可骇入」加的(<c>CanHack()</c> 要求 CurrentHp &gt; 0)。
/// 关掉 ⇒ 车流车不能骇入;只作为定位用的二分开关,验完请改回 true。
/// </summary>
public bool TrafficSendBrokenCollision { get; init; } = true;
/// <summary>
/// 车流车 <c>SyncSpawnVehicle</c> 的 <c>DisableNavigation</c>。
/// ★ 第三嫌疑:我方一直写 true(「位置由车道流决定,别让客户端自己导航」),
/// 但捐赠端写 **false**。若客户端的导航同时负责贴地,关掉它可能让车身与玻璃高度不一致。
/// </summary>
public bool TrafficDisableNavigation { get; init; } = false;
/// <summary>
/// 车流车 <c>SyncSpawnVehicle</c> 的 <c>SeatInfos</c> 是否留空(对齐捐赠端)。
/// 捐赠端车流车 <c>SeatInfos = []</c>;我方按车型填 N 个空座位(SeatState=0=Empty)。
/// 座位挂点同样是「建车时摆一次」的物件,留空可排除它作为浮空源的嫌疑。
/// ⛑ 留空可能让「上车」交互找不到座位,验完请改回 false。
/// </summary>
public bool TrafficEmptySeatInfos { get; init; } = false;
/// <summary>
/// 车流车型池的车长下限(米)。低于此值的摩托 / 三轮(SHIKAGE·Aero 2.1m、M125-T 2.96m)会被排除。
/// 0 = 不限制。
/// </summary>
public float TrafficMinVehicleLengthMeters { get; init; } = 3.0f;
/// <summary>
/// 车流车型池的车长上限(米)。高于此值的客车 / 卡车(MM·M300 13.9m、Transporter 8.5m)会被排除。
/// 0 = 不限制。
/// </summary>
public float TrafficMaxVehicleLengthMeters { get; init; } = 7.0f;
/// <summary>
/// 行走速度(m/s)。
/// ⛑️ 只在 "crowd" 通道有效 —— ClientCrowdInitData 自带 DesiredSpeed 字段(donor 用 1.4)。
/// "static" 通道下 ClientStaticNpcInitData **没有**这个字段,只能靠
/// SyncChangeNpcMoveDesiredSpeed(68739456) 补,但**该 RPC 在客户端是空 stub**
/// (dump 里只有 virtual 0x9148360,没有 override)⇒ 发了也没用。
/// </summary>
public float AmbientPedsDesiredSpeed { get; init; } = 1.6f;
/// <summary>
/// 是否发 SyncChangeNpcMoveDesiredSpeed(68739456)。默认 false —— 客户端是空 stub,
/// 留着只是给未来客户端版本/对照实验用。
/// </summary>
public bool AmbientPedsSendDesiredSpeed { get; init; } = false;
/// <summary>
/// "crowd" 通道的 AgentPersonaConfig id。donor_lyco082 实测 45200091。
/// ⛑️ NpcFormworkId 必须是 AgentConfig id(4013xxxx),填成 persona id 客户端会打
/// `创建AgentUnit失败!AgentConfigId为0!` 并丢掉这个人。
/// </summary>
public uint AmbientPedsCrowdPersonaId { get; init; } = 45200091;
/// <summary>
/// "crowd" 通道的 ZoneGraphTargetLocationReason(1 字节)。dump 权威枚举:
/// 0=None 1=Wander 2=SmartObject 3=Escape 4=StaticNpcReturn
/// 5=QuickRun 6=VehicleLanePathWander 7=VehicleLanePathQuickRun
/// ⛑️⛑️ **默认改成 1(Wander)** —— 之前填 0(None) 就是"人出现但不走"的直接原因:
/// 等于告诉客户端"这个人没有目标位置意图"。donor_lyco082 抄的是 0,所以他们也没走起来。
/// </summary>
public byte AmbientPedsTargetLocationReason { get; init; } = 1;
/// <summary>
/// "crowd" 通道的 ClientCrowdInitData.ActionId(ushort)。
/// ★★★ donor1 第 38 轮实测语义:这是**行为动作组(ActionGroup)**,不是 POI 动作 id ——
/// KNOWLEDGEBASE 证实它索引的是另一张 C# crowd-action 表。
/// donor1 面板实测值:**400 = NPC 默认** / 410 = 同伴 / 23 = 宠物 / 0 = 非法(路人发 0 站着)。
/// ⛑️ 之前填 1(当 POI Walk 用)是误读;09-20 起 400(与 donor1 成功配方 commandActionId=400 一致)。
/// </summary>
public ushort AmbientPedsCrowdActionId { get; init; } = 400;
/// <summary>
/// "static" 通道的 CPoiActionId(= AgentSyncClientInfo.initPoiActionId)。
/// 同上:1=走路 / 2=idle / 4=跑步。填 0 ⇒ 无动作 ⇒ 站着。
/// ⛑️ 非 0 时会额外发 SyncAgentPlayPOIAction(id, poi, IsActive=true, speed=1)。
/// ⚠️ 实测:`UrbanDiversityPOIActionConfig` 里 id=1(走路) 的 **ApplyRootMotion=False**
/// ⇒ POI 动作只是"走的动画状态",**本身不带位移**。位移必须靠移动系统。
/// </summary>
public uint AmbientPedsPoiActionId { get; init; } = 1;
/// <summary>
/// ⛑️⛑️ "crowd" 通道建完人后**必须补发 SyncManagedLogicAgent(68771737)**。
/// client 端没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。
/// ★★★ donor1 第 81 轮(三重证据):它同时是**客户端来要路径(AskClientNpcFindPath)的前提** ——
/// 不发它,状态机关掉、客户端不来问,服务端推多少移动任务都没人接
/// (成功会话 12 条 vs 失败会话 2 条、AskClientNpcFindPath 310 次 vs 0 次)。
/// 2026-09-20:切到 crowd 后 NPC 全 T-pose,就是这个包没发。
/// </summary>
public bool AmbientPedsCrowdManagedLogicAgent { get; init; } = true;
/// <summary>
/// `AskClientNpcFindPath`(67974714) 是否用真实路网作答。
/// 关闭 ⇒ 回空路径(`FF 01 FF 01`),也就是这条包落地之前的行为。A/B / 回滚开关。
/// </summary>
public bool NpcPathPlanEnabled { get; init; } = true;
/// <summary>一条路径最多给多少个点(donor 的 NpcPathAskMaxPoints)。</summary>
public int NpcPathMaxPoints { get; init; } = 64;
/// <summary>重采样最小点间距(米,donor 的 MinMetres)。</summary>
public float NpcPathMinMetres { get; init; } = 1.5f;
/// <summary>规划不出路时,是否改给「最近车道上的脱困点」(1 个点)而不是回空路径。
/// 空路径会让客户端原地站着不动(spawn 后的 From==To、卡在花坛/门框里的单位都只这样问)。</summary>
public bool NpcPathEscapeEnabled { get; init; } = true;
/// <summary>脱困点沿车道前进的距离(米)。太小挪不出花坛,太大可能跨到别的路口。</summary>
public float NpcPathEscapeMetres { get; init; } = 5f;
/// <summary>
/// "crowd" 通道建完人后补发 SyncAgentPlayPOIAction(id, ActionId, active, 1f)。
/// ClientCrowdInitData.ActionId 只是初始值,显式播一次更稳。
/// </summary>
public bool AmbientPedsCrowdPlayPoiAction { get; init; } = true;
/// <summary>
/// 生成后发 SyncAgentForceGo(id, true) = 68698324(客户端有真实现 0x12753C90)。
/// 语义就是"已生成但站着不动的 agent 用它启动行为树" —— 正对"人出现但不走"。
/// </summary>
public bool AmbientPedsForceGo { get; init; } = true;
/// <summary>
/// MoveWanderingData.pathTags(NavTag 位掩码)。
/// ⛑️⛑️ 填 0 = `NavTagType.None` = 没有任何"可走区域" ⇒ 寻路必然失败,
/// `WanderingActionModule.wanderFailCount` 累到 MaxWanderFailCount(10) 就**永久站住**。
/// 6 = WalkableAndJump(最宽松)。
/// </summary>
public uint AmbientPedsWanderPathTags { get; init; } = 6;
/// <summary>
/// 游荡扇形窗口(度),对应客户端 WanderingAreaDate.{leftAngle,rightAngle}。
/// ⛑️ 两个都填 0 = 零宽扇形 ⇒ 选不出目标。-180/180 = 全向。
/// </summary>
public float AmbientPedsWanderLeftAngle { get; init; } = -180f;
public float AmbientPedsWanderRightAngle { get; init; } = 180f;
/// <summary>单次游荡最短/最远位移(米)。donor 人流用 8..35;固定 NPC 用 0..15。</summary>
public float AmbientPedsWanderMinDis { get; init; } = 8f;
public float AmbientPedsWanderMaxDis { get; init; } = 35f;
/// <summary>
/// 游荡预算(秒)。MoveWanderingData.MaxTime 是**总时长**,走完客户端就永久站住,
/// 所以必须在耗尽前重发。donor:人流 150,固定 NPC 50。
/// </summary>
public float AmbientPedsWanderSeconds { get; init; } = 150f;
/// <summary>提前多少秒重发游荡(要 &lt; WanderSeconds)。填 0 = 不续期。</summary>
public float AmbientPedsWanderRearmSeconds { get; init; } = 140f;
}
/// <summary>
/// 车辆改装店(ApplyCar / RepairShop)2026-09-25 移植自俄语上游 donor。
/// 回滚 = <c>enabled:false</c> ⇒ AskTeleport 不再 echo、两个 waypoint 只 ack 不推位移、
/// 不再补推 SyncStartSummonVehicle(即逐字节回到移植前的行为)。
/// </summary>
public sealed class RepairShopSettings
{
public bool Enabled { get; init; } = true;
/// <summary>生成/召唤后补推 <c>SyncStartSummonVehicle</c>(68681875) —— 没有它,
/// 客户端 <c>ApplyCarManager.IsMySummonVehicle</c> 不认这辆车,
/// 门禁第一关就弹「只能驾驶自己的载具进入」(查全流程找不到别的原因,donor 踩出来的)。</summary>
public bool PushSummonStart { get; init; } = true;
/// <summary>离场落地点的街道微调(作者给定的 LeaveWaypoint 已经离入口触发区 25m+,
/// 正常情况下不需要动;真出现「出门立刻又被拽回店」时再调)。</summary>
public float ExitOffsetX { get; init; } = 0f;
public float ExitOffsetZ { get; init; } = 0f;
/// <summary>
/// 买过的改装件是否落盘(<c>save/vehicles/&lt;pid&gt;.json</c>)。
/// 默认 false = 与 donor 未接 store 时一致:重启即清空。
/// 想让改装结果跨重启保留就置 true(读取侧在进世界时做)。
/// </summary>
public bool PersistTuning { get; init; } = false;
/// <summary>
/// 刷车包(SyncLogicVehicleEnter / SyncSpawnVehicle)里要不要带<b>纯数值件</b>
/// (PartTag 13 引擎 / 20 刹车数值 / 21 传动 / 22 车身数值)。
///
/// 默认 false,理由(donor 2026-09-23 实机):这四类件在 VehiclePartConfig 里
/// <b>没有 PartPath</b>,只有 PartPerfID;把它们混进 Parts 会让真车装配把对应槽位
/// 冲空 ⇒ 全黑 / 透明 / 不可驾驶的车(81005006 带 engine 9995002 + transmission
/// 9995404 + body_value 9995602 复现过)。
///
/// 但它们又是<b>唯一的数值通道</b>(客户端本地根本不应用它们,只认服务端的回声),
/// 所以留这个开关做实机 A/B:置 true 且车变黑 ⇒ 立刻改回 false。
/// </summary>
public bool SendStatParts { get; init; } = false;
}
public sealed class VehicleSettings
{
public bool Enabled { get; init; } = true;
public uint[] FleetIds { get; init; } = [];
public float DesiredApproachMeters { get; init; } = 32f;
public float MinimumApproachMeters { get; init; } = 24f;
public float SearchRadiusMeters { get; init; } = 80f;
public int ZoneStorageDataHandle { get; init; } = -1044702623;
/// <summary>
/// 玩家 F1 / 手机叫车(<c>AskSummonVehicle</c> 67012447)时,车不要在脚下凭空出现,
/// 而是<b>先刷在附近车道上,再自己开过来</b>(2026-10-03)。配置见 <see cref="SummonDriveInSettings"/>。
/// </summary>
public SummonDriveInSettings SummonDriveIn { get; init; } = new();
}
/// <summary>
/// 「召唤载具从车道开过来」(2026-10-03)。
///
/// <para>原生设计确实有这一半:<c>SummonVehicleResult</c> 回执带 <c>TaskToken</c>,客户端
/// <c>SummonVehicleManager</c> 拿它去 <c>RegisterTaskAIStartedEvent(taskToken)</c>,
/// 命中才走 <c>OnSummonShowStart(TaskBase)</c> 演出(<c>补丁和工具/dump.cs:429056-429086</c>);
/// 对称的「车开走回收」在 <c>SummonVehicleRule.vehicleRecycleTime/Range</c>(dump.cs:427762)。
/// ⇒ 官方就是要「车有来有走」。我们以前(和捐赠端 v0.6 一样)直接把车放在玩家右手 5.5m,
/// 那条 token 从没被用过。</para>
///
/// <para>⛑ 改的是<b>生成位置 + 补一个到达任务</b>,管线全部是已实机验证过的那几条:
/// <c>SyncAetherAIChangeVehicleControl(2)</c>(出租车血泪:不切回 Raid 就是「轮胎转、车原地」)、
/// <c>SyncVehicleAutonomousDrivingState</c>、<c>SyncAssignVehicleAITask</c> + Cruise。</para>
///
/// <para>回滚:<c>enabled=false</c> ⇒ 逐字节回到「刷在身边 5.5m」的旧行为,不需要重编译。</para>
/// </summary>
public sealed class SummonDriveInSettings
{
/// <summary>
/// 总开关。false = 车照旧刷在玩家右手 5.5m(2026-10-03 之前的行为)。
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 生成点距玩家的最小距离(米)。太近就看不出「车开过来了」。
/// ⛑ 2026-10-03 18:1x 实机:40–80m 用户反馈「太远了」⇒ 调到 **20–45m**(35km/h ≈ 2–4.5 秒到)。
/// ⛑ 别调太小:低于 ~15m 时车基本是「凭空出现在眼前」,演出就没意义了。
/// </summary>
public float MinDistanceMeters { get; init; } = 20f;
/// <summary>生成点距玩家的最大距离(米)。超出这个距离玩家看不到车开来,只会觉得「叫了不来」。</summary>
public float MaxDistanceMeters { get; init; } = 45f;
/// <summary>
/// 停车点(终点)吸附半径(米)。终点取<b>车道上离玩家最近的点</b>——靠边停车,玩家走过去上车。
/// ⛑ 不用玩家脚下当终点:自定义标点吸附失败时客户端一条 Cruise 都不发(2026-09-30 血泪)。
/// </summary>
public float StopSnapMeters { get; init; } = 60f;
/// <summary>开过来的速度(km/h)。市区 35 够看;太快会在我们这份「弦」几何上切弯。</summary>
public float SpeedKmh { get; init; } = 35f;
/// <summary>
/// ★★ 允许的「弦偏离真实车道」横向距离(米),**0 = 不过滤**(2026-10-03 之前的行为)。
///
/// 背景:车道 CSV(`vehicle_lanes.csv`)只给了车道<b>弦的两个端点</b>,真实车道是曲线,
/// 客户端按 `DistanceAlongLane` 在它本地的曲线上插值。我们沿<b>直线弦</b>取生成点/停车点,
/// 弯道时弦中点完全在路面之外 —— 实测一条 70m 弯车道(弦长 62.96m、弧长 70.02m)
/// 弦中点偏离真实路面约 **10m** ⇒ 召唤车「卡在人行道 / 花坛里」。
///
/// 全量统计:14171 条车道弧弦比**中位数 1.072 / p90 1.242 / 最大 23.8**(约一半车道明显不直)。
///
/// 给正值(默认 1.5)后:偏离随弦参数 t 在两端趋 0,所以弯车道只会保留<b>靠近端点</b>的
/// 生成/停车位置。⛑ 太小会导致「附近没有合格车道」⇒ 退回旧路径(刷在身边 5.5m)。
/// </summary>
public float MaxLateralDeviationMeters { get; init; } = 1.5f;
/// <summary>
/// 是否先用车道图 Dijkstra(<c>SolveRoute</c>)算一串点再下发。
/// 解不出路就退回单点(交给客户端 C# <c>NavigationHelper</c> 自己导航),不会失败。
/// </summary>
public bool UseLaneRoute { get; init; } = true;
/// <summary>
/// 下发 Cruise 前先把车切成 <b>RaidVehicle=2</b>(<c>SyncAetherAIChangeVehicleControl</c>)。
/// ⛑ 出租车就是栽在这里:客户端把车切成 AetherVehicle=1 后,Cruise 的位移每帧被车道覆盖,
/// 表现是「轮胎在转,车在原地进行」(<c>GameRouter.Taxi.cs:147-166</c>)。
/// 召唤车是 <c>CreateSourceType=2</c> 完整装配、有 VDE,一样会被 Aether 接管 ⇒ 这条不能关。
/// </summary>
public bool ForceRaidControl { get; init; } = true;
/// <summary>
/// 找不到合格生成点 / 附近没车道(室内、地下、孤岛)时的兜底:
/// true = 退回「刷在身边」;false = 直接回召唤失败(不要这么设,玩家会以为功能坏了)。
/// </summary>
public bool FallbackToBesidePlayer { get; init; } = true;
/// <summary>
/// 车开到之后<b>收尾</b>:撤掉 Cruise 任务(<c>AssignVehicleControl&lt;VehicleStopParameters&gt;</c>)
/// + 推 <c>SyncVehicleAutonomousDrivingState(false)</c>(照抄出租车的 <c>StopTaxiTaskAsync</c>)。
///
/// ⛑⛑ 这是 2026-10-03 实机三个症状的<b>共同解药</b>:客户端
/// <c>SummonVehicleManager</c> 是用 <c>RegisterTaskAIDeletedEvent(token)</c> 等任务被删除,
/// 才走 <c>OnSummonShowFinish</c> 结束「召唤演出」。任务一直挂着 ⇒
/// ① 车还在 AI 手里,玩家方向盘无效(<b>车开不动</b>);
/// ② 客户端认为这台车还在召唤流程中(<b>下车后再也上不去</b>);
/// ③ 上一台车的演出没结束(<b>「叫车小助手忙」,叫不了第二台</b>)。
///
/// 关掉它 ⇒ 回到这三个症状(排障对照用)。
/// </summary>
public bool EndTaskOnArrival { get; init; } = true;
/// <summary>
/// 车开到之后,把 <c>SyncChangeVehicleController</c> 的 <c>controllerPid</c> 还原成 <b>0</b>。
///
/// ⛑⛑ 这是「车开不动」的真凶(2026-10-03 18:4x):下发 Cruise 时我们照自驾的老规矩
/// 发了 <c>SyncChangeVehicleController(controllerPid = Profile.PlayerPid)</c> ——
/// 那是必需的(不锁主控权,玩家的方向盘输入会盖掉 AI 任务,车根本开不过来)。
/// 但到达后<b>没人还原</b> ⇒ 主控权一直挂在服务端 ⇒ 玩家坐进主驾也只算乘客,
/// 踩油门没反应。以前召唤车能开,正因为这条路径从没发过这个包。
/// 实机佐证:18:38:55 坐 seat=0(主驾)一样开不动 ⇒ 不是座位问题。
///
/// 关掉它 ⇒ 回到「车开不动」(排障对照用)。
/// </summary>
public bool ReleaseControlOnArrival { get; init; } = true;
/// <summary>
/// 车开到之后,服务端补一发 <c>SyncTeleportVehicle</c>(68088911, <c>Reset=true</c>),把车钉在
/// <b>它现在真正的位置</b>(<c>AskVehicleMove</c> 上报的最新值)。
///
/// ⛑⛑ 为什么必须有(2026-10-03 实机):服务端在生成时下发过
/// <c>SyncAetherAIVehicleAddData</c>,里面写着<b>生成点</b>的坐标,而
/// <c>LaneHandle=-1</c> ⇒ 这份 Aether 坐标<b>永远不会被更新</b>。车是被 Raid 通道的
/// Cruise 开过来的,客户端两套表示就此分叉:看得见的车在身边,Aether 里那台还在生成点。
/// 玩家一上车,客户端按 Aether 那份数据对齐 ⇒ **车瞬移回生成点**。
/// 位置取自 <c>AskVehicleMove</c>(AI 开车期间客户端每秒都在报),所以这一发在视觉上是
/// 「原地不动」,只是把两边强行对齐。
///
/// 关掉它 ⇒ 回到上车瞬移生成点的行为(排障对照用)。
/// </summary>
public bool SyncPositionOnArrival { get; init; } = true;
/// <summary>每次召唤都打选点日志。排障时才开(正常玩的时候每叫一次车一条)。</summary>
public bool LogEverySummon { get; init; } = true;
}
/// <summary>
/// 载具路线规划。默认全开是因为「有路线」不可能比「没路线」更糟 —— 客户端当时每秒问一次,
/// 而返回形状本来就已被客户端接受(DefaultReturnCatalog 的 complex 677),我们只是把两个
/// 空列表填上真实点。**回滚 = Enabled=false**,不需要重编译。
/// </summary>
public sealed class VehicleNavigationSettings
{
public bool Enabled { get; init; } = true;
/// <summary>把「车的位置」吸到最近车道的最大距离(米)。开车时车应该在路上,40m 已经很宽松。</summary>
public float StartSnapMeters { get; init; } = 40f;
/// <summary>把「目的地」吸到最近车道的最大距离(米)。目的地可能是个楼前的点,所以给到 120m。</summary>
public float TargetSnapMeters { get; init; } = 120f;
/// <summary>下行点集上限。这是一条 INVOKE 的回包,192 点 × 12B ≈ 2.3KB,够画满一条路线。</summary>
public int MaxPathPoints { get; init; } = 192;
/// <summary>丢掉间距小于该值的相邻点(米)。车道弦本身就有大量重合端点。</summary>
public float MinPointSpacing { get; init; } = 1f;
/// <summary>Dijkstra 扩展上限。14171 条车道正常几百次就收敛,卡住说明图有问题。</summary>
public int MaxExpansions { get; init; } = 200_000;
/// <summary>是否填 <c>CenterPoints</c>。今天线上请求带的正是 <c>NeedCenterPoints=true</c>。</summary>
public bool IncludeCenterPoints { get; init; } = true;
/// <summary>
/// 点序翻转为「终点 → 车」。捐赠端探针断言过这是客户端要的顺序,我方**尚未实测**;
/// 若导航线箭头反了(或自驾往反方向开),改成 false 即可。
/// </summary>
public bool ReversePoints { get; init; } = true;
/// <summary>
/// 是否下发 S2C <c>SyncVehicleAutonomousDrivingState</c>(68349264)—— 也就是「车会不会自己开」。
/// 这是客户端自动驾驶的<b>唯一开关</b>(见 <c>GameRouter.VehicleNav.cs</c> 的取证注释):
/// false ⇒ Start/Stop 照常回 Ok,但不推状态包,车永远不会动,等于回到 2026-09-27 之前的行为。
/// 回滚不需要重新编译。
/// </summary>
public bool AutonomousDrivingEnabled { get; init; } = true;
/// <summary>
/// 巡航速度(米/秒)。写进 <c>VehicleCruiseParameters.MaxSpeed / DefaultSpeed</c>。
/// 12 m/s ≈ 43 km/h,市区够看;太快会在我们这份「弦」几何上切弯。
/// </summary>
public float CruiseSpeed { get; init; } = 12f;
/// <summary>巡航路点上限。AI 任务包不宜过大,64 点够跑几百米的主干道。</summary>
public int CruiseMaxPoints { get; init; } = 64;
/// <summary>判定「到达终点」的距离(米)。太小车会在终点附近来回蹭。</summary>
public float CruiseArrivalMeters { get; init; } = 3f;
/// <summary>
/// 巡航任务的 <c>DrivingFlags</c>(客户端枚举 <c>VehicleTaskDrivingFlags</c>,定义见
/// <c>ux_rpc/payload_types.dump.cs:193</c>)。默认 <b>16443</b> = 1|2|8|16|32|16384:
/// <code>
/// 1 DFStopForCars 遇车停
/// 2 DFStopForPeds 遇人停
/// 8 DFSteerAroundStationaryCars 绕开停着的车
/// 16 DFSteerAroundPeds 绕开行人
/// 32 DFSteerAroundObjects 绕开物件
/// 128 DFStopAtLights 等红灯
/// 16384 DFAdjustCruiseSpeedBasedOnRoadSpeed 按道路限速收速
/// </code>
/// ⛑ 2026-09-30 之前这里只有 <c>1</c>(只留 DFStopForCars)⇒ 车不会绕开静止车辆/行人/
/// 物件,前面一停就顶死,也不等红灯。现在这组值取自捐赠端 v0.5-2 的
/// <c>BuildAutonomousDriveTaskAtSpeed4229938</c>(<c>1|2|8|16|32|128|16384</c>)。
/// ⛑⛑ **2026-10-02 去掉了 128(DFStopAtLights),默认从 16571 改成 16443。**
/// 背景:当天把红绿灯点亮之后,客户端开始按灯拦车,表现是「一批车停在路口不动」。
/// 查证:主线 24 台车全是**客户端 Cruise AI 开**(服务端车流仿真 `n=0` 直接 return,
/// `[TRAFFIC-STAT]` 一条都没有 ⇒ `trafficSignalEnabled` 那套根本没参与),
/// 所以拦车的是客户端,而 `128` 正是 Cruise 任务的「等红灯」位。
/// 捐赠端同样是这个取向:它有 `aether.vehicleSignalStopEnabled`(默认 **false**)——
/// 「灯要亮,但车别真等」。⇒ 去掉 128 = 灯照常亮、车照常开。
/// 想让车重新等灯:把 128 加回来(16571)。
/// ⛑ 16384 会让道路限速盖掉 <c>CruiseSpeed</c>:GM 拉到 120 但路上限速 60 时车仍只跑 60。
/// 想让 GM 速度硬生效就把它去掉(59 = 1|2|8|16|32)。
/// 回滚:<c>16571</c> = 等灯(点亮前的行为)/<c>1</c> = 2026-09-30 之前的旧行为。
/// </summary>
public int CruiseDrivingFlags { get; init; } = 16443;
/// <summary>
/// 只把<b>终点</b>(一个点)交给客户端的 Cruise 任务,而不是我们算出来的整串车道点。
///
/// 客户端 C# 侧自带导航(<c>LX6.Drive.AI.NavigationHelper.SetTargetPosition(start, target)</c> +
/// <c>GetNavigationResult(...)</c>),捐赠端就是这么发的:<c>CruiseType=GoToOneTarget</c> + 单点。
/// ⛑ 我们这份车道几何是「弦」不是样条(<c>_gen_vehicle_lanes.py</c> 压掉了中间点),喂整串点会切内弯。
/// 回滚:改成 <c>false</c> ⇒ 回到「服务端下发最多 <see cref="CruiseMaxPoints"/> 个点」的旧行为。
/// </summary>
public bool CruiseSingleTarget { get; init; } = true;
/// <summary>每次请求都打日志(开车时 = 每秒一条)。排障时才开。</summary>
public bool LogEveryRequest { get; init; }
}
/// <summary>
/// 厕所 QTE / 马桶鸵鸟骑乘(2026-09-30)。玩法本体全在客户端
/// (<c>ToiletOstrichGamePanelStore.lua</c> 的音游式三键 QTE:<c>QTE_DURATION=2</c> /
/// <c>PERFECT_WINDOW=0.1</c> / 满分 100),服务端只做三件事:接住骑乘请求、接住失败上报、
/// 把坐骑生成成「能被骑」而不是「会打人」。
///
/// <para>马桶鸵鸟 = 标准 Agent(<c>NpcCatalog</c> 5 条同名,<c>GeneralModelId=86963029</c>,
/// <c>EnemyClassType=1</c>,faction 18000300「通用怪物」),规范 id
/// <c>AgentConfig.ToiletOstrich = 42046401</c>;它已在 <c>AgentSummonCatalog.json</c> 里,
/// 并被置顶到面板「生成敌人」下拉框第一位 ⇒ **不需要新的生成通道、也不需要新的面板卡**。</para>
/// </summary>
public sealed class ToiletQteSettings
{
/// <summary>
/// 总开关。<b>默认 true</b>:三个 handler 都在 <c>Enabled4229938MethodIds</c> 里,
/// 关它只是「不回头推状态、不回收鸵鸟」,注册与日志都还在(保持可观测)。
/// 想彻底回到「什么都不做」:把 <c>GameRouter.cs</c> 里那三个 mid 从白名单删掉。
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 收到 <c>AskRideAgent</c> 时是否先推一条 <c>SyncRideAgent(rider, ridee, hasRideOn=true)</c>
/// (68906846)再回 ack。<b>默认 true(2026-09-30 16:5x 实测后改过来)</b>。
///
/// <para>★ 实测证据(日志 <c>console-20260930-164759.log</c>):默认 false 时
/// <c>AskRideAgent</c> 回了 0 字节 ⇒ 面板<b>确实弹出来了</b>
/// (客户端日志 <c>sss_panel_detail: S_ToiletOstrichMiniGame</c>),但
/// <b>没有驯服动画、骑乘后角色不能动、QTE 只出一个键就结束</b>。
/// ⇒ 面板是客户端本地兜底 fire 的,<b>骑乘状态机没被服务端推起来</b>:
/// 客户端 C# 的骑乘态由 <c>SyncRideAgent</c> 驱动(状态机
/// <c>MuGenStates.Logic.Ride.ChangeRideState</c> 的
/// <c>rideState</c>:0 未骑乘 / 1 准备上 / 2 上骑乘中 / 3 骑乘中 / 4 准备下 / 5 下骑乘中,
/// 位移由 <c>StartRideAgentMotion</c> 负责 ⇒ 没进 3 就没有动画也不会动)。</para>
///
/// <para>⛑ 顺序固定为「状态包 → ack」,别反过来(见 <c>GameRouter.VehicleNav.cs</c> 的
/// §43.1 包序教训)。下马同理:回收实体前先推一条 <c>hasRideOn=false</c>,
/// 否则就是「马桶没了、人还坐在空中」。</para>
/// </summary>
public bool PushRideState { get; init; } = true;
/// <summary>
/// 骑乘时是否额外推 <c>SyncAddUnitState</c>:<b>坐骑 345 = BeRiddenS</b>(behavior 10345,
/// 进入「被骑」移动状态)+ <b>骑手 346</b>;下马时用 <c>SyncRemoveUnitState</c> 撤掉。
/// <b>默认 true</b>。
///
/// <para>★ 这是「驯服动画」和「能骑着跑」的真正开关</b>。出处是 v0.5-2 捐赠端
/// <c>GameRouter.HackerDevices.cs:254</c> 的注释:
/// <c>"UnitStateConfig 345 (BeRiddenS, behavior 10345) enters the mount's riding
/// movement state; 346 marks the rider. An empty successful return alone does not
/// publish either state."</c></para>
///
/// <para>⛑ 2026-09-30 19:2x 实测:只推 <c>SyncRideAgent</c> 不开这条 ⇒
/// 「QTE 正常,但没有驯服动画,成功也骑不起来」,坐骑从来没进 BeRiddenS。</para>
///
/// <para>客户端侧佐证(说明 345 语义可信):<c>LX6.Fight.UnitStateMgr.BeRiddenSStateChange</c>、
/// <c>MuGenStates.Logic.Agent.CheckBeRiddenState</c> 都真实存在。</para>
/// </summary>
public bool PushUnitStates { get; init; } = true;
/// <summary>
/// 是否推 <c>SyncToiletPosture(agentId, isSquatting=false)</c>(68122107,蹲/起姿态)。
/// <b>默认 false</b>。⛑ 这条在客户端是 <b>CSharpOnly</b>(lua 侧无 reader)⇒ 效果只能在画面上看,
/// 别指望 lua 日志。官方对应的是 GM 入口 <c>GmCreateToilet / GmCreateToiletSquatting</c>,我们不走。
/// </summary>
public bool PushPosture { get; init; }
/// <summary>
/// 收到 <c>AskToiletOstrichFlee</c>(QTE 失败)时是否把这只 unit 从世界里摘掉
/// (发 <c>SyncLogicAgentLeave</c> + 清敌人账本条目)。
/// <b>默认 false(2026-09-30 19:2x 改过来)</b>。
///
/// <para>对齐 v0.5-2 捐赠端 <c>GameRouter.ToiletQte.cs:44</c>:
/// <c>"retaining the test mount for retry instead of destroying it"</c> ——
/// 失败只是下马,马桶留在原地可以再来一次。</para>
///
/// <para>⛑ 即使这里是 false,<b>解除骑乘(<c>SyncRideAgent</c> + 撤 345/346)照发</b>;
/// 这个开关只管「要不要把实体从世界里摘掉」。
/// ⛑⛑ 摘除前仍有一道硬门槛:只摘<b>本服生成</b>的 uid(见
/// <c>GameRouter.ToiletQte.IsServerSpawnedUid</c>)—— 2026-09-30 曾误删过场景自带的
/// <c>uid=19000</c>(hp=1500 的真实敌人)。</para>
/// </summary>
public bool RetireOnFlee { get; init; }
/// <summary>
/// 从面板生成马桶鸵鸟时是否走「坐骑」而不是「敌人」:<c>engage=false</c>(不登记进敌人账本、
/// 不下令开战)+ <c>forceEnemyRelation=false</c>(不把玩家和它显式标成敌对)。
/// <b>默认 true</b>:它的用途是被骑 + 玩 QTE,带攻击 AI 的话玩家会先跟它打起来。
///
/// <para>⚠️ 它仍然在 AgentConfig 的 <c>EnemyClassType=1</c> 桶里,所以面板必须按
/// 「生成敌人」调(<c>hostile=true</c>),camp 也仍是 4 —— 这条只影响「要不要打」。</para>
/// 回滚:改 false ⇒ 与普通敌人逐字节同路。
/// </summary>
public bool PassiveSpawn { get; init; } = true;
/// <summary>
/// 骑上马桶后是否下发<b>原生坐骑操控桥</b>(加速 / 跳跃)—— 2026-10-02 新增,
/// 自 7k-v0.6 捐赠端 <c>GameRouter.ToiletControls.cs</c> 逐字搬运,实现见
/// <c>GameRouter.ToiletControls.cs</c>。<b>默认 true</b>。
///
/// <para>★ 它做的事:把客户端 <c>GameplayMountPanelStore</c> 的
/// OnRushBtnPress/Release(加速)与 OnJumpBtnPress/Release(跳跃)等原生 HUD 回调
/// 直接转成 3C 事件发给<b>已确认的挂载目标</b>,绕过骑手自身的战斗 / 跑酷输入闸门
/// (骑着马桶时那批按键本来会被判定成人物动作)。</para>
///
/// <para>⛑⛑ 预期管理:这只保证<b>事件送达</b>。动画、速度、跳跃物理仍然全是客户端原生的,
/// 坐骑动画图里没有的过渡(比如马桶鸵鸟没有冲刺状态)送到了也不会动。
/// 捐赠端原话:"event delivery is NOT proof that an incomplete client animal graph
/// implements every transition."</para>
///
/// <para>⛑ 客户端文件一个都不改,走的是已有 GATE 通道(<c>SyncDoToLuaMaster</c> 45437825)。
/// 回滚:这里改 false 即可(不再下发 Lua,骑乘状态机与 QTE 完全不受影响)。</para>
/// </summary>
public bool NativeControls { get; init; } = true;
}
/// <summary>
/// 出租车(2026-09-30 新增,<c>GameRouter.Taxi.cs</c>)。
///
/// <para>★ 计价公式沿用捐赠端 <c>TaxiFare4229938</c>:
/// <c>Ceiling(BaseFare + Max(0, distance - FreeMeters) * FarePerMeter)</c>。</para>
///
/// <para>⛑ 目的地是<b>配置 uint</b>(<c>TaxiNavigationConfig.json</c> 的 <c>Id</c>,共 18 条),
/// 不是车辆 uid;接单时会校验目的地的 <c>RaidId</c> 必须等于当前场景,
/// 否则会把玩家往别的 raid 送。</para>
///
/// <para>⛑ 服务端只做四件事:认单 → 派 Cruise → 判到达 → 收车费。
/// 选目的地的 UI、上车提示、到达演出全是客户端原生 <c>TaxiSystemManager</c> 的事。</para>
/// </summary>
/// <summary>
/// 里希牢房(2026-09-30)。
///
/// ⛑ 坐标<b>故意不做成配置项</b>:门口 / 牢内那两个点是客户端原生素材
/// (<c>Police_Prison.prefab</c> 79100336 的 Gadget pathId 231849593、
/// <c>PoliceConfig.ArrestNpcPosition[0]</c>),改了就不是那个牢房了,见
/// <c>GameRouter.PolicePrison.cs</c> 的常量注释。真要挪位置得改代码里的常量。
/// </summary>
public sealed class PolicePrisonSettings
{
/// <summary>总开关。false ⇒ commit / release 直接返回失败提示。</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 允许操作的半径(米)—— 玩家必须站在牢房门口这么近才能收押/带出。
/// 捐赠端是硬编码 4 m(<c>distance &lt;= 16</c> 的平方比较)。
/// </summary>
public float NearMeters { get; init; } = 4f;
/// <summary>
/// 是否要求玩家在主城(<c>ActiveInstanceId == 20001222</c>)。
/// 牢房是主城里的一块地,不在主城就不该有这个门;关掉可以方便调试。
/// </summary>
public bool RequireNovaScene { get; init; } = true;
}
public sealed class TaxiSettings
{
/// <summary>总开关。false = 四个出租车 handler 全部直接回失败码(注册仍在,日志仍在)。</summary>
public bool Enabled { get; init; } = true;
/// <summary>起步价(含 <see cref="FreeMeters"/> 米)。捐赠端原值 300。</summary>
public double BaseFare { get; init; } = 300;
/// <summary>起步价包含的里程(米)。捐赠端原值 200。</summary>
public double FreeMeters { get; init; } = 200;
/// <summary>超出起步里程后每米单价。捐赠端原值 <c>80 * 0.005 = 0.4</c>。</summary>
public double FarePerMeter { get; init; } = 0.4;
/// <summary>正常速度(km/h)。捐赠端原值 15 m/s ≈ 54。</summary>
public float NormalSpeedKmh { get; init; } = 54;
/// <summary>「加速」后的速度(km/h)。捐赠端原值 25 m/s = 90。</summary>
public float AcceleratedSpeedKmh { get; init; } = 90;
/// <summary>判定「已到达」的距离阈值(米)。捐赠端原值 3(判到达用 4 m 平方=16)。</summary>
public float ArrivalMeters { get; init; } = 20; // 与捐赠端一致(20m);见 GameRouter.Taxi.cs
/// <summary>
/// 收不到位移上报多久算「卡住」(毫秒)。卡住不结束行程,只提示可换目的地/跳过。
/// 捐赠端原值 45000。
/// </summary>
public int StalledAfterMs { get; init; } = 45000;
/// <summary>单趟最长时长(毫秒),超时强制结算。捐赠端原值 1200000(20 分钟)。</summary>
public int MaxTripMs { get; init; } = 1200000;
}
public sealed class SwitchAnimationSettings
{
public int SwitchType { get; init; }
public string CommonTimelinePrefix { get; init; } = string.Empty;
public bool AvoidImmediateRepeat { get; init; }
/// <summary>
/// 切人演出总开关。false = 回到原来的静默原地切人(只发身份移交,不碰
/// SyncPreSwitchSpirit / SyncSwitchSpiritConfigId)。回滚不需要重新编译。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// ⛑⛑⛑ 为切人演出 spawn <b>演出演员</b>(主演 + 陪衬)到落点,并把它们的 uid 放进
/// <c>SyncSwitchSpiritConfigId</c> 的 <c>spawnedAgentIds</c>。默认 <b>true</b>。
///
/// 官方语义(<c>ClientConfigRepository.cs:862</c> 引的注释):
/// "Those rows carry AgentId entries that <b>stock SwitchTeleport expects the server to spawn
/// before landing</b>." ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent 建好。
///
/// <b>这就是「切人演出里那个在演戏的人」</b>:主演取演出行的 <c>FightSpiritIds[0]</c>,
/// 经 <c>AgentIdForSpirit</c> 映射(嘉姆 15020989→40140032、塔菲 15020992→40140035);
/// 陪衬取行的 <c>AgentIds</c>。
/// ⛑ 与「主角单位换皮」无关 —— 换皮那个是给玩家操控的,这些是演戏的身体,演完撤离。
/// 设 false 即回到改动前(演出里依然只有道具和环境在动)。
/// </summary>
public bool SpawnShowActors { get; set; } = true;
/// <summary>
/// 演完后的<b>收尾握手三包</b>:<c>SyncPlayerSwitchSpiritInSitu</c> +
/// <c>SyncManagedSpirit</c> + <c>SyncManagedAgent</c>(顺序照官方)。
///
/// ⛑⛑ 2026-10-03:主线这三个包<b>一次都没发过</b>(NotifyAsync 各 0 处),而 CBT2 官方测试服务端
/// 的切人序列里有它们(`ClientToGameserver.cs:3142-3160`),后两个的原注释是
/// "explicitly transfer control (**fix: frozen after switch**)"。
/// 对应症状:演出中<b>角色不显示</b>(Timeline 的角色 track 拿不到 spiritId)、
/// 播完<b>T-pose 几秒</b>(控制权没交回新角色)。
/// 默认 <b>true</b>;设 false 即回到改动前的旧行为(一键回滚)。
/// </summary>
public bool PushHandoffPackets { get; set; } = true;
/// <summary>
/// 演出开始<b>之前</b>就把目标角色投放到演出落点(SwitchSpiritConfig 行的 Position),
/// 并在 SyncSwitchSpiritConfigId 的 spawnedAgentIds 里带上它。
/// 这是「镜头飞过去时远处真的站着角色」的关键:捐赠端从来不预生成,只在客户端报告演出播完后
/// 才在<b>玩家原位置</b> materialize,于是镜头飞到行锚点看到的是空地,播完又被拉回原角色。
/// 关掉后仍会落在演出锚点,只是演出期间那里暂时没人(地形未流式加载时可作降级)。
/// </summary>
public bool SpawnTargetBeforeShow { get; init; } = true;
/// <summary>
/// 等客户端 ReportPreSwitchSpiritFinish 的超时(毫秒)。超时后服务端自己完成移交,
/// 但仍然落在演出锚点上(不会退回原位置),保证 roster 不会因为一次丢包永久卡在 pending。
/// </summary>
public long PreSwitchTimeoutMs { get; init; } = 6000;
/// <summary>
/// 演出落点与玩家当前位置的距离上限(米)。0 = 不限制。
/// 63 个可用锚点里「最近锚点距离」中位数约 636 m、p90 约 1675 m,所以开启演出等于切完人
/// 被传送几百米(官方语义)。想减轻就填一个数字,超限时本次退化为静默原地切人。
/// </summary>
public float MaxAnchorDistanceMeters { get; init; }
/// <summary>
/// 把 SyncSwitchSpiritConfigId 与 SyncPreSwitchSpirit 一起在 phase 1 发出。
/// 捐赠端注释:晚发会让客户端先退出切人控制器、再为落地 Timeline 重新进入 ⇒
/// 「切完人后还回去了,然后又回来了」的闪跳。
/// </summary>
public bool PublishConfigIdEarly { get; init; } = true;
/// <summary>
/// ⛑⛑ 已证伪,**默认关闭**(2026-09-24 13:1x 实测)。留着只为记录,别再打开。
///
/// 症状:切完人自己头顶常驻 F(个人信息 / 动作 / 离开)。
/// 根因:客户端 <c>IsMe</c> 在<b>建单位那一刻</b>锁存,之后服务端改不了(Player.log):
/// <code>
/// 13:08:47 InitModules 100000000000 IsMe True ← 登录 + "[LB] 进入场景 创建主角"
/// 13:13:48 InitModules 100000000012 IsMe False ← 切人 + "[LB] 创建角色"
/// 13:14:28 InitModules 100000000000 IsMe False ← 切回登录那个 unit,照样 False
/// </code>
///
/// 两轮服务端修复全部无效,反证如下:
/// ① 提前 / 重复发 <c>SyncPlayerCurrentSpirit</c>(isMeFix=reorder、recreate+300ms)
/// —— 68830687 在 RPCDeserializeAuto.lua:1260 标了 <b>LuaOnly</b>,
/// C# 侧根本不读这个包,改顺序纯属自欺。
/// ② 改 <c>ManagedPid</c> —— 判据被实测推翻:登录走 ExistingUnitProjection
/// (RuntimePayloadFactory.cs:557 显式 <c>ManagedPid = 0</c>)却 True;
/// 切人走 CharacterUnitProjection(同文件 :644 <c>ManagedPid = Pid</c>)却 False。
/// ③ 登录之所以 True,是客户端 native CreateHero 的功劳(跟着 SyncEnterScene 走),
/// <b>纯服务端推包建不出「主角”</b>。
///
/// 真修法在客户端侧:切完人调
/// <c>InteractBtnMgr:RemoveBtnByType(pid, LX6.Interact.BtnTargetType.Unit)</c> 摘掉按钮,
/// 见 <c>Drmk.Proxy/proxy/tools/switch_no_interact.lua</c>(日志前缀 [DRMK-NOF])。
///
/// 副作用:开着时预生成的角色会在演出落地时被 Leave 掉再重建(+300ms 延迟),
/// 画面上多一次闪烁,而 F 并不会消失 —— 纯亏。
/// </summary>
public bool FixIsMeOnHandoff { get; init; } = false;
/// <summary>
/// ⛑⛑ 切人演出开始前预生成的那个目标单位,在移交控制权时<b>先销毁再重建</b>。
/// 默认开,<b>关掉会让部分角色切过去走不动</b>。
///
/// 预生成(<see cref="SpawnTargetBeforeShow"/>)建单位时发过 <c>SyncManagedLogicAgent</c>
/// ⇒ 该 actor 在客户端被标成「服务端驱动」:本地移动被忽略、只等服务端喂
/// <c>SyncLogicAgentSyncData</c>,而我们从不给玩家喂位置。症状就是
/// <b>原地有跑步动画但坐标不动</b>,攻击 / 开手机却正常(那些不走移动系统)。
/// 2026-09-24 14:2x 实测:把它当成「IsMe 修复的副作用」一起砍掉后立刻复现该症状。
///
/// ⛑ 它跟头顶的 F 是两件事:F 是 <c>IsMe</c> 锁存,服务端治不了,走客户端侧摘按钮
/// (<c>switch_no_interact.lua</c>)。别再把这两件事捆在一起。
/// </summary>
public bool RecreatePreSpawnedTargetOnHandoff { get; init; } = true;
/// <summary>
/// 以主角身份重建目标角色时,<c>SyncLogicAgentLeave</c> 之后等多久再发建单位那一组(毫秒)。
/// 0 = 不等。客户端销毁单位是异步的,Leave 与重建挤在同一批包里会被合并掉 ——
/// 12:30 实测就是连一条新的 <c>InitModules</c> 都没有(单位没被销毁)。
/// 仅在 <see cref="RecreatePreSpawnedTargetOnHandoff"/> 打开时有意义。
/// </summary>
public int RecreateDelayMs { get; init; } = 300;
/// <summary>
/// 切换角色时把旧角色**留在大世界里**(不发 <c>SyncLogicAgentLeave</c>),
/// 记录到 <c>WorldEntryState.LeftBehindCharacters</c>。默认开。
/// 留下的角色是「服务端驱动」单位:站着待机、不移动、不吃玩家输入。
/// 关掉 = 回到旧行为(切走后旧角色立刻消失)。
/// </summary>
public bool KeepLeftBehindCharacters { get; init; } = true;
/// <summary>
/// 切回一个「留在大世界里」的角色时,落点取**它留下时的位置**而不是
/// SwitchSpiritConfig 行的演出锚点(用户原话:下次切换直接切到那个角色的所在地)。
/// 演出照常播:configId 照常选(按离留下位置最近挑,镜头锚点≈角色所在),
/// 只有落点坐标被覆盖。默认开;关掉 = 即使角色在大世界里也按演出锚点落地。
/// </summary>
public bool ReturnToLeftBehindLocation { get; init; } = true;
/// <summary>
/// ★★ <b>第一次</b>切某个角色(他还没被切走过、<c>LeftBehindCharacters</c> 里没有落点)时,
/// 也把他自己的<b>日程点位</b>当落点 —— 而不是原地切换(09-26 00:4x 加,用户要的)。
///
/// ⛑ 为什么原来做不到:落点只有两个来源 —— 「这个角色上次被切走时留在哪」
/// 或「玩家现在站在哪」。<c>LeftBehindCharacters</c> 里没有该角色就直接原地切,
/// 于是「有日程的角色明明在城里某个点,切过去却是在我脚下换皮」。
/// 现在补第三个来源:按「现在几点」查他自己的
/// <c>AgentDataSetsActivityConfig</c>(<c>NpcActivityCatalog4229938.Resolve</c>,
/// 与常驻摆放 / 切人留场<b>同一个裁决函数</b>,不另写一套)。
///
/// ⛑ 查不到(两个主角 tag 2/3 没有时刻表、该时段活动没编坐标)⇒ 照旧原地切换,不会飞到 (0,0,0)。
/// ⛑ 朝向仍然无表可查(规则 171②)⇒ 沿用玩家切人时的朝向,想调就配
/// <c>gameplay.rosterNpc.activityPoiOverrides[].facing</c>(那条也作用于常驻摆放)。
/// </summary>
public bool SwitchToScheduleSpot { get; init; } = true;
/// <summary>
/// <see cref="SwitchToScheduleSpot"/> 的距离上限(米)。<b>0</b> = 不限(默认)。
/// 官方语义本来就是「切完人被传送几百米」(见 <see cref="MaxAnchorDistanceMeters"/> 的注释:
/// 锚点距离中位数约 636m),所以默认不限制;嫌远就填一个数字,超限时本次退化为原地切换。
/// </summary>
public float SwitchToScheduleSpotMaxMeters { get; init; }
/// <summary>
/// 按角色分散演出落点:每个角色固定落到「自己的那一行」SwitchSpiritConfig
/// (按 templateId 散列选行),而不是「离玩家最近的那行」。
/// 用户 2026-09-24 17:5x:最近锚点模式让所有角色都挤在咖啡馆/电影院门口那一两个点。
/// 默认开。关掉 = 回到最近锚点模式。仅在目标不在大世界(非留下角色)时生效;
/// 该模式下 MaxAnchorDistanceMeters 不再限制(分散本来就意味着远)。
/// </summary>
public bool DisperseAnchorsByCharacter { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ 给「留在大世界里」的旧角色周期性回正位姿(<c>SyncLogicAgentSyncData</c> 68838628)。
/// 默认开,<b>关掉的话留下的角色会掉出世界</b>。
///
/// 2026-09-24 18:1x 实测(packets 日志):留下的角色客户端每 0.1~0.5 s 上报一次
/// <c>AskReportLogicAgentSyncData</c>,Y 从 -0.13 → -293 → -3000(约 60~90 m/s,自由落体)。
/// 根因:这些单位是靠 <c>SyncManagedLogicAgent</c> 标成「服务端驱动」的,客户端停用自己的
/// AI 之后只做物理模拟,而这类 actor 没有地面吸附 ⇒ 一直往下掉。
/// 后果不只是「人没了」:<b>切回这种角色时拿到的是刚从虚空里捞回来的 actor —— 原地有跑步
/// 动画、朝向会转、坐标不动</b>(用户报的「切换的角色都无法移动」)。
///
/// 修法:客户端每上报一次,就把 <c>LeftBehindCharacters</c> 里记的站立位姿回给它。
/// ⛑ 这个包驱动不了步态(donor1 第 39 轮 + 我们 09-20 实测已证伪:10Hz 推了 6280 条、
/// 字节布局逐字节对齐,人依然纹丝不动)—— 但「纹丝不动」正是我们要的效果。
/// </summary>
public bool FeedLeftBehindPositions { get; init; } = true;
/// <summary>
/// 回正包里的 <c>MoveId</c>(<c>BattleMoveActionType</c>:0 Unknown / 1 Idle / 2 WalkFront / 6 Run)。
/// 站桩用 1(Idle);改成 2 也不会让它走起来(见 <see cref="FeedLeftBehindPositions"/> 的证伪记录)。
/// </summary>
public byte LeftBehindFeedMoveId { get; init; } = 1;
/// <summary>同一个角色两次回正之间的最小间隔(毫秒)。客户端上报是 10Hz 级的,别跟着刷屏。</summary>
public int LeftBehindFeedMinIntervalMs { get; init; } = 90;
/// <summary>
/// 切人演出前预生成的那个目标单位,也给它回一次位姿(钉在落点上,免得镜头飞过去的
/// 那 4~5 秒里它也在往下掉)。只发一次,代价可忽略。
/// </summary>
public bool FeedPreSpawnedTarget { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ 给「留在大世界里」的旧角色周期性发一条**原地路点任务**
///(<c>SyncLogicAgentCommand</c> 68050574 + wire tag 39 = <c>TaskWayPointMove</c>,
/// points = [站立点, 站立点],距离 0)。默认开,**这才是止住自由落体的那一味药**。
///
/// 为什么光靠 <see cref="FeedLeftBehindPositions"/> 不够:位置流是「每 N 毫秒硬拉一次」,
/// 而下落是 60~90 m/s —— 就算 10Hz,两次拉回之间也已经掉了 6~9 m,视觉上是抽搐,
/// 而且拉回只是改坐标,并没让客户端重新接管地面吸附。
///
/// 对照证据:街上那些 830… 开头的 NPC 同样带 <c>SyncManagedLogicAgent</c>,
/// 上报 Y 常年稳定在 -0.13~0.01(packets 日志 130+ 个单位无一掉落),
/// 差别就是它们一直被喂 <c>TaskWayPointMove</c> ⇒ locomotion 状态机接管 ⇒ 贴地。
/// ⇒ 留下的角色照这条机制发一条「原地任务」,到达即停,之后每
/// <see cref="LeftBehindWaypointIntervalMs"/> 毫秒补一条维持住。
/// </summary>
public bool LeftBehindWaypointTask { get; init; } = true;
/// <summary>同一个留下的角色两次「原地路点任务」之间的间隔(毫秒)。太长会在间隔里重新开始掉。</summary>
public int LeftBehindWaypointIntervalMs { get; init; } = 2000;
/// <summary>原地路点任务的 <c>Speed</c>(m/s)。路程是 0,所以只影响停下前的那一瞬。</summary>
public float LeftBehindWaypointSpeed { get; init; } = 0.5f;
/// <summary>
/// 重建留下的旧角色时要不要发 <c>SyncManagedLogicAgent</c>(把它标成「服务端驱动」)。
/// 默认开:关掉会回到 r6 那版「旧角色 T-pose 站在新角色身边」(客户端销毁了 hero actor,
/// 没有完整重建就只剩一个裸 actor)。
/// ⛑ 关掉它同时也等于绕开了自由落体(客户端继续用自己的 AI/地面吸附),
/// 所以这是 <see cref="LeftBehindWaypointTask"/> 万一不灵时的第一退路。
/// </summary>
public bool ManagedForLeftBehind { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ 重建留下的旧角色时补发 <c>SyncAgentForceGo</c>(68698324)(**启动行为树**)。
/// 默认开。这是 Ped 协议里我 09-24 21:5x 才补上的第 5 步 —— 之前只发了
/// Enter + Managed + projection,<b>行为树从没启动过</b>。
///
/// 为什么必须发:21:33 那场实测(packets-20260924-213352.log)证明
/// <see cref="LeftBehindWaypointTask"/> 和 <see cref="FeedLeftBehindPositions"/>
/// **都没能止住下落** —— 000 的 Y 从 -0.01 一路掉到 -2999.99(21:37:25 → 21:38:16),
/// 005 从 -0.08 掉到 -2999.99,全程服务端按 2s 间隔在喂路点任务 + 位姿回正。
/// 差别只有一条:街上那些不掉的路人(<c>GameRouter.Ambient.SendAmbientCrowdPedAsync</c>)
/// 在建人之后还要发 <c>SyncAgentForceGo</c> 启动行为树,客户端才会接管 locomotion(贴地)。
/// 任务包是「派给行为树」的,树没起来 ⇒ 没人接 ⇒ 继续自由落体。
/// </summary>
public bool LeftBehindForceGo { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ 重建留下的旧角色时,要不要<b>先发 <c>SyncLogicAgentLeave</c> 销毁它</b>。
/// **默认 false(不发)** —— 2026-09-24 22:2x 实测:Leave 会把客户端的无缝切换状态机打崩。
///
/// 硬证据(客户端 <c>Player.log</c>,服务端会话 packets-20260924-220911.log):
/// <code>
/// 22:20:40.291 InitModules 100000000012 IsMe False / [LB] 创建角色 100000000012
/// 22:20:41.728 loading_switch_moving_seamless 2 System.NullReferenceException
/// …(每 16 ms 一条,197 条,全部集中在 22:20 这一刀)
/// </code>
/// 同一场里能动的那一次(c=1,22:17)该报错是 <b>0 条</b>。报错刷屏之后新接管的角色
/// 就再也发不出移动包(24 秒里只有 2 条 <c>AskUnitMoveActionSimple</c>)。
/// ⇒ 元凶是「Leave 掉刚被玩家操控的那个单位」,不是 Managed 也不是 ForceGo。
///
/// 不发 Leave 时客户端自己已经销毁了旧 hero 的 actor(r6 观察),服务端这边直接
/// Enter + projection + PosFacing + POI + ForceGo 重建即可;ForceGo/POI 是 r13 才补的
/// Ped 第 4/5 步,正是它们替代了「销毁重建」在动画状态上的作用。
/// 打开它 = 回到 r7~r13 的老写法(会刷空引用)。
/// </summary>
public bool LeftBehindRebuildLeave { get; init; } = false;
/// <summary>
/// 重建留下的旧角色时发的 POI 动作 id(<c>SyncAgentPlayPOIAction</c> 68459398 的
/// <c>CPoiActionId</c>)。Ped 那套里 1=走路;留下来的角色要**站着不动**,
/// 所以默认填 <b>17</b>(站姿闲置 / 1001)。0 = 不发这个包。
/// ⛑ 别把「动作组号」(400) 填到这里 —— 那是 <c>ClientCrowdInitData.ActionId</c> 的表,
/// 两张表(见 Ambient.cs 的 T0-9 注释)。
/// </summary>
public uint LeftBehindPoiActionId { get; init; } = 17;
/// <summary>
/// ⛑⛑ 留下的角色「闲逛」:周期性用 <c>PedLaneGraph.TryPlanJourney</c>(挑一个**已经在人行道上**的
/// 终点 + 沿路网规划整条折线)给他派一段 <c>TaskWayPointMove</c>,让他真的走起来。
/// 默认 <b>false</b>(先把功能做出来,实机验过再开)。
///
/// 为什么这样就能走:同伴协议下客户端已接管 locomotion,而 <c>TaskWayPointMove</c> 就是派给行为树的
/// (街上那些 830… 开头的 NPC 一直被喂这个包才不掉、才在走,见 <see cref="LeftBehindWaypointTask"/>)。
/// 止跌用的那条 points = [锚点, 锚点](距离 0 ⇒ 原地),这里换成真实折线即可。
///
/// ⛑ 终点**必须**走 <c>TryPlanJourney</c>,不能用「当前位置 + 随机方向」:环形随机点 92% 落在
/// 建筑/江里,而 <c>PlanRoute</c> 尾部会把最后一个路径点硬拉到那个点 ⇒ 他最后一段是扎进墙里,
/// 客户端到不了就一直往墙里推(2026-09-23 行人同款坑)。
/// </summary>
public bool LeftBehindWander { get; init; } = false;
/// <summary>闲逛循环的检查间隔(毫秒)。只是「该不该派新行程」的轮询,**不是**行程时长。</summary>
public int LeftBehindWanderPollMs { get; init; } = 500;
/// <summary>
/// 一段行程终点离当前位置的最小 / 最大距离(米)。
/// ⛑ 太大他会走出玩家视野被回收,看起来就是「人凭空消失」。
/// </summary>
public float LeftBehindWanderMinMetres { get; init; } = 12f;
public float LeftBehindWanderMaxMetres { get; init; } = 35f;
/// <summary>走的速度(m/s)。客户端真实速度约 1.846(硬规则 109),别填太快。</summary>
public float LeftBehindWanderSpeed { get; init; } = 1.2f;
/// <summary>
/// 一段行程预计走完之后的额外余量(毫秒)。
/// ⛑ 太短会在他还没走到位时就派下一段 ⇒ 一步一停(硬规则 108/109 同款症状)。
/// </summary>
public int LeftBehindWanderSlackMs { get; init; } = 1500;
/// <summary>
/// ⛑⛑ 把客户端上报的真实坐标写回 <c>LeftBehindCharacter.Position</c>。
/// 不写回 ⇒ 他闲逛到哪都白搭:切回时 <c>GameRouter.Switching.cs</c> 读的
/// (<c>directPos = leftBehind!.Position</c>)还是切走那一刻的落点,人会瞬移回去。
/// 默认 <b>true</b>。
/// </summary>
public bool LeftBehindTrackRealPosition { get; init; } = true;
/// <summary>
/// ★★ 留场角色的「POI 轮换」总开关(2026-09-26 23:4x;里希那组)。
///
/// 开着时,<c>gameplay.rosterNpc.poiOverrides[].leftBehindPoiGroup</c> 里配了组的角色,
/// 在「留在大世界」期间会按**洗牌袋**依次播组里的 POI:把组打乱后一条一条取,
/// 取完一轮再重新洗牌 ⇒ **随机,但一轮之内不重复**(正是用户要的"每组都播到才重来")。
///
/// ⛑ 与 <see cref="LeftBehindWander"/> **互斥,轮换优先**:这个角色配了组 ⇒ 只轮换、
/// 不派行程(走路/跑步 POI 是原位移动,再叠路网行程会互相打断)。
/// 没配组的角色照旧走 wander。
/// </summary>
public bool LeftBehindPoiRotation { get; init; } = false;
/// <summary>
/// 轮换里每个 POI 停留多久(毫秒)。⛑ POI 表里**没有时长字段**,只能统一给一个值;
/// 单个角色要不一样就配 <c>RosterNpcPoiOverride.LeftBehindPoiDwellMs</c>。
/// </summary>
public int LeftBehindPoiDwellMs { get; init; } = 15000;
/// <summary>
/// 换到带道具的 POI(吃汉堡 / 对讲机 / 平板)时补发
/// <c>SyncAgentBelongings</c> + <c>SyncAgentUseBelongingChanged</c>
/// (与 <c>GameRouter.Civilians.cs</c> 出场那套同包同结构)。
///
/// ⛑ 换到**无道具**的 POI 时会发一个<b>空 usage 列表</b>尝试把上一件道具收掉 ——
/// 这个语义<b>没实机验过</b>(可能收不掉、也可能把别的 usage 一起清了)。
/// 出问题就置 false(那时带道具的 POI 会是"空手演",但不会乱)。
/// </summary>
public bool LeftBehindPoiRotationProps { get; init; } = true;
/// <summary>
/// ★★ 留场那具身体**不发骇客手机**(<c>gameplay.hackNpc</c> 那部,usage 6 = 玩手机)。
///
/// 为什么需要:<c>leftBehindAgentUnitChannel=false</c> 时留场走的是
/// <c>SendCivilianAsync</c>(Aether 静态 NPC 通道),那条线末尾固定会
/// <c>MarkNpcHackableOnSpawnAsync</c> 补一部手机 —— 出场 POI 17(站姿闲置)的
/// <c>UsageId = 0</c> ⇒ 判定"这个 POI 本来不带可骇 usage" ⇒ 发手机。
/// 结果就是**留场角色一边闲逛一边玩手机/打电话**,而且看起来"只有这一种动作"
///(2026-09-27 00:1x 用户实测;日志 `[NPC-HACK] phone unit=0x3000000000xx usage=6`)。
/// 置 <b>true</b> ⇒ 留场身体不拿手机(可骇标记照发,只是没手机道具)。默认 <b>true</b>。
/// </summary>
public bool LeftBehindSkipPhone { get; init; } = true;
/// <summary>
/// 闲逛时播的 POI 动作 id。<b>0</b>(默认)= 沿用出场那个(<c>LeftBehindNativePoiActionId</c>,17)
/// —— 位移本身由 <c>TaskWayPointMove</c> 驱动,POI 只管上半身/动画状态。
/// 想让他明显在"走"就填 <b>1</b>(走路),想"跑"填 <b>4</b>(跑步)+ 把
/// <see cref="LeftBehindWanderMovement"/> 设成 2、<see cref="LeftBehindWanderSpeed"/> 提到 4~5。
/// ⛑⛑ POI 1/4 单独播是**原地**走/跑(跑步机效应),位移来自移动命令,两者必须配套。
/// </summary>
public uint LeftBehindWanderPoiActionId { get; init; } = 0;
/// <summary>
/// 闲逛的移动方式(<c>AgentMovementMethods</c>):<b>1 = Walk</b>(默认)、<b>2 = Run</b>。
/// 改成 Run 记得同时给 <see cref="LeftBehindWanderPoiActionId"/> 填 4、速度提到 4~5,
/// 否则会用走路的速度演跑步(脚底打滑)。
/// </summary>
public byte LeftBehindWanderMovement { get; init; } = 1;
/// <summary>
/// ⛑⛑⛑ 留下的旧角色走「同伴协议」:<c>ExistingUnitProjection</c> 之后补发 Ped 的第 4/5 步
/// <c>SyncAgentPlayPOIAction</c>(68459398) + <c>SyncAgentForceGo</c>(68698324),
/// 让客户端行为树接管 locomotion(贴地、有动画状态机),
/// <b>并且停掉 <see cref="FeedLeftBehindPositions"/> / <see cref="LeftBehindWaypointTask"/> /
/// 后台续发循环</b>。默认 <b>true</b>。
///
/// 那三样补丁的存在理由只有一个:这类单位是「服务端驱动」的,客户端停用 AI 后只做物理模拟,
/// 没有地面吸附 ⇒ 自由落体(18:1x 实测 Y → -3000)。它们是「每 N 毫秒把坐标硬拉回来」,
/// 而 21:33 实测证明**两样加起来也没止住下落**(000/005 全程 2s 喂一次仍掉到 -2999.99)——
/// 因为路点任务是派给行为树的,<b>树没起来就没人接</b>。
/// 街上那些不掉的路人(<c>GameRouter.Ambient.SendAmbientCrowdPedAsync</c>)多发的正是
/// POI + ForceGo 这两步。所以真正该做的是把这两步补齐,而不是继续加码喂坐标。
///
/// 关掉 = 回到老的三件套补丁(喂坐标 + 原地路点 + 后台循环)。判据:留下的角色
/// Y 是否稳定在 -0.13~0.01(不掉)。
/// </summary>
/// <summary>
/// ⛑⛑⛑ 把「Leave + 以主角身份重建目标角色」从 phase 2(<c>ReportPreSwitchSpiritFinish</c> 之后)
/// <b>挪到演出开始之前</b>(<c>SyncPreSwitchSpirit</c> 之前)。默认 <b>true</b>。
///
/// 硬证据(2026-09-25 01:31:50~01:32:04,会话 packets-20260925-012445 + 客户端 Player.log):
/// <code>
/// 01:31:50.011 LiftOffOneShot_CreateChangeLodCam(时间线起,由 SyncPreSwitchSpirit 触发)
/// 01:31:53.544 InitModules 100000000000 IsMe False / [LB] 创建角色 ← 我们在时间线里 Leave+重建
/// 01:32:02.6xx Object reference not set … ×4
/// 01:32:04.262 SwitchChar_common_04 2 System.NullReferenceException ×3
/// 01:32:04.308 LiftOffOneShot_TryDestroyChangeLodCam(镜头落回主相机,时间线被打断)
/// </code>
/// ⇒ 时间线全长约 <b>14 秒</b>,我们在第 3.5 秒拆掉主角单位,第 12.5 秒它抛空引用。
/// 这与 r14 那批 197 条 <c>loading_switch_moving_seamless</c> 空引用是同一件事的两个相位。
///
/// 因此:<b>任何 Leave / 重建都不能发生在演出期间</b>,一律挪到发 PreSwitch 之前。
/// 关掉 = 回到旧顺序(演出中途拆单位)。
/// </summary>
/// <summary>
/// ⛔⛔⛔ <b>2026-09-25 03:2x 实机证伪,默认改回 <c>false</c>。别再打开。</b>
///
/// 换皮后用户回报「角色可以正常移动了,但是模型都没有了 / 只有一张主角的脸」。
/// 判别性实验(同一台机器、同一个 build):
/// <code>
/// GmSwitchSpiritHere(就地,allowShow=false,走 3 参 CharacterUnitProjection)
/// ⇒ 模型正常
/// AskSwitchSpirit(演出,allowShow=true,走 4 参 CharacterUnitProjection + 不 Leave)
/// ⇒ 只有一张脸
/// </code>
/// ⇒ <b>根因不是演出/时间线,是换皮本身</b>。两条路的唯一实质差别:
/// <code>
/// 就地路:unitId = roster.UnitId(= unitIdBase + index),
/// Enter + Managed + CharacterUnitProjection(3参) + PosFacing <b>完整四连发</b>。
/// 换皮路:unitId 恒 = Profile.InitialUnitId(100000000000),
/// 只改 templateId,<b>全程不 Leave</b>(怕丢 IsMe)。
/// </code>
/// 不 Leave ⇒ 客户端的 BaseUnit 从来没被销毁重建 ⇒ 身体部件不会按新 templateId 重新加载
/// ⇒ <c>FashionPartResLoadCheck</c> 全挂、只剩脸(bodyType/性别前缀还锁在旧角色上)。
///
/// ⛑ 由此得到的取舍(这个 build 上无解,别再两头追):
/// <b>IsMe=True 与 模型完整,只能选一个。</b>
/// · 要 IsMe ⇒ 必须不 Leave ⇒ 模型只剩脸。
/// · 要模型 ⇒ 必须 Leave+重建 ⇒ IsMe 掉(头顶常驻 F、按 F 出 AgentInteractPanel)。
/// 原因:`initialUnitId`(100000000000) ≡ `roster.unitIdBase`(100000000000),
/// 而 `roster.UnitId = unitIdBase + index` ⇒ 除 index 0 外每个角色的 unitId 都不等于
/// 登录时 CreateHero 那个 ⇒ 5/5 实测 IsMe=False 是<b>号段设计</b>的必然,不是发包姿势问题。
/// IsMe=True 只可能来自客户端自己的 CreateHero,服务端造不出来(见 AskOnlyForClientSwitch 的 B 结论)。
///
/// 关掉 = 每个 roster 行一个 unitId、切人走完整重建(<b>模型正常</b>,代价 IsMe=False)。
/// </summary>
/// ⛑⛑ <b>2026-09-25 03:3x 再改回 <c>true</c></b> —— 与 <c>HeroUnitRebuildOnSwitch</c> 配套:
/// 换皮 + Leave 重建,试图同时拿到「能走」与「模型正常」。详见那一条的注释。
/// </summary>
public bool HeroUnitReuse { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ 换皮时<b>先 Leave 再重建</b>(unitId 仍然是 <c>Profile.InitialUnitId</c>)。默认 <b>true</b>。
///
/// 为什么需要它(2026-09-25 03:3x 用户实机):
/// <code>
/// heroUnitReuse=true (换皮,不 Leave) → 模型只剩一张脸,但**能走**
/// heroUnitReuse=false (独立 unitId,完整四连发)→ 模型正常,但**走不动**
/// </code>
/// ⇒ 两条路唯一的差别是 <b>unitId</b>:`Profile.InitialUnitId`(100000000000) vs
/// `roster.UnitId`(= unitIdBase + index)。⇒ 客户端<b>只对登录时那个 unitId 应用本地移动输入</b>,
/// 与 IsMe 是同一件事的两个表现("走不动" ≡ "不是我")。
///
/// 所以真正的矛盾不是「IsMe vs 模型」,而是「不 Leave(模型坏) vs Leave(IsMe/移动掉)」。
/// 这个开关就是两头都要:id 保持不变(保住移动 / 可能也保住 IsMe),
/// 但**把单位销毁重建**(让身体部件按新 templateId 重新加载 ⇒ 模型正常)。
///
/// ⛑ 与规则 134 的冲突已确认并接受:那条实测的是「Leave 掉 100000000000 就拿不回 IsMe=True」,
/// 测的是 IsMe、不是移动。本次赌的是「id 不变 ⇒ 移动仍然成立」。
/// 若实测还是走不动,说明移动也绑在 BaseUnit 实例上(而不是 id),那就彻底回到
/// `heroUnitReuse=false` 的模型优先档,移动问题另找入口(候选:`SyncControllableAgent`(68376522)
/// + `SyncSwitchControl`(68552741) 两件套,黑客召唤物那条已实机验证的原生交操控通道)。
/// </summary>
public bool HeroUnitRebuildOnSwitch { get; init; } = true;
/// <summary>
/// 切人移交后是否再补发一次 <c>SyncUnitPositionAndFacing</c> 把落点钉住。默认 <b>true</b>。
///
/// ⛑ 用户 03:3x 提出「走不动是不是和绑定了切换角色的坐标有关」,这条就是验证它的开关。
/// 关掉后切人不再回 assert 落点 —— 若角色因此能走,说明 <c>setPositionType=SwitchSpirit(3)</c>
/// 会让客户端停在"等待落点完成"的状态机里锁住输入。
/// ⛑ 关掉的风险:新角色可能落到模板默认出生点而不是切人落点。
/// </summary>
public bool SwitchHandoffPushPose { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ <b>实验开关</b>:客户端(Z 轮盘 / 换人面板那条 <c>AskSwitchSpirit</c>)发起切人时,
/// 服务端<b>只回 ack,一个包都不推</b>(不发 PreSwitch / ConfigId / 不建单位 / 不发 CurrentSpirit)。
/// 默认 <b>true</b>。
///
/// 目的:一次性判掉「客户端到底会不会自己建主角」。
/// 官方链路的最后一棒是客户端在时间线末尾自己 <c>CreateHero</c>;我们一直以为它不会,
/// 于是从 09-24 起一路加了七样补丁(预生成 / ExistingUnitProjection / Leave+重建 / 喂坐标 /
/// 原地路点 / 后台循环 / 同伴协议 / 换皮)去替它建。这个开关就是把手放开,看它自己行不行。
///
/// 三种结果与判据(客户端 <c>Player.log</c>):
/// <b>A 成功</b> —— 角色变了 + 能跑 + 出现 <c>InitModules … IsMe True</c> + <c>[LB] 进入场景 创建主角</c>
/// ⇒ 客户端自己会建主角 ⇒ 那七样补丁全部可以删。
/// <b>B 没反应</b> —— 点了没变化、日志没有任何 <c>InitModules</c>
/// ⇒ 官方服务端还必须发 PreSwitch/CurrentSpirit ⇒ 只能退回换皮方案(<c>HeroUnitReuse</c>)。
/// <b>C 卡住/报错</b> —— Invoke 没 ack 或状态机崩 ⇒ 同样退回换皮。
///
/// ⛑ 只作用于客户端发起的路径(<c>allowShow=true</c>);管理面板那条
/// (<c>SwitchSpiritAsync</c> 传 <c>allowShow:false</c>)照旧走换皮,保证实验期间还有可用的切人手段。
///
/// <b>结论(2026-09-25 实机,用户回报「切人现在根本没反应」)= B</b>:
/// 客户端不会自己建主角。日志里没有 <c>InitModules … IsMe True</c>,也没有 <c>[LB] 进入场景 创建主角</c>。
/// ⇒ 服务端必须发 PreSwitch / ConfigId / CurrentSpirit,这一棒甩不掉。
/// ⇒ 开关关回 <b>false</b>,恢复完整协议。留着备用,别再打开当默认。
/// </summary>
public bool AskOnlyForClientSwitch { get; init; } = false;
public bool RecreateHeroBeforeShow { get; init; } = true;
public bool LeftBehindCompanionProtocol { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ <b>原生留场</b>:老角色不再走「spirit 投影」(<c>ExistingUnitProjection</c>,那条路建出来的
/// 单位是服务端驱动的,没有行为树 ⇒ 自由落体掉出世界,Y → -3000),改成
/// <b>用它自己的 AgentConfig 行刷一个真正的 NPC</b>。默认 <b>true</b>。
///
/// 取证(2026-09-25,见 <c>NpcCatalog.json</c> / <c>FightSpiritConfig.json</c>):
/// <code>
/// FightSpiritConfig.AgentId ≡ AgentConfig.Id (15/15 CanJoin 行全部命中)
/// 15020967 辰宿 -> 40140030 (Chenxiu, persona 45200007, AI 40215025)
/// 15021021 里希 -> 40140040 (Richie, persona 45200010, AI 40213425)
/// 15021044 李扶风 -> 40155013 (Li Fufeng,persona 45200009, AI 40215029)
/// AgentConfig 9345 行里 4014xxxx/40155xxx 共 339 条,全部带 AgentPersonaId / AiSettingId。
/// CivilianGroups 现有 3978 个 formwork 里<b>本来就有 23 个 4014xxxx</b>
/// ⇒ 4014xxxx 天然就是合法 NpcFormworkId("必须是 4013xxxx"那条注释是错的)。
/// ⛔ 反倒是 <c>SpawnIds.npcs</c>(4388 条)里一个可玩角色 agent 都没有 —— 这就是为什么
/// 」老角色留场」一直做不出来:我们从没把正确的 formwork id 喂进 NPC 通道。
/// </code>
///
/// 这正是用户描述的官方行为:「切换后原角色留在大世界里做自己的事(用跑步机那种)」。
/// 官方就是拿 <c>FightSpiritConfig.AgentId</c> 建一个 NPC,交给 <c>NpcDailySchedule</c> +
/// <c>PoiActionCatalog</c>(3978 条 POI 动作)驱动 —— 与满城那 3978 个市民<b>同一条通道</b>。
/// 本开关只是把 formwork id 从「随机市民」换成「这个角色自己的 agent」,<b>零新轮子</b>。
///
/// 关掉 = 回到旧的 spirit 投影(会掉出世界)。
/// </summary>
public bool LeftBehindAsNativeAgent { get; init; } = true;
/// <summary>
/// 留场时把**旧操控单位**回收掉。默认 <b>true</b>。
///
/// 用户 04:2x 实机:切人后场上出现**两个**旧角色 —— 一个穿自己衣服、没有 POI,
/// 一个穿默认服装、有 POI。前者就是 `oldUnitId`(`state.ActiveSpiritUnitId`)那个单位:
/// `SwitchSpiritDirectSameScene4229938` 一直没 `Leave` 它,客户端也没主动销毁,它就杵在原地。
/// 后者才是 `LeftBehindAsNativeAgent` 新刷的市民 NPC。
/// ⛑ `LeftBehindRebuildLeave` 做的是 `Leave(companionUnitId)`(那是**还没建**的新 id),
/// 不是 Leave 旧单位 —— 别搞混,这是另一个动作。
/// </summary>
public bool LeftBehindReclaimOldUnit { get; init; } = true;
/// <summary>
/// 给留场的市民 NPC 补发一次 <c>SyncSetSpiritFashions</c>(64102530),让它穿上玩家给这个角色
/// 搭配的衣服。默认 <b>true</b>。
///
/// ⛑ 市民通道(`SyncAetherAIStaticNpcAddData` / `SyncAetherAICrowdAdd`)**只有**
/// <c>FashionSuitId</c>(套装 id,写死 11190001),**没有单件衣服列表** ——
/// 这是"留场的老角色穿默认衣服"的根本原因。
/// 补救:建完之后补发一次 S2C 换装包,数据源 <c>state.FashionBySpirit[templateId]</c>
/// (`FashionRecord4229938`,含 `FashionIds` + `FunctionSuitId`,由 `AskSetSpiritFashions*` 写入)。
/// ⛑ 该包的字段是 `spiritId`,客户端不会把它落到刚刷出来的市民 NPC 身上 ——
/// 09-25 用户实测「没有穿上我的搭配」,已作废(见下面的 `LeftBehindDressSyncPacket`)。
///
/// ✅ 现在的做法(本开关控制):在**出场包里**就把衣服带上 ——
/// `AgentSyncClientInfo.AIAgentInfo`(`LinkAIAgentInfo`)→ `Fashion`(`LinkAIFashionInfo`)
/// → `WearInfo`(`OtherPlayerSpiritWearFashionsInfo`,含**单件** `WearFashionInfoList`)。
/// 这是官方「把某个可玩角色当作 NPC 放进大世界」时唯一带衣服的字段 —— 麻将/保龄球的
/// AI 对手读的 `unit.ClientData.AIAgentInfo` 就是它(`MajiangGame_UI.lua:181` /
/// `BowlingCharacter.lua:414`)。数据源同样是 `state.FashionBySpirit[templateId]`。
/// ⛑ 顶层 `FashionSuitId` 只能填**套装** id:存档里 9/9 个角色 `FunctionSuitId` 全是 0
/// (纯散件,4~10 件)⇒ 那条路对"自定义搭配"根本没用,只能走 `WearInfo`。
/// </summary>
public bool LeftBehindDressWithPlayerFashion { get; init; } = true;
/// <summary>
/// 走哪条通道刷留场 NPC。默认 <b>true</b> = 新的 <c>SyncRaidBattleUnitAgent</c>(68402349) 通道。
///
/// ⛑ 老通道(Aether 静态 NPC,<c>SendCivilianAsync</c>)行为没问题(站得住、有闲置、有 POI),
/// 但客户端<b>不读</b> <c>AIAgentInfo.Fashion.WearInfo</c>,顶层又只有 <c>FashionSuitId</c>(套装)
/// ⇒ 09-25 两次实测「包发了 6 件衣服,模型还是默认服装」。
/// <c>SyncRaidBattleUnitAgent</c> 带 <c>FashionIdList</c>(<b>单件</b>列表)才有救。
/// 设为 <b>false</b> 就退回老通道(对照 / 新通道出问题时的回滚)。
/// </summary>
public bool LeftBehindAgentUnitChannel { get; init; } = true;
/// <summary>
/// A/B 用:<b>老办法</b> —— 建完 NPC 之后补发一次 <c>SyncSetSpiritFashions</c>(64102530)。
/// 默认 <b>false</b>。
///
/// ⛑ 已实测无效(09-25 用户:「没有穿上我的搭配」):该包按 <c>spiritId</c> 匹配,
/// 落不到场上那个市民 NPC。留着只为对照/回滚,别再当主方案。
/// </summary>
public bool LeftBehindDressSyncPacket { get; init; } = false;
/// <summary>
/// ★ 让留场的老角色真的「过日子」:按 <b>现在这个时刻</b> 从它自己的日程表里挑一条活动,
/// 把它刷到那条活动编好的世界坐标上,并在出场包里带上 <c>AgentDataSetsActivityCfgId</c>。
/// 默认 <b>true</b>。
///
/// 链路(官方原生的三张表,零自造):
/// <code>
/// FightSpiritConfig.NpcCultivationRelatedId -> NpcCultivationConfig.AgentTag
/// (服务端副本:NpcCultivationCatalog.json 的 AgentTag 列)
/// AgentTag + 现在时刻 -> NpcDailySchedule.json 的 5 段之一 -> ActivityId
/// (LifeScheduleTimetableConfig 的四个 HHMM 边界 × AgentDataSetsActivityConfig)
/// ActivityId -> NpcActivityCatalog.json -> MapPosition / ActivityPerformance / Raid
/// </code>
/// 接线点 = 出场包 <c>AgentSyncClientInfo.AgentDataSetsActivityCfgId</c>(填 0 = 只走 ambient
/// POI,就是现在「只会站着」的样子)。坐标则是官方给这条活动编好的点 —— Richie 的跑步机在
/// (1191,0,2052)、Taffy 坐着刷手机在 (2047,9,1764),都是表里真有的行,不是我们猜的。
///
/// ⛑ <b>两个主角没有日程</b>:15020967 辰宿 / 15020968 昭昭 的 AgentTag 是 2 / 3,而 75 行
/// 时刻表是从 tag 4 开始的;它们各只有 1 条「临时点位」活动且 <c>MapPosition</c> 全 0。
/// 这两个(以及任何查不到活动的角色)走<b>原逻辑</b>:留在切人时的坐标、ambient POI。
/// ⛑ 153/284 条活动才有坐标,全部落在两个开放世界 raid(23300888 / 23300999)。
/// 查不到坐标时同样回退,不会把角色刷到 (0,0,0)。
/// </summary>
public bool LeftBehindDailyLife { get; init; } = true;
/// <summary>
/// 「过日子」时是否要求活动一定带世界坐标。默认 <b>true</b>。
///
/// false = 即使该时段的活动没有 MapPosition,也把 <c>AgentDataSetsActivityCfgId</c> 填上
///(角色留在原地播那条活动的表演)。真机还没验过无坐标活动会怎样,留个开关好 A/B。
/// </summary>
public bool LeftBehindDailyLifeRequirePosition { get; init; } = true;
/// <summary>
/// ★★ <b>游戏时间跨进下一个日程时段后</b>,把「被玩家操控过、切走后赖在原地的」角色
/// <b>交还常驻化去演新活动</b>,而不是继续钉在他最后被操控的位置。默认 <b>true</b>。
///
/// 为什么要有它:前一条(被操控过 ⇒ 不过日子)解决的是「切走 A 就被瞬移到日程点」,
/// 但矫枉过正了 —— 玩家玩了半小时、游戏里也过了好几个时段,A 还杵在原地不动,
/// 比瞬移更假。这条就是补回「时间真的会推着他走」那一半(用户 09-26 05:0x 要的)。
///
/// 判据:后台(常驻循环 1.5 s 一跳)拿<b>时段表当下那一段</b>的活动 id(<c>NpcActivityChoice.Timed</c>)
/// 跟 <c>LeftBehindCharacter.TimedActivityId</c>(切走时记的同一个量)比,不同 ⇒ 时段变了 ⇒
/// ① <c>SyncLogicAgentLeave</c> 撤掉留场那具身体(留场单位在 retirable 段,规则 165 已验证收得掉)
/// ② 从 <c>LeftBehindCharacters</c> 移除 ③ 直接调常驻化的摆放,在新活动坐标落地。
///
/// ⛑⛑⛑ 判据<b>必须</b>是 <c>Timed</c>,<b>不能</b>用 <c>Entry</c>(真正落地的那条活动)。
/// 官方 284 条活动里 131 条是没编坐标的「临时点位」(睡觉 / 休息 / 对策局吃薯片……),
/// 傍晚夜里大量命中 ⇒ <c>Resolve</c> 回落到 <c>FirstPositioned</c>,而它与时间无关
/// (同 AgentTag 在本 raid 里 Id 最小的有坐标那条)⇒ 白天和夜里算出的 <c>Entry.Id</c>
/// <b>一模一样</b> ⇒ 判据恒等 ⇒ 时间跳十几个小时也一次都不触发。
/// 2026-09-26 06:0x 实机翻车:时间 05:45 → 18:00,梅卡妮卡 11400031→11400031、
/// 里希 11400012→11400012,`[LEFTBEHIND-SLICE]` 一次都没打出来。换 Timed 后
/// 梅卡妮卡 11400031→11400034、里希 11400011→11400014,正常触发。
/// ⛑ 两个主角(AgentTag 2/3)没有时刻表 ⇒ 两端都是 0 ⇒ 永远不触发,他们就该一直待在
/// 玩家最后离开的地方 —— 这是对的。
///
/// ⛑ 交还常驻化之后,<b>切回他就不再是「上次离开他的地方」,而是他正在做活动的地点</b>
/// —— 这是预期的:他已经在过日子了。
/// ⛑ 摆放<b>不受 <c>EnterMeters</c> 距离限制</b>(这里直接调摆放函数,不走距离筛选),
/// 否则玩家离新日程点一远、人就凭空消失。
/// ⛑ 只有 <c>DailyLife</c> 开着才生效;当前正被操控的角色不参与。
///
/// 回滚:<b>false</b> = 回到「一旦操控过就永远钉在原地」。
/// </summary>
public bool LeftBehindFollowSchedule { get; init; } = true;
/// <summary>
/// ★★ 玩家**主动推时间**(游戏里的「度过时间」<c>AskPassingTime</c> / GM 与面板改时间)之后,
/// 即便还停在<b>同一个日程时段里</b>,也把赖在原地的角色交还常驻化去过日子。默认 <b>true</b>。
///
/// 为什么还要这一条:<c>LeftBehindFollowSchedule</c> 只认「时段翻篇」,而时刻表一段就是
/// 4~7 小时 —— 玩家 06:13 用「度过时间」跳到 06:00,里希仍在 00:00-07:00 这一段
/// ⇒ 判不出来 ⇒ 玩家看到「我明明推了时间,他还杵在那儿」(2026-09-26 06:1x 实机)。
/// 这条抓的是「玩家主动推时间」这个<b>动作</b>,不看时段有没有变。
///
/// 实现:服务端每次处理改时间的请求就把 <c>WorldEntryState.TimeEpoch</c> +1;
/// 切走角色时把当时的值记进留场记录,后台比这两个值。
/// ⛑⛑ <b>不能</b>退化成「直接比 daySecond」—— 实时流逝也是每秒 +1,那样玩家在城里
/// 逛 10 分钟角色就自己跑了,前一条需求(赖在最后被操控的位置)当场失效。
///
/// 回滚:<b>false</b> = 只有时段真的翻篇才放行。
/// </summary>
public bool LeftBehindFollowOnTimeJump { get; init; } = true;
/// <summary>
/// 原生留场时给老角色播的 POI 动作 id(<c>UrbanDiversityPOIActionConfig</c>,共 3978 条)。
/// 17 = 站姿闲置。想让它真的去"过日子",填别的 id(例如健身房/跑步机那几条)。
/// ⛑ 是 POI 动作 id,<b>不是</b>动作组号(400)—— 两张表,别混。
/// ⛑ 开了 <c>LeftBehindDailyLife</c> 且命中活动时,客户端走生活日程状态,这个 POI 会被盖掉。
/// </summary>
public uint LeftBehindNativePoiActionId { get; init; } = 17;
/// <summary>
/// ⛑⛑⛑ 切回「已经留在大世界里」的角色(<c>LeftBehindCharacters</c> 里有它)时,走客户端的
/// <b>CloseUpAgentFlow</b> 而不是 LiftOffOneShot 飞越演出。默认 <b>true</b>。
///
/// 判据(客户端 <c>BattleSpiritMgr.lua:19-38</c>,本机 dump.cs:354025):
/// <code>
/// SyncPlayerCurrentSpirit(pid, templateId, spiritId, isAgentSwitch)
/// isAgentSwitch=true → SwitchTeleport_CloseUpPrepare() + CloseUpSync() // CloseUpAgentFlow
/// isAgentSwitch=false → CheckSwitchTeleport(spiritId) // LiftOffOneShot
/// </code>
/// <c>CloseUpAgentFlow</c> 的行为正好是用户要的那句话:<c>WaitUnitLoadAndStartShowing</c> →
/// <c>CheckUnitReady</c> → <c>DoCameraSwitchOnUnitReady</c> → <c>OnWaitCamMove</c> →
/// <c>OnMoveFinished</c> ⇒ <b>镜头直接平移到那个角色身上</b>(它在哪就飞到哪,室内也算)。
/// LiftOffOneShot 则是「镜头爬升到高空再落到锚点」,落点由 <c>SyncSwitchSpiritConfigId</c>
/// 的 <c>seamlessSwitchPosition</c> 决定 —— 我们切回室内角色时传的是角色真实坐标,
/// 但演出用的锚点是 <c>SwitchSpiritConfig</c> 行里那个室外坐标,于是出现
/// 「镜头飞到一个没人的无关位置,然后视角瞬移回角色身上」。
///
/// 开这个开关后,切回在世界里的角色时<b>不再发</b> SyncPreSwitchSpirit /
/// SyncSwitchSpiritConfigId(不发就不会起 LiftOff 状态机,也就不会再撞
/// <c>loading_switch_moving_seamless</c> 那条空引用),改成
/// 「角色已站在原地 → 直接身份移交(isAgentSwitch=true)→ 客户端把镜头运过去」。
/// 首次切到某角色(它还没在世界里)仍然走完整的角色专属演出。
/// </summary>
public bool CloseUpAgentForInWorld { get; init; } = true;
}
/// <summary>
/// 可玩角色常驻大世界(<c>gameplay.rosterNpc</c>)。
///
/// 为什么需要它:官方这些角色本来就在城里晃(趴卜主页的「日程」和大地图的图钉就是给你去找他们用的),
/// 而我们原先只在「切走角色」那一刻才造一个 NPC ⇒ 玩家跑到日程坐标那儿是空的。
///
/// 为什么要有进入/离开迟滞:客户端会把远处的单位卸载掉,而<b>不会自己把它们加回来</b>
///(09-25 实机:从远处走到点位,人是消失的,只有切走再切回才重新出现)。
/// ⇒ 服务端必须自己按距离管理:走近就放出来,走远就撤掉,回来再放一次。
/// 这是 <c>GameRouter.FunctionalNpc.cs</c> 那套已经被验证的做法,不是新轮子。
/// </summary>
public sealed class RosterNpcSettings
{
/// <summary>总开关。默认 <b>true</b>。</summary>
public bool Enabled { get; init; } = true;
/// <summary>是否按日程挑活动(<c>AgentDataSetsActivityCfgId</c> + 活动坐标)。默认 <b>true</b>;
/// 关掉就只用 <see cref="PoiActionId"/> 在角色当前位置站着 —— 排障对照用。</summary>
public bool DailyLife { get; init; } = true;
/// <summary>进入多少米内放出来(并记住)。默认 <b>260</b>。</summary>
public float EnterMeters { get; init; } = 260f;
/// <summary>离开多少米外撤掉(必须 &gt; 进入值,否则边界上每 tick 反复增删)。默认 <b>420</b>。</summary>
public float ExitMeters { get; init; } = 420f;
/// <summary>跟随循环间隔。默认 <b>1500</b> ms。</summary>
public int TickMs { get; init; } = 1500;
/// <summary>每个 tick 最多放几个(客户端会丢大批量包的尾巴)。默认 <b>2</b>。</summary>
public int PerTick { get; init; } = 2;
/// <summary>没命中日程活动时的兜底 POI 动作(<c>UrbanDiversityPOIActionConfig</c>)。默认 <b>17</b> 站姿闲置。</summary>
public uint PoiActionId { get; init; } = 17;
/// <summary>
/// 用哪条出场通道。默认 <b>false</b> = Aether 静态 NPC(<c>SendCivilianAsync</c>,与满城市民同款,
/// 已验证站得住 / 有闲置 / 有 POI)。
/// 设 <b>true</b> 改走 <c>SyncRaidBattleUnitAgent</c>(46 字段版 AgentSyncClientInfo,
/// 多带 <c>SpoonAgentId</c> / <c>SpoonIndex</c>)。
/// ⛑ 这条是「表演没播出来」的 A/B 对照用:两条通道的 <c>AgentDataSetsActivityCfgId</c>
/// 是不是同一套消费逻辑还没验证过。⚠️ 只在 <c>DailyLife</c> 关闭换装时切它 ——
/// 这条通道一旦带上 <c>AIAgentInfo.FightSpiritId</c> 就会换皮只剩脸(规则 141)。
/// </summary>
public bool AgentUnitChannel { get; init; } = false;
/// <summary>
/// 常驻 NPC 的 unitId 走哪一段。默认 <b>false</b> = 与满城市民同款
/// <c>StaticNpcIdFloor</c>(<c>8300000000000000000</c> 段)。
/// 设 <b>true</b> 改走 <c>GameAdminBridge.NextRetirableNpcUid()</c>(<c>0x300000000000</c> 段,
/// 硬规则 165 里那条「<c>SyncLogicAgentLeave</c> 确实收得掉」的段)。
/// ⛑⛑ 这是**排障开关,不用重新编译**:09-25 22:2x 实机发现
/// <c>SyncAetherAINpcRemove</c> 删不掉 8300 段那具身体(跑步机上的里希一直不消失),
/// 若补发 <c>SyncLogicAgentLeave</c>(已加,同时发两条)仍然无效,就把这个改成 true
/// 再让常驻重新摆一次(配置热加载,摆人才读这个值)。
/// ⛑ 风险:Aether 静态 NPC 通道是否吃这一段还没验过 —— 改完若人直接不出现,改回 false 即可。
/// </summary>
public bool RetirableUidSegment { get; init; } = false;
/// <summary>
/// 是否要求活动一定带世界坐标。默认 <b>true</b> —— 不带坐标的活动(临时点位)无法落地,
/// 这时会退到同 tag 在本场景里最近的一条有坐标活动。
/// </summary>
public bool RequirePosition { get; init; } = true;
/// <summary>
/// 一个角色在当前时段<b>查不到任何可落地的活动</b>时(没有 AgentTag、没有日程表、活动在别的
/// 场景、或官方压根没编坐标)要不要仍然把他放出来。默认 <b>false</b> = 不放。
/// ⛑⛑ 设成 <b>true</b> 会退化成「谁都往玩家脚边塞」:这类角色的坐标只能取玩家位置 +3m,
/// 于是登录和每次传送都会有一圈人贴着你刷出来(09-25 玩家报的那个现象就是它)。
/// 只有想让 OC 占位角色也露个脸时才开,代价是他们不会「过日子」,只是站着。
/// </summary>
public bool PlaceWithoutActivity { get; init; } = false;
/// <summary>
/// 手工常驻点……(见 <see cref="RosterNpcStaticSpot"/>)。
/// </summary>
public RosterNpcStaticSpot[] StaticSpots { get; init; } = [];
/// <summary>
/// 放人前先看目标点位多少米内(3D)有没有别的 NPC 已经站着,有就不放 —— 避免和市民 /
/// 功能 NPC / 切人留场的同一角色叠在一起。默认 <b>2</b> 米;设 0 关掉这个检查。
/// ⛑ 用 3D 距离是刻意的:同一栋楼 1 楼和 2 楼各站一个属于正常,只有真贴在一起才算冲突。
/// </summary>
public float MinSeparationMeters { get; init; } = 2f;
/// <summary>
/// ★ <b>按角色</b>覆盖出场 POI 动作 / 要不要发活动 id。默认空。
///
/// 为什么需要它:09-25 20:0x 实机确认 —— 里希按日程站到跑步机点上了,推
/// <c>SyncAgentPlayPOIAction</c>(68459398) 的 <b>198「站姿_运动_跑步机跑步」</b>
/// 之后他<b>真的跑起来了</b>(POI 通道有效),但上半身还叠着一层「打电话」
/// —— 那是生活日程的 ambient:出场包带 <c>AgentDataSetsActivityCfgId</c>
/// 却没有 loop pose 时客户端的兜底表现(规则 160 那句「没有 loop pose 就只能播 ambient」)。
/// 所以想跑得干净,得让这个角色<b>出场就带 198 且不带活动 id</b>。
///
/// ⛑ 只对列出来的角色生效,其余照旧 —— 别把 52 个角色全改成跑步机。
/// </summary>
public RosterNpcPoiOverride[] PoiOverrides { get; init; } = [];
/// <summary>
/// ★★ <b>按活动</b>覆盖出场 POI 动作(推广版,09-25 23:0x 加)。默认空。
///
/// <c>PoiOverrides</c> 是按<b>角色</b>写死的(里希 → 跑步机 198),只能管一个人;
/// 想让其余有日程的角色也「真的在做那件事」,得按<b>活动 id</b> 给动作 ——
/// 同一个角色一天里换好几个活动(塔菲 13 条:吃汉堡 / 看漫画 / 猫咖…),按角色写死没法覆盖。
///
/// ⛑⛑ 数据来源与局限(务必看清):
/// · <c>AgentDataSetsActivityConfig</c>(284 行)<b>只有中文名 + 坐标,没有 POI 动作 id</b>;
/// <c>UrbanDiversityPOIActionConfig</c>(3978 行)有中文名 ⇒ 只能**按名字匹配**推出来。
/// 表里那一份是脚本按「核心词完全相等 &gt; 包含 &gt; 2-gram 重叠」自动生成的,
/// <b>必然有猜错的</b>,看到不对就改 <c>poiActionId</c>(配置热加载,不用重启)。
/// · <b>朝向没有表可查</b>:`SceneitemConfig` 只是模板注册表(无世界坐标),
/// world dump 里也没有跑步机那类物件的摆放旋转 ⇒ <c>facing</c> 只能实机调,同里希那次。
/// · 配了 POI 的活动默认 <b>不带</b> <c>AgentDataSetsActivityCfgId</c>(否则生活日程的
/// ambient 会盖掉 POI,里希「一边打电话一边跑步」就是这个)。
/// </summary>
public ActivityPoiOverride[] ActivityPoiOverrides { get; init; } = [];
}
/// <summary>
/// 一条「活动 → 出场表现」的映射(<b>按活动 id</b>,不是按角色)。
/// 活动 = <c>AgentDataSetsActivityConfig.Id</c>(<c>NpcActivityCatalog.json</c> 里的 <c>Id</c>)。
/// </summary>
public sealed class ActivityPoiOverride
{
/// <summary>活动 id(<c>AgentDataSetsActivityConfig.Id</c>,如 11400012 = 里希「跑步机」)。</summary>
public uint ActivityId { get; init; }
/// <summary>
/// 出场 POI 动作 id(<c>UrbanDiversityPOIActionConfig</c>,3978 条)。
/// 填 <b>0</b> = 这条不覆盖,退回默认(<see cref="RosterNpcSettings.PoiActionId"/> + 照常发活动 id)。
/// </summary>
public uint PoiActionId { get; init; }
/// <summary>朝向(度)。<b>null</b>(默认)= 沿用 0。没有表可查,只能实机调。</summary>
public float? Facing { get; init; }
/// <summary>
/// 出场包还要不要带 <c>AgentDataSetsActivityCfgId</c>。
/// <b>null</b>(默认)= 本条配了 POI 就<b>不带</b>(让 POI 生效),没配就照常带。
/// </summary>
public bool? SendActivityCfgId { get; init; }
/// <summary>这个活动出场<b>不</b>发骇客手机。<b>null</b>(默认)= 照常发。</summary>
public bool? SkipPhone { get; init; }
/// <summary>
/// ★★ 落地坐标微调(米,**世界轴**,不是角色本地轴)。三个都是 <b>null = 不偏移</b>。
///
/// 为什么需要:官方 <c>AgentDataSetsActivityConfig.MapPosition</c> 是策划打的点,
/// 跟场景里的家具 / 机器经常差那么几十厘米 —— 人飘在床边上、背对着娃娃机站,
/// 这些都是没有表可查的(规则 171②:朝向无表可查),只能实机一点点挪。
///
/// ⛑⛑ 是**世界轴**:<c>OffsetX</c> = +X 方向,<c>OffsetZ</c> = +Z 方向,<c>OffsetY</c> = 上。
/// 判「哪边是右」要看这条活动落地时的朝向(<see cref="Facing"/>,没配就是 0):
/// 朝向 f(度)⇒ 前方 = <c>(sin f, 0, cos f)</c>,<b>右手方向 = <c>(cos f, 0, -sin f)</c></b>
/// (规则 181:喂 <c>MathF.Sin/Cos</c> 前必须把度转弧度)。
/// ⇒ 朝向 0 时右 = **+X**;朝向 90 时右 = **−Z**。
/// 改完朝向要重新对一遍偏移,别照抄。
///
/// 生效范围:常驻摆放 / 切人留场(真去演活动那具)/ 主动切到他日程点 —— 三处同一个偏移,
/// 否则「走过去看到的她」和「切过去落地的她」会差 0.8 米。
/// 实现见 <c>GameRouter.ApplyActivityOffset</c>。
/// </summary>
public float? OffsetX { get; init; }
public float? OffsetY { get; init; }
public float? OffsetZ { get; init; }
/// <summary>备注(自动匹配到的候选 / 人工校对的依据),只给人看。</summary>
public string? Note { get; init; }
}
/// <summary>
/// <c>gameplay.rosterNpc.poiOverrides</c> 的一条:某个角色出场时用哪个 POI 动作、
/// 以及还要不要给他发 <c>AgentDataSetsActivityCfgId</c>。
/// </summary>
public sealed class RosterNpcPoiOverride
{
/// <summary>角色(FightSpiritConfig.Id / roster 里的 spiritId)。</summary>
public uint SpiritId { get; init; }
/// <summary>
/// 出场 POI 动作 id(<c>UrbanDiversityPOIActionConfig</c>,共 3978 条)。
/// 198 = 站姿_运动_跑步机跑步;17 = 站姿闲置(默认)。
/// ⛑ 跑步机只有两条:198 跑、500997「通用04_跑步机_idle」(MainActions 为空)。
/// </summary>
public uint PoiActionId { get; init; } = 17;
/// <summary>
/// 出场包还要不要带 <c>AgentDataSetsActivityCfgId</c>。
/// <b>false</b> = 不带(活动坐标照用,但不进生活日程状态 ⇒ 没有 ambient 那层打电话);
/// <b>true</b> = 带(走生活日程,POI 会被 ambient 盖掉);
/// <b>null</b>(默认)= 沿用 <see cref="RosterNpcSettings.DailyLife"/>。
/// </summary>
public bool? SendActivityCfgId { get; init; }
/// <summary>
/// 出场朝向(<b>度</b>,与 <c>GameRouter.Civilians.cs:107</c> 的 <c>facing * MathF.PI / 180</c>
/// 同一套)。<b>null</b>(默认)= 沿用常驻 NPC 现在传的 <b>0</b>。
///
/// ⛑ 跑步机朝向<b>没有配置表可查</b>:`GadgetCatalog.json` 只有 prefab 注册表,
/// world dump 里 `wodegym_treadmill` 也只出现在 prefab 列表(有 GraphId/pathId,
/// 没有世界摆放与旋转)⇒ 只能实机调。配置有热重载,改完不用重启,
/// 但要等角色被重新放置(走远 420m 再走回来)才生效。
/// ⛑ 客户端没有「原地转身」的帧:`SyncAgentForceGo`(68698324) 只有 `(ulong id, bool)`,
/// 带不了朝向 ⇒ 朝向只能在出场包里给。
/// </summary>
public float? Facing { get; init; }
/// <summary>
/// 这个角色<b>不</b>发手机(<c>gameplay.hackNpc</c> 那部)。默认 <b>false</b> = 照常发。
///
/// 为什么需要它:09-25 实机 —— 里希出场带 POI 198 会在跑步机上跑,但上半身一直
/// 「拿着手机/打电话」,要**打他几下**(刺激打断 usage)才会变成正常跑步。
/// 那部手机不是生活日程 ambient,是骇客功能发的:`SendCivilianAsync` 里
/// POI 198 的 <c>UsageId = 0</c> ⇒ `poiUsageAlreadyHackable = false` ⇒
/// `MarkNpcHackableOnSpawnAsync` 补一部手机 + usage 6(玩手机),和跑步动作叠在一起。
/// 设 <b>true</b> 就让这个角色跳过发手机(可骇标记照旧发,只是没手机道具)。
/// ⛑ 只对列出来的角色生效 —— 用户明确要「每个 NPC 都有手机」,所以默认都发。
/// </summary>
public bool SkipPhone { get; init; }
/// <summary>
/// ★★ 这个角色「留场期间」轮换的 POI 组(开关
/// <c>SwitchAnimations.LeftBehindPoiRotation</c>)。
///
/// 洗牌袋:整组打乱后依次播,播完一轮重新洗牌 ⇒ 随机 + 一轮内不重复。
/// <b>null / 空数组</b> = 这个角色不轮换(沿用出场那个 POI,或走 wander)。
///
/// ⛑⛑ 只在这个角色<b>没在生活日程里</b>时才生效(出场包
/// <c>AgentDataSetsActivityCfgId == 0</c>)—— 带着活动 id 时客户端会走
/// 生活日程,ambient 会把 POI 盖掉(硬规则 163:里希"一边打电话一边跑步"就是这么来的)。
/// </summary>
public uint[]? LeftBehindPoiGroup { get; init; }
/// <summary>这个角色单独指定停留时长(毫秒);null = 用 <c>SwitchAnimations.LeftBehindPoiDwellMs</c>。</summary>
public int? LeftBehindPoiDwellMs { get; init; }
}
/// <summary>
/// <c>gameplay.rosterNpc.staticSpots</c> 的一条。固定站位,不随时间变、不跟着玩家跑。
/// </summary>
public sealed class RosterNpcStaticSpot
{
/// <summary>角色(FightSpiritConfig.Id / roster 里的 spiritId)。</summary>
public uint SpiritId { get; init; }
/// <summary>只在哪个场景生效。<b>0</b> = 任何场景都算。</summary>
public uint Raid { get; init; }
public float X { get; init; }
public float Y { get; init; }
public float Z { get; init; }
/// <summary>给人看的地点名(GM 面板「在干什么」列会显示它)。</summary>
public string Note { get; init; } = "";
}
/// <summary>
/// Plate(盘)演出管线试点 —— <c>gameplay.plate</c>。
///
/// 背景:可玩角色按日程站在跑步机 / 娃娃机 / 床旁边,但**不会真的用它们**(只站着玩手机)。
/// 官方的生活 NPC 是场景里 Spoon 图上的一个「盘」:`SpoonAgentUnit` 同时带
/// <c>AgentDataSetsActivityCfgId</c> / <c>ActivityPlateCreator</c> / <c>ActivityEnterPoint</c>,
/// 而服务端要把盘推下去客户端才会建它 —— 这就是第四条(表演)管线(硬规则 157)。
///
/// <c>SyncPlateAddList</c>(68554224) 的形状(客户端 C# `UX.Game.PlateInfo`):
/// <c>{ UniqueId, GraphId, Position, Rotation, GadgetDic, DestructibleDic, AgentDic,
/// VehicleDic, DynamicAgentDic, DynamicVehicleDic, StaticNpcDic, RoomDic, FavorNpcActivityId }</c>
/// + 包尾一个 <c>uint taskId</c>。
/// ⛑⛑ 客户端这个 reader 是 C# 生成的(`RPCDeserializeAuto.lua` 里 68554224 = CSharpOnly),
/// 我们**没有**它的线格式 ⇒ 下面那几个 <c>*Encoding/*Marker</c> 是给实机 A/B 用的旋钮,
/// 每次推送时现读配置,改了不用重编译。
/// </summary>
public sealed class PlatePilotSettings
{
/// <summary>总开关。关掉就一行都不发(回到 09-25 之前的状态)。默认 <b>false</b>。</summary>
public bool Enabled { get; init; }
/// <summary>进世界时自动推一次。建议先 false,用 <c>POST /api/plate/push</c> 手动推着试。</summary>
public bool PushOnWorldReady { get; init; }
/// <summary>盘实例 id(ulong)。随便给,但同一会话内别重复;换一个 id 就是换一个盘。</summary>
public string UniqueId { get; init; } = "830000000001";
/// <summary>
/// Spoon 盘图 id。跑步机 = <b>36001819</b>(`Res/Prefab/Gadget/wodegym_treadmill.prefab`,
/// occupyCnt=1)。其它候选:娃娃机 36000544 / 单人床 36001042 / 卧推 36001818 /
/// 深蹲 36000424 / 仰卧板 36000106。
/// </summary>
public int GraphId { get; init; } = 36001819;
/// <summary>盘的世界坐标(默认 = 里希跑步机活动 11400012 的 MapPosition)。</summary>
public float X { get; init; } = 1191.037f;
public float Y { get; init; } = 0.053f;
public float Z { get; init; } = 2052.112f;
/// <summary>欧拉角,度。</summary>
public float RotX { get; init; }
public float RotY { get; init; }
public float RotZ { get; init; }
/// <summary>这个盘偏好的活动(`AgentDataSetsActivityConfig.Id`)。里希跑步机 = 11400012。</summary>
public uint FavorNpcActivityId { get; init; } = 11400012;
/// <summary>任务盘才用;生活表演填 0。</summary>
public uint TaskId { get; init; }
/// <summary>
/// NPC 绑定方式:<c>none</c>(只推盘,不绑人)/ <c>staticNpc</c>(StaticNpcDic[slot]=常驻 NPC 的 uid)
/// / <c>agent</c>(AgentDic[slot]=spoonAgentId,用角色的 AgentId)。
/// </summary>
public string NpcBind { get; init; } = "none";
/// <summary>上面那个 Dic 的槽位下标(图里第几个位子)。</summary>
public int SlotIndex { get; init; }
/// <summary>
/// 绑谁:<c>NpcBind != none</c> 时用哪个 spiritId 去找人(默认里希 15021021)。
/// 先去常驻 NPC 账本里查它此刻在不在场上,不在就用它的 AgentId 兜底。
/// </summary>
public uint SpiritId { get; init; } = 15021021;
// —— 以下都是线格式旋钮,只为实机 A/B ——
/// <summary>包体顶层是否先写一个 0xFF 存在标记(多数 S2C 有,GadgetGrid 那种 Inline 没有)。</summary>
public bool RootMarker { get; init; } = true;
/// <summary>盘列表前面是否写存在标记(默认 0xFF)。</summary>
public bool ListMarker { get; init; } = true;
/// <summary>列表 count 的写法:<c>int7</c>(count+1 偏置)/ <c>raw7</c>(count 原值)/ <c>int32</c>。</summary>
public string ListCountEncoding { get; init; } = "int7";
/// <summary>每个 PlateInfo 元素前是否写 0xFF(Complex 有,Struct 没有)。</summary>
public bool ItemMarker { get; init; } = true;
/// <summary>八个字典前面是否写存在标记。空字典通常也要这一字节 + count。</summary>
public bool DictMarker { get; init; } = true;
/// <summary>字典 count 的写法,同 <see cref="ListCountEncoding"/>。</summary>
public string DictCountEncoding { get; init; } = "int32";
/// <summary>Position / Rotation 前是否各写一个 0xFF(UXVector3 通常是裸 3 个 float)。</summary>
public bool VectorMarker { get; init; }
}
/// <summary>
/// 生活日程正式通道(<c>gameplay.npcLife</c>)—— 硬规则 160。
///
/// 为什么需要它:09-25 实机确认「角色按日程站到跑步机上,但只是站着玩手机」。
/// 取证(dump 的 <c>06_RUNTIME_CODE</c>)显示官方有三条我们完全没发的 S2C:
/// <list type="bullet">
/// <item><c>SyncFavorNpcSpoonAgentId</c>(64003213):
/// <c>(uint agentTag, int spoonAgentId, UXVector3 Position, FavorNpcBusyInfo busyInfo)</c> ——
/// 客户端 <c>LifeScheduleNpcModule.AttachNet(net, activityId, spoonAgentId,
/// hasAmbientLoopPose, ambientLoopPos, ambientLoopFacing)</c> 就吃这一套;</item>
/// <item><c>SyncAgentTagTimeTableInfo</c>(64400736):<c>(uint agentTag, NpcTimeTableInfo)</c>;</item>
/// <item><c>SyncFavorNpcTimeTableInfos</c>(64219939):
/// <c>Dictionary&lt;uint, NpcTimeTableInfo&gt;</c>。</item>
/// </list>
/// 而 <c>NpcTimeTableInfo</c> 里的 <c>SpoonAgentId</c> / <c>CurrentSpoonAgentId</c> /
/// <c>SpoonPosition</c> 我们一直填 0(<c>RuntimePayloadFactory</c>)⇒ 客户端拿到活动 id
/// 却不知道人该站进哪个 spoon 节点,只能播 ambient 兜底。
/// </summary>
/// <summary>
/// 按地块铺市民(GM 面板 T0-8)时,对候选池里的 <c>PoiActionId</c> 做二次筛选
/// (2026-10-02 新增,配置键 <c>gameplay.civilianPoi</c>)。
/// 数据来源 = <c>UrbanDiversityPOIActionConfig.json</c> 全字段表(3978 条,
/// 解析器 <c>UrbanDiversityPoiActionCatalog4229938</c>),消费点
/// <c>GameRouter.Civilians.BuildItemPool</c>。
///
/// <para>⛑ <b>两个开关默认全 false</b> ⇒ 不改任何现有行为;只有显式打开才生效。
/// 这也意味着「改坏了」的回滚就是改回 false(不用动代码)。</para>
///
/// <para>★ 为什么需要它:地块 → 分组的匹配只到「类别」这一层
/// (<c>CivilianDistricts.Categories</c> 11 种 ↔ <c>CivilianGroups.Category</c> 27 种),
/// 组内每条是<b>固定的</b> NpcFormworkId + PoiActionId ⇒ 同一个类别里既有「站着不动」的,
/// 也有「带根位移会走开」「需要拿道具」的。全字段表能按动作属性再筛一遍。</para>
/// </summary>
public sealed class CivilianPoiSettings
{
/// <summary>
/// 剔除 <c>ApplyRootMotion = true</c> 的 POI 动作(全表 370 / 3978 条 ≈ 9.3%)。
///
/// <para>★ 打开的理由:带根位移的动作会让 NPC <b>真的走出站位</b> —— 表现为
/// 「铺完过一会儿人就挪走了 / 几个市民叠在一起」。这是 POI 表里唯一能直接判定
/// 「这个动作会让人离开点位」的字段。</para>
///
/// <para>⛑ 副作用:候选池会变小。实测每个类别剩 13~46 人,剔掉约 9% 不会抽空;
/// 真抽空时 <c>BuildItemPool</c> 有「退回未过滤」的兜底。</para>
/// </summary>
public bool ExcludeRootMotion { get; init; }
/// <summary>
/// 剔除需要拿道具的 POI 动作(<c>ActionTag4</c> 含「道具」,全表 763 / 3978 条 ≈ 19%)。
///
/// <para>★ 打开的理由:这类动作(端杯子、用电脑、拿话筒)要客户端把道具挂到手上,
/// 服务端生成时<b>不带道具</b> ⇒ 容易出现「空手比划」。</para>
/// </summary>
public bool ExcludeProps { get; init; }
}
/// <summary>
/// 官方「过街等待区 + 槽位」站位源(<c>paths.pedSpawns</c>)的使用设置。
///
/// <para>★ 这份数据是什么:客户端 <c>MassAI.MassCrowd.CrowdWaitAreaData</c> 里那一套
/// 「路口等待区 + 槽位 + 人行横道」的<b>官方坐标</b>,从 ZoneGraph dump 出来的。
/// 每个等待区挂 3~5 个槽位,槽位自带朝向与 urban_diversity_config_id。</para>
///
/// <para>⛑⛑ 它不是服务端能驱动的东西:整套是客户端原生 ECS(Lua grep 零命中),
/// 客户端自己的 crowd 散人本来就在用。<b>我们只是把自己的 NPC 放到同一批坐标上</b>,
/// 好看上去像「这撮人也在等灯」。</para>
///
/// <para>⛑ 三个开关默认的行为:总开关 false ⇒ 完全不读这张表,现有逻辑一行不动。</para>
/// </summary>
public sealed class CivilianWaitAreaSettings
{
/// <summary>
/// 启用官方等待区站位作为额外的点位来源。默认 <b>false</b>。
/// 打开后,街区铺设会先从等待区取点(不足再退回原有的生态区点位)。
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// 同一个等待区最多放几个人。默认 <b>2</b>(与捐赠端 <c>PedSpawnTable.NearSpread</c> 同值)。
///
/// <para>★ 为什么不能塞满:官方一个等待区只有 3~5 个槽位,全占满就成了
/// 「路口一角戳着一排人」;官方画面是「四角各站两三个」。<c>MaxPerArea</c> 就是那个纪律。</para>
/// </summary>
public int MaxPerArea { get; init; } = 2;
/// <summary>取点半径(米)。默认 <b>250</b>。</summary>
public float RadiusMeters { get; init; } = 250f;
}
public sealed class NpcLifeSettings
{
/// <summary>总开关。默认 <b>false</b>(保持 09-25 的行为,改坏了随时关回来)。</summary>
public bool Enabled { get; init; }
/// <summary>
/// 常驻 NPC 落地后立刻给它推一条 <c>SyncFavorNpcSpoonAgentId</c>。默认 <b>true</b>。
/// </summary>
public bool PushOnPlace { get; init; } = true;
/// <summary>
/// spoonAgentId 怎么取值:<c>agentId</c>(用角色自己的 AgentId = NpcFormworkId,里希 40140040;
/// 与出场包 <c>AgentSyncClientInfo.SpoonAgentId</c> 同值,目前唯一能自圆其说的取法)/
/// <c>fixed</c>(用 <see cref="SpoonAgentIdFixed"/>)/ <c>none</c>(填 0)。
/// </summary>
public string SpoonAgentIdMode { get; init; } = "agentId";
/// <summary><see cref="SpoonAgentIdMode"/> = <c>fixed</c> 时用这个值。</summary>
public int SpoonAgentIdFixed { get; init; }
/// <summary>
/// <c>SyncFavorNpcSpoonAgentId</c> 的线格式旋钮:包尾那个可空的 <c>FavorNpcBusyInfo</c>
/// 怎么表示 null。<b>0</b>(默认)= 写一字节 0x00(服务端 C#
/// <c>SerializeBase.WriteComplex</c> 写 null 就是 0);<b>255</b> = 写 0xFF;
/// <b>-1</b> = 整字节不写。
/// ⛑ 客户端 <c>RPCDeserializeBase.ReadComplex</c> 是反编译出来的,看着像「0 = 有值」,
/// 与 writer 相反 —— 所以两种都留着,客户端报 64003213 反序列化失败就换另一个值再推。
/// </summary>
public int BusyInfoMarkerByte { get; init; }
// —— 时刻表(64219939 / 64400736)—— 09-25 晚补 ——
/// <summary>
/// ★★ 推 spoon 之前<b>先把时刻表推下去</b>。默认 <b>true</b>。
///
/// ⛑⛑ 为什么这条是硬前提(09-25 19:4x 读客户端 lua 才发现的):
/// <c>NpcDaliyManager.lua:143</c> 的 <c>OnSyncFavorNpcSpoonAgentId</c> 第一行就是
/// <code>if table.isNilOrEmpty(self.NpcTimeTableInfos[agentTag]) then return end</code>
/// ⇒ 客户端没有这个 agentTag 的时刻表时,<b>64003213 整帧被静默吞掉</b>,
/// 不报错、不打日志、什么都不做。我们 19:36 那次推完「零反应」就是这个原因。
///
/// 时刻表虽然登录帧 <c>FavorNpcDailyScheduleInfos</c> 里有,但那一份的
/// <c>SpoonAgentId</c> / <c>CurrentSpoonAgentId</c> / <c>SpoonPosition</c> 全是 0
/// (<c>RuntimePayloadFactory</c>),等于「有日程、没有人占坑」。
/// 所以这里重新推一份<b>带 spoon 号</b>的覆盖上去。
/// </summary>
public bool PushTimeTable { get; init; } = true;
/// <summary>
/// 只推<b>一个</b> agentTag 的时刻表(走 <c>SyncAgentTagTimeTableInfo</c> 64400736)
/// 还是推全量字典(走 <c>SyncFavorNpcTimeTableInfos</c> 64219939)。默认 <b>false</b> = 全量。
/// 单条包体小、字段少,排障时先用它排掉「字典线格式不对」这个变量。
/// </summary>
public bool TimeTableSingleTag { get; init; }
/// <summary>
/// 是否把 spoonAgentId 也写进<b>当前时段那一段</b>的
/// <c>NpcScheduleInfo.SpoonAgentId</c>(顶层 <c>CurrentSpoonAgentId</c> 无论如何都写)。
/// 默认 <b>true</b> —— 官方那五个字段命名一致,客户端具体读哪个还没验出来,两个都填最稳。
/// </summary>
public bool TimeTableSpoonInSlice { get; init; } = true;
// —— 兜底:POI 动作(68459398)——
/// <summary>
/// 兜底通道:常驻 NPC 落地后补一帧 <c>SyncAgentPlayPOIAction</c>(68459398) 让它直接播
/// 某个 POI 动作。<b>0</b>(默认)= 不发。
///
/// ⛑ 这是<b>已验证过</b>的原生通道(<c>GameRouter.Ambient.cs:892</c> /
/// <c>GameRouter.Civilians.cs:492/666</c> 一直在用),不是新轮子:
/// 跑步机那条是 <c>UrbanDiversityPOIActionConfig</c> 的
/// <b>198「站姿_运动_跑步机跑步」</b>(MainActionGroup 400 / MainActions [86190])。
/// ⛑ 但 <c>LeftBehindNativePoiActionId</c> 的注释已经警告过:开了生活日程
/// (<c>AgentDataSetsActivityCfgId</c> 非 0)时客户端走生活日程状态,POI 会被盖掉
/// ⇒ 想验这一条就把 <c>gameplay.rosterNpc.dailyLife</c> 关掉(活动 id 归 0)再推。
/// </summary>
public uint PoiActionId { get; init; }
/// <summary>落地后是否自动发 <see cref="PoiActionId"/>。默认 <b>false</b>(只在需要时开)。</summary>
public bool PushPoiOnPlace { get; init; }
}
public sealed class CombatSettings
{
public float MaxHp { get; init; }
public float Attack { get; init; }
public float Defense { get; init; }
public float MoveSpeedMultiplier { get; init; }
public float PanelDamage { get; init; }
public float PanelDefenseDeduct { get; init; }
public uint FightStyleTypeId { get; init; }
public uint FightStyleId { get; init; }
public int[] DefaultUrbanAbilities { get; init; } = [];
public WeaponSettings Weapon { get; init; } = new();
public ResourceSettings Resource { get; init; } = new();
public SkillSettings Skills { get; init; } = new();
public SkillChargeSettings[] Charges { get; init; } = [];
/// <summary>敌人受击手感(T2-44b 之后新增,2026-09-23 夜)。
/// 三个值全部可在 <c>private-server.json</c> 里改,改完只重启服务端即可,不需要重新编译。</summary>
public EnemyCombatSettings Enemies { get; init; } = new();
}
/// <summary>敌人「受打击反馈」的三个旋钮。
///
/// ⛑⛑ <b>PoiseDrainPerHit 是手感的开关</b>,历史沿革(都实测过):
/// · <b>100</b>(等于一击的伤害,2026-09-23 上午 11:3x 那个状态)—— 客户端的架势池就是 100,
/// 所以<b>每一下都打空</b> ⇒ 每一下都进破防/倒地,反馈最强。代价是当时没有退出通道,
/// 敌人卡在破防里出不来,看起来就是"打不死"。现在退出通道(ReportUnitStandUp 回执 +
/// SyncEnemyDisarmState(false) + 5 s 超时兜底)已经有了,所以这个值现在可以安全地开回来。
/// · <b>34</b>(kimika 捐赠端值,14:4x 之后用的)—— 三下破防,节奏最像官服。
/// · <b>0</b>(donor 值,15:2x 之后用的)—— 客户端架势池不动,只靠服务端自己的模型破防,
/// 反馈最弱(只有 SyncUnitAttacked + 67149 那一下硬直)。
/// 服务端账本的扣架势用的是<b>同一个值</b>(见 GameRouter.LocalEnemyCombat),两边永远同步,
/// 不会出现"客户端已经破防、服务端还要再攒两下"的错位。
/// </summary>
public sealed class EnemyCombatSettings
{
/// <summary>每次命中从架势池里扣多少。见类注释:100 = 每下破防(反馈最强),34 = 三下破防,0 = 不破防。</summary>
public float PoiseDrainPerHit { get; init; } = 100f;
/// <summary>
/// 敌人<b>最大生命值</b>(服务端权威账本的分母,也是 spawn 时推给客户端
/// <c>SyncUnitAttrs{[1]}</c> 与 <c>SyncUnitHp</c> 的那个值)。
///
/// ⛑⛑ 这是 <b>GM/服务端 spawn 的敌人</b>和<b>地图上原生生成的敌人</b>共用的
/// <b>唯一</b>血量来源(2026-09-27 改为配置驱动,原来是
/// <c>GameRouter.LocalEnemyMaxHp = 1000f</c> 常量):
/// · GM 召唤(<c>GameAdminBridge.SpawnAgentAsync</c>)用同一常量播种 spawn 批次;
/// · 地图敌人走「首次受击才登记」路径(<c>SendLocalEnemyHpBaselineAsync</c>),
/// 登记的也是这个值。
/// ⇒ 改这一个数,两类敌人<b>同时</b>生效。
///
/// 1000 = 旧默认值(配合 <c>LocalEnemyHitDamage=100</c> ⇒ 第 10 击死,
/// 与 LY-3.3 参考服同一手感)。**1500 = 1.5 倍(2026-09-27 用户要求)⇒ 第 15 击死。**
/// ⛑ 读取处有 <c>Math.Max(1f, …)</c> 兜底,配 0/负数不会让血条消失。
/// </summary>
public float MaxHp { get; init; } = 1000f;
/// <summary>
/// <c>EnemyAiCatalog.json</c> 里<b>没有行</b>的模板,是否按<b>同名变体</b>借一份战斗档案
/// (2026-09-29,地盘敌人受击反馈专项)。
///
/// 背景:目录是 dump 脚本对 <c>AgentConfig.json</c> 的投影,漏了一批换皮 id。
/// 地盘 roster 74 个模板里 20 个无行,**其中 18 个是同名同骨架的变体**
/// (40120148 电视脑职员无行 / 40120694 电视脑职员有行且带 unarmed 池)。
/// 无行 ⇒ <c>Find</c> 返回 null ⇒ <c>SendEnemyCombatStanceAsync</c> 直接 return,
/// **一个姿态包都不发** ⇒ 客户端没有 <c>SyncBattleAnimSet</c> / <c>SyncAgentChangeWeapon</c>,
/// 受击反馈只剩我们补的 67149 一次性硬直 = 「硬直段数明显更少」的根因。
///
/// 开(默认):按 Name 回退(同名 = 同骨架,安全)。关 = 改动前的旧行为,用于 A/B。
/// ⛔ 只按 Name,不按 <c>GroupEnemy</c>(那批的 GroupEnemy 全是 40211001,粒度不够)。
/// </summary>
public bool ProfileNameFallback { get; init; } = true;
/// <summary>命中时额外推一次 67149 硬直动作(SyncEnemyPlayAction)。
///
/// ⛑ 2026-09-26 <b>Wave 3 已在 json 里关掉</b>(补偿层退役的二值实验):
/// Wave 1(FirmHurt 中继 + poiseDrain=0)+ Wave 2(melee 模式空手动画集 +
/// SyncBattleAnimSet)落地后,受击表现应当全部由客户端原生管线(hurtEffect Impact +
/// 原生 SyncUnitAttacked 消费方)产出。json 翻回 true 即恢复补偿硬直,用于 A/B 对照。
/// 若实测反馈消失 ⇒ 原生管线还没接上,翻回 true 并查 [ENEMY-STANCE] / firm= 日志。
/// </summary>
public bool PushStaggerAction { get; init; } = true;
/// <summary>命中时推 SyncUnitHurtEffect(68349329)。客户端自己的受击管线由它驱动。</summary>
public bool PushHurtEffect { get; init; } = true;
/// <summary>
/// <c>SyncUnitHurtEffect</c> 的 <c>HurtImpact</c> / <c>HurtLeftOrRightHit</c>
/// <b>查表</b>而不是硬编码(2026-09-26,移植捐赠端 7k-v0.5
/// <c>HurtEffectCatalog4229938</c> + <c>ResolveHurtPresentation</c>)。
///
/// 开:按 <c>hit.HurtEffectId</c> 去 <c>HurtEffectCatalog.json</c>(1841 行,build 4229938
/// <c>HurtEffectConfig</c> 的投影)里取作者配的 Impact / LeftOrRight / SoundId。
/// 关(false):回到 2026-09-26 之前的行为 —— <c>HurtImpact = 1</c> /
/// <c>HurtLeftOrRightHit = 0</c> 两个常量。
///
/// ⛑ 兜底同捐赠端:<c>Math.Max(1, authored.Impact)</c>。表里有 306 行 Impact 是 0,
/// 直接发给客户端就是"完全没反应",比硬编码 1 还差,所以下限夹到 1。
/// 表文件缺失时 fail-open 退化成旧常量(见 <c>HurtEffectCatalog4229938.Load</c>)。
/// </summary>
public bool HurtEffectCatalogLookup { get; init; } = true;
/// <summary>
/// 枪械命中的表现<b>只升不降</b>(2026-09-26 Wave 1 修订语义):对 PhysicalAttackType 4/5
/// 的精确 FireArmID 预设(55004538 / 55004539 / 55004611-18 / 55004736 / 55004740 /
/// 55004741),<b>保留作者行</b>(hurtId / SoundId / LeftOrRight 原样),只把
/// <c>Impact</c> 抬到 ≥ 2(第一可见档)。
///
/// ⛑⛑ <b>有意偏离捐赠端 7k-v0.5</b>:7K 是把整行<b>替换</b>成 55001201
/// (<c>GameRouter.Enemy.cs:594-600</c>),那会把 55004539 / 55004615 / 55004741 从
/// 作者配的 <c>Impact=4 / SoundId=70400806</c> 打成 <c>Impact=2 / SoundId=0</c>
/// —— 反馈强度和音效双降。拿我们自己的表逐行核对过:这 13 个 id 里 8 行 Impact=1
/// (不可见档,抬到 2 是<b>升</b>)、2 行 Impact=2(不变)、3 行 Impact=4(替换是<b>降</b>)。
/// 只升不降拿到同样的「远程密集硬直」,且不损失任何一行的作者配置。
/// </summary>
public bool HurtEffectBulletOverride { get; init; } = false;
/// <summary>
/// <c>SyncUnitAttacked.PoiseBasicValue</c> 改发<b>客户端上报的 <c>hit.FirmHurt</c></b>
/// (捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:447</c> 的取值来源),不再发服务端账本同源的
/// <c>PoiseDrainPerHit</c>。原生战斗管线复活 Wave 1(2026-09-26)。
///
/// ⛑⛑ 实测先行值:<c>logs/console-20260926-105902.log</c> 10 次命中 <c>firm=0.00</c>
/// —— 客户端当前<b>恒报 0</b>,所以开中继 ≈ 发 0(客户端架势池不动、原生破防不再由
/// 服务端包触发,「打着打着忽然定住」的锁死源随之消失)。关掉(false)回到旧行为 =
/// 发 <c>PoiseDrainPerHit</c>。若后续 spawn 握手(Wave 2)让客户端算出非 0 的
/// FirmHurt,中继自动接上,不用再改代码。服务端自己的架势账本<b>不受此开关影响</b>
/// (仍按 <c>poiseDrainPerHit</c> 扣)。
/// </summary>
public bool RelayFirmHurtAsPoise { get; init; } = true;
/// <summary>
/// 敌对单位 spawn 时补<b>骇入标记</b>:<c>AgentSyncClientInfo.HackerBetray = true</c> +
/// 在 SyncUnitHp 之后发 <c>SyncUnitHackableState{isHackable=true}</c>(68941159)。
/// 对齐捐赠端 7k-v0.5(<c>GameRouter.Enemy.cs:308 / :357-362</c>;原注释
/// "EnemyRebel.CanUseInternal also requires SpawnInfo.hackerBetray")。
/// 这是 Q4「7K 敌人头顶 HUD 比我们多什么」里的骇入图标,也是原生 spawn 握手的一部分。
/// <b>只对 hostile 生成生效</b>,市民 / 友方召唤物路径不变。
/// </summary>
public bool PushHackableState { get; init; } = true;
/// <summary>
/// 敌对单位 spawn 时补<b>飞索/暗杀标记</b>:<c>AgentSyncClientInfo.FeiSuo = true</c>
/// (<c>dump.cs:1745249</c>,紧跟 <c>isAttackInSafeMode</c>,我们的 DTO 顺序已核对一致)。
///
/// ⛑⛑ 这是 spawn 契约里<b>唯一从未赋值的敌对相关布尔</b>(恒 false),
/// 而暗杀管理器就叫 <c>FeisuoAssassMgr</c>、蹲伏态叫 <c>FeiSuoCrouch</c>,
/// 客户端 <c>StealthManager.CrouchAssassinationUnitCheck</c> 的候选集很可能据此筛。
/// 2026-09-26 19:5x 修完「蹲伏被 buff reassert 踢掉」后蹲伏能撑 9 秒(够跑 22 轮 0.4s 检测),
/// <c>ReportCanAssassinateEnemies</c> 仍为 0 ⇒ 候选集本身就是空的。
///
/// ⚠️ 语义未 100% 证实(dump 里另有 <c>FeiSuoCrouchLockTarget</c> 指飞索锁目标),
/// 故默认开但可一键关。关掉即回到恒 false 的旧行为。
/// </summary>
public bool PushFeiSuoSpawnFlag { get; init; } = true;
/// <summary>
/// 敌对单位 spawn 的<b>战斗模式</b>(原生战斗管线复活 Wave 2,2026-09-26):
/// <c>off</c>(代码默认)= Wave 2 之前的旧行为,一个字节不变;
/// <c>template</c> = 解析模板但不发姿态包(7K 对 template 模式本来就跳过 stance);
/// <c>melee</c> = <b>7K 的默认 spawn 模式</b>:有空手技能的模板(2402/3160)卸武器 +
/// 空手动画集(AnimationSetConfig 325 十二面空手 / 327 通用胖子空手)+ 攻击集换成空手技能;
/// <c>ranged</c> = 有武器且有远程技能(448/3160)才成立,AttackRange=18 + 动画集 337/338/340。
///
/// 生效面:武器(<c>RaidBattleUnitAgent.WeaponId</c>,空手 = 0 是<b>有意</b>的)、技能槽、
/// 攻击 AI 的技能池、spawn 尾部两包 <c>SyncAgentChangeWeapon</c> + <c>SyncBattleAnimSet</c>
/// (移植 7K <c>SendEnemyCombatStanceAsync</c>)—— 四处共用
/// <c>EnemyAiCatalog4229938.ResolveSpawnProfile</c> 一份解析,保证三者一致。
/// ⛑ 非法值 fail-safe 落 off(向旧行为倒)。
/// </summary>
public string SpawnCombatMode { get; init; } = "off";
/// <summary>
/// <c>melee</c> 模式下<b>是否保留模板自带武器</b>(<c>RaidBattleUnitAgent.WeaponId</c>)。
///
/// ⛑⛑ 2026-10-01 实测(GM 刷 40100010 黑金节奏手):手上没有武器。日志
/// <c>[AGENT] spawned agent=40100010 ... weapon=0 weaponItem=80000126 ... mode=melee/melee</c>
/// ⇒ 根因<b>不是客户端不认 WeaponId</b>,而是 <c>SelectCombatProfile</c> 的 melee 分支
/// (移植 7K)把 Weapon <b>硬写成 0</b>("空手 = 0 是有意的")。受影响的不是少数:
/// catalog 3160 条里 1802 条有武器、2402 条属 unarmed 档 ⇒ melee 模式下这批全部被卸。
/// 同一份日志里不在 catalog 的模板(晴 40651922 / 盖斯 40155016)走兜底,反而
/// <c>weapon=98005506(fallback)</c> —— 这也是"有的敌人有武器有的没有"的来源。
///
/// 开(true)= 保留 <c>profile.Weapon</c>(仍为 0 的模板照旧走 98005506 兜底),
/// 攻击集与动画集<b>不变</b>(还是空手招 + 325)⇒ 现象是"拿着武器打空手动作"。
/// 关(false)= 回到逐行对齐 7K 的旧行为(weapon=0),一个字节不变。
/// 代码默认 false(fail-safe 向旧行为倒),由 json 显式打开。
/// </summary>
public bool SpawnKeepWeapon { get; init; }
/// <summary>
/// 敌对单位 spawn 帧 <c>RaidBattleUnitAgent.SourceWeaponId</c>(<b>ulong</b>) 的取值。
/// <b>0 = 不变(历史行为,恒 0)。</b>
///
/// ⛑⛑ 2026-10-01 探针:spawn 帧的 <c>WeaponId</c>(uint,WeaponConfig 配置 id) 已实测
/// **发非 0 也不挂武器**(40100010 = 98005506;随从走兜底同样 98005506,手上全空)。
/// 而<b>玩家侧武器一律走"实体"</b> —— <c>WeaponUid</c> / <c>destructibleId</c>
/// (<c>GameRouter.WeaponDrop.cs:84-96</c>,<c>AskSkillDestructibleCreate</c> 分配真 id,
/// 日志 <c>[WEAPON-DROP] create id=…</c>);玩家丢在地上的武器<b>模型是有的</b>
/// (用户 14:1x 确认)⇒ 武器资源不缺,缺的是"敌人拿哪一把"的指认方式。
///
/// 假设:<c>SourceWeaponId</c> 才是"手上那把武器的实体",<c>WeaponId</c> 只是出生配置。
/// 探针用法:游戏里丢一把武器 → console 日志 <c>[WEAPON-DROP] create id=</c> 取 uid →
/// 填进本项 → 重启 → 刷敌人,看手上是否出现那把武器。
/// ⛑ 副作用待观察:地上那把可能被"拿走"(实体挂到单位手上)。
/// </summary>
public ulong SpawnSourceWeaponId { get; init; }
/// <summary>
/// 上面那项的<b>自动模式</b>:spawn 时取<b>本次会话最近一次掉落的武器实体</b>
/// (<c>WorldEntryState.WeaponDrops</c> 里编号最大的那条),覆盖手填的
/// <see cref="SpawnSourceWeaponId"/>。
///
/// ⛑⛑ 为什么必须要有它:地上的武器是<b>客户端创建</b>的(<c>AskSkillDestructibleCreate</c>
/// 是 C2S,服务端只负责分配 9e12 起的 id 并记账)。服务端一重启 <c>WeaponDrops</c> 就空了,
/// 而客户端场景里那个掉落物也大概率不存在了 ⇒ 手填一个<b>旧</b> uid 去 spawn,
/// 实体早已消失,结果必然还是空手 —— 那样<b>无法区分"字段没用"还是"实体早没了"</b>。
/// 自动模式保证用的是"刚刚丢下的那把",实验才可解释。
///
/// 用法:开本项之后,一次会话内「先丢一把武器 → 立刻刷敌人」即可,
/// 不用再抄 uid、不用为了改 uid 重启。
/// </summary>
public bool SpawnSourceWeaponAuto { get; init; }
/// <summary>
/// 每次 <c>SyncUnitAttacked</c> 之前<b>重断言 <c>SyncUnitAttrs{[1]=MaxHp}</c></b>
/// (对齐捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:425-431</c>,原注释:
/// "Model/occur initialization can replace attributes after the spawn snapshot.
/// Reassert the denominator before damage/HUD events, without resetting HP.")。
///
/// ⛑⛑ <b>伤害数字(飘字)的头号剩余嫌疑</b>(2026-09-26):原生飘字是 C# 侧消费
/// <c>SyncUnitAttacked</c> 产生的(§35.5 已定),它需要拿到单位的 MaxHp 当分母。
/// 我们只在 spawn 时发一次 attrs,若模型/occur 重建把它冲掉(7K 注释明说会发生),
/// 客户端 MaxHp=0 ⇒ 飘字算不出来。7K 每击前都重断言,我们没有 —— 这是逐字段
/// diff 后<b>唯一还没对齐的伤害链差异</b>。代码默认 false(旧行为),json 开。
/// </summary>
public bool ReassertMaxHpBeforeHit { get; init; }
/// <summary>
/// 死亡时推 <c>SyncAddUnitState</c>(68026128) 把单位打进 <c>UnitStateConfig.DeadS</c>(state=2)。
///
/// 这是「死后继续挨打 / 倒地被后续连招顶掉」的根因开关。捐赠端 7k-v0.5
/// <c>GameRouter.Enemy.cs:496-506</c> 在 <c>SyncEnemyPrepareDie</c> + <c>SyncUnitHp(0)</c> 之后
/// 必发这一包,源码注释写得很直白:
/// "HP alone only changes the client data/UI and does not enter UnitDeathBehaviorModule."
/// 也就是<b>只把血打到 0 是不够的</b>——客户端的单位还活着,它原生的受击管线照常跑,
/// 于是玩家后续的每一次命中都能重新抢到动作槽,把刚播起来的倒地/死亡动画顶掉。
/// 服务端这边早就有闸门(<c>dead &amp;&amp; !shouldStartDeath</c> 直接 return,一个包都不发),
/// 但那挡的是<b>我们</b>发的包,挡不住客户端自己演。
///
/// ⛑ 与 fastpatch 的死亡闸门(<c>__DRMK_Dead</c>)不是一回事:那个闸门挂在 hurtHook 里,
/// 而我们走的是 "SyncUnitHurtEffect left to native",闸门根本不在链路上(硬规则)。
/// 能让客户端停手的只有"这个单位已经是 DeadS"这个状态本身。
///
/// 关掉(false)即回到 2026-09-25 23:27 的既有行为,用于 A/B 对照。
/// </summary>
public bool PushDeadUnitState { get; init; } = true;
/// <summary>
/// 伤害包 <c>SyncUnitAttacked</c> 里<b>补全攻击者身份</b>:
/// <c>SourceTemplateId</c> 填当前角色模板、<c>HitIndex</c> 填 <c>hit.Id</c>。
///
/// ⛑⛑ <b>这是对齐,不是已验证的修复。</b>依据只有一处事实:捐赠端 7k-v0.5
/// <c>GameRouter.Enemy.cs:441/446</c> 两个字段都填真实值,我们两个都填 <b>0</b>。
/// 0 是明确的"无效",填真值不可能比填 0 更差,但<b>它是否就是伤害数字缺失的原因,
/// 目前没有硬证据</b>——捐赠端日志不打印这两个字段,也没有 diff 可比对。
///
/// 真正的第三候选是 HUD 绑定:见 <see cref="EnemySpawnAttachBattleModule"/>
/// (2026-09-23 的结论,怀疑它会把单位换到另一套战斗模块,HUD 绑的是原来那套)。
/// 那条不用编译,改配置重启即可 A/B,建议<b>先试那个</b>。
///
/// false = 保持 2026-09-26 之前的行为(两个字段都 0)。
/// </summary>
public bool FillDamageSourceIdentity { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ <b>伤害包的 <c>attackerId</c> 在客户端上报值为 0 时,回退到「当前操控单位的 unit id」,
/// 而不是 <c>Profile.PlayerPid</c></b>(2026-09-26 伤害数字攻坚·离线取证)。
///
/// <b>硬证据链</b>(离线反汇编 <c>GameAssembly.dll</c>,IMAGE_BASE 0x180000000):
/// 1. <c>LX6.Utils.GuiUtils.HurtFloat</c>(RVA 0x14824610)是画飘字的唯一入口;
/// 全 .text(第 7 节,285MB)扫 xref 后它<b>只有 1 个调用点</b>:
/// <c>0x1276261B</c>,位于 SyncUnitAttacked(68299892) 原生消费函数 0x1275F320 内。
/// 2. 该函数里唯一的飘字闸门是 <c>0x12760FF5 CanShowHurtFloat(DamageData.CreationId)</c>,
/// <c>CreationId == 0</c> 时在 0x12760FA5 直接 <c>je</c> 放行 —— <b>我们本来就发 0,闸门天然通过</b>。
/// 3. 真正的最后一道在 <c>HurtFloat</c> 内部:<c>unit.IsMe || attacker.IsMe || realAttacker.IsMe</c>
/// 三者全 false ⇒ 只打一条 <c>Warn(LogCategory.HurtFloatText = 371)</c> 然后返回,<b>不画飘字</b>。
/// 4. <c>attacker</c> 由 <c>attackerId</c> 经 <c>GetUnit → GetNpcByPid → Unit_GetByPid</c>
/// 三段解析(0x1275F48D / 0x1275F4AF / 0x1275F882),解析不到 ⇒ null ⇒ 这条判据恒 false。
/// 5. <c>Profile.PlayerPid</c> = json <c>player.pid</c>(我们 100 / 捐赠端 666)是<b>账号 pid</b>,
/// 不在 unit id 空间,三段必空。捐赠端 7k-v0.5 <c>GameRouter.Enemy.cs:400-402</c>
/// 回退的是 <c>world?.ActiveSpiritUnitId ?? Profile.InitialUnitId</c>,所以它能过。
///
/// false = 回到 2026-09-26 之前的旧行为(回退到 <c>Profile.PlayerPid</c>),用于 A/B 对照。
/// </summary>
public bool AttackerIdFallbackToSpiritUnit { get; init; } = true;
/// <summary>
/// ⛑⛑⛑ <b>血条默认收起,第一次命中才显示</b>(2026-09-26 02:1x)。
///
/// 根因(客户端判据 <c>EnemyHUDCtrl.lua:551-583 RefreshHpVisible</c>):
/// <code>
/// canShowHp = cfg.ShowHPBar // AgentQuoteConfig,客户端压缩包,服务端改不了
/// if (canShowHp and cfg.ShowHPBarUnderAttack) or IsFakePerson then
/// canShowHp = dataSet.showHpOrUnderAttack // 被打了才 true,10 s 后自动回 false
/// end
/// shouldShowHp = not isDead and not isBuffHideNameBar and not realInVisiable
/// and not isGrabByMind
/// and not UnitStateMgr:HasState(unit, UnitStateConfig.HideHp) ← 我们能动的就是这个
/// and not UnitStateMgr:HasState(unit, UnitStateConfig.NearDeath)
/// and gBattleMgr.dataSet.showEnemyHp
/// </code>
/// 也就是说:<b>血条跟 detectionState 一点关系都没有</b>。detectionState 只绑到
/// <c>OnShowDetectionEffect</c>(:85),驱动的是潜行警戒条。
/// ⇒ <see cref="WarningStateMachine"/> 那套改的是警戒条,动不了血条(这就是实测"血条还是一直挂着"的原因)。
/// 唯一 per-unit、且服务端能写的判据是 <c>UnitStateConfig.HideHp = 10266</c>:
/// spawn 时 <c>SyncAddUnitState(10266)</c> 收起,第一次命中 <c>SyncRemoveUnitState(10266)</c> 放出。
/// 先例:<c>GameRouter.HackerSummon.cs:341</c>(机械狗顶着敌人血条出戏,就是这么关掉的)。
///
/// 关(false):回到"一生成血条就在那"。
/// ⛑ 只对 <b>server-spawned</b> 的敌人生效 —— client-spawned 的本地单位是客户端自己建的,
/// 我们第一次知道它存在就已经是挨打的时候了,没机会提前挂 HideHp。
/// ⛑ 另一个天然差异:模板若配了 <c>ShowHPBarUnderAttack</c>,客户端自己会在
/// <c>GameConfig.HPBarShowTime = 10</c> 秒后收起血条;我们这条路径是"打过就一直显示"。
/// </summary>
public bool HideHpBarUntilHit { get; init; } = true;
/// <summary>
/// 敌人<b>警戒状态机</b>(2026-09-26,移植捐赠端 7k-v0.5 <c>GameRouter.EnemyWarning.cs</c>)。
///
/// 开:spawn 时只注册 <c>EnemyDetectState.Idle(1)</c> —— 客户端 HUD(血条 + 挂它上面的
/// 警戒条)绑在 <c>unitDataSet.detectionState</c> 上,Idle 时**不显示**;
/// 玩家走进 <see cref="WarningRadiusMeters"/> 才升到 <c>Fight(4)</c>,血条才出现;
/// 走远又退回 Idle。状态不变不发包(2 s 节流),单轮最多刷 8 个。
///
/// 关(false):回到 2026-09-26 之前的行为 —— engage 时直接发 <c>Fight(4)</c>,
/// 敌人一生成血条就在那(这正是用户说的"我们的血条默认就显示")。
///
/// ⛑ 只对 <b>server-spawned</b> 的敌人(GM 生成那条路)生效。client-spawned 的本地单位
/// 是"第一次命中才进账本",本来就是击中才显示,不需要这套。
/// </summary>
public bool WarningStateMachine { get; init; } = true;
/// <summary>
/// 警戒半径(米)。玩家在这个距离内 → <c>Fight(4)</c>,之外 → <c>Idle(1)</c>。
/// 捐赠端是硬编码 25(<c>EnemyFollowDistanceSquared(enemy, state) > 25f * 25f</c> 就回落 Idle)。
/// </summary>
public float WarningRadiusMeters { get; init; } = 25f;
/// <summary>
/// ⛑⛑⛑ <c>AgentSyncClientInfo.beHitType</c>(<c>UX.Game.PlotAgentBeHitType</c>)—— 受击反馈
/// <b>由谁结算</b>的总开关,只在 spawn 那一刻发出,改完要<b>重新生成单位</b>才生效。
///
/// · <b>2 CanBeHitWithDamage</b>(默认):单位可被命中,但伤害交给<b>服务端</b>结算。
/// 客户端自己不扣血、不致死,全靠服务端回 <c>SyncUnitAttacked</c> 才看得见反应
/// ⇒ 我们的 HP 台账 / 架势条 / 67149 硬直 / 88300 倒地 / 死亡链全部成立。
/// · <b>1 CanBeHitNoDamage</b>(同伴那条路):可被命中,<b>客户端本地</b>直接播受击表现,
/// 不等服务端。手感更"原生",但<b>代价是服务端很可能拿不到命中上报</b>——
/// 2026-09-25 用户实测「留场同伴打不死」就是这么来的:单位压根没进过我们的台账。
/// · <b>0</b>:不可命中,任何受击特效都挂不上(09-15 就栽过一次)。
///
/// ⛑ 改成 1 之前先想清楚要哪个:这条字段是「原生受击」与「能打死」的<b>二选一</b>,
/// 不是强弱档位。改完必查三件事:还有没有 <c>[ENEMY] hit</c> 日志、还能不能打死、
/// 是不是出现了"打两下抖两次"的双重反馈。
///
/// ★★ <b>2026-09-25 实测(推翻了上面的二选一假设,别再按它做决定)</b>:
/// 敌人改成 <b>1</b> 之后 —— <b>依然能打死</b>、<c>[ENEMY]</c> 日志照旧、
/// 受击表现<b>和 2 没有任何可察觉的区别</b>。
/// ⇒ 对本 build 的敌人来说这个字段<b>不是受击表现的来源</b>,两边等价。
/// ⇒ 顺带否掉一个流行解释:「留场同伴手感更好」<b>不是</b>因为同伴 beHitType=1,
/// 差别在单位本身(英雄模型的受击动画资源齐全),不在这个字段。
/// 值保持 2(与服务端的伤害/架势/死亡链语义一致),不要再为"手感"去动它。
/// </summary>
public byte BeHitType { get; init; } = 2;
/// <summary>
/// 给服务端生成的敌人手上塞个道具并让它正在用 —— 即 r37 那条
/// <c>SyncAgentBelongings</c> + <c>SyncAgentUseBelongingChanged</c> 的组合。
///
/// 目的是让敌人不再是攥着空气的空港口模型:usage 6 =「右手玩手机」在客户端会真的把手
/// 机模型挂到骨架右手上、并叠一层上半身姿态。默认值(80003481 手机 / usage 6)与
/// <c>gameplay.hackNpc</c> 给路人发的是同一套,只改敌人的时候不会动到城区 NPC。
///
/// ⛑ 服务端的活儿做完这一步就结束了 —— 道具模型怎么挂到骨架上、挂件 resource 怎么流送,
/// 全是客户端自己的事;服务端既不能换它的姿势也不能改它的挂载点,只能决定"给不给"。
///
/// ⛑⛑ <b>2026-09-25 实测:默认关。</b>
/// 开着的样子 = 敌人<b>手里挂着手机模型,但不会玩</b> —— 待机没有 POI 姿态、
/// 上半身不动,只是多了一个穿模风险的挂件,视觉收益为零。
/// 根因:usage 只在「生活日程 / POI」那条管线里才会真的把姿态播出来
/// (见 <c>SyncAgentPlayPOIAction</c> 与 <c>AgentDataSetsActivityCfgId</c>);
/// 敌人走的是战斗行为树,不与那条管线接通 ⇒ 光给 belongings 只等于给了个静态模型。
/// 想让敌人真的"玩",要先把敌人的行为树挂上 POI/日程,不是改这个开关。
/// </summary>
public bool GiveProp { get; init; } = false;
/// <summary>手里的道具 id(<c>SceneitemConfig</c> / belongings 域)。默认 80003481 = 手机。</summary>
public uint PropSceneItemId { get; init; } = 80003481;
/// <summary>
/// <c>BelongingBelongingUsageConfig</c> 的 usage id。默认 <b>6</b> = 右手玩手机。
/// 换成 1 = 右手正常打电话;其余多为拍照 / 喝咖啡之类,挑的时候注意 Boss 会不会因此不抬手攻击。
/// </summary>
public uint PropUsageId { get; init; } = 6;
}
public sealed class WeaponSettings
{
public uint TemplateId { get; init; }
public ulong InstanceId { get; init; }
/// <summary>When true, every account-armory weapon is pushed (SyncArmoryAddWeapon) at login
/// and after each scene load, so the whole catalog is unlocked in the weapon wheel/backpack.</summary>
public bool UnlockAll { get; init; }
}
public sealed class ResourceSettings
{
public uint Id { get; init; }
public float Max { get; init; }
}
public sealed class SkillSettings
{
public uint Common { get; init; }
public uint PressCommon { get; init; }
public uint HeavyCommon { get; init; }
public uint Dodge { get; init; }
public uint DodgeAttack { get; init; }
public uint GrappleAttack { get; init; }
public uint Active { get; init; }
public uint Unique { get; init; }
internal IEnumerable<uint> All()
{
yield return Common;
yield return PressCommon;
yield return HeavyCommon;
yield return Dodge;
yield return DodgeAttack;
yield return GrappleAttack;
yield return Active;
yield return Unique;
}
internal uint ByName(string name) => name.ToLowerInvariant() switch
{
"common" => Common,
"presscommon" => PressCommon,
"heavycommon" => HeavyCommon,
"dodge" => Dodge,
"dodgeattack" => DodgeAttack,
"grappleattack" => GrappleAttack,
"active" => Active,
"unique" => Unique,
_ => throw new InvalidDataException($"Unknown gameplay.combat.charges skill name '{name}'.")
};
}
public sealed class SkillChargeSettings
{
public string Skill { get; init; } = string.Empty;
public uint Current { get; init; }
public uint Max { get; init; }
public float Period { get; init; }
}
public sealed class WebTraversalSettings
{
public uint MaleProtagonistId { get; init; }
public uint FemaleProtagonistId { get; init; }
public uint PersistentGrappleBuffId { get; init; }
public int GrappleRearmDelayMs { get; init; }
public uint[] SharedBuffIds { get; init; } = [];
public uint[] MaleExtraBuffIds { get; init; } = [];
public uint[] FemaleExtraBuffIds { get; init; } = [];
public uint[] ForbidStateIds { get; init; } = [];
/// <summary>Capability buffs granted to EVERY controllable spirit, protagonists included.
///
/// Ported from the donor build (2026-09-23). Before this the non-protagonist branch of
/// <c>WebTraversal.CapabilityBuffIds</c> returned <c>Array.Empty</c>, so switching to Dila /
/// Seymour / ... dropped the whole exploration kit and left only the one buff
/// <c>CharacterCapabilities</c> knows for that template.
///
/// ⛑ Keep this list to buffs whose BuffConfig.IsHidden is true: a non-hidden entry renders a
/// permanent HUD icon (the same effect <see cref="PushResidentBuildBuffs"/> documents).</summary>
public uint[] CommonCapabilityBuffIds { get; init; } = [];
/// <summary>Extra capability buffs appended to <see cref="CommonCapabilityBuffIds"/> for one
/// spirit only, keyed by template id.
///
/// Deliberately empty by default: our <c>CharacterCapabilities</c> already resolves per-spirit
/// unlocks from UrbanBadgeConfig (data-driven, and it corrects two ids the donor build
/// hard-codes wrong). Fill this only to override that for a single template.</summary>
public Dictionary<uint, uint[]> SpiritCapabilityBuffIds { get; init; } = [];
/// <summary>Publish the resident build/web buffs (currently only the grapple-dive damage
/// buff) with the login capability set.
///
/// That buff is the ONLY entry in the whole capability set whose BuffConfig.IsHidden is
/// false, and the client renders a HUD icon for exactly that predicate — so leaving this
/// on (the historical behaviour) pins a permanent buff icon on the HUD. Turning it off
/// removes the icon but also removes the +500% grapple-dive damage it carries.</summary>
public bool PushResidentBuildBuffs { get; init; } = true;
/// <summary>给主角下发原生飞索能力门 <c>SyncPaokuLimitChange</c>(68974941)。
///
/// 飞索 buff(<see cref="PersistentGrappleBuffId"/>)只解决「能不能放」,不解决
/// 「C 键与白圈在不在」:后者由 <c>AbilityLimitConfig.HasFeisuoAbility</c>(3013) 门控,
/// 客户端 <c>PlayerParkourLimitModule</c> 收到后才会挂 <c>State_HasFeisuoAbility</c>(2318)。
/// ⛑ 关掉这个开关即回到改动前的状态(只有 buff、C 键无反应)—— 这就是回滚方式。</summary>
public bool PushFeisuoAbilityGate { get; init; } = true;
/// <summary><c>AbilityLimitConfig</c> 的能力 id —— 3013 = <c>HasFeisuoAbility</c>
/// (<c>dump.cs:1026235</c>,用我们自己的客户端 dump 验证过,不依赖捐赠端)。
/// 填 0 = 用内置默认 3013。</summary>
public uint FeisuoAbilityLimitId { get; init; }
/// <summary>回应蹲伏暗杀裁决:收到 <c>ReportCanAssassinateEnemies</c>(67161256) 后
/// 回 <c>SyncEnemyCanAssassinate</c>(68218494)。
///
/// 客户端自己会算「谁在我背后」(<c>StealthManager</c> 每 0.4s 一次),但<b>授权在服务端</b>:
/// 不发这一条,<c>canAssassinate</c> 恒 false,<c>EnemyInteractCore</c> 不挂交互、
/// HUD 不出暗杀提示也不亮按钮。
/// ⛑ 关掉即回滚到改动前(HUD 无暗杀提示)。</summary>
public bool AnswerCrouchAssassination { get; init; } = true;
/// <summary>下发暗杀能力门 <c>AbilityLimitConfig.UnlockAssassin</c>(5051)
/// → <c>State_UnlockAssassin</c>(1333),随 <c>SyncPaokuLimitChange</c> 一起发。
///
/// ⛑⛑ 这是暗杀 HUD 的<b>前置门</b>,比裁决回包更靠前:不发它,客户端
/// <c>StealthManager.CrouchAssassinationUpdate</c> 根本不跑,
/// <c>ReportCanAssassinateEnemies</c> 一次都不会发(实机:19 次蹲伏 / 0 次上报)。
/// 关掉即回滚到「蹲得了但永远没有暗杀提示」。</summary>
public bool PushAssassinationAbilityGate { get; init; } = true;
/// <summary><c>AbilityLimitConfig.UnlockAssassin</c> 的 id —— 5051(<c>dump.cs:1026213</c>)。
/// 填 0 = 用内置默认 5051。</summary>
public uint AssassinationAbilityLimitId { get; init; }
/// <summary>给璎珑(Yinglong / 15021040)下发专属跑酷能力门 <c>SyncPaokuLimitChange</c>(68974941)。
///
/// 门 = <c>AbilityLimitConfig.KillingPrototype</c>(3040),Desc「开启樱珑八步赶蝉」,
/// 挂的 tag 是 <c>State_CanKilling</c>(1306)。这就是「虐杀原形跑酷」:地面疾跑/跳跃/边缘
/// (574/575/577)与爬墙(2502/2503)共用同一套动作态(ParkourState 10062「璎珑跑酷」)。
/// ⛑ 关掉即回滚到改动前 —— 只剩基础爬墙大跳与空中冲刺。</summary>
public bool PushKillingAbilityGate { get; init; } = true;
/// <summary><c>AbilityLimitConfig.KillingPrototype</c> 的 id —— 3040。填 0 = 用内置默认 3040。</summary>
public uint KillingAbilityLimitId { get; init; }
/// <summary>唯一拿到跑酷门的角色模板 id —— 15021040 璎珑。填 0 = 用内置默认 15021040。
///
/// ⛑ 只给这一个角色发:其余角色的 ABP 未必带 <c>MovementModeType.Yinglong</c>(10) 的动画数据,
/// 挂了 tag 也可能没表现。</summary>
public uint KillingSpiritId { get; init; }
/// <summary>角色 → 专属能力门列表(<c>AbilityLimitConfig.Id</c>),键是角色模板 id。
///
/// 上面三个 killing* 单值开关的通用化版本:一个角色可以同时带多张门
/// (例:<c>"15020990": [5040, 5052]</c> = 连跳 + 大跳)。
/// <b>优先级:字典命中就用字典,没命中才回落 killing*。</b>
/// 留空字典 = 完全保持旧行为 ⇒ 可以直接清空来回滚这一层。</summary>
public Dictionary<uint, uint[]> SpiritAbilityGates { get; init; } = [];
/// <summary>★ <b>跑酷状态 → 临时能力门</b>(键 = <c>ParkourStateConfig.Id</c>,值 = <c>AbilityLimitConfig.Id</c> 列表)。
///
/// 与 <see cref="SpiritAbilityGates"/>(常驻门)的区别:<b>严格跟状态</b> ——
/// 客户端上报 <c>OnParkourStateChange</c>(67897801) 命中某个键 ⇒ <c>SyncPaokuLimitChange</c>
/// <c>isAdd=true</c> 下发;一旦离开这个 state ⇒ 立刻 <c>isAdd=false</c> 撤回。
/// 只在 0→1 / 1→0 的边沿发一次(<c>WorldEntryState.ActiveParkourGateStates</c> 记账),
/// 不会跟着 1.2 次/秒的上报刷屏。
///
/// 当前用途:ParkourState <b>10081「八步赶蝉jump」</b>(= 璎珑虐杀原形跑酷,见 TOPIC §11.10
/// 实机证据:全量 604 份日志里只在下发 3040 之后的两个会话出现过)⇒ 大跳 <c>5052</c>。
/// 例:<c>"10081": [5052]</c>。留空字典 = 整层关闭 ⇒ 可直接清空回滚。</summary>
public Dictionary<uint, uint[]> ParkourStateGates { get; init; } = [];
/// <summary><c>parkourStateGates</c> 的撤销策略:
/// <list type="bullet">
/// <item><c>"onLeave"</c> —— 严格跟状态:离开该 ParkourState 立刻 <c>isAdd=false</c> 撤回。</item>
/// <item><c>"session"</c>(<b>默认</b>)—— 首次触发后本次会话一直保留(切角色换单位才重置)。</item>
/// </list>
/// ⛑ 为什么默认不是 onLeave:2026-10-02 实机证明 <b>10081「八步赶蝉jump」是瞬时态</b>(服务端日志
/// add→remove 间隔只有 1 秒,甚至同一秒内来回),严格跟状态会让门几乎立刻被撤 ⇒
/// 表现是「第一次触发后大跳正常,第二次就没了」。session 模式下只 add 一次、永不撤,
/// 语义仍是「跑酷触发之后才可以用」,且不会抖动。</summary>
public string ParkourStateGateRelease { get; init; } = "session";
/// <summary>★ 主动播种 <c>SyncEnemyCanAssassinate</c>(68218494) —— 不等客户端上报。
///
/// 硬规则 192:客户端的候选筛选是<b>自举死锁</b>。<c>FeiSuoCrouchManager.CheckCanCrouchAss</c>
/// → <c>PreCheckCanAss(0x131F6700)</c> 要求目标的 <c>BattleDataModule.canAssassinate</c>
/// (私有字段偏移 <c>0xB1</c>)== true,而该字段全 .text 唯一写入点是
/// <c>SyncEnemyCanAssassinate</c> 的接收函数(<c>0x127D102F mov byte [rax+0xB1], bl</c>,
/// <c>set_CanAssassinate</c> 零调用者)。可这条 S2C 名义上是对
/// <c>ReportCanAssassinateEnemies</c>(67161256) 的回应,而上报又必须先过 <c>canAssassinate</c>
/// ⇒ 没有任何敌人天生带这个标记,循环永远启动不了(实机:19 次蹲伏 / 0 次上报)。
///
/// 官方靠 Spoon 剧本动作 <c>L18.Spoon.EnemySetCanAssassinate</c>(<c>dump.cs:3194822</c>) 播种,
/// 我们没有 Spoon 系统 ⇒ 用这条主动下发替代。
/// ⛑ 关掉即回到「等上报才回包」的死锁态。</summary>
public bool PushAssassinationSeed { get; init; } = true;
/// <summary>播种半径(米):只有玩家这个距离内的敌人才会被授予 <c>canAssassinate</c>。
/// 客户端自己的 <c>CheckCanCrouchAss</c> 还有更严的距离/视野中心/遮挡门,这里只做粗筛。</summary>
public float AssassinationSeedRadiusMeters { get; init; } = 12f;
/// <summary>播种扫描间隔(毫秒)。挂在敌人 AI worker 的 100 ms tick 上,内部自己节流。
/// 每个敌人<b>每次蹲伏只播种一次</b>(离开半径后重新进入才会再播种)⇒ 调小也不会刷包。</summary>
public int AssassinationSeedIntervalMs { get; init; } = 500;
}
public sealed class UiSettings
{
/// <summary>White text rendered next to UID. This is the user-facing watermark setting.</summary>
public UidLabelSettings UidLabel { get; init; } = new();
public bool RemoveStockConfidentialLabel { get; init; }
public CutscenePanelSettings CutscenePanel { get; init; } = new();
}
public sealed class CutscenePanelSettings
{
public bool Enabled { get; init; } = true;
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 17666;
}
public sealed class UidLabelSettings
{
public bool Enabled { get; init; }
public string Text { get; init; } = string.Empty;
}
public sealed class LoggingSettings
{
/// <summary>Project-relative directory for console and packet logs.</summary>
public string Directory { get; init; } = string.Empty;
public ConsoleLoggingSettings Console { get; init; } = new();
public PacketLoggingSettings Packets { get; init; } = new();
}
public sealed class ConsoleLoggingSettings
{
public bool Enabled { get; init; }
}
public sealed class PacketLoggingSettings
{
public bool Enabled { get; init; }
public bool IncludeHex { get; init; }
public bool IncludeDecoded { get; init; }
/// <summary>Maximum packet body bytes written as hex; 0 means unlimited.</summary>
public int MaxBodyBytes { get; init; }
}
public sealed class PathSettings
{
/// <summary>Directory containing client JSON configs such as FightSpiritConfig.json.</summary>
public string ClientConfigs { get; init; } = string.Empty;
public string ZoneGraphLanes { get; init; } = string.Empty;
/// <summary>
/// ★★ 路口 / 红绿灯相位表(2026-10-02 从捐赠端 PrivateServerPor 搬来)。
/// 格式(CSV,首行表头):
/// <c>zone_index,x,y,z,period_count,current_period_index,next_period_index,rail_period_index,
/// open_lanes,period_open_lanes,period_light_flags</c>
/// · <c>period_open_lanes</c> = <c>0:|1:403;404;405</c>(<c>|</c> 分相位、<c>;</c> 分车道)
/// · <c>period_light_flags</c> = <c>0:2,2|1:4,4</c> —— 客户端 blob 里每相位每灯片组的状态位
/// (<c>MassTrafficLightStateFlags</c>:PedestrianGo=2 / VehicleGoStraight=4 / Left=8 / Right=16);
/// **本仓不解析它**(灯色由客户端按 period index 自己算),留着只为对账。
/// 数据 171 行,lane id 与 <c>vehicle_lanes.csv</c> 同源(已验证 29/29 命中)。
/// </summary>
public string Intersections { get; init; } = "Ananta.Server/Ananta.App/data/zonegraph_intersections.csv";
/// <summary>
/// ★★ 官方 ZoneGraph「行人过街等待区 + 槽位」表(2026-10-02 从捐赠端 PrivateServerPor 搬来)。
/// CSV 列:<c>wait_area_index,slot_index,x,y,z,forward_x,forward_z,urban_diversity_config_id</c>,
/// 数据 4070 行。真身是客户端 <c>MassAI.MassCrowd.CrowdWaitAreaData</c>(含 CrossLaneHandle
/// 人行横道)—— **纯客户端原生 ECS,服务端发不进去**;本表只用来让服务端自建 NPC
/// 站在官方人群本来会站的地方,并按「同一等待区最多 N 个槽位」的纪律散布(见
/// <c>PedSpawnTable4229938.NearSpread</c>)。
/// ⛑ 缺失不报错,退化为空表(<c>Available=false</c>)。
/// </summary>
public string PedSpawns { get; init; } = "Ananta.Server/Ananta.App/data/zonegraph_ped_spawns.csv";
/// <summary>Directory with extracted world data (Gadget.json, SceneItem.json,
/// Destructible.json, gadget_cells.json, gadget_placements.json).</summary>
public string WorldData { get; init; } = string.Empty;
/// <summary>Disposable generated fastpatch staging directory.</summary>
public string RuntimeFastpatch { get; init; } = ".runtime/fastpatch";
}
internal static class PrivateServerConfigStore
{
private static PrivateServerConfig? _current;
private static string? _configPath;
internal static PrivateServerConfig Current => _current
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before protocol/gameplay code.");
internal static string ConfigPath => _configPath
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution.");
private static string? _projectRoot;
/// <summary>Subscribers that want to hear about hot reloads (console/log).</summary>
internal static Action<string>? OnReload;
/// <summary>
/// Re-reads the config file and swaps <see cref="Current"/> in place — no restart needed.
///
/// Why this exists: tuning things like the metro seat counts is an experiment loop (change a
/// number, look at the game, change it again), and a full restart per attempt turns every knob
/// into a five-minute edit. Behaviour that reads config through <c>PrivateServerConfigStore.Current</c>
/// on every use (that is how <c>MetroGadgetSettings</c> and friends are written) picks the new
/// values up immediately; anything that cached a value at startup keeps its old one, which is
/// the intended trade-off — do not hot-swap what you cannot re-derive.
/// </summary>
internal static bool Reload(out string message)
{
try
{
var (config, path) = PrivateServerConfigLoader.Load();
_current = config;
_configPath = Path.GetFullPath(path);
_projectRoot = FindProjectRoot(_configPath);
message = $"已热重载 {_configPath}";
OnReload?.Invoke(message);
Console.WriteLine($"[CONFIG] {message}");
return true;
}
catch (Exception ex)
{
message = $"热重载失败:{ex.Message}";
Console.WriteLine($"[CONFIG] {message}");
return false;
}
}
/// <summary>
/// Watches private-server.json and reloads shortly after it is written. Editors do several
/// writes per save, hence the debounce.
/// </summary>
internal static void EnableAutoReload()
{
if (_watchTimer is not null)
return;
try
{
var path = ConfigPath;
_watchTimer = new Timer(_ => DebouncedReloadTick(), null, Timeout.Infinite, Timeout.Infinite);
_watcher = new FileSystemWatcher(Path.GetDirectoryName(path)!, Path.GetFileName(path))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
};
_watcher.Changed += (_, _) => DebouncedReload();
_watcher.Created += (_, _) => DebouncedReload();
Console.WriteLine($"[CONFIG] 已开启配置文件热重载:{path}");
}
catch (Exception ex)
{
Console.WriteLine($"[CONFIG] 无法开启热重载(不影响正常使用):{ex.Message}");
}
}
private static FileSystemWatcher? _watcher;
private static Timer? _watchTimer;
private static void DebouncedReload()
{
if (_watchTimer is null)
return;
_watchTimer.Change(TimeSpan.FromMilliseconds(400), Timeout.InfiniteTimeSpan);
}
private static void DebouncedReloadTick()
{
Reload(out var message);
Console.WriteLine($"[CONFIG] 文件变更 -> {message}");
}
internal static string ProjectRoot => _projectRoot
?? throw new InvalidOperationException("PrivateServerConfigStore.Initialize must run before path resolution.");
internal static void Initialize(PrivateServerConfig config, string configPath)
{
_current = config;
_configPath = Path.GetFullPath(configPath);
_projectRoot = FindProjectRoot(_configPath);
}
private static string FindProjectRoot(string configPath)
{
var explicitRoot = Environment.GetEnvironmentVariable("DRMK_ROOT");
if (!string.IsNullOrWhiteSpace(explicitRoot))
return Path.GetFullPath(explicitRoot);
static string? SearchUp(string? start)
{
if (string.IsNullOrWhiteSpace(start)) return null;
var first = new DirectoryInfo(Path.GetFullPath(start));
// Prefer the solution root. A copied config may also exist under bin/Debug, but that
// directory is not the project root and must not become the base for ClientData/.runtime.
for (var current = first; current is not null; current = current.Parent)
if (File.Exists(Path.Combine(current.FullName, "AnantaPS.sln")))
return current.FullName;
// Standalone deployments may not contain the solution file. In that case a config
// directory is the best available root marker.
for (var current = first; current is not null; current = current.Parent)
if (File.Exists(Path.Combine(current.FullName, "config", "private-server.json")))
return current.FullName;
return null;
}
var fromConfig = SearchUp(Path.GetDirectoryName(configPath));
if (fromConfig is not null) return fromConfig;
var fromBase = SearchUp(AppContext.BaseDirectory);
if (fromBase is not null) return fromBase;
var fromCurrent = SearchUp(Directory.GetCurrentDirectory());
if (fromCurrent is not null) return fromCurrent;
throw new DirectoryNotFoundException(
"Cannot locate the DRMK project root. Set DRMK_ROOT or launch from the project directory.");
}
internal static string ResolveProjectPath(string configuredPath)
=> Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(Path.Combine(ProjectRoot, configuredPath));
}
internal static class PrivateServerConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase) },
};
internal static (PrivateServerConfig Config, string Path) Load()
{
var explicitPath = Environment.GetEnvironmentVariable("DRMK_CONFIG");
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitPath)) candidates.Add(explicitPath);
candidates.Add(Path.Combine(Directory.GetCurrentDirectory(), "config", "private-server.json"));
candidates.Add(Path.Combine(AppContext.BaseDirectory, "config", "private-server.json"));
var path = candidates.Select(Path.GetFullPath).FirstOrDefault(File.Exists)
?? throw new FileNotFoundException("Cannot find config/private-server.json. Set DRMK_CONFIG to an explicit path if needed.");
var json = File.ReadAllText(path);
var config = JsonSerializer.Deserialize<PrivateServerConfig>(json, JsonOptions)
?? throw new InvalidDataException($"Configuration file is empty: {path}");
config.Validate();
return (config, path);
}
}
// ==== R142 自 v1.9 移植(v2.0 上游没有这两个设置类)====
public sealed class PartyCampSettings
{
/// <summary>False = never place anything. Rollback lever, no rebuild needed.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// 锚点到玩家的额外半径余量(米)。默认 0 = 只用 PartyCamp.json 里每个场地自己的 radius。
/// </summary>
public float RadiusBonus { get; init; } = 0f;
/// <summary>
/// Which S2C channel places the props:
/// 0 = SyncGadgetAdd(68843189) one entity per push ← 已验证
/// 1 = SyncGadgetAOIAddAndRemove(68169646)
/// 2 = both of the above
/// 3 = SyncGadgetAddList(68925813) one push for the whole camp
/// 0 is the one proven to build the entity.
/// </summary>
public int Channel { get; init; } = 0;
/// <summary>
/// PackedGadgetInfo.iScale sent with every prop. Must stay 0: 100 produces an entity with the
/// right name and a working interact point but transform scale (0,0,1) — no prefab, and the
/// interact radius collapses. Same trap as the gym equipment and the prop restock.
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>Log the placements but push nothing. Dry-run lever for a fresh capture.</summary>
public bool LogOnly { get; init; } = false;
/// <summary>
/// ★ 2026-09-30:摆放时**跳过**这些 pathId(用来去掉不想出现的场地家具)。
/// 例:舞会那道「离开舞会的门」<c>Gadgets_Party_DanceDoor</c>(514708797) 用户明确不要 ⇒
/// <c>"skipPathIds": [514708797]</c>。默认空 = 一件不少,与改动前完全一致。
/// ⚠ 只作用于 PartyCamp 这条链,不碰市民 / 机关网格 / PropRestock 等其它刷法。
/// </summary>
public uint[] SkipPathIds { get; init; } = [];
}
public sealed class MiniGameStallSettings
{
/// <summary>False = 一件都不摆。回滚开关,不需重编译。</summary>
public bool Enabled { get; init; } = true;
/// <summary>锚点到玩家的额外半径余量(米)。默认 0 = 只用 MiniGameStalls.json 里每个点位自己的 radius。</summary>
public float RadiusBonus { get; init; } = 0f;
/// <summary>
/// Which S2C channel places the props:
/// 0 = SyncGadgetAdd(68843189) one entity per push ← 已验证能建实体
/// 1 = SyncGadgetAOIAddAndRemove(68169646)
/// 2 = both of the above
/// 3 = SyncGadgetAddList(68925813) one push for the whole village
/// </summary>
public int Channel { get; init; } = 0;
/// <summary>
/// 摊位实体 <c>PackedGadgetInfo.iScale</c>。必须 **0**(单位值 = scale 1.0)。
/// ⛔ 100 ⇒ transform scale (0,0,1) ⇒ 零尺寸空壳:实体在、模型不在(看不见摊位)。
/// ⛔ 与下面 <see cref="BalloonIScale"/> 是**同名不同尺度的两个字段**(铁律 39):
/// 这个走 Gadget 通道、基准 1;那个走 Destructible 通道、基准 1000。
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>
/// ★ 刮刮乐「走近自动叫面板」(2026-10-03 移植自捐赠分支 DRMK-PS_09_HyNB-龙栖村小游戏 R156)。
///
/// 客户端**从不主动发** `AskStartScratchGame`(2349 个 Lua 里 0 命中、9 份 packets 日志里 0 命中),
/// 面板的唯一入口是服务端推 `SyncStartScratchGameZone`(68649573)
/// (`GameSceneToClientImpl.lua:1131` → `gScratchGameManager:Show(gadgetUid, subType, result)`,
/// `result == nil` 就直接 return ⇒ 不出选项)。
/// ⇒ 服务端不主动推,玩家永远只看到「通用交互提示」而没有选项。
/// ⚠️ 默认 false:面板没有退出按钮,自动弹会把玩家困在面板里(捐赠端 R158 定案)。
/// 想恢复「走近自动弹」把配置 `scratchAutoOpen` 改成 true 即可,热重载生效。
/// </summary>
public bool ScratchAutoOpen { get; init; } = false;
/// <summary>触发半径(米):玩家从圈外走进来推一条。</summary>
public float ScratchOfferRadius { get; init; } = 4f;
/// <summary>迟滞半径(米),须 &gt; <see cref="ScratchOfferRadius"/>,否则边界上会连推。</summary>
public float ScratchRearmRadius { get; init; } = 9f;
/// <summary>
/// 气球 <c>DestructibleInfo.IScale</c>。
///
/// ★★★★★ R109 —— **原值 1000 作废,改 100。** 依据:
/// ① 本游戏里**唯一被用户实测确认看得见**的同族(保龄球,同一个 Destructible 通道、
/// 同一份 DestructibleInfo 结构)在 <c>GameRouter.GameGround.cs:712/735</c> 用的是 **100**;
/// 气球用 1000、从来没人看见过 —— 这是目前唯一一个「能跑 vs 不能跑」的参数差。
/// ② 注入脚本 <c>gym_tools/balloon_fix.js:3518-3526</c> 自己记着一条实测教训:
/// placeInfo 里对应这一格的量不是「缩放倍率」而是**尺寸索引/枚举**,
/// **写 1000 是越界枚举 ⇒ 引擎把 localScale 算成退化值 ⇒
/// 症状恰是「hold 拿到了、坐标对、有碰撞有音效、就是看不见」** —— 与用户现象逐字吻合。
/// ③ 「必须 1000」的原始出处是对照包 Lyco,但**该包的作者在代码里自注
/// `<c>ture 无 spawn 能力</c>`**(GameRouter.Balloon.cs:41)⇒ 它根本没真正 spawn 过气球,
/// 1000 从未被验证。用户也明说「Lyco 没有说打气球可以玩」。
/// ⇒ 旧注释「1000 = scale 1.0(与 WorldContent 现行约定一致)」与「真机跑通」两句均**作废**。
/// ⛔ 与 Gadget 通道的 <c>PackedGadgetInfo.iScale</c>(必须 0)仍是同名不同尺度,别混。
/// </summary>
public int BalloonIScale { get; init; } = BalloonWire.DefaultIScale;
/// <summary>
/// ★★★★★ R109 —— **诊断用扫描**:非空时,**按列**给这一局的 12 个气球上不同的 IScale,
/// 于是**一次重启就能同时试 4 个候选值**,用户只要报告「哪一列看得见」即可定值。
/// · 下标 = 列号(0..3,从玩家左手边数起),元素 = 该列用的 IScale。
/// · 空(默认)= 关闭扫描,全部用 <see cref="BalloonIScale"/>(正常玩法必须关)。
/// 为什么值得:每次改 IScale 都要重启服务端,4 个候选值 = 4 次重启;扫描把它压成 1 次。
/// 读数:气球墙是 4 列 × 3 行,列序 = 生成序(<c>ordinal/rows</c>)。
/// </summary>
public int[] BalloonIScaleSweep { get; init; } = System.Array.Empty<int>();
/// <summary>
/// ★★★★★ R122 —— **开新一局前,把上一局没打掉的气球从客户端显式删掉。**(默认 true)
///
/// <para>病因(2026-09-28 07:56 真机实测,用户原话「如果我在第一轮没有把气球打完,
/// 那么那些气球并不会被销毁,所以在第二轮的时候还是会出问题」):</para>
/// <list type="number">
/// <item>R1(07:56:08)spawn 16 颗,玩家只打爆 14 颗,剩 2 颗(id 段 <c>…001D/001E</c>);</item>
/// <item>R1 局末 07:56:48 <c>Timeout(40s)</c> → 服务端 <c>UnprotectDestructibles</c> 撤保护,
/// 07:56:50 的心跳推格 <c>bytes</c> 从 3514 掉回 1642(= 整格重置,不再带 AddInfos);</item>
/// <item><b>但那 2 颗球在客户端并没有消失</b>:R2(07:56:59 起)里 07:57:15 / 07:57:22
/// 客户端仍然为 <c>…001D</c> / <c>…001E</c> 发来破坏报文 ⇒ 服务端只能打
/// <c>[BALLOON] ignore break</c>(账本已换新段,不认识它们)。</item>
/// </list>
/// <para>⇒ 残留球与**新一局的球落在同一个球位**上(同一面 16 个 <c>balloonWallSpots</c>),
/// 子弹先命中残留球的碰撞体 ⇒ 报的是旧 id ⇒ 服务端忽略 ⇒
/// <b>新球打不掉、也不加分</b>,正是用户说的「第二轮还是会出问题」。</para>
///
/// <para>修法(两条正交,本开关同时控制):</para>
/// <list type="number">
/// <item>spawn 前把 <c>SceneItemGrid.Protected</c> 里所有 <c>0xBA11</c> 段的**历史** id 摘掉
/// (否则 5 s 心跳会一直把它们塞回 AddInfos 保活);</item>
/// <item>本次 AOI 报文的 <c>aoiInfo.RemoveIds</c> 填这批旧 id —— 这是
/// <c>DestructibleGridAOIIncrease.removeIds</c> 字段的本职(与 <c>addUniqueIds</c> 成对;
/// 见 <c>GameRouter.GameGround.cs:655</c> 的通道说明),客户端据此删除对应 hold。</item>
/// </list>
/// <para>关掉它 = 回到 R121 行为(残留跨局存活),用于 A/B 对照或排障。</para>
/// <para>判活日志:<c>[BALLOON] purge stale n=&lt;k&gt; ids=[…]</c>(<c>k=0</c> = 上一局打干净了,正常)。</para>
/// </summary>
public bool BalloonRemoveStaleIds { get; init; } = true;
/// <summary>
/// ★★★★★ R110 —— 气球墙的**锚点来源**。取值 <c>"stall"</c> / <c>"player"</c>。
///
/// <para><c>"player"</c>(R89~R109 的旧行为)= 玩家上报位姿 + 沿其朝向前方 6 m。</para>
/// <para><c>"stall"</c>(R110 起默认)= **官方摊位机关自己的位姿**(取自
/// <c>ClientData/4229938/Configs/MiniGameStalls.json</c>,逐条解自关卡块)。</para>
///
/// <para>★ 为什么改(R110 三维几何实证,龙栖村气球摊):</para>
/// <list type="number">
/// <item>官方摊位 <c>Gadget_DaQiQiu</c> 在 <c>(-4750.50,168.60,-2827.60)</c> yaw=315.0;</item>
/// <item>玩家上报位姿 <c>(-4749.00,168.50,-2830.30)</c> yaw=313.3 ⇒ 摊位在玩家正前方 2.97 m;</item>
/// <item>旧算法「玩家前方 6 m」⇒ 气球墙**中心**落在 <c>(-4753.36,-2826.18)</c>,
/// 比摊位机关**再往前 3.19 m** ⇒ 整面墙落在摊位/背景板**之后**,
/// 且只从地面 1.2 m 起 ⇒ 被摊位结构完全遮住 = 「什么都看不见」。</item>
/// </list>
/// <para>⇒ 服务端载荷 / 客户端注入 / 美术资源 / hold / transform 全部正确、物件确实存在,
/// 只是**几何上被挡住**。这与用户长期现象「有碰撞有音效坐标对,就是看不见」一致。</para>
///
/// ⛔ 不用客户端 <c>GadgetConfig.json</c> 的 id 空间;本表 pathId 是服务端 catalog 那一套。
/// </summary>
public string BalloonWallAnchor { get; init; } = "stall";
/// <summary>
/// 锚点定姿后沿**该锚点**的朝向再前移多少米(默认 0 = 就摆在摊位机关本身处)。
/// 仅在 <see cref="BalloonWallAnchorSweep"/> 为空时生效。
/// </summary>
public float BalloonWallForward { get; init; } = 0f;
/// <summary>
/// ★★★★★ R110 —— **锚点按列扫描**(诊断用)。每个元素形如 <c>"stall:-1.5"</c> / <c>"player:6"</c>,
/// 下标 = 列号(0..3,从玩家左手边起),冒号后 = 沿该锚点朝向的前移米数。
/// 空(默认)= 全部用 <see cref="BalloonWallAnchor"/> + <see cref="BalloonWallForward"/>。
///
/// <para>为什么值得:改锚点要重启服务端。按列给不同锚点 ⇒ **一次重启同时试多个摆位**,
/// 用户只要报告「第几列看得见」。含一个 <c>player:6</c> 列即为旧行为对照组。</para>
/// </summary>
public string[] BalloonWallAnchorSweep { get; init; } = System.Array.Empty<string>();
/// <summary>在摊位表里按名字子串找气球摊(默认 <c>DaQiQiu</c>,也覆盖 <c>Gadget_DaQiQiu_Position</c>)。</summary>
public string BalloonWallStallMatch { get; init; } = "DaQiQiu";
/// <summary>锚点查找的告警半径(m)。超过它仍会用最近的命中点,只在日志里标出来。</summary>
public float BalloonWallStallSearchRadius { get; init; } = 12f;
// ===================== R111:摆位参数全部配置驱动 =====================
//
// 为什么这么改:`PrivateServerConfigStore` 在 `Ananta.App/Program.cs:11` 调了
// `EnableAutoReload()`(FileSystemWatcher + 400 ms 防抖)⇒ **本文件是热重载的**,
// 而 `SpawnBalloonsAsync` 每次进区都重新读 `PrivateServerConfigStore.Current`
// ⇒ 改这些数 ⇒ **退出气球区再进一次即可生效,不用重启服务端**。
// ⇒ 摆位调参从「一次重启」压成「一次进出区」。
//
// 病因(R110/R111 真机):
// ① 旧 `BalloonWallAnchorSweep` 让**每列 forward 不同**(-1.5/0/+1.5/+3.0)
// ⇒ 那不是一面墙,是一条**斜穿摊位**的斜排 ⇒ 后 3 列插进摊位内部。
// ② 用户报「气球墙和摊位架子卡在一起了」+「打不爆」= 同一病因:
// 球在摊位结构里 ⇒ 看着穿模、子弹打在摊位上。
// ③ 横列距 1.2 m × 4 列 + 球半径 ⇒ 宽 4.6 m,两端插进摊位立柱;
// 顶排 1.2+2×0.7 = 2.6 m ⇒ 插进摊位屋梁。
// ⇒ 本轮:**所有列共用同一个 forward(平墙)**,并把宽/高收进摊位开口里。
/// <summary>
/// stall 锚点下沿**摊位自身朝向**的前移量(m)。负 = 往玩家方向拉出摊位。
/// 空 sweep 时**所有列共用**这一个值(平墙)。默认 -1.5(≈ 摊位正面/帘幕平面)。
/// </summary>
public float BalloonWallStallForward { get; init; } = -1.5f;
/// <summary>墙的横向列距(m)。R111 从 1.2 收到 0.9,避免两端插进摊位立柱。</summary>
public float BalloonWallLateralStep { get; init; } = 0.9f;
/// <summary>墙最下一排的高度(m,相对摊位原点)。R111 从 1.2 改 1.25(贴着柜台面之上)。</summary>
public float BalloonWallBaseHeight { get; init; } = 1.25f;
/// <summary>排距(m)。R111 从 0.7 收到 0.5,避免顶排插进摊位屋梁。</summary>
public float BalloonWallHeightStep { get; init; } = 0.5f;
/// <summary>排数(默认 3)。列数 = balloonCount / rows。</summary>
public int BalloonWallRows { get; init; } = 3;
/// <summary>
/// ★★★★★ R115 —— **官方球位表**(世界坐标,逐点硬编码)。每个元素形如 <c>"x,y,z"</c>,
/// 下标 = spawn 序号 - 1(即 col 外层 / row 内层的展开顺序:先 col0 的全部 row,再 col1…)。
///
/// <para>取值来源(**不是推算**):R115 运行时探针倒出摊位 prefab 子树
/// <c>Gadget_DaQiQiu_Position_9500000000000/ItemViewNode/QiQiuBan/…</c>,
/// 其中 <c>BalloonBoard_Square2</c>(active=true,16 个 <c>BalloonPointXY</c> 子节点)
/// 就是官方那面气球墙 —— 节点世界坐标即官方球位。</para>
///
/// <para>与「几何算法」的关系:本数组非空且全部可解析 ⇒ **几何算法(forward/right/lateral/
/// baseHeight/heightStep)整段停用**,位置一律取本表。空 ⇒ 退回几何算法(R110/R111 行为)。</para>
///
/// <para>实测形状:4 列 × 4 行 = 16 点;列距 <b>0.4 m</b>、行距 <b>≈0.348 m</b>;
/// 整面墙只有 <b>1.2 m 宽 × 1.05 m 高</b>(相对摊位锚点 (-4750.5,168.6,-2827.6),
/// 沿 yaw=315° 的 right 方向展开、离地 0.775–1.82 m)。
/// 旧几何算法给出的是 4.6 m 宽 × 2.6 m 高的散布 ⇒ 这就是「球不在官方位置」的直接原因。</para>
/// </summary>
public string[] BalloonWallSpots { get; init; } = System.Array.Empty<string>();
/// <summary>
/// ★★★★★ R116 —— **官方球位整体外移(米)**,方向 = 球位质心 → 玩家(水平)。
///
/// <para>用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」「球变成一面墙,都凑在一起」。
/// 官方那 16 个点是**贴在板上**的,而板正面就是摊位的装饰招牌/帘布(`p_map01_level_sister_tile_board`
/// 与 `Lianzi`,探针实测与球位同在 y 169.4–170.4)。把整组点沿板法线(≈朝玩家)外移,球就浮在
/// 帘布前面。</para>
///
/// <para>热重载:改本值 ⇒ 退出气球区再进一次即生效。0 = 官方原始坐标。</para>
/// </summary>
public float BalloonWallSpotPull { get; init; } = 0.35f;
/// <summary>
/// ★★★★★ R118(2026-09-27)—— **官方 16 个球位的「间距倍数」**(1.0 = 官方原样)。
///
/// <para>用户实测:「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。
/// 官方 `BalloonBoard_Square2` 的 4×4 点确实很挤:列距 0.400 m / 行距 0.348 m,
/// 整面墙只有 <b>1.2 m × 1.045 m</b>;而球包围盒是 0.185 × <b>0.3566</b> × 0.185
/// (`_r109_authoring.json`)⇒ 上下几乎贴住。</para>
///
/// <para>算法:以 16 点的**质心**为轴,按本倍率等比放大 x/y/z(只"拉开",不改中心)。</para>
///
/// <para>热重载:改本值 ⇒ 退出气球区再进一次即生效。1.0 = 官方原始间距。</para>
/// </summary>
public float BalloonWallSpotSpread { get; init; } = 1.5f;
/// <summary>
/// ★★★★★ R127(2026-09-28)—— **每局重新随机排列 16 个球位**(打散"整齐方阵")。
///
/// <para>用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。
/// 官方 <c>BalloonBoard_Square2</c> 的 16 个点是 4×4 网格 —— 即使 <see cref="BalloonWallSpotSpread"/>
/// 把它们拉开,**排列仍然是有规律的**(同一行/列一眼看穿)。</para>
///
/// <para>开启时:每局 spawn 在**同一片区域**内重新随机撒点 ——
/// 区域由 <see cref="BalloonWallSpots"/> 现有点集的「主轴(2D PCA)× Y 范围」定义,
/// 深度(沿主轴法线)保持不变,所以球始终「上下 + 左右铺开、不往前后排」;
/// 并用 <see cref="BalloonWallMinGap"/> 做拒绝采样,保证球不互相穿插。</para>
///
/// <para>⚠ 新增键 ⇒ 必须<b>重编 + 重启服务端</b>(铁律 61:JSON 里多出来的键没有对应属性会被丢弃)。</para>
/// </summary>
public bool BalloonWallRandomize { get; init; }
/// <summary>
/// ★ R127 —— 球位重排时的**最小间距(米)**,球包围盒约 0.185 × 0.357 × 0.185 ⇒ 默认 0.42。
/// 只在 <see cref="BalloonWallRandomize"/> 开启时使用。
/// <para>R140:三段禁带切开后可用区域变小(≈3.13 m²),16 球 ⇒ 0.42 会导致拒绝采样失败,
/// 故配置下调到 0.34(球包围盒高 0.357,视觉上仍互不穿插)。</para>
/// </summary>
public float BalloonWallMinGap { get; init; } = 0.42f;
/// <summary>
/// ★★★★★ R140(2026-09-28)—— **球位禁带**(球不许落在「三块帘子之间的木隔断」上)。
///
/// <para>用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。
/// 探针实测:三块帘子 <c>Lianzi</c>×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m)
/// ⇒ 接缝在 local x ≈ <b>−1.614</b> 与 <b>+0.003</b>;而 R139 写进
/// <see cref="BalloonWallSpots"/> 的四个「列」正好是**帘子边界**
/// (local x = −3.232 / −1.615 / +0.002 / +1.619)⇒ 第 2、3 列整列压在接缝上。</para>
///
/// <para>每个元素形如 <c>"x,z"</c>(禁带**中心**的世界坐标,取帘子深度处 z=1.315 的接缝点);
/// 落点按主轴(2D PCA)投影成 t 区间后,落在
/// 「中心 ± <see cref="BalloonWallForbiddenHalfWidth"/>」内的采样一律拒绝。</para>
///
/// <para>⚠ 新增键 ⇒ 必须<b>重编 + 重启服务端</b>(铁律 61)。</para>
/// </summary>
public string[] BalloonWallForbiddenSpans { get; init; } = System.Array.Empty<string>();
/// <summary>
/// ★ R140 —— 每个禁带的**半宽(米,沿主轴投影)**,默认 0.27。
/// 实测木隔断约 0.22 m 宽 + 球半径 0.093 m ⇒ 0.27 已含余量。
/// </summary>
public float BalloonWallForbiddenHalfWidth { get; init; } = 0.27f;
/// <summary>
/// ★★★★★ R116 —— **一局时长(秒)**,对应官方 <c>PoiGameConfig.Balloon_CountDown = 40</c>。
///
/// <para>用户实测/提问:「打完了不应该是结算游戏吗」「这种小游戏肯定会限制只能打几枪吧,
/// 不然不限制谁都能打完,没有挑战性」。官方玩法的约束就是这 40 秒:到点服务端主动结束
/// (final score + GameOver + Dispose,走 <c>FinishBalloonRoundAsync</c>),
/// 客户端按总分排 endType(&gt;50 GoodEnd / &gt;25 NormalEnd,`BalloonGameStore.OnGameEnd`
/// 会在 1.5 s 后自己关面板)。</para>
///
/// <para>0 或负数 = 关闭限时(不推荐:会回到「谁都能慢慢打完」)。</para>
/// </summary>
public int BalloonRoundSeconds { get; init; } = 40;
/// <summary>
/// ★ R116 —— 玩具枪弹匣发数(默认 40,与 <c>BalloonWire.BalloonWeaponMagazineAmmo</c> 一致)。
/// 面板的弹药控件读的就是这个值(`BalloonGameStore.RefreshBalloonWeaponAmmo`
/// → `weapon.MagazineAmmo`)。⚠ 枪本身的 cfg(80300134 打中有赏_玩具枪)是
/// `Durability = -1`(无限)⇒ **硬性「只能打 N 枪」由客户端配表决定,服务端改不动**,
/// 这里只能改面板上显示的那个数。
/// </summary>
public int BalloonWeaponAmmo { get; init; } = 40;
/// <summary>
/// ★ R111 诊断 —— **按列换 (cfg,pathId)**。每个元素形如 <c>"80001468:895168017"</c>,
/// 下标 = 列号(列数 &gt; 元素数时,最后一列的值套用到其余列)。空 = 用
/// <c>BalloonWire.BalloonSceneItemCfgIds / PathIds</c> 的 5 色派对气球。
///
/// <para>用途:一次进出区同时对比两种美术 —— 例如左两列发派对气球
/// (<c>prop_xsb_balloon001*</c>,体积 0.614、teardrop 形),右两列发龙栖村原生
/// <c>prop_lqc_balloon002*</c>(体积 0.0048、与官方玩法气球同档)。
/// 用户只要报告「哪边像摊位上的球 / 哪边打得爆」。</para>
/// </summary>
public string[] BalloonCfgSweep { get; init; } = System.Array.Empty<string>();
/// <summary>
/// ★★★ R89 — 气球 PathId 取哪一组。
/// false(默认)= 用对照包的五色 PathId(BalloonSceneItemPathIds,逐色不同)
/// true = 全部退回 R88o 的 670915924(plastic_balloon) 单一值
/// 为什么留这个开关:对照包自己标注那 5 个 id 是「待复核值」;一旦真机出现
/// 「有碰撞无模型」,改这里即可一键 A/B,不必重编译。
/// </summary>
public bool BalloonUseSharedPathId { get; init; } = false;
/// <summary>
/// ★★★ R89 — 本轮流行的气球数(对照包 = 12:4 列 × 3 行)。
/// R88o 用的 20(= Balloon_RightNum 10 × 2)来自 ClientConfig,是**棋盘上限**而非本局数量。
/// </summary>
public int BalloonCount { get; init; } = 12;
/// <summary>
/// ★★★ R89 — 进气球区时给玩家发气枪(80300134)。
/// 关掉即回到 R88o 行为(无枪 ⇒ 面板 `attempt to index local 'weapon'` 起不来)。
/// </summary>
public bool BalloonGrantWeapon { get; init; } = true;
/// <summary>
/// ★★★ R89 — 气球实例 id 是否用对照包的固定 48 位区间(0xBA11_0000_0000 + 序号)。
/// false(默认)= 固定区间(≤48 位,经 Lua double 回传不丢精度)
/// true = 退回 R88o 的 920000000001 + batch*count 递增批次
/// </summary>
public bool BalloonUseLegacyInstanceIds { get; init; } = false;
/// <summary>只记录不推送。用于首次试跑新坐标。</summary>
public bool LogOnly { get; init; } = false;
}
// ═══════════════════════════════════════════════════════════════════════════════════════
// 赛车检查点门(2026-10-03 移植自捐赠分支 DRMK-PS_09_HyNB-赛车)
// 由服务端摆放 26 个官方检查点(成对镜像 = 52 个 gadget),过点即撤。
// 完整字段语义见类内注释;全部是「不用重编译」的热旋钮。
// ═══════════════════════════════════════════════════════════════════════════════════════
public sealed class RacingGateSettings
{
/// <summary>False = 一道门都不摆。回滚开关,不用重编译。</summary>
public bool Enabled { get; init; } = true;
/// <summary>坐标表路径(相对服务端根)。客户端 R247 探针写这个文件。</summary>
public string TablePath { get; init; } = "config/racing-gates.json";
/// <summary>
/// 放置通道。0 = <c>SyncGadgetAdd(68843189)</c> —— 健身器材实测「实体建得出、小游戏可玩」的那条。
/// 3 = <c>SyncGadgetAddList(68925813)</c>(AnantaGymLoader 用的那条)。
/// </summary>
public int Channel { get; init; } = 0;
/// <summary>
/// <c>PackedGadgetInfo.iScale</c>。⛔ **必须 0** ——
/// 0 = 客户端 1:1 正常渲染(loader 与 400 条 blob 记录用的值);
/// 100(GM 面板默认)= 出来是 <c>transform scale (0,0,1)</c> 的**零缩放空壳、没有 prefab**
/// (这正是保龄球球瓶「有物理有音效但看不见」的根因,见 GymEquipmentSettings.IScale)。
/// </summary>
public int IScale { get; init; } = 0;
/// <summary>
/// 拿不到坐标表时的「验证信标」:在玩家前方 <see cref="BeaconAhead"/> 米处摆若干道门。
/// 用来把「通道对不对」和「坐标对不对」两个变量解耦 —— 看得见信标 ⇒ 通道没问题,只差坐标。
/// </summary>
public bool BeaconEnabled { get; init; } = true;
/// <summary>信标距离(米),会按 1× / 2× / 3× 摆三道。</summary>
public float BeaconAhead { get; init; } = 40f;
/// <summary>只打日志、不推包。用来干跑一份新采集。</summary>
public bool LogOnly { get; init; } = false;
/// <summary>
/// ★ R254 —— 「成对镜像」。true = 每个检查点摆 <b>2 个</b> gadget(同一坐标、yaw 相差 180°)。
///
/// <b>为什么必须成对</b>(R254 实机截图,用户「门显示了但是只显示了右边的」+
/// 「门应该是在整个道路的左右两边」):<c>Jobrace_gongmen</c> 的一个实例只是
/// <b>半扇拱门</b> —— 一根立柱从路面升起、顶端向内弯折,另一半是空的。
/// 官方一扇门 = 两个实例:一个 yaw=θ、一个 yaw=θ+180°,两半合成完整拱门,
/// 拱门中心才落在路中线上(这也正是用户看到的「位置偏右」的来源:只有右半,自然偏)。
///
/// 关掉它 = 回到 R253 行为(每点一个、只有半扇)。回滚开关,不用重编译。
/// </summary>
public bool PairMirror { get; init; } = false;
/// <summary>
/// 成对之后的**额外左右位移**(米,沿路径法向;正 = +normal 那侧)。
/// 默认 0:两半同坐标镜像 ⇒ 拱门正好骑在路中线上。
/// 只有当官方拱门确实比路面宽/窄、需要整体挪动时才调它。
/// </summary>
public float PairOffset { get; init; } = 0f;
/// <summary>
/// ★★ R254 —— **强制覆盖表里的 <c>pathId</c>**。0 = 不覆盖(用表里的值)。
///
/// <b>为什么必须有它</b>:<c>racing-gates.json</c> 是**客户端 R247 采集探针写的**,
/// 而那个探针每进一次赛车就**整表重写**,写进去的是**客户端那套 gadget id**
/// (<c>Racing_challenge01_CheckPoint</c> = 884611825 / <c>…CheckPointEnd</c> = 271608686)。
/// 那两件资源**从未随包** ⇒ 摆出来隐形。
/// R253 我把表手工改成 <c>64493331</c>(Jobrace_gongmen),用户跑完一局后**表又被探针覆盖回去了**
/// —— 于是下一局必然退回「隐形门」。所以 pathId 不能交给客户端探针决定,必须由服务端钉死。
///
/// 当前值:<c>64493331</c> = <c>Jobrace_gongmen</c>(竞速·拱门)。
/// </summary>
public int PathIdOverride { get; init; }
/// <summary>
/// ★★★ R255 —— **使用服务端内置的「官方赛道检查点表」**,忽略客户端采集的
/// <see cref="TablePath"/> 里的坐标。默认 <b>true</b>。
///
/// <b>为什么必须内置</b>(R255 定案,2026-10-02):赛道 track 3(Nova `23300888`,
/// `RacingPathName = RT_888_001`)的**官方检查点位置是权威数据、且离线可读** ——
/// 它们烘焙在关卡块 `vfc_819` 的明文导出 JSON 里:
/// <code>
/// Assets/Res/Prefab/ZoneShapeView/ZoneShapes_WorldMap_Release_Saidao.prefab
/// {"trackIndex":0,"pointCount":259,"isClosed":false,
/// "points":[{"x":..,"y":..,"z":..,"checkpointIndex":k,"distanceToNextCheckpoint":..}, ...]}
/// </code>
/// <c>checkpointIndex</c> 值域 <b>0..25 ⇒ 含终点共 26 个检查点</b>(与用户「跟着视频数的 26 个」一致)。
/// 检查点 <c>k</c> 的位置 = **第 k 组的第一个点**(已由
/// <c>distanceToNextCheckpoint</c> 自证:cp0 组末点 <c>s=215.07, d_last=5.93</c> ⇒ 221.00,
/// 而 cp1 组起点 <c>s=220.99</c>,吻合)。
///
/// <b>为什么不能继续用采集表</b>:R225 的探针走的是
/// <c>LX6.Utils.LuaUtils.LoadRaceTrackData</c> → <c>RaceTrackExportData.ExtractFilteredTrackPoints()</c>,
/// 那条链**只返回 x/y/z、丢掉 checkpointIndex**,而且对点列做了**不等距重采样**
/// (官方 259 点 / 3382.44 m,探针拿到 256 点 / 3225 m ⇒ 在它上面做「弧长等分」必然错位)。
/// R225 当时的常量就是 <c>R225_CP_COUNT = 16 -- 原版 Spoon 图数量未知,取保守中值</c>(自认是猜的)。
///
/// 更糟的是那张采集表还是**客户端探针每局重写**的(见 <see cref="PathIdOverride"/>),
/// 所以坐标也不能交给它决定 —— 服务端必须自带权威表。
///
/// 关掉它 = 回到「读 <see cref="TablePath"/> / 信标」的 R254 行为。回滚开关,不用重编译。
/// </summary>
public bool UseOfficialTrack { get; init; } = true;
/// <summary>
/// 门的朝向修正(度),叠加在内置官方表的 yaw 上。默认 0。
///
/// ★ R291 起它是**真旋钮**(此前是空操作):<c>ExpandGatePairs</c> 的 ±8 m 侧移用
/// **不含本值的基准 yaw**,只有朝向用含它的值 ⇒ 改它 = 每一半**绕自己的根部原地转**,
/// 位置完全不动。<c>180</c> = 把两半的「身体」从朝路外翻成**朝路中央**(合扇)。
///
/// ★ 改它**不用重编译、不用重启服务端**(<c>private-server.json</c> 热重载 + 每局开赛重读),
/// 下一局即生效。实机看到两臂**朝路外张开**就改成另一个值(0 ↔ 180)。
/// </summary>
public float YawOffset { get; init; }
/// <summary>
/// ★★★★★ R293 —— 门的朝向**取哪个方向**。默认 <c>"road"</c>。
///
/// <list type="bullet">
/// <item><c>"road"</c>(默认)= **该检查点处的「道路方向」(局部切向)**,取官方 259 点
/// 折线里<b>含该点那一段</b>的方向。门面法向沿路 ⇒ <b>门横跨路面</b>,
/// 且 ±8 m 成对侧移变成<b>跨路并排</b>(两半合扇)。用户 2026-10-02 定:
/// 「检查点可以不用朝向下一个点,就和道路平行就行了」。</item>
/// <item><c>"next"</c> = R290 遗留的**「指向下一个检查点」弦向**(<c>atan2(Δx,Δz)</c>),
/// 只在 A/B 对照或回滚时用。⚠ 直道上两者等价,**弯道上差极大** ——
/// 26 点里 9 个偏 &gt; 20°,cp07 偏 **114.8°**(那里会把门拧成「顺路躺」、
/// 两半串成一前一后)。</item>
/// </list>
///
/// 写错的值按 <c>"road"</c> 处理。改它**不用重编译、不用重启服务端**,下一局生效。
/// 数据来源与复算脚本见 <c>GameRouter.RacingGates.OfficialTrack3Checkpoints</c>。
/// </summary>
public string YawMode { get; init; } = "road";
/// <summary>
/// ★★★★★ R294(2026-10-02)—— **跑过一个检查点就把它那对门撤掉**,默认 <c>true</c>。
///
/// 用户原话:「检查点开过去不会消失,而且比赛结束了所有检查点还在」。
///
/// <b>为什么要撤</b>:一个门是服务端 spawn 的 gadget,客户端不知道它的存在、不会自己删;
/// 而官方的检查点标记是客户端自建、只有一个、跟着当前目标走
/// (<c>CarRaceManager.arrowGO</c>:<c>AddArrow</c> 建、<c>RemoveArrow</c> 销毁)
/// ⇒ 官方观感 = 「过点即消失」。我们一局摆 52 个后从不回收,于是全程 26 扇门一直杵在赛道上。
///
/// <b>撤哪几个</b>:客户端过点上报 <c>AskSetTaskCounterValue(index=k)</c>,
/// 而 <c>index</c> **恰好等于**服务端检查点下标(客户端 <c>taskCounterList</c> 就是我们 Lua 喂的
/// <c>R255_OFFICIAL_CP</c>)⇒ 撤第 <c>k</c> 个点附近(20 m 半径内)的那一对。
/// ⛔ **不能按 pathId 全撤** —— 52 个门同一个 pathId,会把没跑到的一起抹掉。
///
/// 跑完全程(<c>index == 26</c>)或比赛结束 ⇒ **全清**。
/// 关掉它 = 回到 R293 的「一局只摆不清」。**改它不用重编译、不用重启服务端**,下一局生效。
/// </summary>
public bool RemoveOnPass { get; init; } = true;
/// <summary>
/// <b>R311 · 撤门宽限期(毫秒,默认 12000)</b> —— 一对门**被上报通过后**至少要活满这个时长才允许撤。
///
/// <para><b>为什么需要</b>:用户「<b>不是所有门都没有了啊,还是前两个</b>」——
/// 只有前两个检查点的拱门看不到。<c>console-20261003-015034.log</c> 逐行时序:
/// 一局 52 个门在 <c>01:53:50</c> 同一秒全摆完,而客户端按
/// <c>BabyDriver_CheckPointRange = 22</c>(m) 上报「已过点」⇒ 服务端**上报即撤**:
/// <c>cp00 +5 s</c> / <c>cp01 +9 s</c> / <c>cp02 +13 s</c> / <c>cp03 +16 s</c> …
/// ⇒ 前两个门在「客户端把 <c>Jobrace_gongmen</c> prefab 加载完并实例化」之前
/// 就被 remove 抵消掉了;从第三个起那一对活过了冷启动窗口,所以看得见。</para>
///
/// <para><b>做法</b>:过点上报时若距摆门不足本时长 ⇒ **不撤**,交给一个后台任务到点补撤
/// (⛔ 不能在回执路径里 <c>await Task.Delay</c>:<c>AskSetTaskCounterValue</c> 的回执会被客户端判超时)。</para>
///
/// <para><b>0 = 关</b>(回到 R294 的「上报即撤」)。**改它不用重编译、不用重启**,下一局生效。</para>
/// </summary>
public int GateMinLifetimeMs { get; init; } = 12000;
/// <summary>
/// R312(2026-10-03)· **前 N 个门延迟撤** —— 用户指定规则原话:
/// 「前两个门设置为**到第三个门之后再消失**,第三个门后的所有门都维持现状」。
/// <para>含义:`checkpointIndex &lt; GateKeepFirstNCount` 的那几个点,上报通过时**不撤门**,
/// 只登记挂起;等玩家通过第 `GateKeepFirstNCount` 个点(默认 2 ⇒ 即第三个门 `cp02`)时,
/// 把挂起的和当前这一对**一起撤掉**。这是把「生存期」锚到**玩家的真实进度**上,
/// 而不是靠猜 prefab 冷加载要几秒(R311 的 12 s 宽限期实测没消除用户的现象)。</para>
/// <para>`0` = 关闭本条规则,完全回到 R311 的「纯宽限期」行为。默认 2。</para>
/// </summary>
public int GateKeepFirstNCount { get; init; } = 2;
}
public sealed class DebugSettings
{
public bool Enabled { get; init; } = true;
public string Host { get; init; } = "127.0.0.1";
public int Port { get; init; } = 5809;
}