Files
bd2/go/internal/server/npcinn/inn.go
T

218 lines
6.0 KiB
Go

// Package npcinn implements motel opening and paid/free recovery. Each request
// runs within the account transaction, including currency, health and receipts.
package npcinn
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type Context func(pack, npc uint64) (gamedata.InnRule, uint64, error)
type reply struct {
Digest string
Body []byte
}
type Service struct {
mu sync.Mutex
store stateio.Store
characters *player.CharacterStore
wallet *player.Wallet
context Context
level func() (uint64, error)
battle func() bool
session string
}
func New(store stateio.Store, characters *player.CharacterStore, wallet *player.Wallet, context Context, level func() (uint64, error), battle func() bool) (*Service, error) {
if store == nil || characters == nil || wallet == nil || context == nil || level == nil || battle == nil {
return nil, errors.New("npcinn: incomplete dependencies")
}
return &Service{store: store, characters: characters, wallet: wallet, context: context, level: level, battle: battle}, nil
}
func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
code := 109
if path == "/CharAllRevival" {
code = 11
} else if path != "/InnOpen" {
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, ok, err := wire.Varint(request, 1)
if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" {
return fail(errors.New("npcinn: invalid sequence or session"))
}
if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil {
return fail(err)
}
if s.battle() {
return fail(errors.New("npcinn: unavailable during battle"))
}
if path == "/InnOpen" {
_, state, err := s.context(0, 0)
if err != nil {
return fail(err)
}
return code, wire.AppendVarint(nil, 1, state), true, nil
}
pack, _, err := wire.Varint(request, 2)
if err != nil || pack == 0 || pack > math.MaxInt32 {
return fail(errors.New("npcinn: invalid pack"))
}
npc, _, err := wire.Varint(request, 3)
if err != nil || npc == 0 || npc > math.MaxInt32 {
return fail(errors.New("npcinn: invalid NPC"))
}
keyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", s.session, path, seq)))
key := hex.EncodeToString(keyBytes[:])
hash := sha256.Sum256(request)
digest := hex.EncodeToString(hash[:])
var replies map[string]reply
data, err := s.store.Load("npcinn")
if err != nil {
return fail(err)
}
if data != nil && json.Unmarshal(data, &replies) != nil {
return fail(errors.New("npcinn: invalid receipt store"))
}
if replies == nil {
replies = map[string]reply{}
}
if prior, exists := replies[key]; exists {
if prior.Digest != digest {
return fail(errors.New("npcinn: changed request replay"))
}
return code, prior.Body, true, nil
}
rule, state, err := s.context(pack, npc)
if err != nil {
return fail(err)
}
level, err := s.level()
if err != nil {
return fail(err)
}
var indices []uint64
seen := map[uint64]bool{}
err = wire.Walk(request, func(f wire.Field) error {
if f.Number != 4 {
return nil
}
if f.Type != 0 && f.Type != 2 {
return errors.New("npcinn: invalid character list")
}
for raw := f.Value; len(raw) > 0; {
idx, n := binary.Uvarint(raw)
if n <= 0 || idx == 0 || idx > math.MaxInt64 || seen[idx] || len(indices) >= 4096 {
return errors.New("npcinn: invalid character index")
}
indices = append(indices, idx)
seen[idx] = true
raw = raw[n:]
}
return nil
})
if err != nil {
return fail(err)
}
if len(indices) == 0 {
return fail(errors.New("npcinn: empty recovery targets"))
}
discount := uint64(0)
if state == 2 {
discount = rule.GoodDiscount
}
if discount > 100 || rule.Currency != 4 || rule.ItemCount == 0 || rule.ItemCount > math.MaxInt32 {
return fail(errors.New("npcinn: invalid recovery design"))
}
rate := rule.ItemCount * (100 - discount)
const divisor uint64 = 4000
available := s.wallet.Snapshot().Gold
var total uint64
var targets []player.Character
for _, idx := range indices {
c, found := s.characters.Find(idx)
if !found {
return fail(errors.New("npcinn: recovery character not owned"))
}
maximum, err := s.characters.MaxHealth(idx)
if err != nil {
return fail(err)
}
current, err := s.characters.CurrentHealth(idx)
if err != nil {
return fail(err)
}
if current >= maximum {
c.HP = current
targets = append(targets, c)
continue
}
if current == 0 {
c.HP = 1
targets = append(targets, c)
continue
}
if level <= rule.FreeSquadLevel || rate == 0 {
c.HP = maximum
targets = append(targets, c)
continue
}
missing := maximum - current
if missing > (math.MaxUint64-divisor+1)/rate {
return fail(errors.New("npcinn: recovery price overflow"))
}
cost := (missing*rate + divisor - 1) / divisor
if cost <= available {
c.HP = maximum
} else {
if available > math.MaxUint64/divisor {
return fail(errors.New("npcinn: recovery amount overflow"))
}
heal := available * divisor / rate
if heal > missing {
heal = missing
}
cost = heal * rate / divisor
c.HP = current + heal
}
available -= cost
total += cost
targets = append(targets, c)
}
if total > 0 {
if _, err = s.wallet.SpendGoldOnce("npcinn:"+key, total); err != nil {
return fail(err)
}
}
var out []byte
for _, c := range targets {
if err = s.characters.SetCurrentHealth(c.InvenIndex, c.HP); err != nil {
return fail(err)
}
out = wire.AppendBytes(out, 1, player.CharacterWire(c))
}
replies[key] = reply{Digest: digest, Body: out}
data, err = json.Marshal(replies)
if err != nil {
return fail(err)
}
if err = s.store.Save("npcinn", data); err != nil {
return fail(err)
}
return code, out, true, nil
}