fix(all): complete native client and free gacha flows

This commit is contained in:
2026-10-01 01:19:42 +08:00
parent 197461718f
commit ba388f8a74
12 changed files with 327 additions and 39 deletions
+34
View File
@@ -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
+2
View File
@@ -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
}
+36
View File
@@ -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)
}
}
}
+9
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
//go:build !release && (dev || production)
package main
const wailsDevelopmentBuild = true
@@ -0,0 +1,5 @@
//go:build !release && !dev && !production
package main
const wailsDevelopmentBuild = false
+2 -1
View File
@@ -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)
}
+7 -5
View File
@@ -56,6 +56,7 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<noscript><p style="padding:20px">请启用 JavaScript 以使用客户端设置工具。</p></noscript>
<script>
(()=>{
'use strict';
let HOST_PLATFORM='',INITIAL_CDN='official',AUTO_OPEN=false;
const $=id=>document.getElementById(id);
@@ -96,7 +97,7 @@ function requireResources(){const p=payload();if(p.cdn_mode==='local'&&!p.local_
function setBusy(value){busy=value;document.querySelectorAll('button,input').forEach(e=>{e.disabled=value});document.body.setAttribute('aria-busy',String(value))}
async function action(id,fn){if(busy||transitioning)return;const b=$(id),old=b.innerHTML;setBusy(true);b.textContent=localized('请稍候…','Please wait…');try{await fn()}catch(e){const message=e.message||String(e);show(message,true);if(scene==='directory'||editorKind==='directory'){$('directoryResult').textContent=message;$('directoryResult').className='inline-result error'}if(scene==='server'||editorKind==='server'){$('serverResult').textContent=message;$('serverResult').className='inline-result error'}}finally{b.innerHTML=old;setBusy(false)}}
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function go(next){if(transitioning||scene===next)return;transitioning=true;const old=$(scene+'Scene'),target=$(next+'Scene');if(!reduced.matches){old.classList.add('is-leaving');$('veil').classList.add('play');await delay(570)}old.hidden=true;old.classList.remove('is-leaving','is-entering');scene=next;document.body.dataset.scene=next;target.hidden=false;target.classList.add('is-entering');$('journey').hidden=next==='desk'||next==='quit';document.querySelectorAll('[data-step]').forEach(e=>{const active=e.dataset.step===next;e.classList.toggle('active',active);if(active)e.setAttribute('aria-current','step');else e.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});$(next==='directory'?'directoryTitle':next==='server'?'serverTitle':next==='desk'?'deskTitle':'quitTitle').focus({preventScroll:true});if(!reduced.matches)await delay(680);$('veil').classList.remove('play');transitioning=false}
async function navigateScene(next){if(transitioning||scene===next)return;transitioning=true;const old=$(scene+'Scene'),target=$(next+'Scene');if(!reduced.matches){old.classList.add('is-leaving');$('veil').classList.add('play');await delay(570)}old.hidden=true;old.classList.remove('is-leaving','is-entering');scene=next;document.body.dataset.scene=next;target.hidden=false;target.classList.add('is-entering');$('journey').hidden=next==='desk'||next==='quit';document.querySelectorAll('[data-step]').forEach(e=>{const active=e.dataset.step===next;e.classList.toggle('active',active);if(active)e.setAttribute('aria-current','step');else e.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});$(next==='directory'?'directoryTitle':next==='server'?'serverTitle':next==='desk'?'deskTitle':'quitTitle').focus({preventScroll:true});if(!reduced.matches)await delay(680);$('veil').classList.remove('play');transitioning=false}
async function inspect(){requireDirectory();const r=await api('inspect',payload());applyStatus(r.data);show(r.message);return r}
function openEditor(kind){if(busy||transitioning)return;editorKind=kind;$('editorTitle').textContent=kind==='directory'?localized('修改游戏目录','Change game directory'):localized('修改服务器地址','Change server address');$('editorContent').append($(kind+'Fields'));$('editor').showModal();$(kind==='directory'?'gameDir':'origin').focus()}
function closeEditor(){if(busy)return;$('editor').close()}
@@ -109,9 +110,9 @@ $('gameDir').addEventListener('input',()=>invalidate(true));$('origin').addEvent
$('browse').onclick=()=>action('browse',async()=>{const previous=payload().game_directory;const r=await api('browse',payload());if(r.data){applyStatus(r.data);if(payload().game_directory!==previous)$('policy').hidden=true}show(r.message)});
$('browseResources').onclick=()=>action('browseResources',async()=>{const r=await api('browseResources',payload());if(r.data){$('localResourceDir').value=r.data.local_directory||'';invalidate()}show(r.message)});
$('inspect').onclick=()=>action('inspect',inspect);$('refresh').onclick=()=>action('refresh',inspect);
$('directoryNext').onclick=()=>action('directoryNext',async()=>{await inspect();sync();await go('server')});
$('serverBack').onclick=()=>{if(!busy)go('directory')};
$('serverNext').onclick=()=>action('serverNext',async()=>{requireOrigin();sync();await go('desk')});
$('directoryNext').onclick=()=>action('directoryNext',async()=>{await inspect();sync();await navigateScene('server')});
$('serverBack').onclick=()=>{if(!busy)navigateScene('directory')};
$('serverNext').onclick=()=>action('serverNext',async()=>{requireOrigin();sync();await navigateScene('desk')});
$('checkResources').onclick=()=>action('checkResources',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('resources',payload()),p=r.data||{},missing=localized('未返回','Missing');$('policy').hidden=false;$('policy').textContent=p.mode==='official'?localized('OFFICIAL · 客户端使用版本锁定的官方地址','OFFICIAL · The client uses release-locked official URLs'):p.mode==='local'?`LOCAL · ${p.local_directory||payload().local_resource_directory}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}`:`SERVER · ${localized('由服务器提供资源地址','Resource URLs supplied by the server')}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}\nBundle · ${p.bundle_version||missing}\nGameData · ${p.game_data_version||missing}`;show(r.message)});
function saved(){savedSignature=signature();status.config=true;sync()}
$('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('save',payload());saved();show(r.message)});
@@ -120,8 +121,9 @@ $('install').onclick=()=>action('install',async()=>{requireDirectory();requireOr
$('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('launch',payload());saved();show(r.message)});
$('quit').onclick=()=>{if(!busy){clearTimeout(toastTimer);bridge().Quit().catch(error=>show(error.message||String(error),true))}};
$('gameDir').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('inspect'):$('directoryNext')).click()}});$('origin').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('doneEditor'):$('serverNext')).click()}});
async function initialize(){setBusy(true);try{const initial=await bridge().Initialize();HOST_PLATFORM=initial.platform;INITIAL_CDN=initial.cdn_mode;AUTO_OPEN=initial.auto_open;$('gameDir').value=initial.game_directory||'';$('origin').value=initial.server_origin||'http://127.0.0.1:8080';$('localResourceDir').value=initial.local_resource_directory||'';$('persistentLogPath').textContent=initial.log_path||'';const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}sync();if($('gameDir').value.trim()){await inspect();if(AUTO_OPEN){saved();await go('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
async function initialize(){setBusy(true);try{const initial=await bridge().Initialize();HOST_PLATFORM=initial.platform;INITIAL_CDN=initial.cdn_mode;AUTO_OPEN=initial.auto_open;$('gameDir').value=initial.game_directory||'';$('origin').value=initial.server_origin||'http://127.0.0.1:8080';$('localResourceDir').value=initial.local_resource_directory||'';$('persistentLogPath').textContent=initial.log_path||'';const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}sync();if($('gameDir').value.trim()){await inspect();if(AUTO_OPEN){saved();await navigateScene('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
initialize();
})();
</script>
</body>
</html>
@@ -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)
+76 -22
View File
@@ -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.
+110 -3
View File
@@ -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)
+37 -8
View File
@@ -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
}