fix(server): serve verified story pack exploration details

This commit is contained in:
2026-10-04 14:16:55 +08:00
parent 31d605eaca
commit 3825ee7e60
5 changed files with 210 additions and 0 deletions
@@ -0,0 +1,67 @@
package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PackDetailDesign identifies the monsters whose state is consumed by the
// client's PackMapRewardInfo. Ordinary story battle rewards are excluded.
type PackDetailDesign struct{ RegenMonsterIDs []int }
func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, error) {
if packID <= 0 {
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail pack %d", packID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return PackDetailDesign{}, err
}
dir, err := os.MkdirTemp("", "bd2-pack-detail-")
if err != nil {
return PackDetailDesign{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return PackDetailDesign{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return PackDetailDesign{}, err
}
defer db.Close()
return loadPackDetailDesign(db)
}
func loadPackDetailDesign(db *sql.DB) (PackDetailDesign, error) {
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldMonsterTable ORDER BY id")
if err != nil {
return PackDetailDesign{}, err
}
defer rows.Close()
var design PackDetailDesign
for rows.Next() {
var id int
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return PackDetailDesign{}, err
}
values := map[int]uint64{}
for _, field := range []int{24, 30, 31} {
v, err := packedInts(raw, field)
if err != nil || len(v) > 1 || id <= 0 {
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail monster %d field %d", id, field)
}
if len(v) == 1 {
values[field] = v[0]
}
}
if values[31] == 1 && values[30] != 3 && values[24] > 0 {
design.RegenMonsterIDs = append(design.RegenMonsterIDs, id)
}
}
return design, rows.Err()
}
@@ -0,0 +1,34 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestPackDetailDesignOnlyIncludesClientRegenRewardCategory(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE FieldMonsterTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
for _, r := range []struct{ id, regen, typ, skip int }{{1, 0, 0, 0}, {2, 1, 0, 0}, {3, 0, 0, 1}, {4, 1, 3, 1}, {5, 1, 0, 1}, {6, 1, 1, 1}, {7, 1, 0, 2}} {
raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 24, uint64(r.regen)), 30, uint64(r.typ)), 31, uint64(r.skip))
if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(?,?)", r.id, raw); err != nil {
t.Fatal(err)
}
}
design, err := loadPackDetailDesign(db)
if err != nil || len(design.RegenMonsterIDs) != 2 || design.RegenMonsterIDs[0] != 5 || design.RegenMonsterIDs[1] != 6 {
t.Fatalf("design=%+v err=%v", design, err)
}
if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(wire.AppendVarint(nil, 24, 1), 24, 2)); err != nil {
t.Fatal(err)
}
if _, err := loadPackDetailDesign(db); err == nil {
t.Fatal("duplicate scalar accepted")
}
}
+45
View File
@@ -0,0 +1,45 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"fmt"
)
func (s *Service) handlePackDetail(request []byte) (int, []byte, bool, error) {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > 0x7fffffff {
return 0, nil, true, fmt.Errorf("%w: PackDetailInfo invalid sequence", ErrInvalidRequest)
}
packID, err := requestPack(request)
if err != nil {
return 0, nil, true, err
}
if !s.packUnlocked(packID) || !s.packSummaryTargets[packID] {
return 0, nil, true, fmt.Errorf("%w: PackDetailInfo unavailable pack %d", ErrInvalidRequest, packID)
}
if s.packDetailDesign == nil {
return 0, nil, true, fmt.Errorf("world: missing pack detail design loader")
}
design, err := s.packDetailDesign(packID)
if err != nil {
return 0, nil, true, err
}
if len(design.RegenMonsterIDs) > 0 {
// Empty monster state means defeated to the client. Until regeneration
// has a persisted domain, do not invent active flags or claim history.
return 0, nil, true, fmt.Errorf("%w: PackDetailInfo pack %d requires regenerating monster state", ErrInvalidRequest, packID)
}
// These repeated fields contain obtained field objects and completed
// research objects, not static candidates. No such grants exist in the
// current domain. Ordinary story battle inventory grants are excluded by
// the GameData monster category verified above. The response's protobuf
// collections are non-null even when the payload has no encoded fields.
return 627, []byte{}, true, nil
}
func (s *Service) attachPackDetailDesign(root, version string) {
s.packDetailDesign = func(packID int) (gamedata.PackDetailDesign, error) {
return gamedata.LoadPackDetailDesign(root, version, packID)
}
}
@@ -0,0 +1,60 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"errors"
"testing"
)
func TestPackDetailUsesVerifiedDesignAndRejectsUnavailableState(t *testing.T) {
s := testService()
s.packSummaryTargets = map[int]bool{21: true, 22: true}
called := 0
s.packDetailDesign = func(id int) (gamedata.PackDetailDesign, error) {
called++
if id != 21 {
t.Fatalf("wrong pack %d", id)
}
return gamedata.PackDetailDesign{}, nil
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, 21)
if err := s.state.ClearQuest(1, 21); err != nil {
t.Fatal(err)
}
code, response, handled, err := s.Handle("/PackDetailInfo", request)
if err != nil || !handled || code != 627 || response == nil || len(response) != 0 || called != 1 {
t.Fatalf("detail code=%d body=%x handled=%v err=%v calls=%d", code, response, handled, err, called)
}
s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) {
return gamedata.PackDetailDesign{RegenMonsterIDs: []int{101}}, nil
}
_, _, handled, err = s.Handle("/PackDetailInfo", request)
if !handled || !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("unsupported regeneration handled=%v err=%v", handled, err)
}
s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) {
return gamedata.PackDetailDesign{}, errors.New("missing design")
}
if _, _, _, err := s.Handle("/PackDetailInfo", request); err == nil {
t.Fatal("design failure swallowed")
}
}
func TestPackDetailRejectsInvalidAndLockedRequestsBeforeReadingDesign(t *testing.T) {
s := testService()
s.packSummaryTargets = map[int]bool{21: true, 22: true}
s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) {
t.Fatal("invalid request read GameData")
return gamedata.PackDetailDesign{}, nil
}
for _, request := range [][]byte{nil, {8}, wire.AppendVarint(nil, 1, 1), wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 22), wire.AppendVarint(wire.AppendVarint(nil, 1, 0x80000000), 2, 21)} {
if _, _, handled, err := s.Handle("/PackDetailInfo", request); !handled || err == nil {
t.Fatalf("request %x handled=%v err=%v", request, handled, err)
}
}
delete(s.packSummaryTargets, 21)
if _, _, _, err := s.Handle("/PackDetailInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)); err == nil {
t.Fatal("non-target pack accepted")
}
}
+4
View File
@@ -85,6 +85,7 @@ 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.packSummaryTargets = packSummaryTargets
service.attachPackDetailDesign(gameDataRoot, gameDataVersion)
return service, nil
}
@@ -118,6 +119,7 @@ func (s *Service) setCurrentPack(packID int) {
}
type Service struct {
packDetailDesign func(int) (gamedata.PackDetailDesign, error)
packSummaryTargets map[int]bool
fieldPacks map[int]gamedata.FieldPack
squadLevel func() (uint64, error)
@@ -156,6 +158,8 @@ func (s *Service) AttachDecks(decks *deck.Store) error {
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
switch path {
case "/PackDetailInfo":
return s.handlePackDetail(request)
case "/PackSummaryInfoList":
return s.handlePackSummary(request)
case "/PackInfo":