feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
// Package world implements the local map/quest state that is not part of a
|
||||
// player's inventory. It deliberately stores semantic seed values, never a
|
||||
// captured response payload.
|
||||
package world
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/progress"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
var ErrInvalidRequest = errors.New("world: invalid request")
|
||||
|
||||
type Seed struct {
|
||||
Version string `json:"version"`
|
||||
PackID int `json:"pack_id"`
|
||||
StartQuestID int `json:"start_quest_id"`
|
||||
BattleUnlockQuestID int `json:"battle_unlock_quest_id"`
|
||||
RewardCharacter player.Character `json:"reward_character"`
|
||||
RewardCostume player.Costume `json:"reward_costume"`
|
||||
StoryCharacters []player.Character `json:"story_characters"`
|
||||
}
|
||||
|
||||
// Load reads the small, versioned world seed. Quest IDs are then verified
|
||||
// against the authoritative shared QuestTable<pack> GameData database.
|
||||
func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, state *progress.Store, starter *player.Starter, equipment *player.EquipmentInventory, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) {
|
||||
if state == nil || starter == nil || equipment == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("world: nil player state")
|
||||
}
|
||||
b, err := os.ReadFile(seedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("world: read seed: %w", err)
|
||||
}
|
||||
var seed Seed
|
||||
if err := json.Unmarshal(b, &seed); err != nil {
|
||||
return nil, fmt.Errorf("world: decode seed: %w", err)
|
||||
}
|
||||
if seed.Version != versionconfig.State() || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 {
|
||||
return nil, errors.New("world: invalid seed")
|
||||
}
|
||||
ownedCharacters := append([]player.Character(nil), starter.Characters...)
|
||||
ownedCharacters = append(ownedCharacters, seed.RewardCharacter)
|
||||
ownedCharacters = append(ownedCharacters, seed.StoryCharacters...)
|
||||
characters, err := player.OpenCharacterStore(storage, ownedCharacters, inventory, gameDataRoot, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("world: open character state: %w", err)
|
||||
}
|
||||
packs, transitions, err := gamedata.LoadQuestPackChain(gameDataRoot, gameDataVersion, seed.PackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quests := packs[seed.PackID]
|
||||
if _, ok := quests[seed.StartQuestID]; !ok {
|
||||
return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID)
|
||||
}
|
||||
transition := transitions[seed.PackID]
|
||||
activePack := seed.PackID
|
||||
if saved, found := state.Position(); found {
|
||||
if _, known := packs[saved.PackID]; known {
|
||||
activePack = saved.PackID
|
||||
}
|
||||
}
|
||||
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *Service) CharacterService() *player.CharacterStore { return s.characters }
|
||||
|
||||
func (s *Service) EarnedQuestCostume() (player.Costume, bool) {
|
||||
return s.seed.RewardCostume, s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID)
|
||||
}
|
||||
|
||||
// CurrentPackID returns the story pack selected by the latest successful
|
||||
// PackInGameInfo request. BattleEnter does not carry a pack field, so battle
|
||||
// sessions lock this value when they begin. On restart, Load seeds it from the
|
||||
// persisted position and finally falls back to the versioned starter pack.
|
||||
func (s *Service) CurrentPackID() (int, error) {
|
||||
s.activePackMu.RLock()
|
||||
packID := s.activePack
|
||||
s.activePackMu.RUnlock()
|
||||
if packID == 0 {
|
||||
packID = s.seed.PackID
|
||||
}
|
||||
if _, known := s.questsFor(packID); !known || !s.packUnlocked(packID) {
|
||||
return 0, fmt.Errorf("world: current pack %d is unavailable", packID)
|
||||
}
|
||||
return packID, nil
|
||||
}
|
||||
|
||||
func (s *Service) setCurrentPack(packID int) {
|
||||
s.activePackMu.Lock()
|
||||
s.activePack = packID
|
||||
s.activePackMu.Unlock()
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
seed Seed
|
||||
state *progress.Store
|
||||
starter *player.Starter
|
||||
equipment *player.EquipmentInventory
|
||||
inventory *player.Inventory
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
collection *player.CollectionStore
|
||||
decks *deck.Store
|
||||
quests map[int]gamedata.QuestDesign
|
||||
transition gamedata.PackTransition
|
||||
packs map[int]map[int]gamedata.QuestDesign
|
||||
transitions map[int]gamedata.PackTransition
|
||||
activePackMu sync.RWMutex
|
||||
activePack int
|
||||
}
|
||||
|
||||
func (s *Service) AttachCollection(collection *player.CollectionStore) error {
|
||||
if collection == nil {
|
||||
return errors.New("world: nil collection store")
|
||||
}
|
||||
s.collection = collection
|
||||
return s.characters.AttachCollection(collection)
|
||||
}
|
||||
|
||||
func (s *Service) AttachDecks(decks *deck.Store) error {
|
||||
if decks == nil {
|
||||
return errors.New("world: nil deck store")
|
||||
}
|
||||
s.decks = decks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
switch path {
|
||||
case "/PackInfo":
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return 0, nil, true, errors.New("world: PackInfo missing sequence")
|
||||
}
|
||||
return 4, s.accountPackInfo(), true, nil
|
||||
case "/CharInfo":
|
||||
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
var response []byte
|
||||
characters := s.characters.All()
|
||||
slog.Info("team trace: deliver owned characters", "characters", characters)
|
||||
for _, character := range characters {
|
||||
response = wire.AppendBytes(response, 1, encodeCharacter(character))
|
||||
}
|
||||
response = wire.AppendVarint(response, 2, s.starter.FieldCharControlDeckType)
|
||||
return 9, response, true, nil
|
||||
case "/CostumeInfo":
|
||||
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
var response []byte
|
||||
costumes := s.starter.Costumes
|
||||
if s.collection != nil {
|
||||
costumes = s.collection.Costumes()
|
||||
}
|
||||
slog.Info("team trace: deliver owned costumes", "costumes", costumes, "quest26", s.seed.RewardCostume)
|
||||
for _, costume := range costumes {
|
||||
response = wire.AppendBytes(response, 1, encodeCostume(costume))
|
||||
}
|
||||
if s.collection == nil {
|
||||
response = wire.AppendBytes(response, 1, encodeCostume(s.seed.RewardCostume))
|
||||
}
|
||||
return 40, response, true, nil
|
||||
case "/PackInGameInfo":
|
||||
pack, err := requestPack(request)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if !s.packUnlocked(pack) {
|
||||
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
|
||||
}
|
||||
s.setCurrentPack(pack)
|
||||
slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack))
|
||||
response, err := s.packInfoFor(pack)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
return 5, response, true, nil
|
||||
case "/QuestClear":
|
||||
quest, pack, err := requestQuest(request)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
quests, unlocked := s.questsFor(pack)
|
||||
design, exists := quests[quest]
|
||||
if !unlocked || !s.packUnlocked(pack) || !exists {
|
||||
return 0, nil, true, fmt.Errorf("%w: quest %d pack %d", ErrInvalidRequest, quest, pack)
|
||||
}
|
||||
if !s.canClear(pack, quest) {
|
||||
return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, quest)
|
||||
}
|
||||
items, questEquipment, err := s.grantQuestRewards(pack, quest, design.Rewards[0])
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if err := s.state.ClearQuest(quest, pack); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("world: clear quest: %w", err)
|
||||
}
|
||||
if s.collection != nil && quest == s.seed.BattleUnlockQuestID && pack == s.seed.PackID {
|
||||
if err := s.collection.AttachRewardCostume(s.seed.RewardCostume); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", pack == s.seed.PackID && quest == s.seed.BattleUnlockQuestID)
|
||||
return 18, s.clearResponse(pack, quest, design.Rewards[0], items, questEquipment), true, nil
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requestPack(request []byte) (int, error) {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return 0, ErrInvalidRequest
|
||||
}
|
||||
pack, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) {
|
||||
return 0, ErrInvalidRequest
|
||||
}
|
||||
return int(pack), nil
|
||||
}
|
||||
|
||||
func requestQuest(request []byte) (int, int, error) {
|
||||
quest, err := requestPack(request) // fields 1 and 2 have the same validation.
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
pack, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) {
|
||||
return 0, 0, ErrInvalidRequest
|
||||
}
|
||||
return quest, int(pack), nil
|
||||
}
|
||||
|
||||
func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) {
|
||||
if s.packs != nil {
|
||||
quests, found := s.packs[packID]
|
||||
return quests, found
|
||||
}
|
||||
if packID == s.seed.PackID && s.quests != nil {
|
||||
return s.quests, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) transitionFor(packID int) gamedata.PackTransition {
|
||||
if s.transitions != nil {
|
||||
return s.transitions[packID]
|
||||
}
|
||||
if packID == s.seed.PackID {
|
||||
return s.transition
|
||||
}
|
||||
return gamedata.PackTransition{PackID: packID}
|
||||
}
|
||||
|
||||
// packUnlocked follows the static story chain and requires every preceding
|
||||
// pack to be complete. This accepts the configured next story pack only after
|
||||
// its predecessor is complete, without exposing arbitrary GameData tables.
|
||||
func (s *Service) packUnlocked(packID int) bool {
|
||||
current := s.seed.PackID
|
||||
for steps := 0; steps < 64 && current != 0; steps++ {
|
||||
if current == packID {
|
||||
_, found := s.questsFor(current)
|
||||
return found
|
||||
}
|
||||
if !s.packCompleteFor(current) {
|
||||
return false
|
||||
}
|
||||
current = s.transitionFor(current).NextPackID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) storyCharacters(packID int) []player.Character {
|
||||
if packID != s.seed.PackID {
|
||||
return nil
|
||||
}
|
||||
return s.seed.StoryCharacters
|
||||
}
|
||||
|
||||
func (s *Service) canClear(packID, quest int) bool {
|
||||
if s.state.QuestCleared(quest, packID) {
|
||||
return true
|
||||
}
|
||||
quests, found := s.questsFor(packID)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
startQuestID := 0
|
||||
for id := range quests {
|
||||
if startQuestID == 0 || id < startQuestID {
|
||||
startQuestID = id
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID {
|
||||
startQuestID = s.seed.StartQuestID
|
||||
}
|
||||
if quest == startQuestID {
|
||||
return true
|
||||
}
|
||||
for id := range quests {
|
||||
if id < quest && !s.state.QuestCleared(id, packID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) {
|
||||
identity := fmt.Sprintf("pack%d:quest%d", packID, quest)
|
||||
if s.wallet != nil {
|
||||
if _, err := s.wallet.GrantQuestOnce(identity, designRewards); err != nil {
|
||||
return nil, nil, fmt.Errorf("world: grant quest currency: %w", err)
|
||||
}
|
||||
}
|
||||
var itemRewards []gamedata.BattleReward
|
||||
var equipmentReward *gamedata.Reward
|
||||
for i := range designRewards {
|
||||
reward := designRewards[i]
|
||||
switch reward.Type {
|
||||
case 3, 4:
|
||||
if reward.Count == 0 {
|
||||
return nil, nil, errors.New("world: zero currency reward")
|
||||
}
|
||||
case 10:
|
||||
if reward.ID == 0 || equipmentReward != nil {
|
||||
return nil, nil, errors.New("world: invalid equipment reward")
|
||||
}
|
||||
equipmentReward = &reward
|
||||
case 11:
|
||||
// Quest 26's character/costume instances come from the versioned
|
||||
// story seed and are encoded below; they are not stackable items.
|
||||
if packID != s.seed.PackID || quest != s.seed.BattleUnlockQuestID || reward.ID != s.seed.RewardCostume.ID {
|
||||
return nil, nil, fmt.Errorf("world: unsupported costume reward %d", reward.ID)
|
||||
}
|
||||
default:
|
||||
if reward.ID == 0 || reward.Count == 0 {
|
||||
return nil, nil, fmt.Errorf("world: invalid item reward type=%d id=%d count=%d", reward.Type, reward.ID, reward.Count)
|
||||
}
|
||||
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID {
|
||||
for _, reward := range questPictorialItems[quest] {
|
||||
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
||||
}
|
||||
}
|
||||
var items []player.Item
|
||||
if len(itemRewards) != 0 {
|
||||
if s.inventory == nil {
|
||||
return nil, nil, errors.New("world: inventory unavailable")
|
||||
}
|
||||
var err error
|
||||
items, err = s.inventory.GrantOnce(identity+":items", itemRewards)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("world: grant quest items: %w", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = s.inventory.GrantedItems(identity + ":items")
|
||||
}
|
||||
}
|
||||
var equipment *player.Equipment
|
||||
if equipmentReward != nil {
|
||||
if s.equipment == nil {
|
||||
return nil, nil, errors.New("world: equipment inventory unavailable")
|
||||
}
|
||||
entry, err := s.equipment.GrantOnce(fmt.Sprintf("%s:equip%d", identity, equipmentReward.ID), equipmentReward.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("world: grant quest equipment: %w", err)
|
||||
}
|
||||
equipment = &entry
|
||||
}
|
||||
return items, equipment, nil
|
||||
}
|
||||
|
||||
// packInfo is the canonical protobuf encoding of the semantic new-account
|
||||
// starter-pack state. Its first-call bytes match the 2.34.13 observed response.
|
||||
func (s *Service) packInfo() ([]byte, error) {
|
||||
return s.packInfoFor(s.seed.PackID)
|
||||
}
|
||||
|
||||
func (s *Service) packInfoFor(packID int) ([]byte, error) {
|
||||
var out []byte
|
||||
if packID == s.seed.PackID && s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
||||
for _, expected := range s.seed.StoryCharacters {
|
||||
character, found := s.characters.Find(expected.InvenIndex)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("world: persisted character %d is missing", expected.InvenIndex)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, encodeCharacter(character))
|
||||
}
|
||||
character, found := s.characters.Find(s.seed.RewardCharacter.InvenIndex)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("world: persisted reward character %d is missing", s.seed.RewardCharacter.InvenIndex)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, encodeCharacter(character))
|
||||
}
|
||||
if active := s.firstUnclearedQuestFor(packID); active != 0 {
|
||||
quest := wire.AppendVarint(nil, 1, uint64(active))
|
||||
quest = wire.AppendVarint(quest, 6, uint64(packID))
|
||||
out = wire.AppendBytes(out, 2, quest)
|
||||
}
|
||||
cleared := s.state.ClearedQuests(packID)
|
||||
if len(cleared) != 0 {
|
||||
var packed []byte
|
||||
for _, id := range cleared {
|
||||
packed = binary.AppendUvarint(packed, uint64(id))
|
||||
}
|
||||
out = wire.AppendBytes(out, 3, packed)
|
||||
}
|
||||
position := "{}"
|
||||
if saved, found := s.state.Position(); found && saved.PackID == packID && saved.RawJSON != "" {
|
||||
position = saved.RawJSON
|
||||
}
|
||||
out = wire.AppendString(out, 4, position)
|
||||
// The remaining starter-only records were observed in the official
|
||||
// starter-pack response. They represent reputation, hunting-ground, statue,
|
||||
// and reward state, not generic defaults, so a newly entered later pack must
|
||||
// not inherit them.
|
||||
if packID != s.seed.PackID {
|
||||
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
||||
return wire.AppendBytes(out, 12, visit), nil
|
||||
}
|
||||
open := wire.AppendVarint(nil, 1, 1)
|
||||
open = wire.AppendVarint(open, 2, 1)
|
||||
out = wire.AppendBytes(out, 9, open)
|
||||
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
||||
out = wire.AppendBytes(out, 12, visit)
|
||||
stat := wire.AppendVarint(nil, 1, 3)
|
||||
stat = wire.AppendVarint(stat, 2, 77)
|
||||
stat = wire.AppendVarint(stat, 3, 1)
|
||||
out = wire.AppendBytes(out, 14, stat)
|
||||
reward := wire.AppendVarint(nil, 3, 3)
|
||||
reward = wire.AppendVarint(reward, 4, 150)
|
||||
group := wire.AppendBytes(nil, 1, reward)
|
||||
group = wire.AppendBytes(group, 6, reward)
|
||||
return wire.AppendBytes(out, 16, group), nil
|
||||
}
|
||||
|
||||
func (s *Service) firstUnclearedQuest() int {
|
||||
return s.firstUnclearedQuestFor(s.seed.PackID)
|
||||
}
|
||||
|
||||
func (s *Service) firstUnclearedQuestFor(packID int) int {
|
||||
quests, found := s.questsFor(packID)
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
ids := make([]int, 0, len(quests))
|
||||
for id := range quests {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
for _, id := range ids {
|
||||
if !s.state.QuestCleared(id, packID) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment) []byte {
|
||||
var rewards []byte
|
||||
for _, reward := range designRewards {
|
||||
if reward.Type != 3 && reward.Type != 4 {
|
||||
continue
|
||||
}
|
||||
currency := wire.AppendVarint(nil, 3, reward.Type)
|
||||
currency = wire.AppendVarint(currency, 4, reward.Count)
|
||||
rewards = wire.AppendBytes(rewards, 1, currency)
|
||||
}
|
||||
for _, item := range items {
|
||||
entry := player.ItemWire(item)
|
||||
if item.Type == 17 {
|
||||
pictorial := wire.AppendVarint(nil, 1, 5)
|
||||
pictorial = wire.AppendVarint(pictorial, 2, item.ID)
|
||||
entry = wire.AppendBytes(entry, 7, pictorial)
|
||||
}
|
||||
rewards = wire.AppendBytes(rewards, 1, entry)
|
||||
view := wire.AppendVarint(nil, 2, item.ID)
|
||||
view = wire.AppendVarint(view, 3, item.Type)
|
||||
view = wire.AppendVarint(view, 4, item.Count)
|
||||
rewards = wire.AppendBytes(rewards, 6, view)
|
||||
}
|
||||
if questEquipment != nil {
|
||||
// RewardDBInfoBundle field 4 is EquipDBInfo. Equipment is an instance,
|
||||
// not an ItemDBInfo with a fabricated stack count.
|
||||
rewards = wire.AppendBytes(rewards, 4, player.EquipmentWire(*questEquipment))
|
||||
}
|
||||
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID {
|
||||
rewardCharacter := encodeCharacter(s.seed.RewardCharacter)
|
||||
// Pictorial state: acquired level 1, progress 60.
|
||||
rewardCharacter = wire.AppendBytes(rewardCharacter, 12, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 60))
|
||||
rewards = wire.AppendBytes(rewards, 2, rewardCharacter)
|
||||
costume := encodeCostume(s.seed.RewardCostume)
|
||||
// Pictorial state observed for the starter costume.
|
||||
costume = wire.AppendBytes(costume, 5, wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 116))
|
||||
rewards = wire.AppendBytes(rewards, 3, costume)
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
view := wire.AppendVarint(nil, 2, character.ID)
|
||||
view = wire.AppendVarint(view, 3, 6)
|
||||
rewards = wire.AppendBytes(rewards, 6, view)
|
||||
}
|
||||
viewCostume := wire.AppendVarint(nil, 2, s.seed.RewardCostume.ID)
|
||||
viewCostume = wire.AppendVarint(viewCostume, 3, 11)
|
||||
rewards = wire.AppendBytes(rewards, 6, viewCostume)
|
||||
viewCharacter := wire.AppendVarint(nil, 2, s.seed.RewardCharacter.ID)
|
||||
viewCharacter = wire.AppendVarint(viewCharacter, 3, 6)
|
||||
viewCharacter = wire.AppendVarint(viewCharacter, 4, 1)
|
||||
rewards = wire.AppendBytes(rewards, 6, viewCharacter)
|
||||
}
|
||||
var out []byte
|
||||
out = wire.AppendBytes(out, 1, rewards)
|
||||
next := s.nextQuestFor(packID, quest)
|
||||
if next != 0 {
|
||||
out = wire.AppendBytes(out, 2, wire.AppendVarint(nil, 1, uint64(next)))
|
||||
} else {
|
||||
// QuestClearResponse.QuestInfo is dereferenced by the 2.34.13 client
|
||||
// even when this is the final quest of a pack. An explicitly present,
|
||||
// empty QuestDBInfo gives that generated protobuf property a non-null
|
||||
// object whose Id is the client-recognized zero sentinel. Omitting the
|
||||
// field parses as null and makes the completion coroutine throw before
|
||||
// it can mark the pack complete. A final-pack official capture has not
|
||||
// yet been obtained, so this exact wire choice remains marked for parity
|
||||
// verification even though its client behavior is deterministic.
|
||||
out = wire.AppendBytes(out, 2, nil)
|
||||
}
|
||||
out = wire.AppendVarint(out, 3, uint64(quest))
|
||||
if next == 0 && s.packCompleteFor(packID) {
|
||||
// The final normal quest unlocks PackTable.NextPackId. Without these
|
||||
// PackDBInfo updates the client cannot find the next story pack and
|
||||
// falls back to presenting the hard-difficulty objective.
|
||||
for _, info := range s.packDBInfoRows() {
|
||||
out = wire.AppendBytes(out, 11, info)
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID {
|
||||
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex, s.seed.StoryCharacters[0].InvenIndex, s.seed.StoryCharacters[1].InvenIndex, s.seed.StoryCharacters[2].InvenIndex, s.starter.Characters[0].InvenIndex}
|
||||
for index, characterID := range deckIDs {
|
||||
deck := wire.AppendVarint(nil, 1, characterID)
|
||||
deck = wire.AppendVarint(deck, 2, ^uint64(0))
|
||||
deck = wire.AppendVarint(deck, 3, uint64(index+1))
|
||||
out = wire.AppendBytes(out, 4, deck)
|
||||
}
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
out = wire.AppendBytes(out, 5, encodeCharacter(character))
|
||||
}
|
||||
} else if s.decks != nil {
|
||||
// Official 2.34.13 QuestClear always echoes the authoritative current
|
||||
// DeckInfo. Omitting it after quest 29 makes the client rebuild a party
|
||||
// from ordinary owned characters and overwrite the story formation.
|
||||
for _, current := range s.decks.CurrentDeck() {
|
||||
entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex)
|
||||
if current.CostumeInvenIndex != 0 {
|
||||
entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex)
|
||||
}
|
||||
entry = wire.AppendVarint(entry, 3, current.Slot)
|
||||
out = wire.AppendBytes(out, 4, entry)
|
||||
}
|
||||
}
|
||||
out = wire.AppendBytes(out, 12, nil)
|
||||
return wire.AppendBytes(out, 13, nil)
|
||||
}
|
||||
|
||||
func (s *Service) packComplete() bool {
|
||||
return s.packCompleteFor(s.seed.PackID)
|
||||
}
|
||||
|
||||
func (s *Service) packCompleteFor(packID int) bool {
|
||||
quests, found := s.questsFor(packID)
|
||||
if !found || len(quests) == 0 {
|
||||
return false
|
||||
}
|
||||
for id := range quests {
|
||||
if !s.state.QuestCleared(id, packID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) packDBInfoRows() [][]byte {
|
||||
var rows [][]byte
|
||||
packID := s.seed.PackID
|
||||
for steps := 0; steps < 64 && packID != 0; steps++ {
|
||||
cleared := s.state.ClearedQuests(packID)
|
||||
current := wire.AppendVarint(nil, 1, uint64(packID))
|
||||
if len(cleared) != 0 {
|
||||
current = wire.AppendVarint(current, 2, uint64(len(cleared)))
|
||||
}
|
||||
complete := s.packCompleteFor(packID)
|
||||
if complete {
|
||||
current = wire.AppendVarint(current, 3, 1)
|
||||
}
|
||||
// The seed pack was purchased by the account bootstrap. A later pack is
|
||||
// considered entered once it has its own progress or saved position;
|
||||
// the newly unlocked, untouched row intentionally remains unpurchased.
|
||||
purchased := packID == s.seed.PackID || len(cleared) != 0
|
||||
if saved, found := s.state.Position(); found && saved.PackID == packID {
|
||||
purchased = true
|
||||
}
|
||||
if purchased {
|
||||
current = wire.AppendVarint(current, 8, 1)
|
||||
}
|
||||
rows = append(rows, current)
|
||||
if !complete {
|
||||
break
|
||||
}
|
||||
nextPackID := s.transitionFor(packID).NextPackID
|
||||
if nextPackID == 0 {
|
||||
break
|
||||
}
|
||||
if _, found := s.questsFor(nextPackID); !found {
|
||||
// Unit-sized services and partial deployments may know the real
|
||||
// transition before the next QuestTable is attached. Preserve the
|
||||
// protocol's unlocked row, but packUnlocked still refuses entry until
|
||||
// that table is actually available.
|
||||
rows = append(rows, wire.AppendVarint(nil, 1, uint64(nextPackID)))
|
||||
break
|
||||
}
|
||||
packID = nextPackID
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (s *Service) accountPackInfo() []byte {
|
||||
var out []byte
|
||||
for _, info := range s.packDBInfoRows() {
|
||||
out = wire.AppendBytes(out, 1, info)
|
||||
}
|
||||
if s.packComplete() {
|
||||
level := wire.AppendVarint(nil, 1, uint64(s.seed.PackID))
|
||||
level = wire.AppendVarint(level, 3, uint64(len(s.state.ClearedQuests(s.seed.PackID))))
|
||||
level = wire.AppendVarint(level, 5, 1)
|
||||
out = wire.AppendBytes(out, 2, level)
|
||||
}
|
||||
return wire.AppendVarint(out, 5, 3)
|
||||
}
|
||||
|
||||
var questPictorialItems = map[int][]gamedata.Reward{
|
||||
3: {{ID: 2101, Type: 17, Count: 1}},
|
||||
10: {{ID: 2102, Type: 17, Count: 1}},
|
||||
28: {{ID: 2103, Type: 17, Count: 1}},
|
||||
}
|
||||
|
||||
// PictorialCharacters hides quest-26 rewards until they are earned, even
|
||||
// though their instances already exist in the versioned world seed.
|
||||
func (s *Service) PictorialCharacters() []player.Character {
|
||||
if s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) && s.characters != nil {
|
||||
return s.characters.RawAll()
|
||||
}
|
||||
return append([]player.Character(nil), s.starter.Characters...)
|
||||
}
|
||||
|
||||
func (s *Service) PictorialCostumes() []player.Costume {
|
||||
result := append([]player.Costume(nil), s.starter.Costumes...)
|
||||
if s.collection != nil {
|
||||
result = s.collection.Costumes()
|
||||
}
|
||||
if s.collection == nil && s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
||||
result = append(result, s.seed.RewardCostume)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) PictorialItems() []player.Item {
|
||||
var result []player.Item
|
||||
if s.inventory != nil {
|
||||
result = s.inventory.All()
|
||||
} else {
|
||||
result = append(result, s.starter.Items...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) PictorialEquipment() []player.Equipment {
|
||||
if s.equipment == nil {
|
||||
return nil
|
||||
}
|
||||
return s.equipment.All()
|
||||
}
|
||||
|
||||
func (s *Service) PictorialDiscovered() []player.Pictorial {
|
||||
return append([]player.Pictorial(nil), s.starter.Pictorialbook...)
|
||||
}
|
||||
|
||||
func encodeCostume(c player.Costume) []byte {
|
||||
return player.CostumeWire(c)
|
||||
}
|
||||
|
||||
func (s *Service) nextQuest(current int) int {
|
||||
return s.nextQuestFor(s.seed.PackID, current)
|
||||
}
|
||||
|
||||
func (s *Service) nextQuestFor(packID, current int) int {
|
||||
quests, found := s.questsFor(packID)
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
ids := make([]int, 0, len(quests))
|
||||
for id := range quests {
|
||||
if id > current {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
if len(ids) == 0 {
|
||||
return 0
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
func encodeCharacter(c player.Character) []byte {
|
||||
return player.CharacterWire(c)
|
||||
}
|
||||
Reference in New Issue
Block a user