842 lines
50 KiB
C#
842 lines
50 KiB
C#
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 & 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; }
|
||
}
|