feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
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<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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user