chore: checkpoint stable battle and eidolon progression

This commit is contained in:
CAtlas0784 committed 2026-10-17 16:38:03 +07:00
1 parent 365e66ffe0
commit a681affdb3
23 files changed
+34511 -11310

No files matched your search

+28 -29
View File
@@ -8,7 +8,6 @@ package common
import (
"encoding/json"
"fmt"
"os"
"sort"
)
@@ -149,6 +148,23 @@ type SubAffix struct {
Step uint32 `json:"step"`
}
func (s *SubAffix) UnmarshalJSON(data []byte) error {
type Alias SubAffix
aux := &struct {
Cnt *uint32 `json:"cnt"`
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if aux.Cnt != nil && s.Count == 0 {
s.Count = *aux.Cnt
}
return nil
}
type Relic struct {
Level uint32 `json:"level"`
RelicID uint32 `json:"relic_id"`
@@ -352,6 +368,8 @@ func DefaultPersistent() Persistent {
// ---------------------------------------------------------------------------
type FreesrData struct {
persistentBaseline map[string]any
Lightcones []Lightcone `json:"lightcones"`
Relics []Relic `json:"relics"`
Avatars map[uint32]*AvatarJson `json:"avatars"`
@@ -372,7 +390,7 @@ type FreesrData struct {
// LoadFreesrData reads freesr-data.json + persistent and merges them,
// mirroring FreesrData::load() from the Rust crate.
func LoadFreesrData() (*FreesrData, error) {
raw, err := os.ReadFile("freesr-data.json")
raw, err := readResourceFile("freesr-data.json")
if err != nil {
return nil, fmt.Errorf("failed to read freesr-data.json: %w", err)
}
@@ -390,7 +408,7 @@ func LoadFreesrData() (*FreesrData, error) {
}
// persistent is optional
pers, err := os.ReadFile("persistent")
pers, err := readResourceFile("persistent")
if err == nil {
var p Persistent
if err := json.Unmarshal(pers, &p); err == nil {
@@ -440,6 +458,10 @@ func LoadFreesrData() (*FreesrData, error) {
data.MarchType = MultiPathMarchHunt
}
data.persistentBaseline, err = jsonObject(data.persistentSnapshot())
if err != nil {
return nil, err
}
return &data, nil
}
@@ -453,7 +475,7 @@ func (f *FreesrData) SortedLineupKeys() []uint32 {
return keys
}
// VerifyLineup pads the lineup to 4 slots, defaulting to {0:8001}, then saves.
// VerifyLineup normalizes slots in memory. Reading a save must not rewrite it.
func (f *FreesrData) VerifyLineup() {
if len(f.Lineups) == 0 {
f.Lineups = map[uint32]uint32{0: 8001, 1: 0, 2: 0, 3: 0}
@@ -464,35 +486,12 @@ func (f *FreesrData) VerifyLineup() {
}
}
}
f.SavePersistent()
}
// SavePersistent writes the persistent subset back to the "persistent" file.
func (f *FreesrData) SavePersistent() {
exUnlocked := make(map[uint32]bool)
for aid, av := range f.Avatars {
if av.ExRankUnlocked {
exUnlocked[aid] = true
}
}
p := Persistent{
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,
ExRanksUnlocked: exUnlocked,
}
out, err := json.MarshalIndent(p, "", " ")
if err != nil {
return
}
if err := os.WriteFile("persistent", out, 0o644); err == nil {
LogInfo("persistent saved")
if err := f.SavePersistentChecked(); err != nil {
LogInfo("[SAVE ERROR] %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
package common
import (
"os"
"syscall"
)
func lockPersistentFile() (func(), error) {
file, err := os.OpenFile("persistent.lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
file.Close()
return nil, err
}
return func() { syscall.Flock(int(file.Fd()), syscall.LOCK_UN); file.Close() }, nil
}
@@ -0,0 +1,30 @@
//go:build windows
package common
import (
"fmt"
"os"
"syscall"
"unsafe"
)
// LockFileEx's exclusive byte-range lock is released by Windows if either
// process exits. The shared lock file coordinates SDK and game server saves.
func lockPersistentFile() (func(), error) {
file, err := os.OpenFile("persistent.lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
dll := syscall.NewLazyDLL("kernel32.dll")
lock := dll.NewProc("LockFileEx")
unlock := dll.NewProc("UnlockFileEx")
overlapped := new(syscall.Overlapped)
const lockfileExclusiveLock = 2 // Win32 LOCKFILE_EXCLUSIVE_LOCK.
ok, _, callErr := lock.Call(file.Fd(), lockfileExclusiveLock, 0, 1, 0, uintptr(unsafe.Pointer(overlapped)))
if ok == 0 {
file.Close()
return nil, fmt.Errorf("lock persistent: %w", callErr)
}
return func() { unlock.Call(file.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(overlapped))); file.Close() }, nil
}
+198
View File
@@ -0,0 +1,198 @@
package common
import (
"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{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, 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)
}
+51 -7
View File
@@ -162,11 +162,36 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
}
for anchorType, level := range a.Data.SkillsByAnchorType {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: anchorType,
Level: level,
})
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
}
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pid,
Level: 1,
})
}
}
}
// deterministic order (Rust iterated the HashMap; order is irrelevant to the
// client, but stable output keeps packets reproducible)
@@ -211,8 +236,27 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel
},
}
for pointID, level := range a.Data.Skills {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
}
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pid, Level: 1})
}
}
}
sortAvatarSkillTree(ba.SkilltreeList)
+62
View File
@@ -1,6 +1,7 @@
package common
import (
"encoding/json"
"testing"
goproto "gac-hkrpg-go/protocol/proto"
@@ -33,3 +34,64 @@ func TestStandaloneBattleRelicWire(t *testing.T) {
t.Fatal("inventory and equipped relic references must retain their IDs")
}
}
func TestAvatarPathSkillTreePointIds(t *testing.T) {
// 1. avatar with explicit Skills map uses real PointIDs directly
av := AvatarJson{
AvatarID: 1213,
Data: AvatarData{
Rank: 0,
Skills: map[uint32]uint32{
1213001: 6,
1213002: 10,
},
SkillsByAnchorType: map[uint32]uint32{
1: 6,
2: 10,
},
},
}
proto := av.ToAvatarPathDataProto(nil, nil, 0)
if len(proto.AvatarPathSkillTree) != 2 {
t.Fatalf("expected 2 skill tree nodes, got %d", len(proto.AvatarPathSkillTree))
}
if proto.AvatarPathSkillTree[0].PointId != 1213001 || proto.AvatarPathSkillTree[1].PointId != 1213002 {
t.Fatalf("point ids must be real config point IDs, got %v", proto.AvatarPathSkillTree)
}
// 2. avatar with only SkillsByAnchorType resolves from AvatarSkillTreeConfig.json
avAnchorOnly := AvatarJson{
AvatarID: 1001,
Data: AvatarData{
Rank: 0,
SkillsByAnchorType: map[uint32]uint32{
1: 6,
},
},
}
protoAnchor := avAnchorOnly.ToAvatarPathDataProto(nil, nil, 0)
if len(protoAnchor.AvatarPathSkillTree) != 1 {
t.Fatalf("expected 1 skill tree node, got %d", len(protoAnchor.AvatarPathSkillTree))
}
if protoAnchor.AvatarPathSkillTree[0].PointId != 1001001 {
t.Fatalf("expected resolved point ID 1001001 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
}
}
func TestSubAffixUnmarshal(t *testing.T) {
var sub1 SubAffix
if err := json.Unmarshal([]byte(`{"sub_affix_id": 7, "cnt": 4, "step": 2}`), &sub1); err != nil {
t.Fatal(err)
}
if sub1.Count != 4 || sub1.SubAffixID != 7 || sub1.Step != 2 {
t.Fatalf("cnt alias failed: %+v", sub1)
}
var sub2 SubAffix
if err := json.Unmarshal([]byte(`{"sub_affix_id": 8, "count": 3, "step": 1}`), &sub2); err != nil {
t.Fatal(err)
}
if sub2.Count != 3 || sub2.SubAffixID != 8 || sub2.Step != 1 {
t.Fatalf("count parsing failed: %+v", sub2)
}
}
+44 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
@@ -76,6 +77,7 @@ type LevelOutputConfig struct {
type AvatarConfig struct {
WeaknessBuffID uint32 `json:"weaknessBuffId"`
DamageType string `json:"damageType"`
MaxRank uint32 `json:"maxRank"`
}
type JsonConfig struct {
@@ -85,6 +87,7 @@ type JsonConfig struct {
MapDefaultEntranceMap map[uint32]uint32 `json:"mapDefaultEntranceMap"`
RelicAvatarRecommend map[uint32][]uint32 `json:"relicAvatarRecommend"`
SkinIDs []uint32 `json:"skinIds"`
AvatarSkillTreePoints map[uint32]map[uint32]uint32 `json:"-"`
}
var (
@@ -146,15 +149,23 @@ type AnchorConfigFile struct {
AnchorConfig []AnchorConfigEntry `json:"anchor_config"`
}
func readResourceFile(paths ...string) ([]byte, error) {
// ReadResourceFile reads a resource file checking current directory and parent directories.
func ReadResourceFile(paths ...string) ([]byte, error) {
for _, p := range paths {
if raw, err := os.ReadFile(p); err == nil {
return raw, nil
for _, prefix := range []string{"", "..", "../..", "../../.."} {
target := filepath.Join(prefix, p)
if raw, err := os.ReadFile(target); err == nil {
return raw, nil
}
}
}
return nil, os.ErrNotExist
}
func readResourceFile(paths ...string) ([]byte, error) {
return ReadResourceFile(paths...)
}
// GameRes lazily parses res.json and merges supplementary configs from resources/
func GameRes() (*JsonConfig, error) {
gameResOnce.Do(func() {
@@ -186,6 +197,34 @@ func GameRes() (*JsonConfig, error) {
}
func loadSupplementaryResources(res *JsonConfig) {
// AvatarSkillTreeConfig: maps AvatarID and AnchorType (e.g. Point01 -> 1) to real PointID
if raw, err := readResourceFile("resources/AvatarSkillTreeConfig.json", "AvatarSkillTreeConfig.json"); err == nil {
var tree struct {
Entries []struct {
AvatarID uint32 `json:"AvatarID"`
PointID uint32 `json:"PointID"`
AnchorType string `json:"AnchorType"`
} `json:"avatar_skill_tree"`
}
if err := json.Unmarshal(raw, &tree); err == nil {
if res.AvatarSkillTreePoints == nil {
res.AvatarSkillTreePoints = make(map[uint32]map[uint32]uint32)
}
for _, entry := range tree.Entries {
if entry.AvatarID == 0 || entry.PointID == 0 {
continue
}
var anchorNum uint32
if n, _ := fmt.Sscanf(entry.AnchorType, "Point%d", &anchorNum); n == 1 {
if res.AvatarSkillTreePoints[entry.AvatarID] == nil {
res.AvatarSkillTreePoints[entry.AvatarID] = make(map[uint32]uint32)
}
res.AvatarSkillTreePoints[entry.AvatarID][anchorNum] = entry.PointID
}
}
}
}
// PearlSR's resource export keeps avatar data in AvatarConfig.json;
// res.json may legitimately contain an empty avatarConfigs object.
if raw, err := readResourceFile("resources/AvatarConfig.json"); err == nil {
@@ -193,12 +232,14 @@ func loadSupplementaryResources(res *JsonConfig) {
Entries []struct {
ID uint32 `json:"AvatarID"`
DamageType string `json:"DamageType"`
MaxRank uint32 `json:"MaxRank"`
} `json:"avatar_config"`
}
if err := json.Unmarshal(raw, &avatars); err == nil {
for _, avatar := range avatars.Entries {
cfg := res.AvatarConfigs[avatar.ID]
cfg.DamageType = avatar.DamageType
cfg.MaxRank = avatar.MaxRank
res.AvatarConfigs[avatar.ID] = cfg
}
}