using System.Text.Json; using Ananta.SDK.Logging; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; namespace Ananta.Server.Protocol.Client4229938; /// Wallet + backpack + gacha counters kept in memory for the lifetime of a login. internal sealed class PlayerProgress { internal double Money { get; set; } internal double Gold { get; set; } internal double BindingGold { get; set; } internal List Items { get; } = []; internal Dictionary GachaDrawsSinceGrand { get; } = []; /// Worn phone-skin suit per character (FightSpiritConfig template id). internal Dictionary PhoneSkins { get; } = []; /// Last equipped weapon instance id per character (FightSpiritConfig template id). internal Dictionary LastWeaponBySpirit { get; } = []; /// /// Fight style chosen per (character, style type). The runtime key is a C# tuple, which /// System.Text.Json cannot use as a dictionary key, so the save file flattens it to /// "spiritId:typeId". /// internal Dictionary SpiritStyleOverrides { get; } = []; /// Fight style bound to one weapon instance id. internal Dictionary WeaponStyleOverrides { get; } = []; /// Worn outfit per character (FightSpiritConfig template id). internal Dictionary Fashions { get; } = []; /// /// Dye (染色) state per fashion id. Only fashions the player actually dyed get an entry; /// an absent entry means "every plan slot unlocked at the configured default, no scheme saved". /// internal Dictionary Colorings { get; } = []; /// /// Livehouse music-game clears per song id (2026-10-04). Absent entry = never played. /// internal Dictionary LivehouseRecords { get; } = []; /// True once the client has been told FirstPlay = true for Livehouse. internal bool LivehouseFirstPlayDone { get; set; } /// /// Equipped weapon skins per character (spiritId -> weapon sceneItemId -> skinId). Only /// characters the player actually dressed get an entry; an absent character renders the /// authored default skins. /// internal Dictionary WeaponSkins { get; } = []; /// /// Personal weapon wheel per character (FightSpiritConfig template id): the weapon instance id /// occupying each slot, zero meaning "empty slot". Only wheels the player actually edited are /// written, so an absent entry means "rebuild from the authored loadout". /// /// This has to be persisted together with LastWeaponBySpirit: CombatCodec.SpiritWeaponSnapshot /// silently rewrites CurrentWeaponUid back to slot 0 when the equipped instance is not part of /// the wheel, so restoring the choice without the wheel would still hand the player the default. /// internal Dictionary> WeaponSlotsBySpirit { get; } = []; /// /// Where the player was standing when the snapshot was written. There is no session-end hook, /// so the movement handler throttles a save while the player walks around; this is the value it /// writes. /// /// ⛑ 2026-09-27:RaidId 不再是「必须等于主城才能恢复」的门卫,而是**要回到哪张图**的记载。 /// 旧逻辑把它当守卫用,结果玩家一离开主城(GM 面板切 23300999 凌云/重霄最典型), /// 每次保存都因为 raid 不匹配而写 null ⇒ 已有的坐标被抹掉 ⇒ 下次登录掉回配置出生点。 /// 现在 raid / instance / universe 一起落盘,登录时原样回到那张图的那个点。 /// 唯一还要拦的是搜打撤副本(见 )。 /// internal bool HasSavedPosition { get; set; } internal uint SavedRaidId { get; set; } internal ulong SavedInstanceId { get; set; } internal uint SavedUniverseId { get; set; } internal float SavedPositionX { get; set; } internal float SavedPositionY { get; set; } internal float SavedPositionZ { get; set; } internal float SavedFacing { get; set; } /// /// Gameplay talent trees (天赋) keyed by TalentTreeGamePlayConfig id. Only trees the player /// actually touched are written, so an absent entry means "seed a fresh tree on next login". /// internal Dictionary GameplayTalents { get; } = []; /// /// 已购买的房产(HouseConfig.Id)。2026-09-30 新增。 /// /// 为什么必须有这一份:客户端判断「这套房是不是我的」只有一条路径 —— /// gBuyHouseUtils.CheckHasBuyTheHouse(houseId),它读的是 /// gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList /// (= 登录包 PlayerClientInfoMinor.housesInfo,见 /// PlayerInfoMinorData.lua:54)。购买时的「已购买」动画完全是客户端乐观行为 /// (BuyHouseUtils.BuyTheHouse 拿到 error=0 就自己放动画、开 /// HOUSE_REWARD_PANEL),服务端不回任何东西也不会被它发现; /// 但一走进房子 / 一开大地图,客户端就用上面那条路径重新判一次 —— 服务端没写过, /// 于是「未购买」。所以所有权必须由服务端落盘 + 在登录包里回填。 /// /// 只存 id。版本号 / 家具索引在「只做所有权落地」这一轮恒为 0 /// (见 GameRouter.House.cs 的 PushSyncAddHouseAsync)。 /// internal HashSet OwnedHouseIds { get; } = []; /// /// 车库停车指派:houseId → (车位号 → 车辆配置 id)。2026-09-30 新增。 /// /// 车位号是 0 基(= 线格式里的 ParkingSpaceIndex;客户端 Lua 侧 /// 会 +1 变成它自己的 parkingId)。这一份对应 /// HouseActiveInfo.ParkingSpaceVehicleIdDict(Dict<i32,u32>)—— /// 登录包回填它,客户端 GarageManager.CheckHouseInfo 才认得出「哪个车位有车」。 /// /// ⛔ 这一份只记「哪个车位停了哪辆车(configId)」,不记 EntityId —— /// 车辆实体是每局重新 spawn 的,EntityId 由 SyncHouseParkingEntityBinding 运行时下发。 /// internal Dictionary> GarageParking { get; } = []; internal bool RestoredFromDisk { get; set; } } internal sealed record PlayerProgressItem(uint TemplateId, uint Count, bool IsBind, ulong UniqueId); /// /// One gameplay talent tree as the live session holds it. Mirrors the part of /// PlayerClientInfo.GameplayTalentInfos the talent panel reads: the point balance, the tree exp/level /// and, per talent node, how many layers are activated. /// internal sealed class GameplayTalentState4229938 { internal uint Point { get; set; } internal uint Exp { get; set; } internal uint Level { get; set; } /// TalentTreeTalentConfig id -> activated layers. internal Dictionary Nodes { get; } = []; } /// Save-file shape of . internal sealed record GameplayTalentRecord4229938( uint Point, uint Exp, uint Level, Dictionary Nodes); /// The three part ids the phone is actually wearing for one character. internal sealed record PhoneSkinRecord4229938(uint Wallpaper, uint Decoration, uint Pendant); /// /// What one character is wearing. Mirrors the part of SpiritWearFashionsInfo the wardrobe panel /// actually round-trips: the suit, the source stamp, worn fashion ids and hidden parts. /// WearFashionEditInfoList (per-part scale/rotation/offset) is deliberately not persisted — it is /// a niche editing feature and carrying it would only add a failure mode on restore. /// internal sealed record FashionRecord4229938( uint FunctionSuitId, short Source, uint SourceId, bool IsTryWear, byte HiddenParts, List FashionIds); /// /// What the player dyed on one fashion: every saved colour plan, which plan is currently applied /// and how many plan slots are unlocked. /// /// ⛑ The wire keys are bytes (plan id / coloring type), but System.Text.Json cannot use byte as /// a dictionary key — it would throw on save and take the whole economy snapshot with it. The /// save shape therefore widens every key to uint; FashionColoringStore narrows them back. /// internal sealed record FashionColoringRecord4229938( Dictionary> Schemes, uint AppliedScheme, uint UnlockedSlots); /// /// Which weapon skin each weapon of one character is wearing (sceneItemId -> skinId). Same rule as /// the dye record: every dictionary key is uint because System.Text.Json cannot use byte as a /// dictionary key and would take the whole economy snapshot down with it. /// internal sealed record WeaponSkinRecord4229938(Dictionary SkinBySceneItem); /// /// Minimal character economy persistence for build 4229938. The retail server keeps this in the /// account database; this private server writes one JSON snapshot per player next to /// config/private-server.json and replays it through PlayerInfo at login. /// /// Writes happen on every wallet/backpack mutation because no reliable session-end hook exists, and /// the file is tiny. Failures are logged and never break gameplay. /// internal static class PlayerProgressStore { private static readonly object Gate = new(); private static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; /// /// 背包道具是否落盘。2026-09-23 用户定:**关**。 /// /// 背景:点过一次「发放全道具」后 3783 个道具被写进 `config/player-progress.json`, /// 之后每次登录都要恢复它们,`SyncPlayerInfo` 登录包因此涨到 **214 KB**(实测 2 次共 214569 B)。 /// 这些道具本来就不是"玩家攒出来的",没必要持久化。 /// /// 关掉之后:钱包三种货币 / 抽卡保底计数 / 手机壳 / 时装 / 武器槽 / 天赋**照旧持久化**, /// 只有背包道具变成"每次登录从空开始"。GM 面板「发放全道具」在本次会话内照样能用, /// 只是重登不会带过来。 /// /// 想恢复持久化:把这个常量改回 true 即可(读、写两侧的分支都已经写好)。 /// // 用 static readonly 而不是 const:const false 会让编译器把两个分支判成死代码(CS0162), // 刷两条 warning 出来。改成 readonly 后分支照常编译,改回 true 也不用动别处。 internal static readonly bool PersistBackpackItems = false; internal static string FilePath { get; } = PrivateServerConfigStore.ResolveProjectPath(Path.Combine("config", "player-progress.json")); internal static void LoadInto(PlayerProgress progress, ServerLogger log) { var path = FilePath; if (!File.Exists(path)) { log.Info($"[ECONOMY] no saved character economy yet ({path})"); return; } try { SaveFile? file; lock (Gate) file = JsonSerializer.Deserialize(File.ReadAllText(path)); if (file is null) { log.Warn($"[ECONOMY] saved character economy is empty: {path}"); return; } progress.Money = file.Money; progress.Gold = file.Gold; progress.BindingGold = file.BindingGold; progress.Items.Clear(); progress.GachaDrawsSinceGrand.Clear(); // 背包道具不再恢复(见 PersistBackpackItems)。即使盘上还留着旧档案也一律忽略, // 免得旧存档里的几千个道具又钻回登录包。 if (PersistBackpackItems) { foreach (var item in file.Items) { if (item.TemplateId == 0 || item.Count == 0) continue; progress.Items.RemoveAll(x => x.TemplateId == item.TemplateId); progress.Items.Add(new PlayerProgressItem(item.TemplateId, item.Count, item.IsBind, item.UniqueId)); } } foreach (var (poolId, draws) in file.GachaDraws) { if (poolId != 0) progress.GachaDrawsSinceGrand[poolId] = draws; } progress.PhoneSkins.Clear(); foreach (var (spiritId, skin) in file.PhoneSkins) { if (spiritId == 0 || skin is null) continue; progress.PhoneSkins[spiritId] = new PhoneSkinRecord4229938( skin.Wallpaper, skin.Decoration, skin.Pendant); } progress.LastWeaponBySpirit.Clear(); foreach (var (spiritId, weaponInstanceId) in file.LastWeaponBySpirit) { if (spiritId == 0 || weaponInstanceId == 0) continue; progress.LastWeaponBySpirit[spiritId] = weaponInstanceId; } progress.SpiritStyleOverrides.Clear(); foreach (var (key, styleId) in file.SpiritStyleOverrides) { if (string.IsNullOrEmpty(key) || styleId == 0) continue; progress.SpiritStyleOverrides[key] = styleId; } progress.WeaponStyleOverrides.Clear(); foreach (var (weaponInstanceId, styleId) in file.WeaponStyleOverrides) { if (weaponInstanceId == 0 || styleId == 0) continue; progress.WeaponStyleOverrides[weaponInstanceId] = styleId; } progress.Fashions.Clear(); foreach (var (spiritId, fashion) in file.Fashions) { if (spiritId == 0 || fashion is null) continue; progress.Fashions[spiritId] = new FashionRecord4229938( fashion.FunctionSuitId, fashion.Source, fashion.SourceId, fashion.IsTryWear, fashion.HiddenParts, [.. fashion.FashionIds.Where(id => id != 0)]); } progress.Colorings.Clear(); foreach (var (fashionId, coloring) in file.Colorings) { if (fashionId == 0 || coloring is null) continue; var schemes = new Dictionary>(); foreach (var (planId, colors) in coloring.Schemes) { if (planId == 0 || colors is null) continue; schemes[planId] = new Dictionary( colors.Where(x => x.Key != 0 && x.Value != 0)); } progress.Colorings[fashionId] = new FashionColoringRecord4229938( schemes, coloring.AppliedScheme, coloring.UnlockedSlots); } progress.LivehouseRecords.Clear(); foreach (var (musicId, record) in file.LivehouseRecords) { if (musicId == 0 || record is null) continue; progress.LivehouseRecords[musicId] = record; } progress.LivehouseFirstPlayDone = file.LivehouseFirstPlayDone; progress.WeaponSkins.Clear(); foreach (var (spiritId, skins) in file.WeaponSkins) { if (spiritId == 0 || skins is null) continue; var skinBySceneItem = new Dictionary( skins.Where(x => x.Key != 0 && x.Value != 0)); if (skinBySceneItem.Count > 0) progress.WeaponSkins[spiritId] = new WeaponSkinRecord4229938(skinBySceneItem); } progress.WeaponSlotsBySpirit.Clear(); foreach (var (spiritId, slots) in file.WeaponSlotsBySpirit) { if (spiritId == 0 || slots is null || slots.Count == 0) continue; progress.WeaponSlotsBySpirit[spiritId] = [.. slots]; } progress.GameplayTalents.Clear(); foreach (var (gameplayId, talent) in file.GameplayTalents) { if (gameplayId == 0 || talent is null) continue; var nodes = talent.Nodes is null ? new Dictionary() : new Dictionary(talent.Nodes.Where(x => x.Key != 0 && x.Value != 0)); progress.GameplayTalents[gameplayId] = new GameplayTalentRecord4229938(talent.Point, talent.Exp, talent.Level, nodes); } // 房产所有权。旧存档没有这个字段 ⇒ file.OwnedHouseIds 是空列表 ⇒ 一套都没有, // 语义正确(那时确实没实现购买)。 progress.OwnedHouseIds.Clear(); foreach (var houseId in file.OwnedHouseIds) { if (houseId != 0) progress.OwnedHouseIds.Add(houseId); } // 车库停车指派。旧存档没有这个字段 ⇒ file.GarageParking 为空 ⇒ 车库全空,语义正确。 progress.GarageParking.Clear(); foreach (var (houseId, spaces) in file.GarageParking) { if (houseId == 0 || spaces is null || spaces.Count == 0) continue; var kept = new Dictionary(); foreach (var (index, vehicleId) in spaces) { if (index >= 0 && vehicleId != 0) kept[index] = vehicleId; } if (kept.Count > 0) progress.GarageParking[houseId] = kept; } progress.HasSavedPosition = file.Position is not null && float.IsFinite(file.Position.X) && float.IsFinite(file.Position.Y) && float.IsFinite(file.Position.Z) && float.IsFinite(file.Position.Facing); if (progress.HasSavedPosition) { progress.SavedRaidId = file.Position!.RaidId; // 旧存档没有这两个字段(反序列化得 0);恢复路径会按 raid 补齐。 progress.SavedInstanceId = file.Position.InstanceId; progress.SavedUniverseId = file.Position.UniverseId; progress.SavedPositionX = file.Position.X; progress.SavedPositionY = file.Position.Y; progress.SavedPositionZ = file.Position.Z; progress.SavedFacing = file.Position.Facing; } progress.RestoredFromDisk = true; log.Info( $"[ECONOMY] restored pid={file.Pid} account={file.AccountId} " + $"money={progress.Money} gold={progress.Gold} bindingGold={progress.BindingGold} " + $"items={progress.Items.Count} gachaPools={progress.GachaDrawsSinceGrand.Count} " + $"phoneSkins={progress.PhoneSkins.Count} " + $"weapons={progress.LastWeaponBySpirit.Count} " + $"spiritStyles={progress.SpiritStyleOverrides.Count} " + $"weaponStyles={progress.WeaponStyleOverrides.Count} " + $"fashions={progress.Fashions.Count} " + $"wheels={progress.WeaponSlotsBySpirit.Count} " + $"houses={progress.OwnedHouseIds.Count} " + $"position={(progress.HasSavedPosition ? $"raid={progress.SavedRaidId}/{progress.SavedInstanceId} ({progress.SavedPositionX:0.##},{progress.SavedPositionY:0.##},{progress.SavedPositionZ:0.##})" : "none")} " + $"({path})"); } catch (Exception ex) { log.Warn($"[ECONOMY] saved character economy could not be read ({ex.GetType().Name}: {ex.Message}): {path}"); } } /// Snapshots the live session state (wallet, GM/granted backpack, gacha counters) to disk. /// /// /// Re-validate persisted citizens against the projected catalogs. Anything the client could not /// possibly build is dropped; a POI id that vanished is downgraded to "no ambient action" /// instead of dropping the pedestrian (it still stands there, which beats losing it entirely). /// /// Suppresses the success log. Used by the throttled walking-position save, which would /// otherwise emit a line every few seconds and bury everything else in the console. /// internal static void Save(WorldEntryState state, ServerLogger log, bool quiet = false) { var file = new SaveFile { Pid = PrivateServerConfigStore.Current.Player.Pid, AccountId = PrivateServerConfigStore.Current.Player.AccountId, Money = state.WalletMoney, Gold = state.WalletGold, BindingGold = state.WalletBindingGold, SavedAtUtc = DateTime.UtcNow.ToString("O"), }; lock (state.SyncRoot) { // 背包道具不再落盘(见 PersistBackpackItems)。旧的 3783 条随这一次保存自然消失。 if (PersistBackpackItems) { foreach (var stack in state.GmBackpackItems.Values) { if (stack.TemplateId == 0 || stack.Count == 0) continue; file.Items.Add(new SaveItem { TemplateId = stack.TemplateId, Count = stack.Count, IsBind = stack.IsBind, UniqueId = stack.UniqueId, }); } } foreach (var (spiritId, skin) in state.PhoneSkinBySpirit) { if (spiritId == 0 || skin is null) continue; file.PhoneSkins[spiritId] = new SavePhoneSkin { Wallpaper = skin.Wallpaper, Decoration = skin.Decoration, Pendant = skin.Pendant, }; } foreach (var (spiritId, weaponInstanceId) in state.LastWeaponBySpirit) { if (spiritId == 0 || weaponInstanceId == 0) continue; file.LastWeaponBySpirit[spiritId] = weaponInstanceId; } // Tuple keys cannot be serialized, so flatten (spiritId, typeId) to "spiritId:typeId". foreach (var ((spiritId, typeId), styleId) in state.SpiritStyleOverrides) { if (spiritId == 0 || typeId == 0 || styleId == 0) continue; file.SpiritStyleOverrides[$"{spiritId}:{typeId}"] = styleId; } foreach (var (weaponInstanceId, styleId) in state.WeaponStyleOverrides) { if (weaponInstanceId == 0 || styleId == 0) continue; file.WeaponStyleOverrides[weaponInstanceId] = styleId; } foreach (var (spiritId, fashion) in state.FashionBySpirit) { if (spiritId == 0 || fashion is null) continue; file.Fashions[spiritId] = new SaveFashion { FunctionSuitId = fashion.FunctionSuitId, Source = fashion.Source, SourceId = fashion.SourceId, IsTryWear = fashion.IsTryWear, HiddenParts = fashion.HiddenParts, FashionIds = [.. fashion.FashionIds.Where(id => id != 0)], }; } // Dye (染色). Only fashions the player actually touched are written, so an absent entry // keeps meaning "default slots, no scheme" instead of freezing a snapshot of every // fashion in the game into the save. foreach (var (fashionId, coloring) in state.Colorings) { if (fashionId == 0 || coloring is null) continue; var schemes = new Dictionary>(); foreach (var (planId, colors) in coloring.Schemes) { if (planId == 0 || colors.Count == 0) continue; schemes[planId] = new Dictionary( colors.Where(x => x.Key != 0 && x.Value != 0)); } file.Colorings[fashionId] = new SaveFashionColoring { Schemes = schemes, AppliedScheme = coloring.AppliedScheme, UnlockedSlots = coloring.UnlockedSlots, }; } // Livehouse music-game clears. Only songs the player actually cleared are written. foreach (var (musicId, record) in state.LivehouseRecords) { if (musicId == 0 || record is null) continue; file.LivehouseRecords[musicId] = record; } file.LivehouseFirstPlayDone = state.LivehouseFirstPlayDone; // Equipped weapon skins. Only characters the player actually dressed are written, so an // absent entry keeps meaning "authored default skins". foreach (var (spiritId, skins) in state.WeaponSkins) { if (spiritId == 0 || skins is null || skins.SkinBySceneItem.Count == 0) continue; file.WeaponSkins[spiritId] = new Dictionary( skins.SkinBySceneItem.Where(x => x.Key != 0 && x.Value != 0)); } // Edited weapon wheels only. Writing every character's authored defaults would freeze // them into the save and shadow later config changes, so a wheel that still matches // CombatCodec's loadout is left out and simply rebuilt from config on restore. foreach (var (spiritId, slots) in state.WeaponSlotsBySpirit) { if (spiritId == 0 || slots.Count == 0 || !WheelDiffersFromLoadout4229938(spiritId, slots)) continue; file.WeaponSlotsBySpirit[spiritId] = [.. slots]; } // Gameplay talent trees (天赋). Only trees the player touched are written; an absent // entry is re-seeded from talentPointSeed on the next login. foreach (var (gameplayId, talent) in state.GameplayTalents) { if (gameplayId == 0 || talent is null) continue; file.GameplayTalents[gameplayId] = new SaveGameplayTalent { Point = talent.Point, Exp = talent.Exp, Level = talent.Level, Nodes = new Dictionary(talent.Nodes.Where(x => x.Key != 0 && x.Value != 0)), }; } // 房产所有权。全部写(不是「只写动过的」)—— 这份列表本身就是完整的所有权集合, // 漏一条就等于玩家丢一套房。排序只为让存档 diff 好看。 file.OwnedHouseIds.Clear(); file.OwnedHouseIds.AddRange(state.Progress.OwnedHouseIds.Where(id => id != 0).OrderBy(id => id)); // 车库停车指派。只写「有车」的车位;某套房一个车位都没停就不写这一条。 file.GarageParking.Clear(); foreach (var (houseId, spaces) in state.Progress.GarageParking) { if (houseId == 0 || spaces.Count == 0) continue; var kept = new Dictionary(); foreach (var (index, vehicleId) in spaces) { if (index >= 0 && vehicleId != 0) kept[index] = vehicleId; } if (kept.Count > 0) file.GarageParking[houseId] = kept; } // World transform. Only meaningful once the client has actually reported one; before // that the fields hold the configured spawn, which is not worth persisting. // // ⛑⛑ 2026-09-27 修:判据原来是「必须等于 Profile.RaidId(主城)」。玩家一离开主城 // (GM 面板切 23300999 凌云/重霄最典型),ActiveRaidId 就不再等于主城 ⇒ **每一次** // 保存都走不进这个分支 ⇒ file.Position 为 null ⇒ 覆盖写盘时把上一次存下来的坐标 // **抹掉** ⇒ 下次登录 restored position=none,人掉回配置出生点 // (用户报的「在重霄下线,上线却在『新启』」)。 // 真正该挡的只有搜打撤副本那一类:它们的局内状态(包柜 / 容器 / token) // 刻意不落盘(每局重洗),把副本坐标带进下一次登录会让 HUD 与背包全对不上。 // 其余 raid 一律照存,并把 raid / instance / universe 一起写进去, // 恢复时才能原样回到那张图(见 GameRouter.World.cs SendEnterSceneIfNeeded)。 if (state.HasLastReportedPlayerTransform && float.IsFinite(state.LastReportedPlayerPosition.X) && float.IsFinite(state.LastReportedPlayerPosition.Y) && float.IsFinite(state.LastReportedPlayerPosition.Z) && IsPersistableRaidForPosition(state.ActiveRaidId)) { file.Position = new SaveTransform { RaidId = state.ActiveRaidId, InstanceId = state.ActiveInstanceId, UniverseId = state.ActiveUniverseId, X = state.LastReportedPlayerPosition.X, Y = state.LastReportedPlayerPosition.Y, Z = state.LastReportedPlayerPosition.Z, Facing = state.LastReportedPlayerRotation.Y, }; } // ⛑ 本次不该存(人正在搜打撤副本里 / 还没上报过坐标)时**绝不能留空**: // Save 是整文件覆盖写,Position 为 null 就等于把上一次的坐标抹掉。 // 旧代码正是靠这条把玩家的坐标清成 none 的(2026-09-27)。 // 这里把「本次登录从盘上恢复的那份」原样写回 —— 语义也对: // 进了副本又没在公开世界站过,重登就该回进副本前的那个点。 else if (state.Progress.HasSavedPosition && state.Progress.SavedRaidId != 0) { file.Position = new SaveTransform { RaidId = state.Progress.SavedRaidId, InstanceId = state.Progress.SavedInstanceId, UniverseId = state.Progress.SavedUniverseId, X = state.Progress.SavedPositionX, Y = state.Progress.SavedPositionY, Z = state.Progress.SavedPositionZ, Facing = state.Progress.SavedFacing, }; } } foreach (var (poolId, draws) in state.Progress.GachaDrawsSinceGrand) file.GachaDraws[poolId] = draws; var path = FilePath; var temp = path + ".tmp"; try { var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) Directory.CreateDirectory(directory); lock (Gate) { File.WriteAllText(temp, JsonSerializer.Serialize(file, Options)); File.Move(temp, path, overwrite: true); } if (quiet) return; log.Info( $"[ECONOMY] saved character economy money={file.Money} gold={file.Gold} " + $"bindingGold={file.BindingGold} items={file.Items.Count} " + $"phoneSkins={file.PhoneSkins.Count} weapons={file.LastWeaponBySpirit.Count} " + $"spiritStyles={file.SpiritStyleOverrides.Count} " + $"weaponStyles={file.WeaponStyleOverrides.Count} " + $"fashions={file.Fashions.Count} " + $"wheels={file.WeaponSlotsBySpirit.Count} " + $"houses={file.OwnedHouseIds.Count} -> {path}"); } catch (Exception ex) { log.Warn($"[ECONOMY] character economy could not be saved ({ex.GetType().Name}: {ex.Message}): {path}"); try { if (File.Exists(temp)) File.Delete(temp); } catch { // Nothing useful to do: the snapshot simply stays unwritten. } } } /// /// 这个 raid 的坐标要不要落盘( 的位置守卫)。 /// /// 只挡搜打撤副本:那份存档刻意不记局内状态(包柜 / 容器 / token 每局重洗), /// 把副本坐标带回下一次登录会让 HUD 与背包全对不上。 /// ⛑ 不读 Enabled —— 名单本身就是「哪几张图是搜打撤图」的身份证, /// 关掉开关只是不挂搜打撤玩法,图还是那几张。 /// 其余 raid(主城 / 凌云·重霄 / 各种室内层)一律可存。 /// internal static bool IsPersistableRaidForPosition(uint raidId) { if (raidId == 0) return false; try { var extraction = PrivateServerConfigStore.Current.World.Content.Extraction; return extraction.RaidIds is not { Length: > 0 } raidIds || !raidIds.Contains(raidId); } catch { // 配置读不出来就照存 —— 存错坐标(重登回到别的图)比把坐标抹掉(重登回出生点)好恢复。 return true; } } private static bool WheelDiffersFromLoadout4229938(uint spiritId, IReadOnlyList slots) { // A spirit the catalog no longer carries (removed character, hand-edited save row) has no // authored wheel to compare against. Report "no difference" so the stale row is dropped on // the next write instead of throwing in the middle of persisting the profile. if (CombatCatalogRepository.TryLoadout(spiritId) is not { } loadout) return false; var authored = loadout.Slots; if (slots.Count > authored.Count) return true; for (var index = 0; index < authored.Count; index++) { if ((authored[index]?.InstanceId ?? 0UL) != (index < slots.Count ? slots[index] : 0UL)) return true; } return false; } private sealed class SaveFile { public ulong Pid { get; set; } public string AccountId { get; set; } = string.Empty; public double Money { get; set; } public double Gold { get; set; } public double BindingGold { get; set; } public string SavedAtUtc { get; set; } = string.Empty; public List Items { get; set; } = []; public Dictionary GachaDraws { get; set; } = []; /// Worn phone-skin suit per character. Absent in saves written before this field /// existed; the deserializer then leaves the (empty) default, which just means "wear the /// default suit". public Dictionary PhoneSkins { get; set; } = []; /// Equipped weapon instance id per character. Zero/empty = keep the authored default. public Dictionary LastWeaponBySpirit { get; set; } = []; /// Fight style per (character, style type), key flattened to "spiritId:typeId". public Dictionary SpiritStyleOverrides { get; set; } = []; /// Fight style per weapon instance id. public Dictionary WeaponStyleOverrides { get; set; } = []; /// Worn outfit per character. Absent = wear the authored default. public Dictionary Fashions { get; set; } = []; /// Dye per fashion id. Absent in saves written before this field existed; /// the deserializer then leaves the (empty) default, which means "slots at the configured /// default, no scheme saved". public Dictionary Colorings { get; set; } = []; /// Livehouse music-game clears: song id -> counts. Absent = never cleared. public Dictionary LivehouseRecords { get; set; } = []; /// True once the Livehouse intro timeline has been handed out. public bool LivehouseFirstPlayDone { get; set; } /// Equipped weapon skins per character. Absent = authored default skins. public Dictionary> WeaponSkins { get; set; } = []; /// Edited weapon wheels per character. Zero = empty slot; absent = authored loadout. public Dictionary> WeaponSlotsBySpirit { get; set; } = []; /// Last known world transform, together with the scene it was captured in. The /// raid/instance/universe triple is what lets a re-login land back on that scene instead of /// the configured spawn. All three are absent (0) in saves written before 2026-09-27; the /// restore path back-fills them from the scene presets. public SaveTransform? Position { get; set; } /// Gameplay talent trees per TalentTreeGamePlayConfig id. Absent in saves written /// before this field existed; the deserializer then leaves the (empty) default, which just /// means "seed every tree from talentPointSeed". public Dictionary GameplayTalents { get; set; } = []; /// 已购买的房产(HouseConfig.Id)。缺失 = 一套都没有,这正是旧存档的正确语义。 public List OwnedHouseIds { get; set; } = []; /// 车库停车指派:houseId → (车位号(0 基) → 车辆 configId)。 /// 缺失 = 车库全空,正是旧存档的正确语义。 public Dictionary> GarageParking { get; set; } = []; } private sealed class SaveGameplayTalent { public uint Point { get; set; } public uint Exp { get; set; } public uint Level { get; set; } public Dictionary Nodes { get; set; } = []; } private sealed class SaveTransform { public uint RaidId { get; set; } public ulong InstanceId { get; set; } public uint UniverseId { get; set; } public float X { get; set; } public float Y { get; set; } public float Z { get; set; } public float Facing { get; set; } } private sealed class SaveFashion { public uint FunctionSuitId { get; set; } public short Source { get; set; } public uint SourceId { get; set; } public bool IsTryWear { get; set; } public byte HiddenParts { get; set; } public List FashionIds { get; set; } = []; } /// Save shape of : planId -> coloringType -> /// colorCfgId, plus the applied plan and the unlocked slot count. All keys are uint (see the /// record for why byte would break the serializer). private sealed class SaveFashionColoring { public Dictionary> Schemes { get; set; } = []; public uint AppliedScheme { get; set; } public uint UnlockedSlots { get; set; } } private sealed class SavePhoneSkin { public uint Wallpaper { get; set; } public uint Decoration { get; set; } public uint Pendant { get; set; } } private sealed class SaveItem { public uint TemplateId { get; set; } public uint Count { get; set; } public bool IsBind { get; set; } public ulong UniqueId { get; set; } } }