411 lines
12 KiB
Go
411 lines
12 KiB
Go
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
|
|
}
|