fix(all): complete native client and free gacha flows
This commit is contained in:
@@ -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,6 +2,8 @@
|
||||
|
||||
package main
|
||||
|
||||
func relaunchDevelopmentIfNeeded([]string) (bool, error) { return false, nil }
|
||||
|
||||
func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
|
||||
return args, clientRunOptions{}, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user