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 }