chore: checkpoint stable battle and eidolon progression
This commit is contained in:
1 parent
365e66ffe0
commit
a681affdb3
23 files changed
+34482
-11281
No files matched your search
@@ -10,3 +10,7 @@ battle_last.bin
|
|||||||
battle_last_response.bin
|
battle_last_response.bin
|
||||||
battle_last_capture.json
|
battle_last_capture.json
|
||||||
battle_diagnostics/
|
battle_diagnostics/
|
||||||
|
persistent.lock
|
||||||
|
.gac-save-*.tmp
|
||||||
|
battle_report.json
|
||||||
|
.local-checkpoints/
|
||||||
@@ -14,3 +14,5 @@
|
|||||||
- handler ต้อง resolve CmdID จากชื่อในตาราง protocol ที่มี revision อ้างอิง ห้ามรับ alias เลขจากหลายเวอร์ชันหรือส่ง response หลายเลขเพื่อเดา callback; ถ้า schema ยัง obfuscated ต้องบันทึกข้อสันนิษฐานและข้อจำกัดไว้
|
- handler ต้อง resolve CmdID จากชื่อในตาราง protocol ที่มี revision อ้างอิง ห้ามรับ alias เลขจากหลายเวอร์ชันหรือส่ง response หลายเลขเพื่อเดา callback; ถ้า schema ยัง obfuscated ต้องบันทึกข้อสันนิษฐานและข้อจำกัดไว้
|
||||||
- อย่าเปลี่ยนค่า protocol เช่น ClientDownloadData Lua format ตามเลข patch ของ Client โดยไม่มีต้นฉบับรองรับ และอย่าเปลี่ยนแค่ข้อความเวอร์ชันเพื่ออ้างว่าอัปเดต handler แล้ว
|
- อย่าเปลี่ยนค่า protocol เช่น ClientDownloadData Lua format ตามเลข patch ของ Client โดยไม่มีต้นฉบับรองรับ และอย่าเปลี่ยนแค่ข้อความเวอร์ชันเพื่ออ้างว่าอัปเดต handler แล้ว
|
||||||
- SDK และ game server ที่เปิดผ่าน launcher ต้องแสดงคอนโซลและ log ให้ผู้ใช้เห็น ห้ามซ่อนหน้าต่างหรือ redirect จนคอนโซลว่าง เว้นแต่ผู้ใช้ขอเอง เมนูแบบ interactive ปิดหลังทำรายการเสร็จ 5 วินาที โดยไม่ปิดเซิร์ฟเวอร์ตามไปด้วย
|
- SDK และ game server ที่เปิดผ่าน launcher ต้องแสดงคอนโซลและ log ให้ผู้ใช้เห็น ห้ามซ่อนหน้าต่างหรือ redirect จนคอนโซลว่าง เว้นแต่ผู้ใช้ขอเอง เมนูแบบ interactive ปิดหลังทำรายการเสร็จ 5 วินาที โดยไม่ปิดเซิร์ฟเวอร์ตามไปด้วย
|
||||||
|
- งาน progression/EX ต้องรักษาเส้นทาง Battle ที่ยืนยันกับ Client แล้ว การซิงก์ EX ส่งเฉพาะ avatar/material ที่เปลี่ยน ห้ามล้าง inventory ทั้งชุดเพื่อซิงก์สวิตช์ และให้นำการเปลี่ยนจากภายนอกเข้า Client หลังจบ Battle
|
||||||
|
- ตัวอ่าน persistent ต้องไม่เขียนไฟล์กลับ การบันทึกจาก session เก่าต้องไม่ทับค่าที่ API เปลี่ยน และต้องเก็บ EX false ได้ ห้ามปลด EX อัตโนมัติจากการขึ้น E6 หรือปลดทุกตัวเมื่ออ่าน request ID ไม่ได้
|
||||||
@@ -124,10 +124,14 @@ go test ./...
|
|||||||
| GET, POST | `/api/freesr` | อ่านหรืออัปเดต FreeSR data |
|
| GET, POST | `/api/freesr` | อ่านหรืออัปเดต FreeSR data |
|
||||||
| GET, POST | `/api/freesr/avatars` | อ่านหรืออัปเดตข้อมูลตัวละคร |
|
| GET, POST | `/api/freesr/avatars` | อ่านหรืออัปเดตข้อมูลตัวละคร |
|
||||||
| GET, POST | `/api/persistent` | อ่านหรืออัปเดตข้อมูล persistent |
|
| GET, POST | `/api/persistent` | อ่านหรืออัปเดตข้อมูล persistent |
|
||||||
|
| GET, POST | `/api/battle_report` | อ่านสถิติดาเมจ/การต่อสู้ล่าสุด หรือบันทึกรายงานการต่อสู้ |
|
||||||
|
| POST | `/api/battle_report/reset` | ล้างสถิติรายงานการต่อสู้ |
|
||||||
| POST | `/api/exrank/toggle` | สลับการปลดล็อก EX rank |
|
| POST | `/api/exrank/toggle` | สลับการปลดล็อก EX rank |
|
||||||
| POST | `/api/avatar/rank` | เปลี่ยน rank ตัวละคร |
|
| POST | `/api/avatar/rank` | เปลี่ยน rank ตัวละคร |
|
||||||
| POST | `/api/avatar/max_all` | ปรับข้อมูลตัวละครตามการทำงานของ handler |
|
| POST | `/api/avatar/max_all` | ปรับข้อมูลตัวละครตามการทำงานของ handler |
|
||||||
| POST | `/api/teleport` | อัปเดตข้อมูลจุดหมาย teleport |
|
| POST | `/api/teleport` | อัปเดตข้อมูลจุดหมาย teleport |
|
||||||
|
| POST | `/api/command` | ส่งคำสั่ง GM ไปยังเซิร์ฟเวอร์ |
|
||||||
|
| GET | `/api/data-in-game` | อ่านข้อมูลเกมที่แคชไว้ในเซิร์ฟเวอร์ |
|
||||||
|
|
||||||
รูปแบบ request และเงื่อนไขการอัปเดตอยู่ใน `internal/sdkserver/api.go` การอัปเดตข้อมูลผ่าน API ยังต้องตรวจผลการโหลดข้อมูลของ Client ด้วย
|
รูปแบบ request และเงื่อนไขการอัปเดตอยู่ใน `internal/sdkserver/api.go` การอัปเดตข้อมูลผ่าน API ยังต้องตรวจผลการโหลดข้อมูลของ Client ด้วย
|
||||||
|
|
||||||
@@ -182,6 +186,29 @@ go test ./...
|
|||||||
|
|
||||||
แก้โปรไฟล์เพิ่มเติม: field 4 ของ GetPlayerBoardDataScRsp คือการ์ดส่วนตัว, field 8 คือรูปโปรไฟล์ และ field 9 คือตัวละครช่วยเหลือ โค้ดเดิมใส่ ID ผิดประเภทและไม่มี head frame/display data ปรับให้ใช้ `resources/AvatarPlayerIcon.json`, `resources/ProfileConfig.json` และทีมจริง พร้อมเติม ArchiveData ทั้งสอง response ผู้ใช้ยืนยันหน้าตัวละครหายค้างหลังเปิดเกมใหม่
|
แก้โปรไฟล์เพิ่มเติม: field 4 ของ GetPlayerBoardDataScRsp คือการ์ดส่วนตัว, field 8 คือรูปโปรไฟล์ และ field 9 คือตัวละครช่วยเหลือ โค้ดเดิมใส่ ID ผิดประเภทและไม่มี head frame/display data ปรับให้ใช้ `resources/AvatarPlayerIcon.json`, `resources/ProfileConfig.json` และทีมจริง พร้อมเติม ArchiveData ทั้งสอง response ผู้ใช้ยืนยันหน้าตัวละครหายค้างหลังเปิดเกมใหม่
|
||||||
|
|
||||||
|
## Eidolon และ EX: การแจกไอเท็มและซิงก์
|
||||||
|
|
||||||
|
`resources/EidolonConfig.json` กำหนดรายการแจกของ Jing Yuan, Dan Heng IL และ Acheron:
|
||||||
|
|
||||||
|
| ตัวละคร | ก้อน E1–E6 | ก้อน EX |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Jing Yuan | 11204 | 131204 |
|
||||||
|
| Dan Heng IL | 11213 | 131213 |
|
||||||
|
| Acheron | 11308 | 131308 |
|
||||||
|
|
||||||
|
ก้อนปกติแจกเท่ากับต้นทุน rank ที่ยังไม่ปลด (E0 ได้ 6 ชิ้น) อ้างอิง `AvatarRankConfig.UnlockCost` และ `ItemConfigAvatarRank` จาก [ชุดข้อมูล revision ที่บันทึกไว้](https://gitlab.com/Dimbreath/turnbasedgamedata/-/tree/312b4597691e0a29cf9828a15b592757dba86949/ExcelOutput) และเทียบ RankIDList กับ AvatarConfig ในโปรเจกต์ ก้อน EX ใช้สาม ID ที่ผู้ใช้ระบุ แจกหนึ่งชิ้นเมื่อ EX ปิดอยู่ ไม่สร้าง ID ด้วยสูตรจาก AvatarID ผู้ใช้ยืนยันรายการใหม่และการกดปลดใน Client แล้ว
|
||||||
|
|
||||||
|
- `GetBag` ส่งรายการแจกเมื่อเข้าเกม ก้อนลดตาม rank/EX ที่บันทึกไว้ ไม่รีเซ็ต progression เมื่อเปิดเกมใหม่
|
||||||
|
- RankUpAvatar เซฟ rank ลง freesr-data.json โดยแก้เฉพาะค่า rank และเก็บฟิลด์นำเข้าอื่นไว้ E1–E6 ไม่เปิด EX ให้เอง
|
||||||
|
- EX ทั้งสามคนเริ่มปิดไว้ตามคำขอ `false` เก็บชัดเจนใน persistent; การเปิดจากในเกมบันทึกและส่ง AvatarSync/MaterialList เฉพาะส่วน
|
||||||
|
- เกมอ่าน EX จาก persistent ทุกประมาณ 1 วินาที แล้วส่งข้อมูลตัวละครที่เปลี่ยน ใช้เส้นทางนี้ร่วมกับ API เดิมโดยไม่แก้ source เว็บ GaC srtool
|
||||||
|
- ระหว่าง Battle เลื่อนการนำเข้า EX และ freesr-data ไปหลัง PVEBattleResult เพื่อไม่เปลี่ยนอุปกรณ์หรือข้อมูล actor กลางการต่อสู้
|
||||||
|
- โหลดข้อมูลไม่เซฟกลับอัตโนมัติอีกแล้ว SavePersistent รวมเฉพาะค่าที่เปลี่ยนจาก snapshot ของผู้เขียน รักษาฟิลด์อื่นที่ API แก้ พร้อม lock ข้าม process และเขียนไฟล์แบบ atomic ต้องใช้ SDK/Game server build ใหม่ทั้งคู่
|
||||||
|
|
||||||
|
สถานะรอบ progression: build ทั้งสองตัวผ่าน ผู้ใช้ยืนยันว่าก้อนขึ้น ปลด E1/EX ได้ และยังเข้า Calyx ได้ ตรวจไฟล์ระหว่างลองพบ Dan Heng IL rank=1 และ EX=true ต่อมาผู้ใช้เปิด EX ทั้งสามคนแล้ว จึงเก็บค่า true ตามการกดปลดจริง ผลนี้ไม่ได้รับรองว่าเว็บ GaC srtool ที่แก้แยกอยู่ได้ทดสอบ integration แล้ว
|
||||||
|
|
||||||
|
รอบโหลดกระเป๋า 12:18 ส่ง GetBagScRsp ขนาด 56663 ไบต์แล้ว ไม่พบ TryCreateItemData/Inventory error ใน log ณ เวลาตรวจ และอ่าน API status แล้ว hash ของ persistent ไม่เปลี่ยน EX ยัง false ทั้งสามคน หลังจากนั้นรับ RankUpAvatar เวลา 12:18:23 และคำขอ EX เวลา 12:18:28 สำเร็จ พร้อม PlayerSyncScNotify ขนาด 234/237 ไบต์ แสดงว่าใช้การอัปเดตเฉพาะส่วนและไม่ได้ล้าง inventory ทั้งชุด
|
||||||
|
|
||||||
## โครงสร้างซอร์ส
|
## โครงสร้างซอร์ส
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+33470
-11167
File diff suppressed because it is too large.
Load diff
+28
-29
@@ -8,7 +8,6 @@ package common
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,6 +148,23 @@ type SubAffix struct {
|
|||||||
Step uint32 `json:"step"`
|
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 {
|
type Relic struct {
|
||||||
Level uint32 `json:"level"`
|
Level uint32 `json:"level"`
|
||||||
RelicID uint32 `json:"relic_id"`
|
RelicID uint32 `json:"relic_id"`
|
||||||
@@ -352,6 +368,8 @@ func DefaultPersistent() Persistent {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type FreesrData struct {
|
type FreesrData struct {
|
||||||
|
persistentBaseline map[string]any
|
||||||
|
|
||||||
Lightcones []Lightcone `json:"lightcones"`
|
Lightcones []Lightcone `json:"lightcones"`
|
||||||
Relics []Relic `json:"relics"`
|
Relics []Relic `json:"relics"`
|
||||||
Avatars map[uint32]*AvatarJson `json:"avatars"`
|
Avatars map[uint32]*AvatarJson `json:"avatars"`
|
||||||
@@ -372,7 +390,7 @@ type FreesrData struct {
|
|||||||
// LoadFreesrData reads freesr-data.json + persistent and merges them,
|
// LoadFreesrData reads freesr-data.json + persistent and merges them,
|
||||||
// mirroring FreesrData::load() from the Rust crate.
|
// mirroring FreesrData::load() from the Rust crate.
|
||||||
func LoadFreesrData() (*FreesrData, error) {
|
func LoadFreesrData() (*FreesrData, error) {
|
||||||
raw, err := os.ReadFile("freesr-data.json")
|
raw, err := readResourceFile("freesr-data.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read freesr-data.json: %w", err)
|
return nil, fmt.Errorf("failed to read freesr-data.json: %w", err)
|
||||||
}
|
}
|
||||||
@@ -390,7 +408,7 @@ func LoadFreesrData() (*FreesrData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// persistent is optional
|
// persistent is optional
|
||||||
pers, err := os.ReadFile("persistent")
|
pers, err := readResourceFile("persistent")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
var p Persistent
|
var p Persistent
|
||||||
if err := json.Unmarshal(pers, &p); err == nil {
|
if err := json.Unmarshal(pers, &p); err == nil {
|
||||||
@@ -440,6 +458,10 @@ func LoadFreesrData() (*FreesrData, error) {
|
|||||||
data.MarchType = MultiPathMarchHunt
|
data.MarchType = MultiPathMarchHunt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.persistentBaseline, err = jsonObject(data.persistentSnapshot())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &data, nil
|
return &data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +475,7 @@ func (f *FreesrData) SortedLineupKeys() []uint32 {
|
|||||||
return keys
|
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() {
|
func (f *FreesrData) VerifyLineup() {
|
||||||
if len(f.Lineups) == 0 {
|
if len(f.Lineups) == 0 {
|
||||||
f.Lineups = map[uint32]uint32{0: 8001, 1: 0, 2: 0, 3: 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.
|
// SavePersistent writes the persistent subset back to the "persistent" file.
|
||||||
func (f *FreesrData) SavePersistent() {
|
func (f *FreesrData) SavePersistent() {
|
||||||
exUnlocked := make(map[uint32]bool)
|
if err := f.SavePersistentChecked(); err != nil {
|
||||||
for aid, av := range f.Avatars {
|
LogInfo("[SAVE ERROR] %v", err)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -162,12 +162,37 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
|
|||||||
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
|
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
|
||||||
}
|
}
|
||||||
|
|
||||||
for anchorType, level := range a.Data.SkillsByAnchorType {
|
if len(a.Data.Skills) > 0 {
|
||||||
|
for pointID, level := range a.Data.Skills {
|
||||||
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
|
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
|
||||||
PointId: anchorType,
|
PointId: pointID,
|
||||||
Level: level,
|
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
|
// deterministic order (Rust iterated the HashMap; order is irrelevant to the
|
||||||
// client, but stable output keeps packets reproducible)
|
// client, but stable output keeps packets reproducible)
|
||||||
sortSkillTree(out.AvatarPathSkillTree)
|
sortSkillTree(out.AvatarPathSkillTree)
|
||||||
@@ -211,9 +236,28 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(a.Data.Skills) > 0 {
|
||||||
for pointID, level := range a.Data.Skills {
|
for pointID, level := range a.Data.Skills {
|
||||||
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
|
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)
|
sortAvatarSkillTree(ba.SkilltreeList)
|
||||||
|
|
||||||
if lightcone != nil {
|
if lightcone != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
goproto "gac-hkrpg-go/protocol/proto"
|
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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -76,6 +77,7 @@ type LevelOutputConfig struct {
|
|||||||
type AvatarConfig struct {
|
type AvatarConfig struct {
|
||||||
WeaknessBuffID uint32 `json:"weaknessBuffId"`
|
WeaknessBuffID uint32 `json:"weaknessBuffId"`
|
||||||
DamageType string `json:"damageType"`
|
DamageType string `json:"damageType"`
|
||||||
|
MaxRank uint32 `json:"maxRank"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonConfig struct {
|
type JsonConfig struct {
|
||||||
@@ -85,6 +87,7 @@ type JsonConfig struct {
|
|||||||
MapDefaultEntranceMap map[uint32]uint32 `json:"mapDefaultEntranceMap"`
|
MapDefaultEntranceMap map[uint32]uint32 `json:"mapDefaultEntranceMap"`
|
||||||
RelicAvatarRecommend map[uint32][]uint32 `json:"relicAvatarRecommend"`
|
RelicAvatarRecommend map[uint32][]uint32 `json:"relicAvatarRecommend"`
|
||||||
SkinIDs []uint32 `json:"skinIds"`
|
SkinIDs []uint32 `json:"skinIds"`
|
||||||
|
AvatarSkillTreePoints map[uint32]map[uint32]uint32 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -146,15 +149,23 @@ type AnchorConfigFile struct {
|
|||||||
AnchorConfig []AnchorConfigEntry `json:"anchor_config"`
|
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 {
|
for _, p := range paths {
|
||||||
if raw, err := os.ReadFile(p); err == nil {
|
for _, prefix := range []string{"", "..", "../..", "../../.."} {
|
||||||
|
target := filepath.Join(prefix, p)
|
||||||
|
if raw, err := os.ReadFile(target); err == nil {
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil, os.ErrNotExist
|
return nil, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readResourceFile(paths ...string) ([]byte, error) {
|
||||||
|
return ReadResourceFile(paths...)
|
||||||
|
}
|
||||||
|
|
||||||
// GameRes lazily parses res.json and merges supplementary configs from resources/
|
// GameRes lazily parses res.json and merges supplementary configs from resources/
|
||||||
func GameRes() (*JsonConfig, error) {
|
func GameRes() (*JsonConfig, error) {
|
||||||
gameResOnce.Do(func() {
|
gameResOnce.Do(func() {
|
||||||
@@ -186,6 +197,34 @@ func GameRes() (*JsonConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSupplementaryResources(res *JsonConfig) {
|
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;
|
// PearlSR's resource export keeps avatar data in AvatarConfig.json;
|
||||||
// res.json may legitimately contain an empty avatarConfigs object.
|
// res.json may legitimately contain an empty avatarConfigs object.
|
||||||
if raw, err := readResourceFile("resources/AvatarConfig.json"); err == nil {
|
if raw, err := readResourceFile("resources/AvatarConfig.json"); err == nil {
|
||||||
@@ -193,12 +232,14 @@ func loadSupplementaryResources(res *JsonConfig) {
|
|||||||
Entries []struct {
|
Entries []struct {
|
||||||
ID uint32 `json:"AvatarID"`
|
ID uint32 `json:"AvatarID"`
|
||||||
DamageType string `json:"DamageType"`
|
DamageType string `json:"DamageType"`
|
||||||
|
MaxRank uint32 `json:"MaxRank"`
|
||||||
} `json:"avatar_config"`
|
} `json:"avatar_config"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &avatars); err == nil {
|
if err := json.Unmarshal(raw, &avatars); err == nil {
|
||||||
for _, avatar := range avatars.Entries {
|
for _, avatar := range avatars.Entries {
|
||||||
cfg := res.AvatarConfigs[avatar.ID]
|
cfg := res.AvatarConfigs[avatar.ID]
|
||||||
cfg.DamageType = avatar.DamageType
|
cfg.DamageType = avatar.DamageType
|
||||||
|
cfg.MaxRank = avatar.MaxRank
|
||||||
res.AvatarConfigs[avatar.ID] = cfg
|
res.AvatarConfigs[avatar.ID] = cfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package gameserver
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ var battleRulesErr error
|
|||||||
|
|
||||||
func loadBattleRules() (*battleRules, error) {
|
func loadBattleRules() (*battleRules, error) {
|
||||||
battleRulesOnce.Do(func() {
|
battleRulesOnce.Do(func() {
|
||||||
raw, err := os.ReadFile("resources/BattleRules.json")
|
raw, err := common.ReadResourceFile("resources/BattleRules.json", "BattleRules.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
battleRulesErr = err
|
battleRulesErr = err
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ func hexDump(b []byte, max int) string {
|
|||||||
|
|
||||||
// DispatchMessage is the Go port of CommandHandler::on_message.
|
// DispatchMessage is the Go port of CommandHandler::on_message.
|
||||||
func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
|
||||||
|
s.stateMu.Lock()
|
||||||
|
defer s.stateMu.Unlock()
|
||||||
PacketLog(fmt.Sprintf("[PACKET RECV] cmd_id: %d %s (len: %d)", cmdID, goproto.NameByCmdID[cmdID], len(payload)))
|
PacketLog(fmt.Sprintf("[PACKET RECV] cmd_id: %d %s (len: %d)", cmdID, goproto.NameByCmdID[cmdID], len(payload)))
|
||||||
Log(fmt.Sprintf("[PACKET] Received cmd_id: %d (%s)", cmdID, goproto.NameByCmdID[cmdID]))
|
Log(fmt.Sprintf("[PACKET] Received cmd_id: %d (%s)", cmdID, goproto.NameByCmdID[cmdID]))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package gameserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gac-hkrpg-go/internal/common"
|
||||||
|
goproto "gac-hkrpg-go/protocol/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type eidolonResources struct {
|
||||||
|
Avatars map[uint32]struct {
|
||||||
|
MaxRank uint32 `json:"max_rank"`
|
||||||
|
Ranks []struct {
|
||||||
|
Rank uint32 `json:"rank"`
|
||||||
|
Cost []struct {
|
||||||
|
ItemID uint32
|
||||||
|
ItemNum uint32
|
||||||
|
} `json:"cost"`
|
||||||
|
} `json:"ranks"`
|
||||||
|
} `json:"avatars"`
|
||||||
|
ExMaterials map[uint32]struct {
|
||||||
|
ItemID uint32 `json:"item_id"`
|
||||||
|
Count uint32 `json:"count"`
|
||||||
|
} `json:"ex_materials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var eidolonOnce sync.Once
|
||||||
|
var eidolonData eidolonResources
|
||||||
|
var eidolonError error
|
||||||
|
|
||||||
|
func loadEidolonResources() (*eidolonResources, error) {
|
||||||
|
eidolonOnce.Do(func() {
|
||||||
|
raw, err := common.ReadResourceFile("resources/EidolonConfig.json", "EidolonConfig.json")
|
||||||
|
if err != nil {
|
||||||
|
eidolonError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &eidolonData); err != nil {
|
||||||
|
eidolonError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for id, avatar := range eidolonData.Avatars {
|
||||||
|
if id == 0 || avatar.MaxRank == 0 || len(avatar.Ranks) != int(avatar.MaxRank) {
|
||||||
|
eidolonError = fmt.Errorf("invalid Eidolon ranks for avatar %d", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[uint32]bool{}
|
||||||
|
for _, rank := range avatar.Ranks {
|
||||||
|
if rank.Rank == 0 || rank.Rank > avatar.MaxRank || seen[rank.Rank] || len(rank.Cost) == 0 {
|
||||||
|
eidolonError = fmt.Errorf("invalid rank costs for avatar %d", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[rank.Rank] = true
|
||||||
|
for _, cost := range rank.Cost {
|
||||||
|
if cost.ItemID == 0 || cost.ItemNum == 0 {
|
||||||
|
eidolonError = fmt.Errorf("empty rank cost for avatar %d", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, material := range eidolonData.ExMaterials {
|
||||||
|
if common.AvatarExRankMap[id] == 0 || material.ItemID == 0 || material.Count == 0 {
|
||||||
|
eidolonError = fmt.Errorf("invalid EX material for avatar %d", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if eidolonError != nil {
|
||||||
|
return nil, fmt.Errorf("load EidolonConfig.json: %w", eidolonError)
|
||||||
|
}
|
||||||
|
return &eidolonData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured sandbox grant covers the remaining ranks. Its remaining
|
||||||
|
// quantity follows saved progression, so reconnecting does not refill used ranks.
|
||||||
|
func eidolonMaterials(player *common.FreesrData) ([]*goproto.Material, error) {
|
||||||
|
resources, err := loadEidolonResources()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts := map[uint32]uint32{}
|
||||||
|
for id, rules := range resources.Avatars {
|
||||||
|
avatar := player.Avatars[id]
|
||||||
|
if avatar == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, rank := range rules.Ranks {
|
||||||
|
for _, cost := range rank.Cost {
|
||||||
|
if _, present := counts[cost.ItemID]; !present {
|
||||||
|
counts[cost.ItemID] = 0
|
||||||
|
}
|
||||||
|
if rank.Rank > avatar.Data.Rank {
|
||||||
|
counts[cost.ItemID] += cost.ItemNum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, grant := range resources.ExMaterials {
|
||||||
|
avatar := player.Avatars[id]
|
||||||
|
if avatar == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[grant.ItemID] = 0
|
||||||
|
if !avatar.ExRankUnlocked {
|
||||||
|
counts[grant.ItemID] = grant.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids := make([]uint32, 0, len(counts))
|
||||||
|
for id := range counts {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
|
var materials []*goproto.Material
|
||||||
|
for _, id := range ids {
|
||||||
|
materials = append(materials, &goproto.Material{Tid: id, Num: counts[id]})
|
||||||
|
}
|
||||||
|
return materials, nil
|
||||||
|
}
|
||||||
@@ -197,19 +197,27 @@ func watchFreesrData(session *Session) {
|
|||||||
case <-session.Done():
|
case <-session.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
session.stateMu.Lock()
|
||||||
|
if err := syncPersistedExRanks(session); err != nil {
|
||||||
|
Log("[EX-RANK SYNC] " + err.Error())
|
||||||
|
}
|
||||||
|
session.stateMu.Unlock()
|
||||||
stFreesr, _ := statFile("freesr-data.json")
|
stFreesr, _ := statFile("freesr-data.json")
|
||||||
if stFreesr.IsZero() || !stFreesr.After(lastModFreesr) {
|
if stFreesr.IsZero() || !stFreesr.After(lastModFreesr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lastModFreesr = stFreesr
|
session.stateMu.Lock()
|
||||||
|
if _, active := CURRENT_BATTLE.Load(session.Token); active {
|
||||||
|
session.stateMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
if data, err := common.LoadFreesrData(); err == nil {
|
if data, err := common.LoadFreesrData(); err == nil {
|
||||||
session.SetJSON(data)
|
if err := reloadPlayerData(session, data); err == nil {
|
||||||
if err := session.SyncPlayer(); err == nil {
|
lastModFreesr = stFreesr
|
||||||
Log("player data updated & live synced to client")
|
Log("player data updated & live synced to client")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
session.stateMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,20 @@ func init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
handle("GetLineupAvatarData", func(s *Session, req *goproto.GetLineupAvatarDataCsReq, rsp *goproto.GetLineupAvatarDataScRsp) error {
|
handle("GetLineupAvatarData", func(s *Session, req *goproto.GetLineupAvatarDataCsReq, rsp *goproto.GetLineupAvatarDataScRsp) error {
|
||||||
|
player := s.JSON()
|
||||||
|
if player != nil {
|
||||||
|
for _, slot := range player.SortedLineupKeys() {
|
||||||
|
avatarID := player.Lineups[slot]
|
||||||
|
if avatarID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rsp.AvatarDataList = append(rsp.AvatarDataList, &goproto.LineupAvatarData{
|
||||||
|
AvatarType: goproto.AvatarType_AVATAR_FORMAL_TYPE,
|
||||||
|
Hp: 10000,
|
||||||
|
Id: avatarID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
rsp.Retcode = 0
|
rsp.Retcode = 0
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ func init() {
|
|||||||
{Tid: 101, Num: 999999}, // Normal Pass
|
{Tid: 101, Num: 999999}, // Normal Pass
|
||||||
{Tid: 102, Num: 999999}, // Special Pass
|
{Tid: 102, Num: 999999}, // Special Pass
|
||||||
}
|
}
|
||||||
|
materials, err := eidolonMaterials(player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, material := range materials {
|
||||||
|
if material.Num > 0 {
|
||||||
|
rsp.MaterialList = append(rsp.MaterialList, material)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Avatar IDs do not imply item IDs. The old 130000+avatar formula
|
// Avatar IDs do not imply item IDs. The old 130000+avatar formula
|
||||||
// produced nonexistent materials and broke the client's inventory init.
|
// produced nonexistent materials and broke the client's inventory init.
|
||||||
return nil
|
return nil
|
||||||
@@ -128,32 +137,40 @@ func init() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
isExRank := false
|
res, err := common.GameRes()
|
||||||
if req.CostData != nil {
|
if err != nil {
|
||||||
for _, item := range req.CostData.ItemList {
|
return err
|
||||||
if pile := item.GetPileItem(); pile != nil {
|
|
||||||
if (pile.ItemId >= 130000 && pile.ItemId < 140000) || pile.ItemId == 131204 || pile.ItemId == 131213 || pile.ItemId == 131308 {
|
|
||||||
isExRank = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
maximum := res.AvatarConfigs[req.AvatarId].MaxRank
|
||||||
|
if maximum == 0 || req.Rank > maximum {
|
||||||
|
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_REACH_MAX)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if req.Rank != avatar.Data.Rank+1 {
|
||||||
|
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if _, active := CURRENT_BATTLE.Load(s.Token); active {
|
||||||
|
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if req.Rank > 6 {
|
// Ordinary Eidolons do not implicitly unlock EX, even at the final rank.
|
||||||
isExRank = true
|
materials, err := eidolonMaterials(player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := common.SaveAvatarRank(req.AvatarId, req.Rank); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if isExRank || (avatar.Data.Rank >= 6 && common.AvatarExRankMap[req.AvatarId] != 0) {
|
|
||||||
avatar.ExRankUnlocked = true
|
|
||||||
if req.Rank > 0 && req.Rank <= 6 {
|
|
||||||
avatar.Data.Rank = req.Rank
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
avatar.Data.Rank = req.Rank
|
avatar.Data.Rank = req.Rank
|
||||||
|
materials, err = eidolonMaterials(player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := &goproto.PlayerSyncScNotify{}
|
ret := &goproto.PlayerSyncScNotify{}
|
||||||
buildSync(player, ret, []uint32{avatar.AvatarID}, nil, nil)
|
buildSync(player, ret, []uint32{avatar.AvatarID}, nil, nil)
|
||||||
|
ret.MaterialList = materials
|
||||||
return s.Send(ret)
|
return s.Send(ret)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,52 +67,55 @@ func init() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleExRankUnlock handles CmdID 400 (Affinitive Eidolon / Ex-Rank unlock request from client).
|
// The EX request remains obfuscated in PearlSR 005d2c1: field 5 is
|
||||||
|
// interpreted as an avatar, EX-rank or material ID only by an explicit mapping.
|
||||||
|
// Never unlock every avatar when an unknown value is received.
|
||||||
func handleExRankUnlock(s *Session, payload []byte) error {
|
func handleExRankUnlock(s *Session, payload []byte) error {
|
||||||
Log(fmt.Sprintf("[EX-RANK] Received cmd 400 (len: %d) payload: %x", len(payload), payload))
|
|
||||||
|
|
||||||
var req goproto.HIGLJHLNODG
|
var req goproto.HIGLJHLNODG
|
||||||
if err := proto.Unmarshal(payload, &req); err != nil {
|
if err := proto.Unmarshal(payload, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
parsedVal := req.OJFIGABCKLG
|
value := req.OJFIGABCKLG
|
||||||
|
resources, err := loadEidolonResources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var avatarID uint32
|
var avatarID uint32
|
||||||
if parsedVal >= 100000 {
|
for id, exID := range common.AvatarExRankMap {
|
||||||
avatarID = parsedVal / 100 // e.g. 120401 -> 1204, 121301 -> 1213, 130801 -> 1308
|
if value == id || value == exID || value == resources.ExMaterials[id].ItemID {
|
||||||
} else if parsedVal >= 1000 && parsedVal < 10000 {
|
avatarID = id
|
||||||
avatarID = parsedVal
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Log(fmt.Sprintf("[EX-RANK] Target avatarID: %d (from val: %d)", avatarID, parsedVal))
|
rsp := &goproto.LPOLGDLAADM{}
|
||||||
|
|
||||||
player := s.JSON()
|
player := s.JSON()
|
||||||
if player != nil {
|
if player == nil || avatarID == 0 || player.Avatars[avatarID] == nil {
|
||||||
if avatarID == 0 {
|
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_NOT_EXIST)
|
||||||
// Fallback: unlock for all avatars that have ExRank defined (1204, 1213, 1308)
|
Log(fmt.Sprintf("[EX-RANK] rejected unmapped value=%d", value))
|
||||||
for aid := range common.AvatarExRankMap {
|
return s.Send(rsp)
|
||||||
if av, ok := player.Avatars[aid]; ok {
|
|
||||||
av.ExRankUnlocked = true
|
|
||||||
Log(fmt.Sprintf("[EX-RANK] Fallback set ExRankUnlocked=true for avatar %d", aid))
|
|
||||||
syncNotify := &goproto.PlayerSyncScNotify{}
|
|
||||||
buildSync(player, syncNotify, []uint32{aid}, nil, nil)
|
|
||||||
_ = s.Send(syncNotify)
|
|
||||||
}
|
}
|
||||||
|
if _, active := CURRENT_BATTLE.Load(s.Token); active {
|
||||||
|
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH)
|
||||||
|
return s.Send(rsp)
|
||||||
}
|
}
|
||||||
} else {
|
avatar := player.Avatars[avatarID]
|
||||||
if av, ok := player.Avatars[avatarID]; ok {
|
previous := avatar.ExRankUnlocked
|
||||||
av.ExRankUnlocked = true
|
avatar.ExRankUnlocked = true
|
||||||
Log(fmt.Sprintf("[EX-RANK] Set ExRankUnlocked=true for avatar %d", avatarID))
|
if err := player.SavePersistentChecked(); err != nil {
|
||||||
|
avatar.ExRankUnlocked = previous
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
syncNotify := &goproto.PlayerSyncScNotify{}
|
notify := &goproto.PlayerSyncScNotify{}
|
||||||
buildSync(player, syncNotify, []uint32{avatarID}, nil, nil)
|
buildSync(player, notify, []uint32{avatarID}, nil, nil)
|
||||||
_ = s.Send(syncNotify)
|
notify.MaterialList, err = eidolonMaterials(player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
player.SavePersistent()
|
if err := s.Send(notify); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
Log(fmt.Sprintf("[EX-RANK] unlocked avatar=%d value=%d", avatarID, value))
|
||||||
// The matching response is declared by the current upstream schema.
|
return s.Send(rsp)
|
||||||
return s.Send(&goproto.LPOLGDLAADM{})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// challengeGroupStatsReply uses the current typed statistics response.
|
// challengeGroupStatsReply uses the current typed statistics response.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package gameserver
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"gac-hkrpg-go/internal/common"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
goproto "gac-hkrpg-go/protocol/proto"
|
goproto "gac-hkrpg-go/protocol/proto"
|
||||||
@@ -31,7 +31,7 @@ var profileError error
|
|||||||
|
|
||||||
func loadProfileResources() (*profileResources, error) {
|
func loadProfileResources() (*profileResources, error) {
|
||||||
profileOnce.Do(func() {
|
profileOnce.Do(func() {
|
||||||
raw, err := os.ReadFile("resources/ProfileConfig.json")
|
raw, err := common.ReadResourceFile("resources/ProfileConfig.json", "ProfileConfig.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
profileError = err
|
profileError = err
|
||||||
return
|
return
|
||||||
@@ -48,7 +48,7 @@ func loadProfileResources() (*profileResources, error) {
|
|||||||
profileError = fmt.Errorf("selected phone case is not owned")
|
profileError = fmt.Errorf("selected phone case is not owned")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw, err = os.ReadFile("resources/AvatarPlayerIcon.json")
|
raw, err = common.ReadResourceFile("resources/AvatarPlayerIcon.json", "AvatarPlayerIcon.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
profileError = err
|
profileError = err
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package gameserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gac-hkrpg-go/internal/common"
|
||||||
|
goproto "gac-hkrpg-go/protocol/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func syncPersistedExRanks(s *Session) error {
|
||||||
|
// Publish changes after battle settlement; do not replace actor progression
|
||||||
|
// while the client's combat scene is using its captured BattleInfo.
|
||||||
|
if _, active := CURRENT_BATTLE.Load(s.Token); active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
player := s.JSON()
|
||||||
|
if player == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, err := common.ReadPersistent()
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := player.RefreshExRanks(p)
|
||||||
|
if s.pendingExRankSync == nil {
|
||||||
|
s.pendingExRankSync = make(map[uint32]bool)
|
||||||
|
}
|
||||||
|
for _, id := range changed {
|
||||||
|
s.pendingExRankSync[id] = true
|
||||||
|
}
|
||||||
|
if len(s.pendingExRankSync) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
changed = changed[:0]
|
||||||
|
for id := range s.pendingExRankSync {
|
||||||
|
changed = append(changed, id)
|
||||||
|
}
|
||||||
|
sortU32Slice(changed)
|
||||||
|
notify := &goproto.PlayerSyncScNotify{}
|
||||||
|
buildSync(player, notify, changed, nil, nil)
|
||||||
|
notify.MaterialList, err = eidolonMaterials(player)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Send(notify); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Log(fmt.Sprintf("[EX-RANK SYNC] updated avatars=%v", changed))
|
||||||
|
s.pendingExRankSync = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rank-only edit does not require deleting/recreating inventory. Preserve the
|
||||||
|
// existing full-import path only when equipment, relics or avatar ownership change.
|
||||||
|
func reloadPlayerData(s *Session, next *common.FreesrData) error {
|
||||||
|
previous := s.JSON()
|
||||||
|
full := previous == nil
|
||||||
|
if !full {
|
||||||
|
full = !reflect.DeepEqual(previous.Lightcones, next.Lightcones) || !reflect.DeepEqual(previous.Relics, next.Relics) || len(previous.Avatars) != len(next.Avatars)
|
||||||
|
}
|
||||||
|
var changed []uint32
|
||||||
|
if !full {
|
||||||
|
for id, avatar := range next.Avatars {
|
||||||
|
old, exists := previous.Avatars[id]
|
||||||
|
if !exists {
|
||||||
|
full = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(old, avatar) {
|
||||||
|
changed = append(changed, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.SetJSON(next)
|
||||||
|
if full {
|
||||||
|
return s.SyncPlayer()
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sortU32Slice(changed)
|
||||||
|
notify := &goproto.PlayerSyncScNotify{}
|
||||||
|
buildSync(next, notify, changed, nil, nil)
|
||||||
|
var err error
|
||||||
|
notify.MaterialList, err = eidolonMaterials(next)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Send(notify)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ type Session struct {
|
|||||||
Addr string
|
Addr string
|
||||||
|
|
||||||
jsonMu sync.RWMutex
|
jsonMu sync.RWMutex
|
||||||
|
stateMu sync.Mutex // serializes packet handlers and hot reload mutations
|
||||||
json *common.FreesrData
|
json *common.FreesrData
|
||||||
|
|
||||||
kcpMu sync.Mutex
|
kcpMu sync.Mutex
|
||||||
@@ -46,6 +47,7 @@ type Session struct {
|
|||||||
MonsterEntities map[uint32]uint32
|
MonsterEntities map[uint32]uint32
|
||||||
// Set by the challenge scene loader, cleared when loading an overworld scene.
|
// Set by the challenge scene loader, cleared when loading an overworld scene.
|
||||||
ChallengeStageID uint32
|
ChallengeStageID uint32
|
||||||
|
pendingExRankSync map[uint32]bool
|
||||||
|
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
|
|||||||
+22
-22
@@ -1,30 +1,30 @@
|
|||||||
{
|
{
|
||||||
"lineups": {
|
|
||||||
"0": 1511,
|
|
||||||
"1": 1506,
|
|
||||||
"2": 1502,
|
|
||||||
"3": 1503
|
|
||||||
},
|
|
||||||
"position": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"z": 0,
|
|
||||||
"rot_y": 155566
|
|
||||||
},
|
|
||||||
"scene": {
|
|
||||||
"plane_id": 20001,
|
|
||||||
"floor_id": 20001001,
|
|
||||||
"entry_id": 2000101
|
|
||||||
},
|
|
||||||
"main_character": "FemaleHarmony",
|
|
||||||
"march_type": "MarchHunt",
|
|
||||||
"enable_sw_global": true,
|
|
||||||
"enable_castorice_global": true,
|
|
||||||
"enable_aha_global": true,
|
|
||||||
"dressed_skins": {},
|
"dressed_skins": {},
|
||||||
|
"enable_aha_global": true,
|
||||||
|
"enable_castorice_global": true,
|
||||||
|
"enable_sw_global": true,
|
||||||
"ex_ranks_unlocked": {
|
"ex_ranks_unlocked": {
|
||||||
"1204": true,
|
"1204": true,
|
||||||
"1213": true,
|
"1213": true,
|
||||||
"1308": true
|
"1308": true
|
||||||
|
},
|
||||||
|
"lineups": {
|
||||||
|
"0": 1213,
|
||||||
|
"1": 1306,
|
||||||
|
"2": 1403,
|
||||||
|
"3": 1409
|
||||||
|
},
|
||||||
|
"main_character": "FemaleHarmony",
|
||||||
|
"march_type": "MarchHunt",
|
||||||
|
"position": {
|
||||||
|
"rot_y": 167180,
|
||||||
|
"x": 72091,
|
||||||
|
"y": 7000,
|
||||||
|
"z": 110301
|
||||||
|
},
|
||||||
|
"scene": {
|
||||||
|
"entry_id": 2050101,
|
||||||
|
"floor_id": 20501001,
|
||||||
|
"plane_id": 20501
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"repository": "https://gitlab.com/Dimbreath/turnbasedgamedata",
|
||||||
|
"revision": "312b4597691e0a29cf9828a15b592757dba86949",
|
||||||
|
"files": [
|
||||||
|
"ExcelOutput/AvatarRankConfig.json",
|
||||||
|
"ExcelOutput/ItemConfigAvatarRank.json"
|
||||||
|
],
|
||||||
|
"note": "Normal E1-E6 costs for the three requested characters, joined against local AvatarConfig. EX materials are not inferred from these IDs."
|
||||||
|
},
|
||||||
|
"avatars": {
|
||||||
|
"1204": {
|
||||||
|
"max_rank": 6,
|
||||||
|
"ranks": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 3,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 4,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 5,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 6,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11204,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"1213": {
|
||||||
|
"max_rank": 6,
|
||||||
|
"ranks": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 3,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 4,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 5,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 6,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11213,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"1308": {
|
||||||
|
"max_rank": 6,
|
||||||
|
"ranks": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 2,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 3,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 4,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 5,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rank": 6,
|
||||||
|
"cost": [
|
||||||
|
{
|
||||||
|
"ItemID": 11308,
|
||||||
|
"ItemNum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ex_material_source": {
|
||||||
|
"provided_by": "user",
|
||||||
|
"date": "2026-10-17",
|
||||||
|
"note": "IDs explicitly supplied by user. Client GetBag accepted without item/inventory errors, and user confirmed E1/EX unlock and Calyx entry on 2026-10-17."
|
||||||
|
},
|
||||||
|
"ex_materials": {
|
||||||
|
"1204": {
|
||||||
|
"item_id": 131204,
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"1213": {
|
||||||
|
"item_id": 131213,
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
"1308": {
|
||||||
|
"item_id": 131308,
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user