chore: checkpoint stable battle and eidolon progression
This commit is contained in:
1 parent
365e66ffe0
commit
a681affdb3
23 files changed
+34511
-11310
No files matched your search
@@ -10,3 +10,7 @@ battle_last.bin
|
||||
battle_last_response.bin
|
||||
battle_last_capture.json
|
||||
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 ต้องบันทึกข้อสันนิษฐานและข้อจำกัดไว้
|
||||
- อย่าเปลี่ยนค่า protocol เช่น ClientDownloadData Lua format ตามเลข patch ของ Client โดยไม่มีต้นฉบับรองรับ และอย่าเปลี่ยนแค่ข้อความเวอร์ชันเพื่ออ้างว่าอัปเดต handler แล้ว
|
||||
- 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/avatars` | อ่านหรืออัปเดตข้อมูลตัวละคร |
|
||||
| GET, POST | `/api/persistent` | อ่านหรืออัปเดตข้อมูล persistent |
|
||||
| GET, POST | `/api/battle_report` | อ่านสถิติดาเมจ/การต่อสู้ล่าสุด หรือบันทึกรายงานการต่อสู้ |
|
||||
| POST | `/api/battle_report/reset` | ล้างสถิติรายงานการต่อสู้ |
|
||||
| POST | `/api/exrank/toggle` | สลับการปลดล็อก EX rank |
|
||||
| POST | `/api/avatar/rank` | เปลี่ยน rank ตัวละคร |
|
||||
| POST | `/api/avatar/max_all` | ปรับข้อมูลตัวละครตามการทำงานของ handler |
|
||||
| POST | `/api/teleport` | อัปเดตข้อมูลจุดหมาย teleport |
|
||||
| POST | `/api/command` | ส่งคำสั่ง GM ไปยังเซิร์ฟเวอร์ |
|
||||
| GET | `/api/data-in-game` | อ่านข้อมูลเกมที่แคชไว้ในเซิร์ฟเวอร์ |
|
||||
|
||||
รูปแบบ 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 ผู้ใช้ยืนยันหน้าตัวละครหายค้างหลังเปิดเกมใหม่
|
||||
|
||||
## 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
|
||||
|
||||
+33481
-11178
File diff suppressed because it is too large.
Load diff
+28
-29
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package gameserver
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -51,7 +50,7 @@ var battleRulesErr error
|
||||
|
||||
func loadBattleRules() (*battleRules, error) {
|
||||
battleRulesOnce.Do(func() {
|
||||
raw, err := os.ReadFile("resources/BattleRules.json")
|
||||
raw, err := common.ReadResourceFile("resources/BattleRules.json", "BattleRules.json")
|
||||
if err != nil {
|
||||
battleRulesErr = err
|
||||
return
|
||||
|
||||
@@ -154,6 +154,8 @@ func hexDump(b []byte, max int) string {
|
||||
|
||||
// DispatchMessage is the Go port of CommandHandler::on_message.
|
||||
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)))
|
||||
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():
|
||||
return
|
||||
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")
|
||||
if stFreesr.IsZero() || !stFreesr.After(lastModFreesr) {
|
||||
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 {
|
||||
session.SetJSON(data)
|
||||
if err := session.SyncPlayer(); err == nil {
|
||||
if err := reloadPlayerData(session, data); err == nil {
|
||||
lastModFreesr = stFreesr
|
||||
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 {
|
||||
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
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -74,6 +74,15 @@ func init() {
|
||||
{Tid: 101, Num: 999999}, // Normal 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
|
||||
// produced nonexistent materials and broke the client's inventory init.
|
||||
return nil
|
||||
@@ -128,32 +137,40 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
|
||||
isExRank := false
|
||||
if req.CostData != nil {
|
||||
for _, item := range req.CostData.ItemList {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := common.GameRes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Rank > 6 {
|
||||
isExRank = true
|
||||
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 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
|
||||
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
|
||||
}
|
||||
// Ordinary Eidolons do not implicitly unlock EX, even at the final rank.
|
||||
materials, err := eidolonMaterials(player)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.SaveAvatarRank(req.AvatarId, req.Rank); err != nil {
|
||||
return err
|
||||
}
|
||||
avatar.Data.Rank = req.Rank
|
||||
materials, err = eidolonMaterials(player)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret := &goproto.PlayerSyncScNotify{}
|
||||
buildSync(player, ret, []uint32{avatar.AvatarID}, nil, nil)
|
||||
ret.MaterialList = materials
|
||||
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 {
|
||||
Log(fmt.Sprintf("[EX-RANK] Received cmd 400 (len: %d) payload: %x", len(payload), payload))
|
||||
|
||||
var req goproto.HIGLJHLNODG
|
||||
if err := proto.Unmarshal(payload, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
parsedVal := req.OJFIGABCKLG
|
||||
|
||||
value := req.OJFIGABCKLG
|
||||
resources, err := loadEidolonResources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var avatarID uint32
|
||||
if parsedVal >= 100000 {
|
||||
avatarID = parsedVal / 100 // e.g. 120401 -> 1204, 121301 -> 1213, 130801 -> 1308
|
||||
} else if parsedVal >= 1000 && parsedVal < 10000 {
|
||||
avatarID = parsedVal
|
||||
}
|
||||
|
||||
Log(fmt.Sprintf("[EX-RANK] Target avatarID: %d (from val: %d)", avatarID, parsedVal))
|
||||
|
||||
player := s.JSON()
|
||||
if player != nil {
|
||||
if avatarID == 0 {
|
||||
// Fallback: unlock for all avatars that have ExRank defined (1204, 1213, 1308)
|
||||
for aid := range common.AvatarExRankMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if av, ok := player.Avatars[avatarID]; ok {
|
||||
av.ExRankUnlocked = true
|
||||
Log(fmt.Sprintf("[EX-RANK] Set ExRankUnlocked=true for avatar %d", avatarID))
|
||||
}
|
||||
syncNotify := &goproto.PlayerSyncScNotify{}
|
||||
buildSync(player, syncNotify, []uint32{avatarID}, nil, nil)
|
||||
_ = s.Send(syncNotify)
|
||||
for id, exID := range common.AvatarExRankMap {
|
||||
if value == id || value == exID || value == resources.ExMaterials[id].ItemID {
|
||||
avatarID = id
|
||||
break
|
||||
}
|
||||
player.SavePersistent()
|
||||
}
|
||||
|
||||
// The matching response is declared by the current upstream schema.
|
||||
return s.Send(&goproto.LPOLGDLAADM{})
|
||||
rsp := &goproto.LPOLGDLAADM{}
|
||||
player := s.JSON()
|
||||
if player == nil || avatarID == 0 || player.Avatars[avatarID] == nil {
|
||||
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_NOT_EXIST)
|
||||
Log(fmt.Sprintf("[EX-RANK] rejected unmapped value=%d", value))
|
||||
return s.Send(rsp)
|
||||
}
|
||||
if _, active := CURRENT_BATTLE.Load(s.Token); active {
|
||||
rsp.Retcode = uint32(goproto.Retcode_RET_AVATAR_RANK_NOT_MATCH)
|
||||
return s.Send(rsp)
|
||||
}
|
||||
avatar := player.Avatars[avatarID]
|
||||
previous := avatar.ExRankUnlocked
|
||||
avatar.ExRankUnlocked = true
|
||||
if err := player.SavePersistentChecked(); err != nil {
|
||||
avatar.ExRankUnlocked = previous
|
||||
return err
|
||||
}
|
||||
notify := &goproto.PlayerSyncScNotify{}
|
||||
buildSync(player, notify, []uint32{avatarID}, 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] unlocked avatar=%d value=%d", avatarID, value))
|
||||
return s.Send(rsp)
|
||||
}
|
||||
|
||||
// challengeGroupStatsReply uses the current typed statistics response.
|
||||
|
||||
@@ -3,7 +3,7 @@ package gameserver
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"sync"
|
||||
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
@@ -31,7 +31,7 @@ var profileError error
|
||||
|
||||
func loadProfileResources() (*profileResources, error) {
|
||||
profileOnce.Do(func() {
|
||||
raw, err := os.ReadFile("resources/ProfileConfig.json")
|
||||
raw, err := common.ReadResourceFile("resources/ProfileConfig.json", "ProfileConfig.json")
|
||||
if err != nil {
|
||||
profileError = err
|
||||
return
|
||||
@@ -48,7 +48,7 @@ func loadProfileResources() (*profileResources, error) {
|
||||
profileError = fmt.Errorf("selected phone case is not owned")
|
||||
return
|
||||
}
|
||||
raw, err = os.ReadFile("resources/AvatarPlayerIcon.json")
|
||||
raw, err = common.ReadResourceFile("resources/AvatarPlayerIcon.json", "AvatarPlayerIcon.json")
|
||||
if err != nil {
|
||||
profileError = err
|
||||
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)
|
||||
}
|
||||
@@ -29,8 +29,9 @@ type Session struct {
|
||||
ConvID uint32
|
||||
Addr string
|
||||
|
||||
jsonMu sync.RWMutex
|
||||
json *common.FreesrData
|
||||
jsonMu sync.RWMutex
|
||||
stateMu sync.Mutex // serializes packet handlers and hot reload mutations
|
||||
json *common.FreesrData
|
||||
|
||||
kcpMu sync.Mutex
|
||||
kcp *kcplib.KCP
|
||||
@@ -45,7 +46,8 @@ type Session struct {
|
||||
// attack actually hit a monster (the camera-detach bug fix).
|
||||
MonsterEntities map[uint32]uint32
|
||||
// Set by the challenge scene loader, cleared when loading an overworld scene.
|
||||
ChallengeStageID uint32
|
||||
ChallengeStageID uint32
|
||||
pendingExRankSync map[uint32]bool
|
||||
|
||||
closed atomic.Bool
|
||||
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": {},
|
||||
"enable_aha_global": true,
|
||||
"enable_castorice_global": true,
|
||||
"enable_sw_global": true,
|
||||
"ex_ranks_unlocked": {
|
||||
"1204": true,
|
||||
"1213": 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