feat(server): complete tracker flow and NPC shops, inns, commissions and reputation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user