406 lines
11 KiB
Go
406 lines
11 KiB
Go
package common
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"sync"
|
|
)
|
|
|
|
var persistentWriteMu sync.Mutex
|
|
|
|
func ReadPersistent() (*Persistent, error) {
|
|
raw, err := os.ReadFile("persistent")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var p Persistent
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, fmt.Errorf("parse persistent: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func jsonObject(value any) (map[string]any, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var object map[string]any
|
|
err = json.Unmarshal(raw, &object)
|
|
return object, err
|
|
}
|
|
|
|
func (f *FreesrData) persistentSnapshot() Persistent {
|
|
ex := make(map[uint32]bool)
|
|
for id := range AvatarExRankMap {
|
|
if avatar := f.Avatars[id]; avatar != nil {
|
|
ex[id] = avatar.ExRankUnlocked
|
|
}
|
|
}
|
|
return Persistent{Profile: f.Profile, TeamPresets: f.TeamPresets, ActiveTeamPreset: f.ActiveTeamPreset, TierceProgress: f.TierceProgress, TechniqueFoodStock: f.TechniqueFoodStock, AutoTechniqueFood: f.AutoTechniqueFood, Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
|
|
MainCharacter: f.MainCharacter, MarchType: f.MarchType,
|
|
EnableSWGlobal: f.EnableSWGlobal, EnableCastoriceGlobal: f.EnableCastoriceGlobal,
|
|
EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, PlayerOutfits: f.PlayerOutfits, SceneGroupProperties: f.SceneGroupProperties, ExRanksUnlocked: ex}
|
|
}
|
|
|
|
// Apply only locally changed fields. A stale game session saving its position
|
|
// must not undo an EX switch written by the SDK process. Nested maps are merged
|
|
// per key so different avatars' switches do not overwrite one another.
|
|
func mergePersistentChanges(existing, baseline, next map[string]any) {
|
|
for key, value := range next {
|
|
old, known := baseline[key]
|
|
if known && reflect.DeepEqual(old, value) {
|
|
continue
|
|
}
|
|
newMap, newOK := value.(map[string]any)
|
|
oldMap, oldOK := old.(map[string]any)
|
|
if newOK && oldOK {
|
|
dest, ok := existing[key].(map[string]any)
|
|
if !ok {
|
|
dest = make(map[string]any)
|
|
}
|
|
mergePersistentChanges(dest, oldMap, newMap)
|
|
for removed := range oldMap {
|
|
if _, present := newMap[removed]; !present {
|
|
delete(dest, removed)
|
|
}
|
|
}
|
|
existing[key] = dest
|
|
} else {
|
|
existing[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeJSONAtomic(path string, value any) error {
|
|
raw, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := os.CreateTemp(".", ".gac-save-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := file.Name()
|
|
defer os.Remove(name)
|
|
if _, err := file.Write(raw); err != nil {
|
|
file.Close()
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(name, path)
|
|
}
|
|
|
|
func (f *FreesrData) SavePersistentChecked() error {
|
|
persistentWriteMu.Lock()
|
|
defer persistentWriteMu.Unlock()
|
|
unlock, err := lockPersistentFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
next, err := jsonObject(f.persistentSnapshot())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing := make(map[string]any)
|
|
if raw, err := os.ReadFile("persistent"); err == nil {
|
|
if err := json.Unmarshal(raw, &existing); err != nil {
|
|
return fmt.Errorf("refuse to overwrite invalid persistent: %w", err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
mergePersistentChanges(existing, f.persistentBaseline, next)
|
|
if err := writeJSONAtomic("persistent", existing); err != nil {
|
|
return err
|
|
}
|
|
// Track our own observed snapshot, not external changes we have not loaded.
|
|
f.persistentBaseline = next
|
|
return nil
|
|
}
|
|
|
|
// RefreshExRanks imports persisted switches without replacing inventory,
|
|
// lineup, scene or the configured battle. Call under the session state lock.
|
|
func (f *FreesrData) RefreshExRanks(p *Persistent) []uint32 {
|
|
var changed []uint32
|
|
for id := range AvatarExRankMap {
|
|
avatar := f.Avatars[id]
|
|
if avatar == nil {
|
|
continue
|
|
}
|
|
unlocked := p.ExRanksUnlocked[id]
|
|
if avatar.ExRankUnlocked != unlocked {
|
|
avatar.ExRankUnlocked = unlocked
|
|
changed = append(changed, id)
|
|
}
|
|
}
|
|
if current, err := jsonObject(f.persistentSnapshot()); err == nil {
|
|
if f.persistentBaseline == nil {
|
|
f.persistentBaseline = make(map[string]any)
|
|
}
|
|
f.persistentBaseline["ex_ranks_unlocked"] = current["ex_ranks_unlocked"]
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// Patch only the selected avatar's rank in the latest import. Unknown FreeSR
|
|
// fields and all inventory IDs/affixes retain their original JSON values.
|
|
func SaveAvatarRank(id, rank uint32) error {
|
|
persistentWriteMu.Lock()
|
|
defer persistentWriteMu.Unlock()
|
|
unlock, err := lockPersistentFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
raw, err := os.ReadFile("freesr-data.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &root); err != nil {
|
|
return err
|
|
}
|
|
var avatars map[uint32]json.RawMessage
|
|
if err := json.Unmarshal(root["avatars"], &avatars); err != nil {
|
|
return err
|
|
}
|
|
var avatar map[string]json.RawMessage
|
|
if err := json.Unmarshal(avatars[id], &avatar); err != nil {
|
|
return err
|
|
}
|
|
var data map[string]json.RawMessage
|
|
if err := json.Unmarshal(avatar["data"], &data); err != nil {
|
|
return err
|
|
}
|
|
data["rank"], err = json.Marshal(rank)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
avatar["data"], err = json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
avatars[id], err = json.Marshal(avatar)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root["avatars"], err = json.Marshal(avatars)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSONAtomic("freesr-data.json", root)
|
|
}
|
|
|
|
// SaveEquipmentAssignments patches only equip_avatar on the explicitly changed
|
|
// inventory entries. It preserves all other equipment, relic stats and unknown
|
|
// FreeSR fields, so an in-game equip action never replaces the full inventory.
|
|
func SaveEquipmentAssignments(lightcones, relics map[uint32]uint32) error {
|
|
if len(lightcones) == 0 && len(relics) == 0 {
|
|
return nil
|
|
}
|
|
persistentWriteMu.Lock()
|
|
defer persistentWriteMu.Unlock()
|
|
unlock, err := lockPersistentFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
raw, err := os.ReadFile("freesr-data.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &root); err != nil {
|
|
return err
|
|
}
|
|
for _, collection := range []struct {
|
|
field string
|
|
owners map[uint32]uint32
|
|
}{{"lightcones", lightcones}, {"relics", relics}} {
|
|
if len(collection.owners) == 0 {
|
|
continue
|
|
}
|
|
var entries []json.RawMessage
|
|
if err := json.Unmarshal(root[collection.field], &entries); err != nil {
|
|
return fmt.Errorf("read %s: %w", collection.field, err)
|
|
}
|
|
found := make(map[uint32]bool, len(collection.owners))
|
|
for i, entry := range entries {
|
|
var item map[string]json.RawMessage
|
|
if err := json.Unmarshal(entry, &item); err != nil {
|
|
return fmt.Errorf("decode %s entry: %w", collection.field, err)
|
|
}
|
|
var uid uint32
|
|
if err := json.Unmarshal(item["internal_uid"], &uid); err != nil {
|
|
return fmt.Errorf("read %s internal_uid: %w", collection.field, err)
|
|
}
|
|
owner, update := collection.owners[uid]
|
|
if !update {
|
|
continue
|
|
}
|
|
item["equip_avatar"], err = json.Marshal(owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries[i], err = json.Marshal(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found[uid] = true
|
|
}
|
|
for uid := range collection.owners {
|
|
if !found[uid] {
|
|
return fmt.Errorf("%s item internal_uid %d not found", collection.field, uid)
|
|
}
|
|
}
|
|
root[collection.field], err = json.Marshal(entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writeJSONAtomic("freesr-data.json", root)
|
|
}
|
|
|
|
// SaveNewLightcones appends catalog entries that are absent from the latest
|
|
// import while preserving existing inventory records and unknown fields.
|
|
func SaveNewLightcones(additions []Lightcone) error {
|
|
if len(additions) == 0 {
|
|
return nil
|
|
}
|
|
persistentWriteMu.Lock()
|
|
defer persistentWriteMu.Unlock()
|
|
unlock, err := lockPersistentFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
raw, err := os.ReadFile("freesr-data.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &root); err != nil {
|
|
return err
|
|
}
|
|
entries, err := readInventoryEntries(root, "lightcones")
|
|
if err != nil {
|
|
return fmt.Errorf("read lightcones: %w", err)
|
|
}
|
|
var existing []Lightcone
|
|
for _, entry := range entries {
|
|
var item Lightcone
|
|
if err := json.Unmarshal(entry, &item); err != nil {
|
|
return fmt.Errorf("decode lightcone: %w", err)
|
|
}
|
|
existing = append(existing, item)
|
|
}
|
|
usedIDs := make(map[uint32]bool, len(existing))
|
|
usedUIDs := make(map[uint32]bool, len(existing))
|
|
for _, item := range existing {
|
|
usedIDs[item.ItemID] = true
|
|
usedUIDs[item.InternalUID] = true
|
|
}
|
|
for _, item := range additions {
|
|
if usedIDs[item.ItemID] || usedUIDs[item.InternalUID] {
|
|
continue
|
|
}
|
|
encoded, err := json.Marshal(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, encoded)
|
|
usedIDs[item.ItemID] = true
|
|
usedUIDs[item.InternalUID] = true
|
|
}
|
|
root["lightcones"], err = json.Marshal(entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSONAtomic("freesr-data.json", root)
|
|
}
|
|
|
|
// SaveNewRelics appends catalog instances absent from the latest import.
|
|
func SaveNewRelics(additions []Relic) error {
|
|
if len(additions) == 0 {
|
|
return nil
|
|
}
|
|
persistentWriteMu.Lock()
|
|
defer persistentWriteMu.Unlock()
|
|
unlock, err := lockPersistentFile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
raw, err := os.ReadFile("freesr-data.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &root); err != nil {
|
|
return err
|
|
}
|
|
entries, err := readInventoryEntries(root, "relics")
|
|
if err != nil {
|
|
return fmt.Errorf("read relics: %w", err)
|
|
}
|
|
var existing []Relic
|
|
for _, entry := range entries {
|
|
var item Relic
|
|
if err := json.Unmarshal(entry, &item); err != nil {
|
|
return fmt.Errorf("decode relic: %w", err)
|
|
}
|
|
existing = append(existing, item)
|
|
}
|
|
usedIDs := make(map[uint32]bool, len(existing))
|
|
usedUIDs := make(map[uint32]bool, len(existing))
|
|
for _, item := range existing {
|
|
usedIDs[item.RelicID] = true
|
|
usedUIDs[item.InternalUID] = true
|
|
}
|
|
for _, item := range additions {
|
|
if usedIDs[item.RelicID] || usedUIDs[item.InternalUID] {
|
|
continue
|
|
}
|
|
encoded, err := json.Marshal(item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, encoded)
|
|
usedIDs[item.RelicID] = true
|
|
usedUIDs[item.InternalUID] = true
|
|
}
|
|
root["relics"], err = json.Marshal(entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSONAtomic("freesr-data.json", root)
|
|
}
|
|
|
|
// Empty initial saves may contain account settings without inventory arrays.
|
|
// Treat absent or null collections as empty, while still rejecting malformed
|
|
// collections so a bad import cannot be silently replaced.
|
|
func readInventoryEntries(root map[string]json.RawMessage, field string) ([]json.RawMessage, error) {
|
|
if root == nil {
|
|
return nil, fmt.Errorf("inventory data must be a JSON object")
|
|
}
|
|
raw, ok := root[field]
|
|
if !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return []json.RawMessage{}, nil
|
|
}
|
|
var entries []json.RawMessage
|
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
return nil, err
|
|
}
|
|
if entries == nil {
|
|
entries = []json.RawMessage{}
|
|
}
|
|
return entries, nil
|
|
}
|