Files
GaCy-GO/internal/common/limited_gacha.go
T
2026-10-28 18:54:43 +07:00

411 lines
12 KiB
Go

package common
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"regexp"
"sort"
)
// These are configurable sandbox rules, not captured Client probabilities.
type LimitedGachaRules struct {
Source string `json:"source"`
RateScale uint32 `json:"rate_scale"`
FiveStarRate uint32 `json:"five_star_rate"`
FourStarRate uint32 `json:"four_star_rate"`
FiveStarPity uint32 `json:"five_star_pity"`
FourStarPity uint32 `json:"four_star_pity"`
}
type LimitedGachaBanner struct {
MappedGachaBanner
FourStarIDs []uint32 `json:"four_star_ids"`
}
// Counters count consecutive draws without the corresponding rarity or better.
// All mapped limited character banners share this state.
type LimitedGachaPity struct {
SinceFiveStar uint32 `json:"since_five_star"`
SinceFourStar uint32 `json:"since_four_star"`
TotalDraws uint64 `json:"total_draws"`
}
func (r LimitedGachaRules) validate() error {
if r.Source == "" || r.RateScale == 0 || uint64(r.FiveStarRate)+uint64(r.FourStarRate) > uint64(r.RateScale) || r.FiveStarPity < 1 || r.FourStarPity < 1 || r.FourStarPity > r.FiveStarPity {
return fmt.Errorf("invalid limited gacha sandbox rules")
}
return nil
}
// Four-star rotations are generated sandbox lineups, not official rate-up lists.
// IDs and rarity come from mapping + AvatarConfig; IDs are never rarity heuristics.
func LoadLimitedGachaBanners() ([]LimitedGachaBanner, LimitedGachaRules, error) {
_, roster, policy, err := LoadSandboxDrawPool()
if err != nil {
return nil, policy.LimitedRules, err
}
rules := policy.LimitedRules
if err = rules.validate(); err != nil {
return nil, rules, err
}
mapped, err := LoadGachaBannerMapping()
if err != nil {
return nil, rules, err
}
raw, err := ReadResourceFile("resources/AvatarConfig.json")
if err != nil {
return nil, rules, err
}
var doc struct {
Avatars []struct {
ID uint32 `json:"AvatarID"`
Rarity string
} `json:"avatar_config"`
}
if err = json.Unmarshal(raw, &doc); err != nil {
return nil, rules, err
}
rarity := map[uint32]string{}
for _, a := range doc.Avatars {
if a.ID == 0 || rarity[a.ID] != "" {
return nil, rules, fmt.Errorf("invalid avatar rarity catalog")
}
rarity[a.ID] = a.Rarity
}
owned := map[uint32]bool{}
for _, id := range roster {
owned[id] = true
}
var four []uint32
for _, b := range mapped {
if owned[b.AvatarID] && rarity[b.AvatarID] == "CombatPowerAvatarRarityType4" {
four = append(four, b.AvatarID)
}
}
sort.Slice(four, func(i, j int) bool { return four[i] < four[j] })
if len(four) < 3 {
return nil, rules, fmt.Errorf("limited gacha needs three imported mapped four-star avatars")
}
var banners []LimitedGachaBanner
for _, b := range mapped {
if !owned[b.AvatarID] {
continue
}
switch rarity[b.AvatarID] {
case "CombatPowerAvatarRarityType4":
continue // Stable mapping ID remains reserved.
case "CombatPowerAvatarRarityType5":
start := len(banners) % len(four)
banners = append(banners, LimitedGachaBanner{MappedGachaBanner: b, FourStarIDs: []uint32{four[start], four[(start+1)%len(four)], four[(start+2)%len(four)]}})
default:
return nil, rules, fmt.Errorf("unknown mapped avatar rarity %d", b.AvatarID)
}
}
return banners, rules, nil
}
func readLimitedRoot() (map[string]json.RawMessage, error) {
raw, err := readResourceFile("freesr-data.json")
if err != nil {
return nil, err
}
var root map[string]json.RawMessage
if err = json.Unmarshal(raw, &root); err != nil || root == nil {
return nil, fmt.Errorf("invalid FreeSR import")
}
return root, nil
}
func decodeLimitedPity(root map[string]json.RawMessage) (LimitedGachaPity, error) {
var p LimitedGachaPity
if raw, ok := root["gacha_limited"]; ok {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return p, fmt.Errorf("invalid limited pity state")
}
if err := json.Unmarshal(raw, &p); err != nil {
return p, err
}
}
return p, nil
}
// Read-only: absent state is reported as zero without writing a save file.
func ReadLimitedGachaPity() (LimitedGachaPity, error) {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return LimitedGachaPity{}, err
}
defer unlock()
root, err := readLimitedRoot()
if err != nil {
return LimitedGachaPity{}, err
}
return decodeLimitedPity(root)
}
type LimitedGachaReward struct {
ItemID, Rarity uint32
Lightcone *Lightcone
Materials map[uint32]uint32
}
type LimitedGachaResult struct {
Rewards []LimitedGachaReward
Materials map[uint32]uint32
Pity LimitedGachaPity
}
func limitedThreeStarPool() ([]uint32, error) {
raw, err := ReadResourceFile("resources/GachaLootCatalog.json")
if err != nil {
return nil, err
}
var doc struct {
Format uint32 `json:"format_version"`
Assembly string `json:"client_assembly_sha256"`
Source string
Capture string `json:"capture_sha256"`
Lightcones []struct{ ID, Rarity uint32 }
}
if err = json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
raw, err = ReadResourceFile("resources/PreviewGameplayCatalog.json")
if err != nil {
return nil, err
}
var preview struct {
Assembly string `json:"client_assembly_sha256"`
}
if err = json.Unmarshal(raw, &preview); err != nil {
return nil, err
}
hashPattern := regexp.MustCompile(`^[0-9a-f]{64}$`)
if doc.Format != 1 || doc.Source == "" || !hashPattern.MatchString(doc.Capture) || !hashPattern.MatchString(doc.Assembly) || doc.Assembly != preview.Assembly {
return nil, fmt.Errorf("limited loot catalog provenance mismatch")
}
seen := map[uint32]bool{}
var ids []uint32
for _, row := range doc.Lightcones {
if row.ID == 0 || seen[row.ID] || row.Rarity < 3 || row.Rarity > 5 {
return nil, fmt.Errorf("invalid captured Light Cone rarity")
}
seen[row.ID] = true
if row.Rarity == 3 {
ids = append(ids, row.ID)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("no verified three-star Light Cones")
}
return ids, nil
}
// DrawLimitedGacha atomically commits rewards and pity in the same latest-root
// merge. Validation completes before any RNG; errors never grant partial rewards.
func DrawLimitedGacha(bannerID, count uint32, costs map[uint32]map[uint32]uint32, baseline map[uint32]uint32) (LimitedGachaResult, error) {
result := LimitedGachaResult{}
banners, rules, err := LoadLimitedGachaBanners()
if err != nil {
return result, err
}
var banner *LimitedGachaBanner
for i := range banners {
if banners[i].GachaID == bannerID {
banner = &banners[i]
break
}
}
if banner == nil {
return result, fmt.Errorf("inactive limited banner")
}
_, policy, err := LoadBrowsableGachaBanners()
if err != nil {
return result, err
}
allowed := false
for _, n := range policy.DrawCounts {
allowed = allowed || n == count
}
if !allowed {
return result, fmt.Errorf("invalid limited draw count")
}
pool, err := limitedThreeStarPool()
if err != nil {
return result, err
}
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return result, err
}
defer unlock()
root, err := readLimitedRoot()
if err != nil {
return result, err
}
pity, err := decodeLimitedPity(root)
if err != nil {
return result, err
}
if pity.SinceFiveStar >= rules.FiveStarPity || pity.SinceFourStar >= rules.FourStarPity || ^uint64(0)-pity.TotalDraws < uint64(count) {
return result, fmt.Errorf("invalid or overflowing limited pity")
}
var avatars map[uint32]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
return result, err
}
materials := map[uint32]uint32{}
if raw, ok := root["materials"]; ok {
if err = json.Unmarshal(raw, &materials); err != nil {
return result, err
}
}
if materials == nil {
materials = map[uint32]uint32{}
}
var cones []json.RawMessage
var maxUID uint32
if raw, ok := root["lightcones"]; ok {
if err = json.Unmarshal(raw, &cones); err != nil {
return result, err
}
}
for _, raw := range cones {
var c Lightcone
if err = json.Unmarshal(raw, &c); err != nil {
return result, err
}
if c.InternalUID > maxUID {
maxUID = c.InternalUID
}
}
// UniqueID adds an inventory namespace offset. Derive it from that method.
zero := Lightcone{}
offset := zero.UniqueID()
if uint64(maxUID)+uint64(count)+uint64(offset) > uint64(^uint32(0)) {
return result, fmt.Errorf("Light Cone UniqueID overflow")
}
required := append([]uint32{banner.AvatarID}, banner.FourStarIDs...)
maxCosts := map[uint32]uint32{}
for _, id := range required {
if len(avatars[id]) == 0 || string(avatars[id]) == "null" || len(costs[id]) == 0 {
return result, fmt.Errorf("missing imported avatar or duplicate cost %d", id)
}
for item, amount := range costs[id] {
if item == 0 || amount == 0 {
return result, fmt.Errorf("invalid duplicate cost")
}
if amount > maxCosts[item] {
maxCosts[item] = amount
}
}
}
for item, amount := range maxCosts {
current, ok := materials[item]
if !ok {
current = baseline[item]
}
if uint64(current)+uint64(amount)*uint64(count) > uint64(^uint32(0)) {
return result, fmt.Errorf("gacha material overflow")
}
}
pick := func(n uint32) (uint32, error) {
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(uint64(n)))
if e != nil {
return 0, e
}
return uint32(v.Uint64()), nil
}
result.Materials = map[uint32]uint32{}
for i := uint32(0); i < count; i++ {
roll, e := pick(rules.RateScale)
if e != nil {
return LimitedGachaResult{}, e
}
rarity := uint32(3)
if pity.SinceFiveStar == rules.FiveStarPity-1 || roll < rules.FiveStarRate {
rarity = 5
} else if pity.SinceFourStar == rules.FourStarPity-1 || roll < rules.FiveStarRate+rules.FourStarRate {
rarity = 4
}
reward := LimitedGachaReward{Rarity: rarity}
if rarity == 5 {
reward.ItemID = banner.AvatarID
} else if rarity == 4 {
n, e := pick(uint32(len(banner.FourStarIDs)))
if e != nil {
return LimitedGachaResult{}, e
}
reward.ItemID = banner.FourStarIDs[n]
} else {
n, e := pick(uint32(len(pool)))
if e != nil {
return LimitedGachaResult{}, e
}
reward.ItemID = pool[n]
maxUID++
c := Lightcone{ItemID: reward.ItemID, InternalUID: maxUID, Level: 1, Rank: 1}
reward.Lightcone = &c
raw, e := json.Marshal(c)
if e != nil {
return LimitedGachaResult{}, e
}
cones = append(cones, raw)
}
if rarity >= 4 {
reward.Materials = costs[reward.ItemID]
for item, amount := range reward.Materials {
current, ok := materials[item]
if !ok {
current = baseline[item]
}
materials[item] = current + amount
result.Materials[item] = materials[item]
}
}
pity.TotalDraws++
pity.SinceFiveStar++
pity.SinceFourStar++
if rarity == 5 {
pity.SinceFiveStar = 0
}
if rarity >= 4 {
pity.SinceFourStar = 0
}
result.Rewards = append(result.Rewards, reward)
}
// Preserve unknown pity keys just as the surrounding root and cone rows are preserved.
state := map[string]json.RawMessage{}
if raw, ok := root["gacha_limited"]; ok {
if err = json.Unmarshal(raw, &state); err != nil {
return LimitedGachaResult{}, err
}
}
for key, value := range map[string]interface{}{"since_five_star": pity.SinceFiveStar, "since_four_star": pity.SinceFourStar, "total_draws": pity.TotalDraws} {
state[key], err = json.Marshal(value)
if err != nil {
return LimitedGachaResult{}, err
}
}
root["gacha_limited"], err = json.Marshal(state)
if err != nil {
return LimitedGachaResult{}, err
}
root["materials"], err = json.Marshal(materials)
if err != nil {
return LimitedGachaResult{}, err
}
root["lightcones"], err = json.Marshal(cones)
if err != nil {
return LimitedGachaResult{}, err
}
if err = writeJSONAtomic("freesr-data.json", root); err != nil {
return LimitedGachaResult{}, err
}
result.Pity = pity
return result, nil
}