mirror of Walter-Sparrow / lunar-tear
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add SQLite persistence, import-snapshot tool, and karma functionality

+4148 -830
+2 -4
.gitignore
··· 5 5 server/bin/ 6 6 server/tmp/ 7 7 server/lunar-tear 8 + server/import-snapshot 8 9 9 10 __pycache__/ 10 11 ··· 13 14 14 15 # Go 15 16 server/vendor/ 16 - 17 - # Certs (regenerate per-environment) 18 17 server/certs/ 19 - 20 - # Server assets (binary data, too large for git) 21 18 server/assets/ 19 + db/ 22 20 23 21 # Snapshots (recorded user state) 24 22 snapshots/
+77 -9
README.md
··· 7 7 8 8 ### Prerequisites 9 9 10 - - Go 1.24+ 10 + - Go 1.25+ 11 + - [goose](https://github.com/pressly/goose) migration tool 11 12 - Populated `server/assets/` directory 12 13 14 + ```bash 15 + go install github.com/pressly/goose/v3/cmd/goose@latest 16 + ``` 17 + 13 18 ### Regenerate protobuf stubs 14 19 15 20 ```bash ··· 17 22 make proto 18 23 ``` 19 24 25 + ### Database 26 + 27 + Player state is stored in a SQLite database. Run migrations before starting the server: 28 + 29 + ```bash 30 + cd server 31 + make migrate 32 + ``` 33 + 34 + Or manually: 35 + 36 + ```bash 37 + cd server 38 + mkdir -p db 39 + goose -dir migrations sqlite3 db/game.db up 40 + ``` 41 + 42 + ### Importing a Snapshot 43 + 44 + 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: 45 + 46 + ```bash 47 + cd server 48 + make import SNAPSHOT=snapshots/scene_1.json UUID=<your-client-uuid> 49 + ``` 50 + 51 + Or directly: 52 + 53 + ```bash 54 + go run ./cmd/import-snapshot \ 55 + --snapshot snapshots/scene_1.json \ 56 + --uuid <your-client-uuid> \ 57 + --db db/game.db 58 + ``` 59 + 60 + | Flag | Default | Description | 61 + | ------------ | ------------ | --------------------------------------------- | 62 + | `--snapshot` | *(required)* | Path to JSON snapshot file | 63 + | `--uuid` | *(required)* | UUID to assign (must match the client's UUID) | 64 + | `--db` | `db/game.db` | SQLite database path | 65 + 20 66 ### Run 21 67 22 68 ```bash 23 69 cd server 24 70 sudo go run ./cmd/lunar-tear \ 25 71 --host 10.0.2.2 \ 26 - --http-port 8080 \ 27 - --scene 13 72 + --http-port 8080 28 73 ``` 29 74 30 75 `sudo` is needed because gRPC binds to port 443 (privileged). On Linux you can use `setcap` instead: ··· 32 77 ```bash 33 78 go build -o lunar-tear ./cmd/lunar-tear 34 79 sudo setcap cap_net_bind_service=+ep ./lunar-tear 35 - ./lunar-tear --host 10.0.2.2 --http-port 8080 --scene 13 80 + ./lunar-tear --host 10.0.2.2 --http-port 8080 36 81 ``` 37 82 38 83 ### Ports ··· 44 89 45 90 ### Flags 46 91 47 - | Flag | Default | Description | 48 - | ---------------------- | ------------------- | -------------------------------------------------------- | 49 - | `--host` | `127.0.0.1` | hostname/IP given to the client | 50 - | `--http-port` | `8080` | HTTP/Octo server port | 51 - | `--scene` | `0` | bootstrap new users to scene N (0 = fresh start) | 92 + | Flag | Default | Description | 93 + | ------------- | ------------ | ------------------------------- | 94 + | `--host` | `127.0.0.1` | hostname/IP given to the client | 95 + | `--http-port` | `8080` | HTTP/Octo server port | 96 + | `--db` | `db/game.db` | SQLite database path | 97 + 98 + ### Docker 99 + 100 + Migrations run automatically on container start. 101 + 102 + ```bash 103 + cd server 104 + docker compose up -d 105 + ``` 106 + 107 + The `db/` directory is mounted as a volume so the database persists across restarts. Make sure `assets/` is populated before starting. 108 + 109 + ### Makefile Targets 110 + 111 + All targets run from the `server/` directory. 112 + 113 + | Target | Description | 114 + | -------------- | ------------------------------------------------------- | 115 + | `make proto` | Regenerate protobuf stubs | 116 + | `make build` | Build the server binary | 117 + | `make build-import` | Build the import-snapshot tool | 118 + | `make migrate` | Run goose migrations on `db/game.db` | 119 + | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | 52 120 53 121 ## ⚠️ Legal Disclaimer 54 122
+4 -1
server/Dockerfile
··· 12 12 libcap 13 13 14 14 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest &&\ 15 - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest &&\ 15 + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest &&\ 16 + go install github.com/pressly/goose/v3/cmd/goose@latest &&\ 16 17 PATH="$PATH:$(go env GOPATH)/bin" make proto &&\ 17 18 go build -o lunar-tear ./cmd/lunar-tear &&\ 18 19 setcap cap_net_bind_service=+ep ./lunar-tear ··· 26 27 USER 1000 27 28 28 29 COPY --from=builder /usr/local/src/lunar-tear . 30 + COPY --from=builder /root/go/bin/goose /usr/local/bin/goose 31 + COPY --from=builder /usr/local/src/migrations ./migrations 29 32 30 33 COPY entrypoint.sh . 31 34
+20 -1
server/Makefile
··· 7 7 protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server 8 8 @echo "Generated in gen/proto/" 9 9 10 - .PHONY: proto 10 + build: 11 + go build -o lunar-tear ./cmd/lunar-tear 12 + 13 + build-import: 14 + go build -o import-snapshot ./cmd/import-snapshot 15 + 16 + migrate: 17 + mkdir -p db 18 + goose -dir migrations sqlite3 db/game.db up 19 + 20 + import: 21 + ifndef SNAPSHOT 22 + $(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) 23 + endif 24 + ifndef UUID 25 + $(error UUID is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) 26 + endif 27 + go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID) 28 + 29 + .PHONY: proto build build-import migrate import
+56
server/cmd/import-snapshot/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "flag" 6 + "log" 7 + "os" 8 + 9 + "lunar-tear/server/internal/database" 10 + "lunar-tear/server/internal/store" 11 + "lunar-tear/server/internal/store/sqlite" 12 + ) 13 + 14 + func main() { 15 + dbPath := flag.String("db", "db/game.db", "SQLite database path") 16 + snapshotPath := flag.String("snapshot", "", "Path to JSON snapshot file (required)") 17 + userUuid := flag.String("uuid", "", "UUID to assign to the imported user (must match the client's UUID)") 18 + flag.Parse() 19 + 20 + if *snapshotPath == "" { 21 + log.Fatal("--snapshot flag is required") 22 + } 23 + if *userUuid == "" { 24 + log.Fatal("--uuid flag is required") 25 + } 26 + 27 + data, err := os.ReadFile(*snapshotPath) 28 + if err != nil { 29 + log.Fatalf("read snapshot: %v", err) 30 + } 31 + log.Printf("read %d bytes from %s", len(data), *snapshotPath) 32 + 33 + var u store.UserState 34 + if err := json.Unmarshal(data, &u); err != nil { 35 + log.Fatalf("unmarshal snapshot: %v", err) 36 + } 37 + u.EnsureMaps() 38 + u.Uuid = *userUuid 39 + 40 + log.Printf("parsed user %d (uuid=%s, costumes=%d, weapons=%d, characters=%d, quests=%d)", 41 + u.UserId, u.Uuid, len(u.Costumes), len(u.Weapons), len(u.Characters), len(u.Quests)) 42 + 43 + db, err := database.Open(*dbPath) 44 + if err != nil { 45 + log.Fatalf("open database: %v", err) 46 + } 47 + defer db.Close() 48 + 49 + userStore := sqlite.New(db, nil) 50 + 51 + if err := userStore.ImportUser(&u); err != nil { 52 + log.Fatalf("import user: %v", err) 53 + } 54 + 55 + log.Printf("imported user %d successfully", u.UserId) 56 + }
+14 -5
server/cmd/lunar-tear/grpc.go
··· 12 12 "lunar-tear/server/internal/masterdata" 13 13 "lunar-tear/server/internal/questflow" 14 14 "lunar-tear/server/internal/service" 15 - "lunar-tear/server/internal/store/memory" 15 + "lunar-tear/server/internal/store" 16 16 17 17 "google.golang.org/grpc" 18 18 "google.golang.org/grpc/codes" ··· 38 38 func startGRPC( 39 39 host string, 40 40 octoURL string, 41 - userStore *memory.MemoryStore, 41 + userStore interface { 42 + store.UserRepository 43 + store.SessionRepository 44 + }, 42 45 questEngine *questflow.QuestHandler, 43 46 gachaHandler *gacha.GachaHandler, 47 + gachaEntries []store.GachaCatalogEntry, 44 48 cageOrnamentCatalog *masterdata.CageOrnamentCatalog, 45 49 loginBonusCatalog *masterdata.LoginBonusCatalog, 46 50 characterViewerCatalog *masterdata.CharacterViewerCatalog, ··· 77 81 userStore, 78 82 questEngine, 79 83 gachaHandler, 84 + gachaEntries, 80 85 cageOrnamentCatalog, 81 86 loginBonusCatalog, 82 87 characterViewerCatalog, ··· 111 116 srv *grpc.Server, 112 117 host string, 113 118 octoURL string, 114 - userStore *memory.MemoryStore, 119 + userStore interface { 120 + store.UserRepository 121 + store.SessionRepository 122 + }, 115 123 questEngine *questflow.QuestHandler, 116 124 gachaHandler *gacha.GachaHandler, 125 + gachaEntries []store.GachaCatalogEntry, 117 126 cageOrnamentCatalog *masterdata.CageOrnamentCatalog, 118 127 loginBonusCatalog *masterdata.LoginBonusCatalog, 119 128 characterViewerCatalog *masterdata.CharacterViewerCatalog, ··· 133 142 sideStoryCatalog *masterdata.SideStoryCatalog, 134 143 bigHuntCatalog *masterdata.BigHuntCatalog, 135 144 ) { 136 - pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(userStore)) 145 + pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(gachaEntries)) 137 146 pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore)) 138 147 pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore)) 139 148 pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(host, int32(443), octoURL)) 140 149 pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore)) 141 150 pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine)) 142 - pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, userStore, gachaHandler)) 151 + pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, gachaEntries, gachaHandler)) 143 152 pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore)) 144 153 pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer()) 145 154 pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog))
+10 -25
server/cmd/lunar-tear/main.go
··· 3 3 import ( 4 4 "flag" 5 5 "log" 6 - "os" 7 6 "strconv" 8 7 "strings" 9 8 9 + "lunar-tear/server/internal/database" 10 10 "lunar-tear/server/internal/gacha" 11 11 "lunar-tear/server/internal/gametime" 12 12 "lunar-tear/server/internal/masterdata" 13 13 "lunar-tear/server/internal/questflow" 14 - "lunar-tear/server/internal/store/memory" 14 + "lunar-tear/server/internal/store/sqlite" 15 15 ) 16 16 17 17 func main() { 18 18 httpPort := flag.Int("http-port", 8080, "HTTP server port (Octo API)") 19 19 host := flag.String("host", "127.0.0.1", "hostname the client will connect to") 20 - scene := flag.Int("scene", 0, "Bootstrap to scene N (0 = fresh start)") 21 - latestScene := flag.Bool("latest-scene", false, "Bootstrap from the most recently saved snapshot (overrides -scene)") 22 - starterItems := flag.Bool("starter-items", false, "Grant starter items to new users") 20 + dbPath := flag.String("db", "db/game.db", "SQLite database path") 23 21 flag.Parse() 24 22 25 23 octoURL := "http://" + *host + ":" + strconv.Itoa(*httpPort) ··· 34 32 35 33 go startHTTP(*httpPort, resourcesBaseURL) 36 34 37 - snapshotDir := "snapshots" 38 - if err := os.MkdirAll(snapshotDir, 0755); err != nil { 39 - log.Fatalf("create snapshot dir: %v", err) 35 + db, err := database.Open(*dbPath) 36 + if err != nil { 37 + log.Fatalf("open database: %v", err) 40 38 } 41 - if *latestScene { 42 - if id, ok := memory.LatestSnapshotSceneId(snapshotDir); ok { 43 - *scene = int(id) 44 - log.Printf("[latest-scene] auto-selected most recent snapshot: scene=%d", id) 45 - } else { 46 - log.Printf("[latest-scene] no snapshots found in %q; starting fresh", snapshotDir) 47 - } 48 - } 39 + defer db.Close() 40 + log.Printf("database opened: %s", *dbPath) 49 41 50 42 gameConfig, err := masterdata.LoadGameConfig() 51 43 if err != nil { ··· 65 57 log.Fatalf("load quest catalog: %v", err) 66 58 } 67 59 questHandler := questflow.NewQuestHandler(questCatalog, gameConfig) 68 - userStore := memory.New(gametime.Now, 69 - memory.WithSnapshotDir(snapshotDir), 70 - memory.WithSceneId(int32(*scene)), 71 - memory.WithStarterItems(*starterItems), 72 - ) 73 - if *scene != 0 { 74 - log.Printf("bootstrap scene: %d (from snapshot)", *scene) 75 - } 60 + userStore := sqlite.New(db, gametime.Now) 76 61 77 62 gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog() 78 63 if err != nil { ··· 99 84 gachaPool.BuildFeaturedMapping(gachaEntries) 100 85 gachaPool.BuildBannerPools(gachaEntries) 101 86 masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool) 102 - userStore.ReplaceCatalog(gachaEntries) 103 87 104 88 dupExchange, err := masterdata.LoadDupExchange() 105 89 if err != nil { ··· 185 169 userStore, 186 170 questHandler, 187 171 gachaHandler, 172 + gachaEntries, 188 173 cageOrnamentCatalog, 189 174 loginBonusCatalog, 190 175 characterViewerCatalog,
+1 -1
server/docker-compose.yaml
··· 5 5 environment: 6 6 LUNAR_HOST: 127.0.0.1 7 7 LUNAR_HTTP_PORT: 8080 8 - LUNAR_SCENE: 0 9 8 volumes: 10 9 - ./assets:/opt/lunar-tear/assets 10 + - ./db:/opt/lunar-tear/db 11 11 ports: 12 12 - 443:443 # grpc, hardcoded by the client, not configurable 13 13 - 8080:8080
+4 -1
server/entrypoint.sh
··· 1 1 #!/usr/bin/env sh 2 + set -e 2 3 3 - ./lunar-tear --host "${LUNAR_HOST}" --http-port "${LUNAR_HTTP_PORT}" --scene "${LUNAR_SCENE}" 4 + mkdir -p db 5 + goose -dir migrations sqlite3 db/game.db up 4 6 7 + exec ./lunar-tear --host "${LUNAR_HOST}" --http-port "${LUNAR_HTTP_PORT}"
+22 -6
server/go.mod
··· 1 1 module lunar-tear/server 2 2 3 - go 1.24.2 3 + go 1.25.0 4 4 5 5 require ( 6 + github.com/google/uuid v1.6.0 7 + github.com/vmihailenco/msgpack/v5 v5.4.1 8 + golang.org/x/net v0.50.0 6 9 google.golang.org/grpc v1.79.1 7 10 google.golang.org/protobuf v1.36.11 8 11 ) 9 12 10 13 require ( 11 - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 14 + github.com/dustin/go-humanize v1.0.1 // indirect 15 + github.com/mattn/go-isatty v0.0.20 // indirect 16 + github.com/mfridman/interpolate v0.0.2 // indirect 17 + github.com/ncruces/go-strftime v1.0.0 // indirect 18 + github.com/pressly/goose/v3 v3.27.0 // indirect 19 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 20 + github.com/sethvargo/go-retry v0.3.0 // indirect 21 + github.com/stretchr/testify v1.11.1 // indirect 12 22 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 13 - golang.org/x/net v0.48.0 // indirect 14 - golang.org/x/sys v0.39.0 // indirect 15 - golang.org/x/text v0.32.0 // indirect 16 - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect 23 + go.opentelemetry.io/otel v1.40.0 // indirect 24 + go.uber.org/multierr v1.11.0 // indirect 25 + golang.org/x/sync v0.19.0 // indirect 26 + golang.org/x/sys v0.42.0 // indirect 27 + golang.org/x/text v0.34.0 // indirect 28 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect 29 + modernc.org/libc v1.70.0 // indirect 30 + modernc.org/mathutil v1.7.1 // indirect 31 + modernc.org/memory v1.11.0 // indirect 32 + modernc.org/sqlite v1.48.2 // indirect 17 33 )
+49 -14
server/go.sum
··· 1 1 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 2 2 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 3 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 6 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 7 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 4 8 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 5 9 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= ··· 10 14 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 11 15 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 12 16 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 18 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 19 + github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= 20 + github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 21 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 22 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 23 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 + github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= 26 + github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= 27 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 28 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 29 + github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= 30 + github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 31 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 32 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 13 33 github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 14 34 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 15 35 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 16 36 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 17 37 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 18 38 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 19 - go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= 20 - go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= 21 - go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= 22 - go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= 39 + go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= 40 + go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= 41 + go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= 42 + go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= 23 43 go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 24 44 go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= 25 45 go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= 26 46 go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= 27 - go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= 28 - go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 29 - golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 30 - golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 31 - golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 32 - golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 33 - golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 34 - golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 47 + go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= 48 + go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= 49 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 50 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 51 + golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= 52 + golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 53 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 54 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 55 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 57 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 58 + golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= 59 + golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 35 60 gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 36 61 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 37 - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= 38 - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= 62 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= 63 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= 39 64 google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= 40 65 google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= 41 66 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 42 67 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 68 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 + modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= 71 + modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= 72 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 73 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 74 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 75 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 76 + modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= 77 + modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
+42
server/internal/database/database.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + 9 + _ "modernc.org/sqlite" 10 + ) 11 + 12 + func Open(path string) (*sql.DB, error) { 13 + if dir := filepath.Dir(path); dir != "." && dir != "" { 14 + if err := os.MkdirAll(dir, 0755); err != nil { 15 + return nil, fmt.Errorf("create db directory %q: %w", dir, err) 16 + } 17 + } 18 + 19 + db, err := sql.Open("sqlite", path) 20 + if err != nil { 21 + return nil, fmt.Errorf("open sqlite %q: %w", path, err) 22 + } 23 + 24 + pragmas := []string{ 25 + "PRAGMA journal_mode=WAL", 26 + "PRAGMA foreign_keys=ON", 27 + "PRAGMA busy_timeout=5000", 28 + } 29 + for _, p := range pragmas { 30 + if _, err := db.Exec(p); err != nil { 31 + db.Close() 32 + return nil, fmt.Errorf("exec %q: %w", p, err) 33 + } 34 + } 35 + 36 + if err := db.Ping(); err != nil { 37 + db.Close() 38 + return nil, fmt.Errorf("ping sqlite %q: %w", path, err) 39 + } 40 + 41 + return db, nil 42 + }
+6
server/internal/masterdata/config.go
··· 35 35 QuestSkipMaxCountAtOnce int32 36 36 37 37 WeaponLimitBreakAvailableCount int32 38 + 39 + CostumeLotteryEffectUnlockSlotConsumeGold int32 40 + CostumeLotteryEffectDrawSlotConsumeGold int32 38 41 } 39 42 40 43 func LoadGameConfig() (*GameConfig, error) { ··· 72 75 cfg.QuestSkipMaxCountAtOnce = parseInt32(kv, "QUEST_SKIP_MAX_COUNT_AT_ONCE") 73 76 74 77 cfg.WeaponLimitBreakAvailableCount = parseInt32(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT") 78 + 79 + cfg.CostumeLotteryEffectUnlockSlotConsumeGold = parseInt32(kv, "COSTUME_LOTTERY_EFFECT_UNLOCK_SLOT_CONSUME_GOLD") 80 + cfg.CostumeLotteryEffectDrawSlotConsumeGold = parseInt32(kv, "COSTUME_LOTTERY_EFFECT_DRAW_SLOT_CONSUME_GOLD") 75 81 76 82 return cfg, nil 77 83 }
+59
server/internal/masterdata/costume.go
··· 78 78 SortOrder int32 `json:"SortOrder"` 79 79 } 80 80 81 + type CostumeLotteryEffectRow struct { 82 + CostumeId int32 `json:"CostumeId"` 83 + SlotNumber int32 `json:"SlotNumber"` 84 + CostumeLotteryEffectOddsGroupId int32 `json:"CostumeLotteryEffectOddsGroupId"` 85 + CostumeLotteryEffectUnlockMaterialGroupId int32 `json:"CostumeLotteryEffectUnlockMaterialGroupId"` 86 + CostumeLotteryEffectDrawMaterialGroupId int32 `json:"CostumeLotteryEffectDrawMaterialGroupId"` 87 + CostumeLotteryEffectReleaseScheduleId int32 `json:"CostumeLotteryEffectReleaseScheduleId"` 88 + } 89 + 90 + type CostumeLotteryEffectMaterialGroupRow struct { 91 + CostumeLotteryEffectMaterialGroupId int32 `json:"CostumeLotteryEffectMaterialGroupId"` 92 + MaterialId int32 `json:"MaterialId"` 93 + Count int32 `json:"Count"` 94 + SortOrder int32 `json:"SortOrder"` 95 + } 96 + 97 + type CostumeLotteryEffectOddsRow struct { 98 + CostumeLotteryEffectOddsGroupId int32 `json:"CostumeLotteryEffectOddsGroupId"` 99 + OddsNumber int32 `json:"OddsNumber"` 100 + Weight int32 `json:"Weight"` 101 + CostumeLotteryEffectType int32 `json:"CostumeLotteryEffectType"` 102 + CostumeLotteryEffectTargetId int32 `json:"CostumeLotteryEffectTargetId"` 103 + RarityType int32 `json:"RarityType"` 104 + } 105 + 81 106 type CostumeCatalog struct { 82 107 Costumes map[int32]CostumeMasterRow 83 108 Materials map[int32]MaterialRow ··· 96 121 ActiveSkillEnhanceMats map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow // key: [enhancementMaterialId, skillLevel] 97 122 ActiveSkillMaxLevelByRarity map[int32]NumericalFunc 98 123 ActiveSkillCostByRarity map[int32]NumericalFunc 124 + 125 + LotteryEffects map[[2]int32]CostumeLotteryEffectRow // key: [costumeId, slotNumber] 126 + LotteryEffectMats map[int32][]CostumeLotteryEffectMaterialGroupRow // key: materialGroupId (both unlock and draw) 127 + LotteryEffectOdds map[int32][]CostumeLotteryEffectOddsRow // key: oddsGroupId 99 128 } 100 129 101 130 func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) { ··· 149 178 return nil, fmt.Errorf("load costume active skill enhancement material table: %w", err) 150 179 } 151 180 181 + lotteryEffectRows, err := utils.ReadJSON[CostumeLotteryEffectRow]("EntityMCostumeLotteryEffectTable.json") 182 + if err != nil { 183 + return nil, fmt.Errorf("load costume lottery effect table: %w", err) 184 + } 185 + lotteryEffectMatRows, err := utils.ReadJSON[CostumeLotteryEffectMaterialGroupRow]("EntityMCostumeLotteryEffectMaterialGroupTable.json") 186 + if err != nil { 187 + return nil, fmt.Errorf("load costume lottery effect material group table: %w", err) 188 + } 189 + lotteryEffectOddsRows, err := utils.ReadJSON[CostumeLotteryEffectOddsRow]("EntityMCostumeLotteryEffectOddsGroupTable.json") 190 + if err != nil { 191 + return nil, fmt.Errorf("load costume lottery effect odds group table: %w", err) 192 + } 193 + 152 194 catalog := &CostumeCatalog{ 153 195 Costumes: make(map[int32]CostumeMasterRow, len(costumes)), 154 196 Materials: matCatalog.ByType[model.MaterialTypeCostumeEnhancement], ··· 167 209 ActiveSkillEnhanceMats: make(map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow), 168 210 ActiveSkillMaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)), 169 211 ActiveSkillCostByRarity: make(map[int32]NumericalFunc, len(rarities)), 212 + 213 + LotteryEffects: make(map[[2]int32]CostumeLotteryEffectRow, len(lotteryEffectRows)), 214 + LotteryEffectMats: make(map[int32][]CostumeLotteryEffectMaterialGroupRow), 215 + LotteryEffectOdds: make(map[int32][]CostumeLotteryEffectOddsRow), 170 216 } 171 217 172 218 for _, row := range costumes { ··· 240 286 for _, row := range activeSkillMatRows { 241 287 key := [2]int32{row.CostumeActiveSkillEnhancementMaterialId, row.SkillLevel} 242 288 catalog.ActiveSkillEnhanceMats[key] = append(catalog.ActiveSkillEnhanceMats[key], row) 289 + } 290 + 291 + for _, row := range lotteryEffectRows { 292 + key := [2]int32{row.CostumeId, row.SlotNumber} 293 + catalog.LotteryEffects[key] = row 294 + } 295 + for _, row := range lotteryEffectMatRows { 296 + gid := row.CostumeLotteryEffectMaterialGroupId 297 + catalog.LotteryEffectMats[gid] = append(catalog.LotteryEffectMats[gid], row) 298 + } 299 + for _, row := range lotteryEffectOddsRows { 300 + gid := row.CostumeLotteryEffectOddsGroupId 301 + catalog.LotteryEffectOdds[gid] = append(catalog.LotteryEffectOdds[gid], row) 243 302 } 244 303 245 304 return catalog, nil
+8
server/internal/model/status.go
··· 22 22 CostumeAwakenEffectTypeItemAcquire CostumeAwakenEffectType = 3 23 23 ) 24 24 25 + type CostumeLotteryEffectType int32 26 + 27 + const ( 28 + CostumeLotteryEffectTypeUnknown CostumeLotteryEffectType = 0 29 + CostumeLotteryEffectTypeAbility CostumeLotteryEffectType = 1 30 + CostumeLotteryEffectTypeStatusUp CostumeLotteryEffectType = 2 31 + ) 32 + 25 33 type WeaponAwakenEffectType int32 26 34 27 35 const (
+4 -2
server/internal/questflow/rewards.go
··· 4 4 "fmt" 5 5 "log" 6 6 7 + "github.com/google/uuid" 8 + 7 9 "lunar-tear/server/internal/gameutil" 8 10 "lunar-tear/server/internal/masterdata" 9 11 "lunar-tear/server/internal/model" ··· 276 278 return 277 279 } 278 280 } 279 - key := fmt.Sprintf("reward-companion-%d", companionId) 281 + key := uuid.New().String() 280 282 user.Companions[key] = store.CompanionState{ 281 283 UserCompanionUuid: key, 282 284 CompanionId: companionId, ··· 306 308 } 307 309 } 308 310 309 - key := fmt.Sprintf("reward-parts-%d", partsId) 311 + key := uuid.New().String() 310 312 user.Parts[key] = store.PartsState{ 311 313 UserPartsUuid: key, 312 314 PartsId: partsId,
+4 -4
server/internal/service/banner.go
··· 11 11 12 12 type BannerServiceServer struct { 13 13 pb.UnimplementedBannerServiceServer 14 - gacha store.GachaRepository 14 + catalog []store.GachaCatalogEntry 15 15 } 16 16 17 - func NewBannerServiceServer(gacha store.GachaRepository) *BannerServiceServer { 18 - return &BannerServiceServer{gacha: gacha} 17 + func NewBannerServiceServer(catalog []store.GachaCatalogEntry) *BannerServiceServer { 18 + return &BannerServiceServer{catalog: catalog} 19 19 } 20 20 21 21 func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) { 22 - catalog, _ := s.gacha.SnapshotCatalog() 22 + catalog := s.catalog 23 23 var termLimited []*pb.GachaBanner 24 24 var latestChapter *pb.GachaBanner 25 25 for _, entry := range catalog {
+2 -4
server/internal/service/cageornament.go
··· 43 43 s.granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) 44 44 }) 45 45 46 - diff := userdata.BuildDiffFromTables(userdata.SelectTables( 47 - userdata.FullClientTableMap(user), 46 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, 48 47 []string{ 49 48 "IUserMaterial", "IUserConsumableItem", "IUserGem", 50 49 "IUserCostume", "IUserCostumeActiveSkill", "IUserCharacter", ··· 82 81 } 83 82 }) 84 83 85 - diff := userdata.BuildDiffFromTables(userdata.SelectTables( 86 - userdata.FullClientTableMap(user), 84 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, 87 85 []string{"IUserCageOrnamentReward"}, 88 86 )) 89 87
+2 -2
server/internal/service/character.go
··· 35 35 return &pb.RebirthResponse{}, nil 36 36 } 37 37 38 - oldUser, _ := s.users.SnapshotUser(userId) 38 + oldUser, _ := s.users.LoadUser(userId) 39 39 tracker := userdata.NewDeleteTracker(). 40 40 Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}) 41 41 ··· 78 78 } 79 79 80 80 rebirthTables := []string{"IUserCharacterRebirth", "IUserMaterial", "IUserConsumableItem"} 81 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), rebirthTables) 81 + tables := userdata.ProjectTables(snapshot, rebirthTables) 82 82 diff := tracker.Apply(snapshot, tables) 83 83 84 84 return &pb.RebirthResponse{DiffUserData: diff}, nil
+2 -2
server/internal/service/characterboard.go
··· 27 27 28 28 userId := currentUserId(ctx, s.users, s.sessions) 29 29 30 - oldUser, _ := s.users.SnapshotUser(userId) 30 + oldUser, _ := s.users.LoadUser(userId) 31 31 tracker := userdata.NewDeleteTracker(). 32 32 Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}). 33 33 Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}) ··· 54 54 "IUserConsumableItem", 55 55 "IUserGem", 56 56 } 57 - tables := userdata.SelectTables(userdata.FullClientTableMap(user), boardTables) 57 + tables := userdata.ProjectTables(user, boardTables) 58 58 diff := tracker.Apply(user, tables) 59 59 60 60 return &pb.ReleasePanelResponse{DiffUserData: diff}, nil
+1 -1
server/internal/service/characterviewer.go
··· 29 29 log.Printf("[CharacterViewerService] CharacterViewerTop") 30 30 31 31 userId := currentUserId(ctx, s.users, s.sessions) 32 - user, err := s.users.SnapshotUser(userId) 32 + user, err := s.users.LoadUser(userId) 33 33 if err != nil { 34 34 panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err)) 35 35 }
+1 -2
server/internal/service/companion.go
··· 77 77 return nil, fmt.Errorf("companion enhance: %w", err) 78 78 } 79 79 80 - tables := userdata.FullClientTableMap(snapshot) 81 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, companionDiffTables)) 80 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, companionDiffTables)) 82 81 83 82 return &pb.CompanionEnhanceResponse{ 84 83 DiffUserData: diff,
+2 -2
server/internal/service/consumableitem.go
··· 28 28 29 29 userId := currentUserId(ctx, s.users, s.sessions) 30 30 31 - oldUser, _ := s.users.SnapshotUser(userId) 31 + oldUser, _ := s.users.LoadUser(userId) 32 32 tracker := userdata.NewDeleteTracker(). 33 33 Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}) 34 34 ··· 66 66 return nil, fmt.Errorf("consumable item sell: %w", err) 67 67 } 68 68 69 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), []string{"IUserConsumableItem"}) 69 + tables := userdata.ProjectTables(snapshot, []string{"IUserConsumableItem"}) 70 70 diff := tracker.Apply(snapshot, tables) 71 71 72 72 return &pb.ConsumableItemSellResponse{
+1 -2
server/internal/service/contentsstory.go
··· 34 34 return nil, fmt.Errorf("update user: %w", err) 35 35 } 36 36 37 - tables := userdata.FullClientTableMap(snapshot) 38 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserContentsStory"})) 37 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserContentsStory"})) 39 38 40 39 return &pb.ContentsStoryRegisterPlayedResponse{ 41 40 DiffUserData: diff,
+219 -11
server/internal/service/costume.go
··· 4 4 "context" 5 5 "fmt" 6 6 "log" 7 + "math/rand" 8 + 9 + "github.com/google/uuid" 7 10 8 11 pb "lunar-tear/server/gen/proto" 9 12 "lunar-tear/server/internal/gametime" ··· 95 98 return nil, fmt.Errorf("costume enhance: %w", err) 96 99 } 97 100 98 - tables := userdata.FullClientTableMap(snapshot) 99 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables)) 101 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, costumeDiffTables)) 100 102 101 103 return &pb.EnhanceResponse{ 102 104 IsGreatSuccess: false, ··· 177 179 return nil, fmt.Errorf("costume awaken: %w", err) 178 180 } 179 181 180 - tables := userdata.FullClientTableMap(snapshot) 181 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, awakenDiffTables)) 182 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, awakenDiffTables)) 182 183 183 184 return &pb.AwakenResponse{ 184 185 DiffUserData: diff, ··· 229 230 return 230 231 } 231 232 232 - key := fmt.Sprintf("awaken-thought-%d", acq.PossessionId) 233 - if _, exists := user.Thoughts[key]; exists { 234 - return 233 + for _, t := range user.Thoughts { 234 + if t.ThoughtId == acq.PossessionId { 235 + return 236 + } 235 237 } 238 + key := uuid.New().String() 236 239 user.Thoughts[key] = store.ThoughtState{ 237 240 UserThoughtUuid: key, 238 241 ThoughtId: acq.PossessionId, ··· 329 332 return nil, fmt.Errorf("costume enhance active skill: %w", err) 330 333 } 331 334 332 - tables := userdata.FullClientTableMap(snapshot) 333 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, activeSkillDiffTables)) 335 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, activeSkillDiffTables)) 334 336 335 337 return &pb.EnhanceActiveSkillResponse{ 336 338 DiffUserData: diff, ··· 387 389 return nil, fmt.Errorf("costume limit break: %w", err) 388 390 } 389 391 390 - tables := userdata.FullClientTableMap(snapshot) 391 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables)) 392 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, costumeDiffTables)) 392 393 393 394 return &pb.LimitBreakResponse{ 394 395 DiffUserData: diff, 395 396 }, nil 396 397 } 398 + 399 + var lotteryEffectDiffTables = []string{ 400 + "IUserCostume", 401 + "IUserCostumeLotteryEffect", 402 + "IUserCostumeLotteryEffectAbility", 403 + "IUserCostumeLotteryEffectStatusUp", 404 + "IUserCostumeLotteryEffectPending", 405 + "IUserConsumableItem", 406 + "IUserMaterial", 407 + } 408 + 409 + func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req *pb.UnlockLotteryEffectSlotRequest) (*pb.UnlockLotteryEffectSlotResponse, error) { 410 + log.Printf("[CostumeService] UnlockLotteryEffectSlot: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) 411 + 412 + userId := currentUserId(ctx, s.users, s.sessions) 413 + nowMillis := gametime.NowMillis() 414 + 415 + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { 416 + costume, ok := user.Costumes[req.UserCostumeUuid] 417 + if !ok { 418 + log.Printf("[CostumeService] UnlockLotteryEffectSlot: costume uuid=%s not found", req.UserCostumeUuid) 419 + return 420 + } 421 + 422 + effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] 423 + if !ok { 424 + log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) 425 + return 426 + } 427 + 428 + user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectUnlockSlotConsumeGold 429 + 430 + mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId] 431 + for _, mat := range mats { 432 + cur := user.Materials[mat.MaterialId] 433 + cost := mat.Count 434 + if cur < cost { 435 + log.Printf("[CostumeService] UnlockLotteryEffectSlot: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost) 436 + cost = cur 437 + } 438 + user.Materials[mat.MaterialId] = cur - cost 439 + } 440 + 441 + key := store.CostumeLotteryEffectKey{ 442 + UserCostumeUuid: req.UserCostumeUuid, 443 + SlotNumber: req.SlotNumber, 444 + } 445 + user.CostumeLotteryEffects[key] = store.CostumeLotteryEffectState{ 446 + UserCostumeUuid: req.UserCostumeUuid, 447 + SlotNumber: req.SlotNumber, 448 + OddsNumber: 0, 449 + LatestVersion: nowMillis, 450 + } 451 + 452 + costume.CostumeLotteryEffectUnlockedSlotCount++ 453 + costume.LatestVersion = nowMillis 454 + user.Costumes[req.UserCostumeUuid] = costume 455 + log.Printf("[CostumeService] UnlockLotteryEffectSlot: costumeId=%d slot=%d unlocked slotCount=%d", costume.CostumeId, req.SlotNumber, costume.CostumeLotteryEffectUnlockedSlotCount) 456 + }) 457 + if err != nil { 458 + return nil, fmt.Errorf("costume unlock lottery effect slot: %w", err) 459 + } 460 + 461 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) 462 + 463 + return &pb.UnlockLotteryEffectSlotResponse{ 464 + DiffUserData: diff, 465 + }, nil 466 + } 467 + 468 + func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.DrawLotteryEffectRequest) (*pb.DrawLotteryEffectResponse, error) { 469 + log.Printf("[CostumeService] DrawLotteryEffect: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) 470 + 471 + userId := currentUserId(ctx, s.users, s.sessions) 472 + nowMillis := gametime.NowMillis() 473 + 474 + oldUser, _ := s.users.LoadUser(userId) 475 + tracker := userdata.NewDeleteTracker(). 476 + Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}). 477 + Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}). 478 + Track("IUserCostumeLotteryEffectPending", oldUser, userdata.SortedCostumeLotteryEffectPendingRecords, []string{"userId", "userCostumeUuid"}) 479 + 480 + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { 481 + costume, ok := user.Costumes[req.UserCostumeUuid] 482 + if !ok { 483 + log.Printf("[CostumeService] DrawLotteryEffect: costume uuid=%s not found", req.UserCostumeUuid) 484 + return 485 + } 486 + 487 + effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] 488 + if !ok { 489 + log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) 490 + return 491 + } 492 + 493 + oddsPool := s.catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId] 494 + if len(oddsPool) == 0 { 495 + log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId) 496 + return 497 + } 498 + 499 + user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectDrawSlotConsumeGold 500 + 501 + mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId] 502 + for _, mat := range mats { 503 + cur := user.Materials[mat.MaterialId] 504 + cost := mat.Count 505 + if cur < cost { 506 + log.Printf("[CostumeService] DrawLotteryEffect: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost) 507 + cost = cur 508 + } 509 + user.Materials[mat.MaterialId] = cur - cost 510 + } 511 + 512 + totalWeight := int32(0) 513 + for _, row := range oddsPool { 514 + totalWeight += row.Weight 515 + } 516 + roll := rand.Int31n(totalWeight) 517 + var picked masterdata.CostumeLotteryEffectOddsRow 518 + for _, row := range oddsPool { 519 + roll -= row.Weight 520 + if roll < 0 { 521 + picked = row 522 + break 523 + } 524 + } 525 + 526 + key := store.CostumeLotteryEffectKey{ 527 + UserCostumeUuid: req.UserCostumeUuid, 528 + SlotNumber: req.SlotNumber, 529 + } 530 + existing := user.CostumeLotteryEffects[key] 531 + if existing.OddsNumber == 0 { 532 + existing.UserCostumeUuid = req.UserCostumeUuid 533 + existing.SlotNumber = req.SlotNumber 534 + existing.OddsNumber = picked.OddsNumber 535 + existing.LatestVersion = nowMillis 536 + user.CostumeLotteryEffects[key] = existing 537 + } else { 538 + user.CostumeLotteryEffectPending[req.UserCostumeUuid] = store.CostumeLotteryEffectPendingState{ 539 + UserCostumeUuid: req.UserCostumeUuid, 540 + SlotNumber: req.SlotNumber, 541 + OddsNumber: picked.OddsNumber, 542 + LatestVersion: nowMillis, 543 + } 544 + } 545 + 546 + log.Printf("[CostumeService] DrawLotteryEffect: costumeId=%d slot=%d drew oddsNumber=%d type=%d targetId=%d firstDraw=%v", 547 + costume.CostumeId, req.SlotNumber, picked.OddsNumber, picked.CostumeLotteryEffectType, picked.CostumeLotteryEffectTargetId, existing.OddsNumber == 0) 548 + }) 549 + if err != nil { 550 + return nil, fmt.Errorf("costume draw lottery effect: %w", err) 551 + } 552 + 553 + diff := tracker.Apply(snapshot, userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) 554 + 555 + return &pb.DrawLotteryEffectResponse{ 556 + DiffUserData: diff, 557 + }, nil 558 + } 559 + 560 + func (s *CostumeServiceServer) ConfirmLotteryEffect(ctx context.Context, req *pb.ConfirmLotteryEffectRequest) (*pb.ConfirmLotteryEffectResponse, error) { 561 + log.Printf("[CostumeService] ConfirmLotteryEffect: uuid=%s accept=%v", req.UserCostumeUuid, req.IsAccept) 562 + 563 + userId := currentUserId(ctx, s.users, s.sessions) 564 + nowMillis := gametime.NowMillis() 565 + 566 + oldUser, _ := s.users.LoadUser(userId) 567 + tracker := userdata.NewDeleteTracker(). 568 + Track("IUserCostumeLotteryEffectPending", oldUser, userdata.SortedCostumeLotteryEffectPendingRecords, []string{"userId", "userCostumeUuid"}) 569 + 570 + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { 571 + pending, ok := user.CostumeLotteryEffectPending[req.UserCostumeUuid] 572 + if !ok { 573 + log.Printf("[CostumeService] ConfirmLotteryEffect: no pending for uuid=%s", req.UserCostumeUuid) 574 + return 575 + } 576 + 577 + if req.IsAccept { 578 + key := store.CostumeLotteryEffectKey{ 579 + UserCostumeUuid: pending.UserCostumeUuid, 580 + SlotNumber: pending.SlotNumber, 581 + } 582 + effect := user.CostumeLotteryEffects[key] 583 + effect.UserCostumeUuid = pending.UserCostumeUuid 584 + effect.SlotNumber = pending.SlotNumber 585 + effect.OddsNumber = pending.OddsNumber 586 + effect.LatestVersion = nowMillis 587 + user.CostumeLotteryEffects[key] = effect 588 + log.Printf("[CostumeService] ConfirmLotteryEffect: accepted oddsNumber=%d for slot=%d", pending.OddsNumber, pending.SlotNumber) 589 + } else { 590 + log.Printf("[CostumeService] ConfirmLotteryEffect: rejected oddsNumber=%d for slot=%d", pending.OddsNumber, pending.SlotNumber) 591 + } 592 + 593 + delete(user.CostumeLotteryEffectPending, req.UserCostumeUuid) 594 + }) 595 + if err != nil { 596 + return nil, fmt.Errorf("costume confirm lottery effect: %w", err) 597 + } 598 + 599 + diff := tracker.Apply(snapshot, userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) 600 + 601 + return &pb.ConfirmLotteryEffectResponse{ 602 + DiffUserData: diff, 603 + }, nil 604 + }
+2 -2
server/internal/service/data.go
··· 42 42 log.Printf("[DataService] GetUserData: tables=%v", req.TableName) 43 43 44 44 userId := currentUserId(ctx, s.users, s.sessions) 45 - user, err := s.users.SnapshotUser(userId) 45 + user, err := s.users.LoadUser(userId) 46 46 if err != nil { 47 47 return nil, fmt.Errorf("snapshot user: %w", err) 48 48 } 49 49 50 - defaults := userdata.FirstEntranceClientTableMap(user) 50 + defaults := userdata.FullClientTableMap(user) 51 51 result := userdata.SelectTables(defaults, req.TableName) 52 52 return &pb.UserDataGetResponse{ 53 53 UserDataJson: result,
+7 -7
server/internal/service/deck.go
··· 32 32 user.Decks[deckKey] = deck 33 33 }) 34 34 35 - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserDeck"}) 35 + result := userdata.ProjectTables(user, []string{"IUserDeck"}) 36 36 return &pb.UpdateNameResponse{ 37 37 DiffUserData: userdata.BuildDiffFromTables(result), 38 38 }, nil ··· 81 81 } 82 82 }) 83 83 84 - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 84 + result := userdata.ProjectTables(user, []string{ 85 85 "IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote", 86 86 }) 87 87 return &pb.RefreshDeckPowerResponse{ ··· 133 133 } 134 134 }) 135 135 136 - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 136 + result := userdata.ProjectTables(user, []string{ 137 137 "IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote", 138 138 }) 139 139 return &pb.RefreshMultiDeckPowerResponse{ ··· 173 173 } 174 174 userId := currentUserId(ctx, s.users, s.sessions) 175 175 176 - oldUser, _ := s.users.SnapshotUser(userId) 176 + oldUser, _ := s.users.LoadUser(userId) 177 177 tracker := userdata.NewDeleteTracker(). 178 178 Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords, 179 179 []string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}). ··· 189 189 store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis()) 190 190 }) 191 191 192 - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 192 + result := userdata.ProjectTables(user, []string{ 193 193 "IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup", 194 194 "IUserDeckCharacterDressupCostume", 195 195 }) ··· 202 202 log.Printf("[DeckService] ReplaceTripleDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber) 203 203 userId := currentUserId(ctx, s.users, s.sessions) 204 204 205 - oldUser, _ := s.users.SnapshotUser(userId) 205 + oldUser, _ := s.users.LoadUser(userId) 206 206 tracker := userdata.NewDeleteTracker(). 207 207 Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords, 208 208 []string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}). ··· 231 231 } 232 232 }) 233 233 234 - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 234 + result := userdata.ProjectTables(user, []string{ 235 235 "IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup", 236 236 "IUserDeckCharacterDressupCostume", 237 237 })
+1 -2
server/internal/service/dokan.go
··· 33 33 return nil, fmt.Errorf("update user: %w", err) 34 34 } 35 35 36 - tables := userdata.FullClientTableMap(snapshot) 37 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserDokan"})) 36 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserDokan"})) 38 37 39 38 return &pb.RegisterDokanConfirmedResponse{ 40 39 DiffUserData: diff,
+3 -6
server/internal/service/explore.go
··· 71 71 return nil, fmt.Errorf("start explore: %w", err) 72 72 } 73 73 74 - tables := userdata.FullClientTableMap(snapshot) 75 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreDiffTables)) 74 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, exploreDiffTables)) 76 75 77 76 return &pb.StartExploreResponse{ 78 77 DiffUserData: diff, ··· 124 123 return nil, fmt.Errorf("finish explore: %w", err) 125 124 } 126 125 127 - tables := userdata.FullClientTableMap(snapshot) 128 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreFinishDiffTables)) 126 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, exploreFinishDiffTables)) 129 127 130 128 rewards := []*pb.ExploreReward{ 131 129 { ··· 161 159 return nil, fmt.Errorf("retire explore: %w", err) 162 160 } 163 161 164 - tables := userdata.FullClientTableMap(snapshot) 165 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserExplore"})) 162 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserExplore"})) 166 163 167 164 return &pb.RetireExploreResponse{ 168 165 DiffUserData: diff,
+10 -13
server/internal/service/gacha.go
··· 34 34 pb.UnimplementedGachaServiceServer 35 35 users store.UserRepository 36 36 sessions store.SessionRepository 37 - gacha store.GachaRepository 37 + catalog []store.GachaCatalogEntry 38 38 handler *gacha.GachaHandler 39 39 } 40 40 41 41 func NewGachaServiceServer( 42 42 users store.UserRepository, 43 43 sessions store.SessionRepository, 44 - gachaRepo store.GachaRepository, 44 + catalog []store.GachaCatalogEntry, 45 45 handler *gacha.GachaHandler, 46 46 ) *GachaServiceServer { 47 47 return &GachaServiceServer{ 48 48 users: users, 49 49 sessions: sessions, 50 - gacha: gachaRepo, 50 + catalog: catalog, 51 51 handler: handler, 52 52 } 53 53 } ··· 55 55 func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) { 56 56 log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType) 57 57 58 - catalog, _ := s.gacha.SnapshotCatalog() 58 + catalog := s.catalog 59 59 userId := currentUserId(ctx, s.users, s.sessions) 60 60 nowMillis := gametime.NowMillis() 61 61 ··· 132 132 func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) { 133 133 log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId) 134 134 135 - catalog, _ := s.gacha.SnapshotCatalog() 135 + catalog := s.catalog 136 136 137 137 userId := currentUserId(ctx, s.users, s.sessions) 138 - user, err := s.users.SnapshotUser(userId) 138 + user, err := s.users.LoadUser(userId) 139 139 if err != nil { 140 140 return nil, fmt.Errorf("snapshot user: %w", err) 141 141 } ··· 160 160 func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) { 161 161 log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount) 162 162 163 - catalog, _ := s.gacha.SnapshotCatalog() 164 - entry := findCatalogEntry(catalog, req.GachaId) 163 + entry := findCatalogEntry(s.catalog, req.GachaId) 165 164 if entry == nil { 166 165 return nil, fmt.Errorf("gacha %d not found", req.GachaId) 167 166 } ··· 293 292 294 293 changedStoryIds := s.handler.Granter.DrainChangedStoryWeaponIds() 295 294 diffOrder := append(gachaDiffTables, "IUserWeaponStory") 296 - allTables := userdata.FullClientTableMap(updatedUser) 297 - diff := userdata.BuildDiffFromTablesOrdered(userdata.SelectTables(allTables, diffOrder), diffOrder) 295 + diff := userdata.BuildDiffFromTablesOrdered(userdata.ProjectTables(updatedUser, diffOrder), diffOrder) 298 296 userdata.AddWeaponStoryDiff(diff, updatedUser, changedStoryIds) 299 297 300 298 return &pb.DrawResponse{ ··· 309 307 func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) { 310 308 log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId) 311 309 312 - catalog, _ := s.gacha.SnapshotCatalog() 313 - entry := findCatalogEntry(catalog, req.GachaId) 310 + entry := findCatalogEntry(s.catalog, req.GachaId) 314 311 if entry == nil { 315 312 return nil, fmt.Errorf("gacha %d not found", req.GachaId) 316 313 } ··· 336 333 func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) { 337 334 log.Printf("[GachaService] GetRewardGacha") 338 335 userId := currentUserId(ctx, s.users, s.sessions) 339 - user, err := s.users.SnapshotUser(userId) 336 + user, err := s.users.LoadUser(userId) 340 337 if err != nil { 341 338 return nil, fmt.Errorf("snapshot user: %w", err) 342 339 }
+2 -2
server/internal/service/gift.go
··· 71 71 req.RewardKindType, req.ExpirationType, req.IsAscendingSort, req.NextCursor, req.PreviousCursor, req.GetCount) 72 72 73 73 userId := currentUserId(ctx, s.users, s.sessions) 74 - user, err := s.users.SnapshotUser(userId) 74 + user, err := s.users.LoadUser(userId) 75 75 if err != nil { 76 76 return nil, fmt.Errorf("snapshot user: %w", err) 77 77 } ··· 108 108 func (s *GiftServiceServer) GetGiftReceiveHistoryList(ctx context.Context, req *emptypb.Empty) (*pb.GetGiftReceiveHistoryListResponse, error) { 109 109 log.Printf("[GiftService] GetGiftReceiveHistoryList") 110 110 userId := currentUserId(ctx, s.users, s.sessions) 111 - user, err := s.users.SnapshotUser(userId) 111 + user, err := s.users.LoadUser(userId) 112 112 if err != nil { 113 113 return nil, fmt.Errorf("snapshot user: %w", err) 114 114 }
+4 -4
server/internal/service/gimmick.go
··· 38 38 user.Gimmick.Sequences[key] = sequence 39 39 }) 40 40 return &pb.UpdateSequenceResponse{ 41 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickSequence"})), 41 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserGimmickSequence"})), 42 42 }, nil 43 43 } 44 44 ··· 74 74 GimmickOrnamentReward: []*pb.GimmickReward{}, 75 75 IsSequenceCleared: false, 76 76 GimmickSequenceClearReward: []*pb.GimmickReward{}, 77 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 77 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{ 78 78 "IUserGimmick", 79 79 "IUserGimmickOrnamentProgress", 80 80 })), ··· 98 98 } 99 99 }) 100 100 return &pb.InitSequenceScheduleResponse{ 101 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), gimmickDiffTables)), 101 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, gimmickDiffTables)), 102 102 }, nil 103 103 } 104 104 ··· 119 119 } 120 120 }) 121 121 return &pb.UnlockResponse{ 122 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickUnlock"})), 122 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserGimmickUnlock"})), 123 123 }, nil 124 124 }
+1 -1
server/internal/service/listbin.go
··· 495 495 } 496 496 } 497 497 return candidates, entry.Size, true 498 - } 498 + }
+4 -4
server/internal/service/loginbonus.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "fmt" 6 5 "log" 7 6 "time" 7 + 8 + "github.com/google/uuid" 8 9 9 10 pb "lunar-tear/server/gen/proto" 10 11 "lunar-tear/server/internal/gametime" ··· 55 56 GrantDatetime: now, 56 57 }, 57 58 ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond), 58 - UserGiftUuid: fmt.Sprintf("login-bonus-%d-%d", userId, nextStamp), 59 + UserGiftUuid: uuid.New().String(), 59 60 }) 60 61 user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived)) 61 62 user.LoginBonus.CurrentStampNumber = nextStamp ··· 63 64 user.LoginBonus.LatestVersion = now 64 65 }) 65 66 66 - diff := userdata.BuildDiffFromTables(userdata.SelectTables( 67 - userdata.FullClientTableMap(user), 67 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, 68 68 []string{"IUserLoginBonus"}, 69 69 )) 70 70 setCommonResponseTrailers(ctx, diff, false)
+2 -2
server/internal/service/material.go
··· 33 33 34 34 userId := currentUserId(ctx, s.users, s.sessions) 35 35 36 - oldUser, _ := s.users.SnapshotUser(userId) 36 + oldUser, _ := s.users.LoadUser(userId) 37 37 tracker := userdata.NewDeleteTracker(). 38 38 Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}) 39 39 ··· 71 71 return nil, fmt.Errorf("material sell: %w", err) 72 72 } 73 73 74 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), materialDiffTables) 74 + tables := userdata.ProjectTables(snapshot, materialDiffTables) 75 75 diff := tracker.Apply(snapshot, tables) 76 76 77 77 return &pb.MaterialSellResponse{
+2 -3
server/internal/service/mission.go
··· 24 24 log.Printf("[MissionService] UpdateMissionProgress: cage=%v pictureBook=%v", req.CageMeasurableValues, req.PictureBookMeasurableValues) 25 25 26 26 userId := currentUserId(ctx, s.users, s.sessions) 27 - snapshot, err := s.users.SnapshotUser(userId) 27 + snapshot, err := s.users.LoadUser(userId) 28 28 if err != nil { 29 29 return nil, fmt.Errorf("snapshot user: %w", err) 30 30 } 31 31 32 - tables := userdata.FullClientTableMap(snapshot) 33 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMission"})) 32 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserMission"})) 34 33 35 34 return &pb.UpdateMissionProgressResponse{ 36 35 DiffUserData: diff,
+1 -2
server/internal/service/movie.go
··· 36 36 return nil, fmt.Errorf("update user: %w", err) 37 37 } 38 38 39 - tables := userdata.FullClientTableMap(snapshot) 40 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMovie"})) 39 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserMovie"})) 41 40 42 41 return &pb.SaveViewedMovieResponse{ 43 42 DiffUserData: diff,
+1 -2
server/internal/service/navicutin.go
··· 31 31 return nil, fmt.Errorf("update user: %w", err) 32 32 } 33 33 34 - tables := userdata.FullClientTableMap(snapshot) 35 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserNaviCutIn"})) 34 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserNaviCutIn"})) 36 35 37 36 return &pb.RegisterPlayedResponse{ 38 37 DiffUserData: diff,
+1 -1
server/internal/service/notification.go
··· 24 24 func (s *NotificationServiceServer) GetHeaderNotification(ctx context.Context, req *emptypb.Empty) (*pb.GetHeaderNotificationResponse, error) { 25 25 log.Printf("[NotificationService] GetHeaderNotification") 26 26 userId := currentUserId(ctx, s.users, s.sessions) 27 - user, err := s.users.SnapshotUser(userId) 27 + user, err := s.users.LoadUser(userId) 28 28 if err != nil { 29 29 return &pb.GetHeaderNotificationResponse{ 30 30 GiftNotReceiveCount: 0,
+1 -2
server/internal/service/omikuji.go
··· 36 36 return nil, fmt.Errorf("update user: %w", err) 37 37 } 38 38 39 - tables := userdata.FullClientTableMap(snapshot) 40 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserOmikuji"})) 39 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserOmikuji"})) 41 40 42 41 return &pb.OmikujiDrawResponse{ 43 42 OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId),
+4 -6
server/internal/service/parts.go
··· 37 37 38 38 userId := currentUserId(ctx, s.users, s.sessions) 39 39 40 - oldUser, _ := s.users.SnapshotUser(userId) 40 + oldUser, _ := s.users.LoadUser(userId) 41 41 tracker := userdata.NewDeleteTracker(). 42 42 Track("IUserParts", oldUser, userdata.SortedPartsRecords, []string{"userId", "userPartsUuid"}) 43 43 ··· 81 81 return nil, fmt.Errorf("parts sell: %w", err) 82 82 } 83 83 84 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), partsDiffTables) 84 + tables := userdata.ProjectTables(snapshot, partsDiffTables) 85 85 diff := tracker.Apply(snapshot, tables) 86 86 87 87 return &pb.PartsSellResponse{ ··· 158 158 return nil, fmt.Errorf("parts enhance: %w", err) 159 159 } 160 160 161 - tables := userdata.FullClientTableMap(snapshot) 162 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, partsDiffTables)) 161 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, partsDiffTables)) 163 162 164 163 return &pb.PartsEnhanceResponse{ 165 164 IsSuccess: isSuccess, ··· 187 186 return nil, fmt.Errorf("parts replace preset: %w", err) 188 187 } 189 188 190 - tables := userdata.FullClientTableMap(snapshot) 191 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserPartsPreset"})) 189 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserPartsPreset"})) 192 190 193 191 return &pb.PartsReplacePresetResponse{ 194 192 DiffUserData: diff,
+1 -2
server/internal/service/portalcage.go
··· 30 30 user.PortalCageStatus.LatestVersion = now 31 31 }) 32 32 33 - tables := userdata.SelectTables( 34 - userdata.FullClientTableMap(user), 33 + tables := userdata.ProjectTables(user, 35 34 []string{"IUserPortalCageStatus"}, 36 35 ) 37 36 return &pb.UpdatePortalCageSceneProgressResponse{
+2 -2
server/internal/service/quest_bighunt.go
··· 42 42 } 43 43 44 44 func buildBigHuntDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { 45 - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) 45 + tables := userdata.ProjectTables(user, tableNames) 46 46 return userdata.BuildDiffFromTablesOrdered(tables, tableNames) 47 47 } 48 48 ··· 331 331 log.Printf("[BigHuntService] GetBigHuntTopData") 332 332 333 333 userId := currentUserId(ctx, s.users, s.sessions) 334 - user, _ := s.users.SnapshotUser(userId) 334 + user, _ := s.users.LoadUser(userId) 335 335 336 336 nowMillis := gametime.NowMillis() 337 337 weeklyVersion := gametime.WeeklyVersion(nowMillis)
+1 -1
server/internal/service/quest_main.go
··· 29 29 } 30 30 31 31 func buildSelectedQuestDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { 32 - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) 32 + tables := userdata.ProjectTables(user, tableNames) 33 33 return userdata.BuildDiffFromTablesOrdered(tables, tableNames) 34 34 } 35 35
+1 -1
server/internal/service/quest_sidestory.go
··· 24 24 } 25 25 26 26 func buildSideStoryDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { 27 - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) 27 + tables := userdata.ProjectTables(user, tableNames) 28 28 return userdata.BuildDiffFromTablesOrdered(tables, tableNames) 29 29 } 30 30
+1 -1
server/internal/service/reward.go
··· 106 106 weeklyScoreResults = []*pb.WeeklyScoreResult{} 107 107 } 108 108 109 - tables := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 109 + tables := userdata.ProjectTables(user, []string{ 110 110 "IUserBigHuntWeeklyStatus", 111 111 "IUserBigHuntWeeklyMaxScore", 112 112 "IUserConsumableItem",
+5 -9
server/internal/service/shop.go
··· 89 89 return nil, fmt.Errorf("shop buy: %w", err) 90 90 } 91 91 92 - tables := userdata.FullClientTableMap(snapshot) 93 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) 92 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) 94 93 userdata.AddWeaponStoryDiff(diff, snapshot, s.granter.DrainChangedStoryWeaponIds()) 95 94 96 95 return &pb.BuyResponse{ ··· 132 131 return nil, fmt.Errorf("shop refresh: %w", err) 133 132 } 134 133 135 - tables := userdata.FullClientTableMap(snapshot) 136 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) 134 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) 137 135 138 136 return &pb.RefreshResponse{ 139 137 DiffUserData: diff, ··· 195 193 196 194 txId := fmt.Sprintf("tx_%d_%d_%d", userId, req.ShopItemId, nowMillis) 197 195 198 - tables := userdata.FullClientTableMap(snapshot) 199 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) 196 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) 200 197 201 198 return &pb.CreatePurchaseTransactionResponse{ 202 199 PurchaseTransactionId: txId, ··· 208 205 log.Printf("[ShopService] PurchaseGooglePlayStoreProduct: txId=%s", req.PurchaseTransactionId) 209 206 210 207 userId := currentUserId(ctx, s.users, s.sessions) 211 - snapshot, err := s.users.SnapshotUser(userId) 208 + snapshot, err := s.users.LoadUser(userId) 212 209 if err != nil { 213 210 return nil, fmt.Errorf("purchase google play: %w", err) 214 211 } 215 212 216 - tables := userdata.FullClientTableMap(snapshot) 217 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) 213 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) 218 214 219 215 return &pb.PurchaseGooglePlayStoreProductResponse{ 220 216 OverflowPossession: []*pb.Possession{},
+1 -2
server/internal/service/state.go
··· 15 15 "IUserWeapon", 16 16 "IUserWeaponSkill", 17 17 "IUserWeaponAbility", 18 - "IUserWeaponStory", 19 18 "IUserCompanion", 20 19 "IUserDeckCharacter", 21 20 "IUserDeck", ··· 47 46 48 47 func currentUserId(ctx context.Context, users store.UserRepository, sessions store.SessionRepository) int64 { 49 48 if md, ok := metadata.FromIncomingContext(ctx); ok { 50 - if vals := md.Get("x-session-key"); len(vals) > 0 { 49 + if vals := md.Get("x-apb-session-key"); len(vals) > 0 { 51 50 if userId, err := sessions.ResolveUserId(vals[0]); err == nil { 52 51 return userId 53 52 }
+4 -5
server/internal/service/tutorial.go
··· 37 37 ChoiceId: req.ChoiceId, 38 38 } 39 39 } 40 - if req.TutorialType == int32(model.TutorialTypeMenuFirst) || 41 - req.TutorialType == int32(model.TutorialTypeMenuSecond) { 40 + grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis) 41 + if req.TutorialType == int32(model.TutorialTypeMenuFirst) && req.ProgressPhase == 20 { 42 42 store.EnsureDefaultDeck(user, nowMillis) 43 43 } 44 - grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis) 45 44 }) 46 45 tables := []string{"IUserTutorialProgress"} 47 46 if req.TutorialType == int32(model.TutorialTypeMenuFirst) || ··· 55 54 if len(grants) > 0 { 56 55 tables = append(tables, "IUserCompanion") 57 56 } 58 - result := userdata.SelectTables(userdata.FullClientTableMap(user), tables) 57 + result := userdata.ProjectTables(user, tables) 59 58 for _, t := range tables { 60 59 log.Printf("[TutorialService] DiffTable %s -> %s", t, result[t]) 61 60 } ··· 89 88 } 90 89 }) 91 90 return &pb.SetTutorialProgressAndReplaceDeckResponse{ 92 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{ 91 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{ 93 92 "IUserTutorialProgress", 94 93 "IUserDeck", 95 94 "IUserDeckCharacter",
+24 -17
server/internal/service/user.go
··· 50 50 } 51 51 52 52 func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) { 53 - user, err := s.users.EnsureUser(req.Uuid) 53 + userId, err := s.users.CreateUser(req.Uuid) 54 54 if err != nil { 55 - return nil, fmt.Errorf("ensure user: %w", err) 55 + return nil, fmt.Errorf("create user: %w", err) 56 + } 57 + user, err := s.users.LoadUser(userId) 58 + if err != nil { 59 + return nil, fmt.Errorf("load user: %w", err) 56 60 } 57 61 log.Printf("[UserService] RegisterUser: uuid=%s terminalId=%s -> userId=%d", req.Uuid, req.TerminalId, user.UserId) 58 62 ··· 66 70 func (s *UserServiceServer) Auth(ctx context.Context, req *pb.AuthUserRequest) (*pb.AuthUserResponse, error) { 67 71 log.Printf("[UserService] Auth: uuid=%s", req.Uuid) 68 72 69 - user, session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour) 73 + session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour) 70 74 if err != nil { 71 75 return nil, fmt.Errorf("create session: %w", err) 72 76 } 77 + user, err := s.users.LoadUser(session.UserId) 78 + if err != nil { 79 + return nil, fmt.Errorf("load user: %w", err) 80 + } 73 81 74 82 return &pb.AuthUserResponse{ 75 83 SessionKey: session.SessionKey, ··· 84 92 log.Printf("[UserService] GameStart") 85 93 86 94 if md, ok := metadata.FromIncomingContext(ctx); ok { 87 - if vals := md.Get("x-session-key"); len(vals) > 0 { 95 + if vals := md.Get("x-apb-session-key"); len(vals) > 0 { 88 96 log.Printf("[UserService] GameStart session: %s", vals[0]) 89 97 } 90 98 } ··· 93 101 user, _ := s.users.UpdateUser(userId, func(user *store.UserState) { 94 102 user.GameStartDatetime = gametime.NowMillis() 95 103 }) 96 - fullTables := userdata.FullClientTableMap(user) 97 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(fullTables, startedGameStartTables)) 104 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, startedGameStartTables)) 98 105 setCommonResponseTrailers(ctx, diff, true) 99 106 100 107 return &pb.GameStartResponse{ ··· 106 113 107 114 func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) { 108 115 log.Printf("[UserService] TransferUser") 109 - user, err := s.users.EnsureUser(req.Uuid) 116 + userId, err := s.users.CreateUser(req.Uuid) 110 117 if err != nil { 111 - return nil, fmt.Errorf("ensure user: %w", err) 118 + return nil, fmt.Errorf("create user: %w", err) 112 119 } 113 120 return &pb.TransferUserResponse{ 114 - UserId: user.UserId, 121 + UserId: userId, 115 122 Signature: "transferred-sig", 116 123 DiffUserData: userdata.EmptyDiff(), 117 124 }, nil ··· 126 133 user.Profile.NameUpdateDatetime = nowMillis 127 134 }) 128 135 return &pb.SetUserNameResponse{ 129 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), 136 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), 130 137 }, nil 131 138 } 132 139 ··· 139 146 user.Profile.MessageUpdateDatetime = nowMillis 140 147 }) 141 148 return &pb.SetUserMessageResponse{ 142 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), 149 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), 143 150 }, nil 144 151 } 145 152 ··· 152 159 user.Profile.FavoriteCostumeIdUpdateDatetime = nowMillis 153 160 }) 154 161 return &pb.SetUserFavoriteCostumeIdResponse{ 155 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), 162 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), 156 163 }, nil 157 164 } 158 165 ··· 162 169 if userId == 0 { 163 170 userId = currentUserId(ctx, s.users, s.sessions) 164 171 } 165 - user, err := s.users.SnapshotUser(userId) 172 + user, err := s.users.LoadUser(userId) 166 173 if err != nil { 167 174 return &pb.GetUserProfileResponse{DiffUserData: userdata.EmptyDiff()}, nil 168 175 } ··· 219 226 220 227 func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Empty) (*pb.GetBirthYearMonthResponse, error) { 221 228 userId := currentUserId(ctx, s.users, s.sessions) 222 - user, err := s.users.SnapshotUser(userId) 229 + user, err := s.users.LoadUser(userId) 223 230 if err != nil { 224 231 return &pb.GetBirthYearMonthResponse{BirthYear: 2000, BirthMonth: 1, DiffUserData: userdata.EmptyDiff()}, nil 225 232 } ··· 228 235 229 236 func (s *UserServiceServer) GetChargeMoney(ctx context.Context, _ *emptypb.Empty) (*pb.GetChargeMoneyResponse, error) { 230 237 userId := currentUserId(ctx, s.users, s.sessions) 231 - user, err := s.users.SnapshotUser(userId) 238 + user, err := s.users.LoadUser(userId) 232 239 if err != nil { 233 240 return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: 0, DiffUserData: userdata.EmptyDiff()}, nil 234 241 } ··· 242 249 user.Setting.IsNotifyPurchaseAlert = req.IsNotifyPurchaseAlert 243 250 }) 244 251 return &pb.SetUserSettingResponse{ 245 - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserSetting"})), 252 + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserSetting"})), 246 253 }, nil 247 254 } 248 255 ··· 252 259 253 260 func (s *UserServiceServer) GetBackupToken(ctx context.Context, req *pb.GetBackupTokenRequest) (*pb.GetBackupTokenResponse, error) { 254 261 userId := currentUserId(ctx, s.users, s.sessions) 255 - user, err := s.users.SnapshotUser(userId) 262 + user, err := s.users.LoadUser(userId) 256 263 if err != nil { 257 264 return &pb.GetBackupTokenResponse{BackupToken: "mock-backup-token", DiffUserData: userdata.EmptyDiff()}, nil 258 265 }
+14 -22
server/internal/service/weapon.go
··· 71 71 } 72 72 }) 73 73 74 - tables := userdata.FullClientTableMap(snapshot) 75 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"})) 74 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserWeapon"})) 76 75 return &pb.ProtectResponse{DiffUserData: diff}, nil 77 76 } 78 77 ··· 95 94 } 96 95 }) 97 96 98 - tables := userdata.FullClientTableMap(snapshot) 99 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"})) 97 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserWeapon"})) 100 98 return &pb.UnprotectResponse{DiffUserData: diff}, nil 101 99 } 102 100 ··· 165 163 return nil, fmt.Errorf("weapon enhance by material: %w", err) 166 164 } 167 165 168 - tables := userdata.FullClientTableMap(snapshot) 169 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) 166 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) 170 167 userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) 171 168 172 169 return &pb.EnhanceByMaterialResponse{ ··· 181 178 182 179 userId := currentUserId(ctx, s.users, s.sessions) 183 180 184 - oldUser, _ := s.users.SnapshotUser(userId) 181 + oldUser, _ := s.users.LoadUser(userId) 185 182 tracker := userdata.NewDeleteTracker(). 186 183 Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). 187 184 Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). ··· 229 226 } 230 227 231 228 sellDiffTables := []string{"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", "IUserWeaponAwaken", "IUserConsumableItem"} 232 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), sellDiffTables) 229 + tables := userdata.ProjectTables(snapshot, sellDiffTables) 233 230 diff := tracker.Apply(snapshot, tables) 234 231 235 232 return &pb.SellResponse{DiffUserData: diff}, nil ··· 307 304 return nil, fmt.Errorf("weapon evolve: %w", err) 308 305 } 309 306 310 - tables := userdata.FullClientTableMap(snapshot) 311 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) 307 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) 312 308 userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) 313 309 314 310 return &pb.EvolveResponse{DiffUserData: diff}, nil ··· 407 403 return nil, fmt.Errorf("weapon enhance skill: %w", err) 408 404 } 409 405 410 - tables := userdata.FullClientTableMap(snapshot) 411 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) 406 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) 412 407 413 408 return &pb.EnhanceSkillResponse{DiffUserData: diff}, nil 414 409 } ··· 506 501 return nil, fmt.Errorf("weapon enhance ability: %w", err) 507 502 } 508 503 509 - tables := userdata.FullClientTableMap(snapshot) 510 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) 504 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) 511 505 512 506 return &pb.EnhanceAbilityResponse{DiffUserData: diff}, nil 513 507 } ··· 578 572 return nil, fmt.Errorf("weapon limit break by material: %w", err) 579 573 } 580 574 581 - tables := userdata.FullClientTableMap(snapshot) 582 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, limitBreakDiffTables)) 575 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, limitBreakDiffTables)) 583 576 584 577 return &pb.LimitBreakByMaterialResponse{DiffUserData: diff}, nil 585 578 } ··· 590 583 userId := currentUserId(ctx, s.users, s.sessions) 591 584 nowMillis := gametime.NowMillis() 592 585 593 - oldUser, _ := s.users.SnapshotUser(userId) 586 + oldUser, _ := s.users.LoadUser(userId) 594 587 tracker := userdata.NewDeleteTracker(). 595 588 Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). 596 589 Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). ··· 665 658 return nil, fmt.Errorf("weapon limit break by weapon: %w", err) 666 659 } 667 660 668 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), limitBreakDiffTables) 661 + tables := userdata.ProjectTables(snapshot, limitBreakDiffTables) 669 662 diff := tracker.Apply(snapshot, tables) 670 663 671 664 return &pb.LimitBreakByWeaponResponse{DiffUserData: diff}, nil ··· 677 670 userId := currentUserId(ctx, s.users, s.sessions) 678 671 nowMillis := gametime.NowMillis() 679 672 680 - oldUser, _ := s.users.SnapshotUser(userId) 673 + oldUser, _ := s.users.LoadUser(userId) 681 674 tracker := userdata.NewDeleteTracker(). 682 675 Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). 683 676 Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). ··· 753 746 return nil, fmt.Errorf("weapon enhance by weapon: %w", err) 754 747 } 755 748 756 - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), weaponDiffTables) 749 + tables := userdata.ProjectTables(snapshot, weaponDiffTables) 757 750 diff := tracker.Apply(snapshot, tables) 758 751 userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) 759 752 ··· 864 857 return nil, fmt.Errorf("weapon awaken: %w", err) 865 858 } 866 859 867 - tables := userdata.FullClientTableMap(snapshot) 868 - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponAwakenDiffTables)) 860 + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponAwakenDiffTables)) 869 861 870 862 return &pb.WeaponAwakenResponse{DiffUserData: diff}, nil 871 863 }
+30 -21
server/internal/store/helpers.go
··· 5 5 "log" 6 6 "sort" 7 7 8 + "github.com/google/uuid" 9 + 8 10 "lunar-tear/server/internal/gametime" 9 11 "lunar-tear/server/internal/model" 10 12 ) ··· 139 141 } 140 142 } 141 143 } 142 - key := fmt.Sprintf("reward-costume-%d", costumeId) 144 + key := uuid.New().String() 143 145 user.Costumes[key] = CostumeState{ 144 146 UserCostumeUuid: key, 145 147 CostumeId: costumeId, ··· 155 157 } 156 158 157 159 func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) { 158 - key := fmt.Sprintf("reward-weapon-%d-%d", weaponId, nowMillis) 159 - if _, exists := user.Weapons[key]; exists { 160 - for i := 2; ; i++ { 161 - candidate := fmt.Sprintf("%s-%d", key, i) 162 - if _, exists := user.Weapons[candidate]; !exists { 163 - key = candidate 164 - break 165 - } 166 - } 167 - } 160 + key := uuid.New().String() 168 161 user.Weapons[key] = WeaponState{ 169 162 UserWeaponUuid: key, 170 163 WeaponId: weaponId, ··· 269 262 return 270 263 } 271 264 272 - costumeUuid := FirstSortedKey(user.Costumes) 273 - weaponUuid := FirstSortedKey(user.Weapons) 274 - companionUuid := FirstSortedKey(user.Companions) 265 + const rionCostumeId = int32(10100) 266 + const rionWeaponId = int32(101001) 267 + 268 + var costumeUuid, weaponUuid string 269 + for k, v := range user.Costumes { 270 + if v.CostumeId == rionCostumeId { 271 + costumeUuid = k 272 + break 273 + } 274 + } 275 + for k, v := range user.Weapons { 276 + if v.WeaponId == rionWeaponId { 277 + weaponUuid = k 278 + break 279 + } 280 + } 275 281 276 - dcUuid := "default-deck-character-0001" 282 + dcUuid := uuid.New().String() 277 283 user.DeckCharacters[dcUuid] = DeckCharacterState{ 278 284 UserDeckCharacterUuid: dcUuid, 285 + UserCompanionUuid: "", 279 286 UserCostumeUuid: costumeUuid, 280 287 MainUserWeaponUuid: weaponUuid, 281 - UserCompanionUuid: companionUuid, 282 288 Power: 100, 283 289 LatestVersion: nowMillis, 284 290 } ··· 324 330 deck.Power = 100 325 331 } 326 332 327 - uuids := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03} 328 - for i, uuid := range uuids { 333 + uuidPtrs := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03} 334 + for i, uuidPtr := range uuidPtrs { 329 335 if i >= len(slots) || slots[i].UserCostumeUuid == "" { 330 - *uuid = "" 336 + *uuidPtr = "" 331 337 continue 332 338 } 333 339 slot := slots[i] 334 - dcUuid := fmt.Sprintf("deck-%d-%d-%d", deckType, userDeckNumber, i+1) 340 + dcUuid := *uuidPtr 341 + if dcUuid == "" { 342 + dcUuid = uuid.New().String() 343 + } 335 344 dc := user.DeckCharacters[dcUuid] 336 345 dc.UserDeckCharacterUuid = dcUuid 337 346 dc.UserCostumeUuid = slot.UserCostumeUuid ··· 343 352 user.DeckCharacters[dcUuid] = dc 344 353 user.DeckSubWeapons[dcUuid] = slot.SubWeaponUuids 345 354 user.DeckParts[dcUuid] = slot.PartsUuids 346 - *uuid = dcUuid 355 + *uuidPtr = dcUuid 347 356 } 348 357 349 358 deck.LatestVersion = nowMillis
+45 -42
server/internal/store/memory/clone.go server/internal/store/clone.go
··· 1 - package memory 1 + package store 2 2 3 - import ( 4 - "maps" 3 + import "maps" 5 4 6 - "lunar-tear/server/internal/store" 7 - ) 8 - 9 - func cloneUserState(u store.UserState) store.UserState { 5 + func CloneUserState(u UserState) UserState { 10 6 out := u 11 7 out.Tutorials = maps.Clone(u.Tutorials) 12 8 out.Characters = maps.Clone(u.Characters) ··· 15 11 out.Companions = maps.Clone(u.Companions) 16 12 out.Thoughts = maps.Clone(u.Thoughts) 17 13 out.DeckCharacters = maps.Clone(u.DeckCharacters) 18 - out.DeckSubWeapons = maps.Clone(u.DeckSubWeapons) 14 + out.DeckSubWeapons = cloneSliceMap(u.DeckSubWeapons) 19 15 out.DeckParts = cloneSliceMap(u.DeckParts) 20 16 out.Decks = maps.Clone(u.Decks) 21 17 out.Quests = maps.Clone(u.Quests) 22 18 out.QuestMissions = maps.Clone(u.QuestMissions) 23 19 out.WeaponStories = maps.Clone(u.WeaponStories) 24 20 out.Missions = maps.Clone(u.Missions) 25 - out.Gimmick = store.GimmickState{ 21 + out.Gimmick = GimmickState{ 26 22 Progress: maps.Clone(u.Gimmick.Progress), 27 23 OrnamentProgress: maps.Clone(u.Gimmick.OrnamentProgress), 28 24 Sequences: maps.Clone(u.Gimmick.Sequences), ··· 38 34 out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills) 39 35 out.WeaponSkills = cloneSliceMap(u.WeaponSkills) 40 36 out.WeaponAbilities = cloneSliceMap(u.WeaponAbilities) 37 + out.WeaponAwakens = maps.Clone(u.WeaponAwakens) 41 38 out.DeckTypeNotes = maps.Clone(u.DeckTypeNotes) 42 39 out.WeaponNotes = maps.Clone(u.WeaponNotes) 43 40 out.NaviCutInPlayed = maps.Clone(u.NaviCutInPlayed) ··· 50 47 out.ShopReplaceableLineup = maps.Clone(u.ShopReplaceableLineup) 51 48 out.Explore = u.Explore 52 49 out.ExploreScores = maps.Clone(u.ExploreScores) 53 - out.Gacha = store.GachaState{ 50 + out.Gacha = GachaState{ 54 51 RewardAvailable: u.Gacha.RewardAvailable, 55 52 TodaysCurrentDrawCount: u.Gacha.TodaysCurrentDrawCount, 56 53 DailyMaxCount: u.Gacha.DailyMaxCount, 57 54 LastRewardDrawDate: u.Gacha.LastRewardDrawDate, 58 - ConvertedGachaMedal: store.ConvertedGachaMedalState{ 59 - ConvertedMedalPossession: append([]store.ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...), 55 + ConvertedGachaMedal: ConvertedGachaMedalState{ 56 + ConvertedMedalPossession: append([]ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...), 60 57 ObtainPossession: cloneConsumableItemPtr(u.Gacha.ConvertedGachaMedal.ObtainPossession), 61 58 }, 62 59 BannerStates: cloneBannerStates(u.Gacha.BannerStates), 63 60 } 64 - out.Gifts = store.GiftState{ 61 + out.Gifts = GiftState{ 65 62 NotReceived: cloneNotReceivedGifts(u.Gifts.NotReceived), 66 63 Received: cloneReceivedGifts(u.Gifts.Received), 67 64 } 68 65 out.Battle = u.Battle 66 + out.SideStoryQuests = maps.Clone(u.SideStoryQuests) 67 + out.QuestLimitContentStatus = maps.Clone(u.QuestLimitContentStatus) 68 + out.BigHuntMaxScores = maps.Clone(u.BigHuntMaxScores) 69 + out.BigHuntStatuses = maps.Clone(u.BigHuntStatuses) 70 + out.BigHuntScheduleMaxScores = maps.Clone(u.BigHuntScheduleMaxScores) 71 + out.BigHuntWeeklyMaxScores = maps.Clone(u.BigHuntWeeklyMaxScores) 72 + out.BigHuntWeeklyStatuses = maps.Clone(u.BigHuntWeeklyStatuses) 73 + out.BigHuntBattleBinary = append([]byte(nil), u.BigHuntBattleBinary...) 74 + out.CharacterBoards = maps.Clone(u.CharacterBoards) 75 + out.CharacterBoardAbilities = maps.Clone(u.CharacterBoardAbilities) 76 + out.CharacterBoardStatusUps = maps.Clone(u.CharacterBoardStatusUps) 69 77 out.CostumeAwakenStatusUps = maps.Clone(u.CostumeAwakenStatusUps) 78 + out.CostumeLotteryEffects = maps.Clone(u.CostumeLotteryEffects) 79 + out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending) 70 80 out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings) 71 81 out.CharacterRebirths = maps.Clone(u.CharacterRebirths) 72 82 return out 73 83 } 74 84 75 - func cloneGachaCatalogEntry(entry store.GachaCatalogEntry) store.GachaCatalogEntry { 76 - out := entry 77 - out.PricePhases = append([]store.GachaPricePhaseEntry(nil), entry.PricePhases...) 78 - out.PromotionItems = append([]store.GachaPromotionItem(nil), entry.PromotionItems...) 79 - return out 80 - } 81 - 82 - func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.GachaBannerState { 85 + func cloneBannerStates(m map[int32]GachaBannerState) map[int32]GachaBannerState { 83 86 if m == nil { 84 87 return nil 85 88 } 86 - out := make(map[int32]store.GachaBannerState, len(m)) 89 + out := make(map[int32]GachaBannerState, len(m)) 87 90 for k, v := range m { 88 91 bs := v 89 92 bs.BoxDrewCounts = maps.Clone(v.BoxDrewCounts) ··· 92 95 return out 93 96 } 94 97 95 - func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableItemState { 98 + func cloneConsumableItemPtr(item *ConsumableItemState) *ConsumableItemState { 96 99 if item == nil { 97 100 return nil 98 101 } ··· 100 103 return &out 101 104 } 102 105 103 - func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceivedGiftState { 104 - out := make([]store.NotReceivedGiftState, len(gifts)) 106 + func cloneNotReceivedGifts(gifts []NotReceivedGiftState) []NotReceivedGiftState { 107 + out := make([]NotReceivedGiftState, len(gifts)) 105 108 for i, gift := range gifts { 106 - out[i] = store.NotReceivedGiftState{ 107 - GiftCommon: store.GiftCommonState{ 109 + out[i] = NotReceivedGiftState{ 110 + GiftCommon: GiftCommonState{ 108 111 PossessionType: gift.GiftCommon.PossessionType, 109 112 PossessionId: gift.GiftCommon.PossessionId, 110 113 Count: gift.GiftCommon.Count, ··· 119 122 return out 120 123 } 121 124 122 - func cloneSliceMap[T any](m map[string][]T) map[string][]T { 123 - if m == nil { 124 - return nil 125 - } 126 - out := make(map[string][]T, len(m)) 127 - for k, v := range m { 128 - out[k] = append([]T(nil), v...) 129 - } 130 - return out 131 - } 132 - 133 - func cloneReceivedGifts(gifts []store.ReceivedGiftState) []store.ReceivedGiftState { 134 - out := make([]store.ReceivedGiftState, len(gifts)) 125 + func cloneReceivedGifts(gifts []ReceivedGiftState) []ReceivedGiftState { 126 + out := make([]ReceivedGiftState, len(gifts)) 135 127 for i, gift := range gifts { 136 - out[i] = store.ReceivedGiftState{ 137 - GiftCommon: store.GiftCommonState{ 128 + out[i] = ReceivedGiftState{ 129 + GiftCommon: GiftCommonState{ 138 130 PossessionType: gift.GiftCommon.PossessionType, 139 131 PossessionId: gift.GiftCommon.PossessionId, 140 132 Count: gift.GiftCommon.Count, ··· 147 139 } 148 140 return out 149 141 } 142 + 143 + func cloneSliceMap[T any](m map[string][]T) map[string][]T { 144 + if m == nil { 145 + return nil 146 + } 147 + out := make(map[string][]T, len(m)) 148 + for k, v := range m { 149 + out[k] = append([]T(nil), v...) 150 + } 151 + return out 152 + }
-198
server/internal/store/memory/memory.go
··· 1 - package memory 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - "sync" 7 - "time" 8 - 9 - "lunar-tear/server/internal/store" 10 - ) 11 - 12 - type Option func(*MemoryStore) 13 - 14 - func WithSnapshotDir(dir string) Option { 15 - return func(s *MemoryStore) { 16 - s.snapshotDir = dir 17 - } 18 - } 19 - 20 - func WithSceneId(sceneId int32) Option { 21 - return func(s *MemoryStore) { 22 - s.bootstrapSceneId = sceneId 23 - } 24 - } 25 - 26 - func WithStarterItems(v bool) Option { 27 - return func(s *MemoryStore) { 28 - s.starterItems = v 29 - } 30 - } 31 - 32 - type MemoryStore struct { 33 - mu sync.RWMutex 34 - clock store.Clock 35 - bootstrapSceneId int32 36 - snapshotDir string 37 - starterItems bool 38 - lastSnapshotSceneId int32 39 - nextUserId int64 40 - users map[int64]*store.UserState 41 - userIdsByUuid map[string]int64 42 - sessionToUserId map[string]int64 43 - sessions map[string]store.SessionState 44 - gachaCatalog map[int32]store.GachaCatalogEntry 45 - } 46 - 47 - var ( 48 - _ store.UserRepository = (*MemoryStore)(nil) 49 - _ store.SessionRepository = (*MemoryStore)(nil) 50 - _ store.GachaRepository = (*MemoryStore)(nil) 51 - ) 52 - 53 - func New(clock store.Clock, options ...Option) *MemoryStore { 54 - if clock == nil { 55 - clock = time.Now 56 - } 57 - s := &MemoryStore{ 58 - clock: clock, 59 - nextUserId: defaultUserId, 60 - users: make(map[int64]*store.UserState), 61 - userIdsByUuid: make(map[string]int64), 62 - sessionToUserId: make(map[string]int64), 63 - sessions: make(map[string]store.SessionState), 64 - gachaCatalog: make(map[int32]store.GachaCatalogEntry), 65 - } 66 - for _, opt := range options { 67 - opt(s) 68 - } 69 - return s 70 - } 71 - 72 - func (s *MemoryStore) EnsureUser(uuid string) (store.UserState, error) { 73 - s.mu.Lock() 74 - defer s.mu.Unlock() 75 - return cloneUserState(*s.getOrCreateLocked(normalizeUUID(uuid))), nil 76 - } 77 - 78 - func (s *MemoryStore) CreateSession(uuid string, ttl time.Duration) (store.UserState, store.SessionState, error) { 79 - s.mu.Lock() 80 - defer s.mu.Unlock() 81 - 82 - user := s.getOrCreateLocked(normalizeUUID(uuid)) 83 - now := s.clock() 84 - session := store.SessionState{ 85 - SessionKey: fmt.Sprintf("session_%d_%d", user.UserId, now.UnixNano()), 86 - UserId: user.UserId, 87 - Uuid: user.Uuid, 88 - ExpireAt: now.Add(ttl), 89 - } 90 - 91 - s.sessionToUserId[session.SessionKey] = user.UserId 92 - s.sessions[session.SessionKey] = session 93 - 94 - return cloneUserState(*user), session, nil 95 - } 96 - 97 - func (s *MemoryStore) ResolveUserId(sessionKey string) (int64, error) { 98 - s.mu.RLock() 99 - defer s.mu.RUnlock() 100 - 101 - userId, ok := s.sessionToUserId[sessionKey] 102 - if !ok { 103 - return 0, store.ErrNotFound 104 - } 105 - return userId, nil 106 - } 107 - 108 - func (s *MemoryStore) SnapshotUser(userId int64) (store.UserState, error) { 109 - s.mu.RLock() 110 - defer s.mu.RUnlock() 111 - 112 - user, ok := s.users[userId] 113 - if !ok { 114 - return store.UserState{}, store.ErrNotFound 115 - } 116 - return cloneUserState(*user), nil 117 - } 118 - 119 - func (s *MemoryStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) { 120 - s.mu.Lock() 121 - defer s.mu.Unlock() 122 - 123 - user, ok := s.users[userId] 124 - if !ok { 125 - return store.UserState{}, store.ErrNotFound 126 - } 127 - mutate(user) 128 - sceneId := user.MainQuest.CurrentQuestSceneId 129 - if s.snapshotDir != "" && sceneId != 0 && sceneId != s.lastSnapshotSceneId { 130 - saveSnapshot(user, s.snapshotDir) 131 - s.lastSnapshotSceneId = sceneId 132 - } 133 - return cloneUserState(*user), nil 134 - } 135 - 136 - func (s *MemoryStore) DefaultUserId() (int64, error) { 137 - s.mu.RLock() 138 - defer s.mu.RUnlock() 139 - 140 - if _, ok := s.users[defaultUserId]; ok { 141 - return defaultUserId, nil 142 - } 143 - if len(s.users) == 0 { 144 - return defaultUserId, nil 145 - } 146 - 147 - var minUserId int64 148 - for userId := range s.users { 149 - if minUserId == 0 || userId < minUserId { 150 - minUserId = userId 151 - } 152 - } 153 - return minUserId, nil 154 - } 155 - 156 - func (s *MemoryStore) SnapshotCatalog() ([]store.GachaCatalogEntry, error) { 157 - s.mu.RLock() 158 - defer s.mu.RUnlock() 159 - 160 - out := make([]store.GachaCatalogEntry, 0, len(s.gachaCatalog)) 161 - for _, entry := range s.gachaCatalog { 162 - out = append(out, cloneGachaCatalogEntry(entry)) 163 - } 164 - return out, nil 165 - } 166 - 167 - func (s *MemoryStore) ReplaceCatalog(entries []store.GachaCatalogEntry) error { 168 - s.mu.Lock() 169 - defer s.mu.Unlock() 170 - 171 - s.gachaCatalog = make(map[int32]store.GachaCatalogEntry, len(entries)) 172 - for _, entry := range entries { 173 - s.gachaCatalog[entry.GachaId] = cloneGachaCatalogEntry(entry) 174 - } 175 - return nil 176 - } 177 - 178 - func (s *MemoryStore) getOrCreateLocked(uuid string) *store.UserState { 179 - if userId, ok := s.userIdsByUuid[uuid]; ok { 180 - return s.users[userId] 181 - } 182 - 183 - userId := s.nextUserId 184 - s.nextUserId++ 185 - 186 - user := seedUserState(userId, uuid, s.clock().UnixMilli(), s.bootstrapSceneId, s.snapshotDir, s.starterItems) 187 - s.users[userId] = user 188 - s.userIdsByUuid[uuid] = userId 189 - return user 190 - } 191 - 192 - func normalizeUUID(uuid string) string { 193 - uuid = strings.TrimSpace(uuid) 194 - if uuid == "" { 195 - return defaultUUID 196 - } 197 - return uuid 198 - }
-222
server/internal/store/memory/seed.go
··· 1 - package memory 2 - 3 - import ( 4 - "fmt" 5 - "log" 6 - "time" 7 - 8 - "lunar-tear/server/internal/model" 9 - "lunar-tear/server/internal/store" 10 - ) 11 - 12 - const ( 13 - defaultUUID = "default-user" 14 - defaultUserId = int64(1001) 15 - 16 - starterMissionId = int32(1) 17 - starterMainQuestRouteId = int32(1) 18 - starterMainQuestSeasonId = int32(1) 19 - missionInProgress = int32(1) 20 - giftUUIDPrefix = "default-gift" 21 - 22 - defaultBirthYear = int32(2000) 23 - defaultBirthMonth = int32(1) 24 - defaultBackupToken = "mock-backup-token" 25 - defaultChargeMoneyThisMonth = int64(0) 26 - ) 27 - 28 - type starterItemDef struct { 29 - Type model.PossessionType 30 - Id int32 31 - Qty int32 32 - } 33 - 34 - var defaultStarterItems = []starterItemDef{ 35 - {Type: model.PossessionTypeFreeGem, Id: 0, Qty: 300}, 36 - {Type: model.PossessionTypeConsumableItem, Id: 9001, Qty: 1000}, 37 - {Type: model.PossessionTypeConsumableItem, Id: model.ConsumableIdChapterTicket, Qty: 1000}, 38 - {Type: model.PossessionTypeConsumableItem, Id: 5001, Qty: 1000}, 39 - {Type: model.PossessionTypeConsumableItem, Id: 5002, Qty: 1000}, 40 - {Type: model.PossessionTypeConsumableItem, Id: 5003, Qty: 1000}, 41 - {Type: model.PossessionTypeConsumableItem, Id: 1009, Qty: 1000}, 42 - } 43 - 44 - func seedUserState(userId int64, uuid string, nowMillis int64, sceneId int32, snapshotDir string, grantStarterItems bool) *store.UserState { 45 - if sceneId != 0 && snapshotDir != "" { 46 - user, err := loadSnapshot(snapshotDir, sceneId) 47 - if err != nil { 48 - log.Fatalf("[bootstrap] no snapshot for scene=%d: %v", sceneId, err) 49 - } 50 - log.Printf("[bootstrap] loaded snapshot for scene=%d", sceneId) 51 - if grantStarterItems { 52 - applyStarterItems(user) 53 - } 54 - return user 55 - } 56 - 57 - user := &store.UserState{ 58 - UserId: userId, 59 - Uuid: uuid, 60 - PlayerId: userId, 61 - OsType: 2, 62 - PlatformType: 2, 63 - UserRestrictionType: 0, 64 - RegisterDatetime: nowMillis, 65 - GameStartDatetime: nowMillis, 66 - LatestVersion: 0, 67 - BirthYear: defaultBirthYear, 68 - BirthMonth: defaultBirthMonth, 69 - BackupToken: defaultBackupToken, 70 - ChargeMoneyThisMonth: defaultChargeMoneyThisMonth, 71 - Setting: store.UserSettingState{ 72 - IsNotifyPurchaseAlert: false, 73 - LatestVersion: 0, 74 - }, 75 - Status: store.UserStatusState{ 76 - Level: 1, 77 - Exp: 0, 78 - StaminaMilliValue: 50000, 79 - StaminaUpdateDatetime: nowMillis, 80 - LatestVersion: 0, 81 - }, 82 - Gem: store.UserGemState{ 83 - PaidGem: 10000, 84 - FreeGem: 10000, 85 - }, 86 - Profile: store.UserProfileState{ 87 - Name: "", 88 - NameUpdateDatetime: 0, 89 - Message: "", 90 - MessageUpdateDatetime: nowMillis, 91 - FavoriteCostumeId: 0, 92 - FavoriteCostumeIdUpdateDatetime: nowMillis, 93 - LatestVersion: 0, 94 - }, 95 - Login: store.UserLoginState{ 96 - TotalLoginCount: 1, 97 - ContinualLoginCount: 1, 98 - MaxContinualLoginCount: 1, 99 - LastLoginDatetime: nowMillis, 100 - LastComebackLoginDatetime: 0, 101 - LatestVersion: 0, 102 - }, 103 - LoginBonus: store.UserLoginBonusState{ 104 - LoginBonusId: 1, 105 - CurrentPageNumber: 1, 106 - CurrentStampNumber: 0, 107 - LatestRewardReceiveDatetime: 0, 108 - LatestVersion: 0, 109 - }, 110 - Tutorials: map[int32]store.TutorialProgressState{ 111 - 1: {TutorialType: 1}, 112 - }, 113 - Battle: store.BattleState{}, 114 - Gifts: store.GiftState{ 115 - NotReceived: []store.NotReceivedGiftState{ 116 - { 117 - GiftCommon: store.GiftCommonState{ 118 - PossessionType: int32(model.PossessionTypeFreeGem), 119 - PossessionId: 0, 120 - Count: 300, 121 - GrantDatetime: nowMillis, 122 - }, 123 - ExpirationDatetime: nowMillis + int64((7*24*time.Hour)/time.Millisecond), 124 - UserGiftUuid: fmt.Sprintf("%s-%d-1", giftUUIDPrefix, userId), 125 - }, 126 - }, 127 - Received: []store.ReceivedGiftState{}, 128 - }, 129 - Gacha: store.GachaState{ 130 - ConvertedGachaMedal: store.ConvertedGachaMedalState{ 131 - ConvertedMedalPossession: []store.ConsumableItemState{}, 132 - }, 133 - BannerStates: make(map[int32]store.GachaBannerState), 134 - }, 135 - MainQuest: store.MainQuestState{ 136 - CurrentMainQuestRouteId: starterMainQuestRouteId, 137 - MainQuestSeasonId: starterMainQuestSeasonId, 138 - }, 139 - Notifications: store.NotificationState{ 140 - GiftNotReceiveCount: 1, 141 - }, 142 - Characters: make(map[int32]store.CharacterState), 143 - Costumes: make(map[string]store.CostumeState), 144 - Weapons: make(map[string]store.WeaponState), 145 - Companions: make(map[string]store.CompanionState), 146 - DeckCharacters: make(map[string]store.DeckCharacterState), 147 - Decks: make(map[store.DeckKey]store.DeckState), 148 - DeckSubWeapons: make(map[string][]string), 149 - DeckParts: make(map[string][]string), 150 - Quests: make(map[int32]store.UserQuestState), 151 - QuestMissions: make(map[store.QuestMissionKey]store.UserQuestMissionState), 152 - SideStoryQuests: make(map[int32]store.SideStoryQuestProgress), 153 - QuestLimitContentStatus: make(map[int32]store.QuestLimitContentStatus), 154 - BigHuntMaxScores: make(map[int32]store.BigHuntMaxScore), 155 - BigHuntStatuses: make(map[int32]store.BigHuntStatus), 156 - BigHuntScheduleMaxScores: make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore), 157 - BigHuntWeeklyMaxScores: make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore), 158 - BigHuntWeeklyStatuses: make(map[int64]store.BigHuntWeeklyStatus), 159 - WeaponStories: make(map[int32]store.WeaponStoryState), 160 - Missions: map[int32]store.UserMissionState{ 161 - starterMissionId: { 162 - MissionId: starterMissionId, 163 - StartDatetime: nowMillis, 164 - MissionProgressStatusType: missionInProgress, 165 - }, 166 - }, 167 - Gimmick: store.GimmickState{ 168 - Progress: make(map[store.GimmickKey]store.GimmickProgressState), 169 - OrnamentProgress: make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState), 170 - Sequences: make(map[store.GimmickSequenceKey]store.GimmickSequenceState), 171 - Unlocks: make(map[store.GimmickKey]store.GimmickUnlockState), 172 - }, 173 - CageOrnamentRewards: make(map[int32]store.CageOrnamentRewardState), 174 - ConsumableItems: make(map[int32]int32), 175 - Materials: make(map[int32]int32), 176 - Thoughts: make(map[string]store.ThoughtState), 177 - Parts: make(map[string]store.PartsState), 178 - PartsGroupNotes: make(map[int32]store.PartsGroupNoteState), 179 - PartsPresets: make(map[int32]store.PartsPresetState), 180 - ImportantItems: make(map[int32]int32), 181 - CostumeActiveSkills: make(map[string]store.CostumeActiveSkillState), 182 - WeaponSkills: make(map[string][]store.WeaponSkillState), 183 - WeaponAbilities: make(map[string][]store.WeaponAbilityState), 184 - DeckTypeNotes: make(map[model.DeckType]store.DeckTypeNoteState), 185 - WeaponNotes: make(map[int32]store.WeaponNoteState), 186 - NaviCutInPlayed: make(map[int32]bool), 187 - ViewedMovies: make(map[int32]int64), 188 - ContentsStories: make(map[int32]int64), 189 - DrawnOmikuji: make(map[int32]int64), 190 - PremiumItems: make(map[int32]int64), 191 - DokanConfirmed: make(map[int32]bool), 192 - ShopItems: make(map[int32]store.UserShopItemState), 193 - ShopReplaceableLineup: make(map[int32]store.UserShopReplaceableLineupState), 194 - ExploreScores: make(map[int32]store.ExploreScoreState), 195 - 196 - CharacterBoards: make(map[int32]store.CharacterBoardState), 197 - CharacterBoardAbilities: make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState), 198 - CharacterBoardStatusUps: make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState), 199 - 200 - CostumeAwakenStatusUps: make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState), 201 - AutoSaleSettings: make(map[int32]store.AutoSaleSettingState), 202 - CharacterRebirths: make(map[int32]store.CharacterRebirthState), 203 - } 204 - store.EnsureDefaultDeck(user, nowMillis) 205 - if grantStarterItems { 206 - applyStarterItems(user) 207 - } 208 - return user 209 - } 210 - 211 - func applyStarterItems(user *store.UserState) { 212 - for _, item := range defaultStarterItems { 213 - switch item.Type { 214 - case model.PossessionTypeFreeGem: 215 - user.Gem.FreeGem += item.Qty 216 - case model.PossessionTypeConsumableItem: 217 - user.ConsumableItems[item.Id] += item.Qty 218 - case model.PossessionTypeMaterial: 219 - user.Materials[item.Id] += item.Qty 220 - } 221 - } 222 - }
-95
server/internal/store/memory/snapshot.go
··· 1 - package memory 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "log" 7 - "os" 8 - "path/filepath" 9 - "strconv" 10 - "strings" 11 - 12 - "lunar-tear/server/internal/store" 13 - ) 14 - 15 - func snapshotPath(dir string, sceneId int32) string { 16 - return filepath.Join(dir, fmt.Sprintf("scene_%d.json", sceneId)) 17 - } 18 - 19 - func saveSnapshot(user *store.UserState, dir string) { 20 - sceneId := user.MainQuest.CurrentQuestSceneId 21 - if sceneId == 0 { 22 - return 23 - } 24 - data, err := json.MarshalIndent(user, "", " ") 25 - if err != nil { 26 - log.Printf("[snapshot] marshal error for scene=%d: %v", sceneId, err) 27 - return 28 - } 29 - path := snapshotPath(dir, sceneId) 30 - if err := os.WriteFile(path, data, 0644); err != nil { 31 - log.Printf("[snapshot] write error for scene=%d: %v", sceneId, err) 32 - return 33 - } 34 - log.Printf("[snapshot] saved scene=%d (%d bytes)", sceneId, len(data)) 35 - } 36 - 37 - // parseSceneId extracts the numeric scene ID from a filename of the form "scene_<id>.json". 38 - // Returns (0, false) if the name does not match the expected format. 39 - func parseSceneId(name string) (int32, bool) { 40 - if !strings.HasPrefix(name, "scene_") || !strings.HasSuffix(name, ".json") { 41 - return 0, false 42 - } 43 - raw := strings.TrimSuffix(strings.TrimPrefix(name, "scene_"), ".json") 44 - id, err := strconv.ParseInt(raw, 10, 32) 45 - if err != nil { 46 - return 0, false 47 - } 48 - return int32(id), true 49 - } 50 - 51 - // LatestSnapshotSceneId scans dir for scene_*.json files and returns the scene ID 52 - // of the most recently modified snapshot. Returns (0, false) if none are found. 53 - func LatestSnapshotSceneId(dir string) (int32, bool) { 54 - entries, err := os.ReadDir(dir) 55 - if err != nil { 56 - return 0, false 57 - } 58 - var latestId int32 59 - var latestMod int64 60 - for _, e := range entries { 61 - if e.IsDir() { 62 - continue 63 - } 64 - id, ok := parseSceneId(e.Name()) 65 - if !ok { 66 - continue 67 - } 68 - info, err := e.Info() 69 - if err != nil { 70 - continue 71 - } 72 - if mt := info.ModTime().UnixNano(); mt > latestMod { 73 - latestMod = mt 74 - latestId = id 75 - } 76 - } 77 - if latestId == 0 { 78 - return 0, false 79 - } 80 - return latestId, true 81 - } 82 - 83 - func loadSnapshot(dir string, sceneId int32) (*store.UserState, error) { 84 - path := snapshotPath(dir, sceneId) 85 - data, err := os.ReadFile(path) 86 - if err != nil { 87 - return nil, fmt.Errorf("read snapshot scene=%d: %w", sceneId, err) 88 - } 89 - var user store.UserState 90 - if err := json.Unmarshal(data, &user); err != nil { 91 - return nil, fmt.Errorf("unmarshal snapshot scene=%d: %w", sceneId, err) 92 - } 93 - user.EnsureMaps() 94 - return &user, nil 95 - }
+157
server/internal/store/seed.go
··· 1 + package store 2 + 3 + import ( 4 + "lunar-tear/server/internal/model" 5 + ) 6 + 7 + const ( 8 + starterMissionId = int32(1) 9 + starterMainQuestRouteId = int32(1) 10 + starterMainQuestSeasonId = int32(1) 11 + missionInProgress = int32(1) 12 + 13 + defaultBirthYear = int32(2000) 14 + defaultBirthMonth = int32(1) 15 + defaultBackupToken = "mock-backup-token" 16 + defaultChargeMoneyThisMonth = int64(0) 17 + ) 18 + 19 + func SeedUserState(userId int64, uuid string, nowMillis int64) *UserState { 20 + user := &UserState{ 21 + UserId: userId, 22 + Uuid: uuid, 23 + PlayerId: userId, 24 + OsType: 2, 25 + PlatformType: 2, 26 + UserRestrictionType: 0, 27 + RegisterDatetime: nowMillis, 28 + GameStartDatetime: nowMillis, 29 + LatestVersion: 0, 30 + BirthYear: defaultBirthYear, 31 + BirthMonth: defaultBirthMonth, 32 + BackupToken: defaultBackupToken, 33 + ChargeMoneyThisMonth: defaultChargeMoneyThisMonth, 34 + Setting: UserSettingState{ 35 + IsNotifyPurchaseAlert: false, 36 + LatestVersion: 0, 37 + }, 38 + Status: UserStatusState{ 39 + Level: 1, 40 + Exp: 0, 41 + StaminaMilliValue: 50000, 42 + StaminaUpdateDatetime: nowMillis, 43 + LatestVersion: 0, 44 + }, 45 + Gem: UserGemState{ 46 + PaidGem: 0, 47 + FreeGem: 0, 48 + }, 49 + Profile: UserProfileState{ 50 + Name: "", 51 + NameUpdateDatetime: 0, 52 + Message: "", 53 + MessageUpdateDatetime: nowMillis, 54 + FavoriteCostumeId: 0, 55 + FavoriteCostumeIdUpdateDatetime: nowMillis, 56 + LatestVersion: 0, 57 + }, 58 + Login: UserLoginState{ 59 + TotalLoginCount: 1, 60 + ContinualLoginCount: 1, 61 + MaxContinualLoginCount: 1, 62 + LastLoginDatetime: nowMillis, 63 + LastComebackLoginDatetime: 0, 64 + LatestVersion: 0, 65 + }, 66 + LoginBonus: UserLoginBonusState{ 67 + LoginBonusId: 1, 68 + CurrentPageNumber: 1, 69 + CurrentStampNumber: 0, 70 + LatestRewardReceiveDatetime: 0, 71 + LatestVersion: 0, 72 + }, 73 + Tutorials: map[int32]TutorialProgressState{ 74 + 1: {TutorialType: 1}, 75 + }, 76 + Battle: BattleState{}, 77 + Gifts: GiftState{ 78 + NotReceived: []NotReceivedGiftState{}, 79 + Received: []ReceivedGiftState{}, 80 + }, 81 + Gacha: GachaState{ 82 + ConvertedGachaMedal: ConvertedGachaMedalState{ 83 + ConvertedMedalPossession: []ConsumableItemState{}, 84 + }, 85 + BannerStates: make(map[int32]GachaBannerState), 86 + }, 87 + MainQuest: MainQuestState{ 88 + CurrentMainQuestRouteId: starterMainQuestRouteId, 89 + MainQuestSeasonId: starterMainQuestSeasonId, 90 + }, 91 + Notifications: NotificationState{ 92 + GiftNotReceiveCount: 1, 93 + }, 94 + Characters: make(map[int32]CharacterState), 95 + Costumes: make(map[string]CostumeState), 96 + Weapons: make(map[string]WeaponState), 97 + Companions: make(map[string]CompanionState), 98 + DeckCharacters: make(map[string]DeckCharacterState), 99 + Decks: make(map[DeckKey]DeckState), 100 + DeckSubWeapons: make(map[string][]string), 101 + DeckParts: make(map[string][]string), 102 + Quests: make(map[int32]UserQuestState), 103 + QuestMissions: make(map[QuestMissionKey]UserQuestMissionState), 104 + SideStoryQuests: make(map[int32]SideStoryQuestProgress), 105 + QuestLimitContentStatus: make(map[int32]QuestLimitContentStatus), 106 + BigHuntMaxScores: make(map[int32]BigHuntMaxScore), 107 + BigHuntStatuses: make(map[int32]BigHuntStatus), 108 + BigHuntScheduleMaxScores: make(map[BigHuntScheduleScoreKey]BigHuntScheduleMaxScore), 109 + BigHuntWeeklyMaxScores: make(map[BigHuntWeeklyScoreKey]BigHuntWeeklyMaxScore), 110 + BigHuntWeeklyStatuses: make(map[int64]BigHuntWeeklyStatus), 111 + WeaponStories: make(map[int32]WeaponStoryState), 112 + Missions: map[int32]UserMissionState{ 113 + starterMissionId: { 114 + MissionId: starterMissionId, 115 + StartDatetime: nowMillis, 116 + MissionProgressStatusType: missionInProgress, 117 + }, 118 + }, 119 + Gimmick: GimmickState{ 120 + Progress: make(map[GimmickKey]GimmickProgressState), 121 + OrnamentProgress: make(map[GimmickOrnamentKey]GimmickOrnamentProgressState), 122 + Sequences: make(map[GimmickSequenceKey]GimmickSequenceState), 123 + Unlocks: make(map[GimmickKey]GimmickUnlockState), 124 + }, 125 + CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), 126 + ConsumableItems: make(map[int32]int32), 127 + Materials: make(map[int32]int32), 128 + Thoughts: make(map[string]ThoughtState), 129 + Parts: make(map[string]PartsState), 130 + PartsGroupNotes: make(map[int32]PartsGroupNoteState), 131 + PartsPresets: make(map[int32]PartsPresetState), 132 + ImportantItems: make(map[int32]int32), 133 + CostumeActiveSkills: make(map[string]CostumeActiveSkillState), 134 + WeaponSkills: make(map[string][]WeaponSkillState), 135 + WeaponAbilities: make(map[string][]WeaponAbilityState), 136 + DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState), 137 + WeaponNotes: make(map[int32]WeaponNoteState), 138 + NaviCutInPlayed: make(map[int32]bool), 139 + ViewedMovies: make(map[int32]int64), 140 + ContentsStories: make(map[int32]int64), 141 + DrawnOmikuji: make(map[int32]int64), 142 + PremiumItems: make(map[int32]int64), 143 + DokanConfirmed: make(map[int32]bool), 144 + ShopItems: make(map[int32]UserShopItemState), 145 + ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState), 146 + ExploreScores: make(map[int32]ExploreScoreState), 147 + 148 + CharacterBoards: make(map[int32]CharacterBoardState), 149 + CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState), 150 + CharacterBoardStatusUps: make(map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState), 151 + 152 + CostumeAwakenStatusUps: make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState), 153 + AutoSaleSettings: make(map[int32]AutoSaleSettingState), 154 + CharacterRebirths: make(map[int32]CharacterRebirthState), 155 + } 156 + return user 157 + }
+724
server/internal/store/sqlite/load.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + "lunar-tear/server/internal/model" 8 + "lunar-tear/server/internal/store" 9 + ) 10 + 11 + func (s *SQLiteStore) LoadUser(userId int64) (store.UserState, error) { 12 + var u store.UserState 13 + 14 + err := s.db.QueryRow(`SELECT user_id, uuid, player_id, os_type, platform_type, user_restriction_type, 15 + register_datetime, game_start_datetime, latest_version, birth_year, birth_month, 16 + backup_token, charge_money_this_month FROM users WHERE user_id = ?`, userId).Scan( 17 + &u.UserId, &u.Uuid, &u.PlayerId, &u.OsType, &u.PlatformType, &u.UserRestrictionType, 18 + &u.RegisterDatetime, &u.GameStartDatetime, &u.LatestVersion, &u.BirthYear, &u.BirthMonth, 19 + &u.BackupToken, &u.ChargeMoneyThisMonth) 20 + if err == sql.ErrNoRows { 21 + return u, store.ErrNotFound 22 + } 23 + if err != nil { 24 + return u, fmt.Errorf("load users: %w", err) 25 + } 26 + 27 + initMaps(&u) 28 + 29 + load1to1(s.db, userId, &u) 30 + loadMapTables(s.db, userId, &u) 31 + 32 + return u, nil 33 + } 34 + 35 + func initMaps(u *store.UserState) { 36 + u.Tutorials = make(map[int32]store.TutorialProgressState) 37 + u.Characters = make(map[int32]store.CharacterState) 38 + u.Costumes = make(map[string]store.CostumeState) 39 + u.Weapons = make(map[string]store.WeaponState) 40 + u.Companions = make(map[string]store.CompanionState) 41 + u.Thoughts = make(map[string]store.ThoughtState) 42 + u.DeckCharacters = make(map[string]store.DeckCharacterState) 43 + u.Decks = make(map[store.DeckKey]store.DeckState) 44 + u.DeckSubWeapons = make(map[string][]string) 45 + u.DeckParts = make(map[string][]string) 46 + u.Quests = make(map[int32]store.UserQuestState) 47 + u.QuestMissions = make(map[store.QuestMissionKey]store.UserQuestMissionState) 48 + u.Missions = make(map[int32]store.UserMissionState) 49 + u.WeaponStories = make(map[int32]store.WeaponStoryState) 50 + u.WeaponNotes = make(map[int32]store.WeaponNoteState) 51 + u.WeaponSkills = make(map[string][]store.WeaponSkillState) 52 + u.WeaponAbilities = make(map[string][]store.WeaponAbilityState) 53 + u.WeaponAwakens = make(map[string]store.WeaponAwakenState) 54 + u.CostumeActiveSkills = make(map[string]store.CostumeActiveSkillState) 55 + u.CostumeAwakenStatusUps = make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState) 56 + u.CostumeLotteryEffects = make(map[store.CostumeLotteryEffectKey]store.CostumeLotteryEffectState) 57 + u.CostumeLotteryEffectPending = make(map[string]store.CostumeLotteryEffectPendingState) 58 + u.Parts = make(map[string]store.PartsState) 59 + u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState) 60 + u.PartsPresets = make(map[int32]store.PartsPresetState) 61 + u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState) 62 + u.ConsumableItems = make(map[int32]int32) 63 + u.Materials = make(map[int32]int32) 64 + u.ImportantItems = make(map[int32]int32) 65 + u.PremiumItems = make(map[int32]int64) 66 + u.NaviCutInPlayed = make(map[int32]bool) 67 + u.ViewedMovies = make(map[int32]int64) 68 + u.ContentsStories = make(map[int32]int64) 69 + u.DrawnOmikuji = make(map[int32]int64) 70 + u.DokanConfirmed = make(map[int32]bool) 71 + u.ShopItems = make(map[int32]store.UserShopItemState) 72 + u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState) 73 + u.ExploreScores = make(map[int32]store.ExploreScoreState) 74 + u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) 75 + u.CharacterBoards = make(map[int32]store.CharacterBoardState) 76 + u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) 77 + u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) 78 + u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) 79 + u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) 80 + u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) 81 + u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) 82 + u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) 83 + u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) 84 + u.BigHuntScheduleMaxScores = make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore) 85 + u.BigHuntWeeklyMaxScores = make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore) 86 + u.BigHuntWeeklyStatuses = make(map[int64]store.BigHuntWeeklyStatus) 87 + u.Gacha.BannerStates = make(map[int32]store.GachaBannerState) 88 + u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = []store.ConsumableItemState{} 89 + u.Gifts.NotReceived = []store.NotReceivedGiftState{} 90 + u.Gifts.Received = []store.ReceivedGiftState{} 91 + u.Gimmick.Progress = make(map[store.GimmickKey]store.GimmickProgressState) 92 + u.Gimmick.OrnamentProgress = make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState) 93 + u.Gimmick.Sequences = make(map[store.GimmickSequenceKey]store.GimmickSequenceState) 94 + u.Gimmick.Unlocks = make(map[store.GimmickKey]store.GimmickUnlockState) 95 + } 96 + 97 + func load1to1(db *sql.DB, uid int64, u *store.UserState) { 98 + var b int 99 + _ = db.QueryRow(`SELECT is_notify_purchase_alert, latest_version FROM user_setting WHERE user_id=?`, uid). 100 + Scan(&b, &u.Setting.LatestVersion) 101 + u.Setting.IsNotifyPurchaseAlert = b != 0 102 + 103 + _ = db.QueryRow(`SELECT level, exp, stamina_milli_value, stamina_update_datetime, latest_version FROM user_status WHERE user_id=?`, uid). 104 + Scan(&u.Status.Level, &u.Status.Exp, &u.Status.StaminaMilliValue, &u.Status.StaminaUpdateDatetime, &u.Status.LatestVersion) 105 + 106 + _ = db.QueryRow(`SELECT paid_gem, free_gem FROM user_gem WHERE user_id=?`, uid). 107 + Scan(&u.Gem.PaidGem, &u.Gem.FreeGem) 108 + 109 + _ = db.QueryRow(`SELECT name, name_update_datetime, message, message_update_datetime, favorite_costume_id, 110 + favorite_costume_id_update_datetime, latest_version FROM user_profile WHERE user_id=?`, uid). 111 + Scan(&u.Profile.Name, &u.Profile.NameUpdateDatetime, &u.Profile.Message, &u.Profile.MessageUpdateDatetime, 112 + &u.Profile.FavoriteCostumeId, &u.Profile.FavoriteCostumeIdUpdateDatetime, &u.Profile.LatestVersion) 113 + 114 + _ = db.QueryRow(`SELECT total_login_count, continual_login_count, max_continual_login_count, 115 + last_login_datetime, last_comeback_login_datetime, latest_version FROM user_login WHERE user_id=?`, uid). 116 + Scan(&u.Login.TotalLoginCount, &u.Login.ContinualLoginCount, &u.Login.MaxContinualLoginCount, 117 + &u.Login.LastLoginDatetime, &u.Login.LastComebackLoginDatetime, &u.Login.LatestVersion) 118 + 119 + _ = db.QueryRow(`SELECT login_bonus_id, current_page_number, current_stamp_number, 120 + latest_reward_receive_datetime, latest_version FROM user_login_bonus WHERE user_id=?`, uid). 121 + Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber, 122 + &u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion) 123 + 124 + _ = db.QueryRow(`SELECT current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, 125 + head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, 126 + progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, 127 + saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id 128 + FROM user_main_quest WHERE user_id=?`, uid). 129 + Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId, 130 + &u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId, 131 + &u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion, 132 + &u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId, 133 + &u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId) 134 + u.MainQuest.IsReachedLastQuestScene = b != 0 135 + 136 + _ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id, 137 + head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid). 138 + Scan(&u.EventQuest.CurrentEventQuestChapterId, &u.EventQuest.CurrentQuestId, 139 + &u.EventQuest.CurrentQuestSceneId, &u.EventQuest.HeadQuestSceneId, &u.EventQuest.LatestVersion) 140 + 141 + _ = db.QueryRow(`SELECT current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version 142 + FROM user_extra_quest WHERE user_id=?`, uid). 143 + Scan(&u.ExtraQuest.CurrentQuestId, &u.ExtraQuest.CurrentQuestSceneId, 144 + &u.ExtraQuest.HeadQuestSceneId, &u.ExtraQuest.LatestVersion) 145 + 146 + _ = db.QueryRow(`SELECT current_side_story_quest_id, current_side_story_quest_scene_id, latest_version 147 + FROM user_side_story_active WHERE user_id=?`, uid). 148 + Scan(&u.SideStoryActiveProgress.CurrentSideStoryQuestId, 149 + &u.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, &u.SideStoryActiveProgress.LatestVersion) 150 + 151 + var isDryRun int 152 + _ = db.QueryRow(`SELECT current_big_hunt_boss_quest_id, current_big_hunt_quest_id, current_quest_scene_id, 153 + is_dry_run, latest_version, deck_type, user_triple_deck_number, boss_knock_down_count, 154 + max_combo_count, total_damage, deck_number, battle_binary 155 + FROM user_big_hunt_state WHERE user_id=?`, uid). 156 + Scan(&u.BigHuntProgress.CurrentBigHuntBossQuestId, &u.BigHuntProgress.CurrentBigHuntQuestId, 157 + &u.BigHuntProgress.CurrentQuestSceneId, &isDryRun, &u.BigHuntProgress.LatestVersion, 158 + &u.BigHuntBattleDetail.DeckType, &u.BigHuntBattleDetail.UserTripleDeckNumber, 159 + &u.BigHuntBattleDetail.BossKnockDownCount, &u.BigHuntBattleDetail.MaxComboCount, 160 + &u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary) 161 + u.BigHuntProgress.IsDryRun = isDryRun != 0 162 + 163 + var isActive, isUnread int 164 + _ = db.QueryRow(`SELECT is_active, start_count, finish_count, last_started_at, last_finished_at, 165 + last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count 166 + FROM user_battle WHERE user_id=?`, uid). 167 + Scan(&isActive, &u.Battle.StartCount, &u.Battle.FinishCount, &u.Battle.LastStartedAt, 168 + &u.Battle.LastFinishedAt, &u.Battle.LastUserPartyCount, &u.Battle.LastNpcPartyCount, 169 + &u.Battle.LastBattleBinarySize, &u.Battle.LastElapsedFrameCount) 170 + u.Battle.IsActive = isActive != 0 171 + 172 + _ = db.QueryRow(`SELECT gift_not_receive_count, friend_request_receive_count, is_exist_unread_information 173 + FROM user_notification WHERE user_id=?`, uid). 174 + Scan(&u.Notifications.GiftNotReceiveCount, &u.Notifications.FriendRequestReceiveCount, &isUnread) 175 + u.Notifications.IsExistUnreadInformation = isUnread != 0 176 + 177 + var isCP int 178 + _ = db.QueryRow(`SELECT is_current_progress, drop_item_start_datetime, current_drop_item_count, latest_version 179 + FROM user_portal_cage WHERE user_id=?`, uid). 180 + Scan(&isCP, &u.PortalCageStatus.DropItemStartDatetime, &u.PortalCageStatus.CurrentDropItemCount, 181 + &u.PortalCageStatus.LatestVersion) 182 + u.PortalCageStatus.IsCurrentProgress = isCP != 0 183 + 184 + _ = db.QueryRow(`SELECT start_datetime, open_minutes, daily_opened_count, latest_version 185 + FROM user_guerrilla_free_open WHERE user_id=?`, uid). 186 + Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes, 187 + &u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion) 188 + 189 + var isTicket int 190 + _ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version 191 + FROM user_explore WHERE user_id=?`, uid). 192 + Scan(&isTicket, &u.Explore.PlayingExploreId, &u.Explore.LatestPlayDatetime, &u.Explore.LatestVersion) 193 + u.Explore.IsUseExploreTicket = isTicket != 0 194 + 195 + _ = db.QueryRow(`SELECT lineup_update_count, latest_lineup_update_datetime, latest_version 196 + FROM user_shop_replaceable WHERE user_id=?`, uid). 197 + Scan(&u.ShopReplaceable.LineupUpdateCount, &u.ShopReplaceable.LatestLineupUpdateDatetime, 198 + &u.ShopReplaceable.LatestVersion) 199 + 200 + var rewardAvail int 201 + var obtainItemId, obtainCount sql.NullInt64 202 + _ = db.QueryRow(`SELECT reward_available, todays_current_draw_count, daily_max_count, 203 + last_reward_draw_date, obtain_consumable_item_id, obtain_count 204 + FROM user_gacha WHERE user_id=?`, uid). 205 + Scan(&rewardAvail, &u.Gacha.TodaysCurrentDrawCount, &u.Gacha.DailyMaxCount, 206 + &u.Gacha.LastRewardDrawDate, &obtainItemId, &obtainCount) 207 + u.Gacha.RewardAvailable = rewardAvail != 0 208 + if obtainItemId.Valid { 209 + u.Gacha.ConvertedGachaMedal.ObtainPossession = &store.ConsumableItemState{ 210 + ConsumableItemId: int32(obtainItemId.Int64), 211 + Count: int32(obtainCount.Int64), 212 + } 213 + } 214 + } 215 + 216 + func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { 217 + queryRows(db, `SELECT character_id, level, exp, latest_version FROM user_characters WHERE user_id=?`, uid, 218 + func(rows *sql.Rows) { 219 + var v store.CharacterState 220 + rows.Scan(&v.CharacterId, &v.Level, &v.Exp, &v.LatestVersion) 221 + u.Characters[v.CharacterId] = v 222 + }) 223 + 224 + queryRows(db, `SELECT user_costume_uuid, costume_id, limit_break_count, level, exp, 225 + headup_display_view_id, acquisition_datetime, awaken_count, 226 + costume_lottery_effect_unlocked_slot_count, latest_version 227 + FROM user_costumes WHERE user_id=?`, uid, func(rows *sql.Rows) { 228 + var v store.CostumeState 229 + rows.Scan(&v.UserCostumeUuid, &v.CostumeId, &v.LimitBreakCount, &v.Level, &v.Exp, 230 + &v.HeadupDisplayViewId, &v.AcquisitionDatetime, &v.AwakenCount, 231 + &v.CostumeLotteryEffectUnlockedSlotCount, &v.LatestVersion) 232 + u.Costumes[v.UserCostumeUuid] = v 233 + }) 234 + 235 + queryRows(db, `SELECT user_weapon_uuid, weapon_id, level, exp, limit_break_count, 236 + is_protected, acquisition_datetime, latest_version FROM user_weapons WHERE user_id=?`, uid, 237 + func(rows *sql.Rows) { 238 + var v store.WeaponState 239 + var prot int 240 + rows.Scan(&v.UserWeaponUuid, &v.WeaponId, &v.Level, &v.Exp, &v.LimitBreakCount, 241 + &prot, &v.AcquisitionDatetime, &v.LatestVersion) 242 + v.IsProtected = prot != 0 243 + u.Weapons[v.UserWeaponUuid] = v 244 + }) 245 + 246 + queryRows(db, `SELECT user_companion_uuid, companion_id, headup_display_view_id, level, 247 + acquisition_datetime, latest_version FROM user_companions WHERE user_id=?`, uid, 248 + func(rows *sql.Rows) { 249 + var v store.CompanionState 250 + rows.Scan(&v.UserCompanionUuid, &v.CompanionId, &v.HeadupDisplayViewId, &v.Level, 251 + &v.AcquisitionDatetime, &v.LatestVersion) 252 + u.Companions[v.UserCompanionUuid] = v 253 + }) 254 + 255 + queryRows(db, `SELECT user_thought_uuid, thought_id, acquisition_datetime, latest_version 256 + FROM user_thoughts WHERE user_id=?`, uid, func(rows *sql.Rows) { 257 + var v store.ThoughtState 258 + rows.Scan(&v.UserThoughtUuid, &v.ThoughtId, &v.AcquisitionDatetime, &v.LatestVersion) 259 + u.Thoughts[v.UserThoughtUuid] = v 260 + }) 261 + 262 + queryRows(db, `SELECT user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, 263 + user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version 264 + FROM user_deck_characters WHERE user_id=?`, uid, func(rows *sql.Rows) { 265 + var v store.DeckCharacterState 266 + rows.Scan(&v.UserDeckCharacterUuid, &v.UserCostumeUuid, &v.MainUserWeaponUuid, 267 + &v.UserCompanionUuid, &v.Power, &v.UserThoughtUuid, &v.DressupCostumeId, &v.LatestVersion) 268 + u.DeckCharacters[v.UserDeckCharacterUuid] = v 269 + }) 270 + 271 + queryRows(db, `SELECT deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, 272 + user_deck_character_uuid03, name, power, latest_version FROM user_decks WHERE user_id=?`, uid, 273 + func(rows *sql.Rows) { 274 + var v store.DeckState 275 + var dt int32 276 + rows.Scan(&dt, &v.UserDeckNumber, &v.UserDeckCharacterUuid01, &v.UserDeckCharacterUuid02, 277 + &v.UserDeckCharacterUuid03, &v.Name, &v.Power, &v.LatestVersion) 278 + v.DeckType = model.DeckType(dt) 279 + u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v 280 + }) 281 + 282 + queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid 283 + FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid, 284 + func(rows *sql.Rows) { 285 + var key, val string 286 + var ord int 287 + rows.Scan(&key, &ord, &val) 288 + u.DeckSubWeapons[key] = append(u.DeckSubWeapons[key], val) 289 + }) 290 + 291 + queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_parts_uuid 292 + FROM user_deck_parts WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid, 293 + func(rows *sql.Rows) { 294 + var key, val string 295 + var ord int 296 + rows.Scan(&key, &ord, &val) 297 + u.DeckParts[key] = append(u.DeckParts[key], val) 298 + }) 299 + 300 + queryRows(db, `SELECT quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, 301 + clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version 302 + FROM user_quests WHERE user_id=?`, uid, func(rows *sql.Rows) { 303 + var v store.UserQuestState 304 + var bo, rg int 305 + rows.Scan(&v.QuestId, &v.QuestStateType, &bo, &v.UserDeckNumber, &v.LatestStartDatetime, 306 + &v.ClearCount, &v.DailyClearCount, &v.LastClearDatetime, &v.ShortestClearFrames, &rg, &v.LatestVersion) 307 + v.IsBattleOnly = bo != 0 308 + v.IsRewardGranted = rg != 0 309 + u.Quests[v.QuestId] = v 310 + }) 311 + 312 + queryRows(db, `SELECT quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version 313 + FROM user_quest_missions WHERE user_id=?`, uid, func(rows *sql.Rows) { 314 + var v store.UserQuestMissionState 315 + var ic int 316 + rows.Scan(&v.QuestId, &v.QuestMissionId, &v.ProgressValue, &ic, &v.LatestClearDatetime, &v.LatestVersion) 317 + v.IsClear = ic != 0 318 + u.QuestMissions[store.QuestMissionKey{QuestId: v.QuestId, QuestMissionId: v.QuestMissionId}] = v 319 + }) 320 + 321 + queryRows(db, `SELECT mission_id, start_datetime, progress_value, mission_progress_status_type, 322 + clear_datetime, latest_version FROM user_missions WHERE user_id=?`, uid, func(rows *sql.Rows) { 323 + var v store.UserMissionState 324 + rows.Scan(&v.MissionId, &v.StartDatetime, &v.ProgressValue, &v.MissionProgressStatusType, 325 + &v.ClearDatetime, &v.LatestVersion) 326 + u.Missions[v.MissionId] = v 327 + }) 328 + 329 + queryRows(db, `SELECT tutorial_type, progress_phase, choice_id, latest_version 330 + FROM user_tutorials WHERE user_id=?`, uid, func(rows *sql.Rows) { 331 + var v store.TutorialProgressState 332 + rows.Scan(&v.TutorialType, &v.ProgressPhase, &v.ChoiceId, &v.LatestVersion) 333 + u.Tutorials[v.TutorialType] = v 334 + }) 335 + 336 + queryRows(db, `SELECT side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version 337 + FROM user_side_story_quests WHERE user_id=?`, uid, func(rows *sql.Rows) { 338 + var id, head, st int32 339 + var lv int64 340 + rows.Scan(&id, &head, &st, &lv) 341 + u.SideStoryQuests[id] = store.SideStoryQuestProgress{ 342 + HeadSideStoryQuestSceneId: head, SideStoryQuestStateType: model.SideStoryQuestStateType(st), LatestVersion: lv, 343 + } 344 + }) 345 + 346 + queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version 347 + FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) { 348 + var id int32 349 + var v store.QuestLimitContentStatus 350 + rows.Scan(&id, &v.LimitContentQuestStatusType, &v.EventQuestChapterId, &v.LatestVersion) 351 + u.QuestLimitContentStatus[id] = v 352 + }) 353 + 354 + queryRows(db, `SELECT weapon_id, released_max_story_index, latest_version FROM user_weapon_stories WHERE user_id=?`, uid, 355 + func(rows *sql.Rows) { 356 + var v store.WeaponStoryState 357 + rows.Scan(&v.WeaponId, &v.ReleasedMaxStoryIndex, &v.LatestVersion) 358 + u.WeaponStories[v.WeaponId] = v 359 + }) 360 + 361 + queryRows(db, `SELECT weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version 362 + FROM user_weapon_notes WHERE user_id=?`, uid, func(rows *sql.Rows) { 363 + var v store.WeaponNoteState 364 + rows.Scan(&v.WeaponId, &v.MaxLevel, &v.MaxLimitBreakCount, &v.FirstAcquisitionDatetime, &v.LatestVersion) 365 + u.WeaponNotes[v.WeaponId] = v 366 + }) 367 + 368 + queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_skills WHERE user_id=?`, uid, 369 + func(rows *sql.Rows) { 370 + var v store.WeaponSkillState 371 + rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level) 372 + u.WeaponSkills[v.UserWeaponUuid] = append(u.WeaponSkills[v.UserWeaponUuid], v) 373 + }) 374 + 375 + queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_abilities WHERE user_id=?`, uid, 376 + func(rows *sql.Rows) { 377 + var v store.WeaponAbilityState 378 + rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level) 379 + u.WeaponAbilities[v.UserWeaponUuid] = append(u.WeaponAbilities[v.UserWeaponUuid], v) 380 + }) 381 + 382 + queryRows(db, `SELECT user_weapon_uuid, latest_version FROM user_weapon_awakens WHERE user_id=?`, uid, 383 + func(rows *sql.Rows) { 384 + var v store.WeaponAwakenState 385 + rows.Scan(&v.UserWeaponUuid, &v.LatestVersion) 386 + u.WeaponAwakens[v.UserWeaponUuid] = v 387 + }) 388 + 389 + queryRows(db, `SELECT user_costume_uuid, level, acquisition_datetime, latest_version 390 + FROM user_costume_active_skills WHERE user_id=?`, uid, func(rows *sql.Rows) { 391 + var v store.CostumeActiveSkillState 392 + rows.Scan(&v.UserCostumeUuid, &v.Level, &v.AcquisitionDatetime, &v.LatestVersion) 393 + u.CostumeActiveSkills[v.UserCostumeUuid] = v 394 + }) 395 + 396 + queryRows(db, `SELECT user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, 397 + critical_ratio, critical_attack, latest_version FROM user_costume_awaken_status_ups WHERE user_id=?`, uid, 398 + func(rows *sql.Rows) { 399 + var v store.CostumeAwakenStatusUpState 400 + var sct int32 401 + rows.Scan(&v.UserCostumeUuid, &sct, &v.Hp, &v.Attack, &v.Vitality, &v.Agility, 402 + &v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion) 403 + v.StatusCalculationType = model.StatusCalculationType(sct) 404 + u.CostumeAwakenStatusUps[store.CostumeAwakenStatusKey{ 405 + UserCostumeUuid: v.UserCostumeUuid, StatusCalculationType: v.StatusCalculationType, 406 + }] = v 407 + }) 408 + 409 + queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version 410 + FROM user_costume_lottery_effects WHERE user_id=?`, uid, 411 + func(rows *sql.Rows) { 412 + var v store.CostumeLotteryEffectState 413 + rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion) 414 + u.CostumeLotteryEffects[store.CostumeLotteryEffectKey{ 415 + UserCostumeUuid: v.UserCostumeUuid, SlotNumber: v.SlotNumber, 416 + }] = v 417 + }) 418 + 419 + queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version 420 + FROM user_costume_lottery_effect_pending WHERE user_id=?`, uid, 421 + func(rows *sql.Rows) { 422 + var v store.CostumeLotteryEffectPendingState 423 + rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion) 424 + u.CostumeLotteryEffectPending[v.UserCostumeUuid] = v 425 + }) 426 + 427 + queryRows(db, `SELECT user_parts_uuid, parts_id, level, parts_status_main_id, is_protected, 428 + acquisition_datetime, latest_version FROM user_parts WHERE user_id=?`, uid, 429 + func(rows *sql.Rows) { 430 + var v store.PartsState 431 + var prot int 432 + rows.Scan(&v.UserPartsUuid, &v.PartsId, &v.Level, &v.PartsStatusMainId, &prot, 433 + &v.AcquisitionDatetime, &v.LatestVersion) 434 + v.IsProtected = prot != 0 435 + u.Parts[v.UserPartsUuid] = v 436 + }) 437 + 438 + queryRows(db, `SELECT parts_group_id, first_acquisition_datetime, latest_version 439 + FROM user_parts_group_notes WHERE user_id=?`, uid, func(rows *sql.Rows) { 440 + var v store.PartsGroupNoteState 441 + rows.Scan(&v.PartsGroupId, &v.FirstAcquisitionDatetime, &v.LatestVersion) 442 + u.PartsGroupNotes[v.PartsGroupId] = v 443 + }) 444 + 445 + queryRows(db, `SELECT user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, 446 + name, user_parts_preset_tag_number, latest_version FROM user_parts_presets WHERE user_id=?`, uid, 447 + func(rows *sql.Rows) { 448 + var v store.PartsPresetState 449 + rows.Scan(&v.UserPartsPresetNumber, &v.UserPartsUuid01, &v.UserPartsUuid02, &v.UserPartsUuid03, 450 + &v.Name, &v.UserPartsPresetTagNumber, &v.LatestVersion) 451 + u.PartsPresets[v.UserPartsPresetNumber] = v 452 + }) 453 + 454 + queryRows(db, `SELECT deck_type, max_deck_power, latest_version FROM user_deck_type_notes WHERE user_id=?`, uid, 455 + func(rows *sql.Rows) { 456 + var dt int32 457 + var v store.DeckTypeNoteState 458 + rows.Scan(&dt, &v.MaxDeckPower, &v.LatestVersion) 459 + v.DeckType = model.DeckType(dt) 460 + u.DeckTypeNotes[v.DeckType] = v 461 + }) 462 + 463 + loadSimpleMap(db, uid, `SELECT consumable_item_id, count FROM user_consumable_items WHERE user_id=?`, u.ConsumableItems) 464 + loadSimpleMap(db, uid, `SELECT material_id, count FROM user_materials WHERE user_id=?`, u.Materials) 465 + loadSimpleMap(db, uid, `SELECT important_item_id, count FROM user_important_items WHERE user_id=?`, u.ImportantItems) 466 + 467 + queryRows(db, `SELECT premium_item_id, count FROM user_premium_items WHERE user_id=?`, uid, 468 + func(rows *sql.Rows) { 469 + var k int32 470 + var v int64 471 + rows.Scan(&k, &v) 472 + u.PremiumItems[k] = v 473 + }) 474 + 475 + queryRows(db, `SELECT explore_id, max_score, max_score_update_datetime, latest_version 476 + FROM user_explore_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { 477 + var v store.ExploreScoreState 478 + rows.Scan(&v.ExploreId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) 479 + u.ExploreScores[v.ExploreId] = v 480 + }) 481 + 482 + queryRows(db, `SELECT possession_auto_sale_item_type, possession_auto_sale_item_value 483 + FROM user_auto_sale_settings WHERE user_id=?`, uid, func(rows *sql.Rows) { 484 + var v store.AutoSaleSettingState 485 + rows.Scan(&v.PossessionAutoSaleItemType, &v.PossessionAutoSaleItemValue) 486 + u.AutoSaleSettings[v.PossessionAutoSaleItemType] = v 487 + }) 488 + 489 + queryRows(db, `SELECT navi_cutin_id FROM user_navi_cutin_played WHERE user_id=?`, uid, 490 + func(rows *sql.Rows) { 491 + var id int32 492 + rows.Scan(&id) 493 + u.NaviCutInPlayed[id] = true 494 + }) 495 + 496 + loadTimestampMap(db, uid, `SELECT movie_id, timestamp FROM user_viewed_movies WHERE user_id=?`, u.ViewedMovies) 497 + loadTimestampMap(db, uid, `SELECT contents_story_id, timestamp FROM user_contents_stories WHERE user_id=?`, u.ContentsStories) 498 + loadTimestampMap(db, uid, `SELECT omikuji_id, timestamp FROM user_drawn_omikuji WHERE user_id=?`, u.DrawnOmikuji) 499 + 500 + queryRows(db, `SELECT dokan_id FROM user_dokan_confirmed WHERE user_id=?`, uid, 501 + func(rows *sql.Rows) { 502 + var id int32 503 + rows.Scan(&id) 504 + u.DokanConfirmed[id] = true 505 + }) 506 + 507 + // Gifts 508 + queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, 509 + description_gift_text_id, equipment_data, expiration_datetime, received_datetime 510 + FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) { 511 + var uuid string 512 + var isRecv int 513 + var gc store.GiftCommonState 514 + var expDt, recvDt sql.NullInt64 515 + var equipData []byte 516 + rows.Scan(&uuid, &isRecv, &gc.PossessionType, &gc.PossessionId, &gc.Count, &gc.GrantDatetime, 517 + &gc.DescriptionGiftTextId, &equipData, &expDt, &recvDt) 518 + gc.EquipmentData = equipData 519 + if isRecv == 0 { 520 + u.Gifts.NotReceived = append(u.Gifts.NotReceived, store.NotReceivedGiftState{ 521 + GiftCommon: gc, ExpirationDatetime: expDt.Int64, UserGiftUuid: uuid, 522 + }) 523 + } else { 524 + u.Gifts.Received = append(u.Gifts.Received, store.ReceivedGiftState{ 525 + GiftCommon: gc, ReceivedDatetime: recvDt.Int64, 526 + }) 527 + } 528 + }) 529 + 530 + // Gacha converted medals 531 + queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid, 532 + func(rows *sql.Rows) { 533 + var v store.ConsumableItemState 534 + rows.Scan(&v.ConsumableItemId, &v.Count) 535 + u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v) 536 + }) 537 + 538 + // Gacha banners 539 + queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number 540 + FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) { 541 + var v store.GachaBannerState 542 + rows.Scan(&v.GachaId, &v.MedalCount, &v.StepNumber, &v.LoopCount, &v.DrawCount, &v.BoxNumber) 543 + v.BoxDrewCounts = make(map[int32]int32) 544 + u.Gacha.BannerStates[v.GachaId] = v 545 + }) 546 + queryRows(db, `SELECT gacha_id, box_item_id, count FROM user_gacha_banner_box_drew_counts WHERE user_id=?`, uid, 547 + func(rows *sql.Rows) { 548 + var gachaId, boxItemId, count int32 549 + rows.Scan(&gachaId, &boxItemId, &count) 550 + if bs, ok := u.Gacha.BannerStates[gachaId]; ok { 551 + bs.BoxDrewCounts[boxItemId] = count 552 + u.Gacha.BannerStates[gachaId] = bs 553 + } 554 + }) 555 + 556 + // Character boards 557 + queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, 558 + panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid, 559 + func(rows *sql.Rows) { 560 + var v store.CharacterBoardState 561 + rows.Scan(&v.CharacterBoardId, &v.PanelReleaseBit1, &v.PanelReleaseBit2, 562 + &v.PanelReleaseBit3, &v.PanelReleaseBit4, &v.LatestVersion) 563 + u.CharacterBoards[v.CharacterBoardId] = v 564 + }) 565 + 566 + queryRows(db, `SELECT character_id, ability_id, level, latest_version 567 + FROM user_character_board_abilities WHERE user_id=?`, uid, func(rows *sql.Rows) { 568 + var v store.CharacterBoardAbilityState 569 + rows.Scan(&v.CharacterId, &v.AbilityId, &v.Level, &v.LatestVersion) 570 + u.CharacterBoardAbilities[store.CharacterBoardAbilityKey{CharacterId: v.CharacterId, AbilityId: v.AbilityId}] = v 571 + }) 572 + 573 + queryRows(db, `SELECT character_id, status_calculation_type, hp, attack, vitality, agility, 574 + critical_ratio, critical_attack, latest_version FROM user_character_board_status_ups WHERE user_id=?`, uid, 575 + func(rows *sql.Rows) { 576 + var v store.CharacterBoardStatusUpState 577 + rows.Scan(&v.CharacterId, &v.StatusCalculationType, &v.Hp, &v.Attack, &v.Vitality, &v.Agility, 578 + &v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion) 579 + u.CharacterBoardStatusUps[store.CharacterBoardStatusUpKey{ 580 + CharacterId: v.CharacterId, StatusCalculationType: v.StatusCalculationType, 581 + }] = v 582 + }) 583 + 584 + queryRows(db, `SELECT character_id, rebirth_count, latest_version FROM user_character_rebirths WHERE user_id=?`, uid, 585 + func(rows *sql.Rows) { 586 + var v store.CharacterRebirthState 587 + rows.Scan(&v.CharacterId, &v.RebirthCount, &v.LatestVersion) 588 + u.CharacterRebirths[v.CharacterId] = v 589 + }) 590 + 591 + queryRows(db, `SELECT cage_ornament_id, acquisition_datetime, latest_version 592 + FROM user_cage_ornament_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) { 593 + var v store.CageOrnamentRewardState 594 + rows.Scan(&v.CageOrnamentId, &v.AcquisitionDatetime, &v.LatestVersion) 595 + u.CageOrnamentRewards[v.CageOrnamentId] = v 596 + }) 597 + 598 + queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version 599 + FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) { 600 + var v store.UserShopItemState 601 + rows.Scan(&v.ShopItemId, &v.BoughtCount, &v.LatestBoughtCountChangedDatetime, &v.LatestVersion) 602 + u.ShopItems[v.ShopItemId] = v 603 + }) 604 + 605 + queryRows(db, `SELECT slot_number, shop_item_id, latest_version FROM user_shop_replaceable_lineup WHERE user_id=?`, uid, 606 + func(rows *sql.Rows) { 607 + var v store.UserShopReplaceableLineupState 608 + rows.Scan(&v.SlotNumber, &v.ShopItemId, &v.LatestVersion) 609 + u.ShopReplaceableLineup[v.SlotNumber] = v 610 + }) 611 + 612 + // Gimmick tables 613 + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, 614 + is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid, 615 + func(rows *sql.Rows) { 616 + var v store.GimmickProgressState 617 + var ic int 618 + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, 619 + &ic, &v.StartDatetime, &v.LatestVersion) 620 + v.IsGimmickCleared = ic != 0 621 + u.Gimmick.Progress[v.Key] = v 622 + }) 623 + 624 + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, 625 + gimmick_ornament_index, progress_value_bit, base_datetime, latest_version 626 + FROM user_gimmick_ornament_progress WHERE user_id=?`, uid, func(rows *sql.Rows) { 627 + var v store.GimmickOrnamentProgressState 628 + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, 629 + &v.Key.GimmickOrnamentIndex, &v.ProgressValueBit, &v.BaseDatetime, &v.LatestVersion) 630 + u.Gimmick.OrnamentProgress[v.Key] = v 631 + }) 632 + 633 + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, 634 + is_gimmick_sequence_cleared, clear_datetime, latest_version FROM user_gimmick_sequences WHERE user_id=?`, uid, 635 + func(rows *sql.Rows) { 636 + var v store.GimmickSequenceState 637 + var ic int 638 + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, 639 + &ic, &v.ClearDatetime, &v.LatestVersion) 640 + v.IsGimmickSequenceCleared = ic != 0 641 + u.Gimmick.Sequences[v.Key] = v 642 + }) 643 + 644 + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, 645 + is_unlocked, latest_version FROM user_gimmick_unlocks WHERE user_id=?`, uid, 646 + func(rows *sql.Rows) { 647 + var v store.GimmickUnlockState 648 + var iu int 649 + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, 650 + &iu, &v.LatestVersion) 651 + v.IsUnlocked = iu != 0 652 + u.Gimmick.Unlocks[v.Key] = v 653 + }) 654 + 655 + // Big hunt maps 656 + queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version 657 + FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { 658 + var id int32 659 + var v store.BigHuntMaxScore 660 + rows.Scan(&id, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) 661 + u.BigHuntMaxScores[id] = v 662 + }) 663 + 664 + queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version 665 + FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) { 666 + var id int32 667 + var v store.BigHuntStatus 668 + rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LatestVersion) 669 + u.BigHuntStatuses[id] = v 670 + }) 671 + 672 + queryRows(db, `SELECT big_hunt_schedule_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version 673 + FROM user_big_hunt_schedule_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { 674 + var k store.BigHuntScheduleScoreKey 675 + var v store.BigHuntScheduleMaxScore 676 + rows.Scan(&k.BigHuntScheduleId, &k.BigHuntBossId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) 677 + u.BigHuntScheduleMaxScores[k] = v 678 + }) 679 + 680 + queryRows(db, `SELECT big_hunt_weekly_version, attribute_type, max_score, latest_version 681 + FROM user_big_hunt_weekly_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { 682 + var k store.BigHuntWeeklyScoreKey 683 + var v store.BigHuntWeeklyMaxScore 684 + rows.Scan(&k.BigHuntWeeklyVersion, &k.AttributeType, &v.MaxScore, &v.LatestVersion) 685 + u.BigHuntWeeklyMaxScores[k] = v 686 + }) 687 + 688 + queryRows(db, `SELECT big_hunt_weekly_version, is_received_weekly_reward, latest_version 689 + FROM user_big_hunt_weekly_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) { 690 + var ver int64 691 + var ir int 692 + var lv int64 693 + rows.Scan(&ver, &ir, &lv) 694 + u.BigHuntWeeklyStatuses[ver] = store.BigHuntWeeklyStatus{IsReceivedWeeklyReward: ir != 0, LatestVersion: lv} 695 + }) 696 + } 697 + 698 + func queryRows(db *sql.DB, query string, uid int64, scan func(*sql.Rows)) { 699 + rows, err := db.Query(query, uid) 700 + if err != nil { 701 + return 702 + } 703 + defer rows.Close() 704 + for rows.Next() { 705 + scan(rows) 706 + } 707 + } 708 + 709 + func loadSimpleMap(db *sql.DB, uid int64, query string, m map[int32]int32) { 710 + queryRows(db, query, uid, func(rows *sql.Rows) { 711 + var k, v int32 712 + rows.Scan(&k, &v) 713 + m[k] = v 714 + }) 715 + } 716 + 717 + func loadTimestampMap(db *sql.DB, uid int64, query string, m map[int32]int64) { 718 + queryRows(db, query, uid, func(rows *sql.Rows) { 719 + var k int32 720 + var v int64 721 + rows.Scan(&k, &v) 722 + m[k] = v 723 + }) 724 + }
+1136
server/internal/store/sqlite/save.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + "lunar-tear/server/internal/model" 8 + "lunar-tear/server/internal/store" 9 + ) 10 + 11 + func boolToInt(b bool) int { 12 + if b { 13 + return 1 14 + } 15 + return 0 16 + } 17 + 18 + // writeUserState inserts all child table rows for a newly created user. 19 + // The users row must already exist. 20 + func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { 21 + exec := func(query string, args ...any) error { 22 + _, err := tx.Exec(query, args...) 23 + return err 24 + } 25 + 26 + if err := exec(`INSERT INTO user_setting (user_id, is_notify_purchase_alert, latest_version) VALUES (?,?,?)`, 27 + uid, boolToInt(u.Setting.IsNotifyPurchaseAlert), u.Setting.LatestVersion); err != nil { 28 + return err 29 + } 30 + if err := exec(`INSERT INTO user_status (user_id, level, exp, stamina_milli_value, stamina_update_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 31 + uid, u.Status.Level, u.Status.Exp, u.Status.StaminaMilliValue, u.Status.StaminaUpdateDatetime, u.Status.LatestVersion); err != nil { 32 + return err 33 + } 34 + if err := exec(`INSERT INTO user_gem (user_id, paid_gem, free_gem) VALUES (?,?,?)`, 35 + uid, u.Gem.PaidGem, u.Gem.FreeGem); err != nil { 36 + return err 37 + } 38 + if err := exec(`INSERT INTO user_profile (user_id, name, name_update_datetime, message, message_update_datetime, favorite_costume_id, favorite_costume_id_update_datetime, latest_version) VALUES (?,?,?,?,?,?,?,?)`, 39 + uid, u.Profile.Name, u.Profile.NameUpdateDatetime, u.Profile.Message, u.Profile.MessageUpdateDatetime, 40 + u.Profile.FavoriteCostumeId, u.Profile.FavoriteCostumeIdUpdateDatetime, u.Profile.LatestVersion); err != nil { 41 + return err 42 + } 43 + if err := exec(`INSERT INTO user_login (user_id, total_login_count, continual_login_count, max_continual_login_count, last_login_datetime, last_comeback_login_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 44 + uid, u.Login.TotalLoginCount, u.Login.ContinualLoginCount, u.Login.MaxContinualLoginCount, 45 + u.Login.LastLoginDatetime, u.Login.LastComebackLoginDatetime, u.Login.LatestVersion); err != nil { 46 + return err 47 + } 48 + if err := exec(`INSERT INTO user_login_bonus (user_id, login_bonus_id, current_page_number, current_stamp_number, latest_reward_receive_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 49 + uid, u.LoginBonus.LoginBonusId, u.LoginBonus.CurrentPageNumber, u.LoginBonus.CurrentStampNumber, 50 + u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil { 51 + return err 52 + } 53 + if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, 54 + uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId, 55 + u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId, 56 + u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId, 57 + u.MainQuest.LatestVersion, u.MainQuest.SavedCurrentQuestSceneId, u.MainQuest.SavedHeadQuestSceneId, 58 + u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil { 59 + return err 60 + } 61 + if err := exec(`INSERT INTO user_event_quest (user_id, current_event_quest_chapter_id, current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version) VALUES (?,?,?,?,?,?)`, 62 + uid, u.EventQuest.CurrentEventQuestChapterId, u.EventQuest.CurrentQuestId, u.EventQuest.CurrentQuestSceneId, 63 + u.EventQuest.HeadQuestSceneId, u.EventQuest.LatestVersion); err != nil { 64 + return err 65 + } 66 + if err := exec(`INSERT INTO user_extra_quest (user_id, current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version) VALUES (?,?,?,?,?)`, 67 + uid, u.ExtraQuest.CurrentQuestId, u.ExtraQuest.CurrentQuestSceneId, u.ExtraQuest.HeadQuestSceneId, u.ExtraQuest.LatestVersion); err != nil { 68 + return err 69 + } 70 + if err := exec(`INSERT INTO user_side_story_active (user_id, current_side_story_quest_id, current_side_story_quest_scene_id, latest_version) VALUES (?,?,?,?)`, 71 + uid, u.SideStoryActiveProgress.CurrentSideStoryQuestId, u.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, u.SideStoryActiveProgress.LatestVersion); err != nil { 72 + return err 73 + } 74 + if err := exec(`INSERT INTO user_big_hunt_state (user_id, current_big_hunt_boss_quest_id, current_big_hunt_quest_id, current_quest_scene_id, is_dry_run, latest_version, deck_type, user_triple_deck_number, boss_knock_down_count, max_combo_count, total_damage, deck_number, battle_binary) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, 75 + uid, u.BigHuntProgress.CurrentBigHuntBossQuestId, u.BigHuntProgress.CurrentBigHuntQuestId, 76 + u.BigHuntProgress.CurrentQuestSceneId, boolToInt(u.BigHuntProgress.IsDryRun), u.BigHuntProgress.LatestVersion, 77 + u.BigHuntBattleDetail.DeckType, u.BigHuntBattleDetail.UserTripleDeckNumber, u.BigHuntBattleDetail.BossKnockDownCount, 78 + u.BigHuntBattleDetail.MaxComboCount, u.BigHuntBattleDetail.TotalDamage, u.BigHuntDeckNumber, u.BigHuntBattleBinary); err != nil { 79 + return err 80 + } 81 + if err := exec(`INSERT INTO user_battle (user_id, is_active, start_count, finish_count, last_started_at, last_finished_at, last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count) VALUES (?,?,?,?,?,?,?,?,?,?)`, 82 + uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt, 83 + u.Battle.LastFinishedAt, u.Battle.LastUserPartyCount, u.Battle.LastNpcPartyCount, 84 + u.Battle.LastBattleBinarySize, u.Battle.LastElapsedFrameCount); err != nil { 85 + return err 86 + } 87 + if err := exec(`INSERT INTO user_notification (user_id, gift_not_receive_count, friend_request_receive_count, is_exist_unread_information) VALUES (?,?,?,?)`, 88 + uid, u.Notifications.GiftNotReceiveCount, u.Notifications.FriendRequestReceiveCount, 89 + boolToInt(u.Notifications.IsExistUnreadInformation)); err != nil { 90 + return err 91 + } 92 + if err := exec(`INSERT INTO user_portal_cage (user_id, is_current_progress, drop_item_start_datetime, current_drop_item_count, latest_version) VALUES (?,?,?,?,?)`, 93 + uid, boolToInt(u.PortalCageStatus.IsCurrentProgress), u.PortalCageStatus.DropItemStartDatetime, 94 + u.PortalCageStatus.CurrentDropItemCount, u.PortalCageStatus.LatestVersion); err != nil { 95 + return err 96 + } 97 + if err := exec(`INSERT INTO user_guerrilla_free_open (user_id, start_datetime, open_minutes, daily_opened_count, latest_version) VALUES (?,?,?,?,?)`, 98 + uid, u.GuerrillaFreeOpen.StartDatetime, u.GuerrillaFreeOpen.OpenMinutes, u.GuerrillaFreeOpen.DailyOpenedCount, u.GuerrillaFreeOpen.LatestVersion); err != nil { 99 + return err 100 + } 101 + if err := exec(`INSERT INTO user_explore (user_id, is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version) VALUES (?,?,?,?,?)`, 102 + uid, boolToInt(u.Explore.IsUseExploreTicket), u.Explore.PlayingExploreId, u.Explore.LatestPlayDatetime, u.Explore.LatestVersion); err != nil { 103 + return err 104 + } 105 + if err := exec(`INSERT INTO user_shop_replaceable (user_id, lineup_update_count, latest_lineup_update_datetime, latest_version) VALUES (?,?,?,?)`, 106 + uid, u.ShopReplaceable.LineupUpdateCount, u.ShopReplaceable.LatestLineupUpdateDatetime, u.ShopReplaceable.LatestVersion); err != nil { 107 + return err 108 + } 109 + 110 + var obtainItemId, obtainCount sql.NullInt64 111 + if u.Gacha.ConvertedGachaMedal.ObtainPossession != nil { 112 + obtainItemId = sql.NullInt64{Int64: int64(u.Gacha.ConvertedGachaMedal.ObtainPossession.ConsumableItemId), Valid: true} 113 + obtainCount = sql.NullInt64{Int64: int64(u.Gacha.ConvertedGachaMedal.ObtainPossession.Count), Valid: true} 114 + } 115 + if err := exec(`INSERT INTO user_gacha (user_id, reward_available, todays_current_draw_count, daily_max_count, last_reward_draw_date, obtain_consumable_item_id, obtain_count) VALUES (?,?,?,?,?,?,?)`, 116 + uid, boolToInt(u.Gacha.RewardAvailable), u.Gacha.TodaysCurrentDrawCount, u.Gacha.DailyMaxCount, 117 + u.Gacha.LastRewardDrawDate, obtainItemId, obtainCount); err != nil { 118 + return err 119 + } 120 + 121 + // Map tables 122 + for _, v := range u.Characters { 123 + if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`, 124 + uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil { 125 + return err 126 + } 127 + } 128 + for _, v := range u.Costumes { 129 + if err := exec(`INSERT INTO user_costumes (user_id, user_costume_uuid, costume_id, limit_break_count, level, exp, headup_display_view_id, acquisition_datetime, awaken_count, costume_lottery_effect_unlocked_slot_count, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, 130 + uid, v.UserCostumeUuid, v.CostumeId, v.LimitBreakCount, v.Level, v.Exp, v.HeadupDisplayViewId, v.AcquisitionDatetime, v.AwakenCount, v.CostumeLotteryEffectUnlockedSlotCount, v.LatestVersion); err != nil { 131 + return err 132 + } 133 + } 134 + for _, v := range u.Weapons { 135 + if err := exec(`INSERT INTO user_weapons (user_id, user_weapon_uuid, weapon_id, level, exp, limit_break_count, is_protected, acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, 136 + uid, v.UserWeaponUuid, v.WeaponId, v.Level, v.Exp, v.LimitBreakCount, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion); err != nil { 137 + return err 138 + } 139 + } 140 + for _, v := range u.Companions { 141 + if err := exec(`INSERT INTO user_companions (user_id, user_companion_uuid, companion_id, headup_display_view_id, level, acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 142 + uid, v.UserCompanionUuid, v.CompanionId, v.HeadupDisplayViewId, v.Level, v.AcquisitionDatetime, v.LatestVersion); err != nil { 143 + return err 144 + } 145 + } 146 + for _, v := range u.Thoughts { 147 + if err := exec(`INSERT INTO user_thoughts (user_id, user_thought_uuid, thought_id, acquisition_datetime, latest_version) VALUES (?,?,?,?,?)`, 148 + uid, v.UserThoughtUuid, v.ThoughtId, v.AcquisitionDatetime, v.LatestVersion); err != nil { 149 + return err 150 + } 151 + } 152 + for _, v := range u.DeckCharacters { 153 + if err := exec(`INSERT INTO user_deck_characters (user_id, user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, 154 + uid, v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion); err != nil { 155 + return err 156 + } 157 + } 158 + for k, v := range u.Decks { 159 + if err := exec(`INSERT INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, 160 + uid, int32(k.DeckType), k.UserDeckNumber, v.UserDeckCharacterUuid01, v.UserDeckCharacterUuid02, v.UserDeckCharacterUuid03, v.Name, v.Power, v.LatestVersion); err != nil { 161 + return err 162 + } 163 + } 164 + for key, uuids := range u.DeckSubWeapons { 165 + for i, uuid := range uuids { 166 + if err := exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, 167 + uid, key, i, uuid); err != nil { 168 + return err 169 + } 170 + } 171 + } 172 + for key, uuids := range u.DeckParts { 173 + for i, uuid := range uuids { 174 + if err := exec(`INSERT INTO user_deck_parts (user_id, user_deck_character_uuid, ordinal, user_parts_uuid) VALUES (?,?,?,?)`, 175 + uid, key, i, uuid); err != nil { 176 + return err 177 + } 178 + } 179 + } 180 + for _, v := range u.Quests { 181 + if err := exec(`INSERT INTO user_quests (user_id, quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, 182 + uid, v.QuestId, int32(v.QuestStateType), boolToInt(v.IsBattleOnly), v.UserDeckNumber, v.LatestStartDatetime, 183 + v.ClearCount, v.DailyClearCount, v.LastClearDatetime, v.ShortestClearFrames, boolToInt(v.IsRewardGranted), v.LatestVersion); err != nil { 184 + return err 185 + } 186 + } 187 + for k, v := range u.QuestMissions { 188 + if err := exec(`INSERT INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 189 + uid, k.QuestId, k.QuestMissionId, v.ProgressValue, boolToInt(v.IsClear), v.LatestClearDatetime, v.LatestVersion); err != nil { 190 + return err 191 + } 192 + } 193 + for _, v := range u.Missions { 194 + if err := exec(`INSERT INTO user_missions (user_id, mission_id, start_datetime, progress_value, mission_progress_status_type, clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 195 + uid, v.MissionId, v.StartDatetime, v.ProgressValue, v.MissionProgressStatusType, v.ClearDatetime, v.LatestVersion); err != nil { 196 + return err 197 + } 198 + } 199 + for _, v := range u.Tutorials { 200 + if err := exec(`INSERT INTO user_tutorials (user_id, tutorial_type, progress_phase, choice_id, latest_version) VALUES (?,?,?,?,?)`, 201 + uid, v.TutorialType, v.ProgressPhase, v.ChoiceId, v.LatestVersion); err != nil { 202 + return err 203 + } 204 + } 205 + for id, v := range u.SideStoryQuests { 206 + if err := exec(`INSERT INTO user_side_story_quests (user_id, side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version) VALUES (?,?,?,?,?)`, 207 + uid, id, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion); err != nil { 208 + return err 209 + } 210 + } 211 + for id, v := range u.QuestLimitContentStatus { 212 + if err := exec(`INSERT INTO user_quest_limit_content_status (user_id, limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version) VALUES (?,?,?,?,?)`, 213 + uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil { 214 + return err 215 + } 216 + } 217 + for _, v := range u.WeaponStories { 218 + if err := exec(`INSERT INTO user_weapon_stories (user_id, weapon_id, released_max_story_index, latest_version) VALUES (?,?,?,?)`, 219 + uid, v.WeaponId, v.ReleasedMaxStoryIndex, v.LatestVersion); err != nil { 220 + return err 221 + } 222 + } 223 + for _, v := range u.WeaponNotes { 224 + if err := exec(`INSERT INTO user_weapon_notes (user_id, weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 225 + uid, v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion); err != nil { 226 + return err 227 + } 228 + } 229 + for _, skills := range u.WeaponSkills { 230 + for _, v := range skills { 231 + if err := exec(`INSERT INTO user_weapon_skills (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, 232 + uid, v.UserWeaponUuid, v.SlotNumber, v.Level); err != nil { 233 + return err 234 + } 235 + } 236 + } 237 + for _, abilities := range u.WeaponAbilities { 238 + for _, v := range abilities { 239 + if err := exec(`INSERT INTO user_weapon_abilities (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, 240 + uid, v.UserWeaponUuid, v.SlotNumber, v.Level); err != nil { 241 + return err 242 + } 243 + } 244 + } 245 + for _, v := range u.WeaponAwakens { 246 + if err := exec(`INSERT INTO user_weapon_awakens (user_id, user_weapon_uuid, latest_version) VALUES (?,?,?)`, 247 + uid, v.UserWeaponUuid, v.LatestVersion); err != nil { 248 + return err 249 + } 250 + } 251 + for _, v := range u.CostumeActiveSkills { 252 + if err := exec(`INSERT INTO user_costume_active_skills (user_id, user_costume_uuid, level, acquisition_datetime, latest_version) VALUES (?,?,?,?,?)`, 253 + uid, v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion); err != nil { 254 + return err 255 + } 256 + } 257 + for k, v := range u.CostumeAwakenStatusUps { 258 + if err := exec(`INSERT INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, 259 + uid, k.UserCostumeUuid, int32(k.StatusCalculationType), v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion); err != nil { 260 + return err 261 + } 262 + } 263 + for k, v := range u.CostumeLotteryEffects { 264 + if err := exec(`INSERT INTO user_costume_lottery_effects (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, 265 + uid, k.UserCostumeUuid, k.SlotNumber, v.OddsNumber, v.LatestVersion); err != nil { 266 + return err 267 + } 268 + } 269 + for _, v := range u.CostumeLotteryEffectPending { 270 + if err := exec(`INSERT INTO user_costume_lottery_effect_pending (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, 271 + uid, v.UserCostumeUuid, v.SlotNumber, v.OddsNumber, v.LatestVersion); err != nil { 272 + return err 273 + } 274 + } 275 + for _, v := range u.Parts { 276 + if err := exec(`INSERT INTO user_parts (user_id, user_parts_uuid, parts_id, level, parts_status_main_id, is_protected, acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?,?,?)`, 277 + uid, v.UserPartsUuid, v.PartsId, v.Level, v.PartsStatusMainId, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion); err != nil { 278 + return err 279 + } 280 + } 281 + for _, v := range u.PartsGroupNotes { 282 + if err := exec(`INSERT INTO user_parts_group_notes (user_id, parts_group_id, first_acquisition_datetime, latest_version) VALUES (?,?,?,?)`, 283 + uid, v.PartsGroupId, v.FirstAcquisitionDatetime, v.LatestVersion); err != nil { 284 + return err 285 + } 286 + } 287 + for _, v := range u.PartsPresets { 288 + if err := exec(`INSERT INTO user_parts_presets (user_id, user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version) VALUES (?,?,?,?,?,?,?,?)`, 289 + uid, v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion); err != nil { 290 + return err 291 + } 292 + } 293 + for _, v := range u.DeckTypeNotes { 294 + if err := exec(`INSERT INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, 295 + uid, int32(v.DeckType), v.MaxDeckPower, v.LatestVersion); err != nil { 296 + return err 297 + } 298 + } 299 + for k, v := range u.ConsumableItems { 300 + if err := exec(`INSERT INTO user_consumable_items (user_id, consumable_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { 301 + return err 302 + } 303 + } 304 + for k, v := range u.Materials { 305 + if err := exec(`INSERT INTO user_materials (user_id, material_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { 306 + return err 307 + } 308 + } 309 + for k, v := range u.ImportantItems { 310 + if err := exec(`INSERT INTO user_important_items (user_id, important_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { 311 + return err 312 + } 313 + } 314 + for k, v := range u.PremiumItems { 315 + if err := exec(`INSERT INTO user_premium_items (user_id, premium_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { 316 + return err 317 + } 318 + } 319 + for _, v := range u.ExploreScores { 320 + if err := exec(`INSERT INTO user_explore_scores (user_id, explore_id, max_score, max_score_update_datetime, latest_version) VALUES (?,?,?,?,?)`, 321 + uid, v.ExploreId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { 322 + return err 323 + } 324 + } 325 + for _, v := range u.AutoSaleSettings { 326 + if err := exec(`INSERT INTO user_auto_sale_settings (user_id, possession_auto_sale_item_type, possession_auto_sale_item_value) VALUES (?,?,?)`, 327 + uid, v.PossessionAutoSaleItemType, v.PossessionAutoSaleItemValue); err != nil { 328 + return err 329 + } 330 + } 331 + for k := range u.NaviCutInPlayed { 332 + if err := exec(`INSERT INTO user_navi_cutin_played (user_id, navi_cutin_id) VALUES (?,?)`, uid, k); err != nil { 333 + return err 334 + } 335 + } 336 + for k, v := range u.ViewedMovies { 337 + if err := exec(`INSERT INTO user_viewed_movies (user_id, movie_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { 338 + return err 339 + } 340 + } 341 + for k, v := range u.ContentsStories { 342 + if err := exec(`INSERT INTO user_contents_stories (user_id, contents_story_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { 343 + return err 344 + } 345 + } 346 + for k, v := range u.DrawnOmikuji { 347 + if err := exec(`INSERT INTO user_drawn_omikuji (user_id, omikuji_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { 348 + return err 349 + } 350 + } 351 + for k := range u.DokanConfirmed { 352 + if err := exec(`INSERT INTO user_dokan_confirmed (user_id, dokan_id) VALUES (?,?)`, uid, k); err != nil { 353 + return err 354 + } 355 + } 356 + for _, g := range u.Gifts.NotReceived { 357 + var expDt sql.NullInt64 358 + if g.ExpirationDatetime != 0 { 359 + expDt = sql.NullInt64{Int64: g.ExpirationDatetime, Valid: true} 360 + } 361 + if err := exec(`INSERT INTO user_gifts (user_id, user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, description_gift_text_id, equipment_data, expiration_datetime) VALUES (?,?,0,?,?,?,?,?,?,?)`, 362 + uid, g.UserGiftUuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, 363 + g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, expDt); err != nil { 364 + return err 365 + } 366 + } 367 + for i, g := range u.Gifts.Received { 368 + uuid := fmt.Sprintf("received-%d-%d", uid, i) 369 + if err := exec(`INSERT INTO user_gifts (user_id, user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, description_gift_text_id, equipment_data, received_datetime) VALUES (?,?,1,?,?,?,?,?,?,?)`, 370 + uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, 371 + g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime); err != nil { 372 + return err 373 + } 374 + } 375 + for i, v := range u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { 376 + if err := exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, 377 + uid, i, v.ConsumableItemId, v.Count); err != nil { 378 + return err 379 + } 380 + } 381 + for _, v := range u.Gacha.BannerStates { 382 + if err := exec(`INSERT INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, 383 + uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber); err != nil { 384 + return err 385 + } 386 + for itemId, count := range v.BoxDrewCounts { 387 + if err := exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, 388 + uid, v.GachaId, itemId, count); err != nil { 389 + return err 390 + } 391 + } 392 + } 393 + for _, v := range u.CharacterBoards { 394 + if err := exec(`INSERT INTO user_character_boards (user_id, character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version) VALUES (?,?,?,?,?,?,?)`, 395 + uid, v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion); err != nil { 396 + return err 397 + } 398 + } 399 + for k, v := range u.CharacterBoardAbilities { 400 + if err := exec(`INSERT INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, 401 + uid, k.CharacterId, k.AbilityId, v.Level, v.LatestVersion); err != nil { 402 + return err 403 + } 404 + } 405 + for k, v := range u.CharacterBoardStatusUps { 406 + if err := exec(`INSERT INTO user_character_board_status_ups (user_id, character_id, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, 407 + uid, k.CharacterId, k.StatusCalculationType, v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion); err != nil { 408 + return err 409 + } 410 + } 411 + for _, v := range u.CharacterRebirths { 412 + if err := exec(`INSERT INTO user_character_rebirths (user_id, character_id, rebirth_count, latest_version) VALUES (?,?,?,?)`, 413 + uid, v.CharacterId, v.RebirthCount, v.LatestVersion); err != nil { 414 + return err 415 + } 416 + } 417 + for _, v := range u.CageOrnamentRewards { 418 + if err := exec(`INSERT INTO user_cage_ornament_rewards (user_id, cage_ornament_id, acquisition_datetime, latest_version) VALUES (?,?,?,?)`, 419 + uid, v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion); err != nil { 420 + return err 421 + } 422 + } 423 + for _, v := range u.ShopItems { 424 + if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`, 425 + uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil { 426 + return err 427 + } 428 + } 429 + for _, v := range u.ShopReplaceableLineup { 430 + if err := exec(`INSERT INTO user_shop_replaceable_lineup (user_id, slot_number, shop_item_id, latest_version) VALUES (?,?,?,?)`, 431 + uid, v.SlotNumber, v.ShopItemId, v.LatestVersion); err != nil { 432 + return err 433 + } 434 + } 435 + for k, v := range u.Gimmick.Progress { 436 + if err := exec(`INSERT INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 437 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsGimmickCleared), v.StartDatetime, v.LatestVersion); err != nil { 438 + return err 439 + } 440 + } 441 + for k, v := range u.Gimmick.OrnamentProgress { 442 + if err := exec(`INSERT INTO user_gimmick_ornament_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, gimmick_ornament_index, progress_value_bit, base_datetime, latest_version) VALUES (?,?,?,?,?,?,?,?)`, 443 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex, v.ProgressValueBit, v.BaseDatetime, v.LatestVersion); err != nil { 444 + return err 445 + } 446 + } 447 + for k, v := range u.Gimmick.Sequences { 448 + if err := exec(`INSERT INTO user_gimmick_sequences (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, is_gimmick_sequence_cleared, clear_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 449 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, boolToInt(v.IsGimmickSequenceCleared), v.ClearDatetime, v.LatestVersion); err != nil { 450 + return err 451 + } 452 + } 453 + for k, v := range u.Gimmick.Unlocks { 454 + if err := exec(`INSERT INTO user_gimmick_unlocks (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_unlocked, latest_version) VALUES (?,?,?,?,?,?)`, 455 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsUnlocked), v.LatestVersion); err != nil { 456 + return err 457 + } 458 + } 459 + for id, v := range u.BigHuntMaxScores { 460 + if err := exec(`INSERT INTO user_big_hunt_max_scores (user_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version) VALUES (?,?,?,?,?)`, 461 + uid, id, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { 462 + return err 463 + } 464 + } 465 + for id, v := range u.BigHuntStatuses { 466 + if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version) VALUES (?,?,?,?,?)`, 467 + uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion); err != nil { 468 + return err 469 + } 470 + } 471 + for k, v := range u.BigHuntScheduleMaxScores { 472 + if err := exec(`INSERT INTO user_big_hunt_schedule_max_scores (user_id, big_hunt_schedule_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 473 + uid, k.BigHuntScheduleId, k.BigHuntBossId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { 474 + return err 475 + } 476 + } 477 + for k, v := range u.BigHuntWeeklyMaxScores { 478 + if err := exec(`INSERT INTO user_big_hunt_weekly_max_scores (user_id, big_hunt_weekly_version, attribute_type, max_score, latest_version) VALUES (?,?,?,?,?)`, 479 + uid, k.BigHuntWeeklyVersion, k.AttributeType, v.MaxScore, v.LatestVersion); err != nil { 480 + return err 481 + } 482 + } 483 + for ver, v := range u.BigHuntWeeklyStatuses { 484 + if err := exec(`INSERT INTO user_big_hunt_weekly_statuses (user_id, big_hunt_weekly_version, is_received_weekly_reward, latest_version) VALUES (?,?,?,?)`, 485 + uid, ver, boolToInt(v.IsReceivedWeeklyReward), v.LatestVersion); err != nil { 486 + return err 487 + } 488 + } 489 + 490 + return nil 491 + } 492 + 493 + // diffAndSave compares before/after UserState and writes only changed rows. 494 + // For 1:1 tables, it UPDATEs if any field changed. 495 + // For map tables, it uses INSERT OR REPLACE for added/modified entries and DELETE for removed ones. 496 + // For slice-based data (gifts, medals, deck sub-weapons/parts, weapon skills/abilities), 497 + // it does DELETE-all then INSERT-all for simplicity. 498 + func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { 499 + exec := func(query string, args ...any) error { 500 + _, err := tx.Exec(query, args...) 501 + return err 502 + } 503 + 504 + // users table 505 + if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType || 506 + before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime || 507 + before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion || 508 + before.BirthYear != after.BirthYear || before.BirthMonth != after.BirthMonth || 509 + before.BackupToken != after.BackupToken || before.ChargeMoneyThisMonth != after.ChargeMoneyThisMonth { 510 + if err := exec(`UPDATE users SET player_id=?, os_type=?, platform_type=?, user_restriction_type=?, 511 + register_datetime=?, game_start_datetime=?, latest_version=?, birth_year=?, birth_month=?, 512 + backup_token=?, charge_money_this_month=? WHERE user_id=?`, 513 + after.PlayerId, after.OsType, after.PlatformType, after.UserRestrictionType, 514 + after.RegisterDatetime, after.GameStartDatetime, after.LatestVersion, after.BirthYear, after.BirthMonth, 515 + after.BackupToken, after.ChargeMoneyThisMonth, uid); err != nil { 516 + return err 517 + } 518 + } 519 + 520 + if before.Setting != after.Setting { 521 + if err := exec(`UPDATE user_setting SET is_notify_purchase_alert=?, latest_version=? WHERE user_id=?`, 522 + boolToInt(after.Setting.IsNotifyPurchaseAlert), after.Setting.LatestVersion, uid); err != nil { 523 + return err 524 + } 525 + } 526 + if before.Status != after.Status { 527 + if err := exec(`UPDATE user_status SET level=?, exp=?, stamina_milli_value=?, stamina_update_datetime=?, latest_version=? WHERE user_id=?`, 528 + after.Status.Level, after.Status.Exp, after.Status.StaminaMilliValue, after.Status.StaminaUpdateDatetime, after.Status.LatestVersion, uid); err != nil { 529 + return err 530 + } 531 + } 532 + if before.Gem != after.Gem { 533 + if err := exec(`UPDATE user_gem SET paid_gem=?, free_gem=? WHERE user_id=?`, after.Gem.PaidGem, after.Gem.FreeGem, uid); err != nil { 534 + return err 535 + } 536 + } 537 + if before.Profile != after.Profile { 538 + if err := exec(`UPDATE user_profile SET name=?, name_update_datetime=?, message=?, message_update_datetime=?, favorite_costume_id=?, favorite_costume_id_update_datetime=?, latest_version=? WHERE user_id=?`, 539 + after.Profile.Name, after.Profile.NameUpdateDatetime, after.Profile.Message, after.Profile.MessageUpdateDatetime, 540 + after.Profile.FavoriteCostumeId, after.Profile.FavoriteCostumeIdUpdateDatetime, after.Profile.LatestVersion, uid); err != nil { 541 + return err 542 + } 543 + } 544 + if before.Login != after.Login { 545 + if err := exec(`UPDATE user_login SET total_login_count=?, continual_login_count=?, max_continual_login_count=?, last_login_datetime=?, last_comeback_login_datetime=?, latest_version=? WHERE user_id=?`, 546 + after.Login.TotalLoginCount, after.Login.ContinualLoginCount, after.Login.MaxContinualLoginCount, 547 + after.Login.LastLoginDatetime, after.Login.LastComebackLoginDatetime, after.Login.LatestVersion, uid); err != nil { 548 + return err 549 + } 550 + } 551 + if before.LoginBonus != after.LoginBonus { 552 + if err := exec(`UPDATE user_login_bonus SET login_bonus_id=?, current_page_number=?, current_stamp_number=?, latest_reward_receive_datetime=?, latest_version=? WHERE user_id=?`, 553 + after.LoginBonus.LoginBonusId, after.LoginBonus.CurrentPageNumber, after.LoginBonus.CurrentStampNumber, 554 + after.LoginBonus.LatestRewardReceiveDatetime, after.LoginBonus.LatestVersion, uid); err != nil { 555 + return err 556 + } 557 + } 558 + if before.MainQuest != after.MainQuest { 559 + if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_current_quest_scene_id=?, saved_head_quest_scene_id=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`, 560 + after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId, 561 + after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId, 562 + after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId, 563 + after.MainQuest.LatestVersion, after.MainQuest.SavedCurrentQuestSceneId, after.MainQuest.SavedHeadQuestSceneId, 564 + after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil { 565 + return err 566 + } 567 + } 568 + if before.EventQuest != after.EventQuest { 569 + if err := exec(`UPDATE user_event_quest SET current_event_quest_chapter_id=?, current_quest_id=?, current_quest_scene_id=?, head_quest_scene_id=?, latest_version=? WHERE user_id=?`, 570 + after.EventQuest.CurrentEventQuestChapterId, after.EventQuest.CurrentQuestId, after.EventQuest.CurrentQuestSceneId, after.EventQuest.HeadQuestSceneId, after.EventQuest.LatestVersion, uid); err != nil { 571 + return err 572 + } 573 + } 574 + if before.ExtraQuest != after.ExtraQuest { 575 + if err := exec(`UPDATE user_extra_quest SET current_quest_id=?, current_quest_scene_id=?, head_quest_scene_id=?, latest_version=? WHERE user_id=?`, 576 + after.ExtraQuest.CurrentQuestId, after.ExtraQuest.CurrentQuestSceneId, after.ExtraQuest.HeadQuestSceneId, after.ExtraQuest.LatestVersion, uid); err != nil { 577 + return err 578 + } 579 + } 580 + if before.SideStoryActiveProgress != after.SideStoryActiveProgress { 581 + if err := exec(`UPDATE user_side_story_active SET current_side_story_quest_id=?, current_side_story_quest_scene_id=?, latest_version=? WHERE user_id=?`, 582 + after.SideStoryActiveProgress.CurrentSideStoryQuestId, after.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, after.SideStoryActiveProgress.LatestVersion, uid); err != nil { 583 + return err 584 + } 585 + } 586 + if before.BigHuntProgress != after.BigHuntProgress || before.BigHuntBattleDetail != after.BigHuntBattleDetail || before.BigHuntDeckNumber != after.BigHuntDeckNumber { 587 + if err := exec(`UPDATE user_big_hunt_state SET current_big_hunt_boss_quest_id=?, current_big_hunt_quest_id=?, current_quest_scene_id=?, is_dry_run=?, latest_version=?, deck_type=?, user_triple_deck_number=?, boss_knock_down_count=?, max_combo_count=?, total_damage=?, deck_number=?, battle_binary=? WHERE user_id=?`, 588 + after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId, 589 + after.BigHuntProgress.CurrentQuestSceneId, boolToInt(after.BigHuntProgress.IsDryRun), after.BigHuntProgress.LatestVersion, 590 + after.BigHuntBattleDetail.DeckType, after.BigHuntBattleDetail.UserTripleDeckNumber, after.BigHuntBattleDetail.BossKnockDownCount, 591 + after.BigHuntBattleDetail.MaxComboCount, after.BigHuntBattleDetail.TotalDamage, after.BigHuntDeckNumber, after.BigHuntBattleBinary, uid); err != nil { 592 + return err 593 + } 594 + } 595 + if before.Battle != after.Battle { 596 + if err := exec(`UPDATE user_battle SET is_active=?, start_count=?, finish_count=?, last_started_at=?, last_finished_at=?, last_user_party_count=?, last_npc_party_count=?, last_battle_binary_size=?, last_elapsed_frame_count=? WHERE user_id=?`, 597 + boolToInt(after.Battle.IsActive), after.Battle.StartCount, after.Battle.FinishCount, after.Battle.LastStartedAt, 598 + after.Battle.LastFinishedAt, after.Battle.LastUserPartyCount, after.Battle.LastNpcPartyCount, 599 + after.Battle.LastBattleBinarySize, after.Battle.LastElapsedFrameCount, uid); err != nil { 600 + return err 601 + } 602 + } 603 + if before.Notifications != after.Notifications { 604 + if err := exec(`UPDATE user_notification SET gift_not_receive_count=?, friend_request_receive_count=?, is_exist_unread_information=? WHERE user_id=?`, 605 + after.Notifications.GiftNotReceiveCount, after.Notifications.FriendRequestReceiveCount, boolToInt(after.Notifications.IsExistUnreadInformation), uid); err != nil { 606 + return err 607 + } 608 + } 609 + if before.PortalCageStatus != after.PortalCageStatus { 610 + if err := exec(`UPDATE user_portal_cage SET is_current_progress=?, drop_item_start_datetime=?, current_drop_item_count=?, latest_version=? WHERE user_id=?`, 611 + boolToInt(after.PortalCageStatus.IsCurrentProgress), after.PortalCageStatus.DropItemStartDatetime, after.PortalCageStatus.CurrentDropItemCount, after.PortalCageStatus.LatestVersion, uid); err != nil { 612 + return err 613 + } 614 + } 615 + if before.GuerrillaFreeOpen != after.GuerrillaFreeOpen { 616 + if err := exec(`UPDATE user_guerrilla_free_open SET start_datetime=?, open_minutes=?, daily_opened_count=?, latest_version=? WHERE user_id=?`, 617 + after.GuerrillaFreeOpen.StartDatetime, after.GuerrillaFreeOpen.OpenMinutes, after.GuerrillaFreeOpen.DailyOpenedCount, after.GuerrillaFreeOpen.LatestVersion, uid); err != nil { 618 + return err 619 + } 620 + } 621 + if before.Explore != after.Explore { 622 + if err := exec(`UPDATE user_explore SET is_use_explore_ticket=?, playing_explore_id=?, latest_play_datetime=?, latest_version=? WHERE user_id=?`, 623 + boolToInt(after.Explore.IsUseExploreTicket), after.Explore.PlayingExploreId, after.Explore.LatestPlayDatetime, after.Explore.LatestVersion, uid); err != nil { 624 + return err 625 + } 626 + } 627 + if before.ShopReplaceable != after.ShopReplaceable { 628 + if err := exec(`UPDATE user_shop_replaceable SET lineup_update_count=?, latest_lineup_update_datetime=?, latest_version=? WHERE user_id=?`, 629 + after.ShopReplaceable.LineupUpdateCount, after.ShopReplaceable.LatestLineupUpdateDatetime, after.ShopReplaceable.LatestVersion, uid); err != nil { 630 + return err 631 + } 632 + } 633 + 634 + // Gacha scalar 635 + if before.Gacha.RewardAvailable != after.Gacha.RewardAvailable || before.Gacha.TodaysCurrentDrawCount != after.Gacha.TodaysCurrentDrawCount || 636 + before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate { 637 + var obtainItemId, obtainCount sql.NullInt64 638 + if after.Gacha.ConvertedGachaMedal.ObtainPossession != nil { 639 + obtainItemId = sql.NullInt64{Int64: int64(after.Gacha.ConvertedGachaMedal.ObtainPossession.ConsumableItemId), Valid: true} 640 + obtainCount = sql.NullInt64{Int64: int64(after.Gacha.ConvertedGachaMedal.ObtainPossession.Count), Valid: true} 641 + } 642 + if err := exec(`UPDATE user_gacha SET reward_available=?, todays_current_draw_count=?, daily_max_count=?, last_reward_draw_date=?, obtain_consumable_item_id=?, obtain_count=? WHERE user_id=?`, 643 + boolToInt(after.Gacha.RewardAvailable), after.Gacha.TodaysCurrentDrawCount, after.Gacha.DailyMaxCount, 644 + after.Gacha.LastRewardDrawDate, obtainItemId, obtainCount, uid); err != nil { 645 + return err 646 + } 647 + } 648 + 649 + // Map tables — use generic diff helpers 650 + diffMapInt32(tx, uid, before.Characters, after.Characters, "user_characters", "character_id", 651 + func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} }, 652 + "character_id, level, exp, latest_version") 653 + diffMapStr(tx, uid, before.Costumes, after.Costumes, "user_costumes", "user_costume_uuid", 654 + func(v store.CostumeState) []any { 655 + return []any{v.UserCostumeUuid, v.CostumeId, v.LimitBreakCount, v.Level, v.Exp, v.HeadupDisplayViewId, v.AcquisitionDatetime, v.AwakenCount, v.CostumeLotteryEffectUnlockedSlotCount, v.LatestVersion} 656 + }, "user_costume_uuid, costume_id, limit_break_count, level, exp, headup_display_view_id, acquisition_datetime, awaken_count, costume_lottery_effect_unlocked_slot_count, latest_version") 657 + diffMapStr(tx, uid, before.Weapons, after.Weapons, "user_weapons", "user_weapon_uuid", 658 + func(v store.WeaponState) []any { 659 + return []any{v.UserWeaponUuid, v.WeaponId, v.Level, v.Exp, v.LimitBreakCount, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion} 660 + }, "user_weapon_uuid, weapon_id, level, exp, limit_break_count, is_protected, acquisition_datetime, latest_version") 661 + diffMapStr(tx, uid, before.Companions, after.Companions, "user_companions", "user_companion_uuid", 662 + func(v store.CompanionState) []any { 663 + return []any{v.UserCompanionUuid, v.CompanionId, v.HeadupDisplayViewId, v.Level, v.AcquisitionDatetime, v.LatestVersion} 664 + }, "user_companion_uuid, companion_id, headup_display_view_id, level, acquisition_datetime, latest_version") 665 + diffMapStr(tx, uid, before.Thoughts, after.Thoughts, "user_thoughts", "user_thought_uuid", 666 + func(v store.ThoughtState) []any { 667 + return []any{v.UserThoughtUuid, v.ThoughtId, v.AcquisitionDatetime, v.LatestVersion} 668 + }, "user_thought_uuid, thought_id, acquisition_datetime, latest_version") 669 + diffMapStr(tx, uid, before.DeckCharacters, after.DeckCharacters, "user_deck_characters", "user_deck_character_uuid", 670 + func(v store.DeckCharacterState) []any { 671 + return []any{v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion} 672 + }, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version") 673 + 674 + // Decks (composite key) 675 + for k, v := range after.Decks { 676 + if old, ok := before.Decks[k]; !ok || old != v { 677 + exec(fmt.Sprintf(`INSERT OR REPLACE INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`), 678 + uid, int32(k.DeckType), k.UserDeckNumber, v.UserDeckCharacterUuid01, v.UserDeckCharacterUuid02, v.UserDeckCharacterUuid03, v.Name, v.Power, v.LatestVersion) 679 + } 680 + } 681 + for k := range before.Decks { 682 + if _, ok := after.Decks[k]; !ok { 683 + exec(`DELETE FROM user_decks WHERE user_id=? AND deck_type=? AND user_deck_number=?`, uid, int32(k.DeckType), k.UserDeckNumber) 684 + } 685 + } 686 + 687 + // Slice-based tables: delete all + reinsert 688 + replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) { 689 + for i, uuid := range uuids { 690 + exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) 691 + } 692 + }) 693 + replaceSliceTable(tx, uid, "user_deck_parts", after.DeckParts, func(key string, uuids []string) { 694 + for i, uuid := range uuids { 695 + exec(`INSERT INTO user_deck_parts (user_id, user_deck_character_uuid, ordinal, user_parts_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) 696 + } 697 + }) 698 + 699 + diffMapInt32(tx, uid, before.Quests, after.Quests, "user_quests", "quest_id", 700 + func(v store.UserQuestState) []any { 701 + return []any{v.QuestId, int32(v.QuestStateType), boolToInt(v.IsBattleOnly), v.UserDeckNumber, v.LatestStartDatetime, v.ClearCount, v.DailyClearCount, v.LastClearDatetime, v.ShortestClearFrames, boolToInt(v.IsRewardGranted), v.LatestVersion} 702 + }, "quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version") 703 + 704 + // Quest missions (composite key) 705 + for k, v := range after.QuestMissions { 706 + if old, ok := before.QuestMissions[k]; !ok || old != v { 707 + exec(`INSERT OR REPLACE INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 708 + uid, k.QuestId, k.QuestMissionId, v.ProgressValue, boolToInt(v.IsClear), v.LatestClearDatetime, v.LatestVersion) 709 + } 710 + } 711 + for k := range before.QuestMissions { 712 + if _, ok := after.QuestMissions[k]; !ok { 713 + exec(`DELETE FROM user_quest_missions WHERE user_id=? AND quest_id=? AND quest_mission_id=?`, uid, k.QuestId, k.QuestMissionId) 714 + } 715 + } 716 + 717 + diffMapInt32(tx, uid, before.Missions, after.Missions, "user_missions", "mission_id", 718 + func(v store.UserMissionState) []any { 719 + return []any{v.MissionId, v.StartDatetime, v.ProgressValue, v.MissionProgressStatusType, v.ClearDatetime, v.LatestVersion} 720 + }, "mission_id, start_datetime, progress_value, mission_progress_status_type, clear_datetime, latest_version") 721 + diffMapInt32(tx, uid, before.Tutorials, after.Tutorials, "user_tutorials", "tutorial_type", 722 + func(v store.TutorialProgressState) []any { 723 + return []any{v.TutorialType, v.ProgressPhase, v.ChoiceId, v.LatestVersion} 724 + }, 725 + "tutorial_type, progress_phase, choice_id, latest_version") 726 + 727 + diffMapInt32(tx, uid, before.SideStoryQuests, after.SideStoryQuests, "user_side_story_quests", "side_story_quest_id", 728 + func(v store.SideStoryQuestProgress) []any { 729 + return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} 730 + }, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version") 731 + diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id", 732 + func(v store.QuestLimitContentStatus) []any { 733 + return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} 734 + }, "limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version") 735 + diffMapInt32(tx, uid, before.WeaponStories, after.WeaponStories, "user_weapon_stories", "weapon_id", 736 + func(v store.WeaponStoryState) []any { 737 + return []any{v.WeaponId, v.ReleasedMaxStoryIndex, v.LatestVersion} 738 + }, 739 + "weapon_id, released_max_story_index, latest_version") 740 + diffMapInt32(tx, uid, before.WeaponNotes, after.WeaponNotes, "user_weapon_notes", "weapon_id", 741 + func(v store.WeaponNoteState) []any { 742 + return []any{v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion} 743 + }, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version") 744 + 745 + // Weapon skills/abilities: slice-based, delete+reinsert 746 + exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid) 747 + for _, skills := range after.WeaponSkills { 748 + for _, v := range skills { 749 + exec(`INSERT INTO user_weapon_skills (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, uid, v.UserWeaponUuid, v.SlotNumber, v.Level) 750 + } 751 + } 752 + exec(`DELETE FROM user_weapon_abilities WHERE user_id=?`, uid) 753 + for _, abilities := range after.WeaponAbilities { 754 + for _, v := range abilities { 755 + exec(`INSERT INTO user_weapon_abilities (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, uid, v.UserWeaponUuid, v.SlotNumber, v.Level) 756 + } 757 + } 758 + 759 + diffMapStr(tx, uid, before.WeaponAwakens, after.WeaponAwakens, "user_weapon_awakens", "user_weapon_uuid", 760 + func(v store.WeaponAwakenState) []any { return []any{v.UserWeaponUuid, v.LatestVersion} }, 761 + "user_weapon_uuid, latest_version") 762 + diffMapStr(tx, uid, before.CostumeActiveSkills, after.CostumeActiveSkills, "user_costume_active_skills", "user_costume_uuid", 763 + func(v store.CostumeActiveSkillState) []any { 764 + return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion} 765 + }, "user_costume_uuid, level, acquisition_datetime, latest_version") 766 + 767 + // Costume awaken status ups (composite key) 768 + for k, v := range after.CostumeAwakenStatusUps { 769 + if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v { 770 + exec(`INSERT OR REPLACE INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, 771 + uid, k.UserCostumeUuid, int32(k.StatusCalculationType), v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion) 772 + } 773 + } 774 + for k := range before.CostumeAwakenStatusUps { 775 + if _, ok := after.CostumeAwakenStatusUps[k]; !ok { 776 + exec(`DELETE FROM user_costume_awaken_status_ups WHERE user_id=? AND user_costume_uuid=? AND status_calculation_type=?`, uid, k.UserCostumeUuid, int32(k.StatusCalculationType)) 777 + } 778 + } 779 + 780 + for k, v := range after.CostumeLotteryEffects { 781 + if old, ok := before.CostumeLotteryEffects[k]; !ok || old != v { 782 + exec(`INSERT OR REPLACE INTO user_costume_lottery_effects (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, 783 + uid, k.UserCostumeUuid, k.SlotNumber, v.OddsNumber, v.LatestVersion) 784 + } 785 + } 786 + for k := range before.CostumeLotteryEffects { 787 + if _, ok := after.CostumeLotteryEffects[k]; !ok { 788 + exec(`DELETE FROM user_costume_lottery_effects WHERE user_id=? AND user_costume_uuid=? AND slot_number=?`, uid, k.UserCostumeUuid, k.SlotNumber) 789 + } 790 + } 791 + 792 + diffMapStr(tx, uid, before.CostumeLotteryEffectPending, after.CostumeLotteryEffectPending, "user_costume_lottery_effect_pending", "user_costume_uuid", 793 + func(v store.CostumeLotteryEffectPendingState) []any { 794 + return []any{v.UserCostumeUuid, v.SlotNumber, v.OddsNumber, v.LatestVersion} 795 + }, "user_costume_uuid, slot_number, odds_number, latest_version") 796 + 797 + diffMapStr(tx, uid, before.Parts, after.Parts, "user_parts", "user_parts_uuid", 798 + func(v store.PartsState) []any { 799 + return []any{v.UserPartsUuid, v.PartsId, v.Level, v.PartsStatusMainId, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion} 800 + }, "user_parts_uuid, parts_id, level, parts_status_main_id, is_protected, acquisition_datetime, latest_version") 801 + diffMapInt32(tx, uid, before.PartsGroupNotes, after.PartsGroupNotes, "user_parts_group_notes", "parts_group_id", 802 + func(v store.PartsGroupNoteState) []any { 803 + return []any{v.PartsGroupId, v.FirstAcquisitionDatetime, v.LatestVersion} 804 + }, 805 + "parts_group_id, first_acquisition_datetime, latest_version") 806 + diffMapInt32(tx, uid, before.PartsPresets, after.PartsPresets, "user_parts_presets", "user_parts_preset_number", 807 + func(v store.PartsPresetState) []any { 808 + return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion} 809 + }, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version") 810 + 811 + // Deck type notes (key is model.DeckType which is int32-based) 812 + for k, v := range after.DeckTypeNotes { 813 + if old, ok := before.DeckTypeNotes[k]; !ok || old != v { 814 + exec(`INSERT OR REPLACE INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, 815 + uid, int32(k), v.MaxDeckPower, v.LatestVersion) 816 + } 817 + } 818 + for k := range before.DeckTypeNotes { 819 + if _, ok := after.DeckTypeNotes[k]; !ok { 820 + exec(`DELETE FROM user_deck_type_notes WHERE user_id=? AND deck_type=?`, uid, int32(k)) 821 + } 822 + } 823 + 824 + diffSimpleMap(tx, uid, before.ConsumableItems, after.ConsumableItems, "user_consumable_items", "consumable_item_id", "count") 825 + diffSimpleMap(tx, uid, before.Materials, after.Materials, "user_materials", "material_id", "count") 826 + diffSimpleMap(tx, uid, before.ImportantItems, after.ImportantItems, "user_important_items", "important_item_id", "count") 827 + diffInt64Map(tx, uid, before.PremiumItems, after.PremiumItems, "user_premium_items", "premium_item_id", "count") 828 + 829 + diffMapInt32(tx, uid, before.ExploreScores, after.ExploreScores, "user_explore_scores", "explore_id", 830 + func(v store.ExploreScoreState) []any { 831 + return []any{v.ExploreId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} 832 + }, 833 + "explore_id, max_score, max_score_update_datetime, latest_version") 834 + diffMapInt32(tx, uid, before.AutoSaleSettings, after.AutoSaleSettings, "user_auto_sale_settings", "possession_auto_sale_item_type", 835 + func(v store.AutoSaleSettingState) []any { 836 + return []any{v.PossessionAutoSaleItemType, v.PossessionAutoSaleItemValue} 837 + }, 838 + "possession_auto_sale_item_type, possession_auto_sale_item_value") 839 + diffBoolMap(tx, uid, before.NaviCutInPlayed, after.NaviCutInPlayed, "user_navi_cutin_played", "navi_cutin_id") 840 + diffTimestampMap(tx, uid, before.ViewedMovies, after.ViewedMovies, "user_viewed_movies", "movie_id") 841 + diffTimestampMap(tx, uid, before.ContentsStories, after.ContentsStories, "user_contents_stories", "contents_story_id") 842 + diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id") 843 + diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id") 844 + 845 + // Gifts: delete all + reinsert 846 + exec(`DELETE FROM user_gifts WHERE user_id=?`, uid) 847 + for _, g := range after.Gifts.NotReceived { 848 + var expDt sql.NullInt64 849 + if g.ExpirationDatetime != 0 { 850 + expDt = sql.NullInt64{Int64: g.ExpirationDatetime, Valid: true} 851 + } 852 + exec(`INSERT INTO user_gifts (user_id, user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, description_gift_text_id, equipment_data, expiration_datetime) VALUES (?,?,0,?,?,?,?,?,?,?)`, 853 + uid, g.UserGiftUuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, expDt) 854 + } 855 + for i, g := range after.Gifts.Received { 856 + uuid := fmt.Sprintf("received-%d-%d", uid, i) 857 + exec(`INSERT INTO user_gifts (user_id, user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, description_gift_text_id, equipment_data, received_datetime) VALUES (?,?,1,?,?,?,?,?,?,?)`, 858 + uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime) 859 + } 860 + 861 + // Gacha converted medals: delete+reinsert 862 + exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid) 863 + for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { 864 + exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count) 865 + } 866 + 867 + // Gacha banners 868 + for id, v := range after.Gacha.BannerStates { 869 + if old, ok := before.Gacha.BannerStates[id]; !ok || old.MedalCount != v.MedalCount || old.StepNumber != v.StepNumber || old.LoopCount != v.LoopCount || old.DrawCount != v.DrawCount || old.BoxNumber != v.BoxNumber { 870 + exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, 871 + uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber) 872 + } 873 + // Box drew counts: always delete+reinsert for this gacha 874 + exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) 875 + for itemId, count := range v.BoxDrewCounts { 876 + exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count) 877 + } 878 + } 879 + for id := range before.Gacha.BannerStates { 880 + if _, ok := after.Gacha.BannerStates[id]; !ok { 881 + exec(`DELETE FROM user_gacha_banners WHERE user_id=? AND gacha_id=?`, uid, id) 882 + exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) 883 + } 884 + } 885 + 886 + diffMapInt32(tx, uid, before.CharacterBoards, after.CharacterBoards, "user_character_boards", "character_board_id", 887 + func(v store.CharacterBoardState) []any { 888 + return []any{v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion} 889 + }, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version") 890 + 891 + // Character board abilities (composite key) 892 + for k, v := range after.CharacterBoardAbilities { 893 + if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v { 894 + exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, 895 + uid, k.CharacterId, k.AbilityId, v.Level, v.LatestVersion) 896 + } 897 + } 898 + for k := range before.CharacterBoardAbilities { 899 + if _, ok := after.CharacterBoardAbilities[k]; !ok { 900 + exec(`DELETE FROM user_character_board_abilities WHERE user_id=? AND character_id=? AND ability_id=?`, uid, k.CharacterId, k.AbilityId) 901 + } 902 + } 903 + 904 + // Character board status ups (composite key) 905 + for k, v := range after.CharacterBoardStatusUps { 906 + if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v { 907 + exec(`INSERT OR REPLACE INTO user_character_board_status_ups (user_id, character_id, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, 908 + uid, k.CharacterId, k.StatusCalculationType, v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion) 909 + } 910 + } 911 + for k := range before.CharacterBoardStatusUps { 912 + if _, ok := after.CharacterBoardStatusUps[k]; !ok { 913 + exec(`DELETE FROM user_character_board_status_ups WHERE user_id=? AND character_id=? AND status_calculation_type=?`, uid, k.CharacterId, k.StatusCalculationType) 914 + } 915 + } 916 + 917 + diffMapInt32(tx, uid, before.CharacterRebirths, after.CharacterRebirths, "user_character_rebirths", "character_id", 918 + func(v store.CharacterRebirthState) []any { 919 + return []any{v.CharacterId, v.RebirthCount, v.LatestVersion} 920 + }, 921 + "character_id, rebirth_count, latest_version") 922 + diffMapInt32(tx, uid, before.CageOrnamentRewards, after.CageOrnamentRewards, "user_cage_ornament_rewards", "cage_ornament_id", 923 + func(v store.CageOrnamentRewardState) []any { 924 + return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion} 925 + }, 926 + "cage_ornament_id, acquisition_datetime, latest_version") 927 + diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id", 928 + func(v store.UserShopItemState) []any { 929 + return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion} 930 + }, "shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version") 931 + diffMapInt32(tx, uid, before.ShopReplaceableLineup, after.ShopReplaceableLineup, "user_shop_replaceable_lineup", "slot_number", 932 + func(v store.UserShopReplaceableLineupState) []any { 933 + return []any{v.SlotNumber, v.ShopItemId, v.LatestVersion} 934 + }, 935 + "slot_number, shop_item_id, latest_version") 936 + 937 + // Gimmick tables (composite keys) 938 + for k, v := range after.Gimmick.Progress { 939 + if old, ok := before.Gimmick.Progress[k]; !ok || old != v { 940 + exec(`INSERT OR REPLACE INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, 941 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsGimmickCleared), v.StartDatetime, v.LatestVersion) 942 + } 943 + } 944 + for k := range before.Gimmick.Progress { 945 + if _, ok := after.Gimmick.Progress[k]; !ok { 946 + exec(`DELETE FROM user_gimmick_progress WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=? AND gimmick_id=?`, 947 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId) 948 + } 949 + } 950 + for k, v := range after.Gimmick.OrnamentProgress { 951 + if old, ok := before.Gimmick.OrnamentProgress[k]; !ok || old != v { 952 + exec(`INSERT OR REPLACE INTO user_gimmick_ornament_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, gimmick_ornament_index, progress_value_bit, base_datetime, latest_version) VALUES (?,?,?,?,?,?,?,?)`, 953 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex, v.ProgressValueBit, v.BaseDatetime, v.LatestVersion) 954 + } 955 + } 956 + for k := range before.Gimmick.OrnamentProgress { 957 + if _, ok := after.Gimmick.OrnamentProgress[k]; !ok { 958 + exec(`DELETE FROM user_gimmick_ornament_progress WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=? AND gimmick_id=? AND gimmick_ornament_index=?`, 959 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex) 960 + } 961 + } 962 + for k, v := range after.Gimmick.Sequences { 963 + if old, ok := before.Gimmick.Sequences[k]; !ok || old != v { 964 + exec(`INSERT OR REPLACE INTO user_gimmick_sequences (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, is_gimmick_sequence_cleared, clear_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 965 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, boolToInt(v.IsGimmickSequenceCleared), v.ClearDatetime, v.LatestVersion) 966 + } 967 + } 968 + for k := range before.Gimmick.Sequences { 969 + if _, ok := after.Gimmick.Sequences[k]; !ok { 970 + exec(`DELETE FROM user_gimmick_sequences WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=?`, 971 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId) 972 + } 973 + } 974 + for k, v := range after.Gimmick.Unlocks { 975 + if old, ok := before.Gimmick.Unlocks[k]; !ok || old != v { 976 + exec(`INSERT OR REPLACE INTO user_gimmick_unlocks (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_unlocked, latest_version) VALUES (?,?,?,?,?,?)`, 977 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsUnlocked), v.LatestVersion) 978 + } 979 + } 980 + for k := range before.Gimmick.Unlocks { 981 + if _, ok := after.Gimmick.Unlocks[k]; !ok { 982 + exec(`DELETE FROM user_gimmick_unlocks WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=? AND gimmick_id=?`, 983 + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId) 984 + } 985 + } 986 + 987 + // Big hunt maps 988 + diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id", 989 + func(v store.BigHuntMaxScore) []any { 990 + return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} 991 + }, 992 + "big_hunt_boss_id, max_score, max_score_update_datetime, latest_version") 993 + diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id", 994 + func(v store.BigHuntStatus) []any { 995 + return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion} 996 + }, 997 + "big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version") 998 + 999 + for k, v := range after.BigHuntScheduleMaxScores { 1000 + if old, ok := before.BigHuntScheduleMaxScores[k]; !ok || old != v { 1001 + exec(`INSERT OR REPLACE INTO user_big_hunt_schedule_max_scores (user_id, big_hunt_schedule_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version) VALUES (?,?,?,?,?,?)`, 1002 + uid, k.BigHuntScheduleId, k.BigHuntBossId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion) 1003 + } 1004 + } 1005 + for k := range before.BigHuntScheduleMaxScores { 1006 + if _, ok := after.BigHuntScheduleMaxScores[k]; !ok { 1007 + exec(`DELETE FROM user_big_hunt_schedule_max_scores WHERE user_id=? AND big_hunt_schedule_id=? AND big_hunt_boss_id=?`, uid, k.BigHuntScheduleId, k.BigHuntBossId) 1008 + } 1009 + } 1010 + for k, v := range after.BigHuntWeeklyMaxScores { 1011 + if old, ok := before.BigHuntWeeklyMaxScores[k]; !ok || old != v { 1012 + exec(`INSERT OR REPLACE INTO user_big_hunt_weekly_max_scores (user_id, big_hunt_weekly_version, attribute_type, max_score, latest_version) VALUES (?,?,?,?,?)`, 1013 + uid, k.BigHuntWeeklyVersion, k.AttributeType, v.MaxScore, v.LatestVersion) 1014 + } 1015 + } 1016 + for k := range before.BigHuntWeeklyMaxScores { 1017 + if _, ok := after.BigHuntWeeklyMaxScores[k]; !ok { 1018 + exec(`DELETE FROM user_big_hunt_weekly_max_scores WHERE user_id=? AND big_hunt_weekly_version=? AND attribute_type=?`, uid, k.BigHuntWeeklyVersion, k.AttributeType) 1019 + } 1020 + } 1021 + for ver, v := range after.BigHuntWeeklyStatuses { 1022 + if old, ok := before.BigHuntWeeklyStatuses[ver]; !ok || old != v { 1023 + exec(`INSERT OR REPLACE INTO user_big_hunt_weekly_statuses (user_id, big_hunt_weekly_version, is_received_weekly_reward, latest_version) VALUES (?,?,?,?)`, 1024 + uid, ver, boolToInt(v.IsReceivedWeeklyReward), v.LatestVersion) 1025 + } 1026 + } 1027 + for ver := range before.BigHuntWeeklyStatuses { 1028 + if _, ok := after.BigHuntWeeklyStatuses[ver]; !ok { 1029 + exec(`DELETE FROM user_big_hunt_weekly_statuses WHERE user_id=? AND big_hunt_weekly_version=?`, uid, ver) 1030 + } 1031 + } 1032 + 1033 + return nil 1034 + } 1035 + 1036 + // Generic diff helpers for map tables with int32 keys 1037 + func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) { 1038 + for k, v := range after { 1039 + if old, ok := before[k]; !ok || old != v { 1040 + allVals := vals(v) 1041 + allVals[0] = k 1042 + args := append([]any{uid}, allVals...) 1043 + placeholders := "?" 1044 + for range allVals { 1045 + placeholders += ",?" 1046 + } 1047 + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s) VALUES (%s)`, table, cols, placeholders), args...) 1048 + } 1049 + } 1050 + for k := range before { 1051 + if _, ok := after[k]; !ok { 1052 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1053 + } 1054 + } 1055 + } 1056 + 1057 + func diffMapStr[V comparable](tx *sql.Tx, uid int64, before, after map[string]V, table, keyCol string, vals func(V) []any, cols string) { 1058 + for k, v := range after { 1059 + if old, ok := before[k]; !ok || old != v { 1060 + allVals := vals(v) 1061 + args := append([]any{uid}, allVals...) 1062 + placeholders := "?" 1063 + for range allVals { 1064 + placeholders += ",?" 1065 + } 1066 + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s) VALUES (%s)`, table, cols, placeholders), args...) 1067 + } 1068 + } 1069 + for k := range before { 1070 + if _, ok := after[k]; !ok { 1071 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1072 + } 1073 + } 1074 + } 1075 + 1076 + func diffSimpleMap(tx *sql.Tx, uid int64, before, after map[int32]int32, table, keyCol, valCol string) { 1077 + for k, v := range after { 1078 + if old, ok := before[k]; !ok || old != v { 1079 + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, %s) VALUES (?,?,?)`, table, keyCol, valCol), uid, k, v) 1080 + } 1081 + } 1082 + for k := range before { 1083 + if _, ok := after[k]; !ok { 1084 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1085 + } 1086 + } 1087 + } 1088 + 1089 + func diffInt64Map(tx *sql.Tx, uid int64, before, after map[int32]int64, table, keyCol, valCol string) { 1090 + for k, v := range after { 1091 + if old, ok := before[k]; !ok || old != v { 1092 + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, %s) VALUES (?,?,?)`, table, keyCol, valCol), uid, k, v) 1093 + } 1094 + } 1095 + for k := range before { 1096 + if _, ok := after[k]; !ok { 1097 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1098 + } 1099 + } 1100 + } 1101 + 1102 + func diffBoolMap(tx *sql.Tx, uid int64, before, after map[int32]bool, table, keyCol string) { 1103 + for k := range after { 1104 + if !before[k] { 1105 + tx.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (user_id, %s) VALUES (?,?)`, table, keyCol), uid, k) 1106 + } 1107 + } 1108 + for k := range before { 1109 + if !after[k] { 1110 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1111 + } 1112 + } 1113 + } 1114 + 1115 + func diffTimestampMap(tx *sql.Tx, uid int64, before, after map[int32]int64, table, keyCol string) { 1116 + for k, v := range after { 1117 + if old, ok := before[k]; !ok || old != v { 1118 + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, timestamp) VALUES (?,?,?)`, table, keyCol), uid, k, v) 1119 + } 1120 + } 1121 + for k := range before { 1122 + if _, ok := after[k]; !ok { 1123 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) 1124 + } 1125 + } 1126 + } 1127 + 1128 + func replaceSliceTable(tx *sql.Tx, uid int64, table string, data map[string][]string, insertFn func(string, []string)) { 1129 + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=?`, table), uid) 1130 + for key, vals := range data { 1131 + insertFn(key, vals) 1132 + } 1133 + } 1134 + 1135 + // suppress unused import 1136 + var _ = model.DeckTypeQuest
+56
server/internal/store/sqlite/session.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "lunar-tear/server/internal/store" 8 + ) 9 + 10 + func (s *SQLiteStore) CreateSession(uuid string, ttl time.Duration) (store.SessionState, error) { 11 + var userId int64 12 + err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId) 13 + if err != nil { 14 + return store.SessionState{}, store.ErrNotFound 15 + } 16 + 17 + now := s.clock() 18 + sessionKey := fmt.Sprintf("session_%d_%d", userId, now.UnixNano()) 19 + expireAt := now.Add(ttl) 20 + 21 + _, err = s.db.Exec( 22 + `INSERT INTO sessions (session_key, user_id, uuid, expire_at) VALUES (?, ?, ?, ?)`, 23 + sessionKey, userId, uuid, expireAt.Format(time.RFC3339Nano), 24 + ) 25 + if err != nil { 26 + return store.SessionState{}, fmt.Errorf("insert session: %w", err) 27 + } 28 + 29 + return store.SessionState{ 30 + SessionKey: sessionKey, 31 + UserId: userId, 32 + Uuid: uuid, 33 + ExpireAt: expireAt, 34 + }, nil 35 + } 36 + 37 + func (s *SQLiteStore) ResolveUserId(sessionKey string) (int64, error) { 38 + var userId int64 39 + var expireStr string 40 + err := s.db.QueryRow( 41 + `SELECT user_id, expire_at FROM sessions WHERE session_key = ?`, sessionKey, 42 + ).Scan(&userId, &expireStr) 43 + if err != nil { 44 + return 0, store.ErrNotFound 45 + } 46 + 47 + expireAt, err := time.Parse(time.RFC3339Nano, expireStr) 48 + if err != nil { 49 + return 0, store.ErrNotFound 50 + } 51 + if s.clock().After(expireAt) { 52 + return 0, store.ErrNotFound 53 + } 54 + 55 + return userId, nil 56 + }
+25
server/internal/store/sqlite/store.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + 7 + "lunar-tear/server/internal/store" 8 + ) 9 + 10 + type SQLiteStore struct { 11 + db *sql.DB 12 + clock store.Clock 13 + } 14 + 15 + var ( 16 + _ store.UserRepository = (*SQLiteStore)(nil) 17 + _ store.SessionRepository = (*SQLiteStore)(nil) 18 + ) 19 + 20 + func New(db *sql.DB, clock store.Clock) *SQLiteStore { 21 + if clock == nil { 22 + clock = time.Now 23 + } 24 + return &SQLiteStore{db: db, clock: clock} 25 + }
+218
server/internal/store/sqlite/user.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + "lunar-tear/server/internal/store" 8 + ) 9 + 10 + func (s *SQLiteStore) CreateUser(uuid string) (int64, error) { 11 + tx, err := s.db.Begin() 12 + if err != nil { 13 + return 0, fmt.Errorf("begin tx: %w", err) 14 + } 15 + defer tx.Rollback() 16 + 17 + var existingId int64 18 + err = tx.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&existingId) 19 + if err == nil { 20 + return existingId, nil 21 + } 22 + 23 + nowMillis := s.clock().UnixMilli() 24 + 25 + res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type, 26 + register_datetime, game_start_datetime, latest_version, birth_year, birth_month, 27 + backup_token, charge_money_this_month) VALUES (?, 0, 2, 2, 0, ?, ?, 0, 2000, 1, 'mock-backup-token', 0)`, 28 + uuid, nowMillis, nowMillis) 29 + if err != nil { 30 + return 0, fmt.Errorf("insert user: %w", err) 31 + } 32 + userId, err := res.LastInsertId() 33 + if err != nil { 34 + return 0, fmt.Errorf("last insert id: %w", err) 35 + } 36 + 37 + // player_id = user_id 38 + if _, err := tx.Exec(`UPDATE users SET player_id = ? WHERE user_id = ?`, userId, userId); err != nil { 39 + return 0, fmt.Errorf("update player_id: %w", err) 40 + } 41 + 42 + user := store.SeedUserState(userId, uuid, nowMillis) 43 + if err := writeUserState(tx, userId, user); err != nil { 44 + return 0, fmt.Errorf("write seed state: %w", err) 45 + } 46 + 47 + if err := tx.Commit(); err != nil { 48 + return 0, fmt.Errorf("commit: %w", err) 49 + } 50 + return userId, nil 51 + } 52 + 53 + func (s *SQLiteStore) GetUserByUUID(uuid string) (int64, error) { 54 + var userId int64 55 + err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId) 56 + if err == sql.ErrNoRows { 57 + return 0, store.ErrNotFound 58 + } 59 + if err != nil { 60 + return 0, fmt.Errorf("query user: %w", err) 61 + } 62 + return userId, nil 63 + } 64 + 65 + func (s *SQLiteStore) DefaultUserId() (int64, error) { 66 + var userId int64 67 + err := s.db.QueryRow(`SELECT min(user_id) FROM users`).Scan(&userId) 68 + if err != nil || userId == 0 { 69 + return 0, store.ErrNotFound 70 + } 71 + return userId, nil 72 + } 73 + 74 + // ImportUser replaces all data for u.UserId in the database with the 75 + // contents of u. Any pre-existing rows for that user are deleted first. 76 + func (s *SQLiteStore) ImportUser(u *store.UserState) error { 77 + tx, err := s.db.Begin() 78 + if err != nil { 79 + return fmt.Errorf("begin tx: %w", err) 80 + } 81 + defer tx.Rollback() 82 + 83 + uid := u.UserId 84 + 85 + // Child tables in reverse-dependency order (matches schema's goose Down). 86 + childTables := []string{ 87 + "user_cage_ornament_rewards", 88 + "user_shop_replaceable_lineup", 89 + "user_shop_items", 90 + "user_gacha_banner_box_drew_counts", 91 + "user_gacha_banners", 92 + "user_gacha_converted_medals", 93 + "user_gifts", 94 + "user_dokan_confirmed", 95 + "user_drawn_omikuji", 96 + "user_contents_stories", 97 + "user_viewed_movies", 98 + "user_navi_cutin_played", 99 + "user_auto_sale_settings", 100 + "user_explore_scores", 101 + "user_tutorials", 102 + "user_premium_items", 103 + "user_important_items", 104 + "user_materials", 105 + "user_consumable_items", 106 + "user_gimmick_unlocks", 107 + "user_gimmick_sequences", 108 + "user_gimmick_ornament_progress", 109 + "user_gimmick_progress", 110 + "user_big_hunt_weekly_statuses", 111 + "user_big_hunt_weekly_max_scores", 112 + "user_big_hunt_schedule_max_scores", 113 + "user_big_hunt_statuses", 114 + "user_big_hunt_max_scores", 115 + "user_quest_limit_content_status", 116 + "user_side_story_quests", 117 + "user_missions", 118 + "user_quest_missions", 119 + "user_quests", 120 + "user_deck_type_notes", 121 + "user_deck_parts", 122 + "user_deck_sub_weapons", 123 + "user_decks", 124 + "user_deck_characters", 125 + "user_parts_presets", 126 + "user_parts_group_notes", 127 + "user_parts", 128 + "user_thoughts", 129 + "user_companions", 130 + "user_weapon_notes", 131 + "user_weapon_stories", 132 + "user_weapon_awakens", 133 + "user_weapon_abilities", 134 + "user_weapon_skills", 135 + "user_weapons", 136 + "user_costume_awaken_status_ups", 137 + "user_costume_active_skills", 138 + "user_costumes", 139 + "user_character_rebirths", 140 + "user_character_board_status_ups", 141 + "user_character_board_abilities", 142 + "user_character_boards", 143 + "user_characters", 144 + "user_gacha", 145 + "user_shop_replaceable", 146 + "user_explore", 147 + "user_guerrilla_free_open", 148 + "user_portal_cage", 149 + "user_notification", 150 + "user_battle", 151 + "user_big_hunt_state", 152 + "user_side_story_active", 153 + "user_extra_quest", 154 + "user_event_quest", 155 + "user_main_quest", 156 + "user_login_bonus", 157 + "user_login", 158 + "user_profile", 159 + "user_gem", 160 + "user_status", 161 + "user_setting", 162 + "sessions", 163 + } 164 + 165 + for _, t := range childTables { 166 + if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, t), uid); err != nil { 167 + return fmt.Errorf("delete from %s: %w", t, err) 168 + } 169 + } 170 + if _, err := tx.Exec(`DELETE FROM users WHERE user_id = ?`, uid); err != nil { 171 + return fmt.Errorf("delete user: %w", err) 172 + } 173 + 174 + if _, err := tx.Exec(`INSERT INTO users (user_id, uuid, player_id, os_type, platform_type, 175 + user_restriction_type, register_datetime, game_start_datetime, latest_version, 176 + birth_year, birth_month, backup_token, charge_money_this_month) 177 + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, 178 + uid, u.Uuid, u.PlayerId, u.OsType, u.PlatformType, u.UserRestrictionType, 179 + u.RegisterDatetime, u.GameStartDatetime, u.LatestVersion, 180 + u.BirthYear, u.BirthMonth, u.BackupToken, u.ChargeMoneyThisMonth); err != nil { 181 + return fmt.Errorf("insert user: %w", err) 182 + } 183 + 184 + if err := writeUserState(tx, uid, u); err != nil { 185 + return fmt.Errorf("write user state: %w", err) 186 + } 187 + 188 + if err := tx.Commit(); err != nil { 189 + return fmt.Errorf("commit: %w", err) 190 + } 191 + return nil 192 + } 193 + 194 + func (s *SQLiteStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) { 195 + before, err := s.LoadUser(userId) 196 + if err != nil { 197 + return store.UserState{}, err 198 + } 199 + 200 + after := store.CloneUserState(before) 201 + mutate(&after) 202 + 203 + tx, err := s.db.Begin() 204 + if err != nil { 205 + return store.UserState{}, fmt.Errorf("begin tx: %w", err) 206 + } 207 + defer tx.Rollback() 208 + 209 + if err := diffAndSave(tx, userId, &before, &after); err != nil { 210 + return store.UserState{}, fmt.Errorf("diff and save: %w", err) 211 + } 212 + 213 + if err := tx.Commit(); err != nil { 214 + return store.UserState{}, fmt.Errorf("commit: %w", err) 215 + } 216 + 217 + return after, nil 218 + }
+4 -8
server/internal/store/store.go
··· 10 10 type Clock func() time.Time 11 11 12 12 type UserRepository interface { 13 - EnsureUser(uuid string) (UserState, error) 14 - SnapshotUser(userId int64) (UserState, error) 13 + CreateUser(uuid string) (int64, error) 14 + GetUserByUUID(uuid string) (int64, error) 15 + LoadUser(userId int64) (UserState, error) 15 16 UpdateUser(userId int64, mutate func(*UserState)) (UserState, error) 16 17 DefaultUserId() (int64, error) 17 18 } 18 19 19 20 type SessionRepository interface { 20 - CreateSession(uuid string, ttl time.Duration) (UserState, SessionState, error) 21 + CreateSession(uuid string, ttl time.Duration) (SessionState, error) 21 22 ResolveUserId(sessionKey string) (int64, error) 22 23 } 23 - 24 - type GachaRepository interface { 25 - SnapshotCatalog() ([]GachaCatalogEntry, error) 26 - ReplaceCatalog(entries []GachaCatalogEntry) error 27 - }
+59 -12
server/internal/store/types.go
··· 107 107 CharacterBoardAbilities map[CharacterBoardAbilityKey]CharacterBoardAbilityState 108 108 CharacterBoardStatusUps map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState 109 109 110 - CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState 111 - AutoSaleSettings map[int32]AutoSaleSettingState 112 - CharacterRebirths map[int32]CharacterRebirthState 110 + CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState 111 + CostumeLotteryEffects map[CostumeLotteryEffectKey]CostumeLotteryEffectState 112 + CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid 113 + AutoSaleSettings map[int32]AutoSaleSettingState 114 + CharacterRebirths map[int32]CharacterRebirthState 113 115 } 114 116 115 117 func (u *UserState) EnsureMaps() { ··· 254 256 if u.CostumeAwakenStatusUps == nil { 255 257 u.CostumeAwakenStatusUps = make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState) 256 258 } 259 + if u.CostumeLotteryEffects == nil { 260 + u.CostumeLotteryEffects = make(map[CostumeLotteryEffectKey]CostumeLotteryEffectState) 261 + } 262 + if u.CostumeLotteryEffectPending == nil { 263 + u.CostumeLotteryEffectPending = make(map[string]CostumeLotteryEffectPendingState) 264 + } 257 265 if u.AutoSaleSettings == nil { 258 266 u.AutoSaleSettings = make(map[int32]AutoSaleSettingState) 259 267 } ··· 358 366 } 359 367 360 368 type CostumeState struct { 361 - UserCostumeUuid string 362 - CostumeId int32 363 - LimitBreakCount int32 364 - Level int32 365 - Exp int32 366 - HeadupDisplayViewId int32 367 - AcquisitionDatetime int64 368 - AwakenCount int32 369 - LatestVersion int64 369 + UserCostumeUuid string 370 + CostumeId int32 371 + LimitBreakCount int32 372 + Level int32 373 + Exp int32 374 + HeadupDisplayViewId int32 375 + AcquisitionDatetime int64 376 + AwakenCount int32 377 + CostumeLotteryEffectUnlockedSlotCount int32 378 + LatestVersion int64 370 379 } 371 380 372 381 type WeaponState struct { ··· 1070 1079 RebirthCount int32 1071 1080 LatestVersion int64 1072 1081 } 1082 + 1083 + type CostumeLotteryEffectKey struct { 1084 + UserCostumeUuid string 1085 + SlotNumber int32 1086 + } 1087 + 1088 + func (k CostumeLotteryEffectKey) MarshalText() ([]byte, error) { 1089 + return fmt.Appendf(nil, "%s:%d", k.UserCostumeUuid, k.SlotNumber), nil 1090 + } 1091 + 1092 + func (k *CostumeLotteryEffectKey) UnmarshalText(text []byte) error { 1093 + s := string(text) 1094 + idx := strings.LastIndex(s, ":") 1095 + if idx < 0 { 1096 + return fmt.Errorf("invalid CostumeLotteryEffectKey: %s", text) 1097 + } 1098 + k.UserCostumeUuid = s[:idx] 1099 + v, err := strconv.ParseInt(s[idx+1:], 10, 32) 1100 + if err != nil { 1101 + return err 1102 + } 1103 + k.SlotNumber = int32(v) 1104 + return nil 1105 + } 1106 + 1107 + type CostumeLotteryEffectState struct { 1108 + UserCostumeUuid string 1109 + SlotNumber int32 1110 + OddsNumber int32 1111 + LatestVersion int64 1112 + } 1113 + 1114 + type CostumeLotteryEffectPendingState struct { 1115 + UserCostumeUuid string 1116 + SlotNumber int32 1117 + OddsNumber int32 1118 + LatestVersion int64 1119 + }
+60 -12
server/internal/userdata/proj_inventory.go
··· 105 105 s, _ := encodeJSONMaps(SortedWeaponAwakenRecords(user)...) 106 106 return s 107 107 }) 108 + register("IUserCostumeLotteryEffect", func(user store.UserState) string { 109 + s, _ := encodeJSONMaps(sortedCostumeLotteryEffectRecords(user)...) 110 + return s 111 + }) 112 + register("IUserCostumeLotteryEffectPending", func(user store.UserState) string { 113 + s, _ := encodeJSONMaps(SortedCostumeLotteryEffectPendingRecords(user)...) 114 + return s 115 + }) 108 116 registerStatic( 109 117 "IUserCostumeLevelBonusReleaseStatus", 110 - "IUserCostumeLotteryEffect", 111 118 "IUserCostumeLotteryEffectAbility", 112 119 "IUserCostumeLotteryEffectStatusUp", 113 - "IUserCostumeLotteryEffectPending", 114 120 "IUserPartsPresetTag", 115 121 "IUserPartsStatusSub", 116 122 ) ··· 143 149 for _, key := range keys { 144 150 row := user.Costumes[key] 145 151 records = append(records, map[string]any{ 146 - "userId": user.UserId, 147 - "userCostumeUuid": row.UserCostumeUuid, 148 - "costumeId": row.CostumeId, 149 - "limitBreakCount": row.LimitBreakCount, 150 - "level": row.Level, 151 - "exp": row.Exp, 152 - "headupDisplayViewId": row.HeadupDisplayViewId, 153 - "acquisitionDatetime": row.AcquisitionDatetime, 154 - "awakenCount": row.AwakenCount, 155 - "latestVersion": row.LatestVersion, 152 + "userId": user.UserId, 153 + "userCostumeUuid": row.UserCostumeUuid, 154 + "costumeId": row.CostumeId, 155 + "limitBreakCount": row.LimitBreakCount, 156 + "level": row.Level, 157 + "exp": row.Exp, 158 + "headupDisplayViewId": row.HeadupDisplayViewId, 159 + "acquisitionDatetime": row.AcquisitionDatetime, 160 + "awakenCount": row.AwakenCount, 161 + "costumeLotteryEffectUnlockedSlotCount": row.CostumeLotteryEffectUnlockedSlotCount, 162 + "latestVersion": row.LatestVersion, 156 163 }) 157 164 } 158 165 return records ··· 619 626 } 620 627 return records 621 628 } 629 + 630 + func SortedCostumeLotteryEffectPendingRecords(user store.UserState) []map[string]any { 631 + keys := sortedStringKeys(user.CostumeLotteryEffectPending) 632 + records := make([]map[string]any, 0, len(keys)) 633 + for _, key := range keys { 634 + row := user.CostumeLotteryEffectPending[key] 635 + records = append(records, map[string]any{ 636 + "userId": user.UserId, 637 + "userCostumeUuid": row.UserCostumeUuid, 638 + "slotNumber": row.SlotNumber, 639 + "oddsNumber": row.OddsNumber, 640 + "latestVersion": row.LatestVersion, 641 + }) 642 + } 643 + return records 644 + } 645 + 646 + func sortedCostumeLotteryEffectRecords(user store.UserState) []map[string]any { 647 + keys := make([]store.CostumeLotteryEffectKey, 0, len(user.CostumeLotteryEffects)) 648 + for k := range user.CostumeLotteryEffects { 649 + keys = append(keys, k) 650 + } 651 + sort.Slice(keys, func(i, j int) bool { 652 + if keys[i].UserCostumeUuid != keys[j].UserCostumeUuid { 653 + return keys[i].UserCostumeUuid < keys[j].UserCostumeUuid 654 + } 655 + return keys[i].SlotNumber < keys[j].SlotNumber 656 + }) 657 + records := make([]map[string]any, 0, len(keys)) 658 + for _, k := range keys { 659 + row := user.CostumeLotteryEffects[k] 660 + records = append(records, map[string]any{ 661 + "userId": user.UserId, 662 + "userCostumeUuid": row.UserCostumeUuid, 663 + "slotNumber": row.SlotNumber, 664 + "oddsNumber": row.OddsNumber, 665 + "latestVersion": row.LatestVersion, 666 + }) 667 + } 668 + return records 669 + }
+8
server/internal/userdata/state_projection.go
··· 138 138 return selected 139 139 } 140 140 141 + func ProjectTables(user store.UserState, requested []string) map[string]string { 142 + result := make(map[string]string, len(requested)) 143 + for _, table := range requested { 144 + result[table] = projectTable(table, user) 145 + } 146 + return result 147 + } 148 + 141 149 func BuildDiffFromTables(tables map[string]string) map[string]*pb.DiffData { 142 150 diff := make(map[string]*pb.DiffData, len(tables)) 143 151 for table, payload := range tables {
+874
server/migrations/20260416182710_initial_schema.sql
··· 1 + -- +goose Up 2 + 3 + PRAGMA foreign_keys = ON; 4 + 5 + -- ============================================================================= 6 + -- 1. Identity and Sessions 7 + -- ============================================================================= 8 + 9 + CREATE TABLE users ( 10 + user_id INTEGER PRIMARY KEY, 11 + uuid TEXT NOT NULL UNIQUE, 12 + player_id INTEGER NOT NULL DEFAULT 0, 13 + os_type INTEGER NOT NULL DEFAULT 0, 14 + platform_type INTEGER NOT NULL DEFAULT 0, 15 + user_restriction_type INTEGER NOT NULL DEFAULT 0, 16 + register_datetime INTEGER NOT NULL DEFAULT 0, 17 + game_start_datetime INTEGER NOT NULL DEFAULT 0, 18 + latest_version INTEGER NOT NULL DEFAULT 0, 19 + birth_year INTEGER NOT NULL DEFAULT 0, 20 + birth_month INTEGER NOT NULL DEFAULT 0, 21 + backup_token TEXT NOT NULL DEFAULT '', 22 + charge_money_this_month INTEGER NOT NULL DEFAULT 0 23 + ); 24 + 25 + CREATE TABLE sessions ( 26 + session_key TEXT PRIMARY KEY, 27 + user_id INTEGER NOT NULL REFERENCES users(user_id), 28 + uuid TEXT NOT NULL, 29 + expire_at TEXT NOT NULL 30 + ); 31 + 32 + -- ============================================================================= 33 + -- 1b. Per-User 1:1 State Tables 34 + -- ============================================================================= 35 + 36 + CREATE TABLE user_setting ( 37 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 38 + is_notify_purchase_alert INTEGER NOT NULL DEFAULT 0, 39 + latest_version INTEGER NOT NULL DEFAULT 0 40 + ); 41 + 42 + CREATE TABLE user_status ( 43 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 44 + level INTEGER NOT NULL DEFAULT 0, 45 + exp INTEGER NOT NULL DEFAULT 0, 46 + stamina_milli_value INTEGER NOT NULL DEFAULT 0, 47 + stamina_update_datetime INTEGER NOT NULL DEFAULT 0, 48 + latest_version INTEGER NOT NULL DEFAULT 0 49 + ); 50 + 51 + CREATE TABLE user_gem ( 52 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 53 + paid_gem INTEGER NOT NULL DEFAULT 0, 54 + free_gem INTEGER NOT NULL DEFAULT 0 55 + ); 56 + 57 + CREATE TABLE user_profile ( 58 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 59 + name TEXT NOT NULL DEFAULT '', 60 + name_update_datetime INTEGER NOT NULL DEFAULT 0, 61 + message TEXT NOT NULL DEFAULT '', 62 + message_update_datetime INTEGER NOT NULL DEFAULT 0, 63 + favorite_costume_id INTEGER NOT NULL DEFAULT 0, 64 + favorite_costume_id_update_datetime INTEGER NOT NULL DEFAULT 0, 65 + latest_version INTEGER NOT NULL DEFAULT 0 66 + ); 67 + 68 + CREATE TABLE user_login ( 69 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 70 + total_login_count INTEGER NOT NULL DEFAULT 0, 71 + continual_login_count INTEGER NOT NULL DEFAULT 0, 72 + max_continual_login_count INTEGER NOT NULL DEFAULT 0, 73 + last_login_datetime INTEGER NOT NULL DEFAULT 0, 74 + last_comeback_login_datetime INTEGER NOT NULL DEFAULT 0, 75 + latest_version INTEGER NOT NULL DEFAULT 0 76 + ); 77 + 78 + CREATE TABLE user_login_bonus ( 79 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 80 + login_bonus_id INTEGER NOT NULL DEFAULT 0, 81 + current_page_number INTEGER NOT NULL DEFAULT 0, 82 + current_stamp_number INTEGER NOT NULL DEFAULT 0, 83 + latest_reward_receive_datetime INTEGER NOT NULL DEFAULT 0, 84 + latest_version INTEGER NOT NULL DEFAULT 0 85 + ); 86 + 87 + CREATE TABLE user_main_quest ( 88 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 89 + current_quest_flow_type INTEGER NOT NULL DEFAULT 0, 90 + current_main_quest_route_id INTEGER NOT NULL DEFAULT 0, 91 + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 92 + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, 93 + is_reached_last_quest_scene INTEGER NOT NULL DEFAULT 0, 94 + progress_quest_scene_id INTEGER NOT NULL DEFAULT 0, 95 + progress_head_quest_scene_id INTEGER NOT NULL DEFAULT 0, 96 + progress_quest_flow_type INTEGER NOT NULL DEFAULT 0, 97 + main_quest_season_id INTEGER NOT NULL DEFAULT 0, 98 + latest_version INTEGER NOT NULL DEFAULT 0, 99 + saved_current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 100 + saved_head_quest_scene_id INTEGER NOT NULL DEFAULT 0, 101 + replay_flow_current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 102 + replay_flow_head_quest_scene_id INTEGER NOT NULL DEFAULT 0 103 + ); 104 + 105 + CREATE TABLE user_event_quest ( 106 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 107 + current_event_quest_chapter_id INTEGER NOT NULL DEFAULT 0, 108 + current_quest_id INTEGER NOT NULL DEFAULT 0, 109 + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 110 + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, 111 + latest_version INTEGER NOT NULL DEFAULT 0 112 + ); 113 + 114 + CREATE TABLE user_extra_quest ( 115 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 116 + current_quest_id INTEGER NOT NULL DEFAULT 0, 117 + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 118 + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, 119 + latest_version INTEGER NOT NULL DEFAULT 0 120 + ); 121 + 122 + CREATE TABLE user_side_story_active ( 123 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 124 + current_side_story_quest_id INTEGER NOT NULL DEFAULT 0, 125 + current_side_story_quest_scene_id INTEGER NOT NULL DEFAULT 0, 126 + latest_version INTEGER NOT NULL DEFAULT 0 127 + ); 128 + 129 + CREATE TABLE user_big_hunt_state ( 130 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 131 + current_big_hunt_boss_quest_id INTEGER NOT NULL DEFAULT 0, 132 + current_big_hunt_quest_id INTEGER NOT NULL DEFAULT 0, 133 + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, 134 + is_dry_run INTEGER NOT NULL DEFAULT 0, 135 + latest_version INTEGER NOT NULL DEFAULT 0, 136 + deck_type INTEGER NOT NULL DEFAULT 0, 137 + user_triple_deck_number INTEGER NOT NULL DEFAULT 0, 138 + boss_knock_down_count INTEGER NOT NULL DEFAULT 0, 139 + max_combo_count INTEGER NOT NULL DEFAULT 0, 140 + total_damage INTEGER NOT NULL DEFAULT 0, 141 + deck_number INTEGER NOT NULL DEFAULT 0, 142 + battle_binary BLOB 143 + ); 144 + 145 + CREATE TABLE user_battle ( 146 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 147 + is_active INTEGER NOT NULL DEFAULT 0, 148 + start_count INTEGER NOT NULL DEFAULT 0, 149 + finish_count INTEGER NOT NULL DEFAULT 0, 150 + last_started_at INTEGER NOT NULL DEFAULT 0, 151 + last_finished_at INTEGER NOT NULL DEFAULT 0, 152 + last_user_party_count INTEGER NOT NULL DEFAULT 0, 153 + last_npc_party_count INTEGER NOT NULL DEFAULT 0, 154 + last_battle_binary_size INTEGER NOT NULL DEFAULT 0, 155 + last_elapsed_frame_count INTEGER NOT NULL DEFAULT 0 156 + ); 157 + 158 + CREATE TABLE user_notification ( 159 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 160 + gift_not_receive_count INTEGER NOT NULL DEFAULT 0, 161 + friend_request_receive_count INTEGER NOT NULL DEFAULT 0, 162 + is_exist_unread_information INTEGER NOT NULL DEFAULT 0 163 + ); 164 + 165 + CREATE TABLE user_portal_cage ( 166 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 167 + is_current_progress INTEGER NOT NULL DEFAULT 0, 168 + drop_item_start_datetime INTEGER NOT NULL DEFAULT 0, 169 + current_drop_item_count INTEGER NOT NULL DEFAULT 0, 170 + latest_version INTEGER NOT NULL DEFAULT 0 171 + ); 172 + 173 + CREATE TABLE user_guerrilla_free_open ( 174 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 175 + start_datetime INTEGER NOT NULL DEFAULT 0, 176 + open_minutes INTEGER NOT NULL DEFAULT 0, 177 + daily_opened_count INTEGER NOT NULL DEFAULT 0, 178 + latest_version INTEGER NOT NULL DEFAULT 0 179 + ); 180 + 181 + CREATE TABLE user_explore ( 182 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 183 + is_use_explore_ticket INTEGER NOT NULL DEFAULT 0, 184 + playing_explore_id INTEGER NOT NULL DEFAULT 0, 185 + latest_play_datetime INTEGER NOT NULL DEFAULT 0, 186 + latest_version INTEGER NOT NULL DEFAULT 0 187 + ); 188 + 189 + CREATE TABLE user_shop_replaceable ( 190 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 191 + lineup_update_count INTEGER NOT NULL DEFAULT 0, 192 + latest_lineup_update_datetime INTEGER NOT NULL DEFAULT 0, 193 + latest_version INTEGER NOT NULL DEFAULT 0 194 + ); 195 + 196 + CREATE TABLE user_gacha ( 197 + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), 198 + reward_available INTEGER NOT NULL DEFAULT 0, 199 + todays_current_draw_count INTEGER NOT NULL DEFAULT 0, 200 + daily_max_count INTEGER NOT NULL DEFAULT 0, 201 + last_reward_draw_date INTEGER NOT NULL DEFAULT 0, 202 + obtain_consumable_item_id INTEGER, 203 + obtain_count INTEGER 204 + ); 205 + 206 + -- ============================================================================= 207 + -- 2. Characters and Progression 208 + -- ============================================================================= 209 + 210 + CREATE TABLE user_characters ( 211 + user_id INTEGER NOT NULL REFERENCES users(user_id), 212 + character_id INTEGER NOT NULL, 213 + level INTEGER NOT NULL DEFAULT 0, 214 + exp INTEGER NOT NULL DEFAULT 0, 215 + latest_version INTEGER NOT NULL DEFAULT 0, 216 + PRIMARY KEY (user_id, character_id) 217 + ); 218 + 219 + CREATE TABLE user_character_boards ( 220 + user_id INTEGER NOT NULL REFERENCES users(user_id), 221 + character_board_id INTEGER NOT NULL, 222 + panel_release_bit1 INTEGER NOT NULL DEFAULT 0, 223 + panel_release_bit2 INTEGER NOT NULL DEFAULT 0, 224 + panel_release_bit3 INTEGER NOT NULL DEFAULT 0, 225 + panel_release_bit4 INTEGER NOT NULL DEFAULT 0, 226 + latest_version INTEGER NOT NULL DEFAULT 0, 227 + PRIMARY KEY (user_id, character_board_id) 228 + ); 229 + 230 + CREATE TABLE user_character_board_abilities ( 231 + user_id INTEGER NOT NULL REFERENCES users(user_id), 232 + character_id INTEGER NOT NULL, 233 + ability_id INTEGER NOT NULL, 234 + level INTEGER NOT NULL DEFAULT 0, 235 + latest_version INTEGER NOT NULL DEFAULT 0, 236 + PRIMARY KEY (user_id, character_id, ability_id) 237 + ); 238 + 239 + CREATE TABLE user_character_board_status_ups ( 240 + user_id INTEGER NOT NULL REFERENCES users(user_id), 241 + character_id INTEGER NOT NULL, 242 + status_calculation_type INTEGER NOT NULL, 243 + hp INTEGER NOT NULL DEFAULT 0, 244 + attack INTEGER NOT NULL DEFAULT 0, 245 + vitality INTEGER NOT NULL DEFAULT 0, 246 + agility INTEGER NOT NULL DEFAULT 0, 247 + critical_ratio INTEGER NOT NULL DEFAULT 0, 248 + critical_attack INTEGER NOT NULL DEFAULT 0, 249 + latest_version INTEGER NOT NULL DEFAULT 0, 250 + PRIMARY KEY (user_id, character_id, status_calculation_type) 251 + ); 252 + 253 + CREATE TABLE user_character_rebirths ( 254 + user_id INTEGER NOT NULL REFERENCES users(user_id), 255 + character_id INTEGER NOT NULL, 256 + rebirth_count INTEGER NOT NULL DEFAULT 0, 257 + latest_version INTEGER NOT NULL DEFAULT 0, 258 + PRIMARY KEY (user_id, character_id) 259 + ); 260 + 261 + -- ============================================================================= 262 + -- 3. Equipment (UUID-keyed) 263 + -- ============================================================================= 264 + 265 + CREATE TABLE user_costumes ( 266 + user_id INTEGER NOT NULL REFERENCES users(user_id), 267 + user_costume_uuid TEXT NOT NULL, 268 + costume_id INTEGER NOT NULL, 269 + limit_break_count INTEGER NOT NULL DEFAULT 0, 270 + level INTEGER NOT NULL DEFAULT 0, 271 + exp INTEGER NOT NULL DEFAULT 0, 272 + headup_display_view_id INTEGER NOT NULL DEFAULT 0, 273 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 274 + awaken_count INTEGER NOT NULL DEFAULT 0, 275 + latest_version INTEGER NOT NULL DEFAULT 0, 276 + PRIMARY KEY (user_id, user_costume_uuid) 277 + ); 278 + 279 + CREATE TABLE user_costume_active_skills ( 280 + user_id INTEGER NOT NULL REFERENCES users(user_id), 281 + user_costume_uuid TEXT NOT NULL, 282 + level INTEGER NOT NULL DEFAULT 0, 283 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 284 + latest_version INTEGER NOT NULL DEFAULT 0, 285 + PRIMARY KEY (user_id, user_costume_uuid) 286 + ); 287 + 288 + CREATE TABLE user_costume_awaken_status_ups ( 289 + user_id INTEGER NOT NULL REFERENCES users(user_id), 290 + user_costume_uuid TEXT NOT NULL, 291 + status_calculation_type INTEGER NOT NULL, 292 + hp INTEGER NOT NULL DEFAULT 0, 293 + attack INTEGER NOT NULL DEFAULT 0, 294 + vitality INTEGER NOT NULL DEFAULT 0, 295 + agility INTEGER NOT NULL DEFAULT 0, 296 + critical_ratio INTEGER NOT NULL DEFAULT 0, 297 + critical_attack INTEGER NOT NULL DEFAULT 0, 298 + latest_version INTEGER NOT NULL DEFAULT 0, 299 + PRIMARY KEY (user_id, user_costume_uuid, status_calculation_type) 300 + ); 301 + 302 + CREATE TABLE user_weapons ( 303 + user_id INTEGER NOT NULL REFERENCES users(user_id), 304 + user_weapon_uuid TEXT NOT NULL, 305 + weapon_id INTEGER NOT NULL, 306 + level INTEGER NOT NULL DEFAULT 0, 307 + exp INTEGER NOT NULL DEFAULT 0, 308 + limit_break_count INTEGER NOT NULL DEFAULT 0, 309 + is_protected INTEGER NOT NULL DEFAULT 0, 310 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 311 + latest_version INTEGER NOT NULL DEFAULT 0, 312 + PRIMARY KEY (user_id, user_weapon_uuid) 313 + ); 314 + 315 + CREATE TABLE user_weapon_skills ( 316 + user_id INTEGER NOT NULL REFERENCES users(user_id), 317 + user_weapon_uuid TEXT NOT NULL, 318 + slot_number INTEGER NOT NULL, 319 + level INTEGER NOT NULL DEFAULT 0, 320 + PRIMARY KEY (user_id, user_weapon_uuid, slot_number) 321 + ); 322 + 323 + CREATE TABLE user_weapon_abilities ( 324 + user_id INTEGER NOT NULL REFERENCES users(user_id), 325 + user_weapon_uuid TEXT NOT NULL, 326 + slot_number INTEGER NOT NULL, 327 + level INTEGER NOT NULL DEFAULT 0, 328 + PRIMARY KEY (user_id, user_weapon_uuid, slot_number) 329 + ); 330 + 331 + CREATE TABLE user_weapon_awakens ( 332 + user_id INTEGER NOT NULL REFERENCES users(user_id), 333 + user_weapon_uuid TEXT NOT NULL, 334 + latest_version INTEGER NOT NULL DEFAULT 0, 335 + PRIMARY KEY (user_id, user_weapon_uuid) 336 + ); 337 + 338 + CREATE TABLE user_weapon_stories ( 339 + user_id INTEGER NOT NULL REFERENCES users(user_id), 340 + weapon_id INTEGER NOT NULL, 341 + released_max_story_index INTEGER NOT NULL DEFAULT 0, 342 + latest_version INTEGER NOT NULL DEFAULT 0, 343 + PRIMARY KEY (user_id, weapon_id) 344 + ); 345 + 346 + CREATE TABLE user_weapon_notes ( 347 + user_id INTEGER NOT NULL REFERENCES users(user_id), 348 + weapon_id INTEGER NOT NULL, 349 + max_level INTEGER NOT NULL DEFAULT 0, 350 + max_limit_break_count INTEGER NOT NULL DEFAULT 0, 351 + first_acquisition_datetime INTEGER NOT NULL DEFAULT 0, 352 + latest_version INTEGER NOT NULL DEFAULT 0, 353 + PRIMARY KEY (user_id, weapon_id) 354 + ); 355 + 356 + CREATE TABLE user_companions ( 357 + user_id INTEGER NOT NULL REFERENCES users(user_id), 358 + user_companion_uuid TEXT NOT NULL, 359 + companion_id INTEGER NOT NULL, 360 + headup_display_view_id INTEGER NOT NULL DEFAULT 0, 361 + level INTEGER NOT NULL DEFAULT 0, 362 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 363 + latest_version INTEGER NOT NULL DEFAULT 0, 364 + PRIMARY KEY (user_id, user_companion_uuid) 365 + ); 366 + 367 + CREATE TABLE user_thoughts ( 368 + user_id INTEGER NOT NULL REFERENCES users(user_id), 369 + user_thought_uuid TEXT NOT NULL, 370 + thought_id INTEGER NOT NULL, 371 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 372 + latest_version INTEGER NOT NULL DEFAULT 0, 373 + PRIMARY KEY (user_id, user_thought_uuid) 374 + ); 375 + 376 + CREATE TABLE user_parts ( 377 + user_id INTEGER NOT NULL REFERENCES users(user_id), 378 + user_parts_uuid TEXT NOT NULL, 379 + parts_id INTEGER NOT NULL, 380 + level INTEGER NOT NULL DEFAULT 0, 381 + parts_status_main_id INTEGER NOT NULL DEFAULT 0, 382 + is_protected INTEGER NOT NULL DEFAULT 0, 383 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 384 + latest_version INTEGER NOT NULL DEFAULT 0, 385 + PRIMARY KEY (user_id, user_parts_uuid) 386 + ); 387 + 388 + CREATE TABLE user_parts_group_notes ( 389 + user_id INTEGER NOT NULL REFERENCES users(user_id), 390 + parts_group_id INTEGER NOT NULL, 391 + first_acquisition_datetime INTEGER NOT NULL DEFAULT 0, 392 + latest_version INTEGER NOT NULL DEFAULT 0, 393 + PRIMARY KEY (user_id, parts_group_id) 394 + ); 395 + 396 + CREATE TABLE user_parts_presets ( 397 + user_id INTEGER NOT NULL REFERENCES users(user_id), 398 + user_parts_preset_number INTEGER NOT NULL, 399 + user_parts_uuid01 TEXT NOT NULL DEFAULT '', 400 + user_parts_uuid02 TEXT NOT NULL DEFAULT '', 401 + user_parts_uuid03 TEXT NOT NULL DEFAULT '', 402 + name TEXT NOT NULL DEFAULT '', 403 + user_parts_preset_tag_number INTEGER NOT NULL DEFAULT 0, 404 + latest_version INTEGER NOT NULL DEFAULT 0, 405 + PRIMARY KEY (user_id, user_parts_preset_number) 406 + ); 407 + 408 + -- ============================================================================= 409 + -- 4. Deck System 410 + -- ============================================================================= 411 + 412 + CREATE TABLE user_deck_characters ( 413 + user_id INTEGER NOT NULL REFERENCES users(user_id), 414 + user_deck_character_uuid TEXT NOT NULL, 415 + user_costume_uuid TEXT NOT NULL DEFAULT '', 416 + main_user_weapon_uuid TEXT NOT NULL DEFAULT '', 417 + user_companion_uuid TEXT NOT NULL DEFAULT '', 418 + power INTEGER NOT NULL DEFAULT 0, 419 + user_thought_uuid TEXT NOT NULL DEFAULT '', 420 + dressup_costume_id INTEGER NOT NULL DEFAULT 0, 421 + latest_version INTEGER NOT NULL DEFAULT 0, 422 + PRIMARY KEY (user_id, user_deck_character_uuid) 423 + ); 424 + 425 + CREATE TABLE user_decks ( 426 + user_id INTEGER NOT NULL REFERENCES users(user_id), 427 + deck_type INTEGER NOT NULL, 428 + user_deck_number INTEGER NOT NULL, 429 + user_deck_character_uuid01 TEXT NOT NULL DEFAULT '', 430 + user_deck_character_uuid02 TEXT NOT NULL DEFAULT '', 431 + user_deck_character_uuid03 TEXT NOT NULL DEFAULT '', 432 + name TEXT NOT NULL DEFAULT '', 433 + power INTEGER NOT NULL DEFAULT 0, 434 + latest_version INTEGER NOT NULL DEFAULT 0, 435 + PRIMARY KEY (user_id, deck_type, user_deck_number) 436 + ); 437 + 438 + CREATE TABLE user_deck_sub_weapons ( 439 + user_id INTEGER NOT NULL REFERENCES users(user_id), 440 + user_deck_character_uuid TEXT NOT NULL, 441 + ordinal INTEGER NOT NULL, 442 + user_weapon_uuid TEXT NOT NULL, 443 + PRIMARY KEY (user_id, user_deck_character_uuid, ordinal) 444 + ); 445 + 446 + CREATE TABLE user_deck_parts ( 447 + user_id INTEGER NOT NULL REFERENCES users(user_id), 448 + user_deck_character_uuid TEXT NOT NULL, 449 + ordinal INTEGER NOT NULL, 450 + user_parts_uuid TEXT NOT NULL, 451 + PRIMARY KEY (user_id, user_deck_character_uuid, ordinal) 452 + ); 453 + 454 + CREATE TABLE user_deck_type_notes ( 455 + user_id INTEGER NOT NULL REFERENCES users(user_id), 456 + deck_type INTEGER NOT NULL, 457 + max_deck_power INTEGER NOT NULL DEFAULT 0, 458 + latest_version INTEGER NOT NULL DEFAULT 0, 459 + PRIMARY KEY (user_id, deck_type) 460 + ); 461 + 462 + -- ============================================================================= 463 + -- 5. Quests 464 + -- ============================================================================= 465 + 466 + CREATE TABLE user_quests ( 467 + user_id INTEGER NOT NULL REFERENCES users(user_id), 468 + quest_id INTEGER NOT NULL, 469 + quest_state_type INTEGER NOT NULL DEFAULT 0, 470 + is_battle_only INTEGER NOT NULL DEFAULT 0, 471 + user_deck_number INTEGER NOT NULL DEFAULT 0, 472 + latest_start_datetime INTEGER NOT NULL DEFAULT 0, 473 + clear_count INTEGER NOT NULL DEFAULT 0, 474 + daily_clear_count INTEGER NOT NULL DEFAULT 0, 475 + last_clear_datetime INTEGER NOT NULL DEFAULT 0, 476 + shortest_clear_frames INTEGER NOT NULL DEFAULT 0, 477 + is_reward_granted INTEGER NOT NULL DEFAULT 0, 478 + latest_version INTEGER NOT NULL DEFAULT 0, 479 + PRIMARY KEY (user_id, quest_id) 480 + ); 481 + 482 + CREATE TABLE user_quest_missions ( 483 + user_id INTEGER NOT NULL REFERENCES users(user_id), 484 + quest_id INTEGER NOT NULL, 485 + quest_mission_id INTEGER NOT NULL, 486 + progress_value INTEGER NOT NULL DEFAULT 0, 487 + is_clear INTEGER NOT NULL DEFAULT 0, 488 + latest_clear_datetime INTEGER NOT NULL DEFAULT 0, 489 + latest_version INTEGER NOT NULL DEFAULT 0, 490 + PRIMARY KEY (user_id, quest_id, quest_mission_id) 491 + ); 492 + 493 + CREATE TABLE user_missions ( 494 + user_id INTEGER NOT NULL REFERENCES users(user_id), 495 + mission_id INTEGER NOT NULL, 496 + start_datetime INTEGER NOT NULL DEFAULT 0, 497 + progress_value INTEGER NOT NULL DEFAULT 0, 498 + mission_progress_status_type INTEGER NOT NULL DEFAULT 0, 499 + clear_datetime INTEGER NOT NULL DEFAULT 0, 500 + latest_version INTEGER NOT NULL DEFAULT 0, 501 + PRIMARY KEY (user_id, mission_id) 502 + ); 503 + 504 + CREATE TABLE user_side_story_quests ( 505 + user_id INTEGER NOT NULL REFERENCES users(user_id), 506 + side_story_quest_id INTEGER NOT NULL, 507 + head_side_story_quest_scene_id INTEGER NOT NULL DEFAULT 0, 508 + side_story_quest_state_type INTEGER NOT NULL DEFAULT 0, 509 + latest_version INTEGER NOT NULL DEFAULT 0, 510 + PRIMARY KEY (user_id, side_story_quest_id) 511 + ); 512 + 513 + CREATE TABLE user_quest_limit_content_status ( 514 + user_id INTEGER NOT NULL REFERENCES users(user_id), 515 + limit_content_id INTEGER NOT NULL, 516 + limit_content_quest_status_type INTEGER NOT NULL DEFAULT 0, 517 + event_quest_chapter_id INTEGER NOT NULL DEFAULT 0, 518 + latest_version INTEGER NOT NULL DEFAULT 0, 519 + PRIMARY KEY (user_id, limit_content_id) 520 + ); 521 + 522 + -- ============================================================================= 523 + -- 6. Big Hunt 524 + -- ============================================================================= 525 + 526 + CREATE TABLE user_big_hunt_max_scores ( 527 + user_id INTEGER NOT NULL REFERENCES users(user_id), 528 + big_hunt_boss_id INTEGER NOT NULL, 529 + max_score INTEGER NOT NULL DEFAULT 0, 530 + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, 531 + latest_version INTEGER NOT NULL DEFAULT 0, 532 + PRIMARY KEY (user_id, big_hunt_boss_id) 533 + ); 534 + 535 + CREATE TABLE user_big_hunt_statuses ( 536 + user_id INTEGER NOT NULL REFERENCES users(user_id), 537 + big_hunt_boss_id INTEGER NOT NULL, 538 + daily_challenge_count INTEGER NOT NULL DEFAULT 0, 539 + latest_challenge_datetime INTEGER NOT NULL DEFAULT 0, 540 + latest_version INTEGER NOT NULL DEFAULT 0, 541 + PRIMARY KEY (user_id, big_hunt_boss_id) 542 + ); 543 + 544 + CREATE TABLE user_big_hunt_schedule_max_scores ( 545 + user_id INTEGER NOT NULL REFERENCES users(user_id), 546 + big_hunt_schedule_id INTEGER NOT NULL, 547 + big_hunt_boss_id INTEGER NOT NULL, 548 + max_score INTEGER NOT NULL DEFAULT 0, 549 + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, 550 + latest_version INTEGER NOT NULL DEFAULT 0, 551 + PRIMARY KEY (user_id, big_hunt_schedule_id, big_hunt_boss_id) 552 + ); 553 + 554 + CREATE TABLE user_big_hunt_weekly_max_scores ( 555 + user_id INTEGER NOT NULL REFERENCES users(user_id), 556 + big_hunt_weekly_version INTEGER NOT NULL, 557 + attribute_type INTEGER NOT NULL, 558 + max_score INTEGER NOT NULL DEFAULT 0, 559 + latest_version INTEGER NOT NULL DEFAULT 0, 560 + PRIMARY KEY (user_id, big_hunt_weekly_version, attribute_type) 561 + ); 562 + 563 + CREATE TABLE user_big_hunt_weekly_statuses ( 564 + user_id INTEGER NOT NULL REFERENCES users(user_id), 565 + big_hunt_weekly_version INTEGER NOT NULL, 566 + is_received_weekly_reward INTEGER NOT NULL DEFAULT 0, 567 + latest_version INTEGER NOT NULL DEFAULT 0, 568 + PRIMARY KEY (user_id, big_hunt_weekly_version) 569 + ); 570 + 571 + -- ============================================================================= 572 + -- 7. Gimmicks 573 + -- ============================================================================= 574 + 575 + CREATE TABLE user_gimmick_progress ( 576 + user_id INTEGER NOT NULL REFERENCES users(user_id), 577 + gimmick_sequence_schedule_id INTEGER NOT NULL, 578 + gimmick_sequence_id INTEGER NOT NULL, 579 + gimmick_id INTEGER NOT NULL, 580 + is_gimmick_cleared INTEGER NOT NULL DEFAULT 0, 581 + start_datetime INTEGER NOT NULL DEFAULT 0, 582 + latest_version INTEGER NOT NULL DEFAULT 0, 583 + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id) 584 + ); 585 + 586 + CREATE TABLE user_gimmick_ornament_progress ( 587 + user_id INTEGER NOT NULL REFERENCES users(user_id), 588 + gimmick_sequence_schedule_id INTEGER NOT NULL, 589 + gimmick_sequence_id INTEGER NOT NULL, 590 + gimmick_id INTEGER NOT NULL, 591 + gimmick_ornament_index INTEGER NOT NULL, 592 + progress_value_bit INTEGER NOT NULL DEFAULT 0, 593 + base_datetime INTEGER NOT NULL DEFAULT 0, 594 + latest_version INTEGER NOT NULL DEFAULT 0, 595 + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, gimmick_ornament_index) 596 + ); 597 + 598 + CREATE TABLE user_gimmick_sequences ( 599 + user_id INTEGER NOT NULL REFERENCES users(user_id), 600 + gimmick_sequence_schedule_id INTEGER NOT NULL, 601 + gimmick_sequence_id INTEGER NOT NULL, 602 + is_gimmick_sequence_cleared INTEGER NOT NULL DEFAULT 0, 603 + clear_datetime INTEGER NOT NULL DEFAULT 0, 604 + latest_version INTEGER NOT NULL DEFAULT 0, 605 + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id) 606 + ); 607 + 608 + CREATE TABLE user_gimmick_unlocks ( 609 + user_id INTEGER NOT NULL REFERENCES users(user_id), 610 + gimmick_sequence_schedule_id INTEGER NOT NULL, 611 + gimmick_sequence_id INTEGER NOT NULL, 612 + gimmick_id INTEGER NOT NULL, 613 + is_unlocked INTEGER NOT NULL DEFAULT 0, 614 + latest_version INTEGER NOT NULL DEFAULT 0, 615 + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id) 616 + ); 617 + 618 + -- ============================================================================= 619 + -- 8. Inventory 620 + -- ============================================================================= 621 + 622 + CREATE TABLE user_consumable_items ( 623 + user_id INTEGER NOT NULL REFERENCES users(user_id), 624 + consumable_item_id INTEGER NOT NULL, 625 + count INTEGER NOT NULL DEFAULT 0, 626 + PRIMARY KEY (user_id, consumable_item_id) 627 + ); 628 + 629 + CREATE TABLE user_materials ( 630 + user_id INTEGER NOT NULL REFERENCES users(user_id), 631 + material_id INTEGER NOT NULL, 632 + count INTEGER NOT NULL DEFAULT 0, 633 + PRIMARY KEY (user_id, material_id) 634 + ); 635 + 636 + CREATE TABLE user_important_items ( 637 + user_id INTEGER NOT NULL REFERENCES users(user_id), 638 + important_item_id INTEGER NOT NULL, 639 + count INTEGER NOT NULL DEFAULT 0, 640 + PRIMARY KEY (user_id, important_item_id) 641 + ); 642 + 643 + CREATE TABLE user_premium_items ( 644 + user_id INTEGER NOT NULL REFERENCES users(user_id), 645 + premium_item_id INTEGER NOT NULL, 646 + count INTEGER NOT NULL DEFAULT 0, 647 + PRIMARY KEY (user_id, premium_item_id) 648 + ); 649 + 650 + CREATE TABLE user_tutorials ( 651 + user_id INTEGER NOT NULL REFERENCES users(user_id), 652 + tutorial_type INTEGER NOT NULL, 653 + progress_phase INTEGER NOT NULL DEFAULT 0, 654 + choice_id INTEGER NOT NULL DEFAULT 0, 655 + latest_version INTEGER NOT NULL DEFAULT 0, 656 + PRIMARY KEY (user_id, tutorial_type) 657 + ); 658 + 659 + CREATE TABLE user_explore_scores ( 660 + user_id INTEGER NOT NULL REFERENCES users(user_id), 661 + explore_id INTEGER NOT NULL, 662 + max_score INTEGER NOT NULL DEFAULT 0, 663 + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, 664 + latest_version INTEGER NOT NULL DEFAULT 0, 665 + PRIMARY KEY (user_id, explore_id) 666 + ); 667 + 668 + CREATE TABLE user_auto_sale_settings ( 669 + user_id INTEGER NOT NULL REFERENCES users(user_id), 670 + possession_auto_sale_item_type INTEGER NOT NULL, 671 + possession_auto_sale_item_value TEXT NOT NULL DEFAULT '', 672 + PRIMARY KEY (user_id, possession_auto_sale_item_type) 673 + ); 674 + 675 + -- ============================================================================= 676 + -- 9. Simple Progress Maps 677 + -- ============================================================================= 678 + 679 + CREATE TABLE user_navi_cutin_played ( 680 + user_id INTEGER NOT NULL REFERENCES users(user_id), 681 + navi_cutin_id INTEGER NOT NULL, 682 + PRIMARY KEY (user_id, navi_cutin_id) 683 + ); 684 + 685 + CREATE TABLE user_viewed_movies ( 686 + user_id INTEGER NOT NULL REFERENCES users(user_id), 687 + movie_id INTEGER NOT NULL, 688 + timestamp INTEGER NOT NULL DEFAULT 0, 689 + PRIMARY KEY (user_id, movie_id) 690 + ); 691 + 692 + CREATE TABLE user_contents_stories ( 693 + user_id INTEGER NOT NULL REFERENCES users(user_id), 694 + contents_story_id INTEGER NOT NULL, 695 + timestamp INTEGER NOT NULL DEFAULT 0, 696 + PRIMARY KEY (user_id, contents_story_id) 697 + ); 698 + 699 + CREATE TABLE user_drawn_omikuji ( 700 + user_id INTEGER NOT NULL REFERENCES users(user_id), 701 + omikuji_id INTEGER NOT NULL, 702 + timestamp INTEGER NOT NULL DEFAULT 0, 703 + PRIMARY KEY (user_id, omikuji_id) 704 + ); 705 + 706 + CREATE TABLE user_dokan_confirmed ( 707 + user_id INTEGER NOT NULL REFERENCES users(user_id), 708 + dokan_id INTEGER NOT NULL, 709 + PRIMARY KEY (user_id, dokan_id) 710 + ); 711 + 712 + -- ============================================================================= 713 + -- 10. Gifts 714 + -- ============================================================================= 715 + 716 + CREATE TABLE user_gifts ( 717 + user_id INTEGER NOT NULL REFERENCES users(user_id), 718 + user_gift_uuid TEXT NOT NULL, 719 + is_received INTEGER NOT NULL DEFAULT 0, 720 + possession_type INTEGER NOT NULL DEFAULT 0, 721 + possession_id INTEGER NOT NULL DEFAULT 0, 722 + count INTEGER NOT NULL DEFAULT 0, 723 + grant_datetime INTEGER NOT NULL DEFAULT 0, 724 + description_gift_text_id INTEGER NOT NULL DEFAULT 0, 725 + equipment_data BLOB, 726 + expiration_datetime INTEGER, 727 + received_datetime INTEGER, 728 + PRIMARY KEY (user_id, user_gift_uuid) 729 + ); 730 + 731 + -- ============================================================================= 732 + -- 11. Gacha 733 + -- ============================================================================= 734 + 735 + CREATE TABLE user_gacha_converted_medals ( 736 + user_id INTEGER NOT NULL REFERENCES users(user_id), 737 + ordinal INTEGER NOT NULL, 738 + consumable_item_id INTEGER NOT NULL, 739 + count INTEGER NOT NULL DEFAULT 0, 740 + PRIMARY KEY (user_id, ordinal) 741 + ); 742 + 743 + CREATE TABLE user_gacha_banners ( 744 + user_id INTEGER NOT NULL REFERENCES users(user_id), 745 + gacha_id INTEGER NOT NULL, 746 + medal_count INTEGER NOT NULL DEFAULT 0, 747 + step_number INTEGER NOT NULL DEFAULT 0, 748 + loop_count INTEGER NOT NULL DEFAULT 0, 749 + draw_count INTEGER NOT NULL DEFAULT 0, 750 + box_number INTEGER NOT NULL DEFAULT 0, 751 + PRIMARY KEY (user_id, gacha_id) 752 + ); 753 + 754 + CREATE TABLE user_gacha_banner_box_drew_counts ( 755 + user_id INTEGER NOT NULL REFERENCES users(user_id), 756 + gacha_id INTEGER NOT NULL, 757 + box_item_id INTEGER NOT NULL, 758 + count INTEGER NOT NULL DEFAULT 0, 759 + PRIMARY KEY (user_id, gacha_id, box_item_id) 760 + ); 761 + 762 + -- ============================================================================= 763 + -- 12. Shop 764 + -- ============================================================================= 765 + 766 + CREATE TABLE user_shop_items ( 767 + user_id INTEGER NOT NULL REFERENCES users(user_id), 768 + shop_item_id INTEGER NOT NULL, 769 + bought_count INTEGER NOT NULL DEFAULT 0, 770 + latest_bought_count_changed_datetime INTEGER NOT NULL DEFAULT 0, 771 + latest_version INTEGER NOT NULL DEFAULT 0, 772 + PRIMARY KEY (user_id, shop_item_id) 773 + ); 774 + 775 + CREATE TABLE user_shop_replaceable_lineup ( 776 + user_id INTEGER NOT NULL REFERENCES users(user_id), 777 + slot_number INTEGER NOT NULL, 778 + shop_item_id INTEGER NOT NULL DEFAULT 0, 779 + latest_version INTEGER NOT NULL DEFAULT 0, 780 + PRIMARY KEY (user_id, slot_number) 781 + ); 782 + 783 + -- ============================================================================= 784 + -- 13. Cage Ornaments 785 + -- ============================================================================= 786 + 787 + CREATE TABLE user_cage_ornament_rewards ( 788 + user_id INTEGER NOT NULL REFERENCES users(user_id), 789 + cage_ornament_id INTEGER NOT NULL, 790 + acquisition_datetime INTEGER NOT NULL DEFAULT 0, 791 + latest_version INTEGER NOT NULL DEFAULT 0, 792 + PRIMARY KEY (user_id, cage_ornament_id) 793 + ); 794 + 795 + -- +goose Down 796 + 797 + DROP TABLE IF EXISTS user_cage_ornament_rewards ; 798 + DROP TABLE IF EXISTS user_shop_replaceable_lineup ; 799 + DROP TABLE IF EXISTS user_shop_items ; 800 + DROP TABLE IF EXISTS user_gacha_banner_box_drew_counts; 801 + DROP TABLE IF EXISTS user_gacha_banners ; 802 + DROP TABLE IF EXISTS user_gacha_converted_medals ; 803 + DROP TABLE IF EXISTS user_gifts ; 804 + DROP TABLE IF EXISTS user_dokan_confirmed ; 805 + DROP TABLE IF EXISTS user_drawn_omikuji ; 806 + DROP TABLE IF EXISTS user_contents_stories ; 807 + DROP TABLE IF EXISTS user_viewed_movies ; 808 + DROP TABLE IF EXISTS user_navi_cutin_played ; 809 + DROP TABLE IF EXISTS user_auto_sale_settings ; 810 + DROP TABLE IF EXISTS user_explore_scores ; 811 + DROP TABLE IF EXISTS user_tutorials ; 812 + DROP TABLE IF EXISTS user_premium_items ; 813 + DROP TABLE IF EXISTS user_important_items ; 814 + DROP TABLE IF EXISTS user_materials ; 815 + DROP TABLE IF EXISTS user_consumable_items ; 816 + DROP TABLE IF EXISTS user_gimmick_unlocks ; 817 + DROP TABLE IF EXISTS user_gimmick_sequences ; 818 + DROP TABLE IF EXISTS user_gimmick_ornament_progress ; 819 + DROP TABLE IF EXISTS user_gimmick_progress ; 820 + DROP TABLE IF EXISTS user_big_hunt_weekly_statuses ; 821 + DROP TABLE IF EXISTS user_big_hunt_weekly_max_scores ; 822 + DROP TABLE IF EXISTS user_big_hunt_schedule_max_scores; 823 + DROP TABLE IF EXISTS user_big_hunt_statuses ; 824 + DROP TABLE IF EXISTS user_big_hunt_max_scores ; 825 + DROP TABLE IF EXISTS user_quest_limit_content_status ; 826 + DROP TABLE IF EXISTS user_side_story_quests ; 827 + DROP TABLE IF EXISTS user_missions ; 828 + DROP TABLE IF EXISTS user_quest_missions ; 829 + DROP TABLE IF EXISTS user_quests ; 830 + DROP TABLE IF EXISTS user_deck_type_notes ; 831 + DROP TABLE IF EXISTS user_deck_parts ; 832 + DROP TABLE IF EXISTS user_deck_sub_weapons ; 833 + DROP TABLE IF EXISTS user_decks ; 834 + DROP TABLE IF EXISTS user_deck_characters ; 835 + DROP TABLE IF EXISTS user_parts_presets ; 836 + DROP TABLE IF EXISTS user_parts_group_notes ; 837 + DROP TABLE IF EXISTS user_parts ; 838 + DROP TABLE IF EXISTS user_thoughts ; 839 + DROP TABLE IF EXISTS user_companions ; 840 + DROP TABLE IF EXISTS user_weapon_notes ; 841 + DROP TABLE IF EXISTS user_weapon_stories ; 842 + DROP TABLE IF EXISTS user_weapon_awakens ; 843 + DROP TABLE IF EXISTS user_weapon_abilities ; 844 + DROP TABLE IF EXISTS user_weapon_skills ; 845 + DROP TABLE IF EXISTS user_weapons ; 846 + DROP TABLE IF EXISTS user_costume_awaken_status_ups ; 847 + DROP TABLE IF EXISTS user_costume_active_skills ; 848 + DROP TABLE IF EXISTS user_costumes ; 849 + DROP TABLE IF EXISTS user_character_rebirths ; 850 + DROP TABLE IF EXISTS user_character_board_status_ups ; 851 + DROP TABLE IF EXISTS user_character_board_abilities ; 852 + DROP TABLE IF EXISTS user_character_boards ; 853 + DROP TABLE IF EXISTS user_characters ; 854 + DROP TABLE IF EXISTS user_gacha ; 855 + DROP TABLE IF EXISTS user_shop_replaceable ; 856 + DROP TABLE IF EXISTS user_explore ; 857 + DROP TABLE IF EXISTS user_guerrilla_free_open ; 858 + DROP TABLE IF EXISTS user_portal_cage ; 859 + DROP TABLE IF EXISTS user_notification ; 860 + DROP TABLE IF EXISTS user_battle ; 861 + DROP TABLE IF EXISTS user_big_hunt_state ; 862 + DROP TABLE IF EXISTS user_side_story_active ; 863 + DROP TABLE IF EXISTS user_extra_quest ; 864 + DROP TABLE IF EXISTS user_event_quest ; 865 + DROP TABLE IF EXISTS user_main_quest ; 866 + DROP TABLE IF EXISTS user_login_bonus ; 867 + DROP TABLE IF EXISTS user_login ; 868 + DROP TABLE IF EXISTS user_profile ; 869 + DROP TABLE IF EXISTS user_gem ; 870 + DROP TABLE IF EXISTS user_status ; 871 + DROP TABLE IF EXISTS user_setting ; 872 + DROP TABLE IF EXISTS sessions ; 873 + DROP TABLE IF EXISTS users ; 874 +
+13
server/migrations/20260417173916_fix_deck_weapon.sql
··· 1 + -- +goose Up 2 + 3 + -- Delete deck characters with empty weapons (always a bug). 4 + DELETE FROM user_deck_characters 5 + WHERE main_user_weapon_uuid = ''; 6 + 7 + -- Delete decks that reference deleted deck characters. 8 + DELETE FROM user_decks 9 + WHERE user_deck_character_uuid01 NOT IN (SELECT user_deck_character_uuid FROM user_deck_characters) 10 + AND user_deck_character_uuid01 != ''; 11 + 12 + -- +goose Down 13 + -- No rollback needed: EnsureDefaultDeck recreates decks on next SetTutorialProgress call.
+15
server/migrations/20260419073659_add_costume_lottery_effects.sql
··· 1 + -- +goose Up 2 + CREATE TABLE user_costume_lottery_effects ( 3 + user_id INTEGER NOT NULL REFERENCES users(user_id), 4 + user_costume_uuid TEXT NOT NULL, 5 + slot_number INTEGER NOT NULL, 6 + odds_number INTEGER NOT NULL DEFAULT 0, 7 + latest_version INTEGER NOT NULL DEFAULT 0, 8 + PRIMARY KEY (user_id, user_costume_uuid, slot_number) 9 + ); 10 + 11 + ALTER TABLE user_costumes ADD COLUMN costume_lottery_effect_unlocked_slot_count INTEGER NOT NULL DEFAULT 0; 12 + 13 + -- +goose Down 14 + ALTER TABLE user_costumes DROP COLUMN costume_lottery_effect_unlocked_slot_count; 15 + DROP TABLE IF EXISTS user_costume_lottery_effects;
+12
server/migrations/20260419083839_add_costume_lottery_effect_pending.sql
··· 1 + -- +goose Up 2 + CREATE TABLE user_costume_lottery_effect_pending ( 3 + user_id INTEGER NOT NULL REFERENCES users(user_id), 4 + user_costume_uuid TEXT NOT NULL, 5 + slot_number INTEGER NOT NULL, 6 + odds_number INTEGER NOT NULL DEFAULT 0, 7 + latest_version INTEGER NOT NULL DEFAULT 0, 8 + PRIMARY KEY (user_id, user_costume_uuid) 9 + ); 10 + 11 + -- +goose Down 12 + DROP TABLE IF EXISTS user_costume_lottery_effect_pending;