Files
GaCy-GO/internal/common/currency_wars_shop.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
}