Files
bd2/go/internal/server/eventactions/service.go
T
Flechazo 924a42c32e fix(all): complete activity calendars, shop purchases and reward dispatch
Restore event and mini hubs, current shop and hunt calendars, and regular seasons; preserve all published windows. Implement story/quiz routes, native shop purchases, avatar sets, currencies, rank-change items and permanent buffs with rollback and reward coverage tests. Refresh client buff stats and update Cash Shop to 0.1.1.
2026-10-05 15:49:50 +08:00

562 lines
17 KiB
Go

// Package eventactions owns repeatable event interaction state. It uses the
// injected calendar and shared transactional reward service.
package eventactions
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type receipt struct {
Digest string
Reply []byte
}
type spawn struct {
UID, Group, ID uint64
Start int64
Caught map[string]bool
}
type vote struct{ Round, Candidate, Normal, Additional uint64 }
type snapshot struct {
Version string
BattleUID, BattleStage, BattleDeck uint64
Claims map[string]bool
Favorites map[uint64]bool
Votes map[string]*vote
NormalVoted map[string]bool
VoteRewards map[string]bool
Spawns map[uint64]*spawn
DailyNormal, DailySpecial uint64
CafeteriaCurrency uint64
CafeteriaLast map[string]int64
Day string
Tactics map[uint64][]uint64
Deck []byte
Receipts map[string]receipt
}
type Service struct {
mu sync.Mutex
store stateio.Store
design *gamedata.EventActionsDesign
registry events.Resolver
economy Economy
state snapshot
now func() time.Time
session string
friendship func(uint64) uint64
chargeInfo func() ([]byte, error)
owned func(uint64, uint64) bool
progress func(uint64, uint64, uint64) error
voteTotals func(uint64, uint64) (map[uint64]uint64, error)
miniContent MiniContentResolver
miniDesign *gamedata.MiniContentDesign
}
func Open(store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) {
if store == nil || d == nil || r == nil || e == nil {
return nil, errors.New("eventactions: missing dependency")
}
s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now}
b, err := store.Load("eventactions")
if err != nil {
return nil, err
}
if b != nil {
if err = stateio.RequireExactJSONObject(b, "Version", "BattleUID", "BattleStage", "BattleDeck", "Claims", "Favorites", "Votes", "NormalVoted", "VoteRewards", "Spawns", "DailyNormal", "DailySpecial", "CafeteriaCurrency", "CafeteriaLast", "Day", "Tactics", "Deck", "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.Claims == nil || s.state.Favorites == nil || s.state.Votes == nil || s.state.NormalVoted == nil || s.state.VoteRewards == nil || s.state.Spawns == nil || s.state.Tactics == nil || s.state.Receipts == nil || s.state.CafeteriaLast == nil {
return nil, errors.New("eventactions: invalid state layout")
}
for _, v := range s.state.Votes {
if v == nil || v.Candidate == 0 || v.Round == 0 {
return nil, errors.New("eventactions: invalid saved vote")
}
}
for _, p := range s.state.Spawns {
if p == nil || p.Caught == nil || p.ID == 0 {
return nil, errors.New("eventactions: invalid saved spawn")
}
}
} else {
s.state.Version = versionconfig.State()
}
s.init()
return s, nil
}
func (s *Service) init() {
if s.state.CafeteriaLast == nil {
s.state.CafeteriaLast = map[string]int64{}
}
if s.state.Claims == nil {
s.state.Claims = map[string]bool{}
}
if s.state.Favorites == nil {
s.state.Favorites = map[uint64]bool{}
}
if s.state.Votes == nil {
s.state.Votes = map[string]*vote{}
}
if s.state.NormalVoted == nil {
s.state.NormalVoted = map[string]bool{}
}
if s.state.VoteRewards == nil {
s.state.VoteRewards = map[string]bool{}
}
if s.state.Spawns == nil {
s.state.Spawns = map[uint64]*spawn{}
}
if s.state.Tactics == nil {
s.state.Tactics = map[uint64][]uint64{}
}
if s.state.Receipts == nil {
s.state.Receipts = map[string]receipt{}
}
}
func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f }
func (s *Service) AttachChargeInfo(f func() ([]byte, error)) { s.chargeInfo = f }
func (s *Service) AttachOwnedCharacter(f func(uint64, uint64) bool) { s.owned = f }
func (s *Service) AttachProgress(f func(uint64, uint64, uint64) error) { s.progress = f }
// The current server owns one account save. Its real votes are the default
// leaderboard; a future account aggregator can supply broader server totals.
func (s *Service) AttachVoteTotals(f func(uint64, uint64) (map[uint64]uint64, error)) {
s.voteTotals = f
}
func (s *Service) save() error {
b, e := json.Marshal(s.state)
if e != nil {
return e
}
return s.store.Save("eventactions", b)
}
func val(p []byte, n int) uint64 { v, _, _ := wire.Varint(p, n); return v }
func key(v ...uint64) string {
r := []string{}
for _, n := range v {
r = append(r, fmt.Sprint(n))
}
return strings.Join(r, "/")
}
func (s *Service) active(v events.Schedule) bool {
return s.now().UnixMilli() >= v.Start && s.now().UnixMilli() < v.End
}
func (s *Service) resolve(uid, t uint64) (events.Schedule, error) {
v, e := s.registry.Resolve(uid)
if e != nil {
return v, e
}
if v.Type != t || !s.active(v) {
return v, errors.New("eventactions: event inactive or wrong type")
}
return v, nil
}
func (s *Service) find(t uint64) (events.Schedule, bool) {
for _, v := range s.registry.List() {
if v.Type == t && s.active(v) {
return v, true
}
}
return events.Schedule{}, false
}
func (s *Service) roll() {
day := s.now().UTC().Format("2006-01-02")
if s.state.Day != day {
s.state.Day = day
s.state.DailyNormal = 0
s.state.DailySpecial = 0
s.state.CafeteriaCurrency = 0
s.state.CafeteriaLast = map[string]int64{}
s.state.NormalVoted = map[string]bool{}
}
}
func (s *Service) Handle(path string, b []byte) (int, []byte, bool, error) {
codes := map[string]int{"/FieldEventSpawnInfo": 551, "/FieldEventSpawnStart": 552, "/FieldEventSpawnReward": 553, "/FireWorksInfo": 554, "/FireWorksReward": 555, "/CharVoteInfo": 581, "/CharVoteSave": 582, "/CharVoteRanking": 583, "/CharVoteSeasonRanking": 584, "/CharVoteFavoriteAdd": 585, "/CharVoteFavoriteDelete": 586, "/CharVoteTotalRanking": 587, "/FriendshipSpecialEpisodeInfo": 623, "/FriendshipSpecialEpisodeClear": 624, "/ChargeCostInfo": 123, "/NpcQuizInfo": 535, "/NpcQuizClear": 536, "/TacticsBingoInfo": 546, "/TacticsBingoDeckSave": 550}
code, ok := codes[path]
if path == "/CafeteriaEventNpcInteractionReward" {
code, ok = 414, true
}
if path == "/DailyStoryInfo" {
code, ok = 538, true
}
if path == "/DailyStoryClear" {
code, ok = 539, true
}
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, found, e := wire.Varint(b, 1)
if e != nil || !found || seq == 0 {
return code, nil, true, errors.New("eventactions: invalid request")
}
if e = wire.Walk(b, func(wire.Field) error { return nil }); e != nil {
return code, nil, true, e
}
sum := sha256.Sum256(append([]byte(path), b...))
digest := hex.EncodeToString(sum[:])
rk := s.session + ":" + path + ":" + key(seq)
if r, ok := s.state.Receipts[rk]; ok {
if r.Digest != digest {
return code, nil, true, errors.New("eventactions: replay changed")
}
return code, r.Reply, true, nil
}
before, _ := json.Marshal(s.state)
s.roll()
out, e := s.handle(path, b, rk)
if e != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, e
}
s.state.Receipts[rk] = receipt{digest, out}
if e = s.save(); e != nil {
_ = json.Unmarshal(before, &s.state)
return code, nil, true, e
}
return code, out, true, nil
}
func (s *Service) row(table string, groupField, idField int, g, id uint64) (gamedata.EventActionRow, bool) {
for _, r := range s.design.Tables[table] {
if r.V(groupField) == g && r.V(idField) == id {
return r, true
}
}
return gamedata.EventActionRow{}, false
}
func (s *Service) spawnWire(p *spawn) []byte {
if p == nil {
return nil
}
b := wire.AppendVarint(nil, 1, uint64(p.Start))
b = wire.AppendVarint(b, 2, p.UID)
b = wire.AppendVarint(b, 3, p.ID)
b = wire.AppendVarint(b, 4, p.Group)
var ids []string
for k := range p.Caught {
ids = append(ids, k)
}
sort.Strings(ids)
for _, k := range ids {
var g, id uint64
_, _ = fmt.Sscanf(k, "%d/%d", &g, &id)
x := wire.AppendVarint(nil, 1, p.ID)
x = wire.AppendVarint(x, 2, g)
x = wire.AppendVarint(x, 3, id)
b = wire.AppendBytes(b, 5, x)
}
return b
}
func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) {
switch path {
case "/DailyStoryInfo", "/DailyStoryClear":
return s.dailyStory(path, b, identity)
case "/CafeteriaEventNpcInteractionReward":
return s.cafeteriaReward(b, identity)
case "/ChargeCostInfo":
if s.chargeInfo == nil {
return nil, errors.New("eventactions: charge state source unavailable")
}
return s.chargeInfo()
case "/FieldEventSpawnInfo":
uid := val(b, 2)
if _, e := s.resolve(uid, 21); e != nil {
return nil, e
}
out := wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[uid]))
out = wire.AppendVarint(out, 2, s.state.DailyNormal)
out = wire.AppendVarint(out, 3, s.state.DailySpecial)
return out, nil
case "/FieldEventSpawnStart":
v, e := s.resolve(val(b, 2), 21)
if e != nil {
return nil, e
}
g, id := val(b, 3), val(b, 4)
r, ok := s.row("FieldSpawnEventTable", 4, 5, g, id)
if !ok || g != v.ID {
return nil, errors.New("eventactions: invalid spawn event")
}
if p := s.state.Spawns[v.UID]; p != nil {
defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0)
expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000
if expired {
delete(s.state.Spawns, v.UID)
} else {
if p.Group != g || p.ID != id {
return nil, errors.New("eventactions: spawn already started")
}
return wire.AppendBytes(nil, 1, s.spawnWire(p)), nil
}
}
_ = r
s.state.Spawns[v.UID] = &spawn{v.UID, g, id, s.now().UnixMilli(), map[string]bool{}}
return wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[v.UID])), nil
case "/FieldEventSpawnReward":
v, e := s.resolve(val(b, 6), 21)
if e != nil {
return nil, e
}
p := s.state.Spawns[v.UID]
if p == nil || p.ID != val(b, 2) || p.Group != val(b, 4) {
return nil, errors.New("eventactions: spawn not started")
}
spawnRow, ok := s.row("FieldSpawnEventTable", 4, 5, p.Group, p.ID)
if !ok {
return nil, errors.New("eventactions: spawn definition absent")
}
g, id := val(b, 5), val(b, 3)
r, ok := s.row("FieldEventMonsterTable", 3, 4, g, id)
if !ok || g != spawnRow.V(2) {
return nil, errors.New("eventactions: invalid spawn monster")
}
ck := key(g, id)
if p.Caught[ck] {
return nil, errors.New("eventactions: monster already caught")
}
defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0)
if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start > int64(defaults.V(12))*1000 {
return nil, errors.New("eventactions: spawn time expired")
}
special := r.V(2) != 0
if special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) {
return nil, errors.New("eventactions: daily catch limit")
}
reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}]
if !ok || reward.Count == 0 {
return nil, errors.New("eventactions: spawn reward missing")
}
bundle, e := s.economy.Apply(identity, nil, []gamedata.Reward{reward})
if e != nil {
return nil, e
}
p.Caught[ck] = true
if special {
s.state.DailySpecial++
} else {
s.state.DailyNormal++
}
if s.progress != nil {
if e = s.progress(349, 0, 1); e != nil {
return nil, e
}
}
return wire.AppendBytes(nil, 1, bundle), nil
case "/FireWorksInfo":
var out []byte
for k := range s.state.Claims {
var uid, g uint64
if _, e := fmt.Sscanf(k, "fire:%d/%d", &uid, &g); e == nil {
out = wire.AppendVarint(out, 1, g)
}
}
return out, nil
case "/FireWorksReward":
v, e := s.resolve(val(b, 2), 22)
if e != nil {
return nil, e
}
g := val(b, 3)
if g != v.ID {
return nil, errors.New("eventactions: wrong fireworks group")
}
ck := "fire:" + key(v.UID, g)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: fireworks already received")
}
var rs []gamedata.Reward
for _, r := range s.design.Tables["FireworksTable"] {
if r.V(4) == g {
rs = append(rs, r.Rewards...)
break
}
}
if len(rs) == 0 {
return nil, errors.New("eventactions: fireworks design missing")
}
bundle, e := s.economy.Apply(identity, nil, rs)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
return wire.AppendBytes(nil, 1, bundle), nil
case "/FriendshipSpecialEpisodeInfo":
var out []byte
for k := range s.state.Claims {
var g, id uint64
if _, e := fmt.Sscanf(k, "friend:%d/%d", &g, &id); e == nil {
x := wire.AppendVarint(nil, 1, g)
x = wire.AppendVarint(x, 2, id)
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/FriendshipSpecialEpisodeClear":
g, id := val(b, 2), val(b, 3)
r, ok := s.row("FriendshipSpecialEpisodeTable", 5, 6, g, id)
if !ok {
return nil, errors.New("eventactions: unknown episode")
}
if s.friendship == nil || s.friendship(g) < r.V(12) {
return nil, errors.New("eventactions: friendship level insufficient")
}
if id > 1 && !s.state.Claims["friend:"+key(g, id-1)] {
return nil, errors.New("eventactions: previous episode incomplete")
}
ck := "friend:" + key(g, id)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: episode already cleared")
}
rs := append([]gamedata.Reward(nil), r.Rewards...)
for _, v := range s.registry.List() {
if v.Type == 24 && v.ID == g && s.active(v) {
rs = append(rs, r.EventRewards...)
break
}
}
bundle, e := s.economy.Apply(identity, nil, rs)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
x := wire.AppendVarint(nil, 1, g)
x = wire.AppendVarint(x, 2, id)
out := wire.AppendBytes(nil, 1, bundle)
out = wire.AppendBytes(out, 2, x)
return out, nil
case "/NpcQuizInfo":
uid := val(b, 2)
v, e := s.resolveQuiz(uid)
if e != nil {
return nil, e
}
var out []byte
for _, r := range s.design.Tables["NpcQuizTable"] {
if r.V(1) != v.ID {
continue
}
if s.state.Claims["quiz:"+key(uid, r.V(1), r.V(2))] {
x := wire.AppendVarint(nil, 1, uid)
x = wire.AppendVarint(x, 2, r.V(1))
x = wire.AppendVarint(x, 3, r.V(2))
out = wire.AppendBytes(out, 1, x)
}
}
return out, nil
case "/NpcQuizClear":
uid, g, id := val(b, 2), val(b, 3), val(b, 4)
v, e := s.resolveQuiz(uid)
if e != nil {
return nil, e
}
if !s.active(v) {
return nil, errors.New("eventactions: quiz event inactive")
}
r, ok := s.row("NpcQuizTable", 1, 2, g, id)
if !ok || g != v.ID || s.now().UnixMilli() < v.Start+int64(r.V(8))*86400000 {
return nil, errors.New("eventactions: quiz locked")
}
ck := "quiz:" + key(uid, g, id)
if s.state.Claims[ck] {
return nil, errors.New("eventactions: quiz already cleared")
}
bundle, e := s.economy.Apply(identity, nil, r.Rewards)
if e != nil {
return nil, e
}
s.state.Claims[ck] = true
x := wire.AppendVarint(nil, 1, uid)
x = wire.AppendVarint(x, 2, g)
x = wire.AppendVarint(x, 3, id)
out := wire.AppendBytes(nil, 1, bundle)
return wire.AppendBytes(out, 2, x), nil
case "/TacticsBingoInfo":
uid := val(b, 2)
v, e := s.registry.Resolve(uid)
if e != nil {
return nil, e
}
if v.Type != 20 {
return nil, errors.New("eventactions: wrong tactics event")
}
out := wire.AppendVarint(nil, 1, uid)
out = wire.AppendVarint(out, 2, v.ID)
for _, id := range s.state.Tactics[uid] {
out = wire.AppendVarint(out, 3, id)
}
if !s.active(v) {
out = wire.AppendVarint(out, 4, 1)
}
return out, nil
case "/TacticsBingoDeckSave":
seen := map[uint64]bool{}
count := 0
err := wire.Walk(b, func(f wire.Field) error {
if f.Number != 2 || f.Type != 2 {
return nil
}
idx, id := val(f.Value, 1), val(f.Value, 2)
if idx == 0 || id == 0 || seen[idx] || s.owned == nil || !s.owned(idx, id) || val(f.Value, 4) > 14 || val(f.Value, 5) == 0 {
return errors.New("eventactions: invalid tactics deck")
}
seen[idx] = true
count++
return nil
})
if err != nil {
return nil, err
}
if count > 5 {
return nil, errors.New("eventactions: tactics deck too large")
}
s.state.Deck = append([]byte(nil), b...)
return nil, nil
default:
return s.voteHandle(path, b, identity)
}
}
func (s *Service) RecordTacticsClear(uid, stage uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.resolve(uid, 20)
if e != nil {
return e
}
group, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID)
if !ok {
return errors.New("eventactions: tactics group missing")
}
if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok {
return errors.New("eventactions: invalid tactics stage")
}
for _, id := range s.state.Tactics[uid] {
if id == stage {
return nil
}
}
s.state.Tactics[uid] = append(s.state.Tactics[uid], stage)
sort.Slice(s.state.Tactics[uid], func(i, j int) bool { return s.state.Tactics[uid][i] < s.state.Tactics[uid][j] })
return s.save()
}