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

Load ordinary achievements and send transactional gameplay progress notifications.
Restore main-story clears and recorded gacha history without duplicate credit.
Validate claim thresholds and prior tiers, credit wallet rewards and persist
achievement experience in login responses.
This commit is contained in:
2026-10-04 19:54:26 +08:00
parent 5b5a219dc2
commit aec053f7d2
23 changed files with 1464 additions and 27 deletions
+21
View File
@@ -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")
}
}
}
+27
View File
@@ -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 {
+16 -5
View File
@@ -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)
}
}
+23 -4
View File
@@ -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")
}
}
}
+93 -11
View File
@@ -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
}
+7
View File
@@ -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)
}
}
+24
View File
@@ -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)
}
}
+28 -4
View File
@@ -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)
}
}
+5
View File
@@ -132,6 +132,11 @@ func (s *AchievementService) Handle(path string, request []byte) (int, []byte, b
row := wire.AppendVarint(nil, 1, uint64(group))
row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)]))
var maxID uint64
// AchievementPacket.UpdateAchievementCount initializes title MaxClearId
// to 1000; this is the client protocol default, not a granted tier.
if content == 1 {
maxID = 1000
}
for key := range claimed {
if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID {
maxID = key.ID
@@ -63,6 +63,7 @@ func TestAchievementUpdatePersistsAndRetries(t *testing.T) {
row := wire.AppendVarint(nil, 1, 7)
row = wire.AppendVarint(row, 2, 2)
if content != 0 {
row = wire.AppendVarint(row, 3, 1000)
row = wire.AppendVarint(row, 4, content)
}
expected = wire.AppendBytes(expected, 1, row)
@@ -114,7 +115,7 @@ func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) {
type achievementClaimsFixture struct{}
func (achievementClaimsFixture) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool {
return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 3}: true}
return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 1003}: true}
}
func TestAchievementInfoIncludesRealClaimState(t *testing.T) {
s := achievementTestService(t, stateio.NewMemory())
@@ -136,7 +137,7 @@ func TestAchievementInfoIncludesRealClaimState(t *testing.T) {
t.Fatalf("missing claim group rows: %x", body)
}
clear, found, err := wire.Varint(rows[1], 3)
if err != nil || !found || clear != 3 {
if err != nil || !found || clear != 1003 {
t.Fatal("max_clear_id did not come from actual claims")
}
}