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) }