perf(server): batch achievement updates and eliminate redundant snapshots, writes and JSON round trips

This commit is contained in:
2026-10-06 20:47:33 +08:00
parent c13ff41878
commit b9f1ac4aa6
13 changed files with 813 additions and 79 deletions
+36 -20
View File
@@ -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 := &noticeProvider{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 := &noticeProvider{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)
}
}
}
}
+22 -2
View File
@@ -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()
+7 -1
View File
@@ -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
}
}
+30
View File
@@ -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)
}
})
}
}
+13 -3
View File
@@ -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 {
+1 -5
View File
@@ -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")
})
}
}
}