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:
2026-10-05 15:49:50 +08:00
parent f65e45ee42
commit 924a42c32e
44 changed files with 2220 additions and 91 deletions
+25
View File
@@ -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 {
+17 -1
View File
@@ -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()
+15
View File
@@ -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)
}
}
}
+3 -1
View File
@@ -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 {
+67 -32
View File
@@ -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
+2 -2
View File
@@ -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")
}
}
+25 -15
View File
@@ -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")
}
}
+124
View File
@@ -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)
}
}
+50 -20
View File
@@ -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))
}
+1 -1
View File
@@ -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()
}
+18 -3
View File
@@ -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")
}
}
+14 -1
View File
@@ -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)
}
}
+10
View File
@@ -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
+1
View File
@@ -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)");
+33
View File
@@ -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
View File
@@ -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"
}
}