fix: improve endgame clock and gameplay stability
This commit is contained in:
1 parent
ce6aabfc55
commit
658298ee86
46 files changed
+1915
-99
No files matched your search
@@ -19,6 +19,9 @@ type CurrencyWarsCommand struct {
|
||||
Star uint32 `json:"star,omitempty"`
|
||||
}
|
||||
|
||||
// CurrencyWarsCommandEnd requests the same settlement as quitting the run.
|
||||
const CurrencyWarsCommandEnd = "end"
|
||||
|
||||
func ReadCurrencyWarsCommands() ([]CurrencyWarsCommand, error) {
|
||||
root, err := readCurrencyWarsPersistentRoot()
|
||||
if err != nil {
|
||||
@@ -39,9 +42,12 @@ func QueueCurrencyWarsGrant(uid, runID uint32, field string, value, quantity, st
|
||||
if field == "tb" || field == "trailblaze" {
|
||||
field = "tbpower"
|
||||
}
|
||||
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
|
||||
if uid == 0 || runID == 0 || quantity == 0 || (field != CurrencyWarsCommandEnd && field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
|
||||
return "", fmt.Errorf("invalid CW command")
|
||||
}
|
||||
if field == CurrencyWarsCommandEnd && (value != 0 || quantity != 1 || star != 0) {
|
||||
return "", fmt.Errorf("CW end does not accept arguments")
|
||||
}
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
// Only game-facing calendar time changes. Network timeouts, diagnostics, leases
|
||||
// and battle duration measurement continue to use the host's real clock.
|
||||
type GameClockState struct {
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
WindowBeginMS int64 `json:"window_begin_ms,omitempty"`
|
||||
WindowEndMS int64 `json:"window_end_ms,omitempty"`
|
||||
}
|
||||
|
||||
func gameClockPath() (string, error) {
|
||||
@@ -41,6 +43,9 @@ func ReadGameClock() (GameClockState, error) {
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return state, fmt.Errorf("game clock state: %w", err)
|
||||
}
|
||||
if (state.WindowBeginMS != 0 || state.WindowEndMS != 0) && (state.WindowBeginMS < 0 || state.WindowEndMS <= state.WindowBeginMS || state.WindowEndMS > 253402300799999) {
|
||||
return state, fmt.Errorf("invalid game clock window")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -54,17 +59,18 @@ func GameTime() (time.Time, error) {
|
||||
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
|
||||
}
|
||||
ms := base + s.OffsetMS
|
||||
if s.WindowEndMS != 0 {
|
||||
span := s.WindowEndMS - s.WindowBeginMS
|
||||
delta := (ms - s.WindowBeginMS) % span
|
||||
if delta < 0 {
|
||||
delta += span
|
||||
}
|
||||
ms = s.WindowBeginMS + delta
|
||||
}
|
||||
return time.UnixMilli(ms), nil
|
||||
}
|
||||
|
||||
func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
now := time.Now()
|
||||
state := GameClockState{}
|
||||
if target != nil {
|
||||
@@ -73,32 +79,62 @@ func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
}
|
||||
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
if err := saveGameClockState(state); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
}
|
||||
|
||||
// SetGameTimeWindow keeps the shared game calendar inside an observed Client
|
||||
// interval. It wraps at its end; the host clock and operational timers remain real.
|
||||
func SetGameTimeWindow(begin, end time.Time) (time.Time, error) {
|
||||
start, finish := begin.UnixMilli(), end.UnixMilli()
|
||||
if start < 0 || finish <= start || finish > 253402300799999 {
|
||||
return time.Time{}, fmt.Errorf("invalid game clock window")
|
||||
}
|
||||
now := time.Now()
|
||||
midpoint := start + (finish-start)/2
|
||||
state := GameClockState{OffsetMS: midpoint - now.UnixMilli(), WindowBeginMS: start, WindowEndMS: finish}
|
||||
if err := saveGameClockState(state); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(midpoint), nil
|
||||
}
|
||||
|
||||
func saveGameClockState(state GameClockState) error {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return time.Time{}, err
|
||||
return err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +42,36 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read rewrote state")
|
||||
}
|
||||
begin := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := begin.Add(24 * time.Hour)
|
||||
if _, err := SetGameTimeWindow(begin, end); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
window, err := ReadGameClock()
|
||||
if err != nil || window.WindowBeginMS != begin.UnixMilli() || window.WindowEndMS != end.UnixMilli() {
|
||||
t.Fatalf("window not saved: %+v %v", window, err)
|
||||
}
|
||||
// Simulate the clock having advanced beyond the end without waiting a day.
|
||||
window.OffsetMS = end.Add(time.Hour).UnixMilli() - time.Now().UnixMilli()
|
||||
raw, _ = json.Marshal(window)
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = GameTime()
|
||||
if err != nil || now.Sub(begin.Add(time.Hour)) < 0 || now.Sub(begin.Add(time.Hour)) > time.Second {
|
||||
t.Fatalf("window did not wrap: %v %v", now, err)
|
||||
}
|
||||
before, _ = os.ReadFile(cfg.GameClock.StateFile)
|
||||
if _, err := GameTime(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ = os.ReadFile(cfg.GameClock.StateFile)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("window read rewrote state")
|
||||
}
|
||||
if _, err := SetGameTimeWindow(end, begin); err == nil {
|
||||
t.Fatal("invalid window accepted")
|
||||
}
|
||||
if _, err := SetGameTime(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,6 +79,10 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
if err != nil || time.Since(now).Abs() > time.Second {
|
||||
t.Fatalf("real time not restored: %v %v", now, err)
|
||||
}
|
||||
window, err = ReadGameClock()
|
||||
if err != nil || window.WindowBeginMS != 0 || window.WindowEndMS != 0 {
|
||||
t.Fatalf("reset retained window: %+v %v", window, err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,59 @@ func SaveAvatarRank(id, rank uint32) error {
|
||||
return SaveAvatarProgress(id, nil, &rank)
|
||||
}
|
||||
|
||||
// MaxAvatarProgress updates the latest roster under the shared save lock,
|
||||
// preserving unknown fields and all non-avatar data.
|
||||
func MaxAvatarProgress() (int, error) {
|
||||
persistentWriteMu.Lock()
|
||||
defer persistentWriteMu.Unlock()
|
||||
unlock, err := lockPersistentFile()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
raw, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &root); err != nil || root == nil {
|
||||
return 0, fmt.Errorf("invalid freesr-data object")
|
||||
}
|
||||
var avatars map[string]json.RawMessage
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil || avatars == nil {
|
||||
return 0, fmt.Errorf("invalid avatar roster")
|
||||
}
|
||||
for id, entry := range avatars {
|
||||
var avatar map[string]json.RawMessage
|
||||
if err = json.Unmarshal(entry, &avatar); err != nil || avatar == nil {
|
||||
return 0, fmt.Errorf("invalid avatar %s", id)
|
||||
}
|
||||
var data map[string]json.RawMessage
|
||||
if err = json.Unmarshal(avatar["data"], &data); err != nil || data == nil {
|
||||
return 0, fmt.Errorf("invalid rank data for avatar %s", id)
|
||||
}
|
||||
avatar["level"] = json.RawMessage("80")
|
||||
avatar["promotion"] = json.RawMessage("6")
|
||||
data["rank"] = json.RawMessage("6")
|
||||
avatar["data"], err = json.Marshal(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
avatars[id], err = json.Marshal(avatar)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
root["avatars"], err = json.Marshal(avatars)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = writeJSONAtomic("freesr-data.json", root); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(avatars), nil
|
||||
}
|
||||
|
||||
// SaveAvatarProgress patches only the selected level and/or rank in the latest
|
||||
// import, under the same cross-process lock used by inventory and gacha saves.
|
||||
func SaveAvatarProgress(id uint32, level, rank *uint32) error {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
+32
-31
@@ -835,35 +835,42 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
success = false
|
||||
|
||||
case "/max":
|
||||
for _, av := range data.Avatars {
|
||||
av.Level = 80
|
||||
av.Promotion = 6
|
||||
av.Data.Rank = 6
|
||||
count, err := common.MaxAvatarProgress()
|
||||
if err != nil {
|
||||
output, success = "Cannot update avatars: "+err.Error(), false
|
||||
} else {
|
||||
output = fmt.Sprintf("All %d avatars saved at Level 80, Rank 6. Reconnect the game to load the change.", count)
|
||||
}
|
||||
output = fmt.Sprintf("[OK] All %d avatars set to Level 80, Rank 6.", len(data.Avatars))
|
||||
|
||||
case "/teleport", "/scene":
|
||||
if len(parts) > 1 {
|
||||
planeID, _ := strconv.ParseUint(parts[1], 10, 32)
|
||||
if planeID != 0 {
|
||||
data.Scene.PlaneID = uint32(planeID)
|
||||
output = fmt.Sprintf("[OK] Teleported to PlaneID %d.", planeID)
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
floorID, _ := strconv.ParseUint(parts[2], 10, 32)
|
||||
if floorID != 0 {
|
||||
data.Scene.FloorID = uint32(floorID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
output = "Usage: /teleport <plane_id> [floor_id]"
|
||||
success = false
|
||||
break
|
||||
}
|
||||
planeID, parseErr := strconv.ParseUint(parts[1], 10, 32)
|
||||
if parseErr != nil || planeID == 0 {
|
||||
output, success = "Invalid plane ID.", false
|
||||
break
|
||||
}
|
||||
var floorID uint64
|
||||
if len(parts) == 3 {
|
||||
floorID, parseErr = strconv.ParseUint(parts[2], 10, 32)
|
||||
if parseErr != nil || floorID == 0 {
|
||||
output, success = "Invalid floor ID.", false
|
||||
break
|
||||
}
|
||||
}
|
||||
data.Scene.PlaneID = uint32(planeID)
|
||||
if floorID != 0 {
|
||||
data.Scene.FloorID = uint32(floorID)
|
||||
}
|
||||
output = fmt.Sprintf("Scene saved for plane %d. Reconnect the game to load the change.", planeID)
|
||||
|
||||
case "/clear":
|
||||
if len(parts) > 1 && parts[1] == "lineup" {
|
||||
data.Lineups = make(map[uint32]uint32)
|
||||
output = "[OK] Active lineup cleared."
|
||||
if len(parts) == 2 && strings.EqualFold(parts[1], "lineup") {
|
||||
output = "The active lineup cannot be empty. Change your team in the game."
|
||||
success = false
|
||||
} else {
|
||||
output = "Usage: /clear lineup"
|
||||
success = false
|
||||
@@ -989,8 +996,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "/help":
|
||||
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /clear lineup - Clear active lineup slots\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
|
||||
output = "/faketime [status|off|window <begin Unix seconds> <end Unix seconds>|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
|
||||
|
||||
default:
|
||||
output = fmt.Sprintf("Unknown command: %s", cmd)
|
||||
@@ -1007,14 +1014,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
output = err.Error()
|
||||
}
|
||||
}
|
||||
} else if success && rootCmd != "/help" && rootCmd != "/avatar" {
|
||||
raw, err := json.MarshalIndent(data, "", " ")
|
||||
if err == nil {
|
||||
err = os.WriteFile("freesr-data.json", raw, 0o644)
|
||||
}
|
||||
if err == nil {
|
||||
err = data.SavePersistentChecked()
|
||||
}
|
||||
} else if success && rootCmd != "/help" && rootCmd != "/avatar" && rootCmd != "/max" {
|
||||
err := data.SavePersistentChecked()
|
||||
if err != nil {
|
||||
success = false
|
||||
output = err.Error()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
|
||||
fail := func(err error) { writeJSON(w, map[string]any{"success": false, "output": err.Error()}) }
|
||||
parts := strings.Fields(command)
|
||||
usage := "Usage: /cw status | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
|
||||
usage := "Usage: /cw status | /cw end | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
|
||||
if len(parts) < 2 {
|
||||
fail(fmt.Errorf("%s", usage))
|
||||
return
|
||||
@@ -132,6 +132,19 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
|
||||
return
|
||||
}
|
||||
field := strings.ToLower(parts[1])
|
||||
if field == common.CurrencyWarsCommandEnd {
|
||||
if len(parts) != 2 {
|
||||
fail(fmt.Errorf("Usage: /cw end"))
|
||||
return
|
||||
}
|
||||
id, err := common.QueueCurrencyWarsCommand(cfg.PlayerUID, run.UniqueId, common.CurrencyWarsCommandEnd, 0)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "state": "queued", "request_id": id, "output": fmt.Sprintf("Queued CW end for run %d. The game server will settle it as a quit on heartbeat after any active Battle; this is not a victory. Keep the game connected and check /cw status or CW_GM logs.", run.UniqueId)})
|
||||
return
|
||||
}
|
||||
if field == "tb" || field == "trailblaze" {
|
||||
field = "tbpower"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestCurrencyWarsEndCommandQueuesOnlyActiveRun(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
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)
|
||||
}
|
||||
cfg, err := common.LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &pb.GridFightGetDataScRsp{DHFOFLFCLAH: &pb.MPIMAEMCEFH{UniqueId: 5, DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID, NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: &pb.NIHNGPKHEPC{}}}}}}
|
||||
wire, err := proto.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := common.NewCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256, wire)
|
||||
if _, err := common.CommitCurrencyWarsSnapshot(0, snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invoke := func(command string) map[string]any {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
apiCurrencyWarsCommand(w, command)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if result := invoke("/cw end now"); result["success"] != false {
|
||||
t.Fatal("end accepted extra arguments")
|
||||
}
|
||||
queued, err := common.ReadCurrencyWarsCommands()
|
||||
if err != nil || len(queued) != 0 {
|
||||
t.Fatal("invalid request queued a command")
|
||||
}
|
||||
result := invoke("/cw end")
|
||||
if result["success"] != true || result["state"] != "queued" || !strings.Contains(result["output"].(string), "quit") {
|
||||
t.Fatalf("unexpected response: %v", result)
|
||||
}
|
||||
queued, err = common.ReadCurrencyWarsCommands()
|
||||
if err != nil || len(queued) != 1 || queued[0].UID != cfg.PlayerUID || queued[0].RunID != data.DHFOFLFCLAH.UniqueId || queued[0].Field != common.CurrencyWarsCommandEnd {
|
||||
t.Fatalf("unexpected queue: %v %v", queued, err)
|
||||
}
|
||||
current, err := common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
|
||||
if err != nil || current.Revision != 1 || string(current.WireData) != string(wire) {
|
||||
t.Fatal("API changed active run instead of queuing")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
parts := strings.Fields(cmd)
|
||||
usage := "Usage: /faketime [status | off | <Unix seconds> | <RFC3339 date with timezone>]. Time keeps advancing. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
usage := "Usage: /faketime [status | off | window <begin Unix seconds> <end Unix seconds> | <Unix seconds> | <RFC3339 date with timezone>]. Window mode wraps the game clock at the end; off restores real time. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
var now time.Time
|
||||
var err error
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
|
||||
@@ -21,6 +21,20 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
case "off", "reset", "real":
|
||||
now, err = common.SetGameTime(nil)
|
||||
default:
|
||||
if len(parts) > 1 && strings.EqualFold(parts[1], "window") {
|
||||
if len(parts) != 4 {
|
||||
err = fmt.Errorf("window requires begin and end Unix seconds")
|
||||
} else {
|
||||
begin, e1 := strconv.ParseInt(parts[2], 10, 64)
|
||||
end, e2 := strconv.ParseInt(parts[3], 10, 64)
|
||||
if e1 != nil || e2 != nil || begin < 0 || end > 253402300799 || end <= begin {
|
||||
err = fmt.Errorf("invalid window Unix seconds")
|
||||
} else {
|
||||
now, err = common.SetGameTimeWindow(time.Unix(begin, 0), time.Unix(end, 0))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
var target time.Time
|
||||
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
|
||||
if seconds < 0 || seconds > 253402300799 {
|
||||
@@ -44,5 +58,5 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "output": fmt.Sprintf("Game time: %s (offset %d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, usage)})
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "window_begin_ms": state.WindowBeginMS, "window_end_ms": state.WindowEndMS, "output": fmt.Sprintf("Game time: %s (offset %d ms, window %d..%d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, state.WindowBeginMS, state.WindowEndMS, usage)})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
func TestFakeTimeWindowCommand(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", filepath.Join(t.TempDir(), "clock.json"))
|
||||
call := func(command string) map[string]any {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
apiFakeTimeCommand(w, command)
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
if result := call("/faketime window 1000 2000"); result["success"] != true || result["unix_seconds"] != float64(1500) {
|
||||
t.Fatalf("window command: %v", result)
|
||||
}
|
||||
before, err := common.ReadGameClock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := call("/faketime window 2000 1000"); result["success"] != false {
|
||||
t.Fatalf("invalid window accepted: %v", result)
|
||||
}
|
||||
after, err := common.ReadGameClock()
|
||||
if err != nil || before != after {
|
||||
t.Fatalf("invalid window changed clock: %+v %v", after, err)
|
||||
}
|
||||
if result := call("/faketime off"); result["success"] != true {
|
||||
t.Fatalf("reset: %v", result)
|
||||
}
|
||||
if state, err := common.ReadGameClock(); err != nil || state.WindowEndMS != 0 {
|
||||
t.Fatalf("reset did not remove window: %+v %v", state, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaxCommandPreservesUnknownData(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
raw, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root["future_root"] = json.RawMessage(`{"keep":true}`)
|
||||
var avatars map[string]json.RawMessage
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var avatar map[string]json.RawMessage
|
||||
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatar["future_avatar"] = json.RawMessage(`"preserved"`)
|
||||
avatars["1204"], _ = json.Marshal(avatar)
|
||||
root["avatars"], _ = json.Marshal(avatars)
|
||||
raw, _ = json.Marshal(root)
|
||||
if err = os.WriteFile("freesr-data.json", raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforePersistent, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(`{"command":"/max"}`)))
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil || !result.Success {
|
||||
t.Fatalf("command failed: %s (%v)", w.Body.String(), err)
|
||||
}
|
||||
raw, _ = os.ReadFile("freesr-data.json")
|
||||
if err = json.Unmarshal(raw, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var futureRoot struct {
|
||||
Keep bool `json:"keep"`
|
||||
}
|
||||
if err = json.Unmarshal(root["future_root"], &futureRoot); err != nil || !futureRoot.Keep {
|
||||
t.Fatal("root data lost")
|
||||
}
|
||||
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var data struct {
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
if err = json.Unmarshal(avatar["data"], &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(avatar["future_avatar"]) != `"preserved"` || string(avatar["level"]) != "80" || string(avatar["promotion"]) != "6" || data.Rank != 6 {
|
||||
t.Fatal("avatar fields lost or not updated")
|
||||
}
|
||||
afterPersistent, err := os.ReadFile("persistent")
|
||||
if err != nil || string(beforePersistent) != string(afterPersistent) {
|
||||
t.Fatal("persistent data was rewritten")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSceneCommandsPreserveImportedSave(t *testing.T) {
|
||||
chdirRepoRootSDK(t)
|
||||
before, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := func(command string) bool {
|
||||
body, _ := json.Marshal(map[string]string{"command": command})
|
||||
w := httptest.NewRecorder()
|
||||
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))))
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.Success
|
||||
}
|
||||
persistentBefore, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalid := range []string{"/teleport invalid", "/scene 0", "/teleport 123 bad", "/teleport 123 456 extra", "/clear lineup"} {
|
||||
if run(invalid) {
|
||||
t.Fatalf("accepted %s", invalid)
|
||||
}
|
||||
after, _ := os.ReadFile("persistent")
|
||||
if string(after) != string(persistentBefore) {
|
||||
t.Fatalf("invalid command %s wrote persistent", invalid)
|
||||
}
|
||||
}
|
||||
for _, command := range []string{"/teleport 123 456"} {
|
||||
if !run(command) {
|
||||
t.Fatalf("command failed: %s", command)
|
||||
}
|
||||
after, err := os.ReadFile("freesr-data.json")
|
||||
if err != nil || string(after) != string(before) {
|
||||
t.Fatalf("%s changed imported save", command)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("persistent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved struct {
|
||||
Scene struct {
|
||||
PlaneID uint32 `json:"plane_id"`
|
||||
FloorID uint32 `json:"floor_id"`
|
||||
} `json:"scene"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Scene.PlaneID != 123 || saved.Scene.FloorID != 456 {
|
||||
t.Fatalf("persistent changes missing: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBeforeVerifyReturnsDataObject(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
beforeVerifyReply(w)
|
||||
var body struct {
|
||||
Retcode int `json:"retcode"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || body.Retcode != 0 || body.Data == nil {
|
||||
t.Fatalf("beforeVerify response cannot initialize SDK data: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadHotfixIgnoresEmptyCacheWhenBundledEntryExists(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("hotfix.json", []byte(`{"bundled":{"asset_bundle_url":"bundle"},"override":{"asset_bundle_url":"old"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile("versions.json", []byte(`{"bundled":{},"override":{"asset_bundle_url":"user"},"cached":{"asset_bundle_url":"cached"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := loadHotfix()
|
||||
if got["bundled"].AssetBundleURL != "bundle" || got["override"].AssetBundleURL != "user" || got["cached"].AssetBundleURL != "cached" {
|
||||
t.Fatalf("incorrect hotfix cache merge: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,21 @@ func loadHotfix() map[string]VersionConfig {
|
||||
if raw, err := os.ReadFile("hotfix.json"); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]VersionConfig{}
|
||||
}
|
||||
if raw, err := os.ReadFile("versions.json"); err == nil {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
var cached map[string]VersionConfig
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
for version, cfg := range cached {
|
||||
// A failed fetch previously persisted an empty cache entry. It
|
||||
// must not hide usable bundled metadata for the same version.
|
||||
if cfg == (VersionConfig{}) && out[version] != (VersionConfig{}) {
|
||||
continue
|
||||
}
|
||||
out[version] = cfg
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -201,6 +214,7 @@ func queryGateway(state *appState) http.HandlerFunc {
|
||||
_ = params{}
|
||||
|
||||
cfg := state.getOrInsertHotfix(version, seed)
|
||||
Log(fmt.Sprintf("[gateway] version=%q game=%s:%d asset=%t ex_resource=%t lua=%t", version, state.runtime.PublicGameHost, state.runtime.GamePort, cfg.AssetBundleURL != "", cfg.ExResourceURL != "", cfg.LuaURL != ""))
|
||||
if state.runtime.LuaOverlay.Enabled {
|
||||
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
|
||||
}
|
||||
@@ -305,6 +319,12 @@ func (a *appState) granterLoginReply(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// The Android SDK decodes data as BeforeVerifyEntity before continuing
|
||||
// into the game. An empty object supplies its default false flags.
|
||||
func beforeVerifyReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{"data": map[string]any{}, "message": "OK", "retcode": 0})
|
||||
}
|
||||
|
||||
func riskyCheckReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
@@ -628,6 +648,7 @@ func Listen() error {
|
||||
mux.HandleFunc("/{product_name}/combo/granter/api/getConfig", func(w http.ResponseWriter, _ *http.Request) { comboGetConfigReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/api/compareProtocolVersion", func(w http.ResponseWriter, _ *http.Request) { compareProtocolVersionReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, _ *http.Request) { state.granterLoginReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/login/beforeVerify", func(w http.ResponseWriter, _ *http.Request) { beforeVerifyReply(w) })
|
||||
mux.HandleFunc("/{product_name}/mdk/agreement/api/getAgreementInfos", func(w http.ResponseWriter, _ *http.Request) { agreementInfosReply(w) })
|
||||
|
||||
mux.HandleFunc("/combo/box/api/config/sdk/combo", func(w http.ResponseWriter, _ *http.Request) { comboConfigReply(w) })
|
||||
@@ -664,6 +685,8 @@ func Listen() error {
|
||||
state.loginReply(w, r)
|
||||
case strings.Contains(path, "combo/granter/login/v2/login"):
|
||||
state.granterLoginReply(w)
|
||||
case strings.Contains(path, "combo/granter/login/beforeVerify"):
|
||||
beforeVerifyReply(w)
|
||||
case strings.Contains(path, "mdk/shield/api/loadConfig"):
|
||||
shieldLoadConfigReply(w)
|
||||
case strings.Contains(path, "combo/granter/api/getConfig"):
|
||||
|
||||
Reference in new issue
Block a user