Files
bd2/go/internal/server/eventplay/play.go
T
Flechazo 3b08040a0e fix(server): support dynamic hidden event pack purchase and entry
why the f^^kkkkkk does sh^t AI love writing useless tests so much just to clutter up my repo, looks like I’ll have to find some time to clean this s^^t up later.
2026-10-05 20:37:38 +08:00

722 lines
21 KiB
Go

// Package eventplay owns event stories and client-simulated minigame runs.
package eventplay
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
)
type Rewards interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type Run struct {
UID, Game, Stage, Mode, Started, Score, HP uint64
Family, Session string
Objects, Skills []uint64
Coin, Exp, Char, MapGroup uint64
SkillLevels map[uint64]uint64
MaxHP, Rerolls, Level, SkillCredits uint64
Offers []uint64
LevelExp uint64
Killed, Picked, Available map[uint64]uint64
}
type record struct {
UID, Game, Stage, Mode, Best, Paid, Date uint64
Family string
Clears map[string]bool
PendingBundle []byte
}
type reply struct{ Request, Body []byte }
type snapshot struct {
Version int `json:"version"`
Records map[string]record `json:"records"`
Runs map[string]Run `json:"runs"`
Replies map[string]reply `json:"replies"`
Stories map[string]bool `json:"stories"`
Upgrades map[string]uint64 `json:"upgrades"`
}
type Service struct {
battleChallenges gamedata.EventBattleChallenges
mu sync.Mutex
store stateio.Store
registry events.Resolver
economy Rewards
design *gamedata.EventPlayCatalog
state snapshot
now func() time.Time
rooms RoomRuntime
onProgress func(uint64, uint64, uint64) error
hubCalendars *readonly.Seed
fieldBindings []FieldBinding
}
func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) {
if store == nil || registry == nil || economy == nil {
return nil, fmt.Errorf("eventplay: incomplete configuration")
}
d, e := gamedata.LoadEventPlayCatalog(root, version)
if e != nil {
return nil, e
}
s := &Service{store: store, registry: registry, economy: economy, design: d, now: time.Now, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}}
raw, e := store.Load("eventplay")
if e != nil {
return nil, e
}
if raw != nil {
if e = stateio.RequireExactJSONObject(raw, "version", "records", "runs", "replies", "stories", "upgrades"); e != nil {
return nil, e
}
if e = json.Unmarshal(raw, &s.state); e != nil {
return nil, e
}
if s.state.Version != 1 || s.state.Records == nil || s.state.Runs == nil || s.state.Replies == nil || s.state.Stories == nil || s.state.Upgrades == nil {
return nil, fmt.Errorf("eventplay: incompatible save")
}
}
for _, r := range s.state.Records {
if r.UID == 0 || r.Game == 0 || r.Best > math.MaxInt32 || r.Clears == nil {
return nil, fmt.Errorf("eventplay: invalid saved record")
}
if _, e = s.design.Row("PackEventMiniGameTable", 8, r.Game); e != nil {
return nil, e
}
}
return s, nil
}
func num(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
// AttachHubCalendars installs project-maintained hub layouts, including the
// separate play/end windows and slots referencing multiple domain identities.
func (s *Service) AttachHubCalendars(seed *readonly.Seed) {
s.hubCalendars = seed
}
func list(b []byte, n int) ([]uint64, error) {
var out []uint64
e := wire.Walk(b, func(f wire.Field) error {
if f.Number != n {
return nil
}
if f.Type == 0 {
v, _ := binary.Uvarint(f.Value)
out = append(out, v)
return nil
}
if f.Type != 2 {
return wire.ErrMalformed
}
for p := f.Value; len(p) > 0; {
v, k := binary.Uvarint(p)
if k <= 0 {
return wire.ErrMalformed
}
out = append(out, v)
p = p[k:]
}
return nil
})
return out, e
}
func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) {
return s.HandleSession(path, req, "local")
}
func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) {
code, ok := codes[path]
if !ok {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e }
seq := num(req, 1)
if seq == 0 || seq > math.MaxInt32 {
return fail(fmt.Errorf("eventplay: invalid sequence"))
}
if e := wire.Walk(req, func(f wire.Field) error {
if f.Type == 0 {
v, _ := binary.Uvarint(f.Value)
if v > math.MaxInt64 {
return wire.ErrMalformed
}
}
return nil
}); e != nil {
return fail(e)
}
// Public hub calendars are derived afresh, never stored as account replies.
if path == "/EventHubInfo" || path == "/MiniEventHubInfo" || path == "/MiniGameHubInfo" {
if s.hubCalendars != nil && path == "/MiniGameHubInfo" {
return s.hubCalendars.Handle(path, req)
}
if s.hubCalendars != nil && path == "/EventHubInfo" {
out, err := s.publicEventHubs(req)
return code, out, true, err
}
if path == "/MiniEventHubInfo" {
out, err := s.miniEventHubs(req)
return code, out, true, err
}
return code, nil, true, nil
}
key := fmt.Sprintf("%s:%s:%d", session, path, seq)
if r, ok := s.state.Replies[key]; ok {
if !bytes.Equal(r.Request, req) {
return fail(fmt.Errorf("eventplay: changed request retry"))
}
return code, r.Body, true, nil
}
raw, _ := json.Marshal(s.state)
var next snapshot
_ = json.Unmarshal(raw, &next)
var out []byte
var e error
switch path {
case "/MiniGameUserRecordInfo", "/MiniGameRanking":
out = summaryWire(&next, path == "/MiniGameUserRecordInfo")
case "/MiniGameRelayServerInfo":
return fail(fmt.Errorf("eventplay: native relay channels are not configured"))
case "/PackEventStoryInfo", "/PackEventBattleInfo":
uids, err := list(req, 2)
if err != nil {
return fail(err)
}
for _, uid := range uids {
c, err := s.registry.Resolve(uid)
if err != nil {
return fail(err)
}
if path == "/PackEventStoryInfo" {
for _, r := range s.design.Rows("PackEventStoryTable", 1, c.ID) {
id := num(r, 2)
if next.Stories[fmt.Sprintf("%d:%d:%d", uid, c.ID, id)] {
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, id)
out = wire.AppendBytes(out, 1, v)
}
}
} else {
for _, r := range s.design.Rows("PackEventBattleTable", 4, c.ID) {
id := num(r, 5)
if !next.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, c.ID, id)] {
continue
}
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, c.ID)
v = wire.AppendVarint(v, 3, id)
for _, idx := range s.battleChallengeIndexesLocked(uid, c.ID, id) {
v = wire.AppendVarint(v, 4, idx)
}
out = wire.AppendBytes(out, 1, v)
}
}
}
case "/PackEventStoryClear", "/PackEventStoryReplayClear":
uid, group, id := num(req, 2), num(req, 3), num(req, 4)
if path == "/PackEventStoryReplayClear" {
group, id = num(req, 2), num(req, 3)
uid = 0
}
if uid != 0 {
c, err := s.registry.Resolve(uid)
if err != nil {
return fail(err)
}
if c.ID != group || s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End {
return fail(fmt.Errorf("eventplay: story schedule unavailable"))
}
}
r, err := s.design.Story(group, id)
if err != nil {
return fail(err)
}
storyKey := fmt.Sprintf("%d:%d:%d", uid, group, id)
if !next.Stories[storyKey] {
rs, err := gamedata.EventPlayRewards(r, 11, 10, 9)
if err != nil {
return fail(err)
}
out, e = s.grant("eventplay:story:"+storyKey, rs)
if e != nil {
return fail(e)
}
out = wire.AppendBytes(nil, 1, out)
next.Stories[storyKey] = true
}
if path == "/PackEventStoryClear" {
v := wire.AppendVarint(nil, 1, uid)
v = wire.AppendVarint(v, 2, group)
v = wire.AppendVarint(v, 3, id)
out = wire.AppendBytes(out, 2, v)
}
default:
out, e = s.game(path, req, session, &next, key)
}
if e != nil {
return fail(e)
}
next.Replies[key] = reply{append([]byte(nil), req...), out}
b, e := json.Marshal(next)
if e != nil {
return fail(e)
}
if e = s.store.Save("eventplay", b); e != nil {
return fail(e)
}
s.state = next
return code, out, true, nil
}
func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) {
rs := make([]gamedata.Reward, 0, len(rewards))
for _, r := range rewards {
rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
}
return s.economy.Apply(identity, nil, rs)
}
func recordWire(r record) []byte {
b := wire.AppendVarint(nil, 1, r.UID)
b = wire.AppendVarint(b, 2, r.Paid)
b = wire.AppendVarint(b, 3, r.Best)
if r.Best > 0 && r.Paid < r.Best {
b = wire.AppendVarint(b, 4, 1)
}
return b
}
func family(path string) string {
p := strings.TrimPrefix(path, "/MiniGame")
for _, f := range []string{"Hopscotch", "Survival", "Defense", "Rhythm", "Sichuan", "Action", "Field", "Run"} {
if strings.HasPrefix(p, f) {
return f
}
}
return ""
}
func recKey(uid, stage, mode uint64, f string) string {
return fmt.Sprintf("%d:%s:%d:%d", uid, f, stage, mode)
}
func (s *Service) calendar(uid uint64, f string) (events.Schedule, error) {
if uid == 0 {
for _, c := range s.registry.List() {
if c.Type == 11 {
d, e := s.design.Row("PackEventMiniGameTable", 8, c.ID)
if e != nil {
continue
}
kind := num(d, 3)
expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f]
if f == "Run" && (kind == 2 || kind == 4) {
expected = kind
}
if kind != expected {
continue
}
uid = c.UID
break
}
}
}
c, e := s.registry.Resolve(uid)
if e != nil {
return c, e
}
if c.Type != 11 {
return c, fmt.Errorf("eventplay: minigame schedule mismatch")
}
if _, e = s.design.Row("PackEventMiniGameTable", 8, c.ID); e != nil {
return c, e
}
d, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID)
kind := num(d, 3)
expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f]
if f == "Run" && (kind == 2 || kind == 4) {
expected = kind
}
if kind != expected {
return c, fmt.Errorf("eventplay: configured minigame family mismatch")
}
return c, nil
}
func (s *Service) game(path string, req []byte, session string, next *snapshot, key string) ([]byte, error) {
f := family(path)
if f == "" {
return nil, fmt.Errorf("eventplay: unknown family")
}
start := strings.HasSuffix(path, "Start")
end := strings.HasSuffix(path, "End")
runKey := session + ":" + f
uid := num(req, 2)
if end || path == "/MiniGameFieldScore" || path == "/MiniGameSurvivalPlay" || strings.Contains(path, "Skill") {
if a, ok := next.Runs[runKey]; ok {
uid = a.UID
} else {
return nil, fmt.Errorf("eventplay: no active run")
}
}
c, e := s.calendar(uid, f)
if e != nil {
return nil, e
}
uid = c.UID
stage, mode := num(req, 3), num(req, 4)
if f == "Rhythm" {
mode = num(req, 5)
}
if f == "Survival" {
stage = num(req, 5)
}
if f == "Sichuan" {
stage = num(req, 4)
}
rk := recKey(uid, stage, mode, f)
r := next.Records[rk]
if r.Clears == nil {
r = record{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Family: f, Clears: map[string]bool{}}
}
if path == "/MiniGameSurvivalCharUpgrade" || path == "/MiniGameSurvivalCharUpgradeReset" {
return s.upgrade(path, req, uid, next, key)
}
if path == "/MiniGameDefenseMatching" {
matching := num(req, 3)
if matching > 2 {
return nil, fmt.Errorf("eventplay: unknown matching mode")
}
next.Runs[runKey+":matching"] = Run{UID: uid, Game: c.ID, Family: f, Session: session}
return nil, nil
}
if start {
if s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End {
return nil, fmt.Errorf("eventplay: season closed")
}
if _, active := next.Runs[runKey]; active {
return nil, fmt.Errorf("eventplay: run already active")
}
if e = s.validateStage(f, stage, mode); e != nil {
return nil, e
}
a := Run{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Started: uint64(s.now().UnixMilli()), Family: f, Session: session, HP: 100}
if f == "Action" {
game, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID)
group := num(game, 4)
rows := s.design.Rows("ActionGameStageTable", 3, group)
if len(rows) == 0 {
return nil, fmt.Errorf("eventplay: action stage group missing")
}
a.Stage = num(rows[0], 4)
a.Mode = group
a.Char = num(rows[0], 1)
}
if f == "Action" || f == "Defense" {
if locked, ok := next.Runs[runKey+":native"]; ok {
a.Stage, a.Mode, a.Char = locked.Stage, locked.Mode, locked.Char
a.Session = locked.Session
if s.rooms != nil {
if e = s.rooms.ValidateRoom(session, a.Session, uid); e != nil {
return nil, e
}
}
}
}
var startReply []byte
if f == "Field" {
d, e := s.design.Field(c.ID)
if e != nil {
return nil, e
}
a.HP = d.HP
ids := make([]uint64, 0, len(d.Objects))
for id := range d.Objects {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
startReply = wire.AppendVarint(startReply, 1, id)
}
}
if f == "Survival" {
a.Char = num(req, 3)
a.MapGroup = num(req, 4)
char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char)
if e != nil {
return nil, e
}
a.HP = num(char, 20)
a.MaxHP = a.HP
a.Rerolls = num(char, 19)
a.Level = 1
a.SkillCredits = 0
a.Killed = map[uint64]uint64{}
a.Picked = map[uint64]uint64{}
a.Available = map[uint64]uint64{}
skillGroup := num(char, 3)
skills := s.design.Rows("FieldMiniGameSkillTable", 7, skillGroup)
if len(skills) == 0 {
return nil, fmt.Errorf("eventplay: character starting skill missing")
}
a.Skills = []uint64{num(skills[0], 8)}
a.SkillLevels = map[uint64]uint64{skillGroup: 1}
found := false
for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) {
if num(m, 3) == a.Stage {
found = true
}
}
if !found {
return nil, fmt.Errorf("eventplay: survival map missing")
}
}
next.Runs[runKey] = a
if s.onProgress != nil {
if e = s.onProgress(343, c.ID, 1); e != nil {
return nil, e
}
if e = s.onProgress(315, c.ID, 1); e != nil {
return nil, e
}
}
next.Records[rk] = r
if f == "Survival" {
v := wire.AppendVarint(nil, 1, a.Skills[0])
v = wire.AppendVarint(v, 2, 1)
out := wire.AppendBytes(nil, 1, v)
return wire.AppendVarint(out, 2, seqSeed(a.Started)), nil
}
return startReply, nil
}
if end {
a := next.Runs[runKey]
if a.UID != uid {
return nil, fmt.Errorf("eventplay: run schedule changed")
}
if f == "Action" && (num(req, 3) != a.Mode || num(req, 4) != a.Stage || num(req, 6) != a.Char) {
return nil, fmt.Errorf("eventplay: action submitted stage/monster differs from configured start")
}
if f == "Action" || f == "Defense" {
guid, _, err := wire.Bytes(req, 2)
if err != nil {
return nil, err
}
if a.Session != session && !bytes.Equal(guid, []byte(a.Session)) {
return nil, fmt.Errorf("eventplay: native room guid mismatch")
}
if s.rooms != nil {
if e = s.rooms.ValidateRoom(session, string(guid), uid); e != nil {
return nil, e
}
}
}
score, e := s.endScore(f, req, a)
if e != nil {
return nil, e
}
if s.onProgress != nil {
switch f {
case "Survival":
for condition, count := range map[uint64]uint64{316: a.Level, 317: score} {
if e = s.onProgress(condition, c.ID, count); e != nil {
return nil, e
}
}
if num(req, 2) == 0 {
if e = s.onProgress(320, a.Stage, 1); e != nil {
return nil, e
}
}
case "Defense":
if e = s.onProgress(322, c.ID, score); e != nil {
return nil, e
}
case "Action":
if e = s.onProgress(323, a.Char, 1); e != nil {
return nil, e
}
for condition, field := range map[uint64]int{326: 9, 327: 10, 329: 12, 330: 13, 333: 14, 334: 15, 335: 16, 336: 17} {
if count := num(req, field); count > 0 {
if e = s.onProgress(condition, a.Char, count); e != nil {
return nil, e
}
}
}
}
}
rk = recKey(uid, a.Stage, a.Mode, f)
r = next.Records[rk]
if r.Clears == nil {
r.Clears = map[string]bool{}
}
if score > r.Best {
r.Best = score
}
var out []byte
point, rewards, e := s.scoreRewards(c.ID, score)
if e != nil {
return nil, e
}
if f == "Defense" {
for _, row := range s.design.Tables["MGDRewardTable"] {
id, wave := num(row, 1), num(row, 6)
ck := fmt.Sprintf("wave:%d", id)
if wave <= score && !r.Clears[ck] {
rr, err := gamedata.EventPlayRewards(row, 5, 3, 2)
if err != nil {
return nil, err
}
b, err := s.grant("eventplay:"+key+":"+ck, rr)
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
out = wire.AppendVarint(out, 2, id)
r.Clears[ck] = true
}
}
}
if f == "Survival" {
if num(req, 5) > a.Coin || num(req, 6) > a.Exp {
return nil, fmt.Errorf("eventplay: end coin/exp exceeds committed play progress")
}
out = wire.AppendVarint(out, 2, a.Coin)
out = wire.AppendVarint(out, 3, a.Exp)
if num(req, 2) == 0 {
r.Clears[fmt.Sprintf("map:%d:%d", a.MapGroup, a.Stage)] = true
for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) {
if num(m, 3) != a.Stage {
continue
}
rr, err := gamedata.EventPlayRewards(m, 8, 7, 6)
if err != nil {
return nil, err
}
if len(rr) > 0 {
b, err := s.grant(fmt.Sprintf("eventplay:map:%d:%d:%d", uid, a.MapGroup, a.Stage), rr)
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
}
}
}
if a.Coin > 0 {
b, err := s.grant("eventplay:"+key+":coin", []gamedata.BattleReward{{Type: 43, Count: a.Coin}})
if err != nil {
return nil, err
}
out = wire.AppendBytes(out, 1, b)
}
}
day := uint64(s.now().UnixMilli()) / 86400000
if r.Date != day {
r.Paid = 0
}
if point > r.Paid {
_, previous, e := s.scoreRewards(c.ID, r.Paid)
if e != nil {
return nil, e
}
bundle, e := s.grant("eventplay:"+key, rewardDifference(previous, rewards))
if e != nil {
return nil, e
}
field := 1
if f == "Run" {
field = 3
} else if f == "Field" {
field = 5
} else if f == "Action" {
field = 3
} else if f == "Hopscotch" {
field = 4
}
if f == "Rhythm" {
r.PendingBundle = append(r.PendingBundle, bundle...)
} else {
out = wire.AppendBytes(out, field, bundle)
}
r.Paid = point
r.Date = day
}
switch f {
case "Run":
out = wire.AppendVarint(out, 1, r.Best)
out = wire.AppendVarint(out, 2, r.Paid)
case "Field":
out = wire.AppendVarint(out, 1, uid)
out = wire.AppendVarint(out, 2, score)
out = wire.AppendVarint(out, 3, r.Best)
out = wire.AppendVarint(out, 4, r.Paid)
case "Sichuan":
out = wire.AppendDouble(out, 4, float64(r.Best))
case "Rhythm":
v := wire.AppendVarint(nil, 1, a.Stage)
v = wire.AppendVarint(v, 2, a.Mode)
v = wire.AppendVarint(v, 3, r.Best)
v = wire.AppendVarint(v, 4, num(req, 2))
v = wire.AppendVarint(v, 5, num(req, 6))
out = wire.AppendBytes(out, 1, v)
case "Action":
out = wire.AppendVarint(out, 1, r.Best)
case "Hopscotch":
v := wire.AppendVarint(nil, 1, a.Stage)
if score > 0 {
v = wire.AppendVarint(v, 2, 1)
}
out = wire.AppendBytes(out, 2, v)
}
next.Records[rk] = r
delete(next.Runs, runKey)
if s.rooms != nil && (f == "Action" || f == "Defense") {
if e = s.rooms.CompleteRoom(session, a.Session, score); e != nil {
return nil, e
}
}
return out, nil
}
if strings.Contains(path, "Ranking") || strings.Contains(path, "RecordInfo") {
return rankingWire(next, uid, f, strings.Contains(path, "RecordInfo")), nil
}
if strings.HasSuffix(path, "Info") {
var out []byte
if f == "Rhythm" {
for k, v := range next.Records {
if v.UID == uid && v.Family == f && len(v.PendingBundle) > 0 {
out = wire.AppendBytes(out, 2, v.PendingBundle)
v.PendingBundle = nil
next.Records[k] = v
}
}
}
if f != "Run" && f != "Field" {
if _, exists := next.Records[rk]; !exists {
next.Records[rk] = r
}
return append(out, specializedInfo(next, uid, f)...), nil
}
for _, v := range next.Records {
if v.UID == uid && v.Family == f {
out = wire.AppendBytes(out, 1, recordWire(v))
}
}
if len(out) == 0 {
next.Records[rk] = r
out = wire.AppendBytes(out, 1, recordWire(r))
}
return out, nil
}
if strings.Contains(path, "QuickReward") || path == "/MiniGameFieldReward" {
return s.claimScore(path, uid, f, next, key)
}
return s.progress(path, req, runKey, next, key)
}