diff --git a/.gitignore b/.gitignore index 8296a3a..adef644 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ server/claim-account server/octo-cdn server/dev server/wizard +server/wizard-restore server/.wizard.json __pycache__/ diff --git a/README.md b/README.md index 2ed0934..48b5674 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ mkdir -p db goose -dir migrations -allow-missing sqlite3 db/game.db up ``` +### Backups & Restore + +The wizard backs up your save every time you launch it. To roll back to an earlier save: + +```bash +cd server +make restore +``` + +Pick a backup from the list and confirm. + ### Importing a Snapshot To import a JSON snapshot into the database, use the import tool. The `--uuid` flag must match the UUID your game client sends during authentication: @@ -276,6 +287,7 @@ All targets run from the `server/` directory. | `make clean` | Remove the `bin/` directory | | `make dev` | Run all three services with one command | | `make migrate` | Run goose migrations on `db/game.db` | +| `make restore` | Interactive restore of `db/game.db` from `db/backups/` | | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | ## Claim Account diff --git a/server/Makefile b/server/Makefile index 309ed53..1ab3e7f 100644 --- a/server/Makefile +++ b/server/Makefile @@ -51,6 +51,9 @@ dev: migrate: $(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up +restore: + go run ./cmd/wizard-restore + import: ifndef SNAPSHOT $(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) @@ -60,4 +63,4 @@ ifndef UUID endif go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID) -.PHONY: proto build build-cdn build-auth build-import build-claim-account build-register-account build-dev build-all clean dev migrate import +.PHONY: proto build build-cdn build-auth build-import build-claim-account build-register-account build-dev build-all clean dev migrate restore import diff --git a/server/cmd/wizard-restore/main.go b/server/cmd/wizard-restore/main.go new file mode 100644 index 0000000..ed383ff --- /dev/null +++ b/server/cmd/wizard-restore/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +const ( + gameDBPath = "db/game.db" + backupDir = "db/backups" + backupSuffix = ".bak" +) + +const banner = ` + _ _____ + | | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _ + | |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_| + |____\_,_|_||_\__,_|_| |_|\___|\__,_|_| + + ╭──────────────────────────────╮ + │ RESTORE │ + ╰──────────────────────────────╯ +` + +func main() { + lipgloss.EnableLegacyWindowsANSI(os.Stdout) + lipgloss.EnableLegacyWindowsANSI(os.Stderr) + fmt.Print(banner) + + chosen, ok := pickBackup() + if !ok { + return + } + if !confirmOverwrite(chosen) { + fmt.Println(" cancelled — nothing changed") + return + } + if err := doRestore(chosen); err != nil { + fmt.Fprintf(os.Stderr, " restore failed: %v\n", err) + os.Exit(1) + } + fmt.Printf(" restored %s from %s\n", gameDBPath, chosen) +} + +func pickBackup() (string, bool) { + entries, err := os.ReadDir(backupDir) + if err != nil { + fmt.Fprintln(os.Stderr, " no backups found in", backupDir) + return "", false + } + var backups []string + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) { + backups = append(backups, e.Name()) + } + } + if len(backups) == 0 { + fmt.Fprintln(os.Stderr, " no backups found in", backupDir) + return "", false + } + sort.Slice(backups, func(i, j int) bool { return backups[i] > backups[j] }) + + options := make([]huh.Option[string], 0, len(backups)+1) + for _, name := range backups { + options = append(options, huh.NewOption(name, name)) + } + options = append(options, huh.NewOption("Cancel", "")) + + var chosen string + if err := huh.NewSelect[string](). + Title("Pick a backup to restore"). + Description("db/game.db will be replaced by the chosen file."). + Options(options...). + Value(&chosen). + Run(); err != nil || chosen == "" { + return "", false + } + return chosen, true +} + +func confirmOverwrite(chosen string) bool { + confirm := false + if err := huh.NewConfirm(). + Title("Overwrite db/game.db?"). + Description(fmt.Sprintf( + "This will REPLACE db/game.db with %s.\n"+ + "Any progress since that backup will be lost.\n"+ + "(A fresh backup will be taken on the next ./wizard launch.)", + chosen)). + Affirmative("Yes, restore"). + Negative("Cancel"). + Value(&confirm). + Run(); err != nil { + return false + } + return confirm +} + +func doRestore(chosen string) error { + src := filepath.Join(backupDir, chosen) + if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s no longer exists", src) + } + if err := copyFile(src, gameDBPath); err != nil { + return err + } + _ = os.Remove(gameDBPath + "-wal") + _ = os.Remove(gameDBPath + "-shm") + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} diff --git a/server/cmd/wizard/backup.go b/server/cmd/wizard/backup.go new file mode 100644 index 0000000..cecc6cf --- /dev/null +++ b/server/cmd/wizard/backup.go @@ -0,0 +1,75 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "charm.land/huh/v2/spinner" + + _ "modernc.org/sqlite" +) + +const ( + gameDBPath = "db/game.db" + backupDir = "db/backups" + backupSuffix = ".bak" + backupRetainN = 10 +) + +func backupGameDB() { + if _, err := os.Stat(gameDBPath); errors.Is(err, os.ErrNotExist) { + return + } + + _ = spinner.New().Title(" Backing up db/game.db...").Action(func() { + if err := os.MkdirAll(backupDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err) + return + } + + ts := time.Now().UTC().Format("20060102T150405Z") + dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix)) + + db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)") + if err != nil { + fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err) + return + } + defer db.Close() + + escaped := strings.ReplaceAll(dest, "'", "''") + if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil { + fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err) + _ = os.Remove(dest) + return + } + + pruneOldBackups() + }).Run() +} + +func pruneOldBackups() { + entries, err := os.ReadDir(backupDir) + if err != nil { + return + } + var backups []os.DirEntry + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) { + backups = append(backups, e) + } + } + if len(backups) <= backupRetainN { + return + } + sort.Slice(backups, func(i, j int) bool { return backups[i].Name() < backups[j].Name() }) + for _, old := range backups[:len(backups)-backupRetainN] { + _ = os.Remove(filepath.Join(backupDir, old.Name())) + } +} diff --git a/server/cmd/wizard/main.go b/server/cmd/wizard/main.go index f21b37b..9aeebb2 100644 --- a/server/cmd/wizard/main.go +++ b/server/cmd/wizard/main.go @@ -79,6 +79,7 @@ func main() { validateTools() validateProtocIncludes() runProtoc() + backupGameDB() runMigrate() downloadDeps() } diff --git a/server/internal/masterdata/gacha.go b/server/internal/masterdata/gacha.go index 0648fef..af16a08 100644 --- a/server/internal/masterdata/gacha.go +++ b/server/internal/masterdata/gacha.go @@ -124,9 +124,9 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er }) } - for groupId, steps := range stepupSteps { + for _, steps := range stepupSteps { first := steps[0] - gachaId := groupId + gachaId := first.DestinationDomainId medal := gachaToMedal[first.DestinationDomainId] medalId := medal.GachaMedalId @@ -154,7 +154,7 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er GachaDecorationType: model.GachaDecorationFestival, SortOrder: first.SortOrderDesc, BannerAssetName: first.BannerAssetName, - GroupId: groupId, + GroupId: gachaId, CeilingCount: model.PityCeilingCount, PricePhases: pricePhases, MaxStepNumber: maxStep, diff --git a/server/internal/masterdata/gacha_pool.go b/server/internal/masterdata/gacha_pool.go index 0f4d5bd..411bcdb 100644 --- a/server/internal/masterdata/gacha_pool.go +++ b/server/internal/masterdata/gacha_pool.go @@ -34,16 +34,31 @@ type ShopFeaturedEntry struct { WeaponId int32 } +type CatalogTerm struct { + TermId int32 + StartDatetime int64 + Costumes []GachaPoolItem + Weapons []GachaPoolItem +} + +// StandardPoolTermId is the catalog term whose items form the cross-banner +// standard pool (term 1 holds the launch starter set). +const StandardPoolTermId int32 = 1 + type GachaCatalog struct { - CostumesByRarity map[int32][]GachaPoolItem - WeaponsByRarity map[int32][]GachaPoolItem - Materials []GachaPoolItem - CostumeById map[int32]GachaPoolItem - WeaponById map[int32]GachaPoolItem - CostumeWeaponMap map[int32]int32 // costumeId -> paired weaponId - FeaturedByGacha map[int32]FeaturedSet - BannerPools map[int32]*BannerPool - ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries + CostumesByRarity map[int32][]GachaPoolItem + WeaponsByRarity map[int32][]GachaPoolItem + StandardCostumesByRarity map[int32][]GachaPoolItem + StandardWeaponsByRarity map[int32][]GachaPoolItem + Materials []GachaPoolItem + CostumeById map[int32]GachaPoolItem + WeaponById map[int32]GachaPoolItem + CostumeWeaponMap map[int32]int32 // costumeId -> paired weaponId + FeaturedByGacha map[int32]FeaturedSet + BannerPools map[int32]*BannerPool + ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries + TermById map[int32]*CatalogTerm + TermsByStartDatetime map[int64][]*CatalogTerm } func LoadGachaPool() (*GachaCatalog, error) { @@ -73,6 +88,43 @@ func LoadGachaPool() (*GachaCatalog, error) { } evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows) + terms, err := utils.ReadTable[EntityMCatalogTerm]("m_catalog_term") + if err != nil { + return nil, fmt.Errorf("load catalog term table: %w", err) + } + firstClearRewards, err := utils.ReadTable[EntityMQuestFirstClearRewardGroup]("m_quest_first_clear_reward_group") + if err != nil { + return nil, fmt.Errorf("load quest first clear reward group table: %w", err) + } + sceneGrants, err := utils.ReadTable[EntityMUserQuestSceneGrantPossession]("m_user_quest_scene_grant_possession") + if err != nil { + return nil, fmt.Errorf("load user quest scene grant possession table: %w", err) + } + missionRewardRows, err := utils.ReadTable[EntityMMissionReward]("m_mission_reward") + if err != nil { + return nil, fmt.Errorf("load mission reward table: %w", err) + } + + questGrantedCostumes := make(map[int32]bool) + questGrantedWeapons := make(map[int32]bool) + collectGrant := func(possType, possId int32) { + switch possType { + case int32(model.PossessionTypeCostume): + questGrantedCostumes[possId] = true + case int32(model.PossessionTypeWeapon): + questGrantedWeapons[possId] = true + } + } + for _, r := range firstClearRewards { + collectGrant(r.PossessionType, r.PossessionId) + } + for _, r := range sceneGrants { + collectGrant(r.PossessionType, r.PossessionId) + } + for _, r := range missionRewardRows { + collectGrant(r.PossessionType, r.PossessionId) + } + catalogCostumeSet := make(map[int32]bool, len(catalogCostumes)) costumeTermId := make(map[int32]int32, len(catalogCostumes)) for _, c := range catalogCostumes { @@ -101,14 +153,22 @@ func LoadGachaPool() (*GachaCatalog, error) { } pool := &GachaCatalog{ - CostumesByRarity: make(map[int32][]GachaPoolItem), - WeaponsByRarity: make(map[int32][]GachaPoolItem), - CostumeById: make(map[int32]GachaPoolItem), - WeaponById: make(map[int32]GachaPoolItem), - CostumeWeaponMap: make(map[int32]int32), - FeaturedByGacha: make(map[int32]FeaturedSet), + CostumesByRarity: make(map[int32][]GachaPoolItem), + WeaponsByRarity: make(map[int32][]GachaPoolItem), + CostumeById: make(map[int32]GachaPoolItem), + WeaponById: make(map[int32]GachaPoolItem), + CostumeWeaponMap: make(map[int32]int32), + FeaturedByGacha: make(map[int32]FeaturedSet), + TermById: make(map[int32]*CatalogTerm), + TermsByStartDatetime: make(map[int64][]*CatalogTerm), + } + for _, t := range terms { + ct := &CatalogTerm{TermId: t.CatalogTermId, StartDatetime: t.StartDatetime} + pool.TermById[t.CatalogTermId] = ct + pool.TermsByStartDatetime[t.StartDatetime] = append(pool.TermsByStartDatetime[t.StartDatetime], ct) } + questGrantedCostumeCount := 0 for _, c := range costumes { if !catalogCostumeSet[c.CostumeId] { continue @@ -116,6 +176,10 @@ func LoadGachaPool() (*GachaCatalog, error) { if c.RarityType < model.RaritySRare { continue } + if questGrantedCostumes[c.CostumeId] { + questGrantedCostumeCount++ + continue + } item := GachaPoolItem{ PossessionType: int32(model.PossessionTypeCostume), PossessionId: c.CostumeId, @@ -127,11 +191,18 @@ func LoadGachaPool() (*GachaCatalog, error) { } restrictedCount := 0 + questGrantedWeaponCount := 0 + evolvedFilteredCount := 0 for _, w := range weapons { if !catalogWeaponSet[w.WeaponId] { continue } if evolvedWeapons[w.WeaponId] { + evolvedFilteredCount++ + continue + } + if questGrantedWeapons[w.WeaponId] { + questGrantedWeaponCount++ continue } item := GachaPoolItem{ @@ -147,7 +218,49 @@ func LoadGachaPool() (*GachaCatalog, error) { pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item) } - log.Printf("[GachaPool] excluded %d evolved weapons, %d restricted weapons from pool", len(evolvedWeapons), restrictedCount) + // Bucket catalog items into their terms (uses the post-filter CostumeById/WeaponById). + for _, cc := range catalogCostumes { + ct := pool.TermById[cc.CatalogTermId] + if ct == nil { + continue + } + if item, ok := pool.CostumeById[cc.CostumeId]; ok { + ct.Costumes = append(ct.Costumes, item) + } + } + for _, cw := range catalogWeapons { + ct := pool.TermById[cw.CatalogTermId] + if ct == nil || restrictedWeapons[cw.WeaponId] { + continue + } + if item, ok := pool.WeaponById[cw.WeaponId]; ok { + ct.Weapons = append(ct.Weapons, item) + } + } + + // Standard pool: items in term 1 (the launch starter set, same on every banner). + pool.StandardCostumesByRarity = make(map[int32][]GachaPoolItem) + pool.StandardWeaponsByRarity = make(map[int32][]GachaPoolItem) + if std := pool.TermById[StandardPoolTermId]; std != nil { + for _, c := range std.Costumes { + pool.StandardCostumesByRarity[c.RarityType] = append(pool.StandardCostumesByRarity[c.RarityType], c) + } + for _, w := range std.Weapons { + pool.StandardWeaponsByRarity[w.RarityType] = append(pool.StandardWeaponsByRarity[w.RarityType], w) + } + } + stdCos, stdWea := 0, 0 + for _, items := range pool.StandardCostumesByRarity { + stdCos += len(items) + } + for _, items := range pool.StandardWeaponsByRarity { + stdWea += len(items) + } + + log.Printf("[GachaPool] catalog terms: %d, standard pool: %d costumes + %d weapons (term %d)", + len(pool.TermById), stdCos, stdWea, StandardPoolTermId) + log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons", + evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount) type weaponKey struct { TermId int32 @@ -269,119 +382,138 @@ func (pool *GachaCatalog) PruneUnpairedCostumes() { log.Printf("[GachaPool] pruned %d unpaired costumes", pruned) } -func (pool *GachaCatalog) BuildFeaturedMapping(entries []store.GachaCatalogEntry) { +// BuildFeaturedFromTerms derives a featured set for each non-chapter banner by +// unioning items from catalog terms that started on the banner's StartDatetime +// (excluding term 1 — the standard pool). Falls back to medal-exchange shop +// contents for banners whose StartDatetime doesn't line up with a term. +func (pool *GachaCatalog) BuildFeaturedFromTerms(entries []store.GachaCatalogEntry) { matched := 0 + fromShop := 0 + gachaEligible := 0 for _, entry := range entries { - if entry.MedalConsumableItemId == 0 { - continue - } - shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId] - if !ok || len(shopEntries) == 0 { + if entry.GachaLabelType == model.GachaLabelChapter { continue } + gachaEligible++ - seenCostume := make(map[int32]bool) - linkedWeapons := make(map[int32]bool) - var costumes []GachaPoolItem - for _, se := range shopEntries { - if se.CostumeId != 0 && !seenCostume[se.CostumeId] { - costumes = append(costumes, pool.CostumeById[se.CostumeId]) - seenCostume[se.CostumeId] = true - linkedWeapons[se.WeaponId] = true - } - } + costumes, weapons := pool.unionTermFeatured(entry.StartDatetime) - seenWeapon := make(map[int32]bool) - var weapons []GachaPoolItem - for _, se := range shopEntries { - if se.WeaponId != 0 && !linkedWeapons[se.WeaponId] && !seenWeapon[se.WeaponId] { - if item, ok := pool.WeaponById[se.WeaponId]; ok { - weapons = append(weapons, item) - seenWeapon[se.WeaponId] = true + if len(costumes) == 0 && len(weapons) == 0 && entry.MedalConsumableItemId != 0 { + if shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]; ok { + costumes, weapons = pool.featuredFromShop(shopEntries) + if len(costumes) > 0 || len(weapons) > 0 { + fromShop++ } } } + if len(costumes) == 0 && len(weapons) == 0 { + continue + } + sort.Slice(costumes, func(i, j int) bool { return costumes[i].PossessionId < costumes[j].PossessionId }) + sort.Slice(weapons, func(i, j int) bool { return weapons[i].PossessionId < weapons[j].PossessionId }) pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons} matched++ } - log.Printf("[GachaPool] featured mapping: %d/%d banners matched via shop", matched, len(entries)) + log.Printf("[GachaPool] featured per banner: %d/%d (term-match + %d from shop-fallback)", + matched, gachaEligible, fromShop) +} + +func (pool *GachaCatalog) unionTermFeatured(startDatetime int64) (costumes, weapons []GachaPoolItem) { + coTerms := pool.TermsByStartDatetime[startDatetime] + if len(coTerms) == 0 { + return nil, nil + } + seenCostume := make(map[int32]bool) + seenWeapon := make(map[int32]bool) + for _, t := range coTerms { + if t.TermId == StandardPoolTermId { + continue + } + for _, c := range t.Costumes { + if c.RarityType < model.RaritySRare || seenCostume[c.PossessionId] { + continue + } + costumes = append(costumes, c) + seenCostume[c.PossessionId] = true + } + for _, w := range t.Weapons { + if w.RarityType < model.RaritySRare || seenWeapon[w.PossessionId] { + continue + } + weapons = append(weapons, w) + seenWeapon[w.PossessionId] = true + } + } + return costumes, weapons +} + +func (pool *GachaCatalog) featuredFromShop(shopEntries []ShopFeaturedEntry) (costumes, weapons []GachaPoolItem) { + seenCostume := make(map[int32]bool) + seenWeapon := make(map[int32]bool) + linkedWeapons := make(map[int32]bool) + for _, se := range shopEntries { + if se.CostumeId == 0 || seenCostume[se.CostumeId] { + continue + } + if item, ok := pool.CostumeById[se.CostumeId]; ok && item.RarityType >= model.RaritySRare { + costumes = append(costumes, item) + seenCostume[se.CostumeId] = true + linkedWeapons[se.WeaponId] = true + } + } + for _, se := range shopEntries { + if se.WeaponId == 0 || linkedWeapons[se.WeaponId] || seenWeapon[se.WeaponId] { + continue + } + if item, ok := pool.WeaponById[se.WeaponId]; ok && item.RarityType >= model.RaritySRare { + weapons = append(weapons, item) + seenWeapon[se.WeaponId] = true + } + } + return costumes, weapons } func (pool *GachaCatalog) BuildBannerPools(entries []store.GachaCatalogEntry) { - allFeaturedCostumes := make(map[int32]bool) - allFeaturedWeapons := make(map[int32]bool) - for _, fs := range pool.FeaturedByGacha { - for _, c := range fs.Costumes { - allFeaturedCostumes[c.PossessionId] = true - allFeaturedWeapons[pool.CostumeWeaponMap[c.PossessionId]] = true - } - for _, w := range fs.Weapons { - allFeaturedWeapons[w.PossessionId] = true - } - } - - commonCostumes := make(map[int32][]GachaPoolItem) - for rarity, items := range pool.CostumesByRarity { - for _, item := range items { - if !allFeaturedCostumes[item.PossessionId] { - commonCostumes[rarity] = append(commonCostumes[rarity], item) - } - } - } - commonWeapons := make(map[int32][]GachaPoolItem) - for rarity, items := range pool.WeaponsByRarity { - for _, item := range items { - if !allFeaturedWeapons[item.PossessionId] { - commonWeapons[rarity] = append(commonWeapons[rarity], item) - } - } - } - - commonPool := &BannerPool{ - CostumesByRarity: commonCostumes, - WeaponsByRarity: commonWeapons, - } - pool.BannerPools = make(map[int32]*BannerPool) for _, entry := range entries { fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId] - if !hasFeatured { - pool.BannerPools[entry.GachaId] = commonPool - continue - } + + bannerCostumes := cloneRarityMap(pool.StandardCostumesByRarity) + bannerWeapons := cloneRarityMap(pool.StandardWeaponsByRarity) var allFeatured []GachaPoolItem - bannerCostumes := make(map[int32][]GachaPoolItem) - for rarity, items := range commonCostumes { - bannerCostumes[rarity] = append(bannerCostumes[rarity], items...) + if hasFeatured { + for _, c := range fs.Costumes { + bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c) + allFeatured = append(allFeatured, c) + if wid, ok := pool.CostumeWeaponMap[c.PossessionId]; ok { + if w, ok := pool.WeaponById[wid]; ok { + bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) + allFeatured = append(allFeatured, w) + } + } + } + for _, w := range fs.Weapons { + bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) + allFeatured = append(allFeatured, w) + } } - bannerWeapons := make(map[int32][]GachaPoolItem) - for rarity, items := range commonWeapons { - bannerWeapons[rarity] = append(bannerWeapons[rarity], items...) - } - for _, c := range fs.Costumes { - bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c) - allFeatured = append(allFeatured, c) - wid := pool.CostumeWeaponMap[c.PossessionId] - w := pool.WeaponById[wid] - bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) - allFeatured = append(allFeatured, w) - } - for _, w := range fs.Weapons { - bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) - allFeatured = append(allFeatured, w) - } - pool.BannerPools[entry.GachaId] = &BannerPool{ CostumesByRarity: bannerCostumes, WeaponsByRarity: bannerWeapons, Featured: allFeatured, } } + log.Printf("[GachaPool] banner pools: %d banners built from standard pool + per-banner featured", len(pool.BannerPools)) +} - log.Printf("[GachaPool] banner pools: %d banners, %d featured costumes stripped, %d featured weapons stripped", - len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons)) +func cloneRarityMap(src map[int32][]GachaPoolItem) map[int32][]GachaPoolItem { + dst := make(map[int32][]GachaPoolItem, len(src)) + for k, v := range src { + dst[k] = append([]GachaPoolItem(nil), v...) + } + return dst } func buildEvolvedWeaponSet(rows []EntityMWeaponEvolutionGroup) map[int32]bool { diff --git a/server/internal/runtime/build.go b/server/internal/runtime/build.go index d19a239..8b3ead4 100644 --- a/server/internal/runtime/build.go +++ b/server/internal/runtime/build.go @@ -57,7 +57,7 @@ func buildCatalogs() (*Catalogs, error) { gachaPool.BuildShopFeatured(shopCatalog) gachaPool.PruneUnpairedCostumes() - gachaPool.BuildFeaturedMapping(gachaEntries) + gachaPool.BuildFeaturedFromTerms(gachaEntries) gachaPool.BuildBannerPools(gachaEntries) masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool) diff --git a/server/internal/service/gacha.go b/server/internal/service/gacha.go index cf38bc0..2d7aebe 100644 --- a/server/internal/service/gacha.go +++ b/server/internal/service/gacha.go @@ -166,7 +166,15 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb } var drawResult *gacha.DrawResult + ownedCostumes := map[int32]bool{} + ownedWeapons := map[int32]bool{} updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { + for _, c := range user.Costumes { + ownedCostumes[c.CostumeId] = true + } + for _, w := range user.Weapons { + ownedWeapons[w.WeaponId] = true + } var drawErr error drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount) if drawErr != nil { @@ -203,15 +211,6 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb weaponPT := int32(model.PossessionTypeWeapon) isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType) - ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) - for _, c := range updatedUser.Costumes { - ownedCostumes[c.CostumeId] = true - } - ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) - for _, w := range updatedUser.Weapons { - ownedWeapons[w.WeaponId] = true - } - for i, item := range drawResult.Items { isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser) @@ -352,7 +351,15 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR handler := s.holder.Get().GachaHandler var items []gacha.DrawnItem + ownedCostumes := map[int32]bool{} + ownedWeapons := map[int32]bool{} updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { + for _, c := range user.Costumes { + ownedCostumes[c.CostumeId] = true + } + for _, w := range user.Weapons { + ownedWeapons[w.WeaponId] = true + } var drawErr error items, drawErr = handler.HandleRewardDraw(user, 1) if drawErr != nil { @@ -363,15 +370,6 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR return nil, fmt.Errorf("update user: %w", err) } - ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) - for _, c := range updatedUser.Costumes { - ownedCostumes[c.CostumeId] = true - } - ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) - for _, w := range updatedUser.Weapons { - ownedWeapons[w.WeaponId] = true - } - results := make([]*pb.RewardGachaItem, 0, len(items)) for _, item := range items { results = append(results, &pb.RewardGachaItem{