feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio

This commit is contained in:
CAtlas0784 committed 2026-10-29 02:44:24 +07:00
1 parent 74e2963a12
commit 3e5adf1dc5
1571 files changed
+336468 -6348

No files matched your search

@@ -1,5 +1,6 @@
using System.Text.Json;
using Ananta.SDK.Logging;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
namespace Ananta.Server.Protocol.Client4229938;
@@ -12,23 +13,209 @@ internal sealed class PlayerProgress
internal double BindingGold { get; set; }
internal List<PlayerProgressItem> Items { get; } = [];
internal Dictionary<uint, uint> GachaDrawsSinceGrand { get; } = [];
internal HashSet<uint> Fashions { get; } = [];
internal HashSet<uint> Vehicles { 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"));
@@ -58,16 +245,19 @@ internal static class PlayerProgressStore
progress.BindingGold = file.BindingGold;
progress.Items.Clear();
progress.GachaDrawsSinceGrand.Clear();
progress.Fashions.Clear();
progress.Vehicles.Clear();
foreach (var item in file.Items)
// 背包道具不再恢复(见 PersistBackpackItems)。即使盘上还留着旧档案也一律忽略,
// 免得旧存档里的几千个道具又钻回登录包。
if (PersistBackpackItems)
{
if (item.TemplateId == 0 || item.Count == 0)
continue;
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));
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)
@@ -76,16 +266,166 @@ internal static class PlayerProgressStore
progress.GachaDrawsSinceGrand[poolId] = draws;
}
foreach (var fashionId in file.Fashions)
progress.PhoneSkins.Clear();
foreach (var (spiritId, skin) in file.PhoneSkins)
{
if (fashionId != 0)
progress.Fashions.Add(fashionId);
if (spiritId == 0 || skin is null)
continue;
progress.PhoneSkins[spiritId] = new PhoneSkinRecord4229938(
skin.Wallpaper, skin.Decoration, skin.Pendant);
}
foreach (var vehicleId in file.Vehicles)
progress.LastWeaponBySpirit.Clear();
foreach (var (spiritId, weaponInstanceId) in file.LastWeaponBySpirit)
{
if (vehicleId != 0)
progress.Vehicles.Add(vehicleId);
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;
@@ -93,7 +433,15 @@ internal static class PlayerProgressStore
$"[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} " +
$"fashions={progress.Fashions.Count} vehicles={progress.Vehicles.Count} ({path})");
$"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)
{
@@ -102,7 +450,16 @@ internal static class PlayerProgressStore
}
/// <summary>Snapshots the live session state (wallet, GM/granted backpack, gacha counters) to disk.</summary>
internal static void Save(WorldEntryState state, ServerLogger log)
/// <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
{
@@ -116,22 +473,225 @@ internal static class PlayerProgressStore
lock (state.SyncRoot)
{
foreach (var stack in state.GmBackpackItems.Values)
// 背包道具不再落盘(见 PersistBackpackItems)。旧的 3783 条随这一次保存自然消失。
if (PersistBackpackItems)
{
if (stack.TemplateId == 0 || stack.Count == 0)
continue;
file.Items.Add(new SaveItem
foreach (var stack in state.GmBackpackItems.Values)
{
TemplateId = stack.TemplateId,
Count = stack.Count,
IsBind = stack.IsBind,
UniqueId = stack.UniqueId,
});
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,
};
}
file.Fashions.AddRange(state.Progress.Fashions.Where(x => x != 0).OrderBy(x => x));
file.Vehicles.AddRange(state.Progress.Vehicles.Where(x => x != 0).OrderBy(x => x));
}
foreach (var (poolId, draws) in state.Progress.GachaDrawsSinceGrand)
@@ -151,10 +711,18 @@ internal static class PlayerProgressStore
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} " +
$"fashions={file.Fashions.Count} vehicles={file.Vehicles.Count} -> {path}");
$"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)
{
@@ -166,10 +734,58 @@ internal static class PlayerProgressStore
}
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; }
@@ -180,8 +796,104 @@ internal static class PlayerProgressStore
public string SavedAtUtc { get; set; } = string.Empty;
public List<SaveItem> Items { get; set; } = [];
public Dictionary<uint, uint> GachaDraws { get; set; } = [];
public List<uint> Fashions { get; set; } = [];
public List<uint> Vehicles { 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