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:
@@ -685,6 +685,24 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load achievement counter state: %w", err)
|
||||
}
|
||||
if err := missionService.AttachAchievementProgress(achievementCounters); err != nil {
|
||||
return fmt.Errorf("attach achievement completion validation: %w", err)
|
||||
}
|
||||
if err := login.AttachAchievementExperience(missionService); err != nil {
|
||||
return fmt.Errorf("attach persisted achievement experience: %w", err)
|
||||
}
|
||||
achievementGrades, err := gamedata.LoadGameplayAchievementGrades(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load achievement gameplay grades: %w", err)
|
||||
}
|
||||
achievementObserver, err := world.NewGameplayAchievementObserver(achievementCounters,
|
||||
worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades))
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize achievement gameplay observer: %w", err)
|
||||
}
|
||||
if err := achievementObserver.SyncRecordedHistory(); err != nil {
|
||||
return fmt.Errorf("restore recorded achievement history: %w", err)
|
||||
}
|
||||
game, err := session.NewServerWithProgress(login, progressState,
|
||||
battleService,
|
||||
worldService,
|
||||
@@ -715,6 +733,9 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := game.AttachResponseObserver(achievementObserver); err != nil {
|
||||
return fmt.Errorf("attach achievement progress notifications: %w", err)
|
||||
}
|
||||
if authService != nil {
|
||||
if err := game.AttachLoginAuthenticator(authService); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type testAchievementExperience struct {
|
||||
value uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *testAchievementExperience) AchievementExperience() (uint64, error) { return s.value, s.err }
|
||||
|
||||
func TestLoginReadsPersistedAchievementExperienceEachTime(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 12, 999)}
|
||||
source := &testAchievementExperience{value: 7}
|
||||
if err := seed.AttachAchievementExperience(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []uint64{7, 16, 0} {
|
||||
source.value = value
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(response, 1)
|
||||
exp, found, err := wire.Varint(user, 12)
|
||||
if err != nil || !found || exp != value {
|
||||
t.Fatalf("exp=%d want=%d err=%v", exp, value, err)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []testAchievementExperience{{value: math.MaxInt32 + 1}, {err: errors.New("read failed")}} {
|
||||
*source = invalid
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("invalid experience accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -39,6 +40,19 @@ type LoginSeed struct {
|
||||
firstGacha FirstGachaProvider
|
||||
friendshipAP FriendshipAPProvider
|
||||
lastPlayedPack LastPlayedPackProvider
|
||||
achievementExp AchievementExperienceProvider
|
||||
}
|
||||
|
||||
type AchievementExperienceProvider interface {
|
||||
AchievementExperience() (uint64, error)
|
||||
}
|
||||
|
||||
func (s *LoginSeed) AttachAchievementExperience(provider AchievementExperienceProvider) error {
|
||||
if provider == nil {
|
||||
return errors.New("account: missing achievement experience provider")
|
||||
}
|
||||
s.achievementExp = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
// LastPlayedPackProvider reads the persisted return destination. A zero value
|
||||
@@ -354,6 +368,19 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("account: session key: %w", err)
|
||||
}
|
||||
user := append([]byte(nil), s.UserInfo...)
|
||||
if s.achievementExp != nil {
|
||||
experience, err := s.achievementExp.AchievementExperience()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account: achievement experience: %w", err)
|
||||
}
|
||||
if experience > math.MaxInt32 {
|
||||
return nil, errors.New("account: achievement experience exceeds protocol range")
|
||||
}
|
||||
user, _, err = wire.ReplaceVarint(user, 12, experience)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.lastPlayedPack != nil {
|
||||
packID, err := s.lastPlayedPack.LastPlayedPackID()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,11 @@ import (
|
||||
|
||||
// AchievementCounterDesign indexes the content groups to which each counter belongs.
|
||||
// AchievementTable has several tiers with the same group ID; id is not unique.
|
||||
type AchievementCounterDesign struct{ Groups map[int][]int }
|
||||
type AchievementCondition struct{ Type, SubType, ParentGroup uint64 }
|
||||
type AchievementCounterDesign struct {
|
||||
Groups map[int][]int
|
||||
Conditions map[int]AchievementCondition
|
||||
}
|
||||
|
||||
func LoadAchievementCounterDesign(root, version string) (*AchievementCounterDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
@@ -27,13 +31,14 @@ func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error)
|
||||
}
|
||||
defer rows.Close()
|
||||
sets := map[int]map[int]bool{}
|
||||
conditions := map[int]AchievementCondition{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[int]int{}
|
||||
for _, field := range []int{4, 9, 13} {
|
||||
for _, field := range []int{1, 2, 4, 9, 13} {
|
||||
values, err := packedInts(raw, field)
|
||||
if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) {
|
||||
return nil, fmt.Errorf("gamedata: invalid achievement field%d", field)
|
||||
@@ -42,8 +47,9 @@ func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error)
|
||||
fields[field] = int(values[0])
|
||||
}
|
||||
}
|
||||
// GetAchievementTablesByConditionType emits root groups, including roots
|
||||
// whose UI useType is disabled. The wire update carries no content group.
|
||||
// Client condition lookup SQL filters parentGroupId=0.
|
||||
// The wire update carries no content group.
|
||||
|
||||
if fields[13] != 0 {
|
||||
continue
|
||||
}
|
||||
@@ -51,6 +57,11 @@ func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error)
|
||||
if group <= 0 || content < 0 || content > 1 {
|
||||
return nil, fmt.Errorf("gamedata: invalid achievement group %d/%d", group, content)
|
||||
}
|
||||
condition := AchievementCondition{Type: uint64(fields[2]), SubType: uint64(fields[1]), ParentGroup: uint64(fields[13])}
|
||||
if previous, ok := conditions[group]; ok && previous != condition {
|
||||
return nil, fmt.Errorf("gamedata: achievement group condition mismatch")
|
||||
}
|
||||
conditions[group] = condition
|
||||
if sets[group] == nil {
|
||||
sets[group] = map[int]bool{}
|
||||
}
|
||||
@@ -59,7 +70,7 @@ func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error)
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
design := &AchievementCounterDesign{Groups: map[int][]int{}}
|
||||
design := &AchievementCounterDesign{Groups: map[int][]int{}, Conditions: conditions}
|
||||
for group, contents := range sets {
|
||||
for content := range contents {
|
||||
design.Groups[group] = append(design.Groups[group], content)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GameplayAchievementGrades resolves subtype rarity from the current tables.
|
||||
// Instances contain design IDs, never inferred numeric ID ranges.
|
||||
type GameplayAchievementGrades struct{ Characters, Equipment map[uint64]uint64 }
|
||||
|
||||
func LoadGameplayAchievementGrades(root, version string) (GameplayAchievementGrades, error) {
|
||||
d := GameplayAchievementGrades{Characters: map[uint64]uint64{}, Equipment: map[uint64]uint64{}}
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer closeDB()
|
||||
for _, spec := range []struct {
|
||||
table string
|
||||
field int
|
||||
target map[uint64]uint64
|
||||
}{{"CharTable", 9, d.Characters}, {"EquipmentTable", 3, d.Equipment}} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + spec.table)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return d, err
|
||||
}
|
||||
grade, err := packedInts(raw, spec.field)
|
||||
if err != nil || len(grade) != 1 || grade[0] == 0 {
|
||||
rows.Close()
|
||||
return d, fmt.Errorf("gamedata: invalid achievement grade %s/%d", spec.table, id)
|
||||
}
|
||||
spec.target[id] = grade[0]
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package gamedata
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +34,25 @@ func TestAchievementCounterDesignUsesRootGroupsAndBothContents(t *testing.T) {
|
||||
t.Fatalf("wrong group index: %#v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementRewardDesignIncludesOrdinaryZeroContentsAndTargets(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.Exec("CREATE TABLE AchievementTable(ProtoBuf BLOB)")
|
||||
raw := wire.AppendVarint(wire.AppendVarint(nil, 9, 987), 11, 1)
|
||||
raw = wire.AppendVarint(raw, 8, 9)
|
||||
raw = append(raw, 25)
|
||||
raw = binary.LittleEndian.AppendUint64(raw, math.Float64bits(10))
|
||||
db.Exec("INSERT INTO AchievementTable VALUES (?)", raw)
|
||||
d := &MissionDesign{Achievements: map[AchievementKey]AchievementDesign{}}
|
||||
if err := loadAchievementRows(db, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := d.Achievements[AchievementKey{GroupID: 987, ID: 1}]
|
||||
if !ok || got.Target != 10 || got.AddExp != 9 {
|
||||
t.Fatalf("ordinary=%+v exists=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gamedata
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -45,8 +46,10 @@ type SectionRewardDesign struct {
|
||||
}
|
||||
|
||||
type AchievementDesign struct {
|
||||
AddExp uint64
|
||||
Rewards []Reward
|
||||
Target float64
|
||||
CounterGroup uint64
|
||||
AddExp uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
// LoadMissionDesign loads the three non-event design tables from the shared
|
||||
@@ -226,7 +229,7 @@ func loadAchievementRows(db *sql.DB, design *MissionDesign) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable id: %w", err)
|
||||
}
|
||||
if contents == 0 || groupID == 0 || id == 0 {
|
||||
if groupID == 0 || id == 0 {
|
||||
continue
|
||||
}
|
||||
exp, err := optionalScalar(proto, 8)
|
||||
@@ -237,11 +240,27 @@ func loadAchievementRows(db *sql.DB, design *MissionDesign) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable %d/%d/%d: %w", contents, groupID, id, err)
|
||||
}
|
||||
parent, err := optionalScalar(proto, 13)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
useType, err := optionalScalar(proto, 21)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent != 0 || useType != 0 {
|
||||
continue
|
||||
}
|
||||
key := AchievementKey{contents, groupID, id}
|
||||
if _, exists := design.Achievements[key]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate AchievementTable key %+v", key)
|
||||
}
|
||||
design.Achievements[key] = AchievementDesign{AddExp: exp, Rewards: rewards}
|
||||
target, found, err := fixed64Double(proto, 3)
|
||||
if err != nil || !found || target < 0 || math.IsNaN(target) || math.IsInf(target, 0) {
|
||||
return fmt.Errorf("gamedata: invalid achievement target")
|
||||
}
|
||||
counter := groupID
|
||||
design.Achievements[key] = AchievementDesign{Target: target, CounterGroup: counter, AddExp: exp, Rewards: rewards}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package missions
|
||||
|
||||
import "bd2server/internal/server/gamedata"
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClaimedAchievementIDs exposes committed claims to the counter query adapter.
|
||||
// The mission domain remains the sole owner of reward claims.
|
||||
@@ -15,3 +20,30 @@ func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AchievementExperience derives exact earned experience from durable claimed
|
||||
// identities and versioned table rewards. It does not manufacture levels.
|
||||
func (s *Service) AchievementExperience() (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var total uint64
|
||||
known := map[string]bool{}
|
||||
for key := range s.design.Achievements {
|
||||
known["achievement:"+achievementName(key)] = true
|
||||
}
|
||||
for _, identity := range s.state.Claimed {
|
||||
if strings.HasPrefix(identity, "achievement:") && !known[identity] {
|
||||
return 0, fmt.Errorf("missions: incompatible claimed achievement %q missing from current GameData", identity)
|
||||
}
|
||||
}
|
||||
for key, d := range s.design.Achievements {
|
||||
if !contains(s.state.Claimed, "achievement:"+achievementName(key)) {
|
||||
continue
|
||||
}
|
||||
if total > math.MaxUint64-d.AddExp {
|
||||
return 0, fmt.Errorf("missions: achievement experience overflow")
|
||||
}
|
||||
total += d.AddExp
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package missions
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testAchievementProgress uint64
|
||||
|
||||
func (p *testAchievementProgress) AchievementValue(uint64) (uint64, error) { return uint64(*p), nil }
|
||||
func TestAchievementClearRequiresProgressBeforeAnyBatchReward(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 987, ID: 1}: {Target: 5, CounterGroup: 987, AddExp: 7, Rewards: []gamedata.Reward{{Type: 8, ID: 123, Count: 1}}}, {GroupID: 987, ID: 2}: {Target: 10, CounterGroup: 987, AddExp: 9, Rewards: []gamedata.Reward{{Type: 8, ID: 123, Count: 2}}}}}
|
||||
s, err := Open(store, design, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
progress := testAchievementProgress(5)
|
||||
s.AttachAchievementProgress(&progress)
|
||||
info := wire.AppendVarint(nil, 1, 987)
|
||||
info = wire.AppendVarint(info, 2, 1)
|
||||
info = wire.AppendVarint(info, 2, 2)
|
||||
req := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 3, info)
|
||||
if _, _, _, err := s.Handle("/AchievementClear", req); err == nil {
|
||||
t.Fatal("unearned batch claimed")
|
||||
}
|
||||
if len(inv.All()) != 0 || len(s.state.Claimed) != 0 {
|
||||
t.Fatal("earlier batch reward mutated")
|
||||
}
|
||||
progress = 10
|
||||
if _, _, _, err := s.Handle("/AchievementClear", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp, err := s.AchievementExperience(); err != nil || exp != 16 {
|
||||
t.Fatalf("exp=%d err=%v", exp, err)
|
||||
}
|
||||
if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 3 {
|
||||
t.Fatalf("rewards=%+v", inv.All())
|
||||
}
|
||||
if _, _, _, err := s.Handle("/AchievementClear", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 3 {
|
||||
t.Fatal("replay granted twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementCurrencyRewardsCreditWalletAndReplay(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
wallet, _ := player.OpenWallet(store, player.Currency{})
|
||||
d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 987, ID: 1}: {Target: 1, AddExp: 9, Rewards: []gamedata.Reward{{Type: 3, Count: 50}, {Type: 4, Count: 100}}}}}
|
||||
s, err := Open(store, d, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AttachWallet(wallet)
|
||||
p := testAchievementProgress(1)
|
||||
s.AttachAchievementProgress(&p)
|
||||
req := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 987), 2, 1))
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, _, _, err := s.Handle("/AchievementClear", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if c := wallet.Snapshot(); c.Gold != 100 || c.FreeJewelry != 50 {
|
||||
t.Fatalf("wallet=%+v", c)
|
||||
}
|
||||
if len(inv.All()) != 0 {
|
||||
t.Fatal("currency stored in items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementTierOrderUsesActualIDsAndAllowsUnorderedBatch(t *testing.T) {
|
||||
for _, content := range []uint64{0, 1} {
|
||||
store := stateio.NewMemory()
|
||||
inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
first, last := uint64(17), uint64(93)
|
||||
if content == 1 {
|
||||
first, last = 1017, 1093
|
||||
}
|
||||
d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{ContentsGroup: content, GroupID: 987, ID: first}: {Target: 1, AddExp: 7}, {ContentsGroup: content, GroupID: 987, ID: last}: {Target: 2, AddExp: 9}}}
|
||||
s, err := Open(store, d, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := testAchievementProgress(2)
|
||||
s.AttachAchievementProgress(&p)
|
||||
request := func(ids ...uint64) []byte {
|
||||
info := wire.AppendVarint(nil, 1, 987)
|
||||
for _, id := range ids {
|
||||
info = wire.AppendVarint(info, 2, id)
|
||||
}
|
||||
return wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, content), 3, info)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/AchievementClear", request(last)); err == nil {
|
||||
t.Fatal("skipped earlier tier")
|
||||
}
|
||||
if len(s.state.Claimed) != 0 {
|
||||
t.Fatal("invalid jump mutated")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/AchievementClear", request(last, first)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exp, err := s.AchievementExperience(); err != nil || exp != 16 {
|
||||
t.Fatalf("exp=%d err=%v", exp, err)
|
||||
}
|
||||
s.state.Claimed = append(s.state.Claimed, "achievement:0/12345/1")
|
||||
if _, err := s.AchievementExperience(); err == nil {
|
||||
t.Fatal("unknown historical claim omitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,15 +33,28 @@ var ErrInvalidRequest = errors.New("missions: invalid request")
|
||||
// code. A request cannot manufacture a mission completion merely by naming a
|
||||
// table row. AchievementClear is different: its request carries the exact
|
||||
// client-calculated completed achievement ids, as in the official protocol.
|
||||
type AchievementProgressSource interface {
|
||||
AchievementValue(groupID uint64) (uint64, error)
|
||||
}
|
||||
|
||||
func (s *Service) AttachAchievementProgress(source AchievementProgressSource) error {
|
||||
if source == nil {
|
||||
return errors.New("missions: nil achievement progress")
|
||||
}
|
||||
s.achievementProgress = source
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
storage stateio.Store
|
||||
design *gamedata.MissionDesign
|
||||
inventory *player.Inventory
|
||||
wallet *player.Wallet
|
||||
mail *mail.Service
|
||||
state snapshot
|
||||
now func() time.Time
|
||||
achievementProgress AchievementProgressSource
|
||||
mu sync.Mutex
|
||||
storage stateio.Store
|
||||
design *gamedata.MissionDesign
|
||||
inventory *player.Inventory
|
||||
wallet *player.Wallet
|
||||
mail *mail.Service
|
||||
state snapshot
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) AttachWallet(wallet *player.Wallet) error {
|
||||
@@ -593,8 +606,60 @@ func (s *Service) clearAchievements(request []byte) ([]byte, error) {
|
||||
if err != nil || len(claims) == 0 {
|
||||
return nil, fmt.Errorf("%w: achievement clear info", ErrInvalidRequest)
|
||||
}
|
||||
requested := map[gamedata.AchievementKey]bool{}
|
||||
for _, claim := range claims {
|
||||
for _, id := range claim.IDs {
|
||||
key, _, ok := s.achievementDesign(contents, claim.GroupID, id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest)
|
||||
}
|
||||
requested[key] = true
|
||||
}
|
||||
}
|
||||
for key := range requested {
|
||||
if contains(s.state.Claimed, "achievement:"+achievementName(key)) {
|
||||
continue
|
||||
}
|
||||
for earlier := range s.design.Achievements {
|
||||
if earlier.ContentsGroup != key.ContentsGroup || earlier.GroupID != key.GroupID || earlier.ID >= key.ID {
|
||||
continue
|
||||
}
|
||||
if !requested[earlier] && !contains(s.state.Claimed, "achievement:"+achievementName(earlier)) {
|
||||
return nil, fmt.Errorf("missions: achievement tier %v requires earlier tier %v to be claimed", key, earlier)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate the whole batch before granting any reward.
|
||||
for _, claim := range claims {
|
||||
for _, id := range claim.IDs {
|
||||
key, d, ok := s.achievementDesign(contents, claim.GroupID, id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest)
|
||||
}
|
||||
if contains(s.state.Claimed, "achievement:"+achievementName(key)) {
|
||||
continue
|
||||
}
|
||||
if d.Target > 0 {
|
||||
if s.achievementProgress == nil {
|
||||
return nil, errors.New("missions: achievement progress unavailable")
|
||||
}
|
||||
group := d.CounterGroup
|
||||
if group == 0 {
|
||||
group = key.GroupID
|
||||
}
|
||||
value, err := s.achievementProgress.AchievementValue(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if float64(value) < d.Target {
|
||||
return nil, fmt.Errorf("missions: achievement %v requires %g progress, got %d", key, d.Target, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var allItems []player.Item
|
||||
var addExp uint64
|
||||
var currencyRewards []gamedata.Reward
|
||||
next := cloneSnapshot(s.state)
|
||||
for _, claim := range claims {
|
||||
for _, id := range claim.IDs {
|
||||
@@ -606,11 +671,19 @@ func (s *Service) clearAchievements(request []byte) ([]byte, error) {
|
||||
if contains(next.Claimed, identity) {
|
||||
continue
|
||||
}
|
||||
items, err := s.inventory.GrantOnce(identity, battleRewards(design.Rewards))
|
||||
items, err := s.grantRewards(identity, design.Rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ^uint64(0)-addExp < design.AddExp {
|
||||
return nil, errors.New("missions: achievement exp overflow")
|
||||
}
|
||||
addExp += design.AddExp
|
||||
for _, reward := range design.Rewards {
|
||||
if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 {
|
||||
currencyRewards = append(currencyRewards, reward)
|
||||
}
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
next.Claimed = append(next.Claimed, identity)
|
||||
}
|
||||
@@ -622,7 +695,13 @@ func (s *Service) clearAchievements(request []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
response := wire.AppendVarint(nil, 1, addExp)
|
||||
return wire.AppendBytes(response, 2, rewardBundle(allItems)), nil
|
||||
bundle := rewardBundle(allItems)
|
||||
for _, reward := range currencyRewards {
|
||||
item := wire.AppendVarint(nil, 3, reward.Type)
|
||||
item = wire.AppendVarint(item, 4, reward.Count)
|
||||
bundle = wire.AppendBytes(bundle, 1, item)
|
||||
}
|
||||
return wire.AppendBytes(response, 2, bundle), nil
|
||||
}
|
||||
|
||||
// The official all-clear request omits ContentsGroup (protobuf value zero).
|
||||
@@ -797,7 +876,10 @@ func (s *Service) grantRewards(identity string, rewards []gamedata.Reward) ([]pl
|
||||
}
|
||||
var stack []gamedata.BattleReward
|
||||
for _, reward := range rewards {
|
||||
if reward.Type == 3 || reward.Type == 4 {
|
||||
if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 {
|
||||
if s.wallet == nil {
|
||||
return nil, errors.New("missions: currency reward wallet unavailable")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reward.ID == 0 || reward.Count == 0 {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package player
|
||||
|
||||
// GachaGrantSummary is a read-only projection of real durable purchase grants.
|
||||
// It deliberately excludes grants without a schedule group; ordinary quest
|
||||
// rewards also use ViewCostumeIDs and must not be counted as gacha draws.
|
||||
func (s *CollectionStore) GachaGrantSummary() map[string]uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string]uint64{}
|
||||
for identity, grant := range s.data.Grants {
|
||||
if grant.GachaGroupID != 0 && len(grant.ViewCostumeIDs) > 0 {
|
||||
out[identity] = uint64(len(grant.ViewCostumeIDs))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -25,6 +25,12 @@ type Envelope struct {
|
||||
// Encode encrypts protobuf bytes using the active key and wraps them in the
|
||||
// JSON envelope expected by game API responses.
|
||||
func Encode(code int, proto, key []byte, now int64) ([]byte, error) {
|
||||
return EncodeWithNotify(code, proto, key, now, nil)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -32,6 +38,7 @@ func Encode(code int, proto, key []byte, now int64) ([]byte, error) {
|
||||
return json.Marshal(Envelope{
|
||||
PacketCode: code, Length: base64.StdEncoding.EncodedLen(len(proto)),
|
||||
Data: data, ServerNowTime: now,
|
||||
Notify: base64.StdEncoding.EncodeToString(notify),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ func TestEncode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyIsPlainBase64SeparateFromEncryptedResponse(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
notify := []byte{0x12, 6, 8, 42, 16, 7, 24, 1}
|
||||
raw, err := EncodeWithNotify(167, nil, key, 123, notify)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope Envelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(envelope.Notify)
|
||||
if err != nil || string(decoded) != string(notify) {
|
||||
t.Fatalf("notify=%x err=%v", decoded, err)
|
||||
}
|
||||
response, err := cryptox.DecryptBase64Payload(envelope.Data, key)
|
||||
if err != nil || len(response) != 0 {
|
||||
t.Fatalf("response=%x err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreservesOrderAndRejectsDuplicates(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
input := []BatchRequest{{Path: "/B", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 1})}, {Path: "/A", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 2})}}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/cryptox"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/protocol"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bd2server/internal/server/world"
|
||||
)
|
||||
|
||||
func TestAchievementUpdateEmitsAbsoluteNotificationWithoutChangingEmptyResponse(t *testing.T) {
|
||||
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{987: {0}}, Conditions: map[int]gamedata.AchievementCondition{987: {Type: 66}}}
|
||||
counter, err := world.NewAchievementService(design, stateio.NewMemory())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observer, err := world.NewGameplayAchievementObserver(counter, &world.OwnedGameplayAchievementProvider{Design: design})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, _ := NewServer(fakeLogin{}, counter)
|
||||
server.AttachResponseObserver(observer)
|
||||
logged := login(t, server)
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 987), 3, 1)
|
||||
body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest())
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
reply, err := server.DispatchRaw("/AchievementUpdate", []byte(body), "s="+logged.Cookie)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope protocol.Envelope
|
||||
if err := json.Unmarshal(reply.Body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := cryptox.DecryptBase64Payload(envelope.Data, server.KeyForTest())
|
||||
if err != nil || len(response) != 0 || envelope.PacketCode != 167 {
|
||||
t.Fatalf("response=%x code=%d err=%v", response, envelope.PacketCode, err)
|
||||
}
|
||||
if attempt == 1 {
|
||||
if envelope.Notify != "" {
|
||||
t.Fatal("replayed update emitted another notification")
|
||||
}
|
||||
continue
|
||||
}
|
||||
notify, err := base64.StdEncoding.DecodeString(envelope.Notify)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, _, _ := wire.Bytes(notify, 2)
|
||||
group, _, _ := wire.Varint(row, 1)
|
||||
value, _, _ := wire.Varint(row, 2)
|
||||
isSet, _, _ := wire.Varint(row, 3)
|
||||
if group != 987 || value != 1 || isSet != 1 {
|
||||
t.Fatalf("notify group=%d value=%d isSet=%d", group, value, isSet)
|
||||
}
|
||||
}
|
||||
value, err := counter.AchievementValue(987)
|
||||
if err != nil || value != 1 {
|
||||
t.Fatalf("persisted value=%d err=%v", value, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package session
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (s *Server) dispatchObserved(path string, request []byte) (int, []byte, []byte, error) {
|
||||
for _, observer := range s.observers {
|
||||
if err := observer.BeforeDispatch(path, request); err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("%s before response observation: %w", path, err)
|
||||
}
|
||||
}
|
||||
code, response, err := s.dispatch(path, request)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
var notify []byte
|
||||
for _, observer := range s.observers {
|
||||
fields, err := observer.AfterDispatch(path, request, response)
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("%s after response observation: %w", path, err)
|
||||
}
|
||||
notify = append(notify, fields...)
|
||||
}
|
||||
return code, response, notify, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/cryptox"
|
||||
"bd2server/internal/server/protocol"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type testProgressObserver struct {
|
||||
store *accountstate.Repository
|
||||
fail bool
|
||||
session string
|
||||
}
|
||||
|
||||
func (o *testProgressObserver) BeginSession(id string) { o.session = id }
|
||||
func (o *testProgressObserver) BeforeDispatch(string, []byte) error { return nil }
|
||||
func (o *testProgressObserver) AfterDispatch(path string, request, _ []byte) ([]byte, error) {
|
||||
if o.session == "" {
|
||||
return nil, errors.New("observer missing session")
|
||||
}
|
||||
seq, _, _ := wire.Varint(request, 1)
|
||||
if err := o.store.PutEntry("missions", "test_observation", path, []byte{byte(seq)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.fail {
|
||||
return nil, errors.New("observation failed after write")
|
||||
}
|
||||
return wire.AppendBytes(nil, 2, wire.AppendVarint(nil, 2, seq)), nil
|
||||
}
|
||||
|
||||
func TestObserverNotificationsAndFailuresShareRequestTransaction(t *testing.T) {
|
||||
statePath := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := accountstate.Open(statePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
server, _ := NewServer(fakeLogin{}, fakeDomain{}, &mutatingDomain{store: repository})
|
||||
server.AttachStateStore(repository)
|
||||
observer := &testProgressObserver{store: repository}
|
||||
server.AttachResponseObserver(observer)
|
||||
logged := login(t, server)
|
||||
requests := []protocol.BatchRequest{
|
||||
{Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))},
|
||||
{Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))},
|
||||
}
|
||||
plain, _ := json.Marshal(requests)
|
||||
body, _ := cryptox.EncryptBase64(plain, server.KeyForTest())
|
||||
reply, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+logged.Cookie)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var results []protocol.BatchResponse
|
||||
if err := json.Unmarshal(reply.Body, &results); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, result := range results {
|
||||
notify, err := base64.StdEncoding.DecodeString(result.ResponseData.Notify)
|
||||
row, _, _ := wire.Bytes(notify, 2)
|
||||
value, _, _ := wire.Varint(row, 2)
|
||||
if err != nil || value != uint64(i+2) {
|
||||
t.Fatalf("batch notify value=%d index=%d err=%v", value, i, err)
|
||||
}
|
||||
}
|
||||
observer.fail = true
|
||||
body, _ = cryptox.EncryptBase64Payload(wire.AppendVarint(nil, 1, 4), server.KeyForTest())
|
||||
if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+logged.Cookie); err == nil {
|
||||
t.Fatal("observer failure accepted")
|
||||
}
|
||||
verified, err := sql.Open("sqlite", statePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer verified.Close()
|
||||
var value []byte
|
||||
err = verified.QueryRow("SELECT payload FROM domain_entry WHERE domain_name='missions' AND bucket='test_observation' AND entry_key='/MutateTwoFiles'").Scan(&value)
|
||||
if err != nil || len(value) != 1 || value[0] != 3 {
|
||||
t.Fatalf("rolled back observer value=%v err=%v", value, err)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,14 @@ type SessionAware interface {
|
||||
BeginSession(id string)
|
||||
}
|
||||
|
||||
// ResponseObserver runs inside the same transaction as the authoritative
|
||||
// domain operation. It can derive progress and encode response notifications;
|
||||
// any observer error rolls the entire request or batch back.
|
||||
type ResponseObserver interface {
|
||||
BeforeDispatch(path string, request []byte) error
|
||||
AfterDispatch(path string, request, response []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
type gameSession struct {
|
||||
key []byte
|
||||
accountID string
|
||||
@@ -65,6 +73,7 @@ type Server struct {
|
||||
latestSessionSet bool
|
||||
login LoginService
|
||||
handlers []Handler
|
||||
observers []ResponseObserver
|
||||
progress *progress.Store
|
||||
stateTx stateio.TransactionalStore
|
||||
auth LoginAuthenticator
|
||||
@@ -73,6 +82,16 @@ type Server struct {
|
||||
maxSessions int
|
||||
}
|
||||
|
||||
func (s *Server) AttachResponseObserver(observer ResponseObserver) error {
|
||||
if observer == nil {
|
||||
return errors.New("session response observer is nil")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.observers = append(s.observers, observer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) AttachLoginAuthenticator(authenticator LoginAuthenticator) error {
|
||||
if authenticator == nil {
|
||||
return errors.New("session login authenticator is nil")
|
||||
@@ -177,11 +196,11 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport
|
||||
return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err)
|
||||
}
|
||||
return s.withStateTransaction(func() (transport.RawReply, error) {
|
||||
code, response, err := s.dispatch(path, request)
|
||||
code, response, notify, err := s.dispatchObserved(path, request)
|
||||
if err != nil {
|
||||
return transport.RawReply{}, err
|
||||
}
|
||||
encoded, err := protocol.Encode(code, response, game.key, s.now().UnixMilli())
|
||||
encoded, err := protocol.EncodeWithNotify(code, response, game.key, s.now().UnixMilli(), notify)
|
||||
return transport.RawReply{Body: encoded}, err
|
||||
})
|
||||
}
|
||||
@@ -252,6 +271,11 @@ func (s *Server) activate(game *gameSession) {
|
||||
aware.BeginSession(game.id)
|
||||
}
|
||||
}
|
||||
for _, observer := range s.observers {
|
||||
if aware, ok := observer.(SessionAware); ok {
|
||||
aware.BeginSession(game.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (reply transport.RawReply, err error) {
|
||||
@@ -301,7 +325,7 @@ func (s *Server) handleBatch(body, key []byte) (transport.RawReply, error) {
|
||||
items := make([]protocol.BatchResponse, 0, len(requests))
|
||||
for i, request := range requests {
|
||||
itemStarted := time.Now()
|
||||
code, response, err := s.dispatch(request.Path, decoded[i])
|
||||
code, response, notify, err := s.dispatchObserved(request.Path, decoded[i])
|
||||
if err != nil {
|
||||
return transport.RawReply{}, fmt.Errorf("batch %s: %w", request.Path, err)
|
||||
}
|
||||
@@ -311,7 +335,7 @@ 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.Encode(code, response, key, time.Now().UnixMilli())
|
||||
raw, err := protocol.EncodeWithNotify(code, response, key, time.Now().UnixMilli(), notify)
|
||||
if err != nil {
|
||||
return transport.RawReply{}, err
|
||||
}
|
||||
|
||||
@@ -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