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/Ananta.Server/Ananta.Core/Protocol/Client4229938/PlayerProgressStore.cs
T

907 lines
41 KiB
C#
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.
using System.Text.Json;
using Ananta.SDK.Logging;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>Wallet + backpack + gacha counters kept in memory for the lifetime of a login.</summary>
internal sealed class PlayerProgress
{
internal double Money { get; set; }
internal double Gold { get; set; }
internal double BindingGold { get; set; }
internal List<PlayerProgressItem> Items { get; } = [];
internal Dictionary<uint, uint> GachaDrawsSinceGrand { get; } = [];
/// <summary>Worn phone-skin suit per character (FightSpiritConfig template id).</summary>
internal Dictionary<uint, PhoneSkinRecord4229938> PhoneSkins { get; } = [];
/// <summary>Last equipped weapon instance id per character (FightSpiritConfig template id).</summary>
internal Dictionary<uint, ulong> LastWeaponBySpirit { get; } = [];
/// <summary>
/// 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".
/// </summary>
internal Dictionary<string, uint> SpiritStyleOverrides { get; } = [];
/// <summary>Fight style bound to one weapon instance id.</summary>
internal Dictionary<ulong, uint> WeaponStyleOverrides { get; } = [];
/// <summary>Worn outfit per character (FightSpiritConfig template id).</summary>
internal Dictionary<uint, FashionRecord4229938> Fashions { get; } = [];
/// <summary>
/// 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".
/// </summary>
internal Dictionary<uint, FashionColoringRecord4229938> Colorings { get; } = [];
/// <summary>
/// Livehouse music-game clears per song id (2026-10-04). Absent entry = never played.
/// </summary>
internal Dictionary<uint, LivehouseRecord4229938> LivehouseRecords { get; } = [];
/// <summary>True once the client has been told <c>FirstPlay = true</c> for Livehouse.</summary>
internal bool LivehouseFirstPlayDone { get; set; }
/// <summary>
/// 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.
/// </summary>
internal Dictionary<uint, WeaponSkinRecord4229938> WeaponSkins { get; } = [];
/// <summary>
/// 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.
/// </summary>
internal Dictionary<uint, List<ulong>> WeaponSlotsBySpirit { get; } = [];
/// <summary>
/// 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 一起落盘,登录时原样回到那张图的那个点。
/// 唯一还要拦的是搜打撤副本(见 <see cref="PlayerProgressStore.IsPersistableRaidForPosition"/>)。
/// </summary>
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; }
/// <summary>
/// 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".
/// </summary>
internal Dictionary<uint, GameplayTalentRecord4229938> GameplayTalents { get; } = [];
/// <summary>
/// 已购买的房产(<c>HouseConfig.Id</c>)。2026-09-30 新增。
///
/// <para><b>为什么必须有这一份</b>:客户端判断「这套房是不是我的」只有一条路径 ——
/// <c>gBuyHouseUtils.CheckHasBuyTheHouse(houseId)</c>,它读的是
/// <c>gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList</c>
/// (= 登录包 <c>PlayerClientInfoMinor.housesInfo</c>,见
/// <c>PlayerInfoMinorData.lua:54</c>)。购买时的「已购买」动画完全是客户端乐观行为
/// (<c>BuyHouseUtils.BuyTheHouse</c> 拿到 error=0 就自己放动画、开
/// <c>HOUSE_REWARD_PANEL</c>),服务端不回任何东西也不会被它发现;
/// 但一走进房子 / 一开大地图,客户端就用上面那条路径重新判一次 —— 服务端没写过,
/// 于是「未购买」。<b>所以所有权必须由服务端落盘 + 在登录包里回填。</b></para>
///
/// <para>只存 id。版本号 / 家具索引在「只做所有权落地」这一轮恒为 0
/// (见 <c>GameRouter.House.cs</c> 的 <c>PushSyncAddHouseAsync</c>)。</para>
/// </summary>
internal HashSet<uint> OwnedHouseIds { get; } = [];
/// <summary>
/// 车库停车指派:<c>houseId → (车位号 → 车辆配置 id)</c>。2026-09-30 新增。
///
/// <para>车位号是 <b>0 基</b>(= 线格式里的 <c>ParkingSpaceIndex</c>;客户端 Lua 侧
/// 会 <c>+1</c> 变成它自己的 <c>parkingId</c>)。这一份对应
/// <c>HouseActiveInfo.ParkingSpaceVehicleIdDict</c>(<c>Dict&lt;i32,u32&gt;</c>)——
/// 登录包回填它,客户端 <c>GarageManager.CheckHouseInfo</c> 才认得出「哪个车位有车」。</para>
///
/// <para>⛔ 这一份只记「哪个车位停了哪辆车(configId)」,<b>不记 EntityId</b> ——
/// 车辆实体是每局重新 spawn 的,EntityId 由 <c>SyncHouseParkingEntityBinding</c> 运行时下发。</para>
/// </summary>
internal Dictionary<uint, Dictionary<int, uint>> GarageParking { get; } = [];
internal bool RestoredFromDisk { get; set; }
}
internal sealed record PlayerProgressItem(uint TemplateId, uint Count, bool IsBind, ulong UniqueId);
/// <summary>
/// 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.
/// </summary>
internal sealed class GameplayTalentState4229938
{
internal uint Point { get; set; }
internal uint Exp { get; set; }
internal uint Level { get; set; }
/// <summary>TalentTreeTalentConfig id -> activated layers.</summary>
internal Dictionary<uint, uint> Nodes { get; } = [];
}
/// <summary>Save-file shape of <see cref="GameplayTalentState4229938"/>.</summary>
internal sealed record GameplayTalentRecord4229938(
uint Point,
uint Exp,
uint Level,
Dictionary<uint, uint> Nodes);
/// <summary>The three part ids the phone is actually wearing for one character.</summary>
internal sealed record PhoneSkinRecord4229938(uint Wallpaper, uint Decoration, uint Pendant);
/// <summary>
/// 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.
/// </summary>
internal sealed record FashionRecord4229938(
uint FunctionSuitId,
short Source,
uint SourceId,
bool IsTryWear,
byte HiddenParts,
List<uint> FashionIds);
/// <summary>
/// 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 <b>System.Text.Json cannot use byte as
/// a dictionary key</b> — it would throw on save and take the whole economy snapshot with it. The
/// save shape therefore widens every key to uint; <c>FashionColoringStore</c> narrows them back.
/// </summary>
internal sealed record FashionColoringRecord4229938(
Dictionary<uint, Dictionary<uint, uint>> Schemes,
uint AppliedScheme,
uint UnlockedSlots);
/// <summary>
/// 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.
/// </summary>
internal sealed record WeaponSkinRecord4229938(Dictionary<uint, uint> SkinBySceneItem);
/// <summary>
/// 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
/// <c>config/private-server.json</c> 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.
/// </summary>
internal static class PlayerProgressStore
{
private static readonly object Gate = new();
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
/// <summary>
/// 背包道具是否落盘。2026-09-23 用户定:**关**。
///
/// 背景:点过一次「发放全道具」后 3783 个道具被写进 `config/player-progress.json`,
/// 之后每次登录都要恢复它们,`SyncPlayerInfo` 登录包因此涨到 **214 KB**(实测 2 次共 214569 B)。
/// 这些道具本来就不是"玩家攒出来的",没必要持久化。
///
/// 关掉之后:钱包三种货币 / 抽卡保底计数 / 手机壳 / 时装 / 武器槽 / 天赋**照旧持久化**,
/// 只有背包道具变成"每次登录从空开始"。GM 面板「发放全道具」在本次会话内照样能用,
/// 只是重登不会带过来。
///
/// 想恢复持久化:把这个常量改回 true 即可(读、写两侧的分支都已经写好)。
/// </summary>
// 用 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<SaveFile>(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<uint, Dictionary<uint, uint>>();
foreach (var (planId, colors) in coloring.Schemes)
{
if (planId == 0 || colors is null)
continue;
schemes[planId] = new Dictionary<uint, uint>(
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<uint, uint>(
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<uint, uint>()
: new Dictionary<uint, uint>(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<int, uint>();
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}");
}
}
/// <summary>Snapshots the live session state (wallet, GM/granted backpack, gacha counters) to disk.</summary>
/// <param name="quiet">
/// <summary>
/// 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).
/// </summary>
/// 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.
/// </param>
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<uint, Dictionary<uint, uint>>();
foreach (var (planId, colors) in coloring.Schemes)
{
if (planId == 0 || colors.Count == 0)
continue;
schemes[planId] = new Dictionary<uint, uint>(
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<uint, uint>(
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<uint, uint>(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<int, uint>();
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.
}
}
}
/// <summary>
/// 这个 raid 的坐标要不要落盘(<see cref="Save(WorldEntryState, ServerLogger, bool)"/> 的位置守卫)。
///
/// 只挡搜打撤副本:那份存档刻意不记局内状态(包柜 / 容器 / token 每局重洗),
/// 把副本坐标带回下一次登录会让 HUD 与背包全对不上。
/// ⛑ 不读 <c>Enabled</c> —— 名单本身就是「哪几张图是搜打撤图」的身份证,
/// 关掉开关只是不挂搜打撤玩法,图还是那几张。
/// 其余 raid(主城 / 凌云·重霄 / 各种室内层)一律可存。
/// </summary>
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<ulong> 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<SaveItem> Items { get; set; } = [];
public Dictionary<uint, uint> GachaDraws { get; set; } = [];
/// <summary>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".</summary>
public Dictionary<uint, SavePhoneSkin> PhoneSkins { get; set; } = [];
/// <summary>Equipped weapon instance id per character. Zero/empty = keep the authored default.</summary>
public Dictionary<uint, ulong> LastWeaponBySpirit { get; set; } = [];
/// <summary>Fight style per (character, style type), key flattened to "spiritId:typeId".</summary>
public Dictionary<string, uint> SpiritStyleOverrides { get; set; } = [];
/// <summary>Fight style per weapon instance id.</summary>
public Dictionary<ulong, uint> WeaponStyleOverrides { get; set; } = [];
/// <summary>Worn outfit per character. Absent = wear the authored default.</summary>
public Dictionary<uint, SaveFashion> Fashions { get; set; } = [];
/// <summary>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".</summary>
public Dictionary<uint, SaveFashionColoring> Colorings { get; set; } = [];
/// <summary>Livehouse music-game clears: song id -> counts. Absent = never cleared.</summary>
public Dictionary<uint, LivehouseRecord4229938> LivehouseRecords { get; set; } = [];
/// <summary>True once the Livehouse intro timeline has been handed out.</summary>
public bool LivehouseFirstPlayDone { get; set; }
/// <summary>Equipped weapon skins per character. Absent = authored default skins.</summary>
public Dictionary<uint, Dictionary<uint, uint>> WeaponSkins { get; set; } = [];
/// <summary>Edited weapon wheels per character. Zero = empty slot; absent = authored loadout.</summary>
public Dictionary<uint, List<ulong>> WeaponSlotsBySpirit { get; set; } = [];
/// <summary>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.</summary>
public SaveTransform? Position { get; set; }
/// <summary>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".</summary>
public Dictionary<uint, SaveGameplayTalent> GameplayTalents { get; set; } = [];
/// <summary>已购买的房产(HouseConfig.Id)。缺失 = 一套都没有,这正是旧存档的正确语义。</summary>
public List<uint> OwnedHouseIds { get; set; } = [];
/// <summary>车库停车指派:houseId → (车位号(0 基) → 车辆 configId)。
/// 缺失 = 车库全空,正是旧存档的正确语义。</summary>
public Dictionary<uint, Dictionary<int, uint>> GarageParking { get; set; } = [];
}
private sealed class SaveGameplayTalent
{
public uint Point { get; set; }
public uint Exp { get; set; }
public uint Level { get; set; }
public Dictionary<uint, uint> 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<uint> FashionIds { get; set; } = [];
}
/// <summary>Save shape of <see cref="FashionColoringRecord4229938"/>: 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).</summary>
private sealed class SaveFashionColoring
{
public Dictionary<uint, Dictionary<uint, uint>> 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; }
}
}