This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/pkg/protocol/models/world_entry_state.go
T

648 lines
41 KiB
Go
Raw Normal View History

package models
import (
"context"
"encoding/json"
"sync"
)
var _ = json.RawMessage{}
var _ = sync.Mutex{}
var _ = context.Background
type WorldEntryState struct {
SyncRoot any `json:"syncRoot"`
//
// 静态 NPC 生成的串行门。逐字对齐捐赠端 WorldEntryState.cs:13
// (internal SemaphoreSlim StaticNpcSpawnGate { get; } = new(1, 1);)。
// ⛑ 2026-10-02 补:主线移植 GameRouter.Npc.cs → GameRouter.DonorNpc.cs 时
// 漏了这道门。一个 NPC 的诞生是**一串有序包**(`SyncAetherAIStaticNpcAddData` →
// `SyncManagedLogicAgent` → `SyncBattleAiS=false` → POI → 归属物 → 警察登记),
// 两批并发会把这串包交错发出,客户端拿到的实体就是半初始化的 ——
// 表现是「NPC 看得到,但原生盘查菜单弹不出来」,且服务端日志一切正常。
//
StaticNpcSpawnGate *sync.Mutex `json:"staticNpcSpawnGate"`
//
// 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.
//
StaticNpcPreparedPlotEvents map[uint64]uint32 `json:"staticNpcPreparedPlotEvents"`
//
// 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.
//
StaticNpcPoses map[uint64]StaticNpcPose `json:"staticNpcPoses"`
//
// 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.
//
WeaponDrops map[uint64]WeaponDropRecord `json:"weaponDrops"`
//
// 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.
//
NextWeaponDropId uint64 `json:"nextWeaponDropId"`
//
// 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.
//
SpawnedGadgets map[uint64]SpawnedGadgetRecord `json:"spawnedGadgets"`
//
// Next Gadget instance id. Separate band from the weapon drop ids so the two never collide.
//
NextSpawnedGadgetId uint64 `json:"nextSpawnedGadgetId"`
//
// 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.
//
DynamicGoActive map[int32]bool `json:"dynamicGoActive"`
InitialStateSent bool `json:"initialStateSent"`
EnterSceneSent bool `json:"enterSceneSent"`
SceneLoadAckSeen bool `json:"sceneLoadAckSeen"`
GameResourcesReadySeen bool `json:"gameResourcesReadySeen"`
ControlPublished bool `json:"controlPublished"`
SceneCompletionSent bool `json:"sceneCompletionSent"`
CurrentSpiritStateSent bool `json:"currentSpiritStateSent"`
Ready bool `json:"ready"`
LivePlayerProfilePublished bool `json:"livePlayerProfilePublished"`
SameSceneAckSeen bool `json:"sameSceneAckSeen"`
MovementCapabilityPublished bool `json:"movementCapabilityPublished"`
FreeRoamReleased bool `json:"freeRoamReleased"`
FirstMovementSeen bool `json:"firstMovementSeen"`
InitialCapabilityBuffsPublished bool `json:"initialCapabilityBuffsPublished"`
AllBuildBuffsPublished bool `json:"allBuildBuffsPublished"`
InitialActorPresentationPublished bool `json:"initialActorPresentationPublished"`
CombatProfilePublished bool `json:"combatProfilePublished"`
AccountArmoryPublished bool `json:"accountArmoryPublished"`
// 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).
AccountArmoryAllWeaponsSent bool `json:"accountArmoryAllWeaponsSent"`
// One-shot guard for the SyncSceneFogMapAllUnlock replay. Reset on every scene load
// (see AllBuildBuffsPublished) because the client rebuilds MapFogDataMgr per scene.
MapFogUnlockPublished bool `json:"mapFogUnlockPublished"`
// One-shot guard for the SyncPlayerAllTask replay (reset per scene load, same as fog).
PlayerTasksPublished bool `json:"playerTasksPublished"`
//
// 客户端 SyncChangeIndoor 上报的 indoorConfigId —— 室内判定的权威值。
// 0 = 客户端认为自己不在任何室内。见 GameRouter.IndoorSector.cs 的
// PreferClientReport:几何最近邻在 MiniMapPos 矩形重叠的场馆(ACD 76/129)会抖。
//
ClientIndoorConfigId uint32 `json:"clientIndoorConfigId"`
//
// 上一次收到 SyncChangeIndoor 的 Environment.TickCount64。
// 0 = 从没收到过。用于判断上报值是否还新鲜(ClientReportGraceMs)。
//
ClientIndoorReportMs int64 `json:"clientIndoorReportMs"`
// Current public-world destination. Initial values come from private-server.json; airport
// travel updates these per scene generation without mutating global configuration.
ActiveRaidId uint32 `json:"activeRaidId"`
ActiveInstanceId uint64 `json:"activeInstanceId"`
ActiveUniverseId uint32 `json:"activeUniverseId"`
ActiveContentScene string `json:"activeContentScene"`
WorldEntryIsAirportTravel bool `json:"worldEntryIsAirportTravel"`
// 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.
PendingTeleportId uint64 `json:"pendingTeleportId"`
PendingAirportRouteKey string `json:"pendingAirportRouteKey"`
PendingTeleportPreFinished bool `json:"pendingTeleportPreFinished"`
PendingTeleportSyncSent bool `json:"pendingTeleportSyncSent"`
// 改装店(ApplyCar)离场时间戳:客户端自己的触发区会在离场落地后 0.3s 再补一发
// config=8 的 AskTeleport( garage_in02 ),照着 echo 会把人/车重新拽回店里无限循环。
// 4s 内压掉 enter 侧的 echo(真要再进一次,开车压触发区的时间远不止 4s)。见硬规则 166。
LastShopExitTicks int64 `json:"lastShopExitTicks"`
// Build 4229938: one generation-bound initial-player handoff.
// 0 = edge not claimed; -generation = claimed/in-flight; +generation = published.
WorldEntryControlPending bool `json:"worldEntryControlPending"`
WorldEntryControlFinalized bool `json:"worldEntryControlFinalized"`
WorldEntryControlUnit uint64 `json:"worldEntryControlUnit"`
WorldEntryControlTemplate uint32 `json:"worldEntryControlTemplate"`
WorldEntryControlGeneration int32 `json:"worldEntryControlGeneration"`
WorldEntryLoadingCompletedGeneration int32 `json:"worldEntryLoadingCompletedGeneration"`
WorldEntryOpeningEndedGeneration int32 `json:"worldEntryOpeningEndedGeneration"`
WorldEntryControlFinalizingGeneration int32 `json:"worldEntryControlFinalizingGeneration"`
WorldEntryLogicProjectionPublishedGeneration int32 `json:"worldEntryLogicProjectionPublishedGeneration"`
WorldEntryCurrentMetadataPublishedGeneration int32 `json:"worldEntryCurrentMetadataPublishedGeneration"`
WorldEntrySceneId4229938 uint64 `json:"worldEntrySceneId4229938"`
WorldEntrySessionId4229938 uint64 `json:"worldEntrySessionId4229938"`
//
// 本次 world-entry 事务「应该等的 scene id」= SwitchSceneAsync 收到的 instanceId。
// ★ R101(自 donor v2.1 移植,2026-09-27 定案)—— 这是「所有摊位/摆件一起消失」的根因:
// admin 面板(或任何客户端侧)在**新场景还没加载完**时就发起切换时,时序是
// 1. SwitchSceneAsync 把 WorldEntrySceneId4229938 清 0;
// 2. **上一张图**的 AskLoadSceneCompleted 迟到抵达 ⇒ 命中「== 0」分支
// ⇒ 把权威位占成**旧 scene**;
// 3. 新图的 AskLoadSceneCompleted 随后抵达 ⇒ correlation-mismatch ⇒ 永久拒绝;
// AskLoadingFinished 同样被拒 ⇒ WorldEntryControlFinalized 永远 false
// ⇒ world-ready 钩子 EnsureGadgetGrid 从不执行 ⇒
// GADGET-GRID / SCENE-ITEM-GRID 循环不启动
// ⇒ 摊位 / 健身房器材 / 补货 / 派对家具 **全部不摆**。
// 有它之后,OnLoadSceneCompleted 能把「迟到的旧 ack」识别出来直接丢弃:
// 不占权威位、不报 mismatch。正常路径(ack 与 expected 一致)完全不变。
// 0 = 不校验(首次进世界 / 旧调用方未设置)。
//
WorldEntryExpectedSceneId uint64 `json:"worldEntryExpectedSceneId"`
WorldEntryCreateHeroPosition Vec3 `json:"worldEntryCreateHeroPosition"`
WorldEntryCreateHeroFacing float32 `json:"worldEntryCreateHeroFacing"`
LastReportedPlayerPosition Vec3 `json:"lastReportedPlayerPosition"`
LastReportedPlayerRotation Vec3 `json:"lastReportedPlayerRotation"`
HasLastReportedPlayerTransform bool `json:"hasLastReportedPlayerTransform"`
// AetherCity 人群流式:上次下发 SyncCrowdSpecifiedDensityRefresh 时的中心点。
// 玩家走远后要按这个点算位移并重发,否则走出盒子就没人了(见 GameRouter.Crowd.cs)。
LastCrowdCenter Vec3 `json:"lastCrowdCenter"`
HasCrowdCenter bool `json:"hasCrowdCenter"`
// 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.
LastPositionSaveTicks int64 `json:"lastPositionSaveTicks"`
LastSavedPlayerPosition Vec3 `json:"lastSavedPlayerPosition"`
HasSavedPlayerPosition bool `json:"hasSavedPlayerPosition"`
ActiveSkillId uint32 `json:"activeSkillId"`
RestoreResourcesAfterActiveSkill bool `json:"restoreResourcesAfterActiveSkill"`
ActiveClientSkillInstanceId int32 `json:"activeClientSkillInstanceId"`
ActiveSkillStartedTicks int64 `json:"activeSkillStartedTicks"`
ActiveWeaponInstanceId uint64 `json:"activeWeaponInstanceId"`
ActiveFightStyleId uint32 `json:"activeFightStyleId"`
LastWeaponBySpirit map[uint32]uint64 `json:"lastWeaponBySpirit"`
WeaponSlotsBySpirit map[uint32][]uint64 `json:"weaponSlotsBySpirit"`
//
// 搜打撤武器轮盘:最后一次下发给客户端的「实体化武器」表(CurrWeaponSlots 下标 → InstanceId)。
// 轮盘 bag 每变动一次就要让 gWeaponManager 跟上(否则 Tab 在轮盘武器之间根本切不动),
// 增量下发必须知道「客户端现在认为每一格是什么」,所以这里留一份服务端镜像。
// 下标口径:0 = 拳头、1 = 私有武器、轮盘 CellX c → c + 2。
// ⛑ 只记轮盘那几格(下标 2 起):0/1 是拳头和私有武器,混进来会被当成
// 「轮盘里被删掉的格」而发 Remove,把拳头和角色自带武器摘掉。
//
ExtractionWheelPublished map[int32]uint64 `json:"extractionWheelPublished"`
WeaponStyleOverrides map[uint64]uint32 `json:"weaponStyleOverrides"`
// Persistent weapon-instance ammunition. These dictionaries intentionally survive public-scene
// generations and character switches; a weapon switch must never refill its magazine.
WeaponMagazineAmmo map[uint64]int32 `json:"weaponMagazineAmmo"`
// 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.
WeaponDurabilityAmmo map[uint64]int32 `json:"weaponDurabilityAmmo"`
WeaponBulletByInstance map[uint64]uint32 `json:"weaponBulletByInstance"`
BackpackItemCounts map[uint32]uint32 `json:"backpackItemCounts"`
// 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.
WalletMoney float64 `json:"walletMoney"`
WalletGold float64 `json:"walletGold"`
WalletBindingGold float64 `json:"walletBindingGold"`
Progress PlayerProgress `json:"progress"`
ProgressRestored bool `json:"progressRestored"`
//
// 钓鱼:玩家钓鱼档案(渔具 / 已钓鱼类 / 钓点鱼池)。是 session 级的真相源,登录时灌进
// PlayerClientInfo.InfoMinor.FishingInfo。不落盘(重登从空重建 + 登录 seed 整套渔具)。
// 2026-09-29 自捐赠端 DRMK-PS_09_HyNB-v1.9-钓鱼 移植(GameRouter.Fishing.cs 依赖它)。
//
Fishing PlayerFishingInfo `json:"fishing"`
// Granted backpack stacks keyed by item template id, so repeated grants accumulate into
// one stack instead of spraying one entry per grant.
GmBackpackItems map[uint32]PlayerPackItem `json:"gmBackpackItems"`
// 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.
PhoneSkinBySpirit map[uint32]SpiritMobileSkinInfo `json:"phoneSkinBySpirit"`
// 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.
FashionBySpirit map[uint32]FashionRecord4229938 `json:"fashionBySpirit"`
//
// 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.
//
Colorings map[uint32]FashionColoringRecord4229938 `json:"colorings"`
//
// Livehouse 音游成绩 per 曲目 id(2026-10-04)。 everything here is restored from / written to
// config/player-progress.json through PlayerProgressStore. See
// LivehouseProgressStore; absent entry means "never cleared once".
//
LivehouseRecords map[uint32]LivehouseRecord4229938 `json:"livehouseRecords"`
//
// True once AskQueryLiveHouseInfo has answered FirstPlay = true, which is what
// makes the client play the intro timeline exactly once per account.
//
LivehouseFirstPlayDone bool `json:"livehouseFirstPlayDone"`
//
// Equipped weapon skins per character (spiritId -> weapon sceneItemId -> skinId), restored from
// the player progress snapshot. Written by AskSetWeaponSkins / AskSyncWeaponSkinToSpirits.
//
WeaponSkins map[uint32]WeaponSkinRecord4229938 `json:"weaponSkins"`
SpiritStyleOverrides map[any]uint32 `json:"spiritStyleOverrides"`
CombatUseCount int32 `json:"combatUseCount"`
//
// 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.
//
GameplayTalents map[uint32]GameplayTalentState4229938 `json:"gameplayTalents"`
CombatEndCount int32 `json:"combatEndCount"`
CombatHitCount int32 `json:"combatHitCount"`
NextBuffInstanceId uint32 `json:"nextBuffInstanceId"`
ActiveFeiSuoBuffInstanceId uint32 `json:"activeFeiSuoBuffInstanceId"`
ActiveFeiSuoBuffUnitId uint64 `json:"activeFeiSuoBuffUnitId"`
//
// 玩家是否处在飞索蹲伏态(由 AskEnterFeiSuoCrouch / AskLeaveFeiSuoCrouch 维护)。
// ⛑⛑ 用途只有一个:蹲伏期间不要再把飞索 buff 加回去。
// 客户端进蹲伏态的同一帧会发 AskRemoveClientBuff(52853760) 主动摘掉飞索 buff
// (实测 19:41:32.258 进 / .262 摘)。旧逻辑在 120ms 后无条件 reassert 回去,
// 客户端随即 AskLeaveFeiSuoCrouch ⇒ 蹲伏态撑不过 0.4s ⇒
// StealthManager.CrouchAssassinationUpdate 永远跑不满一轮 ⇒
// ReportCanAssassinateEnemies 命中恒 0 ⇒ 暗杀 HUD 死在源头。
//
FeiSuoCrouchActive bool `json:"feiSuoCrouchActive"`
//
// 已下发 SyncEnemyCanAssassinate = true 的 agent 集合。
// 客户端 StealthManager 每 0.4s 上报一次候选名单(ReportCanAssassinateEnemies),
// 蹲着不动时名单完全一样 ⇒ 用它做差分,只有集合真的变化才发包。
// 不做差分的话一个蹲伏姿态每秒会多推 2~3 个包 * N 个敌人。
//
AssassinationGrantedAgents []uint64 `json:"assassinationGrantedAgents"`
//
// ★ 本次蹲伏里已经播种过 canAssassinate = true 的 agent(硬规则 192)。
// 与 AssassinationGrantedAgents 的区别:后者是「当前还授权着」的集合,会被
// 上报路径的撤销逻辑摘掉;前者只记「我播种过谁」。
// 为什么需要两个:打破死锁的那次播种是盲发(客户端还没算过候选),播完之后
// 客户端下一轮 0.4s 检测若判定它不合格,就会上报一个不含它的名单 ⇒ 上报路径撤销它。
// 若只看 AssassinationGrantedAgents,播种逻辑会以为「没授权过」而再播一次,
// 撤销→播种→撤销来回甩(500ms 一轮不停发包)。有了这个集合,每个敌人每次蹲伏
// 只播种一次;离开播种半径后重新靠近才会再播。
// ⛑ 进出蹲伏态时清空(见 OnEnter/OnLeaveFeiSuoCrouch)。
//
AssassinationSeededAgents []uint64 `json:"assassinationSeededAgents"`
// 播种扫描上次跑的时间戳(Environment.TickCount64)。
// 挂在敌人 AI worker 的 100ms tick 上,用它节流到 AssassinationSeedIntervalMs。
AssassinationSeedAtTicks int64 `json:"assassinationSeedAtTicks"`
// Exact instance ids for client-owned 4229938 traversal states (SwingBuff/WallRushBuff).
// They are created/removed on demand and are intentionally not resident login buffs.
ActiveClientWebBuffInstances map[any]uint32 `json:"activeClientWebBuffInstances"`
SceneId uint64 `json:"sceneId"`
ActiveSpiritUnitId uint64 `json:"activeSpiritUnitId"`
ActiveSpiritTemplateId uint32 `json:"activeSpiritTemplateId"`
//
// ★★ gameplay.webTraversal.parkourStateGates 的记账:当前已下发且仍挂在客户端上的
// ParkourState id 集合(严格跟状态:进了才发、离开立即撤)。
// 为什么需要它:客户端 OnParkourStateChange(67897801) 上报是高频的(实测约 1.2 次/秒,
// 一份 7 分钟的日志 516 次)。没有记账就会每次上报都发一遍 SyncPaokuLimitChange,
// 大跳门会被反复 add/remove 抖动。有了它只在 0→1 / 1→0 的边沿各发一次。
// ⛑ 硬规则 240 的哨兵要求在这里天然满足:集合初值是空(≠「无目标」的任何 state id),
// 所以「从没下发过」和「下发过」可区分,第一次进入稳定触发一次 add。
// ⛑ 会话内状态、不落盘 —— 重连后为空,重新按上报累积。
//
ActiveParkourGateStates []uint32 `json:"activeParkourGateStates"`
//
// ActiveParkourGateStates 记账对应的单位 id。**0 = 还没记过**(哨兵,合法 unitId 不为 0)。
// 用途:切角色后单位换了,旧单位上那几张门随之消失 —— 记账必须整个重置,
// 否则新单位会以为「已经下发过」而永远不发。⛑ 重置时不补发
// isAdd=false:旧单位已经不在场了,撤它的门没有意义(与切角色不撤销常驻门同理)。
//
ActiveParkourGateUnitId uint64 `json:"activeParkourGateUnitId"`
//
// Server-authoritative player HP. Only enemy hits move it, and only while
// PlayerLifeLocked is off. Reset to full by RestorePlayerHealthAsync.
//
PlayerHp float32 `json:"playerHp"`
//
// 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.
//
PlayerLifeLocked bool `json:"playerLifeLocked"`
// True while the knocked-out sequence is running; blocks further damage.
PlayerDowned bool `json:"playerDowned"`
WorldEntrySwitchShowId uint32 `json:"worldEntrySwitchShowId"`
LastSwitchShowId uint32 `json:"lastSwitchShowId"`
SwitchCount int32 `json:"switchCount"`
//
// 本次切人**发起**时的 Environment.TickCount64。
// 唯一用途:客户端回报 AskSwitchSpiritComplete(67272187) 时算
// 「整条演出真实播了多久」—— 这是我们唯一能实测演出时长的点
// (09-26 00:4x 加;CBT3 用它标定 apexDelayMs,我们暂时只记日志)。
//
SwitchStartedTicks int64 `json:"switchStartedTicks"`
//
// ★★ 本次会话里**玩家亲手操控过**的角色(templateId)。
// 用途:切走一个角色时决定要不要让他「去过日子」(LeftBehindDailyLife,
// 即按「现在几点」把他刷到日程活动的坐标上)。用户 09-26 03:3x 定:
// **一旦玩家控制过某个角色,他就该停在玩家最后离开他的地方**,别再被瞬移到日程点。
// ⇒ 被操控过 ⇒ 跳过过日子(留在原地);没操控过 ⇒ 照旧按日程去过日子。
// ⛑ 写入点只有两个:进世界时(GameRouter.World.cs 的初始角色)和
// 切人完成移交时(GameRouter.Switching.cs)。别在别处加,否则语义会漂。
// ⛑ 会话内状态、不落盘 —— 重连后重新累积。
// ⛑ 别拿 LeftBehindCharacters 当这个判据:那是「切走后留在世界里的身体」,
// 切走的那一刻它刚被写进去,恒为真,区分不出「有没有被操控过」。
//
ControlledSpirits []uint32 `json:"controlledSpirits"`
// 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.
PendingSwitchTemplateId uint32 `json:"pendingSwitchTemplateId"`
PendingSwitchUnitId uint64 `json:"pendingSwitchUnitId"`
PendingSwitchOldUnitId uint64 `json:"pendingSwitchOldUnitId"`
PendingSwitchShowId uint32 `json:"pendingSwitchShowId"`
PendingSwitchPosition Vec3 `json:"pendingSwitchPosition"`
PendingSwitchFacing float32 `json:"pendingSwitchFacing"`
PendingSwitchControlTransferred bool `json:"pendingSwitchControlTransferred"`
PendingSwitchLandingStarted bool `json:"pendingSwitchLandingStarted"`
// 切人演出(SwitchSpiritConfig 行驱动的两阶段切人)的挂起态。
// ⛑ PendingSwitchPosition/Facing 存的是「演出落点」(行的 Position/TimeLineAngleY),
// 不是玩家发起切人时站的位置 —— 捐赠端就是因为把两者混为一谈,才出现「镜头飞到远处却没人、
// 播完又拉回原角色原地切人」。
PendingPreSwitchSent bool `json:"pendingPreSwitchSent"`
PendingSwitchRequestedTicks int64 `json:"pendingSwitchRequestedTicks"`
PendingSwitchConfigId uint32 `json:"pendingSwitchConfigId"`
PendingSwitchConfigPublished bool `json:"pendingSwitchConfigPublished"`
PendingSwitchLandingPosition Vec3 `json:"pendingSwitchLandingPosition"`
PendingSwitchLandingFacing float32 `json:"pendingSwitchLandingFacing"`
// 目标角色是否已经在演出开始前预生成到落点了(决定 phase 2 能否被 6s 超时兜底接管)。
PendingSwitchAgentPreSpawned bool `json:"pendingSwitchAgentPreSpawned"`
//
// 目标角色此刻已经留在大世界里(上次被切换掉时没移除,见 LeftBehindCharacters)。
// true ⇒ phase 1 不再预生成(重复投放同一个 unitId),但 phase 2 仍要走 Leave+重建
// 才能把它从「服务端驱动」变回玩家可操控 —— 语义与预生成相同。
//
PendingSwitchTargetInWorld bool `json:"pendingSwitchTargetInWorld"`
//
// 目标角色已经在演出开始之前以主角身份重建好了(RecreateHeroBeforeShow)。
// true ⇒ phase 2 不再重复建一遍,也不再发 SyncLogicAgentLeave。
// ⛑⛑⛑ 为什么必须提前:客户端的 LiftOffOneShot 时间线是 SyncPreSwitchSpirit 起的,
// 实测(2026-09-25 01:31:50 → 01:32:04)整整跑 14 秒。我们把 Leave+重建放在
// ReportPreSwitchSpiritFinish 之后 = 时间线跑到 3.5 秒时把主角单位拆了
// ⇒ 时间线在 12.5 秒处连抛 3 条 SwitchChar_common_04 2 System.NullReferenceException
// ⇒ 客户端再也建不出主角 ⇒ InitModules … IsMe False ⇒ 原地跑步、坐标不动。
//
PendingSwitchHeroPrebuilt bool `json:"pendingSwitchHeroPrebuilt"`
//
// ⛑⛑⛑ 2026-10-04:演出演员的 uid —— 服务端为这出切人演出 spawn 的、站在落点上
// 演戏的那些身体(主演 + 陪衬)。
// 官方语义见 ClientConfigRepository.cs:862 引的注释:
// "Those rows carry AgentId entries that stock SwitchTeleport expects the server to spawn
// before landing."
// ⇒ 官方 SwitchTeleport 期望服务端在落地前把这些 agent spawn 好;
// 主线此前一个都没 spawn(只把主角单位换皮顶上)⇒ 演出里道具/环境在动、人没有。
// 主演取演出行的 FightSpiritIds[0],经 GameAdminBridge.AgentIdForSpirit
// 映射成 AgentId(嘉姆 15020989→40140032、塔菲 15020992→40140035);
// 陪衬直接用行的 AgentIds。
// 演完(ReportPreSwitchSpiritFinish)后由服务端 Leave 掉。
//
PendingSwitchShowActors []uint64 `json:"pendingSwitchShowActors"`
//
// 被切换掉但留在大世界里的角色:templateId → 它的 unitId 与留下时的位姿。
// 移交控制权时旧单位不再发 SyncLogicAgentLeave(配置 KeepLeftBehindCharacters),
// 下次切回这个角色时落点直接取这里记的位置,而不是 SwitchSpiritConfig 行的演出锚点。
// 单位本身不移动(服务端驱动,没人喂位置包),所以这份位置就是它的实时位置。
// ⛑⛑ 2026-09-24 18:1x 实测:**不喂位置包它会自由落体掉出世界**(客户端停掉自己的 AI
// 后只做物理模拟,这类 actor 没有地面吸附 ⇒ Y 一路掉到 -3000)。所以服务端必须按
// FeedLeftBehindPositions 周期性把它钉回 Position(见 WorldCodec.LogicAgentSyncData)。
//
LeftBehindCharacters map[uint32]LeftBehindCharacter `json:"leftBehindCharacters"`
//
// 旧角色(被切换掉的那个)留下时的位姿,在 OnSwitchSpirit 冻结玩家位置的那一刻
// 一并记下。不能在 phase 2 现读 LastReportedPlayerPosition:演出期间客户端的
// SwitchChar Timeline 会把角色移动到落点并照常上报位置,到那时该值已被覆盖成
// 新角色的落点 —— 16:5x 实测「老角色 T-pose 出现在新角色身边」就是这么来的。
//
SwitchOldUnitPosition Vec3 `json:"switchOldUnitPosition"`
SwitchOldUnitFacing float32 `json:"switchOldUnitFacing"`
HasSwitchOldUnitPose bool `json:"hasSwitchOldUnitPose"`
//
// 「留在大世界里」的角色续发路点任务的后台循环句柄。
// 由 SyncRoot 保护;session 断开后循环自己会因写 socket 抛异常而退出。
//
LeftBehindCts *context.CancelFunc `json:"leftBehindCts"`
LeftBehindLoop *any `json:"leftBehindLoop"`
// ── 改装店改装件(2026-09-26 自 repair-shop.zip 移植)─────────────────────────────────
// 「买过的件」是服务端唯一的真相源:客户端 ApplyCar 面板自己按 VehiclePartConfig 应用,
// 但**刷车时**的外观(SyncLogicVehicleEnter / SyncSpawnVehicle / SyncAllUnlockedVehicles
// 的 Parts + SuitId)只能由服务端给出。全部由 SyncRoot 保护。
//
// vehicleConfigId → 买过的改装件(PartTag, PartConfigId)。
// 一个槽位只留一条(新买的按 PartTag 顶掉旧的);type=18 的套件不进这里(见下)。
//
VehiclePartsByConfig map[uint32][]any `json:"vehiclePartsByConfig"`
// vehicleConfigId → 买过的套件 id(VehiclePartSuitConfig 的行 id)。
VehicleSuitByConfig map[uint32]uint32 `json:"vehicleSuitByConfig"`
// 本次会话里因为购买而解锁过的车辆 config(用于 SyncAllUnlockedVehicles 重推)。
SessionUnlockedVehicleConfigs []uint32 `json:"sessionUnlockedVehicleConfigs"`
// Admin / vehicle Phase-1 tracking. Guarded by SyncRoot like the rest of the state.
LastAdminVehicleId uint32 `json:"lastAdminVehicleId"`
LastSpawnedVehicleUid uint64 `json:"lastSpawnedVehicleUid"`
LastVehicleDriveState byte `json:"lastVehicleDriveState"`
HasVehicleDriveState bool `json:"hasVehicleDriveState"`
// 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.
LastSummonedNpcEntityId uint64 `json:"lastSummonedNpcEntityId"`
SummonedNpcEntityIds []uint64 `json:"summonedNpcEntityIds"`
// Scene switches can land on any owned character; the strict initial-identity
// commit check is relaxed only when a switch rearmed the transaction.
WorldEntryAllowNonInitialControl bool `json:"worldEntryAllowNonInitialControl"`
//
// 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.
//
SceneLandingPosition *Vec3 `json:"sceneLandingPosition"`
// Facing (Unity Euler Y, degrees) paired with SceneLandingPosition.
SceneLandingFacing *float32 `json:"sceneLandingFacing"`
// Time/weather state (persist across scene switches; pushed on load when set).
TimeHour uint32 `json:"timeHour"`
TimeMinute uint32 `json:"timeMinute"`
TimeFixed bool `json:"timeFixed"`
//
// ★★ 玩家**主动改时间**的次数(AskPassingTime / GmSetTime / GmPassingTime /
// GmFixRaidTime / 面板设时间 / 面板「解除固定」都 +1)。
// 为什么不用 CurrentDaySecond 直接比:实时流逝也是每秒 +1,
// 而「被玩家操控过的角色赖在他最后的位置」这条需求恰恰要忽略实时流逝
// —— 玩家在城里逛 10 分钟,里希不该自己跑掉。
// 只有玩家主动把时间推走(游戏里的「度过时间」、GM/面板改时间)才算数。
// 由 SyncRoot 保护;调用方一律 `lock (state.SyncRoot) state.TimeEpoch++;`。
//
TimeEpoch uint32 `json:"timeEpoch"`
TimeTransitionSeconds uint32 `json:"timeTransitionSeconds"`
HasExplicitTime bool `json:"hasExplicitTime"`
WeatherId uint32 `json:"weatherId"`
WeatherTransitionSeconds uint32 `json:"weatherTransitionSeconds"`
HasExplicitWeather bool `json:"hasExplicitWeather"`
IsHangup bool `json:"isHangup"`
// ── 通缉系统 (Wanted) ────────────────────────────────────────────────────────────────
// 2026-09-23 自捐赠端 miyuki_fix 移植的「警车版」:服务端自己记犯罪值、自己派警车,
// 完全不接官方 SyncAgentCrimeAdd(68831313) / ReportPoliceCrimeEvent(67146776) /
// GmAddCrimeValue(69946581)。全部由 SyncRoot 保护。
WantedCrimeValue uint32 `json:"wantedCrimeValue"`
WantedLevel uint32 `json:"wantedLevel"`
WantedChaseActive bool `json:"wantedChaseActive"`
WantedPoliceVehicleIds []uint64 `json:"wantedPoliceVehicleIds"`
WantedKilledNpcIds []uint64 `json:"wantedKilledNpcIds"`
WantedDestroyedVehicleIds []uint64 `json:"wantedDestroyedVehicleIds"`
WantedLastIncidentTarget uint64 `json:"wantedLastIncidentTarget"`
WantedLastVehicleHitTicks int64 `json:"wantedLastVehicleHitTicks"`
WantedStartedTicks int64 `json:"wantedStartedTicks"`
WantedEscapeActive bool `json:"wantedEscapeActive"`
WantedEscapeGeneration int32 `json:"wantedEscapeGeneration"`
WantedEscapeDeadlineTicks int64 `json:"wantedEscapeDeadlineTicks"`
WantedEscapeDurationSeconds int32 `json:"wantedEscapeDurationSeconds"`
WantedPendingReward float64 `json:"wantedPendingReward"`
WantedRewardTotal float64 `json:"wantedRewardTotal"`
WantedPenaltyTotal float64 `json:"wantedPenaltyTotal"`
WantedEscapeCount int32 `json:"wantedEscapeCount"`
WantedArrestCount int32 `json:"wantedArrestCount"`
WantedNpcHitCount uint32 `json:"wantedNpcHitCount"`
WantedNpcKillCount uint32 `json:"wantedNpcKillCount"`
WantedVehicleHitCount uint32 `json:"wantedVehicleHitCount"`
WantedVehicleDestroyCount uint32 `json:"wantedVehicleDestroyCount"`
WantedLastIncident string `json:"wantedLastIncident"`
WantedLastOutcome string `json:"wantedLastOutcome"`
WantedLastSync string `json:"wantedLastSync"`
// ── 迷雾 / 探索 POI(2026-09-24 自捐赠端 DremkaPS 移植)──────────────────────────────
// 数据驱动选出来的:66 个「捐赠端有、本服没有」的 handler 里,只有 6 个在 16.5 万行
// unknown-methods.log 里出现过,而 SyncEnterFogMapPoiId 一个人就占了 49 次(活跃到 09-23)。
// 其余 60 个客户端从没主动发过 —— 注册它们只是增加维护面。
// 由 SyncRoot 保护。
// 玩家踏入过的迷雾 POI(SyncEnterFogMapPoiId 上报)。
FogMapPoiIds []uint32 `json:"fogMapPoiIds"`
// 控制台 GmUnlockAllFogMap 请求过的「全解锁」标记。
FogMapAllUnlocked bool `json:"fogMapAllUnlocked"`
// Aether 氛围层开关(GmSetAmbianceEnabled / GmGetAmbianceEnabled 一对)。
AmbianceEnabled bool `json:"ambianceEnabled"`
// AmbianceEnabled 的 type 参数。
AmbianceType int32 `json:"ambianceType"`
// ── GM 控制台职业命令(GmJob*)────────────────────────────────────────────────────────
// 2026-09-24 自捐赠端 DremkaPS 移植,只取职业部分。由 GmJobs.SyncRoot 保护(独立锁:
// HTTP 面板线程在会话拆掉之后仍可能摸到它)。HasGmOverride=false 时职业完全由配置驱动,
// GM 命令一条都没到过,推送照旧。
GmJobs GmJobState4229938 `json:"gmJobs"`
}
type LeftBehindCharacter struct {
UnitId uint64 `json:"unitId"`
Position Vec3 `json:"position"`
Facing float32 `json:"facing"`
// 上一次给它回正位姿的时间戳(Environment.TickCount64),用于节流。
LastFeedTicks int64 `json:"lastFeedTicks"`
// 上一次给它发「原地路点任务」的时间戳(Environment.TickCount64)。
LastWaypointTicks int64 `json:"lastWaypointTicks"`
//
// ★★ 这个角色被切走时,**时段表(NpcDailySchedule)当下那一段**指的活动 id(0 = 查不到)。
// 用途:后台(LeftBehindFollowSchedule)每 1.5 秒拿「现在时段的活动 id」跟它比,
// 不一样 ⇒ 游戏时间跨进了新的日程时段 ⇒ 该角色不再赖在原地,交还常驻化去演新活动。
// ⛑⛑⛑ 必须是 NpcActivityChoice.Timed(时段表真正指的那条),
// 不能是 Entry(落地用的那条)。原因(2026-09-26 06:0x 实机翻车):
// 官方 284 条活动里有 131 条是「临时点位」没编坐标(睡觉 / 休息 / 对策局吃薯片……),
// 傍晚和夜里的时段大量命中这些条目 ⇒ Resolve 一律回落到
// FirstPositioned —— 而它是**与时间无关**的(同 tag 在本 raid 里 Id 最小的
// 有坐标那条),所以白天和夜里算出来的 Entry.Id **完全一样**。
// 实测:05:45 真实时间切走 Mechanika 记 11400031,18:00 再算还是 11400031
// ⇒ 判据恒等 ⇒ 时间跳了 13 小时也一次都不触发。
// 换成 Timed 就对了:11400031(跳大绳) → 11400034(睡觉),里希 11400011 → 11400014。
// ⛑⛑ 必须记**真实活动 id**(不是出场包里那个 activityCfgId —— 那个可能被
// poiOverrides.sendActivityCfgId=false 抹成 0)。记 0 会导致每个 tick 都判定
// 「时段变了」⇒ 1.5 秒 retire+place 一次,客户端又没真撤掉 ⇒ 原地堆出一排人
// (09-25 跑步机堆里希就是这么来的,常驻化那边同一条坑,见 RosterNpc.cs 账本注释)。
// ⛑ 两个主角(AgentTag 2/3)压根没有时刻表 ⇒ 恒为 0 ⇒ 永远不触发,他们就该一直
// 待在玩家最后离开的地方 —— 这是对的,不要当成 bug 去"修"。
//
TimedActivityId uint32 `json:"timedActivityId"`
//
// 切走他时 TimeEpoch 的值。玩家后来主动改过时间 ⇒ 两者不同 ⇒
// 该角色不再赖在原地,交还常驻化去过日子(开关 LeftBehindFollowOnTimeJump)。
// ⛑ 与 TimedActivityId 是两个独立触发条件,任一满足即放行:
// ① 时刻表的时段真的翻篇了(实时流逝也能触发,见 TimedActivityId)
// ② 玩家主动推了时间(哪怕还在同一个时段里 —— 06:13 跳到 06:00 就是这种)
//
TimeEpoch uint32 `json:"timeEpoch"`
// ── 闲逛(2026-09-26 18:5x;开关 SwitchAnimations.LeftBehindWander)────────────────
//
// 当前这一段行程的折线(PedLaneGraph.TryPlanJourney 规划出来的人行道路径)。
// null = 现在没有行程,下次循环会重新挑一个终点并规划。
// ⛑ 只在这里记、只在 Movement.cs 里用;由 SyncRoot 保护。
//
WanderRoute []any `json:"wanderRoute"`
// 行程折线里已经派过的下标(下次从 WanderRouteIdx 开始往下发)。
WanderRouteIdx int32 `json:"wanderRouteIdx"`
//
// 下一次该派新行程的时间戳(Environment.TickCount64)。
// ⛑ 按「本段路程 ÷ 速度 + 余量」算(硬规则 109:不能固定步长,否则一步一停)。
//
WanderNextTicks int64 `json:"wanderNextTicks"`
// ── POI 轮换(2026-09-26 23:4x;开关 SwitchAnimations.LeftBehindPoiRotation)──────
//
// 出场包里实际带出去的 AgentDataSetsActivityCfgId。
// ⛑⛑ 轮换的门禁:非 0 ⇒ 不轮换 —— 客户端那时在走生活日程,
// ambient 会盖掉我们推的 POI(硬规则 163)。与 TimedActivityId 是两回事
// (那个记的是真实活动 id,这个记的是"有没有进生活日程状态")。
//
ActivityCfgId uint32 `json:"activityCfgId"`
//
// 洗牌袋:本轮要播的 POI 顺序(组的副本打乱后的结果)。
// null = 还没建/这个角色不轮换。
//
PoiBag []uint32 `json:"poiBag"`
// 洗牌袋里下一条的下标;走到末尾 ⇒ 重新洗牌、归零。
PoiBagIdx int32 `json:"poiBagIdx"`
// 下一次该换 POI 的时间戳(Environment.TickCount64)。0 = 立刻换。
PoiNextTicks int64 `json:"poiNextTicks"`
}
// Where the server last put a static NPC, and which way it faces (radians).
type StaticNpcPose struct {
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Facing float32 `json:"facing"`
}
//
// 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 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.
//
type WeaponDropRecord struct {
DestructibleId uint64 `json:"destructibleId"`
WeaponInstanceId uint64 `json:"weaponInstanceId"`
SpiritTemplateId uint32 `json:"spiritTemplateId"`
// Index into WorldEntryState.WeaponSlotsBySpirit, or -1 when the weapon had no slot.
SlotIndex int32 `json:"slotIndex"`
}
// One interactive prop (Gadget) placed into the world from the admin panel.
type SpawnedGadgetRecord struct {
InstanceId uint64 `json:"instanceId"`
// Resource path id from GadgetCatalog.json — which prefab the client loaded.
PathId int32 `json:"pathId"`
Name string `json:"name"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
// Yaw in degrees, kept so a re-push reproduces the original facing.
Yaw float32 `json:"yaw"`
// 家具机关标记:网格 AOI 撤掉后要补发(见 GameRouter.HouseFurnitureGadget.cs)。
IsHouseFurniture bool `json:"isHouseFurniture"`
}