diff --git a/go/internal/server/gamedata/pack_detail.go b/go/internal/server/gamedata/pack_detail.go new file mode 100644 index 0000000..3b119ab --- /dev/null +++ b/go/internal/server/gamedata/pack_detail.go @@ -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() +} diff --git a/go/internal/server/gamedata/pack_detail_test.go b/go/internal/server/gamedata/pack_detail_test.go new file mode 100644 index 0000000..a149ea9 --- /dev/null +++ b/go/internal/server/gamedata/pack_detail_test.go @@ -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") + } +} diff --git a/go/internal/server/world/pack_detail.go b/go/internal/server/world/pack_detail.go new file mode 100644 index 0000000..2f1b7e1 --- /dev/null +++ b/go/internal/server/world/pack_detail.go @@ -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) + } +} diff --git a/go/internal/server/world/pack_detail_test.go b/go/internal/server/world/pack_detail_test.go new file mode 100644 index 0000000..1fa1c04 --- /dev/null +++ b/go/internal/server/world/pack_detail_test.go @@ -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") + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index f7dfa40..5c8b8d7 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -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":