From d04791dd31f382ea3bb2b4fde5aa84de5bf32c87 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 21:27:19 +0800 Subject: [PATCH] feat(server): add colored logs, level rewards, hunting dispatch and fiend hunt Persist claims, AP, teams and presets with session receipts and atomic request rollback. Read rewards and growth from GameData; replace fixed Fiend Hunt user state and preserve the account initialization contract. --- README.md | 2 + go/cmd/bd2server/logging.go | 28 + go/cmd/bd2server/logging_test.go | 35 + go/cmd/bd2server/main.go | 53 +- go/internal/server/account/login.go | 107 ++- .../server/account/reward_state_test.go | 58 ++ go/internal/server/battle/battle.go | 88 +- go/internal/server/battle/hunting_test.go | 67 ++ .../server/battle/monster_hunt_test.go | 72 ++ go/internal/server/feature/feature_test.go | 2 +- go/internal/server/feature/standalone.go | 3 - .../server/gamedata/achievement_level.go | 87 ++ go/internal/server/gamedata/dispatch.go | 255 ++++++ go/internal/server/gamedata/dispatch_test.go | 42 + go/internal/server/gamedata/hunting.go | 122 +++ go/internal/server/gamedata/monster_hunt.go | 274 ++++++ .../server/gamedata/monster_hunt_test.go | 52 ++ go/internal/server/hunting/dispatch.go | 367 ++++++++ go/internal/server/hunting/dispatch_test.go | 122 +++ .../hunting/dispatch_transaction_test.go | 88 ++ go/internal/server/hunting/hunting.go | 433 +++++++++ go/internal/server/hunting/hunting_test.go | 87 ++ .../server/hunting/transaction_test.go | 95 ++ go/internal/server/logging/logging.go | 180 ++++ go/internal/server/logging/logging_test.go | 137 +++ go/internal/server/logging/terminal_unix.go | 10 + .../server/logging/terminal_windows.go | 17 + .../missions/achievement_claim_state.go | 3 + go/internal/server/missions/missions.go | 5 + .../server/missions/user_level_reward.go | 148 +++ .../server/missions/user_level_reward_test.go | 62 ++ go/internal/server/monsterhunt/monsterhunt.go | 845 ++++++++++++++++++ .../server/monsterhunt/monsterhunt_test.go | 186 ++++ go/internal/server/monsterhunt/preset.go | 124 +++ go/internal/server/monsterhunt/rewards.go | 93 ++ .../server/monsterhunt/transaction_test.go | 61 ++ go/internal/server/monsterhunt/validation.go | 37 + .../session/level_reward_transaction_test.go | 98 ++ .../server/session/scoped_handler_test.go | 49 + go/internal/server/session/server.go | 21 +- go/internal/server/stateio/entry_snapshot.go | 17 + .../server/stateio/entry_snapshot_test.go | 59 ++ go/seed/v2_35_10/readonly.json | 41 - 43 files changed, 4670 insertions(+), 62 deletions(-) create mode 100644 go/cmd/bd2server/logging.go create mode 100644 go/cmd/bd2server/logging_test.go create mode 100644 go/internal/server/account/reward_state_test.go create mode 100644 go/internal/server/battle/hunting_test.go create mode 100644 go/internal/server/battle/monster_hunt_test.go create mode 100644 go/internal/server/gamedata/achievement_level.go create mode 100644 go/internal/server/gamedata/dispatch.go create mode 100644 go/internal/server/gamedata/dispatch_test.go create mode 100644 go/internal/server/gamedata/hunting.go create mode 100644 go/internal/server/gamedata/monster_hunt.go create mode 100644 go/internal/server/gamedata/monster_hunt_test.go create mode 100644 go/internal/server/hunting/dispatch.go create mode 100644 go/internal/server/hunting/dispatch_test.go create mode 100644 go/internal/server/hunting/dispatch_transaction_test.go create mode 100644 go/internal/server/hunting/hunting.go create mode 100644 go/internal/server/hunting/hunting_test.go create mode 100644 go/internal/server/hunting/transaction_test.go create mode 100644 go/internal/server/logging/logging.go create mode 100644 go/internal/server/logging/logging_test.go create mode 100644 go/internal/server/logging/terminal_unix.go create mode 100644 go/internal/server/logging/terminal_windows.go create mode 100644 go/internal/server/missions/user_level_reward.go create mode 100644 go/internal/server/missions/user_level_reward_test.go create mode 100644 go/internal/server/monsterhunt/monsterhunt.go create mode 100644 go/internal/server/monsterhunt/monsterhunt_test.go create mode 100644 go/internal/server/monsterhunt/preset.go create mode 100644 go/internal/server/monsterhunt/rewards.go create mode 100644 go/internal/server/monsterhunt/transaction_test.go create mode 100644 go/internal/server/monsterhunt/validation.go create mode 100644 go/internal/server/session/level_reward_transaction_test.go create mode 100644 go/internal/server/session/scoped_handler_test.go create mode 100644 go/internal/server/stateio/entry_snapshot.go create mode 100644 go/internal/server/stateio/entry_snapshot_test.go diff --git a/README.md b/README.md index 3f4d7f8..62b20f0 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows 游戏规则统一使用服务端同目录的 `game.json`,修改后重启生效;开发入口读取仓库根目录的文件。联动 UR 专武是否加入 UR 必得装备券池及升级保留配置的方法见 [服务端游戏规则配置](GAME_CONFIGURATION.md)。 +服务端日志默认输出 INFO 及以上级别,仅终端启用颜色:TRACE 灰、DEBUG 青、INFO 绿、WARN 黄、ERROR 红。可用 `serve --log-level debug --log-color auto` 调整,或设置 `BD2_LOG_LEVEL` 和 `BD2_LOG_COLOR` 环境变量;显式命令行参数优先。级别支持 `trace/debug/info/warn/error`,颜色支持 `auto/always/never`。`auto` 下文件和重定向保持纯文本,`NO_COLOR` 或 `TERM=dumb` 也会关闭颜色;如需在 Docker 日志流中显示颜色,可显式选择 `always`。 + ### 启动客户端 先手动安装 [BepInEx](https://github.com/BepInEx/BepInEx/releases)。然后打开我们的客户端工具 `bd2client.exe`。 diff --git a/go/cmd/bd2server/logging.go b/go/cmd/bd2server/logging.go new file mode 100644 index 0000000..86830e7 --- /dev/null +++ b/go/cmd/bd2server/logging.go @@ -0,0 +1,28 @@ +package main + +import ( + "io" + + "bd2server/internal/server/logging" +) + +func configureLogging(writer io.Writer, levelOverride, colorOverride string) error { + options, err := logging.OptionsFromEnv() + if err != nil { + return err + } + if levelOverride != "" { + options.Level, err = logging.ParseLevel(levelOverride) + if err != nil { + return err + } + } + if colorOverride != "" { + options.Color, err = logging.ParseColorMode(colorOverride) + if err != nil { + return err + } + } + _, err = logging.Setup(writer, options) + return err +} diff --git a/go/cmd/bd2server/logging_test.go b/go/cmd/bd2server/logging_test.go new file mode 100644 index 0000000..cda6612 --- /dev/null +++ b/go/cmd/bd2server/logging_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "bd2server/internal/server/logging" +) + +func TestServeLoggingOverridesEnvironmentAndEnablesTrace(t *testing.T) { + previous := slog.Default() + t.Cleanup(func() { slog.SetDefault(previous) }) + t.Setenv("BD2_LOG_LEVEL", "warn") + t.Setenv("BD2_LOG_COLOR", "never") + var output bytes.Buffer + if err := configureLogging(&output, "trace", "always"); err != nil { + t.Fatal(err) + } + logging.Trace("trace enabled") + slog.Info("existing callers use new handler") + if !strings.Contains(output.String(), "\x1b[90mTRACE\x1b[0m") || !strings.Contains(output.String(), "\x1b[32mINFO\x1b[0m") { + t.Fatalf("log output=%q", output.String()) + } + output.Reset() + if err := configureLogging(&output, "", ""); err != nil { + t.Fatal(err) + } + slog.Info("filtered") + slog.Warn("plain warning") + if strings.Contains(output.String(), "filtered") || strings.Contains(output.String(), "\x1b[") || !strings.Contains(output.String(), "level=WARN") { + t.Fatalf("env log output=%q", output.String()) + } +} diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index f23303c..7292171 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -26,9 +26,11 @@ import ( "bd2server/internal/server/gacha" "bd2server/internal/server/gameconfig" "bd2server/internal/server/gamedata" + "bd2server/internal/server/hunting" "bd2server/internal/server/lifecycle" "bd2server/internal/server/mail" "bd2server/internal/server/missions" + "bd2server/internal/server/monsterhunt" "bd2server/internal/server/pictorial" "bd2server/internal/server/player" "bd2server/internal/server/progress" @@ -37,6 +39,7 @@ import ( "bd2server/internal/server/resourcepolicy" "bd2server/internal/server/schedule" "bd2server/internal/server/session" + "bd2server/internal/server/stateio" "bd2server/internal/server/transport" "bd2server/internal/server/versionconfig" "bd2server/internal/server/wire" @@ -44,6 +47,10 @@ import ( ) func main() { + if err := configureLogging(os.Stderr, "", ""); err != nil { + fmt.Fprintln(os.Stderr, "logging configuration failed:", err) + os.Exit(2) + } if handled, err := runDevelopmentCommand(os.Args[1:]); handled { if err != nil { slog.Error("development command failed", "error", err) @@ -79,6 +86,8 @@ func main() { func serve(args []string) (serveErr error) { fs := flag.NewFlagSet("serve", flag.ContinueOnError) + logLevel := fs.String("log-level", "", "log threshold: trace, debug, info, warn, error (default BD2_LOG_LEVEL or info)") + logColor := fs.String("log-color", "", "level colors: auto, always, never (default BD2_LOG_COLOR or auto)") versionConfigPath := fs.String("version-config", "", "repository versions.json override") authConfigPath := fs.String("authentication-config", "", "authentication.json override for development") resourceConfigPath := fs.String("resource-config", "", "resources.json override for development") @@ -101,6 +110,9 @@ func serve(args []string) (serveErr error) { if err := fs.Parse(args); err != nil { return err } + if err := configureLogging(os.Stderr, *logLevel, *logColor); err != nil { + return err + } var versions versionconfig.Config var err error if *versionConfigPath == "" { @@ -419,6 +431,16 @@ func serve(args []string) (serveErr error) { if err := missionService.AttachWallet(wallet); err != nil { return fmt.Errorf("attach mission wallet: %w", err) } + levelDesign, err := gamedata.LoadAchievementLevelDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load user level rewards: %w", err) + } + if err := missionService.AttachUserLevelRewards(levelDesign); err != nil { + return fmt.Errorf("attach user level rewards: %w", err) + } + if err := login.AttachLevelReward(missionService); err != nil { + return fmt.Errorf("attach persisted user level reward: %w", err) + } if err := missionService.AttachMail(mailService); err != nil { return fmt.Errorf("attach mission compensation mailbox: %w", err) } @@ -624,11 +646,36 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("load master title: %w", err) } battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID) + freeHuntingAP, bonusHuntingAP, err := login.SeedHuntingAP() + if err != nil { + return fmt.Errorf("read initial hunting AP: %w", err) + } + gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"} + huntingService, err := hunting.Open(gameplayStore, gameData, *gameDataVersion, ownedItems, wallet, + worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP) + if err != nil { + return fmt.Errorf("load hunting state: %w", err) + } + if err := login.AttachHuntingAP(huntingService); err != nil { + return fmt.Errorf("attach persisted hunting AP: %w", err) + } + battleService.AttachHunting(huntingService) battleService.AttachCurrentDifficulty(worldService.CurrentQuestDifficulty) if err := worldService.AttachBattleActive(battleService.Active); err != nil { return fmt.Errorf("attach world battle guard: %w", err) } characters := worldService.CharacterService() + monsterHuntService, err := monsterhunt.Open(gameplayStore, gameData, *gameDataVersion, serverConfig, ownedItems, wallet) + if err != nil { + return fmt.Errorf("load monster hunt state: %w", err) + } + if err := monsterHuntService.AttachPresetRuntime(characters, ownedEquipment, collection); err != nil { + return fmt.Errorf("attach monster hunt preset ownership: %w", err) + } + if err := login.AttachMonsterHuntSlots(monsterHuntService); err != nil { + return fmt.Errorf("attach monster hunt preset slots: %w", err) + } + battleService.AttachMonsterHunt(monsterHuntService) recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load recruitment GameData: %w", err) @@ -705,6 +752,8 @@ func serve(args []string) (serveErr error) { } game, err := session.NewServerWithProgress(login, progressState, battleService, + huntingService, + monsterHuntService, worldService, worldService.CharacterService(), progressState, @@ -923,5 +972,7 @@ Usage: bd2server resources fetch --output DIR [--version-config FILE] bd2server state check [options] -The server binds to loopback by default.`+developmentUsage()) +The server binds to loopback by default. +Logging: --log-level trace|debug|info|warn|error; --log-color auto|always|never. +BD2_LOG_LEVEL and BD2_LOG_COLOR set defaults for all commands.`+developmentUsage()) } diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go index 579e435..d15d004 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/account/login.go @@ -29,18 +29,64 @@ var ( // deliberately absent. ResponseFields contains the remaining top-level // protobuf fields (such as client-notification state), never an HTTP envelope. type LoginSeed struct { - Version string - PacketCode int - UserInfo []byte - ResponseFields []byte - currencies CurrencyProvider - purchaseCounts PurchaseCountProvider - presetSlots PresetSlotProvider - inventorySlots InventorySlotProvider - firstGacha FirstGachaProvider - friendshipAP FriendshipAPProvider - lastPlayedPack LastPlayedPackProvider - achievementExp AchievementExperienceProvider + Version string + PacketCode int + UserInfo []byte + ResponseFields []byte + currencies CurrencyProvider + purchaseCounts PurchaseCountProvider + presetSlots PresetSlotProvider + inventorySlots InventorySlotProvider + firstGacha FirstGachaProvider + friendshipAP FriendshipAPProvider + lastPlayedPack LastPlayedPackProvider + achievementExp AchievementExperienceProvider + levelReward LevelRewardProvider + huntingAP HuntingAPProvider + monsterHuntSlots PresetSlotProvider +} + +func (s *LoginSeed) AttachMonsterHuntSlots(provider PresetSlotProvider) error { + if provider == nil { + return errors.New("account: missing monster hunt preset provider") + } + s.monsterHuntSlots = provider + return nil +} + +type HuntingAPProvider interface { + HuntingAP() (free, bonus uint64, err error) +} + +func (s *LoginSeed) AttachHuntingAP(provider HuntingAPProvider) error { + if provider == nil { + return errors.New("account: missing hunting AP provider") + } + s.huntingAP = provider + return nil +} + +func (s *LoginSeed) SeedHuntingAP() (free, bonus uint64, err error) { + if err = s.Validate(); err != nil { + return 0, 0, err + } + if free, _, err = wire.Varint(s.UserInfo, 20); err != nil { + return 0, 0, err + } + bonus, _, err = wire.Varint(s.UserInfo, 21) + return +} + +type LevelRewardProvider interface { + LevelRewardCount() (uint64, error) +} + +func (s *LoginSeed) AttachLevelReward(provider LevelRewardProvider) error { + if provider == nil { + return errors.New("account: missing level reward provider") + } + s.levelReward = provider + return nil } type AchievementExperienceProvider interface { @@ -368,6 +414,43 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { return nil, fmt.Errorf("account: session key: %w", err) } user := append([]byte(nil), s.UserInfo...) + if s.monsterHuntSlots != nil { + count := s.monsterHuntSlots.PresetSlotCount() + if count > math.MaxInt32 { + return nil, errors.New("account: monster hunt preset slots exceed protocol range") + } + var err error + if user, _, err = wire.ReplaceVarint(user, 52, count); err != nil { + return nil, err + } + } + if s.huntingAP != nil { + free, bonus, err := s.huntingAP.HuntingAP() + if err != nil { + return nil, fmt.Errorf("account: hunting AP: %w", err) + } + if free > math.MaxInt32 || bonus > math.MaxInt32 { + return nil, errors.New("account: hunting AP exceeds protocol range") + } + if user, _, err = wire.ReplaceVarint(user, 20, free); err != nil { + return nil, err + } + if user, _, err = wire.ReplaceVarint(user, 21, bonus); err != nil { + return nil, err + } + } + if s.levelReward != nil { + claimed, err := s.levelReward.LevelRewardCount() + if err != nil { + return nil, fmt.Errorf("account: level reward: %w", err) + } + if claimed > math.MaxInt32 { + return nil, errors.New("account: level reward exceeds protocol range") + } + if user, _, err = wire.ReplaceVarint(user, 13, claimed); err != nil { + return nil, err + } + } if s.achievementExp != nil { experience, err := s.achievementExp.AchievementExperience() if err != nil { diff --git a/go/internal/server/account/reward_state_test.go b/go/internal/server/account/reward_state_test.go new file mode 100644 index 0000000..93dcb45 --- /dev/null +++ b/go/internal/server/account/reward_state_test.go @@ -0,0 +1,58 @@ +package account + +import ( + "errors" + "math" + "testing" + + "bd2server/internal/server/wire" +) + +type rewardStateSource struct { + claimed, free, bonus uint64 + err error +} + +func (p *rewardStateSource) LevelRewardCount() (uint64, error) { return p.claimed, p.err } +func (p *rewardStateSource) HuntingAP() (uint64, uint64, error) { return p.free, p.bonus, p.err } + +func TestLoginReadsMutableRewardAndHuntingState(t *testing.T) { + user := wire.AppendVarint(nil, 1, 42) + for _, field := range []int{13, 20, 21} { + user = wire.AppendVarint(user, field, 999) + } + seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user} + p := &rewardStateSource{claimed: 2, free: 40, bonus: 7} + if err := seed.AttachLevelReward(p); err != nil { + t.Fatal(err) + } + if err := seed.AttachHuntingAP(p); err != nil { + t.Fatal(err) + } + for _, state := range []rewardStateSource{*p, {claimed: 3, free: 20, bonus: 0}, {}} { + *p = state + response, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + actual, _, _ := wire.Bytes(response, 1) + for field, want := range map[int]uint64{13: p.claimed, 20: p.free, 21: p.bonus} { + got, _, err := wire.Varint(actual, field) + if err != nil || got != want { + t.Fatalf("field %d=%d want %d: %v", field, got, want, err) + } + } + } + for _, bad := range []rewardStateSource{{claimed: math.MaxInt32 + 1}, {free: math.MaxInt32 + 1}, {bonus: math.MaxInt32 + 1}, {err: errors.New("storage failed")}} { + *p = bad + if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil { + t.Fatal("invalid state accepted") + } + } + if err := seed.AttachLevelReward(nil); err == nil { + t.Fatal("nil reward provider accepted") + } + if err := seed.AttachHuntingAP(nil); err == nil { + t.Fatal("nil AP provider accepted") + } +} diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index 602afee..95a55f4 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -31,6 +31,32 @@ type Service struct { onTutorialWin func() error onMonsterWin func() error commitHealth func(map[uint64]uint64) error + hunting HuntingRuntime + monsterHunt MonsterHuntRuntime +} + +type MonsterHuntRuntime interface { + EnterBattle(request []byte, receipt string) ([]byte, error) + CompleteBattle(request []byte, receipt string) ([]byte, error) +} + +func (s *Service) AttachMonsterHunt(runtime MonsterHuntRuntime) { + s.monsterHunt = runtime +} + +func isMonsterHunt(mode uint64) bool { return mode == 8 || mode == 24 } + +// HuntingRuntime validates the active hunting ground and settles each won +// encounter with the account's persistent AP, monsters and reward ledger. +type HuntingRuntime interface { + ValidateBattle(pack int, mode, monster, deck uint64) error + CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) +} + +const huntingGroundMode = 5 + +func (s *Service) AttachHunting(runtime HuntingRuntime) { + s.hunting = runtime } type battleState struct { @@ -40,6 +66,8 @@ type battleState struct { monster uint64 deck uint64 pack int + mode uint64 + enterReceipt string initialBlue [][]byte phases []gamedata.BattlePhase phase int @@ -187,6 +215,32 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("battle: current pack resolver is unavailable") } monster, _, _ := wire.Varint(request, 3) + var huntResponse []byte + if isMonsterHunt(mode) { + if s.monsterHunt == nil { + return 0, nil, true, errors.New("battle: monster hunt runtime unavailable") + } + if validator, ok := s.monsterHunt.(interface { + ValidatePack(int, []byte) error + }); ok { + if err := validator.ValidatePack(packID, request); err != nil { + return 0, nil, true, err + } + } + seq, _, _ := wire.Varint(request, 1) + huntResponse, err = s.monsterHunt.EnterBattle(request, fmt.Sprintf("%s:%d", s.activeSession, seq)) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: enter monster hunt: %w", err) + } + } + if mode == huntingGroundMode { + if s.hunting == nil { + return 0, nil, true, errors.New("battle: hunting runtime unavailable") + } + if err := s.hunting.ValidateBattle(packID, mode, monster, deck); err != nil { + return 0, nil, true, err + } + } if mode == 1 && s.currentDifficulty != nil { difficulty, resolveErr := s.currentDifficulty() if resolveErr != nil { @@ -202,7 +256,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } var phases []gamedata.BattlePhase - if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) { + if !isMonsterHunt(mode) && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) { loader := s.loadPhases if loader == nil { loader = gamedata.BattleDeckPhases @@ -216,6 +270,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } response := wire.AppendVarint(nil, 2, deck) + response = append(response, huntResponse...) if s.buffs != nil { buffs, err := s.buffs() if err != nil { @@ -234,6 +289,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) response = wire.AppendVarint(response, 6, 1) state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil state.monster, state.deck, state.pack = monster, deck, packID + state.mode = mode + seq, _, _ := wire.Varint(request, 1) + state.enterReceipt = fmt.Sprintf("%s:%d", s.activeSession, seq) state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode) return 52, response, true, nil @@ -314,13 +372,24 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("battle: end before enter") } result, found, err := wire.Varint(request, 2) - if err != nil || !found || result == 0 { + if err != nil || !found || result == 0 || result > 4 { return 0, nil, true, errors.New("battle: invalid result") } if result == 1 && len(state.phases) != 0 && (state.phase != len(state.phases)-1 || !state.phaseStarted) { return 0, nil, true, errors.New("battle: victory before final phase start") } response := wire.AppendVarint(nil, 1, result) + if isMonsterHunt(state.mode) { + // Monster Hunt owns its remaining HP, progression and daily/season + // rewards. Field HP and ordinary pack rewards must not settle here. + extra, err := s.monsterHunt.CompleteBattle(request, state.enterReceipt) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: settle monster hunt: %w", err) + } + response = append(response, extra...) + state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil + return 15, response, true, nil + } var resultCharacters [][]byte finishedHealth := make(map[uint64]uint64) participants := make(map[uint64]bool) @@ -374,7 +443,20 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) response = wire.AppendBytes(response, 3, character) } rewardBundle := false - if result == 1 && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" { + if result == 1 && state.mode == huntingGroundMode { + seq, _, _ := wire.Varint(request, 1) + bundle, monsters, err := s.hunting.CompleteBattle(state.pack, state.mode, state.monster, state.deck, + fmt.Sprintf("%s:%d", s.activeSession, seq)) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: settle hunting encounter: %w", err) + } + for _, monster := range monsters { + response = wire.AppendBytes(response, 4, monster) + } + response = wire.AppendBytes(response, 5, bundle) + rewardBundle = true + } + if result == 1 && state.mode != huntingGroundMode && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" { if state.pack <= 0 { return 0, nil, true, errors.New("battle: victory has no locked pack") } diff --git a/go/internal/server/battle/hunting_test.go b/go/internal/server/battle/hunting_test.go new file mode 100644 index 0000000..fdaf817 --- /dev/null +++ b/go/internal/server/battle/hunting_test.go @@ -0,0 +1,67 @@ +package battle + +import ( + "errors" + "testing" + + "bd2server/internal/server/wire" +) + +type huntingFixture struct { + validationError error + settlementError error + pack int + mode, monster, deck uint64 + receipt string + settlements int +} + +func (h *huntingFixture) ValidateBattle(pack int, mode, monster, deck uint64) error { + h.pack, h.mode, h.monster, h.deck = pack, mode, monster, deck + return h.validationError +} +func (h *huntingFixture) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) { + h.pack, h.mode, h.monster, h.deck, h.receipt = pack, mode, monster, deck, receipt + h.settlements++ + return wire.AppendVarint(nil, 2, 7), [][]byte{wire.AppendVarint(nil, 1, monster)}, h.settlementError +} + +func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) { + pack := 1 + s := NewService("", "", nil, func() (int, error) { return pack, nil }) + h := &huntingFixture{} + s.AttachHunting(h) + s.BeginSession("login-A") + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 11), 4, 12), 5, huntingGroundMode) + h.validationError = errors.New("locked difficulty") + if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() { + t.Fatal("invalid encounter accepted") + } + h.validationError = nil + if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil { + t.Fatal(err) + } + pack = 2 + h.settlementError = errors.New("not enough AP") + end := wire.AppendVarint(request(2), 2, 1) + if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() { + t.Fatal("failed settlement ended battle") + } + h.settlementError = nil + code, response, _, err := s.Handle("/BattleEnd", end) + if err != nil || code != 15 { + t.Fatalf("settle: %d %v", code, err) + } + if h.pack != 1 || h.mode != huntingGroundMode || h.monster != 11 || h.deck != 12 || h.receipt != "login-A:2" { + t.Fatalf("settled wrong encounter: %+v", h) + } + if _, found, _ := wire.Bytes(response, 4); !found { + t.Fatal("missing monster progress") + } + if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 { + t.Fatal("missing hunting reward") + } + if _, _, _, err := s.Handle("/BattleEnd", end); err == nil { + t.Fatal("completed battle settled twice") + } +} diff --git a/go/internal/server/battle/monster_hunt_test.go b/go/internal/server/battle/monster_hunt_test.go new file mode 100644 index 0000000..7203a74 --- /dev/null +++ b/go/internal/server/battle/monster_hunt_test.go @@ -0,0 +1,72 @@ +package battle + +import ( + "errors" + "testing" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +type monsterHuntFixture struct { + enter, end []byte + enterReceipt, endReceipt string + err error +} + +func (h *monsterHuntFixture) EnterBattle(request []byte, receipt string) ([]byte, error) { + h.enter, h.enterReceipt = append([]byte(nil), request...), receipt + return wire.AppendBytes(nil, 5, wire.AppendVarint(nil, 1, 11)), h.err +} +func (h *monsterHuntFixture) CompleteBattle(request []byte, receipt string) ([]byte, error) { + h.end, h.endReceipt = append([]byte(nil), request...), receipt + return wire.AppendBytes(nil, 13, wire.AppendVarint(nil, 1, 11)), h.err +} + +func TestMonsterHuntBattleUsesSpecialRuntimeAndLeavesFieldHealthAlone(t *testing.T) { + for _, mode := range []uint64{8, 24} { + s := NewService("", "", nil, func() (int, error) { return 1, nil }) + h := &monsterHuntFixture{} + s.AttachMonsterHunt(h) + s.BeginSession("login-A") + s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { + t.Fatal("monster hunt used pack phases") + return nil, nil + } + s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("monster hunt changed field HP"); return nil }) + s.AttachMonsterWinMission(func() error { t.Fatal("monster hunt awarded field monster mission"); return nil }) + s.AttachTutorialWin(func() error { t.Fatal("monster hunt awarded tutorial progress"); return nil }) + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 4, 12), 5, mode), 6, 91) + h.err = errors.New("hunt unavailable") + if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() { + t.Fatal("rejected hunt entered") + } + h.err = nil + code, response, _, err := s.Handle("/BattleEnter", enter) + if err != nil || code != 52 { + t.Fatalf("enter: %d %v", code, err) + } + if _, found, _ := wire.Bytes(response, 5); !found { + t.Fatal("missing hunt user info at enter") + } + end := wire.AppendVarint(wire.AppendVarint(request(2), 2, 1), 7, 1234) + h.err = errors.New("invalid hunt settlement") + if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() { + t.Fatal("failed hunt settlement finished battle") + } + h.err = nil + code, response, _, err = s.Handle("/BattleEnd", end) + if err != nil || code != 15 || h.enterReceipt != "login-A:1" || h.endReceipt != h.enterReceipt { + t.Fatalf("end: %d %v %+v", code, err, h) + } + if _, found, _ := wire.Bytes(response, 13); !found { + t.Fatal("missing hunt settlement progress") + } + if _, found, _ := wire.Bytes(response, 5); found { + t.Fatal("hunt used ordinary pack reward bundle") + } + if s.Active() { + t.Fatal("hunt battle still active") + } + } +} diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 22d8556..964f610 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -22,7 +22,7 @@ func TestHandleAuditedEmptyResponses(t *testing.T) { } func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { - for _, path := range []string{"/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} { + for _, path := range []string{"/HuntDispatchInfo", "/HuntingGroundInfo", "/HuntingGroundInfoList", "/HuntingGroundEnter", "/UserLevelReward", "/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} { if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil { t.Fatalf("stateful route %s still has a stateless success fallback", path) } diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index b8dba6e..3f3324f 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -9,14 +9,12 @@ var standaloneDefaults = map[string]int{ "/BalanceVersionCheck": 186, "/ChargeCostInfo": 123, "/EquipInfo": 34, - "/HuntDispatchInfo": 189, "/EventMissionInfo": 127, "/MissionInfo": 118, "/EventRewardHistory": 0, "/PackEventStoryInfo": 220, "/PackEventBattleInfo": 214, "/FishingItemInfo": 459, - "/MonsterHuntDeckInfo": 263, "/Attendance": 0, "/AttendanceInfo": 0, // No PvP decks, battle history or one-time reward claims have been saved. @@ -27,7 +25,6 @@ var standaloneDefaults = map[string]int{ "/PvpBattleOnceRewardInfo": 277, "/TodayQuestInfo": 64, "/FieldDeckInfo": 273, - "/HuntingGroundInfoList": 387, "/FriendRecommend": 211, "/SupporterStatus": 438, "/SupporterBattleInfo": 439, diff --git a/go/internal/server/gamedata/achievement_level.go b/go/internal/server/gamedata/achievement_level.go new file mode 100644 index 0000000..6c062a3 --- /dev/null +++ b/go/internal/server/gamedata/achievement_level.go @@ -0,0 +1,87 @@ +package gamedata + +import ( + "fmt" + "math" + "sort" +) + +type AchievementLevel struct { + ID, NeedEXP uint64 + Rewards []Reward +} +type AchievementLevelDesign struct{ Levels []AchievementLevel } + +func LoadAchievementLevelDesign(root, version string) (*AchievementLevelDesign, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + rows, err := db.Query("SELECT ProtoBuf FROM AchievementLevelTable") + if err != nil { + return nil, err + } + defer rows.Close() + d := &AchievementLevelDesign{} + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, err + } + id, err := optionalScalar(raw, 1) + if err != nil { + return nil, err + } + exp, err := optionalScalar(raw, 2) + if err != nil { + return nil, err + } + rewards, err := parallelRewards(raw, 5, 4, 3) + if err != nil { + return nil, err + } + types, err := packedInts(raw, 5) + if err != nil { + return nil, err + } + if len(rewards) != len(types) { + return nil, fmt.Errorf("gamedata: achievement level reward arrays mismatch") + } + d.Levels = append(d.Levels, AchievementLevel{id, exp, rewards}) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(d.Levels, func(i, j int) bool { return d.Levels[i].ID < d.Levels[j].ID }) + if err := d.Validate(); err != nil { + return nil, err + } + return d, nil +} +func (d *AchievementLevelDesign) Validate() error { + if d == nil || len(d.Levels) == 0 { + return fmt.Errorf("gamedata: empty achievement level design") + } + var previous, total uint64 + for _, l := range d.Levels { + if l.ID <= previous || l.ID > math.MaxInt32 || l.NeedEXP == 0 || l.NeedEXP > math.MaxInt32 || total > math.MaxUint64-l.NeedEXP { + return fmt.Errorf("gamedata: invalid achievement level design") + } + previous = l.ID + total += l.NeedEXP + } + return nil +} + +// Level follows the client: the first cumulative requirement strictly above EXP. +func (d *AchievementLevelDesign) Level(exp uint64) uint64 { + var total uint64 + for _, l := range d.Levels { + total += l.NeedEXP + if total > exp { + return l.ID + } + } + return d.Levels[len(d.Levels)-1].ID +} diff --git a/go/internal/server/gamedata/dispatch.go b/go/internal/server/gamedata/dispatch.go new file mode 100644 index 0000000..3623681 --- /dev/null +++ b/go/internal/server/gamedata/dispatch.go @@ -0,0 +1,255 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "sort" +) + +type DispatchDesign struct { + Group, ID, Pack, Difficulty, TypeGroup, TypeID, AP, ClearTime, Growth uint64 + GroundID uint64 + Rewards []BattleReward + boxes map[uint64]*dispatchGroup +} +type dispatchEntry struct { + Reward BattleReward + Weight uint64 + Child *dispatchGroup +} +type dispatchGroup struct { + Drop, Count uint64 + Entries []dispatchEntry +} + +func LoadDispatchDesign(root, version string, group, id uint64) (*DispatchDesign, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + var raw []byte + if err = db.QueryRow("SELECT ProtoBuf FROM HuntDispatchTable WHERE groupId=? AND id=?", group, id).Scan(&raw); err != nil { + return nil, err + } + d := &DispatchDesign{boxes: map[uint64]*dispatchGroup{}} + for n, p := range map[int]*uint64{1: &d.AP, 4: &d.ClearTime, 5: &d.Difficulty, 7: &d.Group, 8: &d.ID, 10: &d.Pack, 12: &d.Growth, 13: &d.TypeGroup, 14: &d.TypeID} { + *p, err = optionalScalar(raw, n) + if err != nil { + return nil, err + } + } + if d.AP == 0 || d.Pack == 0 || d.Group != group || d.ID != id { + return nil, fmt.Errorf("gamedata: invalid dispatch") + } + pdb, closePack, err := openPackDatabase(root, version, int(d.Pack)) + if err != nil { + return nil, err + } + defer closePack() + var ids []uint64 + var boss uint64 + if d.TypeGroup == 0 { + if err = pdb.QueryRow("SELECT ProtoBuf FROM HuntingGroundTable WHERE difficulty=?", d.Difficulty).Scan(&raw); err != nil { + return nil, err + } + ids, err = packedInts(raw, 5) + boss, _ = optionalScalar(raw, 1) + d.GroundID, _ = optionalScalar(raw, 3) + } else { + if err = db.QueryRow("SELECT ProtoBuf FROM SkyWayFieldTable WHERE groupId=? AND id=?", d.TypeGroup, d.TypeID).Scan(&raw); err != nil { + return nil, err + } + ids, err = packedInts(raw, 15) + boss, _ = optionalScalar(raw, 4) + } + if err != nil { + return nil, err + } + ids = append(ids, boss) + for _, monster := range ids { + if err = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monster).Scan(&raw); err != nil { + return nil, err + } + decks, e := packedInts(raw, 2) + if e != nil || len(decks) == 0 { + return nil, fmt.Errorf("gamedata: dispatch monster has no deck") + } + if err = pdb.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", decks[0]).Scan(&raw); err != nil { + return nil, err + } + types, e := packedInts(raw, 36) + if e != nil { + return nil, e + } + rewardIDs, e := packedInts(raw, 34) + if e != nil { + return nil, e + } + counts, e := packedInts(raw, 33) + if e != nil { + return nil, e + } + if len(types) != len(rewardIDs) || len(types) != len(counts) { + return nil, fmt.Errorf("gamedata: dispatch reward arrays mismatch") + } + rs, e := parallelRewards(raw, 36, 34, 33) + if e != nil { + return nil, e + } + for _, r := range rs { + d.Rewards = append(d.Rewards, BattleReward{r.Type, r.ID, r.Count}) + if r.Type == 9 { + g, e := loadDispatchBox(db, r.ID, map[uint64]bool{}) + if e != nil { + return nil, e + } + d.boxes[r.ID] = g + } + } + } + return d, nil +} +func loadDispatchBox(db *sql.DB, id uint64, seen map[uint64]bool) (*dispatchGroup, error) { + if seen[id] || len(seen) > 32 { + return nil, fmt.Errorf("gamedata: dispatch reward cycle") + } + seen[id] = true + defer delete(seen, id) + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", id).Scan(&raw); err != nil { + return nil, err + } + gid, err := optionalScalar(raw, 9) + if err != nil { + return nil, err + } + if err = db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw); err != nil { + return nil, err + } + g := &dispatchGroup{} + g.Drop, _ = optionalScalar(raw, 2) + g.Count, _ = optionalScalar(raw, 1) + fields := map[int][]uint64{} + for _, n := range []int{4, 5, 6, 8} { + fields[n], err = packedInts(raw, n) + if err != nil { + return nil, err + } + } + if len(fields[5]) == 0 || len(fields[4]) != len(fields[5]) || len(fields[6]) != len(fields[5]) || len(fields[8]) != len(fields[5]) || g.Drop > 1 || (g.Drop == 0 && g.Count == 0) { + return nil, fmt.Errorf("gamedata: malformed dispatch reward group %d", gid) + } + for i, item := range fields[5] { + e := dispatchEntry{Reward: BattleReward{fields[6][i], item, fields[4][i]}, Weight: fields[8][i]} + if e.Reward.Count == 0 { + return nil, fmt.Errorf("gamedata: empty dispatch reward") + } + if e.Reward.Type == 9 { + e.Child, err = loadDispatchBox(db, item, seen) + if err != nil { + return nil, err + } + } + g.Entries = append(g.Entries, e) + } + return g, nil +} + +// Roll uses the local server's random source; GameData supplies every weight and quantity. +func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) ([]BattleReward, error) { + if d == nil || count == 0 || count > 20 { + return nil, fmt.Errorf("gamedata: invalid dispatch count") + } + var budget uint64 = 100000 + var out []BattleReward + var run func(*dispatchGroup) error + emit := func(e dispatchEntry) error { + if e.Child != nil { + for i := uint64(0); i < e.Reward.Count; i++ { + if err := run(e.Child); err != nil { + return err + } + } + } else { + if len(out) >= 100000 { + return fmt.Errorf("gamedata: dispatch reward count exceeds operation limit") + } + out = append(out, e.Reward) + } + return nil + } + run = func(g *dispatchGroup) error { + if g == nil || budget == 0 { + return fmt.Errorf("gamedata: dispatch reward program exceeds operation limit") + } + budget-- + if g.Drop == 1 { + for _, e := range g.Entries { + if err := emit(e); err != nil { + return err + } + } + return nil + } + var sum uint64 + for _, e := range g.Entries { + if e.Weight == 0 || math.MaxUint64-sum < e.Weight { + return fmt.Errorf("gamedata: invalid dispatch weight") + } + sum += e.Weight + } + for i := uint64(0); i < g.Count; i++ { + v, err := draw(sum) + if err != nil { + return err + } + if v >= sum { + return fmt.Errorf("gamedata: random out of range") + } + for _, e := range g.Entries { + if v < e.Weight { + if err := emit(e); err != nil { + return err + } + break + } + v -= e.Weight + } + } + return nil + } + for i := uint64(0); i < count; i++ { + for _, r := range d.Rewards { + if r.Type == 9 { + for n := uint64(0); n < r.Count; n++ { + if err := run(d.boxes[r.ID]); err != nil { + return nil, err + } + } + } else { + out = append(out, r) + } + } + } + aggregate := map[[2]uint64]uint64{} + for _, r := range out { + k := [2]uint64{r.Type, r.ID} + if r.Count > math.MaxInt32-aggregate[k] { + return nil, fmt.Errorf("gamedata: dispatch reward overflow") + } + aggregate[k] += r.Count + } + out = nil + for k, n := range aggregate { + out = append(out, BattleReward{k[0], k[1], n}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Type != out[j].Type { + return out[i].Type < out[j].Type + } + return out[i].ID < out[j].ID + }) + return out, nil +} diff --git a/go/internal/server/gamedata/dispatch_test.go b/go/internal/server/gamedata/dispatch_test.go new file mode 100644 index 0000000..994436c --- /dev/null +++ b/go/internal/server/gamedata/dispatch_test.go @@ -0,0 +1,42 @@ +package gamedata + +import ( + "fmt" + "testing" +) + +func TestDispatchWeightedNestedRewards(t *testing.T) { + leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}} + weighted := &dispatchGroup{Drop: 0, Count: 2, Entries: []dispatchEntry{{Reward: BattleReward{Type: 9, Count: 1}, Weight: 90, Child: leaf}, {Reward: BattleReward{Type: 4, Count: 5}, Weight: 10}}} + d := &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: 7, Count: 1}}, boxes: map[uint64]*dispatchGroup{7: weighted}} + draws := 0 + r, err := d.Roll(2, func(n uint64) (uint64, error) { + if n != 100 { + return 0, fmt.Errorf("limit %d", n) + } + draws++ + if draws%2 == 0 { + return 95, nil + } + return 0, nil + }) + if err != nil { + t.Fatal(err) + } + totals := map[uint64]uint64{} + for _, v := range r { + totals[v.Type] += v.Count + } + if totals[8] != 6 || totals[4] != 10 || draws != 4 { + t.Fatalf("rewards %+v draws %d", r, draws) + } + if _, err = d.Roll(1, func(n uint64) (uint64, error) { return n, nil }); err == nil { + t.Fatal("out of range RNG accepted") + } +} +func TestDispatchRewardOverflow(t *testing.T) { + d := &DispatchDesign{Rewards: []BattleReward{{Type: 4, Count: 2147483647}}} + if _, err := d.Roll(2, nil); err == nil { + t.Fatal("overflow accepted") + } +} diff --git a/go/internal/server/gamedata/hunting.go b/go/internal/server/gamedata/hunting.go new file mode 100644 index 0000000..97f65e4 --- /dev/null +++ b/go/internal/server/gamedata/hunting.go @@ -0,0 +1,122 @@ +package gamedata + +import "fmt" + +type HuntingMonster struct { + ID, Type uint64 + Decks []uint64 + Rewards map[uint64][]BattleReward +} +type HuntingGround struct { + ID, MapID, Difficulty, BossID uint64 + Monsters []uint64 +} +type HuntingPack struct { + Grounds []HuntingGround + Monsters map[uint64]HuntingMonster + NormalAP, BossAP uint64 +} + +func LoadHuntingPack(root, version string, pack int) (*HuntingPack, error) { + db, cleanup, err := openPackDatabase(root, version, pack) + if err != nil { + return nil, err + } + defer cleanup() + common, closeCommon, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeCommon() + d := &HuntingPack{Monsters: map[uint64]HuntingMonster{}} + var defaults []byte + if err := common.QueryRow("SELECT ProtoBuf FROM GameDefaultTable").Scan(&defaults); err != nil { + return nil, err + } + d.BossAP, err = optionalScalar(defaults, 121) + if err != nil { + return nil, err + } + d.NormalAP, err = optionalScalar(defaults, 122) + if err != nil { + return nil, err + } + rows, err := db.Query("SELECT ProtoBuf FROM HuntingGroundTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + rows.Close() + return nil, err + } + g := HuntingGround{} + for field, target := range map[int]*uint64{1: &g.BossID, 2: &g.Difficulty, 3: &g.ID, 4: &g.MapID} { + v, e := optionalScalar(raw, field) + if e != nil { + rows.Close() + return nil, e + } + *target = v + } + g.Monsters, err = packedInts(raw, 5) + if err != nil { + rows.Close() + return nil, err + } + if g.ID == 0 || g.MapID == 0 || g.BossID == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid hunting ground") + } + d.Grounds = append(d.Grounds, g) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + for _, g := range d.Grounds { + ids := append(append([]uint64(nil), g.Monsters...), g.BossID) + for _, id := range ids { + if _, ok := d.Monsters[id]; ok { + continue + } + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", id).Scan(&raw); err != nil { + return nil, err + } + m := HuntingMonster{ID: id, Rewards: map[uint64][]BattleReward{}} + m.Type, err = optionalScalar(raw, 30) + if err != nil { + return nil, err + } + m.Decks, err = packedInts(raw, 2) + if err != nil || len(m.Decks) == 0 { + return nil, fmt.Errorf("gamedata: missing hunting monster deck") + } + for _, deck := range m.Decks { + var b []byte + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deck).Scan(&b); err != nil { + return nil, err + } + r, e := parallelRewards(b, 36, 34, 33) + if e != nil { + return nil, e + } + types, e := packedInts(b, 36) + if e != nil { + return nil, e + } + if len(r) != len(types) { + return nil, fmt.Errorf("gamedata: hunting reward arrays mismatch") + } + for _, reward := range r { + m.Rewards[deck] = append(m.Rewards[deck], BattleReward{reward.Type, reward.ID, reward.Count}) + } + } + d.Monsters[id] = m + } + } + return d, nil +} diff --git a/go/internal/server/gamedata/monster_hunt.go b/go/internal/server/gamedata/monster_hunt.go new file mode 100644 index 0000000..84a4770 --- /dev/null +++ b/go/internal/server/gamedata/monster_hunt.go @@ -0,0 +1,274 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" +) + +type MonsterHuntRewards struct{ Clear, Daily []BattleReward } +type MonsterHuntRankReward struct { + ID, Type uint64 + Ranking float64 + Rewards []BattleReward +} + +// MonsterHunt is the immutable common database design. Hunt decks live in +// common.db, as BattleDeckInfo.GetBattleDeckInfo(id, true) specifies. +type MonsterHunt struct { + ID, PackID, MonsterID, DeckID, RewardGroupID, MaxLevel, RewardLevel, ChallengeableLevel uint64 + TeamOpenLevels []uint64 + Rewards map[uint64]MonsterHuntRewards + Ranks map[uint64][]MonsterHuntRankReward + baseHP, healthRate, healthSlope, stage2Ratio, stage3Ratio float64 + stage2Level, stage3Level uint64 +} + +func LoadMonsterHunt(root, version string, id uint64) (*MonsterHunt, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + return loadMonsterHunt(db, id) +} +func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { + if id == 0 { + return nil, fmt.Errorf("gamedata: zero monster hunt") + } + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM MonsterHuntTable WHERE id=?", id).Scan(&raw); err != nil { + return nil, err + } + d := &MonsterHunt{Rewards: map[uint64]MonsterHuntRewards{}, Ranks: map[uint64][]MonsterHuntRankReward{}} + for n, p := range map[int]*uint64{8: &d.ID, 18: &d.PackID, 16: &d.MonsterID, 1: &d.DeckID, 22: &d.RewardGroupID, 23: &d.RewardLevel, 14: &d.ChallengeableLevel, 26: &d.stage2Level, 28: &d.stage3Level} { + v, e := optionalScalar(raw, n) + if e != nil { + return nil, e + } + *p = v + } + for n, p := range map[int]*float64{11: &d.healthRate, 12: &d.healthSlope, 27: &d.stage2Ratio, 29: &d.stage3Ratio} { + v, _, e := fixed64Double(raw, n) + if e != nil { + return nil, e + } + *p = v + } + var err error + d.TeamOpenLevels, err = packedInts(raw, 32) + if err != nil { + return nil, err + } + group, err := optionalScalar(raw, 19) + if err != nil { + return nil, err + } + rows, err := db.Query("SELECT ProtoBuf FROM MonsterHuntPartsTable WHERE groupId=? ORDER BY id", group) + if err != nil { + return nil, err + } + var body uint64 + for rows.Next() { + var part []byte + if err = rows.Scan(&part); err != nil { + rows.Close() + return nil, err + } + v, e := optionalScalar(part, 7) + if e != nil { + rows.Close() + return nil, e + } + if body == 0 && v > 0 { + body = v + } + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + if body == 0 { + return nil, fmt.Errorf("gamedata: hunt %d missing body", id) + } + var deck []byte + if err = db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", d.DeckID).Scan(&deck); err != nil { + return nil, err + } + chars, err := packedInts(deck, 14) + if err != nil { + return nil, err + } + levels, err := packedInts(deck, 30) + if err != nil { + return nil, err + } + if len(chars) != len(levels) { + return nil, fmt.Errorf("gamedata: hunt deck level arrays differ") + } + level := uint64(1) + for i, c := range chars { + if c == body { + level = levels[i] + break + } + } + stats, err := loadCharacterStatDesign(db) + if err != nil { + return nil, err + } + base, err := stats.BaseStats(body, level) + if err != nil { + return nil, err + } + deckRate, _, err := fixed64Double(deck, 28) + if err != nil { + return nil, err + } + d.baseHP = math.Trunc(base.Health*deckRate*100) / 100 + rows, err = db.Query("SELECT level, ProtoBuf FROM MonsterHuntRewardTable WHERE groupId=? ORDER BY level", d.RewardGroupID) + if err != nil { + return nil, err + } + for rows.Next() { + var lv uint64 + var r []byte + if err = rows.Scan(&lv, &r); err != nil { + rows.Close() + return nil, err + } + clear, e := monsterHuntRewardArrays(r, 8, 7, 6) + if e != nil { + rows.Close() + return nil, e + } + daily, e := monsterHuntRewardArrays(r, 3, 2, 1) + if e != nil { + rows.Close() + return nil, e + } + d.Rewards[lv] = MonsterHuntRewards{clear, daily} + if lv > d.MaxLevel { + d.MaxLevel = lv + } + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM MonsterHuntRankTable ORDER BY groupId,id") + if err != nil { + return nil, err + } + for rows.Next() { + var g uint64 + var r MonsterHuntRankReward + var p []byte + if err = rows.Scan(&g, &r.ID, &p); err != nil { + rows.Close() + return nil, err + } + r.Type, err = optionalScalar(p, 6) + if err != nil { + rows.Close() + return nil, err + } + r.Ranking, _, err = fixed64Double(p, 5) + if err != nil { + rows.Close() + return nil, err + } + r.Rewards, err = monsterHuntRewardArrays(p, 9, 8, 7) + if err != nil { + rows.Close() + return nil, err + } + d.Ranks[g] = append(d.Ranks[g], r) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + if d.ID != id || d.PackID == 0 || d.DeckID == 0 || d.MaxLevel == 0 || d.baseHP <= 0 { + return nil, fmt.Errorf("gamedata: invalid hunt %d design", id) + } + return d, nil +} + +// HP matches MonsterHuntInfo.ApplyMonsterHuntStatRate, including banker +// rounding and truncation to three significant decimal digits. +func (d *MonsterHunt) HP(level uint64) (uint64, error) { + if d == nil || level == 0 || level > d.MaxLevel { + return 0, fmt.Errorf("gamedata: invalid hunt level %d", level) + } + ratio := 1.0 + if level > d.stage2Level { + ratio = d.stage2Ratio + if level > d.stage3Level { + ratio = d.stage3Ratio + } + } + rate := 1 + float64(level-1)*d.healthRate*.01*math.Pow(float64(level), d.healthSlope)*ratio + hp := math.RoundToEven(d.baseHP * rate) + if math.IsNaN(hp) || math.IsInf(hp, 0) || hp < 1 || hp >= float64(math.MaxInt64) { + return 0, fmt.Errorf("gamedata: hunt HP overflow") + } + value := uint64(hp) + scale := uint64(1) + for value/scale >= 1000 { + scale *= 10 + } + return value / scale * scale, nil +} +func monsterHuntRewardArrays(raw []byte, typeField, idField, countField int) ([]BattleReward, error) { + types, e := packedInts(raw, typeField) + if e != nil { + return nil, e + } + ids, e := packedInts(raw, idField) + if e != nil { + return nil, e + } + counts, e := packedInts(raw, countField) + if e != nil { + return nil, e + } + if len(types) != len(ids) || len(ids) != len(counts) { + return nil, fmt.Errorf("gamedata: hunt reward arrays differ") + } + var result []BattleReward + for i, t := range types { + if t > 0 && counts[i] > 0 { + result = append(result, BattleReward{Type: t, ID: ids[i], Count: counts[i]}) + } + } + return result, nil +} + +func LoadMonsterHuntPresetDesign(root, version string) (*PresetDesign, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + d, err := loadPresetDesign(db) + if err != nil { + return nil, err + } + var raw []byte + if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil { + return nil, err + } + d.BaseCount, err = optionalScalar(raw, 84) + if err != nil { + return nil, err + } + d.Maximum, err = optionalScalar(raw, 85) + if err != nil { + return nil, err + } + return d, d.Validate() +} diff --git a/go/internal/server/gamedata/monster_hunt_test.go b/go/internal/server/gamedata/monster_hunt_test.go new file mode 100644 index 0000000..80e84a0 --- /dev/null +++ b/go/internal/server/gamedata/monster_hunt_test.go @@ -0,0 +1,52 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestMonsterHuntHPStageBoundariesAndSignificantDigits(t *testing.T) { + d := &MonsterHunt{MaxLevel: 30, baseHP: 6000, healthRate: 400, healthSlope: 2.6, stage2Level: 12, stage2Ratio: 1.02, stage3Level: 14, stage3Ratio: .86} + // Independently evaluated client formula at the two strict greater-than + // stage boundaries; truncation keeps three significant decimal digits. + for level, want := range map[uint64]uint64{1: 6000, 2: 151000, 12: 168000000, 13: 231000000, 14: 303000000, 15: 330000000} { + got, err := d.HP(level) + if err != nil || got != want { + t.Fatalf("level %d hp=%d err=%v want=%d", level, got, err, want) + } + } + if _, err := d.HP(0); err == nil { + t.Fatal("zero level accepted") + } + if _, err := d.HP(31); err == nil { + t.Fatal("outside design level accepted") + } +} +func TestMonsterHuntInstalledDesign23510(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + d, err := LoadMonsterHunt(root, "20260923193640", 1) + if err != nil { + t.Fatal(err) + } + if d.PackID != 1001 || d.DeckID != 10001 || d.MaxLevel != 30 || len(d.Rewards) != 30 { + t.Fatalf("unexpected installed design %+v", d) + } + hp, err := d.HP(1) + if err != nil || hp != 6600 { + t.Fatalf("base HP %d %v", hp, err) + } + r := d.Rewards[1] + if len(r.Clear) != 1 || r.Clear[0].Type != 8 || r.Clear[0].Count != 5 || len(r.Daily) != 1 || r.Daily[0].Type != 4 || r.Daily[0].Count != 20000 { + t.Fatalf("unexpected rewards %+v", r) + } + p, err := LoadMonsterHuntPresetDesign(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + if p.BaseCount != 5 || p.Maximum != 10 || p.Price != 2000 || p.PriceType != 4 { + t.Fatalf("unexpected hunt presets %+v", p) + } +} diff --git a/go/internal/server/hunting/dispatch.go b/go/internal/server/hunting/dispatch.go new file mode 100644 index 0000000..600cca6 --- /dev/null +++ b/go/internal/server/hunting/dispatch.go @@ -0,0 +1,367 @@ +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +type dispatchReceipt struct { + Request, Response []byte + Code int +} +type dispatchJob struct { + Group, ID, Count, Start, End, Free, Bonus uint64 + Rewards []gamedata.BattleReward + Runs [][]gamedata.BattleReward +} +type dispatchState struct { + Version string `json:"version"` + Jobs map[string]dispatchJob `json:"jobs"` + Receipts map[string]dispatchReceipt `json:"receipts"` +} + +func (s *Service) AttachDispatchEligibility(check func(*gamedata.DispatchDesign) error) { + s.mu.Lock() + defer s.mu.Unlock() + s.dispatchEligibility = check +} +func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + if strings.HasPrefix(path, "/HuntDispatch") { + return s.handleDispatch(path, req, session) + } + return s.Handle(path, req) +} +func (s *Service) handleDispatch(path string, req []byte, session string) (int, []byte, bool, error) { + code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path] + if path != "/HuntDispatchInfo" && path != "/HuntDispatchStart" && path != "/HuntDispatchEnd" && path != "/HuntDispatchRewardPreview" && path != "/HuntDispatch" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return code, nil, true, fmt.Errorf("hunting: missing sequence") + } + ds := dispatchState{Version: versionconfig.State(), Jobs: map[string]dispatchJob{}, Receipts: map[string]dispatchReceipt{}} + raw, err := s.storage.Load("huntdispatch") + if err != nil { + return code, nil, true, err + } + if raw != nil { + if err = stateio.RequireExactJSONObject(raw, "version", "jobs", "receipts"); err != nil { + return code, nil, true, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&ds); err != nil { + return code, nil, true, err + } + if ds.Version != versionconfig.State() || ds.Jobs == nil || ds.Receipts == nil { + return code, nil, true, fmt.Errorf("hunting: invalid dispatch state") + } + } + if path == "/HuntDispatchInfo" { + var out []byte + keys := make([]string, 0, len(ds.Jobs)) + for k := range ds.Jobs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = wire.AppendBytes(out, 1, dispatchJobWire(ds.Jobs[k])) + } + // A client without a remembered dispatch pack needs a default. This + // server selects the smallest cleared ordinary hunting pack. + var minimum uint64 + for key, state := range s.state.Packs { + if state.Highest == 0 { + continue + } + pack, err := strconv.ParseUint(key, 10, 32) + if err != nil || pack == 0 { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch pack") + } + if minimum == 0 || pack < minimum { + minimum = pack + } + } + if minimum != 0 { + out = wire.AppendVarint(out, 2, minimum) + } + return code, out, true, nil + } + for k, j := range ds.Jobs { + if k != fmt.Sprintf("%d:%d", j.Group, j.ID) || j.Group == 0 || j.ID == 0 || j.Count == 0 || j.Count > 20 || j.Start == 0 || j.End < j.Start || len(j.Runs) != int(j.Count) || j.Free > 2147483647 || j.Bonus > 2147483647 { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch job") + } + } + for k, r := range ds.Receipts { + if k == "" || len(r.Request) == 0 || r.Response == nil { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch receipt") + } + } + key := session + ":" + strconv.FormatUint(seq, 10) + if r, ok := ds.Receipts[key]; ok { + if !bytes.Equal(r.Request, append([]byte(path), req...)) { + return code, nil, true, fmt.Errorf("hunting: sequence reused with different dispatch request") + } + return r.Code, r.Response, true, nil + } + group, _, e := wire.Varint(req, 2) + id, _, e2 := wire.Varint(req, 3) + if e != nil || e2 != nil || group == 0 || id == 0 { + return code, nil, true, fmt.Errorf("hunting: invalid dispatch ID") + } + jobKey := fmt.Sprintf("%d:%d", group, id) + job, exists := ds.Jobs[jobKey] + d, err := s.dispatchLoad(group, id) + if err != nil { + return code, nil, true, err + } + var out []byte + if path == "/HuntDispatchRewardPreview" { + if !exists { + return code, nil, true, fmt.Errorf("hunting: dispatch not started") + } + played := dispatchPlayed(job, d.ClearTime) + out = wire.AppendVarint(out, 1, played) + preview := dispatchCompletedRewards(job, played) + remaining := d.AP * (job.Count - played) + bonus := remaining + if bonus > job.Bonus { + bonus = job.Bonus + } + free := remaining - bonus + if free > 0 { + preview = append(preview, gamedata.BattleReward{Type: 21, Count: free}) + } + if bonus > 0 { + preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus}) + } + bundle := dispatchPreview(preview) + for _, r := range preview { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + out = wire.AppendBytes(out, 2, bundle) + return code, out, true, nil + } + if path == "/HuntDispatchEnd" { + if !exists { + return code, nil, true, fmt.Errorf("hunting: dispatch not started") + } + played := dispatchPlayed(job, d.ClearTime) + completed := dispatchCompletedRewards(job, played) + remaining := d.AP * (job.Count - played) + refundBonus := remaining + if refundBonus > job.Bonus { + refundBonus = job.Bonus + } + refundFree := remaining - refundBonus + next := s.clone() + next.Free += refundFree + next.Bonus += refundBonus + if e := s.persist(next); e != nil { + return code, nil, true, e + } + if refundFree > 0 { + completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree}) + } + if refundBonus > 0 { + completed = append(completed, gamedata.BattleReward{Type: 23, Count: refundBonus}) + } + bundle, e := s.dispatchGrant("dispatch:"+key, completed) + if e != nil { + return code, nil, true, e + } + out = wire.AppendBytes(out, 1, bundle) + delete(ds.Jobs, jobKey) + } else { + count, _, e := wire.Varint(req, 4) + if e != nil || count == 0 || count > 20 { + return code, nil, true, fmt.Errorf("hunting: dispatch count must be 1..20") + } + if exists { + return code, nil, true, fmt.Errorf("hunting: dispatch already running") + } + for _, active := range ds.Jobs { + design, e := s.dispatchLoad(active.Group, active.ID) + if e != nil { + return code, nil, true, e + } + if design.TypeGroup == d.TypeGroup { + return code, nil, true, fmt.Errorf("hunting: dispatch category already running") + } + } + if s.dispatchEligibility != nil { + if e = s.dispatchEligibility(d); e != nil { + return code, nil, true, e + } + } else { + if d.TypeGroup != 0 { + return code, nil, true, fmt.Errorf("hunting: SkyWay dispatch eligibility unavailable") + } + st := s.state.Packs[strconv.Itoa(int(d.Pack))] + if st.Highest < d.GroundID { + return code, nil, true, fmt.Errorf("hunting: dispatch requires cleared difficulty") + } + } + if d.AP > uint64(2147483647)/count { + return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow") + } + cost := d.AP * count + if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { + return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP") + } + rewards, e := d.Roll(count, func(n uint64) (uint64, error) { + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + }) + if e != nil { + return code, nil, true, e + } + next := s.clone() + free := cost + if free > next.Free { + free = next.Free + } + bonus := cost - free + next.Free -= free + next.Bonus -= bonus + if path == "/HuntDispatchStart" { + now := uint64(time.Now().UnixMilli()) + runs := make([][]gamedata.BattleReward, count) + for i := range runs { + runs[i], e = d.Roll(1, func(n uint64) (uint64, error) { + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + }) + if e != nil { + return code, nil, true, e + } + } + job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs} + ds.Jobs[jobKey] = job + out = wire.AppendBytes(out, 1, dispatchJobWire(job)) + } else { + bundle, e := s.dispatchGrant("dispatch:"+key, rewards) + if e != nil { + return code, nil, true, e + } + out = wire.AppendBytes(out, 1, bundle) + } + if e = s.persist(next); e != nil { + return code, nil, true, e + } + } + ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code} + b, e := json.Marshal(ds) + if e != nil { + return code, nil, true, e + } + if e = s.storage.Save("huntdispatch", b); e != nil { + return code, nil, true, e + } + return code, out, true, nil +} +func dispatchPlayed(j dispatchJob, seconds uint64) uint64 { + if seconds == 0 { + return j.Count + } + now := uint64(time.Now().UnixMilli()) + if now <= j.Start { + return 0 + } + n := (now - j.Start) / (seconds * 1000) + if n > j.Count { + n = j.Count + } + return n +} +func dispatchJobWire(j dispatchJob) []byte { + var out []byte + for n, v := range map[int]uint64{1: j.Group, 2: j.ID, 3: j.Count, 4: j.Start, 5: j.End, 6: j.Free, 7: j.Bonus} { + out = wire.AppendVarint(out, n, v) + } + return out +} +func dispatchPreview(rs []gamedata.BattleReward) []byte { + var out []byte + for _, r := range rs { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 6, v) + } + return out +} +func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([]byte, error) { + var currency []gamedata.Reward + var items []gamedata.BattleReward + for _, r := range rs { + switch r.Type { + case 21, 23: + continue + case 2, 3, 4, 12, 20: + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + default: + items = append(items, r) + } + } + if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { + return nil, err + } + granted, err := s.inventory.GrantOnce(identity+":items", items) + if err != nil { + return nil, err + } + if len(granted) == 0 { + granted = s.inventory.GrantedItems(identity + ":items") + } + out := dispatchPreview(rs) + for _, r := range rs { + if r.Type == 21 || r.Type == 23 { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 1, v) + } + } + for _, r := range currency { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 1, v) + } + for _, item := range granted { + out = wire.AppendBytes(out, 1, player.ItemWire(item)) + } + return out, nil +} + +func dispatchCompletedRewards(j dispatchJob, n uint64) []gamedata.BattleReward { + var out []gamedata.BattleReward + for i := uint64(0); i < n && i < uint64(len(j.Runs)); i++ { + out = append(out, j.Runs[i]...) + } + return out +} diff --git a/go/internal/server/hunting/dispatch_test.go b/go/internal/server/hunting/dispatch_test.go new file mode 100644 index 0000000..f79fb9e --- /dev/null +++ b/go/internal/server/hunting/dispatch_test.go @@ -0,0 +1,122 @@ +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +func TestDispatchSweepReplayAndValidation(t *testing.T) { + store := stateio.NewMemory() + inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(store, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 100, 10) + if err != nil { + t.Fatal(err) + } + s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { + return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}, {Type: 8, ID: 1000, Count: 1}}}, nil + } + s.state.Packs["1"] = packState{Highest: 1} + req := wire.AppendVarint(nil, 1, 1) + req = wire.AppendVarint(req, 2, 1) + req = wire.AppendVarint(req, 3, 1) + req = wire.AppendVarint(req, 4, 2) + _, a, _, err := s.HandleSession("/HuntDispatch", req, "s1") + if err != nil { + t.Fatal(err) + } + _, b, _, err := s.HandleSession("/HuntDispatch", req, "s1") + if err != nil || !bytes.Equal(a, b) { + t.Fatalf("replay %v", err) + } + free, bonus, _ := s.HuntingAP() + if free != 88 || bonus != 10 || wallet.Snapshot().Gold != 20 { + t.Fatalf("AP/currency %d/%d %+v", free, bonus, wallet.Snapshot()) + } + req = wire.AppendVarint(req, 4, 3) + if _, _, _, err = s.HandleSession("/HuntDispatch", req, "s1"); err == nil { + t.Fatal("sequence conflict accepted") + } + s.state.Packs["1"] = packState{} + if _, _, _, err = s.HandleSession("/HuntDispatch", req, "s2"); err == nil { + t.Fatal("uncleared ground accepted") + } +} +func TestDispatchCancelRefundsUnplayedAP(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 10) + s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { + return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}}}, nil + } + s.state.Packs["1"] = packState{Highest: 1} + req := wire.AppendVarint(nil, 1, 1) + req = wire.AppendVarint(req, 2, 1) + req = wire.AppendVarint(req, 3, 1) + req = wire.AppendVarint(req, 4, 2) + if _, _, _, e := s.HandleSession("/HuntDispatchStart", req, "session"); e != nil { + t.Fatal(e) + } + end := wire.AppendVarint(nil, 1, 2) + end = wire.AppendVarint(end, 2, 1) + end = wire.AppendVarint(end, 3, 1) + if _, _, _, e := s.HandleSession("/HuntDispatchEnd", end, "session"); e != nil { + t.Fatal(e) + } + free, bonus, _ := s.HuntingAP() + if free != 10 || bonus != 10 || wallet.Snapshot().Gold != 0 { + t.Fatalf("cancel %d %d %+v", free, bonus, wallet.Snapshot()) + } +} + +func TestDispatchPreviewHasAPItemInfo(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 10) + s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { + return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}}}, nil + } + s.state.Packs["1"] = packState{Highest: 1} + req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1), 4, 2) + if _, _, _, e := s.HandleSession("/HuntDispatchStart", req, "session"); e != nil { + t.Fatal(e) + } + preview := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1), 3, 1) + _, out, _, e := s.HandleSession("/HuntDispatchRewardPreview", preview, "session") + if e != nil { + t.Fatal(e) + } + totals := map[uint64]uint64{} + wire.Walk(out, func(f wire.Field) error { + if f.Number == 2 { + wire.Walk(f.Value, func(item wire.Field) error { + if item.Number == 1 { + typ, _, _ := wire.Varint(item.Value, 3) + n, _, _ := wire.Varint(item.Value, 4) + totals[typ] += n + } + return nil + }) + } + return nil + }) + if totals[21] != 10 || totals[23] != 2 || totals[4] != 0 { + t.Fatalf("preview refund ItemInfo %+v", totals) + } + free, bonus, _ := s.HuntingAP() + if free != 0 || bonus != 8 { + t.Fatal("preview changed AP") + } +} diff --git a/go/internal/server/hunting/dispatch_transaction_test.go b/go/internal/server/hunting/dispatch_transaction_test.go new file mode 100644 index 0000000..d602df4 --- /dev/null +++ b/go/internal/server/hunting/dispatch_transaction_test.go @@ -0,0 +1,88 @@ +package hunting + +import ( + "errors" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type failedDispatchStore struct{ stateio.Store } + +func (s failedDispatchStore) Save(name string, payload []byte) error { + if name == "huntdispatch" { + return errors.New("injected dispatch receipt write failure") + } + return s.Store.Save(name, payload) +} + +func TestDispatchReceiptFailureRollsBackAPAndRewards(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) + if err != nil { + t.Fatal(err) + } + if err = inv.EnsurePersisted(); err != nil { + t.Fatal(err) + } + if err = wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + s, err := Open(failedDispatchStore{repo}, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 4) + if err != nil { + t.Fatal(err) + } + s.state.Packs["1"] = packState{Highest: 1} + s.dispatchLoad = func(group, id uint64) (*gamedata.DispatchDesign, error) { + return &gamedata.DispatchDesign{Group: group, ID: id, Pack: 1, GroundID: 1, AP: 2, Rewards: []gamedata.BattleReward{{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}, nil + } + operation, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1), 4, 2) + if _, _, _, err = s.HandleSession("/HuntDispatch", req, "session-A"); err == nil { + _ = operation.Rollback() + t.Fatal("receipt failure accepted") + } + _ = operation.Rollback() + if err = repo.Close(); err != nil { + t.Fatal(err) + } + reopened, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + for _, name := range []string{"hunting", "huntdispatch"} { + raw, err := reopened.Load(name) + if err != nil || raw != nil { + t.Fatalf("%s survived rollback: %s %v", name, raw, err) + } + } + inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err = player.OpenWallet(reopened, player.Currency{}) + if err != nil { + t.Fatal(err) + } + if len(inv.All()) != 0 || wallet.Snapshot().Gold != 12 { + t.Fatalf("dispatch rewards survived rollback: %+v %+v", inv.All(), wallet.Snapshot()) + } +} diff --git a/go/internal/server/hunting/hunting.go b/go/internal/server/hunting/hunting.go new file mode 100644 index 0000000..d019c6e --- /dev/null +++ b/go/internal/server/hunting/hunting.go @@ -0,0 +1,433 @@ +// Package hunting owns ordinary HuntingGround progress and repeatable rewards. +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "strconv" + "sync" +) + +const BattleMode uint64 = 5 + +type packState struct { + Current, Highest uint64 + Auto bool + Defeated []uint64 +} +type snapshot struct { + Version string `json:"version"` + Free uint64 `json:"free"` + Bonus uint64 `json:"bonus"` + Packs map[string]packState `json:"packs"` + Receipts map[string]bool `json:"receipts"` +} +type Service struct { + mu sync.Mutex + storage stateio.Store + inventory *player.Inventory + wallet *player.Wallet + state snapshot + currentPack func() (int, error) + load func(int) (*gamedata.HuntingPack, error) + dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error) + dispatchEligibility func(*gamedata.DispatchDesign) error +} + +func Open(store stateio.Store, root, version string, inventory *player.Inventory, wallet *player.Wallet, currentPack func() (int, error), free, bonus uint64) (*Service, error) { + if store == nil || inventory == nil || wallet == nil || currentPack == nil { + return nil, fmt.Errorf("hunting: invalid configuration") + } + s := &Service{storage: store, inventory: inventory, wallet: wallet, currentPack: currentPack, state: snapshot{versionconfig.State(), free, bonus, map[string]packState{}, map[string]bool{}}} + s.dispatchLoad = func(group, id uint64) (*gamedata.DispatchDesign, error) { + return gamedata.LoadDispatchDesign(root, version, group, id) + } + s.load = func(pack int) (*gamedata.HuntingPack, error) { return gamedata.LoadHuntingPack(root, version, pack) } + b, err := store.Load("hunting") + if err != nil { + return nil, err + } + if b != nil { + if err := stateio.RequireExactJSONObject(b, "version", "free", "bonus", "packs", "receipts"); err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Packs == nil || s.state.Receipts == nil { + return nil, fmt.Errorf("hunting: incompatible state") + } + } + if s.state.Free > math.MaxInt32 || s.state.Bonus > math.MaxInt32 { + return nil, fmt.Errorf("hunting: AP exceeds protocol range") + } + for key, st := range s.state.Packs { + pack, err := strconv.Atoi(key) + if err != nil || pack <= 0 || strconv.Itoa(pack) != key { + return nil, fmt.Errorf("hunting: invalid saved pack") + } + d, err := s.load(pack) + if err != nil { + return nil, err + } + if _, ok := ground(d, st.Current); !ok { + return nil, fmt.Errorf("hunting: saved current ground missing from GameData") + } + if st.Highest != 0 { + if _, ok := ground(d, st.Highest); !ok { + return nil, fmt.Errorf("hunting: saved highest ground missing from GameData") + } + } + g, _ := ground(d, st.Current) + seen := map[uint64]bool{} + for _, id := range st.Defeated { + member := id == g.BossID + for _, candidate := range g.Monsters { + member = member || id == candidate + } + if !member || seen[id] { + return nil, fmt.Errorf("hunting: invalid defeated monster") + } + seen[id] = true + } + } + for key, claimed := range s.state.Receipts { + if key == "" || !claimed { + return nil, fmt.Errorf("hunting: invalid receipt") + } + } + return s, nil +} +func (s *Service) HuntingAP() (uint64, uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.state.Free, s.state.Bonus, nil +} +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + if path == "/HuntDispatch" || path == "/HuntDispatchInfo" || path == "/HuntDispatchStart" || path == "/HuntDispatchEnd" || path == "/HuntDispatchRewardPreview" { + return s.handleDispatch(path, req, "local") + } + if path != "/HuntingGroundInfo" && path != "/HuntingGroundInfoList" && path != "/HuntingGroundEnter" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("hunting: missing sequence") + } + if path == "/HuntingGroundInfoList" { + ids, err := packed(req, 2) + if err != nil { + return 387, nil, true, err + } + var response []byte + seen := map[uint64]bool{} + for _, id := range ids { + if id == 0 || id > math.MaxInt32 || seen[id] { + return 387, nil, true, fmt.Errorf("hunting: invalid pack list") + } + seen[id] = true + d, err := s.load(int(id)) + if err != nil { + return 387, nil, true, err + } + if len(d.Grounds) == 0 { + continue + } + info, err := s.info(int(id), d) + if err != nil { + return 387, nil, true, err + } + response = wire.AppendBytes(response, 1, info) + } + return 387, response, true, nil + } + pack, found, err := wire.Varint(req, 2) + if err != nil || !found || pack == 0 || pack > math.MaxInt32 { + return 134, nil, true, fmt.Errorf("hunting: invalid pack") + } + d, err := s.load(int(pack)) + if err != nil { + return 134, nil, true, err + } + if len(d.Grounds) == 0 { + return 134, nil, true, fmt.Errorf("hunting: pack has no hunting ground") + } + if path == "/HuntingGroundInfo" { + b, err := s.info(int(pack), d) + return 134, wire.AppendBytes(nil, 1, b), true, err + } + current, err := s.currentPack() + if err != nil || current != int(pack) { + return 110, nil, true, fmt.Errorf("hunting: enter pack is not current") + } + id, _, err := wire.Varint(req, 3) + if err != nil { + return 110, nil, true, err + } + auto, _, err := wire.Varint(req, 4) + if err != nil || auto > 1 { + return 110, nil, true, fmt.Errorf("hunting: invalid auto flag") + } + st := s.state.Packs[strconv.Itoa(int(pack))] + g, ok := ground(d, id) + if !ok { + return 110, nil, true, fmt.Errorf("hunting: unknown ground") + } + first := d.Grounds[0].ID + if id != first && id > st.Highest { + previous := uint64(0) + for _, v := range d.Grounds { + if v.ID < id { + previous = v.ID + } + } + if previous == 0 || previous > st.Highest { + return 110, nil, true, fmt.Errorf("hunting: ground difficulty locked") + } + } + st.Current, st.Auto = id, auto != 0 + st.Defeated = nil + next := s.clone() + next.Packs[strconv.Itoa(int(pack))] = st + if err := s.persist(next); err != nil { + return 110, nil, true, err + } + var out []byte + for _, m := range monstersWithState(d, g, st.Defeated) { + out = wire.AppendBytes(out, 1, m) + } + return 110, out, true, nil +} +func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) { + st := s.state.Packs[strconv.Itoa(pack)] + if st.Current == 0 { + st.Current = d.Grounds[0].ID + } + g, ok := ground(d, st.Current) + if !ok { + return nil, fmt.Errorf("hunting: saved ground missing from GameData") + } + b := wire.AppendVarint(nil, 2, st.Current) + b = wire.AppendVarint(b, 3, st.Highest) + b = wire.AppendVarint(b, 5, uint64(pack)) + if st.Auto { + b = wire.AppendVarint(b, 1, 1) + } + for _, m := range monstersWithState(d, g, st.Defeated) { + b = wire.AppendBytes(b, 4, m) + } + return b, nil +} +func ground(d *gamedata.HuntingPack, id uint64) (gamedata.HuntingGround, bool) { + for _, g := range d.Grounds { + if g.ID == id { + return g, true + } + } + return gamedata.HuntingGround{}, false +} +func monsterWire(m gamedata.HuntingMonster, active bool) []byte { + b := wire.AppendVarint(nil, 1, m.ID) + b = wire.AppendVarint(b, 2, m.Decks[0]) + if active { + b = wire.AppendVarint(b, 6, 1) + } + return b +} +func monsters(d *gamedata.HuntingPack, g gamedata.HuntingGround) [][]byte { + return monstersWithState(d, g, nil) +} +func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte { + ids := append(append([]uint64(nil), g.Monsters...), g.BossID) + out := make([][]byte, 0, len(ids)) + for _, id := range ids { + active := true + for _, dead := range defeated { + if dead == id { + active = false + } + } + out = append(out, monsterWire(d.Monsters[id], active)) + } + return out +} +func (s *Service) ValidateBattle(pack int, mode, monster, deck uint64) error { + if mode != BattleMode { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + _, _, _, err := s.validate(pack, monster, deck) + return err +} +func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPack, gamedata.HuntingGround, gamedata.HuntingMonster, error) { + d, err := s.load(pack) + if err != nil { + return nil, gamedata.HuntingGround{}, gamedata.HuntingMonster{}, err + } + st := s.state.Packs[strconv.Itoa(pack)] + g, ok := ground(d, st.Current) + if !ok { + return nil, g, gamedata.HuntingMonster{}, fmt.Errorf("hunting: battle before ground enter") + } + member := monster == g.BossID + for _, id := range g.Monsters { + member = member || id == monster + } + m := d.Monsters[monster] + for _, dead := range st.Defeated { + if dead == monster { + return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") + } + } + known := false + for _, id := range m.Decks { + known = known || id == deck + } + if !member || !known { + return nil, g, m, fmt.Errorf("hunting: monster/deck does not belong to selected ground") + } + cost := d.NormalAP + if m.Type == 1 { + cost = d.BossAP + } + if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { + return nil, g, m, fmt.Errorf("hunting: insufficient hunting AP") + } + return d, g, m, nil +} +func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) { + if mode != BattleMode { + return nil, nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if receipt == "" { + return nil, nil, fmt.Errorf("hunting: missing battle receipt") + } + if s.state.Receipts[receipt] { + return nil, nil, nil + } + d, g, m, err := s.validate(pack, monster, deck) + if err != nil { + return nil, nil, err + } + rewards := m.Rewards[deck] + identity := "hunting:" + receipt + currency := make([]gamedata.Reward, 0) + stack := make([]gamedata.BattleReward, 0) + for _, r := range rewards { + if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + } else { + stack = append(stack, r) + } + } + if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { + return nil, nil, err + } + items, err := s.inventory.GrantOnce(identity+":items", stack) + if err != nil { + return nil, nil, err + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + next := s.clone() + cost := d.NormalAP + if m.Type == 1 { + cost = d.BossAP + } + if next.Free >= cost { + next.Free -= cost + } else { + next.Bonus -= cost - next.Free + next.Free = 0 + } + if monster == g.BossID { + st := next.Packs[strconv.Itoa(pack)] + if st.Highest < g.ID { + st.Highest = g.ID + } + next.Packs[strconv.Itoa(pack)] = st + } + next.Receipts[receipt] = true + st := next.Packs[strconv.Itoa(pack)] + st.Defeated = append(append([]uint64(nil), st.Defeated...), monster) + next.Packs[strconv.Itoa(pack)] = st + if err := s.persist(next); err != nil { + return nil, nil, err + } + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + for _, r := range currency { + b := wire.AppendVarint(nil, 3, r.Type) + b = wire.AppendVarint(b, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, b) + } + return bundle, [][]byte{monsterWire(m, false)}, nil +} +func (s *Service) clone() snapshot { + next := s.state + next.Packs = map[string]packState{} + for k, v := range s.state.Packs { + next.Packs[k] = v + } + next.Receipts = map[string]bool{} + for k, v := range s.state.Receipts { + next.Receipts[k] = v + } + return next +} +func (s *Service) persist(next snapshot) error { + b, err := json.Marshal(next) + if err != nil { + return err + } + if err := s.storage.Save("hunting", b); err != nil { + return err + } + s.state = next + return nil +} +func packed(req []byte, n int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(req, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return fmt.Errorf("hunting: invalid repeated integer") + } + for raw := f.Value; len(raw) > 0; { + v, n := binary.Uvarint(raw) + if n <= 0 { + return fmt.Errorf("hunting: invalid packed integer") + } + out = append(out, v) + raw = raw[n:] + } + return nil + }) + return out, err +} diff --git a/go/internal/server/hunting/hunting_test.go b/go/internal/server/hunting/hunting_test.go new file mode 100644 index 0000000..0bda5a4 --- /dev/null +++ b/go/internal/server/hunting/hunting_test.go @@ -0,0 +1,87 @@ +package hunting + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + wallet, _ := player.OpenWallet(store, player.Currency{}) + s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 73, nil }, 20, 5) + if err != nil { + t.Fatal(err) + } + d := &gamedata.HuntingPack{NormalAP: 3, BossAP: 7, Grounds: []gamedata.HuntingGround{{ID: 9, MapID: 123, BossID: 41, Monsters: []uint64{31}}, {ID: 15, MapID: 123, BossID: 51, Monsters: []uint64{32}}}, Monsters: map[uint64]gamedata.HuntingMonster{31: {ID: 31, Decks: []uint64{81}, Rewards: map[uint64][]gamedata.BattleReward{81: {{Type: 8, ID: 999, Count: 2}, {Type: 4, Count: 10}}}}, 41: {ID: 41, Type: 1, Decks: []uint64{91}, Rewards: map[uint64][]gamedata.BattleReward{91: {{Type: 8, ID: 998, Count: 4}}}}}} + s.load = func(int) (*gamedata.HuntingPack, error) { return d, nil } + enter := func(id uint64) error { + r := wire.AppendVarint(nil, 1, 1) + r = wire.AppendVarint(r, 2, 73) + r = wire.AppendVarint(r, 3, id) + _, _, _, err := s.Handle("/HuntingGroundEnter", r) + return err + } + if err := enter(15); err == nil { + t.Fatal("higher difficulty unlocked") + } + if err := enter(9); err != nil { + t.Fatal(err) + } + if err := s.ValidateBattle(73, 5, 31, 91); err == nil { + t.Fatal("wrong deck accepted") + } + if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionA:2"); err != nil { + t.Fatal(err) + } + if err := s.ValidateBattle(73, 5, 31, 81); err == nil { + t.Fatal("defeated monster replay accepted") + } + if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionA:2"); err != nil { + t.Fatal(err) + } + free, bonus, _ := s.HuntingAP() + if free != 17 || bonus != 5 { + t.Fatalf("AP=%d/%d", free, bonus) + } + info, err := s.info(73, d) + if err != nil { + t.Fatal(err) + } + var inactive bool + wire.Walk(info, func(f wire.Field) error { + if f.Number == 4 { + id, _, _ := wire.Varint(f.Value, 1) + active, _, _ := wire.Varint(f.Value, 6) + if id == 31 { + inactive = active == 0 + } + } + return nil + }) + if !inactive { + t.Fatal("info resurrected defeated monster") + } + if err := enter(9); err != nil { + t.Fatal(err) + } + if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err != nil { + t.Fatal(err) + } + if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 4 { + t.Fatalf("repeated rewards=%+v", inv.All()) + } + free, _, _ = s.HuntingAP() + if free != 14 { + t.Fatalf("repeat AP=%d", free) + } + if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:3"); err != nil { + t.Fatal(err) + } + if s.state.Packs["73"].Highest != 9 { + t.Fatal("boss did not unlock next ground") + } +} diff --git a/go/internal/server/hunting/transaction_test.go b/go/internal/server/hunting/transaction_test.go new file mode 100644 index 0000000..ff0cd09 --- /dev/null +++ b/go/internal/server/hunting/transaction_test.go @@ -0,0 +1,95 @@ +package hunting + +import ( + "encoding/json" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" +) + +func TestHuntingSettlementRollsBackAPWalletInventoryAndProgress(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) + if err != nil { + t.Fatal(err) + } + if err := inv.EnsurePersisted(); err != nil { + t.Fatal(err) + } + if err := wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + load := func(int) (*gamedata.HuntingPack, error) { + return &gamedata.HuntingPack{NormalAP: 1, BossAP: 2, + Grounds: []gamedata.HuntingGround{{ID: 1, MapID: 13, BossID: 11}}, + Monsters: map[uint64]gamedata.HuntingMonster{11: {ID: 11, Type: 1, Decks: []uint64{21}, Rewards: map[uint64][]gamedata.BattleReward{21: {{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}}}}, nil + } + s, err := Open(repo, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 4) + if err != nil { + t.Fatal(err) + } + s.load = load + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1) + if _, _, _, err := s.Handle("/HuntingGroundEnter", enter); err != nil { + t.Fatal(err) + } + before, err := repo.Load("hunting") + if err != nil { + t.Fatal(err) + } + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, err := s.CompleteBattle(1, BattleMode, 11, 21, "login-A:2"); err != nil { + _ = op.Rollback() + t.Fatal(err) + } + // Model a later domain failure in the same request/batch. A dirty rollback + // fences in-memory domains, requiring restart before they can serve again. + _ = op.Rollback() + if repo.Check() == nil { + t.Fatal("dirty rollback did not fence stale domains") + } + if err := repo.Close(); err != nil { + t.Fatal(err) + } + reopened, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + after, err := reopened.Load("hunting") + if err != nil || string(after) != string(before) { + t.Fatalf("hunting progress survived rollback: %s %v", after, err) + } + inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err = player.OpenWallet(reopened, player.Currency{}) + if err != nil { + t.Fatal(err) + } + var persisted snapshot + if err := json.Unmarshal(after, &persisted); err != nil { + t.Fatal(err) + } + free, bonus := persisted.Free, persisted.Bonus + if err != nil || free != 10 || bonus != 4 || wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 { + t.Fatalf("settlement survived rollback: AP=%d/%d wallet=%+v items=%+v err=%v", free, bonus, wallet.Snapshot(), inv.All(), err) + } +} diff --git a/go/internal/server/logging/logging.go b/go/internal/server/logging/logging.go new file mode 100644 index 0000000..4e9bd53 --- /dev/null +++ b/go/internal/server/logging/logging.go @@ -0,0 +1,180 @@ +// Package logging configures the server's structured console logging. +package logging + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" +) + +const LevelTrace slog.Level = -8 + +type ColorMode string + +const ( + ColorAuto ColorMode = "auto" + ColorAlways ColorMode = "always" + ColorNever ColorMode = "never" +) + +type Options struct { + // Level defaults to INFO. A *slog.LevelVar supports changes at runtime. + Level slog.Leveler + // Color defaults to auto; always explicitly forces ANSI, including in pipes. + Color ColorMode +} + +// OptionsFromEnv reads process-wide defaults for every server command. +func OptionsFromEnv() (Options, error) { + options := Options{Level: slog.LevelInfo, Color: ColorAuto} + if value := os.Getenv("BD2_LOG_LEVEL"); value != "" { + level, err := ParseLevel(value) + if err != nil { + return Options{}, err + } + options.Level = level + } + if value := os.Getenv("BD2_LOG_COLOR"); value != "" { + color, err := ParseColorMode(value) + if err != nil { + return Options{}, err + } + options.Color = color + } + return options, nil +} + +func ParseLevel(value string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "trace": + return LevelTrace, nil + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("invalid log level %q (use trace, debug, info, warn, or error)", value) + } +} + +func ParseColorMode(value string) (ColorMode, error) { + mode := ColorMode(strings.ToLower(strings.TrimSpace(value))) + if mode != ColorAuto && mode != ColorAlways && mode != ColorNever { + return "", fmt.Errorf("invalid log color %q (use auto, always, or never)", value) + } + return mode, nil +} + +// Setup installs a logger as slog.Default, so existing slog callers use it too. +func Setup(writer io.Writer, options Options) (*slog.Logger, error) { + handler, err := NewHandler(writer, options) + if err != nil { + return nil, err + } + logger := slog.New(handler) + slog.SetDefault(logger) + return logger, nil +} + +// NewHandler retains slog's quoting, groups, LogValuer resolution and shared +// write lock. Only the built-in level label is colored; attributes stay intact. +func NewHandler(writer io.Writer, options Options) (slog.Handler, error) { + if writer == nil { + return nil, fmt.Errorf("log writer is nil") + } + mode := options.Color + if mode == "" { + mode = ColorAuto + } + if _, err := ParseColorMode(string(mode)); err != nil { + return nil, err + } + color := mode == ColorAlways + if mode == ColorAuto { + if file, ok := writer.(*os.File); ok && autoColorAllowed() { + color = terminalSupportsColor(file) + } + } + if color { + writer = levelColorWriter{writer} + } + return slog.NewTextHandler(writer, &slog.HandlerOptions{ + Level: options.Level, + ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { + if len(groups) == 0 && attr.Key == slog.LevelKey { + if level, ok := attr.Value.Any().(slog.Level); ok && level == LevelTrace { + return slog.String(slog.LevelKey, "TRACE") + } + } + return attr + }, + }), nil +} + +func autoColorAllowed() bool { + _, noColor := os.LookupEnv("NO_COLOR") + return !noColor && os.Getenv("TERM") != "dumb" +} + +type levelColorWriter struct{ io.Writer } + +func (w levelColorWriter) Write(data []byte) (int, error) { + // TextHandler emits time, level, then msg. Search only before msg, so + // user attributes or messages containing "level=" cannot select a color. + end := bytes.Index(data, []byte(" msg=")) + if end < 0 { + return w.Writer.Write(data) + } + start := bytes.Index(data[:end], []byte(" level=")) + if start < 0 { + return w.Writer.Write(data) + } + start += len(" level=") + label := string(data[start:end]) + var color string + switch { + case strings.HasPrefix(label, "TRACE"): + color = "\x1b[90m" + case strings.HasPrefix(label, "DEBUG"): + color = "\x1b[36m" + case strings.HasPrefix(label, "INFO"): + color = "\x1b[32m" + case strings.HasPrefix(label, "WARN"): + color = "\x1b[33m" + case strings.HasPrefix(label, "ERROR"): + color = "\x1b[31m" + default: + return w.Writer.Write(data) + } + output := make([]byte, 0, len(data)+len(color)+4) + output = append(output, data[:start]...) + output = append(output, color...) + output = append(output, data[start:end]...) + output = append(output, "\x1b[0m"...) + output = append(output, data[end:]...) + n, err := w.Writer.Write(output) + if err == nil && n != len(output) { + err = io.ErrShortWrite + } + if err != nil { + return 0, err + } + return len(data), nil +} + +func Trace(message string, args ...any) { TraceContext(context.Background(), message, args...) } +func TraceContext(ctx context.Context, message string, args ...any) { + slog.Log(ctx, LevelTrace, message, args...) +} +func Debug(message string, args ...any) { slog.Debug(message, args...) } +func Info(message string, args ...any) { slog.Info(message, args...) } +func Warn(message string, args ...any) { slog.Warn(message, args...) } +func Error(message string, args ...any) { slog.Error(message, args...) } diff --git a/go/internal/server/logging/logging_test.go b/go/internal/server/logging/logging_test.go new file mode 100644 index 0000000..eacd3bd --- /dev/null +++ b/go/internal/server/logging/logging_test.go @@ -0,0 +1,137 @@ +package logging + +import ( + "bytes" + "context" + "io" + "log/slog" + "os" + "strings" + "sync" + "testing" +) + +func TestLevelsAndColors(t *testing.T) { + for _, test := range []struct { + name, ansi string + level slog.Level + }{ + {"TRACE", "90", LevelTrace}, {"DEBUG", "36", slog.LevelDebug}, + {"INFO", "32", slog.LevelInfo}, {"WARN", "33", slog.LevelWarn}, {"ERROR", "31", slog.LevelError}, + } { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + h, err := NewHandler(&out, Options{Level: LevelTrace, Color: ColorAlways}) + if err != nil { + t.Fatal(err) + } + slog.New(h).Log(context.Background(), test.level, "hello", "level", "ERROR", "text", "a\nb") + want := "level=\x1b[" + test.ansi + "m" + test.name + "\x1b[0m msg=hello level=ERROR text=\"a\\nb\"" + if !strings.Contains(out.String(), want) { + t.Fatalf("output=%q want fragment=%q", out.String(), want) + } + }) + } +} + +func TestFilterAndDynamicLevel(t *testing.T) { + var out bytes.Buffer + var level slog.LevelVar + h, _ := NewHandler(&out, Options{Level: &level, Color: ColorNever}) + logger := slog.New(h) + logger.Log(context.Background(), LevelTrace, "hidden") + logger.Debug("hidden") + logger.Info("visible") + if strings.Contains(out.String(), "hidden") { + t.Fatal(out.String()) + } + level.Set(LevelTrace) + logger.Log(context.Background(), LevelTrace, "trace visible") + if !strings.Contains(out.String(), "level=TRACE msg=\"trace visible\"") { + t.Fatal(out.String()) + } +} + +func TestGroupsAndConcurrentDerivedLoggers(t *testing.T) { + var out bytes.Buffer + h, _ := NewHandler(&out, Options{Color: ColorAlways}) + logger := slog.New(h).With("service", "server").WithGroup("request").With("id", 7) + var workers sync.WaitGroup + for i := 0; i < 50; i++ { + workers.Add(1) + go func() { defer workers.Done(); logger.Info("handled", slog.Group("result", "ok", true)) }() + } + workers.Wait() + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 50 { + t.Fatalf("lines=%d", len(lines)) + } + for _, line := range lines { + if !strings.Contains(line, "service=server request.id=7 request.result.ok=true") || strings.Count(line, "\x1b[0m") != 1 { + t.Fatalf("damaged record %q", line) + } + } +} + +func TestAutoRedirectedOutputIsPlain(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "log") + if err != nil { + t.Fatal(err) + } + defer file.Close() + h, _ := NewHandler(file, Options{}) + slog.New(h).Warn("redirected") + if _, err := file.Seek(0, 0); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(file) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte("\x1b")) { + t.Fatalf("ANSI in redirected log %q", data) + } + var out bytes.Buffer + h, _ = NewHandler(&out, Options{}) + slog.New(h).Info("buffer") + if strings.Contains(out.String(), "\x1b") { + t.Fatal(out.String()) + } +} + +func TestEnvironmentAndValidation(t *testing.T) { + t.Setenv("BD2_LOG_LEVEL", "trace") + t.Setenv("BD2_LOG_COLOR", "never") + options, err := OptionsFromEnv() + if err != nil || options.Level.Level() != LevelTrace || options.Color != ColorNever { + t.Fatalf("options=%+v err=%v", options, err) + } + t.Setenv("BD2_LOG_LEVEL", "invalid") + if _, err := OptionsFromEnv(); err == nil { + t.Fatal("invalid level accepted") + } + if _, err := ParseColorMode("invalid"); err == nil { + t.Fatal("invalid color accepted") + } + if _, err := NewHandler(nil, Options{}); err == nil { + t.Fatal("nil writer accepted") + } +} + +func TestAutoColorEnvironment(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("NO_COLOR", "") + if autoColorAllowed() { + t.Fatal("NO_COLOR presence must suppress auto color") + } + if err := os.Unsetenv("NO_COLOR"); err != nil { + t.Fatal(err) + } + if !autoColorAllowed() { + t.Fatal("ordinary terminal must allow auto color") + } + t.Setenv("TERM", "dumb") + if autoColorAllowed() { + t.Fatal("dumb terminal must suppress auto color") + } +} diff --git a/go/internal/server/logging/terminal_unix.go b/go/internal/server/logging/terminal_unix.go new file mode 100644 index 0000000..f3ea404 --- /dev/null +++ b/go/internal/server/logging/terminal_unix.go @@ -0,0 +1,10 @@ +//go:build !windows + +package logging + +import ( + "github.com/mattn/go-isatty" + "os" +) + +func terminalSupportsColor(file *os.File) bool { return isatty.IsTerminal(file.Fd()) } diff --git a/go/internal/server/logging/terminal_windows.go b/go/internal/server/logging/terminal_windows.go new file mode 100644 index 0000000..34b6297 --- /dev/null +++ b/go/internal/server/logging/terminal_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package logging + +import ( + "golang.org/x/sys/windows" + "os" +) + +func terminalSupportsColor(file *os.File) bool { + handle := windows.Handle(file.Fd()) + var mode uint32 + if windows.GetConsoleMode(handle, &mode) != nil { + return false + } + return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil +} diff --git a/go/internal/server/missions/achievement_claim_state.go b/go/internal/server/missions/achievement_claim_state.go index 52f62ae..00a7ff0 100644 --- a/go/internal/server/missions/achievement_claim_state.go +++ b/go/internal/server/missions/achievement_claim_state.go @@ -26,6 +26,9 @@ func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { func (s *Service) AchievementExperience() (uint64, error) { s.mu.Lock() defer s.mu.Unlock() + return s.achievementExperienceLocked() +} +func (s *Service) achievementExperienceLocked() (uint64, error) { var total uint64 known := map[string]bool{} for key := range s.design.Achievements { diff --git a/go/internal/server/missions/missions.go b/go/internal/server/missions/missions.go index e63e520..14035af 100644 --- a/go/internal/server/missions/missions.go +++ b/go/internal/server/missions/missions.go @@ -46,6 +46,8 @@ func (s *Service) AttachAchievementProgress(source AchievementProgressSource) er } type Service struct { + levelRewards *gamedata.AchievementLevelDesign + levelReward uint64 achievementProgress AchievementProgressSource mu sync.Mutex storage stateio.Store @@ -221,6 +223,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, err } switch path { + case "/UserLevelReward": + response, err := s.userLevelReward(request) + return 73, response, true, err case "/MissionInfo": if err := requireSeq(request); err != nil { return 118, nil, true, err diff --git a/go/internal/server/missions/user_level_reward.go b/go/internal/server/missions/user_level_reward.go new file mode 100644 index 0000000..a137f56 --- /dev/null +++ b/go/internal/server/missions/user_level_reward.go @@ -0,0 +1,148 @@ +package missions + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" +) + +type levelRewardSnapshot struct { + Version string `json:"version"` + LevelReward uint64 `json:"level_reward"` +} + +// AttachUserLevelRewards reads the dedicated claim bucket in the same store as missions. +func (s *Service) AttachUserLevelRewards(d *gamedata.AchievementLevelDesign) error { + if err := d.Validate(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + entries, ok := s.storage.(stateio.EntryStore) + if !ok { + return fmt.Errorf("missions: user level rewards require entry storage") + } + raw, _, err := entries.LoadEntry("missions", "user_level_rewards", "state") + if err != nil { + return err + } + var state levelRewardSnapshot + if raw != nil { + if err := stateio.RequireExactJSONObject(raw, "version", "level_reward"); err != nil { + return err + } + if err := json.Unmarshal(raw, &state); err != nil { + return err + } + if state.Version != versionconfig.State() { + return fmt.Errorf("missions: incompatible user level reward version") + } + if state.LevelReward != 0 { + found := false + for _, l := range d.Levels { + found = found || l.ID == state.LevelReward + } + if !found { + return fmt.Errorf("missions: claimed user level missing from GameData") + } + } + } + s.levelRewards = d + s.levelReward = state.LevelReward + return nil +} +func (s *Service) LevelRewardCount() (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.levelRewards == nil { + return 0, fmt.Errorf("missions: user level rewards unavailable") + } + return s.levelReward, nil +} + +func (s *Service) userLevelReward(request []byte) ([]byte, error) { + if s.levelRewards == nil { + return nil, fmt.Errorf("missions: user level rewards unavailable") + } + if err := requireSeq(request); err != nil { + return nil, err + } + ids, err := packed(request, 2) + if err != nil || len(ids) == 0 { + return nil, ErrInvalidRequest + } + exp, err := s.achievementExperienceLocked() + if err != nil { + return nil, err + } + level := s.levelRewards.Level(exp) + next := s.levelReward + var fresh []gamedata.AchievementLevel + var previous uint64 + for _, id := range ids { + if id == 0 || id <= previous { + return nil, ErrInvalidRequest + } + previous = id + index := -1 + for i, l := range s.levelRewards.Levels { + if l.ID == id { + index = i + break + } + } + if index < 0 || id > level { + return nil, ErrInvalidRequest + } + if id <= s.levelReward { + continue + } + expected := uint64(0) + for _, l := range s.levelRewards.Levels { + if l.ID > next { + expected = l.ID + break + } + } + if id != expected { + return nil, fmt.Errorf("%w: user level reward requires preceding claims", ErrInvalidRequest) + } + fresh = append(fresh, s.levelRewards.Levels[index]) + next = id + } + var items []player.Item + var currencies []gamedata.Reward + for _, l := range fresh { + granted, err := s.grantRewards(fmt.Sprintf("user-level:%d", l.ID), l.Rewards) + if err != nil { + return nil, err + } + items = append(items, granted...) + for _, r := range l.Rewards { + if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { + currencies = append(currencies, r) + } + } + } + if next != s.levelReward { + raw, err := json.Marshal(levelRewardSnapshot{versionconfig.State(), next}) + if err != nil { + return nil, err + } + if err := s.storage.(stateio.EntryStore).PutEntry("missions", "user_level_rewards", "state", raw); err != nil { + return nil, err + } + s.levelReward = next + } + bundle := rewardBundle(items) + for _, r := range currencies { + item := wire.AppendVarint(nil, 3, r.Type) + item = wire.AppendVarint(item, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, item) + } + return wire.AppendBytes(nil, 1, bundle), nil +} diff --git a/go/internal/server/missions/user_level_reward_test.go b/go/internal/server/missions/user_level_reward_test.go new file mode 100644 index 0000000..71ebcd6 --- /dev/null +++ b/go/internal/server/missions/user_level_reward_test.go @@ -0,0 +1,62 @@ +package missions + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +func TestUserLevelRewardEligibilityBatchPersistenceAndRetry(t *testing.T) { + store := stateio.NewMemory() + inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 9, ID: 1}: {AddExp: 10}}} + s, _ := Open(store, d, inv) + levels := &gamedata.AchievementLevelDesign{Levels: []gamedata.AchievementLevel{{ID: 17, NeedEXP: 4, Rewards: []gamedata.Reward{{Type: 8, ID: 987, Count: 3}}}, {ID: 23, NeedEXP: 8, Rewards: []gamedata.Reward{{Type: 8, ID: 988, Count: 7}}}, {ID: 41, NeedEXP: 10, Rewards: []gamedata.Reward{{Type: 8, ID: 989, Count: 9}}}}} + if err := s.AttachUserLevelRewards(levels); err != nil { + t.Fatal(err) + } + req := func(ids ...uint64) []byte { + r := wire.AppendVarint(nil, 1, 1) + for _, id := range ids { + r = wire.AppendVarint(r, 2, id) + } + return r + } + for _, ids := range [][]uint64{{23}, {17, 23}, {17, 17}, {99}} { + if _, _, _, err := s.Handle("/UserLevelReward", req(ids...)); err == nil { + t.Fatalf("accepted %v", ids) + } + } + if len(inv.All()) != 0 { + t.Fatal("invalid batch granted rewards") + } + s.state.Claimed = []string{"achievement:" + achievementName(gamedata.AchievementKey{GroupID: 9, ID: 1})} + if _, _, _, err := s.Handle("/UserLevelReward", req(17, 23, 41)); err == nil { + t.Fatal("accepted unearned level") + } + if _, _, _, err := s.Handle("/UserLevelReward", req(17, 23)); err != nil { + t.Fatal(err) + } + if count, _ := s.LevelRewardCount(); count != 23 { + t.Fatalf("count=%d", count) + } + if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 10 { + t.Fatalf("items=%+v", inv.All()) + } + reopened, _ := Open(store, d, inv) + if err := reopened.AttachUserLevelRewards(levels); err != nil { + t.Fatal(err) + } + reopened.state.Claimed = s.state.Claimed + if _, _, _, err := reopened.Handle("/UserLevelReward", req(17, 23)); err != nil { + t.Fatal(err) + } + if len(inv.All()) != 2 { + t.Fatal("retry granted twice") + } + if n, _ := reopened.LevelRewardCount(); n != 23 { + t.Fatalf("reopened=%d", n) + } +} diff --git a/go/internal/server/monsterhunt/monsterhunt.go b/go/internal/server/monsterhunt/monsterhunt.go new file mode 100644 index 0000000..b904095 --- /dev/null +++ b/go/internal/server/monsterhunt/monsterhunt.go @@ -0,0 +1,845 @@ +// Package monsterhunt owns the configured Fiend Hunt seasons and local player progress. +package monsterhunt + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/readonly" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" +) + +const BattleMode uint64 = 8 +const PracticeMode uint64 = 24 + +type season struct{ ID, Hunt, Start, End, Calculate, RankGroup uint64 } +type user struct { + Season, Hunt, Level, StartHP, HighestHP, CurrentDamage, DailyDamage, HighestDate, DailyLevel, DailyDate uint64 + Played, Claimed bool + ClearLevel uint64 +} +type reply struct{ Request, Response []byte } +type snapshot struct { + Version string `json:"version"` + Slots uint64 `json:"slots"` + Users map[string]user `json:"users"` + Decks [][]byte `json:"decks"` + Settings [][]byte `json:"settings"` + Presets map[string][]byte `json:"presets"` + Replies map[string]reply `json:"replies"` +} +type encounter struct{ Season, Hunt, Level, HP, Mode, BeforeLevel, BeforeHP, Team uint64 } +type Service struct { + mu sync.Mutex + storage stateio.Store + root, version string + seed *readonly.Seed + seasons []season + state snapshot + inventory *player.Inventory + wallet *player.Wallet + characters *player.CharacterStore + equipment *player.EquipmentInventory + collection *player.CollectionStore + presets *gamedata.PresetDesign + baseSlots, maxSlots uint64 + now func() time.Time + active map[string]encounter + load func(uint64) (*gamedata.MonsterHunt, error) +} + +func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { + if storage == nil || seed == nil || inventory == nil || wallet == nil { + return nil, fmt.Errorf("monsterhunt: incomplete configuration") + } + s := &Service{storage: storage, root: root, version: version, seed: seed, inventory: inventory, wallet: wallet, now: time.Now, active: map[string]encounter{}} + designs := map[uint64]*gamedata.MonsterHunt{} + s.load = func(id uint64) (*gamedata.MonsterHunt, error) { + if d, ok := designs[id]; ok { + return d, nil + } + d, e := gamedata.LoadMonsterHunt(root, version, id) + if e == nil { + designs[id] = d + } + return d, e + } + for _, f := range seed.Responses["/MonsterHuntScheduleInfo"].Fields { + if f.Number != 1 || f.Type != 2 { + continue + } + var c season + for _, v := range f.Fields { + switch v.Number { + case 1: + for _, x := range v.Fields { + switch x.Number { + case 1: + c.ID = x.Varint + case 2: + c.Start = x.Varint + case 3: + c.End = x.Varint + } + } + case 2: + c.Hunt = v.Varint + case 4: + c.Calculate = v.Varint + case 7: + c.RankGroup = v.Varint + } + } + if c.ID == 0 || c.Hunt == 0 || c.Start > c.End { + return nil, fmt.Errorf("monsterhunt: invalid configured season") + } + s.seasons = append(s.seasons, c) + } + if len(s.seasons) == 0 { + return nil, fmt.Errorf("monsterhunt: configured schedule missing") + } + d, e := gamedata.LoadMonsterHuntPresetDesign(root, version) + if e != nil { + return nil, e + } + s.presets = d + s.baseSlots = d.BaseCount + s.maxSlots = d.Maximum + s.state = snapshot{Version: versionconfig.State(), Slots: s.baseSlots, Users: map[string]user{}, Presets: map[string][]byte{}, Replies: map[string]reply{}} + raw, e := storage.Load("monsterhunt") + if e != nil { + return nil, e + } + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "version", "slots", "users", "decks", "settings", "presets", "replies"); e != nil { + return nil, e + } + if e = json.Unmarshal(raw, &s.state); e != nil { + return nil, e + } + if s.state.Version != versionconfig.State() || s.state.Users == nil || s.state.Presets == nil || s.state.Replies == nil || s.state.Slots < s.baseSlots || s.state.Slots > s.maxSlots { + return nil, fmt.Errorf("monsterhunt: incompatible state") + } + } + for _, c := range s.seasons { + if _, e = s.load(c.Hunt); e != nil { + return nil, e + } + } + if e = s.validateDecks(s.state.Decks); e != nil { + return nil, e + } + if e = s.validateSettings(s.state.Settings); e != nil { + return nil, e + } + for key, u := range s.state.Users { + if key != strconv.FormatUint(u.Season, 10) || u.Season == 0 || u.Hunt == 0 || u.Level == 0 { + return nil, fmt.Errorf("monsterhunt: invalid saved user") + } + d, e := s.load(u.Hunt) + if e != nil { + return nil, e + } + hp, e := d.HP(u.Level) + if e != nil || u.StartHP > hp || u.HighestHP != hp || u.ClearLevel > d.MaxLevel { + return nil, fmt.Errorf("monsterhunt: saved progress exceeds design") + } + } + for key, p := range s.state.Presets { + slot, _ := scalar(p, 4) + if key != strconv.FormatUint(slot, 10) { + return nil, fmt.Errorf("monsterhunt: invalid saved preset key") + } + if e = s.validatePreset(p); e != nil { + return nil, e + } + } + return s, nil +} +func (s *Service) AttachCharacters(c *player.CharacterStore) { s.characters = c } +func (s *Service) AttachPresetRuntime(c *player.CharacterStore, e *player.EquipmentInventory, col *player.CollectionStore) error { + if c == nil || e == nil || col == nil { + return fmt.Errorf("monsterhunt: incomplete preset runtime") + } + s.characters = c + s.equipment = e + s.collection = col + if err := s.validateDecks(s.state.Decks); err != nil { + return err + } + if err := s.validateSettings(s.state.Settings); err != nil { + return err + } + for _, p := range s.state.Presets { + if err := s.validatePreset(p); err != nil { + return err + } + } + return nil +} +func (s *Service) PresetSlotCount() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.Slots } +func (s *Service) BeginSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + // Encounters are keyed by session-specific receipts and survive repeated activation. +} +func (s *Service) current() season { + c := s.seasons[0] + for _, v := range s.seasons { + if v.ID > c.ID { + c = v + } + } + return c +} +func (s *Service) playing(c season) bool { + n := uint64(s.now().UnixMilli()) + return n >= c.Start && n <= c.End +} +func (s *Service) getUser(c season) (user, error) { + u, ok := s.state.Users[strconv.FormatUint(c.ID, 10)] + if ok { + if u.DailyDate != 0 && u.DailyDate/86400000 != uint64(s.now().UnixMilli())/86400000 { + u.DailyDamage = 0 + u.DailyLevel = 0 + } + return u, nil + } + d, e := s.load(c.Hunt) + if e != nil { + return u, e + } + hp, e := d.HP(1) + return user{Season: c.ID, Hunt: c.Hunt, Level: 1, StartHP: hp, HighestHP: hp}, e +} +func (s *Service) encodeUser(u user, c season) []byte { + var b []byte + for n, v := range map[int]uint64{1: u.Season, 2: u.Hunt, 3: u.Level, 4: u.StartHP, 6: u.HighestHP, 7: u.HighestDate, 8: u.CurrentDamage, 9: u.DailyDamage, 11: u.DailyLevel, 12: u.DailyDate} { + if n == 3 { + v = u.ClearLevel + if v == 0 { + v = 1 + } + } + if v != 0 { + b = wire.AppendVarint(b, n, v) + } + } + if u.Played && !u.Claimed && uint64(s.now().UnixMilli()) > c.Calculate { + b = wire.AppendVarint(b, 10, 1) + } + return b +} +func (s *Service) save(next snapshot) error { + b, e := json.Marshal(next) + if e != nil { + return e + } + if e = s.storage.Save("monsterhunt", b); e != nil { + return e + } + s.state = next + return nil +} +func (s *Service) clone() snapshot { + raw, _ := json.Marshal(s.state) + var n snapshot + _ = json.Unmarshal(raw, &n) + return n +} +func scalar(b []byte, n int) (uint64, error) { + v, _, e := wire.Varint(b, n) + if e != nil || v > math.MaxInt64 { + return 0, fmt.Errorf("monsterhunt: invalid scalar %d", n) + } + return v, nil +} +func messages(b []byte, n int) ([][]byte, error) { + var out [][]byte + e := wire.Walk(b, func(f wire.Field) error { + if f.Number == n { + if f.Type != 2 { + return wire.ErrMalformed + } + out = append(out, append([]byte(nil), f.Value...)) + } + return nil + }) + return out, e +} +func packed(b []byte, n int) ([]uint64, error) { + var out []uint64 + e := wire.Walk(b, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + for p := f.Value; len(p) > 0; { + v, k := binary.Uvarint(p) + if k <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + p = p[k:] + } + return nil + }) + return out, e +} +func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { + return s.HandleSession(path, req, "local") +} + +var codes = map[string]int{"/MonsterHuntScheduleInfo": 0, "/MonsterHuntUserInfo": 196, "/MonsterHuntRankInfo": 197, "/MonsterHuntDeckInfo": 263, "/MonsterHuntDeckSave": 264, "/MonsterHuntChangeTeam": 265, "/MonsterHuntQuickBattle": 266, "/MonsterHuntPresetSlotAdd": 400, "/MonsterHuntPresetInfo": 405, "/MonsterHuntPresetSave": 406, "/MonsterHuntPresetUse": 410, "/MonsterHuntPresetDelete": 0, "/MonsterHuntPresetInfoChange": 0, "/MonsterHuntSeasonReward": 0} + +func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + code, ok := codes[path] + if !ok { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + seq, e := scalar(req, 1) + if e != nil || seq == 0 || seq > math.MaxInt32 { + return code, nil, true, fmt.Errorf("monsterhunt: invalid sequence") + } + if e = validateRequest(req); e != nil { + return code, nil, true, e + } + key := session + ":" + path + ":" + strconv.FormatUint(seq, 10) + if r, found := s.state.Replies[key]; found { + if !bytes.Equal(req, r.Request) { + return code, nil, true, fmt.Errorf("monsterhunt: sequence reused with different request") + } + return code, r.Response, true, nil + } + c := s.current() + u, e := s.getUser(c) + if e != nil { + return code, nil, true, e + } + next := s.clone() + var out []byte + mutation := false + var updateReceipt string + var updateEncounter encounter + switch path { + case "/MonsterHuntScheduleInfo": + return s.seed.Handle(path, req) + case "/MonsterHuntUserInfo": + out = wire.AppendBytes(nil, 1, s.encodeUser(u, c)) + if u.Played { + out = wire.AppendVarint(out, 2, 1) + out = wire.AppendDouble(out, 3, 100) + } + case "/MonsterHuntDeckInfo": + for _, v := range s.state.Decks { + out = wire.AppendBytes(out, 1, v) + } + for _, v := range s.state.Settings { + out = wire.AppendBytes(out, 2, v) + } + case "/MonsterHuntDeckSave": + next.Decks, e = messages(req, 2) + if e == nil { + e = s.validateDecks(next.Decks) + } + if e == nil { + next.Settings, e = messages(req, 3) + } + if e == nil { + e = s.validateSettings(next.Settings) + } + mutation = true + case "/MonsterHuntChangeTeam": + var d []byte + d, _, e = wire.Bytes(req, 2) + if e == nil { + e = s.validateDecks([][]byte{d}) + } + mode, _ := scalar(req, 4) + if mode != BattleMode && mode != PracticeMode { + e = fmt.Errorf("monsterhunt: invalid team mode") + } + if e == nil { + found := false + team, _ := scalar(d, 1) + for receipt, a := range s.active { + if !strings.HasPrefix(receipt, session+":") || a.Mode != mode { + continue + } + found = true + if team != a.Team+1 { + e = fmt.Errorf("monsterhunt: team transition is out of order") + break + } + design, err := s.load(a.Hunt) + if err != nil { + e = err + break + } + if team > 1 && (int(team-2) >= len(design.TeamOpenLevels) || a.Level < design.TeamOpenLevels[team-2]) { + e = fmt.Errorf("monsterhunt: team not unlocked") + break + } + a.Team = team + updateReceipt = receipt + updateEncounter = a + break + } + if !found { + e = fmt.Errorf("monsterhunt: no active battle for team change") + } + } + if e == nil { + team, _ := scalar(d, 1) + found := false + for i, v := range next.Decks { + t, _ := scalar(v, 1) + if team == t { + next.Decks[i] = d + found = true + } + } + if !found { + next.Decks = append(next.Decks, d) + } + } + mutation = true + case "/MonsterHuntRankInfo": + id, _ := scalar(req, 2) + if id == 0 { + id = c.ID + } + if v, ok := s.state.Users[strconv.FormatUint(id, 10)]; ok && v.Played { + b := s.rankWire(v) + out = wire.AppendBytes(out, 1, b) + out = wire.AppendBytes(out, 2, b) + } + case "/MonsterHuntPresetInfo": + keys := make([]int, 0, len(next.Presets)) + for k := range next.Presets { + i, _ := strconv.Atoi(k) + keys = append(keys, i) + } + sort.Ints(keys) + for _, k := range keys { + out = wire.AppendBytes(out, 1, next.Presets[strconv.Itoa(k)]) + } + case "/MonsterHuntPresetSave": + var p []byte + p, _, e = wire.Bytes(req, 2) + if e == nil { + e = s.validatePreset(p) + } + if e == nil { + slot, _ := scalar(p, 4) + next.Presets[strconv.FormatUint(slot, 10)] = p + } + mutation = true + case "/MonsterHuntPresetInfoChange": + slot, _ := scalar(req, 5) + p, exists := next.Presets[strconv.FormatUint(slot, 10)] + if !exists { + e = fmt.Errorf("monsterhunt: preset missing") + break + } + name, _, _ := wire.Bytes(req, 2) + if !utf8.Valid(name) || utf8.RuneCount(name) > 30 { + e = fmt.Errorf("monsterhunt: invalid preset name") + break + } + p, _, e = wire.ReplaceBytes(p, 1, name) + for a, b := range map[int]int{3: 2, 4: 3} { + v, _ := scalar(req, a) + p, _, e = wire.ReplaceVarint(p, b, v) + } + if e == nil { + e = s.validatePreset(p) + } + next.Presets[strconv.FormatUint(slot, 10)] = p + mutation = true + case "/MonsterHuntPresetDelete": + var slots []uint64 + slots, e = packed(req, 2) + if len(slots) == 0 { + e = fmt.Errorf("monsterhunt: empty preset deletion") + } + for _, slot := range slots { + if slot >= s.state.Slots { + e = fmt.Errorf("monsterhunt: invalid preset slot") + } + delete(next.Presets, strconv.FormatUint(slot, 10)) + } + mutation = true + case "/MonsterHuntPresetSlotAdd": + count, _ := scalar(req, 2) + if count == 0 || count > s.maxSlots-next.Slots { + e = fmt.Errorf("monsterhunt: invalid added slot count") + break + } + if count > math.MaxUint64/s.presets.Price { + e = fmt.Errorf("monsterhunt: preset price overflow") + break + } + cost := count * s.presets.Price + switch s.presets.PriceType { + case 4: + _, e = s.wallet.SpendGoldOnce("monsterhunt:"+key, cost) + case 3: + _, e = s.wallet.SpendFreeJewelryOnce("monsterhunt:"+key, cost) + case 2: + _, e = s.wallet.SpendJewelryOnce("monsterhunt:"+key, cost) + case 12: + _, e = s.wallet.SpendCatalystOnce("monsterhunt:"+key, cost) + default: + e = fmt.Errorf("monsterhunt: unsupported preset currency") + } + if e == nil { + next.Slots += count + } + mutation = true + case "/MonsterHuntPresetUse": + slot, _ := scalar(req, 2) + p, exists := next.Presets[strconv.FormatUint(slot, 10)] + if !exists { + e = fmt.Errorf("monsterhunt: preset missing") + break + } + next.Decks, e = s.presetDecks(p) + if e == nil { + out, e = s.applyPreset(p) + } + if e == nil { + for _, v := range next.Decks { + out = wire.AppendBytes(out, 1, v) + } + } + mutation = true + case "/MonsterHuntQuickBattle": + if !s.playing(c) || !u.Played || u.ClearLevel == 0 || u.CurrentDamage == 0 || u.DailyDamage >= u.CurrentDamage { + e = fmt.Errorf("monsterhunt: no eligible saved battle to sweep") + break + } + u.DailyDamage = u.CurrentDamage + d, loadErr := s.load(u.Hunt) + if loadErr != nil { + e = loadErr + break + } + bundle, grantErr := s.grant("monsterhunt:"+key, dailyDifference(d, u.DailyLevel, u.ClearLevel)) + if grantErr != nil { + e = grantErr + break + } + out = wire.AppendBytes(out, 3, bundle) + u.DailyLevel = u.ClearLevel + u.DailyDate = uint64(s.now().UnixMilli()) + next.Users[strconv.FormatUint(c.ID, 10)] = u + out = wire.AppendBytes(out, 1, s.encodeUser(u, c)) + out = wire.AppendVarint(out, 4, 1) + out = wire.AppendDouble(out, 5, 100) + mutation = true + case "/MonsterHuntSeasonReward": + if !u.Played || u.Claimed || uint64(s.now().UnixMilli()) <= c.Calculate { + e = fmt.Errorf("monsterhunt: season reward unavailable") + break + } + u.Claimed = true + d, loadErr := s.load(u.Hunt) + if loadErr != nil { + e = loadErr + break + } + rewards := s.rankRewards(d, c.RankGroup) + bundle, grantErr := s.grant("monsterhunt:"+key, rewards) + if grantErr != nil { + e = grantErr + break + } + next.Users[strconv.FormatUint(c.ID, 10)] = u + out = wire.AppendVarint(nil, 3, u.Hunt) + out = wire.AppendBytes(out, 4, bundle) + out = wire.AppendVarint(out, 1, 1) + out = wire.AppendVarint(out, 2, uint64(s.score(u))) + out = wire.AppendDouble(out, 5, 100) + mutation = true + } + if e != nil { + return code, nil, true, e + } + if mutation { + next.Replies[key] = reply{append([]byte(nil), req...), out} + if e = s.save(next); e != nil { + return code, nil, true, e + } + if updateReceipt != "" { + s.active[updateReceipt] = updateEncounter + } + } + return code, out, true, nil +} + +func (s *Service) validateDecks(ds [][]byte) error { + seen := map[uint64]bool{} + for _, d := range ds { + t, e := scalar(d, 1) + if e != nil || t == 0 || t > 3 || seen[t] { + return fmt.Errorf("monsterhunt: invalid or duplicate team") + } + seen[t] = true + chars, e := messages(d, 2) + if e != nil || len(chars) > 5 { + return fmt.Errorf("monsterhunt: invalid team size") + } + positions := map[uint64]bool{} + indices := map[uint64]bool{} + for _, b := range chars { + i, e := scalar(b, 1) + p, _ := scalar(b, 2) + seq, _ := scalar(b, 3) + if e != nil || i == 0 || p > 11 || seq == 0 || seq > 5 || positions[p] || indices[i] { + return fmt.Errorf("monsterhunt: invalid deck character") + } + if s.characters != nil { + if _, found := s.characters.Find(i); !found { + return fmt.Errorf("monsterhunt: character not owned") + } + } + positions[p] = true + indices[i] = true + } + } + return nil +} +func (s *Service) validatePreset(p []byte) error { + slot, e := scalar(p, 4) + if e != nil || slot >= s.state.Slots { + return fmt.Errorf("monsterhunt: invalid preset slot") + } + name, _, e := wire.Bytes(p, 1) + if e != nil || !utf8.Valid(name) || utf8.RuneCount(name) > 30 { + return fmt.Errorf("monsterhunt: invalid preset name") + } + icon, _ := scalar(p, 2) + color, _ := scalar(p, 3) + if color > 5 || icon != 0 && !s.presets.Icons[icon] { + return fmt.Errorf("monsterhunt: invalid preset icon") + } + d, e := s.presetDecks(p) + if e != nil { + return e + } + if _, _, e = s.presetBindings(p); e != nil { + return e + } + return s.validateDecks(d) +} +func (s *Service) presetDecks(p []byte) ([][]byte, error) { + entries, e := messages(p, 5) + if e != nil { + return nil, e + } + teams := map[uint64][]byte{} + for _, entry := range entries { + base, ok, e := wire.Bytes(entry, 1) + if e != nil || !ok { + return nil, fmt.Errorf("monsterhunt: missing preset deck") + } + t, e := scalar(entry, 4) + if e != nil || t == 0 || t > 3 { + return nil, fmt.Errorf("monsterhunt: invalid preset team") + } + teams[t] = wire.AppendBytes(teams[t], 2, base) + } + var out [][]byte + for t := uint64(1); t <= 3; t++ { + if b, ok := teams[t]; ok { + out = append(out, append(wire.AppendVarint(nil, 1, t), b...)) + } + } + return out, nil +} + +func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + mode, e := scalar(req, 5) + if e != nil || mode != BattleMode && mode != PracticeMode { + return nil, fmt.Errorf("monsterhunt: invalid battle mode") + } + id, _ := scalar(req, 6) + c := s.current() + if id != c.Hunt { + return nil, fmt.Errorf("monsterhunt: hunt not configured") + } + if mode == BattleMode && !s.playing(c) { + return nil, fmt.Errorf("monsterhunt: season is closed") + } + d, e := s.load(id) + if e != nil { + return nil, e + } + deck, _ := scalar(req, 4) + if deck != d.DeckID { + return nil, fmt.Errorf("monsterhunt: invalid battle deck") + } + level, _ := scalar(req, 10) + u, e := s.getUser(c) + if e != nil { + return nil, e + } + if level == 0 { + level = u.Level + } + if level > d.MaxLevel || mode == BattleMode && level > d.ChallengeableLevel && level > u.ClearLevel+1 { + return nil, fmt.Errorf("monsterhunt: invalid challenge level") + } + if mode == BattleMode && u.ClearLevel >= d.MaxLevel { + return nil, fmt.Errorf("monsterhunt: maximum level already cleared") + } + if receipt == "" { + return nil, fmt.Errorf("monsterhunt: missing battle receipt") + } + hp, e := d.HP(level) + if e != nil { + return nil, e + } + if mode == BattleMode && level == u.Level { + hp = u.StartHP + } + s.active[receipt] = encounter{c.ID, id, level, hp, mode, u.Level, u.StartHP, 1} + if mode == PracticeMode { + u.Level = level + u.StartHP = hp + } + return wire.AppendBytes(nil, 5, s.encodeUser(u, c)), nil +} +func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := "battle:" + receipt + if r, ok := s.state.Replies[key]; ok { + if !bytes.Equal(r.Request, req) { + return nil, fmt.Errorf("monsterhunt: changed battle retry") + } + return r.Response, nil + } + a, ok := s.active[receipt] + if !ok { + return nil, fmt.Errorf("monsterhunt: battle was not entered") + } + remaining, e := scalar(req, 7) + if e != nil || remaining > a.HP { + return nil, fmt.Errorf("monsterhunt: invalid remaining body HP") + } + if a.Mode == PracticeMode { + delete(s.active, receipt) + return nil, nil + } + c := s.current() + u, e := s.getUser(c) + if e != nil { + return nil, e + } + if a.Season != c.ID || a.Hunt != u.Hunt || a.BeforeLevel != u.Level || a.BeforeHP != u.StartHP { + return nil, fmt.Errorf("monsterhunt: stale battle progress") + } + u.Level = a.Level + d, e := s.load(u.Hunt) + if e != nil { + return nil, e + } + u.HighestHP, e = d.HP(a.Level) + if e != nil { + return nil, e + } + damage := a.HP - remaining + if damage > u.CurrentDamage { + u.CurrentDamage = damage + } + u.StartHP = remaining + u.Played = true + u.HighestDate = uint64(s.now().UnixMilli()) + previousClear := u.ClearLevel + var clear uint64 + if remaining == 0 { + clear = u.Level + if clear > u.ClearLevel { + u.ClearLevel = clear + } + d, e := s.load(u.Hunt) + if e != nil { + return nil, e + } + if u.ClearLevel < d.MaxLevel { + u.Level = u.ClearLevel + 1 + u.StartHP, e = d.HP(u.Level) + if e != nil { + return nil, e + } + u.HighestHP = u.StartHP + } + } + out := wire.AppendBytes(nil, 13, s.encodeUser(u, c)) + d, e = s.load(u.Hunt) + if e != nil { + return nil, e + } + if clear != 0 { + out = wire.AppendVarint(out, 27, clear) + var bundle []byte + for level := previousClear + 1; level <= clear; level++ { + b, e := s.grant(fmt.Sprintf("monsterhunt:season:%d:clear:%d", c.ID, level), d.Rewards[level].Clear) + if e != nil { + return nil, e + } + bundle = append(bundle, b...) + } + if len(bundle) > 0 { + out = wire.AppendBytes(out, 7, bundle) + } + } + day := uint64(s.now().UnixMilli()) / 86400000 + if damage > 0 && (u.DailyDate/86400000 != day || a.Level > u.DailyLevel) { + bundle, e := s.grant("monsterhunt:"+key+":daily", dailyDifference(d, u.DailyLevel, a.Level)) + if e != nil { + return nil, e + } + out = wire.AppendBytes(out, 8, bundle) + u.DailyLevel = a.Level + u.DailyDate = uint64(s.now().UnixMilli()) + u.DailyDamage = damage + } + out, _, _ = wire.ReplaceBytes(out, 13, s.encodeUser(u, c)) + out = wire.AppendVarint(out, 14, 1) + out = wire.AppendDouble(out, 26, 100) + next := s.clone() + next.Users[strconv.FormatUint(c.ID, 10)] = u + next.Replies[key] = reply{append([]byte(nil), req...), out} + if e = s.save(next); e != nil { + return nil, e + } + delete(s.active, receipt) + return out, nil +} diff --git a/go/internal/server/monsterhunt/monsterhunt_test.go b/go/internal/server/monsterhunt/monsterhunt_test.go new file mode 100644 index 0000000..7439ef5 --- /dev/null +++ b/go/internal/server/monsterhunt/monsterhunt_test.go @@ -0,0 +1,186 @@ +package monsterhunt + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/readonly" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func installed(t *testing.T) (*Service, *stateio.Memory) { + t.Helper() + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + seed, e := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) + if e != nil { + t.Fatal(e) + } + store := stateio.NewMemory() + inv, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + if e != nil { + t.Fatal(e) + } + wallet, e := player.OpenWallet(store, player.Currency{Gold: 100000}) + if e != nil { + t.Fatal(e) + } + s, e := Open(store, root, "20260923193640", seed, inv, wallet) + if e != nil { + t.Fatal(e) + } + c := s.current() + s.now = func() time.Time { return time.UnixMilli(int64(c.Start + 1000)) } + return s, store +} +func battleRequest(id, deck, mode uint64) []byte { + b := wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, deck) + b = wire.AppendVarint(b, 5, mode) + b = wire.AppendVarint(b, 6, id) + return wire.AppendVarint(b, 10, 1) +} +func TestBattleSessionProgressRewardsAndPersistentRetry(t *testing.T) { + s, store := installed(t) + c := s.current() + d, e := s.load(c.Hunt) + if e != nil { + t.Fatal(e) + } + req := battleRequest(c.Hunt, d.DeckID, BattleMode) + if _, e = s.EnterBattle(req, "session:1"); e != nil { + t.Fatal(e) + } + s.BeginSession("session") + end := wire.AppendVarint(nil, 1, 2) + end = wire.AppendVarint(end, 2, 1) + out, e := s.CompleteBattle(end, "session:1") + if e != nil { + t.Fatal(e) + } + if _, ok, _ := wire.Bytes(out, 7); !ok { + t.Fatal("clear reward missing") + } + u := s.state.Users["78"] + if u.ClearLevel != 1 || u.Level != 2 || !u.Played { + t.Fatalf("progress=%+v", u) + } + retry, e := s.CompleteBattle(end, "session:1") + if e != nil || !bytes.Equal(out, retry) { + t.Fatalf("retry=%x err=%v", retry, e) + } + bad := wire.AppendVarint(end, 7, 1) + if _, e = s.CompleteBattle(bad, "session:1"); e == nil { + t.Fatal("changed retry accepted") + } + restored, e := Open(store, s.root, s.version, s.seed, s.inventory, s.wallet) + if e != nil { + t.Fatal(e) + } + retry, e = restored.CompleteBattle(end, "session:1") + if e != nil || !bytes.Equal(out, retry) { + t.Fatalf("restored retry failed %v", e) + } +} +func TestPracticeHasNoRewardsAndRejectsInvalidHP(t *testing.T) { + s, _ := installed(t) + c := s.current() + d, e := s.load(c.Hunt) + if e != nil { + t.Fatal(e) + } + if _, e = s.EnterBattle(battleRequest(c.Hunt, d.DeckID, PracticeMode), "p:1"); e != nil { + t.Fatal(e) + } + hp, _ := d.HP(1) + bad := wire.AppendVarint(nil, 7, hp+1) + if _, e = s.CompleteBattle(bad, "p:1"); e == nil { + t.Fatal("impossible HP accepted") + } + if b, e := s.CompleteBattle(nil, "p:1"); e != nil || len(b) != 0 { + t.Fatalf("practice=%x err=%v", b, e) + } + if len(s.state.Users) != 0 { + t.Fatal("practice changed competitive progress") + } +} +func TestPresetSlotCurrencyAndIdempotency(t *testing.T) { + s, _ := installed(t) + req := wire.AppendVarint(nil, 1, 41) + req = wire.AppendVarint(req, 2, 1) + before := s.state.Slots + for i := 0; i < 2; i++ { + if _, _, _, e := s.HandleSession("/MonsterHuntPresetSlotAdd", req, "session"); e != nil { + t.Fatal(e) + } + } + if s.state.Slots != before+1 { + t.Fatal("slot retry doubled purchase") + } + changed := wire.AppendVarint(nil, 1, 41) + changed = wire.AppendVarint(changed, 2, 2) + if _, _, _, e := s.HandleSession("/MonsterHuntPresetSlotAdd", changed, "session"); e == nil { + t.Fatal("changed slot retry accepted") + } +} +func TestDeckShapeRejectsRepeatedPositionAndAcceptsTeamThree(t *testing.T) { + s := &Service{} + char := wire.AppendVarint(nil, 1, 1) + char = wire.AppendVarint(char, 3, 1) + deck := wire.AppendVarint(nil, 1, 3) + deck = wire.AppendBytes(deck, 2, char) + if e := s.validateDecks([][]byte{deck}); e != nil { + t.Fatal(e) + } + dup := wire.AppendVarint(nil, 1, 2) + dup = wire.AppendVarint(dup, 3, 2) + deck = wire.AppendBytes(deck, 2, dup) + if e := s.validateDecks([][]byte{deck}); e == nil { + t.Fatal("duplicate position accepted") + } +} +func TestRankRewardUsesActualLocalPercentThreshold(t *testing.T) { + s := &Service{} + d := &gamedata.MonsterHunt{Ranks: map[uint64][]gamedata.MonsterHuntRankReward{1: {{Type: 1, Ranking: 10, Rewards: []gamedata.BattleReward{{Count: 10}}}, {Type: 1, Ranking: 100, Rewards: []gamedata.BattleReward{{Count: 1}}}}}} + got := s.rankRewards(d, 1) + if len(got) != 1 || got[0].Count != 1 { + t.Fatalf("rewards=%+v", got) + } +} + +func TestDailyRewardUpgradePaysDifference(t *testing.T) { + d := &gamedata.MonsterHunt{Rewards: map[uint64]gamedata.MonsterHuntRewards{1: {Daily: []gamedata.BattleReward{{Type: 4, Count: 100}}}, 2: {Daily: []gamedata.BattleReward{{Type: 4, Count: 150}, {Type: 8, ID: 1, Count: 2}}}}} + if got := dailyDifference(d, 1, 1); len(got) != 0 { + t.Fatalf("same level repeated payout=%v", got) + } + got := dailyDifference(d, 1, 2) + if len(got) != 2 || got[0].Count != 50 || got[1].Count != 2 { + t.Fatalf("upgrade=%v", got) + } +} +func TestSessionRetryRejectsChangedBodyWithoutGameData(t *testing.T) { + s := &Service{state: snapshot{Replies: map[string]reply{"sid:/MonsterHuntDeckSave:2": {Request: wire.AppendVarint(nil, 1, 2), Response: []byte{}}}}} + req := wire.AppendVarint(nil, 1, 2) + if _, _, ok, e := s.HandleSession("/MonsterHuntDeckSave", req, "sid"); e != nil || !ok { + t.Fatal(e) + } + changed := wire.AppendVarint(req, 4, 1) + if _, _, _, e := s.HandleSession("/MonsterHuntDeckSave", changed, "sid"); e == nil { + t.Fatal("changed same-sequence request accepted") + } +} +func TestRequestMalformedAndUnsignedWrapRejected(t *testing.T) { + s := &Service{} + for _, req := range [][]byte{{0xff}, wire.AppendVarint(nil, 1, ^uint64(0))} { + if _, _, ok, e := s.HandleSession("/MonsterHuntUserInfo", req, "sid"); e == nil || !ok { + t.Fatalf("bad request accepted %x", req) + } + } +} diff --git a/go/internal/server/monsterhunt/preset.go b/go/internal/server/monsterhunt/preset.go new file mode 100644 index 0000000..ad4b9c9 --- /dev/null +++ b/go/internal/server/monsterhunt/preset.go @@ -0,0 +1,124 @@ +package monsterhunt + +import ( + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "fmt" +) + +func (s *Service) presetBindings(p []byte) (map[uint64]uint64, []player.PresetEquipmentBinding, error) { + assignments := map[uint64]uint64{} + var bindings []player.PresetEquipmentBinding + entries, e := messages(p, 5) + if e != nil { + return nil, nil, e + } + for _, entry := range entries { + base, _, e := wire.Bytes(entry, 1) + if e != nil { + return nil, nil, e + } + index, _ := scalar(base, 1) + costume, _ := scalar(entry, 2) + if _, duplicate := assignments[index]; duplicate { + return nil, nil, fmt.Errorf("monsterhunt: duplicate preset character") + } + assignments[index] = costume + if costume != 0 && s.collection != nil { + c, found := s.collection.CostumeByIndex(costume) + if !found || c.UseChar != index { + return nil, nil, fmt.Errorf("monsterhunt: preset costume not owned by character") + } + } + binding := player.PresetEquipmentBinding{CharacterIndex: index, Equipment: make([]uint64, 5)} + equipment, e := messages(entry, 3) + if e != nil { + return nil, nil, e + } + seen := map[uint64]bool{} + for _, item := range equipment { + t, _ := scalar(item, 1) + id, _ := scalar(item, 2) + if t >= 5 || seen[t] { + return nil, nil, fmt.Errorf("monsterhunt: invalid preset equipment slot") + } + seen[t] = true + binding.Equipment[t] = id + } + bindings = append(bindings, binding) + } + if len(bindings) > 0 && s.equipment != nil { + if e = s.equipment.ValidatePresetEquipment(bindings); e != nil { + return nil, nil, e + } + } + return assignments, bindings, nil +} +func (s *Service) applyPreset(p []byte) ([]byte, error) { + if s.characters == nil || s.equipment == nil || s.collection == nil { + return nil, fmt.Errorf("monsterhunt: preset ownership runtime unavailable") + } + assignments, bindings, e := s.presetBindings(p) + if e != nil { + return nil, e + } + if _, e = s.characters.ApplyPresetCostumes(assignments); e != nil { + return nil, e + } + if len(bindings) > 0 { + if _, e = s.equipment.ApplyPresetEquipment(bindings); e != nil { + return nil, e + } + } + var out []byte + for _, binding := range bindings { + c, ok := s.characters.Find(binding.CharacterIndex) + if ok { + out = wire.AppendBytes(out, 2, player.CharacterWire(c)) + } + b := wire.AppendVarint(nil, 1, binding.CharacterIndex) + for _, id := range binding.Equipment { + b = wire.AppendVarint(b, 2, id) + } + out = wire.AppendBytes(out, 3, b) + } + return out, nil +} + +func (s *Service) validateSettings(settings [][]byte) error { + for _, setting := range settings { + index, e := scalar(setting, 1) + if e != nil || index == 0 { + return fmt.Errorf("monsterhunt: invalid costume setting character") + } + if s.characters != nil { + if _, ok := s.characters.Find(index); !ok { + return fmt.Errorf("monsterhunt: setting character not owned") + } + } + seq, e := messages(setting, 2) + if e != nil { + return e + } + mode, _ := scalar(setting, 3) + if mode != BattleMode && mode != PracticeMode { + return fmt.Errorf("monsterhunt: invalid setting battle mode") + } + for _, item := range seq { + costume, _, e := wire.Varint(item, 1) + if e != nil { + return e + } + if costume == 0 || costume == ^uint64(0) { + continue + } + if s.collection != nil { + c, ok := s.collection.CostumeByIndex(costume) + if !ok || c.UseChar != index { + return fmt.Errorf("monsterhunt: setting costume not owned") + } + } + } + } + return nil +} diff --git a/go/internal/server/monsterhunt/rewards.go b/go/internal/server/monsterhunt/rewards.go new file mode 100644 index 0000000..794737c --- /dev/null +++ b/go/internal/server/monsterhunt/rewards.go @@ -0,0 +1,93 @@ +package monsterhunt + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" +) + +// Daily payout is the best reward achieved that UTC day. Improvements pay +// only positive per-item differences from the already paid level. +func dailyDifference(d *gamedata.MonsterHunt, previous, next uint64) []gamedata.BattleReward { + paid := map[[2]uint64]uint64{} + for _, r := range d.Rewards[previous].Daily { + paid[[2]uint64{r.Type, r.ID}] += r.Count + } + var out []gamedata.BattleReward + for _, r := range d.Rewards[next].Daily { + n := paid[[2]uint64{r.Type, r.ID}] + if r.Count > n { + r.Count -= n + out = append(out, r) + } + } + return out +} + +// This server has one persistent account state: a submitted participant ranks +// first among the local participants. No official leaderboard is imported. +func (s *Service) score(u user) float64 { + progress := float64(0) + if u.HighestHP > 0 { + progress = float64(u.HighestHP-u.StartHP) / float64(u.HighestHP) * 10000 + } + return float64(u.ClearLevel*1000000) + progress +} +func (s *Service) rankWire(u user) []byte { + b := wire.AppendVarint(nil, 7, 1) + b = wire.AppendDouble(b, 8, s.score(u)) + return wire.AppendDouble(b, 10, 100) +} +func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata.BattleReward { + var selected *gamedata.MonsterHuntRankReward + for _, r := range d.Ranks[group] { + r := r + threshold := float64(1) + if r.Type == 1 { + threshold = 100 + } + if r.Ranking >= threshold && (selected == nil || r.Type < selected.Type || r.Type == selected.Type && r.Ranking < selected.Ranking) { + selected = &r + } + } + if selected == nil { + return nil + } + return selected.Rewards +} +func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { + var currency []gamedata.Reward + var stack []gamedata.BattleReward + for _, r := range rewards { + switch r.Type { + case 2, 3, 4, 12, 20: + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + default: + stack = append(stack, r) + } + } + if _, e := s.wallet.GrantQuestOnce(identity+":currency", currency); e != nil { + return nil, e + } + items, e := s.inventory.GrantOnce(identity+":items", stack) + if e != nil { + return nil, e + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + v := wire.AppendVarint(nil, 2, item.ID) + v = wire.AppendVarint(v, 3, item.Type) + v = wire.AppendVarint(v, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, v) + } + for _, r := range currency { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + return bundle, nil +} diff --git a/go/internal/server/monsterhunt/transaction_test.go b/go/internal/server/monsterhunt/transaction_test.go new file mode 100644 index 0000000..d7edd8d --- /dev/null +++ b/go/internal/server/monsterhunt/transaction_test.go @@ -0,0 +1,61 @@ +package monsterhunt + +import ( + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" +) + +func TestPresetPurchaseRollsBackCurrencyAndSlotTogether(t *testing.T) { + s, _ := installed(t) + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + wallet, err := player.OpenWallet(repo, player.Currency{Gold: 100000}) + if err != nil { + t.Fatal(err) + } + if err = wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + s.storage = repo + s.wallet = wallet + before := s.state.Slots + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) + if _, _, _, err = s.HandleSession("/MonsterHuntPresetSlotAdd", req, "session-A"); err != nil { + _ = op.Rollback() + t.Fatal(err) + } + _ = op.Rollback() + if err = repo.Close(); err != nil { + t.Fatal(err) + } + reopened, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if raw, err := reopened.Load("monsterhunt"); err != nil || raw != nil { + t.Fatalf("slot survived rollback: %s %v", raw, err) + } + wallet, err = player.OpenWallet(reopened, player.Currency{}) + if err != nil { + t.Fatal(err) + } + if wallet.Snapshot().Gold != 100000 { + t.Fatalf("gold survived rollback: %+v", wallet.Snapshot()) + } + if s.state.Slots != before+1 || repo.Check() == nil { + t.Fatal("dirty domain memory was not fenced") + } +} diff --git a/go/internal/server/monsterhunt/validation.go b/go/internal/server/monsterhunt/validation.go new file mode 100644 index 0000000..44c5659 --- /dev/null +++ b/go/internal/server/monsterhunt/validation.go @@ -0,0 +1,37 @@ +package monsterhunt + +import ( + "bd2server/internal/server/wire" + "encoding/binary" + "fmt" + "math" +) + +func validateRequest(req []byte) error { + return wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt32 { + return fmt.Errorf("monsterhunt: integer outside protocol range") + } + } + return nil + }) +} + +func (s *Service) ValidatePack(pack int, req []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + id, e := scalar(req, 6) + if e != nil { + return e + } + d, e := s.load(id) + if e != nil { + return e + } + if pack <= 0 || uint64(pack) != d.PackID { + return fmt.Errorf("monsterhunt: hunt does not belong to current pack") + } + return nil +} diff --git a/go/internal/server/session/level_reward_transaction_test.go b/go/internal/server/session/level_reward_transaction_test.go new file mode 100644 index 0000000..aa8337f --- /dev/null +++ b/go/internal/server/session/level_reward_transaction_test.go @@ -0,0 +1,98 @@ +package session + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/cryptox" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/missions" + "bd2server/internal/server/player" + "bd2server/internal/server/protocol" + "bd2server/internal/server/wire" +) + +func TestLevelRewardBatchRollsBackWalletInventoryAndClaimTogether(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) + if err != nil { + t.Fatal(err) + } + if err := inv.EnsurePersisted(); err != nil { + t.Fatal(err) + } + if err := wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + design := &gamedata.MissionDesign{} + m, err := missions.Open(repo, design, inv) + if err != nil { + t.Fatal(err) + } + if err := m.AttachWallet(wallet); err != nil { + t.Fatal(err) + } + levels := &gamedata.AchievementLevelDesign{Levels: []gamedata.AchievementLevel{{ID: 1, NeedEXP: 10, Rewards: []gamedata.Reward{{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}}} + if err := m.AttachUserLevelRewards(levels); err != nil { + t.Fatal(err) + } + server, err := NewServer(fakeLogin{}, m, &mutatingDomain{store: repo}) + if err != nil { + t.Fatal(err) + } + if err := server.AttachStateStore(repo); err != nil { + t.Fatal(err) + } + session := login(t, server) + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1) + batch := []protocol.BatchRequest{ + {Path: "/UserLevelReward", RequestData: base64.StdEncoding.EncodeToString(request)}, + {Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}, + } + plain, _ := json.Marshal(batch) + body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) + if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+session.Cookie); err == nil { + t.Fatal("failing batch accepted") + } + if err := repo.Close(); err != nil { + t.Fatal(err) + } + reopened, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + wallet, err = player.OpenWallet(reopened, player.Currency{}) + if err != nil { + t.Fatal(err) + } + m, err = missions.Open(reopened, design, inv) + if err != nil { + t.Fatal(err) + } + if err := m.AttachUserLevelRewards(levels); err != nil { + t.Fatal(err) + } + if got, err := m.LevelRewardCount(); err != nil || got != 0 { + t.Fatalf("claim survived rollback: %d %v", got, err) + } + if len(inv.All()) != 0 || wallet.Snapshot().Gold != 12 { + t.Fatalf("rewards survived rollback: %+v %+v", inv.All(), wallet.Snapshot()) + } +} diff --git a/go/internal/server/session/scoped_handler_test.go b/go/internal/server/session/scoped_handler_test.go new file mode 100644 index 0000000..9595879 --- /dev/null +++ b/go/internal/server/session/scoped_handler_test.go @@ -0,0 +1,49 @@ +package session + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "bd2server/internal/server/cryptox" + "bd2server/internal/server/protocol" + "bd2server/internal/server/wire" +) + +type scopedHandler struct{ sessions []string } + +func (h *scopedHandler) Handle(string, []byte) (int, []byte, bool, error) { + panic("session handler fell back to unscoped dispatch") +} +func (h *scopedHandler) HandleSession(path string, request []byte, sessionID string) (int, []byte, bool, error) { + h.sessions = append(h.sessions, sessionID) + return 77, nil, true, nil +} + +func TestScopedDispatchUsesAuthorizedSessionInsideBatch(t *testing.T) { + h := &scopedHandler{} + s, err := NewServer(fakeLogin{}, h) + if err != nil { + t.Fatal(err) + } + first := login(t, s) + firstGame := s.sessions[sessionTokenKey(first.Cookie)] + second := login(t, s) + secondGame := s.sessions[sessionTokenKey(second.Cookie)] + for _, test := range []struct { + cookie string + game *gameSession + }{{first.Cookie, firstGame}, {second.Cookie, secondGame}, {first.Cookie, firstGame}} { + requests := []protocol.BatchRequest{{Path: "/Scoped", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, {Path: "/ScopedSecond", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}} + plain, _ := json.Marshal(requests) + body, _ := cryptox.EncryptBase64(plain, test.game.key) + if _, err := s.DispatchRaw("/BatchRequest", []byte(body), "s="+test.cookie); err != nil { + t.Fatal(err) + } + for _, got := range h.sessions[len(h.sessions)-2:] { + if got != test.game.id { + t.Fatalf("session %q want %q", got, test.game.id) + } + } + } +} diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go index 947be96..d03ffee 100644 --- a/go/internal/server/session/server.go +++ b/go/internal/server/session/server.go @@ -44,6 +44,12 @@ type Handler interface { Handle(path string, request []byte) (packetCode int, response []byte, ok bool, err error) } +// SessionHandler receives the same opaque login identity for requests within +// a batch, allowing durable receipts without mutable global session state. +type SessionHandler interface { + HandleSession(path string, request []byte, sessionID string) (packetCode int, response []byte, ok bool, err error) +} + // SessionAware handlers use a login-scoped opaque ID when protobuf request // sequences participate in durable idempotency keys. type SessionAware interface { @@ -71,6 +77,7 @@ type Server struct { sessions map[[sha256.Size]byte]*gameSession latestSessionToken [sha256.Size]byte latestSessionSet bool + activeSessionID string login LoginService handlers []Handler observers []ResponseObserver @@ -266,6 +273,7 @@ func (s *Server) deleteSession(token [sha256.Size]byte, game *gameSession) { } func (s *Server) activate(game *gameSession) { + s.activeSessionID = game.id for _, handler := range s.handlers { if aware, ok := handler.(SessionAware); ok { aware.BeginSession(game.id) @@ -380,7 +388,18 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) { return 102, nil, nil } for _, handler := range s.handlers { - code, response, ok, err := handler.Handle(path, request) + var code int + var response []byte + var ok bool + var err error + if scoped, supports := handler.(SessionHandler); supports { + if s.activeSessionID == "" { + return 0, nil, errSessionRequired + } + code, response, ok, err = scoped.HandleSession(path, request, s.activeSessionID) + } else { + code, response, ok, err = handler.Handle(path, request) + } if err != nil { return 0, nil, err } diff --git a/go/internal/server/stateio/entry_snapshot.go b/go/internal/server/stateio/entry_snapshot.go new file mode 100644 index 0000000..ff978be --- /dev/null +++ b/go/internal/server/stateio/entry_snapshot.go @@ -0,0 +1,17 @@ +package stateio + +// EntrySnapshotStore keeps optional gameplay snapshots within an existing +// account domain. It participates in the parent's SQLite request transaction +// without adding cores to the nine-domain account initialization contract. +type EntrySnapshotStore struct { + Entries EntryStore + Domain, Bucket string +} + +func (s EntrySnapshotStore) Load(name string) ([]byte, error) { + payload, _, err := s.Entries.LoadEntry(s.Domain, s.Bucket, name) + return payload, err +} +func (s EntrySnapshotStore) Save(name string, payload []byte) error { + return s.Entries.PutEntry(s.Domain, s.Bucket, name, payload) +} diff --git a/go/internal/server/stateio/entry_snapshot_test.go b/go/internal/server/stateio/entry_snapshot_test.go new file mode 100644 index 0000000..91ead25 --- /dev/null +++ b/go/internal/server/stateio/entry_snapshot_test.go @@ -0,0 +1,59 @@ +package stateio_test + +import ( + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/stateio" +) + +func TestOptionalGameplaySnapshotsPreserveAccountInitializationOnRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + domains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"} + for _, name := range domains { + if err := repo.Save(name, []byte("{}")); err != nil { + t.Fatal(err) + } + } + if err := repo.MarkInitializationComplete(); err != nil { + t.Fatal(err) + } + store := stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"} + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"hunting", "huntdispatch", "monsterhunt"} { + if err := store.Save(name, []byte("{\"version\":\"2.35.10\"}")); err != nil { + _ = op.Rollback() + t.Fatal(err) + } + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + if err := repo.Close(); err != nil { + t.Fatal(err) + } + reopened, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if status, err := reopened.InitializationState(domains...); err != nil || status != accountstate.InitializationComplete { + t.Fatalf("restart rejected optional gameplay state: %d %v", status, err) + } + store.Entries = reopened + for _, name := range []string{"hunting", "huntdispatch", "monsterhunt"} { + raw, err := store.Load(name) + if err != nil || string(raw) != "{\"version\":\"2.35.10\"}" { + t.Fatalf("%s lost after restart: %s %v", name, raw, err) + } + } +} diff --git a/go/seed/v2_35_10/readonly.json b/go/seed/v2_35_10/readonly.json index 75c10cb..48f108a 100644 --- a/go/seed/v2_35_10/readonly.json +++ b/go/seed/v2_35_10/readonly.json @@ -3229,47 +3229,6 @@ } ] }, - "/MonsterHuntUserInfo": { - "packet_code": 196, - "fields": [ - { - "number": 1, - "type": 2, - "fields": [ - { - "number": 1, - "type": 0, - "varint": 78 - }, - { - "number": 2, - "type": 0, - "varint": 73 - }, - { - "number": 3, - "type": 0, - "varint": 1 - }, - { - "number": 4, - "type": 0, - "varint": 5500 - }, - { - "number": 6, - "type": 0, - "varint": 5500 - }, - { - "number": 7, - "type": 0, - "varint": 1789835605000 - } - ] - } - ] - }, "/PassInfo": { "packet_code": 124, "fields": [