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

866 lines
22 KiB
Go

package common
import (
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
"sync"
"time"
pb "gac-hkrpg-go/protocol/proto"
)
type GachaType string
const (
GachaTypeUnknown GachaType = "Unknown"
GachaTypeNewbie GachaType = "Newbie"
GachaTypeNormal GachaType = "Normal"
GachaTypeAvatarUp GachaType = "AvatarUp"
GachaTypeWeaponUp GachaType = "WeaponUp"
GachaTypeCollaborationAvatarUp GachaType = "CollaborationAvatarUp"
GachaTypeCollaborationWeaponUp GachaType = "CollaborationWeaponUp"
)
type GachaPityFamily int
const (
GachaPityFamilyUnknown GachaPityFamily = 0
GachaPityFamilyNewbie GachaPityFamily = 1
GachaPityFamilyNormal GachaPityFamily = 2
GachaPityFamilyAvatarCollaboration GachaPityFamily = 5
GachaPityFamilyWeaponCollaboration GachaPityFamily = 6
GachaPityFamilyAvatarUp GachaPityFamily = 11
GachaPityFamilyWeaponUp GachaPityFamily = 12
)
type GachaBanner struct {
ID uint32 `json:"id"`
GachaType GachaType `json:"gachaType"`
BeginTime int64 `json:"beginTime"`
EndTime int64 `json:"endTime"`
RateUpItems5 []uint32 `json:"rateUpItems5"`
RateUpItems4 []uint32 `json:"rateUpItems4"`
}
type GachaPityState struct {
PullsSinceFiveStar uint32 `json:"since_five_star"`
PullsSinceFourStarOrHigher uint32 `json:"since_four_star"`
GuaranteedFiveStar bool `json:"guaranteed_five_star"`
GuaranteedFourStar bool `json:"guaranteed_four_star"`
TotalPulls uint32 `json:"total_pulls"`
NewbieFiveStarClaimed bool `json:"newbie_five_star_claimed,omitempty"`
}
func (s *GachaPityState) RecordFiveStar() {
s.PullsSinceFiveStar = 0
s.PullsSinceFourStarOrHigher = 0
s.TotalPulls++
}
func (s *GachaPityState) RecordFourStar() {
s.PullsSinceFiveStar++
s.PullsSinceFourStarOrHigher = 0
s.TotalPulls++
}
func (s *GachaPityState) RecordThreeStar() {
s.PullsSinceFiveStar++
s.PullsSinceFourStarOrHigher++
s.TotalPulls++
}
func (b *GachaBanner) IsActiveAt(unixSeconds int64) bool {
if b.BeginTime > 0 && unixSeconds < b.BeginTime {
return false
}
if b.EndTime > 0 && unixSeconds > b.EndTime {
return false
}
return true
}
func (b *GachaBanner) CostItemID() uint32 {
switch b.GachaType {
case GachaTypeNewbie, GachaTypeNormal:
return 101 // Star Rail Pass
case GachaTypeAvatarUp, GachaTypeWeaponUp, GachaTypeCollaborationAvatarUp, GachaTypeCollaborationWeaponUp:
return 102 // Star Rail Special Pass
default:
return 0
}
}
func (b *GachaBanner) IsLightConeBanner() *bool {
switch b.GachaType {
case GachaTypeAvatarUp, GachaTypeCollaborationAvatarUp:
val := false
return &val
case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp:
val := true
return &val
default:
return nil
}
}
func (b *GachaBanner) PityFamily() GachaPityFamily {
switch b.GachaType {
case GachaTypeNewbie:
return GachaPityFamilyNewbie
case GachaTypeNormal:
return GachaPityFamilyNormal
case GachaTypeWeaponUp:
return GachaPityFamilyWeaponUp
case GachaTypeAvatarUp:
return GachaPityFamilyAvatarUp
case GachaTypeCollaborationAvatarUp:
return GachaPityFamilyAvatarCollaboration
case GachaTypeCollaborationWeaponUp:
return GachaPityFamilyWeaponCollaboration
default:
return GachaPityFamilyNormal
}
}
func (b *GachaBanner) ToProtoInfo() *pb.GachaInfo {
info := &pb.GachaInfo{
GachaId: b.ID,
DetailUrl: "",
HistoryUrl: "",
BeginTime: b.BeginTime,
EndTime: b.EndTime,
}
if info.EndTime == 0 {
info.EndTime = 4102416000
}
// 4.6 client wire mapping:
// Wire #13 (ONIHOMCELJK) = rateUpItems5 (featured 5-star character/lightcone)
// Wire #10 (PrizeItemList) = rateUpItems4 + rateUpItems5 distinct (detail / capsules)
info.ONIHOMCELJK = append([]uint32(nil), b.RateUpItems5...)
seen := make(map[uint32]bool)
for _, id := range append(b.RateUpItems4, b.RateUpItems5...) {
if !seen[id] && id > 0 {
seen[id] = true
info.PrizeItemList = append(info.PrizeItemList, id)
}
}
if b.ID == 1001 {
goldAvatars := []uint32{1003, 1004, 1101, 1107, 1104, 1209, 1211}
ceilingAvatars := make([]*pb.GachaCeilingAvatar, 0, len(goldAvatars))
for _, aid := range goldAvatars {
ceilingAvatars = append(ceilingAvatars, &pb.GachaCeilingAvatar{AvatarId: aid})
}
info.GachaCeiling = &pb.GachaCeiling{
IsClaimed: true,
AvatarList: ceilingAvatars,
}
} else if b.GachaType == GachaTypeAvatarUp || b.GachaType == GachaTypeCollaborationAvatarUp {
info.ItemDetailList = []uint32{11}
}
return info
}
var (
bannersMu sync.RWMutex
loadedBanners []GachaBanner
loadedUnlockAllBanners []GachaBanner
bannersInitialized bool
)
func parseBannersRaw(raw []byte) ([]GachaBanner, error) {
var list []GachaBanner
if err := json.Unmarshal(raw, &list); err == nil && len(list) > 0 {
return list, nil
}
var wrapper struct {
Banners []GachaBanner `json:"Banners"`
BannersLower []GachaBanner `json:"banners"`
}
if err := json.Unmarshal(raw, &wrapper); err == nil {
if len(wrapper.Banners) > 0 {
return wrapper.Banners, nil
}
if len(wrapper.BannersLower) > 0 {
return wrapper.BannersLower, nil
}
}
var rawList []json.RawMessage
if err := json.Unmarshal(raw, &rawList); err == nil {
var banners []GachaBanner
for _, item := range rawList {
var b GachaBanner
if err := json.Unmarshal(item, &b); err == nil && b.ID > 0 {
banners = append(banners, b)
}
}
if len(banners) > 0 {
return banners, nil
}
}
return nil, fmt.Errorf("no valid banners parsed")
}
func LoadBannersConfig() error {
bannersMu.Lock()
defer bannersMu.Unlock()
rawBase, err := ReadResourceFile("resources/Banners.json")
if err != nil {
return fmt.Errorf("failed to read resources/Banners.json: %w", err)
}
baseList, err := parseBannersRaw(rawBase)
if err != nil {
return fmt.Errorf("failed to parse resources/Banners.json: %w", err)
}
var unlockList []GachaBanner
rawUnlock, err := ReadResourceFile("resources/BannersUnlockAll.json")
if err == nil {
if list, err := parseBannersRaw(rawUnlock); err == nil {
unlockList = list
}
}
loadedBanners = baseList
loadedUnlockAllBanners = unlockList
bannersInitialized = true
return nil
}
func ReloadBanners() error {
return LoadBannersConfig()
}
func ensureBannersLoaded() {
bannersMu.RLock()
init := bannersInitialized
bannersMu.RUnlock()
if !init {
_ = LoadBannersConfig()
}
}
// GetVisibleBanners returns active banners according to the player's WarpUnlockMode:
// 0 = only active banners from Banners.json
// 1 = all banners (Banners.json + BannersUnlockAll.json)
// 2 = character banners (Banners.json + character banners from BannersUnlockAll.json)
// 3 = lightcone banners (Banners.json + lightcone banners from BannersUnlockAll.json)
func GetVisibleBanners(mode int) []GachaBanner {
ensureBannersLoaded()
bannersMu.RLock()
defer bannersMu.RUnlock()
now := time.Now().Unix()
if mode == 0 {
out := make([]GachaBanner, 0, len(loadedBanners))
for _, b := range loadedBanners {
if b.IsActiveAt(now) {
out = append(out, b)
}
}
return out
}
out := make([]GachaBanner, 0, len(loadedBanners)+len(loadedUnlockAllBanners))
ids := make(map[uint32]bool)
featured := make(map[[2]uint32]bool)
for _, b := range loadedBanners {
if b.IsActiveAt(now) {
lc := b.IsLightConeBanner()
if mode == 2 && lc != nil && *lc {
continue // character only
}
if mode == 3 && lc != nil && !*lc {
continue // lightcone only
}
out = append(out, b)
ids[b.ID] = true
if lc != nil {
var lcFlag uint32
if *lc {
lcFlag = 1
}
for _, item := range b.RateUpItems5 {
featured[[2]uint32{lcFlag, item}] = true
}
}
}
}
for _, b := range loadedUnlockAllBanners {
lc := b.IsLightConeBanner()
if lc == nil {
continue
}
if mode == 2 && *lc {
continue // character only
}
if mode == 3 && !*lc {
continue // lightcone only
}
if len(b.RateUpItems5) == 0 {
continue
}
var lcFlag uint32
if *lc {
lcFlag = 1
}
if featured[[2]uint32{lcFlag, b.RateUpItems5[0]}] {
continue // already featured by active banner
}
if ids[b.ID] {
continue
}
if !b.IsActiveAt(now) {
continue
}
ids[b.ID] = true
out = append(out, b)
}
return out
}
func FindGachaBanner(gachaID uint32, mode int) *GachaBanner {
visible := GetVisibleBanners(mode)
for i := range visible {
if visible[i].ID == gachaID {
return &visible[i]
}
}
// Fallback to any loaded banner so an in-flight pull is never rejected
bannersMu.RLock()
defer bannersMu.RUnlock()
for i := range loadedBanners {
if loadedBanners[i].ID == gachaID {
return &loadedBanners[i]
}
}
for i := range loadedUnlockAllBanners {
if loadedUnlockAllBanners[i].ID == gachaID {
return &loadedUnlockAllBanners[i]
}
}
return nil
}
// ---------------------------------------------------------------------------
// Gacha Pools & Probabilities
// ---------------------------------------------------------------------------
var (
StandardGoldAvatars = []uint32{1003, 1004, 1101, 1107, 1104, 1209, 1211}
StandardGoldWeapons = []uint32{23000, 23002, 23003, 23004, 23005, 23012, 23013}
)
func FiveStarChance90(pullNumber uint32) float64 {
if pullNumber >= 90 {
return 1.0
}
if pullNumber >= 76 {
return 0.324
}
return 0.006
}
func FiveStarChance80(pullNumber uint32) float64 {
if pullNumber >= 80 {
return 1.0
}
if pullNumber >= 66 {
return 0.219633
}
return 0.008
}
func FiveStarChanceNewbie(pullNumber uint32) float64 {
if pullNumber >= 50 {
return 1.0
}
return 0.006
}
func cryptoRandFloat() float64 {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return 0.5
}
return float64(n.Int64()) / 1000000.0
}
func cryptoPickOne(list []uint32) uint32 {
if len(list) == 0 {
return 0
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(list))))
if err != nil {
return list[0]
}
return list[n.Int64()]
}
func withoutItems(source, excluded []uint32) []uint32 {
exMap := make(map[uint32]bool, len(excluded))
for _, id := range excluded {
exMap[id] = true
}
out := make([]uint32, 0, len(source))
for _, id := range source {
if !exMap[id] {
out = append(out, id)
}
}
return out
}
func pickByCategory(avatars, weapons []uint32) uint32 {
if len(avatars) == 0 {
return cryptoPickOne(weapons)
}
if len(weapons) == 0 {
return cryptoPickOne(avatars)
}
if cryptoRandFloat() < 0.5 {
return cryptoPickOne(avatars)
}
return cryptoPickOne(weapons)
}
func loadGachaItemCatalogs() (purpleAvatars, purpleWeapons, blueWeapons []uint32, err error) {
// 4-star avatars from AvatarConfig.json
rawAv, err := ReadResourceFile("resources/AvatarConfig.json")
if err == nil {
var doc struct {
Avatars []struct {
AvatarID uint32 `json:"AvatarID"`
Rarity string `json:"Rarity"`
MaxRank uint32 `json:"MaxRank"`
} `json:"avatar_config"`
}
if json.Unmarshal(rawAv, &doc) == nil {
for _, a := range doc.Avatars {
if a.Rarity == "CombatPowerAvatarRarityType4" && a.MaxRank > 0 && a.AvatarID < 8000 {
purpleAvatars = append(purpleAvatars, a.AvatarID)
}
}
}
}
if len(purpleAvatars) == 0 {
purpleAvatars = []uint32{
1001, 1002, 1008, 1009, 1013, 1103, 1105, 1106, 1108, 1109,
1110, 1111, 1201, 1202, 1206, 1207, 1210, 1214, 1215, 1223,
1224, 1301, 1312,
}
}
// Lightcones from GachaLootCatalog.json
rawLoot, err := ReadResourceFile("resources/GachaLootCatalog.json")
if err == nil {
var doc struct {
Lightcones []struct {
ID uint32 `json:"id"`
Rarity uint32 `json:"rarity"`
} `json:"lightcones"`
}
bpCones := map[uint32]bool{
21028: true, 21029: true, 21030: true, 21031: true, 21032: true, 21033: true, 21034: true,
21052: true, 21053: true, 21055: true, 21056: true, 21057: true, 21058: true, 21060: true,
21061: true, 21062: true, 21065: true,
}
if json.Unmarshal(rawLoot, &doc) == nil {
for _, c := range doc.Lightcones {
if c.Rarity == 3 {
blueWeapons = append(blueWeapons, c.ID)
} else if c.Rarity == 4 && !bpCones[c.ID] && !(c.ID >= 22000 && c.ID < 23000) {
purpleWeapons = append(purpleWeapons, c.ID)
}
}
}
}
if len(blueWeapons) == 0 {
blueWeapons = []uint32{20000, 20001, 20002, 20003, 20004, 20005, 20006, 20007, 20008, 20009}
}
if len(purpleWeapons) == 0 {
purpleWeapons = []uint32{21000, 21001, 21002, 21003, 21004, 21005, 21006, 21007, 21008, 21009}
}
return purpleAvatars, purpleWeapons, blueWeapons, nil
}
func (b *GachaBanner) ResolveFiveStarItem(state *GachaPityState, standardAvatars, standardWeapons []uint32) uint32 {
featured := b.RateUpItems5
switch b.GachaType {
case GachaTypeNewbie:
return cryptoPickOne(standardAvatars)
case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp:
return resolveFeaturedOrStandard(state, featured, standardWeapons, 0.75)
case GachaTypeAvatarUp, GachaTypeCollaborationAvatarUp:
return resolveFeaturedOrStandard(state, featured, standardAvatars, 0.50)
default:
return pickByCategory(standardAvatars, standardWeapons)
}
}
func resolveFeaturedOrStandard(state *GachaPityState, featured, standard []uint32, featuredChance float64) uint32 {
if len(featured) == 0 {
return cryptoPickOne(standard)
}
if state.GuaranteedFiveStar || cryptoRandFloat() < featuredChance || len(standard) == 0 {
state.GuaranteedFiveStar = false
return cryptoPickOne(featured)
}
state.GuaranteedFiveStar = true
pool := withoutItems(standard, featured)
if len(pool) == 0 {
pool = standard
}
return cryptoPickOne(pool)
}
func (b *GachaBanner) ResolveFourStarItem(state *GachaPityState, purpleAvatars, purpleWeapons []uint32) uint32 {
isWeaponPool := b.GachaType == GachaTypeWeaponUp || b.GachaType == GachaTypeCollaborationWeaponUp
featuredChance := 0.50
if isWeaponPool {
featuredChance = 0.75
}
avMap := make(map[uint32]bool, len(purpleAvatars))
for _, id := range purpleAvatars {
avMap[id] = true
}
wpMap := make(map[uint32]bool, len(purpleWeapons))
for _, id := range purpleWeapons {
wpMap[id] = true
}
var featured []uint32
for _, id := range b.RateUpItems4 {
if isWeaponPool {
if wpMap[id] {
featured = append(featured, id)
}
} else {
if avMap[id] || wpMap[id] {
featured = append(featured, id)
}
}
}
allStandard := append(append([]uint32(nil), purpleAvatars...), purpleWeapons...)
if len(featured) == 0 {
if isWeaponPool {
return cryptoPickOne(purpleWeapons)
}
return pickByCategory(purpleAvatars, purpleWeapons)
}
standard := withoutItems(allStandard, featured)
if state.GuaranteedFourStar || cryptoRandFloat() < featuredChance || len(standard) == 0 {
state.GuaranteedFourStar = false
return cryptoPickOne(featured)
}
state.GuaranteedFourStar = true
return cryptoPickOne(standard)
}
// ---------------------------------------------------------------------------
// Gacha Pull Execution & Duplicate Granting
// ---------------------------------------------------------------------------
type GachaDrawItemResult struct {
ItemID uint32
Rarity uint32 // 3, 4, 5
IsAvatar bool
IsNew bool
Lightcone *Lightcone
TransferItemList []*pb.Item
TokenItemList []*pb.Item
}
type GachaDrawBatchResult struct {
Items []GachaDrawItemResult
PityState GachaPityState
}
func (b *GachaBanner) RollSingle(state *GachaPityState, purpleAvatars, purpleWeapons, blueWeapons []uint32) (uint32, uint32) {
pullNumber5 := state.PullsSinceFiveStar + 1
pullNumber4 := state.PullsSinceFourStarOrHigher + 1
var fiveStarProb float64
var fourStarProb float64 = 0.051
switch b.GachaType {
case GachaTypeNewbie:
fiveStarProb = FiveStarChanceNewbie(pullNumber5)
case GachaTypeWeaponUp, GachaTypeCollaborationWeaponUp:
fiveStarProb = FiveStarChance80(pullNumber5)
fourStarProb = 0.066
default:
fiveStarProb = FiveStarChance90(pullNumber5)
}
if cryptoRandFloat() < fiveStarProb {
item := b.ResolveFiveStarItem(state, StandardGoldAvatars, StandardGoldWeapons)
state.RecordFiveStar()
if b.GachaType == GachaTypeNewbie {
state.NewbieFiveStarClaimed = true
}
return item, 5
}
if pullNumber4 >= 10 || cryptoRandFloat() < fourStarProb {
item := b.ResolveFourStarItem(state, purpleAvatars, purpleWeapons)
state.RecordFourStar()
return item, 4
}
item := cryptoPickOne(blueWeapons)
state.RecordThreeStar()
return item, 3
}
// PerformGachaRolls executes `times` pulls and atomically updates freesr-data.json
func PerformGachaRolls(banner *GachaBanner, times uint32, player *FreesrData) (*GachaDrawBatchResult, error) {
if times != 1 && times != 10 {
return nil, fmt.Errorf("invalid gacha times %d", times)
}
purpleAvatars, purpleWeapons, blueWeapons, err := loadGachaItemCatalogs()
if err != nil {
return nil, err
}
family := banner.PityFamily()
familyKey := fmt.Sprintf("%d", family)
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return nil, err
}
defer unlock()
root, err := readLimitedRoot()
if err != nil {
return nil, err
}
// Load pity states
pityMap := make(map[string]GachaPityState)
if raw, ok := root["gacha_pity"]; ok && len(raw) > 0 {
_ = json.Unmarshal(raw, &pityMap)
}
pityState := pityMap[familyKey]
// Migrate from legacy gacha_limited if empty and AvatarUp
if family == GachaPityFamilyAvatarUp && pityState.TotalPulls == 0 {
if leg, err := decodeLimitedPity(root); err == nil && leg.TotalDraws > 0 {
pityState.PullsSinceFiveStar = leg.SinceFiveStar
pityState.PullsSinceFourStarOrHigher = leg.SinceFourStar
pityState.TotalPulls = uint32(leg.TotalDraws)
}
}
// Load materials
materials := make(map[uint32]uint32)
if raw, ok := root["materials"]; ok {
_ = json.Unmarshal(raw, &materials)
}
if player.Materials == nil {
player.Materials = make(map[uint32]uint32)
}
for k, v := range materials {
player.Materials[k] = v
}
// Load avatars
var avatarDoc map[uint32]json.RawMessage
if raw, ok := root["avatars"]; ok {
_ = json.Unmarshal(raw, &avatarDoc)
}
if avatarDoc == nil {
avatarDoc = make(map[uint32]json.RawMessage)
}
// Load lightcones
var coneDoc []json.RawMessage
if raw, ok := root["lightcones"]; ok {
_ = json.Unmarshal(raw, &coneDoc)
}
var maxUID uint32
for _, raw := range coneDoc {
var lc Lightcone
if json.Unmarshal(raw, &lc) == nil && lc.InternalUID > maxUID {
maxUID = lc.InternalUID
}
}
for _, lc := range player.Lightcones {
if lc.InternalUID > maxUID {
maxUID = lc.InternalUID
}
}
results := make([]GachaDrawItemResult, 0, times)
for i := uint32(0); i < times; i++ {
itemID, rarity := banner.RollSingle(&pityState, purpleAvatars, purpleWeapons, blueWeapons)
if itemID == 0 {
itemID = cryptoPickOne(blueWeapons)
rarity = 3
}
res := GachaDrawItemResult{
ItemID: itemID,
Rarity: rarity,
}
isAvatar := itemID < 20000
if isAvatar {
res.IsAvatar = true
existingAvatar, owned := player.Avatars[itemID]
if !owned || existingAvatar == nil {
// First acquisition
res.IsNew = true
newAv := &AvatarJson{
AvatarID: itemID,
Level: 1,
Promotion: 0,
Techniques: []uint32{},
Data: AvatarData{
Rank: 0,
Skills: make(map[uint32]uint32),
SkillsByAnchorType: make(map[uint32]uint32),
},
}
player.Avatars[itemID] = newAv
raw, _ := json.Marshal(newAv)
avatarDoc[itemID] = raw
} else {
// Duplicate conversion: Eidolon item + Starlight (252)
res.IsNew = false
rankUpItemID := itemID + 10000
curRank := existingAvatar.Data.Rank + materials[rankUpItemID]
var starlight uint32
if curRank < 6 {
materials[rankUpItemID]++
player.Materials[rankUpItemID] = materials[rankUpItemID]
res.TransferItemList = append(res.TransferItemList, &pb.Item{
ItemId: rankUpItemID,
Num: 1,
})
if rarity == 5 {
starlight = 40
} else {
starlight = 8
}
} else {
if rarity == 5 {
starlight = 100
} else {
starlight = 20
}
}
if starlight > 0 {
materials[252] += starlight
player.Materials[252] = materials[252]
res.TokenItemList = append(res.TokenItemList, &pb.Item{
ItemId: 252,
Num: starlight,
})
}
}
} else {
// Lightcone
res.IsAvatar = false
maxUID++
lc := Lightcone{
ItemID: itemID,
InternalUID: maxUID,
Level: 1,
Rank: 1,
Promotion: 0,
}
player.Lightcones = append(player.Lightcones, lc)
raw, _ := json.Marshal(lc)
coneDoc = append(coneDoc, raw)
res.Lightcone = &lc
var embers, starlight uint32
if rarity == 5 {
starlight = 40
} else if rarity == 4 {
starlight = 8
} else {
embers = 20
}
if embers > 0 {
materials[251] += embers
player.Materials[251] = materials[251]
res.TokenItemList = append(res.TokenItemList, &pb.Item{
ItemId: 251,
Num: embers,
})
}
if starlight > 0 {
materials[252] += starlight
player.Materials[252] = materials[252]
res.TokenItemList = append(res.TokenItemList, &pb.Item{
ItemId: 252,
Num: starlight,
})
}
}
results = append(results, res)
}
// Commit state
pityMap[familyKey] = pityState
root["gacha_pity"], err = json.Marshal(pityMap)
if err != nil {
return nil, err
}
// Mirror legacy pity for AvatarUp
if family == GachaPityFamilyAvatarUp {
legacyPity := LimitedGachaPity{
SinceFiveStar: pityState.PullsSinceFiveStar,
SinceFourStar: pityState.PullsSinceFourStarOrHigher,
TotalDraws: uint64(pityState.TotalPulls),
}
root["gacha_limited"], _ = json.Marshal(legacyPity)
}
root["materials"], err = json.Marshal(materials)
if err != nil {
return nil, err
}
root["avatars"], err = json.Marshal(avatarDoc)
if err != nil {
return nil, err
}
root["lightcones"], err = json.Marshal(coneDoc)
if err != nil {
return nil, err
}
if err := writeJSONAtomic("freesr-data.json", root); err != nil {
return nil, err
}
return &GachaDrawBatchResult{
Items: results,
PityState: pityState,
}, nil
}