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 }