fix(all): complete activity calendars, shop purchases and reward dispatch
Restore event and mini hubs, current shop and hunt calendars, and regular seasons; preserve all published windows. Implement story/quiz routes, native shop purchases, avatar sets, currencies, rank-change items and permanent buffs with rollback and reward coverage tests. Refresh client buff stats and update Cash Shop to 0.1.1.
This commit is contained in:
@@ -732,6 +732,21 @@ func serve(args []string) (serveErr error) {
|
||||
return fmt.Errorf("load event inventory design: %w", err)
|
||||
}
|
||||
eventEconomy.AttachOwnedItemDesign(ownedEventItems)
|
||||
avatarRewards, err := gamedata.LoadAvatarRewardDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load avatar rewards: %w", err)
|
||||
}
|
||||
eventEconomy.AttachAvatarRewards(avatarRewards)
|
||||
buffDesign, err := gamedata.LoadBuffRewardDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load permanent buff rewards: %w", err)
|
||||
}
|
||||
buffRewards, err := events.OpenBuffRewards(gameplayStore, buffDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load permanent buff ownership: %w", err)
|
||||
}
|
||||
eventEconomy.AttachBuffRewards(buffRewards)
|
||||
pictorialService.AttachPermanentBuffs(buffRewards.SnapshotBuffs)
|
||||
eventAPCaps, eventAPReset, err := gamedata.LoadEventAPDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event AP reset: %w", err)
|
||||
@@ -786,6 +801,9 @@ func serve(args []string) (serveErr error) {
|
||||
if err := cashService.AttachShopSeed(serverConfig); err != nil {
|
||||
return fmt.Errorf("attach cash product availability: %w", err)
|
||||
}
|
||||
if err := cashService.AttachEventShopSchedules(cashDesign, calendars.Events); err != nil {
|
||||
return fmt.Errorf("attach event shop availability: %w", err)
|
||||
}
|
||||
cashBonusDesign, err := gamedata.LoadCashBonusCatalog(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash bonus design: %w", err)
|
||||
@@ -910,6 +928,13 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event action state: %w", err)
|
||||
}
|
||||
miniContent, err := gamedata.LoadMiniContentDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mini event content: %w", err)
|
||||
}
|
||||
if err = eventActionsService.AttachMiniContent(eventPlayService, miniContent); err != nil {
|
||||
return fmt.Errorf("attach mini event content: %w", err)
|
||||
}
|
||||
eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
|
||||
for _, entry := range collection.FriendshipEntries() {
|
||||
if entry.State != nil && entry.State.CostumeID == id {
|
||||
|
||||
@@ -26,9 +26,25 @@ func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 78 || len(set.CashProducts) != 37 || len(set.EventHubs) != 2 || len(set.MiniGameHubs) != 6 {
|
||||
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 80 || len(set.CashProducts) != 61 || len(set.EventHubs) != 4 || len(set.MiniGameHubs) != 6 {
|
||||
t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs))
|
||||
}
|
||||
var mooncake, chained *EventHub
|
||||
for i := range set.EventHubs {
|
||||
h := &set.EventHubs[i]
|
||||
if h.UID == 3 && h.HubID == 1003 {
|
||||
mooncake = h
|
||||
}
|
||||
if h.UID == 76 && h.HubID == 59 {
|
||||
chained = h
|
||||
}
|
||||
}
|
||||
if mooncake == nil || mooncake.PlayEnd != "2026-09-30T23:59:59.000Z" || len(mooncake.Settings) != 3 || mooncake.Settings[2].EventUIDs[0] != 10000034 {
|
||||
t.Fatal("published Mooncake Wishes calendar omitted its deferred board slot")
|
||||
}
|
||||
if chained == nil || len(chained.Settings) != 6 || chained.Settings[2].EventUIDs[0] != 80 {
|
||||
t.Fatal("published Chained Soldier hub omitted its event shop")
|
||||
}
|
||||
}
|
||||
func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -60,6 +60,21 @@ func (s *Set) validateMiniHubBindings(design *gamedata.EventPlayCatalog) error {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content type %d, want %d", hub.HubID, binding.Slot, binding.ProgressType, contentType)
|
||||
}
|
||||
eventType, supported := gamedata.MiniHubEventType(contentType)
|
||||
// Mini stories and NPC quizzes have their own slot UID namespace,
|
||||
// distinct from Define_EventType (13 there means bingo). Their group
|
||||
// and availability derive from this static slot and the hub window.
|
||||
if contentType == 13 || contentType == 14 {
|
||||
if len(binding.EventUIDs) != 1 || binding.EventUIDs[0] == 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d requires one local content UID", hub.HubID, binding.Slot)
|
||||
}
|
||||
if _, collision := uid[binding.EventUIDs[0]]; collision {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content UID collides with global event", hub.HubID, binding.Slot)
|
||||
}
|
||||
if value(slot, 4) != 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d story/quiz slot has invalid end type", hub.HubID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !supported {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content type %d has no supported scheduled route", hub.HubID, binding.Slot, contentType)
|
||||
}
|
||||
|
||||
@@ -68,4 +68,14 @@ func TestMiniHubPublishedRoutesValidateIndependentEnumsAndWindows(t *testing.T)
|
||||
if err := s.validateMiniHubBindings(design); err == nil {
|
||||
t.Fatal("quiz borrowed global bingo event type")
|
||||
}
|
||||
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 11, 9, 13, 7, 99)
|
||||
s = fresh()
|
||||
s.EventHubs[0].Settings = []HubSetting{{Slot: 11, ProgressType: 13, EventUIDs: []uint64{10000032}}}
|
||||
if err := s.validateMiniHubBindings(design); err != nil {
|
||||
t.Fatalf("independent quiz content UID rejected: %v", err)
|
||||
}
|
||||
s.EventHubs[0].Settings[0].EventUIDs = []uint64{21}
|
||||
if err := s.validateMiniHubBindings(design); err == nil {
|
||||
t.Fatal("quiz content UID collided with global event")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package calendar
|
||||
|
||||
import "testing"
|
||||
|
||||
// Current-version publication facts were verified against the client schemas,
|
||||
// GameData identities and 2026-10-05 schedule comparison. No capture is read.
|
||||
func TestPublishedRegularAndGachaCalendarCompleteness(t *testing.T) {
|
||||
set, err := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[uint64][2]uint64{1: {163, 164}, 2: {58, 58}, 3: {131, 999999}, 4: {877, 999999}, 5: {30, 31}, 6: {27, 28}, 7: {15, 999999}, 8: {44, 45}, 9: {2, 999999}}
|
||||
if len(set.RegularService.Contents) != len(want) || set.RegularService.CalculateMilliseconds != 32400000 {
|
||||
t.Fatal("regular content calendar incomplete")
|
||||
}
|
||||
for _, c := range set.RegularService.Contents {
|
||||
if want[c.ID] != [2]uint64{c.Current.ID, c.Next.ID} {
|
||||
t.Fatalf("content %d stale seasons %d/%d", c.ID, c.Current.ID, c.Next.ID)
|
||||
}
|
||||
}
|
||||
current := map[uint64]bool{166: true, 30010: true, 30011: true, 206: true, 205: true, 208: true, 153: true, 72: true, 71: true, 207: true}
|
||||
for _, g := range set.GachaSeed.Schedules {
|
||||
if current[g.GroupID] {
|
||||
delete(current, g.GroupID)
|
||||
if g.EndTime != 1791417599000 {
|
||||
t.Fatalf("gacha %d truncated window", g.GroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(current) != 0 {
|
||||
t.Fatalf("missing published gacha groups %v", current)
|
||||
}
|
||||
if len(set.GachaSeed.StepUps) != 2 {
|
||||
t.Fatal("step-up schedule omitted")
|
||||
}
|
||||
for _, g := range set.GachaSeed.StepUps {
|
||||
if g.GroupID != 29 && g.GroupID != 30 || g.EndTime != 1791417599000 {
|
||||
t.Fatalf("step-up schedule incorrect %+v", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,9 @@ func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Re
|
||||
if !e.design.AvatarSets[r.ID] {
|
||||
return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID)
|
||||
}
|
||||
special = append(special, player.Item{Type: r.Type, ID: r.ID, Count: r.Count, TimeValue: uint64(e.now().UnixMilli())})
|
||||
// The shared gameplay economy expands AvatarSetTable members and
|
||||
// emits real AvatarItem/AvatarMotion/AvatarChar ownership.
|
||||
regular = append(regular, gamedata.Reward(r))
|
||||
case r.Type == 19 && e.design.TicketTypes[r.ID] == 2:
|
||||
if len(e.design.Attendance[r.ID]) == 0 {
|
||||
return nil, fmt.Errorf("commerce: subscription reward schedule missing")
|
||||
|
||||
@@ -52,9 +52,14 @@ func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 2 || base.Rewards[0].Type != 45 {
|
||||
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 3 || base.Rewards[0].Type != 62 || base.Rewards[1].Type != 45 {
|
||||
t.Fatal("expiry or prestige dispatch invalid")
|
||||
}
|
||||
for _, item := range items.All() {
|
||||
if item.Type == 62 {
|
||||
t.Fatal("commerce stored synthetic avatar set")
|
||||
}
|
||||
}
|
||||
again, err := e.Apply("buy1", nil, rewards)
|
||||
if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 {
|
||||
t.Fatal("retry rerolled or double granted")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// AttachEventShopSchedules joins event type 15 to EventShopTable and then to
|
||||
// CashProductTable. Hub IDs are presentation identities, never product groups.
|
||||
// All scheduled goods are published; purchase handlers enforce their windows.
|
||||
// Call once before serving sessions.
|
||||
func (s *Service) AttachEventShopSchedules(design *gamedata.CashCatalog, schedules []events.Schedule) error {
|
||||
if design == nil || s.shopWindows == nil {
|
||||
return fmt.Errorf("commerce: missing event shop catalog or shop seed")
|
||||
}
|
||||
shops := map[uint64]uint64{}
|
||||
groups := map[uint64]bool{}
|
||||
for _, shop := range design.EventShops {
|
||||
if shop.ID == 0 || shop.ProductGroupID == 0 || shops[shop.ID] != 0 || groups[shop.ProductGroupID] {
|
||||
return fmt.Errorf("commerce: invalid or duplicate event shop identity")
|
||||
}
|
||||
shops[shop.ID], groups[shop.ProductGroupID] = shop.ProductGroupID, true
|
||||
}
|
||||
windows := map[gamedata.CashProductKey][][2]uint64{}
|
||||
var products [][]byte
|
||||
for _, raw := range s.shopProducts {
|
||||
group, _, _ := wire.Varint(raw, 1)
|
||||
if !groups[group] {
|
||||
products = append(products, raw)
|
||||
}
|
||||
}
|
||||
rows := append([]events.Schedule(nil), schedules...)
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID })
|
||||
for _, row := range rows {
|
||||
if row.Type != 15 {
|
||||
continue
|
||||
}
|
||||
group := shops[row.ID]
|
||||
if group == 0 || row.UID == 0 || row.Start <= 0 || row.Start >= row.End {
|
||||
return fmt.Errorf("commerce: invalid event shop schedule %d", row.UID)
|
||||
}
|
||||
found := false
|
||||
start, end := uint64(row.Start), uint64(row.End)
|
||||
for _, product := range s.catalog.Designs() {
|
||||
key := product.Key
|
||||
if key.GroupID != group {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
for _, prior := range windows[key] {
|
||||
if start < prior[1] && prior[0] < end {
|
||||
return fmt.Errorf("commerce: overlapping event shop product windows")
|
||||
}
|
||||
}
|
||||
windows[key] = append(windows[key], [2]uint64{start, end})
|
||||
raw := wire.AppendVarint(nil, 1, key.GroupID)
|
||||
raw = wire.AppendVarint(raw, 2, key.ProductID)
|
||||
raw = wire.AppendVarint(raw, 3, key.SaleGroup)
|
||||
raw = wire.AppendVarint(raw, 4, start)
|
||||
raw = wire.AppendVarint(raw, 5, end)
|
||||
raw = wire.AppendVarint(raw, 8, row.UID)
|
||||
products = append(products, raw)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("commerce: event shop %d has no products", row.ID)
|
||||
}
|
||||
}
|
||||
s.shopProducts, s.eventShopWindows, s.eventShopGroups = products, windows, groups
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEventShopPublishesAllSchedulesAndNativePurchaseReplay(t *testing.T) {
|
||||
s, eco, store := serviceFixture(t, 0)
|
||||
key := gamedata.CashProductKey{GroupID: 700, ProductID: 3}
|
||||
d := gamedata.CashProductDesign{Key: key, PriceType: 8, PriceID: 99, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 2, BulkOrderAvailability: 1}
|
||||
s.catalog.designs[key] = d
|
||||
s.shopWindows = map[gamedata.CashProductKey][2]uint64{}
|
||||
design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}}
|
||||
rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}}
|
||||
if err := s.AttachEventShopSchedules(design, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, now := range []int64{50, 150, 250} {
|
||||
s.SetClock(func() time.Time { return time.UnixMilli(now) }, 0)
|
||||
_, out, _, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
|
||||
raw, found, _ := wire.Bytes(out, 1)
|
||||
group, _, _ := wire.Varint(raw, 1)
|
||||
uid, _, _ := wire.Varint(raw, 8)
|
||||
if err != nil || !found || group != 700 || uid != 9 {
|
||||
t.Fatalf("calendar filtered at %d: %v", now, err)
|
||||
}
|
||||
if s.IsAvailable(key) != (now == 150) {
|
||||
t.Fatal("window authorization differs")
|
||||
}
|
||||
}
|
||||
req := wire.AppendVarint(nil, 1, 1)
|
||||
req = wire.AppendVarint(req, 3, 700)
|
||||
line := wire.AppendVarint(nil, 1, 3)
|
||||
line = wire.AppendVarint(line, 3, 2)
|
||||
req = wire.AppendBytes(req, 4, line)
|
||||
if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil || eco.calls != 0 {
|
||||
t.Fatal("expired purchase authorized")
|
||||
}
|
||||
s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0)
|
||||
_, out, handled, err := s.HandleSession("/CashShopBuy", req, "s")
|
||||
if err != nil || !handled || eco.calls != 1 {
|
||||
t.Fatal(err, handled, eco.calls)
|
||||
}
|
||||
if len(eco.costs) != 1 || eco.costs[0] != (gamedata.Reward{Type: 8, ID: 99, Count: 10}) || eco.rewards[0].Count != 2 {
|
||||
t.Fatal(eco)
|
||||
}
|
||||
// Reload service state, then replay the same request without another grant.
|
||||
s2, err := NewService(s.catalog, store, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2.shopWindows = map[gamedata.CashProductKey][2]uint64{}
|
||||
if err = s2.AttachEventShopSchedules(design, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2.SetClock(func() time.Time { return time.UnixMilli(250) }, 0)
|
||||
_, replay, _, err := s2.HandleSession("/CashShopBuy", req, "s")
|
||||
if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 {
|
||||
t.Fatal("replay failed", err)
|
||||
}
|
||||
req[1] = 2
|
||||
if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 1 {
|
||||
t.Fatal("purchase limit ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeEventShopSQLiteRollsBackDebitGrantAndPurchaseCount(t *testing.T) {
|
||||
fixture, _, _ := serviceFixture(t, 0)
|
||||
key := gamedata.CashProductKey{GroupID: 700, ProductID: 3}
|
||||
fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 1}
|
||||
design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}}
|
||||
rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}}
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = wallet.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open := func() *Service {
|
||||
t.Helper()
|
||||
s, err := NewService(fixture.catalog, repo, walletPurchaseEconomy{wallet, repo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.shopWindows = map[gamedata.CashProductKey][2]uint64{}
|
||||
if err = s.AttachEventShopSchedules(design, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0)
|
||||
return s
|
||||
}
|
||||
s := open()
|
||||
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant hook failed") })
|
||||
req := wire.AppendVarint(nil, 1, 1)
|
||||
req = wire.AppendVarint(req, 3, 700)
|
||||
line := wire.AppendVarint(nil, 1, 3)
|
||||
line = wire.AppendVarint(line, 3, 1)
|
||||
req = wire.AppendBytes(req, 4, line)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil {
|
||||
t.Fatal("hook failure swallowed")
|
||||
}
|
||||
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()
|
||||
wallet, err = player.OpenWallet(repo, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Snapshot().FreeJewelry != 10 {
|
||||
t.Fatal("debit survived rollback")
|
||||
}
|
||||
for _, name := range []string{"commerce", "commerce_test_rewards"} {
|
||||
raw, err := repo.Load(name)
|
||||
if err != nil || raw != nil {
|
||||
t.Fatal("grant or receipt survived rollback", name, err)
|
||||
}
|
||||
}
|
||||
s = open()
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Snapshot().FreeJewelry != 5 || !s.HasPurchased(key) {
|
||||
t.Fatal("retry did not persist")
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,9 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||
if err := shop.AttachShopSeed(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := shop.AttachEventShopSchedules(catalog, calendars.Events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graph, err := gamedata.LoadRewardGraph(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -110,6 +113,10 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatarRewards, err := gamedata.LoadAvatarRewardDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newEconomy := func() (*EntitlementEconomy, *player.Wallet) {
|
||||
store := stateio.NewMemory()
|
||||
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
@@ -139,6 +146,7 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||
}
|
||||
base.AttachPrestigeSkins(skins)
|
||||
base.AttachOwnedItemDesign(ownedDesign)
|
||||
base.AttachAvatarRewards(avatarRewards)
|
||||
ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -150,6 +158,141 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||
}
|
||||
return e, wallet
|
||||
}
|
||||
t.Run("event shop complete calendar and native purchase", func(t *testing.T) {
|
||||
e, wallet := newEconomy()
|
||||
shop, err := NewService(policy, e.store, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = shop.AttachShopSeed(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := append([]events.Schedule(nil), calendars.Events...)
|
||||
var current events.Schedule
|
||||
var lastEnd int64
|
||||
for _, row := range rows {
|
||||
if row.Type == 15 && row.ID == 71 {
|
||||
current = row
|
||||
if row.End > lastEnd {
|
||||
lastEnd = row.End
|
||||
}
|
||||
}
|
||||
}
|
||||
if current.UID == 0 {
|
||||
t.Fatal("real event shop 71 calendar missing")
|
||||
}
|
||||
future := events.Schedule{UID: 99999999, Type: 15, ID: 71, Start: lastEnd + 86400000, End: lastEnd + 2*86400000}
|
||||
rows = append(rows, future)
|
||||
if err = shop.AttachEventShopSchedules(catalog, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop.SetClock(func() time.Time { return time.UnixMilli(current.Start + 1) }, 0)
|
||||
_, info, handled, err := shop.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "audit")
|
||||
if err != nil || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := map[uint64]map[gamedata.CashProductKey]bool{}
|
||||
_ = wire.Walk(info, func(f wire.Field) error {
|
||||
if f.Number != 1 || f.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
uid, _, _ := wire.Varint(f.Value, 8)
|
||||
if uid == 0 {
|
||||
return nil
|
||||
}
|
||||
group, _, _ := wire.Varint(f.Value, 1)
|
||||
id, _, _ := wire.Varint(f.Value, 2)
|
||||
sale, _, _ := wire.Varint(f.Value, 3)
|
||||
if actual[uid] == nil {
|
||||
actual[uid] = map[gamedata.CashProductKey]bool{}
|
||||
}
|
||||
actual[uid][gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}] = true
|
||||
return nil
|
||||
})
|
||||
seenOld := false
|
||||
for _, row := range rows {
|
||||
if row.Type != 15 {
|
||||
continue
|
||||
}
|
||||
group := uint64(0)
|
||||
for _, design := range catalog.EventShops {
|
||||
if design.ID == row.ID {
|
||||
group = design.ProductGroupID
|
||||
}
|
||||
}
|
||||
if row.ID == 70 {
|
||||
seenOld = true
|
||||
}
|
||||
if row.ID == 71 && group != 900071 {
|
||||
t.Fatal("event shop 71 product group mismatch")
|
||||
}
|
||||
expected := 0
|
||||
for _, product := range catalog.Products {
|
||||
if product.Key.GroupID == group {
|
||||
expected++
|
||||
if !actual[row.UID][product.Key] {
|
||||
t.Fatalf("shop %d UID %d missing product %+v", row.ID, row.UID, product.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(actual[row.UID]) != expected {
|
||||
t.Fatalf("shop %d UID %d count=%d expected=%d", row.ID, row.UID, len(actual[row.UID]), expected)
|
||||
}
|
||||
}
|
||||
if !seenOld || len(actual[future.UID]) == 0 {
|
||||
t.Fatal("old or future event shop filtered")
|
||||
}
|
||||
fund := []gamedata.Reward{{Type: 8, ID: 2074, Count: 100}}
|
||||
if _, err = e.Apply("audit funds", nil, fund); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeWallet := wallet.Snapshot()
|
||||
countItem := func(id uint64) uint64 {
|
||||
var n uint64
|
||||
for _, item := range e.items.All() {
|
||||
if item.Type == 8 && item.ID == id {
|
||||
n += item.Count
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
beforeCoin, beforeReward := countItem(2074), countItem(710)
|
||||
request := wire.AppendVarint(nil, 1, 2)
|
||||
request = wire.AppendVarint(request, 3, 900071)
|
||||
for _, id := range []uint64{1, 5} {
|
||||
line := wire.AppendVarint(nil, 1, id)
|
||||
line = wire.AppendVarint(line, 3, 1)
|
||||
request = wire.AppendBytes(request, 4, line)
|
||||
}
|
||||
code, response, handled, err := shop.HandleSession("/CashShopBuy", request, "audit")
|
||||
if err != nil || !handled || code != 61 {
|
||||
t.Fatalf("native batch code=%d handled=%t err=%v", code, handled, err)
|
||||
}
|
||||
if wallet.Snapshot().FreeJewelry != beforeWallet.FreeJewelry-100 || countItem(2074) != beforeCoin-12 || countItem(710) != beforeReward+1 {
|
||||
t.Fatal("native batch debit or real reward missing", wallet.Snapshot(), e.items.All())
|
||||
}
|
||||
for _, id := range []uint64{1, 5} {
|
||||
found := false
|
||||
for _, raw := range shop.PurchaseCountDBInfos() {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
p, _, _ := wire.Varint(raw, 2)
|
||||
n, _, _ := wire.Varint(raw, 4)
|
||||
if g == 900071 && p == id && n == 1 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("native purchase count missing", id)
|
||||
}
|
||||
}
|
||||
balance := wallet.Snapshot()
|
||||
coins := countItem(2074)
|
||||
rewardCount := countItem(710)
|
||||
_, replay, _, err := shop.HandleSession("/CashShopBuy", request, "audit")
|
||||
if err != nil || !bytes.Equal(response, replay) || wallet.Snapshot() != balance || countItem(2074) != coins || countItem(710) != rewardCount {
|
||||
t.Fatal("native purchase replay duplicated", err)
|
||||
}
|
||||
})
|
||||
delegated := map[gamedata.CashProductKey]bool{}
|
||||
retired := map[gamedata.CashProductKey]bool{}
|
||||
for _, group := range groups {
|
||||
@@ -234,6 +377,15 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
||||
t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err)
|
||||
continue
|
||||
}
|
||||
_ = wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Number == 1 && f.Type == 2 {
|
||||
typ, _, _ := wire.Varint(f.Value, 3)
|
||||
if typ == 62 {
|
||||
t.Errorf("cash product %+v returned synthetic avatar set", p.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
balance := wallet.Snapshot()
|
||||
replay, err := e.Apply("installed-audit", costs, rewards)
|
||||
if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance {
|
||||
|
||||
@@ -41,18 +41,20 @@ type purchaseState struct {
|
||||
Counts map[string]purchaseCount `json:"counts"`
|
||||
}
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
catalog *Catalog
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
now func() time.Time
|
||||
resetSeconds int64
|
||||
delegate PurchaseDelegate
|
||||
hook PurchaseHook
|
||||
legacy CountProvider
|
||||
shopProducts [][]byte
|
||||
shopWindows map[gamedata.CashProductKey][2]uint64
|
||||
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
|
||||
mu sync.Mutex
|
||||
catalog *Catalog
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
now func() time.Time
|
||||
resetSeconds int64
|
||||
delegate PurchaseDelegate
|
||||
hook PurchaseHook
|
||||
legacy CountProvider
|
||||
shopProducts [][]byte
|
||||
shopWindows map[gamedata.CashProductKey][2]uint64
|
||||
eventShopWindows map[gamedata.CashProductKey][][2]uint64
|
||||
eventShopGroups map[uint64]bool
|
||||
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
|
||||
}
|
||||
|
||||
func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) {
|
||||
@@ -179,6 +181,15 @@ func (s *Service) AttachShopSeed(seed *readonly.Seed) error {
|
||||
return nil
|
||||
}
|
||||
func (s *Service) available(d gamedata.CashProductDesign) bool {
|
||||
if s.eventShopGroups[d.Key.GroupID] {
|
||||
now := uint64(s.now().UnixMilli())
|
||||
for _, w := range s.eventShopWindows[d.Key] {
|
||||
if w[0] <= now && now < w[1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
w, ok := s.shopWindows[d.Key]
|
||||
if !ok {
|
||||
return d.TimeLimitType == 0
|
||||
@@ -196,8 +207,8 @@ func (s *Service) shopInfo() []byte {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
id, _, _ := wire.Varint(raw, 2)
|
||||
sale, _, _ := wire.Varint(raw, 3)
|
||||
d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
|
||||
if ok && s.available(d) {
|
||||
_, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
|
||||
if ok {
|
||||
response = wire.AppendBytes(response, 1, raw)
|
||||
}
|
||||
}
|
||||
@@ -396,9 +407,13 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
|
||||
}
|
||||
}
|
||||
if native && !cash {
|
||||
return 0, nil, false, nil
|
||||
for _, line := range lines {
|
||||
if !s.eventShopGroups[line.Key.GroupID] {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if native {
|
||||
if native && cash {
|
||||
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
|
||||
}
|
||||
h := sha256.Sum256(request)
|
||||
@@ -436,9 +451,22 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
|
||||
if !s.available(d) {
|
||||
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
|
||||
}
|
||||
q, err := s.catalog.Quote(l.Key, l.Count)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
var q Product
|
||||
if d.PriceType == 1 {
|
||||
var err error
|
||||
q, err = s.catalog.Quote(l.Key, l.Count)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
} else {
|
||||
if d.PriceType == 0 || d.PriceCount == 0 {
|
||||
return 61, nil, true, fmt.Errorf("commerce: invalid native price")
|
||||
}
|
||||
cost, err := boundedProduct(d.PriceCount, l.Count)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
q = Product{Enabled: true, ItemType: d.PriceType, Cost: cost}
|
||||
}
|
||||
if !q.Enabled {
|
||||
return 61, nil, true, fmt.Errorf("commerce: product disabled")
|
||||
@@ -470,7 +498,10 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
|
||||
designs[i] = d
|
||||
quotes[i] = q
|
||||
}
|
||||
if e = validateAcceptedQuote(request, quotes); e != nil {
|
||||
if cash {
|
||||
e = validateAcceptedQuote(request, quotes)
|
||||
}
|
||||
if e != nil {
|
||||
return 61, nil, true, e
|
||||
}
|
||||
var bundle []byte
|
||||
@@ -479,24 +510,28 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
|
||||
operation := fmt.Sprintf("%s:%d", identity, i)
|
||||
var costs []gamedata.Reward
|
||||
if q.Cost > 0 {
|
||||
var typ uint64
|
||||
switch q.Currency {
|
||||
case "paid_diamonds":
|
||||
typ = 2
|
||||
case "diamonds":
|
||||
typ = 3
|
||||
case "gold":
|
||||
typ = 4
|
||||
default:
|
||||
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
|
||||
if d.PriceType != 1 {
|
||||
costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}}
|
||||
} else {
|
||||
var typ uint64
|
||||
switch q.Currency {
|
||||
case "paid_diamonds":
|
||||
typ = 2
|
||||
case "diamonds":
|
||||
typ = 3
|
||||
case "gold":
|
||||
typ = 4
|
||||
default:
|
||||
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
|
||||
}
|
||||
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
|
||||
}
|
||||
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
|
||||
}
|
||||
var special []byte
|
||||
handled := false
|
||||
// Delegate must only select known special products. Parent account transaction
|
||||
// guarantees its grant and the subsequent debit commit together.
|
||||
if s.delegate != nil {
|
||||
if s.delegate != nil && d.PriceType == 1 {
|
||||
special, handled, e = s.delegate(l.Key, request)
|
||||
if e != nil {
|
||||
return 61, nil, true, e
|
||||
|
||||
@@ -195,7 +195,7 @@ func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
|
||||
t.Fatal("stale amount accepted")
|
||||
}
|
||||
}
|
||||
func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
|
||||
func TestShopPublishesAllWindowsAndAuthorizesActiveProducts(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 0)
|
||||
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
s.SetClock(func() time.Time { return now }, 0)
|
||||
@@ -221,7 +221,7 @@ func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if products != 0 || eco.calls != 1 {
|
||||
if products != 1 || eco.calls != 1 {
|
||||
t.Fatal(products, eco.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package eventactions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type MiniContentResolver interface {
|
||||
ResolveMiniContentUID(uint64) (uint64, uint64, int64, int64, error)
|
||||
ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error)
|
||||
}
|
||||
|
||||
func (s *Service) AttachMiniContent(resolver MiniContentResolver, design *gamedata.MiniContentDesign) error {
|
||||
if resolver == nil || design == nil {
|
||||
return errors.New("eventactions: mini content dependencies missing")
|
||||
}
|
||||
routes, err := resolver.ListMiniContentRoutes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.ContentType == 14 {
|
||||
ids := design.Groups[route.ContentID]
|
||||
if len(ids) == 0 {
|
||||
return fmt.Errorf("eventactions: published mini story group %d missing", route.ContentID)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, exists := design.Stories[id]; !exists {
|
||||
return fmt.Errorf("eventactions: published daily story %d missing", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if route.ContentType == 13 {
|
||||
found := false
|
||||
for _, row := range s.design.Tables["NpcQuizTable"] {
|
||||
if row.V(1) == route.ContentID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("eventactions: published NPC quiz group %d missing", route.ContentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.miniContent, s.miniDesign = resolver, design
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveQuiz(uid uint64) (events.Schedule, error) {
|
||||
if s.miniContent != nil {
|
||||
typ, id, start, end, err := s.miniContent.ResolveMiniContentUID(uid)
|
||||
if err == nil {
|
||||
if typ != 13 {
|
||||
return events.Schedule{}, errors.New("eventactions: content UID is not a quiz")
|
||||
}
|
||||
return events.Schedule{UID: uid, ID: id, Start: start, End: end}, nil
|
||||
}
|
||||
}
|
||||
return s.registry.Resolve(uid)
|
||||
}
|
||||
|
||||
func (s *Service) dailyStory(path string, b []byte, identity string) ([]byte, error) {
|
||||
if path == "/DailyStoryInfo" {
|
||||
var ids []uint64
|
||||
for k, claimed := range s.state.Claims {
|
||||
if claimed && strings.HasPrefix(k, "daily-story:") {
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(k, "daily-story:"), 10, 64)
|
||||
if err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
var out []byte
|
||||
for _, id := range ids {
|
||||
out = wire.AppendVarint(out, 1, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
id := val(b, 2)
|
||||
if id == 0 || s.miniContent == nil || s.miniDesign == nil {
|
||||
return nil, errors.New("eventactions: daily story unavailable")
|
||||
}
|
||||
reward, ok := s.miniDesign.Stories[id]
|
||||
if !ok {
|
||||
return nil, errors.New("eventactions: unknown daily story")
|
||||
}
|
||||
ck := fmt.Sprintf("daily-story:%d", id)
|
||||
if s.state.Claims[ck] {
|
||||
return wire.AppendVarint(wire.AppendBytes(nil, 1, nil), 2, id), nil
|
||||
}
|
||||
routes, err := s.miniContent.ListMiniContentRoutes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
available := false
|
||||
now := s.now().UnixMilli()
|
||||
for _, route := range routes {
|
||||
if route.ContentType != 14 || now < route.Start || now > route.End {
|
||||
continue
|
||||
}
|
||||
for _, story := range s.miniDesign.Groups[route.ContentID] {
|
||||
if story == id {
|
||||
available = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !available {
|
||||
return nil, errors.New("eventactions: daily story event inactive")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if reward.Count > 0 {
|
||||
rewards = append(rewards, reward)
|
||||
}
|
||||
bundle, err := s.economy.Apply(identity, nil, rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.state.Claims[ck] = true
|
||||
return wire.AppendVarint(wire.AppendBytes(nil, 1, bundle), 2, id), nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package eventactions
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type miniRoutes []gamedata.MiniContentRoute
|
||||
|
||||
func (r miniRoutes) ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error) { return r, nil }
|
||||
func (r miniRoutes) ResolveMiniContentUID(uid uint64) (uint64, uint64, int64, int64, error) {
|
||||
for _, v := range r {
|
||||
if v.UID == uid {
|
||||
return v.ContentType, v.ContentID, v.Start, v.End, nil
|
||||
}
|
||||
}
|
||||
return 0, 0, 0, 0, errors.New("unknown content UID")
|
||||
}
|
||||
|
||||
func TestMiniContentStoryAndQuizCompleteReplayAndWindows(t *testing.T) {
|
||||
s, e, store := setup(t)
|
||||
now := s.now().UnixMilli()
|
||||
routes := miniRoutes{{UID: 10000031, ContentType: 14, ContentID: 3, Start: now - 86400000, End: now + 86400000}, {UID: 10000032, ContentType: 13, ContentID: 3, Start: now - 86400000, End: now + 86400000}}
|
||||
d := &gamedata.MiniContentDesign{Stories: map[uint64]gamedata.Reward{99: {Type: 4, Count: 10}, 100: {Type: 4, Count: 20}}, Groups: map[uint64][]uint64{3: {99}}}
|
||||
s.design.Tables["NpcQuizTable"] = []gamedata.EventActionRow{{Values: map[int]uint64{1: 3, 2: 1, 8: 0}, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}
|
||||
if err := s.AttachMiniContent(routes, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.design.Tables["NpcQuizTable"] = []gamedata.EventActionRow{{Values: map[int]uint64{1: 3, 2: 1, 8: 0}, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}
|
||||
story := wire.AppendVarint(req(1), 2, 99)
|
||||
code, reply, handled, err := s.Handle("/DailyStoryClear", story)
|
||||
if err != nil || code != 539 || !handled || val(reply, 2) != 99 {
|
||||
t.Fatal("story clear", code, err)
|
||||
}
|
||||
if e.calls != 1 {
|
||||
t.Fatal("story reward omitted")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/DailyStoryClear", wire.AppendVarint(req(2), 2, 100)); err == nil {
|
||||
t.Fatal("unpublished story claimed")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/DailyStoryClear", wire.AppendVarint(req(3), 2, 99)); err != nil || e.calls != 1 {
|
||||
t.Fatal("story repeated reward", err)
|
||||
}
|
||||
_, info, _, err := s.Handle("/DailyStoryInfo", req(4))
|
||||
if err != nil || val(info, 1) != 99 {
|
||||
t.Fatal("story completion info", err)
|
||||
}
|
||||
quiz := wire.AppendVarint(req(5), 2, 10000032)
|
||||
quiz = wire.AppendVarint(quiz, 3, 3)
|
||||
quiz = wire.AppendVarint(quiz, 4, 1)
|
||||
_, quizReply, _, err := s.Handle("/NpcQuizClear", quiz)
|
||||
if err != nil || e.calls != 2 {
|
||||
t.Fatal("dedicated quiz clear", err)
|
||||
}
|
||||
_, quizInfo, _, err := s.Handle("/NpcQuizInfo", wire.AppendVarint(req(6), 2, 10000032))
|
||||
if err != nil || len(quizInfo) == 0 {
|
||||
t.Fatal("quiz completion info", err)
|
||||
}
|
||||
reopened, err := Open(store, s.design, s.registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.BeginSession("test")
|
||||
reopened.AttachMiniContent(routes, d)
|
||||
_, replay, _, err := reopened.Handle("/NpcQuizClear", quiz)
|
||||
if err != nil || !bytes.Equal(replay, quizReply) || e.calls != 2 {
|
||||
t.Fatal("quiz restart replay", err)
|
||||
}
|
||||
_, replay, _, err = reopened.Handle("/DailyStoryClear", story)
|
||||
if err != nil || !bytes.Equal(replay, reply) || e.calls != 2 {
|
||||
t.Fatal("story restart replay", err)
|
||||
}
|
||||
reopened.state.Claims = map[string]bool{}
|
||||
routes[0].End = now - 1
|
||||
routes[1].End = now - 1
|
||||
if _, _, _, err := reopened.Handle("/DailyStoryClear", wire.AppendVarint(req(7), 2, 99)); err == nil {
|
||||
t.Fatal("expired story claimed")
|
||||
}
|
||||
quiz = wire.AppendVarint(req(8), 2, 10000032)
|
||||
quiz = wire.AppendVarint(quiz, 3, 3)
|
||||
quiz = wire.AppendVarint(quiz, 4, 1)
|
||||
if _, _, _, err := reopened.Handle("/NpcQuizClear", quiz); err == nil {
|
||||
t.Fatal("expired quiz claimed")
|
||||
}
|
||||
if e.calls != 2 {
|
||||
t.Fatal("inactive content changed reward ledger")
|
||||
}
|
||||
}
|
||||
@@ -50,19 +50,21 @@ type snapshot struct {
|
||||
Receipts map[string]receipt
|
||||
}
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design *gamedata.EventActionsDesign
|
||||
registry events.Resolver
|
||||
economy Economy
|
||||
state snapshot
|
||||
now func() time.Time
|
||||
session string
|
||||
friendship func(uint64) uint64
|
||||
chargeInfo func() ([]byte, error)
|
||||
owned func(uint64, uint64) bool
|
||||
progress func(uint64, uint64, uint64) error
|
||||
voteTotals func(uint64, uint64) (map[uint64]uint64, error)
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design *gamedata.EventActionsDesign
|
||||
registry events.Resolver
|
||||
economy Economy
|
||||
state snapshot
|
||||
now func() time.Time
|
||||
session string
|
||||
friendship func(uint64) uint64
|
||||
chargeInfo func() ([]byte, error)
|
||||
owned func(uint64, uint64) bool
|
||||
progress func(uint64, uint64, uint64) error
|
||||
voteTotals func(uint64, uint64) (map[uint64]uint64, error)
|
||||
miniContent MiniContentResolver
|
||||
miniDesign *gamedata.MiniContentDesign
|
||||
}
|
||||
|
||||
func Open(store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) {
|
||||
@@ -193,6 +195,12 @@ func (s *Service) Handle(path string, b []byte) (int, []byte, bool, error) {
|
||||
if path == "/CafeteriaEventNpcInteractionReward" {
|
||||
code, ok = 414, true
|
||||
}
|
||||
if path == "/DailyStoryInfo" {
|
||||
code, ok = 538, true
|
||||
}
|
||||
if path == "/DailyStoryClear" {
|
||||
code, ok = 539, true
|
||||
}
|
||||
if !ok {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
@@ -261,6 +269,8 @@ func (s *Service) spawnWire(p *spawn) []byte {
|
||||
}
|
||||
func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
|
||||
switch path {
|
||||
case "/DailyStoryInfo", "/DailyStoryClear":
|
||||
return s.dailyStory(path, b, identity)
|
||||
case "/CafeteriaEventNpcInteractionReward":
|
||||
return s.cafeteriaReward(b, identity)
|
||||
case "/ChargeCostInfo":
|
||||
@@ -436,7 +446,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
return out, nil
|
||||
case "/NpcQuizInfo":
|
||||
uid := val(b, 2)
|
||||
v, e := s.registry.Resolve(uid)
|
||||
v, e := s.resolveQuiz(uid)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -455,7 +465,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
return out, nil
|
||||
case "/NpcQuizClear":
|
||||
uid, g, id := val(b, 2), val(b, 3), val(b, 4)
|
||||
v, e := s.registry.Resolve(uid)
|
||||
v, e := s.resolveQuiz(uid)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
@@ -21,6 +21,84 @@ type miniHubSchedule struct {
|
||||
Bindings []miniHubBinding
|
||||
}
|
||||
|
||||
// ListMiniContentRoutes exposes the same validated hub-scoped calendar used
|
||||
// for presentation, including routes whose play period has already ended.
|
||||
func (s *Service) ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error) {
|
||||
body, err := s.miniEventHubs(wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var routes []gamedata.MiniContentRoute
|
||||
seen := map[uint64]bool{}
|
||||
err = wire.Walk(body, func(hub wire.Field) error {
|
||||
if hub.Number != 1 || hub.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
return wire.Walk(hub.Value, func(slot wire.Field) error {
|
||||
if slot.Number != 6 || slot.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
typ := num(slot.Value, 2)
|
||||
if typ != 13 && typ != 14 {
|
||||
return nil
|
||||
}
|
||||
uid := num(slot.Value, 4)
|
||||
if seen[uid] {
|
||||
return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid)
|
||||
}
|
||||
seen[uid] = true
|
||||
routes = append(routes, gamedata.MiniContentRoute{UID: uid, ContentType: typ, ContentID: num(slot.Value, 3), Start: int64(num(slot.Value, 5)), End: int64(num(slot.Value, 6))})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i].UID < routes[j].UID })
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// ResolveMiniContentUID resolves hub-scoped story/quiz identities without
|
||||
// manufacturing EventScheduleInfo rows. Callers enforce the returned window.
|
||||
func (s *Service) ResolveMiniContentUID(uid uint64) (contentType, contentID uint64, start, end int64, err error) {
|
||||
if uid == 0 {
|
||||
return 0, 0, 0, 0, fmt.Errorf("eventplay: missing mini content UID")
|
||||
}
|
||||
body, err := s.miniEventHubs(wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, err
|
||||
}
|
||||
found := false
|
||||
err = wire.Walk(body, func(hub wire.Field) error {
|
||||
if hub.Number != 1 || hub.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
return wire.Walk(hub.Value, func(slot wire.Field) error {
|
||||
if slot.Number != 6 || slot.Type != 2 || num(slot.Value, 4) != uid {
|
||||
return nil
|
||||
}
|
||||
typ := num(slot.Value, 2)
|
||||
if typ != 13 && typ != 14 {
|
||||
return nil
|
||||
}
|
||||
if found {
|
||||
return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid)
|
||||
}
|
||||
found = true
|
||||
contentType, contentID = typ, num(slot.Value, 3)
|
||||
start, end = int64(num(slot.Value, 5)), int64(num(slot.Value, 6))
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, err
|
||||
}
|
||||
if !found {
|
||||
return 0, 0, 0, 0, fmt.Errorf("eventplay: mini content UID %d unavailable", uid)
|
||||
}
|
||||
return contentType, contentID, start, end, nil
|
||||
}
|
||||
|
||||
func (s *Service) miniEventHubs(req []byte) ([]byte, error) {
|
||||
var hubs []miniHubSchedule
|
||||
if s.hubCalendars == nil {
|
||||
@@ -99,6 +177,39 @@ func (s *Service) miniEventHubs(req []byte) ([]byte, error) {
|
||||
if len(bound) == 0 {
|
||||
continue
|
||||
}
|
||||
// Mini hub stories and NPC quizzes have hub-scoped UIDs, rather
|
||||
// than entries in EventScheduleInfo. Their explicit binding and
|
||||
// static EndDateType supply the calendar; never invent a global
|
||||
// event type for these separate client protocols.
|
||||
if contentType == 13 || contentType == 14 {
|
||||
if len(bound) != 1 || bound[0] {
|
||||
return nil, fmt.Errorf("eventplay: mini hub %d slot %d requires one nonzero hub-scoped UID", hub.UID, num(slot, 10))
|
||||
}
|
||||
var uid uint64
|
||||
for id := range bound {
|
||||
uid = id
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.UID == uid {
|
||||
return nil, fmt.Errorf("eventplay: mini hub %d slot %d hub-scoped UID collides with global schedule", hub.UID, num(slot, 10))
|
||||
}
|
||||
}
|
||||
end := hub.End
|
||||
if num(slot, 4) == 0 {
|
||||
end = hub.PlayEnd
|
||||
}
|
||||
if end <= hub.Start {
|
||||
continue
|
||||
}
|
||||
b := wire.AppendVarint(nil, 1, num(slot, 10))
|
||||
b = wire.AppendVarint(b, 2, contentType)
|
||||
b = wire.AppendVarint(b, 3, contentID)
|
||||
b = wire.AppendVarint(b, 4, uid)
|
||||
b = wire.AppendVarint(b, 5, uint64(hub.Start))
|
||||
b = wire.AppendVarint(b, 6, uint64(end))
|
||||
v = wire.AppendBytes(v, 6, b)
|
||||
continue
|
||||
}
|
||||
eventType, subID, known := s.miniSlotSchedule(contentType, contentID)
|
||||
if !known {
|
||||
return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type %d unsupported", hub.UID, num(slot, 10), contentType)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -165,7 +166,7 @@ func TestNormalAndMiniHubCalendarsUseSeparatePrefabs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestMiniHubExplicitUnsupportedQuizIsRejected(t *testing.T) {
|
||||
func TestMiniHubExplicitQuizUsesHubScopedCalendar(t *testing.T) {
|
||||
s, _ := makeService(t)
|
||||
hub := wire.AppendVarint(nil, 14, 1003)
|
||||
hub = wire.AppendVarint(hub, 13, 1)
|
||||
@@ -175,8 +176,44 @@ func TestMiniHubExplicitUnsupportedQuizIsRejected(t *testing.T) {
|
||||
field.Fields[5].Fields[0] = hubField(1, 11)
|
||||
field.Fields[5].Fields[1] = hubField(2, 13)
|
||||
attachMiniCalendar(s, field)
|
||||
if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err == nil {
|
||||
t.Fatal("unsupported quiz enabled with invented UID")
|
||||
if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
typ, id, start, end, err := s.ResolveMiniContentUID(40)
|
||||
if err != nil || typ != 13 || id != 3 || start != 100 || end != 200 {
|
||||
t.Fatalf("wrong quiz calendar: %d %d %d %d %v", typ, id, start, end, err)
|
||||
}
|
||||
if _, _, _, _, err := s.ResolveMiniContentUID(41); err == nil {
|
||||
t.Fatal("unbound UID accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniHubStoryAndQuizRequireUniqueNonGlobalBindings(t *testing.T) {
|
||||
for _, typ := range []uint64{13, 14} {
|
||||
t.Run(fmt.Sprint(typ), func(t *testing.T) {
|
||||
s, _ := makeService(t)
|
||||
hub := wire.AppendVarint(wire.AppendVarint(nil, 14, 1003), 13, 1)
|
||||
s.design.Tables["PackEventHubTable"] = [][]byte{hub}
|
||||
s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 2, 11, typ, 3, 0)}
|
||||
field := miniCalendar(1, 1003, 100, 200, 300, 40)
|
||||
field.Fields[5].Fields[0], field.Fields[5].Fields[1] = hubField(1, 11), hubField(2, typ)
|
||||
attachMiniCalendar(s, field)
|
||||
if _, _, _, _, err := s.ResolveMiniContentUID(40); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes, err := s.ListMiniContentRoutes()
|
||||
if err != nil || len(routes) != 1 || routes[0].UID != 40 || routes[0].ContentType != typ || routes[0].ContentID != 3 || routes[0].Start != 100 || routes[0].End != 200 {
|
||||
t.Fatalf("route list differs from hub projection: %+v %v", routes, err)
|
||||
}
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{{UID: 40, Type: 1, ID: 3, Start: 100, End: 300}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.registry = registry
|
||||
if _, _, _, _, err := s.ResolveMiniContentUID(40); err == nil {
|
||||
t.Fatal("global UID collision accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestPublicHubsWithoutProjectCalendarDoNotGuessSchedules(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdditionalRewardCurrenciesAndRankChangeItemPersistAndReplay(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
eco, items, _ := economyFixture(t, repo, &economyGraph{})
|
||||
eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{69: {1: true}})
|
||||
rewards := []gamedata.Reward{{Type: 38, Count: 1}, {Type: 39, Count: 20}, {Type: 40, Count: 30}, {Type: 54, Count: 40}, {Type: 70, Count: 50}, {Type: 69, ID: 1, Count: 2}}
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := eco.Apply("new reward types", nil, rewards)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertBalances := func(e *Economy) {
|
||||
t.Helper()
|
||||
got, err := e.AdditionalCurrencies()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for field, want := range map[int]uint64{42: 1, 44: 20, 45: 30, 61: 40, 71: 50} {
|
||||
if got[field] != want {
|
||||
t.Fatalf("UserDB field%d=%d want%d", field, got[field], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertBalances(eco)
|
||||
if len(items.All()) != 1 || items.All()[0].Type != 69 {
|
||||
t.Fatal("currencies stored as inventory", items.All())
|
||||
}
|
||||
types := map[uint64]bool{}
|
||||
_ = wire.Walk(first, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
typ, _, _ := wire.Varint(f.Value, 3)
|
||||
types[typ] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, reward := range rewards {
|
||||
if !types[reward.Type] {
|
||||
t.Fatal("native currency/item receipt missing", reward.Type)
|
||||
}
|
||||
}
|
||||
eco, items, _ = economyFixture(t, repo, &economyGraph{})
|
||||
eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{69: {1: true}})
|
||||
replay, err := eco.Apply("new reward types", nil, rewards)
|
||||
if err != nil || !bytes.Equal(first, replay) || len(items.All()) != 1 {
|
||||
t.Fatal("restart replay duplicated", err)
|
||||
}
|
||||
assertBalances(eco)
|
||||
if _, err = eco.Apply("invalid item", nil, []gamedata.Reward{{Type: 54, Count: 100}, {Type: 69, ID: 999, Count: 1}}); err == nil {
|
||||
t.Fatal("unknown rank change design accepted")
|
||||
}
|
||||
assertBalances(eco)
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = eco.Apply("rollback", nil, []gamedata.Reward{{Type: 39, Count: 100}, {Type: 70, Count: 100}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = op.Rollback()
|
||||
repo.Close()
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
eco, _, _ = economyFixture(t, repo, &economyGraph{})
|
||||
assertBalances(eco)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func avatarDesign() *gamedata.AvatarRewardDesign {
|
||||
return &gamedata.AvatarRewardDesign{Sets: map[uint64][]gamedata.BattleReward{1: {{Type: 49, ID: 10001, Count: 1}, {Type: 50, ID: 7, Count: 1}, {Type: 61, ID: 2, Count: 1}}}, Items: map[uint64]map[uint64]bool{49: {10001: true}, 50: {7: true}, 61: {2: true}}}
|
||||
}
|
||||
func TestAvatarSetBatchPersistsMembersAndRollsBackWithAccount(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eco, _, _ := economyFixture(t, repo, &economyGraph{})
|
||||
eco.AttachAvatarRewards(avatarDesign())
|
||||
rewards := []gamedata.Reward{{Type: 62, ID: 1, Count: 1}, {Type: 4, Count: 10}, {Type: 8, ID: 1000, Count: 2}}
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = eco.Apply("mission", nil, rewards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = op.Rollback()
|
||||
repo.Close()
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
eco, items, wallet := economyFixture(t, repo, &economyGraph{})
|
||||
eco.AttachAvatarRewards(avatarDesign())
|
||||
if len(items.All()) != 0 || wallet.Snapshot().Gold != 5 {
|
||||
t.Fatal("rollback retained avatar grant")
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply, err := eco.Apply("mission", nil, rewards)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items.All()) != 4 || wallet.Snapshot().Gold != 15 {
|
||||
t.Fatal("mixed grant did not persist", items.All())
|
||||
}
|
||||
for _, item := range items.All() {
|
||||
if item.Type == 62 {
|
||||
t.Fatal("synthetic set stored")
|
||||
}
|
||||
}
|
||||
types := map[uint64]bool{}
|
||||
_ = wire.Walk(reply, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
typ, _, _ := wire.Varint(f.Value, 3)
|
||||
types[typ] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for _, typ := range []uint64{49, 50, 61, 4, 8} {
|
||||
if !types[typ] {
|
||||
t.Fatal("native member missing", typ)
|
||||
}
|
||||
}
|
||||
eco, items, wallet = economyFixture(t, repo, &economyGraph{})
|
||||
eco.AttachAvatarRewards(avatarDesign())
|
||||
replay, err := eco.Apply("mission", nil, rewards)
|
||||
if err != nil || !bytes.Equal(reply, replay) || len(items.All()) != 4 || wallet.Snapshot().Gold != 15 {
|
||||
t.Fatal("restart replay duplicated", err)
|
||||
}
|
||||
_, avatar, _, err := eco.OwnedItemInfo("/AvatarInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
_ = wire.Walk(avatar, func(f wire.Field) error {
|
||||
if f.Number == 2 {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if count != 3 {
|
||||
t.Fatal("relogin lost avatar ownership", count)
|
||||
}
|
||||
before := wallet.Snapshot().Gold
|
||||
if _, err = eco.Apply("unknown", nil, []gamedata.Reward{{Type: 4, Count: 50}, {Type: 62, ID: 999, Count: 1}}); err == nil || wallet.Snapshot().Gold != before || len(items.All()) != 4 {
|
||||
t.Fatal("unknown set partially granted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
type buffRewardState struct {
|
||||
Counts map[uint64]uint64
|
||||
Receipts map[string]string
|
||||
}
|
||||
type BuffRewards struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design map[uint64]gamedata.PictorialBuffStat
|
||||
}
|
||||
|
||||
func OpenBuffRewards(store stateio.Store, design map[uint64]gamedata.PictorialBuffStat) (*BuffRewards, error) {
|
||||
if store == nil || len(design) == 0 {
|
||||
return nil, fmt.Errorf("events: buff reward dependencies unavailable")
|
||||
}
|
||||
s := &BuffRewards{store: store, design: design}
|
||||
_, err := s.load()
|
||||
return s, err
|
||||
}
|
||||
func (s *BuffRewards) load() (buffRewardState, error) {
|
||||
v := buffRewardState{Counts: map[uint64]uint64{}, Receipts: map[string]string{}}
|
||||
b, err := s.store.Load("buff_item_rewards")
|
||||
if err != nil || b == nil {
|
||||
return v, err
|
||||
}
|
||||
if err := stateio.RequireExactJSONObject(b, "Counts", "Receipts"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Counts == nil || v.Receipts == nil {
|
||||
return v, fmt.Errorf("events: invalid buff reward state")
|
||||
}
|
||||
for id, count := range v.Counts {
|
||||
if _, ok := s.design[id]; !ok || count == 0 || count > math.MaxInt32 {
|
||||
return v, fmt.Errorf("events: invalid permanent buff ownership")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *BuffRewards) Validate(rewards []gamedata.Reward) error {
|
||||
for _, r := range rewards {
|
||||
if _, ok := s.design[r.ID]; !ok || r.Type != 63 || r.Count == 0 || r.Count > math.MaxInt32 {
|
||||
return fmt.Errorf("events: unknown or invalid buff reward %d:%d:%d", r.Type, r.ID, r.Count)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *BuffRewards) GrantOnce(identity string, rewards []gamedata.Reward) error {
|
||||
if identity == "" {
|
||||
return fmt.Errorf("events: empty buff reward identity")
|
||||
}
|
||||
if err := s.Validate(rewards); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(rewards)
|
||||
sum := sha256.Sum256(raw)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
if previous, ok := v.Receipts[identity]; ok {
|
||||
if previous != digest {
|
||||
return fmt.Errorf("events: buff reward identity reused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, r := range rewards {
|
||||
if v.Counts[r.ID] > math.MaxInt32-r.Count {
|
||||
return fmt.Errorf("events: permanent buff reward overflow")
|
||||
}
|
||||
v.Counts[r.ID] += r.Count
|
||||
}
|
||||
v.Receipts[identity] = digest
|
||||
raw, err = json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Save("buff_item_rewards", raw)
|
||||
}
|
||||
func (s *BuffRewards) SnapshotBuffs() ([]gamedata.PictorialBuffStat, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type key struct{ category, stat uint64 }
|
||||
totals := map[key]float64{}
|
||||
for id, count := range v.Counts {
|
||||
d := s.design[id]
|
||||
totals[key{d.Category, d.StatType}] += d.Value * float64(count)
|
||||
}
|
||||
var out []gamedata.PictorialBuffStat
|
||||
for k, value := range totals {
|
||||
out = append(out, gamedata.PictorialBuffStat{Category: k.category, StatType: k.stat, Value: math.Round(value*1e8) / 1e8})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Category != out[j].Category {
|
||||
return out[i].Category < out[j].Category
|
||||
}
|
||||
return out[i].StatType < out[j].StatType
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
func (e *Economy) AttachBuffRewards(service *BuffRewards) { e.buffRewards = service }
|
||||
@@ -0,0 +1,132 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermanentBuffRewardOwnershipAndRestartReplay(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}}
|
||||
s, err := OpenBuffRewards(store, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reward := []gamedata.Reward{{Type: 63, ID: 1, Count: 1}}
|
||||
if err := s.GrantOnce("mission:1", reward); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err = OpenBuffRewards(store, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.GrantOnce("mission:1", reward); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffs, err := s.SnapshotBuffs()
|
||||
if err != nil || len(buffs) != 1 || buffs[0].Value != 0.02 || buffs[0].Category != 1 {
|
||||
t.Fatal("buff missing or doubled", buffs, err)
|
||||
}
|
||||
if err := s.GrantOnce("mission:1", []gamedata.Reward{{Type: 63, ID: 1, Count: 2}}); err == nil {
|
||||
t.Fatal("identity definition changed")
|
||||
}
|
||||
if err := s.GrantOnce("unknown", []gamedata.Reward{{Type: 63, ID: 999, Count: 1}}); err == nil {
|
||||
t.Fatal("unknown buff granted")
|
||||
}
|
||||
if err := s.GrantOnce("mission:2", reward); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffs, err = s.SnapshotBuffs()
|
||||
if err != nil || buffs[0].Value != 0.04 {
|
||||
t.Fatal("distinct permanent rewards not accumulated", buffs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentBuffAndCurrencyAccountRollback(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}}
|
||||
eco, _, _ := economyFixture(t, repo, &economyGraph{})
|
||||
buffs, err := OpenBuffRewards(repo, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eco.AttachBuffRewards(buffs)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = eco.Apply("buff batch", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}, {Type: 4, Count: 100}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = op.Rollback()
|
||||
repo.Close()
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
eco, _, wallet := economyFixture(t, repo, &economyGraph{})
|
||||
buffs, err = OpenBuffRewards(repo, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats, err := buffs.SnapshotBuffs()
|
||||
if err != nil || len(stats) != 0 || wallet.Snapshot().Gold != 5 {
|
||||
t.Fatal("rollback retained buff or currency", stats, err)
|
||||
}
|
||||
eco.AttachBuffRewards(buffs)
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = eco.Apply("buff batch", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}, {Type: 4, Count: 100}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats, err = buffs.SnapshotBuffs()
|
||||
if err != nil || len(stats) != 1 || stats[0].Value != 0.02 || wallet.Snapshot().Gold != 105 {
|
||||
t.Fatal("retry did not persist", stats, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEconomyBuffRewardChangesOwnedStatsWithoutInventory(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
eco, items, _ := economyFixture(t, store, &economyGraph{})
|
||||
buffs, err := OpenBuffRewards(store, map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eco.AttachBuffRewards(buffs)
|
||||
bundle, err := eco.Apply("mission:buff", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items.All()) != 0 {
|
||||
t.Fatal("buff became fake inventory item")
|
||||
}
|
||||
if _, present, _ := wire.Bytes(bundle, 1); present {
|
||||
t.Fatal("buff encoded as native inventory grant")
|
||||
}
|
||||
if _, present, _ := wire.Bytes(bundle, 6); !present {
|
||||
t.Fatal("buff reward view missing")
|
||||
}
|
||||
replay, err := eco.Apply("mission:buff", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}})
|
||||
if err != nil || !bytes.Equal(bundle, replay) {
|
||||
t.Fatal("economy buff replay changed", err)
|
||||
}
|
||||
stats, err := buffs.SnapshotBuffs()
|
||||
if err != nil || len(stats) != 1 || stats[0].Value != 0.02 {
|
||||
t.Fatal("permanent buff repeated or missing", stats, err)
|
||||
}
|
||||
}
|
||||
@@ -38,22 +38,24 @@ type economySnapshot struct {
|
||||
// Economy dispatches verified static rewards to their owning domains. It is
|
||||
// called inside the same account transaction as the gameplay operation.
|
||||
type Economy struct {
|
||||
ownedDesign map[uint64]map[uint64]bool
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
items *player.Inventory
|
||||
wallet *player.Wallet
|
||||
collection *player.CollectionStore
|
||||
equipment *player.EquipmentInventory
|
||||
costumes player.CostumeDesignSource
|
||||
options equipmentRoller
|
||||
graph rewardResolver
|
||||
hunting HuntingAP
|
||||
prestige map[uint64]uint64
|
||||
apCaps map[uint64]uint64
|
||||
resetSeconds int64
|
||||
now func() time.Time
|
||||
initial map[uint64]uint64
|
||||
ownedDesign map[uint64]map[uint64]bool
|
||||
avatarRewards *gamedata.AvatarRewardDesign
|
||||
buffRewards *BuffRewards
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
items *player.Inventory
|
||||
wallet *player.Wallet
|
||||
collection *player.CollectionStore
|
||||
equipment *player.EquipmentInventory
|
||||
costumes player.CostumeDesignSource
|
||||
options equipmentRoller
|
||||
graph rewardResolver
|
||||
hunting HuntingAP
|
||||
prestige map[uint64]uint64
|
||||
apCaps map[uint64]uint64
|
||||
resetSeconds int64
|
||||
now func() time.Time
|
||||
initial map[uint64]uint64
|
||||
}
|
||||
|
||||
func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) {
|
||||
@@ -73,7 +75,9 @@ func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wal
|
||||
|
||||
// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These
|
||||
// balances use a gameplay entry, leaving the frozen wallet schema unchanged.
|
||||
var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 43: 48, 44: 54, 60: 60}
|
||||
var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 38: 42, 39: 44, 40: 45, 43: 48, 44: 54, 54: 61, 60: 60, 70: 71}
|
||||
|
||||
func (e *Economy) AttachAvatarRewards(d *gamedata.AvatarRewardDesign) { e.avatarRewards = d }
|
||||
|
||||
func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h }
|
||||
func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) {
|
||||
@@ -134,7 +138,7 @@ func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return
|
||||
func apCurrency(t uint64) bool { return t == 21 || t == 23 }
|
||||
func inventoryType(t uint64) bool {
|
||||
switch t {
|
||||
case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49:
|
||||
case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49, 50, 61, 69:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -275,7 +279,11 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var walletRewards, apRewards []gamedata.Reward
|
||||
expanded, err = e.avatarRewards.Expand(expanded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var walletRewards, apRewards, buffItems []gamedata.Reward
|
||||
var itemRewards []gamedata.BattleReward
|
||||
var costumes []uint64
|
||||
var equips []player.Equipment
|
||||
@@ -303,6 +311,12 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
|
||||
}
|
||||
s.Balances[r.Type] += r.Count
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
|
||||
case r.Type == 63:
|
||||
if e.buffRewards == nil {
|
||||
return nil, fmt.Errorf("events: buff reward runtime unavailable")
|
||||
}
|
||||
buffItems = append(buffItems, gamedata.Reward(r))
|
||||
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
|
||||
case r.Type == 11:
|
||||
if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 {
|
||||
return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID)
|
||||
@@ -332,11 +346,17 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
|
||||
equips = append(equips, entry)
|
||||
}
|
||||
case inventoryType(r.Type):
|
||||
if r.Type == 47 || r.Type == 49 {
|
||||
if r.Type == 47 || r.Type == 69 {
|
||||
if !e.ownedDesign[r.Type][r.ID] {
|
||||
return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID)
|
||||
}
|
||||
}
|
||||
if r.Type == 49 || r.Type == 50 || r.Type == 61 {
|
||||
valid := e.avatarRewards != nil && e.avatarRewards.Items[r.Type][r.ID]
|
||||
if !valid && !e.ownedDesign[r.Type][r.ID] {
|
||||
return nil, fmt.Errorf("events: unknown avatar member %d:%d", r.Type, r.ID)
|
||||
}
|
||||
}
|
||||
if r.Type == 45 {
|
||||
if _, ok := e.prestige[r.ID]; !ok {
|
||||
return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID)
|
||||
@@ -350,6 +370,11 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
|
||||
return nil, fmt.Errorf("events: unsupported reward type %d", r.Type)
|
||||
}
|
||||
}
|
||||
if len(buffItems) > 0 {
|
||||
if err = e.buffRewards.Validate(buffItems); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -375,6 +400,11 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(buffItems) > 0 {
|
||||
if err = e.buffRewards.GrantOnce(identity+":buff-items", buffItems); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
items, err := e.items.GrantOnce(identity+":items", itemRewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package events_test
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/calendar"
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/hunting"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type auditedLeaves struct{}
|
||||
|
||||
func (auditedLeaves) Resolve(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
|
||||
return rewards, nil
|
||||
}
|
||||
func auditArray(raw []byte, number int) []uint64 {
|
||||
var result []uint64
|
||||
_ = wire.Walk(raw, func(f wire.Field) error {
|
||||
if f.Number != number {
|
||||
return nil
|
||||
}
|
||||
if f.Type == 0 {
|
||||
v, _ := binary.Uvarint(f.Value)
|
||||
result = append(result, v)
|
||||
}
|
||||
if f.Type == 2 {
|
||||
for p := f.Value; len(p) > 0; {
|
||||
v, n := binary.Uvarint(p)
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
result = append(result, v)
|
||||
p = p[n:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result
|
||||
}
|
||||
func TestInstalledScheduledTaskAttendanceAndPassRewardCoverage(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
const version = "20260923193640"
|
||||
design, err := gamedata.LoadEventTasksDesign(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedules, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
play, err := gamedata.LoadEventPlayCatalog(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exchanges, err := gamedata.LoadEventExchangeCatalog(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roots []gamedata.BattleReward
|
||||
appendReward := func(r gamedata.Reward) {
|
||||
if r.Type != 0 && r.Count != 0 {
|
||||
roots = append(roots, gamedata.BattleReward(r))
|
||||
}
|
||||
}
|
||||
missionGroups := map[uint64]bool{}
|
||||
appendPass := func(id uint64) {
|
||||
pass := design.Passes[id]
|
||||
missionGroups[pass.MissionGroup] = true
|
||||
appendReward(pass.Core)
|
||||
for _, level := range design.PassLevels[pass.LevelGroup] {
|
||||
appendReward(level.Basic)
|
||||
appendReward(level.Premium)
|
||||
}
|
||||
for _, buy := range design.PassBuys[id] {
|
||||
for _, reward := range buy.Rewards {
|
||||
appendReward(reward)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, schedule := range schedules.Events {
|
||||
switch schedule.Type {
|
||||
case 0:
|
||||
group := design.Attendance[schedule.ID].Group
|
||||
groups := map[uint64]bool{group: true}
|
||||
for _, row := range design.AttendanceGroups {
|
||||
if row.Group == group {
|
||||
groups[row.ID] = true
|
||||
}
|
||||
}
|
||||
for group := range groups {
|
||||
for _, reward := range design.AttendanceRewards[group] {
|
||||
appendReward(reward.Basic)
|
||||
appendReward(reward.Premium)
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
for key, box := range design.LimitRewards {
|
||||
if key[0] == schedule.ID {
|
||||
roots = append(roots, gamedata.BattleReward{Type: 9, ID: box, Count: 1})
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
missionGroups[schedule.ID] = true
|
||||
case 7:
|
||||
for _, entry := range exchanges.Groups[schedule.ID].Entries {
|
||||
appendReward(entry.Reward)
|
||||
}
|
||||
case 9:
|
||||
for _, row := range play.Rows("PackEventBattleTable", 4, schedule.ID) {
|
||||
rewards, err := gamedata.EventPlayRewards(row, 9, 10, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots = append(roots, rewards...)
|
||||
}
|
||||
case 10:
|
||||
for _, row := range play.Rows("PackEventStoryTable", 1, schedule.ID) {
|
||||
rewards, err := gamedata.EventPlayRewards(row, 11, 10, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots = append(roots, rewards...)
|
||||
}
|
||||
case 12, 13, 17, 19:
|
||||
game, err := gamedata.LoadEventGame(root, version, schedule.Type, schedule.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rows := range [][]gamedata.EventGameReward{game.Cells, game.Lines, game.Complete} {
|
||||
for _, row := range rows {
|
||||
roots = append(roots, row.Rewards...)
|
||||
}
|
||||
}
|
||||
case 5:
|
||||
appendPass(schedule.ID)
|
||||
}
|
||||
}
|
||||
for id := range missionGroups {
|
||||
for _, group := range design.MissionGroups[id].Groups {
|
||||
for _, mission := range design.Missions {
|
||||
if mission.Group == group {
|
||||
for _, reward := range mission.Rewards {
|
||||
appendReward(reward)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The installed mini-game families have verified direct reward fields;
|
||||
// validate each design row, including the next published game variants.
|
||||
for _, spec := range []struct {
|
||||
table string
|
||||
typ, id, count int
|
||||
}{{"FieldMiniGameRewardTable", 6, 5, 4}, {"SichuanRewardTable", 6, 5, 4}, {"HopscotchRewardTable", 7, 6, 4}, {"ActionGameMissionTable", 10, 9, 8}, {"MGDRewardTable", 5, 3, 2}} {
|
||||
for _, row := range play.Tables[spec.table] {
|
||||
rewards, err := gamedata.EventPlayRewards(row, spec.typ, spec.id, spec.count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots = append(roots, rewards...)
|
||||
}
|
||||
}
|
||||
if schedules.MonsterHunt != nil {
|
||||
seen := map[uint64]bool{}
|
||||
for _, season := range schedules.MonsterHunt.Seasons {
|
||||
if seen[season.HuntID] {
|
||||
continue
|
||||
}
|
||||
seen[season.HuntID] = true
|
||||
hunt, err := gamedata.LoadMonsterHunt(root, version, season.HuntID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reward := range hunt.Rewards {
|
||||
roots = append(roots, reward.Clear...)
|
||||
roots = append(roots, reward.Daily...)
|
||||
}
|
||||
for _, rows := range hunt.Ranks {
|
||||
for _, reward := range rows {
|
||||
roots = append(roots, reward.Rewards...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
t.Fatal("published calendar has no audited reward definitions")
|
||||
}
|
||||
// Walk every possible OPEN-box branch, not one RNG sample. DIRECT boxes are
|
||||
// actual inventory rewards and remain intact. This is a regression oracle
|
||||
// for the verified protobuf reward graph fields.
|
||||
db, closeDB, err := gamedata.OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeDB()
|
||||
tables := map[string]map[uint64][]byte{}
|
||||
for _, name := range []string{"RandomBoxTable", "RewardGroupTable"} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables[name] = map[uint64][]byte{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tables[name][id] = raw
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
leaves := map[[2]uint64]gamedata.BattleReward{}
|
||||
var walk func(gamedata.BattleReward, map[uint64]bool)
|
||||
walk = func(reward gamedata.BattleReward, seen map[uint64]bool) {
|
||||
if reward.Type != 9 {
|
||||
reward.Count = 1
|
||||
leaves[[2]uint64{reward.Type, reward.ID}] = reward
|
||||
return
|
||||
}
|
||||
raw, ok := tables["RandomBoxTable"][reward.ID]
|
||||
if !ok {
|
||||
t.Fatalf("unknown scheduled box%d", reward.ID)
|
||||
}
|
||||
drop, _, _ := wire.Varint(raw, 1)
|
||||
if drop == 1 {
|
||||
reward.Count = 1
|
||||
leaves[[2]uint64{9, reward.ID}] = reward
|
||||
return
|
||||
}
|
||||
if seen[reward.ID] {
|
||||
t.Fatalf("scheduled box cycle%d", reward.ID)
|
||||
}
|
||||
seen[reward.ID] = true
|
||||
defer delete(seen, reward.ID)
|
||||
gid, _, _ := wire.Varint(raw, 9)
|
||||
group, ok := tables["RewardGroupTable"][gid]
|
||||
if !ok {
|
||||
t.Fatalf("unknown scheduled reward group%d", gid)
|
||||
}
|
||||
types, ids, counts := auditArray(group, 6), auditArray(group, 5), auditArray(group, 4)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
t.Fatalf("malformed scheduled reward group%d", gid)
|
||||
}
|
||||
for i, typ := range types {
|
||||
if counts[i] != 0 {
|
||||
walk(gamedata.BattleReward{Type: typ, ID: ids[i], Count: 1}, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, reward := range roots {
|
||||
walk(reward, map[uint64]bool{})
|
||||
}
|
||||
costumes, err := gamedata.LoadRewardCostumeCatalog(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipmentDesign, err := gamedata.LoadRewardEquipmentCatalog(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := gamedata.LoadOwnedEventItemDesign(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prestige, err := gamedata.LoadPrestigeSkins(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
avatars, err := gamedata.LoadAvatarRewardDesign(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffDesign, err := gamedata.LoadBuffRewardDesign(root, version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := stateio.NewMemory()
|
||||
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := player.OpenWallet(store, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := player.OpenCollectionStore(store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment, err := player.OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
economy, err := events.NewEconomy(store, items, wallet, collection, equipment, costumes, equipmentDesign, auditedLeaves{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
economy.AttachOwnedItemDesign(owned)
|
||||
economy.AttachPrestigeSkins(prestige)
|
||||
economy.AttachAvatarRewards(avatars)
|
||||
buffs, err := events.OpenBuffRewards(store, buffDesign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
economy.AttachBuffRewards(buffs)
|
||||
ap, err := hunting.Open(store, root, version, items, wallet, func() (int, error) { return 21, nil }, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
economy.AttachHuntingAP(ap)
|
||||
for key, reward := range leaves {
|
||||
if _, err = economy.Apply(fmt.Sprintf("coverage:%d:%d", key[0], key[1]), nil, []gamedata.Reward{gamedata.Reward(reward)}); err != nil {
|
||||
t.Errorf("scheduled reward leaf %d:%d unsupported: %v", key[0], key[1], err)
|
||||
}
|
||||
}
|
||||
t.Logf("validated %d published reward roots and %d distinct possible leaves against real grant domains", len(roots), len(leaves))
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func (e *Economy) OwnedItemInfo(path string, req []byte) (int, []byte, bool, err
|
||||
}
|
||||
var out []byte
|
||||
for _, item := range e.items.All() {
|
||||
if item.Type == 49 {
|
||||
if item.Type == 49 || item.Type == 50 || item.Type == 61 {
|
||||
out = wire.AppendBytes(out, 2, player.ItemWire(item))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Avatar sets are design bundles: ownership is checked on each member by the
|
||||
// client, rather than on a synthetic AvatarSet ItemDBInfo.
|
||||
type AvatarRewardDesign struct {
|
||||
Sets map[uint64][]BattleReward
|
||||
Items map[uint64]map[uint64]bool
|
||||
}
|
||||
|
||||
func LoadAvatarRewardDesign(root, version string) (*AvatarRewardDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadAvatarRewardDesign(db)
|
||||
}
|
||||
func loadAvatarRewardDesign(db *sql.DB) (*AvatarRewardDesign, error) {
|
||||
d := &AvatarRewardDesign{Sets: map[uint64][]BattleReward{}, Items: map[uint64]map[uint64]bool{49: {}, 50: {}, 61: {}}}
|
||||
for _, table := range []struct {
|
||||
name string
|
||||
typ uint64
|
||||
field int
|
||||
}{{"AvatarItemTable", 49, 3}, {"AvatarMotionTable", 50, 5}, {"AvatarCharTable", 61, 6}} {
|
||||
if err := readCashMetadata(db, table.name, func(raw []byte) error {
|
||||
id, err := optionalScalar(raw, table.field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("gamedata: invalid avatar member")
|
||||
}
|
||||
d.Items[table.typ][id] = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := readCashMetadata(db, "AvatarSetTable", func(raw []byte) error {
|
||||
id, err := optionalScalar(raw, 6)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := eventGameRewards(raw, 5, 4, 3)
|
||||
if err != nil || id == 0 || len(members) == 0 {
|
||||
return fmt.Errorf("gamedata: invalid avatar set")
|
||||
}
|
||||
for _, member := range members {
|
||||
if !d.Items[member.Type][member.ID] || member.Count == 0 || member.Count > math.MaxInt32 {
|
||||
return fmt.Errorf("gamedata: avatar set %d has invalid member %d:%d", id, member.Type, member.ID)
|
||||
}
|
||||
}
|
||||
d.Sets[id] = members
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func (d *AvatarRewardDesign) Expand(rewards []BattleReward) ([]BattleReward, error) {
|
||||
var out []BattleReward
|
||||
for _, reward := range rewards {
|
||||
if reward.Type != 62 {
|
||||
out = append(out, reward)
|
||||
continue
|
||||
}
|
||||
if d == nil || reward.Count == 0 || reward.Count > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("gamedata: avatar reward design or quantity unavailable")
|
||||
}
|
||||
members, ok := d.Sets[reward.ID]
|
||||
if !ok || len(members) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: unknown avatar set %d", reward.ID)
|
||||
}
|
||||
for _, member := range members {
|
||||
if !d.Items[member.Type][member.ID] || member.Count == 0 || member.Count > math.MaxInt32/reward.Count {
|
||||
return nil, fmt.Errorf("gamedata: invalid avatar set member")
|
||||
}
|
||||
member.Count *= reward.Count
|
||||
out = append(out, member)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAvatarRewardsLoadAndValidateTypedMembers(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, table := range []string{"AvatarItemTable", "AvatarMotionTable", "AvatarCharTable", "AvatarSetTable"} {
|
||||
if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
table string
|
||||
field int
|
||||
id uint64
|
||||
}{{"AvatarItemTable", 3, 10001}, {"AvatarMotionTable", 5, 7}, {"AvatarCharTable", 6, 2}} {
|
||||
if _, err = db.Exec("INSERT INTO "+row.table+" VALUES(?)", wire.AppendVarint(nil, row.field, row.id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
set := wire.AppendVarint(nil, 6, 1)
|
||||
for _, member := range []BattleReward{{49, 10001, 1}, {50, 7, 1}, {61, 2, 1}} {
|
||||
set = wire.AppendVarint(set, 5, member.Type)
|
||||
set = wire.AppendVarint(set, 4, member.ID)
|
||||
set = wire.AppendVarint(set, 3, member.Count)
|
||||
}
|
||||
if _, err = db.Exec("INSERT INTO AvatarSetTable VALUES(?)", set); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := loadAvatarRewardDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaves, err := d.Expand([]BattleReward{{Type: 62, ID: 1, Count: 2}, {Type: 4, Count: 10}})
|
||||
if err != nil || len(leaves) != 4 || leaves[0].Count != 2 || leaves[2].Type != 61 {
|
||||
t.Fatal("avatar set members malformed", leaves, err)
|
||||
}
|
||||
if _, err = d.Expand([]BattleReward{{Type: 62, ID: 99, Count: 1}}); err == nil {
|
||||
t.Fatal("unknown set accepted")
|
||||
}
|
||||
d.Sets[1][0].Count = 2
|
||||
if _, err = d.Expand([]BattleReward{{Type: 62, ID: 1, Count: math.MaxInt32}}); err == nil {
|
||||
t.Fatal("avatar quantity overflow accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuffItem is a permanent account-stat reward, not an inventory entity.
|
||||
func LoadBuffRewardDesign(root, version string) (map[uint64]PictorialBuffStat, error) {
|
||||
pictorial, err := LoadPictorialDesign(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM BuffItemTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint64]PictorialBuffStat{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffID, err := optionalScalar(raw, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buff, ok := pictorial.Buffs[buffID]
|
||||
if !ok || buff.Category != 1 {
|
||||
return nil, fmt.Errorf("gamedata: buff item %d permanent buff %d unavailable", id, buffID)
|
||||
}
|
||||
out[id] = PictorialBuffStat{Category: buff.Category, StatType: buff.StatType, Value: buff.Value}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -22,11 +22,13 @@ type CashProductDesign struct {
|
||||
NominalPaidDiamonds uint64
|
||||
}
|
||||
type CashShopDesign struct{ GroupID, ID, ProductGroupID, BulkOrderShow uint64 }
|
||||
type EventShopDesign struct{ ID, ProductGroupID uint64 }
|
||||
type CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 }
|
||||
type CashCatalog struct {
|
||||
Products []CashProductDesign
|
||||
Shops []CashShopDesign
|
||||
Packages []CashPackageDesign
|
||||
Products []CashProductDesign
|
||||
Shops []CashShopDesign
|
||||
Packages []CashPackageDesign
|
||||
EventShops []EventShopDesign
|
||||
}
|
||||
|
||||
func LoadCashCatalog(root, version string) (*CashCatalog, error) {
|
||||
@@ -114,6 +116,19 @@ func loadCashCatalog(db *sql.DB) (*CashCatalog, error) {
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = readCashMetadata(db, "EventShopTable", func(raw []byte) error {
|
||||
v, e := cashScalars(raw, 2, 5)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if v[0] == 0 || v[1] == 0 {
|
||||
return fmt.Errorf("gamedata: invalid event shop")
|
||||
}
|
||||
c.EventShops = append(c.EventShops, EventShopDesign{v[0], v[1]})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error {
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)"} {
|
||||
for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)", "CREATE TABLE EventShopTable(ProtoBuf BLOB)"} {
|
||||
if _, err = db.Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool,
|
||||
}
|
||||
defer done()
|
||||
out := map[uint64]map[uint64]bool{}
|
||||
for typ, table := range map[uint64]string{47: "IdCardItemTable", 49: "AvatarItemTable"} {
|
||||
for typ, table := range map[uint64]string{47: "IdCardItemTable", 49: "AvatarItemTable", 69: "EquipmentRankChangeItemTable"} {
|
||||
rows, e := db.Query("SELECT id FROM " + table)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type MiniContentRoute struct {
|
||||
UID, ContentType, ContentID uint64
|
||||
Start, End int64
|
||||
}
|
||||
|
||||
type MiniContentDesign struct {
|
||||
Stories map[uint64]Reward
|
||||
Groups map[uint64][]uint64
|
||||
}
|
||||
|
||||
func LoadMiniContentDesign(root, version string) (*MiniContentDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
d := &MiniContentDesign{Stories: map[uint64]Reward{}, Groups: map[uint64][]uint64{}}
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM DailyStoryTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err = rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
id, e := optionalScalar(raw, 2)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
var r Reward
|
||||
for field, target := range map[int]*uint64{10: &r.Type, 9: &r.ID, 8: &r.Count} {
|
||||
v, e := optionalScalar(raw, field)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
d.Stories[id] = r
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT ProtoBuf FROM MiniEventStoryTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err = rows.Scan(&raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
id, e := optionalScalar(raw, 1)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
group, e := optionalScalar(raw, 2)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
d.Groups[group] = append(d.Groups[group], id)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err == nil {
|
||||
for group, ids := range d.Groups {
|
||||
for _, id := range ids {
|
||||
if _, exists := d.Stories[id]; !exists {
|
||||
return nil, fmt.Errorf("gamedata: mini story group %d references missing daily story %d", group, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func TestFutureSeasonDoesNotReplaceActiveOrRewardSeason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T) {
|
||||
func TestSchedulePreservesAllCalendarsAndPlacesSelectedCategoriesLast(t *testing.T) {
|
||||
field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} }
|
||||
row := func(id uint64, independent bool) readonly.Field {
|
||||
flag := uint64(0)
|
||||
@@ -36,24 +36,31 @@ func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T)
|
||||
}}
|
||||
}
|
||||
s := &Service{now: func() time.Time { return time.UnixMilli(150) },
|
||||
seasons: []season{{ID: 1, Start: 100, End: 200}, {ID: 2, Start: 300, End: 400}, {ID: 3, Start: 100, End: 200, Independent: true}},
|
||||
seasons: []season{{ID: 1, Start: 100, End: 200}, {ID: 2, Start: 300, End: 400}, {ID: 3, Start: 100, End: 200, Independent: true}, {ID: 4, Start: 300, End: 400, Independent: true}},
|
||||
seed: &readonly.Seed{Responses: map[string]readonly.Response{"/MonsterHuntScheduleInfo": {
|
||||
Fields: []readonly.Field{row(1, false), row(2, false), row(3, true), field(2, 5), {Number: 3, Type: 2, Fields: []readonly.Field{field(1, 77)}}},
|
||||
Fields: []readonly.Field{row(1, false), row(2, false), row(3, true), row(4, true), field(2, 5), {Number: 3, Type: 2, Fields: []readonly.Field{field(1, 77)}}},
|
||||
}}},
|
||||
}
|
||||
for _, now := range []int64{150, 350} {
|
||||
for _, now := range []int64{50, 150, 250, 350, 450} {
|
||||
s.now = func() time.Time { return time.UnixMilli(now) }
|
||||
_, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []uint64
|
||||
var lastRegular, lastIndependent uint64
|
||||
history := 0
|
||||
if err := wire.Walk(response, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
nested, _, _ := wire.Bytes(f.Value, 1)
|
||||
id, _, _ := wire.Varint(nested, 1)
|
||||
ids = append(ids, id)
|
||||
flag, _, _ := wire.Varint(f.Value, 6)
|
||||
if flag == 0 {
|
||||
lastRegular = id
|
||||
} else {
|
||||
lastIndependent = id
|
||||
}
|
||||
}
|
||||
if f.Number == 3 {
|
||||
history++
|
||||
@@ -63,13 +70,17 @@ func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := uint64(1)
|
||||
if now == 350 {
|
||||
if now >= 300 {
|
||||
want = 2
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != want || ids[1] != 3 || history != 1 {
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range ids {
|
||||
seen[id] = true
|
||||
}
|
||||
if len(ids) != 4 || len(seen) != 4 || !seen[1] || !seen[2] || !seen[3] || !seen[4] || lastRegular != want || lastIndependent != want+2 || history != 1 {
|
||||
t.Fatalf("time=%d rows=%v history=%d", now, ids, history)
|
||||
}
|
||||
if len(s.seed.Responses["/MonsterHuntScheduleInfo"].Fields) != 5 {
|
||||
if len(s.seed.Responses["/MonsterHuntScheduleInfo"].Fields) != 6 {
|
||||
t.Fatal("projection mutated published calendars")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,8 +247,16 @@ func selectSeason(rows []season, now uint64) season {
|
||||
|
||||
func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) {
|
||||
current := s.current()
|
||||
var independent []season
|
||||
for _, row := range s.seasons {
|
||||
if row.Independent {
|
||||
independent = append(independent, row)
|
||||
}
|
||||
}
|
||||
selectedIndependent := selectSeason(independent, uint64(s.now().UnixMilli()))
|
||||
response := s.seed.Responses["/MonsterHuntScheduleInfo"]
|
||||
fields := make([]readonly.Field, 0, len(response.Fields))
|
||||
var selected []readonly.Field
|
||||
for _, field := range response.Fields {
|
||||
if field.Number == 1 && field.Type == 2 {
|
||||
var id uint64
|
||||
@@ -265,12 +273,17 @@ func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) {
|
||||
independent = f.Varint != 0
|
||||
}
|
||||
}
|
||||
if !independent && id != current.ID {
|
||||
// The current client assigns each category in a foreach: its last
|
||||
// row wins. Keep every calendar row, but move each selected row
|
||||
// last so future announcements cannot change the playable hunt.
|
||||
if !independent && id == current.ID || independent && id == selectedIndependent.ID {
|
||||
selected = append(selected, field)
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields = append(fields, field)
|
||||
}
|
||||
fields = append(fields, selected...)
|
||||
// Keep the entire loaded calendar immutable; projection only affects this
|
||||
// response so subsequent requests can cross a season boundary without reload.
|
||||
projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -77,7 +78,7 @@ func TestBattleSessionProgressRewardsAndPersistentRetry(t *testing.T) {
|
||||
if _, ok, _ := wire.Bytes(out, 7); !ok {
|
||||
t.Fatal("clear reward missing")
|
||||
}
|
||||
u := s.state.Users["78"]
|
||||
u := s.state.Users[strconv.FormatUint(c.ID, 10)]
|
||||
if u.ClearLevel != 1 || u.Level != 2 || !u.Played {
|
||||
t.Fatalf("progress=%+v", u)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package pictorial
|
||||
|
||||
import "bd2server/internal/server/gamedata"
|
||||
|
||||
// AttachPermanentBuffs makes reward-owned account buffs available to the same
|
||||
// Snapshot used by AllCharRefresh, login and authoritative stat consumers.
|
||||
func (s *Service) AttachPermanentBuffs(provider func() ([]gamedata.PictorialBuffStat, error)) {
|
||||
s.permanentBuffs = provider
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package pictorial
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermanentBuffsRefreshThroughAccountSnapshot(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}}
|
||||
rewards, err := events.OpenBuffRewards(store, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}}
|
||||
service.AttachPermanentBuffs(rewards.SnapshotBuffs)
|
||||
_, before, err := service.Snapshot()
|
||||
if err != nil || len(before) != 0 {
|
||||
t.Fatal("buff present before ownership", err)
|
||||
}
|
||||
if err := rewards.GrantOnce("mission", []gamedata.Reward{{Type: 63, ID: 1, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, after, err := service.Snapshot()
|
||||
if err != nil || len(after) != 1 || after[0].Value != 0.02 {
|
||||
t.Fatal("new buff missing from live stats", after, err)
|
||||
}
|
||||
code, body, handled, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil || code != 165 || !handled || len(body) == 0 {
|
||||
t.Fatal("native buff refresh missing", err)
|
||||
}
|
||||
restored, err := events.OpenBuffRewards(store, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.AttachPermanentBuffs(restored.SnapshotBuffs)
|
||||
_, after, err = service.Snapshot()
|
||||
if err != nil || len(after) != 1 || after[0].Category != 1 {
|
||||
t.Fatal("relogin buff lost", after, err)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ type Service struct {
|
||||
AwakeContributions func(player.Character) ([]gamedata.StatContribution, error)
|
||||
EquipmentContributions func(player.Character) ([]gamedata.StatContribution, error)
|
||||
PotentialContributions func(player.Character) ([]gamedata.StatContribution, error)
|
||||
permanentBuffs func() ([]gamedata.PictorialBuffStat, error)
|
||||
baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP
|
||||
}
|
||||
|
||||
@@ -195,6 +196,15 @@ func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) {
|
||||
})
|
||||
type key struct{ category, stat uint64 }
|
||||
values := map[key]float64{}
|
||||
if s.permanentBuffs != nil {
|
||||
permanent, err := s.permanentBuffs()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, buff := range permanent {
|
||||
values[key{buff.Category, buff.StatType}] += buff.Value
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.BuffID == 0 {
|
||||
continue
|
||||
|
||||
@@ -87,6 +87,7 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
PatchGeneratedReceiver("<SendLoginPassRequest>b__0", nameof(LoginPassRewards));
|
||||
PatchGeneratedReceiver("<SendEventRewardRequest>b__0", nameof(EventRewards));
|
||||
PassRewardPresentation.Install(harmony);
|
||||
RewardBuffRefresh.Install(harmony);
|
||||
MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null,
|
||||
new[] { typeof(IMessage), typeof(int), typeof(int) }, null);
|
||||
if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using HarmonyLib;
|
||||
using Proto.Net;
|
||||
|
||||
namespace Bd2CashShop;
|
||||
|
||||
// BuffItem rewards unlock account stats rather than an inventory item. Native
|
||||
// reward handling ignores them, so refresh the authoritative collection stats.
|
||||
internal static class RewardBuffRefresh
|
||||
{
|
||||
internal static void Install(Harmony harmony)
|
||||
{
|
||||
var target = typeof(DataManager).GetGameMethod("AddRewardInfoBundle",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null,
|
||||
new[] { typeof(RewardDBInfoBundle), typeof(List<ItemBaseInfo>).MakeByRefType(), typeof(bool) }, null);
|
||||
if (target == null) throw new MissingMethodException("DataManager.AddRewardInfoBundle");
|
||||
harmony.Patch(target, postfix: new HarmonyMethod(typeof(RewardBuffRefresh), nameof(Refresh)));
|
||||
}
|
||||
|
||||
private static void Refresh(RewardDBInfoBundle __0, bool __2)
|
||||
{
|
||||
if (__0 == null || !__2) return;
|
||||
foreach (var item in __0.ViewItemInfo)
|
||||
{
|
||||
if (item.Type != (int)EElementType.BuffItem) continue;
|
||||
ContentsPacket.SendRefreshCollectionBuff(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -9,6 +9,6 @@
|
||||
"local_identity": "0.6.1",
|
||||
"capture_environment": "0.2.0",
|
||||
"login_ui": "0.2.0",
|
||||
"cash_shop": "0.1.0"
|
||||
"cash_shop": "0.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user