chore: checkpoint stable battle and eidolon progression

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

No files matched your search

+4
View File
@@ -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/
+2
View File
@@ -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 ไม่ได้
+27
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+28 -29
View File
@@ -8,7 +8,6 @@ package common
import (
"encoding/json"
"fmt"
"os"
"sort"
)
@@ -149,6 +148,23 @@ type SubAffix struct {
Step uint32 `json:"step"`
}
func (s *SubAffix) UnmarshalJSON(data []byte) error {
type Alias SubAffix
aux := &struct {
Cnt *uint32 `json:"cnt"`
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if aux.Cnt != nil && s.Count == 0 {
s.Count = *aux.Cnt
}
return nil
}
type Relic struct {
Level uint32 `json:"level"`
RelicID uint32 `json:"relic_id"`
@@ -352,6 +368,8 @@ func DefaultPersistent() Persistent {
// ---------------------------------------------------------------------------
type FreesrData struct {
persistentBaseline map[string]any
Lightcones []Lightcone `json:"lightcones"`
Relics []Relic `json:"relics"`
Avatars map[uint32]*AvatarJson `json:"avatars"`
@@ -372,7 +390,7 @@ type FreesrData struct {
// LoadFreesrData reads freesr-data.json + persistent and merges them,
// mirroring FreesrData::load() from the Rust crate.
func LoadFreesrData() (*FreesrData, error) {
raw, err := os.ReadFile("freesr-data.json")
raw, err := readResourceFile("freesr-data.json")
if err != nil {
return nil, fmt.Errorf("failed to read freesr-data.json: %w", err)
}
@@ -390,7 +408,7 @@ func LoadFreesrData() (*FreesrData, error) {
}
// persistent is optional
pers, err := os.ReadFile("persistent")
pers, err := readResourceFile("persistent")
if err == nil {
var p Persistent
if err := json.Unmarshal(pers, &p); err == nil {
@@ -440,6 +458,10 @@ func LoadFreesrData() (*FreesrData, error) {
data.MarchType = MultiPathMarchHunt
}
data.persistentBaseline, err = jsonObject(data.persistentSnapshot())
if err != nil {
return nil, err
}
return &data, nil
}
@@ -453,7 +475,7 @@ func (f *FreesrData) SortedLineupKeys() []uint32 {
return keys
}
// VerifyLineup pads the lineup to 4 slots, defaulting to {0:8001}, then saves.
// VerifyLineup normalizes slots in memory. Reading a save must not rewrite it.
func (f *FreesrData) VerifyLineup() {
if len(f.Lineups) == 0 {
f.Lineups = map[uint32]uint32{0: 8001, 1: 0, 2: 0, 3: 0}
@@ -464,35 +486,12 @@ func (f *FreesrData) VerifyLineup() {
}
}
}
f.SavePersistent()
}
// SavePersistent writes the persistent subset back to the "persistent" file.
func (f *FreesrData) SavePersistent() {
exUnlocked := make(map[uint32]bool)
for aid, av := range f.Avatars {
if av.ExRankUnlocked {
exUnlocked[aid] = true
}
}
p := Persistent{
Lineups: f.Lineups,
Position: f.Position,
Scene: f.Scene,
MainCharacter: f.MainCharacter,
MarchType: f.MarchType,
EnableSWGlobal: f.EnableSWGlobal,
EnableCastoriceGlobal: f.EnableCastoriceGlobal,
EnableAhaGlobal: f.EnableAhaGlobal,
DressedSkins: f.DressedSkins,
ExRanksUnlocked: exUnlocked,
}
out, err := json.MarshalIndent(p, "", " ")
if err != nil {
return
}
if err := os.WriteFile("persistent", out, 0o644); err == nil {
LogInfo("persistent saved")
if err := f.SavePersistentChecked(); err != nil {
LogInfo("[SAVE ERROR] %v", err)
}
}
+20
View File
@@ -0,0 +1,20 @@
//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
package common
import (
"os"
"syscall"
)
func lockPersistentFile() (func(), error) {
file, err := os.OpenFile("persistent.lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
file.Close()
return nil, err
}
return func() { syscall.Flock(int(file.Fd()), syscall.LOCK_UN); file.Close() }, nil
}
@@ -0,0 +1,30 @@
//go:build windows
package common
import (
"fmt"
"os"
"syscall"
"unsafe"
)
// LockFileEx's exclusive byte-range lock is released by Windows if either
// process exits. The shared lock file coordinates SDK and game server saves.
func lockPersistentFile() (func(), error) {
file, err := os.OpenFile("persistent.lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
dll := syscall.NewLazyDLL("kernel32.dll")
lock := dll.NewProc("LockFileEx")
unlock := dll.NewProc("UnlockFileEx")
overlapped := new(syscall.Overlapped)
const lockfileExclusiveLock = 2 // Win32 LOCKFILE_EXCLUSIVE_LOCK.
ok, _, callErr := lock.Call(file.Fd(), lockfileExclusiveLock, 0, 1, 0, uintptr(unsafe.Pointer(overlapped)))
if ok == 0 {
file.Close()
return nil, fmt.Errorf("lock persistent: %w", callErr)
}
return func() { unlock.Call(file.Fd(), 0, 1, 0, uintptr(unsafe.Pointer(overlapped))); file.Close() }, nil
}
+198
View File
@@ -0,0 +1,198 @@
package common
import (
"encoding/json"
"fmt"
"os"
"reflect"
"sync"
)
var persistentWriteMu sync.Mutex
func ReadPersistent() (*Persistent, error) {
raw, err := os.ReadFile("persistent")
if err != nil {
return nil, err
}
var p Persistent
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("parse persistent: %w", err)
}
return &p, nil
}
func jsonObject(value any) (map[string]any, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var object map[string]any
err = json.Unmarshal(raw, &object)
return object, err
}
func (f *FreesrData) persistentSnapshot() Persistent {
ex := make(map[uint32]bool)
for id := range AvatarExRankMap {
if avatar := f.Avatars[id]; avatar != nil {
ex[id] = avatar.ExRankUnlocked
}
}
return Persistent{Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
MainCharacter: f.MainCharacter, MarchType: f.MarchType,
EnableSWGlobal: f.EnableSWGlobal, EnableCastoriceGlobal: f.EnableCastoriceGlobal,
EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, ExRanksUnlocked: ex}
}
// Apply only locally changed fields. A stale game session saving its position
// must not undo an EX switch written by the SDK process. Nested maps are merged
// per key so different avatars' switches do not overwrite one another.
func mergePersistentChanges(existing, baseline, next map[string]any) {
for key, value := range next {
old, known := baseline[key]
if known && reflect.DeepEqual(old, value) {
continue
}
newMap, newOK := value.(map[string]any)
oldMap, oldOK := old.(map[string]any)
if newOK && oldOK {
dest, ok := existing[key].(map[string]any)
if !ok {
dest = make(map[string]any)
}
mergePersistentChanges(dest, oldMap, newMap)
for removed := range oldMap {
if _, present := newMap[removed]; !present {
delete(dest, removed)
}
}
existing[key] = dest
} else {
existing[key] = value
}
}
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
file, err := os.CreateTemp(".", ".gac-save-*.tmp")
if err != nil {
return err
}
name := file.Name()
defer os.Remove(name)
if _, err := file.Write(raw); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return os.Rename(name, path)
}
func (f *FreesrData) SavePersistentChecked() error {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return err
}
defer unlock()
next, err := jsonObject(f.persistentSnapshot())
if err != nil {
return err
}
existing := make(map[string]any)
if raw, err := os.ReadFile("persistent"); err == nil {
if err := json.Unmarshal(raw, &existing); err != nil {
return fmt.Errorf("refuse to overwrite invalid persistent: %w", err)
}
} else if !os.IsNotExist(err) {
return err
}
mergePersistentChanges(existing, f.persistentBaseline, next)
if err := writeJSONAtomic("persistent", existing); err != nil {
return err
}
// Track our own observed snapshot, not external changes we have not loaded.
f.persistentBaseline = next
return nil
}
// RefreshExRanks imports persisted switches without replacing inventory,
// lineup, scene or the configured battle. Call under the session state lock.
func (f *FreesrData) RefreshExRanks(p *Persistent) []uint32 {
var changed []uint32
for id := range AvatarExRankMap {
avatar := f.Avatars[id]
if avatar == nil {
continue
}
unlocked := p.ExRanksUnlocked[id]
if avatar.ExRankUnlocked != unlocked {
avatar.ExRankUnlocked = unlocked
changed = append(changed, id)
}
}
if current, err := jsonObject(f.persistentSnapshot()); err == nil {
if f.persistentBaseline == nil {
f.persistentBaseline = make(map[string]any)
}
f.persistentBaseline["ex_ranks_unlocked"] = current["ex_ranks_unlocked"]
}
return changed
}
// Patch only the selected avatar's rank in the latest import. Unknown FreeSR
// fields and all inventory IDs/affixes retain their original JSON values.
func SaveAvatarRank(id, rank uint32) error {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return err
}
defer unlock()
raw, err := os.ReadFile("freesr-data.json")
if err != nil {
return err
}
var root map[string]json.RawMessage
if err := json.Unmarshal(raw, &root); err != nil {
return err
}
var avatars map[uint32]json.RawMessage
if err := json.Unmarshal(root["avatars"], &avatars); err != nil {
return err
}
var avatar map[string]json.RawMessage
if err := json.Unmarshal(avatars[id], &avatar); err != nil {
return err
}
var data map[string]json.RawMessage
if err := json.Unmarshal(avatar["data"], &data); err != nil {
return err
}
data["rank"], err = json.Marshal(rank)
if err != nil {
return err
}
avatar["data"], err = json.Marshal(data)
if err != nil {
return err
}
avatars[id], err = json.Marshal(avatar)
if err != nil {
return err
}
root["avatars"], err = json.Marshal(avatars)
if err != nil {
return err
}
return writeJSONAtomic("freesr-data.json", root)
}
+51 -7
View File
@@ -162,11 +162,36 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
}
for anchorType, level := range a.Data.SkillsByAnchorType {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: anchorType,
Level: level,
})
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
}
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pointID,
Level: level,
})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
PointId: pid,
Level: 1,
})
}
}
}
// deterministic order (Rust iterated the HashMap; order is irrelevant to the
// client, but stable output keeps packets reproducible)
@@ -211,8 +236,27 @@ func (a *AvatarJson) ToBattleAvatarProto(index uint32, lightcone *Lightcone, rel
},
}
for pointID, level := range a.Data.Skills {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
if len(a.Data.Skills) > 0 {
for pointID, level := range a.Data.Skills {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
}
} else if len(a.Data.SkillsByAnchorType) > 0 {
res, _ := GameRes()
for anchorType, level := range a.Data.SkillsByAnchorType {
pointID := anchorType
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
pointID = pid
}
}
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pointID, Level: level})
}
} else {
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
ba.SkilltreeList = append(ba.SkilltreeList, &goproto.AvatarSkillTree{PointId: pid, Level: 1})
}
}
}
sortAvatarSkillTree(ba.SkilltreeList)
+62
View File
@@ -1,6 +1,7 @@
package common
import (
"encoding/json"
"testing"
goproto "gac-hkrpg-go/protocol/proto"
@@ -33,3 +34,64 @@ func TestStandaloneBattleRelicWire(t *testing.T) {
t.Fatal("inventory and equipped relic references must retain their IDs")
}
}
func TestAvatarPathSkillTreePointIds(t *testing.T) {
// 1. avatar with explicit Skills map uses real PointIDs directly
av := AvatarJson{
AvatarID: 1213,
Data: AvatarData{
Rank: 0,
Skills: map[uint32]uint32{
1213001: 6,
1213002: 10,
},
SkillsByAnchorType: map[uint32]uint32{
1: 6,
2: 10,
},
},
}
proto := av.ToAvatarPathDataProto(nil, nil, 0)
if len(proto.AvatarPathSkillTree) != 2 {
t.Fatalf("expected 2 skill tree nodes, got %d", len(proto.AvatarPathSkillTree))
}
if proto.AvatarPathSkillTree[0].PointId != 1213001 || proto.AvatarPathSkillTree[1].PointId != 1213002 {
t.Fatalf("point ids must be real config point IDs, got %v", proto.AvatarPathSkillTree)
}
// 2. avatar with only SkillsByAnchorType resolves from AvatarSkillTreeConfig.json
avAnchorOnly := AvatarJson{
AvatarID: 1001,
Data: AvatarData{
Rank: 0,
SkillsByAnchorType: map[uint32]uint32{
1: 6,
},
},
}
protoAnchor := avAnchorOnly.ToAvatarPathDataProto(nil, nil, 0)
if len(protoAnchor.AvatarPathSkillTree) != 1 {
t.Fatalf("expected 1 skill tree node, got %d", len(protoAnchor.AvatarPathSkillTree))
}
if protoAnchor.AvatarPathSkillTree[0].PointId != 1001001 {
t.Fatalf("expected resolved point ID 1001001 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
}
}
func TestSubAffixUnmarshal(t *testing.T) {
var sub1 SubAffix
if err := json.Unmarshal([]byte(`{"sub_affix_id": 7, "cnt": 4, "step": 2}`), &sub1); err != nil {
t.Fatal(err)
}
if sub1.Count != 4 || sub1.SubAffixID != 7 || sub1.Step != 2 {
t.Fatalf("cnt alias failed: %+v", sub1)
}
var sub2 SubAffix
if err := json.Unmarshal([]byte(`{"sub_affix_id": 8, "count": 3, "step": 1}`), &sub2); err != nil {
t.Fatal(err)
}
if sub2.Count != 3 || sub2.SubAffixID != 8 || sub2.Step != 1 {
t.Fatalf("count parsing failed: %+v", sub2)
}
}
+44 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
@@ -76,6 +77,7 @@ type LevelOutputConfig struct {
type AvatarConfig struct {
WeaknessBuffID uint32 `json:"weaknessBuffId"`
DamageType string `json:"damageType"`
MaxRank uint32 `json:"maxRank"`
}
type JsonConfig struct {
@@ -85,6 +87,7 @@ type JsonConfig struct {
MapDefaultEntranceMap map[uint32]uint32 `json:"mapDefaultEntranceMap"`
RelicAvatarRecommend map[uint32][]uint32 `json:"relicAvatarRecommend"`
SkinIDs []uint32 `json:"skinIds"`
AvatarSkillTreePoints map[uint32]map[uint32]uint32 `json:"-"`
}
var (
@@ -146,15 +149,23 @@ type AnchorConfigFile struct {
AnchorConfig []AnchorConfigEntry `json:"anchor_config"`
}
func readResourceFile(paths ...string) ([]byte, error) {
// ReadResourceFile reads a resource file checking current directory and parent directories.
func ReadResourceFile(paths ...string) ([]byte, error) {
for _, p := range paths {
if raw, err := os.ReadFile(p); err == nil {
return raw, nil
for _, prefix := range []string{"", "..", "../..", "../../.."} {
target := filepath.Join(prefix, p)
if raw, err := os.ReadFile(target); err == nil {
return raw, nil
}
}
}
return nil, os.ErrNotExist
}
func readResourceFile(paths ...string) ([]byte, error) {
return ReadResourceFile(paths...)
}
// GameRes lazily parses res.json and merges supplementary configs from resources/
func GameRes() (*JsonConfig, error) {
gameResOnce.Do(func() {
@@ -186,6 +197,34 @@ func GameRes() (*JsonConfig, error) {
}
func loadSupplementaryResources(res *JsonConfig) {
// AvatarSkillTreeConfig: maps AvatarID and AnchorType (e.g. Point01 -> 1) to real PointID
if raw, err := readResourceFile("resources/AvatarSkillTreeConfig.json", "AvatarSkillTreeConfig.json"); err == nil {
var tree struct {
Entries []struct {
AvatarID uint32 `json:"AvatarID"`
PointID uint32 `json:"PointID"`
AnchorType string `json:"AnchorType"`
} `json:"avatar_skill_tree"`
}
if err := json.Unmarshal(raw, &tree); err == nil {
if res.AvatarSkillTreePoints == nil {
res.AvatarSkillTreePoints = make(map[uint32]map[uint32]uint32)
}
for _, entry := range tree.Entries {
if entry.AvatarID == 0 || entry.PointID == 0 {
continue
}
var anchorNum uint32
if n, _ := fmt.Sscanf(entry.AnchorType, "Point%d", &anchorNum); n == 1 {
if res.AvatarSkillTreePoints[entry.AvatarID] == nil {
res.AvatarSkillTreePoints[entry.AvatarID] = make(map[uint32]uint32)
}
res.AvatarSkillTreePoints[entry.AvatarID][anchorNum] = entry.PointID
}
}
}
}
// PearlSR's resource export keeps avatar data in AvatarConfig.json;
// res.json may legitimately contain an empty avatarConfigs object.
if raw, err := readResourceFile("resources/AvatarConfig.json"); err == nil {
@@ -193,12 +232,14 @@ func loadSupplementaryResources(res *JsonConfig) {
Entries []struct {
ID uint32 `json:"AvatarID"`
DamageType string `json:"DamageType"`
MaxRank uint32 `json:"MaxRank"`
} `json:"avatar_config"`
}
if err := json.Unmarshal(raw, &avatars); err == nil {
for _, avatar := range avatars.Entries {
cfg := res.AvatarConfigs[avatar.ID]
cfg.DamageType = avatar.DamageType
cfg.MaxRank = avatar.MaxRank
res.AvatarConfigs[avatar.ID] = cfg
}
}
+1 -2
View File
@@ -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
+2
View File
@@ -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]))
+122
View File
@@ -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
}
+13 -5
View File
@@ -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()
}
}
}
+14
View File
@@ -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
})
+37 -20
View File
@@ -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)
})
+41 -38
View File
@@ -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 -3
View File
@@ -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
+95
View File
@@ -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)
}
+5 -3
View File
@@ -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
View File
@@ -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
}
}
+209
View File
@@ -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
}
}
}