fix(server): separate story visuals from battle parties and preserve deck choices across quests

This commit is contained in:
2026-10-06 16:07:08 +08:00
parent 0997633b59
commit 8f15b4ec95
4 changed files with 246 additions and 116 deletions
@@ -15,13 +15,9 @@ type StoryCharacterCatalog struct {
}
func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCostumes ...uint64) (*StoryCharacterCatalog, error) {
var placeholder uint64
if len(placeholderCostumes) > 1 {
return nil, fmt.Errorf("gamedata: multiple story placeholders")
}
if len(placeholderCostumes) == 1 {
placeholder = placeholderCostumes[0]
}
db, cleanup, err := openStatDatabase(root, version)
if err != nil {
return nil, err
@@ -85,12 +81,8 @@ func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCos
if err != nil {
return nil, err
}
for _, story := range formation.StoryCostumes {
if story.UniqueCharacterID == unique && (placeholder == 0 || story.CostumeID != placeholder) {
costume = story.CostumeID
break
}
}
// CharGroup supplies battle instances. StoryCharGroup supplies
// cosmetic field actors and cannot override a battle costume.
base, err := stats.BaseStats(row.CharacterID, row.Level)
if err != nil {
return nil, err
+65 -81
View File
@@ -7,8 +7,13 @@ import (
"fmt"
)
// ResolveStoryParty combines the authored temporary CharGroup members with
// actual account instances named by StoryCharGroup costume rows.
// A normal battle deck has five character slots (CommonPacket and DeckSave).
const storyBattlePartySize = 5
// ResolveStoryParty applies the authored temporary CharGroup members to the
// saved battle party. StoryCharGroup costume designs only describe the field
// story cast: FieldDeckPacket builds their cosmetic actors without inventory
// identities, so they do not reserve battle slots, including placeholders.
func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, error) {
if s.storyRoster == nil {
return nil, nil
@@ -26,6 +31,7 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
}
temporary := make([]player.Character, 0, len(designs))
reused := make([]player.Character, 0, len(designs))
var authoredOrder []uint64
for _, d := range designs {
var existing player.Character
for _, c := range s.characters.RawAll() {
@@ -36,119 +42,87 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
}
if existing.InvenIndex != 0 {
reused = append(reused, existing)
authoredOrder = append(authoredOrder, existing.InvenIndex)
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
authoredOrder = append(authoredOrder, index)
temporary = append(temporary, player.Character{InvenIndex: index, ID: d.CharacterID, HP: d.HP, Level: d.Level, CostumeID: d.CostumeID, TalentLevel: d.InitialTalentLevel})
}
if s.characters == nil {
return nil, fmt.Errorf("world: story character store unavailable")
if len(designs) > storyBattlePartySize {
return nil, fmt.Errorf("world: pack%d quest%d character group%d exceeds battle capacity: %d", packID, questID, formation.CharGroupID, len(designs))
}
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{}
authored := make(map[uint64]player.Character, len(designs))
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
authored[current.InvenIndex] = current
}
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
authored[current.InvenIndex] = current
}
// 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 s.seed.PlaceholderCostumeID != 0 && costume.CostumeID == s.seed.PlaceholderCostumeID {
continue
}
temporaryMember := false
for _, design := range designs {
if design.UniqueCharacterID == costume.UniqueCharacterID {
temporaryMember = true
owned := map[uint64]player.Character{}
for _, c := range s.visibleOwnedCharacters(s.characters.All()) {
banned := false
for _, row := range formation.Characters {
// PackManager.CalcBanCharUniqueId groups growth rows by ID/10.
if row.Banned && row.CharacterID/10 == c.ID/10 {
banned = true
break
}
}
if temporaryMember {
if banned {
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
}
owned[c.InvenIndex] = c
}
// 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 (s.seed.PlaceholderCostumeID == 0 || costume.CostumeID != s.seed.PlaceholderCostumeID) || s.decks == nil {
continue
}
var candidates []uint64
for _, entry := range s.decks.CurrentFieldDeck() {
candidates = append(candidates, entry.CharacterInvenIndex)
}
var candidates []uint64
if s.decks != nil {
for _, entry := range s.decks.CurrentDeck() {
candidates = append(candidates, entry.CharacterInvenIndex)
}
for _, index := range candidates {
if used[index] {
continue
// With no saved battle choice, the account's field party supplies its
// initial controlled members. A saved battle choice takes precedence.
if len(candidates) == 0 {
for _, entry := range s.decks.CurrentFieldDeck() {
candidates = append(candidates, entry.CharacterInvenIndex)
}
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)
}
}
result := make([]player.Character, 0, storyBattlePartySize)
used := map[uint64]bool{}
controlledSlots := storyBattlePartySize - len(authored)
for _, index := range candidates {
if used[index] {
continue
}
if c, ok := authored[index]; ok {
result = append(result, c)
used[index] = true
continue
}
if c, ok := owned[index]; ok && controlledSlots > 0 {
result = append(result, c)
used[index] = true
controlledSlots--
}
}
for _, index := range authoredOrder {
if !used[index] {
result = append(result, authored[index])
used[index] = true
break
}
}
return result, nil
@@ -166,10 +140,20 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by
entries := make([]deck.DeckEntry, 0, len(party))
characters := make([][]byte, 0, len(party))
wires := make([][]byte, 0, len(party))
savedPositions := map[uint64]uint64{}
if s.decks != nil {
for _, entry := range s.decks.CurrentDeck() {
savedPositions[entry.CharacterInvenIndex] = entry.CostumeInvenIndex
}
}
for i, c := range party {
position := ^uint64(0)
if i < len(formation.DeckList) {
position = formation.DeckList[i]
} else if len(formation.DeckList) == 0 {
if saved, ok := savedPositions[c.InvenIndex]; ok {
position = saved
}
}
entry := deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: position, Slot: uint64(i + 1)}
entries = append(entries, entry)
@@ -179,7 +163,7 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by
}
if s.decks != nil {
if err := s.decks.SetStoryParty(entries); err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("world: save battle party pack%d quest%d charGroup%d storyCharGroup%d members%d: %w", packID, questID, formation.CharGroupID, formation.StoryCharGroupID, len(entries), err)
}
}
return characters, wires, nil
@@ -2,8 +2,10 @@ package world
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"os"
@@ -93,6 +95,139 @@ func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
t.Fatal("temporary blacksmith omitted from party CharDBInfo")
}
// Quest13 and quest14 share CharGroup102, but their field StoryCharGroup grows
// from three to four costume designs. Owning the whole field cast used to add
// a sixth battle member and roll back this otherwise completed quest clear.
func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1}, 996000)
if err != nil {
t.Fatal(err)
}
catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{
{InvenIndex: 101, ID: 10, HP: 71, Level: 30, CostumeID: 101, UseCostume: 1001, TalentLevel: 1, TalentExp: 13, Exp: 123},
{InvenIndex: 102, ID: 20, HP: 72, Level: 30, CostumeID: 202, UseCostume: 1002, TalentLevel: 1, TalentExp: 14, Exp: 124},
{InvenIndex: 103, ID: 130, HP: 73, Level: 30, CostumeID: 1301, UseCostume: 1003, TalentLevel: 1, TalentExp: 15, Exp: 125},
{InvenIndex: 104, ID: 140, HP: 74, Level: 30, CostumeID: 1401, UseCostume: 1004, TalentLevel: 1},
}, Costumes: []player.Costume{
{InvenIndex: 1001, ID: 101, UseChar: 101}, {InvenIndex: 1002, ID: 202, UseChar: 102},
{InvenIndex: 1003, ID: 1301, UseChar: 103}, {InvenIndex: 1004, ID: 1401, UseChar: 104},
}}
path := filepath.Join(t.TempDir(), "state.db")
open := func() (*accountstate.Repository, *Service) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
s := testService()
s.seed.PackID, s.seed.StartQuestID, s.startingPackID = 1, 1, 1
s.storyRoster, s.storyCatalog, s.starter = roster, catalog, seed
s.quests = catalog.Packs[1].Quests
s.state, err = progress.OpenStore(repo)
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(repo, seed)
if err != nil {
t.Fatal(err)
}
s.characters, err = player.OpenCharacterStore(repo, seed.Characters, s.inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err = s.characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 104}}})
if err != nil {
t.Fatal(err)
}
return repo, s
}
repo, s := open()
for id := 1; id < 13; id++ {
if err := s.state.ClearQuest(id, 1); err != nil {
t.Fatal(err)
}
}
update := wire.AppendVarint(selectionRequest(13, 1, 0), 4, 1)
if _, err := s.state.UpdateQuest(update); err != nil {
t.Fatal(err)
}
party, err := s.ResolveStoryParty(1, 13)
if err != nil || len(party) != 3 { // Two temporaries and the only initial field choice.
t.Fatalf("initial story party=%+v err=%v", party, err)
}
var saved []deck.DeckEntry
for _, c := range party {
if !player.IsStoryCharacter(c) {
continue
}
if err := s.characters.SetCurrentHealth(c.InvenIndex, 7+uint64(len(saved))); err != nil {
t.Fatal(err)
}
saved = append(saved, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(len(saved) * 2), Slot: uint64(len(saved) + 1)})
}
for _, c := range seed.Characters[:3] {
saved = append(saved, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(len(saved) * 2), Slot: uint64(len(saved) + 1)})
}
if err := s.decks.SetStoryParty(saved); err != nil {
t.Fatal(err)
}
before := s.characters.RawAll()
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, response, handled, err := s.Handle("/QuestClear", selectionRequest(13, 1, 0))
if err != nil || !handled {
_ = op.Rollback()
t.Fatalf("quest13 clear handled=%v err=%v", handled, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
var echoed []deck.DeckEntry
var joins int
if err := wire.Walk(response, func(f wire.Field) error {
if f.Number == 5 {
joins++
}
if f.Number == 4 {
index, _, _ := wire.Varint(f.Value, 1)
position, _, _ := wire.Varint(f.Value, 2)
slot, _, _ := wire.Varint(f.Value, 3)
echoed = append(echoed, deck.DeckEntry{CharacterInvenIndex: index, CostumeInvenIndex: position, Slot: slot})
}
return nil
}); err != nil || !reflect.DeepEqual(echoed, saved) || joins != 0 {
t.Fatalf("clear changed saved battle choices/positions or repeated join UI: deck=%+v want=%+v joins=%d err=%v", echoed, saved, joins, err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
_, s = open()
if !s.state.QuestCleared(13, 1) || s.firstUnclearedQuestFor(1) != 14 {
t.Fatal("quest13 clear did not survive SQLite reopen")
}
if !reflect.DeepEqual(s.decks.CurrentDeck(), saved) || !reflect.DeepEqual(s.characters.RawAll(), before) {
t.Fatal("quest transition or reopen changed party, positions, character stats, talents, or health")
}
party, err = s.ResolveStoryParty(1, 14)
if err != nil || len(party) != 5 {
t.Fatalf("reopened quest14 battle party=%+v err=%v", party, err)
}
}
func TestInstalledAdjacentStoryPartyRetainsHealth(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
@@ -24,23 +24,31 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for authored party transition regression")
}
roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1, 21}, 996000)
catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
for _, pack := range []int{1, 21} {
var packs []int
for id := range catalog.Packs {
packs = append(packs, id)
}
sort.Ints(packs)
roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", packs, 996000)
if err != nil {
t.Fatal(err)
}
for _, pack := range packs {
t.Run(fmt.Sprintf("pack%d", pack), func(t *testing.T) {
formations, err := gamedata.LoadQuestFormations(root, "20260923193640", pack)
if err != nil {
t.Fatal(err)
}
var ids []int
for id := range formations {
ids = append(ids, id)
}
sort.Ints(ids)
design := catalog.Packs[pack]
ids := append([]int(nil), design.MainQuestIDs...)
path := filepath.Join(t.TempDir(), "state.db")
seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{{InvenIndex: 77, ID: 10, Level: 1, HP: 7, CostumeID: 101}}}
seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{
{InvenIndex: 77, ID: 10, Level: 1, HP: 7, CostumeID: 101, TalentLevel: 1},
{InvenIndex: 78, ID: 20, Level: 1, HP: 7, CostumeID: 202, TalentLevel: 1},
{InvenIndex: 79, ID: 130, Level: 1, HP: 7, CostumeID: 1301, TalentLevel: 1},
{InvenIndex: 80, ID: 140, Level: 1, HP: 7, CostumeID: 1401, TalentLevel: 1},
{InvenIndex: 81, ID: 350, Level: 1, HP: 7, CostumeID: 3501, TalentLevel: 1},
}}
open := func() (*accountstate.Repository, *Service) {
t.Helper()
repo, err := accountstate.Open(path)
@@ -66,20 +74,35 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
if err = s.characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
var chosen []deck.DeckEntry
for i, c := range seed.Characters {
chosen = append(chosen, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(i), Slot: uint64(i + 1)})
}
s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}})
if err != nil {
t.Fatal(err)
}
if len(s.decks.CurrentDeck()) == 0 {
if err = s.decks.SetStoryParty(chosen); err != nil {
t.Fatal(err)
}
}
s.quests = map[int]gamedata.QuestDesign{}
for _, id := range ids {
s.quests[id] = gamedata.QuestDesign{ID: id}
q := design.Quests[id]
s.quests[id] = gamedata.QuestDesign{ID: id, Type: q.Type, NextQuestID: q.NextQuestID, PriorQuestID: q.PriorQuestID}
}
attachTestStoryCatalog(s)
s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{pack: {ID: pack, Quests: s.quests, MainQuestIDs: ids}}}
s.packs = map[int]map[int]gamedata.QuestDesign{pack: s.quests}
return repo, s
}
repo, s := open()
unchanged, joined := false, false
for i, id := range ids[:len(ids)-1] {
unchanged := false
for _, id := range ids {
next := design.Quests[id].NextQuestID
if next == 0 {
continue
}
before, err := s.ResolveStoryParty(pack, id)
if err != nil {
t.Fatal(err)
@@ -96,7 +119,7 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
if err = op.Commit(); err != nil {
t.Fatal(err)
}
after, err := s.ResolveStoryParty(pack, ids[i+1])
after, err := s.ResolveStoryParty(pack, next)
if err != nil {
t.Fatal(err)
}
@@ -122,11 +145,10 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
decks++
}
return nil
}); err != nil || !reflect.DeepEqual(got, want) || len(after) > 0 && decks != len(after) {
t.Fatalf("pack%d quest%d→%d joins=%v want=%v formation=%d/%d err=%v", pack, id, ids[i+1], got, want, decks, len(after), err)
}); err != nil || !reflect.DeepEqual(got, want) || len(after) > 0 && decks != len(after) || decks > 5 {
t.Fatalf("pack%d quest%d→%d joins=%v want=%v formation=%d/%d err=%v", pack, id, next, got, want, decks, len(after), err)
}
unchanged = unchanged || len(before) > 0 && len(want) == 0
joined = joined || len(want) > 0
if pack == 1 && id == 2 {
if len(got) != 0 {
t.Fatal("pack-1 gate quest repeated character acquisition")
@@ -140,12 +162,9 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
t.Fatal("quest transition healed or replaced existing character")
}
}
if unchanged && (pack == 1 && id >= 3 || pack == 21 && joined) {
break
}
}
if !unchanged || pack == 21 && !joined {
t.Fatal("authored regression did not exercise both stable and joining party transitions")
if !unchanged {
t.Fatal("authored regression did not exercise stable party transitions")
}
})
}