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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user