feat(server): queue dynamic operator mail grants
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
// GrantSpool is a local operator input, independent of the versioned starter
|
||||
// mailbox and player state. Writers atomically replace the whole JSON file.
|
||||
// Version 1 uses Unix milliseconds; a stable identity is never issued twice,
|
||||
// including after the player opens the mail or the server restarts.
|
||||
type GrantSpool struct {
|
||||
Version uint64 `json:"version"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
|
||||
type Grant struct {
|
||||
Identity string `json:"identity"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
SentAt int64 `json:"sent_at"`
|
||||
Rewards []GrantReward `json:"rewards"`
|
||||
}
|
||||
|
||||
type GrantReward struct {
|
||||
Type uint64 `json:"type"`
|
||||
ID uint64 `json:"id"`
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
||||
type compensation struct {
|
||||
identity, title, body string
|
||||
sentAt time.Time
|
||||
rewards []gamedata.Reward
|
||||
}
|
||||
|
||||
func (g compensation) validate() error {
|
||||
if strings.TrimSpace(g.identity) == "" || strings.TrimSpace(g.identity) != g.identity || utf8.RuneCountInString(g.identity) > 500 ||
|
||||
strings.TrimSpace(g.title) == "" || utf8.RuneCountInString(g.title) > 500 ||
|
||||
strings.TrimSpace(g.body) == "" || utf8.RuneCountInString(g.body) > 5000 ||
|
||||
g.sentAt.IsZero() || g.sentAt.UnixMilli() <= 0 || g.sentAt.UnixMilli() > math.MaxInt64-int64(30*24*time.Hour/time.Millisecond) || len(g.rewards) == 0 {
|
||||
return errors.New("mail: invalid compensation identity, content, time or rewards")
|
||||
}
|
||||
for _, reward := range g.rewards {
|
||||
if reward.Count == 0 || reward.Count > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) ||
|
||||
(currencyRewardTypes[reward.Type] && reward.ID != 0) {
|
||||
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", g.identity, reward.Type, reward.ID, reward.Count)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachGrantSpoolPath enables reads before each /MailInfo. An absent file is
|
||||
// an empty queue, allowing an operator to create it after server startup.
|
||||
// Existing malformed input is rejected immediately without issuing mail.
|
||||
func (s *Service) AttachGrantSpoolPath(path string) error {
|
||||
if s == nil || strings.TrimSpace(path) == "" {
|
||||
return errors.New("mail: invalid grant spool path")
|
||||
}
|
||||
abs, err := filepath.Abs(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail: resolve grant spool path: %w", err)
|
||||
}
|
||||
if _, err := readGrantSpool(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.grantSpoolPath = abs
|
||||
return nil
|
||||
}
|
||||
|
||||
func readGrantSpool(path string) ([]compensation, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail: read grant spool: %w", err)
|
||||
}
|
||||
grants, err := decodeGrantSpool(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mail: reject grant spool: %w", err)
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
// Decode the entire file before returning any grants. The exact object reader
|
||||
// rejects missing, unknown, duplicate and null fields at every schema level.
|
||||
func decodeGrantSpool(data []byte) ([]compensation, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return nil, errors.New("invalid UTF-8")
|
||||
}
|
||||
root, err := exactSpoolObject(data, "version", "grants")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var version uint64
|
||||
if err := json.Unmarshal(root["version"], &version); err != nil || version != 1 {
|
||||
return nil, errors.New("unsupported grant spool version")
|
||||
}
|
||||
var rawGrants []json.RawMessage
|
||||
if err := json.Unmarshal(root["grants"], &rawGrants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool, len(rawGrants))
|
||||
grants := make([]compensation, 0, len(rawGrants))
|
||||
for i, raw := range rawGrants {
|
||||
fields, err := exactSpoolObject(raw, "identity", "title", "body", "sent_at", "rewards")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant %d: %w", i, err)
|
||||
}
|
||||
var entry Grant
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return nil, fmt.Errorf("grant %d: %w", i, err)
|
||||
}
|
||||
if seen[entry.Identity] {
|
||||
return nil, fmt.Errorf("duplicate grant identity %q", entry.Identity)
|
||||
}
|
||||
seen[entry.Identity] = true
|
||||
var rawRewards []json.RawMessage
|
||||
if err := json.Unmarshal(fields["rewards"], &rawRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grant := compensation{identity: entry.Identity, title: entry.Title, body: entry.Body, sentAt: time.UnixMilli(entry.SentAt)}
|
||||
for j, reward := range rawRewards {
|
||||
if _, err := exactSpoolObject(reward, "type", "id", "count"); err != nil {
|
||||
return nil, fmt.Errorf("grant %d reward %d: %w", i, j, err)
|
||||
}
|
||||
grant.rewards = append(grant.rewards, gamedata.Reward{Type: entry.Rewards[j].Type, ID: entry.Rewards[j].ID, Count: entry.Rewards[j].Count})
|
||||
}
|
||||
if err := grant.validate(); err != nil {
|
||||
return nil, fmt.Errorf("grant %d: %w", i, err)
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
func exactSpoolObject(data []byte, names ...string) (map[string]json.RawMessage, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != json.Delim('{') {
|
||||
return nil, errors.New("expected JSON object")
|
||||
}
|
||||
wanted := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = true
|
||||
}
|
||||
fields := make(map[string]json.RawMessage, len(names))
|
||||
for decoder.More() {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, ok := token.(string)
|
||||
if !ok || !wanted[name] {
|
||||
return nil, fmt.Errorf("unknown field %q", token)
|
||||
}
|
||||
if _, exists := fields[name]; exists {
|
||||
return nil, fmt.Errorf("duplicate field %q", name)
|
||||
}
|
||||
var value json.RawMessage
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return nil, fmt.Errorf("null field %q", name)
|
||||
}
|
||||
fields[name] = value
|
||||
}
|
||||
if _, err := decoder.Token(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
return nil, errors.New("trailing JSON content")
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, exists := fields[name]; !exists {
|
||||
return nil, fmt.Errorf("missing field %q", name)
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
Reference in New Issue
Block a user