From 42f93b63bcc00a79876f0bef9dde7dacdbc9351c Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 16:02:33 +0800 Subject: [PATCH] feat: persist story quest difficulty and configurable starting chapter --- GAME_CONFIGURATION.md | 18 ++ game.json | 3 + go/cmd/bd2server/main.go | 10 +- go/internal/server/accountstate/validate.go | 30 +- go/internal/server/battle/battle.go | 45 ++- go/internal/server/battle/battle_test.go | 38 +++ go/internal/server/deck/story_party.go | 26 ++ go/internal/server/gameconfig/config.go | 16 +- go/internal/server/gameconfig/config_test.go | 30 ++ .../server/gamedata/battle_difficulty.go | 79 ++++++ .../server/gamedata/battle_difficulty_test.go | 53 ++++ go/internal/server/gamedata/battle_phase.go | 23 ++ .../server/gamedata/battle_phase_test.go | 31 +++ .../server/gamedata/quest_difficulty.go | 58 ++++ go/internal/server/gamedata/quests.go | 15 +- .../server/gamedata/story_characters.go | 101 +++++++ .../server/gamedata/story_characters_test.go | 65 +++++ go/internal/server/player/story_characters.go | 39 +++ .../server/player/story_characters_test.go | 45 +++ .../server/progress/persistence_test.go | 39 ++- go/internal/server/progress/progress.go | 242 ++++++++++++---- go/internal/server/world/field_pack.go | 13 +- go/internal/server/world/health_test.go | 6 +- go/internal/server/world/pack_purchase.go | 2 +- go/internal/server/world/quest_selection.go | 260 ++++++++++++++++++ .../server/world/quest_selection_test.go | 125 +++++++++ go/internal/server/world/start_pack.go | 52 ++++ go/internal/server/world/start_pack_test.go | 80 ++++++ go/internal/server/world/story_catalog.go | 14 +- .../server/world/story_catalog_test.go | 4 +- go/internal/server/world/story_characters.go | 186 +++++++++++++ .../server/world/story_characters_test.go | 79 ++++++ go/internal/server/world/world.go | 174 ++++++++---- go/internal/server/world/world_test.go | 6 +- 34 files changed, 1859 insertions(+), 148 deletions(-) create mode 100644 go/internal/server/deck/story_party.go create mode 100644 go/internal/server/gamedata/battle_difficulty.go create mode 100644 go/internal/server/gamedata/battle_difficulty_test.go create mode 100644 go/internal/server/gamedata/quest_difficulty.go create mode 100644 go/internal/server/gamedata/story_characters.go create mode 100644 go/internal/server/gamedata/story_characters_test.go create mode 100644 go/internal/server/player/story_characters.go create mode 100644 go/internal/server/player/story_characters_test.go create mode 100644 go/internal/server/world/quest_selection.go create mode 100644 go/internal/server/world/quest_selection_test.go create mode 100644 go/internal/server/world/start_pack.go create mode 100644 go/internal/server/world/start_pack_test.go create mode 100644 go/internal/server/world/story_characters.go create mode 100644 go/internal/server/world/story_characters_test.go diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md index 9ee19dd..6f41099 100644 --- a/GAME_CONFIGURATION.md +++ b/GAME_CONFIGURATION.md @@ -9,6 +9,9 @@ ```json { "schema_version": 1, + "story": { + "start_pack_id": 21 + }, "gacha": { "include_collaboration_ur_weapons": false } @@ -26,6 +29,21 @@ 开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。 +## 新玩家的起始章节 + +`story.start_pack_id` 接受整数 `21` 或 `1`,省略时默认 `21`。 + +| 值 | 新账号的开局 | +|---|---| +| `21`(默认) | 从当前客户端默认的店长篇开始,使用该篇的任务和教学流程。 | +| `1` | 从旧主线第一章开始,使用当前 GameData 中该章的开场剧情、初始任务及购卡奖励。 | + +此设置只在创建完整新账号存档时使用。起始章节会保存到该账号,修改配置后不会重置已有玩家、移动其当前位置或改变其选中的任务难度。不要通过删除玩家数据库来切换开局;这会删除玩家存档。 + +起始章节与教学 seed 分开处理,选择 `1` 不会把店长篇任务编号或剧情队伍替换进第一章。章节中的任务顺序、剧情阵容、内容票券以及允许的难度均读取当前 GameData。不同难度分别记录任务进度与奖励,重新连接后继续原有选择。 + +游戏卡的任务列表用于查看进度及开始、继续当前可进行的任务。后续任务仍须满足前置条件;难度列表只提供该章节表中存在的难度。当前已核实第一章与店长篇有普通、困难、极难,不能依据客户端枚举直接开启其他难度。 + ## 校验与升级 `schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。 diff --git a/game.json b/game.json index 971cd85..4a69b02 100644 --- a/game.json +++ b/game.json @@ -1,5 +1,8 @@ { "schema_version": 1, + "story": { + "start_pack_id": 21 + }, "gacha": { "include_collaboration_ur_weapons": false } diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 139efd4..e7643ee 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -240,7 +240,8 @@ func serve(args []string) (serveErr error) { } } slog.Info("server gameplay rules loaded", "config", *gameConfigPath, - "include_collaboration_ur_weapons", gameRules.Gacha.IncludeCollaborationURWeapons) + "include_collaboration_ur_weapons", gameRules.Gacha.IncludeCollaborationURWeapons, + "starting_pack_id", gameRules.Story.StartPackID) limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load limited costume GameData: %w", err) @@ -446,6 +447,9 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load world state: %w", err) } + if err := worldService.ConfigureStartPack(gameRules.Story.StartPackID, initializeAccount); err != nil { + return fmt.Errorf("configure account starting chapter: %w", err) + } if err := login.AttachLastPlayedPack(worldService); err != nil { return fmt.Errorf("attach persisted login destination: %w", err) } @@ -580,6 +584,10 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("load master title: %w", err) } battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID) + battleService.AttachCurrentDifficulty(worldService.CurrentQuestDifficulty) + if err := worldService.AttachBattleActive(battleService.Active); err != nil { + return fmt.Errorf("attach world battle guard: %w", err) + } characters := worldService.CharacterService() recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion) if err != nil { diff --git a/go/internal/server/accountstate/validate.go b/go/internal/server/accountstate/validate.go index d6c982b..094a0d5 100644 --- a/go/internal/server/accountstate/validate.go +++ b/go/internal/server/accountstate/validate.go @@ -52,8 +52,9 @@ type indexedGrant struct { } type questKey struct { - pack uint32 - quest uint32 + difficulty uint32 + pack uint32 + quest uint32 } func validateSnapshot(snapshot validationSnapshot) []Problem { @@ -407,8 +408,9 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) { return nil, nil, err } var quests map[string]struct { - QuestID int `json:"QuestID"` - PackID int `json:"PackID"` + QuestID int `json:"QuestID"` + Difficulty int `json:"Difficulty"` + PackID int `json:"PackID"` } if json.Unmarshal(fields["quests"], &quests) != nil { return nil, nil, errors.New("accountstate: invalid progress.quests") @@ -416,7 +418,7 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) { questKeys := make([]questKey, 0, len(quests)) for key, value := range quests { parsed, parseErr := parseQuestKey(key) - if parseErr != nil || value.PackID != int(parsed.pack) || value.QuestID != int(parsed.quest) { + if parseErr != nil || value.PackID != int(parsed.pack) || value.QuestID != int(parsed.quest) || value.Difficulty != int(parsed.difficulty) { return nil, nil, fmt.Errorf("accountstate: invalid progress quest key %q", key) } questKeys = append(questKeys, parsed) @@ -440,16 +442,17 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) { } func parseQuestKey(value string) (questKey, error) { - left, right, found := strings.Cut(value, ":") - if !found { - return questKey{}, errors.New("missing separator") + parts := strings.Split(value, ":") + if len(parts) != 3 { + return questKey{}, errors.New("invalid quest identity") } - pack, packErr := strconv.ParseUint(left, 10, 32) - quest, questErr := strconv.ParseUint(right, 10, 32) - if packErr != nil || questErr != nil { + pack, e1 := strconv.ParseUint(parts[0], 10, 32) + level, e2 := strconv.ParseUint(parts[1], 10, 32) + quest, e3 := strconv.ParseUint(parts[2], 10, 32) + if e1 != nil || e2 != nil || e3 != nil || level > 4 { return questKey{}, errors.New("invalid integer") } - return questKey{pack: uint32(pack), quest: uint32(quest)}, nil + return questKey{pack: uint32(pack), difficulty: uint32(level), quest: uint32(quest)}, nil } func sortQuestKeys(keys []questKey) { @@ -457,6 +460,9 @@ func sortQuestKeys(keys []questKey) { if keys[i].pack != keys[j].pack { return keys[i].pack < keys[j].pack } + if keys[i].difficulty != keys[j].difficulty { + return keys[i].difficulty < keys[j].difficulty + } return keys[i].quest < keys[j].quest }) } diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index ccc7be5..7930a0b 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -16,18 +16,20 @@ import ( ) type Service struct { - mu sync.Mutex - states map[string]*battleState - activeSession string - gameDataRoot string - gameDataVersion string - inventory *player.Inventory - currentPack func() (int, error) - loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error) - loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) - buffs func() ([]gamedata.PictorialBuffStat, error) - onTutorialWin func() error - commitHealth func(map[uint64]uint64) error + mu sync.Mutex + states map[string]*battleState + activeSession string + gameDataRoot string + gameDataVersion string + inventory *player.Inventory + currentPack func() (int, error) + currentDifficulty func() (uint64, error) + loadDifficultyDeck func(string, string, int, uint64, uint64) (uint64, error) + loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error) + loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) + buffs func() ([]gamedata.PictorialBuffStat, error) + onTutorialWin func() error + commitHealth func(map[uint64]uint64) error } type battleState struct { @@ -95,6 +97,11 @@ func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventor } } +// AttachCurrentDifficulty selects the GameData quest deck for the active pack. +func (s *Service) AttachCurrentDifficulty(resolve func() (uint64, error)) { + s.currentDifficulty = resolve +} + func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) { s.buffs = buffs } @@ -177,6 +184,20 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("battle: current pack resolver is unavailable") } monster, _, _ := wire.Varint(request, 3) + if mode == 1 && s.currentDifficulty != nil { + difficulty, resolveErr := s.currentDifficulty() + if resolveErr != nil { + return 0, nil, true, fmt.Errorf("battle: resolve difficulty: %w", resolveErr) + } + loader := s.loadDifficultyDeck + if loader == nil { + loader = gamedata.BattleDeckForDifficulty + } + deck, err = loader(s.gameDataRoot, s.gameDataVersion, packID, deck, difficulty) + if err != nil { + return 0, nil, true, fmt.Errorf("battle: select difficulty deck: %w", err) + } + } var phases []gamedata.BattlePhase if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) { loader := s.loadPhases diff --git a/go/internal/server/battle/battle_test.go b/go/internal/server/battle/battle_test.go index e6c6833..6b3bd04 100644 --- a/go/internal/server/battle/battle_test.go +++ b/go/internal/server/battle/battle_test.go @@ -12,6 +12,44 @@ import ( func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) } +func TestQuestBattleSelectsAndRetainsDifficultyDeck(t *testing.T) { + s := NewService("design", "version", nil, func() (int, error) { return 1, nil }) + s.AttachCurrentDifficulty(func() (uint64, error) { return 2, nil }) + s.loadDifficultyDeck = func(_, _ string, pack int, deck, difficulty uint64) (uint64, error) { + if pack != 1 || deck != 1 || difficulty != 2 { + t.Fatalf("selection %d/%d/%d", pack, deck, difficulty) + } + return 200001, nil + } + s.loadPhases = func(_, _ string, pack int, monster, deck uint64) ([]gamedata.BattlePhase, error) { + if pack != 1 || deck != 200001 { + t.Fatalf("phase selection %d/%d", pack, deck) + } + return nil, nil + } + enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 1), 5, 1) + _, response, _, err := s.Handle("/BattleEnter", enter) + if err != nil { + t.Fatal(err) + } + deck, _, _ := wire.Varint(response, 2) + if deck != 200001 { + t.Fatalf("deck %d", deck) + } + start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 200001), 5, wire.AppendVarint(nil, 2, 101)) + if _, _, _, err := s.Handle("/BattleStart", start); err != nil { + t.Fatal(err) + } + _, response, _, err = s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 200001)) + if err != nil { + t.Fatal(err) + } + deck, _, _ = wire.Varint(response, 3) + if deck != 200001 { + t.Fatalf("retry deck %d", deck) + } +} + func TestLocalBattleLifecycle(t *testing.T) { s := &Service{} enter := request(1) diff --git a/go/internal/server/deck/story_party.go b/go/internal/server/deck/story_party.go new file mode 100644 index 0000000..1e31015 --- /dev/null +++ b/go/internal/server/deck/story_party.go @@ -0,0 +1,26 @@ +package deck + +func (s *Store) CurrentFieldDeck() []FieldEntry { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]FieldEntry(nil), s.state.FieldDeck...) +} + +// SetStoryParty stores the authored active quest formation using the same +// character ownership checks as DeckSave. Empty formation keeps player choice. +func (s *Store) SetStoryParty(entries []DeckEntry) error { + if len(entries) == 0 { + return nil + } + if err := validDeck(entries); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.validateOwnedDeckLocked(entries); err != nil { + return err + } + next := clone(s.state) + next.Deck = append([]DeckEntry(nil), entries...) + return s.commit(next) +} diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go index a416e7e..6c97d63 100644 --- a/go/internal/server/gameconfig/config.go +++ b/go/internal/server/gameconfig/config.go @@ -16,13 +16,20 @@ const FileName = "game.json" type Config struct { SchemaVersion int `json:"schema_version"` Gacha GachaConfig `json:"gacha"` + Story StoryConfig `json:"story"` +} + +// StartPackID selects the entry chapter for newly initialized accounts. +// Existing accounts retain their persisted entry and selected chapter. +type StoryConfig struct { + StartPackID int `json:"start_pack_id"` } type GachaConfig struct { IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"` } -func Default() Config { return Config{SchemaVersion: 1} } +func Default() Config { return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}} } // Load reads one explicit path. Older installations without the file retain // the default rules; an existing invalid file must never silently fall back. @@ -34,7 +41,9 @@ func Load(path string) (Config, error) { if err != nil { return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err) } - var cfg Config + cfg := Default() + // The format version is required even when gameplay sections are omitted. + cfg.SchemaVersion = 0 decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&cfg); err != nil { @@ -83,6 +92,9 @@ func (c Config) Validate() error { if c.SchemaVersion != 1 { return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion) } + if c.Story.StartPackID != 1 && c.Story.StartPackID != 21 { + return fmt.Errorf("story.start_pack_id must be 1 or 21, got %d", c.Story.StartPackID) + } return nil } diff --git a/go/internal/server/gameconfig/config_test.go b/go/internal/server/gameconfig/config_test.go index afdd963..1400b0e 100644 --- a/go/internal/server/gameconfig/config_test.go +++ b/go/internal/server/gameconfig/config_test.go @@ -44,6 +44,36 @@ func TestLoad(t *testing.T) { } } +func TestStartingChapterConfiguration(t *testing.T) { + for _, tc := range []struct { + text string + want int + }{ + {`{"schema_version":1}`, 21}, + {`{"schema_version":1,"story":{}}`, 21}, + {`{"schema_version":1,"story":{"start_pack_id":1}}`, 1}, + {`{"schema_version":1,"story":{"start_pack_id":21}}`, 21}, + {`{"schema_version":1,"story":{"start_pack_id":0}}`, 0}, + {`{"schema_version":1,"story":{"start_pack_id":22}}`, 0}, + {`{"schema_version":1,"story":{"start_pack_id":"1"}}`, 0}, + {`{"schema_version":1,"story":{"start_pack_id":null}}`, 0}, + {`{"schema_version":1,"story":null}`, 0}, + } { + path := filepath.Join(t.TempDir(), FileName) + if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if tc.want == 0 { + if err == nil { + t.Fatalf("accepted invalid entry: %s", tc.text) + } + } else if err != nil || cfg.Story.StartPackID != tc.want { + t.Fatalf("configuration %s = %+v, %v", tc.text, cfg, err) + } + } +} + func TestMissingFileDefaultsWithoutCreating(t *testing.T) { path := filepath.Join(t.TempDir(), FileName) cfg, err := Load(path) diff --git a/go/internal/server/gamedata/battle_difficulty.go b/go/internal/server/gamedata/battle_difficulty.go new file mode 100644 index 0000000..1e3c027 --- /dev/null +++ b/go/internal/server/gamedata/battle_difficulty.go @@ -0,0 +1,79 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" +) + +// BattleDeckForDifficulty mirrors QuicklyQuestHelper.GetBattleDeckIdByDifficulty +// for the three story difficulties, and verifies the resulting design row. +// Already-selected difficulty decks retain their ID. +func BattleDeckForDifficulty(root, version string, packID int, deckID, difficulty uint64) (uint64, error) { + db, cleanup, err := openPackDatabase(root, version, packID) + if err != nil { + return 0, err + } + defer cleanup() + return battleDeckForDifficultyFromDB(db, deckID, difficulty) +} + +func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error) { + if packID <= 0 { + return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID) + } + plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + if err != nil { + return nil, nil, err + } + dir, err := os.MkdirTemp("", "bd2-difficulty-") + if err != nil { + return nil, nil, err + } + path := filepath.Join(dir, "pack.db") + if err := os.WriteFile(path, plain, 0600); err != nil { + os.RemoveAll(dir) + return nil, nil, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + os.RemoveAll(dir) + return nil, nil, err + } + return db, func() { db.Close(); os.RemoveAll(dir) }, nil +} + +func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) { + if deckID == 0 || deckID > 2147483647 || difficulty > 2 { + return 0, fmt.Errorf("gamedata: unsupported battle deck/difficulty %d/%d", deckID, difficulty) + } + var data []byte + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil { + return 0, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err) + } + actual, err := phaseScalar(data, 24) + if err != nil { + return 0, err + } + if actual == difficulty { + return deckID, nil + } + // Only an ordinary deck can be promoted. Changing a previously selected + // difficulty deck would conceal stale client quest state. + if actual != 0 || difficulty == 0 { + return 0, fmt.Errorf("gamedata: deck %d difficulty %d differs from selected %d", deckID, actual, difficulty) + } + selected := deckID + difficulty*100000 + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", selected).Scan(&data); err != nil { + return 0, fmt.Errorf("gamedata: difficulty deck %d: %w", selected, err) + } + actual, err = phaseScalar(data, 24) + if err != nil { + return 0, err + } + if actual != difficulty { + return 0, fmt.Errorf("gamedata: difficulty deck %d has difficulty %d, want %d", selected, actual, difficulty) + } + return selected, nil +} diff --git a/go/internal/server/gamedata/battle_difficulty_test.go b/go/internal/server/gamedata/battle_difficulty_test.go new file mode 100644 index 0000000..0625ab0 --- /dev/null +++ b/go/internal/server/gamedata/battle_difficulty_test.go @@ -0,0 +1,53 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "os" + "testing" +) + +func TestBattleDifficultyUsesExistingDesignRows(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec("CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + for _, row := range []struct{ id, d uint64 }{{1, 0}, {100001, 1}, {200001, 2}, {7, 0}, {100007, 2}} { + if _, err := db.Exec("INSERT INTO BattleDeckTable VALUES(?,?)", row.id, wire.AppendVarint(nil, 24, row.d)); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct{ id, d, w uint64 }{{1, 0, 1}, {1, 1, 100001}, {1, 2, 200001}, {100001, 1, 100001}} { + got, err := battleDeckForDifficultyFromDB(db, c.id, c.d) + if err != nil || got != c.w { + t.Fatalf("%+v got %d: %v", c, got, err) + } + } + for _, c := range []struct{ id, d uint64 }{{1, 3}, {7, 1}, {7, 2}, {100001, 2}, {100001, 0}} { + if _, err := battleDeckForDifficultyFromDB(db, c.id, c.d); err == nil { + t.Fatalf("accepted %+v", c) + } + } +} + +func TestInstalledStoryBattleDifficultyDecks(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + // Python read-only inspection of current BattleDeckTable confirmed these + // independently authored rows and their field 24 difficulty IDs. + for _, pack := range []int{1, 21} { + for difficulty := uint64(0); difficulty <= 2; difficulty++ { + want := uint64(1) + difficulty*100000 + got, err := BattleDeckForDifficulty(root, "20260923193640", pack, 1, difficulty) + if err != nil || got != want { + t.Fatalf("pack%d difficulty%d got %d: %v", pack, difficulty, got, err) + } + } + } +} diff --git a/go/internal/server/gamedata/battle_phase.go b/go/internal/server/gamedata/battle_phase.go index 5235c6d..1e6441c 100644 --- a/go/internal/server/gamedata/battle_phase.go +++ b/go/internal/server/gamedata/battle_phase.go @@ -89,6 +89,29 @@ func battleDeckPhasesFromDB(db *sql.DB, monsterID, deckID uint64) ([]BattlePhase if err := rows.Err(); err != nil { return nil, err } + if len(phases) != 0 && !selected { + // Phase rows name the normal decks. Hard story battles use the + // independently authored difficulty decks for every phase. + var data []byte + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil { + return nil, fmt.Errorf("gamedata: selected phase deck %d: %w", deckID, err) + } + difficulty, err := phaseScalar(data, 24) + if err != nil { + return nil, err + } + if difficulty == 0 { + return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID) + } + for i := range phases { + mapped, err := battleDeckForDifficultyFromDB(db, phases[i].DeckID, difficulty) + if err != nil { + return nil, err + } + phases[i].DeckID = mapped + selected = selected || mapped == deckID + } + } if len(phases) == 0 || !selected { return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID) } diff --git a/go/internal/server/gamedata/battle_phase_test.go b/go/internal/server/gamedata/battle_phase_test.go index 22c1709..6882142 100644 --- a/go/internal/server/gamedata/battle_phase_test.go +++ b/go/internal/server/gamedata/battle_phase_test.go @@ -1,6 +1,7 @@ package gamedata import ( + "bd2server/internal/server/wire" "database/sql" "reflect" "testing" @@ -57,6 +58,36 @@ func TestBattleDeckPhasesDesignRows(t *testing.T) { } } } + +func TestBattleDeckPhasesRetainSelectedDifficulty(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { + if _, err := db.Exec(q); err != nil { + t.Fatal(err) + } + } + db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(nil, 21, 1)) + for _, v := range []struct{ id, deck uint64 }{{1, 8}, {2, 9}} { + data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, v.deck), 3, 1), 4, v.id) + if _, err := db.Exec("INSERT INTO PhaseBattleTable VALUES(1,?,?)", v.id, data); err != nil { + t.Fatal(err) + } + for d := uint64(0); d <= 2; d++ { + if _, err := db.Exec("INSERT INTO BattleDeckTable VALUES(?,?)", v.deck+d*100000, wire.AppendVarint(nil, 24, d)); err != nil { + t.Fatal(err) + } + } + } + got, err := battleDeckPhasesFromDB(db, 8, 200008) + want := []BattlePhase{{1, 1, 200008}, {1, 2, 200009}} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("got %v error %v", got, err) + } +} func TestPhaseScalarRejectsInvalidDesign(t *testing.T) { for _, b := range [][]byte{{0x1a, 1, 1}, {0x18, 1, 0x18, 2}} { if _, e := phaseScalar(b, 3); e == nil { diff --git a/go/internal/server/gamedata/quest_difficulty.go b/go/internal/server/gamedata/quest_difficulty.go new file mode 100644 index 0000000..d19931a --- /dev/null +++ b/go/internal/server/gamedata/quest_difficulty.go @@ -0,0 +1,58 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" +) + +// LoadQuestDifficulties reads the pack-specific selectable difficulties; +// normal (zero) is the base story chain and is absent from this table. +func LoadQuestDifficulties(root, version string) (map[int]map[int]bool, error) { + plain, err := ReadQuestDatabase(root, version) + if err != nil { + return nil, err + } + file, err := os.CreateTemp("", "bd2-quest-difficulty-*.sqlite") + if err != nil { + return nil, err + } + name := file.Name() + defer os.Remove(name) + if _, err = file.Write(plain); err != nil { + file.Close() + return nil, err + } + if err = file.Close(); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro") + if err != nil { + return nil, err + } + defer db.Close() + return loadQuestDifficultiesDB(db) +} +func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) { + rows, err := db.Query("SELECT groupId,id FROM QuestDifficultyTable ORDER BY groupId,id") + if err != nil { + return nil, err + } + defer rows.Close() + result := map[int]map[int]bool{} + for rows.Next() { + var pack, level int + if err := rows.Scan(&pack, &level); err != nil { + return nil, err + } + if pack <= 0 || level < 1 || level > 4 { + return nil, fmt.Errorf("gamedata: invalid difficulty pack%d level%d", pack, level) + } + if result[pack] == nil { + result[pack] = map[int]bool{0: true} + } + result[pack][level] = true + } + return result, rows.Err() +} diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go index 4cb940d..d2e2ab7 100644 --- a/go/internal/server/gamedata/quests.go +++ b/go/internal/server/gamedata/quests.go @@ -13,11 +13,12 @@ import ( // Reward slot zero is normal story difficulty; later slots are separate // difficulty tiers and must never be added to the same clear. type QuestDesign struct { - ID int - Type int - PriorQuestID int - NextQuestID int - Rewards [5][]Reward + ID int + Type int + PriorQuestID int + NextQuestID int + GiveQuestItemIDs []uint64 + Rewards [5][]Reward } func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, error) { @@ -65,6 +66,10 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { continue } entry := QuestDesign{ID: id} + entry.GiveQuestItemIDs, err = packedInts(proto, 29) + if err != nil { + return nil, fmt.Errorf("gamedata: quest%d give items: %w", id, err) + } for field, dst := range map[int]*int{63: &entry.Type, 37: &entry.PriorQuestID, 35: &entry.NextQuestID} { values, err := packedInts(proto, field) if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > uint64(^uint32(0)>>1)) { diff --git a/go/internal/server/gamedata/story_characters.go b/go/internal/server/gamedata/story_characters.go new file mode 100644 index 0000000..635f24e --- /dev/null +++ b/go/internal/server/gamedata/story_characters.go @@ -0,0 +1,101 @@ +package gamedata + +import "fmt" + +type StoryCharacterDesign struct { + CharacterID, UniqueCharacterID, Level, CostumeID, HP, Order uint64 + TemporaryPack uint64 +} + +// StoryCharacterCatalog contains only authored temporary character rows. +type StoryCharacterCatalog struct { + formations map[int]map[int]QuestFormation + characters map[[2]int][]StoryCharacterDesign +} + +func LoadStoryCharacterCatalog(root, version string, packs []int) (*StoryCharacterCatalog, error) { + db, cleanup, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer cleanup() + stats, err := loadCharacterStatDesign(db) + if err != nil { + return nil, err + } + catalog := &StoryCharacterCatalog{formations: map[int]map[int]QuestFormation{}, characters: map[[2]int][]StoryCharacterDesign{}} + instances := map[[3]uint64]uint64{} + for _, pack := range packs { + formations, err := loadQuestFormationsDB(db, pack) + if err != nil { + return nil, err + } + catalog.formations[pack] = formations + for quest, formation := range formations { + for _, row := range formation.Characters { + if row.Banned { + continue + } + var data []byte + if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", row.CharacterID).Scan(&data); err != nil { + return nil, fmt.Errorf("gamedata: story character %d: %w", row.CharacterID, err) + } + unique, err := requiredScalar(data, 20) + if err != nil { + return nil, err + } + temporaryPack, err := optionalScalar(data, 21) + if err != nil { + return nil, err + } + characterType, err := optionalScalar(data, 19) + if err != nil { + return nil, err + } + if characterType != 1 || temporaryPack == 0 { + continue + } + costume, err := requiredScalar(data, 5) + if err != nil { + return nil, err + } + for _, story := range formation.StoryCostumes { + if story.UniqueCharacterID == unique && story.CostumeID != 996000 { + costume = story.CostumeID + break + } + } + base, err := stats.BaseStats(row.CharacterID, row.Level) + if err != nil { + return nil, err + } + if base.Health <= 0 { + return nil, fmt.Errorf("gamedata: story character %d has invalid health", row.CharacterID) + } + instance := [3]uint64{uint64(pack), row.CharacterID, row.Level} + if previous, exists := instances[instance]; exists && previous != costume { + return nil, fmt.Errorf("gamedata: story instance pack%d character%d level%d has conflicting costumes %d/%d", pack, row.CharacterID, row.Level, previous, costume) + } + instances[instance] = costume + key := [2]int{pack, quest} + catalog.characters[key] = append(catalog.characters[key], StoryCharacterDesign{row.CharacterID, unique, row.Level, costume, uint64(base.Health), row.Order, temporaryPack}) + } + } + } + return catalog, nil +} + +func (c *StoryCharacterCatalog) Formation(packID, questID int) (QuestFormation, bool) { + if c == nil { + return QuestFormation{}, false + } + f, ok := c.formations[packID][questID] + return f, ok +} + +func (c *StoryCharacterCatalog) Characters(packID, questID int) ([]StoryCharacterDesign, error) { + if _, ok := c.Formation(packID, questID); !ok { + return nil, fmt.Errorf("gamedata: missing story formation pack%d quest%d", packID, questID) + } + return append([]StoryCharacterDesign(nil), c.characters[[2]int{packID, questID}]...), nil +} diff --git a/go/internal/server/gamedata/story_characters_test.go b/go/internal/server/gamedata/story_characters_test.go new file mode 100644 index 0000000..9eeeb9a --- /dev/null +++ b/go/internal/server/gamedata/story_characters_test.go @@ -0,0 +1,65 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledStoryCharacterCatalogUsesAuthoredRows(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + catalog, err := LoadStoryCharacterCatalog(root, "20260923193640", []int{1, 21}) + if err != nil { + t.Fatal(err) + } + chars, err := catalog.Characters(1, 1) + if err != nil { + t.Fatal(err) + } + if len(chars) != 0 { + t.Fatalf("pack1 quest1 fabricated temporary characters: %v", chars) + } + chars, err = catalog.Characters(21, 29) + if err != nil { + t.Fatal(err) + } + if len(chars) == 0 { + t.Fatal("pack21 quest29 omitted authored characters") + } + for _, c := range chars { + if c.CharacterID == 0 || c.Level == 0 || c.HP == 0 || c.CostumeID == 996000 { + t.Fatalf("invalid story character %v", c) + } + } +} + +func TestInstalledAllStoryCharacterDesigns(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + story, err := LoadStoryCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + var packs []int + for id := range story.Packs { + packs = append(packs, id) + } + catalog, err := LoadStoryCharacterCatalog(root, "20260923193640", packs) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, formations := range catalog.formations { + for _, f := range formations { + count += len(f.Characters) + } + } + if count == 0 { + t.Fatal("all story catalog omitted character rows") + } + t.Logf("validated %d packs and %d authored character rows", len(packs), count) +} diff --git a/go/internal/server/player/story_characters.go b/go/internal/server/player/story_characters.go new file mode 100644 index 0000000..b8eba35 --- /dev/null +++ b/go/internal/server/player/story_characters.go @@ -0,0 +1,39 @@ +package player + +import "fmt" + +// StoryCharacterIndexBase reserves local instances for temporary quest parties. +// These are not collection rewards and never receive a collection grant. +const StoryCharacterIndexBase uint64 = 1 << 60 + +func IsStoryCharacter(c Character) bool { return c.InvenIndex >= StoryCharacterIndexBase } + +// EnsureStoryCharacters saves new authored temporary characters atomically. +// Existing instances retain their completed-battle health across reconnects. +func (s *CharacterStore) EnsureStoryCharacters(characters []Character) error { + s.mu.Lock() + defer s.mu.Unlock() + next := append([]Character(nil), s.characters...) + seen := map[uint64]Character{} + for _, c := range next { + seen[c.InvenIndex] = c + } + for _, c := range characters { + if !IsStoryCharacter(c) || c.ID == 0 || c.Level == 0 || c.CostumeID == 0 { + return fmt.Errorf("player: invalid temporary story character") + } + if old, ok := seen[c.InvenIndex]; ok { + if old.ID != c.ID || old.Level != c.Level || old.CostumeID != c.CostumeID { + return fmt.Errorf("player: story instance %d design collision", c.InvenIndex) + } + continue + } + next = append(next, c) + seen[c.InvenIndex] = c + } + if err := s.persist(next); err != nil { + return err + } + s.characters = next + return nil +} diff --git a/go/internal/server/player/story_characters_test.go b/go/internal/server/player/story_characters_test.go new file mode 100644 index 0000000..3b0361f --- /dev/null +++ b/go/internal/server/player/story_characters_test.go @@ -0,0 +1,45 @@ +package player + +import ( + "bd2server/internal/server/stateio" + "testing" +) + +func TestEnsureStoryCharactersPersistsWithoutCollectionGrant(t *testing.T) { + storage := stateio.NewMemory() + inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + owned := Character{InvenIndex: 3, ID: 30, Level: 1} + store, err := OpenCharacterStore(storage, []Character{owned}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + story := Character{InvenIndex: StoryCharacterIndexBase + 1, ID: 10383, HP: 777, Level: 15, CostumeID: 3801, UseCostume: 2} + if err := store.EnsureStoryCharacters([]Character{story}); err != nil { + t.Fatal(err) + } + if err := store.SetCurrentHealth(story.InvenIndex, 7); err != nil { + t.Fatal(err) + } + if err := store.EnsureStoryCharacters([]Character{story}); err != nil { + t.Fatal(err) + } + loaded, err := OpenCharacterStore(storage, []Character{owned}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + current, ok := loaded.Find(story.InvenIndex) + if !ok || current.HP != 7 || current.Level != 15 { + t.Fatalf("reloaded story %+v/%v", current, ok) + } + collision := story + collision.ID++ + if err := loaded.EnsureStoryCharacters([]Character{collision}); err == nil { + t.Fatal("accepted conflicting story instance") + } + if err := loaded.EnsureStoryCharacters([]Character{owned}); err == nil { + t.Fatal("accepted owned instance as story") + } +} diff --git a/go/internal/server/progress/persistence_test.go b/go/internal/server/progress/persistence_test.go index 1f80316..02c7a46 100644 --- a/go/internal/server/progress/persistence_test.go +++ b/go/internal/server/progress/persistence_test.go @@ -71,7 +71,7 @@ func TestLastPlayedPackTracksPersistedPositionAcrossRestart(t *testing.T) { func TestStoreKeepsPackOverlap(t *testing.T) { storage := stateio.NewMemory() - initial := `{"version":2,"position":{"PackID":0,"Position":{"MapId":0,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null},"RawJSON":""},"tutorials":[],"quests":{"21:1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"21:1":true}}` + initial := `{"version":3,"selections":{},"start_pack_id":21,"active_pack_id":21,"position":{"PackID":0,"Position":{"MapId":0,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null},"RawJSON":""},"tutorials":[],"quests":{"21:0:1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"21:0:1":true}}` if err := storage.Save("progress", []byte(initial)); err != nil { t.Fatal(err) } @@ -115,14 +115,14 @@ func TestStoreKeepsPackOverlap(t *testing.T) { t.Fatalf("saved version=%d err=%v", version, err) } var cleared map[string]bool - if err := json.Unmarshal(saved["cleared_quests"], &cleared); err != nil || !cleared["21:1"] || !cleared["22:1"] { + if err := json.Unmarshal(saved["cleared_quests"], &cleared); err != nil || !cleared["21:0:1"] || !cleared["22:0:1"] { t.Fatalf("saved clears=%v err=%v", cleared, err) } } func TestStoreRejectsBrokenSave(t *testing.T) { storage := stateio.NewMemory() - if err := storage.Save("progress", []byte(`{"version":2,"quests":{"12":{"QuestID":5,"PackID":21}}}`)); err != nil { + if err := storage.Save("progress", []byte(`{"version":3,"selections":{},"start_pack_id":21,"active_pack_id":21,"quests":{"12":{"QuestID":5,"PackID":21}}}`)); err != nil { t.Fatal(err) } if _, err := OpenStore(storage); err == nil { @@ -137,3 +137,36 @@ func TestStoreRejectsLegacySave(t *testing.T) { t.Fatal("accepted unversioned legacy state") } } + +func TestDifficultyPositionAndActivePackPersistIndependently(t *testing.T) { + storage := stateio.NewMemory() + s, err := OpenStore(storage) + if err != nil { + t.Fatal(err) + } + if err := s.SetStartPack(1); err != nil { + t.Fatal(err) + } + if err := s.SetActivePackID(21); err != nil { + t.Fatal(err) + } + if err := s.SelectQuest(21, QuestSelection{QuestID: 4, Difficulty: 1}); err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(nil, 2, 21) + request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3}}`) + if err := s.SaveUserPosition(request); err != nil { + t.Fatal(err) + } + if err := s.SetActivePackID(22); err != nil { + t.Fatal(err) + } + s, err = OpenStore(storage) + if err != nil { + t.Fatal(err) + } + saved, _ := s.Position() + if saved.Difficulty != 1 || saved.PackID != 21 || s.ActivePackID() != 22 || s.StartPackID() != 1 { + t.Fatalf("state lost %+v", saved) + } +} diff --git a/go/internal/server/progress/progress.go b/go/internal/server/progress/progress.go index ec3464d..ee07333 100644 --- a/go/internal/server/progress/progress.go +++ b/go/internal/server/progress/progress.go @@ -35,52 +35,72 @@ type Position struct { } type SavedPosition struct { - PackID int - Position Position - RawJSON string + Difficulty int + PackID int + Position Position + RawJSON string } type QuestProgress struct { - QuestID int - PackID int - Values []int + QuestID int + Difficulty int + PackID int + Values []int +} + +type QuestSelection struct { + QuestID int + Difficulty int + Option int } type Store struct { - mu sync.RWMutex - storage stateio.Store - position SavedPosition - tutorials map[int]struct{} - quests map[string]QuestProgress - cleared map[string]struct{} + activePackID int + startingPackID int + selections map[string]QuestSelection + mu sync.RWMutex + storage stateio.Store + position SavedPosition + tutorials map[int]struct{} + quests map[string]QuestProgress + cleared map[string]struct{} } func NewStore() *Store { - return &Store{tutorials: make(map[int]struct{}), quests: make(map[string]QuestProgress), cleared: make(map[string]struct{})} + return &Store{selections: make(map[string]QuestSelection), tutorials: make(map[int]struct{}), quests: make(map[string]QuestProgress), cleared: make(map[string]struct{})} } type snapshot struct { - Version int `json:"version"` - Position SavedPosition `json:"position"` - Tutorials []int `json:"tutorials"` - Quests map[string]QuestProgress `json:"quests"` - Cleared map[string]json.RawMessage `json:"cleared_quests"` + ActivePackID int `json:"active_pack_id"` + StartPackID int `json:"start_pack_id"` + Version int `json:"version"` + Selections map[string]QuestSelection `json:"selections"` + Position SavedPosition `json:"position"` + Tutorials []int `json:"tutorials"` + Quests map[string]QuestProgress `json:"quests"` + Cleared map[string]json.RawMessage `json:"cleared_quests"` } -const snapshotVersion = 2 +const snapshotVersion = 3 -func questKey(packID, questID int) string { - return strconv.Itoa(packID) + ":" + strconv.Itoa(questID) -} - -func parseQuestKey(key string) (int, int, bool) { - left, right, found := strings.Cut(key, ":") - if !found { - return 0, 0, false +func questDifficulty(level []int) int { + if len(level) > 0 { + return level[0] } - packID, packErr := strconv.Atoi(left) - questID, questErr := strconv.Atoi(right) - return packID, questID, packErr == nil && questErr == nil && packID > 0 && questID > 0 + return 0 +} +func questKey(packID, questID int, level ...int) string { + return strconv.Itoa(packID) + ":" + strconv.Itoa(questDifficulty(level)) + ":" + strconv.Itoa(questID) +} +func parseQuestKey(key string) (int, int, int, bool) { + parts := strings.Split(key, ":") + if len(parts) != 3 { + return 0, 0, 0, false + } + pack, e1 := strconv.Atoi(parts[0]) + level, e2 := strconv.Atoi(parts[1]) + quest, e3 := strconv.Atoi(parts[2]) + return pack, level, quest, e1 == nil && e2 == nil && e3 == nil && pack > 0 && level >= 0 && level <= 4 && quest > 0 } // OpenStore recovers player progress from its domain snapshot. @@ -97,7 +117,7 @@ func OpenStore(storage stateio.Store) (*Store, error) { if data == nil { return s, nil } - if err := stateio.RequireExactJSONObject(data, "version", "position", "tutorials", "quests", "cleared_quests"); err != nil { + if err := stateio.RequireExactJSONObject(data, "version", "position", "tutorials", "quests", "cleared_quests", "selections", "start_pack_id", "active_pack_id"); err != nil { return nil, fmt.Errorf("progress: incompatible save layout: %w", err) } var state snapshot @@ -107,7 +127,25 @@ func OpenStore(storage stateio.Store) (*Store, error) { if state.Version != snapshotVersion { return nil, fmt.Errorf("progress: unsupported save version %d", state.Version) } + s.activePackID = state.ActivePackID + if s.activePackID < 0 { + return nil, ErrInvalidQuest + } + s.startingPackID = state.StartPackID + if s.startingPackID < 0 { + return nil, ErrInvalidQuest + } + if state.Position.Difficulty < 0 || state.Position.Difficulty > 4 { + return nil, ErrInvalidPosition + } s.position = state.Position + for key, selection := range state.Selections { + pack, err := strconv.Atoi(key) + if err != nil || pack <= 0 || selection.QuestID < 0 || selection.Difficulty < 0 || selection.Difficulty > 4 || selection.Option < 0 { + return nil, ErrInvalidQuest + } + s.selections[key] = selection + } for _, id := range state.Tutorials { if id <= 0 { return nil, errors.New("progress: invalid saved tutorial") @@ -118,19 +156,19 @@ func OpenStore(storage stateio.Store) (*Store, error) { if quest.QuestID <= 0 || quest.PackID <= 0 { return nil, errors.New("progress: invalid saved quest") } - packID, questID, ok := parseQuestKey(key) - if !ok || packID != quest.PackID || questID != quest.QuestID { + packID, difficulty, questID, ok := parseQuestKey(key) + if !ok || packID != quest.PackID || questID != quest.QuestID || difficulty != quest.Difficulty { return nil, errors.New("progress: invalid saved quest key") } - s.quests[questKey(quest.PackID, quest.QuestID)] = quest + s.quests[questKey(quest.PackID, quest.QuestID, quest.Difficulty)] = quest } for key, raw := range state.Cleared { - packID, questID, ok := parseQuestKey(key) + packID, difficulty, questID, ok := parseQuestKey(key) var cleared bool if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared { return nil, errors.New("progress: invalid cleared quest key") } - s.cleared[questKey(packID, questID)] = struct{}{} + s.cleared[questKey(packID, questID, difficulty)] = struct{}{} } return s, nil } @@ -152,7 +190,7 @@ func (s *Store) EnsurePersisted() error { // holds mu; failure leaves the in-memory player state unchanged. func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error { if s.storage != nil { - state := snapshot{Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))} + state := snapshot{ActivePackID: s.activePackID, StartPackID: s.startingPackID, Selections: s.selections, Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))} for id := range tutorials { state.Tutorials = append(state.Tutorials, id) } @@ -220,7 +258,12 @@ func (s *Store) UpdateQuest(request []byte) (int, error) { for key, current := range s.quests { quests[key] = current } - quests[questKey(progress.PackID, progress.QuestID)] = progress + selection := s.selections[strconv.Itoa(progress.PackID)] + progress.Difficulty = selection.Difficulty + if _, acceptedNormal := s.quests[questKey(progress.PackID, progress.QuestID)]; acceptedNormal && selection.QuestID != progress.QuestID { + progress.Difficulty = 0 + } + quests[questKey(progress.PackID, progress.QuestID, progress.Difficulty)] = progress if err := s.commit(s.position, s.tutorials, quests, s.cleared); err != nil { return 0, err } @@ -245,7 +288,7 @@ func (s *Store) SaveUserPosition(request []byte) error { s.mu.Lock() defer s.mu.Unlock() - return s.commit(SavedPosition{PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared) + return s.commit(SavedPosition{Difficulty: s.selections[strconv.Itoa(int(packID))].Difficulty, PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared) } // ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id. @@ -345,23 +388,23 @@ func (s *Store) Quest(id int) (QuestProgress, bool) { return quest, found } -func (s *Store) QuestInPack(questID, packID int) (QuestProgress, bool) { +func (s *Store) QuestInPack(questID, packID int, level ...int) (QuestProgress, bool) { s.mu.RLock() defer s.mu.RUnlock() - quest, found := s.quests[questKey(packID, questID)] + quest, found := s.quests[questKey(packID, questID, level...)] quest.Values = append([]int(nil), quest.Values...) return quest, found } // ClearQuest atomically records a completed quest. Quest identity is the // (pack, quest) pair because every story pack starts numbering from one. -func (s *Store) ClearQuest(questID, packID int) error { - if questID <= 0 || packID <= 0 { +func (s *Store) ClearQuest(questID, packID int, level ...int) error { + if questID <= 0 || packID <= 0 || questDifficulty(level) < 0 || questDifficulty(level) > 4 { return ErrInvalidQuest } s.mu.Lock() defer s.mu.Unlock() - key := questKey(packID, questID) + key := questKey(packID, questID, level...) if _, ok := s.cleared[key]; ok { return nil } @@ -373,25 +416,126 @@ func (s *Store) ClearQuest(questID, packID int) error { return s.commit(s.position, s.tutorials, s.quests, cleared) } -func (s *Store) QuestCleared(questID, packID int) bool { +func (s *Store) QuestCleared(questID, packID int, level ...int) bool { s.mu.RLock() defer s.mu.RUnlock() - _, found := s.cleared[questKey(packID, questID)] + _, found := s.cleared[questKey(packID, questID, level...)] return found } // ClearedQuests returns sorted quest IDs for one pack. The detached slice is // safe for response construction without holding the store lock. -func (s *Store) ClearedQuests(packID int) []int { +func (s *Store) ClearedQuests(packID int, level ...int) []int { s.mu.RLock() defer s.mu.RUnlock() ids := make([]int, 0, len(s.cleared)) for key := range s.cleared { - currentPack, questID, ok := parseQuestKey(key) - if ok && currentPack == packID { + currentPack, difficulty, questID, ok := parseQuestKey(key) + if ok && currentPack == packID && difficulty == questDifficulty(level) { ids = append(ids, questID) } } sort.Ints(ids) return ids } + +// Selection returns the committed quest selection for a pack. +func (s *Store) Selection(packID int) (QuestSelection, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + value, ok := s.selections[strconv.Itoa(packID)] + return value, ok +} +func (s *Store) SelectQuest(packID int, selection QuestSelection) error { + if packID <= 0 || selection.QuestID < 0 || selection.Difficulty < 0 || selection.Difficulty > 4 || selection.Option < 0 { + return ErrInvalidQuest + } + s.mu.Lock() + defer s.mu.Unlock() + old := s.selections + next := make(map[string]QuestSelection, len(old)+1) + for key, value := range old { + next[key] = value + } + next[strconv.Itoa(packID)] = selection + s.selections = next + if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + s.selections = old + return err + } + return nil +} + +func (s *Store) StartPackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.startingPackID } +func (s *Store) SetStartPack(packID int) error { + if packID <= 0 { + return ErrInvalidQuest + } + s.mu.Lock() + defer s.mu.Unlock() + previous := s.startingPackID + s.startingPackID = packID + if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + s.startingPackID = previous + return err + } + return nil +} + +// AcceptQuest commits an explicit active quest without replacing main selection. +func (s *Store) AcceptQuest(questID, packID, difficulty int) error { + if questID <= 0 || packID <= 0 || difficulty < 0 || difficulty > 4 { + return ErrInvalidQuest + } + s.mu.Lock() + defer s.mu.Unlock() + quests := make(map[string]QuestProgress, len(s.quests)+1) + for key, value := range s.quests { + quests[key] = value + } + key := questKey(packID, questID, difficulty) + if _, exists := quests[key]; !exists { + quests[key] = QuestProgress{QuestID: questID, PackID: packID, Difficulty: difficulty} + } + return s.commit(s.position, s.tutorials, quests, s.cleared) +} +func (s *Store) RemoveQuest(questID, packID, difficulty int) error { + s.mu.Lock() + defer s.mu.Unlock() + quests := make(map[string]QuestProgress, len(s.quests)) + for key, value := range s.quests { + if key != questKey(packID, questID, difficulty) { + quests[key] = value + } + } + return s.commit(s.position, s.tutorials, quests, s.cleared) +} +func (s *Store) QuestsInPack(packID, difficulty int) []QuestProgress { + s.mu.RLock() + defer s.mu.RUnlock() + var out []QuestProgress + for _, quest := range s.quests { + if quest.PackID == packID && quest.Difficulty == difficulty { + quest.Values = append([]int(nil), quest.Values...) + out = append(out, quest) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].QuestID < out[j].QuestID }) + return out +} + +func (s *Store) ActivePackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.activePackID } +func (s *Store) SetActivePackID(packID int) error { + if packID <= 0 { + return ErrInvalidQuest + } + s.mu.Lock() + defer s.mu.Unlock() + old := s.activePackID + s.activePackID = packID + if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + s.activePackID = old + return err + } + return nil +} diff --git a/go/internal/server/world/field_pack.go b/go/internal/server/world/field_pack.go index c7e86e2..8d803eb 100644 --- a/go/internal/server/world/field_pack.go +++ b/go/internal/server/world/field_pack.go @@ -9,9 +9,16 @@ import ( // LastPlayedPackID prevents LoginUser from selecting a field that this server // cannot restore. A fresh account keeps the versioned login seed's pack. func (s *Service) LastPlayedPackID() (uint64, error) { - id, err := s.state.LastPlayedPackID() - if err != nil || id == 0 { - return id, err + id := uint64(s.state.ActivePackID()) + if id == 0 { + var err error + id, err = s.state.LastPlayedPackID() + if err != nil { + return id, err + } + } + if id == 0 { + id = uint64(s.startingPack()) } if !s.packUnlocked(int(id)) { return 0, errors.New("world: saved login pack is unavailable") diff --git a/go/internal/server/world/health_test.go b/go/internal/server/world/health_test.go index da47f55..0527b55 100644 --- a/go/internal/server/world/health_test.go +++ b/go/internal/server/world/health_test.go @@ -70,7 +70,7 @@ func TestTutorialCharInfoRestoresFieldHealthWithoutExposingStoryRoster(t *testin } } -func TestPackInfoStoryCharactersUsePersistedDeadAndInjuredHP(t *testing.T) { +func TestPackInfoWithoutFormationDoesNotEmitSeedStoryRoster(t *testing.T) { storage := stateio.NewMemory() starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) @@ -126,7 +126,7 @@ func TestPackInfoStoryCharactersUsePersistedDeadAndInjuredHP(t *testing.T) { }); err != nil { t.Fatal(err) } - if count != 2 { - t.Fatalf("pack story roster count=%d", count) + if count != 0 { + t.Fatalf("unconditional seed story roster count=%d", count) } } diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go index 3f2be07..0a1197f 100644 --- a/go/internal/server/world/pack_purchase.go +++ b/go/internal/server/world/pack_purchase.go @@ -13,7 +13,7 @@ func packPurchaseIdentity(id int) string { return fmt.Sprintf("pack-purchase:%d" // awards the bootstrap pack's real purchase rewards, not a recovery inference // from a saved position or from the former seed-only unlock chain. func (s *Service) EnsureInitialPackPurchase() error { - _, err := s.purchaseStoryPack(s.seed.PackID, true) + _, err := s.purchaseStoryPack(s.startingPack(), true) return err } diff --git a/go/internal/server/world/quest_selection.go b/go/internal/server/world/quest_selection.go new file mode 100644 index 0000000..0b74aec --- /dev/null +++ b/go/internal/server/world/quest_selection.go @@ -0,0 +1,260 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/wire" + "encoding/binary" + "fmt" +) + +func (s *Service) questDifficultyFor(packID, questID int) int { + if s.storyCatalog != nil && s.storyCatalog.Packs[packID].Quests[questID].Type != 0 { + return 0 + } + return s.questDifficulty(packID) +} +func (s *Service) questDifficulty(packID int) int { + selection, _ := s.state.Selection(packID) + return selection.Difficulty +} +func (s *Service) CurrentQuestDifficulty() (uint64, error) { + pack, err := s.CurrentPackID() + if err != nil { + return 0, err + } + return uint64(s.questDifficulty(pack)), nil +} +func questRewardIdentity(packID, questID, difficulty int) string { + if difficulty == 0 { + return fmt.Sprintf("pack%d:quest%d", packID, questID) + } + return fmt.Sprintf("pack%d:difficulty%d:quest%d", packID, difficulty, questID) +} +func (s *Service) questInfoWire(packID, questID int) []byte { + out := wire.AppendVarint(nil, 1, uint64(questID)) + selection, _ := s.state.Selection(packID) + if s.storyCatalog != nil && s.storyCatalog.Packs[packID].Quests[questID].Type != 0 { + selection = progress.QuestSelection{} + } + if current, ok := s.state.QuestInPack(questID, packID, selection.Difficulty); ok { + for _, value := range current.Values { + out = wire.AppendVarint(out, 3, uint64(value)) + } + } + if selection.Difficulty != 0 { + out = wire.AppendVarint(out, 4, uint64(selection.Difficulty)) + } + if selection.Option != 0 { + out = wire.AppendVarint(out, 5, uint64(selection.Option)) + } + return wire.AppendVarint(out, 6, uint64(packID)) +} +func (s *Service) questLevelInfoWire(packID, difficulty int) []byte { + out := wire.AppendVarint(nil, 1, uint64(packID)) + out = wire.AppendVarint(out, 2, uint64(difficulty)) + last := 0 + complete := true + pack := s.storyCatalog.Packs[packID] + for _, id := range pack.MainQuestIDs { + if !s.state.QuestCleared(id, packID, difficulty) { + complete = false + break + } + last = id + } + out = wire.AppendVarint(out, 3, uint64(last)) + selection, _ := s.state.Selection(packID) + if selection.Difficulty == difficulty { + out = wire.AppendVarint(out, 4, uint64(selection.Option)) + } + if complete && len(pack.MainQuestIDs) > 0 { + out = wire.AppendVarint(out, 5, 1) + } + return out +} +func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte, bool, error) { + if path != "/QuestInfo" && s.battleActive != nil && s.battleActive() { + return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest) + } + if path == "/QuestInfo" { + pack, err := requestPack(request) + if err != nil || !s.packUnlocked(pack) { + return 0, nil, true, ErrInvalidRequest + } + var out []byte + if active := s.firstUnclearedQuestFor(pack); active != 0 { + out = wire.AppendBytes(out, 1, s.questInfoWire(pack, active)) + } + for _, quest := range s.activeSideQuestWires(pack) { + out = wire.AppendBytes(out, 1, quest) + } + var cleared []byte + for _, id := range s.state.ClearedQuests(pack, s.questDifficulty(pack)) { + cleared = binary.AppendUvarint(cleared, uint64(id)) + } + if len(cleared) > 0 { + out = wire.AppendBytes(out, 2, cleared) + } + return 16, out, true, nil + } + quest, pack, err := requestQuest(request) + if err != nil || !s.packUnlocked(pack) { + return 0, nil, true, ErrInvalidRequest + } + designs, known := s.questsFor(pack) + design, exists := designs[quest] + if !known || !exists { + return 0, nil, true, ErrInvalidRequest + } + if path == "/QuestGiveUp" { + if design.Type != 0 { + if _, ok := s.state.QuestInPack(quest, pack); !ok { + return 0, nil, true, ErrInvalidRequest + } + if err := s.state.RemoveQuest(quest, pack, 0); err != nil { + return 0, nil, true, err + } + return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil + } + selection, ok := s.state.Selection(pack) + if !ok || selection.QuestID != quest || selection.Difficulty == 0 { + return 0, nil, true, ErrInvalidRequest + } + // Abandoning a difficulty retains its independently committed clears. + if err := s.state.SelectQuest(pack, progress.QuestSelection{}); err != nil { + return 0, nil, true, err + } + return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil + } + level, _, err := wire.Varint(request, 4) + if err != nil || level > 4 { + return 0, nil, true, ErrInvalidRequest + } + opt, _, err := wire.Varint(request, 5) + if err != nil || opt != 0 { + return 0, nil, true, ErrInvalidRequest + } + if level > 0 && !s.questDifficulties[pack][int(level)] { + return 0, nil, true, ErrInvalidRequest + } + if design.Type != 0 { + if design.Type != 1 { + return 0, nil, true, ErrInvalidRequest + } + if level != 0 || (design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, pack)) || s.state.QuestCleared(quest, pack) { + return 0, nil, true, ErrInvalidRequest + } + if err := s.state.AcceptQuest(quest, pack, 0); err != nil { + return 0, nil, true, err + } + out := wire.AppendBytes(nil, 1, s.questInfoWire(pack, quest)) + items, err := s.ensureQuestItems(pack, quest) + if err != nil { + return 0, nil, true, err + } + for _, item := range items { + out = wire.AppendBytes(out, 4, player.ItemWire(item)) + } + return 17, out, true, nil + } + if level > 0 { + ids := s.storyCatalog.Packs[pack].MainQuestIDs + for _, id := range ids { + if !s.state.QuestCleared(id, pack, int(level)-1) { + return 0, nil, true, fmt.Errorf("%w: previous difficulty is incomplete", ErrInvalidRequest) + } + } + } + // Accept only the first unfinished main quest in this difficulty, using + // authoritative links rather than permitting an arbitrary reward jump. + first := 0 + for _, id := range s.storyCatalog.Packs[pack].MainQuestIDs { + if !s.state.QuestCleared(id, pack, int(level)) { + first = id + break + } + } + if quest != first || first == 0 { + return 0, nil, true, ErrInvalidRequest + } + if err := s.state.SelectQuest(pack, progress.QuestSelection{QuestID: quest, Difficulty: int(level), Option: int(opt)}); err != nil { + return 0, nil, true, err + } + if err := s.state.SetActivePackID(pack); err != nil { + return 0, nil, true, err + } + s.setCurrentPack(pack) + chars, decks, err := s.resolveActivePartyWires(pack, quest) + if err != nil { + return 0, nil, true, err + } + out := wire.AppendBytes(nil, 1, s.questInfoWire(pack, quest)) + for _, char := range chars { + out = wire.AppendBytes(out, 2, char) + } + for _, deck := range decks { + out = wire.AppendBytes(out, 3, deck) + } + items, err := s.ensureQuestItems(pack, quest) + if err != nil { + return 0, nil, true, err + } + for _, item := range items { + out = wire.AppendBytes(out, 4, player.ItemWire(item)) + } + return 17, out, true, nil +} + +func (s *Service) activeSideQuestWires(packID int) [][]byte { + var out [][]byte + if s.storyCatalog == nil { + return nil + } + for _, quest := range s.state.QuestsInPack(packID, 0) { + if s.storyCatalog.Packs[packID].Quests[quest.QuestID].Type == 1 && !s.state.QuestCleared(quest.QuestID, packID) { + out = append(out, s.questInfoWire(packID, quest.QuestID)) + } + } + return out +} + +func (s *Service) visibleOwnedCharacters(characters []player.Character) []player.Character { + earned := s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) + out := make([]player.Character, 0, len(characters)) + for _, character := range characters { + if !player.IsStoryCharacter(character) && (earned || s.seed.RewardCharacter.InvenIndex == 0 || character.InvenIndex != s.seed.RewardCharacter.InvenIndex) { + out = append(out, character) + } + } + return out +} + +func (s *Service) ensureQuestItems(packID, questID int) ([]player.Item, error) { + ids := s.storyCatalog.Packs[packID].Quests[questID].GiveQuestItemIDs + if len(ids) == 0 { + return nil, nil + } + if s.inventory == nil { + return nil, fmt.Errorf("world: quest item inventory unavailable") + } + var rewards []gamedata.BattleReward + for _, id := range ids { + rewards = append(rewards, gamedata.BattleReward{Type: 13, ID: id, Count: 1}) + } + identity := questRewardIdentity(packID, questID, s.questDifficultyFor(packID, questID)) + ":give-items" + items, err := s.inventory.GrantOnce(identity, rewards) + if err == nil && len(items) == 0 { + items = s.inventory.GrantedItems(identity) + } + return items, err +} + +func (s *Service) AttachBattleActive(provider func() bool) error { + if provider == nil { + return fmt.Errorf("world: nil battle activity provider") + } + s.battleActive = provider + return nil +} diff --git a/go/internal/server/world/quest_selection_test.go b/go/internal/server/world/quest_selection_test.go new file mode 100644 index 0000000..f6105aa --- /dev/null +++ b/go/internal/server/world/quest_selection_test.go @@ -0,0 +1,125 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +func selectionRequest(quest, pack, level uint64) []byte { + out := wire.AppendVarint(nil, 1, 1) + out = wire.AppendVarint(out, 2, quest) + out = wire.AppendVarint(out, 3, pack) + return wire.AppendVarint(out, 4, level) +} +func TestDifficultySelectionPersistsAndRewardsRemainIndependent(t *testing.T) { + s := testService() + storage := stateio.NewMemory() + var err error + s.state, err = progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s.questDifficulties = map[int]map[int]bool{21: {1: true, 2: true}} + for id, design := range s.storyCatalog.Packs[21].Quests { + design.Rewards[0] = []gamedata.Reward{{Type: 2, Count: 10}} + design.Rewards[1] = []gamedata.Reward{{Type: 2, Count: 20}} + s.storyCatalog.Packs[21].Quests[id] = design + } + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 1)); err == nil { + t.Fatal("hard accepted before normal completion") + } + for id := uint64(1); id <= 3; id++ { + if _, _, _, err := s.Handle("/QuestClear", selectionRequest(id, 21, 0)); err != nil { + t.Fatal(err) + } + } + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(2, 21, 1)); err == nil { + t.Fatal("arbitrary hard quest accepted") + } + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 1)); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if _, _, _, err := s.Handle("/QuestClear", selectionRequest(1, 21, 0)); err != nil { + t.Fatal(err) + } + } + if s.wallet.Snapshot().Jewelry != 50 { + t.Fatalf("reward isolation/replay balance %+v", s.wallet.Snapshot()) + } + reopened, err := progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s.state = reopened + if selection, ok := reopened.Selection(21); !ok || selection.Difficulty != 1 || selection.QuestID != 2 { + t.Fatalf("selection lost %+v", selection) + } + _, response, _, err := s.Handle("/PackInGameInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)) + if err != nil { + t.Fatal(err) + } + quest, _, _ := wire.Bytes(response, 2) + id, _, _ := wire.Varint(quest, 1) + level, _, _ := wire.Varint(quest, 4) + if id != 2 || level != 1 { + t.Fatalf("restored id%d level%d", id, level) + } + if _, _, _, err := s.Handle("/QuestGiveUp", selectionRequest(2, 21, 0)); err != nil { + t.Fatal(err) + } + if !s.state.QuestCleared(1, 21, 1) { + t.Fatal("abandon erased hard clear") + } + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(2, 21, 1)); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 4)); err == nil { + t.Fatal("unsupported table difficulty accepted") + } +} +func TestSideQuestRemainsIndependentFromMainDifficulty(t *testing.T) { + s := testService() + pack := s.storyCatalog.Packs[21] + pack.Quests[50] = gamedata.QuestDesign{ID: 50, Type: 1} + s.storyCatalog.Packs[21] = pack + if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(50, 21, 0)); err != nil { + t.Fatal(err) + } + if first := s.firstUnclearedQuestFor(21); first != 1 { + t.Fatalf("side replaced main%d", first) + } + if _, _, _, err := s.Handle("/QuestClear", selectionRequest(50, 21, 0)); err != nil { + t.Fatal(err) + } + if !s.state.QuestCleared(50, 21) || s.state.QuestCleared(1, 21) { + t.Fatal("side clear affected main") + } +} + +func TestSelectionCannotSwitchWhileBattleIsActive(t *testing.T) { + s := testService() + if err := s.AttachBattleActive(func() bool { return true }); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/QuestAccept", "/QuestGiveUp"} { + if _, _, _, err := s.Handle(path, selectionRequest(1, 21, 0)); err == nil { + t.Fatalf("%s switched active battle", path) + } + } + if _, _, _, err := s.Handle("/PackInGameInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)); err == nil { + t.Fatal("pack switch allowed during battle") + } +} diff --git a/go/internal/server/world/start_pack.go b/go/internal/server/world/start_pack.go new file mode 100644 index 0000000..6db8ce9 --- /dev/null +++ b/go/internal/server/world/start_pack.go @@ -0,0 +1,52 @@ +package world + +import ( + "fmt" + + "bd2server/internal/server/progress" +) + +func (s *Service) startingPack() int { + if s.startingPackID != 0 { + return s.startingPackID + } + if id := s.state.StartPackID(); id != 0 { + return id + } + return s.seed.PackID +} + +// ConfigureStartPack keeps server policy separate from the versioned tutorial +// seed. The entry chapter is committed once when an account is initialized. +func (s *Service) ConfigureStartPack(packID int, initialize bool) error { + if initialize { + pack, ok := s.storyCatalog.Packs[packID] + if !ok || len(pack.MainQuestIDs) == 0 { + return fmt.Errorf("world: starting chapter %d has no main quest", packID) + } + if err := s.state.SetStartPack(packID); err != nil { + return err + } + if err := s.state.SetActivePackID(packID); err != nil { + return err + } + if err := s.state.SelectQuest(packID, progress.QuestSelection{QuestID: pack.MainQuestIDs[0]}); err != nil { + return err + } + } + entry := s.state.StartPackID() + if _, ok := s.storyCatalog.Packs[entry]; !ok { + return fmt.Errorf("world: invalid persisted starting chapter %d", entry) + } + s.startingPackID = entry + active := s.state.ActivePackID() + if active == 0 { + return fmt.Errorf("world: account has no active chapter") + } + s.setCurrentPack(active) + return nil +} + +func (s *Service) tutorialRosterRestricted() bool { + return s.startingPack() == s.seed.PackID && !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) +} diff --git a/go/internal/server/world/start_pack_test.go b/go/internal/server/world/start_pack_test.go new file mode 100644 index 0000000..e695b65 --- /dev/null +++ b/go/internal/server/world/start_pack_test.go @@ -0,0 +1,80 @@ +package world + +import ( + "testing" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" +) + +func TestStartingChapterPolicyPersistsWithoutReplacingTutorialSeed(t *testing.T) { + storage := stateio.NewMemory() + state, err := progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s := testService() + s.state = state + s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, MainQuestIDs: []int{8}, Quests: map[int]gamedata.QuestDesign{8: {ID: 8}}, Open: &gamedata.ContentOpenRule{TicketID: 71}} + if err := s.ConfigureStartPack(1, true); err != nil { + t.Fatal(err) + } + if s.seed.PackID != 21 || !s.packUnlocked(1) || s.tutorialRosterRestricted() { + t.Fatal("entry policy overwrote tutorial seed or blocked entry") + } + if id, err := s.LastPlayedPackID(); err != nil || id != 1 { + t.Fatalf("destination %d, %v", id, err) + } + selection, ok := s.state.Selection(1) + if !ok || selection.QuestID != 8 || selection.Difficulty != 0 { + t.Fatalf("initial quest %+v", selection) + } + reopened, err := progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s.state = reopened + if err := s.ConfigureStartPack(21, false); err != nil { + t.Fatal(err) + } + if s.startingPack() != 1 { + t.Fatal("changed server policy moved existing account") + } +} + +func TestInitialPurchaseUsesSelectedEntryChapter(t *testing.T) { + s := testService() + storage := stateio.NewMemory() + var err error + s.state, err = progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s.collection, err = player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, MainQuestIDs: []int{8}, Quests: map[int]gamedata.QuestDesign{8: {ID: 8}}, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}}} + if err := s.ConfigureStartPack(1, true); err != nil { + t.Fatal(err) + } + if err := s.EnsureInitialPackPurchase(); err != nil { + t.Fatal(err) + } + if _, owned := s.collection.Grant("pack-purchase:1"); !owned || s.wallet.CatalystBalance() != 17 { + t.Fatal("entry purchase not committed") + } + if _, owned := s.collection.Grant("pack-purchase:21"); owned { + t.Fatal("tutorial purchase incorrectly granted") + } +} diff --git a/go/internal/server/world/story_catalog.go b/go/internal/server/world/story_catalog.go index 27146e5..e84606b 100644 --- a/go/internal/server/world/story_catalog.go +++ b/go/internal/server/world/story_catalog.go @@ -44,6 +44,11 @@ func (s *Service) storyPackUnlocked(id int) bool { if !exists { return false } + // A server-selected entry chapter is explicitly available to this account. + // This does not invent tickets for the remaining chapter catalog. + if id == s.startingPack() { + return true + } // ContentOpen.TutorialID triggers a tutorial; the client does not use it // as an authorization requirement. Story NextPackID is navigation only. if pack.Open == nil { @@ -83,9 +88,16 @@ func (s *Service) storyPackDBInfoRows() [][]byte { var rows [][]byte for _, id := range ids { row := wire.AppendVarint(nil, 1, uint64(id)) + selection, _ := s.state.Selection(id) + if selection.Difficulty != 0 { + row = wire.AppendVarint(row, 4, uint64(selection.Difficulty)) + } + if selection.Option != 0 { + row = wire.AppendVarint(row, 5, uint64(selection.Option)) + } mainCleared := 0 for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs { - if s.state.QuestCleared(qid, id) { + if s.state.QuestCleared(qid, id, s.questDifficulty(id)) { mainCleared++ } } diff --git a/go/internal/server/world/story_catalog_test.go b/go/internal/server/world/story_catalog_test.go index da0a391..30bd6e4 100644 --- a/go/internal/server/world/story_catalog_test.go +++ b/go/internal/server/world/story_catalog_test.go @@ -91,8 +91,8 @@ func TestStoryCatalogUsesTicketsAndQuestLinksAcrossIndependentPacks(t *testing.T }); err != nil { t.Fatal(err) } - if len(counts) != 2 || counts[405] != 1 || counts[701] != 2 { - t.Fatalf("completed normal levels count sidequests or omit nonseed pack: %v", counts) + if len(counts) != 2 || counts[405] != 33 || counts[701] != 7 { + t.Fatalf("completed normal levels do not retain linked terminal quest IDs: %v", counts) } } diff --git a/go/internal/server/world/story_characters.go b/go/internal/server/world/story_characters.go new file mode 100644 index 0000000..6c0b67f --- /dev/null +++ b/go/internal/server/world/story_characters.go @@ -0,0 +1,186 @@ +package world + +import ( + "bd2server/internal/server/deck" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "fmt" +) + +// ResolveStoryParty combines the authored temporary CharGroup members with +// actual account instances named by StoryCharGroup costume rows. +func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, error) { + if s.storyRoster == nil { + return nil, nil + } + if s.characters == nil { + return nil, fmt.Errorf("world: story character store unavailable") + } + formation, ok := s.storyRoster.Formation(packID, questID) + if !ok { + return nil, fmt.Errorf("world: missing formation pack%d quest%d", packID, questID) + } + designs, err := s.storyRoster.Characters(packID, questID) + if err != nil { + return nil, err + } + temporary := make([]player.Character, 0, len(designs)) + reused := make([]player.Character, 0, len(designs)) + for _, d := range designs { + var existing player.Character + for _, c := range s.characters.RawAll() { + if d.TemporaryPack != 0 && c.ID == d.CharacterID && c.Level == d.Level && (c.CostumeID == 0 || c.CostumeID == d.CostumeID) { + existing = c + break + } + } + if existing.InvenIndex != 0 { + reused = append(reused, existing) + continue + } + if packID <= 0 || packID >= 65536 || d.CharacterID == 0 || d.CharacterID >= 1<<32 || d.Level == 0 || d.Level >= 256 { + return nil, fmt.Errorf("world: story instance namespace overflow") + } + index := player.StoryCharacterIndexBase | uint64(packID)<<40 | d.CharacterID<<8 | d.Level + temporary = append(temporary, player.Character{InvenIndex: index, ID: d.CharacterID, HP: d.HP, Level: d.Level, CostumeID: d.CostumeID}) + } + if s.characters == nil { + return nil, fmt.Errorf("world: story character store unavailable") + } + if err := s.characters.EnsureStoryCharacters(temporary); err != nil { + return nil, err + } + result := make([]player.Character, 0, len(temporary)+len(formation.StoryCostumes)) + used := map[uint64]bool{} + for _, c := range reused { + current, ok := s.characters.Find(c.InvenIndex) + if !ok { + return nil, fmt.Errorf("world: saved story instance unavailable") + } + result = append(result, current) + used[current.InvenIndex] = true + } + for _, c := range temporary { + current, ok := s.characters.Find(c.InvenIndex) + if !ok { + return nil, fmt.Errorf("world: story character was not saved") + } + result = append(result, current) + used[current.InvenIndex] = true + } + // Placeholder entries are player-controlled party slots. Their costume + // and character cannot be inferred from the design placeholder itself. + for _, costume := range formation.StoryCostumes { + if costume.CostumeID == 996000 { + continue + } + temporaryMember := false + for _, design := range designs { + if design.UniqueCharacterID == costume.UniqueCharacterID { + temporaryMember = true + break + } + } + if temporaryMember { + continue + } + var ownedCostumes []player.Costume + if s.starter != nil { + ownedCostumes = append(ownedCostumes, s.starter.Costumes...) + } + if s.collection != nil { + ownedCostumes = append(ownedCostumes, s.collection.Costumes()...) + } + for _, c := range s.visibleOwnedCharacters(s.characters.All()) { + matched := c.CostumeID == costume.CostumeID + for _, owned := range ownedCostumes { + if owned.ID == costume.CostumeID && owned.UseChar == c.InvenIndex { + matched = true + c.CostumeID = owned.ID + c.UseCostume = owned.InvenIndex + break + } + } + if player.IsStoryCharacter(c) || used[c.InvenIndex] || !matched { + continue + } + result = append(result, c) + used[c.InvenIndex] = true + break + } + } + // Fill authored player-controlled slots from the persisted field party, + // then the persisted battle party. Never synthesize the placeholder ID. + for _, costume := range formation.StoryCostumes { + if costume.CostumeID != 996000 || s.decks == nil { + continue + } + var candidates []uint64 + for _, entry := range s.decks.CurrentFieldDeck() { + candidates = append(candidates, entry.CharacterInvenIndex) + } + for _, entry := range s.decks.CurrentDeck() { + candidates = append(candidates, entry.CharacterInvenIndex) + } + for _, index := range candidates { + if used[index] { + continue + } + var controlled player.Character + for _, c := range s.visibleOwnedCharacters(s.characters.All()) { + if c.InvenIndex == index { + controlled = c + break + } + } + if controlled.InvenIndex == 0 { + continue + } + temporaryDesign := false + for _, d := range designs { + if d.CharacterID == controlled.ID { + temporaryDesign = true + break + } + } + if temporaryDesign { + continue + } + result = append(result, controlled) + used[index] = true + break + } + } + return result, nil +} + +func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]byte, error) { + party, err := s.ResolveStoryParty(packID, questID) + if err != nil { + return nil, nil, err + } + if len(party) == 0 { + return nil, nil, nil + } + formation, _ := s.storyRoster.Formation(packID, questID) + entries := make([]deck.DeckEntry, 0, len(party)) + characters := make([][]byte, 0, len(party)) + wires := make([][]byte, 0, len(party)) + for i, c := range party { + position := ^uint64(0) + if i < len(formation.DeckList) { + position = formation.DeckList[i] + } + entry := deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: position, Slot: uint64(i + 1)} + entries = append(entries, entry) + characters = append(characters, encodeCharacter(c)) + data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, c.InvenIndex), 2, position), 3, uint64(i+1)) + wires = append(wires, data) + } + if s.decks != nil { + if err := s.decks.SetStoryParty(entries); err != nil { + return nil, nil, err + } + } + return characters, wires, nil +} diff --git a/go/internal/server/world/story_characters_test.go b/go/internal/server/world/story_characters_test.go new file mode 100644 index 0000000..563ac2c --- /dev/null +++ b/go/internal/server/world/story_characters_test.go @@ -0,0 +1,79 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "os" + "reflect" + "testing" +) + +func TestInstalledAdjacentStoryPartyRetainsHealth(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + catalog, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{21}) + if err != nil { + t.Fatal(err) + } + first, second := 0, 0 + for quest := 1; quest < 38; quest++ { + a, err := catalog.Characters(21, quest) + if err != nil { + t.Fatal(err) + } + b, err := catalog.Characters(21, quest+1) + if err != nil { + t.Fatal(err) + } + if len(a) > 0 && reflect.DeepEqual(a, b) { + first, second = quest, quest+1 + break + } + } + if first == 0 { + t.Fatal("no adjacent authored same party fixture") + } + storage := stateio.NewMemory() + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + designs, err := catalog.Characters(21, first) + if err != nil { + t.Fatal(err) + } + legacy := player.Character{InvenIndex: 123, ID: designs[0].CharacterID, HP: designs[0].HP, Level: designs[0].Level} + characters, err := player.OpenCharacterStore(storage, []player.Character{legacy}, inventory, "", "") + if err != nil { + t.Fatal(err) + } + if err := characters.AttachMaxHealth(func(c player.Character) (uint64, error) { return c.HP, nil }); err != nil { + t.Fatal(err) + } + s := testService() + s.characters = characters + s.storyRoster = catalog + party, err := s.ResolveStoryParty(21, first) + if err != nil { + t.Fatal(err) + } + if party[0].InvenIndex != legacy.InvenIndex { + t.Fatal("existing seeded temporary identity was not reused") + } + if err := characters.SetCurrentHealth(party[0].InvenIndex, 7); err != nil { + t.Fatal(err) + } + next, err := s.ResolveStoryParty(21, second) + if err != nil { + t.Fatal(err) + } + if next[0].InvenIndex != party[0].InvenIndex || next[0].HP != 7 { + t.Fatalf("quest%d→%d reset party hp or index: %+v→%+v", first, second, party[0], next[0]) + } + if len(characters.RawAll()) != len(party) { + t.Fatal("adjacent quest duplicated temporary instances") + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 4687367..7a1b9a9 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -89,7 +89,10 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, } transition := transitions[seed.PackID] activePack := seed.PackID - if saved, found := state.Position(); found { + if id := state.ActivePackID(); id != 0 { + activePack = id + } + if saved, found := state.Position(); found && state.ActivePackID() == 0 { _, storyKnown := packs[saved.PackID] _, fieldKnown := fieldPacks[saved.PackID] if storyKnown || fieldKnown { @@ -97,6 +100,10 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, } } 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, fieldPacks: fieldPacks} + service.questDifficulties, err = gamedata.LoadQuestDifficulties(gameDataRoot, gameDataVersion) + if err != nil { + return nil, err + } service.packJamDesign = packJamDesign service.packSummaryTargets = packSummaryTargets service.storyCatalog = storyCatalog @@ -104,6 +111,14 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, return nil, err } service.questCostumes = questCostumes + var storyPackIDs []int + for id := range packs { + storyPackIDs = append(storyPackIDs, id) + } + service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs) + if err != nil { + return nil, err + } service.attachPackDetailDesign(gameDataRoot, gameDataVersion) return service, nil } @@ -138,6 +153,10 @@ func (s *Service) setCurrentPack(packID int) { } type Service struct { + battleActive func() bool + questDifficulties map[int]map[int]bool + startingPackID int + storyRoster *gamedata.StoryCharacterCatalog storyCatalog *gamedata.StoryCatalog questCostumes player.CostumeDesignSource packDetailDesign func(int) (gamedata.PackDetailDesign, error) @@ -181,6 +200,8 @@ func (s *Service) AttachDecks(decks *deck.Store) error { func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { switch path { + case "/QuestInfo", "/QuestAccept", "/QuestGiveUp": + return s.handleQuestSelection(path, request) case "/PackBuy": return s.handlePackBuy(request) case "/PackDetailInfo": @@ -201,8 +222,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("world: CharInfo missing sequence") } var response []byte - characters := s.characters.All() - if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + characters := s.visibleOwnedCharacters(s.characters.All()) + if s.tutorialRosterRestricted() { // The tutorial roster still contains only starter identities, but // their field HP must come from persisted state rather than falling // through to Starter.Handle's immutable new-account HP. @@ -215,6 +236,21 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) characters = append(characters, character) } } + if s.decks != nil { + seen := map[uint64]bool{} + for _, char := range characters { + seen[char.InvenIndex] = true + } + for _, entry := range s.decks.CurrentDeck() { + if seen[entry.CharacterInvenIndex] { + continue + } + if char, ok := s.characters.Find(entry.CharacterInvenIndex); ok && player.IsStoryCharacter(char) { + characters = append(characters, char) + seen[char.InvenIndex] = true + } + } + } slog.Info("team trace: deliver owned characters", "characters", characters) for _, character := range characters { response = wire.AppendBytes(response, 1, encodeCharacter(character)) @@ -222,7 +258,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) 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) { + if s.tutorialRosterRestricted() { return 0, nil, false, nil } var response []byte @@ -239,6 +275,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } return 40, response, true, nil case "/PackInGameInfo": + if s.battleActive != nil && s.battleActive() { + return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest) + } pack, err := requestPack(request) if err != nil { return 0, nil, true, err @@ -246,12 +285,20 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) 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)) + if active := s.firstUnclearedQuestFor(pack); active != 0 { + if _, err := s.ensureQuestItems(pack, active); err != nil { + return 0, nil, true, err + } + } response, err := s.packInfoFor(pack) if err != nil { return 0, nil, true, err } + if err := s.state.SetActivePackID(pack); err != nil { + return 0, nil, true, err + } + s.setCurrentPack(pack) return 5, response, true, nil case "/QuestClear": quest, pack, err := requestQuest(request) @@ -266,20 +313,42 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) 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]) + wasCleared := s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) + items, questEquipment, err := s.grantQuestRewards(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)]) if err != nil { return 0, nil, true, err } - if err := s.state.ClearQuest(quest, pack); err != nil { + if err := s.state.ClearQuest(quest, pack, s.questDifficultyFor(pack, quest)); 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 s.collection != nil && quest == s.seed.BattleUnlockQuestID && pack == s.seed.PackID && s.questDifficulty(pack) == 0 && !wasCleared { if err := s.collection.AttachRewardCostume(s.seed.RewardCostume); err != nil { return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err) } } + if selection, selected := s.state.Selection(pack); selected && design.Type == 0 && !wasCleared { + selection.QuestID = s.nextQuestFor(pack, quest) + if err := s.state.SelectQuest(pack, selection); err != nil { + return 0, nil, true, err + } + } + var nextItems []player.Item + var nextChars [][]byte + if design.Type == 0 { + if next := s.nextQuestFor(pack, quest); next != 0 { + var err error + nextChars, _, err = s.resolveActivePartyWires(pack, next) + if err != nil { + return 0, nil, true, err + } + nextItems, err = s.ensureQuestItems(pack, next) + if err != nil { + return 0, nil, true, 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 + return 18, s.clearResponse(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)], items, questEquipment, nextItems, nextChars), true, nil default: return 0, nil, false, nil } @@ -336,7 +405,7 @@ func (s *Service) storyCharacters(packID int) []player.Character { } func (s *Service) canClear(packID, quest int) bool { - if s.state.QuestCleared(quest, packID) { + if s.state.QuestCleared(quest, packID, s.questDifficultyFor(packID, quest)) { return true } quests, found := s.questsFor(packID) @@ -347,18 +416,18 @@ func (s *Service) canClear(packID, quest int) bool { if !found { return false } - if design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, packID) { + 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) + _, active := s.state.QuestInPack(quest, packID, s.questDifficultyFor(packID, quest)) return active } func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) { - identity := fmt.Sprintf("pack%d:quest%d", packID, quest) + identity := questRewardIdentity(packID, quest, s.questDifficultyFor(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) @@ -381,7 +450,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. 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 { + 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 { @@ -399,7 +468,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. } var costumeIDs []uint64 for _, reward := range designRewards { - if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && reward.ID == s.seed.RewardCostume.ID) { + if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { costumeIDs = append(costumeIDs, reward.ID) } } @@ -424,7 +493,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. } } } - if packID == s.seed.PackID { + if packID == s.seed.PackID && s.questDifficultyFor(packID, quest) == 0 { for _, reward := range questPictorialItems[quest] { itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) } @@ -465,26 +534,21 @@ func (s *Service) packInfo() ([]byte, error) { 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)) + quest := s.questInfoWire(packID, active) + chars, _, err := s.resolveActivePartyWires(packID, active) + if err != nil { + return nil, err + } + for _, char := range chars { + out = wire.AppendBytes(out, 1, char) + } out = wire.AppendBytes(out, 2, quest) } - cleared := s.state.ClearedQuests(packID) + for _, quest := range s.activeSideQuestWires(packID) { + out = wire.AppendBytes(out, 2, quest) + } + cleared := s.state.ClearedQuests(packID, s.questDifficulty(packID)) if len(cleared) != 0 { var packed []byte for _, id := range cleared { @@ -495,7 +559,7 @@ func (s *Service) packInfoFor(packID int) ([]byte, error) { position := "{}" mapID := 0 restored := false - if saved, found := s.state.Position(); found && saved.PackID == packID && saved.RawJSON != "" { + if saved, found := s.state.Position(); found && saved.PackID == packID && saved.Difficulty == s.questDifficulty(packID) && saved.RawJSON != "" { if pack, arena := s.fieldPacks[packID]; arena && !pack.MapIDs[saved.Position.MapID] { return nil, fmt.Errorf("world: saved map %d does not belong to arena pack %d", saved.Position.MapID, packID) } @@ -538,17 +602,17 @@ func (s *Service) firstUnclearedQuestFor(packID int) int { return 0 } for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs { - if !s.state.QuestCleared(id, packID) { + if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { return id } } return 0 } -func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment) []byte { +func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment, nextItems []player.Item, nextChars [][]byte) []byte { var rewards []byte if s.collection != nil { - if grant, found := s.collection.Grant(fmt.Sprintf("pack%d:quest%d:costumes", packID, quest)); found { + if grant, found := s.collection.Grant(questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest)) + ":costumes"); found { rewards = append(rewards, player.CollectionRewardBundle(s.collection, grant)...) } } @@ -578,7 +642,7 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa // 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 { + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 { 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)) @@ -603,8 +667,11 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa var out []byte out = wire.AppendBytes(out, 1, rewards) next := s.nextQuestFor(packID, quest) + if s.storyCatalog.Packs[packID].Quests[quest].Type != 0 { + next = 0 + } if next != 0 { - out = wire.AppendBytes(out, 2, wire.AppendVarint(nil, 1, uint64(next))) + out = wire.AppendBytes(out, 2, s.questInfoWire(packID, next)) } else { // QuestClearResponse.QuestInfo is dereferenced by the client // even when this is the final quest of a pack. An explicitly present, @@ -617,7 +684,10 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa out = wire.AppendBytes(out, 2, nil) } out = wire.AppendVarint(out, 3, uint64(quest)) - if next == 0 && s.packCompleteFor(packID) { + for _, item := range nextItems { + out = wire.AppendBytes(out, 6, player.ItemWire(item)) + } + if s.storyCatalog.Packs[packID].Quests[quest].Type == 0 && 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. @@ -625,7 +695,7 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa out = wire.AppendBytes(out, 11, info) } } - if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID { + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 { 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) @@ -649,6 +719,10 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa out = wire.AppendBytes(out, 4, entry) } } + for _, char := range nextChars { + out = wire.AppendBytes(out, 5, char) + } + out = wire.AppendBytes(out, 9, s.questLevelInfoWire(packID, s.questDifficultyFor(packID, quest))) out = wire.AppendBytes(out, 12, nil) return wire.AppendBytes(out, 13, nil) } @@ -666,7 +740,7 @@ func (s *Service) packCompleteFor(packID int) bool { return false } for _, id := range ids { - if !s.state.QuestCleared(id, packID) { + if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { return false } } @@ -687,13 +761,11 @@ func (s *Service) accountPackInfo() []byte { } for _, info := range s.packDBInfoRows() { id, _, _ := wire.Varint(info, 1) - if !s.packCompleteFor(int(id)) { - continue + for level := 0; level <= 4; level++ { + if len(s.state.ClearedQuests(int(id), level)) > 0 || s.questDifficulty(int(id)) == level { + out = wire.AppendBytes(out, 2, s.questLevelInfoWire(int(id), level)) + } } - level := wire.AppendVarint(nil, 1, id) - level = wire.AppendVarint(level, 3, uint64(len(s.storyCatalog.Packs[int(id)].MainQuestIDs))) - level = wire.AppendVarint(level, 5, 1) - out = wire.AppendBytes(out, 2, level) } return wire.AppendVarint(out, 5, 3) } @@ -707,8 +779,8 @@ var questPictorialItems = map[int][]gamedata.Reward{ // 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() + if !s.tutorialRosterRestricted() && s.characters != nil { + return s.visibleOwnedCharacters(s.characters.RawAll()) } return append([]player.Character(nil), s.starter.Characters...) } @@ -718,7 +790,7 @@ func (s *Service) PictorialCostumes() []player.Costume { if s.collection != nil { result = s.collection.Costumes() } - if s.collection == nil && s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) { + if s.collection == nil && !s.tutorialRosterRestricted() && s.startingPack() == s.seed.PackID { result = append(result, s.seed.RewardCostume) } return result diff --git a/go/internal/server/world/world_test.go b/go/internal/server/world/world_test.go index 2e9cc47..6a4e0e4 100644 --- a/go/internal/server/world/world_test.go +++ b/go/internal/server/world/world_test.go @@ -420,7 +420,7 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { } } -func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) { +func TestPackInfoDoesNotEmitFixedRewardRosterWithoutActiveFormation(t *testing.T) { storage := stateio.NewMemory() starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) @@ -455,7 +455,7 @@ func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) { }); err != nil { t.Fatal(err) } - if level != 20 { - t.Fatalf("pack character level=%d want20", level) + if level != 0 { + t.Fatalf("fixed reward character leaked without an authored active formation: level=%d", level) } }