fix(server): implement waypoints, chest rewards and NPC talk progress
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ var emptyResponses = map[string]int{
|
||||
"/EquipPresetInfo": 253,
|
||||
"/EvilCastleDailyRewardState": 441,
|
||||
"/EvilCastleTowerInfo": 200,
|
||||
"/FieldObjectInfo": 28,
|
||||
"/FieldTrapInfo": 171,
|
||||
"/FireWorksInfo": 554,
|
||||
"/FishingCollectionInfo": 465,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user