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") } }