fix: improve endgame clock and gameplay stability

This commit is contained in:
CAtlas0784 committed 2026-11-04 15:59:48 +07:00
1 parent ce6aabfc55
commit 658298ee86
46 files changed
+1915 -99

No files matched your search

+6 -2
View File
@@ -8,7 +8,7 @@ import (
// Inspect actual battle buffs, rather than ChallengeStageID or a reused stage ID.
// This covers an imported custom battle that already includes Corruption.
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32) error {
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32, attackerSlot *uint32) error {
var corruption uint32
for _, buff := range battle.BuffList {
for _, id := range r.InvasionBuffs {
@@ -57,7 +57,11 @@ func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled boo
}
battle.BuffList = filtered
if support != 0 {
battle.BuffList = append(battle.BuffList, battleBuff(support, owner, map[string]float32{"SkillIndex": 1}))
buff := battleBuff(support, owner, map[string]float32{"SkillIndex": 1})
if attackerSlot != nil {
buff.TargetIndexList = []uint32{*attackerSlot}
}
battle.BuffList = append(battle.BuffList, buff)
}
SceneDebugLog("AHA_HELP stage=%d corruption=%d selected=%v legacy=%t support=%d level=%d", battle.StageId, corruption, selectedValue(selected), legacyEnabled, support, level)
return nil
+134
View File
@@ -0,0 +1,134 @@
package gameserver
import (
"sort"
"testing"
"gac-hkrpg-go/internal/common"
"gac-hkrpg-go/internal/testutil"
pb "gac-hkrpg-go/protocol/proto"
)
func TestAhaBuffTargetsSceneAttacker(t *testing.T) {
temporary := t.TempDir()
if err := testutil.CopyRuntime(testWorkspaceRoot, temporary); err != nil {
t.Fatal(err)
}
t.Chdir(temporary)
t.Setenv("GAC_CONFIG", "server.json")
rules, err := loadBattleRules()
if err != nil {
t.Fatal(err)
}
resources, err := common.GameRes()
if err != nil {
t.Fatal(err)
}
world, err := loadWorldBattleResources()
if err != nil {
t.Fatal(err)
}
levels, err := common.LoadAhaHelpLevels()
if err != nil {
t.Fatal(err)
}
if err := common.SaveAhaHelpLevel(&levels[0].BuffLevel); err != nil {
t.Fatal(err)
}
// Pick valid characters and a battle source from the checked-in resources;
// neither the user's live roster nor their persistent settings are changed.
var ids []uint32
for id, config := range resources.AvatarConfigs {
if id != 8001 && id != 1001 && rules.EntryAttackBuffs[config.DamageType] != 0 {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) < 4 || len(world.Cocoons) == 0 || len(world.NPCMonsters) == 0 {
t.Fatal("battle fixture data incomplete")
}
player := &common.FreesrData{Avatars: map[uint32]*common.AvatarJson{}, Lineups: map[uint32]uint32{}}
for i, id := range ids[:4] {
player.Avatars[id] = &common.AvatarJson{AvatarID: id, Level: 1}
player.Lineups[uint32(i)] = id
}
enabled := true
player.EnableAhaGlobal, player.EnableSWGlobal, player.EnableCastoriceGlobal = &enabled, &enabled, &enabled
var monster, corruption, ahaID uint32
for id := range world.NPCMonsters {
monster = id
break
}
for _, id := range rules.InvasionBuffs {
corruption = id
break
}
for _, rule := range rules.GlobalPassives {
if rule.Setting == "enable_aha_global" {
ahaID = rule.BuffID
}
}
if monster == 0 || corruption == 0 || ahaID == 0 {
t.Fatal("battle fixture rule missing")
}
source := &common.BattleConfig{StageID: world.Cocoons[0].StageID, Monsters: [][]common.Monster{{{MonsterID: monster, Level: 1}}}, Blessings: []common.BattleBuffJson{{ID: corruption, Level: 1}}}
session := &Session{MonsterEntities: map[uint32]uint32{777777: monster}}
session.SetJSON(player)
cases := []struct {
name string
caster, want uint32
}{
{"first slot", 1, 0}, {"second slot", 2, 1}, {"third slot", 3, 2}, {"fourth slot", 4, 3},
{"avatar entity namespace", techniqueAvatarEntityOffset + ids[2], 2},
{"direct entry has no caster", 0, 0}, {"unknown actor", 12345, 0}, {"monster ambush", 777777, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
battle, err := createBattleInfo(session, tc.caster, 0, source)
if err != nil {
t.Fatal(err)
}
var aha, help *pb.BattleBuff
for _, buff := range battle.BuffList {
switch buff.Id {
case ahaID:
aha = buff
case levels[0].BuffID:
help = buff
default:
for _, rule := range rules.GlobalPassives {
if rule.Setting != "enable_aha_global" && buff.Id == rule.BuffID && (len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != 0) {
t.Fatal("unrelated passive target changed")
}
}
}
}
for name, buff := range map[string]*pb.BattleBuff{"Aha Global": aha, "Aha Help": help} {
if buff == nil || len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != tc.want {
t.Fatalf("%s target: got %v, want slot %d", name, buff, tc.want)
}
}
// Source ownership and unrelated corruption are unchanged.
if help.OwnerId != battle.BattleAvatarList[0].Index {
t.Fatal("Aha Help owner changed")
}
})
}
off := uint32(0)
if err := common.SaveAhaHelpLevel(&off); err != nil {
t.Fatal(err)
}
battle, err := createBattleInfo(session, 2, 0, source)
if err != nil {
t.Fatal(err)
}
for _, buff := range battle.BuffList {
if buff.Id == levels[0].BuffID {
t.Fatal("disabled Aha Help was added")
}
}
}
+10 -2
View File
@@ -145,7 +145,7 @@ func (r *battleRules) techniqueBuffs(a *common.AvatarJson, slot, leader uint32,
return result
}
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData) {
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData, attackerSlot *uint32) {
for _, rule := range r.GlobalPassives {
var enabled *bool
switch rule.Setting {
@@ -170,7 +170,15 @@ func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player
if !present {
owner = battle.BattleAvatarList[0].Index
}
battle.BuffList = append(battle.BuffList, battleBuff(rule.BuffID, owner, nil))
buff := battleBuff(rule.BuffID, owner, nil)
// Owner is the configured source of the passive; the target is the
// scene actor who started this battle. Other global passives retain
// their captured target. A request without an identified attacker
// keeps the existing wire representation.
if rule.Setting == "enable_aha_global" && attackerSlot != nil {
buff.TargetIndexList = []uint32{*attackerSlot}
}
battle.BuffList = append(battle.BuffList, buff)
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ func cwArchiveSnapshot(c *common.CurrencyWarsCatalog) (*common.CurrencyWarsSnaps
return common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
}
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC) error {
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC, commandID ...string) error {
if record == nil || s.currencyWarsRevision >= math.MaxUint32 {
return fmt.Errorf("CW archive record/identifier invalid")
}
@@ -30,7 +30,7 @@ func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *co
if err != nil {
return err
}
return s.saveCurrencyWarsSnapshot(data, c, nil, wire)
return s.saveCurrencyWarsSnapshot(data, c, nil, wire, commandID...)
}
func cwReadArchives(snapshot *common.CurrencyWarsSnapshot) ([]*pb.FAIHLAKPABN, error) {
+59 -3
View File
@@ -6,9 +6,6 @@ import (
)
func (s *Session) applyCurrencyWarsCommands() error {
if !s.currencyWarsOpen {
return nil
}
if _, active := CURRENT_BATTLE.Load(s.Token); active {
return nil
}
@@ -23,10 +20,20 @@ func (s *Session) applyCurrencyWarsCommands() error {
if err != nil {
return err
}
// A pending grant can be blocked by a stuck page. Give the explicit quit
// request priority so it can recover that run without applying the grant.
for _, command := range commands {
if command.UID == cfg.PlayerUID && command.Field == common.CurrencyWarsCommandEnd {
return s.applyCurrencyWarsEnd(command)
}
}
for _, command := range commands {
if command.UID != cfg.PlayerUID {
continue
}
if !s.currencyWarsOpen {
continue
}
catalog, data, err := cwPreparingData(s)
if err != nil {
return nil
@@ -62,3 +69,52 @@ func (s *Session) applyCurrencyWarsCommands() error {
}
return nil
}
// End is allowed even with a pending choice so it can recover a stuck CW UI.
// Do not end a battle or replace a newer session's saved data. Queue removal,
// archive and quit settlement share the same persistent CAS transaction.
func (s *Session) applyCurrencyWarsEnd(command common.CurrencyWarsCommand) error {
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 || data.DHFOFLFCLAH.UniqueId != command.RunID {
if err := common.AcknowledgeCurrencyWarsCommand(command.ID); err != nil {
return err
}
PacketLog(fmt.Sprintf("CW_GM discarded end request=%s: run already ended or changed", command.ID))
return nil
}
if revision != s.currencyWarsRevision {
// If this session has not opened CW, it holds no CW UI state. Adopt the
// freshly read revision; the CAS still rejects any concurrent changes.
if s.currencyWarsOpen {
return common.ErrCurrencyWarsSaveConflict
}
s.currencyWarsRevision = revision
}
keep, settle, err := currencyWarsQuitSettlement(catalog, data, data.DHFOFLFCLAH)
if err != nil {
return err
}
data.DHFOFLFCLAH = nil
if err := s.saveCurrencyWarsTerminal(data, catalog, settle.BFLLJJBHLDF, command.ID); err != nil {
return err
}
wasOpen := s.currencyWarsOpen
s.currencyWarsOpen = false
if wasOpen {
if err := cwSend(s, keep); err != nil {
return err
}
if err := cwSend(s, settle); err != nil {
return err
}
}
PacketLog(fmt.Sprintf("CW_GM applied end request=%s run=%d revision=%d result=quit", command.ID, command.RunID, s.currencyWarsRevision))
return nil
}
@@ -0,0 +1,294 @@
package gameserver
import (
"bytes"
"encoding/json"
"errors"
"os"
"testing"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func cwEndFixture(t *testing.T) (*common.CurrencyWarsCatalog, *pb.GridFightGetDataScRsp, *Session, uint32) {
t.Helper()
c := isolatedCWTest(t)
data, err := newCurrencyWarsData(c)
if err != nil {
t.Fatal(err)
}
request := &pb.GridFightStartGamePlayCsReq{DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID}
// Select a division actually present in the captured initial-run resource.
raw, err := common.ReadResourceFile("resources/CurrencyWarsInitialRuns.json")
if err != nil {
t.Fatal(err)
}
var resource struct {
Entries []struct {
Division uint32 `json:"division_id"`
Overclock bool `json:"is_overclock"`
} `json:"entries"`
}
if err := json.Unmarshal(raw, &resource); err != nil || len(resource.Entries) == 0 {
t.Fatalf("initial runs: %v", err)
}
request.DivisionId, request.IsOverlock = resource.Entries[0].Division, resource.Entries[0].Overclock
run, err := buildCurrencyWarsInitialRun(c, request, 7)
if err != nil {
t.Fatal(err)
}
offer := run.FMIGOKIDKJL.GetMFFCPLBNHCF()
if offer == nil || len(offer.NPHOIMEEDHM) == 0 {
t.Fatal("portal offer missing")
}
run, _, err = currencyWarsEntryChoice(c, run, run.FMIGOKIDKJL.QueuePosition, offer.NPHOIMEEDHM[0])
if err != nil {
t.Fatal(err)
}
data.DHFOFLFCLAH = run
s := &Session{Token: 444, currencyWarsOpen: true}
if err := s.saveCurrencyWarsData(data, c); err != nil {
t.Fatal(err)
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
t.Fatal(err)
}
return c, data, s, cfg.PlayerUID
}
func TestCurrencyWarsEndWaitsForBattleAndSettlesPendingRun(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
runID := data.DHFOFLFCLAH.UniqueId
if data.DHFOFLFCLAH.FMIGOKIDKJL == nil {
t.Fatal("fixture must have a pending choice")
}
if err := common.PatchPersistentFields(map[string]json.RawMessage{"ex_ranks_unlocked": json.RawMessage(`{"1204":false}`), "unrelated": json.RawMessage(`{"keep":true}`)}); err != nil {
t.Fatal(err)
}
// An ordinary grant queued first must not prevent ending a stuck page.
if _, err := common.QueueCurrencyWarsCommand(uid, runID, "gold", 99); err != nil {
t.Fatal(err)
}
endID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
if err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile("persistent")
CURRENT_BATTLE.Store(s.Token, &pb.SceneBattleInfo{})
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
CURRENT_BATTLE.Delete(s.Token)
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("end ran while Battle was active")
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH != nil {
t.Fatalf("run not ended: %v", err)
}
snapshot, err := cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
archives, err := cwReadArchives(snapshot)
if err != nil || len(archives) != 1 {
t.Fatalf("archive: %v", err)
}
if archives[0].BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
t.Fatal("end must settle as quit")
}
queued, err := common.ReadCurrencyWarsCommands()
if err != nil {
t.Fatal(err)
}
for _, cmd := range queued {
if cmd.ID == endID {
t.Fatal("end was not atomically acknowledged")
}
}
var root map[string]json.RawMessage
raw, _ := os.ReadFile("persistent")
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
var ex, unrelated map[string]bool
if err := json.Unmarshal(root["ex_ranks_unlocked"], &ex); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(root["unrelated"], &unrelated); err != nil {
t.Fatal(err)
}
if len(ex) != 1 || ex["1204"] || !unrelated["keep"] || len(unrelated) != 1 {
t.Fatal("unrelated state changed")
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
again, err := cwArchiveSnapshot(c)
if err != nil || len(again.Archives) != 1 {
t.Fatal("duplicate settlement")
}
}
func TestCurrencyWarsEndRejectsStaleAndDifferentRun(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
runID := data.DHFOFLFCLAH.UniqueId
cmdID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
if err != nil {
t.Fatal(err)
}
s.currencyWarsRevision--
before, _ := os.ReadFile("persistent")
if err := s.applyCurrencyWarsCommands(); !errors.Is(err, common.ErrCurrencyWarsSaveConflict) {
t.Fatalf("stale open session: %v", err)
}
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("stale session changed save")
}
if err := common.AcknowledgeCurrencyWarsCommand(cmdID); err != nil {
t.Fatal(err)
}
if _, err := common.QueueCurrencyWarsCommand(uid, runID+1, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH == nil || saved.DHFOFLFCLAH.UniqueId != runID {
t.Fatal("wrong-run command ended active run")
}
}
func TestCurrencyWarsEndWorksAfterLeavingCW(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
s.currencyWarsOpen = false
s.currencyWarsRevision = 0 // A newly connected session has not read CW yet.
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH != nil {
t.Fatal("closed CW did not end")
}
}
func TestCurrencyWarsEndNotifiesOpenClient(t *testing.T) {
_, data, savedSession, uid := cwEndFixture(t)
s, receive := replySession(t)
s.currencyWarsRevision = savedSession.currencyWarsRevision
s.currencyWarsOpen = true
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
receive(&pb.GridFightSyncKeepWinCntNotify{})
settlement := &pb.GridFightSettleNotify{}
receive(settlement)
if settlement.BFLLJJBHLDF == nil || settlement.BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
t.Fatal("client did not receive quit settlement")
}
}
func TestCurrencyWarsEndDoesNotCommitIfCommandIsMissing(t *testing.T) {
_, data, s, uid := cwEndFixture(t)
before, _ := os.ReadFile("persistent")
cmd := common.CurrencyWarsCommand{ID: "missing", UID: uid, RunID: data.DHFOFLFCLAH.UniqueId, Field: common.CurrencyWarsCommandEnd}
if err := s.applyCurrencyWarsEnd(cmd); err == nil {
t.Fatal("missing command should reject settlement")
}
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("failed acknowledgement committed settlement or archive")
}
}
func TestCurrencyWarsOpenStateTracksStartLeaveAndResume(t *testing.T) {
_, data, writer, uid := cwEndFixture(t)
previous := data.DHFOFLFCLAH
if _, err := common.QueueCurrencyWarsCommand(uid, previous.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
writer.currencyWarsOpen = false
if err := writer.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
s, receive := replySession(t)
s.currencyWarsRevision = writer.currencyWarsRevision
req := &pb.GridFightStartGamePlayCsReq{DivisionId: previous.DivisionId, IsOverlock: previous.IsOverlock, LJNGPCKPJLF: previous.LJNGPCKPJLF}
callReviewRequest(t, s, req)
start := &pb.GridFightStartGamePlayScRsp{}
receive(start)
if start.Retcode != 0 || start.DHFOFLFCLAH == nil || !s.currencyWarsOpen {
t.Fatal("successful start did not mark CW open")
}
callReviewRequest(t, s, &pb.GridFightQuitLeaveGamePlayCsReq{})
receive(&pb.GridFightQuitLeaveGamePlayScRsp{})
if s.currencyWarsOpen {
t.Fatal("leave did not close CW")
}
callReviewRequest(t, s, &pb.GridFightResumeGamePlayCsReq{UniqueId: start.DHFOFLFCLAH.UniqueId})
resume := &pb.GridFightResumeGamePlayScRsp{}
receive(resume)
if resume.Retcode != 0 || !s.currencyWarsOpen {
t.Fatal("successful resume did not mark CW open")
}
}
func TestCurrencyWarsInitialRoundReservesHigherStarStarter(t *testing.T) {
c, data, _, _ := cwEndFixture(t)
run := data.DHFOFLFCLAH
_, team, shop := cwComponents(run)
if team == nil || shop == nil || len(team.GridGameRoleList) == 0 {
t.Fatal("starter team fixture missing")
}
pool, err := c.NewCapturedCardPool()
if err != nil {
t.Fatal(err)
}
var selected common.CurrencyWarsRoleVariant
for _, v := range c.CapturedVariants {
base, err := c.CapturedRole(v.Config.ID)
if err != nil || v.Star <= base.DefaultStar {
continue
}
if _, present := pool.Roles[v.Config.ID]; present {
selected = v
break
}
}
if selected.Config.ID == 0 {
t.Fatal("higher-star pool role missing")
}
// The live failure occurs after a portal has supplied a non-default star.
team.GridGameRoleList = []*pb.CDEAABODKCP{{Id: selected.Config.ID, RoleStar: selected.Star, UniqueId: 1, Pos: 1}}
if run.FMIGOKIDKJL.GetNNDGEJFLGGF() == nil || len(shop.OGDKJPDFFAI) != 0 {
t.Fatal("fixture is not at initial round")
}
invalid := proto.Clone(run).(*pb.MPIMAEMCEFH)
_, invalidTeam, _ := cwComponents(invalid)
invalidTeam.GridGameRoleList[0].RoleStar = ^uint32(0)
before := proto.Clone(invalid)
if _, err := cwInitialRound(c, invalid); err == nil || !proto.Equal(before, invalid) {
t.Fatal("invalid starter star was accepted or changed state")
}
if _, err := cwInitialRound(c, run); err != nil {
t.Fatalf("higher-star starter rejected: %v", err)
}
if run.FMIGOKIDKJL.GetLGNPHHBGKFH() == nil || len(shop.OGDKJPDFFAI) == 0 {
t.Fatal("initial round did not advance to preparation/shop")
}
}
@@ -79,19 +79,20 @@ func cwInitialRound(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*
}
// Reserve starter copies from the same finite pool before drawing the shop.
for _, role := range team.GridGameRoleList {
r, ok := pool.Roles[role.Id]
if !ok {
if role == nil {
return nil, fmt.Errorf("CW starter role is missing")
}
if _, ok := pool.Roles[role.Id]; !ok {
continue
}
config, e := catalog.CapturedRole(role.Id)
if e != nil {
return nil, e
if _, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar); err != nil {
return nil, fmt.Errorf("CW starter role %d star %d not captured: %w", role.Id, role.RoleStar, err)
}
if role.RoleStar != config.DefaultStar || r.Remaining == 0 {
return nil, fmt.Errorf("CW starter pool reservation is not supported")
// Reserve the number of base copies represented by this star, using
// the same captured merge rules as later shop restocks.
if err := pool.ReserveOwned(catalog, role.Id, role.RoleStar); err != nil {
return nil, fmt.Errorf("CW starter role %d star %d: %w", role.Id, role.RoleStar, err)
}
r.Remaining--
pool.Roles[role.Id] = r
}
level, err := catalog.PlayerLevel(player.EHNJDIDABHD)
if err != nil {
+7 -1
View File
@@ -164,7 +164,11 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
// 1. empty-dummy responses
if rspID, ok := dummyResponses[cmdID]; ok {
_ = s.SendRaw(&NetPacket{CmdType: rspID})
if err := s.SendRaw(&NetPacket{CmdType: rspID}); err != nil {
Log(fmt.Sprintf("[DISPATCH] response %d (%s) failed: %v", rspID, goproto.NameByCmdID[rspID], err))
} else {
Log(fmt.Sprintf("[PACKET] queued empty response cmd_id: %d (%s)", rspID, goproto.NameByCmdID[rspID]))
}
return
}
@@ -172,6 +176,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
if h, ok := rawHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] raw handler " + fmt.Sprint(cmdID) + ": " + err.Error())
Log(fmt.Sprintf("[DISPATCH] raw handler %d failed: %v", cmdID, err))
}
return
}
@@ -180,6 +185,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
if h, ok := typedHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] handler cmd " + fmt.Sprint(cmdID) + ": " + err.Error())
Log(fmt.Sprintf("[DISPATCH] handler %d failed: %v", cmdID, err))
}
return
}
+2
View File
@@ -84,6 +84,7 @@ func (g *Gateway) Listen() error {
if n == 20 {
if err := g.processNetOperation(append([]byte(nil), buf[:n]...), addr); err != nil {
PacketLog("[ERROR] netop: " + err.Error())
Log("[KCP] handshake rejected: " + err.Error())
}
} else if n >= 28 {
g.processKCPPayload(append([]byte(nil), buf[:n]...), addr)
@@ -208,6 +209,7 @@ func (g *Gateway) processKCPPayload(data []byte, addr *net.UDPAddr) {
// same-conv replays from a stale client get rejected by the token check
if err := session.Consume(data); err != nil {
PacketLog("[ERROR] Session consume error (" + addr.String() + "): " + err.Error())
Log("[KCP] packet rejected: " + err.Error())
}
}
+6 -4
View File
@@ -457,9 +457,12 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
// actor from the scene's actual slot IDs, rather than assuming cast_entity
// identifies a lineup position.
attackSlot, attackID := firstAvatarIdx, firstAvatarID
var attackerSlot *uint32
_, attackedByMonster := s.MonsterEntities[casterID]
for _, avatar := range battleInfo.BattleAvatarList {
if casterID != 0 && avatar.Index+1 == casterID {
if !attackedByMonster && casterID != 0 && (avatar.Index+1 == casterID || (casterID >= techniqueAvatarEntityOffset && avatar.Id == casterID-techniqueAvatarEntityOffset)) {
attackSlot, attackID = avatar.Index, avatar.Id
attackerSlot = &attackSlot
break
}
}
@@ -472,7 +475,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
if attackBuffID == 0 {
attackBuffID = cfg.WeaknessBuffID
}
_, attackedByMonster := s.MonsterEntities[casterID]
if attackedByMonster {
// An ambush has no player entry attack.
} else if attackBuffID != 0 {
@@ -550,7 +552,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
rules.addGlobalPassives(battleInfo, player)
rules.addGlobalPassives(battleInfo, player, attackerSlot)
// Official challenges add their resource-defined corruption. Custom battles
// retain only the corruption actually supplied in their buff list.
@@ -563,7 +565,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
}
}
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx); err != nil {
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx, attackerSlot); err != nil {
return nil, err
}
if len(battleInfo.MonsterWaveList) == 0 {
@@ -95,6 +95,7 @@ func init() {
return nil
}
rsp.DHFOFLFCLAH = proto.Clone(run).(*goproto.MPIMAEMCEFH)
s.currencyWarsOpen = true
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_START_GAMEPLAY started mode=%s division=%d season=%d run=%d revision=%d source=captured_initial_preset", mode, req.DivisionId, req.LJNGPCKPJLF, run.UniqueId, s.currencyWarsRevision))
return nil
@@ -159,6 +160,7 @@ func init() {
}
}
}
s.currencyWarsOpen = true
rsp.Retcode = 0
return nil
})
+16 -3
View File
@@ -77,9 +77,22 @@ func init() {
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)
mapped, _, err := common.LoadLimitedGachaBanners()
if err != nil {
return err
}
for _, banner := range mapped {
if banner.GachaID == req.GachaId {
return drawLimitedAvatar(s, req, rsp, banner)
}
}
drawn, err := drawSandboxPool(s, req, rsp)
if err != nil {
return err
}
if !drawn {
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
}
return nil
})
+5 -2
View File
@@ -25,12 +25,15 @@ func TestClientRelicCatalogFiltersUnsupportedInventoryEntries(t *testing.T) {
{RelicID: validID, InternalUID: 1},
{RelicID: 71011, InternalUID: 2},
{RelicID: 63295, InternalUID: 3},
{RelicID: 63296, InternalUID: 4},
{RelicID: 63305, InternalUID: 5},
{RelicID: 63306, InternalUID: 6},
})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Tid != validID || omitted != 2 {
t.Fatalf("filtered Relics = %+v, omitted %d; want only Client-supported ID %d", got, omitted, validID)
if len(got) != 5 || got[0].Tid != validID || omitted != 1 {
t.Fatalf("filtered Relics = %+v, omitted %d; want five Client-supported IDs", got, omitted)
}
}
@@ -0,0 +1,66 @@
package gameserver
import (
"encoding/json"
"os"
"testing"
)
func TestCapturedPlanarRelicAffixesMatchCatalog(t *testing.T) {
chdirRepositoryRoot(t)
raw, err := os.ReadFile("resources/RelicClientCapture.json")
if err != nil {
t.Fatal(err)
}
var capture struct {
Relics []struct{ ID, SetID, MainAffixGroup, SubAffixGroup uint32 } `json:"relics"`
Main []struct{ GroupID, AffixID, Property uint32 } `json:"main_affixes"`
Sub []struct{ GroupID, AffixID, Property uint32 } `json:"sub_affixes"`
}
if err := json.Unmarshal(raw, &capture); err != nil {
t.Fatal(err)
}
if _, err := loadInventoryItems(); err != nil {
t.Fatal(err)
}
if len(capture.Relics) != 4 {
t.Fatalf("captured %d relics", len(capture.Relics))
}
for _, row := range capture.Relics {
var found bool
for _, item := range inventoryCatalog.Relics {
if item.RelicID != row.ID {
continue
}
found = true
if item.RelicSetID != row.SetID {
t.Fatalf("relic %d has wrong set", row.ID)
}
var mainProperty uint32
for _, main := range capture.Main {
if main.GroupID == row.MainAffixGroup && main.AffixID == item.MainAffixID {
mainProperty = main.Property
}
}
if mainProperty == 0 {
t.Fatalf("relic %d main affix is outside its group", row.ID)
}
seen := make(map[uint32]bool)
for _, affix := range item.SubAffixes {
valid := false
for _, sub := range capture.Sub {
if sub.GroupID == row.SubAffixGroup && sub.AffixID == affix.SubAffixID && sub.Property != mainProperty {
valid = true
}
}
if !valid || seen[affix.SubAffixID] {
t.Fatalf("relic %d has invalid or duplicate sub affix %d", row.ID, affix.SubAffixID)
}
seen[affix.SubAffixID] = true
}
}
if !found {
t.Fatalf("relic %d missing from catalog", row.ID)
}
}
}
+20 -4
View File
@@ -35,8 +35,9 @@ type Session struct {
stateMu sync.Mutex // serializes packet handlers and hot reload mutations
json *common.FreesrData
kcpMu sync.Mutex
kcp *kcplib.KCP
kcpMu sync.Mutex
kcp *kcplib.KCP
levelRewardAwaitingAck bool // guarded by kcpMu
startTime time.Time
nextSceneSave int64
@@ -129,6 +130,10 @@ func (s *Session) Consume(data []byte) error {
s.kcpMu.Unlock()
return err
}
if s.levelRewardAwaitingAck && s.kcp.PendingSends() == 0 {
Log("[KCP] outstanding responses including level rewards acknowledged")
s.levelRewardAwaitingAck = false
}
if err := s.kcp.Update(s.sessionTimeMs()); err != nil {
s.kcpMu.Unlock()
return err
@@ -141,9 +146,13 @@ func (s *Session) Consume(data []byte) error {
if err != nil {
break
}
pkt, err := DecodeNetPacket(buf[:n])
// DecodeNetPacket returns slices backed by its input. Keep each frame
// independent before the next Recv overwrites the shared buffer.
frame := append([]byte(nil), buf[:n]...)
pkt, err := DecodeNetPacket(frame)
if err != nil {
PacketLog(fmt.Sprintf("[ERROR] bad packet: %v", err))
Log(fmt.Sprintf("[PACKET] invalid frame: %v", err))
continue
}
packets = append(packets, pkt)
@@ -213,7 +222,14 @@ func (s *Session) SendRaw(pkt *NetPacket) error {
if _, err := s.kcp.Send(payload); err != nil {
return err
}
return s.kcp.Flush()
if err := s.kcp.Flush(); err != nil {
return err
}
if pkt.CmdType == uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]) {
s.levelRewardAwaitingAck = s.kcp.PendingSends() > 0
Log(fmt.Sprintf("[KCP] level rewards response queued; pending segments=%d", s.kcp.PendingSends()))
}
return nil
}
// DriveUpdate is called by the gateway ticker to run retransmissions.
+105
View File
@@ -0,0 +1,105 @@
package gameserver
import (
"bytes"
"encoding/binary"
"testing"
kcplib "gac-hkrpg-go/pkg/kcp"
)
// A single UDP datagram can contain multiple complete KCP messages. Each
// handler must receive its own payload even when the receive buffer is reused.
func TestConsumeKeepsBatchedPacketBodiesIndependent(t *testing.T) {
const firstID, secondID uint16 = 65000, 65001
for _, id := range []uint16{firstID, secondID} {
if _, ok := rawHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := typedHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := dummyResponses[id]; ok {
t.Fatalf("test command %d has dummy response", id)
}
}
got := map[uint16]string{}
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = string(body); return nil }
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = string(body); return nil }
defer delete(rawHandlers, firstID)
defer delete(rawHandlers, secondID)
const conv, token uint32 = 77, 88
segment := func(seq uint32, frame []byte) []byte {
out := make([]byte, kcplib.Overhead+len(frame))
binary.LittleEndian.PutUint32(out[0:], conv)
binary.LittleEndian.PutUint32(out[4:], token)
out[8] = kcplib.CmdPush
binary.LittleEndian.PutUint16(out[10:], 128)
binary.LittleEndian.PutUint32(out[16:], seq)
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
copy(out[kcplib.Overhead:], frame)
return out
}
first := (&NetPacket{CmdType: firstID, Body: []byte("first")}).Encode()
second := (&NetPacket{CmdType: secondID, Body: []byte("other")}).Encode()
datagram := append(segment(0, first), segment(1, second)...)
s := NewSession(func([]byte) error { return nil }, conv, token)
defer s.Close()
if err := s.Consume(datagram); err != nil {
t.Fatal(err)
}
if got[firstID] != "first" || got[secondID] != "other" {
t.Fatalf("batched payloads corrupted: %#v", got)
}
}
func TestConsumeOutOfOrderFragmentsAndFollowingPacket(t *testing.T) {
const firstID, secondID uint16 = 65002, 65003
for _, id := range []uint16{firstID, secondID} {
if _, ok := rawHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := typedHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := dummyResponses[id]; ok {
t.Fatalf("test command %d has dummy response", id)
}
}
got := map[uint16][]byte{}
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = append([]byte(nil), body...); return nil }
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = append([]byte(nil), body...); return nil }
defer delete(rawHandlers, firstID)
defer delete(rawHandlers, secondID)
const conv, token uint32 = 77, 88
segment := func(seq uint32, fragment byte, frame []byte) []byte {
out := make([]byte, kcplib.Overhead+len(frame))
binary.LittleEndian.PutUint32(out[0:], conv)
binary.LittleEndian.PutUint32(out[4:], token)
out[8], out[9] = kcplib.CmdPush, fragment
binary.LittleEndian.PutUint16(out[10:], 128)
binary.LittleEndian.PutUint32(out[16:], seq)
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
copy(out[kcplib.Overhead:], frame)
return out
}
payload := bytes.Repeat([]byte("first-fragment-"), 120)
frame := (&NetPacket{CmdType: firstID, Body: payload}).Encode()
following := (&NetPacket{CmdType: secondID, Body: []byte("next")}).Encode()
s := NewSession(func([]byte) error { return nil }, conv, token)
defer s.Close()
if err := s.Consume(segment(1, 0, frame[800:])); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatal("dispatched an incomplete out-of-order message")
}
combined := append(segment(0, 1, frame[:800]), segment(2, 0, following)...)
if err := s.Consume(combined); err != nil {
t.Fatal(err)
}
if !bytes.Equal(got[firstID], payload) || string(got[secondID]) != "next" {
t.Fatal("fragmented or following packet body was corrupted")
}
}
+75 -8
View File
@@ -2,13 +2,13 @@ package gameserver
import (
"encoding/binary"
"strings"
"sync"
"testing"
"time"
"google.golang.org/protobuf/proto"
"gac-hkrpg-go/internal/common"
kcplib "gac-hkrpg-go/pkg/kcp"
goproto "gac-hkrpg-go/protocol/proto"
)
@@ -36,21 +36,25 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
}
t.Logf("handshake ok: conv=%d token=%d", conv, token)
// 2. configure the SERVER-side session held by the gateway
player, _ := common.LoadFreesrData()
if player == nil {
player = &common.FreesrData{}
}
// 2. Verify the gateway loaded the player data itself.
serverSession := gw.GetSession(conv)
if serverSession == nil {
t.Fatal("gateway did not register the session")
}
serverSession.SetJSON(player)
if serverSession.JSON() == nil {
t.Fatal("gateway session has no player data")
}
// 3. send PlayerGetTokenCsReq (cmd 5)
reqID := uint16(goproto.CmdIDByName["PlayerGetTokenCsReq"])
rspID := uint16(goproto.CmdIDByName["PlayerGetTokenScRsp"])
if err := client.sendCmd(reqID, nil); err != nil {
// Synthetic fields exercise non-empty requests without storing account data.
tokenRequest := &goproto.PlayerGetTokenCsReq{AccountUid: "52022", Token: strings.Repeat("x", 32), Uid: playerUID, Platform: 3}
tokenBody, err := proto.Marshal(tokenRequest)
if err != nil {
t.Fatal(err)
}
if err := client.sendCmd(reqID, tokenBody); err != nil {
t.Fatalf("send cmd: %v", err)
}
@@ -71,6 +75,69 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
}
t.Logf("login token ok: retcode=%d uid=%d msg=%q", rsp.Retcode, rsp.Uid, rsp.Msg)
// 4. Login must also round-trip through the same UDP/KCP session.
loginReq := &goproto.PlayerLoginCsReq{LoginRandom: 123456, Signature: strings.Repeat("x", 768)}
loginBody, err := proto.Marshal(loginReq)
if err != nil {
t.Fatal(err)
}
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginCsReq"]), loginBody); err != nil {
t.Fatalf("send player login: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no player login response: %v", err)
}
if want := uint16(goproto.CmdIDByName["PlayerLoginScRsp"]); cmdID != want {
t.Fatalf("login response command: got %d want %d", cmdID, want)
}
loginRsp := &goproto.PlayerLoginScRsp{}
if err := proto.Unmarshal(body, loginRsp); err != nil {
t.Fatalf("decode login response: %v", err)
}
if loginRsp.Retcode != 0 || loginRsp.LoginRandom != loginReq.LoginRandom || loginRsp.BasicInfo == nil {
t.Fatalf("invalid player login response: %v", loginRsp)
}
// The Android client asks for level rewards before login finish.
if err := client.sendCmd(uint16(goproto.CmdIDByName["GetLevelRewardTakenListCsReq"]), nil); err != nil {
t.Fatalf("send level rewards request: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no level rewards response: %v", err)
}
if want := uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]); cmdID != want {
t.Fatalf("level rewards response command: got %d want %d", cmdID, want)
}
levelRsp := &goproto.GetLevelRewardTakenListScRsp{}
if err := proto.Unmarshal(body, levelRsp); err != nil || levelRsp.Retcode != 0 {
t.Fatalf("invalid level rewards response: %v / %v", levelRsp, err)
}
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginFinishCsReq"]), nil); err != nil {
t.Fatalf("send login finish: %v", err)
}
seenFinish := false
for i := 0; i < 3 && !seenFinish; i++ {
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no login finish response: %v", err)
}
if cmdID == uint16(goproto.CmdIDByName["PlayerLoginFinishScRsp"]) {
finishRsp := &goproto.PlayerLoginFinishScRsp{}
if err := proto.Unmarshal(body, finishRsp); err != nil || finishRsp.Retcode != 0 {
t.Fatalf("invalid login finish response: %v / %v", finishRsp, err)
}
seenFinish = true
} else if cmdID != uint16(goproto.CmdIDByName["ContentPackageSyncDataScNotify"]) {
t.Fatalf("unexpected login finish packet: %d", cmdID)
}
}
if !seenFinish {
t.Fatal("login finish response missing")
}
// 4. exercise the SceneCastSkill fix: attack with no real monsters ->
// must NOT enter battle (no battle_info in the response)
skillReqID := uint16(goproto.CmdIDByName["SceneCastSkillCsReq"])