feat: persist story quest difficulty and configurable starting chapter
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"story": {
|
||||
"start_pack_id": 21
|
||||
},
|
||||
"gacha": {
|
||||
"include_collaboration_ur_weapons": false
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user