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.
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user