907 lines
41 KiB
C#
907 lines
41 KiB
C#
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<i32,u32></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; }
|
||
}
|
||
}
|