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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user