865 lines
22 KiB
Go
865 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
|
||
|
|
}
|