fix(server): restore achievements, progress, claims and experience

Load ordinary achievements and send transactional gameplay progress notifications.
Restore main-story clears and recorded gacha history without duplicate credit.
Validate claim thresholds and prior tiers, credit wallet rewards and persist
achievement experience in login responses.
This commit is contained in:
2026-10-04 19:54:26 +08:00
parent 5b5a219dc2
commit aec053f7d2
23 changed files with 1464 additions and 27 deletions
@@ -0,0 +1,130 @@
package world
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func (s *AchievementService) AchievementValue(groupID uint64) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.load()
if err != nil {
return 0, err
}
return uint64(state.Counts[strconv.FormatUint(groupID, 10)]), nil
}
// RecordEvent is an authoritative event increment, independently replayable
// by a domain operation identity. It does not infer cumulative counts from
// inventory balances, which lose consumed and discarded items.
func (s *AchievementService) RecordEvent(identity string, conditionType, subType, count uint64) ([][]byte, error) {
if identity == "" || count == 0 || count > math.MaxInt64 {
return nil, fmt.Errorf("achievement: invalid event")
}
s.mu.Lock()
defer s.mu.Unlock()
digest := fmt.Sprintf("%d/%d/%d", conditionType, subType, count)
if raw, found, err := s.store.LoadEntry("missions", "achievement_events", identity); err != nil {
return nil, err
} else if found {
if string(raw) != digest {
return nil, fmt.Errorf("achievement: event replay conflicts")
}
return nil, nil
}
state, err := s.load()
if err != nil {
return nil, err
}
var groups []int
for group, c := range s.design.Conditions {
if c.Type == conditionType && c.SubType == subType {
groups = append(groups, group)
}
}
sort.Ints(groups)
changes := []stateio.EntryMutation{{Bucket: "achievement_events", Key: identity, Payload: []byte(digest)}}
var updates [][]byte
for _, group := range groups {
key := strconv.Itoa(group)
if state.Counts[key] > math.MaxInt64-int64(count) {
return nil, fmt.Errorf("achievement: event overflow")
}
value := state.Counts[key] + int64(count)
raw, _ := json.Marshal(value)
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw})
update := wire.AppendVarint(nil, 1, uint64(group))
update = wire.AppendVarint(update, 2, uint64(value))
update = wire.AppendVarint(update, 3, 1)
updates = append(updates, update)
}
if err := s.store.SaveWithEntries("missions", nil, changes); err != nil {
return nil, err
}
return updates, nil
}
// SetCondition only accepts exact current-state conditions; cumulative events use RecordEvent.
func (s *AchievementService) SetCondition(conditionType, subType, value uint64) ([][]byte, error) {
if value > math.MaxInt64 {
return nil, fmt.Errorf("achievement: invalid absolute value")
}
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.load()
if err != nil {
return nil, err
}
var groups []int
for group, c := range s.design.Conditions {
if c.Type == conditionType && c.SubType == subType {
groups = append(groups, group)
}
}
sort.Ints(groups)
var changes []stateio.EntryMutation
var updates [][]byte
for _, group := range groups {
key := strconv.Itoa(group)
if state.Counts[key] == int64(value) {
continue
}
raw, _ := json.Marshal(value)
changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw})
update := wire.AppendVarint(nil, 1, uint64(group))
update = wire.AppendVarint(update, 2, value)
updates = append(updates, wire.AppendVarint(update, 3, 1))
}
if len(changes) == 0 {
return nil, nil
}
if err := s.store.SaveWithEntries("missions", nil, changes); err != nil {
return nil, err
}
return updates, nil
}
func (s *AchievementService) CounterValues() (map[int]uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.load()
if err != nil {
return nil, err
}
out := map[int]uint64{}
for key, value := range state.Counts {
group, err := strconv.Atoi(key)
if err != nil {
return nil, err
}
out[group] = uint64(value)
}
return out, nil
}
@@ -0,0 +1,48 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"testing"
)
func TestAchievementEventsMatchDesignAndReplayAcrossReopen(t *testing.T) {
store := stateio.NewMemory()
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{987: {0, 1}, 654: {0}}, Conditions: map[int]gamedata.AchievementCondition{987: {Type: 54}, 654: {Type: 54, SubType: 2}}}
s, err := NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
updates, err := s.RecordEvent("draw:1", 54, 0, 10)
if err != nil || len(updates) != 1 {
t.Fatalf("updates=%d err=%v", len(updates), err)
}
value, _, _ := wire.Varint(updates[0], 2)
isSet, _, _ := wire.Varint(updates[0], 3)
if value != 10 || isSet != 1 {
t.Fatal("notify not absolute")
}
s, err = NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
if updates, err := s.RecordEvent("draw:1", 54, 0, 10); err != nil || len(updates) != 0 {
t.Fatalf("replay=%v err=%v", updates, err)
}
if _, err := s.RecordEvent("draw:1", 54, 0, 9); err == nil {
t.Fatal("replay mismatch accepted")
}
if v, err := s.AchievementValue(987); err != nil || v != 10 {
t.Fatalf("value=%d err=%v", v, err)
}
if v, _ := s.AchievementValue(654); v != 0 {
t.Fatal("wrong subtype incremented")
}
if updates, err := s.SetCondition(54, 2, 7); err != nil || len(updates) != 1 {
t.Fatal(err)
}
if updates, err := s.SetCondition(54, 2, 7); err != nil || len(updates) != 0 {
t.Fatal("absolute unchanged notified")
}
}
@@ -0,0 +1,404 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"fmt"
"sort"
"strconv"
)
// GameplayAchievementCounter persists each event in the request transaction.
type GameplayAchievementCounter interface {
RecordEvent(string, uint64, uint64, uint64) ([][]byte, error)
SetCondition(uint64, uint64, uint64) ([][]byte, error)
CounterValues() (map[int]uint64, error)
}
type GameplayAchievementEvent struct {
Type, SubType, Count uint64
Identity string
StableIdentity bool
}
type GameplayAchievementCondition struct{ Type, SubType, Value uint64 }
type GameplayAchievementSnapshot struct {
Characters map[uint64]player.Character
Costumes map[uint64]player.Costume
Equipment map[uint64]player.Equipment
Items map[[2]uint64]uint64
Conditions []GameplayAchievementCondition
FieldObjects map[string]gamedata.FieldRewardObject
GachaGrants map[string]uint64
}
type GameplayAchievementProvider interface {
Snapshot() (GameplayAchievementSnapshot, error)
Events(string, []byte, []byte, GameplayAchievementSnapshot, GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error)
}
type GameplayAchievementObserver struct {
counter GameplayAchievementCounter
provider GameplayAchievementProvider
session string
before GameplayAchievementSnapshot
counters map[int]uint64
}
func NewGameplayAchievementObserver(counter GameplayAchievementCounter, provider GameplayAchievementProvider) (*GameplayAchievementObserver, error) {
if counter == nil || provider == nil {
return nil, fmt.Errorf("achievement: gameplay provider unavailable")
}
return &GameplayAchievementObserver{counter: counter, provider: provider}, nil
}
func (s *GameplayAchievementObserver) BeginSession(id string) { s.session = id; s.counters = nil }
// SyncRecordedHistory must run within the startup account transaction. Retained
// real grants establish a lower bound; inventory never substitutes for history.
func (s *GameplayAchievementObserver) SyncRecordedHistory() error {
snapshot, err := s.provider.Snapshot()
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)
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 {
return err
}
}
return nil
}
func (s *GameplayAchievementObserver) BeforeDispatch(_ string, _ []byte) error {
var err error
s.counters, err = s.counter.CounterValues()
if err != nil {
return err
}
s.before, err = s.provider.Snapshot()
if err != nil {
return err
}
for _, condition := range s.before.Conditions {
if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil {
return err
}
}
return nil
}
func (s *GameplayAchievementObserver) AfterDispatch(path string, request, response []byte) ([]byte, error) {
after, err := s.provider.Snapshot()
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
}
}
events, err := s.provider.Events(path, request, response, s.before, after)
if err != nil {
return nil, err
}
if len(events) > 0 {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || s.session == "" {
return nil, fmt.Errorf("achievement: gameplay event sequence unavailable")
}
for i, event := range events {
identity := fmt.Sprintf("%s/%s/%d/%d/%s", s.session, path, seq, i, event.Identity)
if event.StableIdentity {
identity = event.Identity
}
if _, err := s.counter.RecordEvent(identity, event.Type, event.SubType, event.Count); err != nil {
return nil, err
}
}
}
values, err := s.counter.CounterValues()
if err != nil {
return nil, err
}
var groups []int
for group, value := range values {
if s.counters[group] != value {
groups = append(groups, group)
}
}
sort.Ints(groups)
var notify []byte
for _, group := range groups {
row := wire.AppendVarint(nil, 1, uint64(group))
row = wire.AppendVarint(row, 2, values[group])
row = wire.AppendVarint(row, 3, 1)
notify = wire.AppendBytes(notify, 2, row)
}
return notify, nil
}
type GameplayCharacterSource interface{ RawAll() []player.Character }
type GameplayGachaSource interface{ GachaGrantSummary() map[string]uint64 }
type GameplayCostumeSource interface{ Costumes() []player.Costume }
type GameplayEquipmentSource interface{ All() []player.Equipment }
type GameplayItemSource interface{ All() []player.Item }
// OwnedGameplayAchievementProvider uses before/after authoritative ownership.
// GET conditions are event counters: existing inventory is never a historical
// acquisition total. Conditions are supplied only by proven state projections.
type OwnedGameplayAchievementProvider struct {
Gacha GameplayGachaSource
Characters GameplayCharacterSource
Costumes GameplayCostumeSource
Equipment GameplayEquipmentSource
Items GameplayItemSource
Design *gamedata.AchievementCounterDesign
CharacterGrades, EquipmentGrades map[uint64]uint64
Conditions func() ([]GameplayAchievementCondition, error)
FieldObjects func() (map[string]gamedata.FieldRewardObject, error)
}
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{}}
if p.Characters != nil {
for _, v := range p.Characters.RawAll() {
s.Characters[v.InvenIndex] = v
}
}
if p.Costumes != nil {
for _, v := range p.Costumes.Costumes() {
s.Costumes[v.InvenIndex] = v
}
}
if p.Equipment != nil {
for _, v := range p.Equipment.All() {
s.Equipment[v.InvenIndex] = v
}
}
if p.Items != nil {
for _, v := range p.Items.All() {
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
}
func (p *OwnedGameplayAchievementProvider) Events(path string, _ []byte, _ []byte, before, after GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) {
var events []GameplayAchievementEvent
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
}
}
events = append(events, GameplayAchievementEvent{Type: kind, SubType: sub, Count: count, Identity: id})
}
for index, v := range after.Characters {
old, exists := before.Characters[index]
id := "char:" + strconv.FormatUint(index, 10)
if !exists {
emit(19, 0, 1, id)
if grade := p.CharacterGrades[v.ID]; grade != 0 {
emit(20, grade, 1, id)
}
}
if exists && v.Level > old.Level {
emit(22, 0, v.Level-old.Level, id)
}
}
for index, v := range after.Costumes {
old, exists := before.Costumes[index]
id := "costume:" + strconv.FormatUint(index, 10)
if !exists {
emit(26, 0, 1, id)
emit(26, v.ID, 1, id)
}
if exists && v.Level > old.Level {
emit(27, 0, v.Level-old.Level, id)
}
}
for index, v := range after.Equipment {
old, exists := before.Equipment[index]
id := "equipment:" + strconv.FormatUint(index, 10)
if !exists {
emit(7, 0, 1, id)
if grade := p.EquipmentGrades[v.ID]; grade != 0 {
emit(7, grade, 1, id)
}
}
if exists && v.UpgradeAttempts > old.UpgradeAttempts {
successes := uint64(0)
if v.Level > old.Level {
successes = v.Level - old.Level
}
attempts := v.UpgradeAttempts - old.UpgradeAttempts
if successes > attempts {
return nil, fmt.Errorf("achievement: equipment upgrade delta exceeds attempts")
}
if successes > 0 {
emit(9, p.EquipmentGrades[v.ID], successes, id)
}
emit(10, 0, attempts-successes, id)
}
}
if path == "/EatFood" || path == "/EatFoodAuto" {
for key, count := range before.Items {
if key[0] == 5 && count > after.Items[key] {
emit(55, 0, count-after.Items[key], "food:"+strconv.FormatUint(key[1], 10))
}
}
}
for key, obj := range after.FieldObjects {
if _, exists := before.FieldObjects[key]; !exists {
switch obj.Type {
case 1:
emit(28, 0, 1, key)
case 2:
emit(31, 0, 1, key)
case 5:
emit(30, 0, 1, key)
}
}
}
for identity, count := range after.GachaGrants {
if _, exists := before.GachaGrants[identity]; !exists {
emit(54, 0, count, "gacha-grant:"+identity)
if len(events) > 0 && events[len(events)-1].Identity == "gacha-grant:"+identity {
events[len(events)-1].StableIdentity = true
}
}
}
sort.Slice(events, func(i, j int) bool {
a, b := events[i], events[j]
if a.Type != b.Type {
return a.Type < b.Type
}
if a.SubType != b.SubType {
return a.SubType < b.SubType
}
return a.Identity < b.Identity
})
return events, nil
}
// GameplayAchievementProvider exposes only authoritative owned instances and
// persisted quest/object state. It does not manufacture past acquisition counts.
func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounterDesign, grades gamedata.GameplayAchievementGrades) *OwnedGameplayAchievementProvider {
p := &OwnedGameplayAchievementProvider{Design: design, CharacterGrades: grades.Characters, EquipmentGrades: grades.Equipment}
if s.characters != nil {
p.Characters = s.characters
}
if s.collection != nil {
p.Costumes = s.collection
p.Gacha = s.collection
}
if s.equipment != nil {
p.Equipment = s.equipment
}
if s.inventory != nil {
p.Items = s.inventory
}
p.Conditions = func() ([]GameplayAchievementCondition, error) {
var conditions []GameplayAchievementCondition
seen := map[[2]uint64]bool{}
if s.storyCatalog == nil {
return nil, nil
}
for _, c := range design.Conditions {
if c.Type < 14 || c.Type > 16 {
continue
}
key := [2]uint64{c.Type, c.SubType}
if seen[key] {
continue
}
seen[key] = true
pack, known := s.storyCatalog.Packs[int(c.SubType)]
if !known || len(pack.MainQuestIDs) == 0 {
continue
}
complete := true
for _, qid := range pack.MainQuestIDs {
if !s.state.QuestCleared(qid, pack.ID, int(c.Type-14)) {
complete = false
break
}
}
if complete {
conditions = append(conditions, GameplayAchievementCondition{Type: c.Type, SubType: c.SubType, Value: 1})
}
}
return conditions, nil
}
p.FieldObjects = func() (map[string]gamedata.FieldRewardObject, error) {
objects := map[string]gamedata.FieldRewardObject{}
// 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{}
for id := range s.packs {
packs[id] = true
}
for id := range s.fieldPacks {
packs[id] = true
}
for pack := range packs {
ids, err := s.state.OpenedFieldRewards(pack)
if err != nil {
return nil, err
}
if len(ids) == 0 {
continue
}
d, err := s.fieldObjectDesign(pack)
if err != nil {
return nil, err
}
for _, id := range ids {
obj, known := d.Objects[id]
if !known {
return nil, fmt.Errorf("achievement: opened field design absent")
}
period, err := s.fieldObjectPeriod(obj)
if err != nil {
continue
}
opened, err := s.state.FieldRewardOpened(pack, id, period)
if err != nil {
return nil, err
}
if opened {
objects[fmt.Sprintf("field:%d:%d:%s", pack, id, period)] = obj
}
}
}
return objects, nil
}
return p
}
@@ -0,0 +1,179 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"testing"
)
type gameplayTestSource struct {
characters []player.Character
costumes []player.Costume
equipment []player.Equipment
items []player.Item
}
func (s *gameplayTestSource) RawAll() []player.Character { return s.characters }
type gameplayCostumes struct{ s *gameplayTestSource }
func (s gameplayCostumes) Costumes() []player.Costume { return s.s.costumes }
type gameplayEquipment struct{ s *gameplayTestSource }
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 TestGameplayAchievementProjectsChangesWithoutFakeHistoricalGets(t *testing.T) {
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{701: {0}, 702: {0}, 703: {0}, 704: {0}, 705: {0}}, Conditions: map[int]gamedata.AchievementCondition{701: {Type: 7, SubType: 4}, 702: {Type: 22}, 703: {Type: 26}, 704: {Type: 55}, 705: {Type: 14, SubType: 909}}}
service, err := NewAchievementService(design, stateio.NewMemory())
if err != nil {
t.Fatal(err)
}
source := &gameplayTestSource{characters: []player.Character{{ID: 1, InvenIndex: 1, Level: 3}}, costumes: []player.Costume{{ID: 2, InvenIndex: 2}}, equipment: []player.Equipment{{ID: 3, InvenIndex: 3}}, items: []player.Item{{Type: 5, ID: 8, Count: 10}}}
provider := &OwnedGameplayAchievementProvider{Characters: source, Costumes: gameplayCostumes{source}, Equipment: gameplayEquipment{source}, Items: gameplayItems{source}, Design: design, EquipmentGrades: map[uint64]uint64{3: 4}}
observer, err := NewGameplayAchievementObserver(service, provider)
if err != nil {
t.Fatal(err)
}
observer.BeginSession("test")
req := wire.AppendVarint(nil, 1, 1)
if err := observer.BeforeDispatch("/AchievementInfo", req); err != nil {
t.Fatal(err)
}
notify, err := observer.AfterDispatch("/AchievementInfo", req, nil)
if err != nil || len(notify) != 0 {
t.Fatalf("invented historical counts notify=%x err=%v", notify, err)
}
if err := observer.BeforeDispatch("/CharGrowth", req); err != nil {
t.Fatal(err)
}
source.characters[0].Level = 5
notify, err = observer.AfterDispatch("/CharGrowth", req, nil)
if err != nil {
t.Fatal(err)
}
row, _, _ := wire.Bytes(notify, 2)
group, _, _ := wire.Varint(row, 1)
value, _, _ := wire.Varint(row, 2)
if group != 702 || value != 2 {
t.Fatalf("absolute level update group=%d value=%d", group, value)
}
if err := observer.BeforeDispatch("/CharGrowth", req); err != nil {
t.Fatal(err)
}
notify, err = observer.AfterDispatch("/CharGrowth", req, nil)
if err != nil || len(notify) != 0 {
t.Fatal("replay emitted increment")
}
req = wire.AppendVarint(nil, 1, 2)
if err := observer.BeforeDispatch("/FieldObjectReward", req); err != nil {
t.Fatal(err)
}
source.equipment = append(source.equipment, player.Equipment{ID: 3, InvenIndex: 4})
if _, err := observer.AfterDispatch("/FieldObjectReward", req, nil); err != nil {
t.Fatal(err)
}
values, err := service.CounterValues()
if err != nil || values[701] != 1 {
t.Fatalf("new equipment values=%v err=%v", values, err)
}
}
func TestGameplayAchievementUsesChangedPackIDsAndDoesNotReplayChest(t *testing.T) {
s := testService()
s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{808: {ID: 808, MainQuestIDs: []int{111, 222}}}}
design := &gamedata.AchievementCounterDesign{Conditions: map[int]gamedata.AchievementCondition{99: {Type: 15, SubType: 808}}}
p := s.GameplayAchievementProvider(design, gamedata.GameplayAchievementGrades{})
if err := s.state.ClearQuest(111, 808, 1); err != nil {
t.Fatal(err)
}
if err := s.state.ClearQuest(222, 808, 1); err != nil {
t.Fatal(err)
}
snapshot, err := p.Snapshot()
if err != nil {
t.Fatal(err)
}
if len(snapshot.Conditions) != 1 || snapshot.Conditions[0].SubType != 808 || snapshot.Conditions[0].Type != 15 || snapshot.Conditions[0].Value != 1 {
t.Fatalf("pack projection=%+v", snapshot.Conditions)
}
before := GameplayAchievementSnapshot{FieldObjects: map[string]gamedata.FieldRewardObject{}}
after := GameplayAchievementSnapshot{FieldObjects: map[string]gamedata.FieldRewardObject{"chest": {Type: 2}}}
p.Design = nil
events, err := p.Events("/FieldObjectReward", nil, nil, before, after)
if err != nil || len(events) != 1 || events[0].Type != 31 {
t.Fatalf("chest events=%+v err=%v", events, err)
}
events, err = p.Events("/FieldObjectReward", nil, nil, after, after)
if err != nil || len(events) != 0 {
t.Fatal("chest replay increments")
}
}
func TestGameplayGachaEventsFollowNewDurableGrantKeys(t *testing.T) {
p := &OwnedGameplayAchievementProvider{}
before := GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10}}
after := GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10, "new": 3}}
events, err := p.Events("/GachaBuy", nil, nil, before, after)
if err != nil || len(events) != 1 || events[0].Type != 54 || events[0].Count != 3 {
t.Fatalf("new grant=%+v err=%v", events, err)
}
events, err = p.Events("/GachaBuy", nil, []byte{1, 2, 3}, after, after)
if err != nil || len(events) != 0 {
t.Fatal("cached response counted again")
}
}
type gameplayHistoryProvider struct {
snapshot GameplayAchievementSnapshot
OwnedGameplayAchievementProvider
}
func (p *gameplayHistoryProvider) Snapshot() (GameplayAchievementSnapshot, error) {
return p.snapshot, nil
}
func TestGameplayRecordedHistoryAndFutureShareStableGrantReceipts(t *testing.T) {
store := stateio.NewMemory()
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{808: {0}}, Conditions: map[int]gamedata.AchievementCondition{808: {Type: 54}}}
service, err := NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
provider := &gameplayHistoryProvider{snapshot: GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10}}}
observer, err := NewGameplayAchievementObserver(service, provider)
if err != nil {
t.Fatal(err)
}
if err := observer.SyncRecordedHistory(); err != nil {
t.Fatal(err)
}
observer.BeginSession("login1")
req := wire.AppendVarint(nil, 1, 1)
if err := observer.BeforeDispatch("/GachaBuy", req); err != nil {
t.Fatal(err)
}
provider.snapshot = GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10, "new": 3}}
if _, err := observer.AfterDispatch("/GachaBuy", req, nil); err != nil {
t.Fatal(err)
}
service, err = NewAchievementService(design, store)
if err != nil {
t.Fatal(err)
}
observer, err = NewGameplayAchievementObserver(service, provider)
if err != nil {
t.Fatal(err)
}
if err := observer.SyncRecordedHistory(); err != nil {
t.Fatal(err)
}
values, err := service.CounterValues()
if err != nil || values[808] != 13 {
t.Fatalf("history/future duplicated values=%v err=%v", values, err)
}
}
+5
View File
@@ -132,6 +132,11 @@ func (s *AchievementService) Handle(path string, request []byte) (int, []byte, b
row := wire.AppendVarint(nil, 1, uint64(group))
row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)]))
var maxID uint64
// AchievementPacket.UpdateAchievementCount initializes title MaxClearId
// to 1000; this is the client protocol default, not a granted tier.
if content == 1 {
maxID = 1000
}
for key := range claimed {
if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID {
maxID = key.ID
@@ -63,6 +63,7 @@ func TestAchievementUpdatePersistsAndRetries(t *testing.T) {
row := wire.AppendVarint(nil, 1, 7)
row = wire.AppendVarint(row, 2, 2)
if content != 0 {
row = wire.AppendVarint(row, 3, 1000)
row = wire.AppendVarint(row, 4, content)
}
expected = wire.AppendBytes(expected, 1, row)
@@ -114,7 +115,7 @@ func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) {
type achievementClaimsFixture struct{}
func (achievementClaimsFixture) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool {
return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 3}: true}
return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 1003}: true}
}
func TestAchievementInfoIncludesRealClaimState(t *testing.T) {
s := achievementTestService(t, stateio.NewMemory())
@@ -136,7 +137,7 @@ func TestAchievementInfoIncludesRealClaimState(t *testing.T) {
t.Fatalf("missing claim group rows: %x", body)
}
clear, found, err := wire.Varint(rows[1], 3)
if err != nil || !found || clear != 3 {
if err != nil || !found || clear != 1003 {
t.Fatal("max_clear_id did not come from actual claims")
}
}