fix(server): implement waypoints, chest rewards and NPC talk progress

This commit is contained in:
2026-10-04 18:06:50 +08:00
parent fe7344582f
commit aa5f0afaf7
29 changed files with 1902 additions and 86 deletions
+22 -52
View File
@@ -8,9 +8,9 @@ import (
"fmt"
"log/slog"
"os"
"sort"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
@@ -38,16 +38,16 @@ type Seed struct {
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"`
}
type state struct {
Version string `json:"version"`
Deck []DeckEntry `json:"deck"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
Waypoints map[uint64]uint64 `json:"waypoints"`
Costumes map[uint64]uint64 `json:"costumes"`
Packs map[uint64]uint64 `json:"packs"`
HighestTotalBattlePower uint64 `json:"highest_total_battle_power"`
PortraitCostumeID uint64 `json:"portrait_costume_id"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"`
Version string `json:"version"`
Deck []DeckEntry `json:"deck"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
Waypoints map[uint64][]uint64 `json:"waypoints"`
Costumes map[uint64]uint64 `json:"costumes"`
Packs map[uint64]uint64 `json:"packs"`
HighestTotalBattlePower uint64 `json:"highest_total_battle_power"`
PortraitCostumeID uint64 `json:"portrait_costume_id"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"`
}
type Store struct {
mu sync.RWMutex
@@ -62,6 +62,8 @@ type Store struct {
collection *player.CollectionStore
sessionID string
replies map[string]deckReply
waypointDesign func(uint64) (gamedata.WaypointPack, error)
waypointPack func(uint64, bool) error
}
type deckReply struct {
@@ -143,7 +145,7 @@ func NewStore(seed Seed) (*Store, error) {
if e := seed.validate(); e != nil {
return nil, e
}
return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
}
func OpenStore(storage stateio.Store, seed Seed) (*Store, error) {
s, e := NewStore(seed)
@@ -175,6 +177,9 @@ func OpenStore(storage stateio.Store, seed Seed) (*Store, error) {
if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil {
return nil, errors.New("deck: invalid saved state")
}
if e = validWaypointState(loaded.Waypoints); e != nil {
return nil, e
}
s.state = loaded
if e = s.loadPresetEntries(); e != nil {
return nil, e
@@ -210,9 +215,9 @@ func clone(x state) state {
y := x
y.Deck = append([]DeckEntry(nil), x.Deck...)
y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...)
y.Waypoints = map[uint64]uint64{}
y.Waypoints = map[uint64][]uint64{}
for k, v := range x.Waypoints {
y.Waypoints[k] = v
y.Waypoints[k] = append([]uint64(nil), v...)
}
y.Costumes = map[uint64]uint64{}
for k, v := range x.Costumes {
@@ -410,12 +415,7 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
}
return 373, response, true, nil
case "/WaypointInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
return 31, encodeWaypoints(s.state.Waypoints), true, nil
return s.handleWaypoint(path, req)
case "/DeckSave":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
@@ -474,24 +474,8 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
n.FieldCharControlDeckType = v
e = s.commit(n)
return 288, nil, true, e
case "/WaypointSave":
pack, ok, e := wire.Varint(req, 2)
if e != nil || !ok || pack == 0 {
return 0, nil, true, errors.New("deck: invalid waypoint pack")
}
way, ok, e := wire.Varint(req, 3)
if e != nil || !ok || way == 0 {
return 0, nil, true, errors.New("deck: invalid waypoint")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.Waypoints[pack] = way
e = s.commit(n)
return 32, nil, true, e
case "/WaypointSave", "/WaypointUse":
return s.handleWaypoint(path, req)
case "/CostumeUse":
raw, ok, e := wire.Bytes(req, 2)
if e != nil || !ok {
@@ -608,17 +592,3 @@ func encodeField(xs []FieldEntry) []byte {
}
return b
}
func encodeWaypoints(xs map[uint64]uint64) []byte {
keys := make([]uint64, 0, len(xs))
for k := range xs {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
var b []byte
for _, k := range keys {
v := wire.AppendVarint(nil, 1, k)
v = wire.AppendVarint(v, 2, xs[k])
b = wire.AppendBytes(b, 1, v)
}
return b
}
+6
View File
@@ -1,6 +1,7 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
@@ -117,6 +118,11 @@ func TestDeckPersistenceAndCommands(t *testing.T) {
t.Fatalf("deck save: %d %v", code, e)
}
way := wire.AppendVarint(nil, 2, 21)
if e = s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) {
return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 212}}}, nil
}, func(uint64, bool) error { return nil }); e != nil {
t.Fatal(e)
}
way = wire.AppendVarint(way, 3, 1)
if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 {
t.Fatalf("way: %d %v", code, e)
+144
View File
@@ -0,0 +1,144 @@
package deck
import (
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
func (s *Store) ConfigureWaypoints(load func(uint64) (gamedata.WaypointPack, error), validate func(uint64, bool) error) error {
if load == nil || validate == nil {
return errors.New("deck: incomplete waypoint runtime")
}
s.mu.Lock()
defer s.mu.Unlock()
s.waypointDesign = load
s.waypointPack = validate
return nil
}
func (s *Store) ActivatedWaypoint(pack, id uint64) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return hasWaypoint(s.state.Waypoints[pack], id)
}
func hasWaypoint(points []uint64, id uint64) bool {
for _, p := range points {
if p == id {
return true
}
}
return false
}
func validWaypointState(packs map[uint64][]uint64) error {
for pack, points := range packs {
if pack == 0 || pack > math.MaxInt32 || points == nil {
return errors.New("deck: invalid saved waypoint pack")
}
seen := map[uint64]bool{}
for _, id := range points {
if id == 0 || id > math.MaxInt32 || seen[id] {
return errors.New("deck: invalid saved waypoint activation")
}
seen[id] = true
}
}
return nil
}
func (s *Store) handleWaypoint(path string, req []byte) (int, []byte, bool, error) {
seq, err := requestSequence(req)
if err != nil {
return 0, nil, true, err
}
pack, found, err := wire.Varint(req, 2)
if err != nil || !found || pack == 0 || pack > math.MaxInt32 {
return 0, nil, true, errors.New("deck: invalid waypoint pack")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.waypointDesign == nil || s.waypointPack == nil {
return 0, nil, true, errors.New("deck: waypoint runtime unavailable")
}
if err = s.waypointPack(pack, path == "/WaypointUse"); err != nil {
return 0, nil, true, err
}
design, err := s.waypointDesign(pack)
if err != nil {
return 0, nil, true, err
}
if path == "/WaypointInfo" {
ids := append([]uint64(nil), s.state.Waypoints[pack]...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var packed []byte
for _, id := range ids {
if _, known := design.Points[id]; !known {
return 0, nil, true, fmt.Errorf("deck: saved waypoint %d absent from pack%d", id, pack)
}
packed = binary.AppendUvarint(packed, id)
}
if len(packed) == 0 {
return 31, nil, true, nil
}
return 31, wire.AppendBytes(nil, 1, packed), true, nil
}
id, found, err := wire.Varint(req, 3)
if err != nil || !found || id == 0 || id > math.MaxInt32 {
return 0, nil, true, errors.New("deck: invalid waypoint")
}
if _, known := design.Points[id]; !known {
return 0, nil, true, errors.New("deck: unknown waypoint")
}
if path == "/WaypointSave" {
if hasWaypoint(s.state.Waypoints[pack], id) {
return 32, nil, true, nil
}
next := clone(s.state)
next.Waypoints[pack] = append(next.Waypoints[pack], id)
err = s.commit(next)
return 32, nil, true, err
}
end, found, err := wire.Varint(req, 4)
if err != nil || !found || end == 0 || end > math.MaxInt32 || end == id {
return 0, nil, true, errors.New("deck: invalid waypoint destination")
}
target, known := design.Points[end]
if !known || target.MapID == 0 || !hasWaypoint(s.state.Waypoints[pack], end) || !hasWaypoint(s.state.Waypoints[pack], id) {
return 0, nil, true, errors.New("deck: waypoint is not activated")
}
moves, found, err := wire.Varint(req, 5)
if err != nil || !found || moves != 1 {
return 0, nil, true, errors.New("deck: invalid waypoint move count")
}
if reply, ok := s.cachedReplyLocked("waypoint-use", seq); ok {
return reply.code, reply.body, true, nil
}
if design.PriceUnit != 0 {
if s.wallet == nil || s.sessionID == "" {
return 0, nil, true, errors.New("deck: waypoint wallet session unavailable")
}
identity := fmt.Sprintf("waypoint:%s:%d", s.sessionID, seq)
switch design.PriceType {
case 4:
_, err = s.wallet.SpendGoldOnce(identity, design.PriceUnit)
case 3:
_, err = s.wallet.SpendFreeJewelryOnce(identity, design.PriceUnit)
case 2:
_, err = s.wallet.SpendJewelryOnce(identity, design.PriceUnit)
default:
err = errors.New("deck: unsupported waypoint currency")
}
if err != nil {
return 0, nil, true, err
}
}
// The client performs its warp and sends SaveUserPosition with scene coordinates.
s.rememberReplyLocked("waypoint-use", seq, 33, nil)
return 33, nil, true, nil
}
+127
View File
@@ -0,0 +1,127 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"errors"
"testing"
)
func waypointFixture(t *testing.T) (*Store, *stateio.Memory) {
t.Helper()
memory := stateio.NewMemory()
seededStore := seeded(t)
s, err := OpenStore(memory, Seed{Version: seededStore.state.Version, FieldDeck: seededStore.state.FieldDeck, FieldCharControlDeckType: seededStore.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, s)
return s, memory
}
func configureWaypointFixture(t *testing.T, s *Store) {
t.Helper()
if err := s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) {
return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 10}, 2: {ID: 2, MapID: 20}}, PriceType: 4, PriceUnit: 7}, nil
}, func(pack uint64, _ bool) error {
if pack == 3 {
return errors.New("locked pack")
}
return nil
}); err != nil {
t.Fatal(err)
}
}
func waypointReq(seq, pack, start, end uint64) []byte {
body := wire.AppendVarint(nil, 2, pack)
if start != 0 {
body = wire.AppendVarint(body, 3, start)
}
if end != 0 {
body = wire.AppendVarint(body, 4, end)
body = wire.AppendVarint(body, 5, 1)
}
return req(seq, body)
}
func TestWaypointActivationPersistsAllIDsAndInfoScopesPack(t *testing.T) {
s, memory := waypointFixture(t)
for _, r := range []struct{ seq, pack, id uint64 }{{1, 1, 2}, {2, 1, 1}, {3, 2, 2}, {4, 1, 1}} {
if _, _, _, err := s.Handle("/WaypointSave", waypointReq(r.seq, r.pack, r.id, 0)); err != nil {
t.Fatal(err)
}
}
reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, reopened)
code, body, _, err := reopened.Handle("/WaypointInfo", waypointReq(5, 1, 0, 0))
if err != nil || code != 31 || !bytes.Equal(body, []byte{10, 2, 1, 2}) {
t.Fatalf("pack1 info %d %x %v", code, body, err)
}
_, body, _, err = reopened.Handle("/WaypointInfo", waypointReq(6, 2, 0, 0))
if err != nil || !bytes.Equal(body, []byte{10, 1, 2}) {
t.Fatalf("pack2 info %x %v", body, err)
}
if len(reopened.state.Waypoints[1]) != 2 {
t.Fatal("duplicate activation persisted")
}
}
func TestWaypointTravelChargesOnceAndRejectsInvalidBeforeSpending(t *testing.T) {
s, memory := waypointFixture(t)
wallet, err := player.OpenWallet(memory, player.Currency{Gold: 14})
if err != nil {
t.Fatal(err)
}
s.wallet = wallet
s.BeginSession("test-session")
for i := uint64(1); i <= 2; i++ {
if _, _, _, err = s.Handle("/WaypointSave", waypointReq(i, 1, i, 0)); err != nil {
t.Fatal(err)
}
}
for _, request := range [][]byte{waypointReq(3, 3, 1, 2), waypointReq(4, 1, 1, 99), waypointReq(5, 2, 1, 2)} {
if _, _, _, err = s.Handle("/WaypointUse", request); err == nil {
t.Fatal("invalid travel accepted")
}
if wallet.Snapshot().Gold != 14 {
t.Fatal("invalid travel charged")
}
}
valid := waypointReq(6, 1, 1, 2)
for i := 0; i < 2; i++ {
code, _, _, err := s.Handle("/WaypointUse", valid)
if err != nil || code != 33 {
t.Fatalf("travel %d %v", code, err)
}
}
if wallet.Snapshot().Gold != 7 {
t.Fatal("repeat travel double charged")
}
reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, reopened)
reopened.wallet = wallet
reopened.BeginSession("test-session")
if _, _, _, err = reopened.Handle("/WaypointUse", valid); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 7 {
t.Fatal("restart retry charged")
}
if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(7, 1, 2, 1)); err != nil {
t.Fatal(err)
}
if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(8, 1, 1, 2)); err == nil {
t.Fatal("insufficient balance accepted")
}
if wallet.Snapshot().Gold != 0 {
t.Fatal("failed debit changed wallet")
}
}
-1
View File
@@ -31,7 +31,6 @@ var emptyResponses = map[string]int{
"/EquipPresetInfo": 253,
"/EvilCastleDailyRewardState": 441,
"/EvilCastleTowerInfo": 200,
"/FieldObjectInfo": 28,
"/FieldTrapInfo": 171,
"/FireWorksInfo": 554,
"/FishingCollectionInfo": 465,
+7 -2
View File
@@ -8,8 +8,8 @@ import (
)
func TestHandleAuditedEmptyResponses(t *testing.T) {
if got := len(EmptyPacketCodes()); got != 30 {
t.Fatalf("audited empty-response registry has %d paths, want 30", got)
if got := len(EmptyPacketCodes()); got != 29 {
t.Fatalf("audited empty-response registry has %d paths, want 29", got)
}
for path, wantCode := range EmptyPacketCodes() {
t.Run(path, func(t *testing.T) {
@@ -22,6 +22,11 @@ func TestHandleAuditedEmptyResponses(t *testing.T) {
}
func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
for _, path := range []string{"/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate"} {
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)
}
}
if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil {
t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err)
}
-3
View File
@@ -9,7 +9,6 @@ var standaloneDefaults = map[string]int{
"/BalanceVersionCheck": 186,
"/ChargeCostInfo": 123,
"/EquipInfo": 34,
"/AchievementInfo": 166,
"/HuntDispatchInfo": 189,
"/EventMissionInfo": 127,
"/MissionInfo": 118,
@@ -27,7 +26,6 @@ var standaloneDefaults = map[string]int{
"/PvpBattleHistory": 97,
"/PvpBattleOnceRewardInfo": 277,
"/TodayQuestInfo": 64,
"/WaypointInfo": 31,
"/FieldDeckInfo": 273,
"/HuntingGroundInfoList": 387,
"/FriendRecommend": 211,
@@ -42,7 +40,6 @@ var standaloneDefaults = map[string]int{
var commandSuccess = map[string]int{
"/UpdateAgeGate": 0,
"/SaveFieldCharControlDeckType": 288,
"/WaypointSave": 32,
"/ActiveMap": 0,
"/FieldDeckSave": 274,
"/CostumeUse": 41,
@@ -0,0 +1,73 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"sort"
)
// AchievementCounterDesign indexes the content groups to which each counter belongs.
// AchievementTable has several tiers with the same group ID; id is not unique.
type AchievementCounterDesign struct{ Groups map[int][]int }
func LoadAchievementCounterDesign(root, version string) (*AchievementCounterDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadAchievementCounterDesign(db)
}
func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error) {
rows, err := db.Query("SELECT ProtoBuf FROM AchievementTable")
if err != nil {
return nil, err
}
defer rows.Close()
sets := map[int]map[int]bool{}
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
fields := map[int]int{}
for _, field := range []int{4, 9, 13} {
values, err := packedInts(raw, field)
if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) {
return nil, fmt.Errorf("gamedata: invalid achievement field%d", field)
}
if len(values) > 0 {
fields[field] = int(values[0])
}
}
// GetAchievementTablesByConditionType emits root groups, including roots
// whose UI useType is disabled. The wire update carries no content group.
if fields[13] != 0 {
continue
}
group, content := fields[9], fields[4]
if group <= 0 || content < 0 || content > 1 {
return nil, fmt.Errorf("gamedata: invalid achievement group %d/%d", group, content)
}
if sets[group] == nil {
sets[group] = map[int]bool{}
}
sets[group][content] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
design := &AchievementCounterDesign{Groups: map[int][]int{}}
for group, contents := range sets {
for content := range contents {
design.Groups[group] = append(design.Groups[group], content)
}
sort.Ints(design.Groups[group])
}
if len(design.Groups) == 0 {
return nil, fmt.Errorf("gamedata: empty achievement design")
}
return design, nil
}
@@ -0,0 +1,34 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestAchievementCounterDesignUsesRootGroupsAndBothContents(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if _, err = db.Exec("CREATE TABLE AchievementTable (ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
for _, row := range [][3]uint64{{7, 0, 0}, {7, 0, 0}, {7, 1, 0}, {8, 0, 7}} {
raw := wire.AppendVarint(nil, 9, row[0])
raw = wire.AppendVarint(raw, 4, row[1])
raw = wire.AppendVarint(raw, 13, row[2])
if _, err = db.Exec("INSERT INTO AchievementTable VALUES (?)", raw); err != nil {
t.Fatal(err)
}
}
d, err := loadAchievementCounterDesign(db)
if err != nil {
t.Fatal(err)
}
if len(d.Groups) != 1 || len(d.Groups[7]) != 2 || d.Groups[7][0] != 0 || d.Groups[7][1] != 1 {
t.Fatalf("wrong group index: %#v", d)
}
}
@@ -0,0 +1,173 @@
package gamedata
import (
"crypto/rand"
"database/sql"
"fmt"
"math/big"
"os"
"path/filepath"
)
// FieldObjectDesign retains the client table identities, including unsupported
// reward graphs, so callers can reject them without inventing a reward.
type FieldObjectDesign struct{ Objects map[int]FieldRewardObject }
type FieldRewardObject struct {
ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int
Rewards []BattleReward
Ratios []uint64
DropCount, DropType uint64
}
func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) {
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", pack))
if err != nil {
return FieldObjectDesign{}, err
}
dir, err := os.MkdirTemp("", "bd2-field-objects-")
if err != nil {
return FieldObjectDesign{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0600); err != nil {
return FieldObjectDesign{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return FieldObjectDesign{}, err
}
defer db.Close()
common, closeDB, err := openStatDatabase(root, version)
if err != nil {
return FieldObjectDesign{}, err
}
defer closeDB()
return loadFieldObjects(db, common)
}
func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}}
groups := map[int]FieldRewardObject{}
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable")
if err != nil {
return design, err
}
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
rows.Close()
return design, err
}
obj := FieldRewardObject{GroupID: 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 {
rows.Close()
return design, fmt.Errorf("gamedata: malformed field object group %d", id)
}
if len(v) > 0 {
*target = int(v[0])
}
}
group, e := packedInts(raw, 10)
if e != nil || len(group) > 1 {
rows.Close()
return design, fmt.Errorf("gamedata: malformed field reward group %d", id)
}
if len(group) == 1 {
var rewardRaw []byte
if err = common.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", group[0]).Scan(&rewardRaw); err != nil {
rows.Close()
return design, err
}
ids, e1 := packedInts(rewardRaw, 5)
types, e2 := packedInts(rewardRaw, 6)
counts, e3 := packedInts(rewardRaw, 4)
drop, e4 := packedInts(rewardRaw, 1)
ratios, e5 := packedInts(rewardRaw, 8)
dropType, e6 := packedInts(rewardRaw, 2)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil || e6 != nil {
rows.Close()
return design, fmt.Errorf("gamedata: malformed field loot %d", group[0])
}
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) == 1 && drop[0] > 0 && len(dropType) <= 1 {
obj.DropCount = drop[0]
obj.Ratios = ratios
if len(dropType) > 0 {
obj.DropType = dropType[0]
}
for i := range ids {
obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]})
}
}
}
groups[id] = obj
}
if err = rows.Err(); err != nil {
rows.Close()
return design, err
}
rows.Close()
rows, err = db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectTable")
if err != nil {
return design, err
}
defer rows.Close()
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return design, err
}
group, e1 := packedInts(raw, 3)
maps, e2 := packedInts(raw, 6)
if e1 != nil || e2 != nil || len(group) != 1 || len(maps) > 1 || id <= 0 {
return design, fmt.Errorf("gamedata: malformed field object %d", id)
}
obj, ok := groups[int(group[0])]
if !ok {
return design, fmt.Errorf("gamedata: missing object group %d", group[0])
}
obj.ID = id
if len(maps) > 0 {
obj.MapID = int(maps[0])
}
design.Objects[id] = obj
}
return design, rows.Err()
}
// Draw uses the table's normalized ratio distribution for DropType 0.
// Independent DropType 1 uses a different ratio unit and needs separate evidence.
func (o FieldRewardObject) Draw() ([]BattleReward, error) {
if o.DropType != 0 || o.DropCount == 0 || o.DropCount > 100 || len(o.Rewards) != len(o.Ratios) {
return nil, fmt.Errorf("gamedata: unsupported field reward draw")
}
var total uint64
for _, r := range o.Ratios {
if ^uint64(0)-total < r {
return nil, fmt.Errorf("gamedata: reward ratio overflow")
}
total += r
}
if total == 0 {
return nil, fmt.Errorf("gamedata: empty field reward distribution")
}
var result []BattleReward
for n := uint64(0); n < o.DropCount; n++ {
value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(total))
if err != nil {
return nil, err
}
pick := value.Uint64()
for i, ratio := range o.Ratios {
if pick < ratio {
result = append(result, o.Rewards[i])
break
}
pick -= ratio
}
}
return result, nil
}
@@ -0,0 +1,45 @@
package gamedata
import (
"testing"
"time"
)
func TestFieldResetUsesConfiguredBoundary(t *testing.T) {
s := FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday}
before := time.Date(2026, 10, 4, 23, 59, 59, 0, time.UTC)
after := before.Add(time.Second)
for _, r := range []int{0, 3} {
a, _ := s.Period(r, before)
b, _ := s.Period(r, after)
if a == b {
t.Fatalf("reset%d did not advance at official boundary %s", r, a)
}
}
s.DailyReset = 10 * time.Hour
a, _ := s.Period(0, after)
b, _ := s.Period(0, after.Add(time.Hour))
if a == b {
t.Fatal("changed design reset ignored")
}
once, _ := s.Period(1, after)
if once != "once" {
t.Fatal(once)
}
if _, err := s.Period(2, after); err == nil {
t.Fatal("invented event reset")
}
}
func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) {
o := FieldRewardObject{DropCount: 2, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 142}, {Type: 5, ID: 2, Count: 71}}, Ratios: []uint64{0, 100}}
for i := 0; i < 20; i++ {
got, err := o.Draw()
if err != nil || len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 {
t.Fatalf("draw=%+v err=%v", got, err)
}
}
o.DropType = 1
if _, err := o.Draw(); err == nil {
t.Fatal("unsupported independent distribution accepted")
}
}
@@ -0,0 +1,63 @@
package gamedata
import (
"bd2server/internal/server/wire"
"fmt"
"strconv"
"strings"
"time"
)
type FieldResetSchedule struct {
DailyReset time.Duration
WeeklyDay time.Weekday
}
func LoadFieldResetSchedule(root, version string) (FieldResetSchedule, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return FieldResetSchedule{}, err
}
defer closeDB()
var raw []byte
if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
return FieldResetSchedule{}, err
}
times, found, err := wire.Bytes(raw, 23)
if err != nil || !found {
return FieldResetSchedule{}, fmt.Errorf("gamedata: missing field daily reset time")
}
parts := strings.Split(string(times), ":")
if len(parts) != 3 {
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time")
}
values := [3]int{}
for i, p := range parts {
values[i], err = strconv.Atoi(p)
if err != nil || values[i] < 0 || (i == 0 && values[i] > 23) || (i > 0 && values[i] > 59) {
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time")
}
}
day, _, err := wire.Varint(raw, 97)
if err != nil || day > 6 {
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid weekly reset day")
}
return FieldResetSchedule{DailyReset: time.Duration(values[0])*time.Hour + time.Duration(values[1])*time.Minute + time.Duration(values[2])*time.Second, WeeklyDay: time.Weekday(day)}, nil
}
// Period follows client TimerManager's UTC+9 conversion and GameDefault reset
// settings. ResetByEvent needs the event's own lifecycle, never a daily fallback.
func (s FieldResetSchedule) Period(reset int, now time.Time) (string, error) {
if reset == 1 {
return "once", nil
}
if reset != 0 && reset != 3 {
return "", fmt.Errorf("gamedata: missing field event reset lifecycle")
}
shifted := now.UTC().Add(9*time.Hour - s.DailyReset)
if reset == 3 {
days := (int(shifted.Weekday()) - int(s.WeeklyDay) + 7) % 7
shifted = shifted.AddDate(0, 0, -days)
}
return shifted.Format("2006-01-02"), nil
}
+109
View File
@@ -0,0 +1,109 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// WaypointPack uses the per-pack FieldWaypointTable and common PackTable.
// A zero PriceUnit is a real proto3 default and means free travel.
type WaypointPack struct {
Points map[uint64]Waypoint
PriceType, PriceUnit uint64
}
type Waypoint struct {
ID, MapID uint64
SafeArea bool
QuestRange []uint64
}
func LoadWaypointPack(root, version string, packID uint64) (WaypointPack, error) {
if packID == 0 || packID > math.MaxInt32 {
return WaypointPack{}, fmt.Errorf("gamedata: invalid waypoint pack")
}
common, closeDB, err := openStatDatabase(root, version)
if err != nil {
return WaypointPack{}, err
}
defer closeDB()
var raw []byte
if err = common.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil {
return WaypointPack{}, err
}
design := WaypointPack{Points: map[uint64]Waypoint{}}
for field, target := range map[int]*uint64{66: &design.PriceType, 67: &design.PriceUnit} {
values, e := packedInts(raw, field)
if e != nil || len(values) > 1 {
return WaypointPack{}, fmt.Errorf("gamedata: invalid waypoint price pack%d", packID)
}
if len(values) == 1 {
*target = values[0]
}
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return WaypointPack{}, err
}
dir, err := os.MkdirTemp("", "bd2-waypoint-")
if err != nil {
return WaypointPack{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0600); err != nil {
return WaypointPack{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return WaypointPack{}, err
}
defer db.Close()
design.Points, err = loadWaypointPoints(db)
return design, err
}
func loadWaypointPoints(db *sql.DB) (map[uint64]Waypoint, error) {
points := map[uint64]Waypoint{}
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldWaypointTable ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uint64
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return nil, err
}
p := Waypoint{ID: id}
if id == 0 || id > math.MaxInt32 {
return nil, fmt.Errorf("gamedata: invalid waypoint id")
}
for field, target := range map[int]*uint64{2: &p.ID, 5: &p.MapID} {
values, e := packedInts(raw, field)
if e != nil || len(values) > 1 {
return nil, fmt.Errorf("gamedata: invalid waypoint field")
}
if len(values) == 1 {
*target = values[0]
}
}
if p.ID != id || p.MapID > math.MaxInt32 {
return nil, fmt.Errorf("gamedata: invalid waypoint identity")
}
safe, e := packedInts(raw, 4)
if e != nil || len(safe) > 1 {
return nil, fmt.Errorf("gamedata: invalid waypoint safe area")
}
p.SafeArea = len(safe) == 1 && safe[0] != 0
p.QuestRange, e = packedInts(raw, 6)
if e != nil {
return nil, e
}
points[id] = p
}
return points, rows.Err()
}
@@ -0,0 +1,17 @@
package missions
import "bd2server/internal/server/gamedata"
// ClaimedAchievementIDs exposes committed claims to the counter query adapter.
// The mission domain remains the sole owner of reward claims.
func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool {
s.mu.Lock()
defer s.mu.Unlock()
out := map[gamedata.AchievementKey]bool{}
for key := range s.design.Achievements {
if contains(s.state.Claimed, "achievement:"+achievementName(key)) {
out[key] = true
}
}
return out
}
+76
View File
@@ -0,0 +1,76 @@
package progress
import (
"bd2server/internal/server/stateio"
"fmt"
"sort"
"strconv"
"strings"
)
func fieldRewardKey(pack, difficulty, id int) string {
return fmt.Sprintf("%d:%d:%d", pack, difficulty, id)
}
// FieldRewardOpened reads persisted entries on every call; request rollback
// therefore does not leave an opened-object cache behind.
func (s *Store) FieldRewardOpened(pack, difficulty, id int, period string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return false, err
}
raw, found, err := store.LoadEntry("progress", "field_rewards", fieldRewardKey(pack, difficulty, id))
return found && string(raw) == period, err
}
func (s *Store) fieldRewardEntries() (stateio.EntryStore, error) {
if s.storage == nil {
s.storage = stateio.NewMemory()
}
store, ok := s.storage.(stateio.EntryStore)
if !ok {
return nil, fmt.Errorf("progress: field rewards require entry storage")
}
return store, nil
}
func (s *Store) MarkFieldRewardOpened(pack, difficulty, id int, period string) error {
s.mu.Lock()
defer s.mu.Unlock()
if pack <= 0 || difficulty < 0 || difficulty > 4 || id <= 0 {
return fmt.Errorf("progress: invalid field reward identity")
}
store, err := s.fieldRewardEntries()
if err != nil {
return err
}
return store.PutEntry("progress", "field_rewards", fieldRewardKey(pack, difficulty, id), []byte(period))
}
func (s *Store) OpenedFieldRewards(pack, difficulty int) ([]int, error) {
s.mu.Lock()
defer s.mu.Unlock()
store, err := s.fieldRewardEntries()
if err != nil {
return nil, err
}
entries, err := store.ListEntries("progress", "field_rewards")
if err != nil {
return nil, err
}
prefix := fmt.Sprintf("%d:%d:", pack, difficulty)
var ids []int
for key, raw := range entries {
if len(raw) == 0 {
return nil, fmt.Errorf("progress: invalid field reward entry")
}
if strings.HasPrefix(key, prefix) {
id, e := strconv.Atoi(strings.TrimPrefix(key, prefix))
if e != nil || id <= 0 {
return nil, fmt.Errorf("progress: invalid field reward key")
}
ids = append(ids, id)
}
}
sort.Ints(ids)
return ids, nil
}
-7
View File
@@ -354,13 +354,6 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) {
return 0, nil, fmt.Errorf("%s: %w", path, err)
}
return 102, nil, nil
case "/QuestUpdate":
questID, err := s.progress.UpdateQuest(request)
if err != nil {
return 0, nil, fmt.Errorf("%s: %w", path, err)
}
response := wire.AppendVarint(nil, 1, uint64(questID))
return 19, wire.AppendBytes(response, 2, nil), nil
}
for _, handler := range s.handlers {
code, response, ok, err := handler.Handle(path, request)
+5 -5
View File
@@ -494,7 +494,7 @@ func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T)
}
}
func TestNativeProgress(t *testing.T) {
func TestNativePositionAndQuestDomainRouting(t *testing.T) {
server, _ := NewServer(fakeLogin{})
reply := login(t, server)
cookie := "s=" + reply.Cookie
@@ -514,10 +514,10 @@ func TestNativeProgress(t *testing.T) {
quest = wire.AppendVarint(quest, 3, 21)
quest = wire.AppendBytes(quest, 4, []byte{121})
body, _ = cryptox.EncryptBase64Payload(quest, server.KeyForTest())
if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); err != nil {
t.Fatal(err)
if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); !errors.Is(err, transport.ErrNotImplemented) {
t.Fatalf("QuestUpdate without an owning world domain must fail closed: %v", err)
}
if stored, ok := server.ProgressForTest().Quest(12); !ok || stored.PackID != 21 {
t.Fatalf("quest not stored: %+v/%v", stored, ok)
if stored, ok := server.ProgressForTest().Quest(12); ok {
t.Fatalf("unvalidated quest bypassed the world domain: %+v", stored)
}
}
+220
View File
@@ -0,0 +1,220 @@
package world
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type AchievementService struct {
mu sync.Mutex
design *gamedata.AchievementCounterDesign
store stateio.AtomicEntryStore
session string
claims AchievementClaimSource
}
type AchievementClaimSource interface {
ClaimedAchievementIDs() map[gamedata.AchievementKey]bool
}
type achievementReceipt struct {
Sequence uint64 `json:"sequence"`
Group int `json:"group"`
Add int `json:"add"`
}
type achievementSnapshot struct {
Counts map[string]int64 `json:"counts"`
Receipts map[string]achievementReceipt `json:"receipts"`
}
func NewAchievementService(design *gamedata.AchievementCounterDesign, store stateio.Store, claims ...AchievementClaimSource) (*AchievementService, error) {
if design == nil || len(design.Groups) == 0 || store == nil {
return nil, fmt.Errorf("achievement: missing design or storage")
}
entries, ok := store.(stateio.AtomicEntryStore)
if !ok {
return nil, fmt.Errorf("achievement: storage requires atomic entries")
}
service := &AchievementService{design: design, store: entries}
if len(claims) > 0 {
service.claims = claims[0]
}
return service, nil
}
func (s *AchievementService) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.session = id
}
// Load each request from the transaction snapshot. A rolled-back request must
// never leave an increment or replay receipt in an in-memory cache.
func (s *AchievementService) load() (achievementSnapshot, error) {
state := achievementSnapshot{Counts: map[string]int64{}, Receipts: map[string]achievementReceipt{}}
counts, err := s.store.ListEntries("missions", "achievement_counts")
if err != nil {
return state, err
}
for key, raw := range counts {
var value int64
if err = json.Unmarshal(raw, &value); err != nil {
return state, fmt.Errorf("achievement: invalid counter: %w", err)
}
state.Counts[key] = value
}
if s.session != "" {
raw, found, err := s.store.LoadEntry("missions", "achievement_receipts", s.session)
if err != nil {
return state, err
}
if found {
var receipt achievementReceipt
if err = json.Unmarshal(raw, &receipt); err != nil {
return state, fmt.Errorf("achievement: invalid receipt: %w", err)
}
state.Receipts[s.session] = receipt
}
}
if state.Counts == nil || state.Receipts == nil {
return state, fmt.Errorf("achievement: incomplete state")
}
for key, value := range state.Counts {
group, err := strconv.Atoi(key)
if err != nil || len(s.design.Groups[group]) == 0 || value < 0 {
return state, fmt.Errorf("achievement: invalid persisted counter")
}
}
return state, nil
}
func (s *AchievementService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/AchievementInfo" && path != "/AchievementUpdate" {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
fail := func(err error) (int, []byte, bool, error) { return 0, nil, true, err }
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return fail(ErrInvalidRequest)
}
state, err := s.load()
if err != nil {
return fail(err)
}
if path == "/AchievementInfo" {
claimed := map[gamedata.AchievementKey]bool{}
if s.claims != nil {
claimed = s.claims.ClaimedAchievementIDs()
}
for key := range claimed {
group := strconv.Itoa(int(key.GroupID))
if _, found := state.Counts[group]; !found && len(s.design.Groups[int(key.GroupID)]) > 0 {
state.Counts[group] = 0
}
}
groups := make([]int, 0, len(state.Counts))
for key := range state.Counts {
group, _ := strconv.Atoi(key)
groups = append(groups, group)
}
sort.Ints(groups)
var response []byte
for _, group := range groups {
for _, content := range s.design.Groups[group] {
row := wire.AppendVarint(nil, 1, uint64(group))
row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)]))
var maxID uint64
for key := range claimed {
if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID {
maxID = key.ID
}
}
if maxID > 0 {
row = wire.AppendVarint(row, 3, maxID)
}
if content != 0 {
row = wire.AppendVarint(row, 4, uint64(content))
}
response = wire.AppendBytes(response, 1, row)
}
}
// Only the mission domain's actual claims advance max_clear_id.
return 166, response, true, nil
}
group, found, err := wire.Varint(request, 2)
if err != nil || !found || group == 0 || group > math.MaxInt32 || len(s.design.Groups[int(group)]) == 0 {
return fail(ErrInvalidRequest)
}
add, found, err := wire.Varint(request, 3)
if err != nil || !found || add == 0 || add > math.MaxInt32 || s.session == "" {
return fail(ErrInvalidRequest)
}
receipt := achievementReceipt{Sequence: seq, Group: int(group), Add: int(add)}
replayKey := s.session + "/" + strconv.FormatUint(seq, 10)
if raw, found, err := s.store.LoadEntry("missions", "achievement_replays", replayKey); err != nil {
return fail(err)
} else if found {
var previous achievementReceipt
if err = json.Unmarshal(raw, &previous); err != nil {
return fail(err)
}
if previous == receipt {
return 167, nil, true, nil
}
return fail(fmt.Errorf("achievement: conflicting request sequence"))
}
if previous, ok := state.Receipts[s.session]; ok && seq <= previous.Sequence {
if previous == receipt {
return 167, nil, true, nil
}
return fail(fmt.Errorf("achievement: stale or conflicting request sequence"))
}
key := strconv.Itoa(int(group))
current := state.Counts[key]
if current > math.MaxInt64-int64(add) {
return fail(fmt.Errorf("achievement: counter overflow"))
}
state.Counts[key] = current + int64(add)
state.Receipts[s.session] = receipt
raw, err := json.Marshal(state.Counts[key])
if err != nil {
return fail(err)
}
receiptRaw, err := json.Marshal(receipt)
if err != nil {
return fail(err)
}
changes := []stateio.EntryMutation{{Bucket: "achievement_counts", Key: key, Payload: raw}, {Bucket: "achievement_receipts", Key: s.session, Payload: receiptRaw}, {Bucket: "achievement_replays", Key: replayKey, Payload: receiptRaw}}
// Retain a complete retry window so a committed BatchRequest whose response
// was lost can replay several updates, not just the final update in the batch.
if seq > 256 {
entries, err := s.store.ListEntries("missions", "achievement_replays")
if err != nil {
return fail(err)
}
prefix := s.session + "/"
for replayKey := range entries {
if strings.HasPrefix(replayKey, prefix) {
old, err := strconv.ParseUint(strings.TrimPrefix(replayKey, prefix), 10, 64)
if err != nil {
return fail(err)
}
if old <= seq-256 {
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_replays", Key: replayKey, Delete: true})
}
}
}
}
if err = s.store.SaveWithEntries("missions", nil, changes); err != nil {
return fail(err)
}
return 167, nil, true, nil
}
@@ -0,0 +1,198 @@
package world
import (
"bytes"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func achievementRequest(seq, group, add uint64) []byte {
req := wire.AppendVarint(nil, 1, seq)
req = wire.AppendVarint(req, 2, group)
return wire.AppendVarint(req, 3, add)
}
func achievementTestService(t *testing.T, store stateio.Store) *AchievementService {
t.Helper()
s, err := NewAchievementService(&gamedata.AchievementCounterDesign{Groups: map[int][]int{7: {0, 1}, 9: {0}}}, store)
if err != nil {
t.Fatal(err)
}
s.BeginSession("login-a")
return s
}
func achievementValue(t *testing.T, s *AchievementService) int64 {
t.Helper()
state, err := s.load()
if err != nil {
t.Fatal(err)
}
return state.Counts["7"]
}
func TestAchievementUpdatePersistsAndRetries(t *testing.T) {
store := stateio.NewMemory()
s := achievementTestService(t, store)
req := achievementRequest(10, 7, 1)
for i := 0; i < 2; i++ {
code, body, ok, err := s.Handle("/AchievementUpdate", req)
if err != nil || code != 167 || !ok || len(body) != 0 {
t.Fatalf("update: %d %x %v %v", code, body, ok, err)
}
}
if got := achievementValue(t, s); got != 1 {
t.Fatalf("retry incremented count: %d", got)
}
reopened := achievementTestService(t, store)
if got := achievementValue(t, reopened); got != 1 {
t.Fatalf("reopen: %d", got)
}
if _, _, _, err := reopened.Handle("/AchievementUpdate", achievementRequest(10, 7, 2)); err == nil {
t.Fatal("conflicting sequence accepted")
}
reopened.BeginSession("login-b")
if _, _, _, err := reopened.Handle("/AchievementUpdate", req); err != nil {
t.Fatal(err)
}
code, body, ok, err := reopened.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 11))
var expected []byte
for _, content := range []uint64{0, 1} {
row := wire.AppendVarint(nil, 1, 7)
row = wire.AppendVarint(row, 2, 2)
if content != 0 {
row = wire.AppendVarint(row, 4, content)
}
expected = wire.AppendBytes(expected, 1, row)
}
if err != nil || code != 166 || !ok || !bytes.Equal(body, expected) {
t.Fatalf("info mismatch: %d %x %v %v", code, body, ok, err)
}
}
func TestAchievementInvalidRequestsDoNotMutate(t *testing.T) {
s := achievementTestService(t, stateio.NewMemory())
for _, req := range [][]byte{achievementRequest(0, 7, 1), achievementRequest(1, 8, 1), achievementRequest(1, 7, 0), achievementRequest(1, 7, 1<<32), {0xff}} {
if _, _, _, err := s.Handle("/AchievementUpdate", req); err == nil {
t.Fatalf("invalid request accepted: %x", req)
}
}
if achievementValue(t, s) != 0 {
t.Fatal("invalid request mutated counter")
}
s.BeginSession("")
if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err == nil {
t.Fatal("missing session accepted")
}
}
func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) {
s := achievementTestService(t, stateio.NewMemory())
requests := [][]byte{achievementRequest(10, 7, 1), achievementRequest(11, 7, 2)}
for retry := 0; retry < 2; retry++ {
for _, request := range requests {
if _, _, _, err := s.Handle("/AchievementUpdate", request); err != nil {
t.Fatal(err)
}
}
}
if achievementValue(t, s) != 3 {
t.Fatal("committed batch retry duplicated increments")
}
if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(300, 7, 1)); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/AchievementUpdate", requests[0]); err == nil {
t.Fatal("expired replay accepted as a new increment")
}
if achievementValue(t, s) != 4 {
t.Fatal("expired replay changed progress")
}
}
type achievementClaimsFixture struct{}
func (achievementClaimsFixture) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool {
return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 3}: true}
}
func TestAchievementInfoIncludesRealClaimState(t *testing.T) {
s := achievementTestService(t, stateio.NewMemory())
s.claims = achievementClaimsFixture{}
_, body, _, err := s.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 1))
if err != nil {
t.Fatal(err)
}
var rows [][]byte
if err = wire.Walk(body, func(field wire.Field) error {
if field.Number == 1 {
rows = append(rows, field.Value)
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("missing claim group rows: %x", body)
}
clear, found, err := wire.Varint(rows[1], 3)
if err != nil || !found || clear != 3 {
t.Fatal("max_clear_id did not come from actual claims")
}
}
func TestAchievementTransactionRollbackAndReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
s := achievementTestService(t, repo)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err != nil {
t.Fatal(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)
}
defer repo.Close()
s = achievementTestService(t, repo)
if achievementValue(t, s) != 1 {
t.Fatal("SQLite reopen lost count")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil {
t.Fatal(err)
}
// Repository fences a dirty rollback because other domains cache memory.
// Reopening is the recovery boundary; this service keeps no cached counts.
_ = op.Rollback()
_ = repo.Close()
recovered, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer recovered.Close()
s = achievementTestService(t, recovered)
if achievementValue(t, s) != 1 {
t.Fatal("rolled-back increment survived")
}
if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil {
t.Fatal(err)
}
if achievementValue(t, s) != 4 {
t.Fatal("rolled-back replay receipt survived")
}
}
+193
View File
@@ -0,0 +1,193 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
"time"
)
func (s *Service) WithFieldObjects(designs map[int]gamedata.FieldObjectDesign) *Service {
s.fieldObjects = designs
return s
}
func (s *Service) AttachFieldObjectRuntime(root, version string) error {
schedule, err := gamedata.LoadFieldResetSchedule(root, version)
if err != nil {
return err
}
s.fieldReset = schedule
s.fieldObjectLoader = func(pack int) (gamedata.FieldObjectDesign, error) {
return gamedata.LoadFieldObjects(root, version, pack)
}
s.fieldObjects = map[int]gamedata.FieldObjectDesign{}
return nil
}
func (s *Service) fieldObjectDesign(pack int) (gamedata.FieldObjectDesign, error) {
if design, ok := s.fieldObjects[pack]; ok {
return design, nil
}
if s.fieldObjectLoader == nil {
return gamedata.FieldObjectDesign{}, fmt.Errorf("world: field object design unavailable")
}
if _, story := s.packs[pack]; !story {
if _, field := s.fieldPacks[pack]; !field {
return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest)
}
}
design, err := s.fieldObjectLoader(pack)
if err != nil {
return design, err
}
s.fieldObjects[pack] = design
return design, nil
}
func (s *Service) openedFieldObjects(pack int) ([]int, error) {
ids, err := s.state.OpenedFieldRewards(pack, s.questDifficulty(pack))
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []int{}, nil
}
design, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
var active []int
for _, id := range ids {
obj, ok := design.Objects[id]
if !ok {
return nil, fmt.Errorf("world: saved field object absent from design")
}
period, e := s.fieldObjectPeriod(obj)
if e != nil {
continue
}
opened, e := s.state.FieldRewardOpened(pack, s.questDifficulty(pack), id, period)
if e != nil {
return nil, e
}
if opened {
active = append(active, id)
}
}
return active, nil
}
func (s *Service) WithFieldResetSchedule(schedule gamedata.FieldResetSchedule) *Service {
s.fieldReset = schedule
return s
}
func (s *Service) fieldObjectPeriod(obj gamedata.FieldRewardObject) (string, error) {
return s.fieldReset.Period(obj.ResetType, time.Now())
}
func (s *Service) handleFieldObjectInfo(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil {
return 0, nil, true, err
}
if !s.packUnlocked(pack) {
return 0, nil, true, fmt.Errorf("%w: unavailable field pack", ErrInvalidRequest)
}
ids, err := s.openedFieldObjects(pack)
if err != nil {
return 0, nil, true, err
}
var response []byte
for _, id := range ids {
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id)))
}
return 28, response, true, nil
}
func (s *Service) handleFieldObjectReward(request []byte) (int, []byte, bool, error) {
pack, err := requestPack(request)
if err != nil {
return 0, nil, true, err
}
group, _, err := wire.Varint(request, 3)
if err != nil || group == 0 || group > uint64(^uint32(0)>>1) {
return 0, nil, true, ErrInvalidRequest
}
id, _, err := wire.Varint(request, 4)
if err != nil || id == 0 || id > uint64(^uint32(0)>>1) {
return 0, nil, true, ErrInvalidRequest
}
bundle, err := s.openFieldObject(pack, int(group), int(id))
if err != nil {
return 0, nil, true, err
}
return 29, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
design, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
obj, exists := design.Objects[id]
if !exists || obj.GroupID != group || !s.packUnlocked(pack) || s.state.ActivePackID() != 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 obj.Type < 1 || obj.Type > 3 || 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)
}
difficulty := s.questDifficulty(pack)
period, err := s.fieldObjectPeriod(obj)
if err != nil {
return nil, err
}
opened, err := s.state.FieldRewardOpened(pack, difficulty, id, period)
if err != nil {
return nil, err
}
if opened {
return []byte{}, nil
}
if s.wallet == nil || s.inventory == nil {
return nil, fmt.Errorf("world: field reward stores unavailable")
}
var rewards []gamedata.Reward
var itemRewards []gamedata.BattleReward
selected, err := obj.Draw()
if err != nil {
return nil, err
}
for _, r := range selected {
if r.Count == 0 {
return nil, fmt.Errorf("world: empty field reward")
}
switch r.Type {
case 2, 3, 4, 12, 20:
rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29:
if r.ID == 0 {
return nil, fmt.Errorf("world: invalid field item")
}
itemRewards = append(itemRewards, r)
default:
return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type)
}
}
identity := fmt.Sprintf("field-reward:%d:%d:%d:%s", pack, difficulty, id, period)
if _, err = s.wallet.GrantQuestOnce(identity, rewards); err != nil {
return nil, err
}
items, err := s.inventory.GrantOnce(identity, itemRewards)
if err != nil {
return nil, err
}
if err = s.state.MarkFieldRewardOpened(pack, difficulty, id, period); err != nil {
return nil, err
}
var bundle []byte
for _, r := range rewards {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{ID: r.ID, Type: r.Type, Count: r.Count}))
}
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
}
return bundle, nil
}
@@ -0,0 +1,194 @@
package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"errors"
"path/filepath"
"testing"
)
type fieldRewardFailStore struct {
*accountstate.Repository
fail bool
}
func (s *fieldRewardFailStore) PutEntry(domain, bucket, key string, payload []byte) error {
if s.fail && bucket == "field_rewards" {
return errors.New("forced field reward marker failure")
}
return s.Repository.PutEntry(domain, bucket, key, payload)
}
func TestFieldChestAtomicAwardRollback(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 := testService()
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{FreeJewelry: 100})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}})
if _, err = s.wallet.GrantQuestOnce("baseline", nil); err != nil {
t.Fatal(err)
}
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, err = s.openFieldObject(21, 101, 1001); err == nil {
t.Fatal("failed persistence returned success")
}
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 = repo
store.fail = false
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)
}
opened, err := s.state.FieldRewardOpened(21, 0, 1001, "once")
if err != nil || opened || s.wallet.Snapshot().FreeJewelry != 100 || len(s.inventory.All()) != 0 {
t.Fatalf("partial state after rollback opened=%v wallet=%+v err=%v", opened, s.wallet.Snapshot(), err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, err = s.openFieldObject(21, 101, 1001); err != nil {
op.Rollback()
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
if s.wallet.Snapshot().FreeJewelry != 150 {
t.Fatal("retry after rollback did not award")
}
obj := s.fieldObjects[21].Objects[1001]
obj.ID = 1002
obj.Rewards = []gamedata.BattleReward{{Type: 5, ID: 1013, Count: 142}}
s.fieldObjects[21].Objects[1002] = obj
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, err = s.openFieldObject(21, 101, 1002); err == nil {
t.Fatal("inventory marker failure returned success")
}
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 = repo
store.fail = false
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
opened, err = s.state.FieldRewardOpened(21, 0, 1002, "once")
if err != nil || opened || len(s.inventory.All()) != 0 {
t.Fatalf("inventory partially committed after rollback opened=%v items=%v err=%v", opened, s.inventory.All(), err)
}
}
func TestFieldChestDeltaAndPersistentRepeat(t *testing.T) {
store := stateio.NewMemory()
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{FreeJewelry: 100})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{10}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}})
req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21), 3, 101), 4, 1001)
code, res, _, err := s.handleFieldObjectReward(req)
if err != nil || code != 29 {
t.Fatalf("code=%d err=%v", code, err)
}
bundle, _, _ := wire.Bytes(res, 1)
item, _, _ := wire.Bytes(bundle, 1)
delta, _, _ := wire.Varint(item, 4)
if delta != 50 || s.wallet.Snapshot().FreeJewelry != 150 {
t.Fatalf("delta=%d wallet=%+v", delta, s.wallet.Snapshot())
}
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
req = wire.AppendVarint(req, 1, 999)
_, res, _, err = s.handleFieldObjectReward(req)
if err != nil {
t.Fatal(err)
}
bundle, _, _ = wire.Bytes(res, 1)
if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 150 {
t.Fatal("repeat awarded")
}
_, res, _, err = s.handleFieldObjectInfo(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21))
if err != nil {
t.Fatal(err)
}
info, _, _ := wire.Bytes(res, 1)
id, _, _ := wire.Varint(info, 1)
if id != 1001 {
t.Fatalf("opened id=%d", id)
}
if _, err = s.openFieldObject(21, 102, 1001); err == nil {
t.Fatal("wrong group accepted")
}
}
+27
View File
@@ -0,0 +1,27 @@
package world
import "bd2server/internal/server/wire"
// handleQuestUpdate accepts the existing task update packet used when an NPC
// conversation finishes. Ordinary Talk is local and sends AchievementUpdate,
// not a separate NPC dialog packet. QuestUpdate has no NPC identity to validate.
func (s *Service) handleQuestUpdate(request []byte) (int, []byte, bool, error) {
quest, pack, err := requestQuest(request)
if err != nil {
return 0, nil, true, err
}
current, err := s.CurrentPackID()
if err != nil || current != pack || !s.canClear(pack, quest) {
return 0, nil, true, ErrInvalidRequest
}
if s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) {
// A delayed replay must not reinsert a cleared quest into active progress.
return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil
}
if _, err := s.state.UpdateQuest(request); err != nil {
return 0, nil, true, err
}
// field 2 is RewardDBInfoBundle, not QuestDBInfo. Task completion/claims
// remain in QuestClear; an update alone must not invent or duplicate rewards.
return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil
}
@@ -0,0 +1,41 @@
package world
import (
"bd2server/internal/server/wire"
"bytes"
"testing"
)
func TestNpcTaskQuestUpdateRejectsUnavailableAndReplaysClear(t *testing.T) {
s := testService()
req := func(quest, pack, value uint64) []byte {
out := wire.AppendVarint(nil, 1, 1)
out = wire.AppendVarint(out, 2, quest)
out = wire.AppendVarint(out, 3, pack)
return wire.AppendVarint(out, 4, value)
}
for _, bad := range [][]byte{req(99, 21, 1), req(2, 21, 1), req(1, 22, 1)} {
if _, _, _, err := s.handleQuestUpdate(bad); err == nil {
t.Fatalf("unavailable update accepted: %x", bad)
}
}
code, body, ok, err := s.handleQuestUpdate(req(1, 21, 1))
expected := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, nil)
if err != nil || code != 19 || !ok || !bytes.Equal(body, expected) {
t.Fatalf("update: %d %x %v %v", code, body, ok, err)
}
current, found := s.state.QuestInPack(1, 21)
if !found || len(current.Values) != 1 || current.Values[0] != 1 {
t.Fatal("task progress not persisted")
}
if err = s.state.ClearQuest(1, 21); err != nil {
t.Fatal(err)
}
if _, _, _, err = s.handleQuestUpdate(req(1, 21, 2)); err != nil {
t.Fatal(err)
}
current, found = s.state.QuestInPack(1, 21)
if !s.state.QuestCleared(1, 21) || !found || len(current.Values) != 1 || current.Values[0] != 1 {
t.Fatal("cleared quest replay changed committed progress")
}
}
+9 -6
View File
@@ -30,12 +30,15 @@ func (s *Service) handlePackDetail(request []byte) (int, []byte, bool, error) {
// has a persisted domain, do not invent active flags or claim history.
return 0, nil, true, fmt.Errorf("%w: PackDetailInfo pack %d requires regenerating monster state", ErrInvalidRequest, packID)
}
// These repeated fields contain obtained field objects and completed
// research objects, not static candidates. No such grants exist in the
// current domain. Ordinary story battle inventory grants are excluded by
// the GameData monster category verified above. The response's protobuf
// collections are non-null even when the payload has no encoded fields.
return 627, []byte{}, true, nil
ids, err := s.openedFieldObjects(packID)
if err != nil {
return 0, nil, true, err
}
response := []byte{}
for _, id := range ids {
response = wire.AppendBytes(response, 2, wire.AppendVarint(nil, 1, uint64(id)))
}
return 627, response, true, nil
}
func (s *Service) attachPackDetailDesign(root, version string) {
@@ -7,6 +7,46 @@ import (
"testing"
)
func TestCollectedFieldObjectsAppearInDetailAndSummary(t *testing.T) {
s := testService()
s.packSummaryTargets = map[int]bool{21: true}
s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { return gamedata.PackDetailDesign{}, nil }
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{
1001: {ID: 1001, GroupID: 101, Type: 2, ResetType: 1},
1002: {ID: 1002, GroupID: 102, Type: 1, ResetType: 1},
}}})
for _, id := range []int{1001, 1002} {
if err := s.state.MarkFieldRewardOpened(21, 0, id, "once"); err != nil {
t.Fatal(err)
}
}
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)
_, detail, _, err := s.Handle("/PackDetailInfo", req)
if err != nil {
t.Fatal(err)
}
rows := 0
err = wire.Walk(detail, func(field wire.Field) error {
if field.Number == 2 {
rows++
}
return nil
})
if err != nil || rows != 2 {
t.Fatalf("opened detail %x: %v", detail, err)
}
_, summary, _, err := s.Handle("/PackSummaryInfoList", req)
if err != nil {
t.Fatal(err)
}
row, _, _ := wire.Bytes(summary, 1)
once, _, _ := wire.Varint(row, 2)
regen, _, _ := wire.Varint(row, 3)
if once != 1 || regen != 1 {
t.Fatalf("collected counts %x", row)
}
}
func TestPackDetailUsesVerifiedDesignAndRejectsUnavailableState(t *testing.T) {
s := testService()
s.packSummaryTargets = map[int]bool{21: true, 22: true}
+21 -10
View File
@@ -19,16 +19,27 @@ func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) {
if !s.packSummaryTargets[int(id)] {
continue
}
// These are collected reward counts, not quest completion or remaining
// rewards. Field object and research grants are not implemented. The
// client's monster category requires UseBattleSkip && Type != 3 &&
// RegenId > 0; the current story battles (pack21 monsters 1..4 and
// pack22 monsters 1..8) have both RegenId and UseBattleSkip zero and
// their persisted battle reward grants do not count in this category.
// Regenerating field reward battles are not implemented. The collected
// counts are therefore zero (protobuf defaults). The client loads the
// maxima from PackRewardSummaryData and calculates the remainder.
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, id))
ids, err := s.openedFieldObjects(int(id))
if err != nil {
return 0, nil, true, err
}
var once, regen uint64
for _, objectID := range ids {
object := s.fieldObjects[int(id)].Objects[objectID]
if object.Type == 2 {
once++
} else if object.Type == 1 || object.Type == 3 {
regen++
}
}
row := wire.AppendVarint(nil, 1, id)
if once > 0 {
row = wire.AppendVarint(row, 2, once)
}
if regen > 0 {
row = wire.AppendVarint(row, 3, regen)
}
response = wire.AppendBytes(response, 1, row)
}
return 625, response, true, nil
}
+34
View File
@@ -0,0 +1,34 @@
package world
import (
"bd2server/internal/server/gamedata"
"fmt"
)
// AttachWaypointRuntime must follow AttachDecks; Save permits the client's
// automatic activation of all safe-area waypoints when it enters a pack.
func (s *Service) AttachWaypointRuntime(root, version string) error {
if s.decks == nil {
return fmt.Errorf("world: waypoint deck unavailable")
}
return s.decks.ConfigureWaypoints(func(pack uint64) (gamedata.WaypointPack, error) {
return gamedata.LoadWaypointPack(root, version, pack)
}, func(pack uint64, use bool) error {
if !s.packUnlocked(int(pack)) {
return fmt.Errorf("world: waypoint pack%d locked", pack)
}
if use {
current, err := s.CurrentPackID()
if err != nil {
return err
}
if uint64(current) != pack {
return fmt.Errorf("world: waypoint pack is not current")
}
if s.battleActive != nil && s.battleActive() {
return fmt.Errorf("world: waypoint travel during battle")
}
}
return nil
})
}
+9
View File
@@ -164,6 +164,9 @@ type Service struct {
packJamDesign *gamedata.PackJamDesign
packJamMu sync.Mutex
fieldPacks map[int]gamedata.FieldPack
fieldObjects map[int]gamedata.FieldObjectDesign
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
fieldReset gamedata.FieldResetSchedule
squadLevel func() (uint64, error)
seed Seed
state *progress.Store
@@ -200,6 +203,12 @@ func (s *Service) AttachDecks(decks *deck.Store) error {
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
switch path {
case "/QuestUpdate":
return s.handleQuestUpdate(request)
case "/FieldObjectInfo":
return s.handleFieldObjectInfo(request)
case "/FieldObjectReward":
return s.handleFieldObjectReward(request)
case "/QuestInfo", "/QuestAccept", "/QuestGiveUp":
return s.handleQuestSelection(path, request)
case "/PackBuy":