7996 lines
491 KiB
C#
7996 lines
491 KiB
C#
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<ulong></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_<IndoorId></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_<N></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 && !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 距离 > 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 > 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 > 本值,以当前所在格为中心)的格子会被显式卸载。
|
||
/// ⛔ 历史:见 <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> > 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"/>;本值 <=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**)。
|
||
/// 想围得更紧就继续调小;太小会互相穿模(实测 < 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 <= 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>
|
||
/// 两条移动命令之间的间隔(毫秒)。必须 >= 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>行人离玩家超过多少米就移除。必须 > 布点半径,否则刚生成就被回收。</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>车离玩家超过多少米就回收。必须 > 生成半径。</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 = 严格取接缝最小的后继(**实测最优**:>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,>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 > 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>提前多少秒重发游荡(要 < 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/<pid>.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<VehicleStopParameters></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 <= 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>离开多少米外撤掉(必须 > 进入值,否则边界上每 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 行)有中文名 ⇒ 只能**按名字匹配**推出来。
|
||
/// 表里那一份是脚本按「核心词完全相等 > 包含 > 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<uint, NpcTimeTableInfo></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 && !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>迟滞半径(米),须 > <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=<k> 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(>50 GoodEnd / >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>,
|
||
/// 下标 = 列号(列数 > 元素数时,最后一列的值套用到其余列)。空 = 用
|
||
/// <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 个偏 > 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 < 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;
|
||
}
|