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.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZeroUIDMissionGroupsKeepIndependentClaimsAfterRestart(t *testing.T) {
|
||||
s, economy, store := setup(t)
|
||||
now := s.now().UnixMilli()
|
||||
rows := []events.Schedule{{Type: 4, ID: 7, Start: now - 1, End: now + 10000}, {Type: 4, ID: 8, Start: now - 1, End: now + 10000}}
|
||||
if err := s.registry.(*events.Registry).Replace(rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{9}}
|
||||
if err := s.RecordEvent(2, 0, 2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claim := func(seq, group uint64) []byte {
|
||||
b := wire.AppendVarint(req(seq), 3, 2)
|
||||
b = wire.AppendVarint(b, 4, 9)
|
||||
b = wire.AppendVarint(b, 5, 10)
|
||||
return wire.AppendVarint(b, 6, group)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/MissionClear", claim(1, 7)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.mission(rows[0], 10).Claimed || s.mission(rows[1], 10).Claimed {
|
||||
t.Fatal("claim leaked across zero-UID groups")
|
||||
}
|
||||
restarted, err := Open(store, s.design, s.registry, economy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted.now = s.now
|
||||
if !restarted.mission(rows[0], 10).Claimed || restarted.mission(rows[1], 10).Claimed {
|
||||
t.Fatal("independent claims lost after restart")
|
||||
}
|
||||
if _, _, _, err := restarted.Handle("/MissionClear", claim(2, 8)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if economy.calls != 2 {
|
||||
t.Fatalf("reward grants=%d", economy.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveCalendarDoesNotHideActivePassMissions(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
now := s.now().UnixMilli()
|
||||
if err := s.registry.(*events.Registry).Replace([]events.Schedule{
|
||||
{UID: 2, Type: 4, ID: 7, Start: now - 20, End: now - 10},
|
||||
{UID: 3, Type: 5, ID: 8, Start: now - 1, End: now + 10000},
|
||||
{UID: 4, Type: 4, ID: 9, Start: now + 10000, End: now + 20000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RecordEvent(2, 0, 1, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, out, _, err := s.Handle("/EventMissionInfo", req(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
if err := wire.Walk(out, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
count++
|
||||
if scalar(f.Value, 1) != 7 || scalar(f.Value, 4) != 1 {
|
||||
t.Fatalf("unexpected mission entry %x", f.Value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("active mission count=%d", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/world"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) BeginSession(id string) { s.SetSession(id) }
|
||||
func (s *Service) BeforeDispatch(string, []byte) error {
|
||||
if s.provider == nil {
|
||||
return nil
|
||||
}
|
||||
var e error
|
||||
s.before, e = s.provider.Snapshot()
|
||||
return e
|
||||
}
|
||||
func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p }
|
||||
func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) {
|
||||
if s.provider != nil {
|
||||
after, e := s.provider.Snapshot()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rk := fmt.Sprintf("observer:%s:%s:%d", s.session, path, scalar(request, 1))
|
||||
s.mu.Lock()
|
||||
_, seen := s.state.Receipts[rk]
|
||||
s.mu.Unlock()
|
||||
if !seen {
|
||||
for kind, old := range s.before.Items {
|
||||
current := after.Items[kind]
|
||||
if current < old {
|
||||
condition := uint64(12)
|
||||
if kind[1] == 0 {
|
||||
condition = 11
|
||||
}
|
||||
if e = s.RecordEvent(condition, kind[0], old-current, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, current := range after.Items {
|
||||
old := s.before.Items[kind]
|
||||
if current > old {
|
||||
if e = s.RecordEvent(32, kind[0], current-old, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx, current := range after.Equipment {
|
||||
old, ok := s.before.Equipment[idx]
|
||||
if ok && current.Level > old.Level {
|
||||
if e = s.RecordEvent(14, 0, current.Level-old.Level, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx, current := range after.Costumes {
|
||||
old, ok := s.before.Costumes[idx]
|
||||
if ok && current.Level > old.Level {
|
||||
if e = s.RecordEvent(104, current.ID, current.Level-old.Level, s.unlocked); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.state.Receipts[rk] = receipt{Digest: "observer"}
|
||||
e = s.save()
|
||||
s.mu.Unlock()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.Notify()
|
||||
}
|
||||
|
||||
func (s *Service) CompleteSingleTargetEvent(condition uint64, unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
before, _ := json.Marshal(s.state)
|
||||
for _, v := range s.taskSchedules() {
|
||||
if !s.active(v) {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.design.Missions {
|
||||
if t.Type != condition || t.SubType != 0 || t.Target != 1 || len(t.Params) != 0 || !s.availableTask(v, t) {
|
||||
continue
|
||||
}
|
||||
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) {
|
||||
continue
|
||||
}
|
||||
s.mission(v, t.ID).Value = 1
|
||||
}
|
||||
}
|
||||
if e := s.save(); e != nil {
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) {
|
||||
for _, paid := range []bool{false, true} {
|
||||
s, eco, store := setup(t)
|
||||
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 1, Ticket: 77}
|
||||
s.design.AttendanceRewards[1] = []gamedata.EventAttendanceReward{{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}, Premium: gamedata.Reward{Type: 4, Count: 100}}}
|
||||
s.AttachAttendancePremium(func(ticket uint64) bool { return paid && ticket == 77 })
|
||||
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claim := wire.AppendVarint(req(2), 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, 1)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
code, response, handled, err := s.Handle("/EventReward", claim)
|
||||
if err != nil || code != 151 || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || !bytes.Equal(bundle, []byte{10, 0}) {
|
||||
t.Fatal("actual bundle absent", bundle, err)
|
||||
}
|
||||
receipt, ok, err := wire.Bytes(response, 1002)
|
||||
if err != nil || !ok || string(receipt) != "test:/EventReward:2" {
|
||||
t.Fatal("durable receipt absent", string(receipt), err)
|
||||
}
|
||||
want := 1
|
||||
if paid {
|
||||
want = 2
|
||||
}
|
||||
if len(eco.rewards) != want {
|
||||
t.Fatal("premium ticket ignored", paid, eco.rewards)
|
||||
}
|
||||
if _, replay, _, err := s.Handle("/EventReward", claim); err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
|
||||
t.Fatal("claim replay duplicated", err)
|
||||
}
|
||||
reopened, err := Open(store, s.design, s.registry, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.SetSession("test")
|
||||
_, replay, _, err := reopened.Handle("/EventReward", claim)
|
||||
if err != nil || eco.calls != 1 || !bytes.Equal(replay, response) {
|
||||
t.Fatal("restart lost reward envelope", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
// Package eventtasks owns attendance, scheduled missions and pass progress.
|
||||
// Calendar policy is supplied by events.Resolver; requests never create dates
|
||||
// or manufacture progress. The parent request transaction includes all saves.
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
"bd2server/internal/server/world"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Economy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}
|
||||
type attendance struct {
|
||||
Group, Count uint64
|
||||
LastDay string
|
||||
Obtained map[string]bool
|
||||
History map[string]bool
|
||||
}
|
||||
type mission struct {
|
||||
Value uint64
|
||||
Claimed bool
|
||||
Period string
|
||||
}
|
||||
type pass struct {
|
||||
Exp uint64
|
||||
Premium bool
|
||||
Claimed map[string]bool
|
||||
}
|
||||
type receipt struct {
|
||||
Digest string
|
||||
Code int
|
||||
Reply []byte
|
||||
}
|
||||
type snapshot struct {
|
||||
Version string
|
||||
NewbieStep uint64
|
||||
Attendance map[string]*attendance
|
||||
Missions map[string]*mission
|
||||
Passes map[string]*pass
|
||||
LoginDays map[string]int64
|
||||
Receipts map[string]receipt
|
||||
}
|
||||
type Service struct {
|
||||
provider world.GameplayAchievementProvider
|
||||
before world.GameplayAchievementSnapshot
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design *gamedata.EventTasksDesign
|
||||
registry events.Resolver
|
||||
economy Economy
|
||||
state snapshot
|
||||
now func() time.Time
|
||||
session string
|
||||
unlocked func(uint64, uint64) bool
|
||||
authorizeCash func(uint64, uint64) bool
|
||||
attendancePremium func(uint64) bool
|
||||
associated func(events.Schedule) uint64
|
||||
}
|
||||
|
||||
func Open(store stateio.Store, d *gamedata.EventTasksDesign, r events.Resolver, e Economy) (*Service, error) {
|
||||
if store == nil || d == nil || r == nil || e == nil {
|
||||
return nil, errors.New("eventtasks: missing dependency")
|
||||
}
|
||||
s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now}
|
||||
b, err := store.Load("eventtasks")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b != nil {
|
||||
if err = stateio.RequireExactJSONObject(b, "Version", "NewbieStep", "Attendance", "Missions", "Passes", "LoginDays", "Receipts"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(b, &s.state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.state.Version != versionconfig.State() || s.state.Attendance == nil || s.state.Missions == nil || s.state.Passes == nil || s.state.LoginDays == nil || s.state.Receipts == nil {
|
||||
return nil, errors.New("eventtasks: invalid state layout")
|
||||
}
|
||||
for _, a := range s.state.Attendance {
|
||||
if a == nil || a.Obtained == nil || a.History == nil {
|
||||
return nil, errors.New("eventtasks: invalid attendance")
|
||||
}
|
||||
}
|
||||
for _, m := range s.state.Missions {
|
||||
if m == nil {
|
||||
return nil, errors.New("eventtasks: invalid mission")
|
||||
}
|
||||
}
|
||||
for _, p := range s.state.Passes {
|
||||
if p == nil || p.Claimed == nil {
|
||||
return nil, errors.New("eventtasks: invalid pass")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.Version = versionconfig.State()
|
||||
}
|
||||
s.init()
|
||||
return s, nil
|
||||
}
|
||||
func (s *Service) init() {
|
||||
if s.state.Attendance == nil {
|
||||
s.state.Attendance = map[string]*attendance{}
|
||||
}
|
||||
if s.state.Missions == nil {
|
||||
s.state.Missions = map[string]*mission{}
|
||||
}
|
||||
if s.state.Passes == nil {
|
||||
s.state.Passes = map[string]*pass{}
|
||||
}
|
||||
if s.state.LoginDays == nil {
|
||||
s.state.LoginDays = map[string]int64{}
|
||||
}
|
||||
if s.state.Receipts == nil {
|
||||
s.state.Receipts = map[string]receipt{}
|
||||
}
|
||||
}
|
||||
func (s *Service) SetSession(v string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = v }
|
||||
func (s *Service) AttachUnlockResolver(f func(uint64, uint64) bool) { s.unlocked = f }
|
||||
func (s *Service) AttachCashAuthorization(f func(uint64, uint64) bool) { s.authorizeCash = f }
|
||||
func (s *Service) AttachAttendancePremium(f func(uint64) bool) { s.attendancePremium = f }
|
||||
func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f }
|
||||
|
||||
func (s *Service) SetNewbieStep(step uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if step > s.state.NewbieStep {
|
||||
s.state.NewbieStep = step
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
func (s *Service) NewbieStep() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.NewbieStep }
|
||||
func (s *Service) save() error {
|
||||
b, e := json.Marshal(s.state)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.store.Save("eventtasks", b)
|
||||
}
|
||||
func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
|
||||
func key(v ...uint64) string {
|
||||
var k string
|
||||
for i, n := range v {
|
||||
if i > 0 {
|
||||
k += "/"
|
||||
}
|
||||
k += strconv.FormatUint(n, 10)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// scheduleKey preserves the public UID where it is present. UID zero is a
|
||||
// protocol default shared by permanent groups, so its local identity also
|
||||
// includes the event type and design group.
|
||||
func scheduleKey(v events.Schedule) string {
|
||||
if v.UID != 0 {
|
||||
return key(v.UID)
|
||||
}
|
||||
return key(0, v.Type, v.ID, v.SubID)
|
||||
}
|
||||
func (s *Service) active(v events.Schedule) bool {
|
||||
n := s.now().UnixMilli()
|
||||
return n >= v.Start && n < v.End
|
||||
}
|
||||
func (s *Service) find(t, id uint64) (events.Schedule, error) {
|
||||
rows := s.registry.List()
|
||||
if t == 4 {
|
||||
rows = s.taskSchedules()
|
||||
}
|
||||
for _, v := range rows {
|
||||
if v.Type == t && v.ID == id && s.active(v) {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return events.Schedule{}, fmt.Errorf("eventtasks: event %d/%d inactive", t, id)
|
||||
}
|
||||
|
||||
func (s *Service) taskSchedules() []events.Schedule {
|
||||
rows := s.registry.List()
|
||||
var out []events.Schedule
|
||||
seen := map[uint64]bool{}
|
||||
for _, v := range rows {
|
||||
if v.Type == 4 {
|
||||
out = append(out, v)
|
||||
if s.active(v) {
|
||||
seen[v.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range rows {
|
||||
if v.Type == 5 && s.active(v) {
|
||||
g := s.design.Passes[v.ID].MissionGroup
|
||||
if g > 0 && !seen[g] {
|
||||
v.Type = 4
|
||||
v.ID = g
|
||||
out = append(out, v)
|
||||
seen[g] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.associated != nil {
|
||||
for _, v := range rows {
|
||||
if !s.active(v) {
|
||||
continue
|
||||
}
|
||||
g := s.associated(v)
|
||||
if g > 0 && !seen[g] {
|
||||
v.Type = 4
|
||||
v.ID = g
|
||||
out = append(out, v)
|
||||
seen[g] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *Service) resolve(uid uint64) (events.Schedule, error) {
|
||||
v, e := s.registry.Resolve(uid)
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
if !s.active(v) {
|
||||
return v, errors.New("eventtasks: event expired")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *Service) day() string { return s.now().UTC().Format("2006-01-02") }
|
||||
func (s *Service) attendance(v events.Schedule) *attendance {
|
||||
k := scheduleKey(v)
|
||||
a := s.state.Attendance[k]
|
||||
if a == nil {
|
||||
g := s.design.Attendance[v.ID].Group
|
||||
var first uint64
|
||||
for _, x := range s.design.AttendanceGroups {
|
||||
if x.Group == g && (first == 0 || x.ID < first) {
|
||||
first = x.ID
|
||||
}
|
||||
}
|
||||
if first > 0 {
|
||||
g = first
|
||||
}
|
||||
a = &attendance{Group: g, Obtained: map[string]bool{}, History: map[string]bool{}}
|
||||
s.state.Attendance[k] = a
|
||||
}
|
||||
return a
|
||||
}
|
||||
func (s *Service) pass(v events.Schedule) *pass {
|
||||
k := scheduleKey(v)
|
||||
p := s.state.Passes[k]
|
||||
if p == nil {
|
||||
p = &pass{Claimed: map[string]bool{}}
|
||||
s.state.Passes[k] = p
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (s *Service) mission(v events.Schedule, id uint64) *mission {
|
||||
k := scheduleKey(v) + "/" + key(id)
|
||||
m := s.state.Missions[k]
|
||||
if m == nil {
|
||||
m = &mission{}
|
||||
s.state.Missions[k] = m
|
||||
}
|
||||
g := s.design.MissionGroups[v.ID]
|
||||
period := ""
|
||||
if g.Type == 1 {
|
||||
period = s.day()
|
||||
}
|
||||
if g.Type == 2 {
|
||||
y, w := s.now().UTC().ISOWeek()
|
||||
period = fmt.Sprintf("%d-%d", y, w)
|
||||
}
|
||||
if m.Period != period {
|
||||
*m = mission{Period: period}
|
||||
}
|
||||
return m
|
||||
}
|
||||
func (s *Service) availableTask(v events.Schedule, t gamedata.EventTask) bool {
|
||||
g := s.design.MissionGroups[v.ID]
|
||||
for i, n := range g.Groups {
|
||||
if n == t.Group {
|
||||
if g.Type == 3 && int64(i) > (s.now().UnixMilli()-v.Start)/86400000 {
|
||||
return false
|
||||
}
|
||||
return t.UnlockPack == 0 && t.UnlockQuest == 0 || s.unlocked != nil && s.unlocked(t.UnlockPack, t.UnlockQuest)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
codes := map[string]int{"/Attendance": 0, "/AttendanceInfo": 0, "/EventRewardHistory": 0, "/EventReward": 151, "/EventMissionInfo": 127, "/MissionClear": 120, "/MissionUpdate": 119, "/PassInfo": 124, "/PassReward": 126, "/PassBuy": 129, "/LoginEvent": 0}
|
||||
code, found := codes[path]
|
||||
if !found {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if path == "/MissionClear" && scalar(req, 3) != 2 {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if path == "/MissionUpdate" {
|
||||
handled := false
|
||||
_ = wire.Walk(req, func(f wire.Field) error {
|
||||
if f.Number == 2 && f.Type == 2 && scalar(f.Value, 4) > 0 {
|
||||
handled = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !handled {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seq, present, err := wire.Varint(req, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return code, nil, true, errors.New("eventtasks: invalid sequence")
|
||||
}
|
||||
if err = wire.Walk(req, func(wire.Field) error { return nil }); err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
digest := sha256.Sum256(append([]byte(path), req...))
|
||||
ds := hex.EncodeToString(digest[:])
|
||||
rk := s.session + ":" + path + ":" + key(seq)
|
||||
if r, ok := s.state.Receipts[rk]; ok {
|
||||
if r.Digest != ds {
|
||||
return code, nil, true, errors.New("eventtasks: changed replay request")
|
||||
}
|
||||
return r.Code, append([]byte(nil), r.Reply...), true, nil
|
||||
}
|
||||
before, _ := json.Marshal(s.state)
|
||||
out, err := s.handle(path, req, rk)
|
||||
if err != nil {
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
return code, nil, true, err
|
||||
}
|
||||
s.state.Receipts[rk] = receipt{ds, code, out}
|
||||
if err = s.save(); err != nil {
|
||||
_ = json.Unmarshal(before, &s.state)
|
||||
return code, nil, true, err
|
||||
}
|
||||
return code, out, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
|
||||
switch path {
|
||||
case "/Attendance", "/LoginEvent":
|
||||
today := s.day()
|
||||
if _, ok := s.state.LoginDays[today]; !ok {
|
||||
s.state.LoginDays[today] = s.now().UnixMilli()
|
||||
}
|
||||
var out []byte
|
||||
for _, v := range s.registry.List() {
|
||||
if !s.active(v) {
|
||||
continue
|
||||
}
|
||||
if v.Type > 1 {
|
||||
continue
|
||||
}
|
||||
a := s.attendance(v)
|
||||
if a.LastDay != today {
|
||||
if v.Type == 0 {
|
||||
complete, maxDay := true, uint64(0)
|
||||
for _, r := range s.design.AttendanceRewards[a.Group] {
|
||||
if r.Day > maxDay {
|
||||
maxDay = r.Day
|
||||
}
|
||||
if !a.Obtained[key(a.Group, r.ID)] {
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
if complete && maxDay > 0 && a.Count >= maxDay {
|
||||
group := s.design.Attendance[v.ID].Group
|
||||
if next := s.design.AttendanceGroups[[2]uint64{group, a.Group}].Next; next > 0 {
|
||||
a.Group, a.Count = next, 0
|
||||
for _, r := range s.design.AttendanceRewards[next] {
|
||||
delete(a.Obtained, key(next, r.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
a.LastDay = today
|
||||
a.Count++
|
||||
if v.Type == 0 {
|
||||
maxDay := uint64(0)
|
||||
for _, r := range s.design.AttendanceRewards[a.Group] {
|
||||
if r.Day > maxDay {
|
||||
maxDay = r.Day
|
||||
}
|
||||
}
|
||||
if maxDay > 0 && a.Count > maxDay {
|
||||
a.Count = maxDay
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "/LoginEvent" {
|
||||
x := wire.AppendVarint(nil, 1, v.UID)
|
||||
x = wire.AppendVarint(x, 2, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli()))
|
||||
out = wire.AppendBytes(out, 1, x)
|
||||
continue
|
||||
}
|
||||
if v.Type == 0 {
|
||||
x := wire.AppendVarint(nil, 1, v.UID)
|
||||
x = wire.AppendVarint(x, 2, a.Group)
|
||||
x = wire.AppendVarint(x, 3, a.Count)
|
||||
out = wire.AppendBytes(out, 1, x)
|
||||
} else {
|
||||
x := wire.AppendVarint(nil, 1, v.UID)
|
||||
for k, box := range s.design.LimitRewards {
|
||||
if k[0] != v.ID {
|
||||
continue
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, k[1])
|
||||
entry = wire.AppendVarint(entry, 2, box)
|
||||
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(k[1])-1).Format("2006-01-02")
|
||||
entry = wire.AppendBytes(entry, 3, []byte(date))
|
||||
x = wire.AppendBytes(x, 2, entry)
|
||||
}
|
||||
out = wire.AppendBytes(out, 2, x)
|
||||
}
|
||||
for claim := range a.Obtained {
|
||||
var group, id uint64
|
||||
_, _ = fmt.Sscanf(claim, "%d/%d", &group, &id)
|
||||
x := wire.AppendVarint(nil, 1, v.UID)
|
||||
x = wire.AppendVarint(x, 2, group)
|
||||
x = wire.AppendVarint(x, 3, id)
|
||||
out = wire.AppendBytes(out, 5, x)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/AttendanceInfo":
|
||||
var out []byte
|
||||
start, end := scalar(b, 2), scalar(b, 3)
|
||||
days := make([]string, 0, len(s.state.LoginDays))
|
||||
for d := range s.state.LoginDays {
|
||||
days = append(days, d)
|
||||
}
|
||||
sort.Strings(days)
|
||||
for _, d := range days {
|
||||
if len(d) != 10 {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseUint(d[0:4]+d[5:7]+d[8:10], 10, 64)
|
||||
if (start == 0 || n >= start) && (end == 0 || n <= end) {
|
||||
out = wire.AppendVarint(out, 1, uint64(s.state.LoginDays[d]))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/EventRewardHistory":
|
||||
var out []byte
|
||||
for uid, a := range s.state.Attendance {
|
||||
groups := map[uint64][]uint64{}
|
||||
for k := range a.History {
|
||||
var g, id uint64
|
||||
_, _ = fmt.Sscanf(k, "%d/%d", &g, &id)
|
||||
groups[g] = append(groups[g], id)
|
||||
}
|
||||
var u uint64
|
||||
_, _ = fmt.Sscanf(uid, "%d", &u)
|
||||
for g, ids := range groups {
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
x := wire.AppendVarint(nil, 1, u)
|
||||
x = wire.AppendVarint(x, 2, g)
|
||||
for _, id := range ids {
|
||||
x = wire.AppendVarint(x, 3, id)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, x)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/EventReward":
|
||||
v, e := s.resolve(scalar(b, 2))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if v.Type > 1 {
|
||||
return nil, errors.New("eventtasks: reward is not attendance")
|
||||
}
|
||||
a := s.attendance(v)
|
||||
g, id := scalar(b, 3), scalar(b, 4)
|
||||
ck := key(g, id)
|
||||
if a.Obtained[ck] {
|
||||
return nil, errors.New("eventtasks: attendance already received")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if v.Type == 0 {
|
||||
if g != a.Group {
|
||||
return nil, errors.New("eventtasks: wrong attendance group")
|
||||
}
|
||||
for _, r := range s.design.AttendanceRewards[g] {
|
||||
if r.ID == id && r.Day <= a.Count {
|
||||
rewards = append(rewards, r.Basic)
|
||||
if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 {
|
||||
rewards = append(rewards, r.Premium)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
box, ok := s.design.LimitRewards[[2]uint64{g, id}]
|
||||
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02")
|
||||
_, logged := s.state.LoginDays[date]
|
||||
if ok && g == v.ID && logged && date == s.day() {
|
||||
rewards = []gamedata.Reward{{Type: 9, ID: box, Count: 1}}
|
||||
}
|
||||
}
|
||||
if len(rewards) == 0 {
|
||||
return nil, errors.New("eventtasks: attendance day unavailable")
|
||||
}
|
||||
bundle, e := s.economy.Apply(identity, nil, rewards)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
a.Obtained[ck] = true
|
||||
a.History[ck] = true
|
||||
// Frozen EventRewardResponse has no known fields. The owned-client plugin
|
||||
// applies this actual grant once using the durable request receipt; Handle
|
||||
// persists the complete response for replay before the account commits.
|
||||
out := wire.AppendBytes(nil, 1001, bundle)
|
||||
out = wire.AppendString(out, 1002, identity)
|
||||
return out, nil
|
||||
case "/EventMissionInfo":
|
||||
var out []byte
|
||||
for _, v := range s.taskSchedules() {
|
||||
if v.Type != 4 || !s.active(v) {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.design.Missions {
|
||||
if !s.availableTask(v, t) {
|
||||
continue
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
x := wire.AppendVarint(nil, 1, v.ID)
|
||||
x = wire.AppendVarint(x, 2, t.Group)
|
||||
x = wire.AppendVarint(x, 3, t.ID)
|
||||
x = wire.AppendVarint(x, 4, m.Value)
|
||||
if m.Claimed {
|
||||
x = wire.AppendVarint(x, 5, 1)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, x)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/MissionUpdate":
|
||||
err := wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number != 2 || f.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
id, value, event := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4)
|
||||
v, e := s.find(4, event)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
t, ok := s.design.Missions[id]
|
||||
if !ok || t.Group != scalar(f.Value, 1) || !s.availableTask(v, t) {
|
||||
return errors.New("eventtasks: invalid mission update")
|
||||
}
|
||||
if value > s.mission(v, id).Value {
|
||||
return errors.New("eventtasks: client progress exceeds authoritative gameplay")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil, err
|
||||
case "/MissionClear":
|
||||
v, e := s.find(4, scalar(b, 6))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
all := scalar(b, 2) != 0
|
||||
g, id := scalar(b, 4), scalar(b, 5)
|
||||
var rewards []gamedata.Reward
|
||||
var done []uint64
|
||||
var exp uint64
|
||||
for _, t := range s.design.Missions {
|
||||
if t.Group != g || !s.availableTask(v, t) || !all && t.ID != id {
|
||||
continue
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
if !m.Claimed && m.Value >= t.Target {
|
||||
rewards = append(rewards, t.Rewards...)
|
||||
done = append(done, t.ID)
|
||||
exp += t.PassExp
|
||||
}
|
||||
}
|
||||
if len(done) == 0 {
|
||||
return nil, errors.New("eventtasks: no completed unclaimed mission")
|
||||
}
|
||||
bundle, e := s.economy.Apply(identity, nil, rewards)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, id := range done {
|
||||
s.mission(v, id).Claimed = true
|
||||
}
|
||||
// Completion-of-mission conditions are derived from durable claims,
|
||||
// rather than a client-supplied total.
|
||||
for _, t := range s.design.Missions {
|
||||
if !s.availableTask(v, t) || (t.Type != 1001 && t.Type != 1002) {
|
||||
continue
|
||||
}
|
||||
var completed uint64
|
||||
for _, other := range s.design.Missions {
|
||||
if !s.availableTask(v, other) || !s.mission(v, other.ID).Claimed {
|
||||
continue
|
||||
}
|
||||
match := other.Group == t.Group
|
||||
if len(t.Params) > 0 {
|
||||
match = false
|
||||
for _, n := range t.Params {
|
||||
if n == other.Group || n == other.ID {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if match {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
if completed > t.Target {
|
||||
completed = t.Target
|
||||
}
|
||||
if completed > m.Value {
|
||||
m.Value = completed
|
||||
}
|
||||
}
|
||||
for _, pv := range s.registry.List() {
|
||||
if pv.Type == 5 && s.active(pv) && s.design.Passes[pv.ID].MissionGroup == v.ID {
|
||||
s.pass(pv).Exp += exp
|
||||
}
|
||||
}
|
||||
return wire.AppendBytes(nil, 1, bundle), nil
|
||||
case "/PassInfo":
|
||||
var out []byte
|
||||
for _, v := range s.registry.List() {
|
||||
if v.Type != 5 || !s.active(v) {
|
||||
continue
|
||||
}
|
||||
p := s.pass(v)
|
||||
x := wire.AppendVarint(nil, 1, v.ID)
|
||||
x = wire.AppendVarint(x, 2, p.Exp)
|
||||
if p.Premium {
|
||||
x = wire.AppendVarint(x, 3, 1)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, x)
|
||||
d := s.design.Passes[v.ID]
|
||||
for _, lv := range s.design.PassLevels[d.LevelGroup] {
|
||||
basic, premium := p.Claimed[key(lv.ID, 0)], p.Claimed[key(lv.ID, 1)]
|
||||
if !basic && !premium {
|
||||
continue
|
||||
}
|
||||
x = wire.AppendVarint(nil, 1, v.ID)
|
||||
x = wire.AppendVarint(x, 2, lv.ID)
|
||||
if basic {
|
||||
x = wire.AppendVarint(x, 3, 1)
|
||||
}
|
||||
if premium {
|
||||
x = wire.AppendVarint(x, 4, 1)
|
||||
}
|
||||
out = wire.AppendBytes(out, 2, x)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/PassReward":
|
||||
v, e := s.find(5, scalar(b, 3))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d, ok := s.design.Passes[v.ID]
|
||||
if !ok {
|
||||
return nil, errors.New("eventtasks: unknown pass")
|
||||
}
|
||||
p := s.pass(v)
|
||||
if d.NewbieStep > s.state.NewbieStep {
|
||||
return nil, errors.New("eventtasks: guide pass step locked")
|
||||
}
|
||||
all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5)
|
||||
if rt > 1 {
|
||||
return nil, errors.New("eventtasks: invalid pass reward kind")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
var claims []string
|
||||
var threshold uint64
|
||||
for _, lv := range s.design.PassLevels[d.LevelGroup] {
|
||||
threshold += lv.NeedExp
|
||||
if p.Exp < threshold || !all && lv.ID != id {
|
||||
continue
|
||||
}
|
||||
for typ := uint64(0); typ <= 1; typ++ {
|
||||
if !all && typ != rt || typ == 1 && !p.Premium {
|
||||
continue
|
||||
}
|
||||
ck := key(lv.ID, typ)
|
||||
if p.Claimed[ck] {
|
||||
continue
|
||||
}
|
||||
r := lv.Basic
|
||||
if typ == 1 {
|
||||
r = lv.Premium
|
||||
}
|
||||
if r.Type > 0 && r.Count > 0 {
|
||||
rewards = append(rewards, r)
|
||||
}
|
||||
claims = append(claims, ck)
|
||||
}
|
||||
}
|
||||
if len(claims) == 0 {
|
||||
return nil, errors.New("eventtasks: pass reward not eligible")
|
||||
}
|
||||
bundle, e := s.economy.Apply(identity, nil, rewards)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, k := range claims {
|
||||
p.Claimed[k] = true
|
||||
}
|
||||
out := wire.AppendBytes(nil, 1, bundle)
|
||||
if d.NewbieStep > 0 {
|
||||
complete := true
|
||||
for _, lv := range s.design.PassLevels[d.LevelGroup] {
|
||||
if !p.Claimed[key(lv.ID, 0)] {
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
mv, err := s.find(4, d.MissionGroup)
|
||||
if err != nil {
|
||||
complete = false
|
||||
} else {
|
||||
for _, t := range s.design.Missions {
|
||||
if s.availableTask(mv, t) && !s.mission(mv, t.ID).Claimed {
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if complete && s.state.NewbieStep == d.NewbieStep {
|
||||
s.state.NewbieStep++
|
||||
out = wire.AppendVarint(out, 2, s.state.NewbieStep)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case "/PassBuy":
|
||||
v, e := s.find(5, scalar(b, 2))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p := s.pass(v)
|
||||
typ := scalar(b, 3)
|
||||
var chosen *gamedata.EventPassBuy
|
||||
for _, b := range s.design.PassBuys[v.ID] {
|
||||
if b.Type == typ {
|
||||
copy := b
|
||||
chosen = ©
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return nil, errors.New("eventtasks: unknown pass purchase")
|
||||
}
|
||||
var level, threshold uint64
|
||||
for _, lv := range s.design.PassLevels[s.design.Passes[v.ID].LevelGroup] {
|
||||
threshold += lv.NeedExp
|
||||
if p.Exp >= threshold {
|
||||
level = lv.ID
|
||||
}
|
||||
}
|
||||
if level < chosen.UnlockLevel {
|
||||
return nil, errors.New("eventtasks: pass purchase level locked")
|
||||
}
|
||||
// Private-server cash products follow the existing CashShopBuy policy:
|
||||
// no external payment is charged. Premium activation is limited once per
|
||||
// scheduled pass by durable Premium state. A supplied entitlement checker
|
||||
// can restrict this policy for installations that integrate payments.
|
||||
if chosen.CashID != 0 && s.authorizeCash != nil && !s.authorizeCash(v.ID, typ) {
|
||||
return nil, errors.New("eventtasks: cash entitlement required")
|
||||
}
|
||||
if p.Premium && (typ == 1 || typ == 3) {
|
||||
return nil, errors.New("eventtasks: premium already active")
|
||||
}
|
||||
var costs []gamedata.Reward
|
||||
if chosen.Cost.Count > 0 {
|
||||
costs = append(costs, chosen.Cost)
|
||||
}
|
||||
if _, e = s.economy.Apply(identity, costs, chosen.Rewards); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if typ == 0 {
|
||||
levels := s.design.PassLevels[s.design.Passes[v.ID].LevelGroup]
|
||||
var threshold uint64
|
||||
for _, lv := range levels {
|
||||
threshold += lv.NeedExp
|
||||
if threshold > p.Exp {
|
||||
p.Exp = threshold
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if typ == 1 || typ == 3 {
|
||||
p.Premium = true
|
||||
}
|
||||
return wire.AppendVarint(nil, 1, p.Exp), nil
|
||||
}
|
||||
return nil, errors.New("eventtasks: unsupported operation")
|
||||
}
|
||||
|
||||
// RecordEvent is called by authoritative gameplay. It returns no rewards and
|
||||
// caps every matching task at its design threshold.
|
||||
func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, v := range s.taskSchedules() {
|
||||
if v.Type != 4 || !s.active(v) {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.design.Missions {
|
||||
if t.Type != condition || t.SubType != 0 && t.SubType != sub || !s.availableTask(v, t) {
|
||||
continue
|
||||
}
|
||||
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) {
|
||||
continue
|
||||
}
|
||||
match := len(t.Params) == 0
|
||||
for _, n := range t.Params {
|
||||
if n == sub {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
if m.Claimed {
|
||||
continue
|
||||
}
|
||||
if m.Value >= t.Target {
|
||||
continue
|
||||
}
|
||||
if count >= t.Target-m.Value {
|
||||
m.Value = t.Target
|
||||
} else {
|
||||
m.Value += count
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
func (s *Service) Notify() ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []byte
|
||||
for _, v := range s.taskSchedules() {
|
||||
if v.Type != 4 || !s.active(v) {
|
||||
continue
|
||||
}
|
||||
groups := map[uint64][]byte{}
|
||||
for _, t := range s.design.Missions {
|
||||
if !s.availableTask(v, t) {
|
||||
continue
|
||||
}
|
||||
m := s.mission(v, t.ID)
|
||||
if m.Value == 0 {
|
||||
continue
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, t.ID)
|
||||
entry = wire.AppendVarint(entry, 2, m.Value)
|
||||
groups[t.Group] = wire.AppendBytes(groups[t.Group], 1, entry)
|
||||
}
|
||||
var groupMap []byte
|
||||
for g, rows := range groups {
|
||||
entry := wire.AppendVarint(nil, 1, g)
|
||||
entry = wire.AppendBytes(entry, 2, rows)
|
||||
groupMap = wire.AppendBytes(groupMap, 1, entry)
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, v.ID)
|
||||
entry = wire.AppendBytes(entry, 2, groupMap)
|
||||
out = wire.AppendBytes(out, 4, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RecordLogin shares the same daily marker as Attendance, preventing repeated
|
||||
// reconnects from inflating event login tasks. Main should invoke this once
|
||||
// through the regular mission observer before serving attendance queries.
|
||||
func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
marker := "mission-login:" + s.day()
|
||||
if _, ok := s.state.LoginDays[marker]; ok {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.state.LoginDays[marker] = s.now().UnixMilli()
|
||||
s.mu.Unlock()
|
||||
return s.RecordEvent(0, 0, 1, unlocked)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package eventtasks
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type economyStub struct {
|
||||
calls int
|
||||
rewards []gamedata.Reward
|
||||
}
|
||||
|
||||
func (e *economyStub) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
e.rewards = append(e.rewards, r...)
|
||||
return []byte{10, 0}, nil
|
||||
}
|
||||
func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 2, Type: 4, ID: 7, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 3, Type: 5, ID: 8, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &gamedata.EventTasksDesign{Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}}, AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}}, MissionGroups: map[uint64]gamedata.EventMissionGroup{7: {ID: 7, Groups: []uint64{9}}}, Missions: map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 2, Target: 2, PassExp: 20, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}, Passes: map[uint64]gamedata.EventPass{8: {ID: 8, MissionGroup: 7, LevelGroup: 8}}, PassLevels: map[uint64][]gamedata.EventPassLevel{8: {{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}}}}
|
||||
e := &economyStub{}
|
||||
store := stateio.NewMemory()
|
||||
s, err := Open(store, d, registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.now = func() time.Time { return now }
|
||||
s.SetSession("test")
|
||||
return s, e, store
|
||||
}
|
||||
func req(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
|
||||
func TestMissionRejectsFabricatedProgressAndReplaySurvivesRestart(t *testing.T) {
|
||||
s, e, store := setup(t)
|
||||
update := req(1)
|
||||
x := wire.AppendVarint(nil, 1, 9)
|
||||
x = wire.AppendVarint(x, 2, 10)
|
||||
x = wire.AppendVarint(x, 3, 2)
|
||||
x = wire.AppendVarint(x, 4, 7)
|
||||
update = wire.AppendBytes(update, 2, x)
|
||||
if _, _, _, err := s.Handle("/MissionUpdate", update); err == nil {
|
||||
t.Fatal("client manufactured completion")
|
||||
}
|
||||
if err := s.RecordEvent(2, 0, 2, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clear := req(2)
|
||||
clear = wire.AppendVarint(clear, 2, 1)
|
||||
clear = wire.AppendVarint(clear, 3, 2)
|
||||
clear = wire.AppendVarint(clear, 4, 9)
|
||||
clear = wire.AppendVarint(clear, 6, 7)
|
||||
_, reply, _, err := s.Handle("/MissionClear", clear)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.calls != 1 || s.pass(s.registry.List()[2]).Exp != 20 {
|
||||
t.Fatal("settlement or pass exp missing")
|
||||
}
|
||||
next, err := Open(store, s.design, s.registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next.now = s.now
|
||||
next.SetSession("test")
|
||||
_, replay, _, err := next.Handle("/MissionClear", clear)
|
||||
if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 {
|
||||
t.Fatal("restart replay duplicated reward")
|
||||
}
|
||||
mutated := wire.AppendVarint(clear, 5, 10)
|
||||
if _, _, _, err = next.Handle("/MissionClear", mutated); err == nil {
|
||||
t.Fatal("changed replay accepted")
|
||||
}
|
||||
passReq := wire.AppendVarint(req(3), 2, 1)
|
||||
passReq = wire.AppendVarint(passReq, 3, 8)
|
||||
if _, _, _, err = next.Handle("/PassReward", passReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.calls != 2 {
|
||||
t.Fatal("pass reward not granted")
|
||||
}
|
||||
}
|
||||
func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) {
|
||||
s, e, _ := setup(t)
|
||||
if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/Attendance", req(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.state.Attendance["1"].Count != 1 {
|
||||
t.Fatal("reconnect counted attendance twice")
|
||||
}
|
||||
claim := req(3)
|
||||
claim = wire.AppendVarint(claim, 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, 1)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.calls != 1 {
|
||||
t.Fatal("attendance reward missing")
|
||||
}
|
||||
again := wire.AppendVarint(nil, 1, 4)
|
||||
again = append(again, claim[2:]...)
|
||||
if _, _, _, err := s.Handle("/EventReward", again); err == nil {
|
||||
t.Fatal("claimed attendance awarded again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) {
|
||||
s, e, _ := setup(t)
|
||||
s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 20}
|
||||
s.design.AttendanceGroups = map[[2]uint64]gamedata.EventAttendanceGroup{{20, 5}: {Group: 20, ID: 5, Next: 6}, {20, 6}: {Group: 20, ID: 6, Next: 5}}
|
||||
s.design.AttendanceRewards = map[uint64][]gamedata.EventAttendanceReward{5: {{Group: 5, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 5}}}, 6: {{Group: 6, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 6}}}}
|
||||
today := s.now()
|
||||
s.now = func() time.Time { return today }
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: today.Add(-24 * time.Hour).UnixMilli(), End: today.Add(7 * 24 * time.Hour).UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.registry = registry
|
||||
seq := uint64(1)
|
||||
for _, group := range []uint64{5, 6, 5} {
|
||||
if _, _, _, err := s.Handle("/Attendance", req(seq)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seq++
|
||||
if s.state.Attendance["1"].Group != group || s.state.Attendance["1"].Count != 1 {
|
||||
t.Fatalf("wrong chain state %+v", s.state.Attendance["1"])
|
||||
}
|
||||
claim := wire.AppendVarint(req(seq), 2, 1)
|
||||
claim = wire.AppendVarint(claim, 3, group)
|
||||
claim = wire.AppendVarint(claim, 4, 1)
|
||||
if _, _, _, err := s.Handle("/EventReward", claim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seq++
|
||||
today = today.Add(24 * time.Hour)
|
||||
}
|
||||
if e.calls != 3 || len(s.state.Attendance["1"].History) != 2 {
|
||||
t.Fatal("repeated cycle reward or retained history missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) {
|
||||
s, e, store := setup(t)
|
||||
s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, CashID: 42, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}}}
|
||||
request := wire.AppendVarint(req(1), 2, 8)
|
||||
request = wire.AppendVarint(request, 3, 1)
|
||||
_, reply, _, err := s.Handle("/PassBuy", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.calls != 1 || !s.state.Passes["3"].Premium {
|
||||
t.Fatal("premium cash pass not activated")
|
||||
}
|
||||
reopened, err := Open(store, s.design, s.registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.SetSession("test")
|
||||
_, again, _, err := reopened.Handle("/PassBuy", request)
|
||||
if err != nil || !bytes.Equal(reply, again) || e.calls != 1 {
|
||||
t.Fatal("cash pass replay duplicated reward")
|
||||
}
|
||||
duplicate := wire.AppendVarint(req(2), 2, 8)
|
||||
duplicate = wire.AppendVarint(duplicate, 3, 1)
|
||||
if _, _, _, err = reopened.Handle("/PassBuy", duplicate); err == nil || e.calls != 1 {
|
||||
t.Fatal("premium cash purchase granted twice")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user