refactor(all): reorganize packages, establish player ownership and isolate accounts

This commit is contained in:
2026-10-07 22:42:16 +08:00
parent 8e8ddeddb6
commit 06a83ada28
850 changed files with 42335 additions and 79891 deletions
@@ -0,0 +1,63 @@
package eventtasks
import (
"bd2server/internal/server/domain/command"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/roster"
"fmt"
)
type InventoryEquipment struct{ Level uint64 }
type InventoryCostume struct{ ID, Level uint64 }
type InventorySnapshot struct {
Items map[[2]uint64]uint64
Equipment map[uint64]InventoryEquipment
Costumes map[uint64]InventoryCostume
}
type InventoryProvider interface {
InventorySnapshot(ctx command.Context) (InventorySnapshot, error)
}
type ItemSource interface {
All(ctx command.Context) []assets.Item
}
type EquipmentSource interface {
All(ctx command.Context) []assets.Equipment
}
type CostumeSource interface{ Costumes() []roster.Costume }
type InventoryProjection struct {
Items ItemSource
Equipment EquipmentSource
Costumes CostumeSource
StateVersion func() uint64
}
func (p *InventoryProjection) ObservationVersion() string {
if p.StateVersion == nil {
return ""
}
var costumes uint64
if source, ok := p.Costumes.(interface{ ObservationVersion() uint64 }); ok {
costumes = source.ObservationVersion()
}
return fmt.Sprintf("%d/%d", p.StateVersion(), costumes)
}
func (p *InventoryProjection) InventorySnapshot(ctx command.Context) (InventorySnapshot, error) {
s := InventorySnapshot{Items: map[[2]uint64]uint64{}, Equipment: map[uint64]InventoryEquipment{}, Costumes: map[uint64]InventoryCostume{}}
if p.Items != nil {
for _, v := range p.Items.All(ctx) {
s.Items[[2]uint64{v.Type, v.ID}] += v.Count
}
}
if p.Equipment != nil {
for _, v := range p.Equipment.All(ctx) {
s.Equipment[v.InvenIndex] = InventoryEquipment{Level: v.Level}
}
}
if p.Costumes != nil {
for _, v := range p.Costumes.Costumes() {
s.Costumes[v.InvenIndex] = InventoryCostume{ID: v.ID, Level: v.Level}
}
}
return s, nil
}
@@ -0,0 +1,65 @@
package eventtasks
import "bd2server/internal/server/domain/command"
import (
"fmt"
"strings"
)
// The baseline belongs to the current account transaction, not a client session.
// Reads and retries therefore do not replay historical progress as notifications.
type missionNoticeKey struct {
Schedule string
Event, Group, Task uint64
}
// visibleMissionValues is a read-only view. In particular, it must not call
// mission(), which initializes and resets persisted progress as a side effect.
func (s *Service) visibleMissionValues(ctx command.Context) map[missionNoticeKey]uint64 {
schedules := s.taskSchedules()
version := s.inventoryObservationVersion()
if version != "" {
var signature strings.Builder
now := s.now().UTC()
year, week := now.ISOWeek()
fmt.Fprintf(&signature, "%s/%s/%d:%d", version, now.Format("2006-01-02"), year, week)
for _, v := range schedules {
fmt.Fprintf(&signature, "/%s:%d:%d:%d:%t:%d", scheduleKey(v), v.ID, v.Start, v.End, s.active(v), (now.UnixMilli()-v.Start)/86400000)
}
version = signature.String()
if s.visibleMissions != nil && s.visibleVersion == version {
return s.visibleMissions
}
}
out := map[missionNoticeKey]uint64{}
for _, v := range schedules {
if v.Type != 4 || !s.active(v) {
continue
}
group := s.design.MissionGroups[v.ID]
period := ""
if group.Type == 1 {
period = s.day()
}
if group.Type == 2 {
y, w := s.now().UTC().ISOWeek()
period = fmt.Sprintf("%d-%d", y, w)
}
for _, t := range s.design.Missions {
if !s.availableTask(ctx, v, t) {
continue
}
k := missionNoticeKey{scheduleKey(v), v.ID, t.Group, t.ID}
value := uint64(0)
if m := s.state.Missions[k.Schedule+"/"+key(t.ID)]; m != nil && m.Period == period {
value = m.Value
}
out[k] = value
}
}
if version != "" {
s.visibleMissions, s.visibleVersion = out, version
}
return out
}
@@ -0,0 +1,66 @@
package eventtasks
import (
"bd2server/internal/server/domain/command"
"encoding/json"
)
func (s *Service) BeginLogin(ctx command.Context) {
s.inventoryReady = false
}
func (s *Service) inventoryObservationVersion() string {
if provider, ok := s.provider.(interface{ ObservationVersion() string }); ok {
return provider.ObservationVersion()
}
return ""
}
func (s *Service) inventorySnapshot(ctx command.Context) (InventorySnapshot, error) {
return s.provider.InventorySnapshot(ctx)
}
func (s *Service) BeforeDispatch(ctx command.Context, _ string, _ []byte) error {
s.beforeMissions = s.visibleMissionValues(ctx)
if s.provider == nil {
return nil
}
version := s.inventoryObservationVersion()
if s.inventoryReady && version != "" && version == s.observationVersion {
return nil
}
s.inventoryReady = false
var e error
s.before, e = s.inventorySnapshot(ctx)
if e == nil {
s.observationVersion = s.inventoryObservationVersion()
s.inventoryReady = true
}
return e
}
func (s *Service) AttachInventoryProvider(p InventoryProvider) { s.provider = p }
func (s *Service) CompleteSingleTargetEvent(ctx command.Context, condition uint64, unlocked func(command.Context, uint64, uint64) bool) error {
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(ctx, v, t) {
continue
}
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(ctx, t.UnlockPack, t.UnlockQuest)) {
continue
}
s.mission(v, t.ID).Value = 1
}
}
if e := s.save(ctx); e != nil {
_ = json.Unmarshal(before, &s.state)
return e
}
return nil
}
@@ -0,0 +1,723 @@
package eventtasks
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/protocol/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strconv"
"time"
)
func (s *Service) Handle(ctx command.Context, 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
}
}
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 := ctx.SessionID + ":" + 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(ctx, path, req, rk)
if err != nil {
s.state = snapshot{}
_ = json.Unmarshal(before, &s.state)
return code, nil, true, err
}
s.state.Receipts[rk] = receipt{ds, code, out}
if err = s.save(ctx); err != nil {
s.state = snapshot{}
_ = json.Unmarshal(before, &s.state)
return code, nil, true, err
}
return code, out, true, nil
}
func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v }
func (s *Service) handle(ctx command.Context, 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
var granted []gamedata.Reward
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)
}
group := a.Group
var ids []uint64
if v.Type == 0 {
for _, r := range s.design.AttendanceRewards[group] {
ids = append(ids, r.ID)
}
} else {
group = v.ID
for k := range s.design.LimitRewards {
if k[0] == group {
ids = append(ids, k[1])
}
}
}
slices.Sort(ids)
for _, id := range ids {
rewards := s.attendanceRewards(ctx, v, a, group, id)
if len(rewards) == 0 {
continue
}
granted = append(granted, rewards...)
a.Obtained[key(group, id)] = true
a.History[key(group, id)] = true
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)
}
}
if len(granted) > 0 {
if err := s.mailAttendance(ctx, identity, granted); err != nil {
return nil, err
}
}
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 {
slices.Sort(ids)
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")
}
rewards := s.attendanceRewards(ctx, v, a, g, id)
if len(rewards) == 0 {
return nil, errors.New("eventtasks: attendance day unavailable")
}
if err := s.mailAttendance(ctx, identity, rewards); err != nil {
return nil, err
}
a.Obtained[ck] = true
a.History[ck] = true
return nil, 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(ctx, 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(ctx, 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(ctx, 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(ctx, 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(ctx, v, t) || (t.Type != 1001 && t.Type != 1002) {
continue
}
var completed uint64
for _, other := range s.design.Missions {
if !s.availableTask(ctx, 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, fmt.Errorf("eventtasks: invalid pass reward kind pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all)
}
if rt == 1 && !p.Premium {
return nil, fmt.Errorf("eventtasks: premium pass required pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all)
}
var rewards []gamedata.Reward
var claims []string
var threshold uint64
for _, lv := range s.design.PassLevels[d.LevelGroup] {
eligible := p.Exp >= threshold
// NextNeedExp advances from this level to the next; level 1 starts at 0.
threshold += lv.NeedExp
if !eligible || !all && lv.ID != id {
continue
}
// IsAll selects levels within RewardType. The native one-click flow
// sends an all-basic request followed by a separate all-premium one.
ck := key(lv.ID, rt)
if p.Claimed[ck] {
continue
}
r := lv.Basic
if rt == 1 {
r = lv.Premium
}
if r.Type > 0 && r.Count > 0 {
rewards = append(rewards, r)
}
claims = append(claims, ck)
}
if len(claims) == 0 && !all {
return nil, fmt.Errorf("eventtasks: pass reward not eligible pass=%d level=%d reward_type=%d all=%t exp=%d premium=%t", v.ID, id, rt, all, p.Exp, p.Premium)
}
var bundle []byte
if len(claims) > 0 {
bundle, e = s.economy.Apply(ctx, identity, nil, rewards)
if e != nil {
return nil, fmt.Errorf("eventtasks: pass reward grant failed pass=%d level=%d reward_type=%d all=%t: %w", v.ID, id, rt, all, e)
}
}
for _, k := range claims {
p.Claimed[k] = true
}
// An already-claimed all-basic phase must still succeed so the client
// can proceed to all-premium. Include a non-null empty reward bundle.
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(ctx, mv, t) && !s.mission(mv, t.ID).Claimed {
complete = false
}
}
}
if complete && s.state.NewbieStep == d.NewbieStep {
s.state.NewbieStep++
}
// The native receiver assigns this field even on an incomplete step.
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 = &copy
break
}
}
if chosen == nil {
return nil, errors.New("eventtasks: unknown pass purchase")
}
d := s.design.Passes[v.ID]
if d.NewbieStep > s.state.NewbieStep {
return nil, fmt.Errorf("eventtasks: guide pass step locked pass=%d type=%d", v.ID, typ)
}
levels := s.design.PassLevels[d.LevelGroup]
if len(levels) == 0 {
return nil, fmt.Errorf("eventtasks: missing pass levels pass=%d type=%d", v.ID, typ)
}
starts := make([]uint64, len(levels))
levelIndex := 0
for i := 1; i < len(levels); i++ {
starts[i] = starts[i-1] + levels[i-1].NeedExp
if p.Exp >= starts[i] {
levelIndex = i
}
}
if typ == 0 && levelIndex == len(levels)-1 {
return nil, fmt.Errorf("eventtasks: pass already at max level pass=%d type=%d", v.ID, typ)
}
if p.Premium && (typ == 1 || typ == 3) {
return nil, fmt.Errorf("eventtasks: premium already active pass=%d type=%d", v.ID, typ)
}
// CashShopBuy charges paid diamonds and grants the pass ticket first.
// Activation consumes that purchase entitlement and the design ticket.
if chosen.CashID != 0 && (s.authorizeCash == nil || !s.authorizeCash(ctx, v.ID, typ)) {
return nil, fmt.Errorf("eventtasks: cash entitlement required pass=%d type=%d sku=%d/%d/%d", v.ID, typ, chosen.CashGroup, chosen.CashID, chosen.CashSales)
}
var costs []gamedata.Reward
if chosen.Cost.Count > 0 {
cost := chosen.Cost
if typ == 0 {
// PassRootUI.GetLevelPassBuyWeight uses the current level.
cost.Count *= levels[levelIndex].ID
}
costs = append(costs, cost)
}
if _, e = s.economy.Apply(ctx, identity, costs, chosen.Rewards); e != nil {
return nil, fmt.Errorf("eventtasks: pass payment failed pass=%d type=%d: %w", v.ID, typ, e)
}
if chosen.LevelsGranted > 0 {
target := uint64(levelIndex) + min(chosen.LevelsGranted, uint64(len(levels)-1-levelIndex))
// Preserve progress within the current level and discard surplus at MAX.
p.Exp = min(starts[target]+p.Exp-starts[levelIndex], starts[len(levels)-1])
}
if typ == 1 || typ == 3 {
p.Premium = true
}
return wire.AppendVarint(nil, 1, p.Exp), nil
}
return nil, errors.New("eventtasks: unsupported operation")
}
func (s *Service) Notify(ctx command.Context) ([]byte, error) {
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(ctx, 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
}
func (s *Service) AfterDispatch(ctx command.Context, path string, request, response []byte) ([]byte, error) {
if s.provider != nil {
version := s.inventoryObservationVersion()
if s.inventoryReady && version != "" && version == s.observationVersion {
return s.notifyMissionChanges(ctx)
}
s.inventoryReady = false
after, e := s.inventorySnapshot(ctx)
if e != nil {
return nil, e
}
rk := fmt.Sprintf("observer:%s:%s:%d", ctx.SessionID, path, scalar(request, 1))
_, seen := s.state.Receipts[rk]
if !seen {
type delta struct{ condition, sub, count uint64 }
var deltas []delta
for kind, old := range s.before.Items {
current := after.Items[kind]
if current < old {
condition := uint64(12)
if kind[1] == 0 {
condition = 11
}
deltas = append(deltas, delta{condition, kind[0], old - current})
}
}
for kind, current := range after.Items {
old := s.before.Items[kind]
if current > old {
deltas = append(deltas, delta{32, kind[0], current - old})
}
}
for idx, current := range after.Equipment {
old, ok := s.before.Equipment[idx]
if ok && current.Level > old.Level {
deltas = append(deltas, delta{14, 0, current.Level - old.Level})
}
}
for idx, current := range after.Costumes {
old, ok := s.before.Costumes[idx]
if ok && current.Level > old.Level {
deltas = append(deltas, delta{104, current.ID, current.Level - old.Level})
}
}
if len(deltas) > 0 {
before, e := json.Marshal(s.state)
if e != nil {
return nil, e
}
changed := false
for _, d := range deltas {
changed = s.recordEventLocked(ctx, d.condition, d.sub, d.count, s.unlocked) || changed
}
if changed {
s.state.Receipts[rk] = receipt{Digest: "observer"}
e = s.save(ctx)
}
if e != nil {
s.state = snapshot{}
_ = json.Unmarshal(before, &s.state)
}
if e != nil {
return nil, e
}
}
}
s.before = after
s.observationVersion = s.inventoryObservationVersion()
s.inventoryReady = true
}
return s.notifyMissionChanges(ctx)
}
func (s *Service) notifyMissionChanges(ctx command.Context) ([]byte, error) {
if s.beforeMissions == nil {
return nil, nil
}
current := s.visibleMissionValues(ctx)
var keys []missionNoticeKey
for k, value := range current {
if value != s.beforeMissions[k] {
keys = append(keys, k)
}
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.Event != b.Event {
return a.Event < b.Event
}
if a.Group != b.Group {
return a.Group < b.Group
}
if a.Task != b.Task {
return a.Task < b.Task
}
return a.Schedule < b.Schedule
})
var out, groupMap, rows []byte
var eventID, groupID uint64
flushGroup := func() {
if len(rows) == 0 {
return
}
entry := wire.AppendVarint(nil, 1, groupID)
entry = wire.AppendBytes(entry, 2, rows)
groupMap = wire.AppendBytes(groupMap, 1, entry)
rows = nil
}
flushEvent := func() {
flushGroup()
if len(groupMap) == 0 {
return
}
entry := wire.AppendVarint(nil, 1, eventID)
entry = wire.AppendBytes(entry, 2, groupMap)
out = wire.AppendBytes(out, 4, entry)
groupMap = nil
}
for _, k := range keys {
if k.Event != eventID {
flushEvent()
eventID = k.Event
groupID = k.Group
}
if k.Group != groupID {
flushGroup()
groupID = k.Group
}
entry := wire.AppendVarint(nil, 1, k.Task)
entry = wire.AppendVarint(entry, 2, current[k])
rows = wire.AppendBytes(rows, 1, entry)
}
flushEvent()
// Consume only this request's baseline; the next BeforeDispatch takes a fresh
// snapshot even when a transaction rolls back or the session changes.
s.beforeMissions = nil
return out, nil
}
@@ -0,0 +1,435 @@
// 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/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/storage/stateio"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
type Economy interface {
Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error)
}
// AttendanceMailIssuer delivers attendance attachments inside the parent
// account transaction. Its durable identity prevents duplicate mail on replay.
type AttendanceMailIssuer interface {
IssueAttachmentsOnce(ctx command.Context, identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) 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 InventoryProvider
before InventorySnapshot
observationVersion string
inventoryReady bool
beforeMissions map[missionNoticeKey]uint64
visibleMissions map[missionNoticeKey]uint64
visibleVersion string
store stateio.Store
design *gamedata.EventTasksDesign
registry events.Resolver
economy Economy
state snapshot
now func() time.Time
unlocked func(command.Context, uint64, uint64) bool
authorizeCash func(command.Context, uint64, uint64) bool
attendancePremium func(command.Context, uint64) bool
attendanceMail AttendanceMailIssuer
associated func(events.Schedule) uint64
}
func Open(ctx command.Context, 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(ctx.State, "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) AttachUnlockResolver(f func(command.Context, uint64, uint64) bool) { s.unlocked = f }
func (s *Service) AttachCashAuthorization(f func(command.Context, uint64, uint64) bool) {
s.authorizeCash = f
}
func (s *Service) AttachAttendancePremium(f func(command.Context, uint64) bool) {
s.attendancePremium = f
}
func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f }
func (s *Service) AttachAttendanceMail(issuer AttendanceMailIssuer) { s.attendanceMail = issuer }
func (s *Service) mailAttendance(ctx command.Context, identity string, rewards []gamedata.Reward) error {
if s.attendanceMail == nil {
return errors.New("eventtasks: attendance mailbox unavailable")
}
return s.attendanceMail.IssueAttachmentsOnce(ctx, identity, "Attendance Rewards", "Your attendance rewards are ready. Please claim the attachments in your mailbox.", rewards, s.now())
}
func (s *Service) SetNewbieStep(ctx command.Context, step uint64) error {
if step > s.state.NewbieStep {
s.state.NewbieStep = step
}
return s.save(ctx)
}
func (s *Service) NewbieStep() uint64 { return s.state.NewbieStep }
func (s *Service) save(ctx command.Context) error {
b, e := json.Marshal(s.state)
if e != nil {
return e
}
return s.store.Save(ctx.State, "eventtasks", b)
}
func key(v ...uint64) string {
var k string
for i, n := range v {
if i > 0 {
k += "/" //nolint:modernize // stringsbuilder
}
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
}
// attendanceRewards is shared by automatic and explicit claims. Obtained is
// the current cycle ledger; History is the client-visible historical ledger.
func (s *Service) attendanceRewards(ctx command.Context, v events.Schedule, a *attendance, group, id uint64) []gamedata.Reward {
if a.Obtained[key(group, id)] {
return nil
}
if v.Type == 0 {
if group != a.Group {
return nil
}
for _, r := range s.design.AttendanceRewards[group] {
if r.ID != id || r.Day > a.Count {
continue
}
var rewards []gamedata.Reward
if r.Basic.Count > 0 {
rewards = append(rewards, r.Basic)
}
if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ctx, ticket) && r.Premium.Count > 0 {
rewards = append(rewards, r.Premium)
}
return rewards
}
} else if v.Type == 1 && group == v.ID && id > 0 {
box, ok := s.design.LimitRewards[[2]uint64{group, id}]
date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02")
_, logged := s.state.LoginDays[date]
if ok && box > 0 && logged && date == s.day() {
return []gamedata.Reward{{Type: 9, ID: box, Count: 1}}
}
}
return nil
}
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(ctx command.Context, 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(ctx, t.UnlockPack, t.UnlockQuest)
}
}
return false
}
// RecordEvent is called by authoritative gameplay. It returns no rewards and
// caps every matching task at its design threshold.
func (s *Service) RecordEvent(ctx command.Context, condition, sub, count uint64, unlocked func(command.Context, uint64, uint64) bool) error {
if !s.recordEventLocked(ctx, condition, sub, count, unlocked) {
return nil
}
return s.save(ctx)
}
// recordEventLocked reports every persistent mutation, including initializing
// a matching task or rolling its period. Irrelevant and already capped events
// leave the account snapshot untouched.
func (s *Service) recordEventLocked(ctx command.Context, condition, sub, count uint64, unlocked func(command.Context, uint64, uint64) bool) bool {
changed := false
if count == 0 {
return false
}
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 || condition == 349 && t.SubType != sub || !s.availableTask(ctx, v, t) {
continue
}
if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(ctx, t.UnlockPack, t.UnlockQuest)) {
continue
}
match := len(t.Params) == 0
for _, n := range t.Params {
if n == sub {
match = true
}
}
if !match {
continue
}
previous := s.state.Missions[scheduleKey(v)+"/"+key(t.ID)]
var prior mission
if previous != nil {
prior = *previous
}
m := s.mission(v, t.ID)
if previous == nil || prior != *m {
changed = true
}
if m.Claimed {
continue
}
if m.Value >= t.Target {
continue
}
if count >= t.Target-m.Value {
m.Value = t.Target
} else {
m.Value += count
}
changed = true
}
}
return changed
}
// 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(ctx command.Context, unlocked func(command.Context, uint64, uint64) bool) error {
marker := "mission-login:" + s.day()
if _, ok := s.state.LoginDays[marker]; ok {
return nil
}
s.state.LoginDays[marker] = s.now().UnixMilli()
return s.RecordEvent(ctx, 0, 0, 1, unlocked)
}