Update GaCy-GO
This commit is contained in:
1 parent
da8a32f60d
commit
0cbe460e49
345 files changed
+98202
-77559
No files matched your search
@@ -0,0 +1,410 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user