304 lines
14 KiB
Go
304 lines
14 KiB
Go
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<i32,u32>)——
|
||
// 登录包回填它,客户端 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"`
|
||
}
|
||
|