Files
bd2/go/internal/server/hunting/progress.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

225 lines
5.2 KiB
Go

package hunting
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"fmt"
"strconv"
)
type battleReceipt struct {
Fingerprint string
Bundle []byte
Monsters [][]byte
}
type battleLedger struct {
Version string `json:"version"`
Receipts map[string]battleReceipt `json:"receipts"`
}
func (s *Service) loadBattleReceipts() (map[string]battleReceipt, error) {
b, e := s.storage.Load("huntingbattle")
if e != nil {
return nil, e
}
if b == nil {
return map[string]battleReceipt{}, nil
}
if e = stateio.RequireExactJSONObject(b, "version", "receipts"); e != nil {
return nil, e
}
var l battleLedger
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if e = d.Decode(&l); e != nil {
return nil, e
}
if l.Version != versionconfig.State() || l.Receipts == nil {
return nil, fmt.Errorf("hunting: invalid battle ledger")
}
return l.Receipts, nil
}
func (s *Service) saveBattleReceipts(rs map[string]battleReceipt) error {
b, e := json.Marshal(battleLedger{versionconfig.State(), rs})
if e != nil {
return e
}
return s.storage.Save("huntingbattle", b)
}
func (s *Service) AttachEligibility(check func(int, uint64) error) {
s.mu.Lock()
defer s.mu.Unlock()
s.eligibility = check
}
func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.grant = grant
}
// EnsureForPack initializes the ordinary stage when the world loads a pack.
func (s *Service) EnsureForPack(pack int) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
d, e := s.load(pack)
if e != nil {
return nil, e
}
if len(d.Grounds) == 0 {
return nil, nil
}
st := s.state.Packs[strconv.Itoa(pack)]
if st.Current == 0 {
st.Current = d.Grounds[0].ID
next := s.clone()
next.Packs[strconv.Itoa(pack)] = st
if e = s.persist(next); e != nil {
return nil, e
}
}
return s.info(pack, d)
}
func (s *Service) SnapshotForPack(pack int) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
d, e := s.load(pack)
if e != nil {
return nil, e
}
if len(d.Grounds) == 0 {
return nil, nil
}
return s.info(pack, d)
}
// Ground mutations have their own persisted session+sequence replay ledger.
func (s *Service) handleGroundSession(path string, req []byte, session string) (int, []byte, bool, error) {
if path != "/HuntingGroundEnter" {
return s.Handle(path, req)
}
seq, found, e := wire.Varint(req, 1)
if e != nil || !found || seq == 0 {
return 110, nil, true, fmt.Errorf("hunting: missing sequence")
}
key := "enter:" + session + ":" + strconv.FormatUint(seq, 10)
rs, e := s.loadBattleReceipts()
if e != nil {
return 110, nil, true, e
}
fp := fmt.Sprintf("%x", req)
if r, ok := rs[key]; ok {
if r.Fingerprint != fp {
return 110, nil, true, fmt.Errorf("hunting: enter sequence conflict")
}
return 110, r.Bundle, true, nil
}
code, out, ok, e := s.Handle(path, req)
if e != nil || !ok {
return code, out, ok, e
}
rs[key] = battleReceipt{Fingerprint: fp, Bundle: out}
if e = s.saveBattleReceipts(rs); e != nil {
return code, nil, true, e
}
return code, out, ok, nil
}
func (s *Service) reload() error {
b, e := s.storage.Load("hunting")
if e != nil {
return e
}
if b == nil {
return nil
}
var st snapshot
if e = json.Unmarshal(b, &st); e != nil {
return e
}
s.state = st
return nil
}
func (s *Service) CanExchangeAP(costs, rewards []gamedata.Reward) error {
s.mu.Lock()
defer s.mu.Unlock()
if e := s.reload(); e != nil {
return e
}
if e := s.refreshAP(); e != nil {
return e
}
_, e := s.exchangeAP(costs, rewards)
return e
}
func (s *Service) exchangeAP(costs, rewards []gamedata.Reward) (snapshot, error) {
st := s.clone()
apply := func(rs []gamedata.Reward, cost bool) error {
for _, r := range rs {
if (r.Type != 21 && r.Type != 23) || r.ID != 0 || r.Count == 0 {
return fmt.Errorf("hunting: invalid AP exchange")
}
p := &st.Free
if r.Type == 23 {
p = &st.Bonus
}
if cost {
if *p < r.Count {
return fmt.Errorf("hunting: insufficient AP")
}
*p -= r.Count
} else {
if r.Count > 2147483647-*p {
return fmt.Errorf("hunting: AP overflow")
}
*p += r.Count
}
}
return nil
}
if e := apply(costs, true); e != nil {
return st, e
}
return st, apply(rewards, false)
}
func (s *Service) ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error {
s.mu.Lock()
defer s.mu.Unlock()
if identity == "" {
return fmt.Errorf("hunting: missing AP identity")
}
if e := s.reload(); e != nil {
return e
}
key := "apexchange:" + identity
fpb, e := json.Marshal([][]gamedata.Reward{costs, rewards})
if e != nil {
return e
}
rs, e := s.loadBattleReceipts()
if e != nil {
return e
}
fp := string(fpb)
if r, ok := rs[key]; ok {
if r.Fingerprint != fp {
return fmt.Errorf("hunting: AP identity conflict")
}
return nil
}
if e := s.refreshAP(); e != nil {
return e
}
st, e := s.exchangeAP(costs, rewards)
if e != nil {
return e
}
if e = s.persist(st); e != nil {
return e
}
rs[key] = battleReceipt{Fingerprint: fp}
return s.saveBattleReceipts(rs)
}