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) return fmt.Errorf("load event inventory design: %w", err)
} }
eventEconomy.AttachOwnedItemDesign(ownedEventItems) 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) eventAPCaps, eventAPReset, err := gamedata.LoadEventAPDesign(gameData, *gameDataVersion)
if err != nil { if err != nil {
return fmt.Errorf("load event AP reset: %w", err) 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 { if err := cashService.AttachShopSeed(serverConfig); err != nil {
return fmt.Errorf("attach cash product availability: %w", err) 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) cashBonusDesign, err := gamedata.LoadCashBonusCatalog(gameData, *gameDataVersion)
if err != nil { if err != nil {
return fmt.Errorf("load cash bonus design: %w", err) return fmt.Errorf("load cash bonus design: %w", err)
@@ -910,6 +928,13 @@ func serve(args []string) (serveErr error) {
if err != nil { if err != nil {
return fmt.Errorf("load event action state: %w", err) 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 { eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
for _, entry := range collection.FriendshipEntries() { for _, entry := range collection.FriendshipEntries() {
if entry.State != nil && entry.State.CostumeID == id { if entry.State != nil && entry.State.CostumeID == id {
+17 -1
View File
@@ -26,9 +26,25 @@ func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
if e != nil { if e != nil {
t.Fatal(e) 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)) 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) { func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
dir := t.TempDir() 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) 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) 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 { if !supported {
return fmt.Errorf("calendar: mini hub %d slot %d content type %d has no supported scheduled route", hub.HubID, binding.Slot, contentType) 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 { if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("quiz borrowed global bingo event type") 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] { if !e.design.AvatarSets[r.ID] {
return nil, fmt.Errorf("commerce: unknown avatar set %d", 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: case r.Type == 19 && e.design.TicketTypes[r.ID] == 2:
if len(e.design.Attendance[r.ID]) == 0 { if len(e.design.Attendance[r.ID]) == 0 {
return nil, fmt.Errorf("commerce: subscription reward schedule missing") return nil, fmt.Errorf("commerce: subscription reward schedule missing")
@@ -52,9 +52,14 @@ func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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") 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) again, err := e.Apply("buy1", nil, rewards)
if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 { if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 {
t.Fatal("retry rerolled or double granted") 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 { if err := shop.AttachShopSeed(seed); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := shop.AttachEventShopSchedules(catalog, calendars.Events); err != nil {
t.Fatal(err)
}
graph, err := gamedata.LoadRewardGraph(root, "20260923193640") graph, err := gamedata.LoadRewardGraph(root, "20260923193640")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -110,6 +113,10 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
avatarRewards, err := gamedata.LoadAvatarRewardDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
newEconomy := func() (*EntitlementEconomy, *player.Wallet) { newEconomy := func() (*EntitlementEconomy, *player.Wallet) {
store := stateio.NewMemory() store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
@@ -139,6 +146,7 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
} }
base.AttachPrestigeSkins(skins) base.AttachPrestigeSkins(skins)
base.AttachOwnedItemDesign(ownedDesign) base.AttachOwnedItemDesign(ownedDesign)
base.AttachAvatarRewards(avatarRewards)
ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0) ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -150,6 +158,141 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
} }
return e, wallet 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{} delegated := map[gamedata.CashProductKey]bool{}
retired := map[gamedata.CashProductKey]bool{} retired := map[gamedata.CashProductKey]bool{}
for _, group := range groups { 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) t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err)
continue 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() balance := wallet.Snapshot()
replay, err := e.Apply("installed-audit", costs, rewards) replay, err := e.Apply("installed-audit", costs, rewards)
if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance { 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"` Counts map[string]purchaseCount `json:"counts"`
} }
type Service struct { type Service struct {
mu sync.Mutex mu sync.Mutex
catalog *Catalog catalog *Catalog
store stateio.Store store stateio.Store
economy Economy economy Economy
now func() time.Time now func() time.Time
resetSeconds int64 resetSeconds int64
delegate PurchaseDelegate delegate PurchaseDelegate
hook PurchaseHook hook PurchaseHook
legacy CountProvider legacy CountProvider
shopProducts [][]byte shopProducts [][]byte
shopWindows map[gamedata.CashProductKey][2]uint64 shopWindows map[gamedata.CashProductKey][2]uint64
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey 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) { 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 return nil
} }
func (s *Service) available(d gamedata.CashProductDesign) bool { 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] w, ok := s.shopWindows[d.Key]
if !ok { if !ok {
return d.TimeLimitType == 0 return d.TimeLimitType == 0
@@ -196,8 +207,8 @@ func (s *Service) shopInfo() []byte {
g, _, _ := wire.Varint(raw, 1) g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2) id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3) sale, _, _ := wire.Varint(raw, 3)
d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}) _, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
if ok && s.available(d) { if ok {
response = wire.AppendBytes(response, 1, raw) 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 { 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") return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
} }
h := sha256.Sum256(request) h := sha256.Sum256(request)
@@ -436,9 +451,22 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
if !s.available(d) { if !s.available(d) {
return 61, nil, true, fmt.Errorf("commerce: product is not currently available") return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
} }
q, err := s.catalog.Quote(l.Key, l.Count) var q Product
if err != nil { if d.PriceType == 1 {
return 61, nil, true, err 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 { if !q.Enabled {
return 61, nil, true, fmt.Errorf("commerce: product disabled") 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 designs[i] = d
quotes[i] = q quotes[i] = q
} }
if e = validateAcceptedQuote(request, quotes); e != nil { if cash {
e = validateAcceptedQuote(request, quotes)
}
if e != nil {
return 61, nil, true, e return 61, nil, true, e
} }
var bundle []byte 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) operation := fmt.Sprintf("%s:%d", identity, i)
var costs []gamedata.Reward var costs []gamedata.Reward
if q.Cost > 0 { if q.Cost > 0 {
var typ uint64 if d.PriceType != 1 {
switch q.Currency { costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}}
case "paid_diamonds": } else {
typ = 2 var typ uint64
case "diamonds": switch q.Currency {
typ = 3 case "paid_diamonds":
case "gold": typ = 2
typ = 4 case "diamonds":
default: typ = 3
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency) 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 var special []byte
handled := false handled := false
// Delegate must only select known special products. Parent account transaction // Delegate must only select known special products. Parent account transaction
// guarantees its grant and the subsequent debit commit together. // 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) special, handled, e = s.delegate(l.Key, request)
if e != nil { if e != nil {
return 61, nil, true, e return 61, nil, true, e
+2 -2
View File
@@ -195,7 +195,7 @@ func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
t.Fatal("stale amount accepted") t.Fatal("stale amount accepted")
} }
} }
func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) { func TestShopPublishesAllWindowsAndAuthorizesActiveProducts(t *testing.T) {
s, eco, _ := serviceFixture(t, 0) s, eco, _ := serviceFixture(t, 0)
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0) s.SetClock(func() time.Time { return now }, 0)
@@ -221,7 +221,7 @@ func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
} }
return nil return nil
}) })
if products != 0 || eco.calls != 1 { if products != 1 || eco.calls != 1 {
t.Fatal(products, eco.calls) 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 Receipts map[string]receipt
} }
type Service struct { type Service struct {
mu sync.Mutex mu sync.Mutex
store stateio.Store store stateio.Store
design *gamedata.EventActionsDesign design *gamedata.EventActionsDesign
registry events.Resolver registry events.Resolver
economy Economy economy Economy
state snapshot state snapshot
now func() time.Time now func() time.Time
session string session string
friendship func(uint64) uint64 friendship func(uint64) uint64
chargeInfo func() ([]byte, error) chargeInfo func() ([]byte, error)
owned func(uint64, uint64) bool owned func(uint64, uint64) bool
progress func(uint64, uint64, uint64) error progress func(uint64, uint64, uint64) error
voteTotals func(uint64, uint64) (map[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) { 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" { if path == "/CafeteriaEventNpcInteractionReward" {
code, ok = 414, true code, ok = 414, true
} }
if path == "/DailyStoryInfo" {
code, ok = 538, true
}
if path == "/DailyStoryClear" {
code, ok = 539, true
}
if !ok { if !ok {
return 0, nil, false, nil 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) { func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
switch path { switch path {
case "/DailyStoryInfo", "/DailyStoryClear":
return s.dailyStory(path, b, identity)
case "/CafeteriaEventNpcInteractionReward": case "/CafeteriaEventNpcInteractionReward":
return s.cafeteriaReward(b, identity) return s.cafeteriaReward(b, identity)
case "/ChargeCostInfo": case "/ChargeCostInfo":
@@ -436,7 +446,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
return out, nil return out, nil
case "/NpcQuizInfo": case "/NpcQuizInfo":
uid := val(b, 2) uid := val(b, 2)
v, e := s.registry.Resolve(uid) v, e := s.resolveQuiz(uid)
if e != nil { if e != nil {
return nil, e return nil, e
} }
@@ -455,7 +465,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
return out, nil return out, nil
case "/NpcQuizClear": case "/NpcQuizClear":
uid, g, id := val(b, 2), val(b, 3), val(b, 4) 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 { if e != nil {
return nil, e return nil, e
} }
@@ -21,6 +21,84 @@ type miniHubSchedule struct {
Bindings []miniHubBinding 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) { func (s *Service) miniEventHubs(req []byte) ([]byte, error) {
var hubs []miniHubSchedule var hubs []miniHubSchedule
if s.hubCalendars == nil { if s.hubCalendars == nil {
@@ -99,6 +177,39 @@ func (s *Service) miniEventHubs(req []byte) ([]byte, error) {
if len(bound) == 0 { if len(bound) == 0 {
continue 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) eventType, subID, known := s.miniSlotSchedule(contentType, contentID)
if !known { if !known {
return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type %d unsupported", hub.UID, num(slot, 10), contentType) 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/events"
"bd2server/internal/server/readonly" "bd2server/internal/server/readonly"
"bd2server/internal/server/wire" "bd2server/internal/server/wire"
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -165,7 +166,7 @@ func TestNormalAndMiniHubCalendarsUseSeparatePrefabs(t *testing.T) {
} }
} }
} }
func TestMiniHubExplicitUnsupportedQuizIsRejected(t *testing.T) { func TestMiniHubExplicitQuizUsesHubScopedCalendar(t *testing.T) {
s, _ := makeService(t) s, _ := makeService(t)
hub := wire.AppendVarint(nil, 14, 1003) hub := wire.AppendVarint(nil, 14, 1003)
hub = wire.AppendVarint(hub, 13, 1) 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[0] = hubField(1, 11)
field.Fields[5].Fields[1] = hubField(2, 13) field.Fields[5].Fields[1] = hubField(2, 13)
attachMiniCalendar(s, field) attachMiniCalendar(s, field)
if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err == nil { if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err != nil {
t.Fatal("unsupported quiz enabled with invented UID") 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) { 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 // Economy dispatches verified static rewards to their owning domains. It is
// called inside the same account transaction as the gameplay operation. // called inside the same account transaction as the gameplay operation.
type Economy struct { type Economy struct {
ownedDesign map[uint64]map[uint64]bool ownedDesign map[uint64]map[uint64]bool
mu sync.Mutex avatarRewards *gamedata.AvatarRewardDesign
store stateio.Store buffRewards *BuffRewards
items *player.Inventory mu sync.Mutex
wallet *player.Wallet store stateio.Store
collection *player.CollectionStore items *player.Inventory
equipment *player.EquipmentInventory wallet *player.Wallet
costumes player.CostumeDesignSource collection *player.CollectionStore
options equipmentRoller equipment *player.EquipmentInventory
graph rewardResolver costumes player.CostumeDesignSource
hunting HuntingAP options equipmentRoller
prestige map[uint64]uint64 graph rewardResolver
apCaps map[uint64]uint64 hunting HuntingAP
resetSeconds int64 prestige map[uint64]uint64
now func() time.Time apCaps map[uint64]uint64
initial 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) { 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 // AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These
// balances use a gameplay entry, leaving the frozen wallet schema unchanged. // 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) AttachHuntingAP(h HuntingAP) { e.hunting = h }
func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) { 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 apCurrency(t uint64) bool { return t == 21 || t == 23 }
func inventoryType(t uint64) bool { func inventoryType(t uint64) bool {
switch t { 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 true
} }
return false return false
@@ -275,7 +279,11 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
if err != nil { if err != nil {
return nil, err 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 itemRewards []gamedata.BattleReward
var costumes []uint64 var costumes []uint64
var equips []player.Equipment var equips []player.Equipment
@@ -303,6 +311,12 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
} }
s.Balances[r.Type] += r.Count s.Balances[r.Type] += r.Count
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: 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: case r.Type == 11:
if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 { if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 {
return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID) 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) equips = append(equips, entry)
} }
case inventoryType(r.Type): 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] { if !e.ownedDesign[r.Type][r.ID] {
return nil, fmt.Errorf("events: unknown owned item design %d:%d", 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 r.Type == 45 {
if _, ok := e.prestige[r.ID]; !ok { if _, ok := e.prestige[r.ID]; !ok {
return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID) 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) 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 { if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil {
return nil, err return nil, err
} }
@@ -375,6 +400,11 @@ func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewar
return nil, err 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) items, err := e.items.GrantOnce(identity+":items", itemRewards)
if err != nil { if err != nil {
return nil, err 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 var out []byte
for _, item := range e.items.All() { 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)) 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 NominalPaidDiamonds uint64
} }
type CashShopDesign struct{ GroupID, ID, ProductGroupID, BulkOrderShow 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 CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 }
type CashCatalog struct { type CashCatalog struct {
Products []CashProductDesign Products []CashProductDesign
Shops []CashShopDesign Shops []CashShopDesign
Packages []CashPackageDesign Packages []CashPackageDesign
EventShops []EventShopDesign
} }
func LoadCashCatalog(root, version string) (*CashCatalog, error) { func LoadCashCatalog(root, version string) (*CashCatalog, error) {
@@ -114,6 +116,19 @@ func loadCashCatalog(db *sql.DB) (*CashCatalog, error) {
}); err != nil { }); err != nil {
return nil, err 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 return c, nil
} }
func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error { func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error {
@@ -13,7 +13,7 @@ func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) {
} }
defer db.Close() defer db.Close()
db.SetMaxOpenConns(1) 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 { if _, err = db.Exec(q); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -7,7 +7,7 @@ func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool,
} }
defer done() defer done()
out := map[uint64]map[uint64]bool{} 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) rows, e := db.Query("SELECT id FROM " + table)
if e != nil { if e != nil {
return nil, e 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} } 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 { row := func(id uint64, independent bool) readonly.Field {
flag := uint64(0) flag := uint64(0)
@@ -36,24 +36,31 @@ func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T)
}} }}
} }
s := &Service{now: func() time.Time { return time.UnixMilli(150) }, 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": { 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) } s.now = func() time.Time { return time.UnixMilli(now) }
_, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1)) _, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var ids []uint64 var ids []uint64
var lastRegular, lastIndependent uint64
history := 0 history := 0
if err := wire.Walk(response, func(f wire.Field) error { if err := wire.Walk(response, func(f wire.Field) error {
if f.Number == 1 { if f.Number == 1 {
nested, _, _ := wire.Bytes(f.Value, 1) nested, _, _ := wire.Bytes(f.Value, 1)
id, _, _ := wire.Varint(nested, 1) id, _, _ := wire.Varint(nested, 1)
ids = append(ids, id) ids = append(ids, id)
flag, _, _ := wire.Varint(f.Value, 6)
if flag == 0 {
lastRegular = id
} else {
lastIndependent = id
}
} }
if f.Number == 3 { if f.Number == 3 {
history++ history++
@@ -63,13 +70,17 @@ func TestScheduleProjectsOneRegularPreservesIndependentAndHistory(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
want := uint64(1) want := uint64(1)
if now == 350 { if now >= 300 {
want = 2 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) 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") 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) { func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) {
current := s.current() 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"] response := s.seed.Responses["/MonsterHuntScheduleInfo"]
fields := make([]readonly.Field, 0, len(response.Fields)) fields := make([]readonly.Field, 0, len(response.Fields))
var selected []readonly.Field
for _, field := range response.Fields { for _, field := range response.Fields {
if field.Number == 1 && field.Type == 2 { if field.Number == 1 && field.Type == 2 {
var id uint64 var id uint64
@@ -265,12 +273,17 @@ func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) {
independent = f.Varint != 0 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 continue
} }
} }
fields = append(fields, field) fields = append(fields, field)
} }
fields = append(fields, selected...)
// Keep the entire loaded calendar immutable; projection only affects this // Keep the entire loaded calendar immutable; projection only affects this
// response so subsequent requests can cross a season boundary without reload. // response so subsequent requests can cross a season boundary without reload.
projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{ projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{
@@ -10,6 +10,7 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"time" "time"
) )
@@ -77,7 +78,7 @@ func TestBattleSessionProgressRewardsAndPersistentRetry(t *testing.T) {
if _, ok, _ := wire.Bytes(out, 7); !ok { if _, ok, _ := wire.Bytes(out, 7); !ok {
t.Fatal("clear reward missing") 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 { if u.ClearLevel != 1 || u.Level != 2 || !u.Played {
t.Fatalf("progress=%+v", u) 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) AwakeContributions func(player.Character) ([]gamedata.StatContribution, error)
EquipmentContributions func(player.Character) ([]gamedata.StatContribution, error) EquipmentContributions func(player.Character) ([]gamedata.StatContribution, error)
PotentialContributions 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 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 } type key struct{ category, stat uint64 }
values := map[key]float64{} 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 { for _, entry := range entries {
if entry.BuffID == 0 { if entry.BuffID == 0 {
continue continue
+1
View File
@@ -87,6 +87,7 @@ public sealed class Plugin : BaseUnityPlugin
PatchGeneratedReceiver("<SendLoginPassRequest>b__0", nameof(LoginPassRewards)); PatchGeneratedReceiver("<SendLoginPassRequest>b__0", nameof(LoginPassRewards));
PatchGeneratedReceiver("<SendEventRewardRequest>b__0", nameof(EventRewards)); PatchGeneratedReceiver("<SendEventRewardRequest>b__0", nameof(EventRewards));
PassRewardPresentation.Install(harmony); PassRewardPresentation.Install(harmony);
RewardBuffRefresh.Install(harmony);
MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null, MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null,
new[] { typeof(IMessage), typeof(int), typeof(int) }, null); new[] { typeof(IMessage), typeof(int), typeof(int) }, null);
if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)"); 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", "local_identity": "0.6.1",
"capture_environment": "0.2.0", "capture_environment": "0.2.0",
"login_ui": "0.2.0", "login_ui": "0.2.0",
"cash_shop": "0.1.0" "cash_shop": "0.1.1"
} }
} }