213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
package common
|
|
|
|
import "fmt"
|
|
|
|
// Shop plans are detached copies. The run action processor must commit the
|
|
// returned shop, gold and purchased board role together in one saved revision.
|
|
// They never mutate adventure inventory or write persistent state themselves.
|
|
type CurrencyWarsShop struct {
|
|
Pool *CurrencyWarsCardPool `json:"pool"`
|
|
Offers []CurrencyWarsShopOffer `json:"offers"`
|
|
Locked bool `json:"locked"`
|
|
FreeRefreshTimes uint32 `json:"free_refresh_times"`
|
|
}
|
|
|
|
type CurrencyWarsShopOffer struct {
|
|
RoleID uint32 `json:"role_id"`
|
|
Star uint32 `json:"star"`
|
|
Cost uint32 `json:"cost"`
|
|
Sold bool `json:"sold"`
|
|
}
|
|
|
|
// Quotes and rarity weights must come from the server's current run modifiers.
|
|
// Neither a client request price nor an implicit base-price fallback is used.
|
|
type CurrencyWarsShopQuote func(roleID uint32) (star, cost uint32, err error)
|
|
|
|
// BaseShopRules returns captured values before run effects. The action
|
|
// processor resolves modifiers and supplies the resulting rules explicitly.
|
|
type CurrencyWarsShopRules struct {
|
|
Slots uint32
|
|
RefreshCost uint32
|
|
}
|
|
|
|
func (c *CurrencyWarsCatalog) BaseShopRules() (CurrencyWarsShopRules, error) {
|
|
slots, err := c.shopRule("GridFight_CardNumberPerRefresh")
|
|
if err != nil {
|
|
return CurrencyWarsShopRules{}, err
|
|
}
|
|
cost, err := c.shopRule("GridFight_LotteryRefreshGold")
|
|
if err != nil {
|
|
return CurrencyWarsShopRules{}, err
|
|
}
|
|
if slots == 0 {
|
|
return CurrencyWarsShopRules{}, fmt.Errorf("CW shop has no configured slots")
|
|
}
|
|
return CurrencyWarsShopRules{Slots: slots, RefreshCost: cost}, nil
|
|
}
|
|
|
|
func (s *CurrencyWarsShop) Clone() *CurrencyWarsShop {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
next := *s
|
|
next.Offers = append([]CurrencyWarsShopOffer(nil), s.Offers...)
|
|
if s.Pool != nil {
|
|
next.Pool = s.Pool.Clone()
|
|
}
|
|
return &next
|
|
}
|
|
|
|
func (s *CurrencyWarsShop) validate() error {
|
|
if s == nil || s.Pool == nil || len(s.Pool.Roles) == 0 {
|
|
return fmt.Errorf("CW shop card pool is missing")
|
|
}
|
|
reserved := make(map[uint32]uint64)
|
|
for _, offer := range s.Offers {
|
|
if offer.RoleID == 0 || offer.Star == 0 {
|
|
return fmt.Errorf("CW shop offer identity is incomplete")
|
|
}
|
|
if _, exists := s.Pool.Roles[offer.RoleID]; !exists {
|
|
return fmt.Errorf("CW shop role %d is absent from pool", offer.RoleID)
|
|
}
|
|
if !offer.Sold {
|
|
reserved[offer.RoleID]++
|
|
}
|
|
}
|
|
for id, role := range s.Pool.Roles {
|
|
if id == 0 || role.Rarity == 0 || role.Capacity == 0 || role.Remaining > role.Capacity ||
|
|
uint64(role.Remaining)+reserved[id] > uint64(role.Capacity) {
|
|
return fmt.Errorf("CW shop pool accounting is invalid for role %d", id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PlanRefresh charges only a manual refresh. Enter-node refreshes must use
|
|
// PlanEnterNode, which also observes the lock. Both return a detached state;
|
|
// failures (including RNG and quote failures) leave the original untouched.
|
|
func (s *CurrencyWarsShop) PlanRefresh(c *CurrencyWarsCatalog, rules CurrencyWarsShopRules, gold uint32, weights []uint32,
|
|
policy CurrencyWarsDrawPolicy, random func(uint64) (uint64, error), quote CurrencyWarsShopQuote,
|
|
) (*CurrencyWarsShop, uint32, error) {
|
|
if err := s.validate(); err != nil {
|
|
return nil, gold, err
|
|
}
|
|
next := s.Clone()
|
|
remainingGold := gold
|
|
if next.FreeRefreshTimes > 0 {
|
|
next.FreeRefreshTimes--
|
|
} else {
|
|
if gold < rules.RefreshCost {
|
|
return nil, gold, fmt.Errorf("CW refresh gold is insufficient")
|
|
}
|
|
remainingGold -= rules.RefreshCost
|
|
}
|
|
if err := next.replaceOffers(c, rules, weights, policy, random, quote); err != nil {
|
|
return nil, gold, err
|
|
}
|
|
return next, remainingGold, nil
|
|
}
|
|
|
|
func (s *CurrencyWarsShop) PlanEnterNode(c *CurrencyWarsCatalog, rules CurrencyWarsShopRules, weights []uint32,
|
|
policy CurrencyWarsDrawPolicy, random func(uint64) (uint64, error), quote CurrencyWarsShopQuote,
|
|
) (*CurrencyWarsShop, error) {
|
|
if err := s.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
next := s.Clone()
|
|
// Keeping a locked shop does not reroll or refund any reserved copies.
|
|
// The run processor decides when the lock is cleared from captured rules.
|
|
if next.Locked {
|
|
return next, nil
|
|
}
|
|
if err := next.replaceOffers(c, rules, weights, policy, random, quote); err != nil {
|
|
return nil, err
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
func (s *CurrencyWarsShop) replaceOffers(c *CurrencyWarsCatalog, rules CurrencyWarsShopRules, weights []uint32,
|
|
policy CurrencyWarsDrawPolicy, random func(uint64) (uint64, error), quote CurrencyWarsShopQuote,
|
|
) error {
|
|
if quote == nil {
|
|
return fmt.Errorf("CW shop quote resolver is missing")
|
|
}
|
|
if c == nil {
|
|
return fmt.Errorf("CW catalog is missing")
|
|
}
|
|
if rules.Slots == 0 {
|
|
return fmt.Errorf("CW shop has no configured slots")
|
|
}
|
|
var returned []uint32
|
|
for _, offer := range s.Offers {
|
|
if !offer.Sold {
|
|
returned = append(returned, offer.RoleID)
|
|
}
|
|
}
|
|
if err := s.Pool.ReturnCopies(returned); err != nil {
|
|
return err
|
|
}
|
|
drawn, err := s.Pool.Draw(rules.Slots, weights, policy, random)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
offers := make([]CurrencyWarsShopOffer, len(drawn))
|
|
for i, id := range drawn {
|
|
star, cost, err := quote(id)
|
|
if err != nil {
|
|
return fmt.Errorf("CW quote role %d: %w", id, err)
|
|
}
|
|
// One reserved pool copy represents a base role. A quoted upgraded role
|
|
// needs an explicit copy-consumption rule before it can be offered here.
|
|
role, err := c.CapturedRole(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if star != role.DefaultStar {
|
|
return fmt.Errorf("CW upgraded shop offer needs a copy-consumption rule")
|
|
}
|
|
if _, err := c.CapturedRoleVariant(id, star); err != nil {
|
|
return err
|
|
}
|
|
offers[i] = CurrencyWarsShopOffer{RoleID: id, Star: star, Cost: cost}
|
|
}
|
|
s.Offers = offers
|
|
return s.validate()
|
|
}
|
|
|
|
// PlanBuy transfers the already-reserved copy to the board action processor.
|
|
// That processor must validate placement, merge and capacity before committing
|
|
// this plan. Rejecting placement discards the whole plan, including gold debit.
|
|
func (s *CurrencyWarsShop) PlanBuy(slot, gold uint32) (*CurrencyWarsShop, uint32, CurrencyWarsShopOffer, error) {
|
|
var empty CurrencyWarsShopOffer
|
|
if err := s.validate(); err != nil {
|
|
return nil, gold, empty, err
|
|
}
|
|
if uint64(slot) >= uint64(len(s.Offers)) {
|
|
return nil, gold, empty, fmt.Errorf("CW shop slot is out of range")
|
|
}
|
|
offer := s.Offers[slot]
|
|
if offer.Sold {
|
|
return nil, gold, empty, fmt.Errorf("CW shop offer was already bought")
|
|
}
|
|
if gold < offer.Cost {
|
|
return nil, gold, empty, fmt.Errorf("CW purchase gold is insufficient")
|
|
}
|
|
next := s.Clone()
|
|
next.Offers[slot].Sold = true
|
|
return next, gold - offer.Cost, offer, nil
|
|
}
|
|
|
|
func (c *CurrencyWarsCatalog) shopRule(name string) (uint32, error) {
|
|
if c == nil {
|
|
return 0, fmt.Errorf("CW catalog is missing")
|
|
}
|
|
value, err := c.IntegerRule(name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if value < 0 || uint64(value) > uint64(^uint32(0)) {
|
|
return 0, fmt.Errorf("CW shop rule %s is outside uint32", name)
|
|
}
|
|
return uint32(value), nil
|
|
}
|