475 lines
17 KiB
Go
475 lines
17 KiB
Go
// Package world implements the local map/quest state that is not part of a
|
|
// player's assets. It deliberately stores semantic seed values, never a
|
|
// captured response payload.
|
|
package world
|
|
|
|
import (
|
|
"bd2server/internal/server/design/gamedata"
|
|
"bd2server/internal/server/domain/command"
|
|
assets "bd2server/internal/server/domain/inventory"
|
|
"bd2server/internal/server/domain/roster"
|
|
"bd2server/internal/server/domain/roster/deck"
|
|
"bd2server/internal/server/domain/world/progress"
|
|
"bd2server/internal/server/domain/world/todayquest"
|
|
"bd2server/internal/server/platform/versionconfig"
|
|
"bd2server/internal/server/storage/stateio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"time"
|
|
)
|
|
|
|
var ErrInvalidRequest = errors.New("world: invalid request")
|
|
|
|
type InitialReputation struct {
|
|
GroupID uint64 `json:"group_id"`
|
|
State uint64 `json:"state"`
|
|
ElapsedSeconds uint64 `json:"elapsed_seconds,omitempty"`
|
|
}
|
|
type InitialRankStatue struct {
|
|
ID uint64 `json:"id"`
|
|
Season uint64 `json:"season"`
|
|
Error bool `json:"error"`
|
|
}
|
|
|
|
type Seed struct {
|
|
SquareSceneID uint64 `json:"square_scene_id,omitempty"`
|
|
InitialReputations []InitialReputation `json:"initial_reputations,omitempty"`
|
|
InitialRankStatues []InitialRankStatue `json:"initial_rank_statues,omitempty"`
|
|
// Versioned story slot placeholder; confirmed current CostumeTable row, slot semantics await current capture.
|
|
PlaceholderCostumeID uint64 `json:"placeholder_costume_id,omitempty"`
|
|
Version string `json:"version"`
|
|
PackID int `json:"pack_id"`
|
|
StartQuestID int `json:"start_quest_id"`
|
|
BattleUnlockQuestID int `json:"battle_unlock_quest_id"`
|
|
RewardCharacter roster.Character `json:"reward_character"`
|
|
RewardCostume roster.Costume `json:"reward_costume"`
|
|
StoryCharacters []roster.Character `json:"story_characters"`
|
|
}
|
|
|
|
func LoadSeed(path string) (Seed, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Seed{}, fmt.Errorf("world: read seed: %w", err)
|
|
}
|
|
var seed Seed
|
|
if err := json.Unmarshal(b, &seed); err != nil {
|
|
return Seed{}, 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 Seed{}, errors.New("world: invalid seed")
|
|
}
|
|
return seed, nil
|
|
}
|
|
|
|
func New(ctx command.Context, seed Seed, rules *gamedata.WorldRules, source *gamedata.Source, storage stateio.Store, state *progress.Store, starter *roster.Starter, equipment *assets.EquipmentInventory, inventory *assets.Inventory, wallet *assets.Wallet) (*Service, error) {
|
|
if rules == nil || source == nil || state == nil || starter == nil || equipment == nil || inventory == nil || wallet == nil {
|
|
return nil, errors.New("world: incomplete player configuration")
|
|
}
|
|
ownedCharacters := append([]roster.Character(nil), starter.Characters...)
|
|
ownedCharacters = append(ownedCharacters, seed.RewardCharacter)
|
|
ownedCharacters = append(ownedCharacters, seed.StoryCharacters...)
|
|
characters, err := roster.OpenCharacterStore(ctx, storage, ownedCharacters, inventory, source.Root(), source.Version())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
quests := rules.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)
|
|
}
|
|
activePack := seed.PackID
|
|
if id := state.ActivePackID(); id != 0 {
|
|
activePack = id
|
|
}
|
|
if saved, found := state.Position(); found && state.ActivePackID() == 0 {
|
|
_, storyKnown := rules.Packs[saved.PackID]
|
|
_, fieldKnown := rules.FieldPacks[saved.PackID]
|
|
if storyKnown || fieldKnown {
|
|
activePack = saved.PackID
|
|
}
|
|
}
|
|
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters,
|
|
quests: quests, transition: rules.Transitions[seed.PackID], packs: rules.Packs, transitions: rules.Transitions, activePack: activePack, fieldPacks: rules.FieldPacks,
|
|
questDifficulties: rules.Difficulties, packJamDesign: rules.Jam, packSummaryTargets: rules.SummaryTargets, storyCatalog: rules.Story, questCostumes: rules.QuestCostumes, storyRoster: rules.Characters}
|
|
service.attachPackDetailDesign(source)
|
|
service.attachFieldMonsterDesign(source)
|
|
service.trapLoader = source.FieldTraps
|
|
return service, nil
|
|
}
|
|
|
|
func (s *Service) CharacterService() *roster.CharacterStore { return s.characters }
|
|
|
|
func (s *Service) EarnedQuestCostume() (roster.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(ctx command.Context) (int, error) {
|
|
|
|
packID := s.activePack
|
|
|
|
if packID == 0 {
|
|
packID = s.seed.PackID
|
|
}
|
|
if _, known := s.questsFor(ctx, packID); !known || !s.packUnlocked(ctx, packID) {
|
|
return 0, fmt.Errorf("world: current pack %d is unavailable", packID)
|
|
}
|
|
return packID, nil
|
|
}
|
|
|
|
func (s *Service) setCurrentPack(packID int) {
|
|
|
|
if s.activePack != packID {
|
|
s.transientVersion++
|
|
}
|
|
s.activePack = packID
|
|
|
|
}
|
|
|
|
func (s *Service) TransientVersion() uint64 {
|
|
|
|
return s.transientVersion
|
|
}
|
|
|
|
type Service struct {
|
|
autoRecoveryPolicy *gamedata.PackRecoveryPolicy
|
|
todayQuests *todayquest.Service
|
|
huntingGround interface {
|
|
EnsureForPack(ctx command.Context, _ int) ([]byte, error)
|
|
}
|
|
battleActive func(command.Context) bool
|
|
questDifficulties map[int]map[int]bool
|
|
startingPackID int
|
|
storyRoster *gamedata.StoryCharacterCatalog
|
|
storyCatalog *gamedata.StoryCatalog
|
|
questCostumes roster.CostumeDesignSource
|
|
packDetailDesign func(int) (gamedata.PackDetailDesign, error)
|
|
packSummaryTargets map[int]bool
|
|
packJamDesign *gamedata.PackJamDesign
|
|
|
|
fieldPacks map[int]gamedata.FieldPack
|
|
eventFieldPacks EventFieldPackSource
|
|
fieldObjects map[int]gamedata.FieldObjectDesign
|
|
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
|
|
monsterLoader func(int) ([]gamedata.FieldMonsterDesign, error)
|
|
monsterStore stateio.Store
|
|
monsterNow func() time.Time
|
|
|
|
monsterRewards func(int, uint64) ([]gamedata.BattleReward, error)
|
|
monsterMaps func(int) (map[int][]int, error)
|
|
monsterDamage func(command.Context, int, uint64, string) ([][]byte, error)
|
|
trapLoader func(int) (gamedata.FieldTrapDesign, error)
|
|
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
|
talentPackInfo func(ctx command.Context, _ int) ([]byte, error)
|
|
overwhelmAuthorize func(ctx command.Context, _ string, _ uint64) error
|
|
skyway *skyWayRuntime
|
|
overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error)
|
|
overwhelmHunting interface {
|
|
ValidateBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64) error
|
|
CompleteBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64, _ string) ([]byte, [][]byte, error)
|
|
}
|
|
npcReputation *npcReputationRuntime
|
|
fieldReset gamedata.FieldResetSchedule
|
|
researchDesigns map[int]gamedata.FieldResearchDesign
|
|
researchLoader func(int) (gamedata.FieldResearchDesign, error)
|
|
researchCharacters map[uint64]bool
|
|
researchEconomy interface {
|
|
Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error)
|
|
}
|
|
squadLevel func() (uint64, error)
|
|
seed Seed
|
|
state *progress.Store
|
|
starter *roster.Starter
|
|
equipment *assets.EquipmentInventory
|
|
inventory *assets.Inventory
|
|
wallet *assets.Wallet
|
|
characters *roster.CharacterStore
|
|
collection *roster.CollectionStore
|
|
decks *deck.Store
|
|
quests map[int]gamedata.QuestDesign
|
|
transition gamedata.PackTransition
|
|
packs map[int]map[int]gamedata.QuestDesign
|
|
transitions map[int]gamedata.PackTransition
|
|
|
|
activePack int
|
|
transientVersion uint64
|
|
prestigeSelections func(ctx command.Context) (map[uint64]uint64, error)
|
|
}
|
|
|
|
func (s *Service) AttachCollection(ctx command.Context, collection *roster.CollectionStore) error {
|
|
if collection == nil {
|
|
return errors.New("world: nil collection store")
|
|
}
|
|
s.collection = collection
|
|
return s.characters.AttachCollection(ctx, collection)
|
|
}
|
|
|
|
// AttachPrestigeSelections projects the durable skin choice into CostumeInfo
|
|
// responses without changing the frozen collection/deck schemas.
|
|
func (s *Service) AttachPrestigeSelections(ctx command.Context, provider func(ctx command.Context) (map[uint64]uint64, error)) error {
|
|
if provider == nil {
|
|
return errors.New("world: nil prestige selection provider")
|
|
}
|
|
s.prestigeSelections = provider
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) AttachDecks(ctx command.Context, decks *deck.Store) error {
|
|
if decks == nil {
|
|
return errors.New("world: nil deck store")
|
|
}
|
|
s.decks = decks
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) questsFor(ctx command.Context, packID int) (map[int]gamedata.QuestDesign, bool) {
|
|
if _, found, err := s.resolveEventFieldPack(ctx, packID); err == nil && found {
|
|
return map[int]gamedata.QuestDesign{}, true
|
|
}
|
|
if s.storyCatalog != nil {
|
|
pack, found := s.storyCatalog.Packs[packID]
|
|
if found {
|
|
return pack.Quests, true
|
|
}
|
|
}
|
|
if _, exists := s.fieldPacks[packID]; exists {
|
|
return map[int]gamedata.QuestDesign{}, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// packUnlocked uses installed ContentOpen rules and real account tickets.
|
|
func (s *Service) packUnlocked(ctx command.Context, packID int) bool {
|
|
if pack, found, err := s.resolveEventFieldPack(ctx, packID); err != nil {
|
|
return false
|
|
} else if found {
|
|
return s.eventPackPurchased(pack.ID)
|
|
}
|
|
if pack, exists := s.fieldPacks[packID]; exists {
|
|
return s.fieldPackUnlocked(ctx, pack)
|
|
}
|
|
return s.storyCatalog != nil && s.storyPackUnlocked(ctx, packID)
|
|
}
|
|
|
|
func (s *Service) canClear(ctx command.Context, packID, quest int) bool {
|
|
if s.state.QuestCleared(quest, packID, s.questDifficultyFor(packID, quest)) {
|
|
return true
|
|
}
|
|
quests, found := s.questsFor(ctx, packID)
|
|
if !found || s.storyCatalog == nil {
|
|
return false
|
|
}
|
|
design, found := quests[quest]
|
|
if !found {
|
|
return false
|
|
}
|
|
if design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, packID, s.questDifficultyFor(packID, quest)) {
|
|
return false
|
|
}
|
|
if design.Type == 0 {
|
|
return quest == s.firstUnclearedQuestFor(packID)
|
|
}
|
|
_, active := s.state.QuestInPack(quest, packID, s.questDifficultyFor(packID, quest))
|
|
return active
|
|
}
|
|
|
|
func (s *Service) grantQuestRewards(ctx command.Context, packID, quest int, designRewards []gamedata.Reward) ([]assets.Item, *assets.Equipment, error) {
|
|
identity := questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest))
|
|
if s.wallet != nil {
|
|
if _, err := s.wallet.GrantQuestOnce(ctx, 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 2, 3, 4, 12, 20:
|
|
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 && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID {
|
|
continue
|
|
}
|
|
if s.collection == nil || s.questCostumes == nil {
|
|
return nil, nil, fmt.Errorf("world: costume reward service unavailable")
|
|
}
|
|
if _, ok := s.questCostumes.Character(reward.ID); !ok {
|
|
return nil, nil, fmt.Errorf("world: missing quest costume %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}) //nolint:staticcheck // S1016
|
|
}
|
|
}
|
|
var costumeIDs []uint64
|
|
for _, reward := range designRewards {
|
|
if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { //nolint:staticcheck // QF1001
|
|
costumeIDs = append(costumeIDs, reward.ID)
|
|
}
|
|
}
|
|
if len(costumeIDs) != 0 {
|
|
grant, err := s.collection.GrantCostumes(ctx, identity+":costumes", costumeIDs, s.questCostumes)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("world: grant quest costume: %w", err)
|
|
}
|
|
var exchanges []gamedata.Reward
|
|
for _, exchange := range grant.Exchanges {
|
|
if exchange.ExchangeItemType != 20 {
|
|
return nil, nil, fmt.Errorf("world: unsupported quest costume exchange type %d", exchange.ExchangeItemType)
|
|
}
|
|
exchanges = append(exchanges, gamedata.Reward{Type: exchange.ExchangeItemType, ID: exchange.ExchangeItemID, Count: exchange.ExchangeCount})
|
|
}
|
|
if len(exchanges) > 0 {
|
|
if s.wallet == nil {
|
|
return nil, nil, fmt.Errorf("world: quest exchange wallet unavailable")
|
|
}
|
|
if _, err := s.wallet.GrantQuestOnce(ctx, identity+":costumes:exchange", exchanges); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
}
|
|
if s.questDifficultyFor(packID, quest) == 0 {
|
|
quests, known := s.questsFor(ctx, packID)
|
|
if !known {
|
|
return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID)
|
|
}
|
|
for _, reward := range quests[quest].CollectionRewards {
|
|
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016
|
|
}
|
|
}
|
|
var items []assets.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(ctx, 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 *assets.Equipment
|
|
if equipmentReward != nil {
|
|
if s.equipment == nil {
|
|
return nil, nil, errors.New("world: equipment inventory unavailable")
|
|
}
|
|
entry, err := s.equipment.GrantOnce(ctx, 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
|
|
}
|
|
|
|
func (s *Service) firstUnclearedQuestFor(packID int) int {
|
|
if s.storyCatalog == nil {
|
|
return 0
|
|
}
|
|
for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs {
|
|
if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) {
|
|
return id
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *Service) packCompleteFor(packID int) bool {
|
|
if s.storyCatalog == nil {
|
|
return false
|
|
}
|
|
ids := s.storyCatalog.Packs[packID].MainQuestIDs
|
|
if len(ids) == 0 {
|
|
return false
|
|
}
|
|
for _, id := range ids {
|
|
if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// PictorialCharacters hides quest-26 rewards until they are earned, even
|
|
// though their instances already exist in the versioned world seed.
|
|
func (s *Service) PictorialCharacters() []roster.Character {
|
|
if !s.tutorialRosterRestricted() && s.characters != nil {
|
|
var permanent []roster.Character
|
|
for _, c := range s.visibleOwnedCharacters(s.characters.RawAll()) {
|
|
if !roster.IsCharmCharacter(c) {
|
|
permanent = append(permanent, c)
|
|
}
|
|
}
|
|
return permanent
|
|
}
|
|
return append([]roster.Character(nil), s.starter.Characters...)
|
|
}
|
|
|
|
func (s *Service) PictorialCostumes() []roster.Costume {
|
|
result := append([]roster.Costume(nil), s.starter.Costumes...)
|
|
if s.collection != nil {
|
|
result = s.collection.Costumes()
|
|
}
|
|
if s.collection == nil && !s.tutorialRosterRestricted() && s.startingPack() == s.seed.PackID {
|
|
result = append(result, s.seed.RewardCostume)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Service) PictorialItems(ctx command.Context) []assets.Item {
|
|
var result []assets.Item
|
|
if s.inventory != nil {
|
|
result = s.inventory.All(ctx)
|
|
} else {
|
|
result = append(result, s.starter.Items...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Service) PictorialEquipment(ctx command.Context) []assets.Equipment {
|
|
if s.equipment == nil {
|
|
return nil
|
|
}
|
|
return s.equipment.All(ctx)
|
|
}
|
|
|
|
func (s *Service) PictorialDiscovered() []roster.Pictorial {
|
|
return append([]roster.Pictorial(nil), s.starter.Pictorialbook...)
|
|
}
|
|
|
|
func encodeCostume(c roster.Costume) []byte {
|
|
return roster.CostumeWire(c)
|
|
}
|
|
|
|
func (s *Service) nextQuestFor(ctx command.Context, packID, current int) int {
|
|
quests, found := s.questsFor(ctx, packID)
|
|
if !found {
|
|
return 0
|
|
}
|
|
return quests[current].NextQuestID
|
|
}
|
|
|
|
func encodeCharacter(c roster.Character) []byte {
|
|
return roster.CharacterWire(c)
|
|
}
|