feat(server): complete tracker flow and NPC shops, inns, commissions and reputation

This commit is contained in:
2026-10-05 21:26:44 +08:00
parent 3b08040a0e
commit e30023053e
33 changed files with 3387 additions and 12 deletions
+51 -1
View File
@@ -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,
+20 -7
View File
@@ -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
}
}
@@ -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 {
@@ -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")
}
}
@@ -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
}
+1 -1
View File
@@ -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)) {
@@ -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")
}
}
@@ -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,
-2
View File
@@ -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
@@ -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()
}
@@ -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")
}
}
+105
View File
@@ -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
}
+56
View File
@@ -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")
}
}
@@ -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()
}
+89
View File
@@ -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
}
@@ -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")
}
}
+119
View File
@@ -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
}
@@ -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)
}
}
+217
View File
@@ -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
}
+166
View File
@@ -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")
}
}
+459
View File
@@ -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
}
+235
View File
@@ -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)
}
}
+472
View File
@@ -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
}
@@ -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)
}
}
@@ -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")
@@ -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)
}
}
+133
View File
@@ -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)
}
}
@@ -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")
}
}
+210
View File
@@ -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")
}
@@ -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")
}
}
@@ -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)
}
+38
View File
@@ -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
}
+25
View File
@@ -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 {