feat(server): persist story quest difficulty and configurable starting chapter
This commit is contained in:
@@ -9,6 +9,9 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
"story": {
|
||||||
|
"start_pack_id": 21
|
||||||
|
},
|
||||||
"gacha": {
|
"gacha": {
|
||||||
"include_collaboration_ur_weapons": false
|
"include_collaboration_ur_weapons": false
|
||||||
}
|
}
|
||||||
@@ -26,6 +29,21 @@
|
|||||||
|
|
||||||
开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。
|
开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。
|
||||||
|
|
||||||
|
## 新玩家的起始章节
|
||||||
|
|
||||||
|
`story.start_pack_id` 接受整数 `21` 或 `1`,省略时默认 `21`。
|
||||||
|
|
||||||
|
| 值 | 新账号的开局 |
|
||||||
|
|---|---|
|
||||||
|
| `21`(默认) | 从当前客户端默认的店长篇开始,使用该篇的任务和教学流程。 |
|
||||||
|
| `1` | 从旧主线第一章开始,使用当前 GameData 中该章的开场剧情、初始任务及购卡奖励。 |
|
||||||
|
|
||||||
|
此设置只在创建完整新账号存档时使用。起始章节会保存到该账号,修改配置后不会重置已有玩家、移动其当前位置或改变其选中的任务难度。不要通过删除玩家数据库来切换开局;这会删除玩家存档。
|
||||||
|
|
||||||
|
起始章节与教学 seed 分开处理,选择 `1` 不会把店长篇任务编号或剧情队伍替换进第一章。章节中的任务顺序、剧情阵容、内容票券以及允许的难度均读取当前 GameData。不同难度分别记录任务进度与奖励,重新连接后继续原有选择。
|
||||||
|
|
||||||
|
游戏卡的任务列表用于查看进度及开始、继续当前可进行的任务。后续任务仍须满足前置条件;难度列表只提供该章节表中存在的难度。当前已核实第一章与店长篇有普通、困难、极难,不能依据客户端枚举直接开启其他难度。
|
||||||
|
|
||||||
## 校验与升级
|
## 校验与升级
|
||||||
|
|
||||||
`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
|
`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
"story": {
|
||||||
|
"start_pack_id": 21
|
||||||
|
},
|
||||||
"gacha": {
|
"gacha": {
|
||||||
"include_collaboration_ur_weapons": false
|
"include_collaboration_ur_weapons": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,8 @@ func serve(args []string) (serveErr error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Info("server gameplay rules loaded", "config", *gameConfigPath,
|
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)
|
limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load limited costume GameData: %w", err)
|
return fmt.Errorf("load limited costume GameData: %w", err)
|
||||||
@@ -446,6 +447,9 @@ func serve(args []string) (serveErr error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load world state: %w", err)
|
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 {
|
if err := login.AttachLastPlayedPack(worldService); err != nil {
|
||||||
return fmt.Errorf("attach persisted login destination: %w", err)
|
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)
|
return fmt.Errorf("load master title: %w", err)
|
||||||
}
|
}
|
||||||
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
|
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()
|
characters := worldService.CharacterService()
|
||||||
recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion)
|
recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type indexedGrant struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type questKey struct {
|
type questKey struct {
|
||||||
|
difficulty uint32
|
||||||
pack uint32
|
pack uint32
|
||||||
quest uint32
|
quest uint32
|
||||||
}
|
}
|
||||||
@@ -408,6 +409,7 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) {
|
|||||||
}
|
}
|
||||||
var quests map[string]struct {
|
var quests map[string]struct {
|
||||||
QuestID int `json:"QuestID"`
|
QuestID int `json:"QuestID"`
|
||||||
|
Difficulty int `json:"Difficulty"`
|
||||||
PackID int `json:"PackID"`
|
PackID int `json:"PackID"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(fields["quests"], &quests) != nil {
|
if json.Unmarshal(fields["quests"], &quests) != nil {
|
||||||
@@ -416,7 +418,7 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) {
|
|||||||
questKeys := make([]questKey, 0, len(quests))
|
questKeys := make([]questKey, 0, len(quests))
|
||||||
for key, value := range quests {
|
for key, value := range quests {
|
||||||
parsed, parseErr := parseQuestKey(key)
|
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)
|
return nil, nil, fmt.Errorf("accountstate: invalid progress quest key %q", key)
|
||||||
}
|
}
|
||||||
questKeys = append(questKeys, parsed)
|
questKeys = append(questKeys, parsed)
|
||||||
@@ -440,16 +442,17 @@ func progressKeys(tx *sql.Tx) ([]questKey, []questKey, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseQuestKey(value string) (questKey, error) {
|
func parseQuestKey(value string) (questKey, error) {
|
||||||
left, right, found := strings.Cut(value, ":")
|
parts := strings.Split(value, ":")
|
||||||
if !found {
|
if len(parts) != 3 {
|
||||||
return questKey{}, errors.New("missing separator")
|
return questKey{}, errors.New("invalid quest identity")
|
||||||
}
|
}
|
||||||
pack, packErr := strconv.ParseUint(left, 10, 32)
|
pack, e1 := strconv.ParseUint(parts[0], 10, 32)
|
||||||
quest, questErr := strconv.ParseUint(right, 10, 32)
|
level, e2 := strconv.ParseUint(parts[1], 10, 32)
|
||||||
if packErr != nil || questErr != nil {
|
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{}, 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) {
|
func sortQuestKeys(keys []questKey) {
|
||||||
@@ -457,6 +460,9 @@ func sortQuestKeys(keys []questKey) {
|
|||||||
if keys[i].pack != keys[j].pack {
|
if keys[i].pack != keys[j].pack {
|
||||||
return 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
|
return keys[i].quest < keys[j].quest
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Service struct {
|
|||||||
gameDataVersion string
|
gameDataVersion string
|
||||||
inventory *player.Inventory
|
inventory *player.Inventory
|
||||||
currentPack func() (int, error)
|
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)
|
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
|
||||||
loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error)
|
loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error)
|
||||||
buffs func() ([]gamedata.PictorialBuffStat, error)
|
buffs func() ([]gamedata.PictorialBuffStat, error)
|
||||||
@@ -95,6 +97,11 @@ func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AttachCurrentDifficulty selects the GameData quest deck for the active pack.
|
||||||
|
func (s *Service) AttachCurrentDifficulty(resolve func() (uint64, error)) {
|
||||||
|
s.currentDifficulty = resolve
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) {
|
func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) {
|
||||||
s.buffs = buffs
|
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")
|
return 0, nil, true, errors.New("battle: current pack resolver is unavailable")
|
||||||
}
|
}
|
||||||
monster, _, _ := wire.Varint(request, 3)
|
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
|
var phases []gamedata.BattlePhase
|
||||||
if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) {
|
if s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0) {
|
||||||
loader := s.loadPhases
|
loader := s.loadPhases
|
||||||
|
|||||||
@@ -12,6 +12,44 @@ import (
|
|||||||
|
|
||||||
func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
|
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) {
|
func TestLocalBattleLifecycle(t *testing.T) {
|
||||||
s := &Service{}
|
s := &Service{}
|
||||||
enter := request(1)
|
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 {
|
type Config struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
Gacha GachaConfig `json:"gacha"`
|
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 {
|
type GachaConfig struct {
|
||||||
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
|
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
|
// Load reads one explicit path. Older installations without the file retain
|
||||||
// the default rules; an existing invalid file must never silently fall back.
|
// 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 {
|
if err != nil {
|
||||||
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
|
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 := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&cfg); err != nil {
|
if err := decoder.Decode(&cfg); err != nil {
|
||||||
@@ -83,6 +92,9 @@ func (c Config) Validate() error {
|
|||||||
if c.SchemaVersion != 1 {
|
if c.SchemaVersion != 1 {
|
||||||
return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
|
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
|
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) {
|
func TestMissingFileDefaultsWithoutCreating(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), FileName)
|
path := filepath.Join(t.TempDir(), FileName)
|
||||||
cfg, err := Load(path)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
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 {
|
if len(phases) == 0 || !selected {
|
||||||
return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID)
|
return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gamedata
|
package gamedata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bd2server/internal/server/wire"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"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) {
|
func TestPhaseScalarRejectsInvalidDesign(t *testing.T) {
|
||||||
for _, b := range [][]byte{{0x1a, 1, 1}, {0x18, 1, 0x18, 2}} {
|
for _, b := range [][]byte{{0x1a, 1, 1}, {0x18, 1, 0x18, 2}} {
|
||||||
if _, e := phaseScalar(b, 3); e == nil {
|
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()
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type QuestDesign struct {
|
|||||||
Type int
|
Type int
|
||||||
PriorQuestID int
|
PriorQuestID int
|
||||||
NextQuestID int
|
NextQuestID int
|
||||||
|
GiveQuestItemIDs []uint64
|
||||||
Rewards [5][]Reward
|
Rewards [5][]Reward
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,10 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entry := QuestDesign{ID: id}
|
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} {
|
for field, dst := range map[int]*int{63: &entry.Type, 37: &entry.PriorQuestID, 35: &entry.NextQuestID} {
|
||||||
values, err := packedInts(proto, field)
|
values, err := packedInts(proto, field)
|
||||||
if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > uint64(^uint32(0)>>1)) {
|
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) {
|
func TestStoreKeepsPackOverlap(t *testing.T) {
|
||||||
storage := stateio.NewMemory()
|
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 {
|
if err := storage.Save("progress", []byte(initial)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -115,14 +115,14 @@ func TestStoreKeepsPackOverlap(t *testing.T) {
|
|||||||
t.Fatalf("saved version=%d err=%v", version, err)
|
t.Fatalf("saved version=%d err=%v", version, err)
|
||||||
}
|
}
|
||||||
var cleared map[string]bool
|
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)
|
t.Fatalf("saved clears=%v err=%v", cleared, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStoreRejectsBrokenSave(t *testing.T) {
|
func TestStoreRejectsBrokenSave(t *testing.T) {
|
||||||
storage := stateio.NewMemory()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := OpenStore(storage); err == nil {
|
if _, err := OpenStore(storage); err == nil {
|
||||||
@@ -137,3 +137,36 @@ func TestStoreRejectsLegacySave(t *testing.T) {
|
|||||||
t.Fatal("accepted unversioned legacy state")
|
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,6 +35,7 @@ type Position struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SavedPosition struct {
|
type SavedPosition struct {
|
||||||
|
Difficulty int
|
||||||
PackID int
|
PackID int
|
||||||
Position Position
|
Position Position
|
||||||
RawJSON string
|
RawJSON string
|
||||||
@@ -42,11 +43,21 @@ type SavedPosition struct {
|
|||||||
|
|
||||||
type QuestProgress struct {
|
type QuestProgress struct {
|
||||||
QuestID int
|
QuestID int
|
||||||
|
Difficulty int
|
||||||
PackID int
|
PackID int
|
||||||
Values []int
|
Values []int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuestSelection struct {
|
||||||
|
QuestID int
|
||||||
|
Difficulty int
|
||||||
|
Option int
|
||||||
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
|
activePackID int
|
||||||
|
startingPackID int
|
||||||
|
selections map[string]QuestSelection
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
storage stateio.Store
|
storage stateio.Store
|
||||||
position SavedPosition
|
position SavedPosition
|
||||||
@@ -56,31 +67,40 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewStore() *Store {
|
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 {
|
type snapshot struct {
|
||||||
|
ActivePackID int `json:"active_pack_id"`
|
||||||
|
StartPackID int `json:"start_pack_id"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
|
Selections map[string]QuestSelection `json:"selections"`
|
||||||
Position SavedPosition `json:"position"`
|
Position SavedPosition `json:"position"`
|
||||||
Tutorials []int `json:"tutorials"`
|
Tutorials []int `json:"tutorials"`
|
||||||
Quests map[string]QuestProgress `json:"quests"`
|
Quests map[string]QuestProgress `json:"quests"`
|
||||||
Cleared map[string]json.RawMessage `json:"cleared_quests"`
|
Cleared map[string]json.RawMessage `json:"cleared_quests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshotVersion = 2
|
const snapshotVersion = 3
|
||||||
|
|
||||||
func questKey(packID, questID int) string {
|
func questDifficulty(level []int) int {
|
||||||
return strconv.Itoa(packID) + ":" + strconv.Itoa(questID)
|
if len(level) > 0 {
|
||||||
|
return level[0]
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
func parseQuestKey(key string) (int, int, bool) {
|
|
||||||
left, right, found := strings.Cut(key, ":")
|
|
||||||
if !found {
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
}
|
||||||
packID, packErr := strconv.Atoi(left)
|
func questKey(packID, questID int, level ...int) string {
|
||||||
questID, questErr := strconv.Atoi(right)
|
return strconv.Itoa(packID) + ":" + strconv.Itoa(questDifficulty(level)) + ":" + strconv.Itoa(questID)
|
||||||
return packID, questID, packErr == nil && questErr == nil && packID > 0 && questID > 0
|
}
|
||||||
|
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.
|
// OpenStore recovers player progress from its domain snapshot.
|
||||||
@@ -97,7 +117,7 @@ func OpenStore(storage stateio.Store) (*Store, error) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return s, 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)
|
return nil, fmt.Errorf("progress: incompatible save layout: %w", err)
|
||||||
}
|
}
|
||||||
var state snapshot
|
var state snapshot
|
||||||
@@ -107,7 +127,25 @@ func OpenStore(storage stateio.Store) (*Store, error) {
|
|||||||
if state.Version != snapshotVersion {
|
if state.Version != snapshotVersion {
|
||||||
return nil, fmt.Errorf("progress: unsupported save version %d", state.Version)
|
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
|
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 {
|
for _, id := range state.Tutorials {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return nil, errors.New("progress: invalid saved tutorial")
|
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 {
|
if quest.QuestID <= 0 || quest.PackID <= 0 {
|
||||||
return nil, errors.New("progress: invalid saved quest")
|
return nil, errors.New("progress: invalid saved quest")
|
||||||
}
|
}
|
||||||
packID, questID, ok := parseQuestKey(key)
|
packID, difficulty, questID, ok := parseQuestKey(key)
|
||||||
if !ok || packID != quest.PackID || questID != quest.QuestID {
|
if !ok || packID != quest.PackID || questID != quest.QuestID || difficulty != quest.Difficulty {
|
||||||
return nil, errors.New("progress: invalid saved quest key")
|
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 {
|
for key, raw := range state.Cleared {
|
||||||
packID, questID, ok := parseQuestKey(key)
|
packID, difficulty, questID, ok := parseQuestKey(key)
|
||||||
var cleared bool
|
var cleared bool
|
||||||
if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared {
|
if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared {
|
||||||
return nil, errors.New("progress: invalid cleared quest key")
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -152,7 +190,7 @@ func (s *Store) EnsurePersisted() error {
|
|||||||
// holds mu; failure leaves the in-memory player state unchanged.
|
// 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 {
|
func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error {
|
||||||
if s.storage != nil {
|
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 {
|
for id := range tutorials {
|
||||||
state.Tutorials = append(state.Tutorials, id)
|
state.Tutorials = append(state.Tutorials, id)
|
||||||
}
|
}
|
||||||
@@ -220,7 +258,12 @@ func (s *Store) UpdateQuest(request []byte) (int, error) {
|
|||||||
for key, current := range s.quests {
|
for key, current := range s.quests {
|
||||||
quests[key] = current
|
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 {
|
if err := s.commit(s.position, s.tutorials, quests, s.cleared); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -245,7 +288,7 @@ func (s *Store) SaveUserPosition(request []byte) error {
|
|||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.
|
// ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id.
|
||||||
@@ -345,23 +388,23 @@ func (s *Store) Quest(id int) (QuestProgress, bool) {
|
|||||||
return quest, found
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
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...)
|
quest.Values = append([]int(nil), quest.Values...)
|
||||||
return quest, found
|
return quest, found
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearQuest atomically records a completed quest. Quest identity is the
|
// ClearQuest atomically records a completed quest. Quest identity is the
|
||||||
// (pack, quest) pair because every story pack starts numbering from one.
|
// (pack, quest) pair because every story pack starts numbering from one.
|
||||||
func (s *Store) ClearQuest(questID, packID int) error {
|
func (s *Store) ClearQuest(questID, packID int, level ...int) error {
|
||||||
if questID <= 0 || packID <= 0 {
|
if questID <= 0 || packID <= 0 || questDifficulty(level) < 0 || questDifficulty(level) > 4 {
|
||||||
return ErrInvalidQuest
|
return ErrInvalidQuest
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
key := questKey(packID, questID)
|
key := questKey(packID, questID, level...)
|
||||||
if _, ok := s.cleared[key]; ok {
|
if _, ok := s.cleared[key]; ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -373,25 +416,126 @@ func (s *Store) ClearQuest(questID, packID int) error {
|
|||||||
return s.commit(s.position, s.tutorials, s.quests, cleared)
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
_, found := s.cleared[questKey(packID, questID)]
|
_, found := s.cleared[questKey(packID, questID, level...)]
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearedQuests returns sorted quest IDs for one pack. The detached slice is
|
// ClearedQuests returns sorted quest IDs for one pack. The detached slice is
|
||||||
// safe for response construction without holding the store lock.
|
// 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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
ids := make([]int, 0, len(s.cleared))
|
ids := make([]int, 0, len(s.cleared))
|
||||||
for key := range s.cleared {
|
for key := range s.cleared {
|
||||||
currentPack, questID, ok := parseQuestKey(key)
|
currentPack, difficulty, questID, ok := parseQuestKey(key)
|
||||||
if ok && currentPack == packID {
|
if ok && currentPack == packID && difficulty == questDifficulty(level) {
|
||||||
ids = append(ids, questID)
|
ids = append(ids, questID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Ints(ids)
|
sort.Ints(ids)
|
||||||
return 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,10 +9,17 @@ import (
|
|||||||
// LastPlayedPackID prevents LoginUser from selecting a field that this server
|
// LastPlayedPackID prevents LoginUser from selecting a field that this server
|
||||||
// cannot restore. A fresh account keeps the versioned login seed's pack.
|
// cannot restore. A fresh account keeps the versioned login seed's pack.
|
||||||
func (s *Service) LastPlayedPackID() (uint64, error) {
|
func (s *Service) LastPlayedPackID() (uint64, error) {
|
||||||
id, err := s.state.LastPlayedPackID()
|
id := uint64(s.state.ActivePackID())
|
||||||
if err != nil || id == 0 {
|
if id == 0 {
|
||||||
|
var err error
|
||||||
|
id, err = s.state.LastPlayedPackID()
|
||||||
|
if err != nil {
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
id = uint64(s.startingPack())
|
||||||
|
}
|
||||||
if !s.packUnlocked(int(id)) {
|
if !s.packUnlocked(int(id)) {
|
||||||
return 0, errors.New("world: saved login pack is unavailable")
|
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()
|
storage := stateio.NewMemory()
|
||||||
starter := &player.Starter{Version: "2.35.10"}
|
starter := &player.Starter{Version: "2.35.10"}
|
||||||
inventory, err := player.OpenInventory(storage, starter)
|
inventory, err := player.OpenInventory(storage, starter)
|
||||||
@@ -126,7 +126,7 @@ func TestPackInfoStoryCharactersUsePersistedDeadAndInjuredHP(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if count != 2 {
|
if count != 0 {
|
||||||
t.Fatalf("pack story roster count=%d", count)
|
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
|
// awards the bootstrap pack's real purchase rewards, not a recovery inference
|
||||||
// from a saved position or from the former seed-only unlock chain.
|
// from a saved position or from the former seed-only unlock chain.
|
||||||
func (s *Service) EnsureInitialPackPurchase() error {
|
func (s *Service) EnsureInitialPackPurchase() error {
|
||||||
_, err := s.purchaseStoryPack(s.seed.PackID, true)
|
_, err := s.purchaseStoryPack(s.startingPack(), true)
|
||||||
return err
|
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 {
|
if !exists {
|
||||||
return false
|
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
|
// ContentOpen.TutorialID triggers a tutorial; the client does not use it
|
||||||
// as an authorization requirement. Story NextPackID is navigation only.
|
// as an authorization requirement. Story NextPackID is navigation only.
|
||||||
if pack.Open == nil {
|
if pack.Open == nil {
|
||||||
@@ -83,9 +88,16 @@ func (s *Service) storyPackDBInfoRows() [][]byte {
|
|||||||
var rows [][]byte
|
var rows [][]byte
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
row := wire.AppendVarint(nil, 1, uint64(id))
|
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
|
mainCleared := 0
|
||||||
for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs {
|
for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs {
|
||||||
if s.state.QuestCleared(qid, id) {
|
if s.state.QuestCleared(qid, id, s.questDifficulty(id)) {
|
||||||
mainCleared++
|
mainCleared++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ func TestStoryCatalogUsesTicketsAndQuestLinksAcrossIndependentPacks(t *testing.T
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(counts) != 2 || counts[405] != 1 || counts[701] != 2 {
|
if len(counts) != 2 || counts[405] != 33 || counts[701] != 7 {
|
||||||
t.Fatalf("completed normal levels count sidequests or omit nonseed pack: %v", counts)
|
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]
|
transition := transitions[seed.PackID]
|
||||||
activePack := 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]
|
_, storyKnown := packs[saved.PackID]
|
||||||
_, fieldKnown := fieldPacks[saved.PackID]
|
_, fieldKnown := fieldPacks[saved.PackID]
|
||||||
if storyKnown || fieldKnown {
|
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 := &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.packJamDesign = packJamDesign
|
||||||
service.packSummaryTargets = packSummaryTargets
|
service.packSummaryTargets = packSummaryTargets
|
||||||
service.storyCatalog = storyCatalog
|
service.storyCatalog = storyCatalog
|
||||||
@@ -104,6 +111,14 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
service.questCostumes = questCostumes
|
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)
|
service.attachPackDetailDesign(gameDataRoot, gameDataVersion)
|
||||||
return service, nil
|
return service, nil
|
||||||
}
|
}
|
||||||
@@ -138,6 +153,10 @@ func (s *Service) setCurrentPack(packID int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
battleActive func() bool
|
||||||
|
questDifficulties map[int]map[int]bool
|
||||||
|
startingPackID int
|
||||||
|
storyRoster *gamedata.StoryCharacterCatalog
|
||||||
storyCatalog *gamedata.StoryCatalog
|
storyCatalog *gamedata.StoryCatalog
|
||||||
questCostumes player.CostumeDesignSource
|
questCostumes player.CostumeDesignSource
|
||||||
packDetailDesign func(int) (gamedata.PackDetailDesign, error)
|
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) {
|
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||||
switch path {
|
switch path {
|
||||||
|
case "/QuestInfo", "/QuestAccept", "/QuestGiveUp":
|
||||||
|
return s.handleQuestSelection(path, request)
|
||||||
case "/PackBuy":
|
case "/PackBuy":
|
||||||
return s.handlePackBuy(request)
|
return s.handlePackBuy(request)
|
||||||
case "/PackDetailInfo":
|
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")
|
return 0, nil, true, errors.New("world: CharInfo missing sequence")
|
||||||
}
|
}
|
||||||
var response []byte
|
var response []byte
|
||||||
characters := s.characters.All()
|
characters := s.visibleOwnedCharacters(s.characters.All())
|
||||||
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
if s.tutorialRosterRestricted() {
|
||||||
// The tutorial roster still contains only starter identities, but
|
// The tutorial roster still contains only starter identities, but
|
||||||
// their field HP must come from persisted state rather than falling
|
// their field HP must come from persisted state rather than falling
|
||||||
// through to Starter.Handle's immutable new-account HP.
|
// 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)
|
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)
|
slog.Info("team trace: deliver owned characters", "characters", characters)
|
||||||
for _, character := range characters {
|
for _, character := range characters {
|
||||||
response = wire.AppendBytes(response, 1, encodeCharacter(character))
|
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)
|
response = wire.AppendVarint(response, 2, s.starter.FieldCharControlDeckType)
|
||||||
return 9, response, true, nil
|
return 9, response, true, nil
|
||||||
case "/CostumeInfo":
|
case "/CostumeInfo":
|
||||||
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
|
if s.tutorialRosterRestricted() {
|
||||||
return 0, nil, false, nil
|
return 0, nil, false, nil
|
||||||
}
|
}
|
||||||
var response []byte
|
var response []byte
|
||||||
@@ -239,6 +275,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
|||||||
}
|
}
|
||||||
return 40, response, true, nil
|
return 40, response, true, nil
|
||||||
case "/PackInGameInfo":
|
case "/PackInGameInfo":
|
||||||
|
if s.battleActive != nil && s.battleActive() {
|
||||||
|
return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest)
|
||||||
|
}
|
||||||
pack, err := requestPack(request)
|
pack, err := requestPack(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, true, err
|
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) {
|
if !s.packUnlocked(pack) {
|
||||||
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, 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))
|
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)
|
response, err := s.packInfoFor(pack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, true, err
|
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
|
return 5, response, true, nil
|
||||||
case "/QuestClear":
|
case "/QuestClear":
|
||||||
quest, pack, err := requestQuest(request)
|
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) {
|
if !s.canClear(pack, quest) {
|
||||||
return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, 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 {
|
if err != nil {
|
||||||
return 0, nil, true, err
|
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)
|
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 {
|
if err := s.collection.AttachRewardCostume(s.seed.RewardCostume); err != nil {
|
||||||
return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err)
|
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)
|
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:
|
default:
|
||||||
return 0, nil, false, nil
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
quests, found := s.questsFor(packID)
|
quests, found := s.questsFor(packID)
|
||||||
@@ -347,18 +416,18 @@ func (s *Service) canClear(packID, quest int) bool {
|
|||||||
if !found {
|
if !found {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
if design.Type == 0 {
|
if design.Type == 0 {
|
||||||
return quest == s.firstUnclearedQuestFor(packID)
|
return quest == s.firstUnclearedQuestFor(packID)
|
||||||
}
|
}
|
||||||
_, active := s.state.QuestInPack(quest, packID)
|
_, active := s.state.QuestInPack(quest, packID, s.questDifficultyFor(packID, quest))
|
||||||
return active
|
return active
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) {
|
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 s.wallet != nil {
|
||||||
if _, err := s.wallet.GrantQuestOnce(identity, designRewards); err != nil {
|
if _, err := s.wallet.GrantQuestOnce(identity, designRewards); err != nil {
|
||||||
return nil, nil, fmt.Errorf("world: grant quest currency: %w", err)
|
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:
|
case 11:
|
||||||
// Quest 26's character/costume instances come from the versioned
|
// Quest 26's character/costume instances come from the versioned
|
||||||
// story seed and are encoded below; they are not stackable items.
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
if s.collection == nil || s.questCostumes == nil {
|
if s.collection == nil || s.questCostumes == nil {
|
||||||
@@ -399,7 +468,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.
|
|||||||
}
|
}
|
||||||
var costumeIDs []uint64
|
var costumeIDs []uint64
|
||||||
for _, reward := range designRewards {
|
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)
|
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] {
|
for _, reward := range questPictorialItems[quest] {
|
||||||
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
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) {
|
func (s *Service) packInfoFor(packID int) ([]byte, error) {
|
||||||
var out []byte
|
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 {
|
if active := s.firstUnclearedQuestFor(packID); active != 0 {
|
||||||
quest := wire.AppendVarint(nil, 1, uint64(active))
|
quest := s.questInfoWire(packID, active)
|
||||||
quest = wire.AppendVarint(quest, 6, uint64(packID))
|
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)
|
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 {
|
if len(cleared) != 0 {
|
||||||
var packed []byte
|
var packed []byte
|
||||||
for _, id := range cleared {
|
for _, id := range cleared {
|
||||||
@@ -495,7 +559,7 @@ func (s *Service) packInfoFor(packID int) ([]byte, error) {
|
|||||||
position := "{}"
|
position := "{}"
|
||||||
mapID := 0
|
mapID := 0
|
||||||
restored := false
|
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] {
|
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)
|
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
|
return 0
|
||||||
}
|
}
|
||||||
for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs {
|
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 id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0
|
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
|
var rewards []byte
|
||||||
if s.collection != nil {
|
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)...)
|
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.
|
// not an ItemDBInfo with a fabricated stack count.
|
||||||
rewards = wire.AppendBytes(rewards, 4, player.EquipmentWire(*questEquipment))
|
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)
|
rewardCharacter := encodeCharacter(s.seed.RewardCharacter)
|
||||||
// Pictorial state: acquired level 1, progress 60.
|
// Pictorial state: acquired level 1, progress 60.
|
||||||
rewardCharacter = wire.AppendBytes(rewardCharacter, 12, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 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
|
var out []byte
|
||||||
out = wire.AppendBytes(out, 1, rewards)
|
out = wire.AppendBytes(out, 1, rewards)
|
||||||
next := s.nextQuestFor(packID, quest)
|
next := s.nextQuestFor(packID, quest)
|
||||||
|
if s.storyCatalog.Packs[packID].Quests[quest].Type != 0 {
|
||||||
|
next = 0
|
||||||
|
}
|
||||||
if 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 {
|
} else {
|
||||||
// QuestClearResponse.QuestInfo is dereferenced by the client
|
// QuestClearResponse.QuestInfo is dereferenced by the client
|
||||||
// even when this is the final quest of a pack. An explicitly present,
|
// 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.AppendBytes(out, 2, nil)
|
||||||
}
|
}
|
||||||
out = wire.AppendVarint(out, 3, uint64(quest))
|
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
|
// The final normal quest unlocks PackTable.NextPackId. Without these
|
||||||
// PackDBInfo updates the client cannot find the next story pack and
|
// PackDBInfo updates the client cannot find the next story pack and
|
||||||
// falls back to presenting the hard-difficulty objective.
|
// 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)
|
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}
|
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 {
|
for index, characterID := range deckIDs {
|
||||||
deck := wire.AppendVarint(nil, 1, characterID)
|
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)
|
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)
|
out = wire.AppendBytes(out, 12, nil)
|
||||||
return wire.AppendBytes(out, 13, nil)
|
return wire.AppendBytes(out, 13, nil)
|
||||||
}
|
}
|
||||||
@@ -666,7 +740,7 @@ func (s *Service) packCompleteFor(packID int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if !s.state.QuestCleared(id, packID) {
|
if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,13 +761,11 @@ func (s *Service) accountPackInfo() []byte {
|
|||||||
}
|
}
|
||||||
for _, info := range s.packDBInfoRows() {
|
for _, info := range s.packDBInfoRows() {
|
||||||
id, _, _ := wire.Varint(info, 1)
|
id, _, _ := wire.Varint(info, 1)
|
||||||
if !s.packCompleteFor(int(id)) {
|
for level := 0; level <= 4; level++ {
|
||||||
continue
|
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)
|
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
|
// PictorialCharacters hides quest-26 rewards until they are earned, even
|
||||||
// though their instances already exist in the versioned world seed.
|
// though their instances already exist in the versioned world seed.
|
||||||
func (s *Service) PictorialCharacters() []player.Character {
|
func (s *Service) PictorialCharacters() []player.Character {
|
||||||
if s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) && s.characters != nil {
|
if !s.tutorialRosterRestricted() && s.characters != nil {
|
||||||
return s.characters.RawAll()
|
return s.visibleOwnedCharacters(s.characters.RawAll())
|
||||||
}
|
}
|
||||||
return append([]player.Character(nil), s.starter.Characters...)
|
return append([]player.Character(nil), s.starter.Characters...)
|
||||||
}
|
}
|
||||||
@@ -718,7 +790,7 @@ func (s *Service) PictorialCostumes() []player.Costume {
|
|||||||
if s.collection != nil {
|
if s.collection != nil {
|
||||||
result = s.collection.Costumes()
|
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)
|
result = append(result, s.seed.RewardCostume)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) {
|
func TestPackInfoDoesNotEmitFixedRewardRosterWithoutActiveFormation(t *testing.T) {
|
||||||
storage := stateio.NewMemory()
|
storage := stateio.NewMemory()
|
||||||
starter := &player.Starter{Version: "2.35.10"}
|
starter := &player.Starter{Version: "2.35.10"}
|
||||||
inventory, err := player.OpenInventory(storage, starter)
|
inventory, err := player.OpenInventory(storage, starter)
|
||||||
@@ -455,7 +455,7 @@ func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if level != 20 {
|
if level != 0 {
|
||||||
t.Fatalf("pack character level=%d want20", level)
|
t.Fatalf("fixed reward character leaked without an authored active formation: level=%d", level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user