feat(server): persist story quest difficulty and configurable starting chapter

This commit is contained in:
2026-10-04 16:02:33 +08:00
parent 4e5c08a8c7
commit 78750f2e12
34 changed files with 1859 additions and 148 deletions
+18
View File
@@ -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`。
+3
View File
@@ -1,5 +1,8 @@
{
"schema_version": 1,
"story": {
"start_pack_id": 21
},
"gacha": {
"include_collaboration_ur_weapons": false
}
+9 -1
View File
@@ -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 {
+14 -8
View File
@@ -52,6 +52,7 @@ type indexedGrant struct {
}
type questKey struct {
difficulty uint32
pack uint32
quest uint32
}
@@ -408,6 +409,7 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) {
}
var quests map[string]struct {
QuestID int `json:"QuestID"`
Difficulty int `json:"Difficulty"`
PackID int `json:"PackID"`
}
if json.Unmarshal(fields["quests"], &quests) != nil {
@@ -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
})
}
+21
View File
@@ -23,6 +23,8 @@ type Service struct {
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)
@@ -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
+38
View File
@@ -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)
+26
View File
@@ -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)
}
+14 -2
View File
@@ -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
}
@@ -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)
@@ -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
}
@@ -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)
}
}
}
}
@@ -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)
}
@@ -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 {
@@ -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()
}
+5
View File
@@ -17,6 +17,7 @@ type QuestDesign struct {
Type int
PriorQuestID int
NextQuestID int
GiveQuestItemIDs []uint64
Rewards [5][]Reward
}
@@ -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)) {
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}
+176 -32
View File
@@ -35,6 +35,7 @@ type Position struct {
}
type SavedPosition struct {
Difficulty int
PackID int
Position Position
RawJSON string
@@ -42,11 +43,21 @@ type SavedPosition struct {
type QuestProgress struct {
QuestID int
Difficulty int
PackID int
Values []int
}
type QuestSelection struct {
QuestID int
Difficulty int
Option int
}
type Store struct {
activePackID int
startingPackID int
selections map[string]QuestSelection
mu sync.RWMutex
storage stateio.Store
position SavedPosition
@@ -56,31 +67,40 @@ type Store 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 {
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
}
+9 -2
View File
@@ -9,10 +9,17 @@ 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 {
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")
}
+3 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+260
View File
@@ -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
}
@@ -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")
}
}
+52
View File
@@ -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)
}
@@ -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")
}
}
+13 -1
View File
@@ -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++
}
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}
+123 -51
View File
@@ -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
+3 -3
View File
@@ -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)
}
}