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; }
}
}