From aa5f0afaf7ad7013facb90cfd4a1c0ae6e065189 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 18:06:50 +0800 Subject: [PATCH] fix(server): implement waypoints, chest rewards and NPC talk progress --- go/cmd/bd2server/main.go | 15 ++ go/internal/server/deck/deck.go | 74 ++---- go/internal/server/deck/deck_test.go | 6 + go/internal/server/deck/waypoint.go | 144 ++++++++++++ go/internal/server/deck/waypoint_test.go | 127 ++++++++++ go/internal/server/feature/feature.go | 1 - go/internal/server/feature/feature_test.go | 9 +- go/internal/server/feature/standalone.go | 3 - go/internal/server/gamedata/achievement.go | 73 ++++++ .../server/gamedata/achievement_test.go | 34 +++ go/internal/server/gamedata/field_objects.go | 173 ++++++++++++++ .../server/gamedata/field_objects_test.go | 45 ++++ go/internal/server/gamedata/field_reset.go | 63 +++++ go/internal/server/gamedata/waypoint.go | 109 +++++++++ .../missions/achievement_claim_state.go | 17 ++ go/internal/server/progress/chests.go | 76 ++++++ go/internal/server/session/server.go | 7 - go/internal/server/session/server_test.go | 10 +- go/internal/server/world/achievements.go | 220 ++++++++++++++++++ go/internal/server/world/achievements_test.go | 198 ++++++++++++++++ go/internal/server/world/field_objects.go | 193 +++++++++++++++ .../server/world/field_objects_test.go | 194 +++++++++++++++ go/internal/server/world/npc_dialog.go | 27 +++ go/internal/server/world/npc_dialog_test.go | 41 ++++ go/internal/server/world/pack_detail.go | 15 +- go/internal/server/world/pack_detail_test.go | 40 ++++ go/internal/server/world/pack_summary.go | 31 ++- go/internal/server/world/waypoint.go | 34 +++ go/internal/server/world/world.go | 9 + 29 files changed, 1902 insertions(+), 86 deletions(-) create mode 100644 go/internal/server/deck/waypoint.go create mode 100644 go/internal/server/deck/waypoint_test.go create mode 100644 go/internal/server/gamedata/achievement.go create mode 100644 go/internal/server/gamedata/achievement_test.go create mode 100644 go/internal/server/gamedata/field_objects.go create mode 100644 go/internal/server/gamedata/field_objects_test.go create mode 100644 go/internal/server/gamedata/field_reset.go create mode 100644 go/internal/server/gamedata/waypoint.go create mode 100644 go/internal/server/missions/achievement_claim_state.go create mode 100644 go/internal/server/progress/chests.go create mode 100644 go/internal/server/world/achievements.go create mode 100644 go/internal/server/world/achievements_test.go create mode 100644 go/internal/server/world/field_objects.go create mode 100644 go/internal/server/world/field_objects_test.go create mode 100644 go/internal/server/world/npc_dialog.go create mode 100644 go/internal/server/world/npc_dialog_test.go create mode 100644 go/internal/server/world/waypoint.go diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 45483d2..c1217d8 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -514,6 +514,12 @@ func serve(args []string) (serveErr error) { if err := worldService.AttachDecks(deckStateStore); err != nil { return fmt.Errorf("attach world deck state: %w", err) } + if err := worldService.AttachWaypointRuntime(gameData, *gameDataVersion); err != nil { + return fmt.Errorf("attach waypoint runtime: %w", err) + } + if err := worldService.AttachFieldObjectRuntime(gameData, *gameDataVersion); err != nil { + return fmt.Errorf("attach field object runtime: %w", err) + } if err := ownedEquipment.AttachCharacters(worldService.CharacterService()); err != nil { return fmt.Errorf("attach equipment character state: %w", err) } @@ -641,6 +647,14 @@ func serve(args []string) (serveErr error) { _, buffs, err := pictorialService.Snapshot() return buffs, err }) + achievementCounterDesign, err := gamedata.LoadAchievementCounterDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load achievement counters: %w", err) + } + achievementCounters, err := world.NewAchievementService(achievementCounterDesign, stateRepository, missionService) + if err != nil { + return fmt.Errorf("load achievement counter state: %w", err) + } game, err := session.NewServerWithProgress(login, progressState, battleService, worldService, @@ -660,6 +674,7 @@ func serve(args []string) (serveErr error) { starter, mailService, gachaService, + achievementCounters, missionService, pictorialService, schedule.Current(), diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 158d4d6..5b588e4 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -8,9 +8,9 @@ import ( "fmt" "log/slog" "os" - "sort" "sync" + "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/stateio" "bd2server/internal/server/versionconfig" @@ -38,16 +38,16 @@ type Seed struct { AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"` } type state struct { - Version string `json:"version"` - Deck []DeckEntry `json:"deck"` - FieldDeck []FieldEntry `json:"field_deck"` - FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` - Waypoints map[uint64]uint64 `json:"waypoints"` - Costumes map[uint64]uint64 `json:"costumes"` - Packs map[uint64]uint64 `json:"packs"` - HighestTotalBattlePower uint64 `json:"highest_total_battle_power"` - PortraitCostumeID uint64 `json:"portrait_costume_id"` - AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"` + Version string `json:"version"` + Deck []DeckEntry `json:"deck"` + FieldDeck []FieldEntry `json:"field_deck"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` + Waypoints map[uint64][]uint64 `json:"waypoints"` + Costumes map[uint64]uint64 `json:"costumes"` + Packs map[uint64]uint64 `json:"packs"` + HighestTotalBattlePower uint64 `json:"highest_total_battle_power"` + PortraitCostumeID uint64 `json:"portrait_costume_id"` + AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"` } type Store struct { mu sync.RWMutex @@ -62,6 +62,8 @@ type Store struct { collection *player.CollectionStore sessionID string replies map[string]deckReply + waypointDesign func(uint64) (gamedata.WaypointPack, error) + waypointPack func(uint64, bool) error } type deckReply struct { @@ -143,7 +145,7 @@ func NewStore(seed Seed) (*Store, error) { if e := seed.validate(); e != nil { return nil, e } - return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil + return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil } func OpenStore(storage stateio.Store, seed Seed) (*Store, error) { s, e := NewStore(seed) @@ -175,6 +177,9 @@ func OpenStore(storage stateio.Store, seed Seed) (*Store, error) { if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil { return nil, errors.New("deck: invalid saved state") } + if e = validWaypointState(loaded.Waypoints); e != nil { + return nil, e + } s.state = loaded if e = s.loadPresetEntries(); e != nil { return nil, e @@ -210,9 +215,9 @@ func clone(x state) state { y := x y.Deck = append([]DeckEntry(nil), x.Deck...) y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...) - y.Waypoints = map[uint64]uint64{} + y.Waypoints = map[uint64][]uint64{} for k, v := range x.Waypoints { - y.Waypoints[k] = v + y.Waypoints[k] = append([]uint64(nil), v...) } y.Costumes = map[uint64]uint64{} for k, v := range x.Costumes { @@ -410,12 +415,7 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { } return 373, response, true, nil case "/WaypointInfo": - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.RLock() - defer s.mu.RUnlock() - return 31, encodeWaypoints(s.state.Waypoints), true, nil + return s.handleWaypoint(path, req) case "/DeckSave": if e := checkSeq(req); e != nil { return 0, nil, true, e @@ -474,24 +474,8 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { n.FieldCharControlDeckType = v e = s.commit(n) return 288, nil, true, e - case "/WaypointSave": - pack, ok, e := wire.Varint(req, 2) - if e != nil || !ok || pack == 0 { - return 0, nil, true, errors.New("deck: invalid waypoint pack") - } - way, ok, e := wire.Varint(req, 3) - if e != nil || !ok || way == 0 { - return 0, nil, true, errors.New("deck: invalid waypoint") - } - if e = checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - n := clone(s.state) - n.Waypoints[pack] = way - e = s.commit(n) - return 32, nil, true, e + case "/WaypointSave", "/WaypointUse": + return s.handleWaypoint(path, req) case "/CostumeUse": raw, ok, e := wire.Bytes(req, 2) if e != nil || !ok { @@ -608,17 +592,3 @@ func encodeField(xs []FieldEntry) []byte { } return b } -func encodeWaypoints(xs map[uint64]uint64) []byte { - keys := make([]uint64, 0, len(xs)) - for k := range xs { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) - var b []byte - for _, k := range keys { - v := wire.AppendVarint(nil, 1, k) - v = wire.AppendVarint(v, 2, xs[k]) - b = wire.AppendBytes(b, 1, v) - } - return b -} diff --git a/go/internal/server/deck/deck_test.go b/go/internal/server/deck/deck_test.go index d459a80..f11d09c 100644 --- a/go/internal/server/deck/deck_test.go +++ b/go/internal/server/deck/deck_test.go @@ -1,6 +1,7 @@ package deck import ( + "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/stateio" "bd2server/internal/server/versionconfig" @@ -117,6 +118,11 @@ func TestDeckPersistenceAndCommands(t *testing.T) { t.Fatalf("deck save: %d %v", code, e) } way := wire.AppendVarint(nil, 2, 21) + if e = s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) { + return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 212}}}, nil + }, func(uint64, bool) error { return nil }); e != nil { + t.Fatal(e) + } way = wire.AppendVarint(way, 3, 1) if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 { t.Fatalf("way: %d %v", code, e) diff --git a/go/internal/server/deck/waypoint.go b/go/internal/server/deck/waypoint.go new file mode 100644 index 0000000..f398c73 --- /dev/null +++ b/go/internal/server/deck/waypoint.go @@ -0,0 +1,144 @@ +package deck + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + "sort" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +func (s *Store) ConfigureWaypoints(load func(uint64) (gamedata.WaypointPack, error), validate func(uint64, bool) error) error { + if load == nil || validate == nil { + return errors.New("deck: incomplete waypoint runtime") + } + s.mu.Lock() + defer s.mu.Unlock() + s.waypointDesign = load + s.waypointPack = validate + return nil +} + +func (s *Store) ActivatedWaypoint(pack, id uint64) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return hasWaypoint(s.state.Waypoints[pack], id) +} +func hasWaypoint(points []uint64, id uint64) bool { + for _, p := range points { + if p == id { + return true + } + } + return false +} + +func validWaypointState(packs map[uint64][]uint64) error { + for pack, points := range packs { + if pack == 0 || pack > math.MaxInt32 || points == nil { + return errors.New("deck: invalid saved waypoint pack") + } + seen := map[uint64]bool{} + for _, id := range points { + if id == 0 || id > math.MaxInt32 || seen[id] { + return errors.New("deck: invalid saved waypoint activation") + } + seen[id] = true + } + } + return nil +} + +func (s *Store) handleWaypoint(path string, req []byte) (int, []byte, bool, error) { + seq, err := requestSequence(req) + if err != nil { + return 0, nil, true, err + } + pack, found, err := wire.Varint(req, 2) + if err != nil || !found || pack == 0 || pack > math.MaxInt32 { + return 0, nil, true, errors.New("deck: invalid waypoint pack") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.waypointDesign == nil || s.waypointPack == nil { + return 0, nil, true, errors.New("deck: waypoint runtime unavailable") + } + if err = s.waypointPack(pack, path == "/WaypointUse"); err != nil { + return 0, nil, true, err + } + design, err := s.waypointDesign(pack) + if err != nil { + return 0, nil, true, err + } + if path == "/WaypointInfo" { + ids := append([]uint64(nil), s.state.Waypoints[pack]...) + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + var packed []byte + for _, id := range ids { + if _, known := design.Points[id]; !known { + return 0, nil, true, fmt.Errorf("deck: saved waypoint %d absent from pack%d", id, pack) + } + packed = binary.AppendUvarint(packed, id) + } + if len(packed) == 0 { + return 31, nil, true, nil + } + return 31, wire.AppendBytes(nil, 1, packed), true, nil + } + id, found, err := wire.Varint(req, 3) + if err != nil || !found || id == 0 || id > math.MaxInt32 { + return 0, nil, true, errors.New("deck: invalid waypoint") + } + if _, known := design.Points[id]; !known { + return 0, nil, true, errors.New("deck: unknown waypoint") + } + if path == "/WaypointSave" { + if hasWaypoint(s.state.Waypoints[pack], id) { + return 32, nil, true, nil + } + next := clone(s.state) + next.Waypoints[pack] = append(next.Waypoints[pack], id) + err = s.commit(next) + return 32, nil, true, err + } + end, found, err := wire.Varint(req, 4) + if err != nil || !found || end == 0 || end > math.MaxInt32 || end == id { + return 0, nil, true, errors.New("deck: invalid waypoint destination") + } + target, known := design.Points[end] + if !known || target.MapID == 0 || !hasWaypoint(s.state.Waypoints[pack], end) || !hasWaypoint(s.state.Waypoints[pack], id) { + return 0, nil, true, errors.New("deck: waypoint is not activated") + } + moves, found, err := wire.Varint(req, 5) + if err != nil || !found || moves != 1 { + return 0, nil, true, errors.New("deck: invalid waypoint move count") + } + if reply, ok := s.cachedReplyLocked("waypoint-use", seq); ok { + return reply.code, reply.body, true, nil + } + if design.PriceUnit != 0 { + if s.wallet == nil || s.sessionID == "" { + return 0, nil, true, errors.New("deck: waypoint wallet session unavailable") + } + identity := fmt.Sprintf("waypoint:%s:%d", s.sessionID, seq) + switch design.PriceType { + case 4: + _, err = s.wallet.SpendGoldOnce(identity, design.PriceUnit) + case 3: + _, err = s.wallet.SpendFreeJewelryOnce(identity, design.PriceUnit) + case 2: + _, err = s.wallet.SpendJewelryOnce(identity, design.PriceUnit) + default: + err = errors.New("deck: unsupported waypoint currency") + } + if err != nil { + return 0, nil, true, err + } + } + // The client performs its warp and sends SaveUserPosition with scene coordinates. + s.rememberReplyLocked("waypoint-use", seq, 33, nil) + return 33, nil, true, nil +} diff --git a/go/internal/server/deck/waypoint_test.go b/go/internal/server/deck/waypoint_test.go new file mode 100644 index 0000000..8dda509 --- /dev/null +++ b/go/internal/server/deck/waypoint_test.go @@ -0,0 +1,127 @@ +package deck + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "errors" + "testing" +) + +func waypointFixture(t *testing.T) (*Store, *stateio.Memory) { + t.Helper() + memory := stateio.NewMemory() + seededStore := seeded(t) + s, err := OpenStore(memory, Seed{Version: seededStore.state.Version, FieldDeck: seededStore.state.FieldDeck, FieldCharControlDeckType: seededStore.state.FieldCharControlDeckType}) + if err != nil { + t.Fatal(err) + } + configureWaypointFixture(t, s) + return s, memory +} +func configureWaypointFixture(t *testing.T, s *Store) { + t.Helper() + if err := s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) { + return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 10}, 2: {ID: 2, MapID: 20}}, PriceType: 4, PriceUnit: 7}, nil + }, func(pack uint64, _ bool) error { + if pack == 3 { + return errors.New("locked pack") + } + return nil + }); err != nil { + t.Fatal(err) + } +} +func waypointReq(seq, pack, start, end uint64) []byte { + body := wire.AppendVarint(nil, 2, pack) + if start != 0 { + body = wire.AppendVarint(body, 3, start) + } + if end != 0 { + body = wire.AppendVarint(body, 4, end) + body = wire.AppendVarint(body, 5, 1) + } + return req(seq, body) +} + +func TestWaypointActivationPersistsAllIDsAndInfoScopesPack(t *testing.T) { + s, memory := waypointFixture(t) + for _, r := range []struct{ seq, pack, id uint64 }{{1, 1, 2}, {2, 1, 1}, {3, 2, 2}, {4, 1, 1}} { + if _, _, _, err := s.Handle("/WaypointSave", waypointReq(r.seq, r.pack, r.id, 0)); err != nil { + t.Fatal(err) + } + } + reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType}) + if err != nil { + t.Fatal(err) + } + configureWaypointFixture(t, reopened) + code, body, _, err := reopened.Handle("/WaypointInfo", waypointReq(5, 1, 0, 0)) + if err != nil || code != 31 || !bytes.Equal(body, []byte{10, 2, 1, 2}) { + t.Fatalf("pack1 info %d %x %v", code, body, err) + } + _, body, _, err = reopened.Handle("/WaypointInfo", waypointReq(6, 2, 0, 0)) + if err != nil || !bytes.Equal(body, []byte{10, 1, 2}) { + t.Fatalf("pack2 info %x %v", body, err) + } + if len(reopened.state.Waypoints[1]) != 2 { + t.Fatal("duplicate activation persisted") + } +} + +func TestWaypointTravelChargesOnceAndRejectsInvalidBeforeSpending(t *testing.T) { + s, memory := waypointFixture(t) + wallet, err := player.OpenWallet(memory, player.Currency{Gold: 14}) + if err != nil { + t.Fatal(err) + } + s.wallet = wallet + s.BeginSession("test-session") + for i := uint64(1); i <= 2; i++ { + if _, _, _, err = s.Handle("/WaypointSave", waypointReq(i, 1, i, 0)); err != nil { + t.Fatal(err) + } + } + for _, request := range [][]byte{waypointReq(3, 3, 1, 2), waypointReq(4, 1, 1, 99), waypointReq(5, 2, 1, 2)} { + if _, _, _, err = s.Handle("/WaypointUse", request); err == nil { + t.Fatal("invalid travel accepted") + } + if wallet.Snapshot().Gold != 14 { + t.Fatal("invalid travel charged") + } + } + valid := waypointReq(6, 1, 1, 2) + for i := 0; i < 2; i++ { + code, _, _, err := s.Handle("/WaypointUse", valid) + if err != nil || code != 33 { + t.Fatalf("travel %d %v", code, err) + } + } + if wallet.Snapshot().Gold != 7 { + t.Fatal("repeat travel double charged") + } + reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType}) + if err != nil { + t.Fatal(err) + } + configureWaypointFixture(t, reopened) + reopened.wallet = wallet + reopened.BeginSession("test-session") + if _, _, _, err = reopened.Handle("/WaypointUse", valid); err != nil { + t.Fatal(err) + } + if wallet.Snapshot().Gold != 7 { + t.Fatal("restart retry charged") + } + if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(7, 1, 2, 1)); err != nil { + t.Fatal(err) + } + if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(8, 1, 1, 2)); err == nil { + t.Fatal("insufficient balance accepted") + } + if wallet.Snapshot().Gold != 0 { + t.Fatal("failed debit changed wallet") + } +} diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 4e4f55e..8de889c 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -31,7 +31,6 @@ var emptyResponses = map[string]int{ "/EquipPresetInfo": 253, "/EvilCastleDailyRewardState": 441, "/EvilCastleTowerInfo": 200, - "/FieldObjectInfo": 28, "/FieldTrapInfo": 171, "/FireWorksInfo": 554, "/FishingCollectionInfo": 465, diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index a1d87b0..671ada0 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -8,8 +8,8 @@ import ( ) func TestHandleAuditedEmptyResponses(t *testing.T) { - if got := len(EmptyPacketCodes()); got != 30 { - t.Fatalf("audited empty-response registry has %d paths, want 30", got) + if got := len(EmptyPacketCodes()); got != 29 { + t.Fatalf("audited empty-response registry has %d paths, want 29", got) } for path, wantCode := range EmptyPacketCodes() { t.Run(path, func(t *testing.T) { @@ -22,6 +22,11 @@ func TestHandleAuditedEmptyResponses(t *testing.T) { } func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { + for _, path := range []string{"/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate"} { + if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil { + t.Fatalf("stateful route %s still has a stateless success fallback", path) + } + } if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil { t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err) } diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index 0838585..b8dba6e 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -9,7 +9,6 @@ var standaloneDefaults = map[string]int{ "/BalanceVersionCheck": 186, "/ChargeCostInfo": 123, "/EquipInfo": 34, - "/AchievementInfo": 166, "/HuntDispatchInfo": 189, "/EventMissionInfo": 127, "/MissionInfo": 118, @@ -27,7 +26,6 @@ var standaloneDefaults = map[string]int{ "/PvpBattleHistory": 97, "/PvpBattleOnceRewardInfo": 277, "/TodayQuestInfo": 64, - "/WaypointInfo": 31, "/FieldDeckInfo": 273, "/HuntingGroundInfoList": 387, "/FriendRecommend": 211, @@ -42,7 +40,6 @@ var standaloneDefaults = map[string]int{ var commandSuccess = map[string]int{ "/UpdateAgeGate": 0, "/SaveFieldCharControlDeckType": 288, - "/WaypointSave": 32, "/ActiveMap": 0, "/FieldDeckSave": 274, "/CostumeUse": 41, diff --git a/go/internal/server/gamedata/achievement.go b/go/internal/server/gamedata/achievement.go new file mode 100644 index 0000000..40afa75 --- /dev/null +++ b/go/internal/server/gamedata/achievement.go @@ -0,0 +1,73 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "sort" +) + +// AchievementCounterDesign indexes the content groups to which each counter belongs. +// AchievementTable has several tiers with the same group ID; id is not unique. +type AchievementCounterDesign struct{ Groups map[int][]int } + +func LoadAchievementCounterDesign(root, version string) (*AchievementCounterDesign, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + return loadAchievementCounterDesign(db) +} + +func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error) { + rows, err := db.Query("SELECT ProtoBuf FROM AchievementTable") + if err != nil { + return nil, err + } + defer rows.Close() + sets := map[int]map[int]bool{} + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return nil, err + } + fields := map[int]int{} + for _, field := range []int{4, 9, 13} { + values, err := packedInts(raw, field) + if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) { + return nil, fmt.Errorf("gamedata: invalid achievement field%d", field) + } + if len(values) > 0 { + fields[field] = int(values[0]) + } + } + // GetAchievementTablesByConditionType emits root groups, including roots + // whose UI useType is disabled. The wire update carries no content group. + if fields[13] != 0 { + continue + } + group, content := fields[9], fields[4] + if group <= 0 || content < 0 || content > 1 { + return nil, fmt.Errorf("gamedata: invalid achievement group %d/%d", group, content) + } + if sets[group] == nil { + sets[group] = map[int]bool{} + } + sets[group][content] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + design := &AchievementCounterDesign{Groups: map[int][]int{}} + for group, contents := range sets { + for content := range contents { + design.Groups[group] = append(design.Groups[group], content) + } + sort.Ints(design.Groups[group]) + } + if len(design.Groups) == 0 { + return nil, fmt.Errorf("gamedata: empty achievement design") + } + return design, nil +} diff --git a/go/internal/server/gamedata/achievement_test.go b/go/internal/server/gamedata/achievement_test.go new file mode 100644 index 0000000..a1da8a4 --- /dev/null +++ b/go/internal/server/gamedata/achievement_test.go @@ -0,0 +1,34 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestAchievementCounterDesignUsesRootGroupsAndBothContents(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.SetMaxOpenConns(1) + if _, err = db.Exec("CREATE TABLE AchievementTable (ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + for _, row := range [][3]uint64{{7, 0, 0}, {7, 0, 0}, {7, 1, 0}, {8, 0, 7}} { + raw := wire.AppendVarint(nil, 9, row[0]) + raw = wire.AppendVarint(raw, 4, row[1]) + raw = wire.AppendVarint(raw, 13, row[2]) + if _, err = db.Exec("INSERT INTO AchievementTable VALUES (?)", raw); err != nil { + t.Fatal(err) + } + } + d, err := loadAchievementCounterDesign(db) + if err != nil { + t.Fatal(err) + } + if len(d.Groups) != 1 || len(d.Groups[7]) != 2 || d.Groups[7][0] != 0 || d.Groups[7][1] != 1 { + t.Fatalf("wrong group index: %#v", d) + } +} diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/gamedata/field_objects.go new file mode 100644 index 0000000..d5aa736 --- /dev/null +++ b/go/internal/server/gamedata/field_objects.go @@ -0,0 +1,173 @@ +package gamedata + +import ( + "crypto/rand" + "database/sql" + "fmt" + "math/big" + "os" + "path/filepath" +) + +// FieldObjectDesign retains the client table identities, including unsupported +// reward graphs, so callers can reject them without inventing a reward. +type FieldObjectDesign struct{ Objects map[int]FieldRewardObject } +type FieldRewardObject struct { + ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int + Rewards []BattleReward + Ratios []uint64 + DropCount, DropType uint64 +} + +func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) { + plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", pack)) + if err != nil { + return FieldObjectDesign{}, err + } + dir, err := os.MkdirTemp("", "bd2-field-objects-") + if err != nil { + return FieldObjectDesign{}, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "pack.db") + if err = os.WriteFile(path, plain, 0600); err != nil { + return FieldObjectDesign{}, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + return FieldObjectDesign{}, err + } + defer db.Close() + common, closeDB, err := openStatDatabase(root, version) + if err != nil { + return FieldObjectDesign{}, err + } + defer closeDB() + return loadFieldObjects(db, common) +} +func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { + design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}} + groups := map[int]FieldRewardObject{} + rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable") + if err != nil { + return design, err + } + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + rows.Close() + return design, err + } + obj := FieldRewardObject{GroupID: id} + for f, target := range map[int]*int{1: &obj.BuffID, 7: &obj.MonsterID, 8: &obj.QuestID, 9: &obj.ResetType, 12: &obj.Type} { + v, e := packedInts(raw, f) + if e != nil || len(v) > 1 { + rows.Close() + return design, fmt.Errorf("gamedata: malformed field object group %d", id) + } + if len(v) > 0 { + *target = int(v[0]) + } + } + group, e := packedInts(raw, 10) + if e != nil || len(group) > 1 { + rows.Close() + return design, fmt.Errorf("gamedata: malformed field reward group %d", id) + } + if len(group) == 1 { + var rewardRaw []byte + if err = common.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", group[0]).Scan(&rewardRaw); err != nil { + rows.Close() + return design, err + } + ids, e1 := packedInts(rewardRaw, 5) + types, e2 := packedInts(rewardRaw, 6) + counts, e3 := packedInts(rewardRaw, 4) + drop, e4 := packedInts(rewardRaw, 1) + ratios, e5 := packedInts(rewardRaw, 8) + dropType, e6 := packedInts(rewardRaw, 2) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil || e6 != nil { + rows.Close() + return design, fmt.Errorf("gamedata: malformed field loot %d", group[0]) + } + if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) == 1 && drop[0] > 0 && len(dropType) <= 1 { + obj.DropCount = drop[0] + obj.Ratios = ratios + if len(dropType) > 0 { + obj.DropType = dropType[0] + } + for i := range ids { + obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]}) + } + } + } + groups[id] = obj + } + if err = rows.Err(); err != nil { + rows.Close() + return design, err + } + rows.Close() + rows, err = db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectTable") + if err != nil { + return design, err + } + defer rows.Close() + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return design, err + } + group, e1 := packedInts(raw, 3) + maps, e2 := packedInts(raw, 6) + if e1 != nil || e2 != nil || len(group) != 1 || len(maps) > 1 || id <= 0 { + return design, fmt.Errorf("gamedata: malformed field object %d", id) + } + obj, ok := groups[int(group[0])] + if !ok { + return design, fmt.Errorf("gamedata: missing object group %d", group[0]) + } + obj.ID = id + if len(maps) > 0 { + obj.MapID = int(maps[0]) + } + design.Objects[id] = obj + } + return design, rows.Err() +} + +// Draw uses the table's normalized ratio distribution for DropType 0. +// Independent DropType 1 uses a different ratio unit and needs separate evidence. +func (o FieldRewardObject) Draw() ([]BattleReward, error) { + if o.DropType != 0 || o.DropCount == 0 || o.DropCount > 100 || len(o.Rewards) != len(o.Ratios) { + return nil, fmt.Errorf("gamedata: unsupported field reward draw") + } + var total uint64 + for _, r := range o.Ratios { + if ^uint64(0)-total < r { + return nil, fmt.Errorf("gamedata: reward ratio overflow") + } + total += r + } + if total == 0 { + return nil, fmt.Errorf("gamedata: empty field reward distribution") + } + var result []BattleReward + for n := uint64(0); n < o.DropCount; n++ { + value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(total)) + if err != nil { + return nil, err + } + pick := value.Uint64() + for i, ratio := range o.Ratios { + if pick < ratio { + result = append(result, o.Rewards[i]) + break + } + pick -= ratio + } + } + return result, nil +} diff --git a/go/internal/server/gamedata/field_objects_test.go b/go/internal/server/gamedata/field_objects_test.go new file mode 100644 index 0000000..55ec026 --- /dev/null +++ b/go/internal/server/gamedata/field_objects_test.go @@ -0,0 +1,45 @@ +package gamedata + +import ( + "testing" + "time" +) + +func TestFieldResetUsesConfiguredBoundary(t *testing.T) { + s := FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday} + before := time.Date(2026, 10, 4, 23, 59, 59, 0, time.UTC) + after := before.Add(time.Second) + for _, r := range []int{0, 3} { + a, _ := s.Period(r, before) + b, _ := s.Period(r, after) + if a == b { + t.Fatalf("reset%d did not advance at official boundary %s", r, a) + } + } + s.DailyReset = 10 * time.Hour + a, _ := s.Period(0, after) + b, _ := s.Period(0, after.Add(time.Hour)) + if a == b { + t.Fatal("changed design reset ignored") + } + once, _ := s.Period(1, after) + if once != "once" { + t.Fatal(once) + } + if _, err := s.Period(2, after); err == nil { + t.Fatal("invented event reset") + } +} +func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) { + o := FieldRewardObject{DropCount: 2, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 142}, {Type: 5, ID: 2, Count: 71}}, Ratios: []uint64{0, 100}} + for i := 0; i < 20; i++ { + got, err := o.Draw() + if err != nil || len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 { + t.Fatalf("draw=%+v err=%v", got, err) + } + } + o.DropType = 1 + if _, err := o.Draw(); err == nil { + t.Fatal("unsupported independent distribution accepted") + } +} diff --git a/go/internal/server/gamedata/field_reset.go b/go/internal/server/gamedata/field_reset.go new file mode 100644 index 0000000..6f8bd8f --- /dev/null +++ b/go/internal/server/gamedata/field_reset.go @@ -0,0 +1,63 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "fmt" + "strconv" + "strings" + "time" +) + +type FieldResetSchedule struct { + DailyReset time.Duration + WeeklyDay time.Weekday +} + +func LoadFieldResetSchedule(root, version string) (FieldResetSchedule, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return FieldResetSchedule{}, err + } + defer closeDB() + var raw []byte + if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil { + return FieldResetSchedule{}, err + } + times, found, err := wire.Bytes(raw, 23) + if err != nil || !found { + return FieldResetSchedule{}, fmt.Errorf("gamedata: missing field daily reset time") + } + parts := strings.Split(string(times), ":") + if len(parts) != 3 { + return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time") + } + values := [3]int{} + for i, p := range parts { + values[i], err = strconv.Atoi(p) + if err != nil || values[i] < 0 || (i == 0 && values[i] > 23) || (i > 0 && values[i] > 59) { + return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time") + } + } + day, _, err := wire.Varint(raw, 97) + if err != nil || day > 6 { + return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid weekly reset day") + } + return FieldResetSchedule{DailyReset: time.Duration(values[0])*time.Hour + time.Duration(values[1])*time.Minute + time.Duration(values[2])*time.Second, WeeklyDay: time.Weekday(day)}, nil +} + +// Period follows client TimerManager's UTC+9 conversion and GameDefault reset +// settings. ResetByEvent needs the event's own lifecycle, never a daily fallback. +func (s FieldResetSchedule) Period(reset int, now time.Time) (string, error) { + if reset == 1 { + return "once", nil + } + if reset != 0 && reset != 3 { + return "", fmt.Errorf("gamedata: missing field event reset lifecycle") + } + shifted := now.UTC().Add(9*time.Hour - s.DailyReset) + if reset == 3 { + days := (int(shifted.Weekday()) - int(s.WeeklyDay) + 7) % 7 + shifted = shifted.AddDate(0, 0, -days) + } + return shifted.Format("2006-01-02"), nil +} diff --git a/go/internal/server/gamedata/waypoint.go b/go/internal/server/gamedata/waypoint.go new file mode 100644 index 0000000..57c289d --- /dev/null +++ b/go/internal/server/gamedata/waypoint.go @@ -0,0 +1,109 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "os" + "path/filepath" +) + +// WaypointPack uses the per-pack FieldWaypointTable and common PackTable. +// A zero PriceUnit is a real proto3 default and means free travel. +type WaypointPack struct { + Points map[uint64]Waypoint + PriceType, PriceUnit uint64 +} +type Waypoint struct { + ID, MapID uint64 + SafeArea bool + QuestRange []uint64 +} + +func LoadWaypointPack(root, version string, packID uint64) (WaypointPack, error) { + if packID == 0 || packID > math.MaxInt32 { + return WaypointPack{}, fmt.Errorf("gamedata: invalid waypoint pack") + } + common, closeDB, err := openStatDatabase(root, version) + if err != nil { + return WaypointPack{}, err + } + defer closeDB() + var raw []byte + if err = common.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil { + return WaypointPack{}, err + } + design := WaypointPack{Points: map[uint64]Waypoint{}} + for field, target := range map[int]*uint64{66: &design.PriceType, 67: &design.PriceUnit} { + values, e := packedInts(raw, field) + if e != nil || len(values) > 1 { + return WaypointPack{}, fmt.Errorf("gamedata: invalid waypoint price pack%d", packID) + } + if len(values) == 1 { + *target = values[0] + } + } + plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID)) + if err != nil { + return WaypointPack{}, err + } + dir, err := os.MkdirTemp("", "bd2-waypoint-") + if err != nil { + return WaypointPack{}, err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "pack.db") + if err = os.WriteFile(path, plain, 0600); err != nil { + return WaypointPack{}, err + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") + if err != nil { + return WaypointPack{}, err + } + defer db.Close() + design.Points, err = loadWaypointPoints(db) + return design, err +} + +func loadWaypointPoints(db *sql.DB) (map[uint64]Waypoint, error) { + points := map[uint64]Waypoint{} + rows, err := db.Query("SELECT id,ProtoBuf FROM FieldWaypointTable ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return nil, err + } + p := Waypoint{ID: id} + if id == 0 || id > math.MaxInt32 { + return nil, fmt.Errorf("gamedata: invalid waypoint id") + } + for field, target := range map[int]*uint64{2: &p.ID, 5: &p.MapID} { + values, e := packedInts(raw, field) + if e != nil || len(values) > 1 { + return nil, fmt.Errorf("gamedata: invalid waypoint field") + } + if len(values) == 1 { + *target = values[0] + } + } + if p.ID != id || p.MapID > math.MaxInt32 { + return nil, fmt.Errorf("gamedata: invalid waypoint identity") + } + safe, e := packedInts(raw, 4) + if e != nil || len(safe) > 1 { + return nil, fmt.Errorf("gamedata: invalid waypoint safe area") + } + p.SafeArea = len(safe) == 1 && safe[0] != 0 + p.QuestRange, e = packedInts(raw, 6) + if e != nil { + return nil, e + } + points[id] = p + } + return points, rows.Err() +} diff --git a/go/internal/server/missions/achievement_claim_state.go b/go/internal/server/missions/achievement_claim_state.go new file mode 100644 index 0000000..6ae06fd --- /dev/null +++ b/go/internal/server/missions/achievement_claim_state.go @@ -0,0 +1,17 @@ +package missions + +import "bd2server/internal/server/gamedata" + +// ClaimedAchievementIDs exposes committed claims to the counter query adapter. +// The mission domain remains the sole owner of reward claims. +func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { + s.mu.Lock() + defer s.mu.Unlock() + out := map[gamedata.AchievementKey]bool{} + for key := range s.design.Achievements { + if contains(s.state.Claimed, "achievement:"+achievementName(key)) { + out[key] = true + } + } + return out +} diff --git a/go/internal/server/progress/chests.go b/go/internal/server/progress/chests.go new file mode 100644 index 0000000..72bc0ab --- /dev/null +++ b/go/internal/server/progress/chests.go @@ -0,0 +1,76 @@ +package progress + +import ( + "bd2server/internal/server/stateio" + "fmt" + "sort" + "strconv" + "strings" +) + +func fieldRewardKey(pack, difficulty, id int) string { + return fmt.Sprintf("%d:%d:%d", pack, difficulty, id) +} + +// FieldRewardOpened reads persisted entries on every call; request rollback +// therefore does not leave an opened-object cache behind. +func (s *Store) FieldRewardOpened(pack, difficulty, id int, period string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + store, err := s.fieldRewardEntries() + if err != nil { + return false, err + } + raw, found, err := store.LoadEntry("progress", "field_rewards", fieldRewardKey(pack, difficulty, id)) + return found && string(raw) == period, err +} +func (s *Store) fieldRewardEntries() (stateio.EntryStore, error) { + if s.storage == nil { + s.storage = stateio.NewMemory() + } + store, ok := s.storage.(stateio.EntryStore) + if !ok { + return nil, fmt.Errorf("progress: field rewards require entry storage") + } + return store, nil +} +func (s *Store) MarkFieldRewardOpened(pack, difficulty, id int, period string) error { + s.mu.Lock() + defer s.mu.Unlock() + if pack <= 0 || difficulty < 0 || difficulty > 4 || id <= 0 { + return fmt.Errorf("progress: invalid field reward identity") + } + store, err := s.fieldRewardEntries() + if err != nil { + return err + } + return store.PutEntry("progress", "field_rewards", fieldRewardKey(pack, difficulty, id), []byte(period)) +} +func (s *Store) OpenedFieldRewards(pack, difficulty int) ([]int, error) { + s.mu.Lock() + defer s.mu.Unlock() + store, err := s.fieldRewardEntries() + if err != nil { + return nil, err + } + entries, err := store.ListEntries("progress", "field_rewards") + if err != nil { + return nil, err + } + prefix := fmt.Sprintf("%d:%d:", pack, difficulty) + var ids []int + for key, raw := range entries { + if len(raw) == 0 { + return nil, fmt.Errorf("progress: invalid field reward entry") + } + if strings.HasPrefix(key, prefix) { + id, e := strconv.Atoi(strings.TrimPrefix(key, prefix)) + if e != nil || id <= 0 { + return nil, fmt.Errorf("progress: invalid field reward key") + } + ids = append(ids, id) + } + } + sort.Ints(ids) + return ids, nil +} diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go index f9bc5d5..576a272 100644 --- a/go/internal/server/session/server.go +++ b/go/internal/server/session/server.go @@ -354,13 +354,6 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) { return 0, nil, fmt.Errorf("%s: %w", path, err) } return 102, nil, nil - case "/QuestUpdate": - questID, err := s.progress.UpdateQuest(request) - if err != nil { - return 0, nil, fmt.Errorf("%s: %w", path, err) - } - response := wire.AppendVarint(nil, 1, uint64(questID)) - return 19, wire.AppendBytes(response, 2, nil), nil } for _, handler := range s.handlers { code, response, ok, err := handler.Handle(path, request) diff --git a/go/internal/server/session/server_test.go b/go/internal/server/session/server_test.go index 005988c..0a42fb2 100644 --- a/go/internal/server/session/server_test.go +++ b/go/internal/server/session/server_test.go @@ -494,7 +494,7 @@ func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) } } -func TestNativeProgress(t *testing.T) { +func TestNativePositionAndQuestDomainRouting(t *testing.T) { server, _ := NewServer(fakeLogin{}) reply := login(t, server) cookie := "s=" + reply.Cookie @@ -514,10 +514,10 @@ func TestNativeProgress(t *testing.T) { quest = wire.AppendVarint(quest, 3, 21) quest = wire.AppendBytes(quest, 4, []byte{121}) body, _ = cryptox.EncryptBase64Payload(quest, server.KeyForTest()) - if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); err != nil { - t.Fatal(err) + if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); !errors.Is(err, transport.ErrNotImplemented) { + t.Fatalf("QuestUpdate without an owning world domain must fail closed: %v", err) } - if stored, ok := server.ProgressForTest().Quest(12); !ok || stored.PackID != 21 { - t.Fatalf("quest not stored: %+v/%v", stored, ok) + if stored, ok := server.ProgressForTest().Quest(12); ok { + t.Fatalf("unvalidated quest bypassed the world domain: %+v", stored) } } diff --git a/go/internal/server/world/achievements.go b/go/internal/server/world/achievements.go new file mode 100644 index 0000000..7085be1 --- /dev/null +++ b/go/internal/server/world/achievements.go @@ -0,0 +1,220 @@ +package world + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "sync" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type AchievementService struct { + mu sync.Mutex + design *gamedata.AchievementCounterDesign + store stateio.AtomicEntryStore + session string + claims AchievementClaimSource +} +type AchievementClaimSource interface { + ClaimedAchievementIDs() map[gamedata.AchievementKey]bool +} +type achievementReceipt struct { + Sequence uint64 `json:"sequence"` + Group int `json:"group"` + Add int `json:"add"` +} +type achievementSnapshot struct { + Counts map[string]int64 `json:"counts"` + Receipts map[string]achievementReceipt `json:"receipts"` +} + +func NewAchievementService(design *gamedata.AchievementCounterDesign, store stateio.Store, claims ...AchievementClaimSource) (*AchievementService, error) { + if design == nil || len(design.Groups) == 0 || store == nil { + return nil, fmt.Errorf("achievement: missing design or storage") + } + entries, ok := store.(stateio.AtomicEntryStore) + if !ok { + return nil, fmt.Errorf("achievement: storage requires atomic entries") + } + service := &AchievementService{design: design, store: entries} + if len(claims) > 0 { + service.claims = claims[0] + } + return service, nil +} +func (s *AchievementService) BeginSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.session = id +} + +// Load each request from the transaction snapshot. A rolled-back request must +// never leave an increment or replay receipt in an in-memory cache. +func (s *AchievementService) load() (achievementSnapshot, error) { + state := achievementSnapshot{Counts: map[string]int64{}, Receipts: map[string]achievementReceipt{}} + counts, err := s.store.ListEntries("missions", "achievement_counts") + if err != nil { + return state, err + } + for key, raw := range counts { + var value int64 + if err = json.Unmarshal(raw, &value); err != nil { + return state, fmt.Errorf("achievement: invalid counter: %w", err) + } + state.Counts[key] = value + } + if s.session != "" { + raw, found, err := s.store.LoadEntry("missions", "achievement_receipts", s.session) + if err != nil { + return state, err + } + if found { + var receipt achievementReceipt + if err = json.Unmarshal(raw, &receipt); err != nil { + return state, fmt.Errorf("achievement: invalid receipt: %w", err) + } + state.Receipts[s.session] = receipt + } + } + if state.Counts == nil || state.Receipts == nil { + return state, fmt.Errorf("achievement: incomplete state") + } + for key, value := range state.Counts { + group, err := strconv.Atoi(key) + if err != nil || len(s.design.Groups[group]) == 0 || value < 0 { + return state, fmt.Errorf("achievement: invalid persisted counter") + } + } + return state, nil +} + +func (s *AchievementService) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/AchievementInfo" && path != "/AchievementUpdate" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + fail := func(err error) (int, []byte, bool, error) { return 0, nil, true, err } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return fail(ErrInvalidRequest) + } + state, err := s.load() + if err != nil { + return fail(err) + } + if path == "/AchievementInfo" { + claimed := map[gamedata.AchievementKey]bool{} + if s.claims != nil { + claimed = s.claims.ClaimedAchievementIDs() + } + for key := range claimed { + group := strconv.Itoa(int(key.GroupID)) + if _, found := state.Counts[group]; !found && len(s.design.Groups[int(key.GroupID)]) > 0 { + state.Counts[group] = 0 + } + } + groups := make([]int, 0, len(state.Counts)) + for key := range state.Counts { + group, _ := strconv.Atoi(key) + groups = append(groups, group) + } + sort.Ints(groups) + var response []byte + for _, group := range groups { + for _, content := range s.design.Groups[group] { + row := wire.AppendVarint(nil, 1, uint64(group)) + row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)])) + var maxID uint64 + for key := range claimed { + if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID { + maxID = key.ID + } + } + if maxID > 0 { + row = wire.AppendVarint(row, 3, maxID) + } + if content != 0 { + row = wire.AppendVarint(row, 4, uint64(content)) + } + response = wire.AppendBytes(response, 1, row) + } + } + // Only the mission domain's actual claims advance max_clear_id. + return 166, response, true, nil + } + group, found, err := wire.Varint(request, 2) + if err != nil || !found || group == 0 || group > math.MaxInt32 || len(s.design.Groups[int(group)]) == 0 { + return fail(ErrInvalidRequest) + } + add, found, err := wire.Varint(request, 3) + if err != nil || !found || add == 0 || add > math.MaxInt32 || s.session == "" { + return fail(ErrInvalidRequest) + } + receipt := achievementReceipt{Sequence: seq, Group: int(group), Add: int(add)} + replayKey := s.session + "/" + strconv.FormatUint(seq, 10) + if raw, found, err := s.store.LoadEntry("missions", "achievement_replays", replayKey); err != nil { + return fail(err) + } else if found { + var previous achievementReceipt + if err = json.Unmarshal(raw, &previous); err != nil { + return fail(err) + } + if previous == receipt { + return 167, nil, true, nil + } + return fail(fmt.Errorf("achievement: conflicting request sequence")) + } + if previous, ok := state.Receipts[s.session]; ok && seq <= previous.Sequence { + if previous == receipt { + return 167, nil, true, nil + } + return fail(fmt.Errorf("achievement: stale or conflicting request sequence")) + } + key := strconv.Itoa(int(group)) + current := state.Counts[key] + if current > math.MaxInt64-int64(add) { + return fail(fmt.Errorf("achievement: counter overflow")) + } + state.Counts[key] = current + int64(add) + state.Receipts[s.session] = receipt + raw, err := json.Marshal(state.Counts[key]) + if err != nil { + return fail(err) + } + receiptRaw, err := json.Marshal(receipt) + if err != nil { + return fail(err) + } + changes := []stateio.EntryMutation{{Bucket: "achievement_counts", Key: key, Payload: raw}, {Bucket: "achievement_receipts", Key: s.session, Payload: receiptRaw}, {Bucket: "achievement_replays", Key: replayKey, Payload: receiptRaw}} + // Retain a complete retry window so a committed BatchRequest whose response + // was lost can replay several updates, not just the final update in the batch. + if seq > 256 { + entries, err := s.store.ListEntries("missions", "achievement_replays") + if err != nil { + return fail(err) + } + prefix := s.session + "/" + for replayKey := range entries { + if strings.HasPrefix(replayKey, prefix) { + old, err := strconv.ParseUint(strings.TrimPrefix(replayKey, prefix), 10, 64) + if err != nil { + return fail(err) + } + if old <= seq-256 { + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_replays", Key: replayKey, Delete: true}) + } + } + } + } + if err = s.store.SaveWithEntries("missions", nil, changes); err != nil { + return fail(err) + } + return 167, nil, true, nil +} diff --git a/go/internal/server/world/achievements_test.go b/go/internal/server/world/achievements_test.go new file mode 100644 index 0000000..09110e5 --- /dev/null +++ b/go/internal/server/world/achievements_test.go @@ -0,0 +1,198 @@ +package world + +import ( + "bytes" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +func achievementRequest(seq, group, add uint64) []byte { + req := wire.AppendVarint(nil, 1, seq) + req = wire.AppendVarint(req, 2, group) + return wire.AppendVarint(req, 3, add) +} +func achievementTestService(t *testing.T, store stateio.Store) *AchievementService { + t.Helper() + s, err := NewAchievementService(&gamedata.AchievementCounterDesign{Groups: map[int][]int{7: {0, 1}, 9: {0}}}, store) + if err != nil { + t.Fatal(err) + } + s.BeginSession("login-a") + return s +} +func achievementValue(t *testing.T, s *AchievementService) int64 { + t.Helper() + state, err := s.load() + if err != nil { + t.Fatal(err) + } + return state.Counts["7"] +} +func TestAchievementUpdatePersistsAndRetries(t *testing.T) { + store := stateio.NewMemory() + s := achievementTestService(t, store) + req := achievementRequest(10, 7, 1) + for i := 0; i < 2; i++ { + code, body, ok, err := s.Handle("/AchievementUpdate", req) + if err != nil || code != 167 || !ok || len(body) != 0 { + t.Fatalf("update: %d %x %v %v", code, body, ok, err) + } + } + if got := achievementValue(t, s); got != 1 { + t.Fatalf("retry incremented count: %d", got) + } + reopened := achievementTestService(t, store) + if got := achievementValue(t, reopened); got != 1 { + t.Fatalf("reopen: %d", got) + } + if _, _, _, err := reopened.Handle("/AchievementUpdate", achievementRequest(10, 7, 2)); err == nil { + t.Fatal("conflicting sequence accepted") + } + reopened.BeginSession("login-b") + if _, _, _, err := reopened.Handle("/AchievementUpdate", req); err != nil { + t.Fatal(err) + } + code, body, ok, err := reopened.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 11)) + var expected []byte + for _, content := range []uint64{0, 1} { + row := wire.AppendVarint(nil, 1, 7) + row = wire.AppendVarint(row, 2, 2) + if content != 0 { + row = wire.AppendVarint(row, 4, content) + } + expected = wire.AppendBytes(expected, 1, row) + } + if err != nil || code != 166 || !ok || !bytes.Equal(body, expected) { + t.Fatalf("info mismatch: %d %x %v %v", code, body, ok, err) + } +} +func TestAchievementInvalidRequestsDoNotMutate(t *testing.T) { + s := achievementTestService(t, stateio.NewMemory()) + for _, req := range [][]byte{achievementRequest(0, 7, 1), achievementRequest(1, 8, 1), achievementRequest(1, 7, 0), achievementRequest(1, 7, 1<<32), {0xff}} { + if _, _, _, err := s.Handle("/AchievementUpdate", req); err == nil { + t.Fatalf("invalid request accepted: %x", req) + } + } + if achievementValue(t, s) != 0 { + t.Fatal("invalid request mutated counter") + } + s.BeginSession("") + if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err == nil { + t.Fatal("missing session accepted") + } +} + +func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) { + s := achievementTestService(t, stateio.NewMemory()) + requests := [][]byte{achievementRequest(10, 7, 1), achievementRequest(11, 7, 2)} + for retry := 0; retry < 2; retry++ { + for _, request := range requests { + if _, _, _, err := s.Handle("/AchievementUpdate", request); err != nil { + t.Fatal(err) + } + } + } + if achievementValue(t, s) != 3 { + t.Fatal("committed batch retry duplicated increments") + } + if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(300, 7, 1)); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/AchievementUpdate", requests[0]); err == nil { + t.Fatal("expired replay accepted as a new increment") + } + if achievementValue(t, s) != 4 { + t.Fatal("expired replay changed progress") + } +} + +type achievementClaimsFixture struct{} + +func (achievementClaimsFixture) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { + return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 3}: true} +} +func TestAchievementInfoIncludesRealClaimState(t *testing.T) { + s := achievementTestService(t, stateio.NewMemory()) + s.claims = achievementClaimsFixture{} + _, body, _, err := s.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + var rows [][]byte + if err = wire.Walk(body, func(field wire.Field) error { + if field.Number == 1 { + rows = append(rows, field.Value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("missing claim group rows: %x", body) + } + clear, found, err := wire.Varint(rows[1], 3) + if err != nil || !found || clear != 3 { + t.Fatal("max_clear_id did not come from actual claims") + } +} +func TestAchievementTransactionRollbackAndReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + s := achievementTestService(t, repo) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + s = achievementTestService(t, repo) + if achievementValue(t, s) != 1 { + t.Fatal("SQLite reopen lost count") + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil { + t.Fatal(err) + } + // Repository fences a dirty rollback because other domains cache memory. + // Reopening is the recovery boundary; this service keeps no cached counts. + _ = op.Rollback() + _ = repo.Close() + recovered, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer recovered.Close() + s = achievementTestService(t, recovered) + if achievementValue(t, s) != 1 { + t.Fatal("rolled-back increment survived") + } + if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil { + t.Fatal(err) + } + if achievementValue(t, s) != 4 { + t.Fatal("rolled-back replay receipt survived") + } +} diff --git a/go/internal/server/world/field_objects.go b/go/internal/server/world/field_objects.go new file mode 100644 index 0000000..617ff19 --- /dev/null +++ b/go/internal/server/world/field_objects.go @@ -0,0 +1,193 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "fmt" + "time" +) + +func (s *Service) WithFieldObjects(designs map[int]gamedata.FieldObjectDesign) *Service { + s.fieldObjects = designs + return s +} +func (s *Service) AttachFieldObjectRuntime(root, version string) error { + schedule, err := gamedata.LoadFieldResetSchedule(root, version) + if err != nil { + return err + } + s.fieldReset = schedule + s.fieldObjectLoader = func(pack int) (gamedata.FieldObjectDesign, error) { + return gamedata.LoadFieldObjects(root, version, pack) + } + s.fieldObjects = map[int]gamedata.FieldObjectDesign{} + return nil +} +func (s *Service) fieldObjectDesign(pack int) (gamedata.FieldObjectDesign, error) { + if design, ok := s.fieldObjects[pack]; ok { + return design, nil + } + if s.fieldObjectLoader == nil { + return gamedata.FieldObjectDesign{}, fmt.Errorf("world: field object design unavailable") + } + if _, story := s.packs[pack]; !story { + if _, field := s.fieldPacks[pack]; !field { + return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest) + } + } + design, err := s.fieldObjectLoader(pack) + if err != nil { + return design, err + } + s.fieldObjects[pack] = design + return design, nil +} +func (s *Service) openedFieldObjects(pack int) ([]int, error) { + ids, err := s.state.OpenedFieldRewards(pack, s.questDifficulty(pack)) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return []int{}, nil + } + design, err := s.fieldObjectDesign(pack) + if err != nil { + return nil, err + } + var active []int + for _, id := range ids { + obj, ok := design.Objects[id] + if !ok { + return nil, fmt.Errorf("world: saved field object absent from design") + } + period, e := s.fieldObjectPeriod(obj) + if e != nil { + continue + } + opened, e := s.state.FieldRewardOpened(pack, s.questDifficulty(pack), id, period) + if e != nil { + return nil, e + } + if opened { + active = append(active, id) + } + } + return active, nil +} +func (s *Service) WithFieldResetSchedule(schedule gamedata.FieldResetSchedule) *Service { + s.fieldReset = schedule + return s +} +func (s *Service) fieldObjectPeriod(obj gamedata.FieldRewardObject) (string, error) { + return s.fieldReset.Period(obj.ResetType, time.Now()) +} +func (s *Service) handleFieldObjectInfo(request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(pack) { + return 0, nil, true, fmt.Errorf("%w: unavailable field pack", ErrInvalidRequest) + } + ids, err := s.openedFieldObjects(pack) + if err != nil { + return 0, nil, true, err + } + var response []byte + for _, id := range ids { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) + } + return 28, response, true, nil +} +func (s *Service) handleFieldObjectReward(request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + group, _, err := wire.Varint(request, 3) + if err != nil || group == 0 || group > uint64(^uint32(0)>>1) { + return 0, nil, true, ErrInvalidRequest + } + id, _, err := wire.Varint(request, 4) + if err != nil || id == 0 || id > uint64(^uint32(0)>>1) { + return 0, nil, true, ErrInvalidRequest + } + bundle, err := s.openFieldObject(pack, int(group), int(id)) + if err != nil { + return 0, nil, true, err + } + return 29, wire.AppendBytes(nil, 1, bundle), true, nil +} +func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) { + design, err := s.fieldObjectDesign(pack) + if err != nil { + return nil, err + } + obj, exists := design.Objects[id] + if !exists || obj.GroupID != group || !s.packUnlocked(pack) || s.state.ActivePackID() != pack { + return nil, fmt.Errorf("%w: unavailable field object", ErrInvalidRequest) + } + if position, ok := s.state.Position(); ok && (position.PackID != pack || position.Position.MapID != obj.MapID) { + return nil, fmt.Errorf("%w: field object outside current map", ErrInvalidRequest) + } + if obj.Type < 1 || obj.Type > 3 || obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 { + return nil, fmt.Errorf("%w: unsupported field object reward graph/reset", ErrInvalidRequest) + } + difficulty := s.questDifficulty(pack) + period, err := s.fieldObjectPeriod(obj) + if err != nil { + return nil, err + } + opened, err := s.state.FieldRewardOpened(pack, difficulty, id, period) + if err != nil { + return nil, err + } + if opened { + return []byte{}, nil + } + if s.wallet == nil || s.inventory == nil { + return nil, fmt.Errorf("world: field reward stores unavailable") + } + var rewards []gamedata.Reward + var itemRewards []gamedata.BattleReward + selected, err := obj.Draw() + if err != nil { + return nil, err + } + for _, r := range selected { + if r.Count == 0 { + return nil, fmt.Errorf("world: empty field reward") + } + switch r.Type { + case 2, 3, 4, 12, 20: + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: + if r.ID == 0 { + return nil, fmt.Errorf("world: invalid field item") + } + itemRewards = append(itemRewards, r) + default: + return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type) + } + } + identity := fmt.Sprintf("field-reward:%d:%d:%d:%s", pack, difficulty, id, period) + if _, err = s.wallet.GrantQuestOnce(identity, rewards); err != nil { + return nil, err + } + items, err := s.inventory.GrantOnce(identity, itemRewards) + if err != nil { + return nil, err + } + if err = s.state.MarkFieldRewardOpened(pack, difficulty, id, period); err != nil { + return nil, err + } + var bundle []byte + for _, r := range rewards { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{ID: r.ID, Type: r.Type, Count: r.Count})) + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + } + return bundle, nil +} diff --git a/go/internal/server/world/field_objects_test.go b/go/internal/server/world/field_objects_test.go new file mode 100644 index 0000000..57c79b0 --- /dev/null +++ b/go/internal/server/world/field_objects_test.go @@ -0,0 +1,194 @@ +package world + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "errors" + "path/filepath" + "testing" +) + +type fieldRewardFailStore struct { + *accountstate.Repository + fail bool +} + +func (s *fieldRewardFailStore) PutEntry(domain, bucket, key string, payload []byte) error { + if s.fail && bucket == "field_rewards" { + return errors.New("forced field reward marker failure") + } + return s.Repository.PutEntry(domain, bucket, key, payload) +} +func TestFieldChestAtomicAwardRollback(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { repo.Close() }() + store := &fieldRewardFailStore{Repository: repo} + s := testService() + s.state, err = progress.OpenStore(store) + if err != nil { + t.Fatal(err) + } + if err = s.state.SetActivePackID(21); err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100}) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(store, s.starter) + if err != nil { + t.Fatal(err) + } + s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}}) + if _, err = s.wallet.GrantQuestOnce("baseline", nil); err != nil { + t.Fatal(err) + } + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + store.fail = true + if _, err = s.openFieldObject(21, 101, 1001); err == nil { + t.Fatal("failed persistence returned success") + } + if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + store.Repository = repo + store.fail = false + s.wallet, err = player.OpenWallet(store, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(store, s.starter) + if err != nil { + t.Fatal(err) + } + opened, err := s.state.FieldRewardOpened(21, 0, 1001, "once") + if err != nil || opened || s.wallet.Snapshot().FreeJewelry != 100 || len(s.inventory.All()) != 0 { + t.Fatalf("partial state after rollback opened=%v wallet=%+v err=%v", opened, s.wallet.Snapshot(), err) + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, err = s.openFieldObject(21, 101, 1001); err != nil { + op.Rollback() + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(store, player.Currency{}) + if err != nil { + t.Fatal(err) + } + if s.wallet.Snapshot().FreeJewelry != 150 { + t.Fatal("retry after rollback did not award") + } + obj := s.fieldObjects[21].Objects[1001] + obj.ID = 1002 + obj.Rewards = []gamedata.BattleReward{{Type: 5, ID: 1013, Count: 142}} + s.fieldObjects[21].Objects[1002] = obj + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + store.fail = true + if _, err = s.openFieldObject(21, 101, 1002); err == nil { + t.Fatal("inventory marker failure returned success") + } + if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + store.Repository = repo + store.fail = false + s.inventory, err = player.OpenInventory(store, s.starter) + if err != nil { + t.Fatal(err) + } + opened, err = s.state.FieldRewardOpened(21, 0, 1002, "once") + if err != nil || opened || len(s.inventory.All()) != 0 { + t.Fatalf("inventory partially committed after rollback opened=%v items=%v err=%v", opened, s.inventory.All(), err) + } +} + +func TestFieldChestDeltaAndPersistentRepeat(t *testing.T) { + store := stateio.NewMemory() + s := testService() + var err error + s.state, err = progress.OpenStore(store) + if err != nil { + t.Fatal(err) + } + if err = s.state.SetActivePackID(21); err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100}) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(store, s.starter) + if err != nil { + t.Fatal(err) + } + s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{10}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}}) + req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21), 3, 101), 4, 1001) + code, res, _, err := s.handleFieldObjectReward(req) + if err != nil || code != 29 { + t.Fatalf("code=%d err=%v", code, err) + } + bundle, _, _ := wire.Bytes(res, 1) + item, _, _ := wire.Bytes(bundle, 1) + delta, _, _ := wire.Varint(item, 4) + if delta != 50 || s.wallet.Snapshot().FreeJewelry != 150 { + t.Fatalf("delta=%d wallet=%+v", delta, s.wallet.Snapshot()) + } + s.state, err = progress.OpenStore(store) + if err != nil { + t.Fatal(err) + } + req = wire.AppendVarint(req, 1, 999) + _, res, _, err = s.handleFieldObjectReward(req) + if err != nil { + t.Fatal(err) + } + bundle, _, _ = wire.Bytes(res, 1) + if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 150 { + t.Fatal("repeat awarded") + } + _, res, _, err = s.handleFieldObjectInfo(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)) + if err != nil { + t.Fatal(err) + } + info, _, _ := wire.Bytes(res, 1) + id, _, _ := wire.Varint(info, 1) + if id != 1001 { + t.Fatalf("opened id=%d", id) + } + if _, err = s.openFieldObject(21, 102, 1001); err == nil { + t.Fatal("wrong group accepted") + } +} diff --git a/go/internal/server/world/npc_dialog.go b/go/internal/server/world/npc_dialog.go new file mode 100644 index 0000000..cc02bdf --- /dev/null +++ b/go/internal/server/world/npc_dialog.go @@ -0,0 +1,27 @@ +package world + +import "bd2server/internal/server/wire" + +// handleQuestUpdate accepts the existing task update packet used when an NPC +// conversation finishes. Ordinary Talk is local and sends AchievementUpdate, +// not a separate NPC dialog packet. QuestUpdate has no NPC identity to validate. +func (s *Service) handleQuestUpdate(request []byte) (int, []byte, bool, error) { + quest, pack, err := requestQuest(request) + if err != nil { + return 0, nil, true, err + } + current, err := s.CurrentPackID() + if err != nil || current != pack || !s.canClear(pack, quest) { + return 0, nil, true, ErrInvalidRequest + } + if s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) { + // A delayed replay must not reinsert a cleared quest into active progress. + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil + } + if _, err := s.state.UpdateQuest(request); err != nil { + return 0, nil, true, err + } + // field 2 is RewardDBInfoBundle, not QuestDBInfo. Task completion/claims + // remain in QuestClear; an update alone must not invent or duplicate rewards. + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil +} diff --git a/go/internal/server/world/npc_dialog_test.go b/go/internal/server/world/npc_dialog_test.go new file mode 100644 index 0000000..594dd7d --- /dev/null +++ b/go/internal/server/world/npc_dialog_test.go @@ -0,0 +1,41 @@ +package world + +import ( + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +func TestNpcTaskQuestUpdateRejectsUnavailableAndReplaysClear(t *testing.T) { + s := testService() + req := func(quest, pack, value 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, value) + } + for _, bad := range [][]byte{req(99, 21, 1), req(2, 21, 1), req(1, 22, 1)} { + if _, _, _, err := s.handleQuestUpdate(bad); err == nil { + t.Fatalf("unavailable update accepted: %x", bad) + } + } + code, body, ok, err := s.handleQuestUpdate(req(1, 21, 1)) + expected := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, nil) + if err != nil || code != 19 || !ok || !bytes.Equal(body, expected) { + t.Fatalf("update: %d %x %v %v", code, body, ok, err) + } + current, found := s.state.QuestInPack(1, 21) + if !found || len(current.Values) != 1 || current.Values[0] != 1 { + t.Fatal("task progress not persisted") + } + if err = s.state.ClearQuest(1, 21); err != nil { + t.Fatal(err) + } + if _, _, _, err = s.handleQuestUpdate(req(1, 21, 2)); err != nil { + t.Fatal(err) + } + current, found = s.state.QuestInPack(1, 21) + if !s.state.QuestCleared(1, 21) || !found || len(current.Values) != 1 || current.Values[0] != 1 { + t.Fatal("cleared quest replay changed committed progress") + } +} diff --git a/go/internal/server/world/pack_detail.go b/go/internal/server/world/pack_detail.go index 2f1b7e1..6f8aed0 100644 --- a/go/internal/server/world/pack_detail.go +++ b/go/internal/server/world/pack_detail.go @@ -30,12 +30,15 @@ func (s *Service) handlePackDetail(request []byte) (int, []byte, bool, error) { // 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 + ids, err := s.openedFieldObjects(packID) + if err != nil { + return 0, nil, true, err + } + response := []byte{} + for _, id := range ids { + response = wire.AppendBytes(response, 2, wire.AppendVarint(nil, 1, uint64(id))) + } + return 627, response, true, nil } func (s *Service) attachPackDetailDesign(root, version string) { diff --git a/go/internal/server/world/pack_detail_test.go b/go/internal/server/world/pack_detail_test.go index 1fa1c04..a7bb868 100644 --- a/go/internal/server/world/pack_detail_test.go +++ b/go/internal/server/world/pack_detail_test.go @@ -7,6 +7,46 @@ import ( "testing" ) +func TestCollectedFieldObjectsAppearInDetailAndSummary(t *testing.T) { + s := testService() + s.packSummaryTargets = map[int]bool{21: true} + s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { return gamedata.PackDetailDesign{}, nil } + s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{ + 1001: {ID: 1001, GroupID: 101, Type: 2, ResetType: 1}, + 1002: {ID: 1002, GroupID: 102, Type: 1, ResetType: 1}, + }}}) + for _, id := range []int{1001, 1002} { + if err := s.state.MarkFieldRewardOpened(21, 0, id, "once"); err != nil { + t.Fatal(err) + } + } + req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) + _, detail, _, err := s.Handle("/PackDetailInfo", req) + if err != nil { + t.Fatal(err) + } + rows := 0 + err = wire.Walk(detail, func(field wire.Field) error { + if field.Number == 2 { + rows++ + } + return nil + }) + if err != nil || rows != 2 { + t.Fatalf("opened detail %x: %v", detail, err) + } + _, summary, _, err := s.Handle("/PackSummaryInfoList", req) + if err != nil { + t.Fatal(err) + } + row, _, _ := wire.Bytes(summary, 1) + once, _, _ := wire.Varint(row, 2) + regen, _, _ := wire.Varint(row, 3) + if once != 1 || regen != 1 { + t.Fatalf("collected counts %x", row) + } +} + func TestPackDetailUsesVerifiedDesignAndRejectsUnavailableState(t *testing.T) { s := testService() s.packSummaryTargets = map[int]bool{21: true, 22: true} diff --git a/go/internal/server/world/pack_summary.go b/go/internal/server/world/pack_summary.go index cf47e75..1365a93 100644 --- a/go/internal/server/world/pack_summary.go +++ b/go/internal/server/world/pack_summary.go @@ -19,16 +19,27 @@ func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) { if !s.packSummaryTargets[int(id)] { continue } - // These are collected reward counts, not quest completion or remaining - // rewards. Field object and research grants are not implemented. The - // client's monster category requires UseBattleSkip && Type != 3 && - // RegenId > 0; the current story battles (pack21 monsters 1..4 and - // pack22 monsters 1..8) have both RegenId and UseBattleSkip zero and - // their persisted battle reward grants do not count in this category. - // Regenerating field reward battles are not implemented. The collected - // counts are therefore zero (protobuf defaults). The client loads the - // maxima from PackRewardSummaryData and calculates the remainder. - response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, id)) + ids, err := s.openedFieldObjects(int(id)) + if err != nil { + return 0, nil, true, err + } + var once, regen uint64 + for _, objectID := range ids { + object := s.fieldObjects[int(id)].Objects[objectID] + if object.Type == 2 { + once++ + } else if object.Type == 1 || object.Type == 3 { + regen++ + } + } + row := wire.AppendVarint(nil, 1, id) + if once > 0 { + row = wire.AppendVarint(row, 2, once) + } + if regen > 0 { + row = wire.AppendVarint(row, 3, regen) + } + response = wire.AppendBytes(response, 1, row) } return 625, response, true, nil } diff --git a/go/internal/server/world/waypoint.go b/go/internal/server/world/waypoint.go new file mode 100644 index 0000000..e71c588 --- /dev/null +++ b/go/internal/server/world/waypoint.go @@ -0,0 +1,34 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "fmt" +) + +// AttachWaypointRuntime must follow AttachDecks; Save permits the client's +// automatic activation of all safe-area waypoints when it enters a pack. +func (s *Service) AttachWaypointRuntime(root, version string) error { + if s.decks == nil { + return fmt.Errorf("world: waypoint deck unavailable") + } + return s.decks.ConfigureWaypoints(func(pack uint64) (gamedata.WaypointPack, error) { + return gamedata.LoadWaypointPack(root, version, pack) + }, func(pack uint64, use bool) error { + if !s.packUnlocked(int(pack)) { + return fmt.Errorf("world: waypoint pack%d locked", pack) + } + if use { + current, err := s.CurrentPackID() + if err != nil { + return err + } + if uint64(current) != pack { + return fmt.Errorf("world: waypoint pack is not current") + } + if s.battleActive != nil && s.battleActive() { + return fmt.Errorf("world: waypoint travel during battle") + } + } + return nil + }) +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 7a1b9a9..39ea131 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -164,6 +164,9 @@ type Service struct { packJamDesign *gamedata.PackJamDesign packJamMu sync.Mutex fieldPacks map[int]gamedata.FieldPack + fieldObjects map[int]gamedata.FieldObjectDesign + fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) + fieldReset gamedata.FieldResetSchedule squadLevel func() (uint64, error) seed Seed state *progress.Store @@ -200,6 +203,12 @@ func (s *Service) AttachDecks(decks *deck.Store) error { func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { switch path { + case "/QuestUpdate": + return s.handleQuestUpdate(request) + case "/FieldObjectInfo": + return s.handleFieldObjectInfo(request) + case "/FieldObjectReward": + return s.handleFieldObjectReward(request) case "/QuestInfo", "/QuestAccept", "/QuestGiveUp": return s.handleQuestSelection(path, request) case "/PackBuy":