505 lines
15 KiB
Go
505 lines
15 KiB
Go
// Package npcshop implements the ordinary NPC buy/sell market from GameData.
|
|
package npcshop
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/player"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
type Economy interface {
|
|
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
|
}
|
|
type receipt struct {
|
|
Digest []byte
|
|
Response []byte
|
|
}
|
|
type count struct {
|
|
Period string
|
|
Bought uint64
|
|
}
|
|
type snapshot struct {
|
|
Counts map[string]count `json:"counts"`
|
|
Receipts map[string]receipt `json:"receipts"`
|
|
}
|
|
type Service struct {
|
|
mu sync.Mutex
|
|
design gamedata.NPCShopDesign
|
|
store stateio.Store
|
|
economy Economy
|
|
items *player.Inventory
|
|
available func(uint64) bool
|
|
now func() time.Time
|
|
session func() string
|
|
reputation func(uint64) (uint64, uint64, error)
|
|
talentDiscount func(uint64, uint64) (uint64, error)
|
|
}
|
|
|
|
func New(d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *player.Inventory, available func(uint64) bool) (*Service, error) {
|
|
if len(d.Shops) == 0 || store == nil || e == nil || items == nil || available == nil {
|
|
return nil, fmt.Errorf("npcshop: invalid configuration")
|
|
}
|
|
s := &Service{design: d, store: store, economy: e, items: items, available: available, now: time.Now}
|
|
_, err := s.load()
|
|
return s, err
|
|
}
|
|
func (s *Service) SetSessionSource(source func() string) { s.session = source }
|
|
func (s *Service) BeginSession(id string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.session = func() string { return id }
|
|
}
|
|
|
|
// SetReputationSource supplies the world's persisted pack reputation and
|
|
// GameData-derived shop discount. Normal reputation is the initial fallback.
|
|
func (s *Service) SetReputationSource(source func(uint64) (uint64, uint64, error)) {
|
|
s.reputation = source
|
|
}
|
|
func (s *Service) SetTalentDiscountSource(source func(uint64, uint64) (uint64, error)) {
|
|
s.talentDiscount = source
|
|
}
|
|
func (s *Service) talentDiscountFor(shop gamedata.NPCShop) (uint64, error) {
|
|
if s.talentDiscount == nil {
|
|
return 0, nil
|
|
}
|
|
var best uint64
|
|
for _, npc := range s.design.ShopNPCs[shop.ID] {
|
|
n, e := s.talentDiscount(shop.PackID, npc)
|
|
if e != nil {
|
|
return 0, e
|
|
}
|
|
if n > 100 {
|
|
return 0, fmt.Errorf("npcshop: invalid talent discount")
|
|
}
|
|
if n > best {
|
|
best = n
|
|
}
|
|
}
|
|
return best, nil
|
|
}
|
|
func (s *Service) reputationFor(pack uint64) (uint64, uint64, error) {
|
|
if s.reputation != nil {
|
|
return s.reputation(pack)
|
|
}
|
|
return 1, 0, nil
|
|
}
|
|
func (s *Service) load() (snapshot, error) {
|
|
v := snapshot{Counts: map[string]count{}, Receipts: map[string]receipt{}}
|
|
b, e := s.store.Load("npc_shop")
|
|
if e != nil || len(b) == 0 {
|
|
return v, e
|
|
}
|
|
if e = stateio.RequireExactJSONObject(b, "counts", "receipts"); e != nil {
|
|
return v, e
|
|
}
|
|
if e = json.Unmarshal(b, &v); e != nil {
|
|
return v, e
|
|
}
|
|
if v.Counts == nil || v.Receipts == nil {
|
|
return v, fmt.Errorf("npcshop: invalid state")
|
|
}
|
|
return v, nil
|
|
}
|
|
func (s *Service) save(v snapshot) error {
|
|
b, e := json.Marshal(v)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
return s.store.Save("npc_shop", b)
|
|
}
|
|
func key(shop, product uint64) string { return fmt.Sprintf("%d:%d", shop, product) }
|
|
func (s *Service) period(shop gamedata.NPCShop) (string, int64) {
|
|
t := s.now().UTC()
|
|
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
|
switch shop.ResetType {
|
|
case 1:
|
|
return day.Format("2006-01-02"), day.AddDate(0, 0, 1).Unix() - t.Unix()
|
|
case 2:
|
|
day = day.AddDate(0, 0, -(int(day.Weekday())+6)%7)
|
|
return day.Format("2006-01-02"), day.AddDate(0, 0, 7).Unix() - t.Unix()
|
|
case 3:
|
|
day = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
return day.Format("2006-01"), day.AddDate(0, 1, 0).Unix() - t.Unix()
|
|
default:
|
|
return "lifetime", 0
|
|
}
|
|
}
|
|
func (s *Service) seed() uint64 { return uint64(s.now().UTC().Unix()/86400) % math.MaxInt32 }
|
|
func (s *Service) bought(v snapshot, shop, product uint64) uint64 {
|
|
period, _ := s.period(s.design.Shops[shop])
|
|
c := v.Counts[key(shop, product)]
|
|
if c.Period != period {
|
|
return 0
|
|
}
|
|
return c.Bought
|
|
}
|
|
func (s *Service) shopWire(v snapshot, id uint64) []byte {
|
|
_, remaining := s.period(s.design.Shops[id])
|
|
b := wire.AppendVarint(nil, 1, id)
|
|
if remaining > 0 {
|
|
b = wire.AppendVarint(b, 2, uint64(remaining))
|
|
}
|
|
ids := make([]uint64, 0, len(s.design.Products[id]))
|
|
for n := range s.design.Products[id] {
|
|
ids = append(ids, n)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for _, n := range ids {
|
|
p := wire.AppendVarint(nil, 1, n)
|
|
p = wire.AppendVarint(p, 2, s.bought(v, id, n))
|
|
b = wire.AppendBytes(b, 3, p)
|
|
}
|
|
return b
|
|
}
|
|
func (s *Service) allShops(v snapshot, field int) []byte {
|
|
ids := make([]uint64, 0)
|
|
for id, r := range s.design.Shops {
|
|
if s.available(r.PackID) {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
var b []byte
|
|
for _, id := range ids {
|
|
b = wire.AppendBytes(b, field, s.shopWire(v, id))
|
|
}
|
|
return b
|
|
}
|
|
func scalar(b []byte, f int) (uint64, error) { v, _, e := wire.Varint(b, f); return v, e }
|
|
func messages(b []byte, f int) ([][]byte, error) {
|
|
var out [][]byte
|
|
e := wire.Walk(b, func(v wire.Field) error {
|
|
if v.Number != f {
|
|
return nil
|
|
}
|
|
if v.Type != 2 {
|
|
return fmt.Errorf("npcshop: malformed nested message")
|
|
}
|
|
out = append(out, append([]byte(nil), v.Value...))
|
|
return nil
|
|
})
|
|
return out, e
|
|
}
|
|
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
codes := map[string]int{"/ShopInfo": 53, "/ShopBuy": 54, "/ShopSell": 55, "/ShopOpen": 107, "/ShopReputationInfo": 520}
|
|
code, ok := codes[path]
|
|
if !ok {
|
|
return 0, nil, false, nil
|
|
}
|
|
seq, e := scalar(request, 1)
|
|
if e != nil || seq == 0 || seq > math.MaxInt32 {
|
|
return 0, nil, true, fmt.Errorf("npcshop: invalid sequence")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
v, e := s.load()
|
|
if e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
if path == "/ShopInfo" {
|
|
b := s.allShops(v, 1)
|
|
return code, wire.AppendVarint(b, 2, s.seed()), true, nil
|
|
}
|
|
if path == "/ShopOpen" {
|
|
var b []byte
|
|
packs := map[uint64]bool{}
|
|
isTalent := false
|
|
for _, r := range s.design.Shops {
|
|
if s.available(r.PackID) {
|
|
discount, e := s.talentDiscountFor(r)
|
|
if e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
isTalent = isTalent || discount > 0
|
|
}
|
|
if !packs[r.PackID] && s.available(r.PackID) {
|
|
packs[r.PackID] = true
|
|
state, _, err := s.reputationFor(r.PackID)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
p := wire.AppendVarint(nil, 1, r.PackID)
|
|
p = wire.AppendVarint(p, 2, state)
|
|
b = wire.AppendBytes(b, 1, p)
|
|
}
|
|
}
|
|
if isTalent {
|
|
b = wire.AppendVarint(b, 2, 1)
|
|
}
|
|
return code, b, true, nil
|
|
}
|
|
if path == "/ShopReputationInfo" {
|
|
pack, err := scalar(request, 2)
|
|
if err != nil || !s.available(pack) {
|
|
return 0, nil, true, fmt.Errorf("npcshop: unavailable reputation pack")
|
|
}
|
|
state, _, err := s.reputationFor(pack)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
return code, wire.AppendVarint(nil, 1, state), true, nil
|
|
}
|
|
if s.session == nil || s.session() == "" {
|
|
return 0, nil, true, fmt.Errorf("npcshop: missing session identity")
|
|
}
|
|
identity := "npcshop:" + s.session() + ":" + path + ":" + strconv.FormatUint(seq, 10)
|
|
digest := sha256.Sum256(request)
|
|
if r, exists := v.Receipts[identity]; exists {
|
|
if !bytes.Equal(r.Digest, digest[:]) {
|
|
return 0, nil, true, fmt.Errorf("npcshop: reused sequence")
|
|
}
|
|
return code, r.Response, true, nil
|
|
}
|
|
var costs, rewards []gamedata.Reward
|
|
var sold []player.Item
|
|
changed := map[uint64]bool{}
|
|
if path == "/ShopBuy" {
|
|
costs, rewards, changed, e = s.buy(request, &v)
|
|
if e == nil {
|
|
costs, sold, e = s.purchaseMaterials(request, costs)
|
|
}
|
|
} else {
|
|
sold, rewards, e = s.sell(request)
|
|
}
|
|
if e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
if len(sold) > 0 {
|
|
if e = s.items.CanConsume(sold); e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
}
|
|
bundle, e := s.economy.Apply(identity, costs, rewards)
|
|
if e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
// This and Economy.Apply execute in the enclosing account transaction.
|
|
// Consume the concrete stacks the client removes, rather than letting a
|
|
// type/id-only debit choose a different stack of the same resource.
|
|
if len(sold) > 0 {
|
|
if e = s.items.Consume(sold); e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
}
|
|
b := wire.AppendBytes(nil, 1, bundle)
|
|
ids := make([]uint64, 0, len(changed))
|
|
for id := range changed {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for _, id := range ids {
|
|
b = wire.AppendBytes(b, 2, s.shopWire(v, id))
|
|
}
|
|
v.Receipts[identity] = receipt{Digest: digest[:], Response: b}
|
|
if e = s.save(v); e != nil {
|
|
return 0, nil, true, e
|
|
}
|
|
return code, b, true, nil
|
|
}
|
|
func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedata.Reward, map[uint64]bool, error) {
|
|
groups, e := messages(request, 2)
|
|
if e != nil || len(groups) == 0 {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: empty purchase")
|
|
}
|
|
var costs, rewards []gamedata.Reward
|
|
changed := map[uint64]bool{}
|
|
seen := map[string]bool{}
|
|
for _, g := range groups {
|
|
shop, e := scalar(g, 1)
|
|
r, exists := s.design.Shops[shop]
|
|
if e != nil || !exists || !s.available(r.PackID) {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: unavailable shop %d", shop)
|
|
}
|
|
state, discount, err := s.reputationFor(r.PackID)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if state == 0 || discount > 100 {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: unavailable reputation")
|
|
}
|
|
talentDiscount, e := s.talentDiscountFor(r)
|
|
if e != nil {
|
|
return nil, nil, nil, e
|
|
}
|
|
rows, e := messages(g, 2)
|
|
if e != nil || len(rows) == 0 {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: empty product list")
|
|
}
|
|
for _, b := range rows {
|
|
id, _ := scalar(b, 2)
|
|
n, _ := scalar(b, 3)
|
|
inven, _ := scalar(b, 1)
|
|
rate, _ := scalar(b, 4)
|
|
p, ok := s.design.Products[shop][id]
|
|
k := key(shop, id)
|
|
if !ok || n == 0 || n > math.MaxInt32 || inven != 0 || seen[k] {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: invalid product %d", id)
|
|
}
|
|
seen[k] = true
|
|
if p.Reputation > 0 && state != 2 {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: product requires good reputation")
|
|
}
|
|
expected := s.rate(p, shop, 1)
|
|
if rate != expected {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: stale/invalid price rate %d expected %d", rate, expected)
|
|
}
|
|
old := s.bought(*v, shop, id)
|
|
if p.MaxCount != 0 && p.Reward.Type != 12 && (old > p.MaxCount || n > p.MaxCount-old) {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: sold out product %d", id)
|
|
}
|
|
price := uint64(float32(p.Price.Count*expected) / 100)
|
|
if talentDiscount > 0 {
|
|
if p.NoBargain == 1 {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: product excluded during bargaining")
|
|
}
|
|
// ShopUI.RefreshProductsPrice uses the base price for talent
|
|
// bargaining, replacing both market variation and reputation.
|
|
price = uint64(float32(p.Price.Count) * (float32(100-talentDiscount) / 100))
|
|
} else if discount > 0 {
|
|
price = uint64(float32(price*(100-discount)) / 100)
|
|
}
|
|
if price > math.MaxInt32/n || p.Reward.Count > math.MaxInt32/n {
|
|
return nil, nil, nil, fmt.Errorf("npcshop: purchase overflow")
|
|
}
|
|
cost := p.Price
|
|
cost.Count = price * n
|
|
if cost.Count > 0 {
|
|
costs = append(costs, cost)
|
|
}
|
|
reward := p.Reward
|
|
reward.Count *= n
|
|
rewards = append(rewards, reward)
|
|
period, _ := s.period(r)
|
|
v.Counts[k] = count{Period: period, Bought: old + n}
|
|
changed[shop] = true
|
|
}
|
|
}
|
|
return costs, rewards, changed, nil
|
|
}
|
|
func (s *Service) sell(request []byte) ([]player.Item, []gamedata.Reward, error) {
|
|
id, e := scalar(request, 2)
|
|
shop, exists := s.design.Shops[id]
|
|
if e != nil || !exists || !s.available(shop.PackID) {
|
|
return nil, nil, fmt.Errorf("npcshop: unavailable sell shop")
|
|
}
|
|
rows, e := messages(request, 3)
|
|
if e != nil || len(rows) == 0 {
|
|
return nil, nil, fmt.Errorf("npcshop: empty sale")
|
|
}
|
|
inventory := map[uint64]player.Item{}
|
|
for _, item := range s.items.All() {
|
|
inventory[item.InvenIndex] = item
|
|
}
|
|
var costs []player.Item
|
|
var rewards []gamedata.Reward
|
|
seen := map[uint64]bool{}
|
|
for _, b := range rows {
|
|
product, _ := scalar(b, 2)
|
|
n, _ := scalar(b, 3)
|
|
inven, _ := scalar(b, 1)
|
|
rate, _ := scalar(b, 4)
|
|
p, ok := s.design.Sell[product]
|
|
item, owned := inventory[inven]
|
|
if !ok || !owned || seen[inven] || n == 0 || n > item.Count || p.Reward.Type != item.Type || p.Reward.ID != item.ID || item.KeepFlag != 0 {
|
|
return nil, nil, fmt.Errorf("npcshop: invalid sale item")
|
|
}
|
|
seen[inven] = true
|
|
expected := s.rate(p, id, 2)
|
|
if rate != expected {
|
|
return nil, nil, fmt.Errorf("npcshop: stale/invalid sell rate")
|
|
}
|
|
price := uint64(float32(p.Price.Count*expected) / 100)
|
|
if price > math.MaxInt32/n {
|
|
return nil, nil, fmt.Errorf("npcshop: sale overflow")
|
|
}
|
|
cost := item
|
|
cost.Count = n
|
|
costs = append(costs, cost)
|
|
reward := p.Price
|
|
reward.Count = price * n
|
|
if reward.Count > 0 {
|
|
rewards = append(rewards, reward)
|
|
}
|
|
}
|
|
return costs, rewards, nil
|
|
}
|
|
|
|
// Resource currency is removed from the exact inventory rows named by the
|
|
// client after independently deriving the required type/id/count from design.
|
|
func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([]gamedata.Reward, []player.Item, error) {
|
|
need := map[[2]uint64]uint64{}
|
|
var currency []gamedata.Reward
|
|
for _, c := range costs {
|
|
if c.Type == 8 {
|
|
need[[2]uint64{c.Type, c.ID}] += c.Count
|
|
} else {
|
|
currency = append(currency, c)
|
|
}
|
|
}
|
|
rows, e := messages(request, 3)
|
|
if e != nil {
|
|
return nil, nil, e
|
|
}
|
|
var selected []player.Item
|
|
got := map[[2]uint64]uint64{}
|
|
seen := map[uint64]bool{}
|
|
for _, b := range rows {
|
|
index, _ := scalar(b, 1)
|
|
id, _ := scalar(b, 2)
|
|
typ, _ := scalar(b, 3)
|
|
n, _ := scalar(b, 4)
|
|
if index == 0 || id == 0 || typ != 8 || n == 0 || n > math.MaxInt32 || seen[index] {
|
|
return nil, nil, fmt.Errorf("npcshop: invalid purchase material")
|
|
}
|
|
seen[index] = true
|
|
k := [2]uint64{typ, id}
|
|
got[k] += n
|
|
selected = append(selected, player.Item{InvenIndex: index, ID: id, Type: typ, Count: n})
|
|
}
|
|
if len(got) != len(need) {
|
|
return nil, nil, fmt.Errorf("npcshop: purchase material mismatch")
|
|
}
|
|
for k, n := range need {
|
|
if got[k] != n {
|
|
return nil, nil, fmt.Errorf("npcshop: purchase material count mismatch")
|
|
}
|
|
}
|
|
return currency, selected, nil
|
|
}
|
|
|
|
// Client ShopPacket.GetShopRandomValue uses the first WELL512 value for a
|
|
// versioned product and server-supplied daily seed. Client Rate is verified,
|
|
// never accepted as the authority for wallet arithmetic.
|
|
func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 {
|
|
if p.PremiumPriceType == 1 && p.HighShop == shop && p.HighDay == uint64(s.now().UTC().Day()) {
|
|
return 100 + p.HighPremium
|
|
}
|
|
seed := s.seed() + shop + tab + p.Reward.Type + p.Reward.ID
|
|
a := seed
|
|
b := seed + 13*90
|
|
c := a ^ b ^ (a << 16) ^ (b << 15)
|
|
b = seed + 9*90
|
|
b ^= b >> 11
|
|
a = c ^ b
|
|
d := a ^ ((a << 5) & uint64(0xffffffffda442d24))
|
|
a = seed + 15*90
|
|
value := a ^ c ^ d ^ (a << 2) ^ (c << 18) ^ (b << 28)
|
|
lo := 100 - p.Discount
|
|
span := p.Discount + p.Premium
|
|
if span == 0 {
|
|
span = 1
|
|
}
|
|
return lo + value%span
|
|
}
|