Files
GaCy-GO/internal/common/gacha_catalog.go
T
2026-10-28 18:54:43 +07:00

285 lines
9.7 KiB
Go

package common
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
)
type FeaturedGachaBanner struct {
ID, AvatarID, SortID uint32
Prefab string
}
type GachaSandboxPolicy struct {
LimitedRules LimitedGachaRules `json:"limited_rules"`
Source string `json:"source"`
DrawCounts []uint32 `json:"allowed_draw_counts"`
ChargeCurrency bool `json:"charge_currency"`
DuplicateReward string `json:"duplicate_reward"`
// A flat-table path does not prove that its bundle exists in this Client.
VerifiedBannerPrefabs []string `json:"verified_banner_prefabs"`
SandboxDrawPool struct {
Source string `json:"source"`
FirstAcquisition string `json:"first_acquisition"`
} `json:"sandbox_draw_pool"`
// MappedBannerPreviewCount caps how many mapped per-character banners the
// GetGachaInfo response advertises. The current Client skips IDs absent
// from its local config before creating a pool. Keep zero until a runtime
// presentation adapter has been verified; filling a nil Row afterward
// cannot create those missing pools.
MappedBannerPreviewCount uint32 `json:"mapped_banner_preview_count"`
NativePresentation struct {
Enabled bool `json:"enabled"`
TemplateGachaID uint32 `json:"template_gacha_id"`
Source string `json:"source"`
} `json:"native_presentation"`
}
func LoadFeaturedGachaBanners() ([]FeaturedGachaBanner, GachaSandboxPolicy, error) {
return loadGachaBanners(true)
}
// Browse entries can include a verified standard panel without pretending it
// represents one featured character. Such entries do not enable sandbox draws.
func LoadBrowsableGachaBanners() ([]FeaturedGachaBanner, GachaSandboxPolicy, error) {
return loadGachaBanners(false)
}
func loadGachaBanners(featuredOnly bool) ([]FeaturedGachaBanner, GachaSandboxPolicy, error) {
raw, err := ReadResourceFile("resources/PreviewGameplayCatalog.json")
if err != nil {
return nil, GachaSandboxPolicy{}, err
}
var catalog struct {
Source string `json:"source"`
Evidence string `json:"evidence_id"`
Tables struct {
Gacha []struct {
ID uint32 `json:"GachaID"`
SortID uint32
Prefab string `json:"PrefabPath"`
} `json:"gacha"`
} `json:"tables"`
}
if err = json.Unmarshal(raw, &catalog); err != nil {
return nil, GachaSandboxPolicy{}, err
}
if catalog.Source == "" || catalog.Evidence == "" {
return nil, GachaSandboxPolicy{}, fmt.Errorf("gacha catalog provenance missing")
}
raw, err = ReadResourceFile("resources/GachaSandboxPolicy.json")
if err != nil {
return nil, GachaSandboxPolicy{}, err
}
var policy GachaSandboxPolicy
if err = json.Unmarshal(raw, &policy); err != nil {
return nil, policy, err
}
if policy.Source == "" || len(policy.DrawCounts) == 0 || policy.ChargeCurrency || policy.DuplicateReward != "configured_first_eidolon_cost" {
return nil, policy, fmt.Errorf("unsupported gacha sandbox policy")
}
for _, n := range policy.DrawCounts {
if n == 0 {
return nil, policy, fmt.Errorf("invalid gacha draw count")
}
}
// The actual captured avatar-prefab basename identifies the pictured avatar.
// No banner-ID arithmetic or synthetic prefab paths are used.
avatarPrefab := regexp.MustCompile(`(?:^|/)AvatarGacha_([0-9]+)\.prefab$`)
verified := map[string]bool{}
for _, prefab := range policy.VerifiedBannerPrefabs {
verified[prefab] = true
}
var banners []FeaturedGachaBanner
seen := map[uint32]bool{}
for _, row := range catalog.Tables.Gacha {
match := avatarPrefab.FindStringSubmatch(row.Prefab)
if match == nil && featuredOnly {
continue
}
if !verified[row.Prefab] {
continue
}
var avatar uint64
if match != nil {
avatar, err = strconv.ParseUint(match[1], 10, 32)
if err != nil || avatar == 0 {
return nil, policy, fmt.Errorf("invalid captured character banner")
}
}
if row.ID == 0 || seen[row.ID] {
return nil, policy, fmt.Errorf("invalid captured character banner")
}
banners = append(banners, FeaturedGachaBanner{ID: row.ID, AvatarID: uint32(avatar), SortID: row.SortID, Prefab: row.Prefab})
seen[row.ID] = true
}
// An empty verified set intentionally disables entry instead of advertising
// missing prefabs and leaving the Client's UI transition incomplete.
sort.Slice(banners, func(i, j int) bool { return banners[i].SortID < banners[j].SortID })
return banners, policy, nil
}
// SandboxDrawPool reports whether the policy enables sandbox draws over the
// imported roster, and returns the drawable roster IDs (sorted). IDs are read
// from the live import, never embedded: every draw resolves to a duplicate
// grant on an avatar that actually exists in freesr-data.json.
func LoadSandboxDrawPool() (bool, []uint32, GachaSandboxPolicy, error) {
_, policy, err := LoadBrowsableGachaBanners()
if err != nil {
return false, nil, policy, err
}
pool := policy.SandboxDrawPool
if pool.Source != "imported_roster" || pool.FirstAcquisition != "reject" {
return false, nil, policy, nil
}
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return false, nil, policy, err
}
defer unlock()
raw, err := readResourceFile("freesr-data.json")
if err != nil {
return false, nil, policy, err
}
var root struct {
Avatars map[uint32]json.RawMessage `json:"avatars"`
}
if err = json.Unmarshal(raw, &root); err != nil {
return false, nil, policy, fmt.Errorf("invalid FreeSR import: %w", err)
}
ids := make([]uint32, 0, len(root.Avatars))
for id := range root.Avatars {
if id != 0 {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return len(ids) > 0, ids, policy, nil
}
// MappedGachaBanner is one entry of the custom per-character banner mapping
// (sandbox 900000+ namespace, generated from the imported roster).
type MappedGachaBanner struct {
GachaID uint32 `json:"gacha_id"`
AvatarID uint32 `json:"avatar_id"`
// nil accepts the original generated format. Explicit false reserves an
// old ID without offering a banner for an avatar absent from the import.
RosterAvailable *bool `json:"roster_available,omitempty"`
}
// LoadGachaBannerMapping returns the generated per-character banner mapping.
// The mapping alone does not render anything in the Client: the custom
// presentation consumes it through DoGacha once its UI lands.
func LoadGachaBannerMapping() ([]MappedGachaBanner, error) {
raw, err := ReadResourceFile("resources/GachaBannerMapping.json")
if err != nil {
return nil, err
}
var doc struct {
Format uint32 `json:"format_version"`
Source string `json:"source"`
ClientAssembly string `json:"client_assembly_sha256"`
Banners []MappedGachaBanner `json:"banners"`
}
if err = json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
if doc.Format != 1 || doc.Source == "" || !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(doc.ClientAssembly) {
return nil, fmt.Errorf("gacha banner mapping provenance missing")
}
var captured struct {
Assembly string `json:"client_assembly_sha256"`
Tables struct {
Gacha []struct {
ID uint32 `json:"GachaID"`
} `json:"gacha"`
} `json:"tables"`
}
raw, err = ReadResourceFile("resources/PreviewGameplayCatalog.json")
if err != nil {
return nil, err
}
if err = json.Unmarshal(raw, &captured); err != nil {
return nil, err
}
if captured.Assembly != doc.ClientAssembly {
return nil, fmt.Errorf("gacha banner mapping/captured catalog identity mismatch")
}
capturedIDs := map[uint32]bool{}
for _, row := range captured.Tables.Gacha {
capturedIDs[row.ID] = true
}
seen := map[uint32]bool{}
seenAvatars := map[uint32]bool{}
active := make([]MappedGachaBanner, 0, len(doc.Banners))
for _, b := range doc.Banners {
if b.GachaID < 900001 || b.AvatarID == 0 || seen[b.GachaID] || seenAvatars[b.AvatarID] || capturedIDs[b.GachaID] {
return nil, fmt.Errorf("invalid gacha banner mapping entry %d", b.GachaID)
}
seen[b.GachaID] = true
seenAvatars[b.AvatarID] = true
if b.RosterAvailable == nil || *b.RosterAvailable {
active = append(active, b)
}
}
return active, nil
}
// Add only duplicate materials to the latest import. This never replaces the
// avatar, its rank/EX/Traces, the lineup, or unrelated inventory entries.
func GrantGachaDuplicate(avatarID uint32, additions, generatedBaseline map[uint32]uint32) (map[uint32]uint32, error) {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return nil, err
}
defer unlock()
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")
}
var avatars map[uint32]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
return nil, err
}
if len(avatars[avatarID]) == 0 {
return nil, fmt.Errorf("banner avatar %d is absent from the imported roster", avatarID)
}
materials := map[uint32]uint32{}
if value, exists := root["materials"]; exists {
if err = json.Unmarshal(value, &materials); err != nil {
return nil, err
}
if materials == nil {
materials = map[uint32]uint32{}
}
}
updated := map[uint32]uint32{}
for id, count := range additions {
current, explicit := materials[id]
if !explicit {
current = generatedBaseline[id]
}
next := uint64(current) + uint64(count)
if id == 0 || next > uint64(^uint32(0)) {
return nil, fmt.Errorf("gacha material count overflow")
}
materials[id] = uint32(next)
updated[id] = uint32(next)
}
root["materials"], err = json.Marshal(materials)
if err != nil {
return nil, err
}
return updated, writeJSONAtomic("freesr-data.json", root)
}