docs & feat: add license & attribution notice (Apache-2.0 / GPL-3.0) and core system enhancements

This commit is contained in:
CAtlas0784 committed 2026-11-01 20:14:36 +07:00
1 parent 507836d367
commit ce6aabfc55
47 files changed
+5674 -724

No files matched your search

+85 -58
View File
@@ -2,87 +2,114 @@ package gameserver
import (
"fmt"
"sync"
"time"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
"math"
)
const archiveClientVersion uint32 = 4006
type cwArchiveStore struct {
mu sync.RWMutex
nextID uint32
entries []*pb.FAIHLAKPABN
func cwArchiveSnapshot(c *common.CurrencyWarsCatalog) (*common.CurrencyWarsSnapshot, error) {
cfg, err := common.LoadRuntimeConfig()
if err != nil {
return nil, err
}
return common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
}
var globalCWArchive = &cwArchiveStore{nextID: 1}
func cwRecordArchive(record *pb.OEDHMAHDECC, seasonID, subSeasonID uint32) *pb.FAIHLAKPABN {
if record == nil {
return nil
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC) error {
if record == nil || s.currencyWarsRevision >= math.MaxUint32 {
return fmt.Errorf("CW archive record/identifier invalid")
}
globalCWArchive.mu.Lock()
defer globalCWArchive.mu.Unlock()
id := globalCWArchive.nextID
globalCWArchive.nextID++
entry := &pb.FAIHLAKPABN{
BAAGDILLLME: id,
OBNEAIPKGKL: time.Now().Unix(),
LJNGPCKPJLF: seasonID,
GEGMHOMNBDG: subSeasonID,
PMBNKEFCICH: archiveClientVersion,
IECKKCHJMLP: false,
BFLLJJBHLDF: proto.Clone(record).(*pb.OEDHMAHDECC),
now, err := common.GameTime()
if err != nil {
return err
}
globalCWArchive.entries = append(globalCWArchive.entries, entry)
if len(globalCWArchive.entries) > 50 {
globalCWArchive.entries = globalCWArchive.entries[len(globalCWArchive.entries)-50:]
entry := &pb.FAIHLAKPABN{BAAGDILLLME: uint32(s.currencyWarsRevision + 1), OBNEAIPKGKL: now.Unix(), LJNGPCKPJLF: c.ActiveSeasonID, GEGMHOMNBDG: c.ActiveSubSeasonID, BFLLJJBHLDF: proto.Clone(record).(*pb.OEDHMAHDECC)}
// Client-version encoding is unmapped; do not invent it from a patch number.
wire, err := proto.Marshal(entry)
if err != nil {
return err
}
return entry
return s.saveCurrencyWarsSnapshot(data, c, nil, wire)
}
func cwReadArchives(snapshot *common.CurrencyWarsSnapshot) ([]*pb.FAIHLAKPABN, error) {
if snapshot == nil {
return nil, nil
}
var entries []*pb.FAIHLAKPABN
for _, wire := range snapshot.Archives {
entry := new(pb.FAIHLAKPABN)
if err := proto.Unmarshal(wire, entry); err != nil {
return nil, fmt.Errorf("decode CW archive: %w", err)
}
if entry.BAAGDILLLME == 0 || entry.BFLLJJBHLDF == nil {
return nil, fmt.Errorf("CW archive entry incomplete")
}
entries = append(entries, entry)
}
return entries, nil
}
func init() {
cwHandle("GridFightGetArchive", func(s *Session, req *pb.GridFightGetArchiveCsReq, rsp *pb.GridFightGetArchiveScRsp) error {
rsp.Retcode = 0
if req != nil {
rsp.Type = req.Type
cwHandle("GridFightGetArchive", func(_ *Session, req *pb.GridFightGetArchiveCsReq, rsp *pb.GridFightGetArchiveScRsp) error {
c, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
globalCWArchive.mu.RLock()
defer globalCWArchive.mu.RUnlock()
favourOnly := req != nil && req.Type == pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH
for i := len(globalCWArchive.entries) - 1; i >= 0; i-- {
e := globalCWArchive.entries[i]
if favourOnly && !e.IECKKCHJMLP {
snapshot, err := cwArchiveSnapshot(c)
if err != nil {
return err
}
entries, err := cwReadArchives(snapshot)
if err != nil {
return err
}
rsp.Type = req.Type
for i := len(entries) - 1; i >= 0; i-- {
if req.Type == pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH && !entries[i].IECKKCHJMLP {
continue
}
rsp.MCGGPONOCKC = append(rsp.MCGGPONOCKC, proto.Clone(e).(*pb.FAIHLAKPABN))
rsp.MCGGPONOCKC = append(rsp.MCGGPONOCKC, entries[i])
}
PacketLog(fmt.Sprintf("CW_GET_ARCHIVE type=%v entries=%d", rsp.Type, len(rsp.MCGGPONOCKC)))
return nil
})
cwHandle("FavourArchive", func(s *Session, req *pb.FavourArchiveCsReq, rsp *pb.FavourArchiveScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
if req == nil {
c, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
snapshot, err := cwArchiveSnapshot(c)
if err != nil {
return err
}
if snapshot == nil || snapshot.Revision != s.currencyWarsRevision {
return nil
}
globalCWArchive.mu.Lock()
defer globalCWArchive.mu.Unlock()
for _, e := range globalCWArchive.entries {
if e.BAAGDILLLME == req.BAAGDILLLME {
e.IECKKCHJMLP = req.IECKKCHJMLP
rsp.BAAGDILLLME = req.BAAGDILLLME
rsp.IECKKCHJMLP = req.IECKKCHJMLP
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_FAVOUR_ARCHIVE id=%d favour=%t", req.BAAGDILLLME, req.IECKKCHJMLP))
return nil
entries, err := cwReadArchives(snapshot)
if err != nil {
return err
}
for i, entry := range entries {
if entry.BAAGDILLLME != req.BAAGDILLLME {
continue
}
entry.IECKKCHJMLP = req.IECKKCHJMLP
wire, err := proto.Marshal(entry)
if err != nil {
return err
}
snapshot.Archives[i] = wire
revision, err := common.CommitCurrencyWarsSnapshot(s.currencyWarsRevision, *snapshot)
if err != nil {
return err
}
s.currencyWarsRevision = revision
rsp.BAAGDILLLME = req.BAAGDILLLME
rsp.IECKKCHJMLP = req.IECKKCHJMLP
rsp.Retcode = 0
return nil
}
return nil
})
+1 -1
View File
@@ -111,7 +111,7 @@ func buildCurrencyWarsBattle(s *Session, catalog *common.CurrencyWarsCatalog, ru
var ownerIndex uint32
hasOwner := false
for _, r := range team.GridGameRoleList {
if r != nil && r.Pos > 0 && r.Pos < benchFirstPos {
if cwRoleOnBoard(catalog, r) {
if variant, err := catalog.CapturedRoleVariant(r.Id, r.RoleStar); err == nil {
for _, tid := range variant.Config.TraitList {
if tid == cwTrailblazeTrait {
+174 -192
View File
@@ -275,217 +275,199 @@ func cwNextRound(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*pb.
}
func init() {
cwHandle("GridFightUpdatePos", func(s *Session, req *pb.GridFightUpdatePosCsReq, rsp *pb.GridFightUpdatePosScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
if _, active := CURRENT_BATTLE.Load(s.Token); active {
PacketLog("CW_UPDATE_POS rejected reason=battle_active")
return nil
}
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil || data.DHFOFLFCLAH == nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY)
return nil
}
if revision != s.currencyWarsRevision {
return nil
}
run := data.DHFOFLFCLAH
if run.FMIGOKIDKJL != nil || len(req.AAKDHMHCMLP) == 0 {
PacketLog("CW_UPDATE_POS rejected reason=not_preparing_or_empty")
return nil
}
player, team, _ := cwComponents(run)
if player == nil || team == nil {
return nil
}
front, err := catalog.IntegerRule("GridFight_Front_AvatarMaxNum")
if err != nil {
return err
}
back, err := catalog.IntegerRule("GridFight_Back_AvatarMaxNum")
if err != nil {
return err
}
activeEnd := uint64(front + back)
overflow, err := catalog.IntegerRule("GridFight_Bench_OverFlow_AvatarNum")
if err != nil {
return err
}
benchEnd := activeEnd + uint64(player.MICEOJBAKGN) + uint64(overflow)
changes := map[uint32]uint32{}
for _, v := range req.AAKDHMHCMLP {
if v == nil || v.UniqueId == 0 {
cwHandle("GridFightUpdatePos", cwUpdatePositions)
// CmdID 8543 (GEBBMDCOLCH) -> ScRsp 8630 (ALNFEBKHCFC)
// Protocol revision: 4.6.53 (upstream_cmdids.go).
// Assumption: Native Client sends GEBBMDCOLCH during preparation / round transition to sync
// the batch formation role positions (CONJCEMAHGN: MDNAGANGNKK = role UID, JFCJNHNBMCB = pos).
// Reuse the validated formation transaction; acknowledge only after commit.
cwHandleNamed("GEBBMDCOLCH", "ALNFEBKHCFC", func(s *Session, req *pb.GEBBMDCOLCH, rsp *pb.ALNFEBKHCFC) error {
mapped := &pb.GridFightUpdatePosCsReq{}
for _, entry := range req.CONJCEMAHGN {
if entry == nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
return nil
}
if _, ok := changes[v.UniqueId]; ok {
return nil
}
if uint64(v.Pos) > benchEnd {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_NOT_VALID)
return nil
}
changes[v.UniqueId] = v.Pos
mapped.AAKDHMHCMLP = append(mapped.AAKDHMHCMLP, &pb.MHEGMHKHAAD{UniqueId: entry.MDNAGANGNKK, Pos: entry.JFCJNHNBMCB})
}
// Validate final positions together, so swaps need no intermediate empty slot.
occupied := map[uint32]bool{}
owned := map[uint32]bool{}
active := uint32(0)
activeAvatars := map[uint32]bool{}
for _, role := range team.GridGameRoleList {
owned[role.UniqueId] = true
pos := role.Pos
if v, ok := changes[role.UniqueId]; ok {
pos = v
}
if pos != 0 {
if occupied[pos] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE)
return nil
}
occupied[pos] = true
}
if pos != 0 && uint64(pos) <= activeEnd {
config, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar)
if err != nil {
return err
}
if activeAvatars[config.Config.AvatarID] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_SAME_ROLE_IN_BATTLE)
return nil
}
activeAvatars[config.Config.AvatarID] = true
active++
}
result := &pb.GridFightUpdatePosScRsp{}
err := cwUpdatePositions(s, mapped, result)
rsp.Retcode = result.Retcode
return err
})
}
func cwUpdatePositions(s *Session, req *pb.GridFightUpdatePosCsReq, rsp *pb.GridFightUpdatePosScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
if _, active := CURRENT_BATTLE.Load(s.Token); active {
PacketLog("CW_UPDATE_POS rejected reason=battle_active")
return nil
}
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil || data.DHFOFLFCLAH == nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY)
return nil
}
if revision != s.currencyWarsRevision {
return nil
}
run := data.DHFOFLFCLAH
if run.FMIGOKIDKJL != nil || len(req.AAKDHMHCMLP) == 0 {
PacketLog("CW_UPDATE_POS rejected reason=not_preparing_or_empty")
return nil
}
player, team, _ := cwComponents(run)
if player == nil || team == nil {
return nil
}
front, err := catalog.IntegerRule("GridFight_Front_AvatarMaxNum")
if err != nil {
return err
}
back, err := catalog.IntegerRule("GridFight_Back_AvatarMaxNum")
if err != nil {
return err
}
activeEnd := uint64(front + back)
overflow, err := catalog.IntegerRule("GridFight_Bench_OverFlow_AvatarNum")
if err != nil {
return err
}
benchEnd := activeEnd + uint64(player.MICEOJBAKGN) + uint64(overflow)
changes := map[uint32]uint32{}
for _, v := range req.AAKDHMHCMLP {
if v == nil || v.UniqueId == 0 {
return nil
}
for _, forge := range team.KCOMPKMJCMC {
pos := forge.Pos
if _, changed := changes[forge.UniqueId]; changed {
return nil
} // Projection movement needs its separately verified sync arm.
if pos != 0 {
if occupied[pos] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE)
return nil
}
occupied[pos] = true
}
if _, ok := changes[v.UniqueId]; ok {
return nil
}
for _, forge := range team.PMEOPPDAMLM {
owned[forge.UniqueId] = true
pos := forge.Pos
if v, ok := changes[forge.UniqueId]; ok {
pos = v
}
if pos == 0 || uint64(pos) <= activeEnd || uint64(pos) > benchEnd {
return nil
}
if uint64(v.Pos) > benchEnd {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_NOT_VALID)
return nil
}
changes[v.UniqueId] = v.Pos
}
// Validate final positions together, so swaps need no intermediate empty slot.
occupied := map[uint32]bool{}
owned := map[uint32]bool{}
active := uint32(0)
activeAvatars := map[uint32]bool{}
for _, role := range team.GridGameRoleList {
owned[role.UniqueId] = true
pos := role.Pos
if v, ok := changes[role.UniqueId]; ok {
pos = v
}
if pos != 0 {
if occupied[pos] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE)
return nil
}
occupied[pos] = true
}
for id := range changes {
if !owned[id] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_ROLE_NOT_EXIST)
if pos != 0 && uint64(pos) <= activeEnd {
config, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar)
if err != nil {
return err
}
if activeAvatars[config.Config.AvatarID] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_SAME_ROLE_IN_BATTLE)
return nil
}
activeAvatars[config.Config.AvatarID] = true
active++
}
if active > player.AFFCFLFIFPH {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_REGION_FULL)
}
for _, forge := range team.KCOMPKMJCMC {
pos := forge.Pos
if _, changed := changes[forge.UniqueId]; changed {
return nil
} // Projection movement needs its separately verified sync arm.
if pos != 0 {
if occupied[pos] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE)
return nil
}
occupied[pos] = true
}
}
for _, forge := range team.PMEOPPDAMLM {
owned[forge.UniqueId] = true
pos := forge.Pos
if v, ok := changes[forge.UniqueId]; ok {
pos = v
}
if pos == 0 || uint64(pos) <= activeEnd || uint64(pos) > benchEnd {
return nil
}
var updates []*pb.KFNCAIHIHCM
for _, role := range team.GridGameRoleList {
if pos, ok := changes[role.UniqueId]; ok {
role.Pos = pos
updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_CFOCDALNCBP{CFOCDALNCBP: role}})
}
if occupied[pos] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_POS_EXIST_ROLE)
return nil
}
for _, forge := range team.PMEOPPDAMLM {
if pos, ok := changes[forge.UniqueId]; ok {
forge.Pos = pos
updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FEKCPKAPHHO{FEKCPKAPHHO: forge}})
}
occupied[pos] = true
}
for id := range changes {
if !owned[id] {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_ROLE_NOT_EXIST)
return nil
}
runtime, _ := cwBonfireRuntime(catalog, run)
cwSyncTraitComponent(catalog, run, runtime)
if tbCat, err := common.LoadCurrencyWarsTrailblaze(); err == nil && (cwGetTrailblazeLayer(catalog, run) > 0 || (runtime != nil && runtime.Rules["TbOpened"] != 0)) {
updates = append(updates,
&pb.KFNCAIHIHCM{
MNLCHEJKDHO: &pb.KFNCAIHIHCM_HKHJJCMKKJE{
HKHJJCMKKJE: &pb.JLKCBOIFJKC{
TraitId: cwTrailblazeTrait,
ENPLACDLJGA: cwTrailblazeTraitEffectProto(tbCat, runtime),
},
}
if active > player.AFFCFLFIFPH {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_REGION_FULL)
return nil
}
var updates []*pb.KFNCAIHIHCM
for _, role := range team.GridGameRoleList {
if pos, ok := changes[role.UniqueId]; ok {
role.Pos = pos
updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_CFOCDALNCBP{CFOCDALNCBP: role}})
}
}
for _, forge := range team.PMEOPPDAMLM {
if pos, ok := changes[forge.UniqueId]; ok {
forge.Pos = pos
updates = append(updates, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FEKCPKAPHHO{FEKCPKAPHHO: forge}})
}
}
runtime, err := cwBonfireRuntime(catalog, run)
if err != nil {
return err
}
cwSyncTraitComponent(catalog, run, runtime)
if tbCat, err := common.LoadCurrencyWarsTrailblaze(); err == nil && (cwGetTrailblazeLayer(catalog, run) > 0 || (runtime != nil && runtime.Rules["TbOpened"] != 0)) {
updates = append(updates,
&pb.KFNCAIHIHCM{
MNLCHEJKDHO: &pb.KFNCAIHIHCM_HKHJJCMKKJE{
HKHJJCMKKJE: &pb.JLKCBOIFJKC{
TraitId: cwTrailblazeTrait,
ENPLACDLJGA: cwTrailblazeTraitEffectProto(tbCat, runtime),
},
},
&pb.KFNCAIHIHCM{
MNLCHEJKDHO: &pb.KFNCAIHIHCM_GOOCHGKBIPN{
GOOCHGKBIPN: cwTrailblazeModifierProto(tbCat, runtime),
},
},
&pb.KFNCAIHIHCM{
MNLCHEJKDHO: &pb.KFNCAIHIHCM_GOOCHGKBIPN{
GOOCHGKBIPN: cwTrailblazeModifierProto(tbCat, runtime),
},
)
}
if err = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime); err != nil {
return err
}
if err = cwSend(s, cwNotify(updates...)); err != nil {
return err
}
rsp.AAKDHMHCMLP = req.AAKDHMHCMLP
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_UPDATE_POS saved count=%d active=%d revision=%d", len(changes), active, s.currencyWarsRevision))
return nil
})
// CmdID 8543 (GEBBMDCOLCH) -> ScRsp 8630 (ALNFEBKHCFC)
// Protocol revision: 4.6.53 (upstream_cmdids.go).
// Assumption: Native Client sends GEBBMDCOLCH during preparation / round transition to sync
// the batch formation role positions (CONJCEMAHGN: MDNAGANGNKK = role UID, JFCJNHNBMCB = pos).
// Acknowledging with retcode 0 unblocks the Client UI input controller.
cwHandleNamed("GEBBMDCOLCH", "ALNFEBKHCFC", func(s *Session, req *pb.GEBBMDCOLCH, rsp *pb.ALNFEBKHCFC) error {
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
rsp.Retcode = 0
return nil
}
data, _, err := readCurrencyWarsData(catalog)
if err != nil || data == nil || data.DHFOFLFCLAH == nil {
rsp.Retcode = 0
return nil
}
run := data.DHFOFLFCLAH
_, team, _ := cwComponents(run)
if team != nil && len(req.CONJCEMAHGN) > 0 {
posMap := make(map[uint32]uint32, len(req.CONJCEMAHGN))
for _, entry := range req.CONJCEMAHGN {
if entry != nil && entry.MDNAGANGNKK != 0 {
posMap[entry.MDNAGANGNKK] = entry.JFCJNHNBMCB
}
}
changed := false
for _, role := range team.GridGameRoleList {
if newPos, ok := posMap[role.UniqueId]; ok && role.Pos != newPos {
role.Pos = newPos
changed = true
}
}
if changed {
runtime, _ := cwBonfireRuntime(catalog, run)
cwSyncTraitComponent(catalog, run, runtime)
_ = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime)
}
}
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_BATCH_UPDATE_POS count=%d revision=%d", len(req.CONJCEMAHGN), s.currencyWarsRevision))
return nil
})
},
)
}
if err = s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime); err != nil {
return err
}
if err = cwSend(s, cwNotify(updates...)); err != nil {
return err
}
rsp.AAKDHMHCMLP = req.AAKDHMHCMLP
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_UPDATE_POS saved count=%d active=%d revision=%d", len(changes), active, s.currencyWarsRevision))
return nil
}
+1 -3
View File
@@ -203,8 +203,6 @@ func currencyWarsTerminalSettlement(catalog *common.CurrencyWarsCatalog, data *p
seasonLevel = maxLevel
}
cwRecordArchive(finish, catalog.ActiveSeasonID, catalog.ActiveSubSeasonID)
if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.HFPEJIDDDJG != nil {
if data.LFLCDNGALME.HFPEJIDDDJG.Exp == nil {
data.LFLCDNGALME.HFPEJIDDDJG.Exp = &pb.GLIFOLCEMIN{}
@@ -275,7 +273,7 @@ func init() {
if !active {
return nil
}
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
if err = s.saveCurrencyWarsTerminal(data, catalog, settlement.BFLLJJBHLDF); err != nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
PacketLog(fmt.Sprintf("CW_QUIT_SETTLE rejected run=%d reason=save_error error=%v", runID, err))
return nil
@@ -84,6 +84,10 @@ func (s *Session) saveCurrencyWarsData(data *goproto.GridFightGetDataScRsp, cata
}
func (s *Session) saveCurrencyWarsDataWithRuntime(data *goproto.GridFightGetDataScRsp, catalog *common.CurrencyWarsCatalog, runtime *common.CurrencyWarsRuntimeState, commandID ...string) error {
return s.saveCurrencyWarsSnapshot(data, catalog, runtime, nil, commandID...)
}
func (s *Session) saveCurrencyWarsSnapshot(data *goproto.GridFightGetDataScRsp, catalog *common.CurrencyWarsCatalog, runtime *common.CurrencyWarsRuntimeState, archive []byte, commandID ...string) error {
cwProjectRoleIDs(data, catalog)
if err := validateCurrencyWarsSavedData(data, catalog); err != nil {
return err
@@ -97,6 +101,7 @@ func (s *Session) saveCurrencyWarsDataWithRuntime(data *goproto.GridFightGetData
return err
}
next := common.NewCurrencyWarsSnapshot(cfg.PlayerUID, catalog.ClientVersion, catalog.Provenance.GameAssemblySHA256, wire)
next.PendingArchive = archive
if run := data.DHFOFLFCLAH; run != nil {
if runtime != nil && runtime.RunID != run.UniqueId {
return fmt.Errorf("CW runtime rules belong to a different run")
@@ -620,7 +620,7 @@ func finishCurrencyWarsBattle(s *Session, req *pb.PVEBattleResultCsReq, current
return termErr
}
data.DHFOFLFCLAH = nil
if termErr = s.saveCurrencyWarsData(data, catalog); termErr != nil {
if termErr = s.saveCurrencyWarsTerminal(data, catalog, settle.BFLLJJBHLDF); termErr != nil {
return termErr
}
CURRENT_BATTLE.Delete(s.Token)
+76 -129
View File
@@ -1,6 +1,7 @@
package gameserver
import (
"bytes"
"os"
"testing"
@@ -10,151 +11,97 @@ import (
)
func TestCurrencyWarsTalentsHandlers(t *testing.T) {
origPersistent, _ := os.ReadFile("persistent")
if origPersistent != nil {
t.Cleanup(func() {
_ = os.WriteFile("persistent", origPersistent, 0644)
})
}
session := &Session{Token: 12345}
catalog, err := common.LoadCurrencyWarsCatalog()
before, err := os.ReadFile("persistent")
if err != nil {
t.Fatalf("load catalog: %v", err)
t.Fatal(err)
}
data, rev, err := readCurrencyWarsData(catalog)
if err != nil || data == nil {
data, err = newCurrencyWarsData(catalog)
s := &Session{Token: 12345}
requests := map[string]proto.Message{
"GridFightPermanentTalentEnableCsReq": &pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)},
"GridFightSeasonTalentEnableCsReq": &pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)},
"GridFightPermanentTalentResetCsReq": &pb.GridFightPermanentTalentResetCsReq{},
"GridFightSeasonTalentResetCsReq": &pb.GridFightSeasonTalentResetCsReq{},
}
for name, req := range requests {
raw, _ := proto.Marshal(req)
if err := typedHandlers[uint16(pb.CmdIDByName[name])](s, raw); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile("persistent")
if err != nil {
t.Fatalf("new data: %v", err)
t.Fatal(err)
}
rev = 0
}
session.currencyWarsRevision = rev
if err := session.saveCurrencyWarsData(data, catalog); err != nil {
t.Fatalf("save initial data: %v", err)
}
// 1. Enable Permanent Talent
permReq := &pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: 101}
permCmdID, ok := pb.CmdIDByName["GridFightPermanentTalentEnableCsReq"]
if !ok || typedHandlers[uint16(permCmdID)] == nil {
t.Fatal("handler GridFightPermanentTalentEnable not registered")
}
rawPerm, err := proto.Marshal(permReq)
if err != nil {
t.Fatalf("marshal permReq: %v", err)
}
if err := typedHandlers[uint16(permCmdID)](session, rawPerm); err != nil {
t.Fatalf("call GridFightPermanentTalentEnable: %v", err)
}
saved, _, err := readCurrencyWarsData(catalog)
if err != nil || saved == nil {
t.Fatalf("read saved data: %v", err)
}
if saved.LFLCDNGALME == nil || saved.LFLCDNGALME.PKELAIPIHMF == nil || saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC == nil {
t.Fatal("permanent talent container missing")
}
found := false
for _, id := range saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL {
if id == 101 {
found = true
break
if !bytes.Equal(before, after) {
t.Fatalf("unsupported talent request changed persistent: %s", name)
}
}
if !found {
t.Errorf("permanent talent 101 was not persisted: %v", saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL)
}
// 2. Enable Season Talent
seasonReq := &pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: 202}
seasonCmdID, ok := pb.CmdIDByName["GridFightSeasonTalentEnableCsReq"]
if !ok || typedHandlers[uint16(seasonCmdID)] == nil {
t.Fatal("handler GridFightSeasonTalentEnable not registered")
}
rawSeason, err := proto.Marshal(seasonReq)
if err != nil {
t.Fatalf("marshal seasonReq: %v", err)
}
if err := typedHandlers[uint16(seasonCmdID)](session, rawSeason); err != nil {
t.Fatalf("call GridFightSeasonTalentEnable: %v", err)
}
saved, _, _ = readCurrencyWarsData(catalog)
if saved.LFLCDNGALME == nil || saved.LFLCDNGALME.HFPEJIDDDJG == nil || saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC == nil {
t.Fatal("season talent container missing")
}
foundSeason := false
for _, id := range saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL {
if id == 202 {
foundSeason = true
break
}
}
if !foundSeason {
t.Errorf("season talent 202 was not persisted: %v", saved.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL)
}
// 3. Reset Talents
resetCmdID, ok := pb.CmdIDByName["GridFightPermanentTalentResetCsReq"]
if ok && typedHandlers[uint16(resetCmdID)] != nil {
rawReset, _ := proto.Marshal(&pb.GridFightPermanentTalentResetCsReq{})
_ = typedHandlers[uint16(resetCmdID)](session, rawReset)
}
saved, _, _ = readCurrencyWarsData(catalog)
if len(saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL) != 0 {
t.Errorf("expected permanent talents to be empty after reset, got %v", saved.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL)
}
}
func TestCurrencyWarsArchiveHandlers(t *testing.T) {
record := &pb.OEDHMAHDECC{
Reason: pb.PEIJNEFOCHI_PEIJNEFOCHI_JOJPGBCECLN,
IsOverlock: false,
}
entry := cwRecordArchive(record, 2, 1)
if entry == nil || entry.BAAGDILLLME == 0 {
t.Fatal("archive record failed")
}
session := &Session{Token: 54321}
// Toggle Favour
favourReq := &pb.FavourArchiveCsReq{BAAGDILLLME: entry.BAAGDILLLME, IECKKCHJMLP: true}
favourCmdID, ok := pb.CmdIDByName["FavourArchiveCsReq"]
if !ok || typedHandlers[uint16(favourCmdID)] == nil {
t.Fatal("FavourArchive handler missing")
}
rawFavour, err := proto.Marshal(favourReq)
c, err := common.LoadCurrencyWarsCatalog()
if err != nil {
t.Fatalf("marshal favourReq: %v", err)
t.Fatal(err)
}
if err := typedHandlers[uint16(favourCmdID)](session, rawFavour); err != nil {
t.Fatalf("call FavourArchive: %v", err)
data, revision, err := readCurrencyWarsData(c)
if err != nil {
t.Fatal(err)
}
foundFavour := false
globalCWArchive.mu.RLock()
for _, e := range globalCWArchive.entries {
if e.BAAGDILLLME == entry.BAAGDILLLME && e.IECKKCHJMLP {
foundFavour = true
break
if data == nil {
data, err = newCurrencyWarsData(c)
if err != nil {
t.Fatal(err)
}
}
globalCWArchive.mu.RUnlock()
if !foundFavour {
t.Errorf("expected archive %d to be favoured", entry.BAAGDILLLME)
session := &Session{Token: 54321, currencyWarsRevision: revision}
record := &pb.OEDHMAHDECC{Reason: pb.PEIJNEFOCHI_PEIJNEFOCHI_JOJPGBCECLN}
if err := session.saveCurrencyWarsTerminal(data, c, record); err != nil {
t.Fatal(err)
}
// Get Archive
getArchiveCmdID, ok := pb.CmdIDByName["GridFightGetArchiveCsReq"]
if !ok || typedHandlers[uint16(getArchiveCmdID)] == nil {
t.Fatal("GridFightGetArchive handler missing")
snapshot, err := cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
rawGetArchive, _ := proto.Marshal(&pb.GridFightGetArchiveCsReq{Type: pb.MLJIILDNDPL_MLJIILDNDPL_FAGECPGMPEH})
if err := typedHandlers[uint16(getArchiveCmdID)](session, rawGetArchive); err != nil {
t.Fatalf("call GridFightGetArchive: %v", err)
entries, err := cwReadArchives(snapshot)
if err != nil || len(entries) == 0 {
t.Fatalf("read archive: %v", err)
}
entry := entries[len(entries)-1]
raw, _ := proto.Marshal(&pb.FavourArchiveCsReq{BAAGDILLLME: entry.BAAGDILLLME, IECKKCHJMLP: true})
if err := typedHandlers[uint16(pb.CmdIDByName["FavourArchiveCsReq"])](session, raw); err != nil {
t.Fatal(err)
}
// A new read/session has no in-memory archive state to rely on.
snapshot, err = cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
entries, err = cwReadArchives(snapshot)
if err != nil {
t.Fatal(err)
}
if !entries[len(entries)-1].IECKKCHJMLP {
t.Fatal("favourite was not persisted")
}
stale := &Session{currencyWarsRevision: revision}
if err := stale.saveCurrencyWarsTerminal(data, c, record); err == nil {
t.Fatal("stale settlement appended archive")
}
again, err := cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
if len(again.Archives) != len(snapshot.Archives) {
t.Fatal("failed transaction changed archives")
}
if err := session.saveCurrencyWarsData(data, c); err != nil {
t.Fatal(err)
}
again, err = cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
if len(again.Archives) != len(snapshot.Archives) {
t.Fatal("ordinary save lost archives")
}
}
+12 -128
View File
@@ -1,141 +1,25 @@
package gameserver
import (
"fmt"
"slices"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
)
import pb "gac-hkrpg-go/protocol/proto"
// The reference talent service is a stub. No revision-bound talent costs,
// prerequisites, refund rules or match effects are available in our catalog.
// Keep the typed callbacks but reject mutation until those contracts exist.
func init() {
cwHandle("GridFightPermanentTalentEnable", func(s *Session, req *pb.GridFightPermanentTalentEnableCsReq, rsp *pb.GridFightPermanentTalentEnableScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
if req == nil || req.CKHCLKOALKE == 0 {
return nil
}
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil {
data, err = newCurrencyWarsData(catalog)
if err != nil {
return err
}
}
s.currencyWarsRevision = revision
if data.LFLCDNGALME == nil {
data.LFLCDNGALME = &pb.CLEDCMIOIPF{}
}
if data.LFLCDNGALME.PKELAIPIHMF == nil {
data.LFLCDNGALME.PKELAIPIHMF = &pb.CKPOBLIFMGN{}
}
if data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC == nil {
data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC = &pb.FMNKKAMOHEE{}
}
list := &data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL
if !slices.Contains(*list, req.CKHCLKOALKE) {
*list = append(*list, req.CKHCLKOALKE)
}
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
}
rsp.CKHCLKOALKE = req.CKHCLKOALKE
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_PERMANENT_TALENT_ENABLE node=%d total=%d", req.CKHCLKOALKE, len(*list)))
cwHandle("GridFightPermanentTalentEnable", func(_ *Session, _ *pb.GridFightPermanentTalentEnableCsReq, rsp *pb.GridFightPermanentTalentEnableScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
cwHandle("GridFightSeasonTalentEnable", func(s *Session, req *pb.GridFightSeasonTalentEnableCsReq, rsp *pb.GridFightSeasonTalentEnableScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
if req == nil || req.CKHCLKOALKE == 0 {
return nil
}
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil {
data, err = newCurrencyWarsData(catalog)
if err != nil {
return err
}
}
s.currencyWarsRevision = revision
if data.LFLCDNGALME == nil {
data.LFLCDNGALME = &pb.CLEDCMIOIPF{}
}
if data.LFLCDNGALME.HFPEJIDDDJG == nil {
data.LFLCDNGALME.HFPEJIDDDJG = &pb.AMEEHIIPMFP{}
}
if data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC == nil {
data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC = &pb.FMNKKAMOHEE{}
}
list := &data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL
if !slices.Contains(*list, req.CKHCLKOALKE) {
*list = append(*list, req.CKHCLKOALKE)
}
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
}
rsp.CKHCLKOALKE = req.CKHCLKOALKE
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_SEASON_TALENT_ENABLE node=%d total=%d", req.CKHCLKOALKE, len(*list)))
cwHandle("GridFightSeasonTalentEnable", func(_ *Session, _ *pb.GridFightSeasonTalentEnableCsReq, rsp *pb.GridFightSeasonTalentEnableScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
cwHandle("GridFightPermanentTalentReset", func(s *Session, _ *pb.GridFightPermanentTalentResetCsReq, rsp *pb.GridFightPermanentTalentResetScRsp) error {
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
s.currencyWarsRevision = revision
if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.PKELAIPIHMF != nil && data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC != nil {
data.LFLCDNGALME.PKELAIPIHMF.MHFHLBHFIBC.BDGMNPIJDDL = nil
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
}
}
rsp.Retcode = 0
PacketLog("CW_PERMANENT_TALENT_RESET completed")
cwHandle("GridFightPermanentTalentReset", func(_ *Session, _ *pb.GridFightPermanentTalentResetCsReq, rsp *pb.GridFightPermanentTalentResetScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
cwHandle("GridFightSeasonTalentReset", func(s *Session, _ *pb.GridFightSeasonTalentResetCsReq, rsp *pb.GridFightSeasonTalentResetScRsp) error {
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
s.currencyWarsRevision = revision
if data != nil && data.LFLCDNGALME != nil && data.LFLCDNGALME.HFPEJIDDDJG != nil && data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC != nil {
data.LFLCDNGALME.HFPEJIDDDJG.MHFHLBHFIBC.BDGMNPIJDDL = nil
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
}
}
rsp.Retcode = 0
PacketLog("CW_SEASON_TALENT_RESET completed")
cwHandle("GridFightSeasonTalentReset", func(_ *Session, _ *pb.GridFightSeasonTalentResetCsReq, rsp *pb.GridFightSeasonTalentResetScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
}
+15 -5
View File
@@ -9,7 +9,17 @@ import (
pb "gac-hkrpg-go/protocol/proto"
)
const benchFirstPos uint32 = 14
func cwRoleOnBoard(c *common.CurrencyWarsCatalog, role *pb.CDEAABODKCP) bool {
if role == nil || role.Pos == 0 {
return false
}
front, err := c.IntegerRule("GridFight_Front_AvatarMaxNum")
if err != nil || front < 0 {
return false
}
back, err := c.IntegerRule("GridFight_Back_AvatarMaxNum")
return err == nil && back >= 0 && uint64(role.Pos) <= uint64(front)+uint64(back)
}
func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) []*pb.FBNGHMHHHLL {
if catalog == nil || run == nil {
@@ -27,7 +37,7 @@ func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEM
tallies := make(map[uint32]uint32)
members := make(map[uint32][]uint32)
for _, role := range team.GridGameRoleList {
if role == nil || role.Pos >= benchFirstPos {
if !cwRoleOnBoard(catalog, role) {
continue
}
variant, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar)
@@ -46,7 +56,7 @@ func cwCalculateBoardTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEM
equipMap, _ := common.LoadCurrencyWarsEquipment(catalog)
if equipMap != nil {
for _, role := range team.GridGameRoleList {
if role == nil || role.Pos >= benchFirstPos {
if !cwRoleOnBoard(catalog, role) {
continue
}
for _, uid := range role.PNJIPHJJONG {
@@ -180,7 +190,7 @@ func cwCalculateBattleTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAE
tallies := make(map[uint32]uint32)
members := make(map[uint32][]*pb.MHFNFINKJKP)
for _, role := range team.GridGameRoleList {
if role == nil || role.Pos >= benchFirstPos {
if !cwRoleOnBoard(catalog, role) {
continue
}
variant, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar)
@@ -199,7 +209,7 @@ func cwCalculateBattleTraits(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAE
equipMap, _ := common.LoadCurrencyWarsEquipment(catalog)
if equipMap != nil {
for _, role := range team.GridGameRoleList {
if role == nil || role.Pos >= benchFirstPos {
if !cwRoleOnBoard(catalog, role) {
continue
}
for _, uid := range role.PNJIPHJJONG {
@@ -0,0 +1,211 @@
package gameserver
import (
"bytes"
"gac-hkrpg-go/internal/common"
"gac-hkrpg-go/internal/testutil"
kcplib "gac-hkrpg-go/pkg/kcp"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
"os"
"testing"
)
func isolatedCWTest(t *testing.T) *common.CurrencyWarsCatalog {
t.Helper()
temporary := t.TempDir()
if err := testutil.CopyRuntime(testWorkspaceRoot, temporary); err != nil {
t.Fatal(err)
}
t.Chdir(temporary)
t.Setenv("GAC_CONFIG", "server.json")
if err := os.WriteFile("persistent", []byte(`{}`), 0600); err != nil {
t.Fatal(err)
}
c, err := common.LoadCurrencyWarsCatalog()
if err != nil {
t.Fatal(err)
}
return c
}
// Use the real typed dispatcher and transport framing, so assertions examine
// the Client response rather than merely checking handler registration.
func replySession(t *testing.T) (*Session, func(proto.Message)) {
t.Helper()
client := kcplib.New(1, 1, false, func([]byte) error { return nil })
s := NewSession(func(data []byte) error { return client.Input(data) }, 1, 1)
s.kcp.SetNodelay(true, 10, 2, true)
if err := s.kcp.Update(s.kcp.Now()); err != nil {
t.Fatal(err)
}
return s, func(response proto.Message) {
t.Helper()
wanted := pb.CmdIDByName[string(response.ProtoReflect().Descriptor().Name())]
for {
buf := make([]byte, 65536)
n, err := client.Recv(buf)
if err != nil {
t.Fatal(err)
}
packet, err := DecodeNetPacket(buf[:n])
if err != nil {
t.Fatal(err)
}
if packet.CmdType != wanted {
continue
}
if err := proto.Unmarshal(packet.Body, response); err != nil {
t.Fatal(err)
}
return
}
}
}
func callReviewRequest(t *testing.T, s *Session, request proto.Message) {
t.Helper()
raw, err := proto.Marshal(request)
if err != nil {
t.Fatal(err)
}
handler := typedHandlers[uint16(pb.CmdIDByName[string(request.ProtoReflect().Descriptor().Name())])]
if handler == nil {
t.Fatal("handler missing")
}
if err := handler(s, raw); err != nil {
t.Fatal(err)
}
}
func TestUnsupportedCWMutationsReturnFailureAndPreserveSave(t *testing.T) {
isolatedCWTest(t)
before, _ := os.ReadFile("persistent")
cases := []struct{ req, rsp proto.Message }{
{&pb.GridFightPermanentTalentEnableCsReq{CKHCLKOALKE: ^uint32(0)}, &pb.GridFightPermanentTalentEnableScRsp{}},
{&pb.GridFightSeasonTalentEnableCsReq{CKHCLKOALKE: 1}, &pb.GridFightSeasonTalentEnableScRsp{}},
{&pb.GridFightPermanentTalentResetCsReq{}, &pb.GridFightPermanentTalentResetScRsp{}},
{&pb.GridFightSeasonTalentResetCsReq{}, &pb.GridFightSeasonTalentResetScRsp{}},
{&pb.GridFightTakeWeeklyRewardCsReq{JKCEBHFIENI: []uint32{1, ^uint32(0)}}, &pb.GridFightTakeWeeklyRewardScRsp{}},
{&pb.CycleScoreRewardTakeRewardCsReq{JKCEBHFIENI: []uint32{1}}, &pb.CycleScoreRewardTakeRewardScRsp{}},
}
for _, tc := range cases {
s, receive := replySession(t)
callReviewRequest(t, s, tc.req)
receive(tc.rsp)
view := tc.rsp.ProtoReflect()
fd := view.Descriptor().Fields().ByName("retcode")
if view.Get(fd).Uint() == 0 {
t.Fatalf("unsupported operation succeeded: %s", view.Descriptor().Name())
}
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("unsupported mutation changed save")
}
}
}
func TestCurrencyWarsPositionValidationBothHandlers(t *testing.T) {
c := isolatedCWTest(t)
data, err := newCurrencyWarsData(c)
if err != nil {
t.Fatal(err)
}
var roles []*pb.CDEAABODKCP
seen := map[uint32]bool{}
for _, v := range c.CapturedVariants {
if seen[v.Config.AvatarID] {
continue
}
seen[v.Config.AvatarID] = true
roles = append(roles, &pb.CDEAABODKCP{Id: v.Config.ID, RoleStar: v.Star, UniqueId: uint32(len(roles) + 1), Pos: uint32(len(roles) + 1)})
if len(roles) == 2 {
break
}
}
if len(roles) != 2 {
t.Fatal("fixture needs two avatars")
}
data.DHFOFLFCLAH = &pb.MPIMAEMCEFH{UniqueId: 1, DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID, NAGDJGMMCBE: []*pb.GridFightGameInfo{
{LEOIAOFBBFA: &pb.GridFightGameInfo_EJIPINIKNCK{EJIPINIKNCK: &pb.HKFECLCHBFJ{GridGameRoleList: roles}}},
{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: &pb.NIHNGPKHEPC{AFFCFLFIFPH: 2, MICEOJBAKGN: 1}}},
}}
writer := &Session{}
if err := writer.saveCurrencyWarsData(data, c); err != nil {
t.Fatal(err)
}
for _, batch := range []bool{false, true} {
for _, kind := range []string{"duplicate", "outside", "unknown", "battle", "stale", "swap"} {
s, receive := replySession(t)
s.currencyWarsRevision = writer.currencyWarsRevision
before, _ := os.ReadFile("persistent")
positions := []*pb.MHEGMHKHAAD{{UniqueId: 1, Pos: 2}, {UniqueId: 2, Pos: 1}}
switch kind {
case "duplicate":
positions[1].Pos = 2
case "outside":
positions[0].Pos = ^uint32(0)
case "unknown":
positions[0].UniqueId = ^uint32(0)
case "battle":
CURRENT_BATTLE.Store(s.Token, &pb.SceneBattleInfo{})
case "stale":
s.currencyWarsRevision = 0
}
var code uint32
if batch {
req := &pb.GEBBMDCOLCH{}
for _, p := range positions {
req.CONJCEMAHGN = append(req.CONJCEMAHGN, &pb.EPKFPIDHMOC{MDNAGANGNKK: p.UniqueId, JFCJNHNBMCB: p.Pos})
}
callReviewRequest(t, s, req)
rsp := &pb.ALNFEBKHCFC{}
receive(rsp)
code = rsp.Retcode
} else {
callReviewRequest(t, s, &pb.GridFightUpdatePosCsReq{AAKDHMHCMLP: positions})
rsp := &pb.GridFightUpdatePosScRsp{}
receive(rsp)
code = rsp.Retcode
}
CURRENT_BATTLE.Delete(s.Token)
after, _ := os.ReadFile("persistent")
if kind == "swap" {
if code != 0 {
t.Fatalf("valid swap rejected batch=%t code=%d", batch, code)
}
writer.currencyWarsRevision = s.currencyWarsRevision
} else if code == 0 || !bytes.Equal(before, after) {
t.Fatalf("invalid move changed save or succeeded: batch=%t case=%s code=%d", batch, kind, code)
}
}
}
}
func TestCurrencyWarsUnplacedAndBenchRolesGiveNoTraits(t *testing.T) {
c := isolatedCWTest(t)
var sample common.CurrencyWarsRoleVariant
for _, v := range c.CapturedVariants {
if len(v.Config.TraitList) > 0 {
sample = v
break
}
}
if sample.Config.ID == 0 {
t.Fatal("trait role fixture missing")
}
front, _ := c.IntegerRule("GridFight_Front_AvatarMaxNum")
back, _ := c.IntegerRule("GridFight_Back_AvatarMaxNum")
role := &pb.CDEAABODKCP{Id: sample.Config.ID, RoleStar: sample.Star, UniqueId: 1}
run := &pb.MPIMAEMCEFH{NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_EJIPINIKNCK{EJIPINIKNCK: &pb.HKFECLCHBFJ{GridGameRoleList: []*pb.CDEAABODKCP{role}}}}}}
for _, pos := range []uint32{0, uint32(front + back + 1)} {
role.Pos = pos
if len(cwCalculateBoardTraits(c, run)) != 0 || len(cwCalculateBattleTraits(c, run, nil)) != 0 {
t.Fatalf("off-board role contributes traits: pos=%d", pos)
}
}
role.Pos = 1
if len(cwCalculateBoardTraits(c, run)) == 0 || len(cwCalculateBattleTraits(c, run, nil)) == 0 {
t.Fatal("active role traits missing")
}
}
+6 -44
View File
@@ -194,54 +194,16 @@ func init() {
})
handle("CycleScoreRewardTakeReward", func(s *Session, req *goproto.CycleScoreRewardTakeRewardCsReq, rsp *goproto.CycleScoreRewardTakeRewardScRsp) error {
cfg, err := common.LoadRuntimeConfig()
if err != nil {
return err
}
rsp.Reward = &goproto.ItemList{}
if req != nil {
rsp.NGKOFMBBGNL = append(rsp.NGKOFMBBGNL, req.JKCEBHFIENI...)
}
rsp.NFEDANFBIBE = &goproto.GAAMBBFPCHB{
EndTime: *cfg.ActivitySchedule.EndTime,
}
if cfg.ActivitySchedule.BeginTime != nil {
rsp.NFEDANFBIBE.IFEPJHGNCOI = *cfg.ActivitySchedule.BeginTime
}
if scoreCatalog, err := common.LoadCurrencyWarsScore(nil); err == nil {
rsp.NFEDANFBIBE.PMJGAJGIFOA = scoreCatalog.WeeklyCap
for _, r := range scoreCatalog.WeeklyRanks {
rsp.NFEDANFBIBE.OPLNKCOLCKJ = append(rsp.NFEDANFBIBE.OPLNKCOLCKJ, r.Rank)
}
}
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CYCLE_SCORE_REWARD_TAKE claims=%v", rsp.NGKOFMBBGNL))
// Reward eligibility, grant mapping and atomic claim persistence are not
// verified yet. Never consume a claim or report an empty success.
rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
cwHandle("GridFightTakeWeeklyReward", func(s *Session, req *goproto.GridFightTakeWeeklyRewardCsReq, rsp *goproto.GridFightTakeWeeklyRewardScRsp) error {
cfg, err := common.LoadRuntimeConfig()
if err != nil {
return err
}
rsp.Reward = &goproto.ItemList{}
if req != nil {
rsp.NGKOFMBBGNL = append(rsp.NGKOFMBBGNL, req.JKCEBHFIENI...)
}
rsp.MGBNEAHCDMA = &goproto.NDLNIKCCALK{
EndTime: *cfg.ActivitySchedule.EndTime,
}
if cfg.ActivitySchedule.BeginTime != nil {
rsp.MGBNEAHCDMA.IFEPJHGNCOI = *cfg.ActivitySchedule.BeginTime
}
if scoreCatalog, err := common.LoadCurrencyWarsScore(nil); err == nil {
rsp.MGBNEAHCDMA.EDHKHJAPMKA = scoreCatalog.WeeklyCap
for _, r := range scoreCatalog.WeeklyRanks {
rsp.MGBNEAHCDMA.OPLNKCOLCKJ = append(rsp.MGBNEAHCDMA.OPLNKCOLCKJ, r.Rank)
}
}
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_TAKE_WEEKLY_REWARD claims=%v", rsp.NGKOFMBBGNL))
// Reward eligibility, grant mapping and atomic claim persistence are not
// verified yet. Never consume a claim or report an empty success.
rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS)
return nil
})
+40 -56
View File
@@ -11,15 +11,30 @@ import (
func init() {
handle("GetGachaInfo", func(s *Session, req *pb.GetGachaInfoCsReq, rsp *pb.GetGachaInfoScRsp) error {
banners, _, err := common.LoadBrowsableGachaBanners()
player := s.JSON()
mode := 0
if player != nil {
mode = player.WarpUnlockMode
}
// Read API changes on reopening Warp without requiring a full login.
savedMode, err := common.ReadWarpUnlockMode()
if err != nil {
return err
}
if savedMode != nil {
mode = *savedMode
}
banners, err := common.LoadVisibleGachaBanners(mode)
if err != nil {
return err
}
if len(banners) == 0 {
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
PacketLog("GACHA entry rejected: no verified installed banner assets")
PacketLog("GACHA entry rejected: no visible banners configured")
return nil
}
config, err := common.LoadRuntimeConfig()
if err != nil {
return err
@@ -32,73 +47,42 @@ func init() {
}
rsp.GachaInfoList = append(rsp.GachaInfoList, info)
}
// Synthetic IDs are not native Client config rows. The presentation
// adapter registers its own pools; sending these IDs in GetGachaInfo has
// caused Client response handlers to throw. Keep the preview opt-in.
mapped, _, err := common.LoadLimitedGachaBanners()
randVal, err := rand.Int(rand.Reader, big.NewInt(1000))
if err != nil {
return err
}
_, _, policy, err := common.LoadSandboxDrawPool()
if err != nil {
return err
}
count := policy.MappedBannerPreviewCount
if count > uint32(len(mapped)) {
count = uint32(len(mapped))
}
for i := uint32(0); i < count; i++ {
banner := mapped[i]
info := &pb.GachaInfo{GachaId: banner.GachaID, BeginTime: *config.ActivitySchedule.BeginTime, EndTime: *config.ActivitySchedule.EndTime, GachaCeiling: &pb.GachaCeiling{}}
info.PrizeItemList = []uint32{banner.AvatarID}
info.ItemDetailList = append([]uint32{banner.AvatarID}, banner.FourStarIDs...)
rsp.GachaInfoList = append(rsp.GachaInfoList, info)
}
PacketLog(fmt.Sprintf("GACHA_INFO banners=%d mapped_preview=%d", len(banners), count))
rsp.GachaRandom = 1000 + uint32(randVal.Int64())
rsp.Retcode = 0
PacketLog(fmt.Sprintf("GACHA_INFO count=%d mode=%d", len(rsp.GachaInfoList), mode))
return nil
})
handle("DoGacha", func(s *Session, req *pb.DoGachaCsReq, rsp *pb.DoGachaScRsp) error {
rsp.GachaId = req.GachaId
rsp.GachaNum = req.GachaNum
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_ID_NOT_EXIST)
banners, _, err := common.LoadFeaturedGachaBanners()
if err != nil {
return err
}
var avatarID uint32
for _, row := range banners {
if row.ID == req.GachaId {
avatarID = row.AvatarID
break
}
}
if avatarID == 0 {
mapped, _, err := common.LoadLimitedGachaBanners()
if err != nil {
return err
}
for _, row := range mapped {
if row.GachaID == req.GachaId {
return drawLimitedAvatar(s, req, rsp, row)
}
}
}
if avatarID != 0 {
return drawFeaturedAvatar(s, req, rsp, avatarID)
}
// Featured-only path found nothing. Verified browsable banners may
// still draw from the policy-declared sandbox pool: every pick is a
// duplicate grant on an avatar from the live imported roster, so no
// invented IDs or first-acquisition synthesis are involved.
sandboxDrawn, err := drawSandboxPool(s, req, rsp)
if err != nil {
return err
}
if sandboxDrawn {
if _, active := CURRENT_BATTLE.Load(s.Token); active {
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
return nil
}
if req.GachaNum != 1 && req.GachaNum != 10 {
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NUM_INVALID)
return nil
}
player := s.JSON()
if player == nil {
return nil
}
// The imported banner lists target another Client. Reject draws until
// their pools, grants and protocol fields are verified for this Client.
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
return nil
})
}
// drawFeaturedAvatar grants req.GachaNum copies of one featured avatar using
@@ -0,0 +1,72 @@
package gameserver
import (
"testing"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestGetGachaInfoScRspWireTags(t *testing.T) {
if err := common.LoadBannersConfig(); err != nil {
t.Fatal(err)
}
rsp := &pb.GetGachaInfoScRsp{
Retcode: 0,
GachaRandom: 1234,
}
banners := common.GetVisibleBanners(1)
if len(banners) == 0 {
t.Fatal("expected visible banners")
}
for _, b := range banners {
rsp.GachaInfoList = append(rsp.GachaInfoList, b.ToProtoInfo())
}
bin, err := proto.Marshal(rsp)
if err != nil {
t.Fatalf("failed to marshal GetGachaInfoScRsp: %v", err)
}
// Verify that tag 15 (gacha_info_list) is present:
// Field number 15, wire type 2 (length delimited) -> (15 << 3) | 2 = 122 = 0x7a
tag15Found := false
for _, b := range bin {
if b == 0x7a {
tag15Found = true
break
}
}
if !tag15Found {
t.Errorf("Tag 15 (0x7a) for gacha_info_list was NOT found in serialized payload!")
}
// Unmarshal back to ensure clean round-trip
var decoded pb.GetGachaInfoScRsp
if err := proto.Unmarshal(bin, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(decoded.GachaInfoList) != len(banners) {
t.Fatalf("expected %d banners, got %d", len(banners), len(decoded.GachaInfoList))
}
// Verify banner 1001
var found1001 *pb.GachaInfo
for _, info := range decoded.GachaInfoList {
if info.GachaId == 1001 {
found1001 = info
break
}
}
if found1001 == nil {
t.Fatal("banner 1001 not found in decoded response")
}
if found1001.EndTime == 0 {
t.Errorf("banner 1001 EndTime should not be 0")
}
if found1001.GachaCeiling == nil {
t.Errorf("banner 1001 GachaCeiling should not be nil")
}
}
+2
View File
@@ -179,6 +179,8 @@ const heartbeatLua = `local function beta_text()
end
end
beta_text()
`
func luaQuotedString(value string) string {
+3 -9
View File
@@ -74,7 +74,9 @@ func sceneOfEntryForTeleport(res *common.JsonConfig, entryID, floorID, teleportI
want := fmt.Sprintf("_F%d", floorID)
for _, name := range names {
cfg := configs[name]
if len(name) > len(want) && name[len(name)-len(want):] == want && sceneHasTeleport(cfg, teleportID) {
// The requested floor is authoritative even when its teleport is
// present only in teleports.json. IDs may repeat on other floors.
if len(name) > len(want) && name[len(name)-len(want):] == want {
return name, cfg, true
}
}
@@ -87,14 +89,6 @@ func sceneOfEntryForTeleport(res *common.JsonConfig, entryID, floorID, teleportI
}
}
}
if floorID != 0 {
want := fmt.Sprintf("_F%d", floorID)
for _, name := range names {
if len(name) > len(want) && name[len(name)-len(want):] == want {
return name, configs[name], true
}
}
}
return firstSceneOfEntry(res, entryID)
}
@@ -0,0 +1,23 @@
package gameserver
import (
"gac-hkrpg-go/internal/common"
"testing"
)
func TestSceneOfEntryForTeleportKeepsRequestedFloorWhenPointIsExternal(t *testing.T) {
res := &common.JsonConfig{LevelOutputConfigs: map[uint32]map[string]*common.LevelOutputConfig{
1: {
"P1_F10": {Scenes: map[uint32]common.SceneData{1: {Teleports: map[uint32]common.TeleportInfo{7: {}}}}},
"P1_F20": {Scenes: map[uint32]common.SceneData{}},
},
}}
name, _, ok := sceneOfEntryForTeleport(res, 1, 20, 7)
if !ok || name != "P1_F20" {
t.Fatalf("requested floor lost: %q, %t", name, ok)
}
name, _, ok = sceneOfEntryForTeleport(res, 1, 0, 7)
if !ok || name != "P1_F10" {
t.Fatalf("floor-less lookup failed: %q, %t", name, ok)
}
}
+17 -3
View File
@@ -2,12 +2,15 @@ package gameserver
import (
"fmt"
"gac-hkrpg-go/internal/testutil"
"os"
"path/filepath"
"runtime"
"testing"
)
var testWorkspaceRoot string
func TestMain(m *testing.M) {
_, source, _, ok := runtime.Caller(0)
if !ok {
@@ -15,11 +18,22 @@ func TestMain(m *testing.M) {
os.Exit(2)
}
root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", ".."))
if err := os.Chdir(root); err != nil {
temporary, err := os.MkdirTemp("", "gac-gameserver-tests-")
if err != nil {
panic(err)
}
if err := testutil.CopyRuntime(root, temporary); err != nil {
panic(err)
}
testWorkspaceRoot = temporary
if err := os.Chdir(temporary); err != nil {
fmt.Fprintf(os.Stderr, "cannot change to repository root %s: %v\n", root, err)
os.Exit(2)
}
os.Exit(m.Run())
code := m.Run()
_ = os.Chdir(root)
_ = os.RemoveAll(temporary)
os.Exit(code)
}
func chdirRepositoryRoot(t *testing.T) {
@@ -32,5 +46,5 @@ func chdirRepositoryRoot(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
t.Fatalf("cannot locate repository root from %s: %v", source, err)
}
t.Chdir(root)
t.Chdir(testWorkspaceRoot)
}