mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Gacha pool overhaul and DB backup wizard
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -11,6 +11,7 @@ server/claim-account
|
|||||||
server/octo-cdn
|
server/octo-cdn
|
||||||
server/dev
|
server/dev
|
||||||
server/wizard
|
server/wizard
|
||||||
|
server/wizard-restore
|
||||||
server/.wizard.json
|
server/.wizard.json
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ mkdir -p db
|
|||||||
goose -dir migrations -allow-missing sqlite3 db/game.db up
|
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
|
### 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:
|
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 clean` | Remove the `bin/` directory |
|
||||||
| `make dev` | Run all three services with one command |
|
| `make dev` | Run all three services with one command |
|
||||||
| `make migrate` | Run goose migrations on `db/game.db` |
|
| `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) |
|
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
|
||||||
|
|
||||||
## Claim Account
|
## Claim Account
|
||||||
|
|||||||
+4
-1
@@ -51,6 +51,9 @@ dev:
|
|||||||
migrate:
|
migrate:
|
||||||
$(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up
|
$(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up
|
||||||
|
|
||||||
|
restore:
|
||||||
|
go run ./cmd/wizard-restore
|
||||||
|
|
||||||
import:
|
import:
|
||||||
ifndef SNAPSHOT
|
ifndef SNAPSHOT
|
||||||
$(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...)
|
$(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...)
|
||||||
@@ -60,4 +63,4 @@ ifndef UUID
|
|||||||
endif
|
endif
|
||||||
go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID)
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ func main() {
|
|||||||
validateTools()
|
validateTools()
|
||||||
validateProtocIncludes()
|
validateProtocIncludes()
|
||||||
runProtoc()
|
runProtoc()
|
||||||
|
backupGameDB()
|
||||||
runMigrate()
|
runMigrate()
|
||||||
downloadDeps()
|
downloadDeps()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for groupId, steps := range stepupSteps {
|
for _, steps := range stepupSteps {
|
||||||
first := steps[0]
|
first := steps[0]
|
||||||
gachaId := groupId
|
gachaId := first.DestinationDomainId
|
||||||
|
|
||||||
medal := gachaToMedal[first.DestinationDomainId]
|
medal := gachaToMedal[first.DestinationDomainId]
|
||||||
medalId := medal.GachaMedalId
|
medalId := medal.GachaMedalId
|
||||||
@@ -154,7 +154,7 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
|
|||||||
GachaDecorationType: model.GachaDecorationFestival,
|
GachaDecorationType: model.GachaDecorationFestival,
|
||||||
SortOrder: first.SortOrderDesc,
|
SortOrder: first.SortOrderDesc,
|
||||||
BannerAssetName: first.BannerAssetName,
|
BannerAssetName: first.BannerAssetName,
|
||||||
GroupId: groupId,
|
GroupId: gachaId,
|
||||||
CeilingCount: model.PityCeilingCount,
|
CeilingCount: model.PityCeilingCount,
|
||||||
PricePhases: pricePhases,
|
PricePhases: pricePhases,
|
||||||
MaxStepNumber: maxStep,
|
MaxStepNumber: maxStep,
|
||||||
|
|||||||
@@ -34,9 +34,22 @@ type ShopFeaturedEntry struct {
|
|||||||
WeaponId int32
|
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 {
|
type GachaCatalog struct {
|
||||||
CostumesByRarity map[int32][]GachaPoolItem
|
CostumesByRarity map[int32][]GachaPoolItem
|
||||||
WeaponsByRarity map[int32][]GachaPoolItem
|
WeaponsByRarity map[int32][]GachaPoolItem
|
||||||
|
StandardCostumesByRarity map[int32][]GachaPoolItem
|
||||||
|
StandardWeaponsByRarity map[int32][]GachaPoolItem
|
||||||
Materials []GachaPoolItem
|
Materials []GachaPoolItem
|
||||||
CostumeById map[int32]GachaPoolItem
|
CostumeById map[int32]GachaPoolItem
|
||||||
WeaponById map[int32]GachaPoolItem
|
WeaponById map[int32]GachaPoolItem
|
||||||
@@ -44,6 +57,8 @@ type GachaCatalog struct {
|
|||||||
FeaturedByGacha map[int32]FeaturedSet
|
FeaturedByGacha map[int32]FeaturedSet
|
||||||
BannerPools map[int32]*BannerPool
|
BannerPools map[int32]*BannerPool
|
||||||
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
|
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
|
||||||
|
TermById map[int32]*CatalogTerm
|
||||||
|
TermsByStartDatetime map[int64][]*CatalogTerm
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadGachaPool() (*GachaCatalog, error) {
|
func LoadGachaPool() (*GachaCatalog, error) {
|
||||||
@@ -73,6 +88,43 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
|||||||
}
|
}
|
||||||
evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows)
|
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))
|
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
|
||||||
costumeTermId := make(map[int32]int32, len(catalogCostumes))
|
costumeTermId := make(map[int32]int32, len(catalogCostumes))
|
||||||
for _, c := range catalogCostumes {
|
for _, c := range catalogCostumes {
|
||||||
@@ -107,8 +159,16 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
|||||||
WeaponById: make(map[int32]GachaPoolItem),
|
WeaponById: make(map[int32]GachaPoolItem),
|
||||||
CostumeWeaponMap: make(map[int32]int32),
|
CostumeWeaponMap: make(map[int32]int32),
|
||||||
FeaturedByGacha: make(map[int32]FeaturedSet),
|
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 {
|
for _, c := range costumes {
|
||||||
if !catalogCostumeSet[c.CostumeId] {
|
if !catalogCostumeSet[c.CostumeId] {
|
||||||
continue
|
continue
|
||||||
@@ -116,6 +176,10 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
|||||||
if c.RarityType < model.RaritySRare {
|
if c.RarityType < model.RaritySRare {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if questGrantedCostumes[c.CostumeId] {
|
||||||
|
questGrantedCostumeCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
item := GachaPoolItem{
|
item := GachaPoolItem{
|
||||||
PossessionType: int32(model.PossessionTypeCostume),
|
PossessionType: int32(model.PossessionTypeCostume),
|
||||||
PossessionId: c.CostumeId,
|
PossessionId: c.CostumeId,
|
||||||
@@ -127,11 +191,18 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restrictedCount := 0
|
restrictedCount := 0
|
||||||
|
questGrantedWeaponCount := 0
|
||||||
|
evolvedFilteredCount := 0
|
||||||
for _, w := range weapons {
|
for _, w := range weapons {
|
||||||
if !catalogWeaponSet[w.WeaponId] {
|
if !catalogWeaponSet[w.WeaponId] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if evolvedWeapons[w.WeaponId] {
|
if evolvedWeapons[w.WeaponId] {
|
||||||
|
evolvedFilteredCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if questGrantedWeapons[w.WeaponId] {
|
||||||
|
questGrantedWeaponCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := GachaPoolItem{
|
item := GachaPoolItem{
|
||||||
@@ -147,7 +218,49 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
|||||||
pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item)
|
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 {
|
type weaponKey struct {
|
||||||
TermId int32
|
TermId int32
|
||||||
@@ -269,119 +382,138 @@ func (pool *GachaCatalog) PruneUnpairedCostumes() {
|
|||||||
log.Printf("[GachaPool] pruned %d unpaired costumes", pruned)
|
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
|
matched := 0
|
||||||
|
fromShop := 0
|
||||||
|
gachaEligible := 0
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.MedalConsumableItemId == 0 {
|
if entry.GachaLabelType == model.GachaLabelChapter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]
|
gachaEligible++
|
||||||
if !ok || len(shopEntries) == 0 {
|
|
||||||
|
costumes, weapons := pool.unionTermFeatured(entry.StartDatetime)
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
sort.Slice(costumes, func(i, j int) bool { return costumes[i].PossessionId < costumes[j].PossessionId })
|
||||||
seenCostume := make(map[int32]bool)
|
sort.Slice(weapons, func(i, j int) bool { return weapons[i].PossessionId < weapons[j].PossessionId })
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
|
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
|
||||||
matched++
|
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) {
|
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)
|
pool.BannerPools = make(map[int32]*BannerPool)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
|
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
|
||||||
if !hasFeatured {
|
|
||||||
pool.BannerPools[entry.GachaId] = commonPool
|
bannerCostumes := cloneRarityMap(pool.StandardCostumesByRarity)
|
||||||
continue
|
bannerWeapons := cloneRarityMap(pool.StandardWeaponsByRarity)
|
||||||
}
|
|
||||||
|
|
||||||
var allFeatured []GachaPoolItem
|
var allFeatured []GachaPoolItem
|
||||||
bannerCostumes := make(map[int32][]GachaPoolItem)
|
if hasFeatured {
|
||||||
for rarity, items := range commonCostumes {
|
|
||||||
bannerCostumes[rarity] = append(bannerCostumes[rarity], items...)
|
|
||||||
}
|
|
||||||
bannerWeapons := make(map[int32][]GachaPoolItem)
|
|
||||||
for rarity, items := range commonWeapons {
|
|
||||||
bannerWeapons[rarity] = append(bannerWeapons[rarity], items...)
|
|
||||||
}
|
|
||||||
for _, c := range fs.Costumes {
|
for _, c := range fs.Costumes {
|
||||||
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
|
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
|
||||||
allFeatured = append(allFeatured, c)
|
allFeatured = append(allFeatured, c)
|
||||||
wid := pool.CostumeWeaponMap[c.PossessionId]
|
if wid, ok := pool.CostumeWeaponMap[c.PossessionId]; ok {
|
||||||
w := pool.WeaponById[wid]
|
if w, ok := pool.WeaponById[wid]; ok {
|
||||||
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
||||||
allFeatured = append(allFeatured, w)
|
allFeatured = append(allFeatured, w)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, w := range fs.Weapons {
|
for _, w := range fs.Weapons {
|
||||||
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
||||||
allFeatured = append(allFeatured, w)
|
allFeatured = append(allFeatured, w)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pool.BannerPools[entry.GachaId] = &BannerPool{
|
pool.BannerPools[entry.GachaId] = &BannerPool{
|
||||||
CostumesByRarity: bannerCostumes,
|
CostumesByRarity: bannerCostumes,
|
||||||
WeaponsByRarity: bannerWeapons,
|
WeaponsByRarity: bannerWeapons,
|
||||||
Featured: allFeatured,
|
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",
|
func cloneRarityMap(src map[int32][]GachaPoolItem) map[int32][]GachaPoolItem {
|
||||||
len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons))
|
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 {
|
func buildEvolvedWeaponSet(rows []EntityMWeaponEvolutionGroup) map[int32]bool {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func buildCatalogs() (*Catalogs, error) {
|
|||||||
|
|
||||||
gachaPool.BuildShopFeatured(shopCatalog)
|
gachaPool.BuildShopFeatured(shopCatalog)
|
||||||
gachaPool.PruneUnpairedCostumes()
|
gachaPool.PruneUnpairedCostumes()
|
||||||
gachaPool.BuildFeaturedMapping(gachaEntries)
|
gachaPool.BuildFeaturedFromTerms(gachaEntries)
|
||||||
gachaPool.BuildBannerPools(gachaEntries)
|
gachaPool.BuildBannerPools(gachaEntries)
|
||||||
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
|
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
|
|||||||
}
|
}
|
||||||
|
|
||||||
var drawResult *gacha.DrawResult
|
var drawResult *gacha.DrawResult
|
||||||
|
ownedCostumes := map[int32]bool{}
|
||||||
|
ownedWeapons := map[int32]bool{}
|
||||||
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
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
|
var drawErr error
|
||||||
drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
|
drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
|
||||||
if drawErr != nil {
|
if drawErr != nil {
|
||||||
@@ -203,15 +211,6 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
|
|||||||
weaponPT := int32(model.PossessionTypeWeapon)
|
weaponPT := int32(model.PossessionTypeWeapon)
|
||||||
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
|
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 {
|
for i, item := range drawResult.Items {
|
||||||
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
|
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
|
handler := s.holder.Get().GachaHandler
|
||||||
|
|
||||||
var items []gacha.DrawnItem
|
var items []gacha.DrawnItem
|
||||||
|
ownedCostumes := map[int32]bool{}
|
||||||
|
ownedWeapons := map[int32]bool{}
|
||||||
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
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
|
var drawErr error
|
||||||
items, drawErr = handler.HandleRewardDraw(user, 1)
|
items, drawErr = handler.HandleRewardDraw(user, 1)
|
||||||
if drawErr != nil {
|
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)
|
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))
|
results := make([]*pb.RewardGachaItem, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
results = append(results, &pb.RewardGachaItem{
|
results = append(results, &pb.RewardGachaItem{
|
||||||
|
|||||||
Reference in New Issue
Block a user