Files
bd2/go/internal/server/missions/missions.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
2026-10-05 11:50:31 +08:00

1198 lines
34 KiB
Go

// Package missions owns local completion eligibility and claim state for the
// three regular (non-scheduled-event) mission endpoints. Static definitions
// come from GameData; this package never replays an HTTP capture.
package missions
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
const (
missionClearPacket = 120
missionSectionPacket = 122
achievementClearPacket = 168
)
var ErrInvalidRequest = errors.New("missions: invalid request")
// Service is deliberately given completion signals by authoritative gameplay
// 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 {
eventHandler interface {
Handle(string, []byte) (int, []byte, bool, error)
}
levelRewards *gamedata.AchievementLevelDesign
levelReward uint64
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 {
if wallet == nil {
return errors.New("missions: nil wallet")
}
s.wallet = wallet
return nil
}
type snapshot struct {
Version string `json:"version"`
DailyPeriod string `json:"daily_period"`
WeeklyPeriod string `json:"weekly_period"`
Completed []string `json:"completed"`
Claimed []string `json:"claimed"`
Progress map[string]uint64 `json:"progress"`
}
func Open(storage stateio.Store, design *gamedata.MissionDesign, inventory *player.Inventory) (*Service, error) {
if storage == nil || design == nil || inventory == nil {
return nil, errors.New("missions: invalid service configuration")
}
now := time.Now().UTC()
s := &Service{storage: storage, design: design, inventory: inventory, now: time.Now, state: snapshot{
Version: versionconfig.State(), DailyPeriod: dailyPeriod(now), WeeklyPeriod: weeklyPeriod(now), Progress: map[string]uint64{},
}}
b, err := storage.Load("missions")
if err != nil {
return nil, fmt.Errorf("missions: load state: %w", err)
}
if b == nil {
return s, nil
}
if err := stateio.RequireExactJSONObject(b, "version", "daily_period", "weekly_period", "completed", "claimed", "progress"); err != nil {
return nil, fmt.Errorf("missions: incompatible state layout: %w", err)
}
if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.DailyPeriod == "" || s.state.WeeklyPeriod == "" {
return nil, errors.New("missions: malformed state")
}
if s.state.Progress == nil {
return nil, errors.New("missions: progress must be an object")
}
return s, nil
}
func (s *Service) AttachMail(mailbox *mail.Service) error {
if mailbox == nil {
return errors.New("missions: nil compensation mailbox")
}
s.mu.Lock()
defer s.mu.Unlock()
s.mail = mailbox
return nil
}
func (s *Service) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := s.storage.Load("missions")
if err != nil {
return err
}
if b != nil {
return nil
}
return s.persist(cloneSnapshot(s.state))
}
// CompleteMission is the only way normal mission eligibility enters this
// package. It is persistent and idempotent. Gameplay/event handlers should
// call it only after they have independently verified the table condition.
func (s *Service) CompleteMission(key gamedata.MissionKey) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return err
}
if _, ok := s.design.Missions[key]; !ok {
return fmt.Errorf("missions: unknown mission %+v", key)
}
condition := s.design.Conditions[key]
value := condition.TargetValue
if value == 0 {
value = 1
}
return s.setProgressLocked(key, value)
}
func (s *Service) SetProgress(key gamedata.MissionKey, value uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return err
}
return s.setProgressLocked(key, value)
}
func (s *Service) setProgressLocked(key gamedata.MissionKey, value uint64) error {
if _, ok := s.design.Missions[key]; !ok {
return fmt.Errorf("missions: unknown mission %+v", key)
}
name := missionName(key)
if s.state.Progress[name] >= value {
return nil
}
next := cloneSnapshot(s.state)
next.Progress[name] = value
condition := s.design.Conditions[key]
target := condition.TargetValue
if target == 0 {
target = 1
}
if s.state.Progress[name] < target && value >= target {
s.applyCompletionDependencies(&next, key)
}
return s.commit(next)
}
func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata.MissionKey) {
queue := []gamedata.MissionKey{completed}
seen := map[string]bool{}
for len(queue) != 0 {
current := queue[0]
queue = queue[1:]
currentName := missionName(current)
if seen[currentName] {
continue
}
seen[currentName] = true
for key, condition := range s.design.Conditions {
if key.GroupType == 2 {
continue
}
matched := condition.Type == 30 && key.GroupType == current.GroupType
matched = matched || (condition.Type == 31 && condition.SubType == current.GroupType)
if !matched {
continue
}
name := missionName(key)
target := condition.TargetValue
if target == 0 {
target = 1
}
before := next.Progress[name]
if before >= target {
continue
}
next.Progress[name] = before + 1
if next.Progress[name] > target {
next.Progress[name] = target
}
if before < target && next.Progress[name] >= target {
queue = append(queue, key)
}
}
}
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if s.eventHandler != nil && path == "/MissionUpdate" {
regular, event, err := splitMissionUpdates(request)
if err != nil {
return 119, nil, true, err
}
if len(event) > 0 {
if len(regular) > 0 {
s.mu.Lock()
err = s.validateRegularUpdates(regular)
s.mu.Unlock()
if err != nil {
return 119, nil, true, err
}
}
if _, _, handled, err := s.eventHandler.Handle(path, event); err != nil || !handled {
if err == nil {
err = ErrInvalidRequest
}
return 119, nil, true, err
}
if len(regular) == 0 {
return 119, nil, true, nil
}
request = regular
}
}
if s.eventHandler != nil && path == "/MissionClear" {
if code, response, handled, err := s.eventHandler.Handle(path, request); handled {
return code, response, handled, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return 0, nil, true, err
}
switch path {
case "/UserLevelReward":
response, err := s.userLevelReward(request)
return 73, response, true, err
case "/MissionInfo":
if err := requireSeq(request); err != nil {
return 118, nil, true, err
}
return 118, s.missionInfo(), true, nil
case "/MissionUpdate":
if err := s.update(request); err != nil {
return 119, nil, true, err
}
return 119, nil, true, nil
case "/AchievementInfo":
if err := requireSeq(request); err != nil {
return 166, nil, true, err
}
return 166, s.achievementInfo(), true, nil
case "/MissionClear":
response, err := s.clearMission(request)
return missionClearPacket, response, true, err
case "/MissionSectionReward":
response, err := s.clearSection(request)
return missionSectionPacket, response, true, err
case "/AchievementClear":
response, err := s.clearAchievements(request)
return achievementClearPacket, response, true, err
default:
return 0, nil, false, nil
}
}
// Each update carries its own event identity; a client can acknowledge regular
// and scheduled missions together in one request.
func splitMissionUpdates(request []byte) (regular, event []byte, err error) {
if err = requireSeq(request); err != nil {
return
}
seq, _, _ := wire.Varint(request, 1)
err = wire.Walk(request, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return ErrInvalidRequest
}
uid, e := scalar(f.Value, 4)
if e != nil {
return e
}
if uid == 0 {
if len(regular) == 0 {
regular = wire.AppendVarint(nil, 1, seq)
}
regular = wire.AppendBytes(regular, 2, f.Value)
} else {
if len(event) == 0 {
event = wire.AppendVarint(nil, 1, seq)
}
event = wire.AppendBytes(event, 2, f.Value)
}
return nil
})
return
}
func (s *Service) validateRegularUpdates(request []byte) error {
return wire.Walk(request, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return ErrInvalidRequest
}
group, err := scalar(f.Value, 1)
if err != nil || group == 0 {
return ErrInvalidRequest
}
id, err := scalar(f.Value, 2)
if err != nil || id == 0 {
return ErrInvalidRequest
}
if _, err = scalar(f.Value, 3); err != nil {
return ErrInvalidRequest
}
matches := 0
for key := range s.design.Missions {
if key.GroupType != 2 && key.GroupID == group && key.ID == id {
matches++
}
}
if matches != 1 {
return ErrInvalidRequest
}
return nil
})
}
// AttachEventHandler delegates scheduled event operations before regular
// mission processing. The event service validates calendar and player state.
func (s *Service) AttachEventHandler(handler interface {
Handle(string, []byte) (int, []byte, bool, error)
}) error {
if handler == nil {
return errors.New("missions: nil event handler")
}
s.eventHandler = handler
return nil
}
func dailyPeriod(now time.Time) string { return now.UTC().Format("2006-01-02") }
func weeklyPeriod(now time.Time) string {
now = now.UTC()
daysSinceMonday := (int(now.Weekday()) + 6) % 7
return now.AddDate(0, 0, -daysSinceMonday).Format("2006-01-02")
}
func (s *Service) rolloverLocked() error {
if s.now == nil {
return errors.New("missions: missing clock")
}
now := s.now().UTC()
daily, weekly := dailyPeriod(now), weeklyPeriod(now)
if daily == s.state.DailyPeriod && weekly == s.state.WeeklyPeriod {
return nil
}
if s.mail == nil {
return errors.New("missions: compensation mailbox is not attached")
}
next := cloneSnapshot(s.state)
if daily != next.DailyPeriod {
if err := s.compensatePeriodLocked(0, next.DailyPeriod, now); err != nil {
return err
}
clearMissionPeriod(&next, 0)
next.DailyPeriod = daily
}
if weekly != next.WeeklyPeriod {
if err := s.compensatePeriodLocked(1, next.WeeklyPeriod, now); err != nil {
return err
}
clearMissionPeriod(&next, 1)
next.WeeklyPeriod = weekly
}
return s.commit(next)
}
func (s *Service) compensatePeriodLocked(groupType uint64, period string, now time.Time) error {
label := "日常"
if groupType == 1 {
label = "周常"
}
for _, key := range s.completedUnclaimedForType(groupType) {
rewards := s.design.Missions[key]
if len(rewards) == 0 {
continue
}
identity := fmt.Sprintf("expired-mission:%d:%s:%s", groupType, period, missionName(key))
title := label + "任务到期补发"
body := fmt.Sprintf("%s周期 %s 已结束。任务 %d/%d 已完成但未领取,奖励由系统自动补发。", label, period, key.GroupID, key.ID)
if err := s.mail.EnqueueCompensation(identity, title, body, rewards, now); err != nil {
return fmt.Errorf("missions: enqueue expired mission %+v: %w", key, err)
}
}
for _, key := range s.eligibleUnclaimedSectionsForType(groupType) {
rewards := s.design.Sections[key].Rewards
if len(rewards) == 0 {
continue
}
identity := fmt.Sprintf("expired-section:%d:%s:%d", groupType, period, key.ID)
title := label + "阶段奖励到期补发"
body := fmt.Sprintf("%s周期 %s 已结束。阶段奖励 %d 已达成但未领取,奖励由系统自动补发。", label, period, key.ID)
if err := s.mail.EnqueueCompensation(identity, title, body, rewards, now); err != nil {
return fmt.Errorf("missions: enqueue expired section %+v: %w", key, err)
}
}
return nil
}
func (s *Service) completedUnclaimedForType(groupType uint64) []gamedata.MissionKey {
var result []gamedata.MissionKey
for key, condition := range s.design.Conditions {
if key.GroupType != groupType {
continue
}
target := condition.TargetValue
if target == 0 {
target = 1
}
if s.state.Progress[missionName(key)] >= target && !contains(s.state.Claimed, "mission:"+missionName(key)) {
result = append(result, key)
}
}
sort.Slice(result, func(i, j int) bool {
if result[i].GroupID != result[j].GroupID {
return result[i].GroupID < result[j].GroupID
}
return result[i].ID < result[j].ID
})
return result
}
func (s *Service) eligibleUnclaimedSectionsForType(groupType uint64) []gamedata.SectionRewardKey {
completed := uint64(len(s.completedForType(groupType)))
var result []gamedata.SectionRewardKey
for key, section := range s.design.Sections {
identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID)
if key.GroupType == groupType && section.SectionValue != 0 && completed >= section.SectionValue && !contains(s.state.Claimed, identity) {
result = append(result, key)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (s *Service) completedForType(groupType uint64) []gamedata.MissionKey {
var result []gamedata.MissionKey
for key, condition := range s.design.Conditions {
if key.GroupType != groupType {
continue
}
target := condition.TargetValue
if target == 0 {
target = 1
}
if s.state.Progress[missionName(key)] >= target {
result = append(result, key)
}
}
return result
}
func clearMissionPeriod(state *snapshot, groupType uint64) {
prefix := strconv.FormatUint(groupType, 10) + "/"
for name := range state.Progress {
if strings.HasPrefix(name, prefix) {
delete(state.Progress, name)
}
}
state.Completed = filterPeriodIdentities(state.Completed, groupType)
state.Claimed = filterPeriodIdentities(state.Claimed, groupType)
}
func filterPeriodIdentities(values []string, groupType uint64) []string {
missionPrefix := "mission:" + strconv.FormatUint(groupType, 10) + "/"
sectionPrefix := "section:" + strconv.FormatUint(groupType, 10) + "/"
result := values[:0]
for _, value := range values {
if !strings.HasPrefix(value, missionPrefix) && !strings.HasPrefix(value, sectionPrefix) {
result = append(result, value)
}
}
return result
}
func (s *Service) missionInfo() []byte {
var response []byte
for _, key := range s.progressMissionKeys() {
entry := wire.AppendVarint(nil, 1, key.GroupID)
entry = wire.AppendVarint(entry, 2, key.ID)
if key.GroupType != 0 {
entry = wire.AppendVarint(entry, 3, key.GroupType)
}
entry = wire.AppendVarint(entry, 4, s.state.Progress[missionName(key)])
if contains(s.state.Claimed, "mission:"+missionName(key)) {
entry = wire.AppendVarint(entry, 5, 1)
}
response = wire.AppendBytes(response, 1, entry)
}
for _, identity := range s.state.Claimed {
var groupType, id uint64
if _, err := fmt.Sscanf(identity, "section:%d/%d", &groupType, &id); err != nil {
continue
}
entry := wire.AppendVarint(nil, 1, groupType)
entry = wire.AppendVarint(entry, 2, id)
response = wire.AppendBytes(response, 2, entry)
}
now := s.now().UTC()
daily := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
daysUntilMonday := (8 - int(now.Weekday())) % 7
if daysUntilMonday == 0 {
daysUntilMonday = 7
}
weekly := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, time.UTC)
response = wire.AppendVarint(response, 3, uint64(daily.UnixMilli()))
response = wire.AppendVarint(response, 4, uint64(weekly.UnixMilli()))
return response
}
func (s *Service) update(request []byte) error {
if err := requireSeq(request); err != nil {
return err
}
var updates int
err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
if field.Type != 2 {
return ErrInvalidRequest
}
groupID, err := scalar(field.Value, 1)
if err != nil || groupID == 0 {
return ErrInvalidRequest
}
id, err := scalar(field.Value, 2)
if err != nil || id == 0 {
return ErrInvalidRequest
}
value, err := scalar(field.Value, 3)
if err != nil {
return ErrInvalidRequest
}
var key gamedata.MissionKey
found := false
for candidate := range s.design.Missions {
if candidate.GroupID == groupID && candidate.ID == id && candidate.GroupType != 2 {
if found {
return ErrInvalidRequest
}
key, found = candidate, true
}
}
if !found {
return ErrInvalidRequest
}
if err := s.setProgressLocked(key, value); err != nil {
return err
}
updates++
return nil
})
if err != nil {
return err
}
if updates == 0 {
return ErrInvalidRequest
}
return nil
}
func (s *Service) achievementInfo() []byte {
// AchievementInfo contains progress and last completed IDs. This local
// account currently records acknowledgement/claim state through the
// inventory idempotency key, but deliberately does not fabricate progress.
return nil
}
func (s *Service) clearMission(request []byte) ([]byte, error) {
if err := requireSeq(request); err != nil {
return nil, err
}
all, err := boolean(request, 2)
if err != nil {
return nil, err
}
groupType, err := scalar(request, 3)
if err != nil {
return nil, err
}
groupID, err := scalar(request, 4)
if err != nil {
return nil, err
}
id, err := scalar(request, 5)
if err != nil {
return nil, err
}
if groupType == 2 {
return nil, errors.New("missions: scheduled event missions require an event service")
}
var keys []gamedata.MissionKey
if all {
if groupType != 0 || groupID != 0 || id != 0 {
return nil, fmt.Errorf("%w: bulk MissionClear has identifiers", ErrInvalidRequest)
}
keys = s.completedMissionKeys()
} else {
if groupID == 0 || id == 0 {
return nil, fmt.Errorf("%w: MissionClear identity", ErrInvalidRequest)
}
keys = []gamedata.MissionKey{{GroupType: groupType, GroupID: groupID, ID: id}}
}
items, err := s.claimMissions(keys)
if err != nil {
return nil, err
}
bundle := rewardBundle(items)
for _, key := range keys {
for _, reward := range s.design.Missions[key] {
if reward.Type == 3 || reward.Type == 4 {
entry := wire.AppendVarint(nil, 3, reward.Type)
entry = wire.AppendVarint(entry, 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, entry)
}
}
}
return bundle, nil
}
func (s *Service) clearSection(request []byte) ([]byte, error) {
if err := requireSeq(request); err != nil {
return nil, err
}
groupType, err := scalar(request, 2)
if err != nil {
return nil, fmt.Errorf("%w: section group type", ErrInvalidRequest)
}
all, err := boolean(request, 3)
if err != nil {
return nil, err
}
id, err := scalar(request, 4)
if err != nil {
return nil, err
}
var keys []gamedata.SectionRewardKey
if all {
if id != 0 {
return nil, fmt.Errorf("%w: bulk section id", ErrInvalidRequest)
}
keys = s.eligibleSections(groupType)
} else {
if id == 0 {
return nil, fmt.Errorf("%w: section id", ErrInvalidRequest)
}
key := gamedata.SectionRewardKey{GroupType: groupType, ID: id}
if !s.sectionEligible(key) {
return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest)
}
keys = []gamedata.SectionRewardKey{key}
}
items, err := s.claimSections(keys)
if err != nil {
return nil, err
}
var response []byte
for _, item := range items {
response = wire.AppendBytes(response, 1, player.ItemWire(item))
}
for _, key := range keys {
for _, reward := range s.design.Sections[key].Rewards {
if reward.Type == 3 || reward.Type == 4 {
entry := wire.AppendVarint(nil, 3, reward.Type)
entry = wire.AppendVarint(entry, 4, reward.Count)
response = wire.AppendBytes(response, 1, entry)
}
}
}
return response, nil
}
func (s *Service) clearAchievements(request []byte) ([]byte, error) {
if err := requireSeq(request); err != nil {
return nil, err
}
contents, err := scalar(request, 2)
if err != nil {
return nil, fmt.Errorf("%w: achievement contents group", ErrInvalidRequest)
}
claims, err := achievementClaims(request)
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 {
key, design, ok := s.achievementDesign(contents, claim.GroupID, id)
if !ok {
return nil, fmt.Errorf("%w: unknown achievement %+v", ErrInvalidRequest, key)
}
identity := "achievement:" + achievementName(key)
if contains(next.Claimed, identity) {
continue
}
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)
}
}
if addExp > uint64(^uint(0)>>1) {
return nil, errors.New("missions: achievement exp overflow")
}
if err := s.commit(next); err != nil {
return nil, err
}
response := wire.AppendVarint(nil, 1, addExp)
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).
// In that form GroupID+ID must identify exactly one static row; ambiguity is
// rejected rather than resolved by map iteration order.
func (s *Service) achievementDesign(contents, groupID, id uint64) (gamedata.AchievementKey, gamedata.AchievementDesign, bool) {
if contents != 0 {
key := gamedata.AchievementKey{ContentsGroup: contents, GroupID: groupID, ID: id}
design, ok := s.design.Achievements[key]
return key, design, ok
}
var foundKey gamedata.AchievementKey
var found gamedata.AchievementDesign
matched := false
for key, design := range s.design.Achievements {
if key.GroupID != groupID || key.ID != id {
continue
}
if matched {
return gamedata.AchievementKey{}, gamedata.AchievementDesign{}, false
}
foundKey, found, matched = key, design, true
}
return foundKey, found, matched
}
func (s *Service) progressMissionKeys() []gamedata.MissionKey {
result := make([]gamedata.MissionKey, 0, len(s.state.Progress))
for name, value := range s.state.Progress {
if value == 0 {
continue
}
for key := range s.design.Missions {
if missionName(key) == name {
result = append(result, key)
break
}
}
}
sort.Slice(result, func(i, j int) bool {
a, b := result[i], result[j]
if a.GroupType != b.GroupType {
return a.GroupType < b.GroupType
}
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
return a.ID < b.ID
})
return result
}
func (s *Service) completedMissionKeys() []gamedata.MissionKey {
var result []gamedata.MissionKey
for key, condition := range s.design.Conditions {
target := condition.TargetValue
if target == 0 {
target = 1
}
name := missionName(key)
if s.state.Progress[name] >= target && !contains(s.state.Claimed, "mission:"+name) {
result = append(result, key)
}
}
sort.Slice(result, func(i, j int) bool {
a, b := result[i], result[j]
if a.GroupType != b.GroupType {
return a.GroupType < b.GroupType
}
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
return a.ID < b.ID
})
return result
}
func (s *Service) claimMissions(keys []gamedata.MissionKey) ([]player.Item, error) {
next := cloneSnapshot(s.state)
var result []player.Item
for _, key := range keys {
condition := s.design.Conditions[key]
target := condition.TargetValue
if target == 0 {
target = 1
}
if s.state.Progress[missionName(key)] < target {
return nil, fmt.Errorf("%w: mission is not complete", ErrInvalidRequest)
}
rewards, ok := s.design.Missions[key]
if !ok {
return nil, fmt.Errorf("%w: unknown mission %+v", ErrInvalidRequest, key)
}
identity := "mission:" + missionName(key)
items, err := s.grantRewards(identity, rewards)
if err != nil {
return nil, err
}
result = append(result, items...)
if !contains(next.Claimed, identity) {
next.Claimed = append(next.Claimed, identity)
}
}
if err := s.commit(next); err != nil {
return nil, err
}
return result, nil
}
func (s *Service) eligibleSections(groupType uint64) []gamedata.SectionRewardKey {
var result []gamedata.SectionRewardKey
for key := range s.design.Sections {
// Proto3 omits group_type for the official bulk request. Zero means
// all mission groups there, not an invalid group.
if (groupType == 0 || key.GroupType == groupType) && s.sectionEligible(key) {
result = append(result, key)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
// Section reward thresholds are authoritative static values. The completed
// set, rather than the claimed set, is deliberately counted as the client does.
func (s *Service) sectionEligible(key gamedata.SectionRewardKey) bool {
section, ok := s.design.Sections[key]
if !ok {
return false
}
if section.SectionValue == 0 {
return false
}
var count uint64
for _, identity := range s.state.Claimed {
var group, missionGroup, missionID uint64
if _, err := fmt.Sscanf(identity, "mission:%d/%d/%d", &group, &missionGroup, &missionID); err == nil && group == key.GroupType {
count++
}
}
return count >= section.SectionValue
}
func (s *Service) claimSections(keys []gamedata.SectionRewardKey) ([]player.Item, error) {
next := cloneSnapshot(s.state)
var result []player.Item
for _, key := range keys {
section, ok := s.design.Sections[key]
if !ok || !s.sectionEligible(key) {
return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest)
}
identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID)
items, err := s.grantRewards(identity, section.Rewards)
if err != nil {
return nil, err
}
result = append(result, items...)
if !contains(next.Claimed, identity) {
next.Claimed = append(next.Claimed, identity)
}
}
if err := s.commit(next); err != nil {
return nil, err
}
return result, nil
}
func (s *Service) grantRewards(identity string, rewards []gamedata.Reward) ([]player.Item, error) {
if s.wallet != nil {
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
return nil, err
}
}
var stack []gamedata.BattleReward
for _, reward := range rewards {
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 {
// Type 12 is an account resource whose full/overflow conversion is
// server-dynamic. It is intentionally not fabricated as ItemDBInfo.
continue
}
stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
}
if len(stack) == 0 {
return nil, nil
}
items, err := s.inventory.GrantOnce(identity+":items", stack)
if err != nil {
return nil, err
}
if len(items) == 0 {
items = s.inventory.GrantedItems(identity + ":items")
}
return items, nil
}
func rewardBundle(items []player.Item) []byte {
var bundle []byte
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
view := wire.AppendVarint(nil, 2, item.ID)
view = wire.AppendVarint(view, 3, item.Type)
view = wire.AppendVarint(view, 4, item.Count)
bundle = wire.AppendBytes(bundle, 6, view)
}
return bundle
}
func battleRewards(rewards []gamedata.Reward) []gamedata.BattleReward {
result := make([]gamedata.BattleReward, len(rewards))
for i, reward := range rewards {
result[i] = gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}
}
return result
}
func (s *Service) commit(next snapshot) error {
sort.Strings(next.Completed)
sort.Strings(next.Claimed)
if next.DailyPeriod == s.state.DailyPeriod && next.WeeklyPeriod == s.state.WeeklyPeriod && equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) {
return nil
}
return s.persist(next)
}
func (s *Service) persist(next snapshot) error {
b, err := json.Marshal(next)
if err != nil {
return err
}
if err := s.storage.Save("missions", b); err != nil {
return err
}
s.state = next
return nil
}
func equalProgress(a, b map[string]uint64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
if b[key] != value {
return false
}
}
return true
}
func cloneSnapshot(in snapshot) snapshot {
progress := make(map[string]uint64, len(in.Progress))
for key, value := range in.Progress {
progress[key] = value
}
return snapshot{Version: in.Version, DailyPeriod: in.DailyPeriod, WeeklyPeriod: in.WeeklyPeriod, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: progress}
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func missionName(k gamedata.MissionKey) string {
return fmt.Sprintf("%d/%d/%d", k.GroupType, k.GroupID, k.ID)
}
func achievementName(k gamedata.AchievementKey) string {
return fmt.Sprintf("%d/%d/%d", k.ContentsGroup, k.GroupID, k.ID)
}
func requireSeq(b []byte) error {
seq, err := scalar(b, 1)
if err != nil || seq == 0 {
return ErrInvalidRequest
}
return nil
}
func scalar(b []byte, number int) (uint64, error) {
value, found, err := wire.Varint(b, number)
if err != nil {
return 0, ErrInvalidRequest
}
if !found {
return 0, nil
}
return value, nil
}
func boolean(b []byte, number int) (bool, error) {
value, err := scalar(b, number)
if err != nil || value > 1 {
return false, ErrInvalidRequest
}
return value == 1, nil
}
type achievementClaim struct {
GroupID uint64
IDs []uint64
}
func achievementClaims(b []byte) ([]achievementClaim, error) {
var claims []achievementClaim
err := wire.Walk(b, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return ErrInvalidRequest
}
group, err := scalar(field.Value, 1)
if err != nil || group == 0 {
return ErrInvalidRequest
}
ids, err := packed(field.Value, 2)
if err != nil || len(ids) == 0 {
return ErrInvalidRequest
}
claims = append(claims, achievementClaim{group, ids})
return nil
})
return claims, err
}
func packed(b []byte, number int) ([]uint64, error) {
var values []uint64
err := wire.Walk(b, func(field wire.Field) error {
if field.Number != number {
return nil
}
if field.Type == 0 {
value, _ := decode(field.Value)
values = append(values, value)
return nil
}
if field.Type != 2 {
return ErrInvalidRequest
}
for remaining := field.Value; len(remaining) > 0; {
value, n := decode(remaining)
if n == 0 {
return ErrInvalidRequest
}
values = append(values, value)
remaining = remaining[n:]
}
return nil
})
return values, err
}
func decode(b []byte) (uint64, int) {
var value uint64
for i, x := range b {
value |= uint64(x&127) << (7 * i)
if x < 128 {
return value, i + 1
}
if i == 9 {
return 0, 0
}
}
return 0, 0
}