package common import ( "fmt" "sort" ) // A run's card pool, separate from the immutable capture and adventure bag. // This initial pool covers captured, pool-enabled handbook roles only. Hidden // roles need captured configurations before they can enter this pool. type CurrencyWarsCardPool struct { Roles map[uint32]CurrencyWarsPoolRole `json:"roles"` } type CurrencyWarsPoolRole struct { Rarity uint32 `json:"rarity"` Capacity uint32 `json:"capacity"` Remaining uint32 `json:"remaining"` Disabled bool `json:"disabled,omitempty"` } // Draw policy is explicit server policy, not a claim that the Client defines // how the original server weights different roles inside a rarity bucket. type CurrencyWarsDrawPolicy string const ( CurrencyWarsDrawRemainingCopies CurrencyWarsDrawPolicy = "remaining_copies" CurrencyWarsDrawUniformRoleIDs CurrencyWarsDrawPolicy = "uniform_role_ids" ) // NewCapturedCardPool reads capacity and initial exclusions from captured // native constants. It never fabricates an ID or silently includes non-pool // roles. The caller must retain the capture-coverage limitation above. func (c *CurrencyWarsCatalog) NewCapturedCardPool() (*CurrencyWarsCardPool, error) { capacities, err := c.IntegerArrayRule("GridFight_CardPoolNumber") if err != nil { return nil, err } excluded, err := c.IntegerArrayRule("GridFight_InitialExceptRoleIdList") if err != nil { return nil, err } blocked := make(map[uint32]bool, len(excluded)) for _, id := range excluded { blocked[id] = true } pool := &CurrencyWarsCardPool{Roles: make(map[uint32]CurrencyWarsPoolRole)} if len(c.CapturedVariants) == 0 { return nil, fmt.Errorf("CW initial card pool needs a completed role capture") } for _, role := range c.CapturedVariants { r := role.Config if !r.IsInPool || blocked[r.ID] { continue } if r.SeasonID != c.ActiveSeasonID || r.Rarity == 0 || uint64(r.Rarity) > uint64(len(capacities)) || capacities[r.Rarity-1] == 0 { return nil, fmt.Errorf("CW role %d has no captured pool capacity", r.ID) } entry := CurrencyWarsPoolRole{Rarity: r.Rarity, Capacity: capacities[r.Rarity-1], Remaining: capacities[r.Rarity-1]} if old, present := pool.Roles[r.ID]; present && old != entry { return nil, fmt.Errorf("conflicting CW pool role %d", r.ID) } pool.Roles[r.ID] = entry } if len(pool.Roles) == 0 { return nil, fmt.Errorf("CW captured initial pool is empty") } return pool, nil } func (p *CurrencyWarsCardPool) Clone() *CurrencyWarsCardPool { copyPool := &CurrencyWarsCardPool{Roles: make(map[uint32]CurrencyWarsPoolRole, len(p.Roles))} for id, entry := range p.Roles { copyPool.Roles[id] = entry } return copyPool } // ReturnCopies is internal inventory bookkeeping. The action processor must // prove ownership/unsold-offer identity before supplying IDs. Capacity checks // prevent overfilling, and validation commits no partial refund on failure. func (p *CurrencyWarsCardPool) ReturnCopies(ids []uint32) error { if p == nil { return fmt.Errorf("CW pool is missing") } next := p.Clone() for _, id := range ids { role, exists := next.Roles[id] if !exists || role.Remaining >= role.Capacity { return fmt.Errorf("invalid CW pool refund for role %d", id) } role.Remaining++ next.Roles[id] = role } p.Roles = next.Roles return nil } // Draw reserves copies for shop offers. Rarity weights must be the run's // resolved weights (including modifiers), not necessarily unmodified level // rows. The supplied random function returns a value in [0,upper); no seeded // generator or machine-specific randomness policy is hidden here. func (p *CurrencyWarsCardPool) Draw(count uint32, rarityWeights []uint32, policy CurrencyWarsDrawPolicy, random func(upper uint64) (uint64, error)) ([]uint32, error) { if p == nil || random == nil || len(rarityWeights) == 0 { return nil, fmt.Errorf("CW draw inputs are incomplete") } if policy != CurrencyWarsDrawRemainingCopies && policy != CurrencyWarsDrawUniformRoleIDs { return nil, fmt.Errorf("unknown CW draw policy %q", policy) } next := p.Clone() ids := make([]uint32, 0, len(next.Roles)) var available uint64 for id, role := range next.Roles { if id == 0 || role.Rarity == 0 || uint64(role.Rarity) > uint64(len(rarityWeights)) || role.Remaining > role.Capacity { return nil, fmt.Errorf("invalid CW pool role %d", id) } if role.Disabled { continue } ids = append(ids, id) if rarityWeights[role.Rarity-1] != 0 { available += uint64(role.Remaining) } } if uint64(count) > available { return nil, fmt.Errorf("CW pool has too few eligible copies") } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) var drawn []uint32 for draw := uint32(0); draw < count; draw++ { present := make([]bool, len(rarityWeights)) for _, id := range ids { role := next.Roles[id] if role.Remaining != 0 { present[role.Rarity-1] = true } } var total uint64 for i, weight := range rarityWeights { if present[i] { total += uint64(weight) } } if total == 0 { return nil, fmt.Errorf("CW eligible rarity weights are empty") } roll, err := random(total) if err != nil { return nil, err } if roll >= total { return nil, fmt.Errorf("CW random source returned an out-of-range rarity roll") } var rarity uint32 for i, weight := range rarityWeights { if !present[i] { continue } if roll < uint64(weight) { rarity = uint32(i) + 1 break } roll -= uint64(weight) } var roleTotal uint64 for _, id := range ids { role := next.Roles[id] if role.Rarity == rarity && role.Remaining != 0 { if policy == CurrencyWarsDrawRemainingCopies { roleTotal += uint64(role.Remaining) } else { roleTotal++ } } } roleRoll, err := random(roleTotal) if err != nil { return nil, err } if roleRoll >= roleTotal { return nil, fmt.Errorf("CW random source returned an out-of-range role roll") } selected := false for _, id := range ids { role := next.Roles[id] if role.Rarity != rarity || role.Remaining == 0 { continue } weight := uint64(1) if policy == CurrencyWarsDrawRemainingCopies { weight = uint64(role.Remaining) } if roleRoll < weight { role.Remaining-- next.Roles[id] = role drawn = append(drawn, id) selected = true break } roleRoll -= weight } if !selected { return nil, fmt.Errorf("CW role draw did not resolve") } } p.Roles = next.Roles return drawn, nil }