fix(server): restore commission chains on pack entry and verify durable rewards and weekly resets
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
package todayquest
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -126,3 +129,108 @@ func TestGiveUpRestoresQuotaAndClearsPriorChain(t *testing.T) {
|
||||
t.Fatalf("prior clear retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackInfoResetsExpiredSQLiteCommissionAtWeeklyBoundary(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
s, economy, _ := fixture(t)
|
||||
s.store = stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"}
|
||||
now := time.Date(2026, 10, 11, 23, 59, 59, 0, time.UTC)
|
||||
s.now = func() time.Time { return now }
|
||||
call(t, s, "/QuestAccept", 101)
|
||||
call(t, s, "/QuestUpdate", 101, 1)
|
||||
call(t, s, "/QuestClear", 101)
|
||||
call(t, s, "/QuestUpdate", 102, 71, 72)
|
||||
call(t, s, "/QuestClear", 102)
|
||||
st, err := s.load()
|
||||
if err != nil || s.secondsLeft(st) != 1 {
|
||||
t.Fatalf("weekly countdown: %d %v", s.secondsLeft(st), err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
s.store = stateio.EntrySnapshotStore{Entries: reopened, Domain: "missions", Bucket: "gameplay"}
|
||||
now = now.Add(time.Second)
|
||||
// PackInGameInfo calls Info without an intervening TodayQuestInfo request.
|
||||
rows, cleared, err := s.Info(7)
|
||||
if err != nil || len(rows) != 0 || len(cleared) != 0 {
|
||||
t.Fatalf("expired pack tasks: %v %v %v", rows, cleared, err)
|
||||
}
|
||||
st, err = s.load()
|
||||
if err != nil || s.secondsLeft(st) != 7*24*60*60 {
|
||||
t.Fatalf("new weekly countdown: %d %v", s.secondsLeft(st), err)
|
||||
}
|
||||
if score, err := s.Score(); err != nil || score != 7 {
|
||||
t.Fatalf("prior score: %d %v", score, err)
|
||||
}
|
||||
call(t, s, "/QuestAccept", 101)
|
||||
call(t, s, "/QuestUpdate", 101, 1)
|
||||
call(t, s, "/QuestClear", 101)
|
||||
call(t, s, "/QuestUpdate", 102, 71, 72)
|
||||
call(t, s, "/QuestClear", 102)
|
||||
if score, err := s.Score(); err != nil || score != 14 || economy.grants != 2 {
|
||||
t.Fatalf("new week score/reward: %d %d %v", score, economy.grants, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentCommissionConditionsRestoreProgressAndRejectForeignObjects(t *testing.T) {
|
||||
// Proto.Net.Define_QuestConditionType and PackManager.RefreshQuestCondition:
|
||||
// Hunt/ TalkManual use Value; Collection/ObjectMove/FieldResearchObject use ObjectId.
|
||||
for _, condition := range []int{1, 2, 9, 18, 19} {
|
||||
t.Run(fmt.Sprint(condition), func(t *testing.T) {
|
||||
s, _, _ := fixture(t)
|
||||
q := s.design.Quests[101]
|
||||
q.ConditionType, q.ConditionCount = condition, 2
|
||||
q.MagicValues = []uint64{71, 72}
|
||||
s.design.Quests[101] = q
|
||||
call(t, s, "/QuestAccept", 101)
|
||||
objectCondition := condition == 2 || condition == 9 || condition == 18
|
||||
if objectCondition {
|
||||
call(t, s, "/QuestUpdate", 101, 71)
|
||||
call(t, s, "/QuestUpdate", 101, 71)
|
||||
if _, _, _, err := s.Handle("/QuestUpdate", packet(101, 999)); err == nil {
|
||||
t.Fatal("foreign object accepted")
|
||||
}
|
||||
} else {
|
||||
call(t, s, "/QuestUpdate", 101, 1)
|
||||
call(t, s, "/QuestUpdate", 101, 0)
|
||||
if _, _, _, err := s.Handle("/QuestUpdate", packet(101, 3)); err == nil {
|
||||
t.Fatal("progress exceeded condition count")
|
||||
}
|
||||
}
|
||||
rows, _, err := s.Info(7)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("restored rows: %v %v", rows, err)
|
||||
}
|
||||
if objectCondition {
|
||||
object, _, _ := wire.Varint(rows[0], 3)
|
||||
if object != 71 {
|
||||
t.Fatalf("object progress %d", object)
|
||||
}
|
||||
} else {
|
||||
value, _, _ := wire.Varint(rows[0], 2)
|
||||
if value != 1 {
|
||||
t.Fatalf("scalar progress %d", value)
|
||||
}
|
||||
}
|
||||
if _, _, _, err := s.Handle("/QuestClear", packet(101)); err == nil {
|
||||
t.Fatal("partial progress cleared")
|
||||
}
|
||||
if objectCondition {
|
||||
call(t, s, "/QuestUpdate", 101, 72)
|
||||
} else {
|
||||
call(t, s, "/QuestUpdate", 101, 2)
|
||||
}
|
||||
call(t, s, "/QuestClear", 101)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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/todayquest"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type commissionEconomy struct{ wallet *player.Wallet }
|
||||
|
||||
func (e commissionEconomy) Apply(id string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
||||
_, err := e.wallet.GrantQuestOnce(id, rewards)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Synthetic IDs describe a two-node collection commission and an independent
|
||||
// pack. The source-backed contract is PackInGameInfo replacing both client
|
||||
// quest lists (CommonPacket.SendPackInGameInfoRequest), not an official capture.
|
||||
func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
design := &gamedata.TodayQuestCatalog{Limit: 3, PostCount: 3, AchievementScore: 7,
|
||||
Reset: gamedata.FieldResetSchedule{WeeklyDay: time.Monday, DailyReset: 9 * time.Hour},
|
||||
Quests: map[int]gamedata.TodayQuest{
|
||||
101: {ID: 101, PackID: 21, NextID: 102, ConditionType: 19, ConditionCount: 1},
|
||||
102: {ID: 102, PackID: 21, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, GiveItemIDs: []uint64{100001}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}},
|
||||
201: {ID: 201, PackID: 22, ConditionType: 19, ConditionCount: 1},
|
||||
}}
|
||||
open := func() (*accountstate.Repository, *Service, *player.Wallet, *player.Inventory) {
|
||||
t.Helper()
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
s := testService()
|
||||
s.storyCatalog.Packs[22] = gamedata.StoryPack{ID: 22, Quests: map[int]gamedata.QuestDesign{}}
|
||||
s.state, err = progress.OpenStore(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := player.OpenWallet(repo, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"}
|
||||
commissions, err := todayquest.Open(store, design, commissionEconomy{wallet}, inv, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commissions.CompleteAchievement = func(id string) error { return store.Save("commission_test_achievement", []byte(id)) }
|
||||
if err := s.AttachTodayQuests(commissions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repo, s, wallet, inv
|
||||
}
|
||||
call := func(s *Service, path string, id, pack int, values ...uint64) []byte {
|
||||
t.Helper()
|
||||
req := selectionRequest(uint64(id), uint64(pack), 0)
|
||||
if len(values) > 0 {
|
||||
// QuestUpdate uses field 4 as repeated int32, not difficulty.
|
||||
req = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, uint64(id)), 3, uint64(pack))
|
||||
for _, value := range values {
|
||||
req = wire.AppendVarint(req, 4, value)
|
||||
}
|
||||
}
|
||||
_, b, handled, err := s.Handle(path, req)
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("%s: handled=%v %v", path, handled, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
assertEntry := func(s *Service, pack int, active map[int][]uint64, cleared []int) {
|
||||
t.Helper()
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, uint64(pack))
|
||||
_, body, handled, err := s.Handle("/PackInGameInfo", request)
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("pack entry: handled=%v %v", handled, err)
|
||||
}
|
||||
got := map[int][]uint64{}
|
||||
var clears []int
|
||||
if err := wire.Walk(body, func(f wire.Field) error {
|
||||
if f.Number == 2 {
|
||||
id, _, _ := wire.Varint(f.Value, 1)
|
||||
if _, exists := design.Quests[int(id)]; exists {
|
||||
got[int(id)] = nil
|
||||
return wire.Walk(f.Value, func(q wire.Field) error {
|
||||
if q.Number == 3 {
|
||||
n, _ := binary.Uvarint(q.Value)
|
||||
got[int(id)] = append(got[int(id)], n)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
if f.Number == 3 {
|
||||
for b := f.Value; len(b) > 0; {
|
||||
id, n := binary.Uvarint(b)
|
||||
if n <= 0 {
|
||||
t.Fatal("invalid cleared IDs")
|
||||
}
|
||||
if _, exists := design.Quests[int(id)]; exists {
|
||||
clears = append(clears, int(id))
|
||||
}
|
||||
b = b[n:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, active) || !reflect.DeepEqual(clears, cleared) {
|
||||
t.Fatalf("pack %d active=%v cleared=%v", pack, got, clears)
|
||||
}
|
||||
}
|
||||
repo, s, wallet, inv := open()
|
||||
call(s, "/QuestAccept", 101, 21)
|
||||
call(s, "/QuestAccept", 201, 22)
|
||||
call(s, "/QuestUpdate", 101, 21, 1)
|
||||
call(s, "/QuestClear", 101, 21)
|
||||
call(s, "/QuestUpdate", 102, 21, 71)
|
||||
if len(inv.All()) != 1 {
|
||||
t.Fatal("continuation item missing")
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, s, wallet, inv = open()
|
||||
assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101})
|
||||
assertEntry(s, 22, map[int][]uint64{201: nil}, nil)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call(s, "/QuestUpdate", 102, 21, 72)
|
||||
call(s, "/QuestClear", 102, 21)
|
||||
if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatalf("dirty rollback must require domain reopen: %v", err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, s, wallet, inv = open()
|
||||
assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101})
|
||||
if wallet.Snapshot().Gold != 0 {
|
||||
t.Fatal("rolled-back reward persisted")
|
||||
}
|
||||
if len(inv.All()) != 1 {
|
||||
t.Fatal("continuation item duplicated on restart")
|
||||
}
|
||||
call(s, "/QuestUpdate", 102, 21, 72)
|
||||
call(s, "/QuestClear", 102, 21)
|
||||
call(s, "/QuestClear", 102, 21)
|
||||
assertEntry(s, 21, map[int][]uint64{}, []int{101, 102})
|
||||
if wallet.Snapshot().Gold != 10 {
|
||||
t.Fatal("reward replay changed balance")
|
||||
}
|
||||
call(s, "/QuestGiveUp", 201, 22)
|
||||
assertEntry(s, 22, map[int][]uint64{}, nil)
|
||||
}
|
||||
@@ -713,6 +713,19 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) {
|
||||
out = wire.AppendBytes(out, 2, quest)
|
||||
}
|
||||
cleared := s.state.ClearedQuests(packID, s.questDifficulty(packID))
|
||||
// Entering a pack replaces the client's entire task and completion lists.
|
||||
// Include commissions here as well as in QuestInfo so cross-pack travel and
|
||||
// login restore the same active chain and already completed nodes.
|
||||
if s.todayQuests != nil {
|
||||
quests, ids, err := s.todayQuests.Info(packID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, quest := range quests {
|
||||
out = wire.AppendBytes(out, 2, quest)
|
||||
}
|
||||
cleared = append(cleared, ids...)
|
||||
}
|
||||
if len(cleared) != 0 {
|
||||
var packed []byte
|
||||
for _, id := range cleared {
|
||||
|
||||
Reference in New Issue
Block a user