403 lines
13 KiB
Go
403 lines
13 KiB
Go
package player
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
// FoodService participates in the account request transaction: consuming the
|
|
// stack, saving health and recording the replay response commit together.
|
|
type FoodService struct {
|
|
mu sync.Mutex
|
|
store stateio.AtomicEntryStore
|
|
design *gamedata.FoodDesign
|
|
inventory *Inventory
|
|
characters *CharacterStore
|
|
session string
|
|
currentPack func() (int, error)
|
|
battleActive func() bool
|
|
}
|
|
|
|
type foodReply struct {
|
|
Digest string `json:"digest"`
|
|
Body []byte `json:"body"`
|
|
}
|
|
|
|
func OpenFoodService(store stateio.Store, design *gamedata.FoodDesign, inventory *Inventory, characters *CharacterStore) (*FoodService, error) {
|
|
entries, ok := store.(stateio.AtomicEntryStore)
|
|
if !ok || design == nil || inventory == nil || characters == nil {
|
|
return nil, errors.New("player: incomplete food configuration")
|
|
}
|
|
s := &FoodService{store: entries, design: design, inventory: inventory, characters: characters}
|
|
health, err := entries.ListEntries("characters", "current_hp")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, raw := range health {
|
|
index, err := strconv.ParseUint(key, 10, 64)
|
|
var hp uint64
|
|
if err != nil || index == 0 || json.Unmarshal(raw, &hp) != nil {
|
|
return nil, errors.New("player: malformed saved current health")
|
|
}
|
|
if _, err := characters.MaxHealth(index); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
replies, err := entries.ListEntries("characters", "food_requests")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, raw := range replies {
|
|
var reply foodReply
|
|
decodedKey, keyErr := hex.DecodeString(key)
|
|
if keyErr != nil || len(decodedKey) != sha256.Size || json.Unmarshal(raw, &reply) != nil || len(reply.Body) == 0 {
|
|
return nil, errors.New("player: malformed saved food replay")
|
|
}
|
|
digest, err := hex.DecodeString(reply.Digest)
|
|
if err != nil || len(digest) != sha256.Size {
|
|
return nil, errors.New("player: malformed saved food replay digest")
|
|
}
|
|
if err := wire.Walk(reply.Body, func(field wire.Field) error { return nil }); err != nil {
|
|
return nil, errors.New("player: malformed saved food replay body")
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *FoodService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
|
|
|
|
// AttachContext supplies authoritative pack and active-battle state; the client
|
|
// omits PackId in ordinary recovery requests, which means the current pack.
|
|
func (s *FoodService) AttachContext(currentPack func() (int, error), battleActive func() bool) error {
|
|
if currentPack == nil || battleActive == nil {
|
|
return errors.New("player: incomplete food context")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.currentPack = currentPack
|
|
s.battleActive = battleActive
|
|
return nil
|
|
}
|
|
|
|
func (s *FoodService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
if path == "/EatFoodAuto" {
|
|
return s.eatFoodAuto(request)
|
|
}
|
|
if path != "/EatFood" {
|
|
return 0, nil, false, nil
|
|
}
|
|
var seq, pack, index uint64
|
|
seenHeaders := map[int]bool{}
|
|
parseErr := wire.Walk(request, func(field wire.Field) error {
|
|
if field.Number == 4 {
|
|
return nil
|
|
}
|
|
target := map[int]*uint64{1: &seq, 2: &pack, 3: &index}[field.Number]
|
|
if target == nil || field.Type != 0 || seenHeaders[field.Number] {
|
|
return errors.New("player: invalid EatFood header")
|
|
}
|
|
seenHeaders[field.Number] = true
|
|
*target, _ = binary.Uvarint(field.Value)
|
|
return nil
|
|
})
|
|
if parseErr != nil || seq == 0 || seq > math.MaxInt32 || pack > math.MaxInt32 || index == 0 || index > math.MaxInt64 {
|
|
return 0, nil, true, errors.New("player: invalid EatFood request")
|
|
}
|
|
items, err := equipmentRequestItems(request, 4, "EatFood")
|
|
if err != nil || len(items) == 0 {
|
|
return 0, nil, true, errors.New("player: EatFood requires food stacks")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.session == "" || s.currentPack == nil || s.battleActive == nil {
|
|
return 0, nil, true, errors.New("player: EatFood session or context unavailable")
|
|
}
|
|
keyDigest := sha256.Sum256([]byte(s.session + ":EatFood:" + strconv.FormatUint(seq, 10)))
|
|
key := hex.EncodeToString(keyDigest[:])
|
|
digest := sha256.Sum256(request)
|
|
digestString := hex.EncodeToString(digest[:])
|
|
prior, found, err := s.store.LoadEntry("characters", "food_requests", key)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if found {
|
|
var reply foodReply
|
|
if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString {
|
|
return 0, nil, true, errors.New("player: EatFood sequence reused with different request")
|
|
}
|
|
return 22, append([]byte(nil), reply.Body...), true, nil
|
|
}
|
|
currentPack, err := s.currentPack()
|
|
if err != nil || currentPack <= 0 || (pack != 0 && pack != uint64(currentPack)) {
|
|
return 0, nil, true, errors.New("player: EatFood pack unavailable")
|
|
}
|
|
if s.battleActive() {
|
|
return 0, nil, true, errors.New("player: cannot eat food during battle")
|
|
}
|
|
character, err := s.recoverCharacter(index, items)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if err := s.inventory.CanConsume(items); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
body := wire.AppendBytes(nil, 1, CharacterWire(character))
|
|
if err := s.inventory.Consume(items); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
healthRaw, _ := json.Marshal(character.HP)
|
|
replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body})
|
|
if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{{Bucket: "current_hp", Key: strconv.FormatUint(index, 10), Payload: healthRaw}, {Bucket: "food_requests", Key: key, Payload: replyRaw}}); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
return 22, body, true, nil
|
|
}
|
|
|
|
// MaxHealth reads the shared stat calculator without replacing it with field HP.
|
|
func (s *CharacterStore) MaxHealth(index uint64) (uint64, error) {
|
|
var character Character
|
|
for _, candidate := range s.RawAll() {
|
|
if candidate.InvenIndex == index {
|
|
character = candidate
|
|
break
|
|
}
|
|
}
|
|
if character.InvenIndex == 0 {
|
|
return 0, fmt.Errorf("player: unknown health character %d", index)
|
|
}
|
|
s.mu.Lock()
|
|
calculate := s.maxHealth
|
|
s.mu.Unlock()
|
|
if calculate != nil {
|
|
return calculate(character)
|
|
}
|
|
if character.HP == 0 {
|
|
return 0, errors.New("player: maximum health unavailable")
|
|
}
|
|
return character.HP, nil
|
|
}
|
|
|
|
// CurrentHealth resolves persisted current HP independently from maximum HP.
|
|
// A missing current_hp entry retains the owned character record's HP, including
|
|
// zero. Recomputing equipment or other maximum-health stats must never heal it.
|
|
func (s *CharacterStore) CurrentHealth(index uint64) (uint64, error) {
|
|
var saved Character
|
|
for _, character := range s.RawAll() {
|
|
if character.InvenIndex == index {
|
|
saved = character
|
|
break
|
|
}
|
|
}
|
|
if saved.InvenIndex == 0 {
|
|
return 0, fmt.Errorf("player: unknown health character %d", index)
|
|
}
|
|
return s.savedCurrentHealth(saved)
|
|
}
|
|
|
|
func (s *CharacterStore) savedCurrentHealth(saved Character) (uint64, error) {
|
|
if s.store == nil {
|
|
return saved.HP, nil
|
|
}
|
|
data, found, err := s.store.LoadEntry("characters", "current_hp", strconv.FormatUint(saved.InvenIndex, 10))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !found {
|
|
return saved.HP, nil
|
|
}
|
|
var hp uint64
|
|
if json.Unmarshal(data, &hp) != nil {
|
|
return 0, errors.New("player: invalid saved current health")
|
|
}
|
|
return hp, nil
|
|
}
|
|
|
|
// SetCurrentHealth accepts server-validated battle results and recovery only.
|
|
// Food requests never accept health from the caller.
|
|
func (s *CharacterStore) SetCurrentHealth(index, hp uint64) error {
|
|
maximum, err := s.MaxHealth(index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hp > maximum {
|
|
return errors.New("player: current health exceeds maximum")
|
|
}
|
|
raw, _ := json.Marshal(hp)
|
|
return s.store.PutEntry("characters", "current_hp", strconv.FormatUint(index, 10), raw)
|
|
}
|
|
|
|
func (s *CharacterStore) resetCurrentHealth(index uint64) error {
|
|
_, err := s.store.DeleteEntry("characters", "current_hp", strconv.FormatUint(index, 10))
|
|
return err
|
|
}
|
|
|
|
func (s *FoodService) eatFoodAuto(request []byte) (int, []byte, bool, error) {
|
|
var seq uint64
|
|
var targets []struct {
|
|
index uint64
|
|
items []Item
|
|
}
|
|
seenSequence := false
|
|
err := wire.Walk(request, func(field wire.Field) error {
|
|
switch field.Number {
|
|
case 1:
|
|
if field.Type != 0 || seenSequence {
|
|
return errors.New("player: invalid EatFoodAuto sequence")
|
|
}
|
|
seenSequence = true
|
|
seq, _ = binary.Uvarint(field.Value)
|
|
case 2:
|
|
if field.Type != 2 {
|
|
return errors.New("player: invalid EatFoodAuto target")
|
|
}
|
|
var index uint64
|
|
seenIndex := false
|
|
if err := wire.Walk(field.Value, func(inner wire.Field) error {
|
|
if inner.Number == 2 {
|
|
return nil
|
|
}
|
|
if inner.Number != 1 || inner.Type != 0 || seenIndex {
|
|
return errors.New("player: invalid EatFoodAuto character")
|
|
}
|
|
seenIndex = true
|
|
index, _ = binary.Uvarint(inner.Value)
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if index == 0 || index > math.MaxInt64 {
|
|
return errors.New("player: invalid EatFoodAuto character index")
|
|
}
|
|
items, err := equipmentRequestItems(field.Value, 2, "EatFoodAuto")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
return errors.New("player: EatFoodAuto character requires food stacks")
|
|
}
|
|
targets = append(targets, struct {
|
|
index uint64
|
|
items []Item
|
|
}{index, items})
|
|
default:
|
|
return errors.New("player: unknown EatFoodAuto field")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || seq == 0 || seq > math.MaxInt32 || len(targets) == 0 {
|
|
return 0, nil, true, errors.New("player: invalid EatFoodAuto request")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.session == "" || s.currentPack == nil || s.battleActive == nil {
|
|
return 0, nil, true, errors.New("player: EatFoodAuto context unavailable")
|
|
}
|
|
keyDigest := sha256.Sum256([]byte(s.session + ":EatFoodAuto:" + strconv.FormatUint(seq, 10)))
|
|
key := hex.EncodeToString(keyDigest[:])
|
|
digest := sha256.Sum256(request)
|
|
digestString := hex.EncodeToString(digest[:])
|
|
prior, found, err := s.store.LoadEntry("characters", "food_requests", key)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if found {
|
|
var reply foodReply
|
|
if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString {
|
|
return 0, nil, true, errors.New("player: EatFoodAuto sequence reused with different request")
|
|
}
|
|
return 27, append([]byte(nil), reply.Body...), true, nil
|
|
}
|
|
pack, err := s.currentPack()
|
|
if err != nil || pack <= 0 || s.battleActive() {
|
|
return 0, nil, true, errors.New("player: EatFoodAuto unavailable during battle or outside pack")
|
|
}
|
|
seenCharacters := map[uint64]bool{}
|
|
var items []Item
|
|
var body []byte
|
|
var changes []stateio.EntryMutation
|
|
for _, target := range targets {
|
|
if seenCharacters[target.index] {
|
|
return 0, nil, true, errors.New("player: duplicate EatFoodAuto character")
|
|
}
|
|
seenCharacters[target.index] = true
|
|
character, err := s.recoverCharacter(target.index, target.items)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
items = append(items, target.items...)
|
|
raw, _ := json.Marshal(character.HP)
|
|
changes = append(changes, stateio.EntryMutation{Bucket: "current_hp", Key: strconv.FormatUint(target.index, 10), Payload: raw})
|
|
info := wire.AppendVarint(nil, 1, target.index)
|
|
info = wire.AppendVarint(info, 2, character.HP)
|
|
body = wire.AppendBytes(body, 1, info)
|
|
}
|
|
// CanConsume accounts for a shared stack requested by several characters.
|
|
if err := s.inventory.CanConsume(items); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
if err := s.inventory.Consume(items); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body})
|
|
changes = append(changes, stateio.EntryMutation{Bucket: "food_requests", Key: key, Payload: replyRaw})
|
|
if err := s.store.SaveWithEntries("characters", nil, changes); err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
return 27, body, true, nil
|
|
}
|
|
|
|
func (s *FoodService) recoverCharacter(index uint64, items []Item) (Character, error) {
|
|
character, found := s.characters.Find(index)
|
|
if !found {
|
|
return Character{}, errors.New("player: EatFood character is not owned")
|
|
}
|
|
maximum, err := s.characters.MaxHealth(index)
|
|
if err != nil {
|
|
return Character{}, err
|
|
}
|
|
current, err := s.characters.CurrentHealth(index)
|
|
if err != nil {
|
|
return Character{}, err
|
|
}
|
|
var total uint64
|
|
seen := map[uint64]bool{}
|
|
for _, item := range items {
|
|
if item.Type != 5 || item.InvenIndex == 0 || item.Count == 0 || item.Count > math.MaxInt32 || seen[item.InvenIndex] {
|
|
return Character{}, errors.New("player: invalid EatFood inventory stack")
|
|
}
|
|
seen[item.InvenIndex] = true
|
|
food, exists := s.design.Foods[item.ID]
|
|
if !exists {
|
|
return Character{}, errors.New("player: unknown EatFood dish")
|
|
}
|
|
value, err := food.Recovery(character.ID, maximum, item.Count)
|
|
if err != nil {
|
|
return Character{}, err
|
|
}
|
|
if value > math.MaxUint64-total {
|
|
return Character{}, errors.New("player: EatFood recovery overflow")
|
|
}
|
|
total += value
|
|
}
|
|
if current > maximum {
|
|
current = maximum
|
|
}
|
|
if total >= maximum-current {
|
|
character.HP = maximum
|
|
} else {
|
|
character.HP = current + total
|
|
}
|
|
return character, nil
|
|
}
|