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 }