210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
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
|
|
}
|