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

+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