285 lines
9.7 KiB
Go
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)
|
|
}
|