diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 0b30e8d..318c645 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -39,6 +39,8 @@ import ( "bd2server/internal/server/mail" "bd2server/internal/server/missions" "bd2server/internal/server/monsterhunt" + "bd2server/internal/server/npcinn" + "bd2server/internal/server/npcshop" "bd2server/internal/server/pictorial" "bd2server/internal/server/player" "bd2server/internal/server/progress" @@ -47,6 +49,7 @@ import ( "bd2server/internal/server/resourcepolicy" "bd2server/internal/server/session" "bd2server/internal/server/stateio" + "bd2server/internal/server/todayquest" "bd2server/internal/server/transport" "bd2server/internal/server/versionconfig" "bd2server/internal/server/wire" @@ -670,6 +673,9 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("read initial hunting AP: %w", err) } gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"} + if err := worldService.AttachFieldMonsterState(gameplayStore); err != nil { + return fmt.Errorf("attach field monster state: %w", err) + } contentOpeningDesign, err := gamedata.LoadContentOpeningDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load content opening GameData: %w", err) @@ -733,6 +739,41 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("load event economy: %w", err) } eventEconomy.AttachHuntingAP(huntingService) + if err := worldService.ConfigureNPCRuntime(gameData, *gameDataVersion, gameplayStore); err != nil { + return fmt.Errorf("configure NPC world runtime: %w", err) + } + innService, err := npcinn.New(gameplayStore, worldService.CharacterService(), wallet, worldService.InnContext, + func() (uint64, error) { + experience, err := missionService.AchievementExperience() + if err != nil { + return 0, err + } + return levelDesign.Level(experience), nil + }, battleService.Active) + if err != nil { + return fmt.Errorf("load inn recovery: %w", err) + } + npcShopDesign, err := gamedata.LoadNPCShopDesign(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load NPC shop design: %w", err) + } + npcShopService, err := npcshop.New(npcShopDesign, gameplayStore, eventEconomy, ownedItems, worldService.PackAvailable) + if err != nil { + return fmt.Errorf("load NPC shop state: %w", err) + } + npcShopService.SetReputationSource(worldService.NPCShopReputation) + commissionDesign, err := gamedata.LoadTodayQuests(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load NPC commission design: %w", err) + } + commissionService, err := todayquest.Open(gameplayStore, commissionDesign, eventEconomy, ownedItems, worldService.CommissionPackUnlocked) + if err != nil { + return fmt.Errorf("load NPC commission state: %w", err) + } + commissionService.CompleteReputation = worldService.CompleteNPCReputation + if err := worldService.AttachTodayQuests(commissionService); err != nil { + return fmt.Errorf("attach NPC commissions: %w", err) + } if err = worldService.AttachResearchRuntime(gameData, *gameDataVersion, eventEconomy); err != nil { return fmt.Errorf("attach field research: %w", err) } @@ -1002,7 +1043,10 @@ func serve(args []string) (serveErr error) { return append(rows, huntingRows...), nil }) eventActionsService.AttachProgress(func(condition, sub, count uint64) error { - return missionService.RecordEvent(condition, sub, count, missionUnlocked) + if err := missionService.RecordEvent(condition, sub, count, missionUnlocked); err != nil { + return err + } + return eventTasksService.RecordEvent(condition, sub, count, missionUnlocked) }) eventPlayService.AttachProgress(func(condition, sub, count uint64) error { return missionService.RecordEvent(condition, sub, count, missionUnlocked) @@ -1096,6 +1140,10 @@ func serve(args []string) (serveErr error) { if err := missionService.AttachAchievementProgress(achievementCounters); err != nil { return fmt.Errorf("attach achievement completion validation: %w", err) } + commissionService.CompleteAchievement = func(identity string) error { + _, err := achievementCounters.RecordEvent(identity, 17, 0, 1) + return err + } if err := login.AttachAchievementExperience(missionService); err != nil { return fmt.Errorf("attach persisted achievement experience: %w", err) } @@ -1124,6 +1172,8 @@ func serve(args []string) (serveErr error) { boxService, eventPlayService, eventActionsService, + npcShopService, + innService, events.SkinHandler{Economy: eventEconomy}, battleService, huntingService, diff --git a/go/internal/server/eventactions/service.go b/go/internal/server/eventactions/service.go index e800d73..dc276f0 100644 --- a/go/internal/server/eventactions/service.go +++ b/go/internal/server/eventactions/service.go @@ -184,6 +184,7 @@ func (s *Service) roll() { s.state.Day = day s.state.DailyNormal = 0 s.state.DailySpecial = 0 + s.state.Spawns = map[uint64]*spawn{} s.state.CafeteriaCurrency = 0 s.state.CafeteriaLast = map[string]int64{} s.state.NormalVoted = map[string]bool{} @@ -283,7 +284,10 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) if _, e := s.resolve(uid, 21); e != nil { return nil, e } - out := wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[uid])) + var out []byte + if p := s.state.Spawns[uid]; p != nil { + out = wire.AppendBytes(out, 1, s.spawnWire(p)) + } out = wire.AppendVarint(out, 2, s.state.DailyNormal) out = wire.AppendVarint(out, 3, s.state.DailySpecial) return out, nil @@ -297,10 +301,13 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) if !ok || g != v.ID { return nil, errors.New("eventactions: invalid spawn event") } + if err := s.spawnWindow(r); err != nil { + return nil, err + } if p := s.state.Spawns[v.UID]; p != nil { defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 - if expired { + if expired && (p.Group != g || p.ID != id) { delete(s.state.Spawns, v.UID) } else { if p.Group != g || p.ID != id { @@ -332,15 +339,21 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } ck := key(g, id) if p.Caught[ck] { - return nil, errors.New("eventactions: monster already caught") + // A new transport sequence must not turn a confirmed catch into + // either another grant or an error/recovery loop. + return wire.AppendBytes(nil, 1, nil), nil } defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) - if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start > int64(defaults.V(12))*1000 { + if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 { return nil, errors.New("eventactions: spawn time expired") } special := r.V(2) != 0 - if special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) { - return nil, errors.New("eventactions: daily catch limit") + limited := special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) + if limited { + // Participation remains available after the daily reward quota. + // Completing the capture without a grant also prevents client retries. + p.Caught[ck] = true + return wire.AppendBytes(nil, 1, nil), nil } reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}] if !ok || reward.Count == 0 { @@ -357,7 +370,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) s.state.DailyNormal++ } if s.progress != nil { - if e = s.progress(349, 0, 1); e != nil { + if e = s.progress(349, r.V(2), 1); e != nil { return nil, e } } diff --git a/go/internal/server/eventactions/service_test.go b/go/internal/server/eventactions/service_test.go index da30651..1595588 100644 --- a/go/internal/server/eventactions/service_test.go +++ b/go/internal/server/eventactions/service_test.go @@ -31,6 +31,7 @@ func setup(t *testing.T) (*Service, *economyStub, stateio.Store) { } d := &gamedata.EventActionsDesign{Tables: map[string][]gamedata.EventActionRow{"VotingEventTable": {row(map[int]uint64{6: 1, 10: 1, 14: 1, 7: 1, 8: 6001, 9: 8, 1: 1, 2: 6002, 3: 8})}, "VotingCandidateTable": {row(map[int]uint64{1: 1, 2: 9})}, "VotingRoundTable": {row(map[int]uint64{2: 1, 3: 1})}, "TacticsBingoGroupTable": {row(map[int]uint64{3: 1, 1: 5, 4: 2})}, "TacticsBingoTable": {row(map[int]uint64{4: 5, 5: 1, 2: 99})}, "FieldSpawnEventTable": {row(map[int]uint64{4: 1, 5: 1, 2: 1})}, "FieldEventMonsterTable": {row(map[int]uint64{3: 1, 4: 1, 1: 3001, 5: 3009})}, "FieldEventDefaultTable": {row(map[int]uint64{4: 10, 5: 2, 12: 40})}}, SpawnRewards: map[[2]uint64]gamedata.Reward{{3009, 3001}: {Type: 4, Count: 10000}}} economy := &economyStub{} + d.Tables["FieldSpawnEventTable"][0].Text = map[int]string{7: "12:00:00", 1: "12:10:00"} store := stateio.NewMemory() s, e := Open(store, d, r, economy) if e != nil { diff --git a/go/internal/server/eventactions/spawn_test.go b/go/internal/server/eventactions/spawn_test.go new file mode 100644 index 0000000..cb8d44c --- /dev/null +++ b/go/internal/server/eventactions/spawn_test.go @@ -0,0 +1,202 @@ +package eventactions + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "errors" + "path/filepath" + "testing" + "time" +) + +type trackerTransactionEconomy struct{ store stateio.Store } + +func (e trackerTransactionEconomy) Apply(_ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) { + return nil, e.store.Save("tracker-reward", []byte(`{"Granted":true}`)) +} + +func TestTrackerRewardAndProgressFailureRollbackTogether(t *testing.T) { + s, _, _ := setup(t) + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + s.store = repo + s.economy = trackerTransactionEconomy{repo} + if _, _, _, err = s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { + t.Fatal(err) + } + s.AttachProgress(func(uint64, uint64, uint64) error { return errors.New("mission write failed") }) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err == nil { + t.Fatal("mission failure was ignored") + } + if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + repo.Close() + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + b, err := repo.Load("tracker-reward") + if err != nil || b != nil { + t.Fatal("reward survived transaction rollback") + } + next, err := Open(repo, s.design, s.registry, trackerTransactionEconomy{repo}) + if err != nil { + t.Fatal(err) + } + next.now = s.now + next.BeginSession("retry") + if next.state.DailyNormal != 0 || len(next.state.Spawns[3].Caught) != 0 { + t.Fatal("catch survived failed mission transaction") + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = next.Handle("/FieldEventSpawnReward", spawnCatch(3)); err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + b, err = repo.Load("tracker-reward") + if err != nil || b == nil || next.state.DailyNormal != 1 { + t.Fatal("retry failed to commit reward and catch together") + } +} + +func spawnStart(seq, id uint64) []byte { + b := wire.AppendVarint(req(seq), 2, 3) + b = wire.AppendVarint(b, 3, 1) + return wire.AppendVarint(b, 4, id) +} +func spawnCatch(seq uint64) []byte { + b := req(seq) + for _, f := range [][2]uint64{{2, 1}, {3, 1}, {4, 1}, {5, 1}, {6, 3}} { + b = wire.AppendVarint(b, int(f[0]), f[1]) + } + return b +} + +func TestTrackerPrepareCatchResumeAndCrossSequenceRetry(t *testing.T) { + s, e, store := setup(t) + info := wire.AppendVarint(req(1), 2, 3) + _, b, _, err := s.Handle("/FieldEventSpawnInfo", info) + if err != nil { + t.Fatal(err) + } + if _, exists, _ := wire.Bytes(b, 1); exists { + t.Fatal("new player received fabricated non-null progress") + } + if _, _, _, err = s.Handle("/FieldEventSpawnStart", spawnStart(2, 1)); err != nil { + t.Fatal(err) + } + progressCalls := 0 + s.AttachProgress(func(kind, target, count uint64) error { + if kind != 349 || target != 0 || count != 1 { + t.Fatal("wrong catch mission progress") + } + progressCalls++ + return nil + }) + if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(3)); err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(4)); err != nil { + t.Fatal(err) + } + if e.calls != 1 || progressCalls != 1 || s.state.DailyNormal != 1 { + t.Fatal("repeat catch duplicated settlement") + } + next, err := Open(store, s.design, s.registry, e) + if err != nil { + t.Fatal(err) + } + next.now = s.now + next.BeginSession("restart") + _, b, _, err = next.Handle("/FieldEventSpawnInfo", wire.AppendVarint(req(5), 2, 3)) + if err != nil { + t.Fatal(err) + } + p, exists, _ := wire.Bytes(b, 1) + if !exists || val(p, 3) != 1 || val(b, 2) != 1 { + t.Fatal("reconnect lost durable catch progress") + } + caught := 0 + wire.Walk(p, func(f wire.Field) error { + if f.Number == 5 { + caught++ + } + return nil + }) + if caught != 1 { + t.Fatal("resume lost caught monster") + } + if _, _, _, err = next.Handle("/FieldEventSpawnReward", spawnCatch(6)); err != nil || e.calls != 1 { + t.Fatal("restart catch retry duplicated reward") + } +} + +func TestTrackerSlotsExpiryAndDailyReset(t *testing.T) { + s, _, _ := setup(t) + base := s.now() + now := base + s.now = func() time.Time { return now } + s.design.Tables["FieldSpawnEventTable"] = append(s.design.Tables["FieldSpawnEventTable"], gamedata.EventActionRow{Values: map[int]uint64{4: 1, 5: 2, 2: 1}, Text: map[int]string{7: "13:00:00", 1: "13:10:00"}}) + r := events.NewRegistry() + r.Replace([]events.Schedule{{UID: 3, Type: 21, ID: 1, Start: base.Add(-time.Hour).UnixMilli(), End: base.Add(48 * time.Hour).UnixMilli()}}) + s.registry = r + if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { + t.Fatal(err) + } + start := s.state.Spawns[3].Start + now = base.Add(40 * time.Second) + if _, _, _, err := s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err == nil { + t.Fatal("accepted catch at time limit") + } + if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(3, 1)); err != nil || s.state.Spawns[3].Start != start { + t.Fatal("expired slot was renewed") + } + if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(4, 2)); err == nil { + t.Fatal("future slot started early") + } + now = base.Add(time.Hour) + if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(5, 2)); err != nil || s.state.Spawns[3].ID != 2 { + t.Fatal("next slot did not replace expired progress") + } + s.state.DailyNormal = 10 + s.state.DailySpecial = 2 + now = base.Add(24 * time.Hour) + _, b, _, err := s.Handle("/FieldEventSpawnInfo", wire.AppendVarint(req(6), 2, 3)) + if err != nil { + t.Fatal(err) + } + if _, ok, _ := wire.Bytes(b, 1); ok || val(b, 2) != 0 || val(b, 3) != 0 { + t.Fatal("daily reset kept yesterday's progress or quotas") + } +} + +func TestTrackerQuotaStopsRewardsWithoutRejectingCapture(t *testing.T) { + s, e, _ := setup(t) + if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { + t.Fatal(err) + } + s.state.DailyNormal = 10 + if _, _, _, err := s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err != nil { + t.Fatal("daily reward quota rejected gameplay capture", err) + } + if e.calls != 0 || s.state.DailyNormal != 10 || !s.state.Spawns[3].Caught[key(1, 1)] { + t.Fatal("quota either awarded again or failed to finish capture") + } +} diff --git a/go/internal/server/eventactions/spawn_window.go b/go/internal/server/eventactions/spawn_window.go new file mode 100644 index 0000000..a2152fb --- /dev/null +++ b/go/internal/server/eventactions/spawn_window.go @@ -0,0 +1,31 @@ +package eventactions + +import ( + "bd2server/internal/server/gamedata" + "fmt" + "time" +) + +// Spawn slots repeat daily in the same server UTC clock used by the game's +// schedule response. Preparation time is client presentation; Start must fall +// inside the actual playing interval declared by FieldSpawnEventTable. +func (s *Service) spawnWindow(row gamedata.EventActionRow) error { + start, err := time.Parse("15:04:05", row.Text[7]) + if err != nil { + return fmt.Errorf("eventactions: malformed spawn start time") + } + end, err := time.Parse("15:04:05", row.Text[1]) + if err != nil { + return fmt.Errorf("eventactions: malformed spawn end time") + } + seconds := func(t time.Time) int { return t.Hour()*3600 + t.Minute()*60 + t.Second() } + from, to, now := seconds(start), seconds(end), seconds(s.now().UTC()) + available := now >= from && now < to + if to < from { + available = now >= from || now < to + } + if !available { + return fmt.Errorf("eventactions: spawn slot not playing") + } + return nil +} diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/eventtasks/service.go index 465a8cb..a5da8f0 100644 --- a/go/internal/server/eventtasks/service.go +++ b/go/internal/server/eventtasks/service.go @@ -893,7 +893,7 @@ func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64 continue } for _, t := range s.design.Missions { - if t.Type != condition || t.SubType != 0 && t.SubType != sub || !s.availableTask(v, t) { + if t.Type != condition || t.SubType != 0 && t.SubType != sub || condition == 349 && t.SubType != sub || !s.availableTask(v, t) { continue } if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) { diff --git a/go/internal/server/eventtasks/tracker_missions_test.go b/go/internal/server/eventtasks/tracker_missions_test.go new file mode 100644 index 0000000..f4189e5 --- /dev/null +++ b/go/internal/server/eventtasks/tracker_missions_test.go @@ -0,0 +1,29 @@ +package eventtasks + +import ( + "bd2server/internal/server/gamedata" + "testing" +) + +// MC_FIELD_SPAWN_EVENT_REWARD subtype zero is the normal monster kind, +// unlike other event conditions where a zero subtype means any item. +func TestTrackerMissionsSeparateNormalAndSpecialRewards(t *testing.T) { + s, _, _ := setup(t) + s.design.Missions = map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 349, SubType: 0, Target: 20}, 11: {ID: 11, Group: 9, Type: 349, SubType: 1, Target: 5}} + if err := s.RecordEvent(349, 1, 1, nil); err != nil { + t.Fatal(err) + } + v, err := s.registry.Resolve(2) + if err != nil { + t.Fatal(err) + } + if s.mission(v, 10).Value != 0 || s.mission(v, 11).Value != 1 { + t.Fatal("special capture advanced normal capture mission") + } + if err := s.RecordEvent(349, 0, 1, nil); err != nil { + t.Fatal(err) + } + if s.mission(v, 10).Value != 1 || s.mission(v, 11).Value != 1 { + t.Fatal("normal capture advanced special capture mission") + } +} diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index c2d011a..ef0d9c3 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -66,7 +66,6 @@ func TestEmptyPacketCodesReturnsCopy(t *testing.T) { func TestStandaloneNativeDefaults(t *testing.T) { for path, want := range map[string]int{ "/EquipInfo": 34, - "/TodayQuestInfo": 64, "/UpdateAgeGate": 0, "/ActiveMap": 0, "/PvpBattleDeckInfo": 87, diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index beb0df8..859c77c 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -14,12 +14,10 @@ var standaloneDefaults = map[string]int{ "/PvpBattleDeckInfo": 87, "/PvpBattleHistory": 97, "/PvpBattleOnceRewardInfo": 277, - "/TodayQuestInfo": 64, "/FieldDeckInfo": 273, "/FriendRecommend": 211, "/SupporterStatus": 438, "/SupporterBattleInfo": 439, - "/InnOpen": 109, } // commandSuccess contains idempotent local mutations whose response type is diff --git a/go/internal/server/gamedata/field_monsters.go b/go/internal/server/gamedata/field_monsters.go new file mode 100644 index 0000000..7c88976 --- /dev/null +++ b/go/internal/server/gamedata/field_monsters.go @@ -0,0 +1,94 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" +) + +// FieldMonsterDesign keeps the regeneration group requested by MonsterInfo, +// not the unrelated event monster group used by FieldEventSpawnReward. +type FieldMonsterDesign struct { + ID, GroupID, QuestID int + BattleDeck uint64 + LifeSeconds uint64 +} + +func LoadFieldMonsters(root, version string, pack int) ([]FieldMonsterDesign, error) { + db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack)) + if err != nil { + return nil, err + } + defer release() + return loadFieldMonsters(db) +} + +func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) { + groups := map[int]FieldMonsterDesign{} + rows, err := db.Query("SELECT id, ProtoBuf FROM FieldMonsterRegenTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + q, e1 := packedInts(raw, 6) + life, e2 := packedInts(raw, 3) + if e1 != nil || e2 != nil || len(q) > 1 || len(life) > 1 { + rows.Close() + return nil, fmt.Errorf("gamedata: malformed monster regeneration %d", id) + } + g := FieldMonsterDesign{GroupID: id} + if len(q) > 0 { + g.QuestID = int(q[0]) + } + if len(life) > 0 { + if life[0] > math.MaxInt32 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid monster lifetime %d", id) + } + g.LifeSeconds = life[0] + } + groups[id] = g + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + rows, err = db.Query("SELECT id, ProtoBuf FROM FieldMonsterTable ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var result []FieldMonsterDesign + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return nil, err + } + group, e1 := packedInts(raw, 24) + decks, e2 := packedInts(raw, 2) + if e1 != nil || e2 != nil || len(group) > 1 { + return nil, fmt.Errorf("gamedata: malformed field monster %d", id) + } + if len(group) == 0 || group[0] == 0 { + continue + } + g, ok := groups[int(group[0])] + if !ok { + return nil, fmt.Errorf("gamedata: monster %d references missing regeneration %d", id, group[0]) + } + g.ID = id + if len(decks) > 0 { + g.BattleDeck = decks[0] + } + result = append(result, g) + } + return result, rows.Err() +} diff --git a/go/internal/server/gamedata/field_monsters_test.go b/go/internal/server/gamedata/field_monsters_test.go new file mode 100644 index 0000000..0847df2 --- /dev/null +++ b/go/internal/server/gamedata/field_monsters_test.go @@ -0,0 +1,38 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestFieldMonsterCatalogJoinsRegenerationAndRejectsBrokenReference(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, table := range []string{"FieldMonsterTable", "FieldMonsterRegenTable"} { + if _, err = db.Exec("CREATE TABLE " + table + " (id INTEGER,ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + } + regen := wire.AppendVarint(wire.AppendVarint(nil, 6, 91), 3, 30) + if _, err = db.Exec("INSERT INTO FieldMonsterRegenTable VALUES (?,?)", 17, regen); err != nil { + t.Fatal(err) + } + monster := wire.AppendVarint(wire.AppendVarint(nil, 24, 17), 2, 78) + if _, err = db.Exec("INSERT INTO FieldMonsterTable VALUES (?,?)", 3, monster); err != nil { + t.Fatal(err) + } + rows, err := loadFieldMonsters(db) + if err != nil || len(rows) != 1 || rows[0] != (FieldMonsterDesign{ID: 3, GroupID: 17, QuestID: 91, BattleDeck: 78, LifeSeconds: 30}) { + t.Fatalf("catalog %+v: %v", rows, err) + } + if _, err = db.Exec("UPDATE FieldMonsterTable SET ProtoBuf=?", wire.AppendVarint(nil, 24, 18)); err != nil { + t.Fatal(err) + } + if _, err = loadFieldMonsters(db); err == nil { + t.Fatal("accepted unknown regeneration group") + } +} diff --git a/go/internal/server/gamedata/inn.go b/go/internal/server/gamedata/inn.go new file mode 100644 index 0000000..7769385 --- /dev/null +++ b/go/internal/server/gamedata/inn.go @@ -0,0 +1,105 @@ +package gamedata + +import ( + "database/sql" + "errors" + "fmt" + "math" + "slices" +) + +type InnRule struct { + NPCID, MapID, MapGroup, Currency, ItemCount, FreeSquadLevel, GoodDiscount uint64 +} + +// LoadInns reads motel NPCs from the selected pack and their shared recovery +// rules. The /40 conversion is the client's HP pricing unit, not a pack fact. +func LoadInns(root, version string, pack int) ([]InnRule, error) { + db, release, err := openPackDatabase(root, version, pack) + if err != nil { + return nil, err + } + defer release() + shared, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + return loadInns(db, shared) +} + +func loadInns(db, shared *sql.DB) ([]InnRule, error) { + rows, err := db.Query("SELECT id,ProtoBuf FROM FieldNpcTable ORDER BY id") + if err != nil { + return nil, err + } + var rules []InnRule + var recoveryIDs []uint64 + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + interactions, e := packedInts(raw, 9) + if e != nil { + rows.Close() + return nil, e + } + if !slices.Contains(interactions, uint64(2)) { + continue + } + mapID, e := optionalScalar(raw, 14) + if e != nil { + rows.Close() + return nil, e + } + recovery, e := optionalScalar(raw, 21) + if e != nil || recovery == 0 || mapID == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid motel NPC %d", id) + } + rules = append(rules, InnRule{NPCID: id, MapID: mapID}) + recoveryIDs = append(recoveryIDs, recovery) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + for i := range rules { + r := &rules[i] + var raw []byte + if err = shared.QueryRow("SELECT ProtoBuf FROM CharRecoveryTable WHERE id=?", recoveryIDs[i]).Scan(&raw); err != nil { + return nil, err + } + for field, target := range map[int]*uint64{3: &r.ItemCount, 4: &r.Currency, 5: &r.FreeSquadLevel} { + *target, err = optionalScalar(raw, field) + if err != nil || *target > math.MaxInt32 { + return nil, fmt.Errorf("gamedata: invalid inn recovery rule") + } + } + if r.Currency != 4 || r.ItemCount == 0 { + return nil, fmt.Errorf("gamedata: unsupported inn currency or HP rate") + } + if err = shared.QueryRow("SELECT ProtoBuf FROM MapTable WHERE id=?", r.MapID).Scan(&raw); err != nil { + return nil, err + } + r.MapGroup, err = optionalScalar(raw, 18) + if err != nil { + return nil, err + } + err = db.QueryRow("SELECT ProtoBuf FROM ReputationGroupTable WHERE id=?", r.MapGroup).Scan(&raw) + if err == nil { + r.GoodDiscount, err = optionalScalar(raw, 2) + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if r.GoodDiscount > 100 { + return nil, fmt.Errorf("gamedata: invalid motel discount") + } + } + return rules, nil +} diff --git a/go/internal/server/gamedata/inn_test.go b/go/internal/server/gamedata/inn_test.go new file mode 100644 index 0000000..a61e245 --- /dev/null +++ b/go/internal/server/gamedata/inn_test.go @@ -0,0 +1,56 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestInnLoaderJoinsNPCRecoveryMapAndReputation(t *testing.T) { + pack, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer pack.Close() + shared, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer shared.Close() + for _, table := range []string{"FieldNpcTable", "ReputationGroupTable"} { + if _, err = pack.Exec("CREATE TABLE " + table + " (id INTEGER, ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + } + for _, table := range []string{"CharRecoveryTable", "MapTable"} { + if _, err = shared.Exec("CREATE TABLE " + table + " (id INTEGER, ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + } + npc := wire.AppendBytes(nil, 9, []byte{2}) + npc = wire.AppendVarint(npc, 14, 88) + npc = wire.AppendVarint(npc, 21, 4) + if _, err = pack.Exec("INSERT INTO FieldNpcTable VALUES(?,?)", 7, npc); err != nil { + t.Fatal(err) + } + if _, err = pack.Exec("INSERT INTO ReputationGroupTable VALUES(?,?)", 3, wire.AppendVarint(nil, 2, 15)); err != nil { + t.Fatal(err) + } + if _, err = shared.Exec("INSERT INTO MapTable VALUES(?,?)", 88, wire.AppendVarint(nil, 18, 3)); err != nil { + t.Fatal(err) + } + recovery := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 3, 2), 4, 4), 5, 20) + if _, err = shared.Exec("INSERT INTO CharRecoveryTable VALUES(?,?)", 4, recovery); err != nil { + t.Fatal(err) + } + rules, err := loadInns(pack, shared) + if err != nil || len(rules) != 1 || rules[0] != (InnRule{NPCID: 7, MapID: 88, MapGroup: 3, Currency: 4, ItemCount: 2, FreeSquadLevel: 20, GoodDiscount: 15}) { + t.Fatalf("joined inn %+v: %v", rules, err) + } + if _, err = shared.Exec("DELETE FROM CharRecoveryTable"); err != nil { + t.Fatal(err) + } + if _, err = loadInns(pack, shared); err == nil { + t.Fatal("missing recovery foreign key accepted") + } +} diff --git a/go/internal/server/gamedata/npc_reputation.go b/go/internal/server/gamedata/npc_reputation.go new file mode 100644 index 0000000..d9d849a --- /dev/null +++ b/go/internal/server/gamedata/npc_reputation.go @@ -0,0 +1,78 @@ +package gamedata + +import "fmt" + +type NPCReputationRule struct{ ID, DownHours, GoodInn, GoodPrice uint64 } +type NPCReputationDesign struct { + Groups map[uint64]NPCReputationRule + MapGroups map[int]uint64 +} + +func LoadNPCReputation(root, version string, pack int) (NPCReputationDesign, error) { + d := NPCReputationDesign{Groups: map[uint64]NPCReputationRule{}, MapGroups: map[int]uint64{}} + db, done, err := openPackDatabase(root, version, pack) + if err != nil { + return d, err + } + defer done() + rows, err := db.Query("SELECT id,ProtoBuf FROM ReputationGroupTable") + if err != nil { + return d, err + } + for rows.Next() { + var id uint64 + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + rows.Close() + return d, err + } + r := NPCReputationRule{ID: id} + for f, p := range map[int]*uint64{1: &r.DownHours, 2: &r.GoodInn, 3: &r.GoodPrice} { + *p, err = optionalScalar(raw, f) + if err != nil { + rows.Close() + return d, err + } + } + if id == 0 || r.GoodInn > 100 || r.GoodPrice > 100 || r.DownHours > 596523 { + rows.Close() + return d, fmt.Errorf("gamedata: invalid reputation rule") + } + d.Groups[id] = r + } + err = rows.Err() + rows.Close() + if err != nil { + return d, err + } + shared, release, err := openStatDatabase(root, version) + if err != nil { + return d, err + } + defer release() + rows, err = shared.Query("SELECT id,ProtoBuf FROM MapTable") + if err != nil { + return d, err + } + defer rows.Close() + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return d, err + } + p, e := optionalScalar(raw, 28) + if e != nil { + return d, e + } + if p != uint64(pack) { + continue + } + group, e := optionalScalar(raw, 18) + if e != nil { + return d, e + } + d.MapGroups[id] = group + } + return d, rows.Err() +} diff --git a/go/internal/server/gamedata/npc_shop.go b/go/internal/server/gamedata/npc_shop.go new file mode 100644 index 0000000..89229eb --- /dev/null +++ b/go/internal/server/gamedata/npc_shop.go @@ -0,0 +1,89 @@ +package gamedata + +import ( + "database/sql" + "fmt" +) + +// NPCShopDesign is the ordinary in-game shop, distinct from CashShopTable. +type NPCShopDesign struct { + Shops map[uint64]NPCShop + Products map[uint64]map[uint64]NPCProduct + Sell map[uint64]NPCProduct +} +type NPCShop struct{ ID, PackID, ResetType, ResetCount, StartDay uint64 } +type NPCProduct struct { + ID, GroupID, MaxCount, Discount, Premium, NoBargain, Reputation uint64 + Reward, Price Reward + PremiumPriceType, HighPremium, HighDay, HighShop uint64 +} + +func LoadNPCShopDesign(root, version string) (NPCShopDesign, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return NPCShopDesign{}, err + } + defer closeDB() + return loadNPCShopDesign(db) +} +func loadNPCShopDesign(db *sql.DB) (NPCShopDesign, error) { + d := NPCShopDesign{Shops: map[uint64]NPCShop{}, Products: map[uint64]map[uint64]NPCProduct{}, Sell: map[uint64]NPCProduct{}} + err := friendshipRows(db, "SELECT id,ProtoBuf FROM ShopTable ORDER BY id", func(id uint64, p []byte) error { + r := NPCShop{} + for i, dst := range []*uint64{&r.ID, &r.PackID, &r.ResetType, &r.ResetCount, &r.StartDay} { + v, e := friendshipScalar(p, []int{5, 8, 12, 11, 14}[i]) + if e != nil { + return e + } + *dst = v + } + if r.ID != id || id == 0 || r.ResetType > 3 { + return fmt.Errorf("gamedata: invalid shop %d", id) + } + d.Shops[id] = r + return nil + }) + if err != nil { + return d, err + } + err = friendshipRows(db, "SELECT id,ProtoBuf FROM ProductTable ORDER BY groupId,id", func(id uint64, p []byte) error { + r := NPCProduct{} + for i, dst := range []*uint64{&r.ID, &r.GroupID, &r.MaxCount, &r.Discount, &r.Premium, &r.NoBargain, &r.Reputation, &r.Reward.Type, &r.Reward.ID, &r.Reward.Count, &r.Price.Type, &r.Price.ID, &r.Price.Count} { + v, e := friendshipScalar(p, []int{7, 6, 1, 2, 9, 8, 13, 5, 4, 3, 12, 11, 10}[i]) + if e != nil { + return e + } + *dst = v + } + if r.ID != id || r.Reward.Count == 0 || r.Price.Count == 0 || r.Discount > 100 { + return fmt.Errorf("gamedata: invalid shop product %d", id) + } + if _, ok := d.Shops[r.GroupID]; !ok { + return fmt.Errorf("gamedata: product missing shop %d", r.GroupID) + } + if d.Products[r.GroupID] == nil { + d.Products[r.GroupID] = map[uint64]NPCProduct{} + } + d.Products[r.GroupID][id] = r + return nil + }) + if err != nil { + return d, err + } + err = friendshipRows(db, "SELECT id,ProtoBuf FROM SellItemTable ORDER BY id", func(id uint64, p []byte) error { + r := NPCProduct{} + for i, dst := range []*uint64{&r.ID, &r.Discount, &r.Premium, &r.Reward.Type, &r.Reward.ID, &r.Reward.Count, &r.Price.Type, &r.Price.ID, &r.Price.Count, &r.PremiumPriceType, &r.HighPremium, &r.HighDay, &r.HighShop} { + v, e := friendshipScalar(p, []int{8, 1, 10, 4, 3, 2, 13, 12, 11, 9, 6, 5, 7}[i]) + if e != nil { + return e + } + *dst = v + } + if r.ID != id || r.Reward.Count == 0 || r.Price.Count == 0 || r.Discount > 100 { + return fmt.Errorf("gamedata: invalid sell product %d", id) + } + d.Sell[id] = r + return nil + }) + return d, err +} diff --git a/go/internal/server/gamedata/npc_shop_test.go b/go/internal/server/gamedata/npc_shop_test.go new file mode 100644 index 0000000..3b51a09 --- /dev/null +++ b/go/internal/server/gamedata/npc_shop_test.go @@ -0,0 +1,43 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestNPCShopCatalogKeepsCompositeProductKeysAndRejectsOrphans(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, query := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} { + if _, err = db.Exec(query); err != nil { + t.Fatal(err) + } + } + for _, shop := range []uint64{37, 48} { + p := wire.AppendVarint(wire.AppendVarint(nil, 5, shop), 8, 91) + if _, err = db.Exec("INSERT INTO ShopTable VALUES(?,?)", shop, p); err != nil { + t.Fatal(err) + } + p = nil + for _, f := range [][2]uint64{{7, 2}, {6, shop}, {3, 3}, {5, 5}, {4, 400}, {10, 25}, {12, 4}} { + p = wire.AppendVarint(p, int(f[0]), f[1]) + } + if _, err = db.Exec("INSERT INTO ProductTable VALUES(?,?,?)", 2, shop, p); err != nil { + t.Fatal(err) + } + } + d, err := loadNPCShopDesign(db) + if err != nil || len(d.Products) != 2 || d.Products[37][2].Reward.Count != 3 || d.Products[48][2].Price.Count != 25 { + t.Fatalf("lost composite product keys: %+v %v", d, err) + } + if _, err = db.Exec("DELETE FROM ShopTable WHERE id=48"); err != nil { + t.Fatal(err) + } + if _, err = loadNPCShopDesign(db); err == nil { + t.Fatal("accepted product whose shop is missing") + } +} diff --git a/go/internal/server/gamedata/today_quests.go b/go/internal/server/gamedata/today_quests.go new file mode 100644 index 0000000..c8f5a2a --- /dev/null +++ b/go/internal/server/gamedata/today_quests.go @@ -0,0 +1,119 @@ +package gamedata + +import ( + "fmt" +) + +// TodayQuest is a node of the weekly NPC commission chain. The historical +// table/packet name says Today, but current LocalText397 says quests per week. +type TodayQuest struct { + ID, PackID, PriorID, NextID, ConditionType, ConditionCount, ReputationCompleteID int + MagicValues, GiveItemIDs []uint64 + Rewards []Reward +} +type TodayQuestCatalog struct { + Quests map[int]TodayQuest + Limit, PostCount, AchievementScore int + Reset FieldResetSchedule +} + +func LoadTodayQuests(root, version string) (*TodayQuestCatalog, error) { + reset, err := LoadFieldResetSchedule(root, version) + if err != nil { + return nil, err + } + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + c := &TodayQuestCatalog{Quests: map[int]TodayQuest{}, Reset: reset} + var defaults []byte + if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil { + return nil, err + } + for field, dst := range map[int]*int{110: &c.AchievementScore, 111: &c.Limit, 112: &c.PostCount} { + v, e := optionalScalar(defaults, field) + if e != nil || v == 0 || v > 2147483647 { + return nil, fmt.Errorf("gamedata: invalid today defaults %d", field) + } + *dst = int(v) + } + rows, err := db.Query("SELECT id,ProtoBuf FROM TodayQuestTable ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id int + var raw []byte + if err = rows.Scan(&id, &raw); err != nil { + return nil, err + } + q := TodayQuest{ID: id} + for field, dst := range map[int]*int{22: &q.PackID, 23: &q.PriorID, 21: &q.NextID, 10: &q.ConditionType, 9: &q.ConditionCount, 27: &q.ReputationCompleteID} { + v, e := optionalScalar(raw, field) + if e != nil || v > 2147483647 { + return nil, fmt.Errorf("gamedata: invalid today quest %d field %d", id, field) + } + *dst = int(v) + } + typ, e := optionalScalar(raw, 31) + if e != nil || typ != 2 || id <= 0 || q.PackID <= 0 || q.ConditionCount <= 0 { + return nil, fmt.Errorf("gamedata: invalid today quest %d", id) + } + switch q.ConditionType { + case 1, 2, 9, 18, 19: + default: + return nil, fmt.Errorf("gamedata: unsupported today condition %d", q.ConditionType) + } + if q.MagicValues, err = packedInts(raw, 19); err != nil { + return nil, err + } + if q.GiveItemIDs, err = packedInts(raw, 17); err != nil { + return nil, err + } + ts, e := packedInts(raw, 30) + if e != nil { + return nil, e + } + ids, e := packedInts(raw, 29) + if e != nil { + return nil, e + } + ns, e := packedInts(raw, 28) + if e != nil { + return nil, e + } + if len(ts) != len(ids) || len(ids) != len(ns) { + return nil, fmt.Errorf("gamedata: today quest %d reward arrays", id) + } + for i, t := range ts { + if t == 0 { + return nil, fmt.Errorf("gamedata: today quest zero reward") + } + q.Rewards = append(q.Rewards, Reward{Type: t, ID: ids[i], Count: ns[i]}) + } + c.Quests[id] = q + } + if err = rows.Err(); err != nil { + return nil, err + } + for _, q := range c.Quests { + seen := map[int]bool{} + for n := q.ID; n != 0; n = c.Quests[n].NextID { + if seen[n] { + return nil, fmt.Errorf("gamedata: cyclic today chain") + } + seen[n] = true + node, ok := c.Quests[n] + if !ok || node.PackID != q.PackID { + return nil, fmt.Errorf("gamedata: broken today chain") + } + if node.NextID != 0 && c.Quests[node.NextID].PriorID != n { + return nil, fmt.Errorf("gamedata: asymmetric today chain") + } + } + } + return c, nil +} diff --git a/go/internal/server/gamedata/today_quests_test.go b/go/internal/server/gamedata/today_quests_test.go new file mode 100644 index 0000000..57a8dd8 --- /dev/null +++ b/go/internal/server/gamedata/today_quests_test.go @@ -0,0 +1,35 @@ +package gamedata + +import ( + "os" + "testing" +) + +func TestInstalledTodayQuestChains(t *testing.T) { + root := "../../../../data/resources/GameData" + if _, err := os.Stat(root); err != nil { + t.Skip("installed GameData absent") + } + c, err := LoadTodayQuests(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + if len(c.Quests) != 397 || c.Limit != 3 || c.PostCount != 3 || c.AchievementScore != 7 { + t.Fatalf("unexpected current defaults: %+v", c) + } + roots, finals := 0, 0 + for _, q := range c.Quests { + if q.PriorID == 0 { + roots++ + } + if q.NextID == 0 { + finals++ + if len(q.Rewards) != 1 || q.Rewards[0].Type != 4 || q.ReputationCompleteID != 1 { + t.Fatalf("missing commissioned reward %d", q.ID) + } + } + } + if roots != 121 || finals != 121 { + t.Fatalf("roots=%d finals=%d", roots, finals) + } +} diff --git a/go/internal/server/npcinn/inn.go b/go/internal/server/npcinn/inn.go new file mode 100644 index 0000000..1444a20 --- /dev/null +++ b/go/internal/server/npcinn/inn.go @@ -0,0 +1,217 @@ +// Package npcinn implements motel opening and paid/free recovery. Each request +// runs within the account transaction, including currency, health and receipts. +package npcinn + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "sync" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type Context func(pack, npc uint64) (gamedata.InnRule, uint64, error) +type reply struct { + Digest string + Body []byte +} +type Service struct { + mu sync.Mutex + store stateio.Store + characters *player.CharacterStore + wallet *player.Wallet + context Context + level func() (uint64, error) + battle func() bool + session string +} + +func New(store stateio.Store, characters *player.CharacterStore, wallet *player.Wallet, context Context, level func() (uint64, error), battle func() bool) (*Service, error) { + if store == nil || characters == nil || wallet == nil || context == nil || level == nil || battle == nil { + return nil, errors.New("npcinn: incomplete dependencies") + } + return &Service{store: store, characters: characters, wallet: wallet, context: context, level: level, battle: battle}, nil +} +func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + code := 109 + if path == "/CharAllRevival" { + code = 11 + } else if path != "/InnOpen" { + return 0, nil, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e } + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" { + return fail(errors.New("npcinn: invalid sequence or session")) + } + if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil { + return fail(err) + } + if s.battle() { + return fail(errors.New("npcinn: unavailable during battle")) + } + if path == "/InnOpen" { + _, state, err := s.context(0, 0) + if err != nil { + return fail(err) + } + return code, wire.AppendVarint(nil, 1, state), true, nil + } + pack, _, err := wire.Varint(request, 2) + if err != nil || pack == 0 || pack > math.MaxInt32 { + return fail(errors.New("npcinn: invalid pack")) + } + npc, _, err := wire.Varint(request, 3) + if err != nil || npc == 0 || npc > math.MaxInt32 { + return fail(errors.New("npcinn: invalid NPC")) + } + keyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", s.session, path, seq))) + key := hex.EncodeToString(keyBytes[:]) + hash := sha256.Sum256(request) + digest := hex.EncodeToString(hash[:]) + var replies map[string]reply + data, err := s.store.Load("npcinn") + if err != nil { + return fail(err) + } + if data != nil && json.Unmarshal(data, &replies) != nil { + return fail(errors.New("npcinn: invalid receipt store")) + } + if replies == nil { + replies = map[string]reply{} + } + if prior, exists := replies[key]; exists { + if prior.Digest != digest { + return fail(errors.New("npcinn: changed request replay")) + } + return code, prior.Body, true, nil + } + rule, state, err := s.context(pack, npc) + if err != nil { + return fail(err) + } + level, err := s.level() + if err != nil { + return fail(err) + } + var indices []uint64 + seen := map[uint64]bool{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 4 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return errors.New("npcinn: invalid character list") + } + for raw := f.Value; len(raw) > 0; { + idx, n := binary.Uvarint(raw) + if n <= 0 || idx == 0 || idx > math.MaxInt64 || seen[idx] || len(indices) >= 4096 { + return errors.New("npcinn: invalid character index") + } + indices = append(indices, idx) + seen[idx] = true + raw = raw[n:] + } + return nil + }) + if err != nil { + return fail(err) + } + if len(indices) == 0 { + return fail(errors.New("npcinn: empty recovery targets")) + } + discount := uint64(0) + if state == 2 { + discount = rule.GoodDiscount + } + if discount > 100 || rule.Currency != 4 || rule.ItemCount == 0 || rule.ItemCount > math.MaxInt32 { + return fail(errors.New("npcinn: invalid recovery design")) + } + rate := rule.ItemCount * (100 - discount) + const divisor uint64 = 4000 + available := s.wallet.Snapshot().Gold + var total uint64 + var targets []player.Character + for _, idx := range indices { + c, found := s.characters.Find(idx) + if !found { + return fail(errors.New("npcinn: recovery character not owned")) + } + maximum, err := s.characters.MaxHealth(idx) + if err != nil { + return fail(err) + } + current, err := s.characters.CurrentHealth(idx) + if err != nil { + return fail(err) + } + if current >= maximum { + c.HP = current + targets = append(targets, c) + continue + } + if current == 0 { + c.HP = 1 + targets = append(targets, c) + continue + } + if level <= rule.FreeSquadLevel || rate == 0 { + c.HP = maximum + targets = append(targets, c) + continue + } + missing := maximum - current + if missing > (math.MaxUint64-divisor+1)/rate { + return fail(errors.New("npcinn: recovery price overflow")) + } + cost := (missing*rate + divisor - 1) / divisor + if cost <= available { + c.HP = maximum + } else { + if available > math.MaxUint64/divisor { + return fail(errors.New("npcinn: recovery amount overflow")) + } + heal := available * divisor / rate + if heal > missing { + heal = missing + } + cost = heal * rate / divisor + c.HP = current + heal + } + available -= cost + total += cost + targets = append(targets, c) + } + if total > 0 { + if _, err = s.wallet.SpendGoldOnce("npcinn:"+key, total); err != nil { + return fail(err) + } + } + var out []byte + for _, c := range targets { + if err = s.characters.SetCurrentHealth(c.InvenIndex, c.HP); err != nil { + return fail(err) + } + out = wire.AppendBytes(out, 1, player.CharacterWire(c)) + } + replies[key] = reply{Digest: digest, Body: out} + data, err = json.Marshal(replies) + if err != nil { + return fail(err) + } + if err = s.store.Save("npcinn", data); err != nil { + return fail(err) + } + return code, out, true, nil +} diff --git a/go/internal/server/npcinn/inn_test.go b/go/internal/server/npcinn/inn_test.go new file mode 100644 index 0000000..0ede1e5 --- /dev/null +++ b/go/internal/server/npcinn/inn_test.go @@ -0,0 +1,166 @@ +package npcinn + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "errors" + "path/filepath" + "testing" +) + +func innTestService(t *testing.T, gold, level, state uint64) (*Service, *player.CharacterStore, *player.Wallet, stateio.Store) { + t.Helper() + store := stateio.NewMemory() + return innTestServiceWithStore(t, store, gold, level, state) +} + +func innTestServiceWithStore(t *testing.T, store stateio.Store, gold, level, state uint64) (*Service, *player.CharacterStore, *player.Wallet, stateio.Store) { + t.Helper() + inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + chars, err := player.OpenCharacterStore(store, []player.Character{{InvenIndex: 71, ID: 350, Level: 1, HP: 10}, {InvenIndex: 72, ID: 360, Level: 1, HP: 10}}, inv, "", "") + if err != nil { + t.Fatal(err) + } + chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }) + if err = chars.EnsurePersisted(); err != nil { + t.Fatal(err) + } + wallet, err := player.OpenWallet(store, player.Currency{Gold: gold}) + if err != nil { + t.Fatal(err) + } + if err = wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + s, err := New(store, chars, wallet, func(pack, npc uint64) (gamedata.InnRule, uint64, error) { + return gamedata.InnRule{NPCID: 7, MapID: 33, ItemCount: 2, Currency: 4, FreeSquadLevel: 20, GoodDiscount: 10}, state, nil + }, func() (uint64, error) { return level, nil }, func() bool { return false }) + if err != nil { + t.Fatal(err) + } + s.BeginSession("test") + return s, chars, wallet, store +} + +type innReceiptFailStore struct{ stateio.Store } + +func (s innReceiptFailStore) Save(name string, b []byte) error { + if name == "npcinn" { + return errors.New("receipt write failed") + } + return s.Store.Save(name, b) +} + +func TestInnReceiptFailureRollsBackGoldAndHealthTogether(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + s, _, _, _ := innTestServiceWithStore(t, repo, 10, 21, 1) + s.store = innReceiptFailStore{repo} + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/CharAllRevival", recoveryRequest(1, 71)); err == nil { + t.Fatal("receipt failure ignored") + } + if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + repo.Close() + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + next, chars, wallet, _ := innTestServiceWithStore(t, repo, 0, 21, 1) + hp, _ := chars.CurrentHealth(71) + if hp != 10 || wallet.Snapshot().Gold != 10 { + t.Fatal("failed inn request committed health or charge") + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = next.Handle("/CharAllRevival", recoveryRequest(1, 71)); err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + hp, _ = chars.CurrentHealth(71) + if hp != 100 || wallet.Snapshot().Gold != 5 { + t.Fatal("retry did not commit recovery once") + } +} +func recoveryRequest(seq uint64, indices ...uint64) []byte { + b := wire.AppendVarint(nil, 1, seq) + b = wire.AppendVarint(b, 2, 1) + b = wire.AppendVarint(b, 3, 7) + for _, i := range indices { + b = wire.AppendVarint(b, 4, i) + } + return b +} + +func TestInnPartialRecoveryAndReplayUsePersistedHealth(t *testing.T) { + s, chars, wallet, store := innTestService(t, 2, 21, 1) + b := recoveryRequest(1, 71, 72) + code, response, handled, err := s.Handle("/CharAllRevival", b) + if err != nil || !handled || code != 11 { + t.Fatal(err) + } + hp, _ := chars.CurrentHealth(71) + second, _ := chars.CurrentHealth(72) + if hp != 50 || second != 10 || wallet.Snapshot().Gold != 0 { + t.Fatalf("partial recovery hp=%d second=%d wallet=%+v", hp, second, wallet.Snapshot()) + } + next, err := New(store, chars, wallet, s.context, s.level, s.battle) + if err != nil { + t.Fatal(err) + } + next.BeginSession("test") + _, replay, _, err := next.Handle("/CharAllRevival", b) + if err != nil || !bytes.Equal(response, replay) { + t.Fatal("receipt lost on reopening") + } + changed := recoveryRequest(1, 72) + if _, _, _, err = next.Handle("/CharAllRevival", changed); err == nil { + t.Fatal("changed recovery replay accepted") + } +} + +func TestInnFreeLevelDeadRevivalAndDiscountPrice(t *testing.T) { + s, chars, wallet, _ := innTestService(t, 0, 20, 1) + if err := chars.SetCurrentHealth(72, 0); err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(1, 71, 72)); err != nil { + t.Fatal(err) + } + a, _ := chars.CurrentHealth(71) + b, _ := chars.CurrentHealth(72) + if a != 100 || b != 1 || wallet.Snapshot().Gold != 0 { + t.Fatal("free squad recovery or free 1 HP resurrection incorrect") + } + s, chars, wallet, _ = innTestService(t, 10, 21, 2) + if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(1, 71)); err != nil { + t.Fatal(err) + } + a, _ = chars.CurrentHealth(71) + if a != 100 || wallet.Snapshot().Gold != 5 { + t.Fatal("discount recovery must ceil 90 * 2 /40 *90%=4.05 to 5 gold") + } + if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(2, 71, 71)); err == nil { + t.Fatal("duplicate targets accepted") + } +} diff --git a/go/internal/server/npcshop/shop.go b/go/internal/server/npcshop/shop.go new file mode 100644 index 0000000..133c593 --- /dev/null +++ b/go/internal/server/npcshop/shop.go @@ -0,0 +1,459 @@ +// Package npcshop implements the ordinary NPC buy/sell market from GameData. +package npcshop + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "sync" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type Economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type receipt struct { + Digest []byte + Response []byte +} +type count struct { + Period string + Bought uint64 +} +type snapshot struct { + Counts map[string]count `json:"counts"` + Receipts map[string]receipt `json:"receipts"` +} +type Service struct { + mu sync.Mutex + design gamedata.NPCShopDesign + store stateio.Store + economy Economy + items *player.Inventory + available func(uint64) bool + now func() time.Time + session func() string + reputation func(uint64) (uint64, uint64, error) +} + +func New(d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *player.Inventory, available func(uint64) bool) (*Service, error) { + if len(d.Shops) == 0 || store == nil || e == nil || items == nil || available == nil { + return nil, fmt.Errorf("npcshop: invalid configuration") + } + s := &Service{design: d, store: store, economy: e, items: items, available: available, now: time.Now} + _, err := s.load() + return s, err +} +func (s *Service) SetSessionSource(source func() string) { s.session = source } +func (s *Service) BeginSession(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.session = func() string { return id } +} + +// SetReputationSource supplies the world's persisted pack reputation and +// GameData-derived shop discount. Normal reputation is the initial fallback. +func (s *Service) SetReputationSource(source func(uint64) (uint64, uint64, error)) { + s.reputation = source +} +func (s *Service) reputationFor(pack uint64) (uint64, uint64, error) { + if s.reputation != nil { + return s.reputation(pack) + } + return 1, 0, nil +} +func (s *Service) load() (snapshot, error) { + v := snapshot{Counts: map[string]count{}, Receipts: map[string]receipt{}} + b, e := s.store.Load("npc_shop") + if e != nil || len(b) == 0 { + return v, e + } + if e = stateio.RequireExactJSONObject(b, "counts", "receipts"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil { + return v, e + } + if v.Counts == nil || v.Receipts == nil { + return v, fmt.Errorf("npcshop: invalid state") + } + return v, nil +} +func (s *Service) save(v snapshot) error { + b, e := json.Marshal(v) + if e != nil { + return e + } + return s.store.Save("npc_shop", b) +} +func key(shop, product uint64) string { return fmt.Sprintf("%d:%d", shop, product) } +func (s *Service) period(shop gamedata.NPCShop) (string, int64) { + t := s.now().UTC() + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + switch shop.ResetType { + case 1: + return day.Format("2006-01-02"), day.AddDate(0, 0, 1).Unix() - t.Unix() + case 2: + day = day.AddDate(0, 0, -(int(day.Weekday())+6)%7) + return day.Format("2006-01-02"), day.AddDate(0, 0, 7).Unix() - t.Unix() + case 3: + day = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + return day.Format("2006-01"), day.AddDate(0, 1, 0).Unix() - t.Unix() + default: + return "lifetime", 0 + } +} +func (s *Service) seed() uint64 { return uint64(s.now().UTC().Unix()/86400) % math.MaxInt32 } +func (s *Service) bought(v snapshot, shop, product uint64) uint64 { + period, _ := s.period(s.design.Shops[shop]) + c := v.Counts[key(shop, product)] + if c.Period != period { + return 0 + } + return c.Bought +} +func (s *Service) shopWire(v snapshot, id uint64) []byte { + _, remaining := s.period(s.design.Shops[id]) + b := wire.AppendVarint(nil, 1, id) + if remaining > 0 { + b = wire.AppendVarint(b, 2, uint64(remaining)) + } + ids := make([]uint64, 0, len(s.design.Products[id])) + for n := range s.design.Products[id] { + ids = append(ids, n) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + for _, n := range ids { + p := wire.AppendVarint(nil, 1, n) + p = wire.AppendVarint(p, 2, s.bought(v, id, n)) + b = wire.AppendBytes(b, 3, p) + } + return b +} +func (s *Service) allShops(v snapshot, field int) []byte { + ids := make([]uint64, 0) + for id, r := range s.design.Shops { + if s.available(r.PackID) { + ids = append(ids, id) + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + var b []byte + for _, id := range ids { + b = wire.AppendBytes(b, field, s.shopWire(v, id)) + } + return b +} +func scalar(b []byte, f int) (uint64, error) { v, _, e := wire.Varint(b, f); return v, e } +func messages(b []byte, f int) ([][]byte, error) { + var out [][]byte + e := wire.Walk(b, func(v wire.Field) error { + if v.Number != f { + return nil + } + if v.Type != 2 { + return fmt.Errorf("npcshop: malformed nested message") + } + out = append(out, append([]byte(nil), v.Value...)) + return nil + }) + return out, e +} +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + codes := map[string]int{"/ShopInfo": 53, "/ShopBuy": 54, "/ShopSell": 55, "/ShopOpen": 107, "/ShopReputationInfo": 520} + code, ok := codes[path] + if !ok { + return 0, nil, false, nil + } + seq, e := scalar(request, 1) + if e != nil || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, fmt.Errorf("npcshop: invalid sequence") + } + s.mu.Lock() + defer s.mu.Unlock() + v, e := s.load() + if e != nil { + return 0, nil, true, e + } + if path == "/ShopInfo" { + b := s.allShops(v, 1) + return code, wire.AppendVarint(b, 2, s.seed()), true, nil + } + if path == "/ShopOpen" { + var b []byte + packs := map[uint64]bool{} + for _, r := range s.design.Shops { + if !packs[r.PackID] && s.available(r.PackID) { + packs[r.PackID] = true + state, _, err := s.reputationFor(r.PackID) + if err != nil { + return 0, nil, true, err + } + p := wire.AppendVarint(nil, 1, r.PackID) + p = wire.AppendVarint(p, 2, state) + b = wire.AppendBytes(b, 1, p) + } + } + return code, b, true, nil + } + if path == "/ShopReputationInfo" { + pack, err := scalar(request, 2) + if err != nil || !s.available(pack) { + return 0, nil, true, fmt.Errorf("npcshop: unavailable reputation pack") + } + state, _, err := s.reputationFor(pack) + if err != nil { + return 0, nil, true, err + } + return code, wire.AppendVarint(nil, 1, state), true, nil + } + if s.session == nil || s.session() == "" { + return 0, nil, true, fmt.Errorf("npcshop: missing session identity") + } + identity := "npcshop:" + s.session() + ":" + path + ":" + strconv.FormatUint(seq, 10) + digest := sha256.Sum256(request) + if r, exists := v.Receipts[identity]; exists { + if !bytes.Equal(r.Digest, digest[:]) { + return 0, nil, true, fmt.Errorf("npcshop: reused sequence") + } + return code, r.Response, true, nil + } + var costs, rewards []gamedata.Reward + var sold []player.Item + changed := map[uint64]bool{} + if path == "/ShopBuy" { + costs, rewards, changed, e = s.buy(request, &v) + if e == nil { + costs, sold, e = s.purchaseMaterials(request, costs) + } + } else { + sold, rewards, e = s.sell(request) + } + if e != nil { + return 0, nil, true, e + } + if len(sold) > 0 { + if e = s.items.CanConsume(sold); e != nil { + return 0, nil, true, e + } + } + bundle, e := s.economy.Apply(identity, costs, rewards) + if e != nil { + return 0, nil, true, e + } + // This and Economy.Apply execute in the enclosing account transaction. + // Consume the concrete stacks the client removes, rather than letting a + // type/id-only debit choose a different stack of the same resource. + if len(sold) > 0 { + if e = s.items.Consume(sold); e != nil { + return 0, nil, true, e + } + } + b := wire.AppendBytes(nil, 1, bundle) + ids := make([]uint64, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + for _, id := range ids { + b = wire.AppendBytes(b, 2, s.shopWire(v, id)) + } + v.Receipts[identity] = receipt{Digest: digest[:], Response: b} + if e = s.save(v); e != nil { + return 0, nil, true, e + } + return code, b, true, nil +} +func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedata.Reward, map[uint64]bool, error) { + groups, e := messages(request, 2) + if e != nil || len(groups) == 0 { + return nil, nil, nil, fmt.Errorf("npcshop: empty purchase") + } + var costs, rewards []gamedata.Reward + changed := map[uint64]bool{} + seen := map[string]bool{} + for _, g := range groups { + shop, e := scalar(g, 1) + r, exists := s.design.Shops[shop] + if e != nil || !exists || !s.available(r.PackID) { + return nil, nil, nil, fmt.Errorf("npcshop: unavailable shop %d", shop) + } + state, discount, err := s.reputationFor(r.PackID) + if err != nil { + return nil, nil, nil, err + } + if state == 0 || discount > 100 { + return nil, nil, nil, fmt.Errorf("npcshop: unavailable reputation") + } + rows, e := messages(g, 2) + if e != nil || len(rows) == 0 { + return nil, nil, nil, fmt.Errorf("npcshop: empty product list") + } + for _, b := range rows { + id, _ := scalar(b, 2) + n, _ := scalar(b, 3) + inven, _ := scalar(b, 1) + rate, _ := scalar(b, 4) + p, ok := s.design.Products[shop][id] + k := key(shop, id) + if !ok || n == 0 || n > math.MaxInt32 || inven != 0 || seen[k] { + return nil, nil, nil, fmt.Errorf("npcshop: invalid product %d", id) + } + seen[k] = true + if p.Reputation > 0 && state != 2 { + return nil, nil, nil, fmt.Errorf("npcshop: product requires good reputation") + } + expected := s.rate(p, shop, 1) + if rate != expected { + return nil, nil, nil, fmt.Errorf("npcshop: stale/invalid price rate %d expected %d", rate, expected) + } + old := s.bought(*v, shop, id) + if p.MaxCount != 0 && p.Reward.Type != 12 && (old > p.MaxCount || n > p.MaxCount-old) { + return nil, nil, nil, fmt.Errorf("npcshop: sold out product %d", id) + } + price := uint64(float32(p.Price.Count*expected) / 100) + if discount > 0 { + price = uint64(float32(price*(100-discount)) / 100) + } + if price > math.MaxInt32/n || p.Reward.Count > math.MaxInt32/n { + return nil, nil, nil, fmt.Errorf("npcshop: purchase overflow") + } + cost := p.Price + cost.Count = price * n + if cost.Count > 0 { + costs = append(costs, cost) + } + reward := p.Reward + reward.Count *= n + rewards = append(rewards, reward) + period, _ := s.period(r) + v.Counts[k] = count{Period: period, Bought: old + n} + changed[shop] = true + } + } + return costs, rewards, changed, nil +} +func (s *Service) sell(request []byte) ([]player.Item, []gamedata.Reward, error) { + id, e := scalar(request, 2) + shop, exists := s.design.Shops[id] + if e != nil || !exists || !s.available(shop.PackID) { + return nil, nil, fmt.Errorf("npcshop: unavailable sell shop") + } + rows, e := messages(request, 3) + if e != nil || len(rows) == 0 { + return nil, nil, fmt.Errorf("npcshop: empty sale") + } + inventory := map[uint64]player.Item{} + for _, item := range s.items.All() { + inventory[item.InvenIndex] = item + } + var costs []player.Item + var rewards []gamedata.Reward + seen := map[uint64]bool{} + for _, b := range rows { + product, _ := scalar(b, 2) + n, _ := scalar(b, 3) + inven, _ := scalar(b, 1) + rate, _ := scalar(b, 4) + p, ok := s.design.Sell[product] + item, owned := inventory[inven] + if !ok || !owned || seen[inven] || n == 0 || n > item.Count || p.Reward.Type != item.Type || p.Reward.ID != item.ID || item.KeepFlag != 0 { + return nil, nil, fmt.Errorf("npcshop: invalid sale item") + } + seen[inven] = true + expected := s.rate(p, id, 2) + if rate != expected { + return nil, nil, fmt.Errorf("npcshop: stale/invalid sell rate") + } + price := uint64(float32(p.Price.Count*expected) / 100) + if price > math.MaxInt32/n { + return nil, nil, fmt.Errorf("npcshop: sale overflow") + } + cost := item + cost.Count = n + costs = append(costs, cost) + reward := p.Price + reward.Count = price * n + if reward.Count > 0 { + rewards = append(rewards, reward) + } + } + return costs, rewards, nil +} + +// Resource currency is removed from the exact inventory rows named by the +// client after independently deriving the required type/id/count from design. +func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([]gamedata.Reward, []player.Item, error) { + need := map[[2]uint64]uint64{} + var currency []gamedata.Reward + for _, c := range costs { + if c.Type == 8 { + need[[2]uint64{c.Type, c.ID}] += c.Count + } else { + currency = append(currency, c) + } + } + rows, e := messages(request, 3) + if e != nil { + return nil, nil, e + } + var selected []player.Item + got := map[[2]uint64]uint64{} + seen := map[uint64]bool{} + for _, b := range rows { + index, _ := scalar(b, 1) + id, _ := scalar(b, 2) + typ, _ := scalar(b, 3) + n, _ := scalar(b, 4) + if index == 0 || id == 0 || typ != 8 || n == 0 || n > math.MaxInt32 || seen[index] { + return nil, nil, fmt.Errorf("npcshop: invalid purchase material") + } + seen[index] = true + k := [2]uint64{typ, id} + got[k] += n + selected = append(selected, player.Item{InvenIndex: index, ID: id, Type: typ, Count: n}) + } + if len(got) != len(need) { + return nil, nil, fmt.Errorf("npcshop: purchase material mismatch") + } + for k, n := range need { + if got[k] != n { + return nil, nil, fmt.Errorf("npcshop: purchase material count mismatch") + } + } + return currency, selected, nil +} + +// Client ShopPacket.GetShopRandomValue uses the first WELL512 value for a +// versioned product and server-supplied daily seed. Client Rate is verified, +// never accepted as the authority for wallet arithmetic. +func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 { + if p.PremiumPriceType == 1 && p.HighShop == shop && p.HighDay == uint64(s.now().UTC().Day()) { + return 100 + p.HighPremium + } + seed := s.seed() + shop + tab + p.Reward.Type + p.Reward.ID + a := seed + b := seed + 13*90 + c := a ^ b ^ (a << 16) ^ (b << 15) + b = seed + 9*90 + b ^= b >> 11 + a = c ^ b + d := a ^ ((a << 5) & uint64(0xffffffffda442d24)) + a = seed + 15*90 + value := a ^ c ^ d ^ (a << 2) ^ (c << 18) ^ (b << 28) + lo := 100 - p.Discount + span := p.Discount + p.Premium + if span == 0 { + span = 1 + } + return lo + value%span +} diff --git a/go/internal/server/npcshop/shop_test.go b/go/internal/server/npcshop/shop_test.go new file mode 100644 index 0000000..6858813 --- /dev/null +++ b/go/internal/server/npcshop/shop_test.go @@ -0,0 +1,235 @@ +package npcshop + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" + "bytes" + "errors" + "fmt" + "path/filepath" + "testing" + "time" +) + +type economy struct { + calls int + costs, rewards []gamedata.Reward + fail bool +} +type failedReceiptStore struct{ stateio.Store } + +func (s failedReceiptStore) Save(name string, b []byte) error { + if name == "npc_shop" { + return fmt.Errorf("receipt disk failure") + } + return s.Store.Save(name, b) +} + +type storedEconomy struct{ store stateio.Store } + +func (e storedEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { + if err := e.store.Save("npc_test_grants", []byte{1}); err != nil { + return nil, err + } + return []byte{8, 1}, nil +} +func TestFailedSaleReceiptRollsBackConcreteInventoryAndReward(t *testing.T) { + base, _, _ := fixture(t) + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + starter := &player.Starter{Version: versionconfig.State()} + items, err := player.OpenInventory(repo, starter) + if err != nil { + t.Fatal(err) + } + granted, err := items.GrantOnce("initial-food", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}}) + if err != nil { + t.Fatal(err) + } + s, err := New(base.design, failedReceiptStore{repo}, storedEconomy{repo}, items, base.available) + if err != nil { + t.Fatal(err) + } + s.now = base.now + s.SetSessionSource(base.session) + line := wire.AppendVarint(nil, 1, granted[0].InvenIndex) + line = wire.AppendVarint(line, 2, 19) + line = wire.AppendVarint(line, 3, 3) + line = wire.AppendVarint(line, 4, 100) + req := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 71), 3, line) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/ShopSell", req); err == nil { + t.Fatal("expected failed receipt persistence") + } + 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) + } + defer repo.Close() + reopened, err := player.OpenInventory(repo, starter) + if err != nil { + t.Fatal(err) + } + if rows := reopened.All(); len(rows) != 1 || rows[0].Count != 8 { + t.Fatal("inventory removal survived rollback", rows) + } + for _, name := range []string{"npc_shop", "npc_test_grants"} { + raw, err := repo.Load(name) + if err != nil || raw != nil { + t.Fatal("reward or receipt survived rollback", name, err) + } + } +} + +func (e *economy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { + if e.fail { + return nil, fmt.Errorf("insufficient balance") + } + e.calls++ + e.costs = c + e.rewards = r + return []byte{8, 1}, nil +} +func fixture(t *testing.T) (*Service, *economy, *stateio.Memory) { + t.Helper() + store := stateio.NewMemory() + items, err := player.OpenInventory(store, &player.Starter{Version: versionconfig.State()}) + if err != nil { + t.Fatal(err) + } + d := gamedata.NPCShopDesign{Shops: map[uint64]gamedata.NPCShop{71: {ID: 71, PackID: 91, ResetType: 1}}, Products: map[uint64]map[uint64]gamedata.NPCProduct{71: {3: {ID: 3, GroupID: 71, MaxCount: 3, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}, Price: gamedata.Reward{Type: 4, Count: 25}}}}, Sell: map[uint64]gamedata.NPCProduct{19: {ID: 19, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 7}}}} + e := &economy{} + s, err := New(d, store, e, items, func(pack uint64) bool { return pack == 91 }) + if err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } + s.SetSessionSource(func() string { return "login" }) + return s, e, store +} +func buy(seq, n, rate uint64) []byte { + b := wire.AppendVarint(nil, 1, seq) + g := wire.AppendVarint(nil, 1, 71) + p := wire.AppendVarint(nil, 2, 3) + p = wire.AppendVarint(p, 3, n) + p = wire.AppendVarint(p, 4, rate) + return wire.AppendBytes(b, 2, wire.AppendBytes(g, 2, p)) +} +func TestPurchaseReceiptLimitsResetAndWholeBatchValidation(t *testing.T) { + s, e, store := fixture(t) + request := buy(1, 2, 100) + code, response, ok, err := s.Handle("/ShopBuy", request) + if err != nil || !ok || code != 54 { + t.Fatal(code, ok, err) + } + if e.calls != 1 || len(e.costs) != 1 || e.costs[0].Count != 50 || e.rewards[0].Count != 4 { + t.Fatal(e) + } + reopened, err := New(s.design, store, e, s.items, s.available) + if err != nil { + t.Fatal(err) + } + reopened.now = s.now + reopened.SetSessionSource(s.session) + _, replay, _, err := reopened.Handle("/ShopBuy", request) + if err != nil || !bytes.Equal(response, replay) || e.calls != 1 { + t.Fatal("retry did not preserve receipt", err) + } + for _, bad := range [][]byte{buy(1, 1, 100), buy(2, 2, 100), buy(3, 1, 1)} { + if _, _, _, err = s.Handle("/ShopBuy", bad); err == nil { + t.Fatalf("invalid purchase accepted %x", bad) + } + } + batch := buy(4, 1, 100) + g := wire.AppendVarint(nil, 1, 71) + p := wire.AppendVarint(wire.AppendVarint(nil, 2, 999), 3, 1) + batch = wire.AppendBytes(batch, 2, wire.AppendBytes(g, 2, p)) + if _, _, _, err = s.Handle("/ShopBuy", batch); err == nil || e.calls != 1 { + t.Fatal("partially granted invalid batch") + } + s.now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) } + if _, _, _, err = s.Handle("/ShopBuy", buy(5, 3, 100)); err != nil { + t.Fatal("daily reset did not reopen stock", err) + } +} +func TestSaleConsumesTheRequestedStackAndRejectsDuplicates(t *testing.T) { + s, e, _ := fixture(t) + granted, err := s.items.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}}) + if err != nil { + t.Fatal(err) + } + item := granted[0] + line := wire.AppendVarint(nil, 1, item.InvenIndex) + line = wire.AppendVarint(line, 2, 19) + line = wire.AppendVarint(line, 3, 3) + line = wire.AppendVarint(line, 4, 100) + req := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 71), 3, line) + duplicate := wire.AppendBytes(req, 3, line) + if _, _, _, err = s.Handle("/ShopSell", duplicate); err == nil || e.calls != 0 { + t.Fatal("duplicate stack sale accepted") + } + code, first, _, err := s.Handle("/ShopSell", req) + if err != nil || code != 55 { + t.Fatal(code, err) + } + if len(e.costs) != 0 || e.rewards[0].Count != 21 { + t.Fatal("wrong sale reward", e) + } + var remaining uint64 + for _, v := range s.items.All() { + if v.InvenIndex == item.InvenIndex { + remaining = v.Count + } + } + if remaining != 5 { + t.Fatal("wrong concrete stack consumed", remaining) + } + _, replay, _, err := s.Handle("/ShopSell", req) + if err != nil || !bytes.Equal(first, replay) || e.calls != 1 { + t.Fatal("sale retry spent twice", err) + } +} +func TestResourcePurchaseValidatesAndConsumesExactMaterialRows(t *testing.T) { + s, e, _ := fixture(t) + p := s.design.Products[71][3] + p.Price = gamedata.Reward{Type: 8, ID: 401, Count: 5} + s.design.Products[71][3] = p + granted, err := s.items.GrantOnce("resource", []gamedata.BattleReward{{Type: 8, ID: 401, Count: 18}}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err = s.Handle("/ShopBuy", buy(1, 2, 100)); err == nil || e.calls != 0 { + t.Fatal("missing resource payment accepted") + } + material := wire.AppendVarint(nil, 1, granted[0].InvenIndex) + material = wire.AppendVarint(material, 2, 401) + material = wire.AppendVarint(material, 3, 8) + material = wire.AppendVarint(material, 4, 10) + if _, _, _, err = s.Handle("/ShopBuy", wire.AppendBytes(buy(2, 2, 100), 3, material)); err != nil { + t.Fatal(err) + } + if len(e.costs) != 0 || e.rewards[0].Count != 4 { + t.Fatal("material cost sent to generic stack selection", e) + } + rows := s.items.All() + if len(rows) != 1 || rows[0].Count != 8 { + t.Fatal("resource stack was not spent", rows) + } +} diff --git a/go/internal/server/todayquest/todayquest.go b/go/internal/server/todayquest/todayquest.go new file mode 100644 index 0000000..656d963 --- /dev/null +++ b/go/internal/server/todayquest/todayquest.go @@ -0,0 +1,472 @@ +// Package todayquest implements the NPC commission board from TodayQuestTable. +package todayquest + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "fmt" + "sort" + "sync" + "time" +) + +type Economy interface { + Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} +type Inventory interface { + GrantOnce(string, []gamedata.BattleReward) ([]player.Item, error) + GrantedItems(string) []player.Item +} +type Active struct { + ID int `json:"id"` + Value int `json:"value"` + Objects []uint64 `json:"objects"` +} +type snapshot struct { + Period string `json:"period"` + Offered []int `json:"offered"` + Active map[int]Active `json:"active"` + Cleared map[int]bool `json:"cleared"` + Responses map[int][]byte `json:"responses"` + ScoreAwards map[string]uint64 `json:"score_awards"` +} +type Service struct { + mu sync.Mutex + store stateio.Store + design *gamedata.TodayQuestCatalog + economy Economy + inventory Inventory + now func() time.Time + unlocked func(int) bool + CompleteReputation func(string, int, uint64) ([]byte, error) + CompleteAchievement func(string) error +} + +// Request dispatch owns the account transaction; economy/inventory writes and +// this snapshot are committed together by session.BeginOperation. +func Open(store stateio.Store, design *gamedata.TodayQuestCatalog, economy Economy, inventory Inventory, unlocked func(int) bool) (*Service, error) { + if store == nil || design == nil || len(design.Quests) == 0 || design.Limit <= 0 || design.PostCount <= 0 || economy == nil || inventory == nil || unlocked == nil { + return nil, fmt.Errorf("todayquest: invalid configuration") + } + return &Service{store: store, design: design, economy: economy, inventory: inventory, unlocked: unlocked, now: time.Now}, nil +} +func (s *Service) load() (snapshot, error) { + p, e := s.design.Reset.Period(3, s.now()) + if e != nil { + return snapshot{}, e + } + raw, e := s.store.Load("today_quests") + if e != nil { + return snapshot{}, e + } + st := snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: map[string]uint64{}} + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "period", "offered", "active", "cleared", "responses", "score_awards"); e != nil { + return st, e + } + if e = json.Unmarshal(raw, &st); e != nil { + return st, e + } + if st.Period == "" || st.Active == nil || st.Cleared == nil || st.Responses == nil || st.ScoreAwards == nil { + return st, fmt.Errorf("todayquest: invalid saved state") + } + } + if st.Period != p { + st = snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: st.ScoreAwards} + } + for id, a := range st.Active { + q, ok := s.design.Quests[id] + if !ok || a.ID != id || st.Cleared[id] || a.Value < 0 || a.Value > q.ConditionCount || len(a.Objects) > q.ConditionCount { + return st, fmt.Errorf("todayquest: invalid active progress") + } + seen := map[uint64]bool{} + for _, object := range a.Objects { + allowed := false + for _, v := range q.MagicValues { + if object == v { + allowed = true + } + } + if !allowed || seen[object] { + return st, fmt.Errorf("todayquest: invalid saved object") + } + seen[object] = true + } + if q.PriorID != 0 && !st.Cleared[q.PriorID] { + return st, fmt.Errorf("todayquest: missing prior clear") + } + } + for id, cleared := range st.Cleared { + if _, ok := s.design.Quests[id]; !ok || !cleared { + return st, fmt.Errorf("todayquest: invalid cleared node") + } + } + for id, b := range st.Responses { + if _, ok := s.design.Quests[id]; !ok || len(b) == 0 { + return st, fmt.Errorf("todayquest: invalid clear receipt") + } + } + // Deterministic sampling is a local server policy. Persisted offers are stable + // across restarts, with PostCount independent roots for every unlocked pack. + byPack := map[int][]int{} + for id, q := range s.design.Quests { + if q.PriorID == 0 && s.unlocked(q.PackID) { + byPack[q.PackID] = append(byPack[q.PackID], id) + } + } + existing := map[int]bool{} + for _, id := range st.Offered { + q, ok := s.design.Quests[id] + if !ok || q.PriorID != 0 { + return st, fmt.Errorf("todayquest: invalid offered root") + } + existing[q.PackID] = true + } + packs := []int{} + for p := range byPack { + packs = append(packs, p) + } + sort.Ints(packs) + for _, pack := range packs { + if existing[pack] { + continue + } + ids := byPack[pack] + sort.Slice(ids, func(i, j int) bool { + a := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[i]))) + b := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[j]))) + return string(a[:]) < string(b[:]) + }) + n := s.design.PostCount + if n > len(ids) { + n = len(ids) + } + st.Offered = append(st.Offered, ids[:n]...) + } + sort.Ints(st.Offered) + return st, nil +} +func (s *Service) save(st snapshot) error { + b, e := json.Marshal(st) + if e != nil { + return e + } + return s.store.Save("today_quests", b) +} +func (s *Service) identity(st snapshot, id int, part string) string { + return fmt.Sprintf("todayquest:%s:%d:%s", st.Period, id, part) +} +func (s *Service) root(id int) int { + for s.design.Quests[id].PriorID != 0 { + id = s.design.Quests[id].PriorID + } + return id +} +func (s *Service) questWire(a Active) []byte { + q := s.design.Quests[a.ID] + b := wire.AppendVarint(nil, 1, uint64(a.ID)) + b = wire.AppendVarint(b, 2, uint64(a.Value)) + for _, id := range a.Objects { + b = wire.AppendVarint(b, 3, id) + } + return wire.AppendVarint(b, 6, uint64(q.PackID)) +} +func (s *Service) give(st snapshot, id int) ([]player.Item, error) { + var rs []gamedata.BattleReward + for _, item := range s.design.Quests[id].GiveItemIDs { + rs = append(rs, gamedata.BattleReward{Type: 13, ID: item, Count: 1}) + } + if len(rs) == 0 { + return nil, nil + } + key := s.identity(st, id, "give") + items, e := s.inventory.GrantOnce(key, rs) + if e == nil && len(items) == 0 { + items = s.inventory.GrantedItems(key) + } + return items, e +} +func (s *Service) secondsLeft(st snapshot) uint64 { + base, _ := time.Parse("2006-01-02", st.Period) + end := base.AddDate(0, 0, 7).Add(s.design.Reset.DailyReset - 9*time.Hour) + n := int64(end.Sub(s.now()).Seconds()) + if n < 0 { + return 0 + } + return uint64(n) +} +func (s *Service) Info(pack int) ([][]byte, []int, error) { + s.mu.Lock() + defer s.mu.Unlock() + st, e := s.load() + if e != nil { + return nil, nil, e + } + if e = s.save(st); e != nil { + return nil, nil, e + } + var rows [][]byte + var cleared []int + ids := []int{} + for id := range st.Active { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + if s.design.Quests[id].PackID == pack { + rows = append(rows, s.questWire(st.Active[id])) + } + } + for id := range st.Cleared { + if s.design.Quests[id].PackID == pack { + cleared = append(cleared, id) + } + } + sort.Ints(cleared) + return rows, cleared, nil +} +func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/TodayQuestInfo" && path != "/QuestAccept" && path != "/QuestUpdate" && path != "/QuestClear" && path != "/QuestGiveUp" { + return 0, nil, false, nil + } + id := uint64(0) + var e error + if path != "/TodayQuestInfo" { + id, _, e = wire.Varint(request, 2) + if e != nil { + return 0, nil, true, e + } + if _, ok := s.design.Quests[int(id)]; !ok { + return 0, nil, false, nil + } + } + s.mu.Lock() + defer s.mu.Unlock() + seq, found, e := wire.Varint(request, 1) + if e != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("todayquest: missing sequence") + } + st, e := s.load() + if e != nil { + return 0, nil, true, e + } + if path == "/TodayQuestInfo" { + var b []byte + ids := []int{} + for id := range st.Active { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + b = wire.AppendBytes(b, 1, s.questWire(st.Active[id])) + } + ids = nil + for id := range st.Cleared { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + b = wire.AppendVarint(b, 2, uint64(id)) + } + b = wire.AppendVarint(b, 3, s.secondsLeft(st)) + for _, id := range st.Offered { + b = wire.AppendVarint(b, 4, uint64(id)) + } + return 64, b, true, s.save(st) + } + q := s.design.Quests[int(id)] + pack, _, e := wire.Varint(request, 3) + if e != nil || int(pack) != q.PackID || !s.unlocked(q.PackID) { + return 0, nil, true, fmt.Errorf("todayquest: unavailable pack") + } + a, active := st.Active[q.ID] + switch path { + case "/QuestAccept": + level, _, e := wire.Varint(request, 4) + opt, _, optErr := wire.Varint(request, 5) + if e != nil || level != 0 || optErr != nil || opt != 0 { + return 0, nil, true, fmt.Errorf("todayquest: invalid difficulty") + } + if !active { + offered := false + for _, x := range st.Offered { + if x == q.ID { + offered = true + } + } + completed := 0 + for x := range st.Cleared { + if s.design.Quests[x].NextID == 0 { + completed++ + } + } + if q.PriorID != 0 || !offered || st.Cleared[q.ID] || completed+len(st.Active) >= s.design.Limit { + return 0, nil, true, fmt.Errorf("todayquest: root not available or limit reached") + } + a = Active{ID: q.ID} + st.Active[q.ID] = a + } + items, e := s.give(st, q.ID) + if e != nil { + return 0, nil, true, e + } + b := wire.AppendBytes(nil, 1, s.questWire(a)) + for _, it := range items { + b = wire.AppendBytes(b, 4, player.ItemWire(it)) + } + return 17, b, true, s.save(st) + case "/QuestGiveUp": + if !active { + return 0, nil, true, fmt.Errorf("todayquest: quest is not active") + } + delete(st.Active, q.ID) + root := s.root(q.ID) + for x := range st.Cleared { + if s.root(x) == root { + delete(st.Cleared, x) + } + } + return 20, wire.AppendVarint(nil, 1, id), true, s.save(st) + case "/QuestUpdate": + if !active { + return 0, nil, true, fmt.Errorf("todayquest: quest is not active") + } + vs, e := values(request, 4) + if e != nil || len(vs) == 0 { + return 0, nil, true, fmt.Errorf("todayquest: missing progress") + } + if q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18 { + for _, v := range vs { + allowed := false + for _, x := range q.MagicValues { + if x == v { + allowed = true + } + } + if !allowed { + return 0, nil, true, fmt.Errorf("todayquest: foreign quest object") + } + seen := false + for _, x := range a.Objects { + if x == v { + seen = true + } + } + if !seen { + a.Objects = append(a.Objects, v) + } + } + } else { + if len(vs) != 1 || vs[0] > uint64(q.ConditionCount) { + return 0, nil, true, fmt.Errorf("todayquest: invalid progress") + } + if int(vs[0]) > a.Value { + a.Value = int(vs[0]) + } + } + st.Active[q.ID] = a + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, id), 2, nil), true, s.save(st) + case "/QuestClear": + if b, ok := st.Responses[q.ID]; ok && st.Cleared[q.ID] { + return 18, b, true, nil + } + if !active || (q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && len(a.Objects) < q.ConditionCount || !(q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && a.Value < q.ConditionCount { + return 0, nil, true, fmt.Errorf("todayquest: incomplete quest") + } + if q.ReputationCompleteID != 0 && s.CompleteReputation == nil { + return 0, nil, true, fmt.Errorf("todayquest: reputation provider unavailable") + } + if q.NextID == 0 && s.CompleteAchievement == nil { + return 0, nil, true, fmt.Errorf("todayquest: achievement provider unavailable") + } + bundle, e := s.economy.Apply(s.identity(st, q.ID, "reward"), nil, q.Rewards) + if e != nil { + return 0, nil, true, e + } + b := wire.AppendBytes(nil, 1, bundle) + b = wire.AppendVarint(b, 3, id) + delete(st.Active, q.ID) + st.Cleared[q.ID] = true + if q.ReputationCompleteID != 0 { + rep, e := s.CompleteReputation(s.identity(st, q.ID, "reputation"), q.PackID, uint64(q.ReputationCompleteID)) + if e != nil { + return 0, nil, true, e + } + b = wire.AppendBytes(b, 8, rep) + } + if q.NextID != 0 { + next := Active{ID: q.NextID} + st.Active[next.ID] = next + b = wire.AppendBytes(b, 2, s.questWire(next)) + items, e := s.give(st, next.ID) + if e != nil { + return 0, nil, true, e + } + for _, it := range items { + b = wire.AppendBytes(b, 6, player.ItemWire(it)) + } + } else { + b = wire.AppendBytes(b, 2, nil) + if e = s.CompleteAchievement(s.identity(st, q.ID, "achievement")); e != nil { + return 0, nil, true, e + } + key := s.identity(st, q.ID, "score") + if _, ok := st.ScoreAwards[key]; !ok { + st.ScoreAwards[key] = uint64(s.design.AchievementScore) + } + } + st.Responses[q.ID] = b + return 18, b, true, s.save(st) + } + return 0, nil, false, nil +} + +// Score projects durable commission points. These are deliberately separate +// from user experience: no current QuestClear/Notify protocol supports an Exp +// update, and the board's score label alone does not establish that meaning. +func (s *Service) Score() (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + st, e := s.load() + if e != nil { + return 0, e + } + var total uint64 + for _, n := range st.ScoreAwards { + if n == 0 || n > 2147483647 || total > 2147483647-n { + return 0, fmt.Errorf("todayquest: invalid score awards") + } + total += n + } + return total, nil +} +func values(raw []byte, number int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(raw, func(f wire.Field) error { + if f.Number != number { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("todayquest: invalid progress type") + } + b := f.Value + for len(b) > 0 { + n, k := binary.Uvarint(b) + if k <= 0 { + return fmt.Errorf("todayquest: invalid packed progress") + } + out = append(out, n) + b = b[k:] + if f.Type == 0 && len(b) > 0 { + return fmt.Errorf("todayquest: invalid scalar progress") + } + } + return nil + }) + return out, err +} diff --git a/go/internal/server/todayquest/todayquest_test.go b/go/internal/server/todayquest/todayquest_test.go new file mode 100644 index 0000000..88f7e5e --- /dev/null +++ b/go/internal/server/todayquest/todayquest_test.go @@ -0,0 +1,128 @@ +package todayquest + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "encoding/binary" + "testing" + "time" +) + +type economyFake struct { + seen map[string]bool + grants int +} + +func (e *economyFake) Apply(id string, c, r []gamedata.Reward) ([]byte, error) { + if !e.seen[id] { + e.seen[id] = true + if len(r) > 0 { + e.grants++ + } + } + return nil, nil +} + +type itemsFake struct{} + +func (itemsFake) GrantOnce(string, []gamedata.BattleReward) ([]player.Item, error) { return nil, nil } +func (itemsFake) GrantedItems(string) []player.Item { return nil } +func packet(id int, vs ...uint64) []byte { + b := wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 2, uint64(id)) + b = wire.AppendVarint(b, 3, 7) + var p []byte + for _, v := range vs { + p = binary.AppendUvarint(p, v) + } + if len(p) > 0 { + b = wire.AppendBytes(b, 4, p) + } + return b +} +func fixture(t *testing.T) (*Service, *economyFake, stateio.Store) { + t.Helper() + d := &gamedata.TodayQuestCatalog{Quests: map[int]gamedata.TodayQuest{101: {ID: 101, PackID: 7, NextID: 102, ConditionType: 19, ConditionCount: 1}, 102: {ID: 102, PackID: 7, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}, ReputationCompleteID: 1}}, Limit: 1, PostCount: 1, Reset: gamedata.FieldResetSchedule{WeeklyDay: time.Monday, DailyReset: 9 * time.Hour}} + e := &economyFake{seen: map[string]bool{}} + store := stateio.NewMemory() + s, err := Open(store, d, e, itemsFake{}, func(int) bool { return true }) + if err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } + s.CompleteReputation = func(string, int, uint64) ([]byte, error) { return wire.AppendVarint(nil, 2, 2), nil } + s.design.AchievementScore = 7 + s.CompleteAchievement = func(string) error { return nil } + return s, e, store +} +func call(t *testing.T, s *Service, path string, id int, vs ...uint64) []byte { + t.Helper() + _, b, ok, e := s.Handle(path, packet(id, vs...)) + if e != nil || !ok { + t.Fatalf("%s %d: %v", path, id, e) + } + return b +} +func TestChainCompletionRestartReplayAndWeeklyReset(t *testing.T) { + s, e, store := fixture(t) + call(t, s, "/TodayQuestInfo", 0) + if _, _, _, err := s.Handle("/QuestAccept", packet(102)); err == nil { + t.Fatal("accepted arbitrary continuation") + } + call(t, s, "/QuestAccept", 101) + if _, _, _, err := s.Handle("/QuestClear", packet(101)); err == nil { + t.Fatal("cleared without progress") + } + call(t, s, "/QuestUpdate", 101, 1) + call(t, s, "/QuestClear", 101) + if _, _, _, err := s.Handle("/QuestUpdate", packet(102, 999)); err == nil { + t.Fatal("foreign object accepted") + } + call(t, s, "/QuestUpdate", 102, 71) + call(t, s, "/QuestUpdate", 102, 71) + if _, _, _, err := s.Handle("/QuestClear", packet(102)); err == nil { + t.Fatal("duplicate object counted twice") + } + call(t, s, "/QuestUpdate", 102, 72) + b := call(t, s, "/QuestClear", 102) + if e.grants != 1 { + t.Fatal(e.grants) + } + reopened, err := Open(store, s.design, e, itemsFake{}, func(int) bool { return true }) + if err != nil { + t.Fatal(err) + } + reopened.now = s.now + reopened.CompleteReputation = s.CompleteReputation + reopened.CompleteAchievement = s.CompleteAchievement + if got := call(t, reopened, "/QuestClear", 102); !bytes.Equal(got, b) || e.grants != 1 { + t.Fatal("restart reward replay changed") + } + if n, err := reopened.Score(); err != nil || n != 7 { + t.Fatalf("score %d %v", n, err) + } + if _, _, _, err := s.Handle("/QuestAccept", packet(101)); err == nil { + t.Fatal("completed root accepted") + } + reopened.now = func() time.Time { return time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) } + call(t, reopened, "/TodayQuestInfo", 0) + call(t, reopened, "/QuestAccept", 101) + if n, err := reopened.Score(); err != nil || n != 7 { + t.Fatalf("weekly reset lost score %d %v", n, err) + } +} +func TestGiveUpRestoresQuotaAndClearsPriorChain(t *testing.T) { + s, _, _ := fixture(t) + call(t, s, "/QuestAccept", 101) + call(t, s, "/QuestUpdate", 101, 1) + call(t, s, "/QuestClear", 101) + call(t, s, "/QuestGiveUp", 102) + call(t, s, "/QuestAccept", 101) + st, err := s.load() + if err != nil || st.Cleared[101] { + t.Fatalf("prior clear retained: %v", err) + } +} diff --git a/go/internal/server/world/event_field_pack.go b/go/internal/server/world/event_field_pack.go index c241444..6b6c54d 100644 --- a/go/internal/server/world/event_field_pack.go +++ b/go/internal/server/world/event_field_pack.go @@ -15,6 +15,15 @@ type EventFieldPackSource interface { ListEventFieldPacks() ([]gamedata.EventFieldPack, error) } +// PackAvailable exposes the same account/calendar authorization to NPC +// services without letting them infer access from the client's pack number. +func (s *Service) PackAvailable(id uint64) bool { + if id == 0 || id > uint64(^uint32(0)>>1) { + return false + } + return s.packUnlocked(int(id)) +} + func (s *Service) AttachEventFieldPacks(source EventFieldPackSource) error { if source == nil { return fmt.Errorf("world: nil event field pack source") diff --git a/go/internal/server/world/event_field_pack_test.go b/go/internal/server/world/event_field_pack_test.go new file mode 100644 index 0000000..35e324d --- /dev/null +++ b/go/internal/server/world/event_field_pack_test.go @@ -0,0 +1,128 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +type eventPackSource struct { + active bool + pack gamedata.EventFieldPack +} + +func (p *eventPackSource) ResolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { + return p.pack, p.active && p.pack.ID == id, nil +} +func (p *eventPackSource) ListEventFieldPacks() ([]gamedata.EventFieldPack, error) { + if p.active { + return []gamedata.EventFieldPack{p.pack}, nil + } + return nil, nil +} + +// Exercise the actual purchase-to-field request chain rather than a single +// permissive route. A hidden scene must not overwrite relogin's outside map. +func TestHiddenPackPurchaseEntryMonstersAndOutsideRestore(t *testing.T) { + s := testService() + storage := stateio.NewMemory() + var err error + s.state, err = progress.OpenStore(storage) + if err != nil { + t.Fatal(err) + } + s.collection, err = player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(storage, s.starter) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{Gold: 100}) + if err != nil { + t.Fatal(err) + } + source := &eventPackSource{active: true, pack: gamedata.EventFieldPack{ID: 912, MapIDs: []int{9121}, InitialPosition: "{}", BuyType: 4, BuyPrice: 20}} + if err = s.AttachEventFieldPacks(source); err != nil { + t.Fatal(err) + } + s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { + if pack != 912 { + t.Fatalf("wrong pack for monsters %d", pack) + } + return []gamedata.FieldMonsterDesign{{ID: 3, GroupID: 7}}, nil + } + outside := wire.AppendString(wire.AppendVarint(nil, 2, 21), 3, `{"MapId":211,"PlayerPosition":{"x":4}}`) + if err = s.state.SaveUserPosition(outside); err != nil { + t.Fatal(err) + } + if err = s.state.SetActivePackID(21); err != nil { + t.Fatal(err) + } + r := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 912) + if _, _, _, err = s.Handle("/PackInGameInfo", r); err == nil { + t.Fatal("unbought hidden pack admitted") + } + for i := 0; i < 2; i++ { + code, out, ok, err := s.Handle("/PackBuy", r) + if err != nil || code != 6 || !ok { + t.Fatalf("buy %d %v", code, err) + } + info, found, err := wire.Bytes(out, 1) + if err != nil || !found { + t.Fatal("missing PackInfo") + } + id, _, _ := wire.Varint(info, 1) + owned, _, _ := wire.Varint(info, 8) + if id != 912 || owned != 1 { + t.Fatal("hidden buy response missing purchased identity") + } + } + if s.wallet.Snapshot().Gold != 80 { + t.Fatal("replayed purchase spent twice") + } + if _, _, _, err = s.Handle("/FieldObjectInfo", r); err != nil { + t.Fatal(err) + } + _, out, _, err := s.Handle("/PackInGameInfo", r) + if err != nil { + t.Fatal(err) + } + position, _, _ := wire.Bytes(out, 4) + if string(position) != "{}" { + t.Fatal("outside position leaked into hidden scene") + } + if _, found, _ := wire.Bytes(out, 12); !found { + t.Fatal("client HuntingGroundInfo null") + } + if _, found, _ := wire.Bytes(out, 2); found { + t.Fatal("hidden scene inherited story quests") + } + monster := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 7) + code, out, _, err := s.Handle("/MonsterInfo", monster) + if err != nil || code != 51 { + t.Fatalf("monster info %v", err) + } + m, _, _ := wire.Bytes(out, 1) + id, _, _ := wire.Varint(m, 1) + if id != 3 { + t.Fatal("hidden monster not generated") + } + if pack, err := s.LastPlayedPackID(); err != nil || pack != 21 { + t.Fatalf("hidden scene replaced persistent login %d %v", pack, err) + } + source.active = false + if _, _, _, err = s.Handle("/PackBuy", r); err == nil { + t.Fatal("expired pack repurchased") + } + if _, _, _, err = s.Handle("/PackInGameInfo", r); err == nil { + t.Fatal("expired pack entered") + } + if pack, err := s.LastPlayedPackID(); err != nil || pack != 21 { + t.Fatalf("expired hidden scene locked login %d %v", pack, err) + } +} diff --git a/go/internal/server/world/monster_info.go b/go/internal/server/world/monster_info.go new file mode 100644 index 0000000..f685646 --- /dev/null +++ b/go/internal/server/world/monster_info.go @@ -0,0 +1,133 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "encoding/binary" + "encoding/json" + "fmt" + "time" +) + +// AttachFieldMonsterState uses the gameplay snapshot store, which participates +// in the account transaction. No in-memory lifetime survives a rollback. +func (s *Service) AttachFieldMonsterState(store stateio.Store) error { + if store == nil { + return fmt.Errorf("world: missing monster state store") + } + s.monsterStore = store + return nil +} + +func (s *Service) handleMonsterInfo(request []byte) (int, []byte, bool, error) { + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 { + return 0, nil, true, ErrInvalidRequest + } + groups := map[int]bool{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return ErrInvalidRequest + } + data := f.Value + for len(data) > 0 { + v, n := binary.Uvarint(data) + if n <= 0 || v == 0 || v > uint64(^uint32(0)>>1) { + return ErrInvalidRequest + } + groups[int(v)] = true + if len(groups) > 4096 { + return ErrInvalidRequest + } + data = data[n:] + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + pack, err := s.CurrentPackID() + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(pack) || s.monsterLoader == nil { + return 0, nil, true, fmt.Errorf("%w: unavailable monster pack", ErrInvalidRequest) + } + design, err := s.monsterLoader(pack) + if err != nil { + return 0, nil, true, err + } + var response []byte + starts := map[string]int64{} + if s.monsterStore != nil { + b, e := s.monsterStore.Load("fieldmonsters") + if e != nil { + return 0, nil, true, e + } + if b != nil { + if e = json.Unmarshal(b, &starts); e != nil || starts == nil { + return 0, nil, true, fmt.Errorf("world: invalid monster lifetime state") + } + } + for _, t := range starts { + if t <= 0 { + return 0, nil, true, fmt.Errorf("world: invalid monster start time") + } + } + } + changed := false + difficulty := 0 + if selection, ok := s.state.Selection(pack); ok { + difficulty = selection.Difficulty + } + for _, m := range design { + if !groups[m.GroupID] { + continue + } + active := m.QuestID == 0 || s.state.QuestCleared(m.QuestID, pack, difficulty) + row := wire.AppendVarint(nil, 1, uint64(m.ID)) + row = wire.AppendVarint(row, 5, uint64(m.GroupID)) + if m.BattleDeck != 0 { + row = wire.AppendVarint(row, 2, m.BattleDeck) + } + if active && m.LifeSeconds > 0 { + if s.monsterStore == nil { + return 0, nil, true, fmt.Errorf("world: monster lifetime state unavailable") + } + key := fmt.Sprintf("%d/%d/%d", pack, difficulty, m.ID) + start, ok := starts[key] + if !ok { + start = time.Now().UnixMilli() + starts[key] = start + changed = true + } + end := start + int64(m.LifeSeconds)*1000 + row = wire.AppendVarint(row, 4, uint64(end)) + active = time.Now().UnixMilli() < end + } + if active { + row = wire.AppendVarint(row, 6, 1) + } + response = wire.AppendBytes(response, 1, row) + } + if changed { + b, e := json.Marshal(starts) + if e != nil { + return 0, nil, true, e + } + if e = s.monsterStore.Save("fieldmonsters", b); e != nil { + return 0, nil, true, e + } + } + return 51, response, true, nil +} + +func (s *Service) attachFieldMonsterDesign(root, version string) { + s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { + return gamedata.LoadFieldMonsters(root, version, pack) + } +} diff --git a/go/internal/server/world/monster_info_test.go b/go/internal/server/world/monster_info_test.go new file mode 100644 index 0000000..a308247 --- /dev/null +++ b/go/internal/server/world/monster_info_test.go @@ -0,0 +1,92 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +// This regression exercises the missing field entry route with duplicate packed +// regeneration IDs, and verifies that other groups cannot leak into the map. +func TestMonsterInfoRegenerationGroupsAndQuestGate(t *testing.T) { + s := testService() + s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { + if pack != 21 { + t.Fatalf("loaded wrong pack %d", pack) + } + return []gamedata.FieldMonsterDesign{{ID: 1001, GroupID: 7}, {ID: 1002, GroupID: 7, QuestID: 2, BattleDeck: 77}, {ID: 1003, GroupID: 8}}, nil + } + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendBytes(request, 2, []byte{7, 7}) + code, body, handled, err := s.Handle("/MonsterInfo", request) + if err != nil || !handled || code != 51 { + t.Fatalf("MonsterInfo: %d %v %v", code, handled, err) + } + var ids []uint64 + var active []uint64 + err = wire.Walk(body, func(f wire.Field) error { + id, _, e := wire.Varint(f.Value, 1) + if e != nil { + return e + } + a, _, e := wire.Varint(f.Value, 6) + ids = append(ids, id) + active = append(active, a) + return e + }) + if err != nil || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 || active[0] != 1 || active[1] != 0 { + t.Fatalf("wrong field monsters %v active %v: %v", ids, active, err) + } + _, retry, _, err := s.Handle("/MonsterInfo", request) + if err != nil || !bytes.Equal(body, retry) { + t.Fatal("reading stable field monsters changed the response") + } + unpacked := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7) + _, alternate, _, err := s.Handle("/MonsterInfo", unpacked) + if err != nil || !bytes.Equal(body, alternate) { + t.Fatal("unpacked protobuf group IDs changed results") + } + if _, _, _, err = s.Handle("/MonsterInfo", wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, []byte{128})); err == nil { + t.Fatal("accepted truncated group ID") + } +} + +func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) { + s := testService() + store := stateio.NewMemory() + if err := s.AttachFieldMonsterState(store); err != nil { + t.Fatal(err) + } + s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { + return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, LifeSeconds: 30}}, nil + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7) + _, first, _, err := s.Handle("/MonsterInfo", request) + if err != nil { + t.Fatal(err) + } + s2 := testService() + s2.monsterLoader = s.monsterLoader + s2.AttachFieldMonsterState(store) + _, again, _, err := s2.Handle("/MonsterInfo", request) + if err != nil || !bytes.Equal(first, again) { + t.Fatal("restart renewed finite monster lifetime") + } + if err := store.Save("fieldmonsters", []byte(`{"21/0/9":1}`)); err != nil { + t.Fatal(err) + } + _, expired, _, err := s2.Handle("/MonsterInfo", request) + if err != nil { + t.Fatal(err) + } + row, _, err := wire.Bytes(expired, 1) + if err != nil { + t.Fatal(err) + } + active, _, _ := wire.Varint(row, 6) + if active != 0 { + t.Fatal("expired monster revived by reading info") + } +} diff --git a/go/internal/server/world/npc_reputation.go b/go/internal/server/world/npc_reputation.go new file mode 100644 index 0000000..9fc6271 --- /dev/null +++ b/go/internal/server/world/npc_reputation.go @@ -0,0 +1,210 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "encoding/json" + "fmt" + "sort" + "time" +) + +type npcReputationRuntime struct { + store stateio.Store + load func(int) (gamedata.NPCReputationDesign, error) + inns func(int) ([]gamedata.InnRule, error) + now func() time.Time +} +type npcReputationSnapshot struct { + Starts map[string]int64 + Claims map[string]bool +} + +func (s *Service) ConfigureNPCRuntime(root, version string, store stateio.Store) error { + if store == nil { + return fmt.Errorf("world: missing NPC reputation store") + } + s.npcReputation = &npcReputationRuntime{store: store, load: func(pack int) (gamedata.NPCReputationDesign, error) { + return gamedata.LoadNPCReputation(root, version, pack) + }, inns: func(pack int) ([]gamedata.InnRule, error) { return gamedata.LoadInns(root, version, pack) }, now: time.Now} + return nil +} +func (r *npcReputationRuntime) snapshot() (npcReputationSnapshot, error) { + v := npcReputationSnapshot{Starts: map[string]int64{}, Claims: map[string]bool{}} + b, err := r.store.Load("npcreputation") + if err != nil { + return v, err + } + if b != nil { + if err = stateio.RequireExactJSONObject(b, "Starts", "Claims"); err != nil { + return v, err + } + if err = json.Unmarshal(b, &v); err != nil || v.Starts == nil || v.Claims == nil { + return v, fmt.Errorf("world: invalid NPC reputation state") + } + } + for _, start := range v.Starts { + if start <= 0 { + return v, fmt.Errorf("world: invalid NPC reputation timestamp") + } + } + return v, nil +} +func reputationKey(pack int, group uint64) string { return fmt.Sprintf("%d/%d", pack, group) } +func (s *Service) reputationState(pack int, rule gamedata.NPCReputationRule) (uint64, uint64, error) { + if s.npcReputation == nil { + return 1, 0, nil + } + v, err := s.npcReputation.snapshot() + if err != nil { + return 0, 0, err + } + start, ok := v.Starts[reputationKey(pack, rule.ID)] + if !ok { + return 1, 0, nil + } + elapsed := s.npcReputation.now().Unix() - start + if elapsed < 0 { + elapsed = 0 + } + if uint64(elapsed) >= rule.DownHours*3600 { + return 1, 0, nil + } + return 2, uint64(elapsed), nil +} +func (s *Service) CompleteNPCReputation(identity string, pack int, group uint64) ([]byte, error) { + if s.npcReputation == nil || identity == "" || group == 0 || !s.packUnlocked(pack) { + return nil, ErrInvalidRequest + } + d, err := s.npcReputation.load(pack) + if err != nil { + return nil, err + } + rule, ok := d.Groups[group] + if !ok { + return nil, fmt.Errorf("world: unknown reputation group") + } + v, err := s.npcReputation.snapshot() + if err != nil { + return nil, err + } + claim := fmt.Sprintf("%s:%d:%d", identity, pack, group) + if !v.Claims[claim] { + v.Claims[claim] = true + v.Starts[reputationKey(pack, group)] = s.npcReputation.now().Unix() + b, e := json.Marshal(v) + if e != nil { + return nil, e + } + if e = s.npcReputation.store.Save("npcreputation", b); e != nil { + return nil, e + } + } + state, elapsed, err := s.reputationState(pack, rule) + if err != nil { + return nil, err + } + return reputationWire(group, state, elapsed), nil +} +func reputationWire(group, state, elapsed uint64) []byte { + b := wire.AppendVarint(nil, 1, group) + b = wire.AppendVarint(b, 2, state) + if elapsed > 0 { + b = wire.AppendVarint(b, 3, elapsed) + } + return b +} + +func (s *Service) npcReputationRows(pack int) ([][]byte, error) { + if s.npcReputation == nil { + return nil, nil + } + d, err := s.npcReputation.load(pack) + if err != nil { + return nil, err + } + var groups []uint64 + for id := range d.Groups { + groups = append(groups, id) + } + sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] }) + var rows [][]byte + for _, id := range groups { + state, elapsed, e := s.reputationState(pack, d.Groups[id]) + if e != nil { + return nil, e + } + rows = append(rows, reputationWire(id, state, elapsed)) + } + return rows, nil +} +func (s *Service) NPCShopReputation(pack uint64) (uint64, uint64, error) { + current, err := s.CurrentPackID() + if err != nil || pack == 0 || !s.packUnlocked(int(pack)) || s.npcReputation == nil { + return 0, 0, ErrInvalidRequest + } + d, err := s.npcReputation.load(int(pack)) + if err != nil { + return 0, 0, err + } + var group uint64 + position, ok := s.state.Position() + if ok && position.PackID == current { + currentDesign := d + if pack != uint64(current) { + currentDesign, err = s.npcReputation.load(current) + if err != nil { + return 0, 0, err + } + } + group = currentDesign.MapGroups[position.Position.MapID] + } + if group == 0 && len(d.Groups) == 1 { + for id := range d.Groups { + group = id + } + } + rule, ok := d.Groups[group] + if !ok { + return 1, 0, nil + } + state, _, err := s.reputationState(int(pack), rule) + if err != nil { + return 0, 0, err + } + if state == 2 { + return state, rule.GoodPrice, nil + } + return state, 0, nil +} + +// InnContext validates the motel's real map, rather than trusting an NPC ID +// supplied from another pack or the previously visited scene. +func (s *Service) InnContext(pack, npc uint64) (gamedata.InnRule, uint64, error) { + var empty gamedata.InnRule + current, err := s.CurrentPackID() + if err != nil || s.npcReputation == nil || (pack != 0 && pack != uint64(current)) || !s.packUnlocked(current) { + return empty, 0, ErrInvalidRequest + } + position, ok := s.state.Position() + if !ok || position.PackID != current { + return empty, 0, fmt.Errorf("world: inn requires current field position") + } + rules, err := s.npcReputation.inns(current) + if err != nil { + return empty, 0, err + } + d, err := s.npcReputation.load(current) + if err != nil { + return empty, 0, err + } + for _, r := range rules { + if r.MapID != uint64(position.Position.MapID) || (npc != 0 && npc != r.NPCID) { + continue + } + state, _, e := s.reputationState(current, d.Groups[r.MapGroup]) + return r, state, e + } + return empty, 0, fmt.Errorf("world: motel NPC outside current map") +} diff --git a/go/internal/server/world/npc_reputation_test.go b/go/internal/server/world/npc_reputation_test.go new file mode 100644 index 0000000..c888ae8 --- /dev/null +++ b/go/internal/server/world/npc_reputation_test.go @@ -0,0 +1,69 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" + "time" +) + +func TestNPCReputationCompletionRetriesDoNotExtendDiscount(t *testing.T) { + s := testService() + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + store := stateio.NewMemory() + d := gamedata.NPCReputationDesign{Groups: map[uint64]gamedata.NPCReputationRule{9: {ID: 9, DownHours: 2, GoodInn: 10, GoodPrice: 20}}, MapGroups: map[int]uint64{211: 9}} + s.npcReputation = &npcReputationRuntime{store: store, now: func() time.Time { return now }, load: func(int) (gamedata.NPCReputationDesign, error) { return d, nil }} + b, err := s.CompleteNPCReputation("quest-1", 21, 9) + if err != nil { + t.Fatal(err) + } + if v, _, _ := wire.Varint(b, 2); v != 2 { + t.Fatal("completion did not grant good reputation") + } + now = now.Add(time.Hour) + b, err = s.CompleteNPCReputation("quest-1", 21, 9) + if err != nil { + t.Fatal(err) + } + if elapsed, _, _ := wire.Varint(b, 3); elapsed != 3600 { + t.Fatal("retry reset good reputation expiry") + } + now = now.Add(time.Hour) + state, _, err := s.reputationState(21, d.Groups[9]) + if err != nil || state != 1 { + t.Fatal("good reputation did not expire after GameData hours") + } + if _, err = s.CompleteNPCReputation("quest-2", 21, 99); err == nil { + t.Fatal("unknown reputation group accepted") + } +} + +func TestInnContextUsesCurrentMapAndShopCanProjectOtherUnlockedPacks(t *testing.T) { + s := testService() + s.storyCatalog.Packs[77] = gamedata.StoryPack{ID: 77} + position := wire.AppendString(wire.AppendVarint(nil, 2, 21), 3, `{"mapId":211}`) + if err := s.state.SaveUserPosition(position); err != nil { + t.Fatal(err) + } + d := gamedata.NPCReputationDesign{Groups: map[uint64]gamedata.NPCReputationRule{9: {ID: 9, DownHours: 2, GoodInn: 10, GoodPrice: 20}}, MapGroups: map[int]uint64{211: 9}} + s.npcReputation = &npcReputationRuntime{store: stateio.NewMemory(), now: time.Now, load: func(int) (gamedata.NPCReputationDesign, error) { return d, nil }, inns: func(int) ([]gamedata.InnRule, error) { + return []gamedata.InnRule{{NPCID: 7, MapID: 211, MapGroup: 9}}, nil + }} + if _, state, err := s.InnContext(21, 7); err != nil || state != 1 { + t.Fatal("valid motel context rejected") + } + if _, _, err := s.InnContext(21, 8); err == nil { + t.Fatal("unrelated NPC treated as motel") + } + if _, _, err := s.InnContext(77, 7); err == nil { + t.Fatal("motel from another pack accepted") + } + if _, err := s.CompleteNPCReputation("other-pack-delegation", 77, 9); err != nil { + t.Fatal(err) + } + state, discount, err := s.NPCShopReputation(77) + if err != nil || state != 2 || discount != 20 { + t.Fatal("ShopOpen cannot project reputation for another unlocked pack") + } +} diff --git a/go/internal/server/world/quest_selection.go b/go/internal/server/world/quest_selection.go index 0b74aec..0d2e10a 100644 --- a/go/internal/server/world/quest_selection.go +++ b/go/internal/server/world/quest_selection.go @@ -90,10 +90,24 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte for _, quest := range s.activeSideQuestWires(pack) { out = wire.AppendBytes(out, 1, quest) } + var todayCleared []int + if s.todayQuests != nil { + rows, ids, err := s.todayQuests.Info(pack) + if err != nil { + return 0, nil, true, err + } + todayCleared = ids + for _, row := range rows { + out = wire.AppendBytes(out, 1, row) + } + } var cleared []byte for _, id := range s.state.ClearedQuests(pack, s.questDifficulty(pack)) { cleared = binary.AppendUvarint(cleared, uint64(id)) } + for _, id := range todayCleared { + cleared = binary.AppendUvarint(cleared, uint64(id)) + } if len(cleared) > 0 { out = wire.AppendBytes(out, 2, cleared) } diff --git a/go/internal/server/world/today_quests.go b/go/internal/server/world/today_quests.go new file mode 100644 index 0000000..3dbac75 --- /dev/null +++ b/go/internal/server/world/today_quests.go @@ -0,0 +1,38 @@ +package world + +import ( + "bd2server/internal/server/todayquest" + "fmt" +) + +func (s *Service) AttachTodayQuests(service *todayquest.Service) error { + if service == nil { + return fmt.Errorf("world: nil commission service") + } + s.todayQuests = service + return nil +} + +// NPCController checks whether any owned pack has completed its main story, +// rather than requiring completion of the board's own pack. +func (s *Service) CommissionPackUnlocked(pack int) bool { + if !s.packUnlocked(pack) || s.storyCatalog == nil { + return false + } + for id, design := range s.storyCatalog.Packs { + if !s.packUnlocked(id) || len(design.MainQuestIDs) == 0 { + continue + } + complete := true + for _, quest := range design.MainQuestIDs { + if !s.state.QuestCleared(quest, id, 0) { + complete = false + break + } + } + if complete { + return true + } + } + return false +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 75a576b..f25fff9 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -17,6 +17,7 @@ import ( "bd2server/internal/server/player" "bd2server/internal/server/progress" "bd2server/internal/server/stateio" + "bd2server/internal/server/todayquest" "bd2server/internal/server/versionconfig" "bd2server/internal/server/wire" ) @@ -136,6 +137,7 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, return nil, err } service.attachPackDetailDesign(gameDataRoot, gameDataVersion) + service.attachFieldMonsterDesign(gameDataRoot, gameDataVersion) return service, nil } @@ -169,6 +171,7 @@ func (s *Service) setCurrentPack(packID int) { } type Service struct { + todayQuests *todayquest.Service huntingGround interface{ EnsureForPack(int) ([]byte, error) } battleActive func() bool questDifficulties map[int]map[int]bool @@ -184,6 +187,9 @@ type Service struct { eventFieldPacks EventFieldPackSource fieldObjects map[int]gamedata.FieldObjectDesign fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) + monsterLoader func(int) ([]gamedata.FieldMonsterDesign, error) + monsterStore stateio.Store + npcReputation *npcReputationRuntime fieldReset gamedata.FieldResetSchedule researchDesigns map[int]gamedata.FieldResearchDesign researchLoader func(int) (gamedata.FieldResearchDesign, error) @@ -237,7 +243,14 @@ func (s *Service) AttachDecks(decks *deck.Store) error { } func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + if s.todayQuests != nil { + if code, body, handled, err := s.todayQuests.Handle(path, request); handled { + return code, body, handled, err + } + } switch path { + case "/MonsterInfo": + return s.handleMonsterInfo(request) case "/QuestUpdate": return s.handleQuestUpdate(request) case "/FieldObjectInfo": @@ -673,6 +686,15 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) { } slog.Info("world: deliver field position", "pack", packID, "map", mapID, "restored", restored) out = wire.AppendString(out, 4, position) + if s.npcReputation != nil { + rows, err := s.npcReputationRows(packID) + if err != nil { + return nil, err + } + for _, row := range rows { + out = wire.AppendBytes(out, 9, row) + } + } // The remaining starter-only records were observed in the official // starter-pack response. They represent reputation, hunting-ground, statue, // and reward state, not generic defaults, so a newly entered later pack must @@ -682,6 +704,9 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) { return wire.AppendBytes(out, 12, visit), nil } for _, state := range s.seed.InitialReputations { + if s.npcReputation != nil { + break + } row := wire.AppendVarint(nil, 1, state.GroupID) row = wire.AppendVarint(row, 2, state.State) if state.ElapsedSeconds != 0 {