Files
bd2/go/internal/server/domain/world/world.go
T

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)
}