Files
bd2/go/internal/server/battle/battle.go
T
Flechazo 5b5a219dc2 feat(server): add special loot and data-driven gameplay rules
Generate equipment loot atomically, grant random boxes and prevent repeat claims.
Read gacha pools, pity, products, tickets, missions, presets, chapter rewards
and inventory costs from GameData; extend both collaboration UR ticket pools.
Sync owned recipes, configure startup state and calendars in versioned seeds,
and validate unlock tickets and revival talents.
2026-10-04 19:15:59 +08:00

442 lines
15 KiB
Go

// 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"
"math"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
type Service struct {
mu sync.Mutex
states map[string]*battleState
activeSession string
gameDataRoot string
gameDataVersion string
inventory *player.Inventory
currentPack func() (int, error)
currentDifficulty func() (uint64, error)
loadDifficultyDeck func(string, string, int, uint64, uint64) (uint64, error)
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error)
buffs func() ([]gamedata.PictorialBuffStat, error)
onTutorialWin func() error
onMonsterWin func() error
commitHealth func(map[uint64]uint64) error
}
type battleState struct {
entered bool
index uint64
round uint64
monster uint64
deck uint64
pack int
initialBlue [][]byte
phases []gamedata.BattlePhase
phase int
phaseStarted bool
phaseSeq uint64
phaseReply []byte
}
// BeginSession discards an unfinished battle when LoginUser creates a new
// game session. Persistent rewards are written only by a successful BattleEnd
// request transaction, so reconnect returns to the last pre-battle commit.
func (s *Service) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" {
return
}
if s.states == nil {
s.states = make(map[string]*battleState)
}
if s.states[id] == nil {
if len(s.states) >= 1024 {
for key := range s.states {
if key != id {
delete(s.states, key)
break
}
}
}
s.states[id] = &battleState{}
}
s.activeSession = id
}
func (s *Service) AttachMonsterWinMission(callback func() error) { s.onMonsterWin = callback }
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
// AttachCommittedHealth persists only completed battle results. Round state
// remains transient, so reconnect rolls back an unfinished battle.
// The callback may normalize values to field HP; the response uses those
// committed values rather than echoing battle-only HP buffs.
func (s *Service) AttachCommittedHealth(callback func(map[uint64]uint64) error) {
s.commitHealth = callback
}
func (s *Service) Active() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.stateLocked().entered
}
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,
states: make(map[string]*battleState),
}
}
// AttachCurrentDifficulty selects the GameData quest deck for the active pack.
func (s *Service) AttachCurrentDifficulty(resolve func() (uint64, error)) {
s.currentDifficulty = resolve
}
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" && path != "/BattlePhaseChange" {
return 0, nil, false, nil
}
if err := checkSeq(request); err != nil {
return 0, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
state := s.stateLocked()
switch path {
case "/BattlePhaseChange":
if !state.entered {
return 0, nil, true, errors.New("battle: phase change before enter")
}
seq, _, _ := wire.Varint(request, 1)
if seq == state.phaseSeq && state.phaseReply != nil {
return 632, append([]byte(nil), state.phaseReply...), true, nil
}
if seq <= state.phaseSeq {
return 0, nil, true, errors.New("battle: stale phase change sequence")
}
if len(state.phases) == 0 || state.phase+1 >= len(state.phases) {
return 0, nil, true, errors.New("battle: no next phase")
}
if !state.phaseStarted {
return 0, nil, true, errors.New("battle: phase change before current phase start")
}
next := state.phases[state.phase+1]
response := wire.AppendVarint(nil, 1, next.GroupID)
response = wire.AppendVarint(response, 2, next.ID)
response = wire.AppendVarint(response, 8, next.DeckID)
// The local engine does not simulate combat. With verification disabled
// and no battle_result the client explicitly preserves its blue team;
// fabricating a verified result would overwrite HP, SP and action state.
// Dynamic official turn/SP values are not available in this request.
state.phase++
state.index, state.deck, state.phaseStarted = next.DeckID, next.DeckID, false
state.phaseSeq, state.phaseReply = seq, append([]byte(nil), response...)
slog.Info("team trace: battle phase changed", "pack", state.pack, "monster", state.monster, "group", next.GroupID, "phase", next.ID, "enemyDeck", next.DeckID)
return 632, response, true, nil
case "/BattleVerifyState":
if !state.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)
if mode == 1 && s.currentDifficulty != nil {
difficulty, resolveErr := s.currentDifficulty()
if resolveErr != nil {
return 0, nil, true, fmt.Errorf("battle: resolve difficulty: %w", resolveErr)
}
loader := s.loadDifficultyDeck
if loader == nil {
loader = gamedata.BattleDeckForDifficulty
}
deck, err = loader(s.gameDataRoot, s.gameDataVersion, packID, deck, difficulty)
if err != nil {
return 0, nil, true, fmt.Errorf("battle: select difficulty deck: %w", err)
}
}
var phases []gamedata.BattlePhase
if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) {
loader := s.loadPhases
if loader == nil {
loader = gamedata.BattleDeckPhases
}
phases, err = loader(s.gameDataRoot, s.gameDataVersion, packID, monster, deck)
if err != nil {
return 0, nil, true, fmt.Errorf("battle: load phases: %w", err)
}
if len(phases) != 0 && phases[0].DeckID != deck {
return 0, nil, true, errors.New("battle: enter must select first phase deck")
}
}
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)
state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil
state.monster, state.deck, state.pack = monster, deck, packID
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
case "/BattleRetry":
if !state.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(state.initialBlue) == 0 {
return 0, nil, true, errors.New("battle: retry before initial battle state")
}
if len(state.phases) != 0 {
// Retry requests carry the current deck; the response must restore
// the first phase's deck, as PhaseBattleManager.ApplyRetryResponse does.
if index != state.index {
return 0, nil, true, errors.New("battle: retry index does not match current phase")
}
index = state.phases[0].DeckID
}
var response []byte
for _, character := range state.initialBlue {
response = wire.AppendBytes(response, 2, character)
}
response = wire.AppendVarint(response, 3, index)
state.index, state.round = index, 0
if len(state.phases) != 0 {
state.deck = state.phases[0].DeckID
}
state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil
return 58, response, true, nil
case "/BattleStart":
if !state.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 state.index != 0 && index != state.index {
return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", state.index, index)
}
if len(state.phases) != 0 && index != state.phases[state.phase].DeckID {
return 0, nil, true, errors.New("battle: start index does not match current phase")
}
nextRound := state.round + 1
var initialBlue [][]byte
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 nextRound == 1 {
initialBlue = append(initialBlue, append([]byte(nil), field.Value...))
}
}
return nil
})
if err != nil {
return 0, nil, true, err
}
state.index, state.round, state.phaseStarted = index, nextRound, true
if nextRound == 1 {
state.initialBlue = initialBlue
}
// Stable per-battle/round seed; reproducible across retries.
seed := index*7919 + state.round*104729
response = wire.AppendVarint(response, 3, seed)
return 14, response, true, nil
case "/BattleEnd":
if !state.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")
}
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)
var resultCharacters [][]byte
finishedHealth := make(map[uint64]uint64)
participants := make(map[uint64]bool)
if s.commitHealth != nil {
for _, character := range state.initialBlue {
index, _, err := wire.Varint(character, 2)
if err != nil {
return 0, nil, true, err
}
if index != 0 {
participants[index] = true
}
}
}
err = wire.Walk(request, func(field wire.Field) error {
if field.Number == 3 && field.Type == 2 {
if s.commitHealth != nil {
index, present, err := wire.Varint(field.Value, 1)
if err != nil || !present || !participants[index] {
return errors.New("battle: result character was not a battle participant")
}
if _, duplicate := finishedHealth[index]; duplicate {
return errors.New("battle: duplicate result character")
}
hp, _, err := wire.Varint(field.Value, 3)
if err != nil || hp > math.MaxInt64 {
return errors.New("battle: invalid result health")
}
finishedHealth[index] = hp
}
resultCharacters = append(resultCharacters, field.Value)
}
return nil
})
if err != nil {
return 0, nil, true, err
}
if s.commitHealth != nil && len(finishedHealth) != 0 {
if err := s.commitHealth(finishedHealth); err != nil {
return 0, nil, true, fmt.Errorf("battle: persist completed character health: %w", err)
}
}
for _, character := range resultCharacters {
if s.commitHealth != nil {
index, _, _ := wire.Varint(character, 1)
character, _, err = wire.ReplaceVarint(character, 3, finishedHealth[index])
if err != nil {
return 0, nil, true, err
}
}
response = wire.AppendBytes(response, 3, character)
}
rewardBundle := false
if result == 1 && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" {
if state.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, state.pack, state.deck)
if rewardErr != nil {
return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", state.pack, state.monster, state.deck, rewardErr)
}
items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", state.pack, state.monster, state.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 && state.monster != 0 && s.onMonsterWin != nil {
if err := s.onMonsterWin(); err != nil {
return 0, nil, true, fmt.Errorf("battle: monster win mission: %w", err)
}
}
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)
}
state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil
return 15, response, true, nil
case "/BattleExit":
state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil
return 388, nil, true, nil
}
panic("unreachable")
}
func (s *Service) stateLocked() *battleState {
if s.states == nil {
s.states = make(map[string]*battleState)
}
key := s.activeSession
if key == "" {
key = "__direct_test__"
}
state := s.states[key]
if state == nil {
state = &battleState{}
s.states[key] = state
}
return state
}