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.
405 lines
12 KiB
Go
405 lines
12 KiB
Go
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
|
|
}
|