feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// Package battle implements a deterministic local battle session. The client
|
||||
// remains authoritative for turn simulation; the server validates sequencing
|
||||
// and echoes the submitted combat state without capture replay.
|
||||
package battle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
entered bool
|
||||
index uint64
|
||||
round uint64
|
||||
monster uint64
|
||||
deck uint64
|
||||
pack int
|
||||
initialBlue [][]byte
|
||||
gameDataRoot string
|
||||
gameDataVersion string
|
||||
inventory *player.Inventory
|
||||
currentPack func() (int, error)
|
||||
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
|
||||
buffs func() ([]gamedata.PictorialBuffStat, error)
|
||||
onTutorialWin func() error
|
||||
}
|
||||
|
||||
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
|
||||
|
||||
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service {
|
||||
return &Service{
|
||||
gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion,
|
||||
inventory: inventory, currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) {
|
||||
s.buffs = buffs
|
||||
}
|
||||
|
||||
func checkSeq(request []byte) error {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return errors.New("battle: invalid request sequence")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if err := checkSeq(request); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch path {
|
||||
case "/BattleVerifyState":
|
||||
if !s.entered {
|
||||
return 0, nil, true, errors.New("battle: verify before enter")
|
||||
}
|
||||
// Packet code 142 follows BattleVerify(141). State 3 is the protocol's
|
||||
// explicit SUCCESS value; PVE does not require authoritative team lists.
|
||||
return 142, wire.AppendVarint(nil, 1, 3), true, nil
|
||||
case "/BattleEnter":
|
||||
deck, deckFound, err := wire.Varint(request, 4)
|
||||
if err != nil || !deckFound || deck == 0 {
|
||||
return 0, nil, true, errors.New("battle: missing battle deck")
|
||||
}
|
||||
mode, modeFound, err := wire.Varint(request, 5)
|
||||
if err != nil || !modeFound || mode == 0 {
|
||||
return 0, nil, true, errors.New("battle: missing battle mode")
|
||||
}
|
||||
packID := 0
|
||||
if s.currentPack != nil {
|
||||
packID, err = s.currentPack()
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: resolve current pack: %w", err)
|
||||
}
|
||||
if packID <= 0 {
|
||||
return 0, nil, true, errors.New("battle: current pack is invalid")
|
||||
}
|
||||
} else if s.inventory != nil && s.gameDataRoot != "" {
|
||||
return 0, nil, true, errors.New("battle: current pack resolver is unavailable")
|
||||
}
|
||||
monster, _, _ := wire.Varint(request, 3)
|
||||
response := wire.AppendVarint(nil, 2, deck)
|
||||
if s.buffs != nil {
|
||||
buffs, err := s.buffs()
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: account pictorial buffs: %w", err)
|
||||
}
|
||||
for _, buff := range buffs {
|
||||
entry := wire.AppendVarint(nil, 1, buff.StatType)
|
||||
entry = wire.AppendDouble(entry, 2, buff.Value)
|
||||
if buff.Category != 0 {
|
||||
entry = wire.AppendVarint(entry, 3, buff.Category)
|
||||
}
|
||||
response = wire.AppendBytes(response, 4, entry)
|
||||
}
|
||||
}
|
||||
// The local engine is the normal deterministic engine.
|
||||
response = wire.AppendVarint(response, 6, 1)
|
||||
s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil
|
||||
s.monster, s.deck, s.pack = monster, deck, packID
|
||||
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
|
||||
return 52, response, true, nil
|
||||
case "/BattleRetry":
|
||||
if !s.entered {
|
||||
return 0, nil, true, errors.New("battle: retry before enter")
|
||||
}
|
||||
index, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || index == 0 {
|
||||
return 0, nil, true, errors.New("battle: retry missing battle index")
|
||||
}
|
||||
if len(s.initialBlue) == 0 {
|
||||
return 0, nil, true, errors.New("battle: retry before initial battle state")
|
||||
}
|
||||
var response []byte
|
||||
for _, character := range s.initialBlue {
|
||||
response = wire.AppendBytes(response, 2, character)
|
||||
}
|
||||
response = wire.AppendVarint(response, 3, index)
|
||||
s.index, s.round = index, 0
|
||||
return 58, response, true, nil
|
||||
case "/BattleStart":
|
||||
if !s.entered {
|
||||
return 0, nil, true, errors.New("battle: start before enter")
|
||||
}
|
||||
index, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || index == 0 {
|
||||
return 0, nil, true, errors.New("battle: invalid battle index")
|
||||
}
|
||||
if s.index != 0 && index != s.index {
|
||||
return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", s.index, index)
|
||||
}
|
||||
s.index, s.round = index, s.round+1
|
||||
if s.round == 1 {
|
||||
s.initialBlue = nil
|
||||
}
|
||||
var response []byte
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
if field.Number == 4 {
|
||||
response = wire.AppendBytes(response, 1, field.Value)
|
||||
} else if field.Number == 5 {
|
||||
response = wire.AppendBytes(response, 2, field.Value)
|
||||
if s.round == 1 {
|
||||
s.initialBlue = append(s.initialBlue, append([]byte(nil), field.Value...))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
// Stable per-battle/round seed; reproducible across retries.
|
||||
seed := index*7919 + s.round*104729
|
||||
response = wire.AppendVarint(response, 3, seed)
|
||||
return 14, response, true, nil
|
||||
case "/BattleEnd":
|
||||
if !s.entered {
|
||||
return 0, nil, true, errors.New("battle: end before enter")
|
||||
}
|
||||
result, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || result == 0 {
|
||||
return 0, nil, true, errors.New("battle: invalid result")
|
||||
}
|
||||
response := wire.AppendVarint(nil, 1, result)
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number == 3 && field.Type == 2 {
|
||||
response = wire.AppendBytes(response, 3, field.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
rewardBundle := false
|
||||
if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" {
|
||||
if s.pack <= 0 {
|
||||
return 0, nil, true, errors.New("battle: victory has no locked pack")
|
||||
}
|
||||
loader := s.loadRewards
|
||||
if loader == nil {
|
||||
loader = gamedata.BattleDeckRewards
|
||||
}
|
||||
rewards, rewardErr := loader(s.gameDataRoot, s.gameDataVersion, s.pack, s.deck)
|
||||
if rewardErr != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", s.pack, s.monster, s.deck, rewardErr)
|
||||
}
|
||||
items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", s.pack, s.monster, s.deck), rewards)
|
||||
if grantErr != nil {
|
||||
return 0, nil, true, grantErr
|
||||
}
|
||||
var bundle []byte
|
||||
for _, item := range items {
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
|
||||
}
|
||||
if len(bundle) != 0 {
|
||||
response = wire.AppendBytes(response, 5, bundle)
|
||||
rewardBundle = true
|
||||
}
|
||||
}
|
||||
if result == 1 && s.onTutorialWin != nil {
|
||||
if err := s.onTutorialWin(); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: update tutorial kill mission: %w", err)
|
||||
}
|
||||
}
|
||||
for _, field := range []int{5, 6, 7, 8, 10, 11, 15, 16, 25} {
|
||||
if field == 5 && rewardBundle {
|
||||
continue
|
||||
}
|
||||
response = wire.AppendBytes(response, field, nil)
|
||||
}
|
||||
s.entered, s.deck, s.pack, s.initialBlue = false, 0, 0, nil
|
||||
return 15, response, true, nil
|
||||
case "/BattleExit":
|
||||
s.entered, s.index, s.round, s.deck, s.pack, s.initialBlue = false, 0, 0, 0, 0, nil
|
||||
return 388, nil, true, nil
|
||||
}
|
||||
panic("unreachable")
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
|
||||
|
||||
func TestLocalBattleLifecycle(t *testing.T) {
|
||||
s := &Service{}
|
||||
enter := request(1)
|
||||
enter = wire.AppendVarint(enter, 4, 1)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
code, response, ok, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil || !ok || code != 52 {
|
||||
t.Fatalf("enter: %d %v %v", code, ok, err)
|
||||
}
|
||||
engine, found, _ := wire.Varint(response, 6)
|
||||
if !found || engine != 1 {
|
||||
t.Fatalf("engine=%d/%v", engine, found)
|
||||
}
|
||||
red := wire.AppendVarint(nil, 1, 1)
|
||||
blue := wire.AppendVarint(nil, 1, 6)
|
||||
start := request(2)
|
||||
start = wire.AppendVarint(start, 2, 1)
|
||||
start = wire.AppendBytes(start, 4, red)
|
||||
start = wire.AppendBytes(start, 5, blue)
|
||||
code, response, _, err = s.Handle("/BattleStart", start)
|
||||
if err != nil || code != 14 {
|
||||
t.Fatalf("start: %d %v", code, err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 1); !found {
|
||||
t.Fatal("red state not echoed")
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 2); !found {
|
||||
t.Fatal("blue state not echoed")
|
||||
}
|
||||
code, response, _, err = s.Handle("/BattleVerifyState", request(3))
|
||||
state, found, _ := wire.Varint(response, 1)
|
||||
if err != nil || code != 142 || !found || state != 3 {
|
||||
t.Fatalf("verify: code=%d state=%d/%v err=%v", code, state, found, err)
|
||||
}
|
||||
end := request(4)
|
||||
end = wire.AppendVarint(end, 2, 2)
|
||||
end = wire.AppendBytes(end, 3, blue)
|
||||
code, response, _, err = s.Handle("/BattleEnd", end)
|
||||
result, found, _ := wire.Varint(response, 1)
|
||||
if err != nil || code != 15 || !found || result != 2 {
|
||||
t.Fatalf("end: code=%d result=%d/%v err=%v", code, result, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleOrdering(t *testing.T) {
|
||||
s := &Service{}
|
||||
start := request(1)
|
||||
start = wire.AppendVarint(start, 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
|
||||
t.Fatal("start without enter accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleRetryRestoresFirstSubmittedBlueTeam(t *testing.T) {
|
||||
s := &Service{}
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 4), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := wire.AppendVarint(wire.AppendVarint(nil, 2, 101), 4, 513)
|
||||
second := wire.AppendVarint(wire.AppendVarint(nil, 2, 102), 4, 200)
|
||||
start := wire.AppendVarint(request(2), 2, 77)
|
||||
start = wire.AppendBytes(start, 5, first)
|
||||
start = wire.AppendBytes(start, 5, second)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry := wire.AppendVarint(request(3), 2, 77)
|
||||
code, response, handled, err := s.Handle("/BattleRetry", retry)
|
||||
if err != nil || !handled || code != 58 {
|
||||
t.Fatalf("retry code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
index, found, err := wire.Varint(response, 3)
|
||||
if err != nil || !found || index != 77 {
|
||||
t.Fatalf("retry battle index=%d found=%v err=%v", index, found, err)
|
||||
}
|
||||
var restored [][]byte
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 2 {
|
||||
restored = append(restored, field.Value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil || len(restored) != 2 || string(restored[0]) != string(first) || string(restored[1]) != string(second) {
|
||||
t.Fatalf("retry team=%x err=%v", restored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
|
||||
s := &Service{}
|
||||
s.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
|
||||
return []gamedata.PictorialBuffStat{{StatType: 2, Value: .0175}, {StatType: 4, Value: .01}}, nil
|
||||
})
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(9), 4, 1), 5, 1)
|
||||
code, response, handled, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil || !handled || code != 52 {
|
||||
t.Fatalf("battle entry code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
var count int
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 4 {
|
||||
count++
|
||||
stat, found, err := wire.Varint(field.Value, 1)
|
||||
if err != nil || !found || (stat != 2 && stat != 4) {
|
||||
t.Fatalf("invalid battle buff stat=%d found=%v err=%v", stat, found, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil || count != 2 {
|
||||
t.Fatalf("battle buffs count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentPack := 22
|
||||
s := NewService("test-root", "test-version", inventory, func() (int, error) {
|
||||
return currentPack, nil
|
||||
})
|
||||
var loadedPack int
|
||||
var loadedDeck uint64
|
||||
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
|
||||
loadedPack, loadedDeck = packID, deckID
|
||||
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
|
||||
}
|
||||
|
||||
enter := wire.AppendVarint(request(1), 3, 7)
|
||||
enter = wire.AppendVarint(enter, 4, 9)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A later world transition must not change the identity of an in-flight
|
||||
// battle; the pack is captured at BattleEnter.
|
||||
currentPack = 21
|
||||
end := wire.AppendVarint(request(2), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadedPack != 22 || loadedDeck != 9 {
|
||||
t.Fatalf("reward lookup pack/deck=%d/%d, want 22/9", loadedPack, loadedDeck)
|
||||
}
|
||||
if got := inventory.GrantedItems("pack22:monster7:deck9"); len(got) != 1 || got[0].ID != 8 || got[0].Count != 3 {
|
||||
t.Fatalf("pack22 reward grant=%+v", got)
|
||||
}
|
||||
if got := inventory.GrantedItems("pack21:monster7:deck9"); len(got) != 0 {
|
||||
t.Fatalf("reward leaked into pack21 identity: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user