perf(server): batch achievement updates and eliminate redundant snapshots, writes and JSON round trips
This commit is contained in:
@@ -7,6 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) BeginSession(id string) { s.SetSession(id) }
|
||||
func (s *Service) inventorySnapshot() (world.GameplayAchievementSnapshot, error) {
|
||||
if provider, ok := s.provider.(interface {
|
||||
InventorySnapshot() (world.GameplayAchievementSnapshot, error)
|
||||
}); ok {
|
||||
return provider.InventorySnapshot()
|
||||
}
|
||||
return s.provider.Snapshot()
|
||||
}
|
||||
func (s *Service) BeforeDispatch(string, []byte) error {
|
||||
s.mu.Lock()
|
||||
s.beforeMissions = s.visibleMissionValues()
|
||||
@@ -15,13 +23,13 @@ func (s *Service) BeforeDispatch(string, []byte) error {
|
||||
return nil
|
||||
}
|
||||
var e error
|
||||
s.before, e = s.provider.Snapshot()
|
||||
s.before, e = s.inventorySnapshot()
|
||||
return e
|
||||
}
|
||||
func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p }
|
||||
func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) {
|
||||
if s.provider != nil {
|
||||
after, e := s.provider.Snapshot()
|
||||
after, e := s.inventorySnapshot()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -30,6 +38,8 @@ func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte,
|
||||
_, seen := s.state.Receipts[rk]
|
||||
s.mu.Unlock()
|
||||
if !seen {
|
||||
type delta struct{ condition, sub, count uint64 }
|
||||
var deltas []delta
|
||||
for kind, old := range s.before.Items {
|
||||
current := after.Items[kind]
|
||||
if current < old {
|
||||
@@ -37,41 +47,47 @@ func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte,
|
||||
if kind[1] == 0 {
|
||||
condition = 11
|
||||
}
|
||||
if e = s.RecordEvent(condition, kind[0], old-current, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
deltas = append(deltas, delta{condition, kind[0], old - current})
|
||||
}
|
||||
}
|
||||
for kind, current := range after.Items {
|
||||
old := s.before.Items[kind]
|
||||
if current > old {
|
||||
if e = s.RecordEvent(32, kind[0], current-old, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
deltas = append(deltas, delta{32, kind[0], current - old})
|
||||
}
|
||||
}
|
||||
for idx, current := range after.Equipment {
|
||||
old, ok := s.before.Equipment[idx]
|
||||
if ok && current.Level > old.Level {
|
||||
if e = s.RecordEvent(14, 0, current.Level-old.Level, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
deltas = append(deltas, delta{14, 0, current.Level - old.Level})
|
||||
}
|
||||
}
|
||||
for idx, current := range after.Costumes {
|
||||
old, ok := s.before.Costumes[idx]
|
||||
if ok && current.Level > old.Level {
|
||||
if e = s.RecordEvent(104, current.ID, current.Level-old.Level, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
deltas = append(deltas, delta{104, current.ID, current.Level - old.Level})
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.state.Receipts[rk] = receipt{Digest: "observer"}
|
||||
e = s.save()
|
||||
s.mu.Unlock()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
if len(deltas) > 0 {
|
||||
s.mu.Lock()
|
||||
before, e := json.Marshal(s.state)
|
||||
if e != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, e
|
||||
}
|
||||
for _, d := range deltas {
|
||||
s.recordEventLocked(d.condition, d.sub, d.count, s.unlocked)
|
||||
}
|
||||
s.state.Receipts[rk] = receipt{Digest: "observer"}
|
||||
e = s.save()
|
||||
if e != nil {
|
||||
s.state = snapshot{}
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/world"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type narrowNoticeProvider struct {
|
||||
noticeProvider
|
||||
fullCalls, narrowCalls int
|
||||
}
|
||||
|
||||
func (p *narrowNoticeProvider) Snapshot() (world.GameplayAchievementSnapshot, error) {
|
||||
p.fullCalls++
|
||||
return p.noticeProvider.Snapshot()
|
||||
}
|
||||
func (p *narrowNoticeProvider) InventorySnapshot() (world.GameplayAchievementSnapshot, error) {
|
||||
p.narrowCalls++
|
||||
return p.noticeProvider.Snapshot()
|
||||
}
|
||||
|
||||
type failedObserverStore struct{ stateio.Store }
|
||||
|
||||
func (s failedObserverStore) Save(string, []byte) error { return errors.New("observer save failure") }
|
||||
|
||||
func TestObserverUsesInventoryProjectionAndRestoresMemoryOnSaveFailure(t *testing.T) {
|
||||
s, _, store := setup(t)
|
||||
p := &narrowNoticeProvider{noticeProvider: noticeProvider{count: 1}}
|
||||
s.AttachGameplayProvider(p)
|
||||
task := s.design.Missions[10]
|
||||
task.Type = 32
|
||||
s.design.Missions[10] = task
|
||||
if e := s.BeforeDispatch("/grant", req(1)); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p.count = 2
|
||||
s.store = failedObserverStore{store}
|
||||
if _, e := s.AfterDispatch("/grant", req(1), nil); e == nil {
|
||||
t.Fatal("observer persistence failure ignored")
|
||||
}
|
||||
if len(s.state.Missions) != 0 || len(s.state.Receipts) != 0 {
|
||||
t.Fatal("failed observer save retained partial tasks or receipt")
|
||||
}
|
||||
if p.fullCalls != 0 || p.narrowCalls != 2 {
|
||||
t.Fatalf("observer requested expensive full snapshot: full=%d narrow=%d", p.fullCalls, p.narrowCalls)
|
||||
}
|
||||
s.store = store
|
||||
p.count = 1
|
||||
s.BeforeDispatch("/grant", req(1))
|
||||
p.count = 2
|
||||
b, e := s.AfterDispatch("/grant", req(1), nil)
|
||||
if e != nil || len(b) == 0 {
|
||||
t.Fatal("retry after failed observer persistence lost progress")
|
||||
}
|
||||
}
|
||||
|
||||
type writeCountingStore struct {
|
||||
stateio.Store
|
||||
writes int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (s *writeCountingStore) Save(name string, b []byte) error {
|
||||
s.writes++
|
||||
s.bytes += len(b)
|
||||
return s.Store.Save(name, b)
|
||||
}
|
||||
|
||||
func TestReadOnlyBatchHasNoObserverWritesButRealDeltaNotifiesOnce(t *testing.T) {
|
||||
s, _, store := setup(t)
|
||||
counter := &writeCountingStore{Store: store}
|
||||
s.store = counter
|
||||
p := ¬iceProvider{count: 1}
|
||||
s.AttachGameplayProvider(p)
|
||||
start := time.Now()
|
||||
for seq := uint64(1); seq <= 57; seq++ {
|
||||
if e := s.BeforeDispatch("/read", req(seq)); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, e := s.AfterDispatch("/read", req(seq), nil)
|
||||
if e != nil || len(b) > 0 {
|
||||
t.Fatalf("read-only changed missions: %x %v", b, e)
|
||||
}
|
||||
}
|
||||
if counter.writes != 0 || len(s.state.Receipts) != 0 {
|
||||
t.Fatalf("read-only 57 packets made %d writes/%d receipts", counter.writes, len(s.state.Receipts))
|
||||
}
|
||||
t.Logf("57 unchanged observer boundaries: %s, writes=%d", time.Since(start), counter.writes)
|
||||
task := s.design.Missions[10]
|
||||
task.Type = 32
|
||||
s.design.Missions[10] = task
|
||||
if e := s.BeforeDispatch("/grant", req(58)); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p.count = 2
|
||||
b, e := s.AfterDispatch("/grant", req(58), nil)
|
||||
if e != nil || len(b) == 0 || counter.writes != 1 {
|
||||
t.Fatalf("real delta not persisted/notified once: writes=%d body=%x error=%v", counter.writes, b, e)
|
||||
}
|
||||
// Even if an upstream replay temporarily exposes the same before/after
|
||||
// delta, the committed request receipt must not increment tasks twice.
|
||||
p.count = 1
|
||||
s.BeforeDispatch("/grant", req(58))
|
||||
p.count = 2
|
||||
b, e = s.AfterDispatch("/grant", req(58), nil)
|
||||
if e != nil || len(b) != 0 || counter.writes != 1 {
|
||||
t.Fatal("replay repeated mission increment or write")
|
||||
}
|
||||
if e = s.RecordEvent(999999, 0, 1, nil); e != nil || counter.writes != 1 {
|
||||
t.Fatal("irrelevant condition wrote state")
|
||||
}
|
||||
if e = s.RecordEvent(32, 0, 100, nil); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
writes := counter.writes
|
||||
if e = s.RecordEvent(32, 0, 100, nil); e != nil || counter.writes != writes {
|
||||
t.Fatal("capped task still wrote whole snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnchanged57PacketObserverBatch(b *testing.B) {
|
||||
// Snapshot costs belong to the provider; this benchmark isolates event
|
||||
// mission observation and persistence decisions without a user's database.
|
||||
s, _, _ := setup(b)
|
||||
p := ¬iceProvider{count: 1}
|
||||
s.AttachGameplayProvider(p)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for n := 0; n < b.N; n++ {
|
||||
for seq := uint64(1); seq <= 57; seq++ {
|
||||
if e := s.BeforeDispatch("/read", req(seq)); e != nil {
|
||||
b.Fatal(e)
|
||||
}
|
||||
if _, e := s.AfterDispatch("/read", req(seq), nil); e != nil {
|
||||
b.Fatal(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -885,9 +885,20 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if count == 0 {
|
||||
if !s.recordEventLocked(condition, sub, count, unlocked) {
|
||||
return nil
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// recordEventLocked reports every persistent mutation, including initializing
|
||||
// a matching task or rolling its period. Irrelevant and already capped events
|
||||
// leave the account snapshot untouched.
|
||||
func (s *Service) recordEventLocked(condition, sub, count uint64, unlocked func(uint64, uint64) bool) bool {
|
||||
changed := false
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range s.taskSchedules() {
|
||||
if v.Type != 4 || !s.active(v) {
|
||||
continue
|
||||
@@ -908,7 +919,15 @@ func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
previous := s.state.Missions[scheduleKey(v)+"/"+key(t.ID)]
|
||||
var prior mission
|
||||
if previous != nil {
|
||||
prior = *previous
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
if previous == nil || prior != *m {
|
||||
changed = true
|
||||
}
|
||||
if m.Claimed {
|
||||
continue
|
||||
}
|
||||
@@ -920,9 +939,10 @@ func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64
|
||||
} else {
|
||||
m.Value += count
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
return changed
|
||||
}
|
||||
func (s *Service) Notify() ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -51,7 +51,7 @@ func (m *attendanceMailStub) IssueAttachmentsOnce(identity, title, body string,
|
||||
m.identity, m.title, m.body, m.sentAt = identity, title, body, sentAt
|
||||
return nil
|
||||
}
|
||||
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
|
||||
func setup(t testing.TB) (*Service, *economyStub, stateio.Store) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
registry := events.NewRegistry()
|
||||
|
||||
@@ -14,8 +14,14 @@ func (s *Service) BeforeDispatch(string, []byte) error {
|
||||
func (s *Service) AfterDispatch(string, []byte, []byte) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Allocation is monotonic. Without a new ID, no existing row can match
|
||||
// the notification window, so read-only packets need not scan the inbox.
|
||||
if s.state.NextDynamicMailID == s.beforeMailID {
|
||||
return nil, nil
|
||||
}
|
||||
now := uint64(s.now().UnixMilli())
|
||||
for id, entry := range s.dynamic {
|
||||
if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > uint64(s.now().UnixMilli()) {
|
||||
if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > now {
|
||||
return wire.AppendVarint(nil, 1, 1), nil // Notify.IsNewMail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,33 @@ func (s *Store) OpenedFieldRewards(pack int) ([]int, error) {
|
||||
sort.Ints(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// OpenedFieldRewardPeriods reads the complete opened-object snapshot once from
|
||||
// the current request transaction. The returned maps are owned by the caller;
|
||||
// nothing is cached across requests or transaction rollback.
|
||||
func (s *Store) OpenedFieldRewardPeriods() (map[int]map[int]string, 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
|
||||
}
|
||||
periods := map[int]map[int]string{}
|
||||
for key, raw := range entries {
|
||||
packKey, objectKey, found := strings.Cut(key, ":")
|
||||
pack, packErr := strconv.Atoi(packKey)
|
||||
id, idErr := strconv.Atoi(objectKey)
|
||||
if !found || packErr != nil || idErr != nil || pack <= 0 || id <= 0 || key != fieldRewardKey(pack, id) || len(raw) == 0 {
|
||||
return nil, fmt.Errorf("progress: invalid field reward entry %q", key)
|
||||
}
|
||||
if periods[pack] == nil {
|
||||
periods[pack] = map[int]string{}
|
||||
}
|
||||
periods[pack][id] = string(raw)
|
||||
}
|
||||
return periods, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/stateio"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenedFieldRewardPeriodsValidatesAndOwnsSnapshot(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
s, err := OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
pack, id int
|
||||
period string
|
||||
}{{21, 71, "once"}, {22, 71, "2026-10-06"}, {22, 72, "event:777"}} {
|
||||
if err := s.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := map[int]map[int]string{21: {71: "once"}, 22: {71: "2026-10-06", 72: "event:777"}}
|
||||
got, err := s.OpenedFieldRewardPeriods()
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("opened periods=%v err=%v", got, err)
|
||||
}
|
||||
got[21][71] = "modified"
|
||||
fresh, err := s.OpenedFieldRewardPeriods()
|
||||
if err != nil || !reflect.DeepEqual(fresh, want) {
|
||||
t.Fatalf("caller modified persisted snapshot: %v err=%v", fresh, err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
key string
|
||||
raw []byte
|
||||
}{
|
||||
{"0:71", []byte("once")}, {"21:0", []byte("once")}, {"21:71:1", []byte("once")},
|
||||
{"pack:71", []byte("once")}, {"21:object", []byte("once")}, {"021:71", []byte("once")},
|
||||
{"21:071", []byte("once")}, {"21:73", nil},
|
||||
} {
|
||||
t.Run(row.key, func(t *testing.T) {
|
||||
if err := storage.PutEntry("progress", "field_rewards", row.key, row.raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.OpenedFieldRewardPeriods(); err == nil {
|
||||
t.Fatal("malformed field reward entry accepted")
|
||||
}
|
||||
if _, err := storage.DeleteEntry("progress", "field_rewards", row.key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,25 @@ func Encode(code int, proto, key []byte, now int64) ([]byte, error) {
|
||||
// Notify is plain base64 protobuf in the JSON envelope. The client parses it
|
||||
// separately after decrypting the response data (NetworkManager.OnAfterResponse).
|
||||
func EncodeWithNotify(code int, proto, key []byte, now int64, notify []byte) ([]byte, error) {
|
||||
data, err := cryptox.EncryptBase64Payload(proto, key)
|
||||
envelope, err := EnvelopeWithNotify(code, proto, key, now, notify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(Envelope{
|
||||
return json.Marshal(envelope)
|
||||
}
|
||||
|
||||
// EnvelopeWithNotify builds the same wire response without an intermediate
|
||||
// JSON round trip when the caller is assembling a batch.
|
||||
func EnvelopeWithNotify(code int, proto, key []byte, now int64, notify []byte) (Envelope, error) {
|
||||
data, err := cryptox.EncryptBase64Payload(proto, key)
|
||||
if err != nil {
|
||||
return Envelope{}, err
|
||||
}
|
||||
return Envelope{
|
||||
PacketCode: code, Length: base64.StdEncoding.EncodedLen(len(proto)),
|
||||
Data: data, ServerNowTime: now,
|
||||
Notify: base64.StdEncoding.EncodeToString(notify),
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
type BatchRequest struct {
|
||||
|
||||
@@ -343,14 +343,10 @@ func (s *Server) handleBatch(body, key []byte) (transport.RawReply, error) {
|
||||
} else if itemElapsed >= 100*time.Millisecond {
|
||||
slog.Warn("slow batch item", "index", i, "path", request.Path, "duration_ms", float64(itemElapsed.Microseconds())/1000)
|
||||
}
|
||||
raw, err := protocol.EncodeWithNotify(code, response, key, time.Now().UnixMilli(), notify)
|
||||
envelope, err := protocol.EnvelopeWithNotify(code, response, key, time.Now().UnixMilli(), notify)
|
||||
if err != nil {
|
||||
return transport.RawReply{}, err
|
||||
}
|
||||
var envelope protocol.Envelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return transport.RawReply{}, err
|
||||
}
|
||||
items = append(items, protocol.BatchResponse{Path: request.Path, ResponseData: envelope})
|
||||
}
|
||||
encoded, err := json.Marshal(items)
|
||||
|
||||
@@ -11,6 +11,93 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type GameplayAchievementRecordedEvent struct {
|
||||
Identity string
|
||||
Type, SubType, Count uint64
|
||||
}
|
||||
|
||||
// ApplyGameplayProgress keeps one observer boundary on one validated counter
|
||||
// snapshot. Conditions are applied first and events retain their original
|
||||
// order. Event receipts and all counter changes are written atomically; the
|
||||
// next boundary loads fresh state from its transaction, including after retry.
|
||||
func (s *AchievementService) ApplyGameplayProgress(conditions []GameplayAchievementCondition, events []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
values := func() map[int]uint64 {
|
||||
out := map[int]uint64{}
|
||||
for key, value := range state.Counts {
|
||||
group, _ := strconv.Atoi(key) // load validated every key.
|
||||
out[group] = uint64(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := values()
|
||||
groups := map[[2]uint64][]int{}
|
||||
for group, condition := range s.design.Conditions {
|
||||
key := [2]uint64{condition.Type, condition.SubType}
|
||||
groups[key] = append(groups[key], group)
|
||||
}
|
||||
for key := range groups {
|
||||
sort.Ints(groups[key])
|
||||
}
|
||||
var changes []stateio.EntryMutation
|
||||
set := func(group int, value int64) {
|
||||
key := strconv.Itoa(group)
|
||||
state.Counts[key] = value
|
||||
raw, _ := json.Marshal(value)
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw})
|
||||
}
|
||||
for _, condition := range conditions {
|
||||
if condition.Value > math.MaxInt64 {
|
||||
return nil, nil, fmt.Errorf("achievement: invalid absolute value")
|
||||
}
|
||||
for _, group := range groups[[2]uint64{condition.Type, condition.SubType}] {
|
||||
if state.Counts[strconv.Itoa(group)] != int64(condition.Value) {
|
||||
set(group, int64(condition.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
pending := map[string]string{}
|
||||
for _, event := range events {
|
||||
if event.Identity == "" || event.Count == 0 || event.Count > math.MaxInt64 {
|
||||
return nil, nil, fmt.Errorf("achievement: invalid event")
|
||||
}
|
||||
digest := fmt.Sprintf("%d/%d/%d", event.Type, event.SubType, event.Count)
|
||||
raw, found, err := s.store.LoadEntry("missions", "achievement_events", event.Identity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if prior, exists := pending[event.Identity]; exists {
|
||||
raw, found = []byte(prior), true
|
||||
}
|
||||
if found {
|
||||
if string(raw) != digest {
|
||||
return nil, nil, fmt.Errorf("achievement: event replay conflicts")
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, group := range groups[[2]uint64{event.Type, event.SubType}] {
|
||||
old := state.Counts[strconv.Itoa(group)]
|
||||
if old > math.MaxInt64-int64(event.Count) {
|
||||
return nil, nil, fmt.Errorf("achievement: event overflow")
|
||||
}
|
||||
set(group, old+int64(event.Count))
|
||||
}
|
||||
pending[event.Identity] = digest
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_events", Key: event.Identity, Payload: []byte(digest)})
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
if err := s.store.SaveWithEntries("missions", nil, changes); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return before, values(), nil
|
||||
}
|
||||
|
||||
func (s *AchievementService) AchievementValue(groupID uint64) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -15,6 +15,10 @@ type GameplayAchievementCounter interface {
|
||||
SetCondition(uint64, uint64, uint64) ([][]byte, error)
|
||||
CounterValues() (map[int]uint64, error)
|
||||
}
|
||||
|
||||
type gameplayAchievementProgressBatch interface {
|
||||
ApplyGameplayProgress([]GameplayAchievementCondition, []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error)
|
||||
}
|
||||
type GameplayAchievementEvent struct {
|
||||
Type, SubType, Count uint64
|
||||
Identity string
|
||||
@@ -57,22 +61,30 @@ func (s *GameplayAchievementObserver) SyncRecordedHistory() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, condition := range snapshot.Conditions {
|
||||
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var ids []string
|
||||
for identity := range snapshot.GachaGrants {
|
||||
ids = append(ids, identity)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
var events []GameplayAchievementRecordedEvent
|
||||
for _, identity := range ids {
|
||||
count := snapshot.GachaGrants[identity]
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := s.counter.RecordEvent("gacha-grant:"+identity, 54, 0, count); err != nil {
|
||||
events = append(events, GameplayAchievementRecordedEvent{Identity: "gacha-grant:" + identity, Type: 54, Count: count})
|
||||
}
|
||||
if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok {
|
||||
_, _, err := batch.ApplyGameplayProgress(snapshot.Conditions, events)
|
||||
return err
|
||||
}
|
||||
for _, condition := range snapshot.Conditions {
|
||||
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, event := range events {
|
||||
if _, err := s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -80,11 +92,15 @@ func (s *GameplayAchievementObserver) SyncRecordedHistory() error {
|
||||
}
|
||||
func (s *GameplayAchievementObserver) BeforeDispatch(_ string, _ []byte) error {
|
||||
var err error
|
||||
s.counters, err = s.counter.CounterValues()
|
||||
s.before, err = s.provider.Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.before, err = s.provider.Snapshot()
|
||||
if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok {
|
||||
s.counters, _, err = batch.ApplyGameplayProgress(s.before.Conditions, nil)
|
||||
return err
|
||||
}
|
||||
s.counters, err = s.counter.CounterValues()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,15 +116,19 @@ func (s *GameplayAchievementObserver) AfterDispatch(path string, request, respon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, condition := range after.Conditions {
|
||||
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
|
||||
return nil, err
|
||||
batch, batches := s.counter.(gameplayAchievementProgressBatch)
|
||||
if !batches {
|
||||
for _, condition := range after.Conditions {
|
||||
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
events, err := s.provider.Events(path, request, response, s.before, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recorded []GameplayAchievementRecordedEvent
|
||||
if len(events) > 0 {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || s.session == "" {
|
||||
@@ -119,12 +139,20 @@ func (s *GameplayAchievementObserver) AfterDispatch(path string, request, respon
|
||||
if event.StableIdentity {
|
||||
identity = event.Identity
|
||||
}
|
||||
if _, err := s.counter.RecordEvent(identity, event.Type, event.SubType, event.Count); err != nil {
|
||||
recorded = append(recorded, GameplayAchievementRecordedEvent{Identity: identity, Type: event.Type, SubType: event.SubType, Count: event.Count})
|
||||
}
|
||||
}
|
||||
var values map[int]uint64
|
||||
if batches {
|
||||
_, values, err = batch.ApplyGameplayProgress(after.Conditions, recorded)
|
||||
} else {
|
||||
for _, event := range recorded {
|
||||
if _, err = s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
values, err = s.counter.CounterValues()
|
||||
}
|
||||
values, err := s.counter.CounterValues()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,7 +195,11 @@ type OwnedGameplayAchievementProvider struct {
|
||||
}
|
||||
|
||||
func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapshot, error) {
|
||||
s := GameplayAchievementSnapshot{Characters: map[uint64]player.Character{}, Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}}
|
||||
s, err := p.InventorySnapshot()
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Characters = map[uint64]player.Character{}
|
||||
if p.Characters != nil {
|
||||
for _, v := range p.Characters.RawAll() {
|
||||
if player.IsCharmCharacter(v) || player.IsStoryCharacter(v) {
|
||||
@@ -176,6 +208,23 @@ func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapsh
|
||||
s.Characters[v.InvenIndex] = v
|
||||
}
|
||||
}
|
||||
if p.Conditions != nil {
|
||||
s.Conditions, err = p.Conditions()
|
||||
}
|
||||
if err == nil && p.FieldObjects != nil {
|
||||
s.FieldObjects, err = p.FieldObjects()
|
||||
}
|
||||
if p.Gacha != nil {
|
||||
s.GachaGrants = p.Gacha.GachaGrantSummary()
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// InventorySnapshot projects the three authoritative domains used by event
|
||||
// missions. It reads a fresh before/after view, without querying unrelated
|
||||
// field-object, main-quest, character or gacha-history projections.
|
||||
func (p *OwnedGameplayAchievementProvider) InventorySnapshot() (GameplayAchievementSnapshot, error) {
|
||||
s := GameplayAchievementSnapshot{Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}}
|
||||
if p.Costumes != nil {
|
||||
for _, v := range p.Costumes.Costumes() {
|
||||
s.Costumes[v.InvenIndex] = v
|
||||
@@ -191,35 +240,22 @@ func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapsh
|
||||
s.Items[[2]uint64{v.Type, v.ID}] += v.Count
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if p.Conditions != nil {
|
||||
s.Conditions, err = p.Conditions()
|
||||
}
|
||||
if err == nil && p.FieldObjects != nil {
|
||||
s.FieldObjects, err = p.FieldObjects()
|
||||
}
|
||||
if p.Gacha != nil {
|
||||
s.GachaGrants = p.Gacha.GachaGrantSummary()
|
||||
}
|
||||
return s, err
|
||||
return s, nil
|
||||
}
|
||||
func (p *OwnedGameplayAchievementProvider) Events(path string, _ []byte, _ []byte, before, after GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) {
|
||||
var events []GameplayAchievementEvent
|
||||
conditions := map[[2]uint64]bool{}
|
||||
if p.Design != nil {
|
||||
for _, condition := range p.Design.Conditions {
|
||||
conditions[[2]uint64{condition.Type, condition.SubType}] = true
|
||||
}
|
||||
}
|
||||
emit := func(kind, sub, count uint64, id string) {
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
if p.Design != nil {
|
||||
matched := false
|
||||
for _, c := range p.Design.Conditions {
|
||||
if c.Type == kind && c.SubType == sub {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return
|
||||
}
|
||||
if p.Design != nil && !conditions[[2]uint64{kind, sub}] {
|
||||
return
|
||||
}
|
||||
events = append(events, GameplayAchievementEvent{Type: kind, SubType: sub, Count: count, Identity: id})
|
||||
}
|
||||
@@ -362,6 +398,10 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
|
||||
}
|
||||
p.FieldObjects = func() (map[string]gamedata.FieldRewardObject, error) {
|
||||
objects := map[string]gamedata.FieldRewardObject{}
|
||||
openedPeriods, err := s.state.OpenedFieldRewardPeriods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Opened IDs are persisted independently of quest difficulty. Only loaded
|
||||
// packs with real opened entries require their reward design to be resolved.
|
||||
packs := map[int]bool{}
|
||||
@@ -372,18 +412,15 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
|
||||
packs[id] = true
|
||||
}
|
||||
for pack := range packs {
|
||||
ids, err := s.state.OpenedFieldRewards(pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
periods := openedPeriods[pack]
|
||||
if len(periods) == 0 {
|
||||
continue
|
||||
}
|
||||
d, err := s.fieldObjectDesign(pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
for id, openedPeriod := range periods {
|
||||
obj, known := d.Objects[id]
|
||||
if !known {
|
||||
return nil, fmt.Errorf("achievement: opened field design absent")
|
||||
@@ -392,11 +429,7 @@ func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounte
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
opened, err := s.state.FieldRewardOpened(pack, id, period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opened {
|
||||
if openedPeriod == period {
|
||||
objects[fmt.Sprintf("field:%d:%d:%s", pack, id, period)] = obj
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,121 @@
|
||||
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"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fieldSnapshotCountStore struct {
|
||||
*accountstate.Repository
|
||||
lists, loads int
|
||||
}
|
||||
|
||||
func (s *fieldSnapshotCountStore) ListEntries(domain, bucket string) (map[string][]byte, error) {
|
||||
if domain == "progress" && bucket == "field_rewards" {
|
||||
s.lists++
|
||||
}
|
||||
return s.Repository.ListEntries(domain, bucket)
|
||||
}
|
||||
func (s *fieldSnapshotCountStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) {
|
||||
if domain == "progress" && bucket == "field_rewards" {
|
||||
s.loads++
|
||||
}
|
||||
return s.Repository.LoadEntry(domain, bucket, key)
|
||||
}
|
||||
|
||||
func TestGameplayFieldSnapshotReadsOneBucketAndRestoresAfterRollback(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() }()
|
||||
storage := &fieldSnapshotCountStore{Repository: repo}
|
||||
s := testService()
|
||||
s.state, err = progress.OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.packs = map[int]map[int]gamedata.QuestDesign{}
|
||||
for pack := 1; pack <= 40; pack++ {
|
||||
s.packs[pack] = nil
|
||||
}
|
||||
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
s.monsterNow = func() time.Time { return now }
|
||||
s.fieldReset = gamedata.FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday}
|
||||
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{
|
||||
21: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 1}, 72: {ID: 72, Type: 2, ResetType: 0}, 73: {ID: 73, Type: 2, ResetType: 3}}},
|
||||
22: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 0}}},
|
||||
})
|
||||
for _, row := range []struct {
|
||||
pack, id int
|
||||
period string
|
||||
}{{21, 71, "once"}, {21, 72, "2026-10-05"}, {21, 73, "2026-10-05"}, {22, 71, "2026-10-06"}} {
|
||||
if err := s.state.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := s.GameplayAchievementProvider(&gamedata.AchievementCounterDesign{}, gamedata.GameplayAchievementGrades{})
|
||||
assert := func(want map[string]gamedata.FieldRewardObject) {
|
||||
t.Helper()
|
||||
storage.lists, storage.loads = 0, 0
|
||||
got, err := p.Snapshot()
|
||||
if err != nil || !reflect.DeepEqual(got.FieldObjects, want) {
|
||||
t.Fatalf("field snapshot=%v err=%v want=%v", got.FieldObjects, err, want)
|
||||
}
|
||||
if storage.lists != 1 || storage.loads != 0 {
|
||||
t.Fatalf("snapshot queried bucket %d times and objects %d times", storage.lists, storage.loads)
|
||||
}
|
||||
}
|
||||
want := map[string]gamedata.FieldRewardObject{"field:21:71:once": s.fieldObjects[21].Objects[71], "field:21:73:2026-10-05": s.fieldObjects[21].Objects[73], "field:22:71:2026-10-06": s.fieldObjects[22].Objects[71]}
|
||||
assert(want)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.state.MarkFieldRewardOpened(21, 72, "2026-10-06"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want["field:21:72:2026-10-06"] = s.fieldObjects[21].Objects[72]
|
||||
assert(want)
|
||||
if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatalf("dirty rollback=%v", err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storage.Repository = repo
|
||||
s.state, err = progress.OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(want, "field:21:72:2026-10-06")
|
||||
assert(want)
|
||||
now = now.AddDate(0, 0, 7)
|
||||
delete(want, "field:21:73:2026-10-05")
|
||||
delete(want, "field:22:71:2026-10-06")
|
||||
assert(want)
|
||||
if err := s.state.MarkFieldRewardOpened(21, 999, "once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := p.Snapshot(); err == nil {
|
||||
t.Fatal("unknown opened object skipped validation")
|
||||
}
|
||||
}
|
||||
|
||||
type gameplayTestSource struct {
|
||||
characters []player.Character
|
||||
costumes []player.Costume
|
||||
@@ -28,6 +136,31 @@ func (s gameplayEquipment) All() []player.Equipment { return s.s.equipment }
|
||||
type gameplayItems struct{ s *gameplayTestSource }
|
||||
|
||||
func (s gameplayItems) All() []player.Item { return s.s.items }
|
||||
|
||||
func TestInventorySnapshotProjectsMissionInputsWithoutUnrelatedReads(t *testing.T) {
|
||||
source := &gameplayTestSource{costumes: []player.Costume{{ID: 1, InvenIndex: 101, Level: 2}}, equipment: []player.Equipment{{ID: 2, InvenIndex: 201, Level: 3}}, items: []player.Item{{Type: 5, ID: 4, Count: 7}, {Type: 5, ID: 4, Count: 2}}}
|
||||
p := &OwnedGameplayAchievementProvider{Costumes: gameplayCostumes{source}, Equipment: gameplayEquipment{source}, Items: gameplayItems{source}, Conditions: func() ([]GameplayAchievementCondition, error) {
|
||||
t.Fatal("inventory projection read quest conditions")
|
||||
return nil, nil
|
||||
}, FieldObjects: func() (map[string]gamedata.FieldRewardObject, error) {
|
||||
t.Fatal("inventory projection queried field objects")
|
||||
return nil, nil
|
||||
}}
|
||||
before, err := p.InventorySnapshot()
|
||||
if err != nil || before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 {
|
||||
t.Fatalf("inventory projection=%+v err=%v", before, err)
|
||||
}
|
||||
source.costumes[0].Level = 4
|
||||
source.equipment[0].Level = 5
|
||||
source.items[0].Count = 1
|
||||
after, err := p.InventorySnapshot()
|
||||
if err != nil || after.Items[[2]uint64{5, 4}] != 3 || after.Costumes[101].Level != 4 || after.Equipment[201].Level != 5 {
|
||||
t.Fatalf("changed inventory projection=%+v err=%v", after, err)
|
||||
}
|
||||
if before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 {
|
||||
t.Fatal("new snapshot modified event baseline")
|
||||
}
|
||||
}
|
||||
func TestTemporaryPartyMembersCannotGrantPermanentAcquisitionAchievements(t *testing.T) {
|
||||
source := &gameplayTestSource{}
|
||||
p := &OwnedGameplayAchievementProvider{Characters: source}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type achievementQueryStore struct {
|
||||
*accountstate.Repository
|
||||
lists, loads, saves int
|
||||
}
|
||||
|
||||
func (s *achievementQueryStore) ListEntries(domain, bucket string) (map[string][]byte, error) {
|
||||
s.lists++
|
||||
return s.Repository.ListEntries(domain, bucket)
|
||||
}
|
||||
func (s *achievementQueryStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) {
|
||||
s.loads++
|
||||
return s.Repository.LoadEntry(domain, bucket, key)
|
||||
}
|
||||
func (s *achievementQueryStore) SaveWithEntries(domain string, core []byte, entries []stateio.EntryMutation) error {
|
||||
s.saves++
|
||||
return s.Repository.SaveWithEntries(domain, core, entries)
|
||||
}
|
||||
|
||||
func TestAchievementProgressBoundaryIsAtomicOrderedAndReplayable(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 := &achievementQueryStore{Repository: repo}
|
||||
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{1: {0}, 2: {0}}, Conditions: map[int]gamedata.AchievementCondition{1: {Type: 14, SubType: 21}, 2: {Type: 7}}}
|
||||
s, err := NewAchievementService(design, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("test")
|
||||
conditions := []GameplayAchievementCondition{{Type: 14, SubType: 21, Value: 1}}
|
||||
events := []GameplayAchievementRecordedEvent{{Identity: "a", Type: 14, SubType: 21, Count: 3}, {Identity: "b", Type: 14, SubType: 21, Count: 4}, {Identity: "b", Type: 14, SubType: 21, Count: 4}}
|
||||
before, after, err := s.ApplyGameplayProgress(conditions, events)
|
||||
if err != nil || len(before) != 0 || !reflect.DeepEqual(after, map[int]uint64{1: 8}) {
|
||||
t.Fatalf("ordered boundary before=%v after=%v err=%v", before, after, err)
|
||||
}
|
||||
if store.lists != 1 || store.saves != 1 {
|
||||
t.Fatalf("boundary lists=%d saves=%d", store.lists, store.saves)
|
||||
}
|
||||
if _, after, err = s.ApplyGameplayProgress(nil, events); err != nil || after[1] != 8 {
|
||||
t.Fatalf("receipt replay after=%v err=%v", after, err)
|
||||
}
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A conflicting event after a new event must install neither event nor
|
||||
// the preceding condition changes, even before outer rollback executes.
|
||||
bad := append([]GameplayAchievementRecordedEvent{{Identity: "unwritten", Type: 7, Count: 5}}, GameplayAchievementRecordedEvent{Identity: "a", Type: 14, SubType: 21, Count: 9})
|
||||
if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: 2}}, bad); err == nil {
|
||||
t.Fatal("receipt conflict accepted")
|
||||
}
|
||||
if err := op.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := repo.LoadEntry("missions", "achievement_events", "unwritten"); err != nil || found {
|
||||
t.Fatalf("failed boundary persisted event=%v err=%v", found, err)
|
||||
}
|
||||
if got, err := s.CounterValues(); err != nil || !reflect.DeepEqual(got, map[int]uint64{1: 8}) {
|
||||
t.Fatalf("failed boundary installed partial counts=%v err=%v", got, err)
|
||||
}
|
||||
if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: math.MaxInt64}}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.ApplyGameplayProgress(conditions, []GameplayAchievementRecordedEvent{{Identity: "overflow", Type: 7, Count: 1}}); err == nil {
|
||||
t.Fatal("overflow accepted")
|
||||
}
|
||||
if value, err := s.AchievementValue(1); err != nil || value != 8 {
|
||||
t.Fatalf("overflow installed preceding condition=%d err=%v", value, err)
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatalf("dirty rollback=%v", err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Repository = repo
|
||||
s, err = NewAchievementService(design, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, after, err = s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}})
|
||||
if err != nil || after[1] != 10 {
|
||||
t.Fatalf("rollback poisoned retry counts=%v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
// This comparison uses the prior public counter operations as the baseline,
|
||||
// and the optimized boundary against the same synthetic SQLite counter set.
|
||||
// The 57-read case models login: no events, with 20 completed pack conditions.
|
||||
func BenchmarkAchievementProgressBoundary(b *testing.B) {
|
||||
for _, eventCount := range []int{0, 20} {
|
||||
for _, bulk := range []bool{false, true} {
|
||||
name := fmt.Sprintf("events%d/bulk%v", eventCount, bulk)
|
||||
b.Run(name, func(b *testing.B) {
|
||||
repo, err := accountstate.Open(filepath.Join(b.TempDir(), "state.db"))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
store := &achievementQueryStore{Repository: repo}
|
||||
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{}, Conditions: map[int]gamedata.AchievementCondition{}}
|
||||
for id := 1; id <= 500; id++ {
|
||||
design.Groups[id] = []int{0}
|
||||
design.Conditions[id] = gamedata.AchievementCondition{Type: 14, SubType: uint64(id)}
|
||||
}
|
||||
s, err := NewAchievementService(design, store)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
s.BeginSession("benchmark")
|
||||
var conditions []GameplayAchievementCondition
|
||||
for id := 1; id <= 20; id++ {
|
||||
conditions = append(conditions, GameplayAchievementCondition{Type: 14, SubType: uint64(id), Value: 1})
|
||||
}
|
||||
var seed []stateio.EntryMutation
|
||||
for id := 1; id <= 500; id++ {
|
||||
seed = append(seed, stateio.EntryMutation{Bucket: "achievement_counts", Key: fmt.Sprint(id), Payload: []byte("1")})
|
||||
}
|
||||
if err := repo.SaveWithEntries("missions", nil, seed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
store.lists, store.loads, store.saves = 0, 0, 0
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for iteration := 0; iteration < b.N; iteration++ {
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for request := 0; request < 57; request++ {
|
||||
if bulk {
|
||||
_, _, err = s.ApplyGameplayProgress(conditions, nil)
|
||||
} else {
|
||||
_, err = s.CounterValues()
|
||||
for _, c := range conditions {
|
||||
if err == nil {
|
||||
_, err = s.SetCondition(c.Type, c.SubType, c.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
var events []GameplayAchievementRecordedEvent
|
||||
for id := 0; id < eventCount; id++ {
|
||||
events = append(events, GameplayAchievementRecordedEvent{Identity: fmt.Sprintf("%d/%d/%d", iteration, request, id), Type: 14, SubType: uint64(id + 1), Count: 1})
|
||||
}
|
||||
if bulk {
|
||||
_, _, err = s.ApplyGameplayProgress(conditions, events)
|
||||
} else {
|
||||
for _, c := range conditions {
|
||||
if err == nil {
|
||||
_, err = s.SetCondition(c.Type, c.SubType, c.Value)
|
||||
}
|
||||
}
|
||||
for _, e := range events {
|
||||
if err == nil {
|
||||
_, err = s.RecordEvent(e.Identity, e.Type, e.SubType, e.Count)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
_, err = s.CounterValues()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
b.ReportMetric(float64(store.lists)/float64(b.N), "bucket-reads/op")
|
||||
b.ReportMetric(float64(store.loads)/float64(b.N), "entry-reads/op")
|
||||
b.ReportMetric(float64(store.saves)/float64(b.N), "entry-writes/op")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user