This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/pkg/protocol/models/player_progress_store.go
T

304 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package models
import (
"encoding/json"
"sync"
)
var _ = json.RawMessage{}
var _ = sync.Mutex{}
// Wallet + backpack + gacha counters kept in memory for the lifetime of a login.
type PlayerProgress struct {
Money float64 `json:"money"`
Gold float64 `json:"gold"`
BindingGold float64 `json:"bindingGold"`
Items []PlayerProgressItem `json:"items"`
GachaDrawsSinceGrand map[uint32]uint32 `json:"gachaDrawsSinceGrand"`
// Worn phone-skin suit per character (FightSpiritConfig template id).
PhoneSkins map[uint32]PhoneSkinRecord4229938 `json:"phoneSkins"`
// Last equipped weapon instance id per character (FightSpiritConfig template id).
LastWeaponBySpirit map[uint32]uint64 `json:"lastWeaponBySpirit"`
//
// 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".
//
SpiritStyleOverrides map[string]uint32 `json:"spiritStyleOverrides"`
// Fight style bound to one weapon instance id.
WeaponStyleOverrides map[uint64]uint32 `json:"weaponStyleOverrides"`
// Worn outfit per character (FightSpiritConfig template id).
Fashions map[uint32]FashionRecord4229938 `json:"fashions"`
//
// 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".
//
Colorings map[uint32]FashionColoringRecord4229938 `json:"colorings"`
//
// Livehouse music-game clears per song id (2026-10-04). Absent entry = never played.
//
LivehouseRecords map[uint32]LivehouseRecord4229938 `json:"livehouseRecords"`
// True once the client has been told FirstPlay = true for Livehouse.
LivehouseFirstPlayDone bool `json:"livehouseFirstPlayDone"`
//
// 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.
//
WeaponSkins map[uint32]WeaponSkinRecord4229938 `json:"weaponSkins"`
//
// 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.
//
WeaponSlotsBySpirit map[uint32][]uint64 `json:"weaponSlotsBySpirit"`
//
// 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 一起落盘,登录时原样回到那张图的那个点。
// 唯一还要拦的是搜打撤副本(见 PlayerProgressStore.IsPersistableRaidForPosition)。
//
HasSavedPosition bool `json:"hasSavedPosition"`
SavedRaidId uint32 `json:"savedRaidId"`
SavedInstanceId uint64 `json:"savedInstanceId"`
SavedUniverseId uint32 `json:"savedUniverseId"`
SavedPositionX float32 `json:"savedPositionX"`
SavedPositionY float32 `json:"savedPositionY"`
SavedPositionZ float32 `json:"savedPositionZ"`
SavedFacing float32 `json:"savedFacing"`
//
// 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".
//
GameplayTalents map[uint32]GameplayTalentRecord4229938 `json:"gameplayTalents"`
//
// 已购买的房产(HouseConfig.Id)。2026-09-30 新增。
// <para>为什么必须有这一份:客户端判断「这套房是不是我的」只有一条路径 ——
// gBuyHouseUtils.CheckHasBuyTheHouse(houseId),它读的是
// gPlayerManager.infoMinor.bindData.housesInfo.HouseInfoList
// (= 登录包 PlayerClientInfoMinor.housesInfo,见
// PlayerInfoMinorData.lua:54)。购买时的「已购买」动画完全是客户端乐观行为
// (BuyHouseUtils.BuyTheHouse 拿到 error=0 就自己放动画、开
// HOUSE_REWARD_PANEL),服务端不回任何东西也不会被它发现;
// 但一走进房子 / 一开大地图,客户端就用上面那条路径重新判一次 —— 服务端没写过,
// 于是「未购买」。所以所有权必须由服务端落盘 + 在登录包里回填。</para>
// <para>只存 id。版本号 / 家具索引在「只做所有权落地」这一轮恒为 0
// (见 GameRouter.House.cs 的 PushSyncAddHouseAsync)。</para>
//
OwnedHouseIds []uint32 `json:"ownedHouseIds"`
//
// 车库停车指派:houseId → (车位号 → 车辆配置 id)。2026-09-30 新增。
// <para>车位号是 0 基(= 线格式里的 ParkingSpaceIndex;客户端 Lua 侧
// 会 +1 变成它自己的 parkingId)。这一份对应
// HouseActiveInfo.ParkingSpaceVehicleIdDict(Dict&lt;i32,u32&gt;)——
// 登录包回填它,客户端 GarageManager.CheckHouseInfo 才认得出「哪个车位有车」。</para>
// <para>⛔ 这一份只记「哪个车位停了哪辆车(configId)」,不记 EntityId ——
// 车辆实体是每局重新 spawn 的,EntityId 由 SyncHouseParkingEntityBinding 运行时下发。</para>
//
GarageParking map[uint32]map[int32]uint32 `json:"garageParking"`
RestoredFromDisk bool `json:"restoredFromDisk"`
}
type PlayerProgressItem struct {
TemplateId uint32 `json:"templateId"`
Count uint32 `json:"count"`
IsBind bool `json:"isBind"`
UniqueId uint64 `json:"uniqueId"`
}
//
// One gameplay talent tree as the live session holds it. Mirrors the part of
// PlayerClientInfo.GameplayTalentInfos the talent panel reads: the point balance, the tree exp/level
// and, per talent node, how many layers are activated.
//
type GameplayTalentState4229938 struct {
Point uint32 `json:"point"`
Exp uint32 `json:"exp"`
Level uint32 `json:"level"`
// TalentTreeTalentConfig id -> activated layers.
Nodes map[uint32]uint32 `json:"nodes"`
}
// Save-file shape of GameplayTalentState4229938.
type GameplayTalentRecord4229938 struct {
Point uint32 `json:"point"`
Exp uint32 `json:"exp"`
Level uint32 `json:"level"`
Nodes map[uint32]uint32 `json:"nodes"`
}
// The three part ids the phone is actually wearing for one character.
type PhoneSkinRecord4229938 struct {
Wallpaper uint32 `json:"wallpaper"`
Decoration uint32 `json:"decoration"`
Pendant uint32 `json:"pendant"`
}
//
// What one character is wearing. Mirrors the part of SpiritWearFashionsInfo the wardrobe panel
// actually round-trips: the suit, the source stamp, worn fashion ids and hidden parts.
// WearFashionEditInfoList (per-part scale/rotation/offset) is deliberately not persisted — it is
// a niche editing feature and carrying it would only add a failure mode on restore.
//
type FashionRecord4229938 struct {
FunctionSuitId uint32 `json:"functionSuitId"`
Source int16 `json:"source"`
SourceId uint32 `json:"sourceId"`
IsTryWear bool `json:"isTryWear"`
HiddenParts byte `json:"hiddenParts"`
FashionIds []uint32 `json:"fashionIds"`
}
//
// What the player dyed on one fashion: every saved colour plan, which plan is currently applied
// and how many plan slots are unlocked.
// ⛑ The wire keys are bytes (plan id / coloring type), but System.Text.Json cannot use byte as
// a dictionary key — it would throw on save and take the whole economy snapshot with it. The
// save shape therefore widens every key to uint; FashionColoringStore narrows them back.
//
type FashionColoringRecord4229938 struct {
Schemes map[uint32]map[uint32]uint32 `json:"schemes"`
AppliedScheme uint32 `json:"appliedScheme"`
UnlockedSlots uint32 `json:"unlockedSlots"`
}
//
// Which weapon skin each weapon of one character is wearing (sceneItemId -> skinId). Same rule as
// the dye record: every dictionary key is uint because System.Text.Json cannot use byte as a
// dictionary key and would take the whole economy snapshot down with it.
//
type WeaponSkinRecord4229938 struct {
SkinBySceneItem map[uint32]uint32 `json:"skinBySceneItem"`
}
//
// Minimal character economy persistence for build 4229938. The retail server keeps this in the
// account database; this private server writes one JSON snapshot per player next to
// config/private-server.json and replays it through PlayerInfo at login.
// Writes happen on every wallet/backpack mutation because no reliable session-end hook exists, and
// the file is tiny. Failures are logged and never break gameplay.
//
type PlayerProgressStore struct {
Gate any `json:"gate"`
Options any `json:"options"`
//
// 背包道具是否落盘。2026-09-23 用户定:**关**。
// 背景:点过一次「发放全道具」后 3783 个道具被写进 `config/player-progress.json`,
// 之后每次登录都要恢复它们,`SyncPlayerInfo` 登录包因此涨到 **214 KB**(实测 2 次共 214569 B)。
// 这些道具本来就不是"玩家攒出来的",没必要持久化。
// 关掉之后:钱包三种货币 / 抽卡保底计数 / 手机壳 / 时装 / 武器槽 / 天赋**照旧持久化**,
// 只有背包道具变成"每次登录从空开始"。GM 面板「发放全道具」在本次会话内照样能用,
// 只是重登不会带过来。
// 想恢复持久化:把这个常量改回 true 即可(读、写两侧的分支都已经写好)。
//
// 用 static readonly 而不是 const:const false 会让编译器把两个分支判成死代码(CS0162),
// 刷两条 warning 出来。改成 readonly 后分支照常编译,改回 true 也不用动别处。
PersistBackpackItems bool `json:"persistBackpackItems"`
FilePath string `json:"filePath"`
}
type SaveFile struct {
Pid uint64 `json:"pid"`
AccountId string `json:"accountId"`
Money float64 `json:"money"`
Gold float64 `json:"gold"`
BindingGold float64 `json:"bindingGold"`
SavedAtUtc string `json:"savedAtUtc"`
Items []SaveItem `json:"items"`
GachaDraws map[uint32]uint32 `json:"gachaDraws"`
// 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".
PhoneSkins map[uint32]SavePhoneSkin `json:"phoneSkins"`
// Equipped weapon instance id per character. Zero/empty = keep the authored default.
LastWeaponBySpirit map[uint32]uint64 `json:"lastWeaponBySpirit"`
// Fight style per (character, style type), key flattened to "spiritId:typeId".
SpiritStyleOverrides map[string]uint32 `json:"spiritStyleOverrides"`
// Fight style per weapon instance id.
WeaponStyleOverrides map[uint64]uint32 `json:"weaponStyleOverrides"`
// Worn outfit per character. Absent = wear the authored default.
Fashions map[uint32]SaveFashion `json:"fashions"`
// 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".
Colorings map[uint32]SaveFashionColoring `json:"colorings"`
// Livehouse music-game clears: song id -> counts. Absent = never cleared.
LivehouseRecords map[uint32]LivehouseRecord4229938 `json:"livehouseRecords"`
// True once the Livehouse intro timeline has been handed out.
LivehouseFirstPlayDone bool `json:"livehouseFirstPlayDone"`
// Equipped weapon skins per character. Absent = authored default skins.
WeaponSkins map[uint32]map[uint32]uint32 `json:"weaponSkins"`
// Edited weapon wheels per character. Zero = empty slot; absent = authored loadout.
WeaponSlotsBySpirit map[uint32][]uint64 `json:"weaponSlotsBySpirit"`
// 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.
Position *SaveTransform `json:"position"`
// 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".
GameplayTalents map[uint32]SaveGameplayTalent `json:"gameplayTalents"`
// 已购买的房产(HouseConfig.Id)。缺失 = 一套都没有,这正是旧存档的正确语义。
OwnedHouseIds []uint32 `json:"ownedHouseIds"`
// 车库停车指派:houseId → (车位号(0 基) → 车辆 configId)。
// 缺失 = 车库全空,正是旧存档的正确语义。
GarageParking map[uint32]map[int32]uint32 `json:"garageParking"`
}
type SaveGameplayTalent struct {
Point uint32 `json:"point"`
Exp uint32 `json:"exp"`
Level uint32 `json:"level"`
Nodes map[uint32]uint32 `json:"nodes"`
}
type SaveTransform struct {
RaidId uint32 `json:"raidId"`
InstanceId uint64 `json:"instanceId"`
UniverseId uint32 `json:"universeId"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Facing float32 `json:"facing"`
}
type SaveFashion struct {
FunctionSuitId uint32 `json:"functionSuitId"`
Source int16 `json:"source"`
SourceId uint32 `json:"sourceId"`
IsTryWear bool `json:"isTryWear"`
HiddenParts byte `json:"hiddenParts"`
FashionIds []uint32 `json:"fashionIds"`
}
// Save shape of 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).
type SaveFashionColoring struct {
Schemes map[uint32]map[uint32]uint32 `json:"schemes"`
AppliedScheme uint32 `json:"appliedScheme"`
UnlockedSlots uint32 `json:"unlockedSlots"`
}
type SavePhoneSkin struct {
Wallpaper uint32 `json:"wallpaper"`
Decoration uint32 `json:"decoration"`
Pendant uint32 `json:"pendant"`
}
type SaveItem struct {
TemplateId uint32 `json:"templateId"`
Count uint32 `json:"count"`
IsBind bool `json:"isBind"`
UniqueId uint64 `json:"uniqueId"`
}