diff --git a/.gitignore b/.gitignore index 4e805d0..e95f0c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ server/bin/ server/tmp/ server/lunar-tear +server/import-snapshot __pycache__/ @@ -13,12 +14,9 @@ node_modules/ # Go server/vendor/ - -# Certs (regenerate per-environment) server/certs/ - -# Server assets (binary data, too large for git) server/assets/ +db/ # Snapshots (recorded user state) snapshots/ diff --git a/README.md b/README.md index 9ce8a66..de2da35 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,14 @@ Discord server: https://discord.gg/MZAf5aVkJG ### Prerequisites -- Go 1.24+ +- Go 1.25+ +- [goose](https://github.com/pressly/goose) migration tool - Populated `server/assets/` directory +```bash +go install github.com/pressly/goose/v3/cmd/goose@latest +``` + ### Regenerate protobuf stubs ```bash @@ -17,14 +22,54 @@ cd server make proto ``` +### Database + +Player state is stored in a SQLite database. Run migrations before starting the server: + +```bash +cd server +make migrate +``` + +Or manually: + +```bash +cd server +mkdir -p db +goose -dir migrations sqlite3 db/game.db up +``` + +### Importing a Snapshot + +To import a JSON snapshot into the database, use the import tool. The `--uuid` flag must match the UUID your game client sends during authentication: + +```bash +cd server +make import SNAPSHOT=snapshots/scene_1.json UUID= +``` + +Or directly: + +```bash +go run ./cmd/import-snapshot \ + --snapshot snapshots/scene_1.json \ + --uuid \ + --db db/game.db +``` + +| Flag | Default | Description | +| ------------ | ------------ | --------------------------------------------- | +| `--snapshot` | *(required)* | Path to JSON snapshot file | +| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) | +| `--db` | `db/game.db` | SQLite database path | + ### Run ```bash cd server sudo go run ./cmd/lunar-tear \ --host 10.0.2.2 \ - --http-port 8080 \ - --scene 13 + --http-port 8080 ``` `sudo` is needed because gRPC binds to port 443 (privileged). On Linux you can use `setcap` instead: @@ -32,7 +77,7 @@ sudo go run ./cmd/lunar-tear \ ```bash go build -o lunar-tear ./cmd/lunar-tear sudo setcap cap_net_bind_service=+ep ./lunar-tear -./lunar-tear --host 10.0.2.2 --http-port 8080 --scene 13 +./lunar-tear --host 10.0.2.2 --http-port 8080 ``` ### Ports @@ -44,11 +89,34 @@ sudo setcap cap_net_bind_service=+ep ./lunar-tear ### Flags -| Flag | Default | Description | -| ---------------------- | ------------------- | -------------------------------------------------------- | -| `--host` | `127.0.0.1` | hostname/IP given to the client | -| `--http-port` | `8080` | HTTP/Octo server port | -| `--scene` | `0` | bootstrap new users to scene N (0 = fresh start) | +| Flag | Default | Description | +| ------------- | ------------ | ------------------------------- | +| `--host` | `127.0.0.1` | hostname/IP given to the client | +| `--http-port` | `8080` | HTTP/Octo server port | +| `--db` | `db/game.db` | SQLite database path | + +### Docker + +Migrations run automatically on container start. + +```bash +cd server +docker compose up -d +``` + +The `db/` directory is mounted as a volume so the database persists across restarts. Make sure `assets/` is populated before starting. + +### Makefile Targets + +All targets run from the `server/` directory. + +| Target | Description | +| -------------- | ------------------------------------------------------- | +| `make proto` | Regenerate protobuf stubs | +| `make build` | Build the server binary | +| `make build-import` | Build the import-snapshot tool | +| `make migrate` | Run goose migrations on `db/game.db` | +| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | ## ⚠️ Legal Disclaimer diff --git a/server/Dockerfile b/server/Dockerfile index 6610bbc..8e71a07 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -12,7 +12,8 @@ RUN apk add --no-cache \ libcap RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest &&\ - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest &&\ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest &&\ + go install github.com/pressly/goose/v3/cmd/goose@latest &&\ PATH="$PATH:$(go env GOPATH)/bin" make proto &&\ go build -o lunar-tear ./cmd/lunar-tear &&\ setcap cap_net_bind_service=+ep ./lunar-tear @@ -26,6 +27,8 @@ RUN chown 1000:1000 /opt/lunar-tear USER 1000 COPY --from=builder /usr/local/src/lunar-tear . +COPY --from=builder /root/go/bin/goose /usr/local/bin/goose +COPY --from=builder /usr/local/src/migrations ./migrations COPY entrypoint.sh . diff --git a/server/Makefile b/server/Makefile index b7918e1..91347ee 100644 --- a/server/Makefile +++ b/server/Makefile @@ -7,4 +7,23 @@ proto: protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server @echo "Generated in gen/proto/" -.PHONY: proto +build: + go build -o lunar-tear ./cmd/lunar-tear + +build-import: + go build -o import-snapshot ./cmd/import-snapshot + +migrate: + mkdir -p db + goose -dir migrations sqlite3 db/game.db up + +import: +ifndef SNAPSHOT + $(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) +endif +ifndef UUID + $(error UUID is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) +endif + go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID) + +.PHONY: proto build build-import migrate import diff --git a/server/cmd/import-snapshot/main.go b/server/cmd/import-snapshot/main.go new file mode 100644 index 0000000..5a89e7a --- /dev/null +++ b/server/cmd/import-snapshot/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "os" + + "lunar-tear/server/internal/database" + "lunar-tear/server/internal/store" + "lunar-tear/server/internal/store/sqlite" +) + +func main() { + dbPath := flag.String("db", "db/game.db", "SQLite database path") + snapshotPath := flag.String("snapshot", "", "Path to JSON snapshot file (required)") + userUuid := flag.String("uuid", "", "UUID to assign to the imported user (must match the client's UUID)") + flag.Parse() + + if *snapshotPath == "" { + log.Fatal("--snapshot flag is required") + } + if *userUuid == "" { + log.Fatal("--uuid flag is required") + } + + data, err := os.ReadFile(*snapshotPath) + if err != nil { + log.Fatalf("read snapshot: %v", err) + } + log.Printf("read %d bytes from %s", len(data), *snapshotPath) + + var u store.UserState + if err := json.Unmarshal(data, &u); err != nil { + log.Fatalf("unmarshal snapshot: %v", err) + } + u.EnsureMaps() + u.Uuid = *userUuid + + log.Printf("parsed user %d (uuid=%s, costumes=%d, weapons=%d, characters=%d, quests=%d)", + u.UserId, u.Uuid, len(u.Costumes), len(u.Weapons), len(u.Characters), len(u.Quests)) + + db, err := database.Open(*dbPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + + userStore := sqlite.New(db, nil) + + if err := userStore.ImportUser(&u); err != nil { + log.Fatalf("import user: %v", err) + } + + log.Printf("imported user %d successfully", u.UserId) +} diff --git a/server/cmd/lunar-tear/grpc.go b/server/cmd/lunar-tear/grpc.go index dc94045..733654b 100644 --- a/server/cmd/lunar-tear/grpc.go +++ b/server/cmd/lunar-tear/grpc.go @@ -12,7 +12,7 @@ import ( "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/questflow" "lunar-tear/server/internal/service" - "lunar-tear/server/internal/store/memory" + "lunar-tear/server/internal/store" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -38,9 +38,13 @@ func (l loggingListener) Accept() (net.Conn, error) { func startGRPC( host string, octoURL string, - userStore *memory.MemoryStore, + userStore interface { + store.UserRepository + store.SessionRepository + }, questEngine *questflow.QuestHandler, gachaHandler *gacha.GachaHandler, + gachaEntries []store.GachaCatalogEntry, cageOrnamentCatalog *masterdata.CageOrnamentCatalog, loginBonusCatalog *masterdata.LoginBonusCatalog, characterViewerCatalog *masterdata.CharacterViewerCatalog, @@ -77,6 +81,7 @@ func startGRPC( userStore, questEngine, gachaHandler, + gachaEntries, cageOrnamentCatalog, loginBonusCatalog, characterViewerCatalog, @@ -111,9 +116,13 @@ func registerServices( srv *grpc.Server, host string, octoURL string, - userStore *memory.MemoryStore, + userStore interface { + store.UserRepository + store.SessionRepository + }, questEngine *questflow.QuestHandler, gachaHandler *gacha.GachaHandler, + gachaEntries []store.GachaCatalogEntry, cageOrnamentCatalog *masterdata.CageOrnamentCatalog, loginBonusCatalog *masterdata.LoginBonusCatalog, characterViewerCatalog *masterdata.CharacterViewerCatalog, @@ -133,13 +142,13 @@ func registerServices( sideStoryCatalog *masterdata.SideStoryCatalog, bigHuntCatalog *masterdata.BigHuntCatalog, ) { - pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(userStore)) + pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(gachaEntries)) pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore)) pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore)) pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(host, int32(443), octoURL)) pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore)) pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine)) - pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, userStore, gachaHandler)) + pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, gachaEntries, gachaHandler)) pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore)) pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer()) pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog)) diff --git a/server/cmd/lunar-tear/main.go b/server/cmd/lunar-tear/main.go index f0f2d16..5acab96 100644 --- a/server/cmd/lunar-tear/main.go +++ b/server/cmd/lunar-tear/main.go @@ -3,23 +3,21 @@ package main import ( "flag" "log" - "os" "strconv" "strings" + "lunar-tear/server/internal/database" "lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/questflow" - "lunar-tear/server/internal/store/memory" + "lunar-tear/server/internal/store/sqlite" ) func main() { httpPort := flag.Int("http-port", 8080, "HTTP server port (Octo API)") host := flag.String("host", "127.0.0.1", "hostname the client will connect to") - scene := flag.Int("scene", 0, "Bootstrap to scene N (0 = fresh start)") - latestScene := flag.Bool("latest-scene", false, "Bootstrap from the most recently saved snapshot (overrides -scene)") - starterItems := flag.Bool("starter-items", false, "Grant starter items to new users") + dbPath := flag.String("db", "db/game.db", "SQLite database path") flag.Parse() octoURL := "http://" + *host + ":" + strconv.Itoa(*httpPort) @@ -34,18 +32,12 @@ func main() { go startHTTP(*httpPort, resourcesBaseURL) - snapshotDir := "snapshots" - if err := os.MkdirAll(snapshotDir, 0755); err != nil { - log.Fatalf("create snapshot dir: %v", err) - } - if *latestScene { - if id, ok := memory.LatestSnapshotSceneId(snapshotDir); ok { - *scene = int(id) - log.Printf("[latest-scene] auto-selected most recent snapshot: scene=%d", id) - } else { - log.Printf("[latest-scene] no snapshots found in %q; starting fresh", snapshotDir) - } + db, err := database.Open(*dbPath) + if err != nil { + log.Fatalf("open database: %v", err) } + defer db.Close() + log.Printf("database opened: %s", *dbPath) gameConfig, err := masterdata.LoadGameConfig() if err != nil { @@ -65,14 +57,7 @@ func main() { log.Fatalf("load quest catalog: %v", err) } questHandler := questflow.NewQuestHandler(questCatalog, gameConfig) - userStore := memory.New(gametime.Now, - memory.WithSnapshotDir(snapshotDir), - memory.WithSceneId(int32(*scene)), - memory.WithStarterItems(*starterItems), - ) - if *scene != 0 { - log.Printf("bootstrap scene: %d (from snapshot)", *scene) - } + userStore := sqlite.New(db, gametime.Now) gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog() if err != nil { @@ -99,7 +84,6 @@ func main() { gachaPool.BuildFeaturedMapping(gachaEntries) gachaPool.BuildBannerPools(gachaEntries) masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool) - userStore.ReplaceCatalog(gachaEntries) dupExchange, err := masterdata.LoadDupExchange() if err != nil { @@ -185,6 +169,7 @@ func main() { userStore, questHandler, gachaHandler, + gachaEntries, cageOrnamentCatalog, loginBonusCatalog, characterViewerCatalog, diff --git a/server/docker-compose.yaml b/server/docker-compose.yaml index e20b69b..1f432aa 100644 --- a/server/docker-compose.yaml +++ b/server/docker-compose.yaml @@ -5,9 +5,9 @@ services: environment: LUNAR_HOST: 127.0.0.1 LUNAR_HTTP_PORT: 8080 - LUNAR_SCENE: 0 volumes: - ./assets:/opt/lunar-tear/assets + - ./db:/opt/lunar-tear/db ports: - 443:443 # grpc, hardcoded by the client, not configurable - 8080:8080 diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 0b1e4bb..46acc15 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -1,4 +1,7 @@ #!/usr/bin/env sh +set -e -./lunar-tear --host "${LUNAR_HOST}" --http-port "${LUNAR_HTTP_PORT}" --scene "${LUNAR_SCENE}" +mkdir -p db +goose -dir migrations sqlite3 db/game.db up +exec ./lunar-tear --host "${LUNAR_HOST}" --http-port "${LUNAR_HTTP_PORT}" diff --git a/server/go.mod b/server/go.mod index 01f7c7e..2fdd93a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,17 +1,33 @@ module lunar-tear/server -go 1.24.2 +go 1.25.0 require ( + github.com/google/uuid v1.6.0 + github.com/vmihailenco/msgpack/v5 v5.4.1 + golang.org/x/net v0.50.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 ) require ( - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pressly/goose/v3 v3.27.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.2 // indirect ) diff --git a/server/go.sum b/server/go.sum index 10d34e9..e52cbf7 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,9 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -10,33 +14,64 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= +modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/server/internal/database/database.go b/server/internal/database/database.go new file mode 100644 index 0000000..7649895 --- /dev/null +++ b/server/internal/database/database.go @@ -0,0 +1,42 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + if dir := filepath.Dir(path); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create db directory %q: %w", dir, err) + } + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite %q: %w", path, err) + } + + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + db.Close() + return nil, fmt.Errorf("exec %q: %w", p, err) + } + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping sqlite %q: %w", path, err) + } + + return db, nil +} diff --git a/server/internal/masterdata/config.go b/server/internal/masterdata/config.go index ca97ea5..9e8f1b7 100644 --- a/server/internal/masterdata/config.go +++ b/server/internal/masterdata/config.go @@ -35,6 +35,9 @@ type GameConfig struct { QuestSkipMaxCountAtOnce int32 WeaponLimitBreakAvailableCount int32 + + CostumeLotteryEffectUnlockSlotConsumeGold int32 + CostumeLotteryEffectDrawSlotConsumeGold int32 } func LoadGameConfig() (*GameConfig, error) { @@ -73,6 +76,9 @@ func LoadGameConfig() (*GameConfig, error) { cfg.WeaponLimitBreakAvailableCount = parseInt32(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT") + cfg.CostumeLotteryEffectUnlockSlotConsumeGold = parseInt32(kv, "COSTUME_LOTTERY_EFFECT_UNLOCK_SLOT_CONSUME_GOLD") + cfg.CostumeLotteryEffectDrawSlotConsumeGold = parseInt32(kv, "COSTUME_LOTTERY_EFFECT_DRAW_SLOT_CONSUME_GOLD") + return cfg, nil } diff --git a/server/internal/masterdata/costume.go b/server/internal/masterdata/costume.go index e7aae18..98b8eda 100644 --- a/server/internal/masterdata/costume.go +++ b/server/internal/masterdata/costume.go @@ -78,6 +78,31 @@ type CostumeActiveSkillEnhanceMaterialRow struct { SortOrder int32 `json:"SortOrder"` } +type CostumeLotteryEffectRow struct { + CostumeId int32 `json:"CostumeId"` + SlotNumber int32 `json:"SlotNumber"` + CostumeLotteryEffectOddsGroupId int32 `json:"CostumeLotteryEffectOddsGroupId"` + CostumeLotteryEffectUnlockMaterialGroupId int32 `json:"CostumeLotteryEffectUnlockMaterialGroupId"` + CostumeLotteryEffectDrawMaterialGroupId int32 `json:"CostumeLotteryEffectDrawMaterialGroupId"` + CostumeLotteryEffectReleaseScheduleId int32 `json:"CostumeLotteryEffectReleaseScheduleId"` +} + +type CostumeLotteryEffectMaterialGroupRow struct { + CostumeLotteryEffectMaterialGroupId int32 `json:"CostumeLotteryEffectMaterialGroupId"` + MaterialId int32 `json:"MaterialId"` + Count int32 `json:"Count"` + SortOrder int32 `json:"SortOrder"` +} + +type CostumeLotteryEffectOddsRow struct { + CostumeLotteryEffectOddsGroupId int32 `json:"CostumeLotteryEffectOddsGroupId"` + OddsNumber int32 `json:"OddsNumber"` + Weight int32 `json:"Weight"` + CostumeLotteryEffectType int32 `json:"CostumeLotteryEffectType"` + CostumeLotteryEffectTargetId int32 `json:"CostumeLotteryEffectTargetId"` + RarityType int32 `json:"RarityType"` +} + type CostumeCatalog struct { Costumes map[int32]CostumeMasterRow Materials map[int32]MaterialRow @@ -96,6 +121,10 @@ type CostumeCatalog struct { ActiveSkillEnhanceMats map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow // key: [enhancementMaterialId, skillLevel] ActiveSkillMaxLevelByRarity map[int32]NumericalFunc ActiveSkillCostByRarity map[int32]NumericalFunc + + LotteryEffects map[[2]int32]CostumeLotteryEffectRow // key: [costumeId, slotNumber] + LotteryEffectMats map[int32][]CostumeLotteryEffectMaterialGroupRow // key: materialGroupId (both unlock and draw) + LotteryEffectOdds map[int32][]CostumeLotteryEffectOddsRow // key: oddsGroupId } func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) { @@ -149,6 +178,19 @@ func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) { return nil, fmt.Errorf("load costume active skill enhancement material table: %w", err) } + lotteryEffectRows, err := utils.ReadJSON[CostumeLotteryEffectRow]("EntityMCostumeLotteryEffectTable.json") + if err != nil { + return nil, fmt.Errorf("load costume lottery effect table: %w", err) + } + lotteryEffectMatRows, err := utils.ReadJSON[CostumeLotteryEffectMaterialGroupRow]("EntityMCostumeLotteryEffectMaterialGroupTable.json") + if err != nil { + return nil, fmt.Errorf("load costume lottery effect material group table: %w", err) + } + lotteryEffectOddsRows, err := utils.ReadJSON[CostumeLotteryEffectOddsRow]("EntityMCostumeLotteryEffectOddsGroupTable.json") + if err != nil { + return nil, fmt.Errorf("load costume lottery effect odds group table: %w", err) + } + catalog := &CostumeCatalog{ Costumes: make(map[int32]CostumeMasterRow, len(costumes)), Materials: matCatalog.ByType[model.MaterialTypeCostumeEnhancement], @@ -167,6 +209,10 @@ func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) { ActiveSkillEnhanceMats: make(map[[2]int32][]CostumeActiveSkillEnhanceMaterialRow), ActiveSkillMaxLevelByRarity: make(map[int32]NumericalFunc, len(rarities)), ActiveSkillCostByRarity: make(map[int32]NumericalFunc, len(rarities)), + + LotteryEffects: make(map[[2]int32]CostumeLotteryEffectRow, len(lotteryEffectRows)), + LotteryEffectMats: make(map[int32][]CostumeLotteryEffectMaterialGroupRow), + LotteryEffectOdds: make(map[int32][]CostumeLotteryEffectOddsRow), } for _, row := range costumes { @@ -242,5 +288,18 @@ func LoadCostumeCatalog(matCatalog *MaterialCatalog) (*CostumeCatalog, error) { catalog.ActiveSkillEnhanceMats[key] = append(catalog.ActiveSkillEnhanceMats[key], row) } + for _, row := range lotteryEffectRows { + key := [2]int32{row.CostumeId, row.SlotNumber} + catalog.LotteryEffects[key] = row + } + for _, row := range lotteryEffectMatRows { + gid := row.CostumeLotteryEffectMaterialGroupId + catalog.LotteryEffectMats[gid] = append(catalog.LotteryEffectMats[gid], row) + } + for _, row := range lotteryEffectOddsRows { + gid := row.CostumeLotteryEffectOddsGroupId + catalog.LotteryEffectOdds[gid] = append(catalog.LotteryEffectOdds[gid], row) + } + return catalog, nil } diff --git a/server/internal/model/status.go b/server/internal/model/status.go index 2d02d6d..b9aee5c 100644 --- a/server/internal/model/status.go +++ b/server/internal/model/status.go @@ -22,6 +22,14 @@ const ( CostumeAwakenEffectTypeItemAcquire CostumeAwakenEffectType = 3 ) +type CostumeLotteryEffectType int32 + +const ( + CostumeLotteryEffectTypeUnknown CostumeLotteryEffectType = 0 + CostumeLotteryEffectTypeAbility CostumeLotteryEffectType = 1 + CostumeLotteryEffectTypeStatusUp CostumeLotteryEffectType = 2 +) + type WeaponAwakenEffectType int32 const ( diff --git a/server/internal/questflow/rewards.go b/server/internal/questflow/rewards.go index cb39793..d0608ed 100644 --- a/server/internal/questflow/rewards.go +++ b/server/internal/questflow/rewards.go @@ -4,6 +4,8 @@ import ( "fmt" "log" + "github.com/google/uuid" + "lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/model" @@ -276,7 +278,7 @@ func (h *QuestHandler) grantCompanion(user *store.UserState, companionId int32, return } } - key := fmt.Sprintf("reward-companion-%d", companionId) + key := uuid.New().String() user.Companions[key] = store.CompanionState{ UserCompanionUuid: key, CompanionId: companionId, @@ -306,7 +308,7 @@ func (h *QuestHandler) grantParts(user *store.UserState, partsId int32, nowMilli } } - key := fmt.Sprintf("reward-parts-%d", partsId) + key := uuid.New().String() user.Parts[key] = store.PartsState{ UserPartsUuid: key, PartsId: partsId, diff --git a/server/internal/service/banner.go b/server/internal/service/banner.go index 39b3f8f..607d0e3 100644 --- a/server/internal/service/banner.go +++ b/server/internal/service/banner.go @@ -11,15 +11,15 @@ import ( type BannerServiceServer struct { pb.UnimplementedBannerServiceServer - gacha store.GachaRepository + catalog []store.GachaCatalogEntry } -func NewBannerServiceServer(gacha store.GachaRepository) *BannerServiceServer { - return &BannerServiceServer{gacha: gacha} +func NewBannerServiceServer(catalog []store.GachaCatalogEntry) *BannerServiceServer { + return &BannerServiceServer{catalog: catalog} } func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) { - catalog, _ := s.gacha.SnapshotCatalog() + catalog := s.catalog var termLimited []*pb.GachaBanner var latestChapter *pb.GachaBanner for _, entry := range catalog { diff --git a/server/internal/service/cageornament.go b/server/internal/service/cageornament.go index 2518bcf..f53960b 100644 --- a/server/internal/service/cageornament.go +++ b/server/internal/service/cageornament.go @@ -43,8 +43,7 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R s.granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) }) - diff := userdata.BuildDiffFromTables(userdata.SelectTables( - userdata.FullClientTableMap(user), + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{ "IUserMaterial", "IUserConsumableItem", "IUserGem", "IUserCostume", "IUserCostumeActiveSkill", "IUserCharacter", @@ -82,8 +81,7 @@ func (s *CageOrnamentServiceServer) RecordAccess(ctx context.Context, req *pb.Re } }) - diff := userdata.BuildDiffFromTables(userdata.SelectTables( - userdata.FullClientTableMap(user), + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserCageOrnamentReward"}, )) diff --git a/server/internal/service/character.go b/server/internal/service/character.go index 168118f..ae12367 100644 --- a/server/internal/service/character.go +++ b/server/internal/service/character.go @@ -35,7 +35,7 @@ func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthReq return &pb.RebirthResponse{}, nil } - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}) @@ -78,7 +78,7 @@ func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthReq } rebirthTables := []string{"IUserCharacterRebirth", "IUserMaterial", "IUserConsumableItem"} - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), rebirthTables) + tables := userdata.ProjectTables(snapshot, rebirthTables) diff := tracker.Apply(snapshot, tables) return &pb.RebirthResponse{DiffUserData: diff}, nil diff --git a/server/internal/service/characterboard.go b/server/internal/service/characterboard.go index 42f8978..e586d25 100644 --- a/server/internal/service/characterboard.go +++ b/server/internal/service/characterboard.go @@ -27,7 +27,7 @@ func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb. userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}). Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}) @@ -54,7 +54,7 @@ func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb. "IUserConsumableItem", "IUserGem", } - tables := userdata.SelectTables(userdata.FullClientTableMap(user), boardTables) + tables := userdata.ProjectTables(user, boardTables) diff := tracker.Apply(user, tables) return &pb.ReleasePanelResponse{DiffUserData: diff}, nil diff --git a/server/internal/service/characterviewer.go b/server/internal/service/characterviewer.go index 535397c..d6ba310 100644 --- a/server/internal/service/characterviewer.go +++ b/server/internal/service/characterviewer.go @@ -29,7 +29,7 @@ func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ log.Printf("[CharacterViewerService] CharacterViewerTop") userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err)) } diff --git a/server/internal/service/companion.go b/server/internal/service/companion.go index fbc6bce..69a1287 100644 --- a/server/internal/service/companion.go +++ b/server/internal/service/companion.go @@ -77,8 +77,7 @@ func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionE return nil, fmt.Errorf("companion enhance: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, companionDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, companionDiffTables)) return &pb.CompanionEnhanceResponse{ DiffUserData: diff, diff --git a/server/internal/service/consumableitem.go b/server/internal/service/consumableitem.go index 98d7e3a..a9d061c 100644 --- a/server/internal/service/consumableitem.go +++ b/server/internal/service/consumableitem.go @@ -28,7 +28,7 @@ func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.Consumab userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}) @@ -66,7 +66,7 @@ func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.Consumab return nil, fmt.Errorf("consumable item sell: %w", err) } - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), []string{"IUserConsumableItem"}) + tables := userdata.ProjectTables(snapshot, []string{"IUserConsumableItem"}) diff := tracker.Apply(snapshot, tables) return &pb.ConsumableItemSellResponse{ diff --git a/server/internal/service/contentsstory.go b/server/internal/service/contentsstory.go index 5c7b084..87ae421 100644 --- a/server/internal/service/contentsstory.go +++ b/server/internal/service/contentsstory.go @@ -34,8 +34,7 @@ func (s *ContentsStoryServiceServer) RegisterPlayed(ctx context.Context, req *pb return nil, fmt.Errorf("update user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserContentsStory"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserContentsStory"})) return &pb.ContentsStoryRegisterPlayedResponse{ DiffUserData: diff, diff --git a/server/internal/service/costume.go b/server/internal/service/costume.go index c769aa0..50ebfe3 100644 --- a/server/internal/service/costume.go +++ b/server/internal/service/costume.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "log" + "math/rand" + + "github.com/google/uuid" pb "lunar-tear/server/gen/proto" "lunar-tear/server/internal/gametime" @@ -95,8 +98,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque return nil, fmt.Errorf("costume enhance: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, costumeDiffTables)) return &pb.EnhanceResponse{ IsGreatSuccess: false, @@ -177,8 +179,7 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest return nil, fmt.Errorf("costume awaken: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, awakenDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, awakenDiffTables)) return &pb.AwakenResponse{ DiffUserData: diff, @@ -229,10 +230,12 @@ func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, ite return } - key := fmt.Sprintf("awaken-thought-%d", acq.PossessionId) - if _, exists := user.Thoughts[key]; exists { - return + for _, t := range user.Thoughts { + if t.ThoughtId == acq.PossessionId { + return + } } + key := uuid.New().String() user.Thoughts[key] = store.ThoughtState{ UserThoughtUuid: key, ThoughtId: acq.PossessionId, @@ -329,8 +332,7 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E return nil, fmt.Errorf("costume enhance active skill: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, activeSkillDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, activeSkillDiffTables)) return &pb.EnhanceActiveSkillResponse{ DiffUserData: diff, @@ -387,10 +389,216 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea return nil, fmt.Errorf("costume limit break: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, costumeDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, costumeDiffTables)) return &pb.LimitBreakResponse{ DiffUserData: diff, }, nil } + +var lotteryEffectDiffTables = []string{ + "IUserCostume", + "IUserCostumeLotteryEffect", + "IUserCostumeLotteryEffectAbility", + "IUserCostumeLotteryEffectStatusUp", + "IUserCostumeLotteryEffectPending", + "IUserConsumableItem", + "IUserMaterial", +} + +func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req *pb.UnlockLotteryEffectSlotRequest) (*pb.UnlockLotteryEffectSlotResponse, error) { + log.Printf("[CostumeService] UnlockLotteryEffectSlot: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) + + userId := currentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { + costume, ok := user.Costumes[req.UserCostumeUuid] + if !ok { + log.Printf("[CostumeService] UnlockLotteryEffectSlot: costume uuid=%s not found", req.UserCostumeUuid) + return + } + + effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] + if !ok { + log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) + return + } + + user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectUnlockSlotConsumeGold + + mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId] + for _, mat := range mats { + cur := user.Materials[mat.MaterialId] + cost := mat.Count + if cur < cost { + log.Printf("[CostumeService] UnlockLotteryEffectSlot: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost) + cost = cur + } + user.Materials[mat.MaterialId] = cur - cost + } + + key := store.CostumeLotteryEffectKey{ + UserCostumeUuid: req.UserCostumeUuid, + SlotNumber: req.SlotNumber, + } + user.CostumeLotteryEffects[key] = store.CostumeLotteryEffectState{ + UserCostumeUuid: req.UserCostumeUuid, + SlotNumber: req.SlotNumber, + OddsNumber: 0, + LatestVersion: nowMillis, + } + + costume.CostumeLotteryEffectUnlockedSlotCount++ + costume.LatestVersion = nowMillis + user.Costumes[req.UserCostumeUuid] = costume + log.Printf("[CostumeService] UnlockLotteryEffectSlot: costumeId=%d slot=%d unlocked slotCount=%d", costume.CostumeId, req.SlotNumber, costume.CostumeLotteryEffectUnlockedSlotCount) + }) + if err != nil { + return nil, fmt.Errorf("costume unlock lottery effect slot: %w", err) + } + + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) + + return &pb.UnlockLotteryEffectSlotResponse{ + DiffUserData: diff, + }, nil +} + +func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.DrawLotteryEffectRequest) (*pb.DrawLotteryEffectResponse, error) { + log.Printf("[CostumeService] DrawLotteryEffect: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) + + userId := currentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + oldUser, _ := s.users.LoadUser(userId) + tracker := userdata.NewDeleteTracker(). + Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}). + Track("IUserConsumableItem", oldUser, userdata.SortedConsumableItemRecords, []string{"userId", "consumableItemId"}). + Track("IUserCostumeLotteryEffectPending", oldUser, userdata.SortedCostumeLotteryEffectPendingRecords, []string{"userId", "userCostumeUuid"}) + + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { + costume, ok := user.Costumes[req.UserCostumeUuid] + if !ok { + log.Printf("[CostumeService] DrawLotteryEffect: costume uuid=%s not found", req.UserCostumeUuid) + return + } + + effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] + if !ok { + log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) + return + } + + oddsPool := s.catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId] + if len(oddsPool) == 0 { + log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId) + return + } + + user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectDrawSlotConsumeGold + + mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId] + for _, mat := range mats { + cur := user.Materials[mat.MaterialId] + cost := mat.Count + if cur < cost { + log.Printf("[CostumeService] DrawLotteryEffect: insufficient material id=%d have=%d need=%d", mat.MaterialId, cur, cost) + cost = cur + } + user.Materials[mat.MaterialId] = cur - cost + } + + totalWeight := int32(0) + for _, row := range oddsPool { + totalWeight += row.Weight + } + roll := rand.Int31n(totalWeight) + var picked masterdata.CostumeLotteryEffectOddsRow + for _, row := range oddsPool { + roll -= row.Weight + if roll < 0 { + picked = row + break + } + } + + key := store.CostumeLotteryEffectKey{ + UserCostumeUuid: req.UserCostumeUuid, + SlotNumber: req.SlotNumber, + } + existing := user.CostumeLotteryEffects[key] + if existing.OddsNumber == 0 { + existing.UserCostumeUuid = req.UserCostumeUuid + existing.SlotNumber = req.SlotNumber + existing.OddsNumber = picked.OddsNumber + existing.LatestVersion = nowMillis + user.CostumeLotteryEffects[key] = existing + } else { + user.CostumeLotteryEffectPending[req.UserCostumeUuid] = store.CostumeLotteryEffectPendingState{ + UserCostumeUuid: req.UserCostumeUuid, + SlotNumber: req.SlotNumber, + OddsNumber: picked.OddsNumber, + LatestVersion: nowMillis, + } + } + + log.Printf("[CostumeService] DrawLotteryEffect: costumeId=%d slot=%d drew oddsNumber=%d type=%d targetId=%d firstDraw=%v", + costume.CostumeId, req.SlotNumber, picked.OddsNumber, picked.CostumeLotteryEffectType, picked.CostumeLotteryEffectTargetId, existing.OddsNumber == 0) + }) + if err != nil { + return nil, fmt.Errorf("costume draw lottery effect: %w", err) + } + + diff := tracker.Apply(snapshot, userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) + + return &pb.DrawLotteryEffectResponse{ + DiffUserData: diff, + }, nil +} + +func (s *CostumeServiceServer) ConfirmLotteryEffect(ctx context.Context, req *pb.ConfirmLotteryEffectRequest) (*pb.ConfirmLotteryEffectResponse, error) { + log.Printf("[CostumeService] ConfirmLotteryEffect: uuid=%s accept=%v", req.UserCostumeUuid, req.IsAccept) + + userId := currentUserId(ctx, s.users, s.sessions) + nowMillis := gametime.NowMillis() + + oldUser, _ := s.users.LoadUser(userId) + tracker := userdata.NewDeleteTracker(). + Track("IUserCostumeLotteryEffectPending", oldUser, userdata.SortedCostumeLotteryEffectPendingRecords, []string{"userId", "userCostumeUuid"}) + + snapshot, err := s.users.UpdateUser(userId, func(user *store.UserState) { + pending, ok := user.CostumeLotteryEffectPending[req.UserCostumeUuid] + if !ok { + log.Printf("[CostumeService] ConfirmLotteryEffect: no pending for uuid=%s", req.UserCostumeUuid) + return + } + + if req.IsAccept { + key := store.CostumeLotteryEffectKey{ + UserCostumeUuid: pending.UserCostumeUuid, + SlotNumber: pending.SlotNumber, + } + effect := user.CostumeLotteryEffects[key] + effect.UserCostumeUuid = pending.UserCostumeUuid + effect.SlotNumber = pending.SlotNumber + effect.OddsNumber = pending.OddsNumber + effect.LatestVersion = nowMillis + user.CostumeLotteryEffects[key] = effect + log.Printf("[CostumeService] ConfirmLotteryEffect: accepted oddsNumber=%d for slot=%d", pending.OddsNumber, pending.SlotNumber) + } else { + log.Printf("[CostumeService] ConfirmLotteryEffect: rejected oddsNumber=%d for slot=%d", pending.OddsNumber, pending.SlotNumber) + } + + delete(user.CostumeLotteryEffectPending, req.UserCostumeUuid) + }) + if err != nil { + return nil, fmt.Errorf("costume confirm lottery effect: %w", err) + } + + diff := tracker.Apply(snapshot, userdata.ProjectTables(snapshot, lotteryEffectDiffTables)) + + return &pb.ConfirmLotteryEffectResponse{ + DiffUserData: diff, + }, nil +} diff --git a/server/internal/service/data.go b/server/internal/service/data.go index b67c600..1ccbcaa 100644 --- a/server/internal/service/data.go +++ b/server/internal/service/data.go @@ -42,12 +42,12 @@ func (s *DataServiceServer) GetUserData(ctx context.Context, req *pb.UserDataGet log.Printf("[DataService] GetUserData: tables=%v", req.TableName) userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } - defaults := userdata.FirstEntranceClientTableMap(user) + defaults := userdata.FullClientTableMap(user) result := userdata.SelectTables(defaults, req.TableName) return &pb.UserDataGetResponse{ UserDataJson: result, diff --git a/server/internal/service/deck.go b/server/internal/service/deck.go index 5e1d65d..f1ae15a 100644 --- a/server/internal/service/deck.go +++ b/server/internal/service/deck.go @@ -32,7 +32,7 @@ func (s *DeckServiceServer) UpdateName(ctx context.Context, req *pb.UpdateNameRe user.Decks[deckKey] = deck }) - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserDeck"}) + result := userdata.ProjectTables(user, []string{"IUserDeck"}) return &pb.UpdateNameResponse{ DiffUserData: userdata.BuildDiffFromTables(result), }, nil @@ -81,7 +81,7 @@ func (s *DeckServiceServer) RefreshDeckPower(ctx context.Context, req *pb.Refres } }) - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + result := userdata.ProjectTables(user, []string{ "IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote", }) return &pb.RefreshDeckPowerResponse{ @@ -133,7 +133,7 @@ func (s *DeckServiceServer) RefreshMultiDeckPower(ctx context.Context, req *pb.R } }) - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + result := userdata.ProjectTables(user, []string{ "IUserDeck", "IUserDeckCharacter", "IUserDeckTypeNote", }) return &pb.RefreshMultiDeckPowerResponse{ @@ -173,7 +173,7 @@ func (s *DeckServiceServer) ReplaceDeck(ctx context.Context, req *pb.ReplaceDeck } userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords, []string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}). @@ -189,7 +189,7 @@ func (s *DeckServiceServer) ReplaceDeck(ctx context.Context, req *pb.ReplaceDeck store.ApplyDeckReplacement(user, model.DeckType(req.DeckType), req.UserDeckNumber, deckSlotsFromProto(req.Deck), gametime.NowMillis()) }) - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + result := userdata.ProjectTables(user, []string{ "IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup", "IUserDeckCharacterDressupCostume", }) @@ -202,7 +202,7 @@ func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.Repla log.Printf("[DeckService] ReplaceTripleDeck: deckType=%d deckNumber=%d", req.DeckType, req.UserDeckNumber) userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserDeckSubWeaponGroup", oldUser, userdata.DeckSubWeaponRecords, []string{"userId", "userDeckCharacterUuid", "userWeaponUuid"}). @@ -231,7 +231,7 @@ func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.Repla } }) - result := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + result := userdata.ProjectTables(user, []string{ "IUserDeck", "IUserDeckCharacter", "IUserDeckSubWeaponGroup", "IUserDeckPartsGroup", "IUserDeckCharacterDressupCostume", }) diff --git a/server/internal/service/dokan.go b/server/internal/service/dokan.go index a24d9a9..4fe12bf 100644 --- a/server/internal/service/dokan.go +++ b/server/internal/service/dokan.go @@ -33,8 +33,7 @@ func (s *DokanServiceServer) RegisterDokanConfirmed(ctx context.Context, req *pb return nil, fmt.Errorf("update user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserDokan"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserDokan"})) return &pb.RegisterDokanConfirmedResponse{ DiffUserData: diff, diff --git a/server/internal/service/explore.go b/server/internal/service/explore.go index 32ca95e..619b976 100644 --- a/server/internal/service/explore.go +++ b/server/internal/service/explore.go @@ -71,8 +71,7 @@ func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartEx return nil, fmt.Errorf("start explore: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, exploreDiffTables)) return &pb.StartExploreResponse{ DiffUserData: diff, @@ -124,8 +123,7 @@ func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.Finish return nil, fmt.Errorf("finish explore: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, exploreFinishDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, exploreFinishDiffTables)) rewards := []*pb.ExploreReward{ { @@ -161,8 +159,7 @@ func (s *ExploreServiceServer) RetireExplore(ctx context.Context, req *pb.Retire return nil, fmt.Errorf("retire explore: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserExplore"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserExplore"})) return &pb.RetireExploreResponse{ DiffUserData: diff, diff --git a/server/internal/service/gacha.go b/server/internal/service/gacha.go index 98170ae..174922e 100644 --- a/server/internal/service/gacha.go +++ b/server/internal/service/gacha.go @@ -34,20 +34,20 @@ type GachaServiceServer struct { pb.UnimplementedGachaServiceServer users store.UserRepository sessions store.SessionRepository - gacha store.GachaRepository + catalog []store.GachaCatalogEntry handler *gacha.GachaHandler } func NewGachaServiceServer( users store.UserRepository, sessions store.SessionRepository, - gachaRepo store.GachaRepository, + catalog []store.GachaCatalogEntry, handler *gacha.GachaHandler, ) *GachaServiceServer { return &GachaServiceServer{ users: users, sessions: sessions, - gacha: gachaRepo, + catalog: catalog, handler: handler, } } @@ -55,7 +55,7 @@ func NewGachaServiceServer( func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) { log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType) - catalog, _ := s.gacha.SnapshotCatalog() + catalog := s.catalog userId := currentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() @@ -132,10 +132,10 @@ func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, cat func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) { log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId) - catalog, _ := s.gacha.SnapshotCatalog() + catalog := s.catalog userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } @@ -160,8 +160,7 @@ func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaReque func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) { log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount) - catalog, _ := s.gacha.SnapshotCatalog() - entry := findCatalogEntry(catalog, req.GachaId) + entry := findCatalogEntry(s.catalog, req.GachaId) if entry == nil { return nil, fmt.Errorf("gacha %d not found", req.GachaId) } @@ -293,8 +292,7 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb changedStoryIds := s.handler.Granter.DrainChangedStoryWeaponIds() diffOrder := append(gachaDiffTables, "IUserWeaponStory") - allTables := userdata.FullClientTableMap(updatedUser) - diff := userdata.BuildDiffFromTablesOrdered(userdata.SelectTables(allTables, diffOrder), diffOrder) + diff := userdata.BuildDiffFromTablesOrdered(userdata.ProjectTables(updatedUser, diffOrder), diffOrder) userdata.AddWeaponStoryDiff(diff, updatedUser, changedStoryIds) return &pb.DrawResponse{ @@ -309,8 +307,7 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) { log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId) - catalog, _ := s.gacha.SnapshotCatalog() - entry := findCatalogEntry(catalog, req.GachaId) + entry := findCatalogEntry(s.catalog, req.GachaId) if entry == nil { return nil, fmt.Errorf("gacha %d not found", req.GachaId) } @@ -336,7 +333,7 @@ func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBox func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) { log.Printf("[GachaService] GetRewardGacha") userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } diff --git a/server/internal/service/gift.go b/server/internal/service/gift.go index 47afe01..b54ccd8 100644 --- a/server/internal/service/gift.go +++ b/server/internal/service/gift.go @@ -71,7 +71,7 @@ func (s *GiftServiceServer) GetGiftList(ctx context.Context, req *pb.GetGiftList req.RewardKindType, req.ExpirationType, req.IsAscendingSort, req.NextCursor, req.PreviousCursor, req.GetCount) userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } @@ -108,7 +108,7 @@ func (s *GiftServiceServer) GetGiftList(ctx context.Context, req *pb.GetGiftList func (s *GiftServiceServer) GetGiftReceiveHistoryList(ctx context.Context, req *emptypb.Empty) (*pb.GetGiftReceiveHistoryListResponse, error) { log.Printf("[GiftService] GetGiftReceiveHistoryList") userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } diff --git a/server/internal/service/gimmick.go b/server/internal/service/gimmick.go index 3dc0d4a..103f7bd 100644 --- a/server/internal/service/gimmick.go +++ b/server/internal/service/gimmick.go @@ -38,7 +38,7 @@ func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.Updat user.Gimmick.Sequences[key] = sequence }) return &pb.UpdateSequenceResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickSequence"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserGimmickSequence"})), }, nil } @@ -74,7 +74,7 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p GimmickOrnamentReward: []*pb.GimmickReward{}, IsSequenceCleared: false, GimmickSequenceClearReward: []*pb.GimmickReward{}, - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{ "IUserGimmick", "IUserGimmickOrnamentProgress", })), @@ -98,7 +98,7 @@ func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *empt } }) return &pb.InitSequenceScheduleResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), gimmickDiffTables)), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, gimmickDiffTables)), }, nil } @@ -119,6 +119,6 @@ func (s *GimmickServiceServer) Unlock(ctx context.Context, req *pb.UnlockRequest } }) return &pb.UnlockResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserGimmickUnlock"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserGimmickUnlock"})), }, nil } diff --git a/server/internal/service/listbin.go b/server/internal/service/listbin.go index 63333b1..ff6c13a 100644 --- a/server/internal/service/listbin.go +++ b/server/internal/service/listbin.go @@ -495,4 +495,4 @@ func objectIdToFilePathCandidates(revision, assetType, objectId string) (candida } } return candidates, entry.Size, true -} \ No newline at end of file +} diff --git a/server/internal/service/loginbonus.go b/server/internal/service/loginbonus.go index 3c6f597..cb90890 100644 --- a/server/internal/service/loginbonus.go +++ b/server/internal/service/loginbonus.go @@ -2,10 +2,11 @@ package service import ( "context" - "fmt" "log" "time" + "github.com/google/uuid" + pb "lunar-tear/server/gen/proto" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/masterdata" @@ -55,7 +56,7 @@ func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb GrantDatetime: now, }, ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond), - UserGiftUuid: fmt.Sprintf("login-bonus-%d-%d", userId, nextStamp), + UserGiftUuid: uuid.New().String(), }) user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived)) user.LoginBonus.CurrentStampNumber = nextStamp @@ -63,8 +64,7 @@ func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb user.LoginBonus.LatestVersion = now }) - diff := userdata.BuildDiffFromTables(userdata.SelectTables( - userdata.FullClientTableMap(user), + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserLoginBonus"}, )) setCommonResponseTrailers(ctx, diff, false) diff --git a/server/internal/service/material.go b/server/internal/service/material.go index b111d64..6b448a7 100644 --- a/server/internal/service/material.go +++ b/server/internal/service/material.go @@ -33,7 +33,7 @@ func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRe userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserMaterial", oldUser, userdata.SortedMaterialRecords, []string{"userId", "materialId"}) @@ -71,7 +71,7 @@ func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRe return nil, fmt.Errorf("material sell: %w", err) } - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), materialDiffTables) + tables := userdata.ProjectTables(snapshot, materialDiffTables) diff := tracker.Apply(snapshot, tables) return &pb.MaterialSellResponse{ diff --git a/server/internal/service/mission.go b/server/internal/service/mission.go index ed91238..e7721e2 100644 --- a/server/internal/service/mission.go +++ b/server/internal/service/mission.go @@ -24,13 +24,12 @@ func (s *MissionServiceServer) UpdateMissionProgress(ctx context.Context, req *p log.Printf("[MissionService] UpdateMissionProgress: cage=%v pictureBook=%v", req.CageMeasurableValues, req.PictureBookMeasurableValues) userId := currentUserId(ctx, s.users, s.sessions) - snapshot, err := s.users.SnapshotUser(userId) + snapshot, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMission"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserMission"})) return &pb.UpdateMissionProgressResponse{ DiffUserData: diff, diff --git a/server/internal/service/movie.go b/server/internal/service/movie.go index dd544f9..2feebdf 100644 --- a/server/internal/service/movie.go +++ b/server/internal/service/movie.go @@ -36,8 +36,7 @@ func (s *MovieServiceServer) SaveViewedMovie(ctx context.Context, req *pb.SaveVi return nil, fmt.Errorf("update user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserMovie"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserMovie"})) return &pb.SaveViewedMovieResponse{ DiffUserData: diff, diff --git a/server/internal/service/navicutin.go b/server/internal/service/navicutin.go index 44dcf14..84d058b 100644 --- a/server/internal/service/navicutin.go +++ b/server/internal/service/navicutin.go @@ -31,8 +31,7 @@ func (s *NaviCutInServiceServer) RegisterPlayed(ctx context.Context, req *pb.Reg return nil, fmt.Errorf("update user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserNaviCutIn"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserNaviCutIn"})) return &pb.RegisterPlayedResponse{ DiffUserData: diff, diff --git a/server/internal/service/notification.go b/server/internal/service/notification.go index 0cbbb24..9459c74 100644 --- a/server/internal/service/notification.go +++ b/server/internal/service/notification.go @@ -24,7 +24,7 @@ func NewNotificationServiceServer(users store.UserRepository, sessions store.Ses func (s *NotificationServiceServer) GetHeaderNotification(ctx context.Context, req *emptypb.Empty) (*pb.GetHeaderNotificationResponse, error) { log.Printf("[NotificationService] GetHeaderNotification") userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return &pb.GetHeaderNotificationResponse{ GiftNotReceiveCount: 0, diff --git a/server/internal/service/omikuji.go b/server/internal/service/omikuji.go index b501434..1b43699 100644 --- a/server/internal/service/omikuji.go +++ b/server/internal/service/omikuji.go @@ -36,8 +36,7 @@ func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiD return nil, fmt.Errorf("update user: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserOmikuji"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserOmikuji"})) return &pb.OmikujiDrawResponse{ OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId), diff --git a/server/internal/service/parts.go b/server/internal/service/parts.go index 3fc4b2d..b9a3754 100644 --- a/server/internal/service/parts.go +++ b/server/internal/service/parts.go @@ -37,7 +37,7 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserParts", oldUser, userdata.SortedPartsRecords, []string{"userId", "userPartsUuid"}) @@ -81,7 +81,7 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) return nil, fmt.Errorf("parts sell: %w", err) } - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), partsDiffTables) + tables := userdata.ProjectTables(snapshot, partsDiffTables) diff := tracker.Apply(snapshot, tables) return &pb.PartsSellResponse{ @@ -158,8 +158,7 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe return nil, fmt.Errorf("parts enhance: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, partsDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, partsDiffTables)) return &pb.PartsEnhanceResponse{ IsSuccess: isSuccess, @@ -187,8 +186,7 @@ func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsRep return nil, fmt.Errorf("parts replace preset: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserPartsPreset"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserPartsPreset"})) return &pb.PartsReplacePresetResponse{ DiffUserData: diff, diff --git a/server/internal/service/portalcage.go b/server/internal/service/portalcage.go index c1af161..b293c1c 100644 --- a/server/internal/service/portalcage.go +++ b/server/internal/service/portalcage.go @@ -30,8 +30,7 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont user.PortalCageStatus.LatestVersion = now }) - tables := userdata.SelectTables( - userdata.FullClientTableMap(user), + tables := userdata.ProjectTables(user, []string{"IUserPortalCageStatus"}, ) return &pb.UpdatePortalCageSceneProgressResponse{ diff --git a/server/internal/service/quest_bighunt.go b/server/internal/service/quest_bighunt.go index 2fd5c01..c6e8a47 100644 --- a/server/internal/service/quest_bighunt.go +++ b/server/internal/service/quest_bighunt.go @@ -42,7 +42,7 @@ var bigHuntDiffTables = []string{ } func buildBigHuntDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) + tables := userdata.ProjectTables(user, tableNames) return userdata.BuildDiffFromTablesOrdered(tables, tableNames) } @@ -331,7 +331,7 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb log.Printf("[BigHuntService] GetBigHuntTopData") userId := currentUserId(ctx, s.users, s.sessions) - user, _ := s.users.SnapshotUser(userId) + user, _ := s.users.LoadUser(userId) nowMillis := gametime.NowMillis() weeklyVersion := gametime.WeeklyVersion(nowMillis) diff --git a/server/internal/service/quest_main.go b/server/internal/service/quest_main.go index c1e3a41..a16918c 100644 --- a/server/internal/service/quest_main.go +++ b/server/internal/service/quest_main.go @@ -29,7 +29,7 @@ func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRep } func buildSelectedQuestDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) + tables := userdata.ProjectTables(user, tableNames) return userdata.BuildDiffFromTablesOrdered(tables, tableNames) } diff --git a/server/internal/service/quest_sidestory.go b/server/internal/service/quest_sidestory.go index b33887d..366a7a3 100644 --- a/server/internal/service/quest_sidestory.go +++ b/server/internal/service/quest_sidestory.go @@ -24,7 +24,7 @@ func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.S } func buildSideStoryDiff(user store.UserState, tableNames []string) map[string]*pb.DiffData { - tables := userdata.SelectTables(userdata.FullClientTableMap(user), tableNames) + tables := userdata.ProjectTables(user, tableNames) return userdata.BuildDiffFromTablesOrdered(tables, tableNames) } diff --git a/server/internal/service/reward.go b/server/internal/service/reward.go index 0ffe96e..133dbb1 100644 --- a/server/internal/service/reward.go +++ b/server/internal/service/reward.go @@ -106,7 +106,7 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty weeklyScoreResults = []*pb.WeeklyScoreResult{} } - tables := userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + tables := userdata.ProjectTables(user, []string{ "IUserBigHuntWeeklyStatus", "IUserBigHuntWeeklyMaxScore", "IUserConsumableItem", diff --git a/server/internal/service/shop.go b/server/internal/service/shop.go index f081377..ddf2dcd 100644 --- a/server/internal/service/shop.go +++ b/server/internal/service/shop.go @@ -89,8 +89,7 @@ func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.Bu return nil, fmt.Errorf("shop buy: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) userdata.AddWeaponStoryDiff(diff, snapshot, s.granter.DrainChangedStoryWeaponIds()) return &pb.BuyResponse{ @@ -132,8 +131,7 @@ func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.Refresh return nil, fmt.Errorf("shop refresh: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) return &pb.RefreshResponse{ DiffUserData: diff, @@ -195,8 +193,7 @@ func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req * txId := fmt.Sprintf("tx_%d_%d_%d", userId, req.ShopItemId, nowMillis) - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) return &pb.CreatePurchaseTransactionResponse{ PurchaseTransactionId: txId, @@ -208,13 +205,12 @@ func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context, log.Printf("[ShopService] PurchaseGooglePlayStoreProduct: txId=%s", req.PurchaseTransactionId) userId := currentUserId(ctx, s.users, s.sessions) - snapshot, err := s.users.SnapshotUser(userId) + snapshot, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("purchase google play: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, shopDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, shopDiffTables)) return &pb.PurchaseGooglePlayStoreProductResponse{ OverflowPossession: []*pb.Possession{}, diff --git a/server/internal/service/state.go b/server/internal/service/state.go index 6d83441..47b1c07 100644 --- a/server/internal/service/state.go +++ b/server/internal/service/state.go @@ -15,7 +15,6 @@ var startedGameStartTables = []string{ "IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", - "IUserWeaponStory", "IUserCompanion", "IUserDeckCharacter", "IUserDeck", @@ -47,7 +46,7 @@ var gimmickDiffTables = []string{ func currentUserId(ctx context.Context, users store.UserRepository, sessions store.SessionRepository) int64 { if md, ok := metadata.FromIncomingContext(ctx); ok { - if vals := md.Get("x-session-key"); len(vals) > 0 { + if vals := md.Get("x-apb-session-key"); len(vals) > 0 { if userId, err := sessions.ResolveUserId(vals[0]); err == nil { return userId } diff --git a/server/internal/service/tutorial.go b/server/internal/service/tutorial.go index f8712a8..4f72592 100644 --- a/server/internal/service/tutorial.go +++ b/server/internal/service/tutorial.go @@ -37,11 +37,10 @@ func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb ChoiceId: req.ChoiceId, } } - if req.TutorialType == int32(model.TutorialTypeMenuFirst) || - req.TutorialType == int32(model.TutorialTypeMenuSecond) { + grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis) + if req.TutorialType == int32(model.TutorialTypeMenuFirst) && req.ProgressPhase == 20 { store.EnsureDefaultDeck(user, nowMillis) } - grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis) }) tables := []string{"IUserTutorialProgress"} if req.TutorialType == int32(model.TutorialTypeMenuFirst) || @@ -55,7 +54,7 @@ func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb if len(grants) > 0 { tables = append(tables, "IUserCompanion") } - result := userdata.SelectTables(userdata.FullClientTableMap(user), tables) + result := userdata.ProjectTables(user, tables) for _, t := range tables { log.Printf("[TutorialService] DiffTable %s -> %s", t, result[t]) } @@ -89,7 +88,7 @@ func (s *TutorialServiceServer) SetTutorialProgressAndReplaceDeck(ctx context.Co } }) return &pb.SetTutorialProgressAndReplaceDeckResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{ + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{ "IUserTutorialProgress", "IUserDeck", "IUserDeckCharacter", diff --git a/server/internal/service/user.go b/server/internal/service/user.go index a980331..d322211 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -50,9 +50,13 @@ func setCommonResponseTrailers(ctx context.Context, diff map[string]*pb.DiffData } func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) { - user, err := s.users.EnsureUser(req.Uuid) + userId, err := s.users.CreateUser(req.Uuid) if err != nil { - return nil, fmt.Errorf("ensure user: %w", err) + return nil, fmt.Errorf("create user: %w", err) + } + user, err := s.users.LoadUser(userId) + if err != nil { + return nil, fmt.Errorf("load user: %w", err) } log.Printf("[UserService] RegisterUser: uuid=%s terminalId=%s -> userId=%d", req.Uuid, req.TerminalId, user.UserId) @@ -66,10 +70,14 @@ func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUs func (s *UserServiceServer) Auth(ctx context.Context, req *pb.AuthUserRequest) (*pb.AuthUserResponse, error) { log.Printf("[UserService] Auth: uuid=%s", req.Uuid) - user, session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour) + session, err := s.sessions.CreateSession(req.Uuid, 24*time.Hour) if err != nil { return nil, fmt.Errorf("create session: %w", err) } + user, err := s.users.LoadUser(session.UserId) + if err != nil { + return nil, fmt.Errorf("load user: %w", err) + } return &pb.AuthUserResponse{ SessionKey: session.SessionKey, @@ -84,7 +92,7 @@ func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*p log.Printf("[UserService] GameStart") if md, ok := metadata.FromIncomingContext(ctx); ok { - if vals := md.Get("x-session-key"); len(vals) > 0 { + if vals := md.Get("x-apb-session-key"); len(vals) > 0 { log.Printf("[UserService] GameStart session: %s", vals[0]) } } @@ -93,8 +101,7 @@ func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*p user, _ := s.users.UpdateUser(userId, func(user *store.UserState) { user.GameStartDatetime = gametime.NowMillis() }) - fullTables := userdata.FullClientTableMap(user) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(fullTables, startedGameStartTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(user, startedGameStartTables)) setCommonResponseTrailers(ctx, diff, true) return &pb.GameStartResponse{ @@ -106,12 +113,12 @@ func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*p func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) { log.Printf("[UserService] TransferUser") - user, err := s.users.EnsureUser(req.Uuid) + userId, err := s.users.CreateUser(req.Uuid) if err != nil { - return nil, fmt.Errorf("ensure user: %w", err) + return nil, fmt.Errorf("create user: %w", err) } return &pb.TransferUserResponse{ - UserId: user.UserId, + UserId: userId, Signature: "transferred-sig", DiffUserData: userdata.EmptyDiff(), }, nil @@ -126,7 +133,7 @@ func (s *UserServiceServer) SetUserName(ctx context.Context, req *pb.SetUserName user.Profile.NameUpdateDatetime = nowMillis }) return &pb.SetUserNameResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), }, nil } @@ -139,7 +146,7 @@ func (s *UserServiceServer) SetUserMessage(ctx context.Context, req *pb.SetUserM user.Profile.MessageUpdateDatetime = nowMillis }) return &pb.SetUserMessageResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), }, nil } @@ -152,7 +159,7 @@ func (s *UserServiceServer) SetUserFavoriteCostumeId(ctx context.Context, req *p user.Profile.FavoriteCostumeIdUpdateDatetime = nowMillis }) return &pb.SetUserFavoriteCostumeIdResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserProfile"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserProfile"})), }, nil } @@ -162,7 +169,7 @@ func (s *UserServiceServer) GetUserProfile(ctx context.Context, req *pb.GetUserP if userId == 0 { userId = currentUserId(ctx, s.users, s.sessions) } - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return &pb.GetUserProfileResponse{DiffUserData: userdata.EmptyDiff()}, nil } @@ -219,7 +226,7 @@ func (s *UserServiceServer) SetBirthYearMonth(ctx context.Context, req *pb.SetBi func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Empty) (*pb.GetBirthYearMonthResponse, error) { userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return &pb.GetBirthYearMonthResponse{BirthYear: 2000, BirthMonth: 1, DiffUserData: userdata.EmptyDiff()}, nil } @@ -228,7 +235,7 @@ func (s *UserServiceServer) GetBirthYearMonth(ctx context.Context, _ *emptypb.Em func (s *UserServiceServer) GetChargeMoney(ctx context.Context, _ *emptypb.Empty) (*pb.GetChargeMoneyResponse, error) { userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return &pb.GetChargeMoneyResponse{ChargeMoneyThisMonth: 0, DiffUserData: userdata.EmptyDiff()}, nil } @@ -242,7 +249,7 @@ func (s *UserServiceServer) SetUserSetting(ctx context.Context, req *pb.SetUserS user.Setting.IsNotifyPurchaseAlert = req.IsNotifyPurchaseAlert }) return &pb.SetUserSettingResponse{ - DiffUserData: userdata.BuildDiffFromTables(userdata.SelectTables(userdata.FullClientTableMap(user), []string{"IUserSetting"})), + DiffUserData: userdata.BuildDiffFromTables(userdata.ProjectTables(user, []string{"IUserSetting"})), }, nil } @@ -252,7 +259,7 @@ func (s *UserServiceServer) GetAndroidArgs(ctx context.Context, req *pb.GetAndro func (s *UserServiceServer) GetBackupToken(ctx context.Context, req *pb.GetBackupTokenRequest) (*pb.GetBackupTokenResponse, error) { userId := currentUserId(ctx, s.users, s.sessions) - user, err := s.users.SnapshotUser(userId) + user, err := s.users.LoadUser(userId) if err != nil { return &pb.GetBackupTokenResponse{BackupToken: "mock-backup-token", DiffUserData: userdata.EmptyDiff()}, nil } diff --git a/server/internal/service/weapon.go b/server/internal/service/weapon.go index fb13c0d..14351e2 100644 --- a/server/internal/service/weapon.go +++ b/server/internal/service/weapon.go @@ -71,8 +71,7 @@ func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectReques } }) - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserWeapon"})) return &pb.ProtectResponse{DiffUserData: diff}, nil } @@ -95,8 +94,7 @@ func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRe } }) - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, []string{"IUserWeapon"})) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, []string{"IUserWeapon"})) return &pb.UnprotectResponse{DiffUserData: diff}, nil } @@ -165,8 +163,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh return nil, fmt.Errorf("weapon enhance by material: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) return &pb.EnhanceByMaterialResponse{ @@ -181,7 +178,7 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p userId := currentUserId(ctx, s.users, s.sessions) - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). @@ -229,7 +226,7 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p } sellDiffTables := []string{"IUserWeapon", "IUserWeaponSkill", "IUserWeaponAbility", "IUserWeaponAwaken", "IUserConsumableItem"} - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), sellDiffTables) + tables := userdata.ProjectTables(snapshot, sellDiffTables) diff := tracker.Apply(snapshot, tables) return &pb.SellResponse{DiffUserData: diff}, nil @@ -307,8 +304,7 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) return nil, fmt.Errorf("weapon evolve: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) return &pb.EvolveResponse{DiffUserData: diff}, nil @@ -407,8 +403,7 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS return nil, fmt.Errorf("weapon enhance skill: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) return &pb.EnhanceSkillResponse{DiffUserData: diff}, nil } @@ -506,8 +501,7 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc return nil, fmt.Errorf("weapon enhance ability: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponDiffTables)) return &pb.EnhanceAbilityResponse{DiffUserData: diff}, nil } @@ -578,8 +572,7 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb. return nil, fmt.Errorf("weapon limit break by material: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, limitBreakDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, limitBreakDiffTables)) return &pb.LimitBreakByMaterialResponse{DiffUserData: diff}, nil } @@ -590,7 +583,7 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li userId := currentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). @@ -665,7 +658,7 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li return nil, fmt.Errorf("weapon limit break by weapon: %w", err) } - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), limitBreakDiffTables) + tables := userdata.ProjectTables(snapshot, limitBreakDiffTables) diff := tracker.Apply(snapshot, tables) return &pb.LimitBreakByWeaponResponse{DiffUserData: diff}, nil @@ -677,7 +670,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan userId := currentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() - oldUser, _ := s.users.SnapshotUser(userId) + oldUser, _ := s.users.LoadUser(userId) tracker := userdata.NewDeleteTracker(). Track("IUserWeapon", oldUser, userdata.SortedWeaponRecords, []string{"userId", "userWeaponUuid"}). Track("IUserWeaponSkill", oldUser, userdata.SortedWeaponSkillRecords, []string{"userId", "userWeaponUuid", "slotNumber"}). @@ -753,7 +746,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan return nil, fmt.Errorf("weapon enhance by weapon: %w", err) } - tables := userdata.SelectTables(userdata.FullClientTableMap(snapshot), weaponDiffTables) + tables := userdata.ProjectTables(snapshot, weaponDiffTables) diff := tracker.Apply(snapshot, tables) userdata.AddWeaponStoryDiff(diff, snapshot, changedStoryIds) @@ -864,8 +857,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe return nil, fmt.Errorf("weapon awaken: %w", err) } - tables := userdata.FullClientTableMap(snapshot) - diff := userdata.BuildDiffFromTables(userdata.SelectTables(tables, weaponAwakenDiffTables)) + diff := userdata.BuildDiffFromTables(userdata.ProjectTables(snapshot, weaponAwakenDiffTables)) return &pb.WeaponAwakenResponse{DiffUserData: diff}, nil } diff --git a/server/internal/store/memory/clone.go b/server/internal/store/clone.go similarity index 68% rename from server/internal/store/memory/clone.go rename to server/internal/store/clone.go index a72880f..ff9c3e3 100644 --- a/server/internal/store/memory/clone.go +++ b/server/internal/store/clone.go @@ -1,12 +1,8 @@ -package memory +package store -import ( - "maps" +import "maps" - "lunar-tear/server/internal/store" -) - -func cloneUserState(u store.UserState) store.UserState { +func CloneUserState(u UserState) UserState { out := u out.Tutorials = maps.Clone(u.Tutorials) out.Characters = maps.Clone(u.Characters) @@ -15,14 +11,14 @@ func cloneUserState(u store.UserState) store.UserState { out.Companions = maps.Clone(u.Companions) out.Thoughts = maps.Clone(u.Thoughts) out.DeckCharacters = maps.Clone(u.DeckCharacters) - out.DeckSubWeapons = maps.Clone(u.DeckSubWeapons) + out.DeckSubWeapons = cloneSliceMap(u.DeckSubWeapons) out.DeckParts = cloneSliceMap(u.DeckParts) out.Decks = maps.Clone(u.Decks) out.Quests = maps.Clone(u.Quests) out.QuestMissions = maps.Clone(u.QuestMissions) out.WeaponStories = maps.Clone(u.WeaponStories) out.Missions = maps.Clone(u.Missions) - out.Gimmick = store.GimmickState{ + out.Gimmick = GimmickState{ Progress: maps.Clone(u.Gimmick.Progress), OrnamentProgress: maps.Clone(u.Gimmick.OrnamentProgress), Sequences: maps.Clone(u.Gimmick.Sequences), @@ -38,6 +34,7 @@ func cloneUserState(u store.UserState) store.UserState { out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills) out.WeaponSkills = cloneSliceMap(u.WeaponSkills) out.WeaponAbilities = cloneSliceMap(u.WeaponAbilities) + out.WeaponAwakens = maps.Clone(u.WeaponAwakens) out.DeckTypeNotes = maps.Clone(u.DeckTypeNotes) out.WeaponNotes = maps.Clone(u.WeaponNotes) out.NaviCutInPlayed = maps.Clone(u.NaviCutInPlayed) @@ -50,40 +47,46 @@ func cloneUserState(u store.UserState) store.UserState { out.ShopReplaceableLineup = maps.Clone(u.ShopReplaceableLineup) out.Explore = u.Explore out.ExploreScores = maps.Clone(u.ExploreScores) - out.Gacha = store.GachaState{ + out.Gacha = GachaState{ RewardAvailable: u.Gacha.RewardAvailable, TodaysCurrentDrawCount: u.Gacha.TodaysCurrentDrawCount, DailyMaxCount: u.Gacha.DailyMaxCount, LastRewardDrawDate: u.Gacha.LastRewardDrawDate, - ConvertedGachaMedal: store.ConvertedGachaMedalState{ - ConvertedMedalPossession: append([]store.ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...), + ConvertedGachaMedal: ConvertedGachaMedalState{ + ConvertedMedalPossession: append([]ConsumableItemState(nil), u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession...), ObtainPossession: cloneConsumableItemPtr(u.Gacha.ConvertedGachaMedal.ObtainPossession), }, BannerStates: cloneBannerStates(u.Gacha.BannerStates), } - out.Gifts = store.GiftState{ + out.Gifts = GiftState{ NotReceived: cloneNotReceivedGifts(u.Gifts.NotReceived), Received: cloneReceivedGifts(u.Gifts.Received), } out.Battle = u.Battle + out.SideStoryQuests = maps.Clone(u.SideStoryQuests) + out.QuestLimitContentStatus = maps.Clone(u.QuestLimitContentStatus) + out.BigHuntMaxScores = maps.Clone(u.BigHuntMaxScores) + out.BigHuntStatuses = maps.Clone(u.BigHuntStatuses) + out.BigHuntScheduleMaxScores = maps.Clone(u.BigHuntScheduleMaxScores) + out.BigHuntWeeklyMaxScores = maps.Clone(u.BigHuntWeeklyMaxScores) + out.BigHuntWeeklyStatuses = maps.Clone(u.BigHuntWeeklyStatuses) + out.BigHuntBattleBinary = append([]byte(nil), u.BigHuntBattleBinary...) + out.CharacterBoards = maps.Clone(u.CharacterBoards) + out.CharacterBoardAbilities = maps.Clone(u.CharacterBoardAbilities) + out.CharacterBoardStatusUps = maps.Clone(u.CharacterBoardStatusUps) out.CostumeAwakenStatusUps = maps.Clone(u.CostumeAwakenStatusUps) + out.CostumeLotteryEffects = maps.Clone(u.CostumeLotteryEffects) + out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending) out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings) out.CharacterRebirths = maps.Clone(u.CharacterRebirths) return out } -func cloneGachaCatalogEntry(entry store.GachaCatalogEntry) store.GachaCatalogEntry { - out := entry - out.PricePhases = append([]store.GachaPricePhaseEntry(nil), entry.PricePhases...) - out.PromotionItems = append([]store.GachaPromotionItem(nil), entry.PromotionItems...) - return out -} - -func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.GachaBannerState { +func cloneBannerStates(m map[int32]GachaBannerState) map[int32]GachaBannerState { if m == nil { return nil } - out := make(map[int32]store.GachaBannerState, len(m)) + out := make(map[int32]GachaBannerState, len(m)) for k, v := range m { bs := v bs.BoxDrewCounts = maps.Clone(v.BoxDrewCounts) @@ -92,7 +95,7 @@ func cloneBannerStates(m map[int32]store.GachaBannerState) map[int32]store.Gacha return out } -func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableItemState { +func cloneConsumableItemPtr(item *ConsumableItemState) *ConsumableItemState { if item == nil { return nil } @@ -100,11 +103,11 @@ func cloneConsumableItemPtr(item *store.ConsumableItemState) *store.ConsumableIt return &out } -func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceivedGiftState { - out := make([]store.NotReceivedGiftState, len(gifts)) +func cloneNotReceivedGifts(gifts []NotReceivedGiftState) []NotReceivedGiftState { + out := make([]NotReceivedGiftState, len(gifts)) for i, gift := range gifts { - out[i] = store.NotReceivedGiftState{ - GiftCommon: store.GiftCommonState{ + out[i] = NotReceivedGiftState{ + GiftCommon: GiftCommonState{ PossessionType: gift.GiftCommon.PossessionType, PossessionId: gift.GiftCommon.PossessionId, Count: gift.GiftCommon.Count, @@ -119,6 +122,24 @@ func cloneNotReceivedGifts(gifts []store.NotReceivedGiftState) []store.NotReceiv return out } +func cloneReceivedGifts(gifts []ReceivedGiftState) []ReceivedGiftState { + out := make([]ReceivedGiftState, len(gifts)) + for i, gift := range gifts { + out[i] = ReceivedGiftState{ + GiftCommon: GiftCommonState{ + PossessionType: gift.GiftCommon.PossessionType, + PossessionId: gift.GiftCommon.PossessionId, + Count: gift.GiftCommon.Count, + GrantDatetime: gift.GiftCommon.GrantDatetime, + DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId, + EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...), + }, + ReceivedDatetime: gift.ReceivedDatetime, + } + } + return out +} + func cloneSliceMap[T any](m map[string][]T) map[string][]T { if m == nil { return nil @@ -129,21 +150,3 @@ func cloneSliceMap[T any](m map[string][]T) map[string][]T { } return out } - -func cloneReceivedGifts(gifts []store.ReceivedGiftState) []store.ReceivedGiftState { - out := make([]store.ReceivedGiftState, len(gifts)) - for i, gift := range gifts { - out[i] = store.ReceivedGiftState{ - GiftCommon: store.GiftCommonState{ - PossessionType: gift.GiftCommon.PossessionType, - PossessionId: gift.GiftCommon.PossessionId, - Count: gift.GiftCommon.Count, - GrantDatetime: gift.GiftCommon.GrantDatetime, - DescriptionGiftTextId: gift.GiftCommon.DescriptionGiftTextId, - EquipmentData: append([]byte(nil), gift.GiftCommon.EquipmentData...), - }, - ReceivedDatetime: gift.ReceivedDatetime, - } - } - return out -} diff --git a/server/internal/store/helpers.go b/server/internal/store/helpers.go index 7d9e44b..1b12ef2 100644 --- a/server/internal/store/helpers.go +++ b/server/internal/store/helpers.go @@ -5,6 +5,8 @@ import ( "log" "sort" + "github.com/google/uuid" + "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/model" ) @@ -139,7 +141,7 @@ func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMi } } } - key := fmt.Sprintf("reward-costume-%d", costumeId) + key := uuid.New().String() user.Costumes[key] = CostumeState{ UserCostumeUuid: key, CostumeId: costumeId, @@ -155,16 +157,7 @@ func (g *PossessionGranter) GrantCostume(user *UserState, costumeId int32, nowMi } func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) { - key := fmt.Sprintf("reward-weapon-%d-%d", weaponId, nowMillis) - if _, exists := user.Weapons[key]; exists { - for i := 2; ; i++ { - candidate := fmt.Sprintf("%s-%d", key, i) - if _, exists := user.Weapons[candidate]; !exists { - key = candidate - break - } - } - } + key := uuid.New().String() user.Weapons[key] = WeaponState{ UserWeaponUuid: key, WeaponId: weaponId, @@ -269,16 +262,29 @@ func EnsureDefaultDeck(user *UserState, nowMillis int64) { return } - costumeUuid := FirstSortedKey(user.Costumes) - weaponUuid := FirstSortedKey(user.Weapons) - companionUuid := FirstSortedKey(user.Companions) + const rionCostumeId = int32(10100) + const rionWeaponId = int32(101001) - dcUuid := "default-deck-character-0001" + var costumeUuid, weaponUuid string + for k, v := range user.Costumes { + if v.CostumeId == rionCostumeId { + costumeUuid = k + break + } + } + for k, v := range user.Weapons { + if v.WeaponId == rionWeaponId { + weaponUuid = k + break + } + } + + dcUuid := uuid.New().String() user.DeckCharacters[dcUuid] = DeckCharacterState{ UserDeckCharacterUuid: dcUuid, + UserCompanionUuid: "", UserCostumeUuid: costumeUuid, MainUserWeaponUuid: weaponUuid, - UserCompanionUuid: companionUuid, Power: 100, LatestVersion: nowMillis, } @@ -324,14 +330,17 @@ func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumb deck.Power = 100 } - uuids := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03} - for i, uuid := range uuids { + uuidPtrs := []*string{&deck.UserDeckCharacterUuid01, &deck.UserDeckCharacterUuid02, &deck.UserDeckCharacterUuid03} + for i, uuidPtr := range uuidPtrs { if i >= len(slots) || slots[i].UserCostumeUuid == "" { - *uuid = "" + *uuidPtr = "" continue } slot := slots[i] - dcUuid := fmt.Sprintf("deck-%d-%d-%d", deckType, userDeckNumber, i+1) + dcUuid := *uuidPtr + if dcUuid == "" { + dcUuid = uuid.New().String() + } dc := user.DeckCharacters[dcUuid] dc.UserDeckCharacterUuid = dcUuid dc.UserCostumeUuid = slot.UserCostumeUuid @@ -343,7 +352,7 @@ func ApplyDeckReplacement(user *UserState, deckType model.DeckType, userDeckNumb user.DeckCharacters[dcUuid] = dc user.DeckSubWeapons[dcUuid] = slot.SubWeaponUuids user.DeckParts[dcUuid] = slot.PartsUuids - *uuid = dcUuid + *uuidPtr = dcUuid } deck.LatestVersion = nowMillis diff --git a/server/internal/store/memory/memory.go b/server/internal/store/memory/memory.go deleted file mode 100644 index ad3c6fb..0000000 --- a/server/internal/store/memory/memory.go +++ /dev/null @@ -1,198 +0,0 @@ -package memory - -import ( - "fmt" - "strings" - "sync" - "time" - - "lunar-tear/server/internal/store" -) - -type Option func(*MemoryStore) - -func WithSnapshotDir(dir string) Option { - return func(s *MemoryStore) { - s.snapshotDir = dir - } -} - -func WithSceneId(sceneId int32) Option { - return func(s *MemoryStore) { - s.bootstrapSceneId = sceneId - } -} - -func WithStarterItems(v bool) Option { - return func(s *MemoryStore) { - s.starterItems = v - } -} - -type MemoryStore struct { - mu sync.RWMutex - clock store.Clock - bootstrapSceneId int32 - snapshotDir string - starterItems bool - lastSnapshotSceneId int32 - nextUserId int64 - users map[int64]*store.UserState - userIdsByUuid map[string]int64 - sessionToUserId map[string]int64 - sessions map[string]store.SessionState - gachaCatalog map[int32]store.GachaCatalogEntry -} - -var ( - _ store.UserRepository = (*MemoryStore)(nil) - _ store.SessionRepository = (*MemoryStore)(nil) - _ store.GachaRepository = (*MemoryStore)(nil) -) - -func New(clock store.Clock, options ...Option) *MemoryStore { - if clock == nil { - clock = time.Now - } - s := &MemoryStore{ - clock: clock, - nextUserId: defaultUserId, - users: make(map[int64]*store.UserState), - userIdsByUuid: make(map[string]int64), - sessionToUserId: make(map[string]int64), - sessions: make(map[string]store.SessionState), - gachaCatalog: make(map[int32]store.GachaCatalogEntry), - } - for _, opt := range options { - opt(s) - } - return s -} - -func (s *MemoryStore) EnsureUser(uuid string) (store.UserState, error) { - s.mu.Lock() - defer s.mu.Unlock() - return cloneUserState(*s.getOrCreateLocked(normalizeUUID(uuid))), nil -} - -func (s *MemoryStore) CreateSession(uuid string, ttl time.Duration) (store.UserState, store.SessionState, error) { - s.mu.Lock() - defer s.mu.Unlock() - - user := s.getOrCreateLocked(normalizeUUID(uuid)) - now := s.clock() - session := store.SessionState{ - SessionKey: fmt.Sprintf("session_%d_%d", user.UserId, now.UnixNano()), - UserId: user.UserId, - Uuid: user.Uuid, - ExpireAt: now.Add(ttl), - } - - s.sessionToUserId[session.SessionKey] = user.UserId - s.sessions[session.SessionKey] = session - - return cloneUserState(*user), session, nil -} - -func (s *MemoryStore) ResolveUserId(sessionKey string) (int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - userId, ok := s.sessionToUserId[sessionKey] - if !ok { - return 0, store.ErrNotFound - } - return userId, nil -} - -func (s *MemoryStore) SnapshotUser(userId int64) (store.UserState, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - user, ok := s.users[userId] - if !ok { - return store.UserState{}, store.ErrNotFound - } - return cloneUserState(*user), nil -} - -func (s *MemoryStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) { - s.mu.Lock() - defer s.mu.Unlock() - - user, ok := s.users[userId] - if !ok { - return store.UserState{}, store.ErrNotFound - } - mutate(user) - sceneId := user.MainQuest.CurrentQuestSceneId - if s.snapshotDir != "" && sceneId != 0 && sceneId != s.lastSnapshotSceneId { - saveSnapshot(user, s.snapshotDir) - s.lastSnapshotSceneId = sceneId - } - return cloneUserState(*user), nil -} - -func (s *MemoryStore) DefaultUserId() (int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if _, ok := s.users[defaultUserId]; ok { - return defaultUserId, nil - } - if len(s.users) == 0 { - return defaultUserId, nil - } - - var minUserId int64 - for userId := range s.users { - if minUserId == 0 || userId < minUserId { - minUserId = userId - } - } - return minUserId, nil -} - -func (s *MemoryStore) SnapshotCatalog() ([]store.GachaCatalogEntry, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]store.GachaCatalogEntry, 0, len(s.gachaCatalog)) - for _, entry := range s.gachaCatalog { - out = append(out, cloneGachaCatalogEntry(entry)) - } - return out, nil -} - -func (s *MemoryStore) ReplaceCatalog(entries []store.GachaCatalogEntry) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.gachaCatalog = make(map[int32]store.GachaCatalogEntry, len(entries)) - for _, entry := range entries { - s.gachaCatalog[entry.GachaId] = cloneGachaCatalogEntry(entry) - } - return nil -} - -func (s *MemoryStore) getOrCreateLocked(uuid string) *store.UserState { - if userId, ok := s.userIdsByUuid[uuid]; ok { - return s.users[userId] - } - - userId := s.nextUserId - s.nextUserId++ - - user := seedUserState(userId, uuid, s.clock().UnixMilli(), s.bootstrapSceneId, s.snapshotDir, s.starterItems) - s.users[userId] = user - s.userIdsByUuid[uuid] = userId - return user -} - -func normalizeUUID(uuid string) string { - uuid = strings.TrimSpace(uuid) - if uuid == "" { - return defaultUUID - } - return uuid -} diff --git a/server/internal/store/memory/seed.go b/server/internal/store/memory/seed.go deleted file mode 100644 index 9d76d76..0000000 --- a/server/internal/store/memory/seed.go +++ /dev/null @@ -1,222 +0,0 @@ -package memory - -import ( - "fmt" - "log" - "time" - - "lunar-tear/server/internal/model" - "lunar-tear/server/internal/store" -) - -const ( - defaultUUID = "default-user" - defaultUserId = int64(1001) - - starterMissionId = int32(1) - starterMainQuestRouteId = int32(1) - starterMainQuestSeasonId = int32(1) - missionInProgress = int32(1) - giftUUIDPrefix = "default-gift" - - defaultBirthYear = int32(2000) - defaultBirthMonth = int32(1) - defaultBackupToken = "mock-backup-token" - defaultChargeMoneyThisMonth = int64(0) -) - -type starterItemDef struct { - Type model.PossessionType - Id int32 - Qty int32 -} - -var defaultStarterItems = []starterItemDef{ - {Type: model.PossessionTypeFreeGem, Id: 0, Qty: 300}, - {Type: model.PossessionTypeConsumableItem, Id: 9001, Qty: 1000}, - {Type: model.PossessionTypeConsumableItem, Id: model.ConsumableIdChapterTicket, Qty: 1000}, - {Type: model.PossessionTypeConsumableItem, Id: 5001, Qty: 1000}, - {Type: model.PossessionTypeConsumableItem, Id: 5002, Qty: 1000}, - {Type: model.PossessionTypeConsumableItem, Id: 5003, Qty: 1000}, - {Type: model.PossessionTypeConsumableItem, Id: 1009, Qty: 1000}, -} - -func seedUserState(userId int64, uuid string, nowMillis int64, sceneId int32, snapshotDir string, grantStarterItems bool) *store.UserState { - if sceneId != 0 && snapshotDir != "" { - user, err := loadSnapshot(snapshotDir, sceneId) - if err != nil { - log.Fatalf("[bootstrap] no snapshot for scene=%d: %v", sceneId, err) - } - log.Printf("[bootstrap] loaded snapshot for scene=%d", sceneId) - if grantStarterItems { - applyStarterItems(user) - } - return user - } - - user := &store.UserState{ - UserId: userId, - Uuid: uuid, - PlayerId: userId, - OsType: 2, - PlatformType: 2, - UserRestrictionType: 0, - RegisterDatetime: nowMillis, - GameStartDatetime: nowMillis, - LatestVersion: 0, - BirthYear: defaultBirthYear, - BirthMonth: defaultBirthMonth, - BackupToken: defaultBackupToken, - ChargeMoneyThisMonth: defaultChargeMoneyThisMonth, - Setting: store.UserSettingState{ - IsNotifyPurchaseAlert: false, - LatestVersion: 0, - }, - Status: store.UserStatusState{ - Level: 1, - Exp: 0, - StaminaMilliValue: 50000, - StaminaUpdateDatetime: nowMillis, - LatestVersion: 0, - }, - Gem: store.UserGemState{ - PaidGem: 10000, - FreeGem: 10000, - }, - Profile: store.UserProfileState{ - Name: "", - NameUpdateDatetime: 0, - Message: "", - MessageUpdateDatetime: nowMillis, - FavoriteCostumeId: 0, - FavoriteCostumeIdUpdateDatetime: nowMillis, - LatestVersion: 0, - }, - Login: store.UserLoginState{ - TotalLoginCount: 1, - ContinualLoginCount: 1, - MaxContinualLoginCount: 1, - LastLoginDatetime: nowMillis, - LastComebackLoginDatetime: 0, - LatestVersion: 0, - }, - LoginBonus: store.UserLoginBonusState{ - LoginBonusId: 1, - CurrentPageNumber: 1, - CurrentStampNumber: 0, - LatestRewardReceiveDatetime: 0, - LatestVersion: 0, - }, - Tutorials: map[int32]store.TutorialProgressState{ - 1: {TutorialType: 1}, - }, - Battle: store.BattleState{}, - Gifts: store.GiftState{ - NotReceived: []store.NotReceivedGiftState{ - { - GiftCommon: store.GiftCommonState{ - PossessionType: int32(model.PossessionTypeFreeGem), - PossessionId: 0, - Count: 300, - GrantDatetime: nowMillis, - }, - ExpirationDatetime: nowMillis + int64((7*24*time.Hour)/time.Millisecond), - UserGiftUuid: fmt.Sprintf("%s-%d-1", giftUUIDPrefix, userId), - }, - }, - Received: []store.ReceivedGiftState{}, - }, - Gacha: store.GachaState{ - ConvertedGachaMedal: store.ConvertedGachaMedalState{ - ConvertedMedalPossession: []store.ConsumableItemState{}, - }, - BannerStates: make(map[int32]store.GachaBannerState), - }, - MainQuest: store.MainQuestState{ - CurrentMainQuestRouteId: starterMainQuestRouteId, - MainQuestSeasonId: starterMainQuestSeasonId, - }, - Notifications: store.NotificationState{ - GiftNotReceiveCount: 1, - }, - Characters: make(map[int32]store.CharacterState), - Costumes: make(map[string]store.CostumeState), - Weapons: make(map[string]store.WeaponState), - Companions: make(map[string]store.CompanionState), - DeckCharacters: make(map[string]store.DeckCharacterState), - Decks: make(map[store.DeckKey]store.DeckState), - DeckSubWeapons: make(map[string][]string), - DeckParts: make(map[string][]string), - Quests: make(map[int32]store.UserQuestState), - QuestMissions: make(map[store.QuestMissionKey]store.UserQuestMissionState), - SideStoryQuests: make(map[int32]store.SideStoryQuestProgress), - QuestLimitContentStatus: make(map[int32]store.QuestLimitContentStatus), - BigHuntMaxScores: make(map[int32]store.BigHuntMaxScore), - BigHuntStatuses: make(map[int32]store.BigHuntStatus), - BigHuntScheduleMaxScores: make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore), - BigHuntWeeklyMaxScores: make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore), - BigHuntWeeklyStatuses: make(map[int64]store.BigHuntWeeklyStatus), - WeaponStories: make(map[int32]store.WeaponStoryState), - Missions: map[int32]store.UserMissionState{ - starterMissionId: { - MissionId: starterMissionId, - StartDatetime: nowMillis, - MissionProgressStatusType: missionInProgress, - }, - }, - Gimmick: store.GimmickState{ - Progress: make(map[store.GimmickKey]store.GimmickProgressState), - OrnamentProgress: make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState), - Sequences: make(map[store.GimmickSequenceKey]store.GimmickSequenceState), - Unlocks: make(map[store.GimmickKey]store.GimmickUnlockState), - }, - CageOrnamentRewards: make(map[int32]store.CageOrnamentRewardState), - ConsumableItems: make(map[int32]int32), - Materials: make(map[int32]int32), - Thoughts: make(map[string]store.ThoughtState), - Parts: make(map[string]store.PartsState), - PartsGroupNotes: make(map[int32]store.PartsGroupNoteState), - PartsPresets: make(map[int32]store.PartsPresetState), - ImportantItems: make(map[int32]int32), - CostumeActiveSkills: make(map[string]store.CostumeActiveSkillState), - WeaponSkills: make(map[string][]store.WeaponSkillState), - WeaponAbilities: make(map[string][]store.WeaponAbilityState), - DeckTypeNotes: make(map[model.DeckType]store.DeckTypeNoteState), - WeaponNotes: make(map[int32]store.WeaponNoteState), - NaviCutInPlayed: make(map[int32]bool), - ViewedMovies: make(map[int32]int64), - ContentsStories: make(map[int32]int64), - DrawnOmikuji: make(map[int32]int64), - PremiumItems: make(map[int32]int64), - DokanConfirmed: make(map[int32]bool), - ShopItems: make(map[int32]store.UserShopItemState), - ShopReplaceableLineup: make(map[int32]store.UserShopReplaceableLineupState), - ExploreScores: make(map[int32]store.ExploreScoreState), - - CharacterBoards: make(map[int32]store.CharacterBoardState), - CharacterBoardAbilities: make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState), - CharacterBoardStatusUps: make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState), - - CostumeAwakenStatusUps: make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState), - AutoSaleSettings: make(map[int32]store.AutoSaleSettingState), - CharacterRebirths: make(map[int32]store.CharacterRebirthState), - } - store.EnsureDefaultDeck(user, nowMillis) - if grantStarterItems { - applyStarterItems(user) - } - return user -} - -func applyStarterItems(user *store.UserState) { - for _, item := range defaultStarterItems { - switch item.Type { - case model.PossessionTypeFreeGem: - user.Gem.FreeGem += item.Qty - case model.PossessionTypeConsumableItem: - user.ConsumableItems[item.Id] += item.Qty - case model.PossessionTypeMaterial: - user.Materials[item.Id] += item.Qty - } - } -} diff --git a/server/internal/store/memory/snapshot.go b/server/internal/store/memory/snapshot.go deleted file mode 100644 index ce15a79..0000000 --- a/server/internal/store/memory/snapshot.go +++ /dev/null @@ -1,95 +0,0 @@ -package memory - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "strconv" - "strings" - - "lunar-tear/server/internal/store" -) - -func snapshotPath(dir string, sceneId int32) string { - return filepath.Join(dir, fmt.Sprintf("scene_%d.json", sceneId)) -} - -func saveSnapshot(user *store.UserState, dir string) { - sceneId := user.MainQuest.CurrentQuestSceneId - if sceneId == 0 { - return - } - data, err := json.MarshalIndent(user, "", " ") - if err != nil { - log.Printf("[snapshot] marshal error for scene=%d: %v", sceneId, err) - return - } - path := snapshotPath(dir, sceneId) - if err := os.WriteFile(path, data, 0644); err != nil { - log.Printf("[snapshot] write error for scene=%d: %v", sceneId, err) - return - } - log.Printf("[snapshot] saved scene=%d (%d bytes)", sceneId, len(data)) -} - -// parseSceneId extracts the numeric scene ID from a filename of the form "scene_.json". -// Returns (0, false) if the name does not match the expected format. -func parseSceneId(name string) (int32, bool) { - if !strings.HasPrefix(name, "scene_") || !strings.HasSuffix(name, ".json") { - return 0, false - } - raw := strings.TrimSuffix(strings.TrimPrefix(name, "scene_"), ".json") - id, err := strconv.ParseInt(raw, 10, 32) - if err != nil { - return 0, false - } - return int32(id), true -} - -// LatestSnapshotSceneId scans dir for scene_*.json files and returns the scene ID -// of the most recently modified snapshot. Returns (0, false) if none are found. -func LatestSnapshotSceneId(dir string) (int32, bool) { - entries, err := os.ReadDir(dir) - if err != nil { - return 0, false - } - var latestId int32 - var latestMod int64 - for _, e := range entries { - if e.IsDir() { - continue - } - id, ok := parseSceneId(e.Name()) - if !ok { - continue - } - info, err := e.Info() - if err != nil { - continue - } - if mt := info.ModTime().UnixNano(); mt > latestMod { - latestMod = mt - latestId = id - } - } - if latestId == 0 { - return 0, false - } - return latestId, true -} - -func loadSnapshot(dir string, sceneId int32) (*store.UserState, error) { - path := snapshotPath(dir, sceneId) - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read snapshot scene=%d: %w", sceneId, err) - } - var user store.UserState - if err := json.Unmarshal(data, &user); err != nil { - return nil, fmt.Errorf("unmarshal snapshot scene=%d: %w", sceneId, err) - } - user.EnsureMaps() - return &user, nil -} diff --git a/server/internal/store/seed.go b/server/internal/store/seed.go new file mode 100644 index 0000000..4d996e4 --- /dev/null +++ b/server/internal/store/seed.go @@ -0,0 +1,157 @@ +package store + +import ( + "lunar-tear/server/internal/model" +) + +const ( + starterMissionId = int32(1) + starterMainQuestRouteId = int32(1) + starterMainQuestSeasonId = int32(1) + missionInProgress = int32(1) + + defaultBirthYear = int32(2000) + defaultBirthMonth = int32(1) + defaultBackupToken = "mock-backup-token" + defaultChargeMoneyThisMonth = int64(0) +) + +func SeedUserState(userId int64, uuid string, nowMillis int64) *UserState { + user := &UserState{ + UserId: userId, + Uuid: uuid, + PlayerId: userId, + OsType: 2, + PlatformType: 2, + UserRestrictionType: 0, + RegisterDatetime: nowMillis, + GameStartDatetime: nowMillis, + LatestVersion: 0, + BirthYear: defaultBirthYear, + BirthMonth: defaultBirthMonth, + BackupToken: defaultBackupToken, + ChargeMoneyThisMonth: defaultChargeMoneyThisMonth, + Setting: UserSettingState{ + IsNotifyPurchaseAlert: false, + LatestVersion: 0, + }, + Status: UserStatusState{ + Level: 1, + Exp: 0, + StaminaMilliValue: 50000, + StaminaUpdateDatetime: nowMillis, + LatestVersion: 0, + }, + Gem: UserGemState{ + PaidGem: 0, + FreeGem: 0, + }, + Profile: UserProfileState{ + Name: "", + NameUpdateDatetime: 0, + Message: "", + MessageUpdateDatetime: nowMillis, + FavoriteCostumeId: 0, + FavoriteCostumeIdUpdateDatetime: nowMillis, + LatestVersion: 0, + }, + Login: UserLoginState{ + TotalLoginCount: 1, + ContinualLoginCount: 1, + MaxContinualLoginCount: 1, + LastLoginDatetime: nowMillis, + LastComebackLoginDatetime: 0, + LatestVersion: 0, + }, + LoginBonus: UserLoginBonusState{ + LoginBonusId: 1, + CurrentPageNumber: 1, + CurrentStampNumber: 0, + LatestRewardReceiveDatetime: 0, + LatestVersion: 0, + }, + Tutorials: map[int32]TutorialProgressState{ + 1: {TutorialType: 1}, + }, + Battle: BattleState{}, + Gifts: GiftState{ + NotReceived: []NotReceivedGiftState{}, + Received: []ReceivedGiftState{}, + }, + Gacha: GachaState{ + ConvertedGachaMedal: ConvertedGachaMedalState{ + ConvertedMedalPossession: []ConsumableItemState{}, + }, + BannerStates: make(map[int32]GachaBannerState), + }, + MainQuest: MainQuestState{ + CurrentMainQuestRouteId: starterMainQuestRouteId, + MainQuestSeasonId: starterMainQuestSeasonId, + }, + Notifications: NotificationState{ + GiftNotReceiveCount: 1, + }, + Characters: make(map[int32]CharacterState), + Costumes: make(map[string]CostumeState), + Weapons: make(map[string]WeaponState), + Companions: make(map[string]CompanionState), + DeckCharacters: make(map[string]DeckCharacterState), + Decks: make(map[DeckKey]DeckState), + DeckSubWeapons: make(map[string][]string), + DeckParts: make(map[string][]string), + Quests: make(map[int32]UserQuestState), + QuestMissions: make(map[QuestMissionKey]UserQuestMissionState), + SideStoryQuests: make(map[int32]SideStoryQuestProgress), + QuestLimitContentStatus: make(map[int32]QuestLimitContentStatus), + BigHuntMaxScores: make(map[int32]BigHuntMaxScore), + BigHuntStatuses: make(map[int32]BigHuntStatus), + BigHuntScheduleMaxScores: make(map[BigHuntScheduleScoreKey]BigHuntScheduleMaxScore), + BigHuntWeeklyMaxScores: make(map[BigHuntWeeklyScoreKey]BigHuntWeeklyMaxScore), + BigHuntWeeklyStatuses: make(map[int64]BigHuntWeeklyStatus), + WeaponStories: make(map[int32]WeaponStoryState), + Missions: map[int32]UserMissionState{ + starterMissionId: { + MissionId: starterMissionId, + StartDatetime: nowMillis, + MissionProgressStatusType: missionInProgress, + }, + }, + Gimmick: GimmickState{ + Progress: make(map[GimmickKey]GimmickProgressState), + OrnamentProgress: make(map[GimmickOrnamentKey]GimmickOrnamentProgressState), + Sequences: make(map[GimmickSequenceKey]GimmickSequenceState), + Unlocks: make(map[GimmickKey]GimmickUnlockState), + }, + CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), + ConsumableItems: make(map[int32]int32), + Materials: make(map[int32]int32), + Thoughts: make(map[string]ThoughtState), + Parts: make(map[string]PartsState), + PartsGroupNotes: make(map[int32]PartsGroupNoteState), + PartsPresets: make(map[int32]PartsPresetState), + ImportantItems: make(map[int32]int32), + CostumeActiveSkills: make(map[string]CostumeActiveSkillState), + WeaponSkills: make(map[string][]WeaponSkillState), + WeaponAbilities: make(map[string][]WeaponAbilityState), + DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState), + WeaponNotes: make(map[int32]WeaponNoteState), + NaviCutInPlayed: make(map[int32]bool), + ViewedMovies: make(map[int32]int64), + ContentsStories: make(map[int32]int64), + DrawnOmikuji: make(map[int32]int64), + PremiumItems: make(map[int32]int64), + DokanConfirmed: make(map[int32]bool), + ShopItems: make(map[int32]UserShopItemState), + ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState), + ExploreScores: make(map[int32]ExploreScoreState), + + CharacterBoards: make(map[int32]CharacterBoardState), + CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState), + CharacterBoardStatusUps: make(map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState), + + CostumeAwakenStatusUps: make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState), + AutoSaleSettings: make(map[int32]AutoSaleSettingState), + CharacterRebirths: make(map[int32]CharacterRebirthState), + } + return user +} diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go new file mode 100644 index 0000000..380022b --- /dev/null +++ b/server/internal/store/sqlite/load.go @@ -0,0 +1,724 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/store" +) + +func (s *SQLiteStore) LoadUser(userId int64) (store.UserState, error) { + var u store.UserState + + err := s.db.QueryRow(`SELECT user_id, uuid, player_id, os_type, platform_type, user_restriction_type, + register_datetime, game_start_datetime, latest_version, birth_year, birth_month, + backup_token, charge_money_this_month FROM users WHERE user_id = ?`, userId).Scan( + &u.UserId, &u.Uuid, &u.PlayerId, &u.OsType, &u.PlatformType, &u.UserRestrictionType, + &u.RegisterDatetime, &u.GameStartDatetime, &u.LatestVersion, &u.BirthYear, &u.BirthMonth, + &u.BackupToken, &u.ChargeMoneyThisMonth) + if err == sql.ErrNoRows { + return u, store.ErrNotFound + } + if err != nil { + return u, fmt.Errorf("load users: %w", err) + } + + initMaps(&u) + + load1to1(s.db, userId, &u) + loadMapTables(s.db, userId, &u) + + return u, nil +} + +func initMaps(u *store.UserState) { + u.Tutorials = make(map[int32]store.TutorialProgressState) + u.Characters = make(map[int32]store.CharacterState) + u.Costumes = make(map[string]store.CostumeState) + u.Weapons = make(map[string]store.WeaponState) + u.Companions = make(map[string]store.CompanionState) + u.Thoughts = make(map[string]store.ThoughtState) + u.DeckCharacters = make(map[string]store.DeckCharacterState) + u.Decks = make(map[store.DeckKey]store.DeckState) + u.DeckSubWeapons = make(map[string][]string) + u.DeckParts = make(map[string][]string) + u.Quests = make(map[int32]store.UserQuestState) + u.QuestMissions = make(map[store.QuestMissionKey]store.UserQuestMissionState) + u.Missions = make(map[int32]store.UserMissionState) + u.WeaponStories = make(map[int32]store.WeaponStoryState) + u.WeaponNotes = make(map[int32]store.WeaponNoteState) + u.WeaponSkills = make(map[string][]store.WeaponSkillState) + u.WeaponAbilities = make(map[string][]store.WeaponAbilityState) + u.WeaponAwakens = make(map[string]store.WeaponAwakenState) + u.CostumeActiveSkills = make(map[string]store.CostumeActiveSkillState) + u.CostumeAwakenStatusUps = make(map[store.CostumeAwakenStatusKey]store.CostumeAwakenStatusUpState) + u.CostumeLotteryEffects = make(map[store.CostumeLotteryEffectKey]store.CostumeLotteryEffectState) + u.CostumeLotteryEffectPending = make(map[string]store.CostumeLotteryEffectPendingState) + u.Parts = make(map[string]store.PartsState) + u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState) + u.PartsPresets = make(map[int32]store.PartsPresetState) + u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState) + u.ConsumableItems = make(map[int32]int32) + u.Materials = make(map[int32]int32) + u.ImportantItems = make(map[int32]int32) + u.PremiumItems = make(map[int32]int64) + u.NaviCutInPlayed = make(map[int32]bool) + u.ViewedMovies = make(map[int32]int64) + u.ContentsStories = make(map[int32]int64) + u.DrawnOmikuji = make(map[int32]int64) + u.DokanConfirmed = make(map[int32]bool) + u.ShopItems = make(map[int32]store.UserShopItemState) + u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState) + u.ExploreScores = make(map[int32]store.ExploreScoreState) + u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) + u.CharacterBoards = make(map[int32]store.CharacterBoardState) + u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) + u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) + u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) + u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) + u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) + u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) + u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) + u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) + u.BigHuntScheduleMaxScores = make(map[store.BigHuntScheduleScoreKey]store.BigHuntScheduleMaxScore) + u.BigHuntWeeklyMaxScores = make(map[store.BigHuntWeeklyScoreKey]store.BigHuntWeeklyMaxScore) + u.BigHuntWeeklyStatuses = make(map[int64]store.BigHuntWeeklyStatus) + u.Gacha.BannerStates = make(map[int32]store.GachaBannerState) + u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = []store.ConsumableItemState{} + u.Gifts.NotReceived = []store.NotReceivedGiftState{} + u.Gifts.Received = []store.ReceivedGiftState{} + u.Gimmick.Progress = make(map[store.GimmickKey]store.GimmickProgressState) + u.Gimmick.OrnamentProgress = make(map[store.GimmickOrnamentKey]store.GimmickOrnamentProgressState) + u.Gimmick.Sequences = make(map[store.GimmickSequenceKey]store.GimmickSequenceState) + u.Gimmick.Unlocks = make(map[store.GimmickKey]store.GimmickUnlockState) +} + +func load1to1(db *sql.DB, uid int64, u *store.UserState) { + var b int + _ = db.QueryRow(`SELECT is_notify_purchase_alert, latest_version FROM user_setting WHERE user_id=?`, uid). + Scan(&b, &u.Setting.LatestVersion) + u.Setting.IsNotifyPurchaseAlert = b != 0 + + _ = db.QueryRow(`SELECT level, exp, stamina_milli_value, stamina_update_datetime, latest_version FROM user_status WHERE user_id=?`, uid). + Scan(&u.Status.Level, &u.Status.Exp, &u.Status.StaminaMilliValue, &u.Status.StaminaUpdateDatetime, &u.Status.LatestVersion) + + _ = db.QueryRow(`SELECT paid_gem, free_gem FROM user_gem WHERE user_id=?`, uid). + Scan(&u.Gem.PaidGem, &u.Gem.FreeGem) + + _ = db.QueryRow(`SELECT name, name_update_datetime, message, message_update_datetime, favorite_costume_id, + favorite_costume_id_update_datetime, latest_version FROM user_profile WHERE user_id=?`, uid). + Scan(&u.Profile.Name, &u.Profile.NameUpdateDatetime, &u.Profile.Message, &u.Profile.MessageUpdateDatetime, + &u.Profile.FavoriteCostumeId, &u.Profile.FavoriteCostumeIdUpdateDatetime, &u.Profile.LatestVersion) + + _ = db.QueryRow(`SELECT total_login_count, continual_login_count, max_continual_login_count, + last_login_datetime, last_comeback_login_datetime, latest_version FROM user_login WHERE user_id=?`, uid). + Scan(&u.Login.TotalLoginCount, &u.Login.ContinualLoginCount, &u.Login.MaxContinualLoginCount, + &u.Login.LastLoginDatetime, &u.Login.LastComebackLoginDatetime, &u.Login.LatestVersion) + + _ = db.QueryRow(`SELECT login_bonus_id, current_page_number, current_stamp_number, + latest_reward_receive_datetime, latest_version FROM user_login_bonus WHERE user_id=?`, uid). + Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber, + &u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion) + + _ = db.QueryRow(`SELECT 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 + FROM user_main_quest WHERE user_id=?`, uid). + Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId, + &u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId, + &u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion, + &u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId, + &u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId) + u.MainQuest.IsReachedLastQuestScene = b != 0 + + _ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id, + head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid). + Scan(&u.EventQuest.CurrentEventQuestChapterId, &u.EventQuest.CurrentQuestId, + &u.EventQuest.CurrentQuestSceneId, &u.EventQuest.HeadQuestSceneId, &u.EventQuest.LatestVersion) + + _ = db.QueryRow(`SELECT current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version + FROM user_extra_quest WHERE user_id=?`, uid). + Scan(&u.ExtraQuest.CurrentQuestId, &u.ExtraQuest.CurrentQuestSceneId, + &u.ExtraQuest.HeadQuestSceneId, &u.ExtraQuest.LatestVersion) + + _ = db.QueryRow(`SELECT current_side_story_quest_id, current_side_story_quest_scene_id, latest_version + FROM user_side_story_active WHERE user_id=?`, uid). + Scan(&u.SideStoryActiveProgress.CurrentSideStoryQuestId, + &u.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, &u.SideStoryActiveProgress.LatestVersion) + + var isDryRun int + _ = db.QueryRow(`SELECT 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 + FROM user_big_hunt_state WHERE user_id=?`, uid). + Scan(&u.BigHuntProgress.CurrentBigHuntBossQuestId, &u.BigHuntProgress.CurrentBigHuntQuestId, + &u.BigHuntProgress.CurrentQuestSceneId, &isDryRun, &u.BigHuntProgress.LatestVersion, + &u.BigHuntBattleDetail.DeckType, &u.BigHuntBattleDetail.UserTripleDeckNumber, + &u.BigHuntBattleDetail.BossKnockDownCount, &u.BigHuntBattleDetail.MaxComboCount, + &u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary) + u.BigHuntProgress.IsDryRun = isDryRun != 0 + + var isActive, isUnread int + _ = db.QueryRow(`SELECT 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 + FROM user_battle WHERE user_id=?`, uid). + Scan(&isActive, &u.Battle.StartCount, &u.Battle.FinishCount, &u.Battle.LastStartedAt, + &u.Battle.LastFinishedAt, &u.Battle.LastUserPartyCount, &u.Battle.LastNpcPartyCount, + &u.Battle.LastBattleBinarySize, &u.Battle.LastElapsedFrameCount) + u.Battle.IsActive = isActive != 0 + + _ = db.QueryRow(`SELECT gift_not_receive_count, friend_request_receive_count, is_exist_unread_information + FROM user_notification WHERE user_id=?`, uid). + Scan(&u.Notifications.GiftNotReceiveCount, &u.Notifications.FriendRequestReceiveCount, &isUnread) + u.Notifications.IsExistUnreadInformation = isUnread != 0 + + var isCP int + _ = db.QueryRow(`SELECT is_current_progress, drop_item_start_datetime, current_drop_item_count, latest_version + FROM user_portal_cage WHERE user_id=?`, uid). + Scan(&isCP, &u.PortalCageStatus.DropItemStartDatetime, &u.PortalCageStatus.CurrentDropItemCount, + &u.PortalCageStatus.LatestVersion) + u.PortalCageStatus.IsCurrentProgress = isCP != 0 + + _ = db.QueryRow(`SELECT start_datetime, open_minutes, daily_opened_count, latest_version + FROM user_guerrilla_free_open WHERE user_id=?`, uid). + Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes, + &u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion) + + var isTicket int + _ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version + FROM user_explore WHERE user_id=?`, uid). + Scan(&isTicket, &u.Explore.PlayingExploreId, &u.Explore.LatestPlayDatetime, &u.Explore.LatestVersion) + u.Explore.IsUseExploreTicket = isTicket != 0 + + _ = db.QueryRow(`SELECT lineup_update_count, latest_lineup_update_datetime, latest_version + FROM user_shop_replaceable WHERE user_id=?`, uid). + Scan(&u.ShopReplaceable.LineupUpdateCount, &u.ShopReplaceable.LatestLineupUpdateDatetime, + &u.ShopReplaceable.LatestVersion) + + var rewardAvail int + var obtainItemId, obtainCount sql.NullInt64 + _ = db.QueryRow(`SELECT reward_available, todays_current_draw_count, daily_max_count, + last_reward_draw_date, obtain_consumable_item_id, obtain_count + FROM user_gacha WHERE user_id=?`, uid). + Scan(&rewardAvail, &u.Gacha.TodaysCurrentDrawCount, &u.Gacha.DailyMaxCount, + &u.Gacha.LastRewardDrawDate, &obtainItemId, &obtainCount) + u.Gacha.RewardAvailable = rewardAvail != 0 + if obtainItemId.Valid { + u.Gacha.ConvertedGachaMedal.ObtainPossession = &store.ConsumableItemState{ + ConsumableItemId: int32(obtainItemId.Int64), + Count: int32(obtainCount.Int64), + } + } +} + +func loadMapTables(db *sql.DB, uid int64, u *store.UserState) { + queryRows(db, `SELECT character_id, level, exp, latest_version FROM user_characters WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CharacterState + rows.Scan(&v.CharacterId, &v.Level, &v.Exp, &v.LatestVersion) + u.Characters[v.CharacterId] = v + }) + + queryRows(db, `SELECT 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 + FROM user_costumes WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.CostumeState + rows.Scan(&v.UserCostumeUuid, &v.CostumeId, &v.LimitBreakCount, &v.Level, &v.Exp, + &v.HeadupDisplayViewId, &v.AcquisitionDatetime, &v.AwakenCount, + &v.CostumeLotteryEffectUnlockedSlotCount, &v.LatestVersion) + u.Costumes[v.UserCostumeUuid] = v + }) + + queryRows(db, `SELECT user_weapon_uuid, weapon_id, level, exp, limit_break_count, + is_protected, acquisition_datetime, latest_version FROM user_weapons WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.WeaponState + var prot int + rows.Scan(&v.UserWeaponUuid, &v.WeaponId, &v.Level, &v.Exp, &v.LimitBreakCount, + &prot, &v.AcquisitionDatetime, &v.LatestVersion) + v.IsProtected = prot != 0 + u.Weapons[v.UserWeaponUuid] = v + }) + + queryRows(db, `SELECT user_companion_uuid, companion_id, headup_display_view_id, level, + acquisition_datetime, latest_version FROM user_companions WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CompanionState + rows.Scan(&v.UserCompanionUuid, &v.CompanionId, &v.HeadupDisplayViewId, &v.Level, + &v.AcquisitionDatetime, &v.LatestVersion) + u.Companions[v.UserCompanionUuid] = v + }) + + queryRows(db, `SELECT user_thought_uuid, thought_id, acquisition_datetime, latest_version + FROM user_thoughts WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.ThoughtState + rows.Scan(&v.UserThoughtUuid, &v.ThoughtId, &v.AcquisitionDatetime, &v.LatestVersion) + u.Thoughts[v.UserThoughtUuid] = v + }) + + queryRows(db, `SELECT user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, + user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version + FROM user_deck_characters WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.DeckCharacterState + rows.Scan(&v.UserDeckCharacterUuid, &v.UserCostumeUuid, &v.MainUserWeaponUuid, + &v.UserCompanionUuid, &v.Power, &v.UserThoughtUuid, &v.DressupCostumeId, &v.LatestVersion) + u.DeckCharacters[v.UserDeckCharacterUuid] = v + }) + + queryRows(db, `SELECT deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, + user_deck_character_uuid03, name, power, latest_version FROM user_decks WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.DeckState + var dt int32 + rows.Scan(&dt, &v.UserDeckNumber, &v.UserDeckCharacterUuid01, &v.UserDeckCharacterUuid02, + &v.UserDeckCharacterUuid03, &v.Name, &v.Power, &v.LatestVersion) + v.DeckType = model.DeckType(dt) + u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v + }) + + queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid + FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid, + func(rows *sql.Rows) { + var key, val string + var ord int + rows.Scan(&key, &ord, &val) + u.DeckSubWeapons[key] = append(u.DeckSubWeapons[key], val) + }) + + queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_parts_uuid + FROM user_deck_parts WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid, + func(rows *sql.Rows) { + var key, val string + var ord int + rows.Scan(&key, &ord, &val) + u.DeckParts[key] = append(u.DeckParts[key], val) + }) + + queryRows(db, `SELECT 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 + FROM user_quests WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.UserQuestState + var bo, rg int + rows.Scan(&v.QuestId, &v.QuestStateType, &bo, &v.UserDeckNumber, &v.LatestStartDatetime, + &v.ClearCount, &v.DailyClearCount, &v.LastClearDatetime, &v.ShortestClearFrames, &rg, &v.LatestVersion) + v.IsBattleOnly = bo != 0 + v.IsRewardGranted = rg != 0 + u.Quests[v.QuestId] = v + }) + + queryRows(db, `SELECT quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version + FROM user_quest_missions WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.UserQuestMissionState + var ic int + rows.Scan(&v.QuestId, &v.QuestMissionId, &v.ProgressValue, &ic, &v.LatestClearDatetime, &v.LatestVersion) + v.IsClear = ic != 0 + u.QuestMissions[store.QuestMissionKey{QuestId: v.QuestId, QuestMissionId: v.QuestMissionId}] = v + }) + + queryRows(db, `SELECT mission_id, start_datetime, progress_value, mission_progress_status_type, + clear_datetime, latest_version FROM user_missions WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.UserMissionState + rows.Scan(&v.MissionId, &v.StartDatetime, &v.ProgressValue, &v.MissionProgressStatusType, + &v.ClearDatetime, &v.LatestVersion) + u.Missions[v.MissionId] = v + }) + + queryRows(db, `SELECT tutorial_type, progress_phase, choice_id, latest_version + FROM user_tutorials WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.TutorialProgressState + rows.Scan(&v.TutorialType, &v.ProgressPhase, &v.ChoiceId, &v.LatestVersion) + u.Tutorials[v.TutorialType] = v + }) + + queryRows(db, `SELECT side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version + FROM user_side_story_quests WHERE user_id=?`, uid, func(rows *sql.Rows) { + var id, head, st int32 + var lv int64 + rows.Scan(&id, &head, &st, &lv) + u.SideStoryQuests[id] = store.SideStoryQuestProgress{ + HeadSideStoryQuestSceneId: head, SideStoryQuestStateType: model.SideStoryQuestStateType(st), LatestVersion: lv, + } + }) + + queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version + FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) { + var id int32 + var v store.QuestLimitContentStatus + rows.Scan(&id, &v.LimitContentQuestStatusType, &v.EventQuestChapterId, &v.LatestVersion) + u.QuestLimitContentStatus[id] = v + }) + + queryRows(db, `SELECT weapon_id, released_max_story_index, latest_version FROM user_weapon_stories WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.WeaponStoryState + rows.Scan(&v.WeaponId, &v.ReleasedMaxStoryIndex, &v.LatestVersion) + u.WeaponStories[v.WeaponId] = v + }) + + queryRows(db, `SELECT weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version + FROM user_weapon_notes WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.WeaponNoteState + rows.Scan(&v.WeaponId, &v.MaxLevel, &v.MaxLimitBreakCount, &v.FirstAcquisitionDatetime, &v.LatestVersion) + u.WeaponNotes[v.WeaponId] = v + }) + + queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_skills WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.WeaponSkillState + rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level) + u.WeaponSkills[v.UserWeaponUuid] = append(u.WeaponSkills[v.UserWeaponUuid], v) + }) + + queryRows(db, `SELECT user_weapon_uuid, slot_number, level FROM user_weapon_abilities WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.WeaponAbilityState + rows.Scan(&v.UserWeaponUuid, &v.SlotNumber, &v.Level) + u.WeaponAbilities[v.UserWeaponUuid] = append(u.WeaponAbilities[v.UserWeaponUuid], v) + }) + + queryRows(db, `SELECT user_weapon_uuid, latest_version FROM user_weapon_awakens WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.WeaponAwakenState + rows.Scan(&v.UserWeaponUuid, &v.LatestVersion) + u.WeaponAwakens[v.UserWeaponUuid] = v + }) + + queryRows(db, `SELECT user_costume_uuid, level, acquisition_datetime, latest_version + FROM user_costume_active_skills WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.CostumeActiveSkillState + rows.Scan(&v.UserCostumeUuid, &v.Level, &v.AcquisitionDatetime, &v.LatestVersion) + u.CostumeActiveSkills[v.UserCostumeUuid] = v + }) + + queryRows(db, `SELECT user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, + critical_ratio, critical_attack, latest_version FROM user_costume_awaken_status_ups WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CostumeAwakenStatusUpState + var sct int32 + rows.Scan(&v.UserCostumeUuid, &sct, &v.Hp, &v.Attack, &v.Vitality, &v.Agility, + &v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion) + v.StatusCalculationType = model.StatusCalculationType(sct) + u.CostumeAwakenStatusUps[store.CostumeAwakenStatusKey{ + UserCostumeUuid: v.UserCostumeUuid, StatusCalculationType: v.StatusCalculationType, + }] = v + }) + + queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version + FROM user_costume_lottery_effects WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CostumeLotteryEffectState + rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion) + u.CostumeLotteryEffects[store.CostumeLotteryEffectKey{ + UserCostumeUuid: v.UserCostumeUuid, SlotNumber: v.SlotNumber, + }] = v + }) + + queryRows(db, `SELECT user_costume_uuid, slot_number, odds_number, latest_version + FROM user_costume_lottery_effect_pending WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CostumeLotteryEffectPendingState + rows.Scan(&v.UserCostumeUuid, &v.SlotNumber, &v.OddsNumber, &v.LatestVersion) + u.CostumeLotteryEffectPending[v.UserCostumeUuid] = v + }) + + queryRows(db, `SELECT user_parts_uuid, parts_id, level, parts_status_main_id, is_protected, + acquisition_datetime, latest_version FROM user_parts WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.PartsState + var prot int + rows.Scan(&v.UserPartsUuid, &v.PartsId, &v.Level, &v.PartsStatusMainId, &prot, + &v.AcquisitionDatetime, &v.LatestVersion) + v.IsProtected = prot != 0 + u.Parts[v.UserPartsUuid] = v + }) + + queryRows(db, `SELECT parts_group_id, first_acquisition_datetime, latest_version + FROM user_parts_group_notes WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.PartsGroupNoteState + rows.Scan(&v.PartsGroupId, &v.FirstAcquisitionDatetime, &v.LatestVersion) + u.PartsGroupNotes[v.PartsGroupId] = v + }) + + queryRows(db, `SELECT user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, + name, user_parts_preset_tag_number, latest_version FROM user_parts_presets WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.PartsPresetState + rows.Scan(&v.UserPartsPresetNumber, &v.UserPartsUuid01, &v.UserPartsUuid02, &v.UserPartsUuid03, + &v.Name, &v.UserPartsPresetTagNumber, &v.LatestVersion) + u.PartsPresets[v.UserPartsPresetNumber] = v + }) + + queryRows(db, `SELECT deck_type, max_deck_power, latest_version FROM user_deck_type_notes WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var dt int32 + var v store.DeckTypeNoteState + rows.Scan(&dt, &v.MaxDeckPower, &v.LatestVersion) + v.DeckType = model.DeckType(dt) + u.DeckTypeNotes[v.DeckType] = v + }) + + loadSimpleMap(db, uid, `SELECT consumable_item_id, count FROM user_consumable_items WHERE user_id=?`, u.ConsumableItems) + loadSimpleMap(db, uid, `SELECT material_id, count FROM user_materials WHERE user_id=?`, u.Materials) + loadSimpleMap(db, uid, `SELECT important_item_id, count FROM user_important_items WHERE user_id=?`, u.ImportantItems) + + queryRows(db, `SELECT premium_item_id, count FROM user_premium_items WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var k int32 + var v int64 + rows.Scan(&k, &v) + u.PremiumItems[k] = v + }) + + queryRows(db, `SELECT explore_id, max_score, max_score_update_datetime, latest_version + FROM user_explore_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.ExploreScoreState + rows.Scan(&v.ExploreId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) + u.ExploreScores[v.ExploreId] = v + }) + + queryRows(db, `SELECT possession_auto_sale_item_type, possession_auto_sale_item_value + FROM user_auto_sale_settings WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.AutoSaleSettingState + rows.Scan(&v.PossessionAutoSaleItemType, &v.PossessionAutoSaleItemValue) + u.AutoSaleSettings[v.PossessionAutoSaleItemType] = v + }) + + queryRows(db, `SELECT navi_cutin_id FROM user_navi_cutin_played WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var id int32 + rows.Scan(&id) + u.NaviCutInPlayed[id] = true + }) + + loadTimestampMap(db, uid, `SELECT movie_id, timestamp FROM user_viewed_movies WHERE user_id=?`, u.ViewedMovies) + loadTimestampMap(db, uid, `SELECT contents_story_id, timestamp FROM user_contents_stories WHERE user_id=?`, u.ContentsStories) + loadTimestampMap(db, uid, `SELECT omikuji_id, timestamp FROM user_drawn_omikuji WHERE user_id=?`, u.DrawnOmikuji) + + queryRows(db, `SELECT dokan_id FROM user_dokan_confirmed WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var id int32 + rows.Scan(&id) + u.DokanConfirmed[id] = true + }) + + // Gifts + queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, + description_gift_text_id, equipment_data, expiration_datetime, received_datetime + FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) { + var uuid string + var isRecv int + var gc store.GiftCommonState + var expDt, recvDt sql.NullInt64 + var equipData []byte + rows.Scan(&uuid, &isRecv, &gc.PossessionType, &gc.PossessionId, &gc.Count, &gc.GrantDatetime, + &gc.DescriptionGiftTextId, &equipData, &expDt, &recvDt) + gc.EquipmentData = equipData + if isRecv == 0 { + u.Gifts.NotReceived = append(u.Gifts.NotReceived, store.NotReceivedGiftState{ + GiftCommon: gc, ExpirationDatetime: expDt.Int64, UserGiftUuid: uuid, + }) + } else { + u.Gifts.Received = append(u.Gifts.Received, store.ReceivedGiftState{ + GiftCommon: gc, ReceivedDatetime: recvDt.Int64, + }) + } + }) + + // Gacha converted medals + queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid, + func(rows *sql.Rows) { + var v store.ConsumableItemState + rows.Scan(&v.ConsumableItemId, &v.Count) + u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v) + }) + + // Gacha banners + queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number + FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.GachaBannerState + rows.Scan(&v.GachaId, &v.MedalCount, &v.StepNumber, &v.LoopCount, &v.DrawCount, &v.BoxNumber) + v.BoxDrewCounts = make(map[int32]int32) + u.Gacha.BannerStates[v.GachaId] = v + }) + queryRows(db, `SELECT gacha_id, box_item_id, count FROM user_gacha_banner_box_drew_counts WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var gachaId, boxItemId, count int32 + rows.Scan(&gachaId, &boxItemId, &count) + if bs, ok := u.Gacha.BannerStates[gachaId]; ok { + bs.BoxDrewCounts[boxItemId] = count + u.Gacha.BannerStates[gachaId] = bs + } + }) + + // Character boards + queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, + panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CharacterBoardState + rows.Scan(&v.CharacterBoardId, &v.PanelReleaseBit1, &v.PanelReleaseBit2, + &v.PanelReleaseBit3, &v.PanelReleaseBit4, &v.LatestVersion) + u.CharacterBoards[v.CharacterBoardId] = v + }) + + queryRows(db, `SELECT character_id, ability_id, level, latest_version + FROM user_character_board_abilities WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.CharacterBoardAbilityState + rows.Scan(&v.CharacterId, &v.AbilityId, &v.Level, &v.LatestVersion) + u.CharacterBoardAbilities[store.CharacterBoardAbilityKey{CharacterId: v.CharacterId, AbilityId: v.AbilityId}] = v + }) + + queryRows(db, `SELECT character_id, status_calculation_type, hp, attack, vitality, agility, + critical_ratio, critical_attack, latest_version FROM user_character_board_status_ups WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CharacterBoardStatusUpState + rows.Scan(&v.CharacterId, &v.StatusCalculationType, &v.Hp, &v.Attack, &v.Vitality, &v.Agility, + &v.CriticalRatio, &v.CriticalAttack, &v.LatestVersion) + u.CharacterBoardStatusUps[store.CharacterBoardStatusUpKey{ + CharacterId: v.CharacterId, StatusCalculationType: v.StatusCalculationType, + }] = v + }) + + queryRows(db, `SELECT character_id, rebirth_count, latest_version FROM user_character_rebirths WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.CharacterRebirthState + rows.Scan(&v.CharacterId, &v.RebirthCount, &v.LatestVersion) + u.CharacterRebirths[v.CharacterId] = v + }) + + queryRows(db, `SELECT cage_ornament_id, acquisition_datetime, latest_version + FROM user_cage_ornament_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.CageOrnamentRewardState + rows.Scan(&v.CageOrnamentId, &v.AcquisitionDatetime, &v.LatestVersion) + u.CageOrnamentRewards[v.CageOrnamentId] = v + }) + + queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version + FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.UserShopItemState + rows.Scan(&v.ShopItemId, &v.BoughtCount, &v.LatestBoughtCountChangedDatetime, &v.LatestVersion) + u.ShopItems[v.ShopItemId] = v + }) + + queryRows(db, `SELECT slot_number, shop_item_id, latest_version FROM user_shop_replaceable_lineup WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.UserShopReplaceableLineupState + rows.Scan(&v.SlotNumber, &v.ShopItemId, &v.LatestVersion) + u.ShopReplaceableLineup[v.SlotNumber] = v + }) + + // Gimmick tables + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, + is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.GimmickProgressState + var ic int + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, + &ic, &v.StartDatetime, &v.LatestVersion) + v.IsGimmickCleared = ic != 0 + u.Gimmick.Progress[v.Key] = v + }) + + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, + gimmick_ornament_index, progress_value_bit, base_datetime, latest_version + FROM user_gimmick_ornament_progress WHERE user_id=?`, uid, func(rows *sql.Rows) { + var v store.GimmickOrnamentProgressState + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, + &v.Key.GimmickOrnamentIndex, &v.ProgressValueBit, &v.BaseDatetime, &v.LatestVersion) + u.Gimmick.OrnamentProgress[v.Key] = v + }) + + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, + is_gimmick_sequence_cleared, clear_datetime, latest_version FROM user_gimmick_sequences WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.GimmickSequenceState + var ic int + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, + &ic, &v.ClearDatetime, &v.LatestVersion) + v.IsGimmickSequenceCleared = ic != 0 + u.Gimmick.Sequences[v.Key] = v + }) + + queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, + is_unlocked, latest_version FROM user_gimmick_unlocks WHERE user_id=?`, uid, + func(rows *sql.Rows) { + var v store.GimmickUnlockState + var iu int + rows.Scan(&v.Key.GimmickSequenceScheduleId, &v.Key.GimmickSequenceId, &v.Key.GimmickId, + &iu, &v.LatestVersion) + v.IsUnlocked = iu != 0 + u.Gimmick.Unlocks[v.Key] = v + }) + + // Big hunt maps + queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version + FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { + var id int32 + var v store.BigHuntMaxScore + rows.Scan(&id, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) + u.BigHuntMaxScores[id] = v + }) + + queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version + FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) { + var id int32 + var v store.BigHuntStatus + rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LatestVersion) + u.BigHuntStatuses[id] = v + }) + + queryRows(db, `SELECT big_hunt_schedule_id, big_hunt_boss_id, max_score, max_score_update_datetime, latest_version + FROM user_big_hunt_schedule_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { + var k store.BigHuntScheduleScoreKey + var v store.BigHuntScheduleMaxScore + rows.Scan(&k.BigHuntScheduleId, &k.BigHuntBossId, &v.MaxScore, &v.MaxScoreUpdateDatetime, &v.LatestVersion) + u.BigHuntScheduleMaxScores[k] = v + }) + + queryRows(db, `SELECT big_hunt_weekly_version, attribute_type, max_score, latest_version + FROM user_big_hunt_weekly_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { + var k store.BigHuntWeeklyScoreKey + var v store.BigHuntWeeklyMaxScore + rows.Scan(&k.BigHuntWeeklyVersion, &k.AttributeType, &v.MaxScore, &v.LatestVersion) + u.BigHuntWeeklyMaxScores[k] = v + }) + + queryRows(db, `SELECT big_hunt_weekly_version, is_received_weekly_reward, latest_version + FROM user_big_hunt_weekly_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) { + var ver int64 + var ir int + var lv int64 + rows.Scan(&ver, &ir, &lv) + u.BigHuntWeeklyStatuses[ver] = store.BigHuntWeeklyStatus{IsReceivedWeeklyReward: ir != 0, LatestVersion: lv} + }) +} + +func queryRows(db *sql.DB, query string, uid int64, scan func(*sql.Rows)) { + rows, err := db.Query(query, uid) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + scan(rows) + } +} + +func loadSimpleMap(db *sql.DB, uid int64, query string, m map[int32]int32) { + queryRows(db, query, uid, func(rows *sql.Rows) { + var k, v int32 + rows.Scan(&k, &v) + m[k] = v + }) +} + +func loadTimestampMap(db *sql.DB, uid int64, query string, m map[int32]int64) { + queryRows(db, query, uid, func(rows *sql.Rows) { + var k int32 + var v int64 + rows.Scan(&k, &v) + m[k] = v + }) +} diff --git a/server/internal/store/sqlite/save.go b/server/internal/store/sqlite/save.go new file mode 100644 index 0000000..cd99e9e --- /dev/null +++ b/server/internal/store/sqlite/save.go @@ -0,0 +1,1136 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/store" +) + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// writeUserState inserts all child table rows for a newly created user. +// The users row must already exist. +func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { + exec := func(query string, args ...any) error { + _, err := tx.Exec(query, args...) + return err + } + + if err := exec(`INSERT INTO user_setting (user_id, is_notify_purchase_alert, latest_version) VALUES (?,?,?)`, + uid, boolToInt(u.Setting.IsNotifyPurchaseAlert), u.Setting.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_status (user_id, level, exp, stamina_milli_value, stamina_update_datetime, latest_version) VALUES (?,?,?,?,?,?)`, + uid, u.Status.Level, u.Status.Exp, u.Status.StaminaMilliValue, u.Status.StaminaUpdateDatetime, u.Status.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_gem (user_id, paid_gem, free_gem) VALUES (?,?,?)`, + uid, u.Gem.PaidGem, u.Gem.FreeGem); err != nil { + return err + } + 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 (?,?,?,?,?,?,?,?)`, + uid, u.Profile.Name, u.Profile.NameUpdateDatetime, u.Profile.Message, u.Profile.MessageUpdateDatetime, + u.Profile.FavoriteCostumeId, u.Profile.FavoriteCostumeIdUpdateDatetime, u.Profile.LatestVersion); err != nil { + return err + } + 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 (?,?,?,?,?,?,?)`, + uid, u.Login.TotalLoginCount, u.Login.ContinualLoginCount, u.Login.MaxContinualLoginCount, + u.Login.LastLoginDatetime, u.Login.LastComebackLoginDatetime, u.Login.LatestVersion); err != nil { + return err + } + 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 (?,?,?,?,?,?)`, + uid, u.LoginBonus.LoginBonusId, u.LoginBonus.CurrentPageNumber, u.LoginBonus.CurrentStampNumber, + u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil { + return err + } + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId, + u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId, + u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId, + u.MainQuest.LatestVersion, u.MainQuest.SavedCurrentQuestSceneId, u.MainQuest.SavedHeadQuestSceneId, + u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil { + return err + } + 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 (?,?,?,?,?,?)`, + uid, u.EventQuest.CurrentEventQuestChapterId, u.EventQuest.CurrentQuestId, u.EventQuest.CurrentQuestSceneId, + u.EventQuest.HeadQuestSceneId, u.EventQuest.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_extra_quest (user_id, current_quest_id, current_quest_scene_id, head_quest_scene_id, latest_version) VALUES (?,?,?,?,?)`, + uid, u.ExtraQuest.CurrentQuestId, u.ExtraQuest.CurrentQuestSceneId, u.ExtraQuest.HeadQuestSceneId, u.ExtraQuest.LatestVersion); err != nil { + return err + } + 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 (?,?,?,?)`, + uid, u.SideStoryActiveProgress.CurrentSideStoryQuestId, u.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, u.SideStoryActiveProgress.LatestVersion); err != nil { + return err + } + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + uid, u.BigHuntProgress.CurrentBigHuntBossQuestId, u.BigHuntProgress.CurrentBigHuntQuestId, + u.BigHuntProgress.CurrentQuestSceneId, boolToInt(u.BigHuntProgress.IsDryRun), u.BigHuntProgress.LatestVersion, + u.BigHuntBattleDetail.DeckType, u.BigHuntBattleDetail.UserTripleDeckNumber, u.BigHuntBattleDetail.BossKnockDownCount, + u.BigHuntBattleDetail.MaxComboCount, u.BigHuntBattleDetail.TotalDamage, u.BigHuntDeckNumber, u.BigHuntBattleBinary); err != nil { + return err + } + 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 (?,?,?,?,?,?,?,?,?,?)`, + uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt, + u.Battle.LastFinishedAt, u.Battle.LastUserPartyCount, u.Battle.LastNpcPartyCount, + u.Battle.LastBattleBinarySize, u.Battle.LastElapsedFrameCount); err != nil { + return err + } + if err := exec(`INSERT INTO user_notification (user_id, gift_not_receive_count, friend_request_receive_count, is_exist_unread_information) VALUES (?,?,?,?)`, + uid, u.Notifications.GiftNotReceiveCount, u.Notifications.FriendRequestReceiveCount, + boolToInt(u.Notifications.IsExistUnreadInformation)); err != nil { + return err + } + if err := exec(`INSERT INTO user_portal_cage (user_id, is_current_progress, drop_item_start_datetime, current_drop_item_count, latest_version) VALUES (?,?,?,?,?)`, + uid, boolToInt(u.PortalCageStatus.IsCurrentProgress), u.PortalCageStatus.DropItemStartDatetime, + u.PortalCageStatus.CurrentDropItemCount, u.PortalCageStatus.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_guerrilla_free_open (user_id, start_datetime, open_minutes, daily_opened_count, latest_version) VALUES (?,?,?,?,?)`, + uid, u.GuerrillaFreeOpen.StartDatetime, u.GuerrillaFreeOpen.OpenMinutes, u.GuerrillaFreeOpen.DailyOpenedCount, u.GuerrillaFreeOpen.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_explore (user_id, is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, boolToInt(u.Explore.IsUseExploreTicket), u.Explore.PlayingExploreId, u.Explore.LatestPlayDatetime, u.Explore.LatestVersion); err != nil { + return err + } + if err := exec(`INSERT INTO user_shop_replaceable (user_id, lineup_update_count, latest_lineup_update_datetime, latest_version) VALUES (?,?,?,?)`, + uid, u.ShopReplaceable.LineupUpdateCount, u.ShopReplaceable.LatestLineupUpdateDatetime, u.ShopReplaceable.LatestVersion); err != nil { + return err + } + + var obtainItemId, obtainCount sql.NullInt64 + if u.Gacha.ConvertedGachaMedal.ObtainPossession != nil { + obtainItemId = sql.NullInt64{Int64: int64(u.Gacha.ConvertedGachaMedal.ObtainPossession.ConsumableItemId), Valid: true} + obtainCount = sql.NullInt64{Int64: int64(u.Gacha.ConvertedGachaMedal.ObtainPossession.Count), Valid: true} + } + 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 (?,?,?,?,?,?,?)`, + uid, boolToInt(u.Gacha.RewardAvailable), u.Gacha.TodaysCurrentDrawCount, u.Gacha.DailyMaxCount, + u.Gacha.LastRewardDrawDate, obtainItemId, obtainCount); err != nil { + return err + } + + // Map tables + for _, v := range u.Characters { + if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`, + uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Costumes { + 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 (?,?,?,?,?,?,?,?,?,?,?)`, + uid, v.UserCostumeUuid, v.CostumeId, v.LimitBreakCount, v.Level, v.Exp, v.HeadupDisplayViewId, v.AcquisitionDatetime, v.AwakenCount, v.CostumeLotteryEffectUnlockedSlotCount, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Weapons { + 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 (?,?,?,?,?,?,?,?,?)`, + uid, v.UserWeaponUuid, v.WeaponId, v.Level, v.Exp, v.LimitBreakCount, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Companions { + if err := exec(`INSERT INTO user_companions (user_id, user_companion_uuid, companion_id, headup_display_view_id, level, acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, + uid, v.UserCompanionUuid, v.CompanionId, v.HeadupDisplayViewId, v.Level, v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Thoughts { + if err := exec(`INSERT INTO user_thoughts (user_id, user_thought_uuid, thought_id, acquisition_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, v.UserThoughtUuid, v.ThoughtId, v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.DeckCharacters { + 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 (?,?,?,?,?,?,?,?,?)`, + uid, v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.Decks { + 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 (?,?,?,?,?,?,?,?,?)`, + uid, int32(k.DeckType), k.UserDeckNumber, v.UserDeckCharacterUuid01, v.UserDeckCharacterUuid02, v.UserDeckCharacterUuid03, v.Name, v.Power, v.LatestVersion); err != nil { + return err + } + } + for key, uuids := range u.DeckSubWeapons { + for i, uuid := range uuids { + if err := exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, + uid, key, i, uuid); err != nil { + return err + } + } + } + for key, uuids := range u.DeckParts { + for i, uuid := range uuids { + if err := exec(`INSERT INTO user_deck_parts (user_id, user_deck_character_uuid, ordinal, user_parts_uuid) VALUES (?,?,?,?)`, + uid, key, i, uuid); err != nil { + return err + } + } + } + for _, v := range u.Quests { + 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 (?,?,?,?,?,?,?,?,?,?,?,?)`, + uid, 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); err != nil { + return err + } + } + for k, v := range u.QuestMissions { + 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 (?,?,?,?,?,?,?)`, + uid, k.QuestId, k.QuestMissionId, v.ProgressValue, boolToInt(v.IsClear), v.LatestClearDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Missions { + if err := exec(`INSERT INTO user_missions (user_id, mission_id, start_datetime, progress_value, mission_progress_status_type, clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, + uid, v.MissionId, v.StartDatetime, v.ProgressValue, v.MissionProgressStatusType, v.ClearDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Tutorials { + if err := exec(`INSERT INTO user_tutorials (user_id, tutorial_type, progress_phase, choice_id, latest_version) VALUES (?,?,?,?,?)`, + uid, v.TutorialType, v.ProgressPhase, v.ChoiceId, v.LatestVersion); err != nil { + return err + } + } + for id, v := range u.SideStoryQuests { + 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 (?,?,?,?,?)`, + uid, id, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion); err != nil { + return err + } + } + for id, v := range u.QuestLimitContentStatus { + 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 (?,?,?,?,?)`, + uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.WeaponStories { + if err := exec(`INSERT INTO user_weapon_stories (user_id, weapon_id, released_max_story_index, latest_version) VALUES (?,?,?,?)`, + uid, v.WeaponId, v.ReleasedMaxStoryIndex, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.WeaponNotes { + if err := exec(`INSERT INTO user_weapon_notes (user_id, weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version) VALUES (?,?,?,?,?,?)`, + uid, v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, skills := range u.WeaponSkills { + for _, v := range skills { + if err := exec(`INSERT INTO user_weapon_skills (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, + uid, v.UserWeaponUuid, v.SlotNumber, v.Level); err != nil { + return err + } + } + } + for _, abilities := range u.WeaponAbilities { + for _, v := range abilities { + if err := exec(`INSERT INTO user_weapon_abilities (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, + uid, v.UserWeaponUuid, v.SlotNumber, v.Level); err != nil { + return err + } + } + } + for _, v := range u.WeaponAwakens { + if err := exec(`INSERT INTO user_weapon_awakens (user_id, user_weapon_uuid, latest_version) VALUES (?,?,?)`, + uid, v.UserWeaponUuid, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.CostumeActiveSkills { + if err := exec(`INSERT INTO user_costume_active_skills (user_id, user_costume_uuid, level, acquisition_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.CostumeAwakenStatusUps { + 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 (?,?,?,?,?,?,?,?,?,?)`, + uid, k.UserCostumeUuid, int32(k.StatusCalculationType), v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.CostumeLotteryEffects { + if err := exec(`INSERT INTO user_costume_lottery_effects (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, + uid, k.UserCostumeUuid, k.SlotNumber, v.OddsNumber, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.CostumeLotteryEffectPending { + if err := exec(`INSERT INTO user_costume_lottery_effect_pending (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, + uid, v.UserCostumeUuid, v.SlotNumber, v.OddsNumber, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.Parts { + 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 (?,?,?,?,?,?,?,?)`, + uid, v.UserPartsUuid, v.PartsId, v.Level, v.PartsStatusMainId, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.PartsGroupNotes { + if err := exec(`INSERT INTO user_parts_group_notes (user_id, parts_group_id, first_acquisition_datetime, latest_version) VALUES (?,?,?,?)`, + uid, v.PartsGroupId, v.FirstAcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.PartsPresets { + 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 (?,?,?,?,?,?,?,?)`, + uid, v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.DeckTypeNotes { + if err := exec(`INSERT INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, + uid, int32(v.DeckType), v.MaxDeckPower, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.ConsumableItems { + if err := exec(`INSERT INTO user_consumable_items (user_id, consumable_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k, v := range u.Materials { + if err := exec(`INSERT INTO user_materials (user_id, material_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k, v := range u.ImportantItems { + if err := exec(`INSERT INTO user_important_items (user_id, important_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k, v := range u.PremiumItems { + if err := exec(`INSERT INTO user_premium_items (user_id, premium_item_id, count) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for _, v := range u.ExploreScores { + if err := exec(`INSERT INTO user_explore_scores (user_id, explore_id, max_score, max_score_update_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, v.ExploreId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.AutoSaleSettings { + if err := exec(`INSERT INTO user_auto_sale_settings (user_id, possession_auto_sale_item_type, possession_auto_sale_item_value) VALUES (?,?,?)`, + uid, v.PossessionAutoSaleItemType, v.PossessionAutoSaleItemValue); err != nil { + return err + } + } + for k := range u.NaviCutInPlayed { + if err := exec(`INSERT INTO user_navi_cutin_played (user_id, navi_cutin_id) VALUES (?,?)`, uid, k); err != nil { + return err + } + } + for k, v := range u.ViewedMovies { + if err := exec(`INSERT INTO user_viewed_movies (user_id, movie_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k, v := range u.ContentsStories { + if err := exec(`INSERT INTO user_contents_stories (user_id, contents_story_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k, v := range u.DrawnOmikuji { + if err := exec(`INSERT INTO user_drawn_omikuji (user_id, omikuji_id, timestamp) VALUES (?,?,?)`, uid, k, v); err != nil { + return err + } + } + for k := range u.DokanConfirmed { + if err := exec(`INSERT INTO user_dokan_confirmed (user_id, dokan_id) VALUES (?,?)`, uid, k); err != nil { + return err + } + } + for _, g := range u.Gifts.NotReceived { + var expDt sql.NullInt64 + if g.ExpirationDatetime != 0 { + expDt = sql.NullInt64{Int64: g.ExpirationDatetime, Valid: true} + } + 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,?,?,?,?,?,?,?)`, + uid, g.UserGiftUuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, + g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, expDt); err != nil { + return err + } + } + for i, g := range u.Gifts.Received { + uuid := fmt.Sprintf("received-%d-%d", uid, i) + 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,?,?,?,?,?,?,?)`, + uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, + g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime); err != nil { + return err + } + } + for i, v := range u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { + if err := exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, + uid, i, v.ConsumableItemId, v.Count); err != nil { + return err + } + } + for _, v := range u.Gacha.BannerStates { + if err := exec(`INSERT INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, + uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber); err != nil { + return err + } + for itemId, count := range v.BoxDrewCounts { + if err := exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, + uid, v.GachaId, itemId, count); err != nil { + return err + } + } + } + for _, v := range u.CharacterBoards { + 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 (?,?,?,?,?,?,?)`, + uid, v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.CharacterBoardAbilities { + if err := exec(`INSERT INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, + uid, k.CharacterId, k.AbilityId, v.Level, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.CharacterBoardStatusUps { + 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 (?,?,?,?,?,?,?,?,?,?)`, + uid, k.CharacterId, k.StatusCalculationType, v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.CharacterRebirths { + if err := exec(`INSERT INTO user_character_rebirths (user_id, character_id, rebirth_count, latest_version) VALUES (?,?,?,?)`, + uid, v.CharacterId, v.RebirthCount, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.CageOrnamentRewards { + if err := exec(`INSERT INTO user_cage_ornament_rewards (user_id, cage_ornament_id, acquisition_datetime, latest_version) VALUES (?,?,?,?)`, + uid, v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.ShopItems { + if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil { + return err + } + } + for _, v := range u.ShopReplaceableLineup { + if err := exec(`INSERT INTO user_shop_replaceable_lineup (user_id, slot_number, shop_item_id, latest_version) VALUES (?,?,?,?)`, + uid, v.SlotNumber, v.ShopItemId, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.Gimmick.Progress { + 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 (?,?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsGimmickCleared), v.StartDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.Gimmick.OrnamentProgress { + 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 (?,?,?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex, v.ProgressValueBit, v.BaseDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.Gimmick.Sequences { + 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 (?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, boolToInt(v.IsGimmickSequenceCleared), v.ClearDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.Gimmick.Unlocks { + if err := exec(`INSERT INTO user_gimmick_unlocks (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_unlocked, latest_version) VALUES (?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsUnlocked), v.LatestVersion); err != nil { + return err + } + } + for id, v := range u.BigHuntMaxScores { + 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 (?,?,?,?,?)`, + uid, id, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { + return err + } + } + for id, v := range u.BigHuntStatuses { + if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version) VALUES (?,?,?,?,?)`, + uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.BigHuntScheduleMaxScores { + 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 (?,?,?,?,?,?)`, + uid, k.BigHuntScheduleId, k.BigHuntBossId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion); err != nil { + return err + } + } + for k, v := range u.BigHuntWeeklyMaxScores { + if err := exec(`INSERT INTO user_big_hunt_weekly_max_scores (user_id, big_hunt_weekly_version, attribute_type, max_score, latest_version) VALUES (?,?,?,?,?)`, + uid, k.BigHuntWeeklyVersion, k.AttributeType, v.MaxScore, v.LatestVersion); err != nil { + return err + } + } + for ver, v := range u.BigHuntWeeklyStatuses { + if err := exec(`INSERT INTO user_big_hunt_weekly_statuses (user_id, big_hunt_weekly_version, is_received_weekly_reward, latest_version) VALUES (?,?,?,?)`, + uid, ver, boolToInt(v.IsReceivedWeeklyReward), v.LatestVersion); err != nil { + return err + } + } + + return nil +} + +// diffAndSave compares before/after UserState and writes only changed rows. +// For 1:1 tables, it UPDATEs if any field changed. +// For map tables, it uses INSERT OR REPLACE for added/modified entries and DELETE for removed ones. +// For slice-based data (gifts, medals, deck sub-weapons/parts, weapon skills/abilities), +// it does DELETE-all then INSERT-all for simplicity. +func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { + exec := func(query string, args ...any) error { + _, err := tx.Exec(query, args...) + return err + } + + // users table + if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType || + before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime || + before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion || + before.BirthYear != after.BirthYear || before.BirthMonth != after.BirthMonth || + before.BackupToken != after.BackupToken || before.ChargeMoneyThisMonth != after.ChargeMoneyThisMonth { + if err := exec(`UPDATE users SET player_id=?, os_type=?, platform_type=?, user_restriction_type=?, + register_datetime=?, game_start_datetime=?, latest_version=?, birth_year=?, birth_month=?, + backup_token=?, charge_money_this_month=? WHERE user_id=?`, + after.PlayerId, after.OsType, after.PlatformType, after.UserRestrictionType, + after.RegisterDatetime, after.GameStartDatetime, after.LatestVersion, after.BirthYear, after.BirthMonth, + after.BackupToken, after.ChargeMoneyThisMonth, uid); err != nil { + return err + } + } + + if before.Setting != after.Setting { + if err := exec(`UPDATE user_setting SET is_notify_purchase_alert=?, latest_version=? WHERE user_id=?`, + boolToInt(after.Setting.IsNotifyPurchaseAlert), after.Setting.LatestVersion, uid); err != nil { + return err + } + } + if before.Status != after.Status { + if err := exec(`UPDATE user_status SET level=?, exp=?, stamina_milli_value=?, stamina_update_datetime=?, latest_version=? WHERE user_id=?`, + after.Status.Level, after.Status.Exp, after.Status.StaminaMilliValue, after.Status.StaminaUpdateDatetime, after.Status.LatestVersion, uid); err != nil { + return err + } + } + if before.Gem != after.Gem { + if err := exec(`UPDATE user_gem SET paid_gem=?, free_gem=? WHERE user_id=?`, after.Gem.PaidGem, after.Gem.FreeGem, uid); err != nil { + return err + } + } + if before.Profile != after.Profile { + 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=?`, + after.Profile.Name, after.Profile.NameUpdateDatetime, after.Profile.Message, after.Profile.MessageUpdateDatetime, + after.Profile.FavoriteCostumeId, after.Profile.FavoriteCostumeIdUpdateDatetime, after.Profile.LatestVersion, uid); err != nil { + return err + } + } + if before.Login != after.Login { + 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=?`, + after.Login.TotalLoginCount, after.Login.ContinualLoginCount, after.Login.MaxContinualLoginCount, + after.Login.LastLoginDatetime, after.Login.LastComebackLoginDatetime, after.Login.LatestVersion, uid); err != nil { + return err + } + } + if before.LoginBonus != after.LoginBonus { + 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=?`, + after.LoginBonus.LoginBonusId, after.LoginBonus.CurrentPageNumber, after.LoginBonus.CurrentStampNumber, + after.LoginBonus.LatestRewardReceiveDatetime, after.LoginBonus.LatestVersion, uid); err != nil { + return err + } + } + if before.MainQuest != after.MainQuest { + 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=?`, + after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId, + after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId, + after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId, + after.MainQuest.LatestVersion, after.MainQuest.SavedCurrentQuestSceneId, after.MainQuest.SavedHeadQuestSceneId, + after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil { + return err + } + } + if before.EventQuest != after.EventQuest { + 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=?`, + after.EventQuest.CurrentEventQuestChapterId, after.EventQuest.CurrentQuestId, after.EventQuest.CurrentQuestSceneId, after.EventQuest.HeadQuestSceneId, after.EventQuest.LatestVersion, uid); err != nil { + return err + } + } + if before.ExtraQuest != after.ExtraQuest { + if err := exec(`UPDATE user_extra_quest SET current_quest_id=?, current_quest_scene_id=?, head_quest_scene_id=?, latest_version=? WHERE user_id=?`, + after.ExtraQuest.CurrentQuestId, after.ExtraQuest.CurrentQuestSceneId, after.ExtraQuest.HeadQuestSceneId, after.ExtraQuest.LatestVersion, uid); err != nil { + return err + } + } + if before.SideStoryActiveProgress != after.SideStoryActiveProgress { + 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=?`, + after.SideStoryActiveProgress.CurrentSideStoryQuestId, after.SideStoryActiveProgress.CurrentSideStoryQuestSceneId, after.SideStoryActiveProgress.LatestVersion, uid); err != nil { + return err + } + } + if before.BigHuntProgress != after.BigHuntProgress || before.BigHuntBattleDetail != after.BigHuntBattleDetail || before.BigHuntDeckNumber != after.BigHuntDeckNumber { + 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=?`, + after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId, + after.BigHuntProgress.CurrentQuestSceneId, boolToInt(after.BigHuntProgress.IsDryRun), after.BigHuntProgress.LatestVersion, + after.BigHuntBattleDetail.DeckType, after.BigHuntBattleDetail.UserTripleDeckNumber, after.BigHuntBattleDetail.BossKnockDownCount, + after.BigHuntBattleDetail.MaxComboCount, after.BigHuntBattleDetail.TotalDamage, after.BigHuntDeckNumber, after.BigHuntBattleBinary, uid); err != nil { + return err + } + } + if before.Battle != after.Battle { + 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=?`, + boolToInt(after.Battle.IsActive), after.Battle.StartCount, after.Battle.FinishCount, after.Battle.LastStartedAt, + after.Battle.LastFinishedAt, after.Battle.LastUserPartyCount, after.Battle.LastNpcPartyCount, + after.Battle.LastBattleBinarySize, after.Battle.LastElapsedFrameCount, uid); err != nil { + return err + } + } + if before.Notifications != after.Notifications { + if err := exec(`UPDATE user_notification SET gift_not_receive_count=?, friend_request_receive_count=?, is_exist_unread_information=? WHERE user_id=?`, + after.Notifications.GiftNotReceiveCount, after.Notifications.FriendRequestReceiveCount, boolToInt(after.Notifications.IsExistUnreadInformation), uid); err != nil { + return err + } + } + if before.PortalCageStatus != after.PortalCageStatus { + if err := exec(`UPDATE user_portal_cage SET is_current_progress=?, drop_item_start_datetime=?, current_drop_item_count=?, latest_version=? WHERE user_id=?`, + boolToInt(after.PortalCageStatus.IsCurrentProgress), after.PortalCageStatus.DropItemStartDatetime, after.PortalCageStatus.CurrentDropItemCount, after.PortalCageStatus.LatestVersion, uid); err != nil { + return err + } + } + if before.GuerrillaFreeOpen != after.GuerrillaFreeOpen { + if err := exec(`UPDATE user_guerrilla_free_open SET start_datetime=?, open_minutes=?, daily_opened_count=?, latest_version=? WHERE user_id=?`, + after.GuerrillaFreeOpen.StartDatetime, after.GuerrillaFreeOpen.OpenMinutes, after.GuerrillaFreeOpen.DailyOpenedCount, after.GuerrillaFreeOpen.LatestVersion, uid); err != nil { + return err + } + } + if before.Explore != after.Explore { + if err := exec(`UPDATE user_explore SET is_use_explore_ticket=?, playing_explore_id=?, latest_play_datetime=?, latest_version=? WHERE user_id=?`, + boolToInt(after.Explore.IsUseExploreTicket), after.Explore.PlayingExploreId, after.Explore.LatestPlayDatetime, after.Explore.LatestVersion, uid); err != nil { + return err + } + } + if before.ShopReplaceable != after.ShopReplaceable { + if err := exec(`UPDATE user_shop_replaceable SET lineup_update_count=?, latest_lineup_update_datetime=?, latest_version=? WHERE user_id=?`, + after.ShopReplaceable.LineupUpdateCount, after.ShopReplaceable.LatestLineupUpdateDatetime, after.ShopReplaceable.LatestVersion, uid); err != nil { + return err + } + } + + // Gacha scalar + if before.Gacha.RewardAvailable != after.Gacha.RewardAvailable || before.Gacha.TodaysCurrentDrawCount != after.Gacha.TodaysCurrentDrawCount || + before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate { + var obtainItemId, obtainCount sql.NullInt64 + if after.Gacha.ConvertedGachaMedal.ObtainPossession != nil { + obtainItemId = sql.NullInt64{Int64: int64(after.Gacha.ConvertedGachaMedal.ObtainPossession.ConsumableItemId), Valid: true} + obtainCount = sql.NullInt64{Int64: int64(after.Gacha.ConvertedGachaMedal.ObtainPossession.Count), Valid: true} + } + 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=?`, + boolToInt(after.Gacha.RewardAvailable), after.Gacha.TodaysCurrentDrawCount, after.Gacha.DailyMaxCount, + after.Gacha.LastRewardDrawDate, obtainItemId, obtainCount, uid); err != nil { + return err + } + } + + // Map tables — use generic diff helpers + diffMapInt32(tx, uid, before.Characters, after.Characters, "user_characters", "character_id", + func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} }, + "character_id, level, exp, latest_version") + diffMapStr(tx, uid, before.Costumes, after.Costumes, "user_costumes", "user_costume_uuid", + func(v store.CostumeState) []any { + return []any{v.UserCostumeUuid, v.CostumeId, v.LimitBreakCount, v.Level, v.Exp, v.HeadupDisplayViewId, v.AcquisitionDatetime, v.AwakenCount, v.CostumeLotteryEffectUnlockedSlotCount, v.LatestVersion} + }, "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") + diffMapStr(tx, uid, before.Weapons, after.Weapons, "user_weapons", "user_weapon_uuid", + func(v store.WeaponState) []any { + return []any{v.UserWeaponUuid, v.WeaponId, v.Level, v.Exp, v.LimitBreakCount, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion} + }, "user_weapon_uuid, weapon_id, level, exp, limit_break_count, is_protected, acquisition_datetime, latest_version") + diffMapStr(tx, uid, before.Companions, after.Companions, "user_companions", "user_companion_uuid", + func(v store.CompanionState) []any { + return []any{v.UserCompanionUuid, v.CompanionId, v.HeadupDisplayViewId, v.Level, v.AcquisitionDatetime, v.LatestVersion} + }, "user_companion_uuid, companion_id, headup_display_view_id, level, acquisition_datetime, latest_version") + diffMapStr(tx, uid, before.Thoughts, after.Thoughts, "user_thoughts", "user_thought_uuid", + func(v store.ThoughtState) []any { + return []any{v.UserThoughtUuid, v.ThoughtId, v.AcquisitionDatetime, v.LatestVersion} + }, "user_thought_uuid, thought_id, acquisition_datetime, latest_version") + diffMapStr(tx, uid, before.DeckCharacters, after.DeckCharacters, "user_deck_characters", "user_deck_character_uuid", + func(v store.DeckCharacterState) []any { + return []any{v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion} + }, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version") + + // Decks (composite key) + for k, v := range after.Decks { + if old, ok := before.Decks[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?,?,?)`), + uid, int32(k.DeckType), k.UserDeckNumber, v.UserDeckCharacterUuid01, v.UserDeckCharacterUuid02, v.UserDeckCharacterUuid03, v.Name, v.Power, v.LatestVersion) + } + } + for k := range before.Decks { + if _, ok := after.Decks[k]; !ok { + exec(`DELETE FROM user_decks WHERE user_id=? AND deck_type=? AND user_deck_number=?`, uid, int32(k.DeckType), k.UserDeckNumber) + } + } + + // Slice-based tables: delete all + reinsert + replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) { + for i, uuid := range uuids { + exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) + } + }) + replaceSliceTable(tx, uid, "user_deck_parts", after.DeckParts, func(key string, uuids []string) { + for i, uuid := range uuids { + exec(`INSERT INTO user_deck_parts (user_id, user_deck_character_uuid, ordinal, user_parts_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) + } + }) + + diffMapInt32(tx, uid, before.Quests, after.Quests, "user_quests", "quest_id", + func(v store.UserQuestState) []any { + 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} + }, "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") + + // Quest missions (composite key) + for k, v := range after.QuestMissions { + if old, ok := before.QuestMissions[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?)`, + uid, k.QuestId, k.QuestMissionId, v.ProgressValue, boolToInt(v.IsClear), v.LatestClearDatetime, v.LatestVersion) + } + } + for k := range before.QuestMissions { + if _, ok := after.QuestMissions[k]; !ok { + exec(`DELETE FROM user_quest_missions WHERE user_id=? AND quest_id=? AND quest_mission_id=?`, uid, k.QuestId, k.QuestMissionId) + } + } + + diffMapInt32(tx, uid, before.Missions, after.Missions, "user_missions", "mission_id", + func(v store.UserMissionState) []any { + return []any{v.MissionId, v.StartDatetime, v.ProgressValue, v.MissionProgressStatusType, v.ClearDatetime, v.LatestVersion} + }, "mission_id, start_datetime, progress_value, mission_progress_status_type, clear_datetime, latest_version") + diffMapInt32(tx, uid, before.Tutorials, after.Tutorials, "user_tutorials", "tutorial_type", + func(v store.TutorialProgressState) []any { + return []any{v.TutorialType, v.ProgressPhase, v.ChoiceId, v.LatestVersion} + }, + "tutorial_type, progress_phase, choice_id, latest_version") + + diffMapInt32(tx, uid, before.SideStoryQuests, after.SideStoryQuests, "user_side_story_quests", "side_story_quest_id", + func(v store.SideStoryQuestProgress) []any { + return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} + }, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version") + diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id", + func(v store.QuestLimitContentStatus) []any { + return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} + }, "limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version") + diffMapInt32(tx, uid, before.WeaponStories, after.WeaponStories, "user_weapon_stories", "weapon_id", + func(v store.WeaponStoryState) []any { + return []any{v.WeaponId, v.ReleasedMaxStoryIndex, v.LatestVersion} + }, + "weapon_id, released_max_story_index, latest_version") + diffMapInt32(tx, uid, before.WeaponNotes, after.WeaponNotes, "user_weapon_notes", "weapon_id", + func(v store.WeaponNoteState) []any { + return []any{v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion} + }, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version") + + // Weapon skills/abilities: slice-based, delete+reinsert + exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid) + for _, skills := range after.WeaponSkills { + for _, v := range skills { + exec(`INSERT INTO user_weapon_skills (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, uid, v.UserWeaponUuid, v.SlotNumber, v.Level) + } + } + exec(`DELETE FROM user_weapon_abilities WHERE user_id=?`, uid) + for _, abilities := range after.WeaponAbilities { + for _, v := range abilities { + exec(`INSERT INTO user_weapon_abilities (user_id, user_weapon_uuid, slot_number, level) VALUES (?,?,?,?)`, uid, v.UserWeaponUuid, v.SlotNumber, v.Level) + } + } + + diffMapStr(tx, uid, before.WeaponAwakens, after.WeaponAwakens, "user_weapon_awakens", "user_weapon_uuid", + func(v store.WeaponAwakenState) []any { return []any{v.UserWeaponUuid, v.LatestVersion} }, + "user_weapon_uuid, latest_version") + diffMapStr(tx, uid, before.CostumeActiveSkills, after.CostumeActiveSkills, "user_costume_active_skills", "user_costume_uuid", + func(v store.CostumeActiveSkillState) []any { + return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion} + }, "user_costume_uuid, level, acquisition_datetime, latest_version") + + // Costume awaken status ups (composite key) + for k, v := range after.CostumeAwakenStatusUps { + if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?,?,?,?)`, + uid, k.UserCostumeUuid, int32(k.StatusCalculationType), v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion) + } + } + for k := range before.CostumeAwakenStatusUps { + if _, ok := after.CostumeAwakenStatusUps[k]; !ok { + 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)) + } + } + + for k, v := range after.CostumeLotteryEffects { + if old, ok := before.CostumeLotteryEffects[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_costume_lottery_effects (user_id, user_costume_uuid, slot_number, odds_number, latest_version) VALUES (?,?,?,?,?)`, + uid, k.UserCostumeUuid, k.SlotNumber, v.OddsNumber, v.LatestVersion) + } + } + for k := range before.CostumeLotteryEffects { + if _, ok := after.CostumeLotteryEffects[k]; !ok { + exec(`DELETE FROM user_costume_lottery_effects WHERE user_id=? AND user_costume_uuid=? AND slot_number=?`, uid, k.UserCostumeUuid, k.SlotNumber) + } + } + + diffMapStr(tx, uid, before.CostumeLotteryEffectPending, after.CostumeLotteryEffectPending, "user_costume_lottery_effect_pending", "user_costume_uuid", + func(v store.CostumeLotteryEffectPendingState) []any { + return []any{v.UserCostumeUuid, v.SlotNumber, v.OddsNumber, v.LatestVersion} + }, "user_costume_uuid, slot_number, odds_number, latest_version") + + diffMapStr(tx, uid, before.Parts, after.Parts, "user_parts", "user_parts_uuid", + func(v store.PartsState) []any { + return []any{v.UserPartsUuid, v.PartsId, v.Level, v.PartsStatusMainId, boolToInt(v.IsProtected), v.AcquisitionDatetime, v.LatestVersion} + }, "user_parts_uuid, parts_id, level, parts_status_main_id, is_protected, acquisition_datetime, latest_version") + diffMapInt32(tx, uid, before.PartsGroupNotes, after.PartsGroupNotes, "user_parts_group_notes", "parts_group_id", + func(v store.PartsGroupNoteState) []any { + return []any{v.PartsGroupId, v.FirstAcquisitionDatetime, v.LatestVersion} + }, + "parts_group_id, first_acquisition_datetime, latest_version") + diffMapInt32(tx, uid, before.PartsPresets, after.PartsPresets, "user_parts_presets", "user_parts_preset_number", + func(v store.PartsPresetState) []any { + return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion} + }, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version") + + // Deck type notes (key is model.DeckType which is int32-based) + for k, v := range after.DeckTypeNotes { + if old, ok := before.DeckTypeNotes[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, + uid, int32(k), v.MaxDeckPower, v.LatestVersion) + } + } + for k := range before.DeckTypeNotes { + if _, ok := after.DeckTypeNotes[k]; !ok { + exec(`DELETE FROM user_deck_type_notes WHERE user_id=? AND deck_type=?`, uid, int32(k)) + } + } + + diffSimpleMap(tx, uid, before.ConsumableItems, after.ConsumableItems, "user_consumable_items", "consumable_item_id", "count") + diffSimpleMap(tx, uid, before.Materials, after.Materials, "user_materials", "material_id", "count") + diffSimpleMap(tx, uid, before.ImportantItems, after.ImportantItems, "user_important_items", "important_item_id", "count") + diffInt64Map(tx, uid, before.PremiumItems, after.PremiumItems, "user_premium_items", "premium_item_id", "count") + + diffMapInt32(tx, uid, before.ExploreScores, after.ExploreScores, "user_explore_scores", "explore_id", + func(v store.ExploreScoreState) []any { + return []any{v.ExploreId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} + }, + "explore_id, max_score, max_score_update_datetime, latest_version") + diffMapInt32(tx, uid, before.AutoSaleSettings, after.AutoSaleSettings, "user_auto_sale_settings", "possession_auto_sale_item_type", + func(v store.AutoSaleSettingState) []any { + return []any{v.PossessionAutoSaleItemType, v.PossessionAutoSaleItemValue} + }, + "possession_auto_sale_item_type, possession_auto_sale_item_value") + diffBoolMap(tx, uid, before.NaviCutInPlayed, after.NaviCutInPlayed, "user_navi_cutin_played", "navi_cutin_id") + diffTimestampMap(tx, uid, before.ViewedMovies, after.ViewedMovies, "user_viewed_movies", "movie_id") + diffTimestampMap(tx, uid, before.ContentsStories, after.ContentsStories, "user_contents_stories", "contents_story_id") + diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id") + diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id") + + // Gifts: delete all + reinsert + exec(`DELETE FROM user_gifts WHERE user_id=?`, uid) + for _, g := range after.Gifts.NotReceived { + var expDt sql.NullInt64 + if g.ExpirationDatetime != 0 { + expDt = sql.NullInt64{Int64: g.ExpirationDatetime, Valid: true} + } + 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,?,?,?,?,?,?,?)`, + uid, g.UserGiftUuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, expDt) + } + for i, g := range after.Gifts.Received { + uuid := fmt.Sprintf("received-%d-%d", uid, i) + 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,?,?,?,?,?,?,?)`, + uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime) + } + + // Gacha converted medals: delete+reinsert + exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid) + for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { + exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count) + } + + // Gacha banners + for id, v := range after.Gacha.BannerStates { + 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 { + exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, + uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber) + } + // Box drew counts: always delete+reinsert for this gacha + exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) + for itemId, count := range v.BoxDrewCounts { + exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count) + } + } + for id := range before.Gacha.BannerStates { + if _, ok := after.Gacha.BannerStates[id]; !ok { + exec(`DELETE FROM user_gacha_banners WHERE user_id=? AND gacha_id=?`, uid, id) + exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) + } + } + + diffMapInt32(tx, uid, before.CharacterBoards, after.CharacterBoards, "user_character_boards", "character_board_id", + func(v store.CharacterBoardState) []any { + return []any{v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion} + }, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version") + + // Character board abilities (composite key) + for k, v := range after.CharacterBoardAbilities { + if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, + uid, k.CharacterId, k.AbilityId, v.Level, v.LatestVersion) + } + } + for k := range before.CharacterBoardAbilities { + if _, ok := after.CharacterBoardAbilities[k]; !ok { + exec(`DELETE FROM user_character_board_abilities WHERE user_id=? AND character_id=? AND ability_id=?`, uid, k.CharacterId, k.AbilityId) + } + } + + // Character board status ups (composite key) + for k, v := range after.CharacterBoardStatusUps { + if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?,?,?,?)`, + uid, k.CharacterId, k.StatusCalculationType, v.Hp, v.Attack, v.Vitality, v.Agility, v.CriticalRatio, v.CriticalAttack, v.LatestVersion) + } + } + for k := range before.CharacterBoardStatusUps { + if _, ok := after.CharacterBoardStatusUps[k]; !ok { + exec(`DELETE FROM user_character_board_status_ups WHERE user_id=? AND character_id=? AND status_calculation_type=?`, uid, k.CharacterId, k.StatusCalculationType) + } + } + + diffMapInt32(tx, uid, before.CharacterRebirths, after.CharacterRebirths, "user_character_rebirths", "character_id", + func(v store.CharacterRebirthState) []any { + return []any{v.CharacterId, v.RebirthCount, v.LatestVersion} + }, + "character_id, rebirth_count, latest_version") + diffMapInt32(tx, uid, before.CageOrnamentRewards, after.CageOrnamentRewards, "user_cage_ornament_rewards", "cage_ornament_id", + func(v store.CageOrnamentRewardState) []any { + return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion} + }, + "cage_ornament_id, acquisition_datetime, latest_version") + diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id", + func(v store.UserShopItemState) []any { + return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion} + }, "shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version") + diffMapInt32(tx, uid, before.ShopReplaceableLineup, after.ShopReplaceableLineup, "user_shop_replaceable_lineup", "slot_number", + func(v store.UserShopReplaceableLineupState) []any { + return []any{v.SlotNumber, v.ShopItemId, v.LatestVersion} + }, + "slot_number, shop_item_id, latest_version") + + // Gimmick tables (composite keys) + for k, v := range after.Gimmick.Progress { + if old, ok := before.Gimmick.Progress[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsGimmickCleared), v.StartDatetime, v.LatestVersion) + } + } + for k := range before.Gimmick.Progress { + if _, ok := after.Gimmick.Progress[k]; !ok { + exec(`DELETE FROM user_gimmick_progress WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=? AND gimmick_id=?`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId) + } + } + for k, v := range after.Gimmick.OrnamentProgress { + if old, ok := before.Gimmick.OrnamentProgress[k]; !ok || old != v { + 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 (?,?,?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex, v.ProgressValueBit, v.BaseDatetime, v.LatestVersion) + } + } + for k := range before.Gimmick.OrnamentProgress { + if _, ok := after.Gimmick.OrnamentProgress[k]; !ok { + 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=?`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, k.GimmickOrnamentIndex) + } + } + for k, v := range after.Gimmick.Sequences { + if old, ok := before.Gimmick.Sequences[k]; !ok || old != v { + 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 (?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, boolToInt(v.IsGimmickSequenceCleared), v.ClearDatetime, v.LatestVersion) + } + } + for k := range before.Gimmick.Sequences { + if _, ok := after.Gimmick.Sequences[k]; !ok { + exec(`DELETE FROM user_gimmick_sequences WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=?`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId) + } + } + for k, v := range after.Gimmick.Unlocks { + if old, ok := before.Gimmick.Unlocks[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_gimmick_unlocks (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_unlocked, latest_version) VALUES (?,?,?,?,?,?)`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId, boolToInt(v.IsUnlocked), v.LatestVersion) + } + } + for k := range before.Gimmick.Unlocks { + if _, ok := after.Gimmick.Unlocks[k]; !ok { + exec(`DELETE FROM user_gimmick_unlocks WHERE user_id=? AND gimmick_sequence_schedule_id=? AND gimmick_sequence_id=? AND gimmick_id=?`, + uid, k.GimmickSequenceScheduleId, k.GimmickSequenceId, k.GimmickId) + } + } + + // Big hunt maps + diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id", + func(v store.BigHuntMaxScore) []any { + return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} + }, + "big_hunt_boss_id, max_score, max_score_update_datetime, latest_version") + diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id", + func(v store.BigHuntStatus) []any { + return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion} + }, + "big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version") + + for k, v := range after.BigHuntScheduleMaxScores { + if old, ok := before.BigHuntScheduleMaxScores[k]; !ok || old != v { + 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 (?,?,?,?,?,?)`, + uid, k.BigHuntScheduleId, k.BigHuntBossId, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion) + } + } + for k := range before.BigHuntScheduleMaxScores { + if _, ok := after.BigHuntScheduleMaxScores[k]; !ok { + 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) + } + } + for k, v := range after.BigHuntWeeklyMaxScores { + if old, ok := before.BigHuntWeeklyMaxScores[k]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_big_hunt_weekly_max_scores (user_id, big_hunt_weekly_version, attribute_type, max_score, latest_version) VALUES (?,?,?,?,?)`, + uid, k.BigHuntWeeklyVersion, k.AttributeType, v.MaxScore, v.LatestVersion) + } + } + for k := range before.BigHuntWeeklyMaxScores { + if _, ok := after.BigHuntWeeklyMaxScores[k]; !ok { + 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) + } + } + for ver, v := range after.BigHuntWeeklyStatuses { + if old, ok := before.BigHuntWeeklyStatuses[ver]; !ok || old != v { + exec(`INSERT OR REPLACE INTO user_big_hunt_weekly_statuses (user_id, big_hunt_weekly_version, is_received_weekly_reward, latest_version) VALUES (?,?,?,?)`, + uid, ver, boolToInt(v.IsReceivedWeeklyReward), v.LatestVersion) + } + } + for ver := range before.BigHuntWeeklyStatuses { + if _, ok := after.BigHuntWeeklyStatuses[ver]; !ok { + exec(`DELETE FROM user_big_hunt_weekly_statuses WHERE user_id=? AND big_hunt_weekly_version=?`, uid, ver) + } + } + + return nil +} + +// Generic diff helpers for map tables with int32 keys +func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) { + for k, v := range after { + if old, ok := before[k]; !ok || old != v { + allVals := vals(v) + allVals[0] = k + args := append([]any{uid}, allVals...) + placeholders := "?" + for range allVals { + placeholders += ",?" + } + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s) VALUES (%s)`, table, cols, placeholders), args...) + } + } + for k := range before { + if _, ok := after[k]; !ok { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func diffMapStr[V comparable](tx *sql.Tx, uid int64, before, after map[string]V, table, keyCol string, vals func(V) []any, cols string) { + for k, v := range after { + if old, ok := before[k]; !ok || old != v { + allVals := vals(v) + args := append([]any{uid}, allVals...) + placeholders := "?" + for range allVals { + placeholders += ",?" + } + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s) VALUES (%s)`, table, cols, placeholders), args...) + } + } + for k := range before { + if _, ok := after[k]; !ok { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func diffSimpleMap(tx *sql.Tx, uid int64, before, after map[int32]int32, table, keyCol, valCol string) { + for k, v := range after { + if old, ok := before[k]; !ok || old != v { + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, %s) VALUES (?,?,?)`, table, keyCol, valCol), uid, k, v) + } + } + for k := range before { + if _, ok := after[k]; !ok { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func diffInt64Map(tx *sql.Tx, uid int64, before, after map[int32]int64, table, keyCol, valCol string) { + for k, v := range after { + if old, ok := before[k]; !ok || old != v { + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, %s) VALUES (?,?,?)`, table, keyCol, valCol), uid, k, v) + } + } + for k := range before { + if _, ok := after[k]; !ok { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func diffBoolMap(tx *sql.Tx, uid int64, before, after map[int32]bool, table, keyCol string) { + for k := range after { + if !before[k] { + tx.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (user_id, %s) VALUES (?,?)`, table, keyCol), uid, k) + } + } + for k := range before { + if !after[k] { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func diffTimestampMap(tx *sql.Tx, uid int64, before, after map[int32]int64, table, keyCol string) { + for k, v := range after { + if old, ok := before[k]; !ok || old != v { + tx.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (user_id, %s, timestamp) VALUES (?,?,?)`, table, keyCol), uid, k, v) + } + } + for k := range before { + if _, ok := after[k]; !ok { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=? AND %s=?`, table, keyCol), uid, k) + } + } +} + +func replaceSliceTable(tx *sql.Tx, uid int64, table string, data map[string][]string, insertFn func(string, []string)) { + tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id=?`, table), uid) + for key, vals := range data { + insertFn(key, vals) + } +} + +// suppress unused import +var _ = model.DeckTypeQuest diff --git a/server/internal/store/sqlite/session.go b/server/internal/store/sqlite/session.go new file mode 100644 index 0000000..8cda64b --- /dev/null +++ b/server/internal/store/sqlite/session.go @@ -0,0 +1,56 @@ +package sqlite + +import ( + "fmt" + "time" + + "lunar-tear/server/internal/store" +) + +func (s *SQLiteStore) CreateSession(uuid string, ttl time.Duration) (store.SessionState, error) { + var userId int64 + err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId) + if err != nil { + return store.SessionState{}, store.ErrNotFound + } + + now := s.clock() + sessionKey := fmt.Sprintf("session_%d_%d", userId, now.UnixNano()) + expireAt := now.Add(ttl) + + _, err = s.db.Exec( + `INSERT INTO sessions (session_key, user_id, uuid, expire_at) VALUES (?, ?, ?, ?)`, + sessionKey, userId, uuid, expireAt.Format(time.RFC3339Nano), + ) + if err != nil { + return store.SessionState{}, fmt.Errorf("insert session: %w", err) + } + + return store.SessionState{ + SessionKey: sessionKey, + UserId: userId, + Uuid: uuid, + ExpireAt: expireAt, + }, nil +} + +func (s *SQLiteStore) ResolveUserId(sessionKey string) (int64, error) { + var userId int64 + var expireStr string + err := s.db.QueryRow( + `SELECT user_id, expire_at FROM sessions WHERE session_key = ?`, sessionKey, + ).Scan(&userId, &expireStr) + if err != nil { + return 0, store.ErrNotFound + } + + expireAt, err := time.Parse(time.RFC3339Nano, expireStr) + if err != nil { + return 0, store.ErrNotFound + } + if s.clock().After(expireAt) { + return 0, store.ErrNotFound + } + + return userId, nil +} diff --git a/server/internal/store/sqlite/store.go b/server/internal/store/sqlite/store.go new file mode 100644 index 0000000..5d3997d --- /dev/null +++ b/server/internal/store/sqlite/store.go @@ -0,0 +1,25 @@ +package sqlite + +import ( + "database/sql" + "time" + + "lunar-tear/server/internal/store" +) + +type SQLiteStore struct { + db *sql.DB + clock store.Clock +} + +var ( + _ store.UserRepository = (*SQLiteStore)(nil) + _ store.SessionRepository = (*SQLiteStore)(nil) +) + +func New(db *sql.DB, clock store.Clock) *SQLiteStore { + if clock == nil { + clock = time.Now + } + return &SQLiteStore{db: db, clock: clock} +} diff --git a/server/internal/store/sqlite/user.go b/server/internal/store/sqlite/user.go new file mode 100644 index 0000000..e45d4b2 --- /dev/null +++ b/server/internal/store/sqlite/user.go @@ -0,0 +1,218 @@ +package sqlite + +import ( + "database/sql" + "fmt" + + "lunar-tear/server/internal/store" +) + +func (s *SQLiteStore) CreateUser(uuid string) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var existingId int64 + err = tx.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&existingId) + if err == nil { + return existingId, nil + } + + nowMillis := s.clock().UnixMilli() + + res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type, + register_datetime, game_start_datetime, latest_version, birth_year, birth_month, + backup_token, charge_money_this_month) VALUES (?, 0, 2, 2, 0, ?, ?, 0, 2000, 1, 'mock-backup-token', 0)`, + uuid, nowMillis, nowMillis) + if err != nil { + return 0, fmt.Errorf("insert user: %w", err) + } + userId, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("last insert id: %w", err) + } + + // player_id = user_id + if _, err := tx.Exec(`UPDATE users SET player_id = ? WHERE user_id = ?`, userId, userId); err != nil { + return 0, fmt.Errorf("update player_id: %w", err) + } + + user := store.SeedUserState(userId, uuid, nowMillis) + if err := writeUserState(tx, userId, user); err != nil { + return 0, fmt.Errorf("write seed state: %w", err) + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit: %w", err) + } + return userId, nil +} + +func (s *SQLiteStore) GetUserByUUID(uuid string) (int64, error) { + var userId int64 + err := s.db.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&userId) + if err == sql.ErrNoRows { + return 0, store.ErrNotFound + } + if err != nil { + return 0, fmt.Errorf("query user: %w", err) + } + return userId, nil +} + +func (s *SQLiteStore) DefaultUserId() (int64, error) { + var userId int64 + err := s.db.QueryRow(`SELECT min(user_id) FROM users`).Scan(&userId) + if err != nil || userId == 0 { + return 0, store.ErrNotFound + } + return userId, nil +} + +// ImportUser replaces all data for u.UserId in the database with the +// contents of u. Any pre-existing rows for that user are deleted first. +func (s *SQLiteStore) ImportUser(u *store.UserState) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + uid := u.UserId + + // Child tables in reverse-dependency order (matches schema's goose Down). + childTables := []string{ + "user_cage_ornament_rewards", + "user_shop_replaceable_lineup", + "user_shop_items", + "user_gacha_banner_box_drew_counts", + "user_gacha_banners", + "user_gacha_converted_medals", + "user_gifts", + "user_dokan_confirmed", + "user_drawn_omikuji", + "user_contents_stories", + "user_viewed_movies", + "user_navi_cutin_played", + "user_auto_sale_settings", + "user_explore_scores", + "user_tutorials", + "user_premium_items", + "user_important_items", + "user_materials", + "user_consumable_items", + "user_gimmick_unlocks", + "user_gimmick_sequences", + "user_gimmick_ornament_progress", + "user_gimmick_progress", + "user_big_hunt_weekly_statuses", + "user_big_hunt_weekly_max_scores", + "user_big_hunt_schedule_max_scores", + "user_big_hunt_statuses", + "user_big_hunt_max_scores", + "user_quest_limit_content_status", + "user_side_story_quests", + "user_missions", + "user_quest_missions", + "user_quests", + "user_deck_type_notes", + "user_deck_parts", + "user_deck_sub_weapons", + "user_decks", + "user_deck_characters", + "user_parts_presets", + "user_parts_group_notes", + "user_parts", + "user_thoughts", + "user_companions", + "user_weapon_notes", + "user_weapon_stories", + "user_weapon_awakens", + "user_weapon_abilities", + "user_weapon_skills", + "user_weapons", + "user_costume_awaken_status_ups", + "user_costume_active_skills", + "user_costumes", + "user_character_rebirths", + "user_character_board_status_ups", + "user_character_board_abilities", + "user_character_boards", + "user_characters", + "user_gacha", + "user_shop_replaceable", + "user_explore", + "user_guerrilla_free_open", + "user_portal_cage", + "user_notification", + "user_battle", + "user_big_hunt_state", + "user_side_story_active", + "user_extra_quest", + "user_event_quest", + "user_main_quest", + "user_login_bonus", + "user_login", + "user_profile", + "user_gem", + "user_status", + "user_setting", + "sessions", + } + + for _, t := range childTables { + if _, err := tx.Exec(fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, t), uid); err != nil { + return fmt.Errorf("delete from %s: %w", t, err) + } + } + if _, err := tx.Exec(`DELETE FROM users WHERE user_id = ?`, uid); err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if _, err := tx.Exec(`INSERT INTO users (user_id, uuid, player_id, os_type, platform_type, + user_restriction_type, register_datetime, game_start_datetime, latest_version, + birth_year, birth_month, backup_token, charge_money_this_month) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + uid, u.Uuid, u.PlayerId, u.OsType, u.PlatformType, u.UserRestrictionType, + u.RegisterDatetime, u.GameStartDatetime, u.LatestVersion, + u.BirthYear, u.BirthMonth, u.BackupToken, u.ChargeMoneyThisMonth); err != nil { + return fmt.Errorf("insert user: %w", err) + } + + if err := writeUserState(tx, uid, u); err != nil { + return fmt.Errorf("write user state: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} + +func (s *SQLiteStore) UpdateUser(userId int64, mutate func(*store.UserState)) (store.UserState, error) { + before, err := s.LoadUser(userId) + if err != nil { + return store.UserState{}, err + } + + after := store.CloneUserState(before) + mutate(&after) + + tx, err := s.db.Begin() + if err != nil { + return store.UserState{}, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + if err := diffAndSave(tx, userId, &before, &after); err != nil { + return store.UserState{}, fmt.Errorf("diff and save: %w", err) + } + + if err := tx.Commit(); err != nil { + return store.UserState{}, fmt.Errorf("commit: %w", err) + } + + return after, nil +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index edd2ad4..f75db72 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -10,18 +10,14 @@ var ErrNotFound = errors.New("store: not found") type Clock func() time.Time type UserRepository interface { - EnsureUser(uuid string) (UserState, error) - SnapshotUser(userId int64) (UserState, error) + CreateUser(uuid string) (int64, error) + GetUserByUUID(uuid string) (int64, error) + LoadUser(userId int64) (UserState, error) UpdateUser(userId int64, mutate func(*UserState)) (UserState, error) DefaultUserId() (int64, error) } type SessionRepository interface { - CreateSession(uuid string, ttl time.Duration) (UserState, SessionState, error) + CreateSession(uuid string, ttl time.Duration) (SessionState, error) ResolveUserId(sessionKey string) (int64, error) } - -type GachaRepository interface { - SnapshotCatalog() ([]GachaCatalogEntry, error) - ReplaceCatalog(entries []GachaCatalogEntry) error -} diff --git a/server/internal/store/types.go b/server/internal/store/types.go index 0c6cc5e..c1f3ab1 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -107,9 +107,11 @@ type UserState struct { CharacterBoardAbilities map[CharacterBoardAbilityKey]CharacterBoardAbilityState CharacterBoardStatusUps map[CharacterBoardStatusUpKey]CharacterBoardStatusUpState - CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState - AutoSaleSettings map[int32]AutoSaleSettingState - CharacterRebirths map[int32]CharacterRebirthState + CostumeAwakenStatusUps map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState + CostumeLotteryEffects map[CostumeLotteryEffectKey]CostumeLotteryEffectState + CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid + AutoSaleSettings map[int32]AutoSaleSettingState + CharacterRebirths map[int32]CharacterRebirthState } func (u *UserState) EnsureMaps() { @@ -254,6 +256,12 @@ func (u *UserState) EnsureMaps() { if u.CostumeAwakenStatusUps == nil { u.CostumeAwakenStatusUps = make(map[CostumeAwakenStatusKey]CostumeAwakenStatusUpState) } + if u.CostumeLotteryEffects == nil { + u.CostumeLotteryEffects = make(map[CostumeLotteryEffectKey]CostumeLotteryEffectState) + } + if u.CostumeLotteryEffectPending == nil { + u.CostumeLotteryEffectPending = make(map[string]CostumeLotteryEffectPendingState) + } if u.AutoSaleSettings == nil { u.AutoSaleSettings = make(map[int32]AutoSaleSettingState) } @@ -358,15 +366,16 @@ type CharacterState struct { } type CostumeState struct { - UserCostumeUuid string - CostumeId int32 - LimitBreakCount int32 - Level int32 - Exp int32 - HeadupDisplayViewId int32 - AcquisitionDatetime int64 - AwakenCount int32 - LatestVersion int64 + UserCostumeUuid string + CostumeId int32 + LimitBreakCount int32 + Level int32 + Exp int32 + HeadupDisplayViewId int32 + AcquisitionDatetime int64 + AwakenCount int32 + CostumeLotteryEffectUnlockedSlotCount int32 + LatestVersion int64 } type WeaponState struct { @@ -1070,3 +1079,41 @@ type CharacterRebirthState struct { RebirthCount int32 LatestVersion int64 } + +type CostumeLotteryEffectKey struct { + UserCostumeUuid string + SlotNumber int32 +} + +func (k CostumeLotteryEffectKey) MarshalText() ([]byte, error) { + return fmt.Appendf(nil, "%s:%d", k.UserCostumeUuid, k.SlotNumber), nil +} + +func (k *CostumeLotteryEffectKey) UnmarshalText(text []byte) error { + s := string(text) + idx := strings.LastIndex(s, ":") + if idx < 0 { + return fmt.Errorf("invalid CostumeLotteryEffectKey: %s", text) + } + k.UserCostumeUuid = s[:idx] + v, err := strconv.ParseInt(s[idx+1:], 10, 32) + if err != nil { + return err + } + k.SlotNumber = int32(v) + return nil +} + +type CostumeLotteryEffectState struct { + UserCostumeUuid string + SlotNumber int32 + OddsNumber int32 + LatestVersion int64 +} + +type CostumeLotteryEffectPendingState struct { + UserCostumeUuid string + SlotNumber int32 + OddsNumber int32 + LatestVersion int64 +} diff --git a/server/internal/userdata/proj_inventory.go b/server/internal/userdata/proj_inventory.go index c11be8f..3b27ef9 100644 --- a/server/internal/userdata/proj_inventory.go +++ b/server/internal/userdata/proj_inventory.go @@ -105,12 +105,18 @@ func init() { s, _ := encodeJSONMaps(SortedWeaponAwakenRecords(user)...) return s }) + register("IUserCostumeLotteryEffect", func(user store.UserState) string { + s, _ := encodeJSONMaps(sortedCostumeLotteryEffectRecords(user)...) + return s + }) + register("IUserCostumeLotteryEffectPending", func(user store.UserState) string { + s, _ := encodeJSONMaps(SortedCostumeLotteryEffectPendingRecords(user)...) + return s + }) registerStatic( "IUserCostumeLevelBonusReleaseStatus", - "IUserCostumeLotteryEffect", "IUserCostumeLotteryEffectAbility", "IUserCostumeLotteryEffectStatusUp", - "IUserCostumeLotteryEffectPending", "IUserPartsPresetTag", "IUserPartsStatusSub", ) @@ -143,16 +149,17 @@ func sortedCostumeRecords(user store.UserState) []map[string]any { for _, key := range keys { row := user.Costumes[key] records = append(records, map[string]any{ - "userId": user.UserId, - "userCostumeUuid": row.UserCostumeUuid, - "costumeId": row.CostumeId, - "limitBreakCount": row.LimitBreakCount, - "level": row.Level, - "exp": row.Exp, - "headupDisplayViewId": row.HeadupDisplayViewId, - "acquisitionDatetime": row.AcquisitionDatetime, - "awakenCount": row.AwakenCount, - "latestVersion": row.LatestVersion, + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "costumeId": row.CostumeId, + "limitBreakCount": row.LimitBreakCount, + "level": row.Level, + "exp": row.Exp, + "headupDisplayViewId": row.HeadupDisplayViewId, + "acquisitionDatetime": row.AcquisitionDatetime, + "awakenCount": row.AwakenCount, + "costumeLotteryEffectUnlockedSlotCount": row.CostumeLotteryEffectUnlockedSlotCount, + "latestVersion": row.LatestVersion, }) } return records @@ -619,3 +626,44 @@ func sortedCageOrnamentRewardRecords(user store.UserState) []map[string]any { } return records } + +func SortedCostumeLotteryEffectPendingRecords(user store.UserState) []map[string]any { + keys := sortedStringKeys(user.CostumeLotteryEffectPending) + records := make([]map[string]any, 0, len(keys)) + for _, key := range keys { + row := user.CostumeLotteryEffectPending[key] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "slotNumber": row.SlotNumber, + "oddsNumber": row.OddsNumber, + "latestVersion": row.LatestVersion, + }) + } + return records +} + +func sortedCostumeLotteryEffectRecords(user store.UserState) []map[string]any { + keys := make([]store.CostumeLotteryEffectKey, 0, len(user.CostumeLotteryEffects)) + for k := range user.CostumeLotteryEffects { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].UserCostumeUuid != keys[j].UserCostumeUuid { + return keys[i].UserCostumeUuid < keys[j].UserCostumeUuid + } + return keys[i].SlotNumber < keys[j].SlotNumber + }) + records := make([]map[string]any, 0, len(keys)) + for _, k := range keys { + row := user.CostumeLotteryEffects[k] + records = append(records, map[string]any{ + "userId": user.UserId, + "userCostumeUuid": row.UserCostumeUuid, + "slotNumber": row.SlotNumber, + "oddsNumber": row.OddsNumber, + "latestVersion": row.LatestVersion, + }) + } + return records +} diff --git a/server/internal/userdata/state_projection.go b/server/internal/userdata/state_projection.go index c57f643..a852001 100644 --- a/server/internal/userdata/state_projection.go +++ b/server/internal/userdata/state_projection.go @@ -138,6 +138,14 @@ func SelectTables(all map[string]string, requested []string) map[string]string { return selected } +func ProjectTables(user store.UserState, requested []string) map[string]string { + result := make(map[string]string, len(requested)) + for _, table := range requested { + result[table] = projectTable(table, user) + } + return result +} + func BuildDiffFromTables(tables map[string]string) map[string]*pb.DiffData { diff := make(map[string]*pb.DiffData, len(tables)) for table, payload := range tables { diff --git a/server/migrations/20260416182710_initial_schema.sql b/server/migrations/20260416182710_initial_schema.sql new file mode 100644 index 0000000..4da1b94 --- /dev/null +++ b/server/migrations/20260416182710_initial_schema.sql @@ -0,0 +1,874 @@ +-- +goose Up + +PRAGMA foreign_keys = ON; + +-- ============================================================================= +-- 1. Identity and Sessions +-- ============================================================================= + +CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + uuid TEXT NOT NULL UNIQUE, + player_id INTEGER NOT NULL DEFAULT 0, + os_type INTEGER NOT NULL DEFAULT 0, + platform_type INTEGER NOT NULL DEFAULT 0, + user_restriction_type INTEGER NOT NULL DEFAULT 0, + register_datetime INTEGER NOT NULL DEFAULT 0, + game_start_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + birth_year INTEGER NOT NULL DEFAULT 0, + birth_month INTEGER NOT NULL DEFAULT 0, + backup_token TEXT NOT NULL DEFAULT '', + charge_money_this_month INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE sessions ( + session_key TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(user_id), + uuid TEXT NOT NULL, + expire_at TEXT NOT NULL +); + +-- ============================================================================= +-- 1b. Per-User 1:1 State Tables +-- ============================================================================= + +CREATE TABLE user_setting ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + is_notify_purchase_alert INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_status ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + level INTEGER NOT NULL DEFAULT 0, + exp INTEGER NOT NULL DEFAULT 0, + stamina_milli_value INTEGER NOT NULL DEFAULT 0, + stamina_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_gem ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + paid_gem INTEGER NOT NULL DEFAULT 0, + free_gem INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_profile ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + name TEXT NOT NULL DEFAULT '', + name_update_datetime INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + message_update_datetime INTEGER NOT NULL DEFAULT 0, + favorite_costume_id INTEGER NOT NULL DEFAULT 0, + favorite_costume_id_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_login ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + total_login_count INTEGER NOT NULL DEFAULT 0, + continual_login_count INTEGER NOT NULL DEFAULT 0, + max_continual_login_count INTEGER NOT NULL DEFAULT 0, + last_login_datetime INTEGER NOT NULL DEFAULT 0, + last_comeback_login_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_login_bonus ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + login_bonus_id INTEGER NOT NULL DEFAULT 0, + current_page_number INTEGER NOT NULL DEFAULT 0, + current_stamp_number INTEGER NOT NULL DEFAULT 0, + latest_reward_receive_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_main_quest ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + current_quest_flow_type INTEGER NOT NULL DEFAULT 0, + current_main_quest_route_id INTEGER NOT NULL DEFAULT 0, + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, + is_reached_last_quest_scene INTEGER NOT NULL DEFAULT 0, + progress_quest_scene_id INTEGER NOT NULL DEFAULT 0, + progress_head_quest_scene_id INTEGER NOT NULL DEFAULT 0, + progress_quest_flow_type INTEGER NOT NULL DEFAULT 0, + main_quest_season_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + saved_current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + saved_head_quest_scene_id INTEGER NOT NULL DEFAULT 0, + replay_flow_current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + replay_flow_head_quest_scene_id INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_event_quest ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + current_event_quest_chapter_id INTEGER NOT NULL DEFAULT 0, + current_quest_id INTEGER NOT NULL DEFAULT 0, + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_extra_quest ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + current_quest_id INTEGER NOT NULL DEFAULT 0, + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + head_quest_scene_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_side_story_active ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + current_side_story_quest_id INTEGER NOT NULL DEFAULT 0, + current_side_story_quest_scene_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_big_hunt_state ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + current_big_hunt_boss_quest_id INTEGER NOT NULL DEFAULT 0, + current_big_hunt_quest_id INTEGER NOT NULL DEFAULT 0, + current_quest_scene_id INTEGER NOT NULL DEFAULT 0, + is_dry_run INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + deck_type INTEGER NOT NULL DEFAULT 0, + user_triple_deck_number INTEGER NOT NULL DEFAULT 0, + boss_knock_down_count INTEGER NOT NULL DEFAULT 0, + max_combo_count INTEGER NOT NULL DEFAULT 0, + total_damage INTEGER NOT NULL DEFAULT 0, + deck_number INTEGER NOT NULL DEFAULT 0, + battle_binary BLOB +); + +CREATE TABLE user_battle ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + is_active INTEGER NOT NULL DEFAULT 0, + start_count INTEGER NOT NULL DEFAULT 0, + finish_count INTEGER NOT NULL DEFAULT 0, + last_started_at INTEGER NOT NULL DEFAULT 0, + last_finished_at INTEGER NOT NULL DEFAULT 0, + last_user_party_count INTEGER NOT NULL DEFAULT 0, + last_npc_party_count INTEGER NOT NULL DEFAULT 0, + last_battle_binary_size INTEGER NOT NULL DEFAULT 0, + last_elapsed_frame_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_notification ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + gift_not_receive_count INTEGER NOT NULL DEFAULT 0, + friend_request_receive_count INTEGER NOT NULL DEFAULT 0, + is_exist_unread_information INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_portal_cage ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + is_current_progress INTEGER NOT NULL DEFAULT 0, + drop_item_start_datetime INTEGER NOT NULL DEFAULT 0, + current_drop_item_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_guerrilla_free_open ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + start_datetime INTEGER NOT NULL DEFAULT 0, + open_minutes INTEGER NOT NULL DEFAULT 0, + daily_opened_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_explore ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + is_use_explore_ticket INTEGER NOT NULL DEFAULT 0, + playing_explore_id INTEGER NOT NULL DEFAULT 0, + latest_play_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_shop_replaceable ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + lineup_update_count INTEGER NOT NULL DEFAULT 0, + latest_lineup_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE user_gacha ( + user_id INTEGER PRIMARY KEY REFERENCES users(user_id), + reward_available INTEGER NOT NULL DEFAULT 0, + todays_current_draw_count INTEGER NOT NULL DEFAULT 0, + daily_max_count INTEGER NOT NULL DEFAULT 0, + last_reward_draw_date INTEGER NOT NULL DEFAULT 0, + obtain_consumable_item_id INTEGER, + obtain_count INTEGER +); + +-- ============================================================================= +-- 2. Characters and Progression +-- ============================================================================= + +CREATE TABLE user_characters ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + character_id INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + exp INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, character_id) +); + +CREATE TABLE user_character_boards ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + character_board_id INTEGER NOT NULL, + panel_release_bit1 INTEGER NOT NULL DEFAULT 0, + panel_release_bit2 INTEGER NOT NULL DEFAULT 0, + panel_release_bit3 INTEGER NOT NULL DEFAULT 0, + panel_release_bit4 INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, character_board_id) +); + +CREATE TABLE user_character_board_abilities ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + character_id INTEGER NOT NULL, + ability_id INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, character_id, ability_id) +); + +CREATE TABLE user_character_board_status_ups ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + character_id INTEGER NOT NULL, + status_calculation_type INTEGER NOT NULL, + hp INTEGER NOT NULL DEFAULT 0, + attack INTEGER NOT NULL DEFAULT 0, + vitality INTEGER NOT NULL DEFAULT 0, + agility INTEGER NOT NULL DEFAULT 0, + critical_ratio INTEGER NOT NULL DEFAULT 0, + critical_attack INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, character_id, status_calculation_type) +); + +CREATE TABLE user_character_rebirths ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + character_id INTEGER NOT NULL, + rebirth_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, character_id) +); + +-- ============================================================================= +-- 3. Equipment (UUID-keyed) +-- ============================================================================= + +CREATE TABLE user_costumes ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_costume_uuid TEXT NOT NULL, + costume_id INTEGER NOT NULL, + limit_break_count INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 0, + exp INTEGER NOT NULL DEFAULT 0, + headup_display_view_id INTEGER NOT NULL DEFAULT 0, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + awaken_count INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_costume_uuid) +); + +CREATE TABLE user_costume_active_skills ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_costume_uuid TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_costume_uuid) +); + +CREATE TABLE user_costume_awaken_status_ups ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_costume_uuid TEXT NOT NULL, + status_calculation_type INTEGER NOT NULL, + hp INTEGER NOT NULL DEFAULT 0, + attack INTEGER NOT NULL DEFAULT 0, + vitality INTEGER NOT NULL DEFAULT 0, + agility INTEGER NOT NULL DEFAULT 0, + critical_ratio INTEGER NOT NULL DEFAULT 0, + critical_attack INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_costume_uuid, status_calculation_type) +); + +CREATE TABLE user_weapons ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_weapon_uuid TEXT NOT NULL, + weapon_id INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + exp INTEGER NOT NULL DEFAULT 0, + limit_break_count INTEGER NOT NULL DEFAULT 0, + is_protected INTEGER NOT NULL DEFAULT 0, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_weapon_uuid) +); + +CREATE TABLE user_weapon_skills ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_weapon_uuid TEXT NOT NULL, + slot_number INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_weapon_uuid, slot_number) +); + +CREATE TABLE user_weapon_abilities ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_weapon_uuid TEXT NOT NULL, + slot_number INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_weapon_uuid, slot_number) +); + +CREATE TABLE user_weapon_awakens ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_weapon_uuid TEXT NOT NULL, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_weapon_uuid) +); + +CREATE TABLE user_weapon_stories ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + weapon_id INTEGER NOT NULL, + released_max_story_index INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, weapon_id) +); + +CREATE TABLE user_weapon_notes ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + weapon_id INTEGER NOT NULL, + max_level INTEGER NOT NULL DEFAULT 0, + max_limit_break_count INTEGER NOT NULL DEFAULT 0, + first_acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, weapon_id) +); + +CREATE TABLE user_companions ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_companion_uuid TEXT NOT NULL, + companion_id INTEGER NOT NULL, + headup_display_view_id INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 0, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_companion_uuid) +); + +CREATE TABLE user_thoughts ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_thought_uuid TEXT NOT NULL, + thought_id INTEGER NOT NULL, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_thought_uuid) +); + +CREATE TABLE user_parts ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_parts_uuid TEXT NOT NULL, + parts_id INTEGER NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + parts_status_main_id INTEGER NOT NULL DEFAULT 0, + is_protected INTEGER NOT NULL DEFAULT 0, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_parts_uuid) +); + +CREATE TABLE user_parts_group_notes ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + parts_group_id INTEGER NOT NULL, + first_acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, parts_group_id) +); + +CREATE TABLE user_parts_presets ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_parts_preset_number INTEGER NOT NULL, + user_parts_uuid01 TEXT NOT NULL DEFAULT '', + user_parts_uuid02 TEXT NOT NULL DEFAULT '', + user_parts_uuid03 TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + user_parts_preset_tag_number INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_parts_preset_number) +); + +-- ============================================================================= +-- 4. Deck System +-- ============================================================================= + +CREATE TABLE user_deck_characters ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_deck_character_uuid TEXT NOT NULL, + user_costume_uuid TEXT NOT NULL DEFAULT '', + main_user_weapon_uuid TEXT NOT NULL DEFAULT '', + user_companion_uuid TEXT NOT NULL DEFAULT '', + power INTEGER NOT NULL DEFAULT 0, + user_thought_uuid TEXT NOT NULL DEFAULT '', + dressup_costume_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_deck_character_uuid) +); + +CREATE TABLE user_decks ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + deck_type INTEGER NOT NULL, + user_deck_number INTEGER NOT NULL, + user_deck_character_uuid01 TEXT NOT NULL DEFAULT '', + user_deck_character_uuid02 TEXT NOT NULL DEFAULT '', + user_deck_character_uuid03 TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + power INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, deck_type, user_deck_number) +); + +CREATE TABLE user_deck_sub_weapons ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_deck_character_uuid TEXT NOT NULL, + ordinal INTEGER NOT NULL, + user_weapon_uuid TEXT NOT NULL, + PRIMARY KEY (user_id, user_deck_character_uuid, ordinal) +); + +CREATE TABLE user_deck_parts ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_deck_character_uuid TEXT NOT NULL, + ordinal INTEGER NOT NULL, + user_parts_uuid TEXT NOT NULL, + PRIMARY KEY (user_id, user_deck_character_uuid, ordinal) +); + +CREATE TABLE user_deck_type_notes ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + deck_type INTEGER NOT NULL, + max_deck_power INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, deck_type) +); + +-- ============================================================================= +-- 5. Quests +-- ============================================================================= + +CREATE TABLE user_quests ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + quest_id INTEGER NOT NULL, + quest_state_type INTEGER NOT NULL DEFAULT 0, + is_battle_only INTEGER NOT NULL DEFAULT 0, + user_deck_number INTEGER NOT NULL DEFAULT 0, + latest_start_datetime INTEGER NOT NULL DEFAULT 0, + clear_count INTEGER NOT NULL DEFAULT 0, + daily_clear_count INTEGER NOT NULL DEFAULT 0, + last_clear_datetime INTEGER NOT NULL DEFAULT 0, + shortest_clear_frames INTEGER NOT NULL DEFAULT 0, + is_reward_granted INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, quest_id) +); + +CREATE TABLE user_quest_missions ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + quest_id INTEGER NOT NULL, + quest_mission_id INTEGER NOT NULL, + progress_value INTEGER NOT NULL DEFAULT 0, + is_clear INTEGER NOT NULL DEFAULT 0, + latest_clear_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, quest_id, quest_mission_id) +); + +CREATE TABLE user_missions ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + mission_id INTEGER NOT NULL, + start_datetime INTEGER NOT NULL DEFAULT 0, + progress_value INTEGER NOT NULL DEFAULT 0, + mission_progress_status_type INTEGER NOT NULL DEFAULT 0, + clear_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, mission_id) +); + +CREATE TABLE user_side_story_quests ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + side_story_quest_id INTEGER NOT NULL, + head_side_story_quest_scene_id INTEGER NOT NULL DEFAULT 0, + side_story_quest_state_type INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, side_story_quest_id) +); + +CREATE TABLE user_quest_limit_content_status ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + limit_content_id INTEGER NOT NULL, + limit_content_quest_status_type INTEGER NOT NULL DEFAULT 0, + event_quest_chapter_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, limit_content_id) +); + +-- ============================================================================= +-- 6. Big Hunt +-- ============================================================================= + +CREATE TABLE user_big_hunt_max_scores ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + big_hunt_boss_id INTEGER NOT NULL, + max_score INTEGER NOT NULL DEFAULT 0, + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, big_hunt_boss_id) +); + +CREATE TABLE user_big_hunt_statuses ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + big_hunt_boss_id INTEGER NOT NULL, + daily_challenge_count INTEGER NOT NULL DEFAULT 0, + latest_challenge_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, big_hunt_boss_id) +); + +CREATE TABLE user_big_hunt_schedule_max_scores ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + big_hunt_schedule_id INTEGER NOT NULL, + big_hunt_boss_id INTEGER NOT NULL, + max_score INTEGER NOT NULL DEFAULT 0, + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, big_hunt_schedule_id, big_hunt_boss_id) +); + +CREATE TABLE user_big_hunt_weekly_max_scores ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + big_hunt_weekly_version INTEGER NOT NULL, + attribute_type INTEGER NOT NULL, + max_score INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, big_hunt_weekly_version, attribute_type) +); + +CREATE TABLE user_big_hunt_weekly_statuses ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + big_hunt_weekly_version INTEGER NOT NULL, + is_received_weekly_reward INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, big_hunt_weekly_version) +); + +-- ============================================================================= +-- 7. Gimmicks +-- ============================================================================= + +CREATE TABLE user_gimmick_progress ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gimmick_sequence_schedule_id INTEGER NOT NULL, + gimmick_sequence_id INTEGER NOT NULL, + gimmick_id INTEGER NOT NULL, + is_gimmick_cleared INTEGER NOT NULL DEFAULT 0, + start_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id) +); + +CREATE TABLE user_gimmick_ornament_progress ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gimmick_sequence_schedule_id INTEGER NOT NULL, + gimmick_sequence_id INTEGER NOT NULL, + gimmick_id INTEGER NOT NULL, + gimmick_ornament_index INTEGER NOT NULL, + progress_value_bit INTEGER NOT NULL DEFAULT 0, + base_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, gimmick_ornament_index) +); + +CREATE TABLE user_gimmick_sequences ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gimmick_sequence_schedule_id INTEGER NOT NULL, + gimmick_sequence_id INTEGER NOT NULL, + is_gimmick_sequence_cleared INTEGER NOT NULL DEFAULT 0, + clear_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id) +); + +CREATE TABLE user_gimmick_unlocks ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gimmick_sequence_schedule_id INTEGER NOT NULL, + gimmick_sequence_id INTEGER NOT NULL, + gimmick_id INTEGER NOT NULL, + is_unlocked INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id) +); + +-- ============================================================================= +-- 8. Inventory +-- ============================================================================= + +CREATE TABLE user_consumable_items ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + consumable_item_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, consumable_item_id) +); + +CREATE TABLE user_materials ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + material_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, material_id) +); + +CREATE TABLE user_important_items ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + important_item_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, important_item_id) +); + +CREATE TABLE user_premium_items ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + premium_item_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, premium_item_id) +); + +CREATE TABLE user_tutorials ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + tutorial_type INTEGER NOT NULL, + progress_phase INTEGER NOT NULL DEFAULT 0, + choice_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, tutorial_type) +); + +CREATE TABLE user_explore_scores ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + explore_id INTEGER NOT NULL, + max_score INTEGER NOT NULL DEFAULT 0, + max_score_update_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, explore_id) +); + +CREATE TABLE user_auto_sale_settings ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + possession_auto_sale_item_type INTEGER NOT NULL, + possession_auto_sale_item_value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (user_id, possession_auto_sale_item_type) +); + +-- ============================================================================= +-- 9. Simple Progress Maps +-- ============================================================================= + +CREATE TABLE user_navi_cutin_played ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + navi_cutin_id INTEGER NOT NULL, + PRIMARY KEY (user_id, navi_cutin_id) +); + +CREATE TABLE user_viewed_movies ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + movie_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, movie_id) +); + +CREATE TABLE user_contents_stories ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + contents_story_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, contents_story_id) +); + +CREATE TABLE user_drawn_omikuji ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + omikuji_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, omikuji_id) +); + +CREATE TABLE user_dokan_confirmed ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + dokan_id INTEGER NOT NULL, + PRIMARY KEY (user_id, dokan_id) +); + +-- ============================================================================= +-- 10. Gifts +-- ============================================================================= + +CREATE TABLE user_gifts ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_gift_uuid TEXT NOT NULL, + is_received INTEGER NOT NULL DEFAULT 0, + possession_type INTEGER NOT NULL DEFAULT 0, + possession_id INTEGER NOT NULL DEFAULT 0, + count INTEGER NOT NULL DEFAULT 0, + grant_datetime INTEGER NOT NULL DEFAULT 0, + description_gift_text_id INTEGER NOT NULL DEFAULT 0, + equipment_data BLOB, + expiration_datetime INTEGER, + received_datetime INTEGER, + PRIMARY KEY (user_id, user_gift_uuid) +); + +-- ============================================================================= +-- 11. Gacha +-- ============================================================================= + +CREATE TABLE user_gacha_converted_medals ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + ordinal INTEGER NOT NULL, + consumable_item_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, ordinal) +); + +CREATE TABLE user_gacha_banners ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gacha_id INTEGER NOT NULL, + medal_count INTEGER NOT NULL DEFAULT 0, + step_number INTEGER NOT NULL DEFAULT 0, + loop_count INTEGER NOT NULL DEFAULT 0, + draw_count INTEGER NOT NULL DEFAULT 0, + box_number INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gacha_id) +); + +CREATE TABLE user_gacha_banner_box_drew_counts ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + gacha_id INTEGER NOT NULL, + box_item_id INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, gacha_id, box_item_id) +); + +-- ============================================================================= +-- 12. Shop +-- ============================================================================= + +CREATE TABLE user_shop_items ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + shop_item_id INTEGER NOT NULL, + bought_count INTEGER NOT NULL DEFAULT 0, + latest_bought_count_changed_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, shop_item_id) +); + +CREATE TABLE user_shop_replaceable_lineup ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + slot_number INTEGER NOT NULL, + shop_item_id INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, slot_number) +); + +-- ============================================================================= +-- 13. Cage Ornaments +-- ============================================================================= + +CREATE TABLE user_cage_ornament_rewards ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + cage_ornament_id INTEGER NOT NULL, + acquisition_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, cage_ornament_id) +); + +-- +goose Down + +DROP TABLE IF EXISTS user_cage_ornament_rewards ; +DROP TABLE IF EXISTS user_shop_replaceable_lineup ; +DROP TABLE IF EXISTS user_shop_items ; +DROP TABLE IF EXISTS user_gacha_banner_box_drew_counts; +DROP TABLE IF EXISTS user_gacha_banners ; +DROP TABLE IF EXISTS user_gacha_converted_medals ; +DROP TABLE IF EXISTS user_gifts ; +DROP TABLE IF EXISTS user_dokan_confirmed ; +DROP TABLE IF EXISTS user_drawn_omikuji ; +DROP TABLE IF EXISTS user_contents_stories ; +DROP TABLE IF EXISTS user_viewed_movies ; +DROP TABLE IF EXISTS user_navi_cutin_played ; +DROP TABLE IF EXISTS user_auto_sale_settings ; +DROP TABLE IF EXISTS user_explore_scores ; +DROP TABLE IF EXISTS user_tutorials ; +DROP TABLE IF EXISTS user_premium_items ; +DROP TABLE IF EXISTS user_important_items ; +DROP TABLE IF EXISTS user_materials ; +DROP TABLE IF EXISTS user_consumable_items ; +DROP TABLE IF EXISTS user_gimmick_unlocks ; +DROP TABLE IF EXISTS user_gimmick_sequences ; +DROP TABLE IF EXISTS user_gimmick_ornament_progress ; +DROP TABLE IF EXISTS user_gimmick_progress ; +DROP TABLE IF EXISTS user_big_hunt_weekly_statuses ; +DROP TABLE IF EXISTS user_big_hunt_weekly_max_scores ; +DROP TABLE IF EXISTS user_big_hunt_schedule_max_scores; +DROP TABLE IF EXISTS user_big_hunt_statuses ; +DROP TABLE IF EXISTS user_big_hunt_max_scores ; +DROP TABLE IF EXISTS user_quest_limit_content_status ; +DROP TABLE IF EXISTS user_side_story_quests ; +DROP TABLE IF EXISTS user_missions ; +DROP TABLE IF EXISTS user_quest_missions ; +DROP TABLE IF EXISTS user_quests ; +DROP TABLE IF EXISTS user_deck_type_notes ; +DROP TABLE IF EXISTS user_deck_parts ; +DROP TABLE IF EXISTS user_deck_sub_weapons ; +DROP TABLE IF EXISTS user_decks ; +DROP TABLE IF EXISTS user_deck_characters ; +DROP TABLE IF EXISTS user_parts_presets ; +DROP TABLE IF EXISTS user_parts_group_notes ; +DROP TABLE IF EXISTS user_parts ; +DROP TABLE IF EXISTS user_thoughts ; +DROP TABLE IF EXISTS user_companions ; +DROP TABLE IF EXISTS user_weapon_notes ; +DROP TABLE IF EXISTS user_weapon_stories ; +DROP TABLE IF EXISTS user_weapon_awakens ; +DROP TABLE IF EXISTS user_weapon_abilities ; +DROP TABLE IF EXISTS user_weapon_skills ; +DROP TABLE IF EXISTS user_weapons ; +DROP TABLE IF EXISTS user_costume_awaken_status_ups ; +DROP TABLE IF EXISTS user_costume_active_skills ; +DROP TABLE IF EXISTS user_costumes ; +DROP TABLE IF EXISTS user_character_rebirths ; +DROP TABLE IF EXISTS user_character_board_status_ups ; +DROP TABLE IF EXISTS user_character_board_abilities ; +DROP TABLE IF EXISTS user_character_boards ; +DROP TABLE IF EXISTS user_characters ; +DROP TABLE IF EXISTS user_gacha ; +DROP TABLE IF EXISTS user_shop_replaceable ; +DROP TABLE IF EXISTS user_explore ; +DROP TABLE IF EXISTS user_guerrilla_free_open ; +DROP TABLE IF EXISTS user_portal_cage ; +DROP TABLE IF EXISTS user_notification ; +DROP TABLE IF EXISTS user_battle ; +DROP TABLE IF EXISTS user_big_hunt_state ; +DROP TABLE IF EXISTS user_side_story_active ; +DROP TABLE IF EXISTS user_extra_quest ; +DROP TABLE IF EXISTS user_event_quest ; +DROP TABLE IF EXISTS user_main_quest ; +DROP TABLE IF EXISTS user_login_bonus ; +DROP TABLE IF EXISTS user_login ; +DROP TABLE IF EXISTS user_profile ; +DROP TABLE IF EXISTS user_gem ; +DROP TABLE IF EXISTS user_status ; +DROP TABLE IF EXISTS user_setting ; +DROP TABLE IF EXISTS sessions ; +DROP TABLE IF EXISTS users ; + diff --git a/server/migrations/20260417173916_fix_deck_weapon.sql b/server/migrations/20260417173916_fix_deck_weapon.sql new file mode 100644 index 0000000..0668a4c --- /dev/null +++ b/server/migrations/20260417173916_fix_deck_weapon.sql @@ -0,0 +1,13 @@ +-- +goose Up + +-- Delete deck characters with empty weapons (always a bug). +DELETE FROM user_deck_characters +WHERE main_user_weapon_uuid = ''; + +-- Delete decks that reference deleted deck characters. +DELETE FROM user_decks +WHERE user_deck_character_uuid01 NOT IN (SELECT user_deck_character_uuid FROM user_deck_characters) + AND user_deck_character_uuid01 != ''; + +-- +goose Down +-- No rollback needed: EnsureDefaultDeck recreates decks on next SetTutorialProgress call. diff --git a/server/migrations/20260419073659_add_costume_lottery_effects.sql b/server/migrations/20260419073659_add_costume_lottery_effects.sql new file mode 100644 index 0000000..3ba9a75 --- /dev/null +++ b/server/migrations/20260419073659_add_costume_lottery_effects.sql @@ -0,0 +1,15 @@ +-- +goose Up +CREATE TABLE user_costume_lottery_effects ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_costume_uuid TEXT NOT NULL, + slot_number INTEGER NOT NULL, + odds_number INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_costume_uuid, slot_number) +); + +ALTER TABLE user_costumes ADD COLUMN costume_lottery_effect_unlocked_slot_count INTEGER NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE user_costumes DROP COLUMN costume_lottery_effect_unlocked_slot_count; +DROP TABLE IF EXISTS user_costume_lottery_effects; diff --git a/server/migrations/20260419083839_add_costume_lottery_effect_pending.sql b/server/migrations/20260419083839_add_costume_lottery_effect_pending.sql new file mode 100644 index 0000000..1bf8cd0 --- /dev/null +++ b/server/migrations/20260419083839_add_costume_lottery_effect_pending.sql @@ -0,0 +1,12 @@ +-- +goose Up +CREATE TABLE user_costume_lottery_effect_pending ( + user_id INTEGER NOT NULL REFERENCES users(user_id), + user_costume_uuid TEXT NOT NULL, + slot_number INTEGER NOT NULL, + odds_number INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, user_costume_uuid) +); + +-- +goose Down +DROP TABLE IF EXISTS user_costume_lottery_effect_pending;