feat(server): complete field object rewards, effects, positions and calendar resets

This commit is contained in:
2026-10-06 15:23:31 +08:00
parent edef3ca008
commit 626fc09a55
26 changed files with 1671 additions and 55 deletions
+1
View File
@@ -699,6 +699,7 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("attach field monster state: %w", err)
}
battleService.AttachFieldMonsters(worldService)
battleService.AttachFieldBuffConsume(worldService.ConsumeFieldBattleBuff)
if err := worldService.AttachFieldBuffRuntime(gameData, *gameDataVersion); err != nil {
return fmt.Errorf("attach field monster damage: %w", err)
}
+11 -2
View File
@@ -36,6 +36,7 @@ type Service struct {
monsterHunt MonsterHuntRuntime
eventBattles []EventBattleRuntime
fieldMonsters FieldMonsterRuntime
consumeFieldBuff func(string) error
}
type FieldMonsterRuntime interface {
BeginFieldMonsterBattle(int, uint64, uint64) (string, bool, error)
@@ -44,6 +45,8 @@ type FieldMonsterRuntime interface {
func (s *Service) AttachFieldMonsters(runtime FieldMonsterRuntime) { s.fieldMonsters = runtime }
func (s *Service) AttachFieldBuffConsume(consume func(string) error) { s.consumeFieldBuff = consume }
// EventBattleRuntime owns event stage eligibility, costs and settlement while
// the normal battle service transports the client's turn simulation.
type EventBattleRuntime interface {
@@ -339,11 +342,17 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
// The local engine is the normal deterministic engine.
response = wire.AppendVarint(response, 6, 1)
seq, _, _ := wire.Varint(request, 1)
identity := fmt.Sprintf("%s:%d", s.activeSession, seq)
if s.consumeFieldBuff != nil {
if err := s.consumeFieldBuff(identity); err != nil {
return 0, nil, true, fmt.Errorf("battle: consume field buff: %w", err)
}
}
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.enterReceipt = identity
state.fieldInstance = fieldInstance
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)
@@ -0,0 +1,56 @@
package battle
import (
"bd2server/internal/server/wire"
"errors"
"testing"
)
func TestFieldBuffConsumptionAfterValidationAndBeforeBattleActivation(t *testing.T) {
s := NewService("", "", nil, nil)
s.BeginSession("first-session")
remaining := 3
used := map[string]bool{}
fail := false
s.AttachFieldBuffConsume(func(identity string) error {
if fail {
return errors.New("field buff persistence failed")
}
if !used[identity] {
remaining--
used[identity] = true
}
return nil
})
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", request(1)); err == nil || remaining != 3 {
t.Fatal("invalid battle consumed buff")
}
fail = true
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("persistence failure activated battle")
}
fail = false
for i := 0; i < 2; i++ {
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
}
if remaining != 2 {
t.Fatal("same enter receipt consumed twice")
}
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 1), 5, wire.AppendVarint(nil, 1, 6))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 1)); err != nil {
t.Fatal(err)
}
if remaining != 2 {
t.Fatal("retry consumed extra field buff")
}
s.BeginSession("second-session")
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil || remaining != 1 {
t.Fatal("new battle did not consume once", err)
}
}
@@ -0,0 +1,42 @@
package eventplay
import "fmt"
// FieldObjectEventPeriod follows EventLostCoinInfo: the exact public calendar
// must be active, and the installed event must explicitly contain this pack.
func (s *Service) FieldObjectEventPeriod(pack int) (string, int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now().UnixMilli()
period := ""
var end int64
for _, schedule := range s.registry.List() {
if schedule.Type != 14 || now < schedule.Start || now >= schedule.End {
continue
}
row, err := s.design.Row("EventLostCoinTable", 4, schedule.ID)
if err != nil {
return "", 0, err
}
packs, err := list(row, 1)
if err != nil {
return "", 0, err
}
matches := false
for _, id := range packs {
matches = matches || id == uint64(pack)
}
if !matches {
continue
}
if period != "" {
return "", 0, fmt.Errorf("eventplay: ambiguous active lost coin calendar for pack %d", pack)
}
period = fmt.Sprintf("event:%d:%d:%d:%d", schedule.UID, schedule.ID, schedule.Start, schedule.End)
end = schedule.End
}
if period == "" {
return "", 0, fmt.Errorf("eventplay: no active lost coin calendar for pack %d", pack)
}
return period, end, nil
}
@@ -0,0 +1,36 @@
package eventplay
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"testing"
"time"
)
func TestLostCoinPeriodFollowsCalendarIdentityPackAndWindow(t *testing.T) {
registry := events.NewRegistry()
row := wire.AppendVarint(wire.AppendVarint(nil, 4, 9), 1, 21)
s := &Service{registry: registry, design: &gamedata.EventPlayCatalog{Tables: map[string][][]byte{"EventLostCoinTable": {row}}}, now: func() time.Time { return time.UnixMilli(150) }}
if err := registry.Replace([]events.Schedule{{UID: 1, Type: 14, ID: 9, Start: 100, End: 200}}); err != nil {
t.Fatal(err)
}
first, end, err := s.FieldObjectEventPeriod(21)
if err != nil || end != 200 || first == "" {
t.Fatal(first, end, err)
}
if _, _, err = s.FieldObjectEventPeriod(22); err == nil {
t.Fatal("event applied to unlisted pack")
}
s.now = func() time.Time { return time.UnixMilli(200) }
if _, _, err = s.FieldObjectEventPeriod(21); err == nil {
t.Fatal("event remained open at end boundary")
}
if err = registry.Replace([]events.Schedule{{UID: 2, Type: 14, ID: 9, Start: 200, End: 300}}); err != nil {
t.Fatal(err)
}
next, _, err := s.FieldObjectEventPeriod(21)
if err != nil || first == next {
t.Fatal("new calendar reused old claim period", err)
}
}
@@ -24,6 +24,7 @@ func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) {
return nil, e
}
names := []string{"PackEventHubTable", "PackEventListTable", "PackEventStoryGroupTable", "PackEventStoryTable", "PackEventBattleGroupTable", "PackEventBattleTable", "PackEventMiniGameTable", "FieldMiniGameRewardTable", "FieldMiniGameSpeedTable", "RhythmGameMusicTable", "RhythmGameGradeTable", "SichuanStageTable", "SichuanEventTable", "SichuanRewardTable", "HopscotchStageTable", "HopscotchRewardTable", "HopscotchDefaultTable", "FieldMiniGameSurvivalTable", "ActionGameDefaultTable", "ActionGameMissionTable", "ActionGameStageTable", "FieldMiniGameCharTable", "FieldMiniGameMapTable", "FieldMiniGameUpgradeTable", "FieldMiniGameUpgradeGroupTable", "FieldMiniGameSkillGroupTable", "FieldMiniGameSkillTable", "FieldMiniGameCharLevelTable", "FieldMiniGameMonsterTable", "FieldMiniGameSurvivalItemTable", "FieldMiniGameSurvivalBoxTable", "MGDRewardTable", "MGDWaveTable", "MGDDefaultTable"}
names = append(names, "EventLostCoinTable")
for _, name := range names {
rows, e := db.Query("SELECT ProtoBuf FROM " + name)
if e != nil {
@@ -0,0 +1,86 @@
package gamedata
import (
"database/sql"
"fmt"
)
type FieldActionObject struct {
ID, GroupID, MapID, QuestID, QuestEnableType, Type, RegenSeconds int
}
func loadFieldActionObjects(db *sql.DB) (map[int]FieldActionObject, error) {
out := map[int]FieldActionObject{}
var tables int
if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('FieldActionObjectTable','FieldActionObjectGroupTable')").Scan(&tables); err != nil {
return nil, err
}
if tables == 0 {
return out, nil
}
if tables != 2 {
return nil, fmt.Errorf("gamedata: incomplete field action tables")
}
groups := map[int]FieldActionObject{}
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldActionObjectGroupTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
group := FieldActionObject{GroupID: id}
for field, dst := range map[int]*int{3: &group.RegenSeconds, 4: &group.Type} {
v, e := optionalScalar(raw, field)
if e != nil || v > 0x7fffffff {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid action group %d", id)
}
*dst = int(v)
}
if id <= 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid action group identity")
}
groups[id] = group
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,ProtoBuf FROM FieldActionObjectTable")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return nil, err
}
groupID, e := optionalScalar(raw, 4)
group, found := groups[int(groupID)]
if e != nil || groupID > 0x7fffffff || !found || id <= 0 {
return nil, fmt.Errorf("gamedata: invalid action object group %d", id)
}
group.ID = id
for field, dst := range map[int]*int{8: &group.MapID, 10: &group.QuestEnableType, 11: &group.QuestID} {
v, e := optionalScalar(raw, field)
if e != nil || v > 0x7fffffff {
return nil, fmt.Errorf("gamedata: invalid action object %d", id)
}
*dst = int(v)
}
if group.MapID <= 0 || group.QuestEnableType > 2 {
return nil, fmt.Errorf("gamedata: invalid action object map/quest %d", id)
}
out[id] = group
}
return out, rows.Err()
}
@@ -10,6 +10,7 @@ import (
type FieldBuffDesign struct {
ID, Type, TargetType uint64
Value float64
Time float64
}
func optionalDouble(raw []byte, field int) (float64, error) {
@@ -57,6 +58,9 @@ func LoadFieldBuffDesign(root, version string) (map[uint64]FieldBuffDesign, erro
return nil, x
}
r.Value = v
if r.Time, e = optionalDouble(raw, 5); e != nil {
return nil, e
}
if id == 0 {
return nil, fmt.Errorf("gamedata: invalid field buff")
}
+14 -1
View File
@@ -9,6 +9,7 @@ import (
// reward graphs, so callers can reject them without inventing a reward.
type FieldObjectDesign struct {
Objects map[int]FieldRewardObject
Actions map[int]FieldActionObject
Equipment *EquipmentGachaCatalog
}
type FieldRewardObject struct {
@@ -46,6 +47,13 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
return design, err
}
obj := FieldRewardObject{GroupID: id}
for _, field := range []int{2, 3} {
v, e := packedInts(raw, field)
if e != nil || len(v) > 0 {
rows.Close()
return design, fmt.Errorf("gamedata: field object random buff graph %d requires authored selection rules", id)
}
}
for f, target := range map[int]*int{1: &obj.BuffID, 7: &obj.MonsterID, 8: &obj.QuestID, 9: &obj.ResetType, 12: &obj.Type} {
v, e := packedInts(raw, f)
if e != nil || len(v) > 1 {
@@ -132,7 +140,12 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
}
design.Objects[id] = obj
}
return design, rows.Err()
if err = rows.Err(); err != nil {
return design, err
}
rows.Close()
design.Actions, err = loadFieldActionObjects(db)
return design, err
}
// DropType 0 makes DropCount weighted selections. DropType 1 checks each
@@ -61,3 +61,18 @@ func (s FieldResetSchedule) Period(reset int, now time.Time) (string, error) {
}
return shifted.Format("2006-01-02"), nil
}
func (s FieldResetSchedule) Next(reset int, now time.Time) (time.Time, error) {
if reset != 0 && reset != 3 {
return time.Time{}, fmt.Errorf("gamedata: field reset has no calendar deadline")
}
shifted := now.UTC().Add(9*time.Hour - s.DailyReset)
start := time.Date(shifted.Year(), shifted.Month(), shifted.Day(), 0, 0, 0, 0, time.UTC)
if reset == 3 {
days := (int(shifted.Weekday()) - int(s.WeeklyDay) + 7) % 7
start = start.AddDate(0, 0, 7-days)
} else {
start = start.AddDate(0, 0, 1)
}
return start.Add(s.DailyReset - 9*time.Hour), nil
}
@@ -0,0 +1,62 @@
package progress
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// FieldActionPosition stores the dropped position and quest state at that time.
// It is read from the transaction on demand, avoiding stale caches on rollback.
type FieldActionPosition struct {
Position []byte `json:"position"`
QuestCleared bool `json:"quest_cleared"`
}
func (s *Store) SaveFieldActionPosition(pack, id int, position FieldActionPosition) error {
s.mu.Lock()
defer s.mu.Unlock()
if pack <= 0 || id <= 0 || len(position.Position) == 0 {
return fmt.Errorf("progress: invalid field action position")
}
store, err := s.fieldRewardEntries()
if err != nil {
return err
}
raw, err := json.Marshal(position)
if err != nil {
return err
}
return store.PutEntry("progress", "field_action_positions", fieldRewardKey(pack, id), raw)
}
func (s *Store) FieldActionPositions(pack int) (map[int]FieldActionPosition, error) {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return nil, err
}
entries, err := store.ListEntries("progress", "field_action_positions")
if err != nil {
return nil, err
}
out := map[int]FieldActionPosition{}
prefix := fmt.Sprintf("%d:", pack)
for key, raw := range entries {
if !strings.HasPrefix(key, prefix) {
continue
}
id, err := strconv.Atoi(strings.TrimPrefix(key, prefix))
if err != nil || id <= 0 {
return nil, fmt.Errorf("progress: invalid field action key")
}
var value FieldActionPosition
if err = json.Unmarshal(raw, &value); err != nil || len(value.Position) == 0 {
return nil, fmt.Errorf("progress: invalid field action position")
}
out[id] = value
}
return out, nil
}
@@ -0,0 +1,81 @@
package progress
import (
"bd2server/internal/server/wire"
"fmt"
"sort"
"strconv"
)
func (s *Store) SaveFieldBuff(id uint64, raw []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
storedID, _, err := wire.Varint(raw, 1)
if err != nil || id == 0 || id > 0x7fffffff || storedID != id {
return fmt.Errorf("progress: invalid field buff identity")
}
store, err := s.fieldRewardEntries()
if err != nil {
return err
}
return store.PutEntry("progress", "field_buffs", strconv.FormatUint(id, 10), raw)
}
func (s *Store) FieldBuffs() ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return nil, err
}
entries, err := store.ListEntries("progress", "field_buffs")
if err != nil {
return nil, err
}
ids := make([]uint64, 0, len(entries))
for key, raw := range entries {
id, err := strconv.ParseUint(key, 10, 32)
storedID, _, parseErr := wire.Varint(raw, 1)
if err != nil || parseErr != nil || id == 0 || id > 0x7fffffff || id != storedID {
return nil, fmt.Errorf("progress: invalid saved field buff")
}
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var out [][]byte
for _, id := range ids {
out = append(out, entries[strconv.FormatUint(id, 10)])
}
return out, nil
}
func (s *Store) RemoveFieldBuff(id uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return err
}
_, err = store.DeleteEntry("progress", "field_buffs", strconv.FormatUint(id, 10))
return err
}
func (s *Store) ClaimFieldBuffBattle(identity string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if identity == "" {
return false, fmt.Errorf("progress: empty field buff battle identity")
}
store, err := s.fieldRewardEntries()
if err != nil {
return false, err
}
_, found, err := store.LoadEntry("progress", "field_buff_battles", identity)
if err != nil || found {
return false, err
}
if err = store.PutEntry("progress", "field_buff_battles", identity, []byte("used")); err != nil {
return false, err
}
return true, nil
}
@@ -388,7 +388,7 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
if !known {
return nil, fmt.Errorf("achievement: opened field design absent")
}
period, err := s.fieldObjectPeriod(obj)
period, err := s.fieldObjectPeriodFor(pack, obj)
if err != nil {
continue
}
@@ -88,6 +88,11 @@ func (s *Service) enterEventFieldPack(pack gamedata.EventFieldPack) (int, []byte
position = saved.RawJSON
}
response := wire.AppendString(nil, 4, position)
buffs, err := s.fieldBuffInfo()
if err != nil {
return 0, nil, true, err
}
response = append(response, buffs...)
// The common callback dereferences HuntingGroundInfo even in hidden packs.
// Use the domain-generated empty/current snapshot; never borrow the outside
// map's monsters or story progress.
+99
View File
@@ -0,0 +1,99 @@
package world
import (
"bd2server/internal/server/progress"
"bd2server/internal/server/wire"
"encoding/binary"
"fmt"
"math"
"sort"
)
func (s *Service) handleFieldObjectPosition(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil {
return 95, nil, true, err
}
group, _, err := wire.Varint(request, 3)
if err != nil || group == 0 || group > 0x7fffffff {
return 95, nil, true, ErrInvalidRequest
}
id, _, err := wire.Varint(request, 4)
if err != nil || id == 0 || id > 0x7fffffff {
return 95, nil, true, ErrInvalidRequest
}
position, found, err := wire.Bytes(request, 5)
if err != nil || !found {
return 95, nil, true, ErrInvalidRequest
}
mapID, _, err := wire.Varint(position, 1)
if err != nil || mapID == 0 || mapID > 0x7fffffff {
return 95, nil, true, ErrInvalidRequest
}
if err = wire.Walk(position, func(f wire.Field) error {
if f.Number >= 2 && f.Number <= 4 {
if f.Type != 5 {
return ErrInvalidRequest
}
value := math.Float32frombits(binary.LittleEndian.Uint32(f.Value))
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return ErrInvalidRequest
}
}
return nil
}); err != nil {
return 95, nil, true, err
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return 95, nil, true, err
}
obj, exists := design.Actions[int(id)]
if !exists || obj.GroupID != int(group) || obj.Type != 1 || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) {
return 95, nil, true, fmt.Errorf("%w: unavailable field action object", ErrInvalidRequest)
}
currentMap, err := s.currentFieldMap(pack)
if err != nil || currentMap != int(mapID) {
return 95, nil, true, fmt.Errorf("%w: field action outside current map", ErrInvalidRequest)
}
cleared := obj.QuestID > 0 && s.state.QuestCleared(obj.QuestID, pack, s.questDifficulty(pack))
if obj.QuestID > 0 && (obj.QuestEnableType == 1 && !cleared || obj.QuestEnableType == 2 && cleared) {
return 95, nil, true, fmt.Errorf("%w: field action unavailable for quest", ErrInvalidRequest)
}
if err = s.state.SaveFieldActionPosition(pack, int(id), progress.FieldActionPosition{Position: position, QuestCleared: cleared}); err != nil {
return 95, nil, true, err
}
return 95, []byte{}, true, nil
}
func (s *Service) fieldActionInfo(pack int) ([]byte, error) {
positions, err := s.state.FieldActionPositions(pack)
if err != nil || len(positions) == 0 {
return nil, err
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
ids := make([]int, 0, len(positions))
for id := range positions {
ids = append(ids, id)
}
sort.Ints(ids)
var out []byte
for _, id := range ids {
obj, found := design.Actions[id]
if !found {
return nil, fmt.Errorf("world: saved field action absent from design")
}
stored := positions[id]
// Client resets authored action positions when the related quest ends.
// Omit that stale position on reconnect so the prefab uses its origin.
if obj.QuestID > 0 && stored.QuestCleared != s.state.QuestCleared(obj.QuestID, pack, s.questDifficulty(pack)) {
continue
}
info := wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(id)), 2, stored.Position)
out = wire.AppendBytes(out, 2, info)
}
return out, nil
}
@@ -0,0 +1,160 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/binary"
"errors"
"math"
"os"
"path/filepath"
"testing"
)
func TestCarriedFieldObjectSQLiteReconnectQuestResetAndRollback(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = repo.Close() }()
s := testService()
s.state, err = progress.OpenStore(repo)
if err != nil {
t.Fatal(err)
}
if err = s.state.SetActivePackID(21); err != nil {
t.Fatal(err)
}
if err = s.state.SaveUserPosition(wire.AppendString(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21), 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`)); err != nil {
t.Fatal(err)
}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{}, Actions: map[int]gamedata.FieldActionObject{6011: {ID: 6011, GroupID: 601, MapID: 211, Type: 1, QuestID: 1}}}})
position := wire.AppendVarint(nil, 1, 211)
position = append(position, 0x15)
position = binary.LittleEndian.AppendUint32(position, math.Float32bits(2.5))
request := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21), 3, 601), 4, 6011), 5, position)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, _, handled, err := s.Handle("/FieldObjecPositionUpdate", request)
if err != nil || !handled || code != 95 {
t.Fatalf("carry save: %d %v %v", code, handled, err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
s.state, err = progress.OpenStore(repo)
if err != nil {
t.Fatal(err)
}
infoRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 21)
_, info, _, err := s.Handle("/FieldObjectInfo", infoRequest)
if err != nil {
t.Fatal(err)
}
object, found, err := wire.Bytes(info, 2)
saved, _, _ := wire.Bytes(object, 2)
x := float32(0)
_ = wire.Walk(saved, func(f wire.Field) error {
if f.Number == 2 {
x = math.Float32frombits(binary.LittleEndian.Uint32(f.Value))
}
return nil
})
if err != nil || !found || x != 2.5 {
t.Fatalf("reconnect lost carried position: %x %v", info, err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
changed := append([]byte(nil), request...)
changed[len(changed)-1] ^= 0x80
if _, _, _, err = s.Handle("/FieldObjecPositionUpdate", changed); err != nil {
t.Fatal(err)
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
s.state, err = progress.OpenStore(repo)
if err != nil {
t.Fatal(err)
}
_, after, _, err := s.Handle("/FieldObjectInfo", infoRequest)
if err != nil || string(info) != string(after) {
t.Fatal("rolled-back position survived")
}
bad := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 21), 3, 999), 4, 6011), 5, position)
if _, _, _, err = s.Handle("/FieldObjecPositionUpdate", bad); err == nil {
t.Fatal("forged group accepted")
}
if err = s.state.ClearQuest(1, 21, 0); err != nil {
t.Fatal(err)
}
_, after, _, err = s.Handle("/FieldObjectInfo", infoRequest)
_, found, _ = wire.Bytes(after, 2)
if err != nil || found {
t.Fatal("quest transition restored stale carried position")
}
}
func TestInstalledFieldObjectCountersUseClientIndependentCategories(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA to verify current field summary categories")
}
design, err := gamedata.LoadFieldObjects(root, "20260923193640", 21)
if err != nil {
t.Fatal(err)
}
s := testService()
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: design})
if len(design.Objects) != 9 {
t.Fatal("authored field regression requires review")
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)
request = wire.AppendVarint(wire.AppendVarint(request, 3, 2), 3, 3)
_, body, _, err := s.Handle("/PackRewardObjectCount", request)
if err != nil {
t.Fatal(err)
}
totals := map[uint64]uint64{}
if err = wire.Walk(body, func(f wire.Field) error {
if f.Number == 1 {
category, _, e := wire.Varint(f.Value, 1)
total, _, e2 := wire.Varint(f.Value, 4)
if e != nil {
return e
}
if e2 != nil {
return e2
}
totals[category] = total
}
return nil
}); err != nil {
t.Fatal(err)
}
if totals[2] != 9 || totals[3] != 0 {
t.Fatalf("one-time vs renewable counts: %v", totals)
}
}
+46 -21
View File
@@ -24,27 +24,9 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string)
if s.characters == nil || s.decks == nil {
return nil, fmt.Errorf("world: field health runtime unavailable")
}
entries := s.decks.CurrentFieldDeck()
indices := make([]uint64, 0, len(entries))
for _, entry := range entries {
indices = append(indices, entry.CharacterInvenIndex)
}
if r.TargetType == 0 {
party, e := s.ResolveStoryParty(pack, s.firstUnclearedQuestFor(pack))
if e != nil {
return nil, e
}
if len(party) > 0 && s.questDifficulty(pack) == 0 {
indices = []uint64{party[0].InvenIndex}
} else if s.decks.FieldControlType() == 0 || len(indices) == 0 {
indices = nil
for _, entry := range s.decks.CurrentDeck() {
indices = append(indices, entry.CharacterInvenIndex)
}
}
if len(indices) > 1 {
indices = indices[:1]
}
indices, e := s.fieldBuffTargets(pack, r.TargetType)
if e != nil {
return nil, e
}
var response [][]byte
for _, index := range indices {
@@ -82,3 +64,46 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string)
}
return response, nil
}
func (s *Service) fieldBuffTargets(pack int, target uint64) ([]uint64, error) {
if target > 2 || s.decks == nil {
return nil, fmt.Errorf("world: invalid field buff target")
}
var indices []uint64
if s.questDifficulty(pack) == 0 && s.storyRoster != nil {
if quest := s.firstUnclearedQuestFor(pack); quest != 0 {
party, err := s.ResolveStoryParty(pack, quest)
if err != nil {
return nil, err
}
for _, c := range party {
indices = append(indices, c.InvenIndex)
}
}
}
if len(indices) == 0 {
if s.decks.FieldControlType() == 0 || len(s.decks.CurrentFieldDeck()) == 0 {
for _, entry := range s.decks.CurrentDeck() {
indices = append(indices, entry.CharacterInvenIndex)
}
} else {
for _, entry := range s.decks.CurrentFieldDeck() {
indices = append(indices, entry.CharacterInvenIndex)
}
}
}
// The client animates either party target through the active field train;
// LEADER applies only to its first member, never all owned characters.
if target == 0 && len(indices) > 1 {
indices = indices[:1]
}
seen := map[uint64]bool{}
var unique []uint64
for _, id := range indices {
if !seen[id] {
unique = append(unique, id)
seen[id] = true
}
}
return unique, nil
}
@@ -0,0 +1,23 @@
package world
import "bd2server/internal/server/wire"
func (s *Service) fieldBuffInfo() ([]byte, error) {
rows, err := s.state.FieldBuffs()
if err != nil {
return nil, err
}
now := s.monsterTime().UnixMilli()
var out []byte
for _, row := range rows {
end, _, err := wire.Varint(row, 3)
if err != nil {
return nil, err
}
if end != 0 && (end > 0x7fffffffffffffff || int64(end) <= now) {
continue
}
out = wire.AppendBytes(out, 8, row)
}
return out, nil
}
@@ -0,0 +1,225 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
"math"
)
func (s *Service) openFieldObjectResponse(pack, group, id int) ([]byte, error) {
design, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
obj, ok := design.Objects[id]
if !ok || obj.GroupID != group || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) {
return nil, ErrInvalidRequest
}
if err := s.validateFieldObjectMap(pack, obj.MapID); err != nil {
return nil, err
}
period, err := s.fieldObjectPeriodFor(pack, obj)
if err != nil {
return nil, err
}
opened, err := s.state.FieldRewardOpened(pack, id, period)
if err != nil {
return nil, err
}
if opened {
return wire.AppendBytes(nil, 1, nil), nil
}
var effects []byte
if obj.BuffID != 0 {
buff, exists := s.fieldBuffs[uint64(obj.BuffID)]
if !exists {
return nil, fmt.Errorf("world: missing field object buff %d", obj.BuffID)
}
row, chars, e := s.applyFieldObjectBuff(pack, buff)
if e != nil {
return nil, e
}
effects = wire.AppendBytes(effects, 2, row)
for _, char := range chars {
effects = wire.AppendBytes(effects, 3, char)
}
} else if obj.MonsterID != 0 && len(obj.Rewards) == 0 {
if s.monsterLoader == nil || s.monsterStore == nil {
return nil, fmt.Errorf("world: dynamic field monster runtime unavailable")
}
monsters, e := s.monsterLoader(pack)
if e != nil {
return nil, e
}
found := false
for _, monster := range monsters {
if monster.ID != obj.MonsterID {
continue
}
if !s.monsterEligible(pack, monster) {
return nil, ErrInvalidRequest
}
snapshot, e := s.loadMonsterState()
if e != nil {
return nil, e
}
state, e := s.monsterState(&snapshot, pack, monster)
if e != nil {
return nil, e
}
if e = s.saveMonsterState(snapshot); e != nil {
return nil, e
}
effects = wire.AppendBytes(effects, 4, monsterWire(monster, state, true))
found = true
break
}
if !found {
return nil, fmt.Errorf("world: missing field object monster %d", obj.MonsterID)
}
}
if obj.BuffID != 0 {
if err := s.state.MarkFieldRewardOpened(pack, id, period); err != nil {
return nil, err
}
return append(wire.AppendBytes(nil, 1, nil), effects...), nil
}
bundle, err := s.openFieldObject(pack, group, id)
if err != nil {
return nil, err
}
return append(wire.AppendBytes(nil, 1, bundle), effects...), nil
}
func (s *Service) applyFieldObjectBuff(pack int, buff gamedata.FieldBuffDesign) ([]byte, [][]byte, error) {
if buff.ID == 0 || buff.Type > 5 || buff.TargetType > 2 || math.IsNaN(buff.Value) || math.IsInf(buff.Value, 0) || buff.Value < 0 || math.IsNaN(buff.Time) || math.IsInf(buff.Time, 0) || buff.Time < 0 {
return nil, nil, fmt.Errorf("world: invalid field object buff")
}
row := wire.AppendVarint(nil, 1, buff.ID)
if buff.Type <= 1 {
if buff.Time <= 0 || buff.Time > float64(^uint32(0)>>1) {
return nil, nil, fmt.Errorf("world: invalid persistent field buff duration")
}
if buff.Type == 0 {
row = wire.AppendVarint(row, 2, uint64(buff.Time))
} else {
row = wire.AppendVarint(row, 3, uint64(s.monsterTime().UnixMilli()+int64(buff.Time*1000)))
}
prior, err := s.state.FieldBuffs()
if err != nil {
return nil, nil, err
}
for _, raw := range prior {
id, _, e := wire.Varint(raw, 1)
if e != nil {
return nil, nil, e
}
old, found := s.fieldBuffs[id]
if !found {
return nil, nil, fmt.Errorf("world: saved field buff absent from design")
}
if old.Type == buff.Type {
if e := s.state.RemoveFieldBuff(id); e != nil {
return nil, nil, e
}
}
}
if err := s.state.SaveFieldBuff(buff.ID, row); err != nil {
return nil, nil, err
}
return row, nil, nil
}
if buff.Type == 4 || buff.Type == 5 {
chars, err := s.applyMonsterFieldDamage(pack, buff.ID, "")
return row, chars, err
}
if s.characters == nil || s.decks == nil {
return nil, nil, fmt.Errorf("world: field healing runtime unavailable")
}
indices, err := s.fieldBuffTargets(pack, buff.TargetType)
if err != nil {
return nil, nil, err
}
var chars [][]byte
for _, index := range indices {
c, found := s.characters.Find(index)
if !found {
return nil, nil, fmt.Errorf("world: field healing character missing")
}
hp, err := s.characters.CurrentHealth(index)
if err != nil {
return nil, nil, err
}
if hp == 0 {
continue
}
max, err := s.characters.MaxHealth(index)
if err != nil {
return nil, nil, err
}
amount := buff.Value
if buff.Type == 3 {
amount *= float64(max)
}
remaining := max
if hp < max && amount < float64(max-hp) {
remaining = hp + uint64(amount)
}
if err = s.characters.SetCurrentHealth(index, remaining); err != nil {
return nil, nil, err
}
c.HP = remaining
chars = append(chars, player.CharacterWire(c))
}
return row, chars, nil
}
// ConsumeFieldBattleBuff runs inside the successful BattleEnter transaction.
// A persisted battle identity makes retries and reconnects safe.
func (s *Service) ConsumeFieldBattleBuff(identity string) error {
if identity == "" {
return ErrInvalidRequest
}
rows, err := s.state.FieldBuffs()
if err != nil {
return err
}
var active []byte
for _, row := range rows {
id, _, e := wire.Varint(row, 1)
if e != nil {
return e
}
buff, found := s.fieldBuffs[id]
if !found {
return fmt.Errorf("world: saved battle field buff absent from design")
}
if buff.Type == 0 {
active = row
break
}
}
if active == nil {
return nil
}
claimed, err := s.state.ClaimFieldBuffBattle(identity)
if err != nil || !claimed {
return err
}
id, _, err := wire.Varint(active, 1)
if err != nil {
return err
}
count, _, err := wire.Varint(active, 2)
if err != nil {
return err
}
if count <= 1 {
return s.state.RemoveFieldBuff(id)
}
updated := wire.AppendVarint(nil, 1, id)
updated = wire.AppendVarint(updated, 2, count-1)
return s.state.SaveFieldBuff(id, updated)
}
@@ -0,0 +1,220 @@
package world
import (
"bd2server/internal/server/wire"
"fmt"
"sort"
)
func (s *Service) fieldObjectCurrentPack(pack int) bool {
s.activePackMu.RLock()
current := s.activePack
s.activePackMu.RUnlock()
if current == 0 {
current = s.state.ActivePackID()
}
return current == pack
}
func (s *Service) validateFieldObjectMap(pack, mapID int) error {
_, saved := s.state.Position()
_, event, err := s.resolveEventFieldPack(pack)
if err != nil {
return err
}
if !saved && !event {
return nil
}
current, err := s.currentFieldMap(pack)
if err != nil || current != mapID {
return fmt.Errorf("%w: field object outside current map", ErrInvalidRequest)
}
return nil
}
func fieldObjectInt(raw []byte, field int, required bool) (int, error) {
v, found, err := wire.Varint(raw, field)
if err != nil || required && (!found || v == 0) || v > uint64(^uint32(0)>>1) {
return 0, ErrInvalidRequest
}
return int(v), nil
}
// The client batches LostCoin collections. Validate the complete list before
// drawing or settling any component; the dispatcher commits the whole request.
func (s *Service) handleFieldObjectRewardList(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil {
return 260, nil, true, err
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return 260, nil, true, err
}
type selection struct{ group, id int }
var selections []selection
seen := map[int]bool{}
err = wire.Walk(request, func(f wire.Field) error {
if f.Number != 3 {
return nil
}
if f.Type != 2 || len(selections) >= 100 {
return ErrInvalidRequest
}
group, e := fieldObjectInt(f.Value, 3, true)
if e != nil {
return e
}
id, e := fieldObjectInt(f.Value, 4, true)
if e != nil {
return e
}
obj, ok := design.Objects[id]
if !ok || obj.GroupID != group || obj.BuffID != 0 || obj.MonsterID != 0 || len(obj.Rewards) == 0 && obj.Type != 6 {
return ErrInvalidRequest
}
if !seen[id] {
seen[id] = true
selections = append(selections, selection{group, id})
}
return nil
})
if err != nil || len(selections) == 0 || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) {
return 260, nil, true, ErrInvalidRequest
}
var bundle []byte
for _, selection := range selections {
part, e := s.openFieldObject(pack, selection.group, selection.id)
if e != nil {
return 260, nil, true, e
}
bundle = append(bundle, part...)
}
return 260, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Service) handleFieldObjectPreview(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil || !s.packUnlocked(pack) {
return 144, nil, true, ErrInvalidRequest
}
ids, err := s.openedFieldObjects(pack)
if err != nil {
return 144, nil, true, err
}
var response []byte
for _, id := range ids {
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id)))
}
research, err := s.state.ResearchObjects(pack)
if err != nil {
return 144, nil, true, err
}
for _, id := range research {
response = wire.AppendVarint(response, 2, uint64(id))
}
return 144, response, true, nil
}
// Respawn is a projection of the reset schedule, never an instruction to clear
// receipts. A request before the reset cannot make an object collectible again.
func (s *Service) handleFieldObjectRespawn(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil || !s.packUnlocked(pack) {
return 30, nil, true, ErrInvalidRequest
}
group, err := fieldObjectInt(request, 3, true)
if err != nil {
return 30, nil, true, err
}
kind, err := fieldObjectInt(request, 4, false)
if err != nil || kind != 0 && kind != 6 {
return 30, nil, true, ErrInvalidRequest
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return 30, nil, true, err
}
var ids []int
reset := -1
for id, obj := range design.Objects {
if obj.GroupID == group {
ids = append(ids, id)
if reset != -1 && reset != obj.ResetType {
return 30, nil, true, fmt.Errorf("world: inconsistent field reset group")
}
reset = obj.ResetType
}
}
if len(ids) == 0 {
return 30, nil, true, ErrInvalidRequest
}
sort.Ints(ids)
var response []byte
for _, id := range ids {
obj := design.Objects[id]
period, e := s.fieldObjectPeriodFor(pack, obj)
if e != nil {
return 30, nil, true, e
}
opened, e := s.state.FieldRewardOpened(pack, id, period)
if e != nil {
return 30, nil, true, e
}
if opened {
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id)))
}
}
if reset == 0 || reset == 3 {
next, e := s.fieldReset.Next(reset, s.monsterTime())
if e != nil {
return 30, nil, true, e
}
row := wire.AppendVarint(nil, 1, uint64(group))
row = wire.AppendVarint(row, 2, uint64(next.UnixMilli()))
response = wire.AppendBytes(response, 2, row)
} else if reset == 2 {
resolver, ok := s.eventFieldPacks.(interface {
FieldObjectEventPeriod(int) (string, int64, error)
})
if !ok {
return 30, nil, true, ErrInvalidRequest
}
_, end, err := resolver.FieldObjectEventPeriod(pack)
if err != nil {
return 30, nil, true, err
}
row := wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(group)), 2, uint64(end))
response = wire.AppendBytes(response, 2, row)
}
return 30, response, true, nil
}
func (s *Service) rewardMonsterAvailable(pack, monster int) (bool, error) {
if s.fieldObjectLoader == nil && s.fieldObjects == nil {
return true, nil
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return false, err
}
linked := false
for _, obj := range design.Objects {
if obj.MonsterID != monster || obj.BuffID != 0 || len(obj.Rewards) != 0 {
continue
}
linked = true
period, e := s.fieldObjectPeriodFor(pack, obj)
if e != nil {
continue
}
opened, e := s.state.FieldRewardOpened(pack, obj.ID, period)
if e != nil {
return false, e
}
if opened {
return true, nil
}
}
return !linked, nil
}
@@ -0,0 +1,378 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func sqliteFieldService(t *testing.T, store *fieldRewardFailStore) *Service {
t.Helper()
s := testService()
var err error
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
if err = s.state.SetActivePackID(21); err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
s.monsterStore = store
return s
}
func fieldBatchRequest(ids ...int) []byte {
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)
for _, id := range ids {
obj := wire.AppendVarint(wire.AppendVarint(nil, 3, 101), 4, uint64(id))
request = wire.AppendBytes(request, 3, obj)
}
return request
}
func TestFieldBatchSQLiteAtomicResetAndRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { repo.Close() }()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store)
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
s.monsterNow = func() time.Time { return now }
objects := map[int]gamedata.FieldRewardObject{}
for _, id := range []int{1001, 1002} {
objects[id] = gamedata.FieldRewardObject{ID: id, GroupID: 101, MapID: 211, Type: 6, ResetType: 0, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}
}
designs := map[int]gamedata.FieldObjectDesign{21: {Objects: objects}}
s.WithFieldObjects(designs)
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 9999)); err == nil {
t.Fatal("invalid later batch object accepted")
}
if s.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("invalid batch settled earlier object")
}
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err == nil {
t.Fatal("marker failure accepted")
}
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository, store.fail = repo, false
s = sqliteFieldService(t, store).WithFieldObjects(designs)
s.monsterNow = func() time.Time { return now }
if s.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("rollback persisted partial currency")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, reply, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002, 1001))
if err != nil || code != 260 {
t.Fatalf("batch code=%d err=%v", code, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
bundle, _, _ := wire.Bytes(reply, 1)
var total uint64
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 {
count, _, e := wire.Varint(f.Value, 4)
total += count
return e
}
return nil
}); err != nil {
t.Fatal(err)
}
if total != 100 || s.wallet.Snapshot().FreeJewelry != 100 {
t.Fatalf("batch delta=%d wallet=%+v", total, s.wallet.Snapshot())
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s = sqliteFieldService(t, store).WithFieldObjects(designs)
s.monsterNow = func() time.Time { return now }
_, reply, _, err = s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002))
if err != nil {
t.Fatal(err)
}
bundle, _, _ = wire.Bytes(reply, 1)
if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 100 {
t.Fatal("restart duplicate paid again")
}
respawnRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21), 3, 101)
_, before, _, err := s.handleFieldObjectRespawn(respawnRequest)
if err != nil {
t.Fatal(err)
}
if fieldWireCount(t, before, 1) != 2 {
t.Fatal("premature respawn cleared opened state")
}
now = now.Add(24 * time.Hour)
_, after, _, err := s.handleFieldObjectPreview(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 21))
if err != nil || fieldWireCount(t, after, 1) != 0 {
t.Fatalf("reset preview=%x err=%v", after, err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if s.wallet.Snapshot().FreeJewelry != 200 {
t.Fatal("new reset period did not award")
}
}
func fieldWireCount(t *testing.T, raw []byte, number int) int {
t.Helper()
count := 0
if err := wire.Walk(raw, func(f wire.Field) error {
if f.Number == number {
count++
}
return nil
}); err != nil {
t.Fatal(err)
}
return count
}
func TestFieldEffectsSQLitePersistenceRepeatAndBattleConsumption(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { repo.Close() }()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store)
designs := map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{
1001: {ID: 1001, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 1},
1002: {ID: 1002, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 2},
1003: {ID: 1003, GroupID: 101, MapID: 211, ResetType: 1, MonsterID: 9},
}}}
buffs := map[uint64]gamedata.FieldBuffDesign{1: {ID: 1, Type: 0, Time: 2}, 2: {ID: 2, Type: 0, Time: 3}}
attach := func(s *Service) {
s.WithFieldObjects(designs)
s.fieldBuffs = buffs
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, Type: 3, BattleDeck: 99}}, nil
}
}
attach(s)
for _, id := range []int{1001, 1002, 1003} {
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err := s.openFieldObjectResponse(21, 101, id)
if err != nil {
t.Fatal(err)
}
if id == 1003 {
monster, _, _ := wire.Bytes(response, 4)
mid, _, _ := wire.Varint(monster, 1)
if mid != 9 {
t.Fatal("dynamic monster response missing")
}
} else if fieldWireCount(t, response, 2) != 1 {
t.Fatal("buff response missing")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
s = sqliteFieldService(t, store)
attach(s)
rows, err := s.state.FieldBuffs()
if err != nil || len(rows) != 1 {
t.Fatalf("buff rows=%v err=%v", rows, err)
}
id, _, _ := wire.Varint(rows[0], 1)
count, _, _ := wire.Varint(rows[0], 2)
if id != 2 || count != 3 {
t.Fatal("same-type replacement or persisted duration lost")
}
response, err := s.openFieldObjectResponse(21, 101, 1002)
if err != nil || fieldWireCount(t, response, 2) != 0 {
t.Fatal("repeat reapplied buff")
}
for _, identity := range []string{"battle-a", "battle-a", "battle-b", "battle-c"} {
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := s.ConsumeFieldBattleBuff(identity); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
rows, err = s.state.FieldBuffs()
if err != nil || len(rows) != 0 {
t.Fatal("battle retries decremented incorrectly or buff never expired")
}
snapshot, err := s.loadMonsterState()
if err != nil || len(snapshot.Monsters) != 1 {
t.Fatal("dynamic monster spawn lost on restart")
}
}
func TestInstalledFieldChestAndHealingObjectSQLiteSettlement(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed field settlement regression")
}
version := "20260923193640"
chests, err := gamedata.LoadFieldObjects(root, version, 21)
if err != nil {
t.Fatal(err)
}
traps, err := gamedata.LoadFieldObjects(root, version, 1)
if err != nil {
t.Fatal(err)
}
buffs, err := gamedata.LoadFieldBuffDesign(root, version)
if err != nil {
t.Fatal(err)
}
var chest, healer gamedata.FieldRewardObject
for _, obj := range chests.Objects {
if len(obj.Rewards) > 0 && (chest.ID == 0 || obj.ID < chest.ID) {
chest = obj
}
}
for _, obj := range traps.Objects {
if buff, ok := buffs[uint64(obj.BuffID)]; ok && buff.Type == 3 && (healer.ID == 0 || obj.ID < healer.ID) {
healer = obj
}
}
if chest.ID == 0 || healer.ID == 0 {
t.Fatal("current authored chest/healer graphs missing")
}
repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer repo.Close()
store := &fieldRewardFailStore{Repository: repo}
s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: chests, 1: traps})
s.fieldBuffs = buffs
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err := s.openFieldObjectResponse(21, chest.GroupID, chest.ID)
if err != nil {
t.Fatal(err)
}
bundle, _, _ := wire.Bytes(response, 1)
if len(bundle) == 0 {
t.Fatal("installed chest gave no authored rewards")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, Quests: map[int]gamedata.QuestDesign{1: {ID: 1}}, MainQuestIDs: []int{1}}
if s.packs == nil {
s.packs = map[int]map[int]gamedata.QuestDesign{}
}
s.packs[1] = s.storyCatalog.Packs[1].Quests
if err := s.state.SetActivePackID(1); err != nil {
t.Fatal(err)
}
s.setCurrentPack(1)
s.characters, err = player.OpenCharacterStore(store, []player.Character{{ID: 350, InvenIndex: 77, Level: 1, HP: 50}}, s.inventory, "", "")
if err != nil {
t.Fatal(err)
}
s.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil })
if err := s.characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := s.characters.SetCurrentHealth(77, 50); err != nil {
t.Fatal(err)
}
s.decks, err = deck.NewStore(deck.Seed{Version: s.seed.Version, FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}})
if err != nil {
t.Fatal(err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
response, err = s.openFieldObjectResponse(1, healer.GroupID, healer.ID)
if err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
hp, err := s.characters.CurrentHealth(77)
if err != nil || hp != 80 || fieldWireCount(t, response, 2) != 1 || fieldWireCount(t, response, 3) != 1 {
t.Fatalf("installed 30%% healer hp=%d response=%x err=%v", hp, response, err)
}
if _, err := s.openFieldObjectResponse(1, healer.GroupID, healer.ID); err != nil {
t.Fatal(err)
}
hp, _ = s.characters.CurrentHealth(77)
if hp != 80 {
t.Fatal("healing retry applied twice")
}
}
+40 -13
View File
@@ -5,7 +5,6 @@ import (
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
"time"
)
func (s *Service) WithFieldObjects(designs map[int]gamedata.FieldObjectDesign) *Service {
@@ -63,7 +62,7 @@ func (s *Service) openedFieldObjects(pack int) ([]int, error) {
if !ok {
return nil, fmt.Errorf("world: saved field object absent from design")
}
period, e := s.fieldObjectPeriod(obj)
period, e := s.fieldObjectPeriodFor(pack, obj)
if e != nil {
continue
}
@@ -82,7 +81,26 @@ func (s *Service) WithFieldResetSchedule(schedule gamedata.FieldResetSchedule) *
return s
}
func (s *Service) fieldObjectPeriod(obj gamedata.FieldRewardObject) (string, error) {
return s.fieldReset.Period(obj.ResetType, time.Now())
return s.fieldReset.Period(obj.ResetType, s.monsterTime())
}
func (s *Service) fieldObjectPeriodFor(pack int, obj gamedata.FieldRewardObject) (string, error) {
if obj.ResetType == 2 {
resolver, ok := s.eventFieldPacks.(interface {
FieldObjectEventPeriod(int) (string, int64, error)
})
if !ok {
return "", fmt.Errorf("%w: field event calendar unavailable", ErrInvalidRequest)
}
period, _, err := resolver.FieldObjectEventPeriod(pack)
if err != nil {
return "", err
}
if period == "" {
return "", fmt.Errorf("%w: field event inactive", ErrInvalidRequest)
}
return "event:" + period, nil
}
return s.fieldObjectPeriod(obj)
}
func (s *Service) handleFieldObjectInfo(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
@@ -100,6 +118,11 @@ func (s *Service) handleFieldObjectInfo(request []byte) (int, []byte, bool, erro
for _, id := range ids {
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id)))
}
actions, err := s.fieldActionInfo(pack)
if err != nil {
return 0, nil, true, err
}
response = append(response, actions...)
return 28, response, true, nil
}
func (s *Service) handleFieldObjectReward(request []byte) (int, []byte, bool, error) {
@@ -115,11 +138,11 @@ func (s *Service) handleFieldObjectReward(request []byte) (int, []byte, bool, er
if err != nil || id == 0 || id > uint64(^uint32(0)>>1) {
return 0, nil, true, ErrInvalidRequest
}
bundle, err := s.openFieldObject(pack, int(group), int(id))
response, err := s.openFieldObjectResponse(pack, int(group), int(id))
if err != nil {
return 0, nil, true, err
}
return 29, wire.AppendBytes(nil, 1, bundle), true, nil
return 29, response, true, nil
}
func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
design, err := s.fieldObjectDesign(pack)
@@ -127,17 +150,13 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
return nil, err
}
obj, exists := design.Objects[id]
if !exists || obj.GroupID != group || !s.packUnlocked(pack) || s.state.ActivePackID() != pack {
if !exists || obj.GroupID != group || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) {
return nil, fmt.Errorf("%w: unavailable field object", ErrInvalidRequest)
}
if position, ok := s.state.Position(); ok && (position.PackID != pack || position.Position.MapID != obj.MapID) {
return nil, fmt.Errorf("%w: field object outside current map", ErrInvalidRequest)
if err := s.validateFieldObjectMap(pack, obj.MapID); err != nil {
return nil, err
}
if obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 {
return nil, fmt.Errorf("%w: unsupported field object reward graph/reset", ErrInvalidRequest)
}
period, err := s.fieldObjectPeriod(obj)
period, err := s.fieldObjectPeriodFor(pack, obj)
if err != nil {
return nil, err
}
@@ -148,6 +167,14 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
if opened {
return []byte{}, nil
}
// Buff and dynamic-monster objects have no loot group. Their state change
// belongs to the same dispatcher transaction as this consumed-object marker.
if len(obj.Rewards) == 0 {
if obj.BuffID == 0 && obj.MonsterID == 0 && obj.QuestID == 0 && obj.Type != 5 && obj.Type != 6 {
return nil, fmt.Errorf("%w: empty field object", ErrInvalidRequest)
}
return nil, s.state.MarkFieldRewardOpened(pack, id, period)
}
if s.wallet == nil || s.inventory == nil {
return nil, fmt.Errorf("world: field reward stores unavailable")
}
+23
View File
@@ -159,6 +159,13 @@ func (s *Service) monsterRows(pack int, filter map[int]bool) ([][]byte, error) {
continue
}
eligible := s.monsterEligible(pack, m)
if eligible {
available, err := s.rewardMonsterAvailable(pack, m.ID)
if err != nil {
return nil, err
}
eligible = available
}
state := fieldMonsterState{}
if eligible {
state, e = s.monsterState(&v, pack, m)
@@ -226,6 +233,13 @@ func (s *Service) attachFieldMonsterDesign(root, version string) {
s.monsterMaps = func(pack int) (map[int][]int, error) { return gamedata.LoadFieldMonsterMaps(root, version, pack) }
}
func (s *Service) authorizeMonsterMap(pack, id int) error {
available, err := s.rewardMonsterAvailable(pack, id)
if err != nil {
return err
}
if !available {
return fmt.Errorf("world: reward monster not summoned")
}
if s.monsterMaps == nil {
return nil
}
@@ -264,6 +278,15 @@ func (s *Service) findFieldMonster(pack, id int) (gamedata.FieldMonsterDesign, b
// Scripted monsters without regeneration return handled=false.
func (s *Service) BeginFieldMonsterBattle(pack int, id, deck uint64) (string, bool, error) {
m, found, e := s.findFieldMonster(pack, int(id))
if e == nil && found {
available, err := s.rewardMonsterAvailable(pack, int(id))
if err != nil {
return "", true, err
}
if !available {
return "", true, fmt.Errorf("world: reward monster not summoned")
}
}
if found && m.GroupID == 0 {
return "", false, nil
}
+22 -14
View File
@@ -70,7 +70,7 @@ func intsRequest(raw []byte, number int) ([]uint64, error) {
}
func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error) {
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 {
if err != nil || !present || seq == 0 || seq > 0x7fffffff {
return 59, nil, true, ErrInvalidRequest
}
pack, err := requestPack(request)
@@ -78,7 +78,7 @@ func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error)
return 59, nil, true, err
}
id, _, err := wire.Varint(request, 3)
if err != nil || id == 0 || !s.packUnlocked(pack) || s.state.ActivePackID() != pack {
if err != nil || id == 0 || id > 0x7fffffff || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) {
return 59, nil, true, ErrInvalidRequest
}
design, err := s.researchDesign(pack)
@@ -128,14 +128,16 @@ func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error)
}
for _, v := range prior {
if v == int(id) {
items, found, e := s.state.ResearchObjectReply(pack, int(id))
_, found, e := s.state.ResearchObjectReply(pack, int(id))
if e != nil {
return 59, nil, true, e
}
if !found {
return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing")
}
return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
// RewardItem is a delta. A fresh request for an already researched
// object must not credit that delta again on the client.
return 59, wire.AppendVarint(nil, 1, seq), true, nil
}
}
if s.researchEconomy == nil {
@@ -167,14 +169,18 @@ func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error)
}
return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
}
func fieldCountType(obj gamedata.FieldRewardObject) uint64 {
if obj.Type == 3 {
return 2
func matchesFieldCount(obj gamedata.FieldRewardObject, category uint64) bool {
// PackMapRewardInfo uses independent predicates; a normal or hidden box
// with a one-time reset belongs in both once and acquisition totals.
switch category {
case 2:
return obj.ResetType == 1 && obj.Type != 5
case 3:
return obj.Type == 1 || obj.Type == 3
case 4:
return obj.Type == 6 && obj.ResetType == 2
}
if obj.Type == 6 {
return 4
}
return 3
return false
}
func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, error) {
seq, present, err := wire.Varint(request, 1)
@@ -234,11 +240,13 @@ func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, err
return 226, nil, true, e
}
for _, o := range d.Objects {
if fieldCountType(o) != t {
if !matchesFieldCount(o, t) {
continue
}
max++
period, e := s.fieldObjectPeriod(o)
period, e := s.fieldObjectPeriodFor(pack, o)
if t != 4 || e == nil {
max++
}
if e != nil {
continue
}
@@ -5,7 +5,6 @@ import (
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
)
@@ -61,7 +60,9 @@ func TestResearchRequiresCurrentMapPersistsRewardAndCounts(t *testing.T) {
t.Fatal(err)
}
_, again, _, err := s.Handle("/FieldObjectResearch", req)
if err != nil || !bytes.Equal(first, again) || spy.calls != 1 {
_, firstReward, _ := wire.Bytes(first, 2)
_, repeatedReward, _ := wire.Bytes(again, 2)
if err != nil || !firstReward || repeatedReward || spy.calls != 1 {
t.Fatalf("replay duplicated reward or lost response: calls%d err%v", spy.calls, err)
}
counts := wire.AppendVarint(nil, 1, 11)
+17 -1
View File
@@ -281,6 +281,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return s.handleFieldObjectInfo(request)
case "/FieldObjectReward":
return s.handleFieldObjectReward(request)
case "/FieldObjectRewardList":
return s.handleFieldObjectRewardList(request)
case "/FieldObjectPreview":
return s.handleFieldObjectPreview(request)
case "/FieldObjectRespawn":
return s.handleFieldObjectRespawn(request)
case "/FieldObjecPositionUpdate", "/FieldObjectPositionUpdate":
return s.handleFieldObjectPosition(request)
case "/FieldObjectResearch":
return s.handleFieldResearch(request)
case "/PackRewardObjectCount":
@@ -693,9 +701,17 @@ func (s *Service) packInfo() ([]byte, error) {
func (s *Service) packInfoFor(packID int) ([]byte, error) {
out, err := s.basePackInfoFor(packID)
if err != nil || s.huntingGround == nil {
if err != nil {
return out, err
}
buffs, err := s.fieldBuffInfo()
if err != nil {
return nil, err
}
out = append(out, buffs...)
if s.huntingGround == nil {
return out, nil
}
ground, err := s.huntingGround.EnsureForPack(packID)
if err != nil {
return nil, err