Files
GaCy-GO/internal/common/currency_wars_pool.go
T

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
}