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

649 lines
41 KiB
Go
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.
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"`
}