Files
Ananta-ps/Ananta.Server/Ananta.Core/Protocol/Client4229938/WorldEntryState.cs
T

842 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ananta.Server.RpcTypes.Client4229938;
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
namespace Ananta.Server.Protocol.Client4229938;
internal sealed class WorldEntryState
{
internal object SyncRoot { get; } = new();
/// <summary>
/// 静态 NPC 生成的串行门。逐字对齐捐赠端 <c>WorldEntryState.cs:13</c>
/// (<c>internal SemaphoreSlim StaticNpcSpawnGate { get; } = new(1, 1);</c>)。
///
/// ⛑ 2026-10-02 补:主线移植 <c>GameRouter.Npc.cs</c> → <c>GameRouter.DonorNpc.cs</c> 时
/// 漏了这道门。一个 NPC 的诞生是**一串有序包**(`SyncAetherAIStaticNpcAddData` →
/// `SyncManagedLogicAgent` → `SyncBattleAiS=false` → POI → 归属物 → 警察登记),
/// 两批并发会把这串包交错发出,客户端拿到的实体就是半初始化的 ——
/// 表现是「NPC 看得到,但原生盘查菜单弹不出来」,且服务端日志一切正常。
/// </summary>
internal SemaphoreSlim StaticNpcSpawnGate { get; } = new(1, 1);
/// <summary>
/// Static-NPC entity id -> pending plot event id (0 = nothing pending). The donor build seeds
/// every spawned citizen here; GameRouter.Plot uses it to match a server-driven plot step to
/// the pedestrian that should perform it.
/// </summary>
internal Dictionary<ulong, uint> StaticNpcPreparedPlotEvents { get; } = new();
/// <summary>
/// Static-NPC entity id -> last position/facing the server believes it is at.
///
/// The citizen channel normally hands movement to the client's Aether crowd, so nothing on the
/// server ever needed a position. Server-driven walking does: it has to interpolate from where
/// the citizen stands, and after one walk that place is no longer the spawn point. Seeded at
/// spawn, updated after every walk.
/// </summary>
internal Dictionary<ulong, StaticNpcPose> StaticNpcPoses { get; } = new();
/// <summary>
/// Dropped-weapon destructible id -> what was dropped.
///
/// Dropping a weapon makes the client call AskSkillDestructibleCreate and wait for the new
/// destructible's id; every later step (the long-press-Q psychokinesis absorb, the pick-up
/// prompt) is addressed by that id. It used to be answered with a hard 0, so the client spent
/// the whole drop/absord cycle talking to an entity that does not exist — hence the slide loop,
/// the sticky Q prompt and the weapon falling straight back out of the hand.
/// See GameRouter.WeaponDrop.cs.
/// </summary>
internal Dictionary<ulong, WeaponDropRecord> WeaponDrops { get; } = new();
/// <summary>
/// Next destructible id handed out for a dropped weapon. Deliberately far away from the player
/// unit id (1e11) and weapon instance ids (~2e5) so nothing collides.
/// </summary>
internal ulong NextWeaponDropId { get; set; } = 9_000_000_000_000UL;
/// <summary>
/// Interactive props (Gadget) the admin panel spawned with SyncGadgetAdd, keyed by the instance
/// id the client was told to use. Kept so the panel can remove them again and so a re-publish
/// after a scene change reuses the same ids instead of leaving orphans behind.
/// </summary>
internal Dictionary<ulong, SpawnedGadgetRecord> SpawnedGadgets { get; } = new();
/// <summary>
/// Next Gadget instance id. Separate band from the weapon drop ids so the two never collide.
/// </summary>
internal ulong NextSpawnedGadgetId { get; set; } = 9_500_000_000_000UL;
/// <summary>
/// Activation state of scene-authored DynamicGo props, as reported by the client through
/// AskActiveDynamicGo. Their ids only exist inside the client's scene resources, so this map is
/// built purely from what the client tells us — it can never be enumerated up front.
/// </summary>
internal Dictionary<int, bool> DynamicGoActive { get; } = new();
internal bool InitialStateSent { get; set; }
internal bool EnterSceneSent { get; set; }
internal bool SceneLoadAckSeen { get; set; }
internal bool GameResourcesReadySeen { get; set; }
internal bool ControlPublished { get; set; }
internal bool SceneCompletionSent { get; set; }
internal bool CurrentSpiritStateSent { get; set; }
internal bool Ready { get; set; }
internal bool LivePlayerProfilePublished { get; set; }
internal bool SameSceneAckSeen { get; set; }
internal bool MovementCapabilityPublished { get; set; }
internal bool FreeRoamReleased { get; set; }
internal bool FirstMovementSeen { get; set; }
internal bool InitialCapabilityBuffsPublished { get; set; }
internal bool AllBuildBuffsPublished { get; set; }
internal bool InitialActorPresentationPublished { get; set; }
internal bool CombatProfilePublished { get; set; }
internal bool AccountArmoryPublished { get; set; }
internal HashSet<ulong> ArmoryWeaponsAnnounced { get; } = new();
/// <summary>One-shot guard for the unlockAll full-catalog SyncArmoryAddWeapon replay
/// (reset per scene load so the freshly-built client store receives the whole armory again).</summary>
internal bool AccountArmoryAllWeaponsSent { get; set; }
/// <summary>One-shot guard for the SyncSceneFogMapAllUnlock replay. Reset on every scene load
/// (see <see cref="AllBuildBuffsPublished"/>) because the client rebuilds MapFogDataMgr per scene.</summary>
internal bool MapFogUnlockPublished { get; set; }
/// <summary>One-shot guard for the SyncPlayerAllTask replay (reset per scene load, same as fog).</summary>
internal bool PlayerTasksPublished { get; set; }
/// <summary>
/// 客户端 <c>SyncChangeIndoor</c> 上报的 <c>indoorConfigId</c> —— <b>室内判定的权威值</b>。
/// 0 = 客户端认为自己不在任何室内。见 <c>GameRouter.IndoorSector.cs</c> 的
/// <c>PreferClientReport</c>:几何最近邻在 MiniMapPos 矩形重叠的场馆(ACD 76/129)会抖。
/// </summary>
internal uint ClientIndoorConfigId { get; set; }
/// <summary>
/// 上一次收到 <c>SyncChangeIndoor</c> 的 <c>Environment.TickCount64</c>。
/// 0 = 从没收到过。用于判断上报值是否还新鲜(<c>ClientReportGraceMs</c>)。
/// </summary>
internal long ClientIndoorReportMs { get; set; }
// Current public-world destination. Initial values come from private-server.json; airport
// travel updates these per scene generation without mutating global configuration.
internal uint ActiveRaidId { get; set; } = Profile.RaidId;
internal ulong ActiveInstanceId { get; set; } = Profile.SceneInstanceId;
internal uint ActiveUniverseId { get; set; } = Profile.UniverseId;
internal string ActiveContentScene { get; set; } = "WorldMap_Release";
internal bool WorldEntryIsAirportTravel { get; set; }
// Native LoadingManager common-teleport transaction. Airport travel reuses the retail
// AskTeleport -> SyncPreTeleportOption -> ReportPreTeleportFinish -> domain RPC ->
// SyncTeleport -> ReportPostTeleportFinish sequence so SwitchTeleportManager cannot retain
// a stale AcrossRaid flow after landing.
internal ulong PendingTeleportId { get; set; }
internal string PendingAirportRouteKey { get; set; } = string.Empty;
internal bool PendingTeleportPreFinished { get; set; }
internal bool PendingTeleportSyncSent { get; set; }
// 改装店(ApplyCar)离场时间戳:客户端自己的触发区会在离场落地后 0.3s 再补一发
// config=8 的 AskTeleport( garage_in02 ),照着 echo 会把人/车重新拽回店里无限循环。
// 4s 内压掉 enter 侧的 echo(真要再进一次,开车压触发区的时间远不止 4s)。见硬规则 166。
internal long LastShopExitTicks { get; set; }
// Build 4229938: one generation-bound initial-player handoff.
// 0 = edge not claimed; -generation = claimed/in-flight; +generation = published.
internal bool WorldEntryControlPending { get; set; }
internal bool WorldEntryControlFinalized { get; set; }
internal ulong WorldEntryControlUnit { get; set; }
internal uint WorldEntryControlTemplate { get; set; }
internal int WorldEntryControlGeneration { get; set; }
internal int WorldEntryLoadingCompletedGeneration { get; set; }
internal int WorldEntryOpeningEndedGeneration { get; set; }
internal int WorldEntryControlFinalizingGeneration { get; set; }
internal int WorldEntryLogicProjectionPublishedGeneration { get; set; }
internal int WorldEntryCurrentMetadataPublishedGeneration { get; set; }
internal ulong WorldEntrySceneId4229938 { get; set; }
internal ulong WorldEntrySessionId4229938 { get; set; }
/// <summary>
/// 本次 world-entry 事务「应该等的 scene id」= <c>SwitchSceneAsync</c> 收到的 instanceId。
///
/// ★ R101(自 donor v2.1 移植,2026-09-27 定案)—— 这是「所有摊位/摆件一起消失」的根因:
/// admin 面板(或任何客户端侧)在**新场景还没加载完**时就发起切换时,时序是
/// 1. <c>SwitchSceneAsync</c> 把 <c>WorldEntrySceneId4229938</c> 清 0;
/// 2. **上一张图**的 <c>AskLoadSceneCompleted</c> 迟到抵达 ⇒ 命中「== 0」分支
/// ⇒ 把权威位占成**旧 scene**;
/// 3. 新图的 <c>AskLoadSceneCompleted</c> 随后抵达 ⇒ correlation-mismatch ⇒ 永久拒绝;
/// <c>AskLoadingFinished</c> 同样被拒 ⇒ <c>WorldEntryControlFinalized</c> 永远 false
/// ⇒ world-ready 钩子 <c>EnsureGadgetGrid</c> 从不执行 ⇒
/// <c>GADGET-GRID</c> / <c>SCENE-ITEM-GRID</c> 循环不启动
/// ⇒ 摊位 / 健身房器材 / 补货 / 派对家具 **全部不摆**。
/// 有它之后,<c>OnLoadSceneCompleted</c> 能把「迟到的旧 ack」识别出来直接丢弃:
/// 不占权威位、不报 mismatch。正常路径(ack 与 expected 一致)完全不变。
/// 0 = 不校验(首次进世界 / 旧调用方未设置)。
/// </summary>
internal ulong WorldEntryExpectedSceneId { get; set; }
internal Vec3 WorldEntryCreateHeroPosition { get; set; } = Profile.WorldSpawn;
internal float WorldEntryCreateHeroFacing { get; set; } = Profile.WorldFacing;
internal Vec3 LastReportedPlayerPosition { get; set; } = Profile.WorldSpawn;
internal Vec3 LastReportedPlayerRotation { get; set; } = new(0f, Profile.WorldFacing, 0f);
internal bool HasLastReportedPlayerTransform { get; set; }
// AetherCity 人群流式:上次下发 SyncCrowdSpecifiedDensityRefresh 时的中心点。
// 玩家走远后要按这个点算位移并重发,否则走出盒子就没人了(见 GameRouter.Crowd.cs)。
internal Vec3 LastCrowdCenter { get; set; } = Profile.WorldSpawn;
internal bool HasCrowdCenter { get; set; }
// Throttle for the walking save. No session-end hook exists, so the movement handler writes the
// snapshot periodically; these two fields decide when it has moved/waited long enough.
internal long LastPositionSaveTicks { get; set; }
internal Vec3 LastSavedPlayerPosition { get; set; }
internal bool HasSavedPlayerPosition { get; set; }
internal Dictionary<int, uint> SkillByInstanceId { get; } = new();
internal uint ActiveSkillId { get; set; }
internal bool RestoreResourcesAfterActiveSkill { get; set; }
internal int ActiveClientSkillInstanceId { get; set; }
internal long ActiveSkillStartedTicks { get; set; }
internal ulong ActiveWeaponInstanceId { get; set; }
internal uint ActiveFightStyleId { get; set; }
internal Dictionary<uint, ulong> LastWeaponBySpirit { get; } = [];
internal Dictionary<uint, List<ulong>> WeaponSlotsBySpirit { get; } = [];
/// <summary>
/// 搜打撤武器轮盘:最后一次下发给客户端的「实体化武器」表(CurrWeaponSlots 下标 → InstanceId)。
///
/// 轮盘 bag 每变动一次就要让 gWeaponManager 跟上(否则 Tab 在轮盘武器之间根本切不动),
/// 增量下发必须知道「客户端现在认为每一格是什么」,所以这里留一份服务端镜像。
/// 下标口径:0 = 拳头、1 = 私有武器、轮盘 CellX c → c + 2。
/// ⛑ <b>只记轮盘那几格</b>(下标 2 起):0/1 是拳头和私有武器,混进来会被当成
/// 「轮盘里被删掉的格」而发 Remove,把拳头和角色自带武器摘掉。
/// </summary>
internal Dictionary<int, ulong> ExtractionWheelPublished { get; } = [];
internal Dictionary<ulong, uint> WeaponStyleOverrides { get; } = [];
// Persistent weapon-instance ammunition. These dictionaries intentionally survive public-scene
// generations and character switches; a weapon switch must never refill its magazine.
internal Dictionary<ulong, int> WeaponMagazineAmmo { get; } = [];
// For firearms without separate backpack bullets, Durability is the remaining total ammo
// (loaded magazine + reserve). Keep it instance-persistent for the same reason as the magazine.
internal Dictionary<ulong, int> WeaponDurabilityAmmo { get; } = [];
internal Dictionary<ulong, uint> WeaponBulletByInstance { get; } = [];
internal Dictionary<uint, uint> BackpackItemCounts { get; } = [];
// Economy: wallet balances and granted backpack stacks. Fed by the GM console (ALT+F1),
// mall purchases and gacha draws. PlayerProgress mirrors them to disk so a re-login
// restores the character instead of starting from the auth-time values again.
internal double WalletMoney { get; set; }
internal double WalletGold { get; set; }
internal double WalletBindingGold { get; set; }
internal PlayerProgress Progress { get; } = new();
internal bool ProgressRestored { get; set; }
/// <summary>
/// 钓鱼:玩家钓鱼档案(渔具 / 已钓鱼类 / 钓点鱼池)。是 session 级的真相源,登录时灌进
/// <c>PlayerClientInfo.InfoMinor.FishingInfo</c>。不落盘(重登从空重建 + 登录 seed 整套渔具)。
/// 2026-09-29 自捐赠端 DRMK-PS_09_HyNB-v1.9-钓鱼 移植(GameRouter.Fishing.cs 依赖它)。
/// </summary>
internal Auto.PlayerFishingInfo Fishing { get; } = new();
// Granted backpack stacks keyed by item template id, so repeated grants accumulate into
// one stack instead of spraying one entry per grant.
internal Dictionary<uint, Auto.PlayerPackItem> GmBackpackItems { get; } = [];
/// <summary>True once this session holds any wallet value or granted stack worth publishing at login.</summary>
internal bool HasSessionEconomy4229938()
=> WalletMoney != 0 || WalletGold != 0 || WalletBindingGold != 0 || GmBackpackItems.Count > 0;
// Phone menu skin parts (手机壁纸) actually worn, keyed by FightSpiritConfig template id —
// SpiritInfo.MobileSkinInfo is a PER-CHARACTER struct, and SpiritManager keys its view store
// by TemplateId, so the S2C packet has to name the character too.
// Fed by AskSetMobileSkinPart and mirrored to disk through PlayerProgress so a re-login seeds
// the login payload with the player's own choice instead of the default suit.
internal Dictionary<uint, Auto.SpiritMobileSkinInfo> PhoneSkinBySpirit { get; } = [];
// Outfit actually worn, keyed by FightSpiritConfig template id. The wardrobe panel only sends
// AskSetSpiritFashionsWithSource; the authoritative value lives in SyncSetSpiritFashions, so
// without this the model snaps back to the authored default on every re-login.
internal Dictionary<uint, FashionRecord4229938> FashionBySpirit { get; } = [];
/// <summary>
/// Dye (染色) per fashion id, restored from the player progress snapshot. Only fashions the
/// player actually dyed appear here; everything else reads the configured default slot count.
/// </summary>
internal Dictionary<uint, FashionColoringRecord4229938> Colorings { get; } = [];
/// <summary>
/// Livehouse 音游成绩 per 曲目 id(2026-10-04)。 everything here is restored from / written to
/// <c>config/player-progress.json</c> through <c>PlayerProgressStore</c>. See
/// <see cref="LivehouseProgressStore"/>; absent entry means "never cleared once".
/// </summary>
internal Dictionary<uint, LivehouseRecord4229938> LivehouseRecords { get; } = [];
/// <summary>
/// True once <c>AskQueryLiveHouseInfo</c> has answered <c>FirstPlay = true</c>, which is what
/// makes the client play the intro timeline exactly once per account.
/// </summary>
internal bool LivehouseFirstPlayDone { get; set; }
/// <summary>
/// Equipped weapon skins per character (spiritId -> weapon sceneItemId -> skinId), restored from
/// the player progress snapshot. Written by AskSetWeaponSkins / AskSyncWeaponSkinToSpirits.
/// </summary>
internal Dictionary<uint, WeaponSkinRecord4229938> WeaponSkins { get; } = [];
internal Dictionary<(uint SpiritId, uint FightStyleTypeId), uint> SpiritStyleOverrides { get; } = [];
internal int CombatUseCount { get; set; }
/// <summary>
/// Gameplay talent trees (天赋, T1-6), keyed by TalentTreeGamePlayConfig id (1..5 in stock
/// 4229938). Holds the point balance the server owns plus every node the player activated.
///
/// Why the server has to keep this at all: AskActiveGameplayTalentLayer carries no cost and no
/// resulting layer, only (gameplayId, talentId, addLayer), and the eight talent S2Cs
/// (SyncGameplayTalentPoint 64859579 &amp; friends) have no contract and no client reader we can
/// verify. PlayerClientInfo.GameplayTalentInfos is the one talent wire we know is correct — it
/// rides the login frame — so this dictionary is published there and re-pushed through
/// SyncPlayerInfo after each accepted activation.
/// </summary>
internal Dictionary<uint, GameplayTalentState4229938> GameplayTalents { get; } = [];
internal int CombatEndCount { get; set; }
internal int CombatHitCount { get; set; }
internal uint NextBuffInstanceId { get; set; } = 1200000u;
internal uint ActiveFeiSuoBuffInstanceId { get; set; }
internal ulong ActiveFeiSuoBuffUnitId { get; set; }
/// <summary>
/// 玩家是否处在飞索蹲伏态(由 <c>AskEnterFeiSuoCrouch</c> / <c>AskLeaveFeiSuoCrouch</c> 维护)。
///
/// ⛑⛑ 用途只有一个:<b>蹲伏期间不要再把飞索 buff 加回去</b>。
/// 客户端进蹲伏态的同一帧会发 <c>AskRemoveClientBuff(52853760)</c> 主动摘掉飞索 buff
/// (实测 19:41:32.258 进 / .262 摘)。旧逻辑在 120ms 后无条件 reassert 回去,
/// 客户端随即 <c>AskLeaveFeiSuoCrouch</c> ⇒ 蹲伏态撑不过 0.4s ⇒
/// <c>StealthManager.CrouchAssassinationUpdate</c> 永远跑不满一轮 ⇒
/// <c>ReportCanAssassinateEnemies</c> 命中恒 0 ⇒ 暗杀 HUD 死在源头。
/// </summary>
internal bool FeiSuoCrouchActive { get; set; }
/// <summary>
/// 已下发 <c>SyncEnemyCanAssassinate = true</c> 的 agent 集合。
///
/// 客户端 <c>StealthManager</c> 每 0.4s 上报一次候选名单(<c>ReportCanAssassinateEnemies</c>),
/// 蹲着不动时名单完全一样 ⇒ 用它做<b>差分</b>,只有集合真的变化才发包。
/// 不做差分的话一个蹲伏姿态每秒会多推 2~3 个包 * N 个敌人。
/// </summary>
internal HashSet<ulong> AssassinationGrantedAgents { get; } = [];
/// <summary>
/// ★ 本次蹲伏里<b>已经播种过</b> <c>canAssassinate = true</c> 的 agent(硬规则 192)。
///
/// 与 <c>AssassinationGrantedAgents</c> 的区别:后者是「当前还授权着」的集合,会被
/// 上报路径的撤销逻辑摘掉;前者只记「我播种过谁」。
///
/// 为什么需要两个:打破死锁的那次播种是<b>盲发</b>(客户端还没算过候选),播完之后
/// 客户端下一轮 0.4s 检测若判定它不合格,就会上报一个不含它的名单 ⇒ 上报路径撤销它。
/// 若只看 <c>AssassinationGrantedAgents</c>,播种逻辑会以为「没授权过」而再播一次,
/// 撤销→播种→撤销来回甩(500ms 一轮不停发包)。有了这个集合,<b>每个敌人每次蹲伏
/// 只播种一次</b>;离开播种半径后重新靠近才会再播。
/// ⛑ 进出蹲伏态时清空(见 <c>OnEnter/OnLeaveFeiSuoCrouch</c>)。
/// </summary>
internal HashSet<ulong> AssassinationSeededAgents { get; } = [];
/// <summary>播种扫描上次跑的时间戳(<c>Environment.TickCount64</c>)。
/// 挂在敌人 AI worker 的 100ms tick 上,用它节流到 <c>AssassinationSeedIntervalMs</c>。</summary>
internal long AssassinationSeedAtTicks { get; set; }
// Exact instance ids for client-owned 4229938 traversal states (SwingBuff/WallRushBuff).
// They are created/removed on demand and are intentionally not resident login buffs.
internal Dictionary<(ulong UnitId, uint BuffId), uint> ActiveClientWebBuffInstances { get; } = [];
internal ulong SceneId { get; set; }
internal ulong ActiveSpiritUnitId { get; set; } = Profile.InitialUnitId;
internal uint ActiveSpiritTemplateId { get; set; } = Profile.InitialSpiritTemplateId;
/// <summary>
/// ★★ <c>gameplay.webTraversal.parkourStateGates</c> 的记账:当前<b>已下发且仍挂在客户端上</b>的
/// ParkourState id 集合(严格跟状态:进了才发、离开立即撤)。
///
/// 为什么需要它:客户端 <c>OnParkourStateChange</c>(67897801) 上报是<b>高频</b>的(实测约 1.2 次/秒,
/// 一份 7 分钟的日志 516 次)。没有记账就会每次上报都发一遍 <c>SyncPaokuLimitChange</c>,
/// 大跳门会被反复 add/remove 抖动。有了它只在 <b>0→1 / 1→0 的边沿</b>各发一次。
///
/// ⛑ 硬规则 240 的哨兵要求在这里天然满足:集合初值是<b>空</b>(≠「无目标」的任何 state id),
/// 所以「从没下发过」和「下发过」可区分,第一次进入稳定触发一次 add。
/// ⛑ 会话内状态、不落盘 —— 重连后为空,重新按上报累积。
/// </summary>
internal HashSet<uint> ActiveParkourGateStates { get; } = [];
/// <summary>
/// <see cref="ActiveParkourGateStates"/> 记账对应的单位 id。**0 = 还没记过**(哨兵,合法 unitId 不为 0)。
///
/// 用途:切角色后单位换了,旧单位上那几张门随之消失 —— 记账必须整个重置,
/// 否则新单位会以为「已经下发过」而永远不发。⛑ 重置时<b>不</b>补发
/// <c>isAdd=false</c>:旧单位已经不在场了,撤它的门没有意义(与切角色不撤销常驻门同理)。
/// </summary>
internal ulong ActiveParkourGateUnitId { get; set; }
/// <summary>
/// Server-authoritative player HP. Only enemy hits move it, and only while
/// <see cref="PlayerLifeLocked"/> is off. Reset to full by RestorePlayerHealthAsync.
/// </summary>
internal float PlayerHp { get; set; } = CombatCodec.MaxHp;
/// <summary>
/// GM vitals lock. ON by default: the player still gets hit, stiffened and cast-locked, but HP
/// never moves, so a session is never ended by an enemy we cannot balance. Turn it off from the
/// admin panel to make the player mortal.
/// </summary>
internal bool PlayerLifeLocked { get; set; } = true;
/// <summary>True while the knocked-out sequence is running; blocks further damage.</summary>
internal bool PlayerDowned { get; set; }
internal uint WorldEntrySwitchShowId { get; set; }
internal uint LastSwitchShowId { get; set; }
internal int SwitchCount { get; set; }
/// <summary>
/// 本次切人**发起**时的 <c>Environment.TickCount64</c>。
/// 唯一用途:客户端回报 <c>AskSwitchSpiritComplete</c>(67272187) 时算
/// 「整条演出真实播了多久」—— 这是我们唯一能实测演出时长的点
/// (09-26 00:4x 加;CBT3 用它标定 apexDelayMs,我们暂时只记日志)。
/// </summary>
internal long SwitchStartedTicks { get; set; }
/// <summary>
/// ★★ 本次会话里**玩家亲手操控过**的角色(templateId)。
///
/// 用途:切走一个角色时决定要不要让他「去过日子」(<c>LeftBehindDailyLife</c>,
/// 即按「现在几点」把他刷到日程活动的坐标上)。用户 09-26 03:3x 定:
/// **一旦玩家控制过某个角色,他就该停在玩家最后离开他的地方**,别再被瞬移到日程点。
/// ⇒ 被操控过 ⇒ 跳过过日子(留在原地);没操控过 ⇒ 照旧按日程去过日子。
///
/// ⛑ 写入点只有两个:进世界时(<c>GameRouter.World.cs</c> 的初始角色)和
/// 切人完成移交时(<c>GameRouter.Switching.cs</c>)。别在别处加,否则语义会漂。
/// ⛑ 会话内状态、不落盘 —— 重连后重新累积。
/// ⛑ 别拿 <c>LeftBehindCharacters</c> 当这个判据:那是「切走后留在世界里的身体」,
/// 切走的那一刻它刚被写进去,恒为真,区分不出「有没有被操控过」。
/// </summary>
internal HashSet<uint> ControlledSpirits { get; } = new();
/// <summary>这个角色玩家是否亲手操控过(见 <see cref="ControlledSpirits"/>)。</summary>
internal bool HasControlledSpirit(uint templateId) => ControlledSpirits.Contains(templateId);
/// <summary>登记「玩家开始操控这个角色」。0 表示无效 id,直接忽略。</summary>
internal void MarkSpiritControlled(uint templateId)
{
if (templateId != 0)
ControlledSpirits.Add(templateId);
}
// Native 4229938 SwitchTeleport is a three-phase transaction. Only the current character
// exists as the controlled actor. Every target is materialized by the same generic AOI path
// after the client reports that the source close-up has finished.
internal uint PendingSwitchTemplateId { get; set; }
internal ulong PendingSwitchUnitId { get; set; }
internal ulong PendingSwitchOldUnitId { get; set; }
internal uint PendingSwitchShowId { get; set; }
internal Vec3 PendingSwitchPosition { get; set; } = Profile.WorldSpawn;
internal float PendingSwitchFacing { get; set; } = Profile.WorldFacing;
internal bool PendingSwitchControlTransferred { get; set; }
internal bool PendingSwitchLandingStarted { get; set; }
// 切人演出(SwitchSpiritConfig 行驱动的两阶段切人)的挂起态。
// ⛑ PendingSwitchPosition/Facing 存的是「演出落点」(行的 Position/TimeLineAngleY),
// 不是玩家发起切人时站的位置 —— 捐赠端就是因为把两者混为一谈,才出现「镜头飞到远处却没人、
// 播完又拉回原角色原地切人」。
internal bool PendingPreSwitchSent { get; set; }
internal long PendingSwitchRequestedTicks { get; set; }
internal uint PendingSwitchConfigId { get; set; }
internal bool PendingSwitchConfigPublished { get; set; }
internal Vec3 PendingSwitchLandingPosition { get; set; } = Profile.WorldSpawn;
internal float PendingSwitchLandingFacing { get; set; } = Profile.WorldFacing;
/// <summary>目标角色是否已经在演出开始前预生成到落点了(决定 phase 2 能否被 6s 超时兜底接管)。</summary>
internal bool PendingSwitchAgentPreSpawned { get; set; }
/// <summary>
/// 目标角色此刻已经留在大世界里(上次被切换掉时没移除,见 <see cref="LeftBehindCharacters"/>)。
/// true ⇒ phase 1 不再预生成(重复投放同一个 unitId),但 phase 2 仍要走 Leave+重建
/// 才能把它从「服务端驱动」变回玩家可操控 —— 语义与预生成相同。
/// </summary>
internal bool PendingSwitchTargetInWorld { get; set; }
/// <summary>
/// 目标角色已经在<b>演出开始之前</b>以主角身份重建好了(<c>RecreateHeroBeforeShow</c>)。
/// true ⇒ phase 2 不再重复建一遍,也不再发 <c>SyncLogicAgentLeave</c>。
///
/// ⛑⛑⛑ 为什么必须提前:客户端的 LiftOffOneShot 时间线是 <c>SyncPreSwitchSpirit</c> 起的,
/// 实测(2026-09-25 01:31:50 → 01:32:04)整整跑 <b>14 秒</b>。我们把 Leave+重建放在
/// <c>ReportPreSwitchSpiritFinish</c> 之后 = 时间线跑到 3.5 秒时把主角单位拆了
/// ⇒ 时间线在 12.5 秒处连抛 3 条 <c>SwitchChar_common_04 2 System.NullReferenceException</c>
/// ⇒ 客户端再也建不出主角 ⇒ <c>InitModules … IsMe False</c> ⇒ 原地跑步、坐标不动。
/// </summary>
internal bool PendingSwitchHeroPrebuilt { get; set; }
/// <summary>
/// ⛑⛑⛑ 2026-10-04:<b>演出演员</b>的 uid —— 服务端为这出切人演出 spawn 的、站在落点上
/// 演戏的那些身体(主演 + 陪衬)。
///
/// 官方语义见 <c>ClientConfigRepository.cs:862</c> 引的注释:
/// "Those rows carry AgentId entries that <b>stock SwitchTeleport expects the server to spawn
/// before landing</b>."
/// ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent spawn 好;
/// 主线此前<b>一个都没 spawn</b>(只把主角单位换皮顶上)⇒ 演出里道具/环境在动、人没有。
///
/// 主演取演出行的 <c>FightSpiritIds[0]</c>,经 <c>GameAdminBridge.AgentIdForSpirit</c>
/// 映射成 AgentId(嘉姆 15020989→40140032、塔菲 15020992→40140035);
/// 陪衬直接用行的 <c>AgentIds</c>。
/// 演完(<c>ReportPreSwitchSpiritFinish</c>)后由服务端 Leave 掉。
/// </summary>
internal List<ulong> PendingSwitchShowActors { get; set; } = [];
/// <summary>
/// 被切换掉但留在大世界里的角色:templateId → 它的 unitId 与留下时的位姿。
/// 移交控制权时旧单位不再发 SyncLogicAgentLeave(配置 KeepLeftBehindCharacters),
/// 下次切回这个角色时落点直接取这里记的位置,而不是 SwitchSpiritConfig 行的演出锚点。
/// 单位本身不移动(服务端驱动,没人喂位置包),所以这份位置就是它的实时位置。
/// ⛑⛑ 2026-09-24 18:1x 实测:**不喂位置包它会自由落体掉出世界**(客户端停掉自己的 AI
/// 后只做物理模拟,这类 actor 没有地面吸附 ⇒ Y 一路掉到 -3000)。所以服务端必须按
/// <c>FeedLeftBehindPositions</c> 周期性把它钉回 <c>Position</c>(见 WorldCodec.LogicAgentSyncData)。
/// </summary>
internal Dictionary<uint, LeftBehindCharacter> LeftBehindCharacters { get; } = new();
/// <summary>
/// 旧角色(被切换掉的那个)留下时的位姿,在 <c>OnSwitchSpirit</c> 冻结玩家位置的那一刻
/// 一并记下。<b>不能</b>在 phase 2 现读 <c>LastReportedPlayerPosition</c>:演出期间客户端的
/// SwitchChar Timeline 会把角色移动到落点并照常上报位置,到那时该值已被覆盖成
/// 新角色的落点 —— 16:5x 实测「老角色 T-pose 出现在新角色身边」就是这么来的。
/// </summary>
internal Vec3 SwitchOldUnitPosition { get; set; } = Profile.WorldSpawn;
internal float SwitchOldUnitFacing { get; set; }
internal bool HasSwitchOldUnitPose { get; set; }
internal sealed class LeftBehindCharacter
{
internal ulong UnitId { get; set; }
internal Vec3 Position { get; set; } = Profile.WorldSpawn;
internal float Facing { get; set; }
/// <summary>上一次给它回正位姿的时间戳(Environment.TickCount64),用于节流。</summary>
internal long LastFeedTicks { get; set; }
/// <summary>上一次给它发「原地路点任务」的时间戳(Environment.TickCount64)。</summary>
internal long LastWaypointTicks { get; set; }
/// <summary>
/// ★★ 这个角色被切走时,**时段表(NpcDailySchedule)当下那一段**指的活动 id(0 = 查不到)。
///
/// 用途:后台(<c>LeftBehindFollowSchedule</c>)每 1.5 秒拿「现在时段的活动 id」跟它比,
/// 不一样 ⇒ 游戏时间跨进了新的日程时段 ⇒ 该角色不再赖在原地,交还常驻化去演新活动。
///
/// ⛑⛑⛑ 必须是 <c>NpcActivityChoice.Timed</c>(时段表真正指的那条),
/// <b>不能</b>是 <c>Entry</c>(落地用的那条)。原因(2026-09-26 06:0x 实机翻车):
/// 官方 284 条活动里有 131 条是「临时点位」没编坐标(睡觉 / 休息 / 对策局吃薯片……),
/// 傍晚和夜里的时段大量命中这些条目 ⇒ <c>Resolve</c> 一律回落到
/// <c>FirstPositioned</c> —— 而它是**与时间无关**的(同 tag 在本 raid 里 Id 最小的
/// 有坐标那条),所以白天和夜里算出来的 <c>Entry.Id</c> **完全一样**。
/// 实测:05:45 真实时间切走 Mechanika 记 11400031,18:00 再算还是 11400031
/// ⇒ 判据恒等 ⇒ 时间跳了 13 小时也一次都不触发。
/// 换成 Timed 就对了:11400031(跳大绳) → 11400034(睡觉),里希 11400011 → 11400014。
///
/// ⛑⛑ 必须记**真实活动 id**(不是出场包里那个 activityCfgId —— 那个可能被
/// <c>poiOverrides.sendActivityCfgId=false</c> 抹成 0)。记 0 会导致每个 tick 都判定
/// 「时段变了」⇒ 1.5 秒 retire+place 一次,客户端又没真撤掉 ⇒ 原地堆出一排人
///(09-25 跑步机堆里希就是这么来的,常驻化那边同一条坑,见 RosterNpc.cs 账本注释)。
/// ⛑ 两个主角(AgentTag 2/3)压根没有时刻表 ⇒ 恒为 0 ⇒ 永远不触发,他们就该一直
/// 待在玩家最后离开的地方 —— 这是对的,不要当成 bug 去"修"。
/// </summary>
internal uint TimedActivityId { get; set; }
/// <summary>
/// 切走他时 <see cref="TimeEpoch"/> 的值。玩家后来主动改过时间 ⇒ 两者不同 ⇒
/// 该角色不再赖在原地,交还常驻化去过日子(开关 LeftBehindFollowOnTimeJump)。
///
/// ⛑ 与 <see cref="TimedActivityId"/> 是<b>两个独立触发条件</b>,任一满足即放行:
/// ① 时刻表的时段真的翻篇了(实时流逝也能触发,见 TimedActivityId)
/// ② 玩家主动推了时间(哪怕还在同一个时段里 —— 06:13 跳到 06:00 就是这种)
/// </summary>
internal uint TimeEpoch { get; set; }
// ── 闲逛(2026-09-26 18:5x;开关 SwitchAnimations.LeftBehindWander)────────────────
/// <summary>
/// 当前这一段行程的折线(<c>PedLaneGraph.TryPlanJourney</c> 规划出来的人行道路径)。
/// null = 现在没有行程,下次循环会重新挑一个终点并规划。
/// ⛑ 只在这里记、只在 <c>Movement.cs</c> 里用;由 <c>SyncRoot</c> 保护。
/// </summary>
internal List<(float X, float Y, float Z)>? WanderRoute { get; set; }
/// <summary>行程折线里已经派过的下标(下次从 <c>WanderRouteIdx</c> 开始往下发)。</summary>
internal int WanderRouteIdx { get; set; }
/// <summary>
/// 下一次该派新行程的时间戳(<c>Environment.TickCount64</c>)。
/// ⛑ 按「本段路程 ÷ 速度 + 余量」算(硬规则 109:不能固定步长,否则一步一停)。
/// </summary>
internal long WanderNextTicks { get; set; }
// ── POI 轮换(2026-09-26 23:4x;开关 SwitchAnimations.LeftBehindPoiRotation)──────
/// <summary>
/// 出场包里实际带出去的 <c>AgentDataSetsActivityCfgId</c>。
/// ⛑⛑ 轮换的门禁:<b>非 0 ⇒ 不轮换</b> —— 客户端那时在走生活日程,
/// ambient 会盖掉我们推的 POI(硬规则 163)。与 <see cref="TimedActivityId"/> 是两回事
/// (那个记的是真实活动 id,这个记的是"有没有进生活日程状态")。
/// </summary>
internal uint ActivityCfgId { get; set; }
/// <summary>
/// 洗牌袋:本轮要播的 POI 顺序(组的副本打乱后的结果)。
/// null = 还没建/这个角色不轮换。
/// </summary>
internal uint[]? PoiBag { get; set; }
/// <summary>洗牌袋里下一条的下标;走到末尾 ⇒ 重新洗牌、归零。</summary>
internal int PoiBagIdx { get; set; }
/// <summary>下一次该换 POI 的时间戳(<c>Environment.TickCount64</c>)。0 = 立刻换。</summary>
internal long PoiNextTicks { get; set; }
}
/// <summary>
/// 「留在大世界里」的角色续发路点任务的后台循环句柄。
/// 由 SyncRoot 保护;session 断开后循环自己会因写 socket 抛异常而退出。
/// </summary>
internal System.Threading.CancellationTokenSource? LeftBehindCts { get; set; }
internal System.Threading.Tasks.Task? LeftBehindLoop { get; set; }
// ── 改装店改装件(2026-09-26 自 repair-shop.zip 移植)─────────────────────────────────
// 「买过的件」是服务端唯一的真相源:客户端 ApplyCar 面板自己按 VehiclePartConfig 应用,
// 但**刷车时**的外观(SyncLogicVehicleEnter / SyncSpawnVehicle / SyncAllUnlockedVehicles
// 的 Parts + SuitId)只能由服务端给出。全部由 SyncRoot 保护。
/// <summary>
/// vehicleConfigId → 买过的改装件(PartTag, PartConfigId)。
/// 一个槽位只留一条(新买的按 PartTag 顶掉旧的);type=18 的套件不进这里(见下)。
/// </summary>
internal Dictionary<uint, List<(uint Type, uint ConfigId)>> VehiclePartsByConfig { get; } = new();
/// <summary>vehicleConfigId → 买过的套件 id(VehiclePartSuitConfig 的行 id)。</summary>
internal Dictionary<uint, uint> VehicleSuitByConfig { get; } = new();
/// <summary>本次会话里因为购买而解锁过的车辆 config(用于 SyncAllUnlockedVehicles 重推)。</summary>
internal HashSet<uint> SessionUnlockedVehicleConfigs { get; } = new();
// Admin / vehicle Phase-1 tracking. Guarded by SyncRoot like the rest of the state.
internal uint LastAdminVehicleId { get; set; }
internal ulong LastSpawnedVehicleUid { get; set; }
internal byte LastVehicleDriveState { get; set; }
internal bool HasVehicleDriveState { get; set; }
// Admin "spawn NPC": spirit-entity units materialized beside the player. Kept as a list
// because SyncLogicAgentLeave does not retire these units (the roster unitIds are also the
// switch-flow identities, so a summoned copy has to carry its own id), which means several
// can legitimately be standing at once.
internal ulong LastSummonedNpcEntityId { get; set; }
internal List<ulong> SummonedNpcEntityIds { get; } = new();
// Scene switches can land on any owned character; the strict initial-identity
// commit check is relaxed only when a switch rearmed the transaction.
internal bool WorldEntryAllowNonInitialControl { get; set; }
/// <summary>
/// Landing pose to re-apply once the client finishes loading a scene switch into the
/// raid/instance it was already running.
///
/// Re-pushing SyncEnterScene for the same raid+instance makes the client reload the level but
/// leave the avatar standing where it was — verified 2026-09-19: two re-logins restored the
/// pre-switch position even though the destination was ~1900m away. The load is still worth
/// doing (it refreshes props and re-arms the world-entry transaction), so the landing is
/// stored here and sent as a real move once the entry flow reaches ready.
/// </summary>
internal Vec3? SceneLandingPosition { get; set; }
/// <summary>Facing (Unity Euler Y, degrees) paired with <see cref="SceneLandingPosition"/>.</summary>
internal float? SceneLandingFacing { get; set; }
// Time/weather state (persist across scene switches; pushed on load when set).
internal uint TimeHour { get; set; }
internal uint TimeMinute { get; set; }
internal bool TimeFixed { get; set; }
/// <summary>
/// ★★ 玩家**主动改时间**的次数(<c>AskPassingTime</c> / <c>GmSetTime</c> / <c>GmPassingTime</c> /
/// <c>GmFixRaidTime</c> / 面板设时间 / 面板「解除固定」都 +1)。
///
/// 为什么不用 <see cref="CurrentDaySecond"/> 直接比:实时流逝也是每秒 +1,
/// 而「被玩家操控过的角色赖在他最后的位置」这条需求<b>恰恰要忽略实时流逝</b>
/// —— 玩家在城里逛 10 分钟,里希不该自己跑掉。
/// 只有玩家<b>主动</b>把时间推走(游戏里的「度过时间」、GM/面板改时间)才算数。
///
/// 由 <c>SyncRoot</c> 保护;调用方一律 `lock (state.SyncRoot) state.TimeEpoch++;`。
/// </summary>
internal uint TimeEpoch { get; set; }
internal uint TimeTransitionSeconds { get; set; }
internal bool HasExplicitTime { get; set; }
internal uint WeatherId { get; set; }
/// <summary>
/// 现在是一天里的第几秒 —— 所有「按几点挑配置」的逻辑(NPC 日程 / 人群密度)都用这个值。
/// ⛑ 基准是<b>本地时间</b>(规则 149),与 <c>GameAdminBridge</c> 人群密度同款;
/// 面板设过固定时间(<see cref="HasExplicitTime"/>)就按面板。
/// </summary>
internal int CurrentDaySecond()
=> HasExplicitTime
? (int)TimeHour * 3600 + (int)TimeMinute * 60
: (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds;
internal uint WeatherTransitionSeconds { get; set; }
internal bool HasExplicitWeather { get; set; }
internal bool IsHangup { get; set; }
// ── 通缉系统 (Wanted) ────────────────────────────────────────────────────────────────
// 2026-09-23 自捐赠端 miyuki_fix 移植的「警车版」:服务端自己记犯罪值、自己派警车,
// 完全不接官方 SyncAgentCrimeAdd(68831313) / ReportPoliceCrimeEvent(67146776) /
// GmAddCrimeValue(69946581)。全部由 SyncRoot 保护。
internal uint WantedCrimeValue { get; set; }
internal uint WantedLevel { get; set; }
internal bool WantedChaseActive { get; set; }
internal HashSet<ulong> WantedPoliceVehicleIds { get; } = new();
internal HashSet<ulong> WantedKilledNpcIds { get; } = new();
internal HashSet<ulong> WantedDestroyedVehicleIds { get; } = new();
internal ulong WantedLastIncidentTarget { get; set; }
internal long WantedLastVehicleHitTicks { get; set; }
internal long WantedStartedTicks { get; set; }
internal bool WantedEscapeActive { get; set; }
internal int WantedEscapeGeneration { get; set; }
internal long WantedEscapeDeadlineTicks { get; set; }
internal int WantedEscapeDurationSeconds { get; set; }
internal double WantedPendingReward { get; set; }
internal double WantedRewardTotal { get; set; }
internal double WantedPenaltyTotal { get; set; }
internal int WantedEscapeCount { get; set; }
internal int WantedArrestCount { get; set; }
internal uint WantedNpcHitCount { get; set; }
internal uint WantedNpcKillCount { get; set; }
internal uint WantedVehicleHitCount { get; set; }
internal uint WantedVehicleDestroyCount { get; set; }
internal string WantedLastIncident { get; set; } = "";
internal string WantedLastOutcome { get; set; } = "";
internal string WantedLastSync { get; set; } = "";
// ── 迷雾 / 探索 POI(2026-09-24 自捐赠端 DremkaPS 移植)──────────────────────────────
// 数据驱动选出来的:66 个「捐赠端有、本服没有」的 handler 里,只有 6 个在 16.5 万行
// unknown-methods.log 里出现过,而 SyncEnterFogMapPoiId 一个人就占了 49 次(活跃到 09-23)。
// 其余 60 个客户端从没主动发过 —— 注册它们只是增加维护面。
// 由 SyncRoot 保护。
/// <summary>玩家踏入过的迷雾 POI(<c>SyncEnterFogMapPoiId</c> 上报)。</summary>
internal HashSet<uint> FogMapPoiIds { get; } = new();
/// <summary>控制台 <c>GmUnlockAllFogMap</c> 请求过的「全解锁」标记。</summary>
internal bool FogMapAllUnlocked { get; set; }
/// <summary>Aether 氛围层开关(<c>GmSetAmbianceEnabled</c> / <c>GmGetAmbianceEnabled</c> 一对)。</summary>
internal bool AmbianceEnabled { get; set; }
/// <summary><see cref="AmbianceEnabled"/> 的 type 参数。</summary>
internal int AmbianceType { get; set; }
// ── GM 控制台职业命令(GmJob*)────────────────────────────────────────────────────────
// 2026-09-24 自捐赠端 DremkaPS 移植,只取职业部分。由 GmJobs.SyncRoot 保护(独立锁:
// HTTP 面板线程在会话拆掉之后仍可能摸到它)。HasGmOverride=false 时职业完全由配置驱动,
// GM 命令一条都没到过,推送照旧。
internal GmJobState4229938 GmJobs { get; } = new();
// 10-Second Position History Rollback Buffer
internal sealed record PositionCheckpoint(DateTime Timestamp, Vec3 Position, float Facing);
private readonly LinkedList<PositionCheckpoint> _positionHistory = new();
private DateTime _lastCheckpointTime = DateTime.MinValue;
private static readonly LinkedList<PositionCheckpoint> s_globalPositionHistory = new();
private static readonly object s_globalSyncRoot = new();
internal static void RecordGlobalCheckpoint(Vec3 pos, float facing)
{
lock (s_globalSyncRoot)
{
var now = DateTime.UtcNow;
s_globalPositionHistory.AddLast(new PositionCheckpoint(now, pos, facing));
var cutoff = now.AddSeconds(-60);
while (s_globalPositionHistory.Count > 0 && s_globalPositionHistory.First!.Value.Timestamp < cutoff)
s_globalPositionHistory.RemoveFirst();
}
}
internal static PositionCheckpoint? TryGetGlobalCheckpointSecondsAgo(double seconds = 10.0)
{
lock (s_globalSyncRoot)
{
if (s_globalPositionHistory.Count == 0) return null;
var targetTime = DateTime.UtcNow.AddSeconds(-seconds);
PositionCheckpoint? best = null;
double bestDiff = double.MaxValue;
foreach (var cp in s_globalPositionHistory)
{
var diff = Math.Abs((cp.Timestamp - targetTime).TotalSeconds);
if (diff < bestDiff) { bestDiff = diff; best = cp; }
}
return best ?? s_globalPositionHistory.First?.Value;
}
}
internal void RecordPositionCheckpoint(Vec3 pos, float facing)
{
lock (SyncRoot)
{
var now = DateTime.UtcNow;
if ((now - _lastCheckpointTime).TotalMilliseconds < 500) return;
_lastCheckpointTime = now;
_positionHistory.AddLast(new PositionCheckpoint(now, pos, facing));
var cutoff = now.AddSeconds(-30);
while (_positionHistory.Count > 0 && _positionHistory.First!.Value.Timestamp < cutoff)
_positionHistory.RemoveFirst();
}
RecordGlobalCheckpoint(pos, facing);
}
internal PositionCheckpoint? TryGetCheckpointSecondsAgo(double seconds = 10.0)
{
lock (SyncRoot)
{
if (_positionHistory.Count == 0)
return TryGetGlobalCheckpointSecondsAgo(seconds);
var targetTime = DateTime.UtcNow.AddSeconds(-seconds);
PositionCheckpoint? best = null;
double bestDiff = double.MaxValue;
foreach (var cp in _positionHistory)
{
var diff = Math.Abs((cp.Timestamp - targetTime).TotalSeconds);
if (diff < bestDiff) { bestDiff = diff; best = cp; }
}
return best ?? _positionHistory.First?.Value;
}
}
}
/// <summary>Where the server last put a static NPC, and which way it faces (radians).</summary>
internal readonly record struct StaticNpcPose(float X, float Y, float Z, float Facing);
/// <summary>
/// One weapon lying in the world.
///
/// The drop is authoritative now: the slot the weapon came from is emptied while it is on the
/// ground and restored on pick-up, so <see cref="SlotIndex"/> is what makes the round trip exact.
/// A drop that was not a weapon (or one whose weapon was not in a slot) carries SlotIndex = -1 and
/// only the destructible is tracked.
/// </summary>
internal sealed class WeaponDropRecord
{
internal ulong DestructibleId { get; init; }
internal ulong WeaponInstanceId { get; init; }
internal uint SpiritTemplateId { get; init; }
/// <summary>Index into WorldEntryState.WeaponSlotsBySpirit, or -1 when the weapon had no slot.</summary>
internal int SlotIndex { get; init; } = -1;
}
/// <summary>One interactive prop (Gadget) placed into the world from the admin panel.</summary>
internal sealed class SpawnedGadgetRecord
{
internal ulong InstanceId { get; init; }
/// <summary>Resource path id from GadgetCatalog.json — which prefab the client loaded.</summary>
internal int PathId { get; init; }
internal string Name { get; init; } = "";
internal float X { get; init; }
internal float Y { get; init; }
internal float Z { get; init; }
/// <summary>Yaw in degrees, kept so a re-push reproduces the original facing.</summary>
internal float Yaw { get; init; }
/// <summary>家具机关标记:网格 AOI 撤掉后要补发(见 GameRouter.HouseFurnitureGadget.cs)。</summary>
internal bool IsHouseFurniture { get; init; }
}