diff --git a/go/cmd/bd2client/dev_command.go b/go/cmd/bd2client/dev_command.go index 577c9fc..0a739b8 100644 --- a/go/cmd/bd2client/dev_command.go +++ b/go/cmd/bd2client/dev_command.go @@ -20,6 +20,40 @@ type clientDevelopmentConfig struct { GameDirectory string `json:"game_directory"` } +var runDevelopmentChild = func(root string, args []string) error { + command := exec.Command("go", args...) + command.Dir = filepath.Join(root, "go") + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Run() +} + +// relaunchDevelopmentIfNeeded preserves the documented plain `go run` +// command. Wails selects its real desktop backend with the `dev` or +// `production` build tag; without either it deliberately links a diagnostic +// placeholder. The outer process therefore rebuilds itself once with +// `production`, retaining the embedded frontend and normal native host while +// the project-level --dev option supplies development paths and plugin builds. +func relaunchDevelopmentIfNeeded(args []string) (bool, error) { + if len(args) == 0 || args[0] != "--dev" || wailsDevelopmentBuild { + return false, nil + } + if len(args) < 2 || args[1] != "run" { + return false, nil + } + root, err := findClientDevelopmentRoot() + if err != nil { + return false, err + } + childArgs := []string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run"} + childArgs = append(childArgs, args[2:]...) + if err := runDevelopmentChild(root, childArgs); err != nil { + return false, fmt.Errorf("run Wails development client: %w", err) + } + return true, nil +} + func developmentRunOptions(args []string) ([]string, clientRunOptions, error) { if len(args) == 0 || args[0] != "--dev" { return args, clientRunOptions{}, nil diff --git a/go/cmd/bd2client/dev_command_release.go b/go/cmd/bd2client/dev_command_release.go index 5f9e45a..53ced98 100644 --- a/go/cmd/bd2client/dev_command_release.go +++ b/go/cmd/bd2client/dev_command_release.go @@ -2,6 +2,8 @@ package main +func relaunchDevelopmentIfNeeded([]string) (bool, error) { return false, nil } + func developmentRunOptions(args []string) ([]string, clientRunOptions, error) { return args, clientRunOptions{}, nil } diff --git a/go/cmd/bd2client/dev_command_test.go b/go/cmd/bd2client/dev_command_test.go index 86f6511..1f3fdce 100644 --- a/go/cmd/bd2client/dev_command_test.go +++ b/go/cmd/bd2client/dev_command_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "testing" ) @@ -105,3 +106,38 @@ func TestDevelopmentRunOptionsRequiresRun(t *testing.T) { t.Fatal("development command without run unexpectedly succeeded") } } + +func TestDevelopmentRunRelaunchesWithWailsProductionHost(t *testing.T) { + if wailsDevelopmentBuild { + t.Skip("test exercises the untagged bootstrap process") + } + previous := runDevelopmentChild + t.Cleanup(func() { runDevelopmentChild = previous }) + var gotRoot string + var gotArgs []string + runDevelopmentChild = func(root string, args []string) error { + gotRoot = root + gotArgs = append([]string(nil), args...) + return nil + } + relaunched, err := relaunchDevelopmentIfNeeded([]string{"--dev", "run", "--game-dir", "example"}) + if err != nil || !relaunched { + t.Fatalf("relaunched=%v err=%v", relaunched, err) + } + if !filepath.IsAbs(gotRoot) { + t.Fatalf("root is not absolute: %q", gotRoot) + } + want := []string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run", "--game-dir", "example"} + if !reflect.DeepEqual(gotArgs, want) { + t.Fatalf("child args=%v want=%v", gotArgs, want) + } +} + +func TestDevelopmentRunDoesNotRelaunchOtherCommands(t *testing.T) { + for _, args := range [][]string{nil, {"--game-dir", "example"}, {"--dev"}, {"--dev", "other"}} { + relaunched, err := relaunchDevelopmentIfNeeded(args) + if err != nil || relaunched { + t.Fatalf("args=%v relaunched=%v err=%v", args, relaunched, err) + } + } +} diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go index 6c1dcb3..5c5d104 100644 --- a/go/cmd/bd2client/main.go +++ b/go/cmd/bd2client/main.go @@ -17,6 +17,15 @@ type clientRunOptions struct { } func main() { + relaunched, err := relaunchDevelopmentIfNeeded(os.Args[1:]) + if err != nil { + clientapp.ShowFatalError(err) + fmt.Fprintln(os.Stderr, "bd2client:", err) + os.Exit(1) + } + if relaunched { + return + } args, options, err := developmentRunOptions(os.Args[1:]) if err == nil { err = runClient(args, options) diff --git a/go/cmd/bd2client/wails_dev_tag.go b/go/cmd/bd2client/wails_dev_tag.go new file mode 100644 index 0000000..f7294b0 --- /dev/null +++ b/go/cmd/bd2client/wails_dev_tag.go @@ -0,0 +1,5 @@ +//go:build !release && (dev || production) + +package main + +const wailsDevelopmentBuild = true diff --git a/go/cmd/bd2client/wails_dev_tag_missing.go b/go/cmd/bd2client/wails_dev_tag_missing.go new file mode 100644 index 0000000..ef3bb52 --- /dev/null +++ b/go/cmd/bd2client/wails_dev_tag_missing.go @@ -0,0 +1,5 @@ +//go:build !release && !dev && !production + +package main + +const wailsDevelopmentBuild = false diff --git a/go/internal/client/app/app_test.go b/go/internal/client/app/app_test.go index c8f3494..2448df5 100644 --- a/go/internal/client/app/app_test.go +++ b/go/internal/client/app/app_test.go @@ -21,6 +21,7 @@ func TestEmbeddedStudioUsesNativeBindings(t *testing.T) { text := string(page) for _, marker := range []string{ "BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()", + "(()=>{", "async function navigateScene(next)", `id="directoryScene"`, `id="serverScene"`, `id="deskScene"`, `id="patch"`, `id="install"`, `id="launch"`, `value="official"`, `value="local"`, `value="server"`, @@ -32,7 +33,7 @@ func TestEmbeddedStudioUsesNativeBindings(t *testing.T) { t.Errorf("embedded desktop interface lacks %q", marker) } } - for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js"} { + for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js", "function go("} { if strings.Contains(text, obsolete) { t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete) } diff --git a/go/internal/client/app/web/index.html b/go/internal/client/app/web/index.html index 11347e8..3bf9926 100644 --- a/go/internal/client/app/web/index.html +++ b/go/internal/client/app/web/index.html @@ -56,6 +56,7 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
diff --git a/go/internal/server/gacha/first_gacha_test.go b/go/internal/server/gacha/first_gacha_test.go index 769fbc4..80acdec 100644 --- a/go/internal/server/gacha/first_gacha_test.go +++ b/go/internal/server/gacha/first_gacha_test.go @@ -76,6 +76,10 @@ func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) { if err != nil || !bytes.Equal(preview, replayPreview) { t.Fatalf("same preview request was not idempotent: err=%v", err) } + // session.Server activates every SessionAware handler before every request. + // Re-activating the same login between preview and confirmation must not + // discard the server-owned result selected by the player. + service.BeginSession("first-session") buyRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 20), 3, 1) code, response, handled, err := service.Handle("/GachaBuy", buyRequest) diff --git a/go/internal/server/gacha/gacha.go b/go/internal/server/gacha/gacha.go index aed72e7..61f5301 100644 --- a/go/internal/server/gacha/gacha.go +++ b/go/internal/server/gacha/gacha.go @@ -93,19 +93,17 @@ func (s *Service) FirstGachaCompleted() bool { return s.collection.FirstGachaCompleted() } -// BeginSession is called by the transport session after each successful -// login. Request sequence numbers restart with the client, so the login ID is -// part of the durable idempotency key: retries in one login remain idempotent, -// while a new login may legitimately reuse the same protobuf sequence. +// BeginSession activates the transport's current login. The transport calls +// it before every authenticated request, not only when the login is created. +// Request sequence numbers restart with the client, so the login ID remains +// part of durable idempotency keys. In-memory first-gacha previews are already +// keyed by that ID and must survive activation: preview and confirmation are +// separate requests, and clearing here would discard the result immediately +// before the client confirms it. func (s *Service) BeginSession(id string) { s.sessionMu.Lock() s.sessionID = id s.sessionMu.Unlock() - s.firstMu.Lock() - if s.firstPreviews != nil { - s.firstPreviews = make(map[string]firstGachaPreview) - } - s.firstMu.Unlock() } func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) { @@ -266,9 +264,6 @@ func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) { return 175, nil, true, nil } -// multiBuy is the client's AllGacha daily-free macro, not a paid multi-pull. -// Every item runs within the transport's one account transaction. 146 follows -// GachaBuy; its exact MultiBuy envelope awaits a 2.35.10 official capture. func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error) { const maxBatchSize = 256 var ids []uint64 @@ -341,7 +336,13 @@ func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error } } } - if allowance == 0 || !s.dailyFreeScheduleOpen(groupID) { + var open bool + var allowanceErr error + allowance, open, allowanceErr = s.dailyFreeAllowance(groupID, allowance) + if allowanceErr != nil { + return 146, nil, true, allowanceErr + } + if allowance == 0 || !open { return 146, nil, true, fmt.Errorf("gacha: unavailable daily free gacha %d", id) } requested[groupID]++ @@ -385,17 +386,25 @@ func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error return 146, response, true, nil } -func (s *Service) dailyFreeScheduleOpen(groupID uint64) bool { +func (s *Service) dailyFreeAllowance(groupID, base uint64) (uint64, bool, error) { if s.schedule == nil { - return false + return 0, false, nil } now := s.now().UnixMilli() for _, entry := range s.schedule.Schedules { - if entry.GroupID == groupID && !entry.FreeCountBonus && now >= 0 && uint64(now) >= entry.StartTime && uint64(now) <= entry.EndTime { - return true + if entry.GroupID != groupID || now < 0 || uint64(now) < entry.StartTime || uint64(now) > entry.EndTime { + continue } + if entry.FreeCountBonus { + bonus := s.regular.GachaEventAddFreeCount() + if ^uint64(0)-base < bonus { + return 0, false, errors.New("gacha: daily free allowance overflows") + } + base += bonus + } + return base, true, nil } - return false + return 0, false, nil } func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) { @@ -462,6 +471,17 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( group = gamedata.GachaGroupDesign{ID: id, PointCount: 1} } isDailyFree := buyType == 0 && design.Count == 1 && design.FreeCountDay != 0 && knownGroup + var freeDailyLimit uint64 + if isDailyFree { + var open bool + freeDailyLimit, open, err = s.dailyFreeAllowance(group.ID, design.FreeCountDay) + if err != nil { + return 146, nil, true, err + } + if !open { + return 146, nil, true, fmt.Errorf("gacha: daily free schedule is closed for group %d", group.ID) + } + } isDailyPaid := buyType == 2 && design.Count == 1 && design.DailyPayGachaCount != 0 && design.DailyPayGachaPriceCount != 0 && knownGroup isMoonrise := buyType == 3 && design.PriceType == moonriseTicketType && design.PriceID == moonriseTicketID && id == moonriseProductID && group.ID == paidTwelvePickGroupID validOrdinary := buyType == 1 && (design.PriceType == 2 || design.PriceType == 3) @@ -519,7 +539,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( day := dailyResetKey(s.now()) var dailyLimit uint64 if isDailyFree { - dailyLimit = design.FreeCountDay + dailyLimit = freeDailyLimit } else if isDailyPaid { dailyLimit = design.DailyPayGachaCount } @@ -558,6 +578,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( roll, fixedResult, err = design.RollWithCostumeFixedSelection(s.collection.GachaFixedCount(group.FixedID, 0), s.collection.GachaFixedCount(group.FixedID, 1), fixed, normalSelected, pitySelected) if err == nil { fixedStates = costumeFixedStates(fixed, fixedResult) + fixedStates = s.completeSharedFixedStates(fixed.ID, fixedStates) for _, sortID := range fixedResult.SelectionSorts { selectionApplySortIDs = append(selectionApplySortIDs, uint64(sortID)) } @@ -726,6 +747,18 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player if isDailyFree && len(tickets) != 0 { return 146, nil, true, errors.New("gacha: daily free equipment draw cannot use tickets") } + var freeDailyLimit uint64 + if isDailyFree { + var open bool + var err error + freeDailyLimit, open, err = s.dailyFreeAllowance(group.ID, design.FreeCountDay) + if err != nil { + return 146, nil, true, err + } + if !open { + return 146, nil, true, fmt.Errorf("gacha: daily free equipment schedule is closed for group %d", group.ID) + } + } if identity == "" { identity = s.requestIdentity(design.ID, seq) } @@ -740,7 +773,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player entries = append(entries, entry) } } else { - if isDailyFree && s.collection.GachaDailyCount(dailyResetKey(s.now()), group.ID, 0) >= design.FreeCountDay { + if isDailyFree && s.collection.GachaDailyCount(dailyResetKey(s.now()), group.ID, 0) >= freeDailyLimit { return 146, nil, true, fmt.Errorf("gacha: daily equipment draw exhausted group=%d", group.ID) } var ticketCount uint64 @@ -779,6 +812,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player var fixedStates []player.GachaFixedState if !design.TicketOnly { fixedStates = []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} + fixedStates = s.completeSharedFixedStates(fixed.ID, fixedStates) } for sort, equipmentID := range roll { main, sub, private, err := s.equipmentCatalog.RollOptions(equipmentID) @@ -806,7 +840,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player } else { purchase := player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates} if isDailyFree { - purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), design.FreeCountDay + purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), freeDailyLimit } grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), purchase) } @@ -850,6 +884,26 @@ func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFi return states } +func (s *Service) completeSharedFixedStates(fixedID uint64, updates []player.GachaFixedState) []player.GachaFixedState { + if fixedID == 0 || s.equipmentCatalog == nil || s.equipmentCatalog.Fixed().ID != fixedID { + return updates + } + states := make([]player.GachaFixedState, 4) + for fixedType := range states { + states[fixedType] = player.GachaFixedState{ + FixedID: fixedID, Type: uint64(fixedType), + Count: s.collection.GachaFixedCount(fixedID, uint64(fixedType)), ApplySort: -1, + } + } + for _, update := range updates { + if update.FixedID != fixedID || update.Type >= uint64(len(states)) { + return updates + } + states[update.Type] = update + } + return states +} + func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, bool, error) { groupID, found, err := wire.Varint(request, 2) if err != nil || !found || groupID == 0 { @@ -1234,7 +1288,7 @@ func (s *Service) gachaInfo() []byte { return out } -// The 2.35.10 client converts Unix time to UTC+9 and applies GameData's +// The client converts Unix time to UTC+9 and applies GameData's // 09:00:00 DailyResetTime. That boundary is exactly 00:00 UTC. Keeping the // key independent of the host OS timezone prevents an early reset on a // Chinese development machine. diff --git a/go/internal/server/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go index 4abcd68..01c3228 100644 --- a/go/internal/server/gacha/gacha_test.go +++ b/go/internal/server/gacha/gacha_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "errors" + "fmt" "os" "path/filepath" "testing" @@ -613,6 +614,67 @@ func multiBuyRequest(seq uint64, packed bool, ids ...uint64) []byte { return request } +func multiFixedSnapshots(t *testing.T, response []byte, wantIDs []uint64) [][4]uint64 { + t.Helper() + index := 0 + snapshots := make([][4]uint64, 0, len(wantIDs)) + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number != 1 || field.Type != 2 || index >= len(wantIDs) { + return errors.New("unexpected multi-buy result") + } + id, found, err := wire.Varint(field.Value, 1) + if err != nil || !found || id != wantIDs[index] { + return fmt.Errorf("result %d id=%d found=%v want=%d", index, id, found, wantIDs[index]) + } + point, found, err := wire.Varint(field.Value, 3) + if err != nil || !found || point != 1 { + return fmt.Errorf("result %d point=%d found=%v", index, point, found) + } + seen := [4]bool{} + counts := [4]uint64{} + if err := wire.Walk(field.Value, func(resultField wire.Field) error { + if resultField.Number != 4 { + return nil + } + fixedID, fixedFound, err := wire.Varint(resultField.Value, 1) + if err != nil || !fixedFound || fixedID != 1 { + return fmt.Errorf("result %d fixed id=%d found=%v", index, fixedID, fixedFound) + } + fixedType, _, err := wire.Varint(resultField.Value, 2) + if err != nil || fixedType >= uint64(len(seen)) || seen[fixedType] { + return fmt.Errorf("result %d invalid fixed type=%d", index, fixedType) + } + count, _, err := wire.Varint(resultField.Value, 3) + if err != nil { + return err + } + applySort, applyFound, err := wire.Varint(resultField.Value, 4) + if err != nil || !applyFound || applySort != ^uint64(0) { + return fmt.Errorf("result %d type=%d apply=%d found=%v", index, fixedType, applySort, applyFound) + } + seen[fixedType] = true + counts[fixedType] = count + return nil + }); err != nil { + return err + } + for fixedType, found := range seen { + if !found { + return fmt.Errorf("result %d missing fixed type %d", index, fixedType) + } + } + snapshots = append(snapshots, counts) + index++ + return nil + }); err != nil { + t.Fatal(err) + } + if index != len(wantIDs) { + t.Fatalf("results=%d want=%d", index, len(wantIDs)) + } + return snapshots +} + func TestGachaMultiBuyPreservesOrderRepeatedIDsAndRestartReplay(t *testing.T) { storage := stateio.NewMemory() service := newMultiBuyTestService(t, storage) @@ -696,6 +758,25 @@ func TestGachaMultiBuyInvalidOrExcessBatchDoesNotGrantAnySlot(t *testing.T) { } } +func TestGachaMultiBuyUsesGameDefaultBonusAllowanceAndRepeatedIDs(t *testing.T) { + service := newMultiBuyTestService(t, stateio.NewMemory()) + if err := service.regular.SetGachaEventAddFreeCount(1); err != nil { + t.Fatal(err) + } + service.schedule.Schedules[0].FreeCountBonus = true + request := multiBuyRequest(1, true, 11, 11, 11) + if code, response, handled, err := service.Handle("/GachaMultiBuy", request); err != nil || !handled || code != 146 || countFields(response, 1) != 3 { + t.Fatalf("bonus multi code=%d results=%d handled=%v err=%v", code, countFields(response, 1), handled, err) + } + if got := service.collection.GachaDailyCount("2026-09-30", 1, 0); got != 3 { + t.Fatalf("bonus daily count=%d want=3", got) + } + service.BeginSession("bonus-next-login") + if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(2, true, 11)); err == nil { + t.Fatal("bonus allowance accepted a fourth draw") + } +} + func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") repo, err := accountstate.Open(path) @@ -749,6 +830,9 @@ func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) { if err != nil { t.Fatal(err) } + if bonus := regular.GachaEventAddFreeCount(); bonus != 1 { + t.Fatalf("2.35.10 GachaEventAddFreeCount=%d want=1", bonus) + } equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{72, 153, 206, 208}) if err != nil { t.Fatal(err) @@ -763,15 +847,37 @@ func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) { for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} { service.schedule.Schedules = append(service.schedule.Schedules, ScheduleWindow{GroupID: groupID, StartTime: 1, EndTime: 2000000000000}) } - ids := []uint64{10100040, 20100034, 10100113, 20100070, 10100145, 20100082, 10100146, 20100083} - request := multiBuyRequest(99, true, ids...) + // Exact ordered ID list captured from the official 2.35.10 client at + // 2026-10-01 00:44:17. Each result must carry the complete fixed-id 1 + // snapshot, including the untouched equipment counters. + ids := []uint64{10100146, 10100145, 10100040, 10100113} + request := multiBuyRequest(98, true, ids...) code, response, handled, err := service.Handle("/GachaMultiBuy", request) if err != nil || !handled || code != 146 { t.Fatalf("installed mixed batch: code=%d handled=%v err=%v", code, handled, err) } - if countFields(response, 1) != 8 || len(equipment.All()) != 4 { + if countFields(response, 1) != 4 || len(equipment.All()) != 0 { t.Fatalf("results=%d equipment=%d", countFields(response, 1), len(equipment.All())) } + costumeSnapshots := multiFixedSnapshots(t, response, ids) + for index, snapshot := range costumeSnapshots { + if snapshot[2] != 0 || snapshot[3] != 0 { + t.Fatalf("costume result %d changed equipment counters: %v", index, snapshot) + } + } + wantCostumeCounters := costumeSnapshots[len(costumeSnapshots)-1] + equipmentIDs := []uint64{20100034, 20100070, 20100082, 20100083} + equipmentRequest := multiBuyRequest(99, true, equipmentIDs...) + _, equipmentResponse, _, err := service.Handle("/GachaMultiBuy", equipmentRequest) + if err != nil || countFields(equipmentResponse, 1) != 4 || len(equipment.All()) != 4 { + t.Fatalf("equipment results=%d equipment=%d err=%v", countFields(equipmentResponse, 1), len(equipment.All()), err) + } + equipmentSnapshots := multiFixedSnapshots(t, equipmentResponse, equipmentIDs) + for index, snapshot := range equipmentSnapshots { + if snapshot[0] != wantCostumeCounters[0] || snapshot[1] != wantCostumeCounters[1] { + t.Fatalf("equipment result %d lost costume counters: got=%v want=%v", index, snapshot, wantCostumeCounters) + } + } if service.wallet.Snapshot().FreeJewelry != 1000 { t.Fatal("installed free batch spent diamonds") } @@ -1227,6 +1333,7 @@ func TestDailyFreeAndPaidSingleDrawsResetByUTCDate(t *testing.T) { } now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) service.now = func() time.Time { return now } + service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 205, StartTime: 1, EndTime: 2000000000000}}} buy := func(seq, buyType uint64) error { request := wire.AppendVarint(nil, 1, seq) request = wire.AppendVarint(request, 2, 101) diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go index 7439ff3..6e6ffb1 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -137,14 +137,30 @@ type GachaFixedRoll struct { } type RegularGachaCatalog struct { - Gachas map[uint64]RegularGacha - characters map[uint64]CharacterDesign - groups map[uint64]GachaGroupDesign - byGacha map[uint64]uint64 - fixed map[uint64]GachaFixedDesign - grades map[uint64]uint64 - stepUps map[uint64]GachaStepUpDesign - stepByGacha map[uint64]GachaStepDesign + Gachas map[uint64]RegularGacha + gachaEventAddFreeCount uint64 + characters map[uint64]CharacterDesign + groups map[uint64]GachaGroupDesign + byGacha map[uint64]uint64 + fixed map[uint64]GachaFixedDesign + grades map[uint64]uint64 + stepUps map[uint64]GachaStepUpDesign + stepByGacha map[uint64]GachaStepDesign +} + +func (c *RegularGachaCatalog) GachaEventAddFreeCount() uint64 { + if c == nil { + return 0 + } + return c.gachaEventAddFreeCount +} + +func (c *RegularGachaCatalog) SetGachaEventAddFreeCount(count uint64) error { + if c == nil || count > 256 { + return errors.New("gamedata: invalid gacha event free-count bonus") + } + c.gachaEventAddFreeCount = count + return nil } // GachaMigrationFact is the minimal stable GameData view consumed by the @@ -1021,6 +1037,19 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{}, stepUps: map[uint64]GachaStepUpDesign{}, stepByGacha: map[uint64]GachaStepDesign{}, } + var defaults []byte + if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil { + return nil, fmt.Errorf("gamedata: gacha defaults: %w", err) + } + bonusCounts, err := packedInts(defaults, 48) + if err != nil || len(bonusCounts) > 1 { + return nil, errors.New("gamedata: malformed GachaEventAddFreeCount") + } + if len(bonusCounts) == 1 { + if err := catalog.SetGachaEventAddFreeCount(bonusCounts[0]); err != nil { + return nil, err + } + } for id, character := range infinite.characters { catalog.characters[id] = character }