Files
bd2/go/internal/server/hunting/dispatch.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
2026-10-05 11:50:31 +08:00

415 lines
12 KiB
Go

package hunting
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
"time"
)
type dispatchReceipt struct {
Request, Response []byte
Code int
}
type dispatchJob struct {
Group, ID, Count, Start, End, Free, Bonus uint64
Rewards []gamedata.BattleReward
Runs [][]gamedata.BattleReward
}
type dispatchState struct {
Version string `json:"version"`
Jobs map[string]dispatchJob `json:"jobs"`
Receipts map[string]dispatchReceipt `json:"receipts"`
}
func (s *Service) AttachDispatchEligibility(check func(*gamedata.DispatchDesign) error) {
s.mu.Lock()
defer s.mu.Unlock()
s.dispatchEligibility = check
}
func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
if strings.HasPrefix(path, "/HuntDispatch") {
return s.handleDispatch(path, req, session)
}
return s.handleGroundSession(path, req, session)
}
func (s *Service) handleDispatch(path string, req []byte, session string) (int, []byte, bool, error) {
code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path]
if path != "/HuntDispatchInfo" && path != "/HuntDispatchStart" && path != "/HuntDispatchEnd" && path != "/HuntDispatchRewardPreview" && path != "/HuntDispatch" {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if e := s.refreshAP(); e != nil {
return code, nil, true, e
}
seq, found, err := wire.Varint(req, 1)
if err != nil || !found || seq == 0 {
return code, nil, true, fmt.Errorf("hunting: missing sequence")
}
ds := dispatchState{Version: versionconfig.State(), Jobs: map[string]dispatchJob{}, Receipts: map[string]dispatchReceipt{}}
raw, err := s.storage.Load("huntdispatch")
if err != nil {
return code, nil, true, err
}
if raw != nil {
if err = stateio.RequireExactJSONObject(raw, "version", "jobs", "receipts"); err != nil {
return code, nil, true, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err = decoder.Decode(&ds); err != nil {
return code, nil, true, err
}
if ds.Version != versionconfig.State() || ds.Jobs == nil || ds.Receipts == nil {
return code, nil, true, fmt.Errorf("hunting: invalid dispatch state")
}
}
if path == "/HuntDispatchInfo" {
var out []byte
keys := make([]string, 0, len(ds.Jobs))
for k := range ds.Jobs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = wire.AppendBytes(out, 1, dispatchJobWire(ds.Jobs[k]))
}
// A client without a remembered dispatch pack needs a default. This
// server selects the smallest cleared ordinary hunting pack.
var minimum uint64
for key, state := range s.state.Packs {
if state.Highest == 0 {
continue
}
pack, err := strconv.ParseUint(key, 10, 32)
if err != nil || pack == 0 {
return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch pack")
}
if minimum == 0 || pack < minimum {
minimum = pack
}
}
if minimum != 0 {
out = wire.AppendVarint(out, 2, minimum)
}
return code, out, true, nil
}
for k, j := range ds.Jobs {
if k != fmt.Sprintf("%d:%d", j.Group, j.ID) || j.Group == 0 || j.ID == 0 || j.Count == 0 || j.Count > 20 || j.Start == 0 || j.End < j.Start || len(j.Runs) != int(j.Count) || j.Free > 2147483647 || j.Bonus > 2147483647 {
return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch job")
}
}
for k, r := range ds.Receipts {
if k == "" || len(r.Request) == 0 || r.Response == nil {
return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch receipt")
}
}
key := session + ":" + strconv.FormatUint(seq, 10)
if r, ok := ds.Receipts[key]; ok {
if !bytes.Equal(r.Request, append([]byte(path), req...)) {
return code, nil, true, fmt.Errorf("hunting: sequence reused with different dispatch request")
}
return r.Code, r.Response, true, nil
}
group, _, e := wire.Varint(req, 2)
id, _, e2 := wire.Varint(req, 3)
if e != nil || e2 != nil || group == 0 || id == 0 {
return code, nil, true, fmt.Errorf("hunting: invalid dispatch ID")
}
jobKey := fmt.Sprintf("%d:%d", group, id)
job, exists := ds.Jobs[jobKey]
d, err := s.dispatchLoad(group, id)
if err != nil {
return code, nil, true, err
}
var out []byte
if path == "/HuntDispatchRewardPreview" {
if !exists {
return code, nil, true, fmt.Errorf("hunting: dispatch not started")
}
played := dispatchPlayed(job, d.ClearTime)
out = wire.AppendVarint(out, 1, played)
preview := dispatchCompletedRewards(job, played)
remaining := d.AP * (job.Count - played)
bonus := remaining
if bonus > job.Bonus {
bonus = job.Bonus
}
free := remaining - bonus
if free > 0 {
preview = append(preview, gamedata.BattleReward{Type: 21, Count: free})
}
if bonus > 0 {
preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus})
}
bundle := dispatchPreview(preview)
for _, r := range preview {
v := wire.AppendVarint(nil, 2, r.ID)
v = wire.AppendVarint(v, 3, r.Type)
v = wire.AppendVarint(v, 4, r.Count)
bundle = wire.AppendBytes(bundle, 1, v)
}
out = wire.AppendBytes(out, 2, bundle)
return code, out, true, nil
}
if path == "/HuntDispatchEnd" {
if !exists {
return code, nil, true, fmt.Errorf("hunting: dispatch not started")
}
played := dispatchPlayed(job, d.ClearTime)
completed := dispatchCompletedRewards(job, played)
remaining := d.AP * (job.Count - played)
refundBonus := remaining
if refundBonus > job.Bonus {
refundBonus = job.Bonus
}
refundFree := remaining - refundBonus
next := s.clone()
next.Free += refundFree
next.Bonus += refundBonus
if e := s.persist(next); e != nil {
return code, nil, true, e
}
if refundFree > 0 {
completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree})
}
if refundBonus > 0 {
completed = append(completed, gamedata.BattleReward{Type: 23, Count: refundBonus})
}
bundle, e := s.dispatchGrant("dispatch:"+key, completed)
if e != nil {
return code, nil, true, e
}
out = wire.AppendBytes(out, 1, bundle)
delete(ds.Jobs, jobKey)
} else {
count, _, e := wire.Varint(req, 4)
if e != nil || count == 0 || count > 20 {
return code, nil, true, fmt.Errorf("hunting: dispatch count must be 1..20")
}
if exists {
return code, nil, true, fmt.Errorf("hunting: dispatch already running")
}
for _, active := range ds.Jobs {
design, e := s.dispatchLoad(active.Group, active.ID)
if e != nil {
return code, nil, true, e
}
if design.TypeGroup == d.TypeGroup {
return code, nil, true, fmt.Errorf("hunting: dispatch category already running")
}
}
if s.dispatchEligibility != nil {
if e = s.dispatchEligibility(d); e != nil {
return code, nil, true, e
}
} else {
if d.TypeGroup != 0 {
return code, nil, true, fmt.Errorf("hunting: SkyWay dispatch eligibility unavailable")
}
st := s.state.Packs[strconv.Itoa(int(d.Pack))]
if st.Highest < d.GroundID {
return code, nil, true, fmt.Errorf("hunting: dispatch requires cleared difficulty")
}
}
if d.AP > uint64(2147483647)/count {
return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow")
}
cost := d.AP * count
if s.state.Free < cost && s.state.Bonus < cost-s.state.Free {
return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP")
}
rewards, e := d.Roll(count, func(n uint64) (uint64, error) {
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
if e != nil {
return 0, e
}
return v.Uint64(), nil
})
if e != nil {
return code, nil, true, e
}
next := s.clone()
free := cost
if free > next.Free {
free = next.Free
}
bonus := cost - free
next.Free -= free
next.Bonus -= bonus
if path == "/HuntDispatchStart" {
now := uint64(time.Now().UnixMilli())
runs := make([][]gamedata.BattleReward, count)
for i := range runs {
runs[i], e = d.Roll(1, func(n uint64) (uint64, error) {
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
if e != nil {
return 0, e
}
return v.Uint64(), nil
})
if e != nil {
return code, nil, true, e
}
}
job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs}
ds.Jobs[jobKey] = job
out = wire.AppendBytes(out, 1, dispatchJobWire(job))
} else {
beforeFree, beforeBonus := s.state.Free, s.state.Bonus
bundle, e := s.dispatchGrant("dispatch:"+key, rewards)
if e != nil {
return code, nil, true, e
}
out = wire.AppendBytes(out, 1, bundle)
if s.state.Free >= beforeFree {
next.Free += s.state.Free - beforeFree
} else {
delta := beforeFree - s.state.Free
if next.Free < delta {
return code, nil, true, fmt.Errorf("hunting: AP grant conflict")
}
next.Free -= delta
}
if s.state.Bonus >= beforeBonus {
next.Bonus += s.state.Bonus - beforeBonus
} else {
delta := beforeBonus - s.state.Bonus
if next.Bonus < delta {
return code, nil, true, fmt.Errorf("hunting: AP grant conflict")
}
next.Bonus -= delta
}
}
if e = s.persist(next); e != nil {
return code, nil, true, e
}
}
ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code}
b, e := json.Marshal(ds)
if e != nil {
return code, nil, true, e
}
if e = s.storage.Save("huntdispatch", b); e != nil {
return code, nil, true, e
}
return code, out, true, nil
}
func dispatchPlayed(j dispatchJob, seconds uint64) uint64 {
if seconds == 0 {
return j.Count
}
now := uint64(time.Now().UnixMilli())
if now <= j.Start {
return 0
}
n := (now - j.Start) / (seconds * 1000)
if n > j.Count {
n = j.Count
}
return n
}
func dispatchJobWire(j dispatchJob) []byte {
var out []byte
for n, v := range map[int]uint64{1: j.Group, 2: j.ID, 3: j.Count, 4: j.Start, 5: j.End, 6: j.Free, 7: j.Bonus} {
out = wire.AppendVarint(out, n, v)
}
return out
}
func dispatchPreview(rs []gamedata.BattleReward) []byte {
var out []byte
for _, r := range rs {
v := wire.AppendVarint(nil, 2, r.ID)
v = wire.AppendVarint(v, 3, r.Type)
v = wire.AppendVarint(v, 4, r.Count)
out = wire.AppendBytes(out, 6, v)
}
return out
}
func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([]byte, error) {
if s.grant != nil {
var rewards []gamedata.Reward
var ap []gamedata.BattleReward
for _, r := range rs {
if r.Type == 21 || r.Type == 23 {
ap = append(ap, r)
} else {
rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
}
}
s.mu.Unlock()
bundle, err := s.grant(identity, rewards)
s.mu.Lock()
if err != nil {
return nil, err
}
bundle = append(bundle, dispatchPreview(ap)...)
for _, r := range ap {
v := wire.AppendVarint(nil, 3, r.Type)
v = wire.AppendVarint(v, 4, r.Count)
bundle = wire.AppendBytes(bundle, 1, v)
}
return bundle, nil
}
var currency []gamedata.Reward
var items []gamedata.BattleReward
for _, r := range rs {
switch r.Type {
case 21, 23:
continue
case 2, 3, 4, 12, 20:
currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
default:
items = append(items, r)
}
}
if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil {
return nil, err
}
granted, err := s.inventory.GrantOnce(identity+":items", items)
if err != nil {
return nil, err
}
if len(granted) == 0 {
granted = s.inventory.GrantedItems(identity + ":items")
}
out := dispatchPreview(rs)
for _, r := range rs {
if r.Type == 21 || r.Type == 23 {
v := wire.AppendVarint(nil, 3, r.Type)
v = wire.AppendVarint(v, 4, r.Count)
out = wire.AppendBytes(out, 1, v)
}
}
for _, r := range currency {
v := wire.AppendVarint(nil, 2, r.ID)
v = wire.AppendVarint(v, 3, r.Type)
v = wire.AppendVarint(v, 4, r.Count)
out = wire.AppendBytes(out, 1, v)
}
for _, item := range granted {
out = wire.AppendBytes(out, 1, player.ItemWire(item))
}
return out, nil
}
func dispatchCompletedRewards(j dispatchJob, n uint64) []gamedata.BattleReward {
var out []gamedata.BattleReward
for i := uint64(0); i < n && i < uint64(len(j.Runs)); i++ {
out = append(out, j.Runs[i]...)
}
return out
}