Files
bd2/go/internal/missions/missions.go
T
2026-09-21 09:53:46 +08:00

821 lines
22 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"
"os"
"path/filepath"
"sort"
"sync"
"time"
"bd2server/internal/gamedata"
"bd2server/internal/player"
"bd2server/internal/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 Service struct {
mu sync.Mutex
path string
design *gamedata.MissionDesign
inventory *player.Inventory
wallet *player.Wallet
state snapshot
}
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"`
Completed []string `json:"completed"`
Claimed []string `json:"claimed"`
Progress map[string]uint64 `json:"progress,omitempty"`
}
func Open(path string, design *gamedata.MissionDesign, inventory *player.Inventory) (*Service, error) {
if path == "" || design == nil || inventory == nil {
return nil, errors.New("missions: invalid service configuration")
}
s := &Service{path: filepath.Clean(path), design: design, inventory: inventory, state: snapshot{Version: "2.34.13", Progress: map[string]uint64{}}}
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("missions: read state: %w", err)
}
if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != "2.34.13" {
return nil, errors.New("missions: malformed state")
}
if s.state.Progress == nil {
s.state.Progress = map[string]uint64{}
}
return s, nil
}
// 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 _, 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()
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) {
s.mu.Lock()
defer s.mu.Unlock()
switch path {
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
}
}
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 := time.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)
}
var allItems []player.Item
var addExp uint64
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.inventory.GrantOnce(identity, battleRewards(design.Rewards))
if err != nil {
return nil, err
}
addExp += design.AddExp
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)
return wire.AppendBytes(response, 2, rewardBundle(allItems)), 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 == 3 || reward.Type == 4 {
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 equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) {
return nil
}
b, err := json.Marshal(next)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(s.path), ".missions-*.tmp")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err := os.Rename(f.Name(), s.path); 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, 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
}