542 lines
17 KiB
Go
542 lines
17 KiB
Go
// Package progress owns mutable player progress that is independent of the
|
|
// HTTP, encryption, and captured-fixture compatibility layers.
|
|
package progress
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidPosition = errors.New("progress: invalid saved position")
|
|
ErrInvalidTutorial = errors.New("progress: invalid tutorial id")
|
|
ErrInvalidQuest = errors.New("progress: invalid quest update")
|
|
)
|
|
|
|
type Vector3 struct {
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Z float64 `json:"z"`
|
|
}
|
|
|
|
type Position struct {
|
|
MapID int `json:"MapId"`
|
|
PlayerPosition Vector3 `json:"PlayerPosition"`
|
|
ColleaguePositions json.RawMessage `json:"ColleaguePositions"`
|
|
}
|
|
|
|
type SavedPosition struct {
|
|
Difficulty int
|
|
PackID int
|
|
Position Position
|
|
RawJSON string
|
|
}
|
|
|
|
type QuestProgress struct {
|
|
QuestID int
|
|
Difficulty int
|
|
PackID int
|
|
Values []int
|
|
}
|
|
|
|
type QuestSelection struct {
|
|
QuestID int
|
|
Difficulty int
|
|
Option int
|
|
}
|
|
|
|
type Store struct {
|
|
activePackID int
|
|
startingPackID int
|
|
selections map[string]QuestSelection
|
|
mu sync.RWMutex
|
|
storage stateio.Store
|
|
position SavedPosition
|
|
tutorials map[int]struct{}
|
|
quests map[string]QuestProgress
|
|
cleared map[string]struct{}
|
|
}
|
|
|
|
func NewStore() *Store {
|
|
return &Store{selections: make(map[string]QuestSelection), tutorials: make(map[int]struct{}), quests: make(map[string]QuestProgress), cleared: make(map[string]struct{})}
|
|
}
|
|
|
|
type snapshot struct {
|
|
ActivePackID int `json:"active_pack_id"`
|
|
StartPackID int `json:"start_pack_id"`
|
|
Version int `json:"version"`
|
|
Selections map[string]QuestSelection `json:"selections"`
|
|
Position SavedPosition `json:"position"`
|
|
Tutorials []int `json:"tutorials"`
|
|
Quests map[string]QuestProgress `json:"quests"`
|
|
Cleared map[string]json.RawMessage `json:"cleared_quests"`
|
|
}
|
|
|
|
const snapshotVersion = 3
|
|
|
|
func questDifficulty(level []int) int {
|
|
if len(level) > 0 {
|
|
return level[0]
|
|
}
|
|
return 0
|
|
}
|
|
func questKey(packID, questID int, level ...int) string {
|
|
return strconv.Itoa(packID) + ":" + strconv.Itoa(questDifficulty(level)) + ":" + strconv.Itoa(questID)
|
|
}
|
|
func parseQuestKey(key string) (int, int, int, bool) {
|
|
parts := strings.Split(key, ":")
|
|
if len(parts) != 3 {
|
|
return 0, 0, 0, false
|
|
}
|
|
pack, e1 := strconv.Atoi(parts[0])
|
|
level, e2 := strconv.Atoi(parts[1])
|
|
quest, e3 := strconv.Atoi(parts[2])
|
|
return pack, level, quest, e1 == nil && e2 == nil && e3 == nil && pack > 0 && level >= 0 && level <= 4 && quest > 0
|
|
}
|
|
|
|
// OpenStore recovers player progress from its domain snapshot.
|
|
func OpenStore(storage stateio.Store) (*Store, error) {
|
|
s := NewStore()
|
|
if storage == nil {
|
|
return nil, errors.New("progress: nil storage")
|
|
}
|
|
s.storage = storage
|
|
data, err := storage.Load("progress")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("progress: load state: %w", err)
|
|
}
|
|
if data == nil {
|
|
return s, nil
|
|
}
|
|
if err := stateio.RequireExactJSONObject(data, "version", "position", "tutorials", "quests", "cleared_quests", "selections", "start_pack_id", "active_pack_id"); err != nil {
|
|
return nil, fmt.Errorf("progress: incompatible save layout: %w", err)
|
|
}
|
|
var state snapshot
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil, fmt.Errorf("progress: malformed save: %w", err)
|
|
}
|
|
if state.Version != snapshotVersion {
|
|
return nil, fmt.Errorf("progress: unsupported save version %d", state.Version)
|
|
}
|
|
s.activePackID = state.ActivePackID
|
|
if s.activePackID < 0 {
|
|
return nil, ErrInvalidQuest
|
|
}
|
|
s.startingPackID = state.StartPackID
|
|
if s.startingPackID < 0 {
|
|
return nil, ErrInvalidQuest
|
|
}
|
|
if state.Position.Difficulty < 0 || state.Position.Difficulty > 4 {
|
|
return nil, ErrInvalidPosition
|
|
}
|
|
s.position = state.Position
|
|
for key, selection := range state.Selections {
|
|
pack, err := strconv.Atoi(key)
|
|
if err != nil || pack <= 0 || selection.QuestID < 0 || selection.Difficulty < 0 || selection.Difficulty > 4 || selection.Option < 0 {
|
|
return nil, ErrInvalidQuest
|
|
}
|
|
s.selections[key] = selection
|
|
}
|
|
for _, id := range state.Tutorials {
|
|
if id <= 0 {
|
|
return nil, errors.New("progress: invalid saved tutorial")
|
|
}
|
|
s.tutorials[id] = struct{}{}
|
|
}
|
|
for key, quest := range state.Quests {
|
|
if quest.QuestID <= 0 || quest.PackID <= 0 {
|
|
return nil, errors.New("progress: invalid saved quest")
|
|
}
|
|
packID, difficulty, questID, ok := parseQuestKey(key)
|
|
if !ok || packID != quest.PackID || questID != quest.QuestID || difficulty != quest.Difficulty {
|
|
return nil, errors.New("progress: invalid saved quest key")
|
|
}
|
|
s.quests[questKey(quest.PackID, quest.QuestID, quest.Difficulty)] = quest
|
|
}
|
|
for key, raw := range state.Cleared {
|
|
packID, difficulty, questID, ok := parseQuestKey(key)
|
|
var cleared bool
|
|
if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared {
|
|
return nil, errors.New("progress: invalid cleared quest key")
|
|
}
|
|
s.cleared[questKey(packID, questID, difficulty)] = struct{}{}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) EnsurePersisted() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
data, err := s.storage.Load("progress")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if data != nil {
|
|
return nil
|
|
}
|
|
return s.commit(s.position, s.tutorials, s.quests, s.cleared)
|
|
}
|
|
|
|
// commit writes a complete snapshot before exposing the new state. Caller
|
|
// holds mu; failure leaves the in-memory player state unchanged.
|
|
func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error {
|
|
if s.storage != nil {
|
|
state := snapshot{ActivePackID: s.activePackID, StartPackID: s.startingPackID, Selections: s.selections, Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))}
|
|
for id := range tutorials {
|
|
state.Tutorials = append(state.Tutorials, id)
|
|
}
|
|
sort.Ints(state.Tutorials)
|
|
for key := range cleared {
|
|
state.Cleared[key] = json.RawMessage("true")
|
|
}
|
|
data, err := json.Marshal(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.storage.Save("progress", data); err != nil {
|
|
return fmt.Errorf("progress: save state: %w", err)
|
|
}
|
|
}
|
|
s.position, s.tutorials, s.quests, s.cleared = position, tutorials, quests, cleared
|
|
return nil
|
|
}
|
|
|
|
// UpdateQuest consumes QuestUpdateRequest: field 1 seq, field 2 quest_id,
|
|
// field 3 pack_id, and repeated packed/unpacked int32 field 4 quest_value.
|
|
// It returns the quest id that the response must echo as update_quest_id.
|
|
func (s *Store) UpdateQuest(request []byte) (int, error) {
|
|
questID, found, err := wire.Varint(request, 2)
|
|
if err != nil || !found || questID == 0 || questID > uint64(^uint32(0)>>1) {
|
|
return 0, fmt.Errorf("%w: quest id", ErrInvalidQuest)
|
|
}
|
|
packID, found, err := wire.Varint(request, 3)
|
|
if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) {
|
|
return 0, fmt.Errorf("%w: pack id", ErrInvalidQuest)
|
|
}
|
|
values := make([]int, 0, 4)
|
|
if err := wire.Walk(request, func(field wire.Field) error {
|
|
if field.Number != 4 {
|
|
return nil
|
|
}
|
|
switch field.Type {
|
|
case 0:
|
|
value, count := binary.Uvarint(field.Value)
|
|
if count <= 0 || value > uint64(^uint32(0)>>1) {
|
|
return ErrInvalidQuest
|
|
}
|
|
values = append(values, int(value))
|
|
case 2:
|
|
for remaining := field.Value; len(remaining) > 0; {
|
|
value, count := binary.Uvarint(remaining)
|
|
if count <= 0 || value > uint64(^uint32(0)>>1) {
|
|
return ErrInvalidQuest
|
|
}
|
|
values = append(values, int(value))
|
|
remaining = remaining[count:]
|
|
}
|
|
default:
|
|
return ErrInvalidQuest
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("%w: quest values", ErrInvalidQuest)
|
|
}
|
|
|
|
progress := QuestProgress{QuestID: int(questID), PackID: int(packID), Values: append([]int(nil), values...)}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
quests := make(map[string]QuestProgress, len(s.quests)+1)
|
|
for key, current := range s.quests {
|
|
quests[key] = current
|
|
}
|
|
selection := s.selections[strconv.Itoa(progress.PackID)]
|
|
progress.Difficulty = selection.Difficulty
|
|
if _, acceptedNormal := s.quests[questKey(progress.PackID, progress.QuestID)]; acceptedNormal && selection.QuestID != progress.QuestID {
|
|
progress.Difficulty = 0
|
|
}
|
|
quests[questKey(progress.PackID, progress.QuestID, progress.Difficulty)] = progress
|
|
if err := s.commit(s.position, s.tutorials, quests, s.cleared); err != nil {
|
|
return 0, err
|
|
}
|
|
return progress.QuestID, nil
|
|
}
|
|
|
|
// SaveUserPosition consumes SaveUserPositionRequest:
|
|
// field 1 seq, field 2 pack_id, field 3 pack_position JSON.
|
|
func (s *Store) SaveUserPosition(request []byte) error {
|
|
packID, found, err := wire.Varint(request, 2)
|
|
if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) {
|
|
return fmt.Errorf("%w: pack id", ErrInvalidPosition)
|
|
}
|
|
raw, found, err := wire.Bytes(request, 3)
|
|
if err != nil || !found || len(raw) == 0 || len(raw) > 64<<10 {
|
|
return fmt.Errorf("%w: position JSON", ErrInvalidPosition)
|
|
}
|
|
var position Position
|
|
if err := json.Unmarshal(raw, &position); err != nil || position.MapID <= 0 {
|
|
return fmt.Errorf("%w: decode JSON", ErrInvalidPosition)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.commit(SavedPosition{Difficulty: s.selections[strconv.Itoa(int(packID))].Difficulty, PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared)
|
|
}
|
|
|
|
// ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id.
|
|
// Repeated requests are idempotent.
|
|
func (s *Store) ClearTutorial(request []byte) error {
|
|
id, found, err := wire.Varint(request, 2)
|
|
if err != nil || !found || id == 0 || id > uint64(^uint32(0)>>1) {
|
|
return ErrInvalidTutorial
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
tutorials := make(map[int]struct{}, len(s.tutorials)+1)
|
|
for cleared := range s.tutorials {
|
|
tutorials[cleared] = struct{}{}
|
|
}
|
|
tutorials[int(id)] = struct{}{}
|
|
return s.commit(s.position, tutorials, s.quests, s.cleared)
|
|
}
|
|
|
|
func (s *Store) Position() (SavedPosition, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.position, s.position.PackID != 0
|
|
}
|
|
|
|
// LastPlayedPackID is the pack of the latest committed field position. The
|
|
// client suppresses SaveUserPosition in hidden packs, retaining the outside
|
|
// field position rather than choosing a temporary hidden scene on relogin.
|
|
// Zero means no position has been committed, so LoginUser can keep its seed.
|
|
func (s *Store) LastPlayedPackID() (uint64, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.position.PackID == 0 {
|
|
return 0, nil
|
|
}
|
|
if s.position.PackID < 0 || s.position.PackID > int(^uint32(0)>>1) || s.position.Position.MapID <= 0 || s.position.RawJSON == "" {
|
|
return 0, ErrInvalidPosition
|
|
}
|
|
return uint64(s.position.PackID), nil
|
|
}
|
|
|
|
func (s *Store) TutorialCleared(id int) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
_, found := s.tutorials[id]
|
|
return found
|
|
}
|
|
|
|
func (s *Store) Tutorials() []int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
ids := make([]int, 0, len(s.tutorials))
|
|
for id := range s.tutorials {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Ints(ids)
|
|
return ids
|
|
}
|
|
|
|
// Handle exposes the persisted tutorial set through TutorialInfoResponse.
|
|
// TutorialClear itself remains in session so both routes share this store.
|
|
func (s *Store) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
if path != "/TutorialInfo" {
|
|
return 0, nil, false, nil
|
|
}
|
|
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
|
|
return 0, nil, true, ErrInvalidTutorial
|
|
}
|
|
var packed []byte
|
|
for _, id := range s.Tutorials() {
|
|
packed = binary.AppendUvarint(packed, uint64(id))
|
|
}
|
|
var response []byte
|
|
if len(packed) > 0 {
|
|
response = wire.AppendBytes(response, 1, packed)
|
|
}
|
|
return 101, response, true, nil
|
|
}
|
|
|
|
func (s *Store) Quest(id int) (QuestProgress, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var quest QuestProgress
|
|
found := false
|
|
for _, candidate := range s.quests {
|
|
if candidate.QuestID != id {
|
|
continue
|
|
}
|
|
if found {
|
|
// A quest ID alone becomes ambiguous once more than one pack has
|
|
// progress. Callers that know the pack must use QuestInPack.
|
|
return QuestProgress{}, false
|
|
}
|
|
quest, found = candidate, true
|
|
}
|
|
quest.Values = append([]int(nil), quest.Values...)
|
|
return quest, found
|
|
}
|
|
|
|
func (s *Store) QuestInPack(questID, packID int, level ...int) (QuestProgress, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
quest, found := s.quests[questKey(packID, questID, level...)]
|
|
quest.Values = append([]int(nil), quest.Values...)
|
|
return quest, found
|
|
}
|
|
|
|
// ClearQuest atomically records a completed quest. Quest identity is the
|
|
// (pack, quest) pair because every story pack starts numbering from one.
|
|
func (s *Store) ClearQuest(questID, packID int, level ...int) error {
|
|
if questID <= 0 || packID <= 0 || questDifficulty(level) < 0 || questDifficulty(level) > 4 {
|
|
return ErrInvalidQuest
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
key := questKey(packID, questID, level...)
|
|
if _, ok := s.cleared[key]; ok {
|
|
return nil
|
|
}
|
|
cleared := make(map[string]struct{}, len(s.cleared)+1)
|
|
for current := range s.cleared {
|
|
cleared[current] = struct{}{}
|
|
}
|
|
cleared[key] = struct{}{}
|
|
return s.commit(s.position, s.tutorials, s.quests, cleared)
|
|
}
|
|
|
|
func (s *Store) QuestCleared(questID, packID int, level ...int) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
_, found := s.cleared[questKey(packID, questID, level...)]
|
|
return found
|
|
}
|
|
|
|
// ClearedQuests returns sorted quest IDs for one pack. The detached slice is
|
|
// safe for response construction without holding the store lock.
|
|
func (s *Store) ClearedQuests(packID int, level ...int) []int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
ids := make([]int, 0, len(s.cleared))
|
|
for key := range s.cleared {
|
|
currentPack, difficulty, questID, ok := parseQuestKey(key)
|
|
if ok && currentPack == packID && difficulty == questDifficulty(level) {
|
|
ids = append(ids, questID)
|
|
}
|
|
}
|
|
sort.Ints(ids)
|
|
return ids
|
|
}
|
|
|
|
// Selection returns the committed quest selection for a pack.
|
|
func (s *Store) Selection(packID int) (QuestSelection, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
value, ok := s.selections[strconv.Itoa(packID)]
|
|
return value, ok
|
|
}
|
|
func (s *Store) SelectQuest(packID int, selection QuestSelection) error {
|
|
if packID <= 0 || selection.QuestID < 0 || selection.Difficulty < 0 || selection.Difficulty > 4 || selection.Option < 0 {
|
|
return ErrInvalidQuest
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
old := s.selections
|
|
next := make(map[string]QuestSelection, len(old)+1)
|
|
for key, value := range old {
|
|
next[key] = value
|
|
}
|
|
next[strconv.Itoa(packID)] = selection
|
|
s.selections = next
|
|
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
|
|
s.selections = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) StartPackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.startingPackID }
|
|
func (s *Store) SetStartPack(packID int) error {
|
|
if packID <= 0 {
|
|
return ErrInvalidQuest
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
previous := s.startingPackID
|
|
s.startingPackID = packID
|
|
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
|
|
s.startingPackID = previous
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AcceptQuest commits an explicit active quest without replacing main selection.
|
|
func (s *Store) AcceptQuest(questID, packID, difficulty int) error {
|
|
if questID <= 0 || packID <= 0 || difficulty < 0 || difficulty > 4 {
|
|
return ErrInvalidQuest
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
quests := make(map[string]QuestProgress, len(s.quests)+1)
|
|
for key, value := range s.quests {
|
|
quests[key] = value
|
|
}
|
|
key := questKey(packID, questID, difficulty)
|
|
if _, exists := quests[key]; !exists {
|
|
quests[key] = QuestProgress{QuestID: questID, PackID: packID, Difficulty: difficulty}
|
|
}
|
|
return s.commit(s.position, s.tutorials, quests, s.cleared)
|
|
}
|
|
func (s *Store) RemoveQuest(questID, packID, difficulty int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
quests := make(map[string]QuestProgress, len(s.quests))
|
|
for key, value := range s.quests {
|
|
if key != questKey(packID, questID, difficulty) {
|
|
quests[key] = value
|
|
}
|
|
}
|
|
return s.commit(s.position, s.tutorials, quests, s.cleared)
|
|
}
|
|
func (s *Store) QuestsInPack(packID, difficulty int) []QuestProgress {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var out []QuestProgress
|
|
for _, quest := range s.quests {
|
|
if quest.PackID == packID && quest.Difficulty == difficulty {
|
|
quest.Values = append([]int(nil), quest.Values...)
|
|
out = append(out, quest)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].QuestID < out[j].QuestID })
|
|
return out
|
|
}
|
|
|
|
func (s *Store) ActivePackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.activePackID }
|
|
func (s *Store) SetActivePackID(packID int) error {
|
|
if packID <= 0 {
|
|
return ErrInvalidQuest
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
old := s.activePackID
|
|
s.activePackID = packID
|
|
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
|
|
s.activePackID = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|