14 Commits

Author SHA1 Message Date
Ilya Groshev 63df7d7055 Add auto-repeat quest and memoir auto-sell
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-28 10:48:26 +03:00
Ilya Groshev c961fde8ac Share exp-cap helper across enhance flows; fix quest-reward rebirth
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-27 13:23:07 +03:00
Ilya Groshev 72b2bd1ec5 Fix enhance campaign bonus scale and int32 overflow
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-27 10:36:09 +03:00
Ilya Groshev dc7c1df4fd Add campaign bonuses; fix parts variant/sub-stat grants and menu-pick quest resume state
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-25 09:31:53 +03:00
Ilya Groshev 2d0c0d8ef0 Add cross-platform prebuilt release workflow
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-22 23:12:08 +03:00
Ilya Groshev 810adcf990 Derive main-quest season routes at projection time 2026-05-22 17:24:30 +03:00
Ilya Groshev ef69c54949 Pair gacha costume bonuses via curated lookup table
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-21 17:49:19 +03:00
Ilya Groshev b65c1c5fce Implement world-map entities
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-21 14:15:11 +03:00
Ilya Groshev ab5a999ffe Fix black screen re-entering a side story with no quest cleared
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-18 20:52:19 +03:00
Ilya Groshev 8520b67a8b Fix endless battle loop on background quests with battle scenes 2026-05-18 19:52:27 +03:00
Ilya Groshev 42ff8ec88f Fix replay flow corrupting the main-flow scene pointer
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-18 13:09:21 +03:00
Ilya Groshev 2cf0c153e1 Implement Fate Boards
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-17 18:21:57 +03:00
Ilya Groshev 956dbfaefd Fix retire wiping the cleared status of event and extra quests
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-17 11:29:16 +03:00
Ilya Groshev 0d46ee4557 Fix replay flow loop on background quests and another-route flow type 2026-05-17 11:02:40 +03:00
62 changed files with 3651 additions and 492 deletions
+117
View File
@@ -0,0 +1,117 @@
name: Build and Publish Release Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { goos: linux, goarch: amd64, archive: tar.gz }
- { goos: linux, goarch: arm64, archive: tar.gz }
- { goos: darwin, goarch: amd64, archive: tar.gz }
- { goos: darwin, goarch: arm64, archive: tar.gz }
- { goos: windows, goarch: amd64, archive: zip }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Install protoc
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: Install protoc-gen-go plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Generate proto stubs
working-directory: server
run: make proto
- name: Cross-compile binaries
working-directory: server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
set -euo pipefail
stage="staging"
mkdir -p "$stage/bin"
ext=""
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
build() {
local name="$1" dest="$2"
go build -trimpath -ldflags="-s -w" -o "$dest/${name}${ext}" "./cmd/${name}"
}
# Wizard sits at the root so end-users see it immediately.
build wizard "$stage"
# Sub-services and admin tools go in bin/ to match the runtime layout.
for name in dev lunar-tear octo-cdn auth-server import-snapshot claim-account register-account wizard-restore; do
build "$name" "$stage/bin"
done
- name: Stage docs
working-directory: server
run: |
cp ../README.md staging/README.md
cp ../LICENSE staging/LICENSE
- name: Archive
working-directory: server
run: |
set -euo pipefail
name="lunar-tear-server-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}"
mv staging "$name"
if [ "${{ matrix.archive }}" = "zip" ]; then
zip -r "${name}.zip" "$name"
else
tar -czf "${name}.tar.gz" "$name"
fi
ls -lh "${name}".*
- uses: actions/upload-artifact@v4
with:
name: lunar-tear-server-${{ matrix.goos }}-${{ matrix.goarch }}
path: server/lunar-tear-server-*.${{ matrix.archive }}
if-no-files-found: error
release:
name: Attach archives to GitHub Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List downloaded artifacts
run: ls -lh artifacts/
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
draft: true
+94 -87
View File
@@ -5,7 +5,14 @@ Discord server: https://discord.gg/MZAf5aVkJG
## How To Launch The Server ## How To Launch The Server
### Prerequisites ### Download & Run (no setup)
Prebuilt binaries are published for Linux, macOS, and Windows on the [Releases page](https://github.com/Walter-Sparrow/lunar-tear/releases).
1. Download the archive for your OS/arch (`lunar-tear-server-<version>-<os>-<arch>.{tar.gz,zip}`).
2. Run `./wizard` (macOS/Linux) or double-click `wizard.exe` (Windows).
### Prerequisites (build from source)
- Go 1.25+ - Go 1.25+
- [goose](https://github.com/pressly/goose) migration tool - [goose](https://github.com/pressly/goose) migration tool
@@ -40,12 +47,12 @@ By default the wizard uses ports 8003 (gRPC), 8080 (CDN), and 3000 (auth). Overr
go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080 go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ------- | ---------------------------------- | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `--prefer-saved` | `false` | Reuse saved config without prompting | | `--prefer-saved` | `false` | Reuse saved config without prompting |
| `--grpc-port` | `8003` | gRPC server port | | `--grpc-port` | `8003` | gRPC server port |
| `--cdn-port` | `8080` | CDN server port | | `--cdn-port` | `8080` | CDN server port |
| `--auth-port` | `3000` | Auth server port | | `--auth-port` | `3000` | Auth server port |
| `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. |
Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing. Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing.
@@ -105,9 +112,9 @@ go run ./cmd/import-snapshot \
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | --------------------------------------------- | | ------------ | ------------ | --------------------------------------------- |
| `--snapshot` | *(required)* | Path to JSON snapshot file | | `--snapshot` | _(required)_ | Path to JSON snapshot file |
| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) | | `--uuid` | _(required)_ | UUID to assign (must match the client's UUID) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
### Run ### Run
@@ -174,40 +181,40 @@ Or via `make`:
make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000" make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| --------------------- | ------------------ | ---------------------------------------- | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address | | `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
| `--auth.db` | `db/auth.db` | auth-server SQLite database path | | `--auth.db` | `db/auth.db` | auth-server SQLite database path |
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address | | `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
| `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr | | `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr |
| `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address | | `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address |
| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr | | `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
| `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear | | `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear |
| `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear | | `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear |
| `--no-register` | `false` | disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | disable new user registrations (only already registered users can connect). |
| `--admin.listen` | *(empty)* | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin.listen` | _(empty)_ | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
| `--no-color` | `false` | disable colored output | | `--no-color` | `false` | disable colored output |
### Ports ### Ports
| Protocol | Port | Binary | Notes | | Protocol | Port | Binary | Notes |
| -------- | ---- | ------------- | ----------------------------------------------------------- | | -------- | ---- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) | | gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages | | HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
| HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set | | HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set |
| HTTP | 3000 | `auth-server` | account registration and login | | HTTP | 3000 | `auth-server` | account registration and login |
### Game Server Flags (`lunar-tear`) ### Game Server Flags (`lunar-tear`)
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ----------------- | ---------------------------------------------------- | | ---------------- | ---------------- | --------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) | | `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients | | `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) | | `--octo-url` | _(required)_ | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) | | `--auth-url` | _(empty)_ | Auth server base URL (e.g. `http://localhost:3000`) |
| `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. | | `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. |
| `--no-register` | `false` | Disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | Disable new user registrations (only already registered users can connect). |
### Live Master Data Reload ### Live Master Data Reload
@@ -231,11 +238,11 @@ Security defaults are fail-closed:
### CDN Flags (`octo-cdn`) ### CDN Flags (`octo-cdn`)
| Flag | Default | Description | | Flag | Default | Description |
| --------------- | ----------------- | -------------------------------------------------------- | | --------------- | ---------------- | --------------------------------------------------------- |
| `--listen` | `0.0.0.0:8080` | local bind address | | `--listen` | `0.0.0.0:8080` | local bind address |
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) | | `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
| `--assets-dir` | `.` | root directory containing the `assets/` tree | | `--assets-dir` | `.` | root directory containing the `assets/` tree |
### Docker ### Docker
@@ -250,22 +257,22 @@ The `db/` directory is mounted as a volume so both `game.db` and `auth.db` persi
Each service has its own image and can be deployed independently: Each service has its own image and can be deployed independently:
| Service | Image | Default Port | Notes | | Service | Image | Default Port | Notes |
| -------- | --------------------------- | ------------ | ------------------------------ | | -------- | --------------------------- | ------------ | -------------------------------- |
| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook | | `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN | | `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login | | `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
The game server is configured via environment variables in the compose file: The game server is configured via environment variables in the compose file:
| Env var | Description | | Env var | Description |
| --------------------- | -------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------- |
| `LUNAR_LISTEN` | gRPC bind address | | `LUNAR_LISTEN` | gRPC bind address |
| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game | | `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets | | `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
| `LUNAR_AUTH_URL` | Auth server base URL (optional) | | `LUNAR_AUTH_URL` | Auth server base URL (optional) |
| `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) | | `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) |
| `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** | | `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** |
Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up. Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up.
@@ -273,22 +280,22 @@ Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without
All targets run from the `server/` directory. All targets run from the `server/` directory.
| Target | Description | | Target | Description |
| -------------- | ------------------------------------------------------- | | ----------------------------- | ------------------------------------------------------ |
| `make proto` | Regenerate protobuf stubs | | `make proto` | Regenerate protobuf stubs |
| `make build` | Build the game server binary | | `make build` | Build the game server binary |
| `make build-cdn` | Build the CDN binary | | `make build-cdn` | Build the CDN binary |
| `make build-auth` | Build the auth server binary | | `make build-auth` | Build the auth server binary |
| `make build-dev` | Build the dev runner binary to `bin/` | | `make build-dev` | Build the dev runner binary to `bin/` |
| `make build-all` | Build all service binaries to `bin/` | | `make build-all` | Build all service binaries to `bin/` |
| `make build-import` | Build the import-snapshot tool | | `make build-import` | Build the import-snapshot tool |
| `make build-claim-account` | Build the claim-account tool | | `make build-claim-account` | Build the claim-account tool |
| `make build-register-account` | Build the register-account tool | | `make build-register-account` | Build the register-account tool |
| `make clean` | Remove the `bin/` directory | | `make clean` | Remove the `bin/` directory |
| `make dev` | Run all three services with one command | | `make dev` | Run all three services with one command |
| `make migrate` | Run goose migrations on `db/game.db` | | `make migrate` | Run goose migrations on `db/game.db` |
| `make restore` | Interactive restore of `db/game.db` from `db/backups/` | | `make restore` | Interactive restore of `db/game.db` from `db/backups/` |
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
## Claim Account ## Claim Account
@@ -301,10 +308,10 @@ cd server
go run ./cmd/claim-account --name "PlayerName" --db db/game.db go run ./cmd/claim-account --name "PlayerName" --db db/game.db
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| -------- | ------------ | ---------------------------------------------------- | | -------- | ------------ | ---------------------------- |
| `--name` | *(required)* | In-game player name to claim | | `--name` | _(required)_ | In-game player name to claim |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
## Auth Server ## Auth Server
@@ -323,12 +330,12 @@ The `--secret` flag accepts a hex-encoded HMAC key. If omitted, a random key is
### Flags ### Flags
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | --------------- | -------------------------------------------- | | --------------- | -------------- | -------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) | | `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
| `--db` | `db/auth.db` | SQLite database path for auth users | | `--db` | `db/auth.db` | SQLite database path for auth users |
| `--secret` | *(generated)* | Hex-encoded HMAC secret for token signing | | `--secret` | _(generated)_ | Hex-encoded HMAC secret for token signing |
| `--no-register` | `false` | Disable new user registrations (only already registered users can log in). | | `--no-register` | `false` | Disable new user registrations (only already registered users can log in). |
## Create account ## Create account
@@ -339,13 +346,13 @@ A primary mean of registering new accounts when `--no-register` flag is passed t
go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android" go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | ------------------------------------------------------------ | | ------------ | ------------ | ------------------------------------------------- |
| `--name` | *(required)* | Auth Server account nickname to be registered | | `--name` | _(required)_ | Auth Server account nickname to be registered |
| `--password` | *(required)* | Auth Server account password to be registered | | `--password` | _(required)_ | Auth Server account password to be registered |
| `--platform` | `android` | Platform of new user account (`android` or `ios`) | | `--platform` | `android` | Platform of new user account (`android` or `ios`) |
| `--db` | `db/game.db` | SQLite main database path | | `--db` | `db/game.db` | SQLite main database path |
| `--auth-db` | `db/auth.db` | SQLite Auth Server database path | | `--auth-db` | `db/auth.db` | SQLite Auth Server database path |
This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login! This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login!
+1
View File
@@ -40,6 +40,7 @@ var childTables = []string{
"user_big_hunt_max_scores", "user_big_hunt_max_scores",
"user_quest_limit_content_status", "user_quest_limit_content_status",
"user_side_story_quests", "user_side_story_quests",
"user_main_quest_season_routes",
"user_missions", "user_missions",
"user_quest_missions", "user_quest_missions",
"user_quests", "user_quests",
+6 -2
View File
@@ -120,8 +120,12 @@ func main() {
colorCyan = "" colorCyan = ""
} }
log.Println("building services...") if _, err := os.Stat("go.mod"); err == nil {
buildAll() log.Println("building services...")
buildAll()
} else {
log.Println("prebuilt mode: skipping build, using bin/ from archive")
}
ext := binExt() ext := binExt()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+1
View File
@@ -124,4 +124,5 @@ func registerServices(
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder)) pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder)) pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder))
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder)) pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder))
pb.RegisterLabyrinthServiceServer(srv, service.NewLabyrinthServiceServer(userStore, userStore, holder))
} }
+29 -21
View File
@@ -27,31 +27,39 @@ func backupGameDB() {
return return
} }
_ = spinner.New().Title(" Backing up db/game.db...").Action(func() { if !sourceMode {
if err := os.MkdirAll(backupDir, 0o755); err != nil { fmt.Println(" Backing up db/game.db...")
fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err) doBackupGameDB()
return return
} }
ts := time.Now().UTC().Format("20060102T150405Z") _ = spinner.New().Title(" Backing up db/game.db...").Action(doBackupGameDB).Run()
dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix)) }
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)") func doBackupGameDB() {
if err != nil { if err := os.MkdirAll(backupDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err) fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err)
return return
} }
defer db.Close()
escaped := strings.ReplaceAll(dest, "'", "''") ts := time.Now().UTC().Format("20060102T150405Z")
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil { dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix))
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
_ = os.Remove(dest)
return
}
pruneOldBackups() db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
}).Run() if err != nil {
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err)
return
}
defer db.Close()
escaped := strings.ReplaceAll(dest, "'", "''")
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
_ = os.Remove(dest)
return
}
pruneOldBackups()
} }
func pruneOldBackups() { func pruneOldBackups() {
+22 -13
View File
@@ -74,14 +74,21 @@ func main() {
fmt.Print(banner) fmt.Print(banner)
sourceMode = isSourceCheckout()
if !*setupOnly { if !*setupOnly {
validateAssets() validateAssets()
validateTools() if sourceMode {
validateProtocIncludes() validateTools()
runProtoc() validateProtocIncludes()
backupGameDB() runProtoc()
runMigrate() backupGameDB()
downloadDeps() runMigrate()
downloadDeps()
} else {
backupGameDB()
runMigrateEmbedded()
}
} }
ip, cfg, firstRun := resolveIP(*preferSaved) ip, cfg, firstRun := resolveIP(*preferSaved)
@@ -901,13 +908,15 @@ func launchDev(ip string, p ports) {
} }
devBin := filepath.Join("bin", "dev"+ext) devBin := filepath.Join("bin", "dev"+ext)
_ = spinner.New().Title(" Building services...").Action(func() { if sourceMode {
if err := os.MkdirAll("bin", 0755); err != nil { _ = spinner.New().Title(" Building services...").Action(func() {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err) if err := os.MkdirAll("bin", 0755); err != nil {
os.Exit(1) fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
} os.Exit(1)
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev") }
}).Run() runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run()
}
devArgs := []string{ devArgs := []string{
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC), "--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"lunar-tear/server/migrations"
)
var sourceMode bool
func isSourceCheckout() bool {
if _, err := os.Stat("go.mod"); err != nil {
return false
}
if _, err := os.Stat("proto"); err != nil {
return false
}
return true
}
func runMigrateEmbedded() {
fmt.Println(" Running migrations...")
if err := os.MkdirAll("db", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create db/: %v\n", err)
os.Exit(1)
}
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
if err != nil {
fmt.Fprintf(os.Stderr, " open db: %v\n", err)
os.Exit(1)
}
defer db.Close()
if err := migrations.Up(context.Background(), db); err != nil {
fmt.Fprintf(os.Stderr, " migration failed: %v\n", err)
os.Exit(1)
}
}
+11 -7
View File
@@ -7,12 +7,12 @@ require (
github.com/pierrec/lz4/v4 v4.1.26 github.com/pierrec/lz4/v4 v4.1.26
github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/vmihailenco/msgpack/v5 v5.4.1
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/net v0.52.0 golang.org/x/net v0.53.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0 golang.org/x/term v0.42.0
google.golang.org/grpc v1.79.1 google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.48.2 modernc.org/sqlite v1.49.1
) )
require ( require (
@@ -34,21 +34,25 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
+28
View File
@@ -54,8 +54,12 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -66,10 +70,14 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
@@ -82,20 +90,28 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= 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 v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= 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/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= 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 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 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= 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.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -109,18 +125,25 @@ golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
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 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/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/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 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -131,16 +154,21 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+170
View File
@@ -0,0 +1,170 @@
package campaign
import (
"fmt"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/utils"
)
type Catalog struct {
enhance []enhanceRow
quest []questRow
}
type enhanceRow struct {
effectType EnhanceCampaignEffectType
effectValue int32
targets []enhanceMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type enhanceMatch struct {
t EnhanceCampaignTargetType
v int32
}
type questRow struct {
effectType QuestCampaignEffectType
effectValue int32
bonusItems []BonusDrop
targets []questMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type questMatch struct {
t QuestCampaignTargetType
v int32
}
func Load() (*Catalog, error) {
enhance, err := loadEnhanceRows()
if err != nil {
return nil, fmt.Errorf("load enhance campaigns: %w", err)
}
quest, err := loadQuestRows()
if err != nil {
return nil, fmt.Errorf("load quest campaigns: %w", err)
}
return &Catalog{enhance: enhance, quest: quest}, nil
}
func (c *Catalog) EnhanceCount() int { return len(c.enhance) }
func (c *Catalog) QuestCount() int { return len(c.quest) }
func loadEnhanceRows() ([]enhanceRow, error) {
campaigns, err := utils.ReadTable[masterdata.EntityMEnhanceCampaign]("m_enhance_campaign")
if err != nil {
return nil, err
}
targets, err := utils.ReadTable[masterdata.EntityMEnhanceCampaignTargetGroup]("m_enhance_campaign_target_group")
if err != nil {
return nil, err
}
byGroup := make(map[int32][]enhanceMatch, len(targets))
for _, t := range targets {
byGroup[t.EnhanceCampaignTargetGroupId] = append(byGroup[t.EnhanceCampaignTargetGroupId], enhanceMatch{
t: EnhanceCampaignTargetType(t.EnhanceCampaignTargetType),
v: t.EnhanceCampaignTargetValue,
})
}
rows := make([]enhanceRow, 0, len(campaigns))
for _, c := range campaigns {
grp := byGroup[c.EnhanceCampaignTargetGroupId]
if len(grp) == 0 {
continue
}
rows = append(rows, enhanceRow{
effectType: EnhanceCampaignEffectType(c.EnhanceCampaignEffectType),
effectValue: c.EnhanceCampaignEffectValue / 10,
targets: grp,
startMillis: c.StartDatetime,
endMillis: c.EndDatetime,
userStatus: TargetUserStatusType(c.TargetUserStatusType),
})
}
return rows, nil
}
func loadQuestRows() ([]questRow, error) {
campaigns, err := utils.ReadTable[masterdata.EntityMQuestCampaign]("m_quest_campaign")
if err != nil {
return nil, err
}
targets, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetGroup]("m_quest_campaign_target_group")
if err != nil {
return nil, err
}
effects, err := utils.ReadTable[masterdata.EntityMQuestCampaignEffectGroup]("m_quest_campaign_effect_group")
if err != nil {
return nil, err
}
itemGroups, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetItemGroup]("m_quest_campaign_target_item_group")
if err != nil {
return nil, err
}
targetsByGroup := make(map[int32][]questMatch, len(targets))
for _, t := range targets {
targetsByGroup[t.QuestCampaignTargetGroupId] = append(targetsByGroup[t.QuestCampaignTargetGroupId], questMatch{
t: QuestCampaignTargetType(t.QuestCampaignTargetType),
v: t.QuestCampaignTargetValue,
})
}
bonusByGroup := make(map[int32][]BonusDrop, len(itemGroups))
for _, ig := range itemGroups {
bonusByGroup[ig.QuestCampaignTargetItemGroupId] = append(bonusByGroup[ig.QuestCampaignTargetItemGroupId], BonusDrop{
PossessionType: ig.PossessionType,
PossessionId: ig.PossessionId,
Count: ig.Count,
})
}
effectByGroup := make(map[int32]masterdata.EntityMQuestCampaignEffectGroup, len(effects))
for _, e := range effects {
effectByGroup[e.QuestCampaignEffectGroupId] = e
}
rows := make([]questRow, 0, len(campaigns))
for _, c := range campaigns {
grp := targetsByGroup[c.QuestCampaignTargetGroupId]
if len(grp) == 0 {
continue
}
eff, ok := effectByGroup[c.QuestCampaignEffectGroupId]
if !ok {
continue
}
rows = append(rows, questRow{
effectType: QuestCampaignEffectType(eff.QuestCampaignEffectType),
effectValue: eff.QuestCampaignEffectValue,
bonusItems: bonusByGroup[eff.QuestCampaignTargetItemGroupId],
targets: grp,
startMillis: c.StartDatetime,
endMillis: c.EndDatetime,
userStatus: TargetUserStatusType(c.TargetUserStatusType),
})
}
return rows, nil
}
func (r enhanceRow) isActive(f Filter) bool {
if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis {
return false
}
return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus
}
func (r questRow) isActive(f Filter) bool {
if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis {
return false
}
return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus
}
+113
View File
@@ -0,0 +1,113 @@
package campaign
func (c *Catalog) PartsRateBonus(t PartsTarget, f Filter) RateBonus {
var out RateBonus
for _, r := range c.enhance {
if !r.isActive(f) {
continue
}
if !matchesParts(r.targets, t) {
continue
}
out = applyEnhanceEffect(out, r)
}
return out
}
func (c *Catalog) CostumeExpBonus(t CostumeTarget, f Filter) ExpBonus {
var sum int32
for _, r := range c.enhance {
if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm {
continue
}
if matchesCostume(r.targets, t) {
sum += r.effectValue
}
}
return ExpBonus{bonusPermil: sum}
}
func (c *Catalog) WeaponExpBonus(t WeaponTarget, f Filter) ExpBonus {
var sum int32
for _, r := range c.enhance {
if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm {
continue
}
if matchesWeapon(r.targets, t) {
sum += r.effectValue
}
}
return ExpBonus{bonusPermil: sum}
}
func applyEnhanceEffect(b RateBonus, r enhanceRow) RateBonus {
switch r.effectType {
case EnhanceEffectProbability:
b.override = r.effectValue
case EnhanceEffectAdditionalPerm:
b.bonusPermil += r.effectValue
}
return b
}
func matchesParts(targets []enhanceMatch, t PartsTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetPartsAll:
return true
case EnhanceTargetPartsSeriesId:
if m.v == t.PartsGroupId {
return true
}
case EnhanceTargetPartsId:
if m.v == t.PartsId {
return true
}
}
}
return false
}
func matchesCostume(targets []enhanceMatch, t CostumeTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetCostumeAll:
return true
case EnhanceTargetCostumeCharacterId:
if m.v == t.CharacterId {
return true
}
case EnhanceTargetCostumeSkillfulWeapon:
if m.v == t.SkillfulWeaponType {
return true
}
case EnhanceTargetCostumeId:
if m.v == t.CostumeId {
return true
}
}
}
return false
}
func matchesWeapon(targets []enhanceMatch, t WeaponTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetWeaponAll:
return true
case EnhanceTargetWeaponTypeId:
if m.v == t.WeaponType {
return true
}
case EnhanceTargetWeaponAttributeTypeId:
if m.v == t.AttributeType {
return true
}
case EnhanceTargetWeaponId:
if m.v == t.WeaponId {
return true
}
}
}
return false
}
+57
View File
@@ -0,0 +1,57 @@
package campaign
type RateBonus struct {
override int32
bonusPermil int32
}
func (b RateBonus) Apply(basePermil int32) int32 {
base := basePermil
if b.override > 0 {
base = b.override
}
return clampPermil(int32(int64(base) + int64(b.bonusPermil)))
}
type ExpBonus struct {
bonusPermil int32
}
func (b ExpBonus) Apply(base int32) int32 {
return int32(int64(base) * int64(1000+b.bonusPermil) / 1000)
}
type StaminaMul struct {
permil int32
}
func (m StaminaMul) Apply(base int32) int32 {
if m.permil == 1000 {
return base
}
return int32(int64(base) * int64(m.permil) / 1000)
}
type DropRateMul struct {
bonusPermil int32
}
func (m DropRateMul) Apply(base int32) int32 {
return int32((int64(base)*int64(1000+m.bonusPermil) + 999) / 1000)
}
type BonusDrop struct {
PossessionType int32
PossessionId int32
Count int32
}
func clampPermil(v int32) int32 {
if v < 0 {
return 0
}
if v > 1000 {
return 1000
}
return v
}
+85
View File
@@ -0,0 +1,85 @@
package campaign
func (c *Catalog) QuestStamina(t QuestTarget, f Filter) StaminaMul {
return questPermilMin(c.quest, QuestEffectStaminaConsume, t, f)
}
func (c *Catalog) QuestDropRate(t QuestTarget, f Filter) DropRateMul {
var best int32
for _, r := range c.quest {
if !r.isActive(f) || r.effectType != QuestEffectDropRate {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
if r.effectValue > best {
best = r.effectValue
}
}
return DropRateMul{bonusPermil: best}
}
func (c *Catalog) QuestBonusDrops(t QuestTarget, f Filter) []BonusDrop {
var out []BonusDrop
for _, r := range c.quest {
if !r.isActive(f) || r.effectType != QuestEffectDropItemAdd {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
out = append(out, r.bonusItems...)
}
return out
}
func questPermilMin(rows []questRow, want QuestCampaignEffectType, t QuestTarget, f Filter) StaminaMul {
min := int32(1000)
for _, r := range rows {
if !r.isActive(f) || r.effectType != want {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
if r.effectValue < min {
min = r.effectValue
}
}
return StaminaMul{permil: min}
}
func matchesQuest(targets []questMatch, t QuestTarget) bool {
for _, m := range targets {
switch m.t {
case QuestTargetWholeQuest:
return true
case QuestTargetQuestType:
if int32(t.QuestType) == m.v {
return true
}
case QuestTargetEventQuestType:
if t.QuestType == QuestTypeEventQuest && t.EventQuestType == m.v {
return true
}
case QuestTargetMainQuestChapterId:
if t.QuestType == QuestTypeMainQuest && t.ChapterId == m.v {
return true
}
case QuestTargetMainQuestQuestId:
if t.QuestType == QuestTypeMainQuest && t.QuestId == m.v {
return true
}
case QuestTargetSubQuestChapterId:
if t.QuestType == QuestTypeEventQuest && t.ChapterId == m.v {
return true
}
case QuestTargetSubQuestQuestId:
if t.QuestType == QuestTypeEventQuest && t.QuestId == m.v {
return true
}
}
}
return false
}
+101
View File
@@ -0,0 +1,101 @@
package campaign
import "lunar-tear/server/internal/model"
type EnhanceCampaignEffectType int32
const (
EnhanceEffectUnknown EnhanceCampaignEffectType = 0
EnhanceEffectProbability EnhanceCampaignEffectType = 1
EnhanceEffectAdditionalPerm EnhanceCampaignEffectType = 2
)
type EnhanceCampaignTargetType int32
const (
EnhanceTargetUnknown EnhanceCampaignTargetType = 0
EnhanceTargetCostumeAll EnhanceCampaignTargetType = 1
EnhanceTargetWeaponAll EnhanceCampaignTargetType = 2
EnhanceTargetPartsAll EnhanceCampaignTargetType = 3
EnhanceTargetCostumeCharacterId EnhanceCampaignTargetType = 11
EnhanceTargetCostumeSkillfulWeapon EnhanceCampaignTargetType = 12
EnhanceTargetCostumeId EnhanceCampaignTargetType = 13
EnhanceTargetWeaponTypeId EnhanceCampaignTargetType = 21
EnhanceTargetWeaponAttributeTypeId EnhanceCampaignTargetType = 22
EnhanceTargetWeaponId EnhanceCampaignTargetType = 23
EnhanceTargetPartsSeriesId EnhanceCampaignTargetType = 31
EnhanceTargetPartsId EnhanceCampaignTargetType = 32
)
type QuestCampaignEffectType int32
const (
QuestEffectUnknown QuestCampaignEffectType = 0
QuestEffectDropRate QuestCampaignEffectType = 1
QuestEffectDropCount QuestCampaignEffectType = 2
QuestEffectStaminaConsume QuestCampaignEffectType = 3
QuestEffectClearRewardGold QuestCampaignEffectType = 4
QuestEffectDropItemAdd QuestCampaignEffectType = 5
)
type QuestCampaignTargetType int32
const (
QuestTargetUnknown QuestCampaignTargetType = 0
QuestTargetWholeQuest QuestCampaignTargetType = 1
QuestTargetQuestType QuestCampaignTargetType = 2
QuestTargetEventQuestType QuestCampaignTargetType = 3
QuestTargetMainQuestChapterId QuestCampaignTargetType = 4
QuestTargetMainQuestQuestId QuestCampaignTargetType = 5
QuestTargetSubQuestChapterId QuestCampaignTargetType = 6
QuestTargetSubQuestQuestId QuestCampaignTargetType = 7
)
type QuestType int32
const (
QuestTypeUnknown QuestType = 0
QuestTypeMainQuest QuestType = 1
QuestTypeEventQuest QuestType = 2
QuestTypeExtraQuest QuestType = 3
QuestTypeBigHunt QuestType = 4
)
type TargetUserStatusType int32
const (
TargetUserStatusUnknown TargetUserStatusType = 0
TargetUserStatusAll TargetUserStatusType = 1
TargetUserStatusComeback TargetUserStatusType = 2
TargetUserStatusBeginner TargetUserStatusType = 3
)
type Filter struct {
NowMillis int64
UserStatus TargetUserStatusType
}
type PartsTarget struct {
PartsId int32
PartsGroupId int32
Rarity model.RarityType
}
type CostumeTarget struct {
CostumeId int32
CharacterId int32
SkillfulWeaponType int32
}
type WeaponTarget struct {
WeaponId int32
WeaponType int32
AttributeType int32
}
type QuestTarget struct {
QuestId int32
QuestType QuestType
EventQuestType int32
ChapterId int32
}
+13
View File
@@ -17,3 +17,16 @@ func LevelAndCap(exp int32, thresholds []int32) (level, capped int32) {
} }
return level, exp return level, exp
} }
// ApplyExpWithMaxLevel runs LevelAndCap and then clamps the resulting
// level to the per-instance maxLevel (e.g. limit break + awaken for
// weapons, limit break + rebirth for costumes). A maxLevel <= 0 means
// "no per-instance cap" and the result is identical to LevelAndCap.
func ApplyExpWithMaxLevel(exp int32, thresholds []int32, maxLevel int32) (level, capped int32) {
level, capped = LevelAndCap(exp, thresholds)
if maxLevel > 0 && level > maxLevel && int(maxLevel) < len(thresholds) {
level = maxLevel
capped = thresholds[maxLevel]
}
return
}
@@ -17,6 +17,25 @@ type CharacterRebirthCatalog struct {
MaterialsByGroupId map[int32][]EntityMCharacterRebirthMaterialGroup MaterialsByGroupId map[int32][]EntityMCharacterRebirthMaterialGroup
} }
func (c *CharacterRebirthCatalog) CostumeLevelLimitUp(characterId, rebirthCount int32) int32 {
if c == nil || rebirthCount <= 0 {
return 0
}
stepGroupId, ok := c.StepGroupByCharacterId[characterId]
if !ok {
return 0
}
var total int32
for i := range rebirthCount {
step, ok := c.StepByGroupAndCount[StepKey{GroupId: stepGroupId, BeforeRebirthCount: i}]
if !ok {
continue
}
total += step.CostumeLevelLimitUp
}
return total
}
func LoadCharacterRebirthCatalog() (*CharacterRebirthCatalog, error) { func LoadCharacterRebirthCatalog() (*CharacterRebirthCatalog, error) {
rebirthRows, err := utils.ReadTable[EntityMCharacterRebirth]("m_character_rebirth") rebirthRows, err := utils.ReadTable[EntityMCharacterRebirth]("m_character_rebirth")
if err != nil { if err != nil {
@@ -0,0 +1,286 @@
package masterdata
// Source: based on public_costumes_link_export_2026-05-21_142314.csv by @Keziah,
// with each weapon resolved to its m_weapon_evolution_group EvolutionOrder=1 root
var costumeWeaponPairings = map[int32]int32{
10100: 101001,
10101: 101011,
10102: 101021,
10103: 101031,
10104: 101041,
10105: 101051,
10106: 101061,
10107: 101071,
10108: 101081,
10109: 101091,
10110: 101101,
10111: 101121,
10112: 101111,
10113: 101131,
10114: 101141,
10115: 101151,
10116: 101161,
10117: 101171,
10118: 101181,
10119: 101191,
10120: 101201,
10121: 101211,
21000: 210161,
21001: 210031,
21002: 210171,
21003: 210181,
21004: 210191,
21005: 210271,
22000: 220081,
22001: 220021,
22002: 220051,
22003: 220061,
22004: 220141,
22005: 220161,
22006: 220181,
22007: 220191,
22008: 220211,
22009: 220231,
22010: 220241,
23000: 230001,
23001: 230021,
23004: 230051,
23005: 230151,
23006: 230261,
23007: 230271,
23008: 230281,
24000: 240091,
24001: 240121,
24002: 240131,
24003: 240011,
24004: 240081,
24005: 240201,
24006: 240221,
24007: 240241,
24008: 240271,
24009: 240311,
25000: 250121,
25001: 250071,
25002: 250011,
25003: 250151,
25005: 250021,
25006: 250171,
25007: 250221,
25008: 250231,
25009: 250261,
31000: 310081,
31001: 310061,
31002: 310021,
31004: 310191,
31005: 310211,
31008: 310221,
31009: 310241,
31010: 310261,
31011: 310291,
31013: 310321,
31014: 310331,
31015: 310371,
31016: 310401,
31017: 310411,
31018: 310421,
31019: 310431,
31020: 310461,
31021: 310471,
31022: 310481,
31023: 310511,
31024: 310531,
31025: 310541,
31026: 310551,
31027: 310571,
31028: 310591,
31029: 310621,
31030: 310641,
31031: 310661,
31032: 310691,
31033: 310701,
31034: 310711,
32000: 320081,
32001: 320041,
32002: 320011,
32003: 320111,
32004: 320051,
32005: 320141,
32006: 320151,
32007: 320171,
32008: 320181,
32009: 320201,
32011: 320231,
32012: 320241,
32013: 320271,
32014: 320281,
32015: 320301,
32016: 320331,
32017: 320351,
32018: 320371,
32019: 320381,
32020: 320391,
32021: 320421,
32022: 320431,
32023: 320441,
32024: 320451,
32025: 320461,
32026: 320471,
32027: 320501,
32028: 320531,
32029: 320541,
32030: 320551,
32031: 320561,
32032: 320581,
32033: 320601,
32034: 320611,
32035: 320621,
32036: 320641,
33000: 330001,
33001: 330121,
33002: 330011,
33003: 330021,
33005: 330161,
33006: 330171,
33007: 330191,
33009: 330211,
33010: 330231,
33011: 330261,
33012: 330281,
33013: 330321,
33014: 330341,
33015: 330381,
33016: 330401,
33017: 330421,
33018: 330451,
33019: 330471,
33020: 330501,
33021: 330521,
33022: 330541,
33023: 330551,
33024: 330561,
33025: 330571,
33026: 330581,
33027: 330591,
33028: 330601,
33029: 330631,
33030: 330641,
33031: 330671,
33032: 330691,
33033: 330701,
34000: 340011,
34001: 340121,
34002: 340151,
34003: 340161,
34004: 340131,
34005: 340071,
34009: 340231,
34010: 340241,
34011: 340251,
34012: 340261,
34013: 340281,
34014: 340291,
34015: 340301,
34016: 340321,
34017: 340341,
34018: 340351,
34019: 340361,
34020: 340381,
34021: 340391,
34022: 340411,
34023: 340421,
34024: 340441,
34025: 340451,
34026: 340461,
34027: 340491,
34028: 340501,
34029: 340521,
34030: 340531,
34031: 340541,
34032: 340571,
34033: 340601,
34034: 340611,
34035: 340621,
34036: 340631,
34037: 340651,
34038: 340681,
34039: 340701,
34040: 340721,
34041: 340731,
34042: 340751,
34043: 340761,
34044: 340781,
34045: 340801,
34046: 340831,
34047: 340861,
34048: 340871,
35000: 350011,
35001: 350161,
35002: 350141,
35003: 350061,
35005: 350081,
35006: 350121,
35008: 350181,
35009: 350191,
35010: 350221,
35011: 350231,
35012: 350261,
35013: 350271,
35014: 350301,
35015: 350321,
35016: 350341,
35017: 350361,
35018: 350391,
35019: 350401,
35020: 350411,
35021: 350431,
35022: 350441,
35023: 350451,
35024: 350461,
35025: 350491,
35026: 350501,
35027: 350511,
35028: 350531,
35029: 350551,
35030: 350601,
35031: 350621,
35032: 350631,
35033: 350641,
35034: 350661,
35035: 350681,
35036: 350691,
35037: 350701,
35038: 350711,
41000: 410031,
41001: 410071,
41002: 410111,
41003: 410151,
42000: 420031,
42001: 420071,
42002: 420111,
42003: 420151,
43000: 430031,
43001: 430071,
43002: 430111,
43003: 430151,
44000: 440031,
44001: 440071,
44002: 440111,
44003: 440151,
44004: 440191,
45000: 450031,
45001: 450071,
45002: 450111,
45003: 450151,
51001: 510011,
51002: 510021,
51003: 510031,
52001: 520011,
52002: 520021,
53001: 530011,
53002: 530021,
54001: 540011,
54002: 540021,
55001: 550011,
55002: 550021,
55003: 550031,
}
+5 -70
View File
@@ -3,7 +3,6 @@ package masterdata
import ( import (
"fmt" "fmt"
"log" "log"
"slices"
"sort" "sort"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
@@ -126,27 +125,16 @@ func LoadGachaPool() (*GachaCatalog, error) {
} }
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes)) catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
costumeTermId := make(map[int32]int32, len(catalogCostumes))
for _, c := range catalogCostumes { for _, c := range catalogCostumes {
catalogCostumeSet[c.CostumeId] = true catalogCostumeSet[c.CostumeId] = true
costumeTermId[c.CostumeId] = c.CatalogTermId
} }
catalogWeaponSet := make(map[int32]bool, len(catalogWeapons)) catalogWeaponSet := make(map[int32]bool, len(catalogWeapons))
for _, w := range catalogWeapons { for _, w := range catalogWeapons {
catalogWeaponSet[w.WeaponId] = true catalogWeaponSet[w.WeaponId] = true
} }
costumeWeaponType := make(map[int32]int32, len(costumes))
for _, c := range costumes {
costumeWeaponType[c.CostumeId] = c.SkillfulWeaponType
}
weaponTypeById := make(map[int32]int32, len(weapons))
weaponRarityById := make(map[int32]int32, len(weapons))
restrictedWeapons := make(map[int32]bool) restrictedWeapons := make(map[int32]bool)
for _, w := range weapons { for _, w := range weapons {
weaponTypeById[w.WeaponId] = w.WeaponType
weaponRarityById[w.WeaponId] = w.RarityType
if w.IsRestrictDiscard { if w.IsRestrictDiscard {
restrictedWeapons[w.WeaponId] = true restrictedWeapons[w.WeaponId] = true
} }
@@ -262,60 +250,12 @@ func LoadGachaPool() (*GachaCatalog, error) {
log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons", log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons",
evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount) evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount)
type weaponKey struct { for costumeId := range pool.CostumeById {
TermId int32 if wid, ok := costumeWeaponPairings[costumeId]; ok {
WeaponType int32 pool.CostumeWeaponMap[costumeId] = wid
Rarity int32
}
weaponsByKey := make(map[weaponKey][]int32)
for _, cw := range catalogWeapons {
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] {
continue
}
wt := weaponTypeById[cw.WeaponId]
r := weaponRarityById[cw.WeaponId]
if wt == 0 || r < model.RaritySRare {
continue
}
k := weaponKey{TermId: cw.CatalogTermId, WeaponType: wt, Rarity: r}
weaponsByKey[k] = append(weaponsByKey[k], cw.WeaponId)
}
for k, ids := range weaponsByKey {
slices.Sort(ids)
weaponsByKey[k] = ids
}
exact, pattern, bestGuess := 0, 0, 0
for costumeId, item := range pool.CostumeById {
tid := costumeTermId[costumeId]
wt := costumeWeaponType[costumeId]
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType}
candidates := weaponsByKey[k]
if len(candidates) == 0 {
continue
}
if len(candidates) == 1 {
pool.CostumeWeaponMap[costumeId] = candidates[0]
exact++
continue
}
idPattern := costumeId*10 + 1
found := false
for _, wid := range candidates {
if wid == idPattern {
pool.CostumeWeaponMap[costumeId] = wid
pattern++
found = true
break
}
}
if !found {
pool.CostumeWeaponMap[costumeId] = candidates[0]
bestGuess++
} }
} }
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total", log.Printf("[GachaPool] costume-weapon pairing: %d entries from lookup table", len(pool.CostumeWeaponMap))
exact, pattern, bestGuess, len(pool.CostumeWeaponMap))
for _, m := range materials { for _, m := range materials {
pool.Materials = append(pool.Materials, GachaPoolItem{ pool.Materials = append(pool.Materials, GachaPoolItem{
@@ -330,7 +270,6 @@ func LoadGachaPool() (*GachaCatalog, error) {
func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) { func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry) pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry)
shopPairs := 0
for _, cells := range shop.ExchangeShopCells { for _, cells := range shop.ExchangeShopCells {
consumableId := shop.Items[cells[0].ShopItemId].PriceId consumableId := shop.Items[cells[0].ShopItemId].PriceId
@@ -350,16 +289,12 @@ func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
continue continue
} }
entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId}) entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId})
if costumeId != 0 && weaponId != 0 {
pool.CostumeWeaponMap[costumeId] = weaponId
shopPairs++
}
} }
if len(entries) > 0 { if len(entries) > 0 {
pool.ShopFeaturedByMedal[consumableId] = entries pool.ShopFeaturedByMedal[consumableId] = entries
} }
} }
log.Printf("[GachaPool] shop featured: %d consumables, %d costume-weapon pairs overridden", len(pool.ShopFeaturedByMedal), shopPairs) log.Printf("[GachaPool] shop featured: %d consumables", len(pool.ShopFeaturedByMedal))
} }
func (pool *GachaCatalog) PruneUnpairedCostumes() { func (pool *GachaCatalog) PruneUnpairedCostumes() {
+539 -10
View File
@@ -3,57 +3,463 @@ package masterdata
import ( import (
"fmt" "fmt"
"log" "log"
"sort"
"sync"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
// MaxUserGimmickRows is the server's per-table cap for gimmick projections and the
// in-memory user.Gimmick.Sequences map. The client hard-caps each of its
// IUserGimmick* tables at 1024 rows; we project up to this many with a small
// safety margin so we never overflow client buffers. Shared by InitSequenceSchedule
// (limits user.Gimmick.Sequences map size) and the IUserGimmick / Ornament /
// Sequence projections.
const MaxUserGimmickRows = 1000
type gimmickScheduleEntry struct { type gimmickScheduleEntry struct {
ScheduleId int32 ScheduleId int32
StartDatetime int64 StartDatetime int64
EndDatetime int64 EndDatetime int64
FirstSequenceId int32 FirstSequenceId int32
RequiredQuestId int32 // 0 = always active RequiredQuestId int32 // 0 = always active
IsHidden bool // hidden-story or cage-memory gimmick: bypasses the quest gate
Rank int // trim priority — see gimmickTypeRank
}
func readGimmickTable[T any](name, what string) ([]T, bool) {
rows, err := utils.ReadTable[T](name)
if err != nil {
log.Printf("[gimmick] %s unavailable, %s empty: %v", name, what, err)
return nil, false
}
return rows, true
}
func gimmickTypeRank(t model.GimmickType) int {
switch t {
case model.GimmickTypeReport: // hidden missions / stories
return 0
case model.GimmickTypeCageMemory: // lost archives
return 1
case model.GimmickTypeCageTreasureHunt: // treasure
return 2
case model.GimmickTypeBrokenObelisk, model.GimmickTypeFirstBrokenObelisk:
return 3
case model.GimmickTypeIronGrill:
return 4
case model.GimmickTypeRadioMessage:
return 5
case model.GimmickTypeMapOnlyCageTreasureHunt, model.GimmickTypeMapOnlyHideObelisk:
return 6
case model.GimmickTypeCageIntervalDropItem, model.GimmickTypeMapOnlyCageIntervalDrop:
return 7 // birds — bottom
}
return 8
}
type gimmickTypeTables struct {
byGimmick map[int32]model.GimmickType
bySequence map[int32]model.GimmickType
}
var gimmickTypes = sync.OnceValue(loadGimmickTypes)
func loadGimmickTypes() gimmickTypeTables {
empty := gimmickTypeTables{
byGimmick: map[int32]model.GimmickType{},
bySequence: map[int32]model.GimmickType{},
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "type tables")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "type tables")
if !ok {
return empty
}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "type tables")
if !ok {
return empty
}
byGimmick := make(map[int32]model.GimmickType, len(gimmicks))
for _, g := range gimmicks {
byGimmick[g.GimmickId] = model.GimmickType(g.GimmickType)
}
typeByGroup := make(map[int32]model.GimmickType, len(groups))
for _, grp := range groups {
if _, seen := typeByGroup[grp.GimmickGroupId]; seen {
continue
}
if t, ok := byGimmick[grp.GimmickId]; ok {
typeByGroup[grp.GimmickGroupId] = t
}
}
bySequence := make(map[int32]model.GimmickType, len(sequences))
for _, seq := range sequences {
if t, ok := typeByGroup[seq.GimmickGroupId]; ok {
bySequence[seq.GimmickSequenceId] = t
}
}
return gimmickTypeTables{byGimmick: byGimmick, bySequence: bySequence}
}
func gimmickSequenceTypes() map[int32]model.GimmickType {
return gimmickTypes().bySequence
}
func LoadGimmickSequenceRanks() map[int32]int {
types := gimmickSequenceTypes()
out := make(map[int32]int, len(types))
for sid, t := range types {
out[sid] = gimmickTypeRank(t)
}
return out
}
type SequenceReward struct {
PossessionType int32
PossessionId int32
Count int32
} }
type GimmickCatalog struct { type GimmickCatalog struct {
schedules []gimmickScheduleEntry schedules []gimmickScheduleEntry
hiddenSequences map[int32]bool // GimmickSequenceId -> report/cage-memory
sequenceRewards map[int32][]SequenceReward // GimmickSequenceId -> clear rewards
gimmickTypes map[int32]model.GimmickType
cageMemoryItems map[int32]int32 // CageMemory GimmickId -> ImportantItemId (type 4)
hiddenBirdRewards map[GimmickOrnamentRef]SequenceReward
} }
func LoadGimmickCatalog(resolver *ConditionResolver) (*GimmickCatalog, error) { func LoadGimmickCatalog(resolver *ConditionResolver, cageOrnaments *CageOrnamentCatalog) (*GimmickCatalog, error) {
rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule") rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule")
if err != nil { if err != nil {
return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err) return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err)
} }
entries := make([]gimmickScheduleEntry, 0, len(rows)) seqTypes := gimmickSequenceTypes()
hiddenSeq := make(map[int32]bool, len(seqTypes))
for sid, t := range seqTypes {
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory {
hiddenSeq[sid] = true
}
}
// Pick rule: prefer schedules with EndDatetime in the future (so the client's
// in-cage filter doesn't drop them), then by lowest StartDatetime (longest
// historical validity, tends to carry the simplest RequiredQuestId), tie-broken
// by lowest ScheduleId for determinism. The future-end preference matters for
// "Fickle Black Birds" (type 1) where real expiry dates vary; other types have
// EndDatetime = 9999-03-31 so the preference is a no-op.
now := gametime.NowMillis()
bestBySeq := make(map[int32]gimmickScheduleEntry, len(rows))
for _, r := range rows { for _, r := range rows {
entry := gimmickScheduleEntry{ entry := gimmickScheduleEntry{
ScheduleId: r.GimmickSequenceScheduleId, ScheduleId: r.GimmickSequenceScheduleId,
StartDatetime: r.StartDatetime, StartDatetime: r.StartDatetime,
EndDatetime: r.EndDatetime, EndDatetime: r.EndDatetime,
FirstSequenceId: r.FirstGimmickSequenceId, FirstSequenceId: r.FirstGimmickSequenceId,
IsHidden: hiddenSeq[r.FirstGimmickSequenceId],
Rank: gimmickTypeRank(seqTypes[r.FirstGimmickSequenceId]),
} }
if r.ReleaseEvaluateConditionId != 0 { if r.ReleaseEvaluateConditionId != 0 {
if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok { if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok {
entry.RequiredQuestId = qid entry.RequiredQuestId = qid
} }
} }
entries = append(entries, entry) if existing, ok := bestBySeq[entry.FirstSequenceId]; ok {
existingFuture := existing.EndDatetime > now
entryFuture := entry.EndDatetime > now
if existingFuture != entryFuture {
// Future-end schedule wins over expired one.
if existingFuture {
continue
}
} else if existing.StartDatetime < entry.StartDatetime ||
(existing.StartDatetime == entry.StartDatetime && existing.ScheduleId <= entry.ScheduleId) {
continue
}
}
bestBySeq[entry.FirstSequenceId] = entry
} }
log.Printf("gimmick catalog loaded: %d schedules", len(entries)) entries := make([]gimmickScheduleEntry, 0, len(bestBySeq))
return &GimmickCatalog{schedules: entries}, nil hiddenCount := 0
for _, entry := range bestBySeq {
if entry.IsHidden {
hiddenCount++
}
entries = append(entries, entry)
}
dedupedCount := len(rows) - len(entries)
// Sort by (Rank, ScheduleId) so ActiveScheduleKeys returns the priority order
// directly: treasure/lost-archives/hidden-missions first, birds last. This is
// what lets InitSequenceSchedule's 1000-row cap trim from the bottom.
sort.Slice(entries, func(i, j int) bool {
if entries[i].Rank != entries[j].Rank {
return entries[i].Rank < entries[j].Rank
}
return entries[i].ScheduleId < entries[j].ScheduleId
})
sequenceRewards := loadGimmickSequenceRewards()
cageMemoryItems := loadCageMemoryImportantItems(gimmickTypes().byGimmick)
hiddenBirdRewards := loadHiddenBirdRewards(cageOrnaments)
log.Printf("gimmick catalog loaded: %d schedules (%d hidden-content, %d duplicates dropped), %d reward sequences, %d cage-memory items, %d hidden-bird rewards",
len(entries), hiddenCount, dedupedCount, len(sequenceRewards), len(cageMemoryItems), len(hiddenBirdRewards))
return &GimmickCatalog{
schedules: entries,
hiddenSequences: hiddenSeq,
sequenceRewards: sequenceRewards,
gimmickTypes: gimmickTypes().byGimmick,
cageMemoryItems: cageMemoryItems,
hiddenBirdRewards: hiddenBirdRewards,
}, nil
}
// HiddenBirdReward returns the per-tap reward for a MAP_ONLY_CAGE_TREASURE_HUNT
// ("Hidden Black Birds", type 7) ornament. Returns false if there's no mapping
// (e.g. the ornament view has no corresponding cage-ornament-reward entry).
func (c *GimmickCatalog) HiddenBirdReward(gimmickId, ornamentIndex int32) (SequenceReward, bool) {
r, ok := c.hiddenBirdRewards[GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex}]
return r, ok
}
// loadHiddenBirdRewards resolves (GimmickId, OrnamentIndex) -> CageOrnamentReward for
// every type-7 ("Hidden Black Birds") gimmick. The mapping is structural:
//
// m_gimmick (GimmickType == 7) -> GimmickOrnamentGroupId
// m_gimmick_ornament (matching group) -> GimmickOrnamentViewId
// m_cage_ornament (CageOrnamentId == ViewId) -> CageOrnamentRewardId
// m_cage_ornament_reward (matching id) -> PossessionType / PossessionId / Count
//
// 110 of 114 type-7 ornaments have a matching m_cage_ornament row in the current
// data; the rest log a warning and are silently skipped so the player just gets
// no reward on those (no crash).
func loadHiddenBirdRewards(cageOrnaments *CageOrnamentCatalog) map[GimmickOrnamentRef]SequenceReward {
empty := map[GimmickOrnamentRef]SequenceReward{}
if cageOrnaments == nil {
return empty
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "hidden-bird rewards")
if !ok {
return empty
}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "hidden-bird rewards")
if !ok {
return empty
}
gimmicksByGroup := make(map[int32][]int32)
for _, g := range gimmicks {
if model.GimmickType(g.GimmickType) == model.GimmickTypeMapOnlyCageTreasureHunt {
gimmicksByGroup[g.GimmickOrnamentGroupId] = append(gimmicksByGroup[g.GimmickOrnamentGroupId], g.GimmickId)
}
}
out := make(map[GimmickOrnamentRef]SequenceReward)
missing := 0
for _, o := range ornaments {
gids, ok := gimmicksByGroup[o.GimmickOrnamentGroupId]
if !ok {
continue
}
reward, ok := cageOrnaments.LookupReward(o.GimmickOrnamentViewId)
if !ok {
missing++
continue
}
entry := SequenceReward{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
}
for _, gid := range gids {
out[GimmickOrnamentRef{GimmickId: gid, OrnamentIndex: o.GimmickOrnamentIndex}] = entry
}
}
if missing > 0 {
log.Printf("[gimmick] %d hidden-bird ornaments had no m_cage_ornament_reward row", missing)
}
return out
}
func (c *GimmickCatalog) GimmickType(gimmickId int32) model.GimmickType {
return c.gimmickTypes[gimmickId]
}
// CageMemoryImportantItem returns the ImportantItemId (type 4) that the library uses
// to mark a tapped cage memory as collected, given the world-gimmick id. The mapping
// is derived from m_gimmick_additional_asset texture suffixes — see
// loadCageMemoryImportantItems.
func (c *GimmickCatalog) CageMemoryImportantItem(gimmickId int32) (int32, bool) {
id, ok := c.cageMemoryItems[gimmickId]
return id, ok
}
// importantItemTypeCageMemory mirrors EntityMImportantItem.ImportantItemType==4 — the
// CageMemory entry that the library's HasCageMemory check resolves to.
const importantItemTypeCageMemory int32 = 4
func loadCageMemoryImportantItems(typeByGimmick map[int32]model.GimmickType) map[int32]int32 {
empty := map[int32]int32{}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "cage-memory items")
if !ok {
return empty
}
chapters, ok := readGimmickTable[EntityMMainQuestChapter]("m_main_quest_chapter", "cage-memory items")
if !ok {
return empty
}
routes, ok := readGimmickTable[EntityMMainQuestRoute]("m_main_quest_route", "cage-memory items")
if !ok {
return empty
}
cageMemories, ok := readGimmickTable[EntityMCageMemory]("m_cage_memory", "cage-memory items")
if !ok {
return empty
}
items, ok := readGimmickTable[EntityMImportantItem]("m_important_item", "cage-memory items")
if !ok {
return empty
}
chapterByOrnamentGroup := make(map[int32]int32, len(ornaments))
for _, o := range ornaments {
if _, seen := chapterByOrnamentGroup[o.GimmickOrnamentGroupId]; seen {
continue
}
chapterByOrnamentGroup[o.GimmickOrnamentGroupId] = o.ChapterId
}
routeByChapter := make(map[int32]int32, len(chapters))
for _, c := range chapters {
routeByChapter[c.MainQuestChapterId] = c.MainQuestRouteId
}
seasonByRoute := make(map[int32]int32, len(routes))
for _, r := range routes {
seasonByRoute[r.MainQuestRouteId] = r.MainQuestSeasonId
}
cmsBySeason := make(map[int32][]int32)
for _, c := range cageMemories {
cmsBySeason[c.MainQuestSeasonId] = append(cmsBySeason[c.MainQuestSeasonId], c.CageMemoryId)
}
for s := range cmsBySeason {
sort.Slice(cmsBySeason[s], func(i, j int) bool { return cmsBySeason[s][i] < cmsBySeason[s][j] })
}
itemByCageMemory := make(map[int32]int32)
for _, it := range items {
if it.ImportantItemType == importantItemTypeCageMemory && it.CageMemoryId != 0 {
itemByCageMemory[it.CageMemoryId] = it.ImportantItemId
}
}
gimmicksByRoute := make(map[int32][]int32)
for gid, t := range typeByGimmick {
if t != model.GimmickTypeCageMemory {
continue
}
chapter, ok := chapterByOrnamentGroup[gid]
if !ok {
log.Printf("[gimmick] cage-memory %d has no ornament row, skipping mapping", gid)
continue
}
route, ok := routeByChapter[chapter]
if !ok {
log.Printf("[gimmick] cage-memory %d chapter %d has no route, skipping mapping", gid, chapter)
continue
}
gimmicksByRoute[route] = append(gimmicksByRoute[route], gid)
}
for r := range gimmicksByRoute {
sort.Slice(gimmicksByRoute[r], func(i, j int) bool { return gimmicksByRoute[r][i] < gimmicksByRoute[r][j] })
}
out := make(map[int32]int32)
for route, gids := range gimmicksByRoute {
season, ok := seasonByRoute[route]
if !ok {
log.Printf("[gimmick] route %d has no season, skipping %d cage-memory gimmicks", route, len(gids))
continue
}
seasonCms := cmsBySeason[season]
for i, gid := range gids {
if i >= len(seasonCms) {
log.Printf("[gimmick] route %d (season %d) has %d cage-memory gimmicks but only %d cage memories; gimmick %d skipped",
route, season, len(gids), len(seasonCms), gid)
continue
}
cageMemoryId := seasonCms[i]
itemId, ok := itemByCageMemory[cageMemoryId]
if !ok {
log.Printf("[gimmick] cage memory %d (gimmick %d) has no m_important_item row (type 4), skipping",
cageMemoryId, gid)
continue
}
out[gid] = itemId
}
}
return out
}
func loadGimmickSequenceRewards() map[int32][]SequenceReward {
empty := map[int32][]SequenceReward{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence rewards")
if !ok {
return empty
}
rewardGroups, ok := readGimmickTable[EntityMGimmickSequenceRewardGroup]("m_gimmick_sequence_reward_group", "sequence rewards")
if !ok {
return empty
}
rewardsByGroup := make(map[int32][]SequenceReward)
for _, rg := range rewardGroups {
if rg.PossessionType == 0 || rg.PossessionId == 0 {
continue
}
rewardsByGroup[rg.GimmickSequenceRewardGroupId] = append(
rewardsByGroup[rg.GimmickSequenceRewardGroupId], SequenceReward{
PossessionType: rg.PossessionType,
PossessionId: rg.PossessionId,
Count: rg.Count,
})
}
rewardsBySequence := make(map[int32][]SequenceReward, len(sequences))
for _, seq := range sequences {
if rewards := rewardsByGroup[seq.GimmickSequenceRewardGroupId]; len(rewards) > 0 {
rewardsBySequence[seq.GimmickSequenceId] = rewards
}
}
return rewardsBySequence
}
func (c *GimmickCatalog) IsHiddenSequence(sequenceId int32) bool {
return c.hiddenSequences[sequenceId]
}
func (c *GimmickCatalog) SequenceRewards(sequenceId int32) []SequenceReward {
return c.sequenceRewards[sequenceId]
} }
func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey { func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey {
var keys []store.GimmickSequenceKey keys := make([]store.GimmickSequenceKey, 0, len(c.schedules))
for _, s := range c.schedules { for _, s := range c.schedules {
if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime { if nowMillis < s.StartDatetime {
continue continue // future schedules still skipped
} }
if s.RequiredQuestId != 0 { if !s.IsHidden && s.RequiredQuestId != 0 {
q, ok := user.Quests[s.RequiredQuestId] q, ok := user.Quests[s.RequiredQuestId]
if !ok || q.QuestStateType != model.UserQuestStateTypeCleared { if !ok || q.QuestStateType != model.UserQuestStateTypeCleared {
continue continue
@@ -66,3 +472,126 @@ func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int6
} }
return keys return keys
} }
type GimmickOrnamentRef struct {
GimmickId int32
OrnamentIndex int32
}
func LoadGimmickOrnamentRefs() map[int32][]GimmickOrnamentRef {
empty := map[int32][]GimmickOrnamentRef{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "ornament refs")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "ornament refs")
if !ok {
return empty
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "ornament refs")
if !ok {
return empty
}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "ornament refs")
if !ok {
return empty
}
indicesByOrnamentGroup := make(map[int32][]int32)
for _, o := range ornaments {
indicesByOrnamentGroup[o.GimmickOrnamentGroupId] = append(
indicesByOrnamentGroup[o.GimmickOrnamentGroupId], o.GimmickOrnamentIndex)
}
ornamentGroupByGimmick := make(map[int32]int32, len(gimmicks))
for _, g := range gimmicks {
ornamentGroupByGimmick[g.GimmickId] = g.GimmickOrnamentGroupId
}
gimmicksByGroup := make(map[int32][]int32)
for _, grp := range groups {
gimmicksByGroup[grp.GimmickGroupId] = append(gimmicksByGroup[grp.GimmickGroupId], grp.GimmickId)
}
refsBySequence := make(map[int32][]GimmickOrnamentRef, len(sequences))
for _, seq := range sequences {
var refs []GimmickOrnamentRef
for _, gimmickId := range gimmicksByGroup[seq.GimmickGroupId] {
for _, ornamentIndex := range indicesByOrnamentGroup[ornamentGroupByGimmick[gimmickId]] {
refs = append(refs, GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex})
}
}
if len(refs) > 0 {
refsBySequence[seq.GimmickSequenceId] = refs
}
}
log.Printf("gimmick ornament refs loaded: %d sequences", len(refsBySequence))
return refsBySequence
}
func LoadHiddenGimmickSequenceIDs() map[int32]bool {
types := gimmickSequenceTypes()
out := make(map[int32]bool, len(types))
for sid, t := range types {
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory || t == model.GimmickTypeMapOnlyCageTreasureHunt {
out[sid] = true
}
}
return out
}
func LoadBirdGimmickIDs() map[int32]bool {
byGimmick := gimmickTypes().byGimmick
out := make(map[int32]bool, len(byGimmick))
for gid, t := range byGimmick {
if t == model.GimmickTypeCageIntervalDropItem || t == model.GimmickTypeMapOnlyCageIntervalDrop {
out[gid] = true
}
}
return out
}
func LoadGimmickSequenceChains() map[int32][]int32 {
empty := map[int32][]int32{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence chains")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickSequenceGroup]("m_gimmick_sequence_group", "sequence chains")
if !ok {
return empty
}
membersByGroup := make(map[int32][]int32)
for _, g := range groups {
membersByGroup[g.GimmickSequenceGroupId] = append(membersByGroup[g.GimmickSequenceGroupId], g.GimmickSequenceId)
}
nextGroupBySequence := make(map[int32]int32, len(sequences))
for _, seq := range sequences {
nextGroupBySequence[seq.GimmickSequenceId] = seq.NextGimmickSequenceGroupId
}
chains := make(map[int32][]int32, len(sequences))
for _, seq := range sequences {
start := seq.GimmickSequenceId
seen := map[int32]bool{start: true}
chain := []int32{start}
for queue := []int32{start}; len(queue) > 0; {
cur := queue[0]
queue = queue[1:]
nextGroup := nextGroupBySequence[cur]
if nextGroup == 0 {
continue
}
for _, member := range membersByGroup[nextGroup] {
if !seen[member] {
seen[member] = true
chain = append(chain, member)
queue = append(queue, member)
}
}
}
chains[start] = chain
}
return chains
}
+103
View File
@@ -0,0 +1,103 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type HiddenStoryRequirements struct {
MissionIds []int32
QuestMissions []store.QuestMissionKey
}
func LoadHiddenStoryRequirements() HiddenStoryRequirements {
var empty HiddenStoryRequirements
gimmicks, err := utils.ReadTable[EntityMGimmick]("m_gimmick")
if err != nil {
log.Printf("[hiddenstory] m_gimmick unavailable: %v", err)
return empty
}
conditions, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
if err != nil {
log.Printf("[hiddenstory] m_evaluate_condition unavailable: %v", err)
return empty
}
valueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
if err != nil {
log.Printf("[hiddenstory] m_evaluate_condition_value_group unavailable: %v", err)
return empty
}
condById := make(map[int32]EntityMEvaluateCondition, len(conditions))
for _, c := range conditions {
condById[c.EvaluateConditionId] = c
}
valuesByGroup := make(map[int32]map[int32]int64)
for _, vg := range valueGroups {
g := valuesByGroup[vg.EvaluateConditionValueGroupId]
if g == nil {
g = make(map[int32]int64)
valuesByGroup[vg.EvaluateConditionValueGroupId] = g
}
g[vg.GroupIndex] = vg.Value
}
missionSet := make(map[int32]struct{})
questMissionSet := make(map[store.QuestMissionKey]struct{})
seen := make(map[int32]bool)
var resolve func(conditionId int32, depth int)
resolve = func(conditionId int32, depth int) {
if conditionId == 0 || depth > 16 || seen[conditionId] {
return
}
seen[conditionId] = true
c, ok := condById[conditionId]
if !ok {
return
}
group := valuesByGroup[c.EvaluateConditionValueGroupId]
switch model.EvaluateConditionFunctionType(c.EvaluateConditionFunctionType) {
case model.EvaluateConditionFunctionTypeRecursion:
// Value-group entries are sub-condition ids; satisfying all leaves makes
// both AND and OR recursion conditions evaluate true.
for _, sub := range group {
resolve(int32(sub), depth+1)
}
case model.EvaluateConditionFunctionTypeMissionClear:
if v, ok := group[defaultGroupIndex]; ok {
missionSet[int32(v)] = struct{}{}
}
case model.EvaluateConditionFunctionTypeQuestMissionClear:
questId, ok1 := group[1]
questMissionId, ok2 := group[2]
if ok1 && ok2 {
questMissionSet[store.QuestMissionKey{
QuestId: int32(questId),
QuestMissionId: int32(questMissionId),
}] = struct{}{}
}
}
}
for _, g := range gimmicks {
switch model.GimmickType(g.GimmickType) {
case model.GimmickTypeReport, model.GimmickTypeCageMemory:
resolve(g.ClearEvaluateConditionId, 0)
}
}
req := HiddenStoryRequirements{}
for id := range missionSet {
req.MissionIds = append(req.MissionIds, id)
}
for key := range questMissionSet {
req.QuestMissions = append(req.QuestMissions, key)
}
log.Printf("hidden-story requirements: %d missions, %d quest-missions", len(req.MissionIds), len(req.QuestMissions))
return req
}
+225
View File
@@ -0,0 +1,225 @@
package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/utils"
)
type LabyrinthChapter struct {
EventQuestChapterId int32
LatestSeasonNumber int32
StageOrders []int32
}
type LabyrinthStageTier struct {
QuestMissionClearCount int32
Rewards []RewardItem
}
type LabyrinthSeasonMilestone struct {
HeadQuestId int32
HeadStageOrder int32
Rewards []RewardItem
}
type labyrinthStageKey struct {
ChapterId int32
StageOrder int32
}
type LabyrinthCatalog struct {
ChaptersByOrder []LabyrinthChapter
ClearRewardsByStage map[labyrinthStageKey][]RewardItem
AccumTiersByStage map[labyrinthStageKey][]LabyrinthStageTier
SeasonMilestonesByChapter map[int32][]LabyrinthSeasonMilestone
}
func (c *LabyrinthCatalog) StageClearReward(chapterId, stageOrder int32) []RewardItem {
return c.ClearRewardsByStage[labyrinthStageKey{chapterId, stageOrder}]
}
func (c *LabyrinthCatalog) CollectAccumulationRewards(chapterId, stageOrder, oldCount, targetCount int32) ([]RewardItem, int32) {
var items []RewardItem
highest := int32(0)
for _, t := range c.AccumTiersByStage[labyrinthStageKey{chapterId, stageOrder}] {
if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount {
items = append(items, t.Rewards...)
if t.QuestMissionClearCount > highest {
highest = t.QuestMissionClearCount
}
}
}
return items, highest
}
func (c *LabyrinthCatalog) SeasonMilestones(chapterId int32) []LabyrinthSeasonMilestone {
return c.SeasonMilestonesByChapter[chapterId]
}
func LoadLabyrinthCatalog() *LabyrinthCatalog {
seasonRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeason]("m_event_quest_labyrinth_season")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_season unavailable, labyrinth disabled: %v", err)
return &LabyrinthCatalog{}
}
stageRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStage]("m_event_quest_labyrinth_stage")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_stage unavailable, labyrinth disabled: %v", err)
return &LabyrinthCatalog{}
}
// chapterId -> highest SeasonNumber
latestSeason := make(map[int32]int32)
for _, r := range seasonRows {
if r.SeasonNumber > latestSeason[r.EventQuestChapterId] {
latestSeason[r.EventQuestChapterId] = r.SeasonNumber
}
}
// chapterId -> stage orders
stagesByChapter := make(map[int32][]int32)
for _, r := range stageRows {
stagesByChapter[r.EventQuestChapterId] = append(stagesByChapter[r.EventQuestChapterId], r.StageOrder)
}
chapters := make([]LabyrinthChapter, 0, len(latestSeason))
for chapterId, season := range latestSeason {
stages := stagesByChapter[chapterId]
sort.Slice(stages, func(i, j int) bool { return stages[i] < stages[j] })
chapters = append(chapters, LabyrinthChapter{
EventQuestChapterId: chapterId,
LatestSeasonNumber: season,
StageOrders: stages,
})
}
sort.Slice(chapters, func(i, j int) bool {
return chapters[i].EventQuestChapterId < chapters[j].EventQuestChapterId
})
clearRewards, accumTiers, seasonMilestones := loadLabyrinthRewards(seasonRows, stageRows)
log.Printf("labyrinth catalog loaded: %d chapters, %d stages with clear rewards, %d with accumulation rewards, %d chapters with season rewards",
len(chapters), len(clearRewards), len(accumTiers), len(seasonMilestones))
return &LabyrinthCatalog{
ChaptersByOrder: chapters,
ClearRewardsByStage: clearRewards,
AccumTiersByStage: accumTiers,
SeasonMilestonesByChapter: seasonMilestones,
}
}
func loadLabyrinthRewards(seasonRows []EntityMEventQuestLabyrinthSeason, stageRows []EntityMEventQuestLabyrinthStage) (
clearRewards map[labyrinthStageKey][]RewardItem,
accumTiers map[labyrinthStageKey][]LabyrinthStageTier,
seasonMilestones map[int32][]LabyrinthSeasonMilestone,
) {
rewardGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthRewardGroup]("m_event_quest_labyrinth_reward_group")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_reward_group unavailable, rewards disabled: %v", err)
return nil, nil, nil
}
// reward group id -> reward items
itemsByRewardGroup := make(map[int32][]RewardItem)
for _, r := range rewardGroupRows {
itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = append(itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], RewardItem{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
// per-stage one-time clear reward
clearRewards = make(map[labyrinthStageKey][]RewardItem)
for _, r := range stageRows {
if r.StageClearRewardGroupId == 0 {
continue
}
if items := itemsByRewardGroup[r.StageClearRewardGroupId]; len(items) > 0 {
clearRewards[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = items
}
}
if accumGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStageAccumulationRewardGroup]("m_event_quest_labyrinth_stage_accumulation_reward_group"); err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_stage_accumulation_reward_group unavailable, accumulation rewards disabled: %v", err)
} else {
// accumulation group id -> tiers (threshold + resolved reward items)
tiersByGroup := make(map[int32][]LabyrinthStageTier)
for _, r := range accumGroupRows {
tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId], LabyrinthStageTier{
QuestMissionClearCount: r.QuestMissionClearCount,
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
})
}
accumTiers = make(map[labyrinthStageKey][]LabyrinthStageTier)
for _, r := range stageRows {
if r.StageAccumulationRewardGroupId == 0 {
continue
}
tiers := tiersByGroup[r.StageAccumulationRewardGroupId]
sort.Slice(tiers, func(i, j int) bool {
return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount
})
accumTiers[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = tiers
}
}
// per-chapter season-reward milestones
if seasonRewardRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeasonRewardGroup]("m_event_quest_labyrinth_season_reward_group"); err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_season_reward_group unavailable, season rewards disabled: %v", err)
} else {
seasonMilestones = buildLabyrinthSeasonMilestones(seasonRows, seasonRewardRows, itemsByRewardGroup)
}
return clearRewards, accumTiers, seasonMilestones
}
func buildLabyrinthSeasonMilestones(
seasonRows []EntityMEventQuestLabyrinthSeason,
seasonRewardRows []EntityMEventQuestLabyrinthSeasonRewardGroup,
itemsByRewardGroup map[int32][]RewardItem,
) map[int32][]LabyrinthSeasonMilestone {
// chapter -> SeasonRewardGroupId (all seasons of a chapter share one)
groupByChapter := make(map[int32]int32)
for _, r := range seasonRows {
groupByChapter[r.EventQuestChapterId] = r.SeasonRewardGroupId
}
// SeasonRewardGroupId -> its rows, in table order
rowsByGroup := make(map[int32][]EntityMEventQuestLabyrinthSeasonRewardGroup)
for _, r := range seasonRewardRows {
rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId] = append(rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId], r)
}
milestones := make(map[int32][]LabyrinthSeasonMilestone)
for chapterId, seasonGroupId := range groupByChapter {
rows := rowsByGroup[seasonGroupId]
if len(rows) == 0 {
continue
}
// rank distinct reward-group ids ascending -> 1-based head stage order
stageByRewardGroup := make(map[int32]int32)
var distinct []int32
for _, r := range rows {
if _, seen := stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId]; !seen {
stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = 0
distinct = append(distinct, r.EventQuestLabyrinthRewardGroupId)
}
}
sort.Slice(distinct, func(i, j int) bool { return distinct[i] < distinct[j] })
for i, gid := range distinct {
stageByRewardGroup[gid] = int32(i + 1)
}
list := make([]LabyrinthSeasonMilestone, 0, len(rows))
for _, r := range rows {
list = append(list, LabyrinthSeasonMilestone{
HeadQuestId: r.HeadQuestId,
HeadStageOrder: stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
})
}
milestones[chapterId] = list
}
return milestones
}
+4
View File
@@ -158,5 +158,9 @@ func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) {
id++ id++
} }
} }
// Newer parts groups (PartsGroupId 401-490) use PartsStatusSubLotteryGroupId
// 11/12 for rarities 10/20 instead of 1/2. Same stat pools — alias them.
pool[11] = pool[1]
pool[12] = pool[2]
return defs, pool return defs, pool
} }
+64
View File
@@ -34,7 +34,11 @@ type QuestCatalog struct {
TutorialUnlockConditions []EntityMTutorialUnlockCondition TutorialUnlockConditions []EntityMTutorialUnlockCondition
ChapterLastSceneByQuestId map[int32]int32 ChapterLastSceneByQuestId map[int32]int32
SeasonIdByRouteId map[int32]int32 SeasonIdByRouteId map[int32]int32
RoutesBySeason map[int32][]int32
RouteCompletionQuestId map[int32]int32
BattleOnlyTargetSceneByQuestId map[int32]int32 BattleOnlyTargetSceneByQuestId map[int32]int32
MainQuestChapterIdByQuestId map[int32]int32
EventQuestTypeByChapterId map[int32]int32
UserExpThresholds []int32 UserExpThresholds []int32
CharacterExpThresholds []int32 CharacterExpThresholds []int32
@@ -114,8 +118,53 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
return nil, fmt.Errorf("load main quest route table: %w", err) return nil, fmt.Errorf("load main quest route table: %w", err)
} }
seasonIdByRouteId := make(map[int32]int32, len(routes)) seasonIdByRouteId := make(map[int32]int32, len(routes))
routesBySeason := make(map[int32][]int32, len(routes))
sortOrderByRoute := make(map[int32]int32, len(routes))
for _, r := range routes { for _, r := range routes {
seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId
routesBySeason[r.MainQuestSeasonId] = append(routesBySeason[r.MainQuestSeasonId], r.MainQuestRouteId)
sortOrderByRoute[r.MainQuestRouteId] = r.SortOrder
}
for seasonId, ids := range routesBySeason {
s := ids
sort.Slice(s, func(i, j int) bool { return sortOrderByRoute[s[i]] > sortOrderByRoute[s[j]] })
routesBySeason[seasonId] = s
}
anotherReplayConds, err := utils.ReadTable[EntityMMainQuestRouteAnotherReplayFlowUnlockCondition]("m_main_quest_route_another_replay_flow_unlock_condition")
if err != nil {
return nil, fmt.Errorf("load main quest route another replay flow unlock condition table: %w", err)
}
evaluateConds, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
if err != nil {
return nil, fmt.Errorf("load evaluate condition table: %w", err)
}
valueGroupByConditionId := make(map[int32]int32, len(evaluateConds))
for _, c := range evaluateConds {
valueGroupByConditionId[c.EvaluateConditionId] = c.EvaluateConditionValueGroupId
}
evaluateValueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
if err != nil {
return nil, fmt.Errorf("load evaluate condition value group table: %w", err)
}
valueByGroupId := make(map[int32]int32, len(evaluateValueGroups))
for _, vg := range evaluateValueGroups {
if _, exists := valueByGroupId[vg.EvaluateConditionValueGroupId]; exists {
continue
}
valueByGroupId[vg.EvaluateConditionValueGroupId] = int32(vg.Value)
}
routeCompletionQuestId := make(map[int32]int32, len(anotherReplayConds))
for _, c := range anotherReplayConds {
valueGroupId, ok := valueGroupByConditionId[c.UnlockEvaluateConditionId]
if !ok {
continue
}
questId, ok := valueByGroupId[valueGroupId]
if !ok {
continue
}
routeCompletionQuestId[c.MainQuestRouteId] = questId
} }
firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch") firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch")
@@ -335,12 +384,23 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter
} }
routeIdByQuestId := make(map[int32]int32) routeIdByQuestId := make(map[int32]int32)
mainQuestChapterIdByQuestId := make(map[int32]int32)
for _, sequence := range sequences { for _, sequence := range sequences {
if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok { if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok {
routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId
mainQuestChapterIdByQuestId[sequence.QuestId] = chapter.MainQuestChapterId
} }
} }
eventChapters, err := utils.ReadTable[EntityMEventQuestChapter]("m_event_quest_chapter")
if err != nil {
return nil, fmt.Errorf("load event quest chapter table: %w", err)
}
eventQuestTypeByChapterId := make(map[int32]int32, len(eventChapters))
for _, ec := range eventChapters {
eventQuestTypeByChapterId[ec.EventQuestChapterId] = ec.EventQuestType
}
sortedChapters := make([]EntityMMainQuestChapter, len(chapters)) sortedChapters := make([]EntityMMainQuestChapter, len(chapters))
copy(sortedChapters, chapters) copy(sortedChapters, chapters)
sort.Slice(sortedChapters, func(i, j int) bool { sort.Slice(sortedChapters, func(i, j int) bool {
@@ -539,7 +599,11 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds, TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId, ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId, SeasonIdByRouteId: seasonIdByRouteId,
RoutesBySeason: routesBySeason,
RouteCompletionQuestId: routeCompletionQuestId,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
MainQuestChapterIdByQuestId: mainQuestChapterIdByQuestId,
EventQuestTypeByChapterId: eventQuestTypeByChapterId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1), UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
+18
View File
@@ -0,0 +1,18 @@
package model
type GimmickType int32
const (
GimmickTypeUnknown GimmickType = 0
GimmickTypeCageTreasureHunt GimmickType = 1 // "Fickle Black Birds" — in-cage flick-and-tap birds
GimmickTypeCageIntervalDropItem GimmickType = 2 // "Lost Items" — in-cage 3-dot drops that respawn on interval
GimmickTypeBrokenObelisk GimmickType = 3 // "Broken Scarecrow" (per tip text); zero rows in m_gimmick, unused
GimmickTypeIronGrill GimmickType = 4 // unused (zero rows in m_gimmick), in-game name unknown
GimmickTypeRadioMessage GimmickType = 5 // unused (zero rows in m_gimmick); client has GimmickRadioMessage class but no data
GimmickTypeFirstBrokenObelisk GimmickType = 6 // variant of Broken Scarecrow; zero rows in m_gimmick, unused
GimmickTypeMapOnlyCageTreasureHunt GimmickType = 7 // "Hidden Black Birds" — world-map birds; per-tap reward from m_cage_ornament_reward
GimmickTypeMapOnlyCageIntervalDrop GimmickType = 8 // map-side variant of Lost Items
GimmickTypeReport GimmickType = 9 // "Hidden Stories" — hidden mission markers
GimmickTypeCageMemory GimmickType = 10 // "Lost Archives" — collectible library entries (one-shot ImportantItem type-4)
GimmickTypeMapOnlyHideObelisk GimmickType = 11 // "Stray Scarecrow" — world-map scarecrows (not yet implemented)
)
+19 -5
View File
@@ -2,6 +2,16 @@ package model
import "fmt" import "fmt"
type QuestType int32
const (
QuestTypeUnknown QuestType = 0
QuestTypeMain QuestType = 1
QuestTypeEvent QuestType = 2
QuestTypeExtra QuestType = 3
QuestTypeBigHunt QuestType = 4
)
type QuestFlowType int32 type QuestFlowType int32
const ( const (
@@ -12,10 +22,6 @@ const (
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4
) )
// IsReplayQuestFlowType reports whether the flow type indicates an active
// replay session — either same-route REPLAY_FLOW or cross-route
// ANOTHER_ROUTE_REPLAY_FLOW. Mirrors the client's Story.IsReplayQuestFlowType
// predicate (dump.cs:768202).
func IsReplayQuestFlowType(t int32) bool { func IsReplayQuestFlowType(t int32) bool {
return t == int32(QuestFlowTypeReplayFlow) || return t == int32(QuestFlowTypeReplayFlow) ||
t == int32(QuestFlowTypeAnotherRouteReplayFlow) t == int32(QuestFlowTypeAnotherRouteReplayFlow)
@@ -47,6 +53,15 @@ const (
QuestResultTypeFullResult QuestResultType = 3 QuestResultTypeFullResult QuestResultType = 3
) )
type MissionProgressStatusType int32
const (
MissionProgressStatusTypeUnknown MissionProgressStatusType = 0
MissionProgressStatusTypeInProgress MissionProgressStatusType = 1
MissionProgressStatusTypeClear MissionProgressStatusType = 2
MissionProgressStatusTypeRewardReceived MissionProgressStatusType = 9
)
type QuestSceneType int32 type QuestSceneType int32
const ( const (
@@ -170,7 +185,6 @@ const (
type SideStorySceneIdType int32 type SideStorySceneIdType int32
// Values mirror SideStoryTypes.SceneIdTypes in the client (dump.cs).
const ( const (
SideStorySceneInvalid SideStorySceneIdType = 0 SideStorySceneInvalid SideStorySceneIdType = 0
SideStorySceneIntroduction SideStorySceneIdType = 1 SideStorySceneIntroduction SideStorySceneIdType = 1
+9 -5
View File
@@ -17,7 +17,8 @@ func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, u
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForBigHunt(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -33,13 +34,16 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForBigHunt(questId)
if !isRetired { var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
+56
View File
@@ -0,0 +1,56 @@
package questflow
import (
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/model"
)
func (h *QuestHandler) targetForMain(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeMainQuest,
ChapterId: h.MainQuestChapterIdByQuestId[questId],
}
}
func (h *QuestHandler) targetForEvent(eventChapterId, questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeEventQuest,
EventQuestType: h.EventQuestTypeByChapterId[eventChapterId],
ChapterId: eventChapterId,
}
}
func (h *QuestHandler) targetForExtra(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeExtraQuest}
}
func (h *QuestHandler) targetForBigHunt(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeBigHunt}
}
func (h *QuestHandler) campaignFilter(nowMillis int64) campaign.Filter {
return campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}
}
func (h *QuestHandler) staminaWithCampaign(baseStamina int32, t campaign.QuestTarget, nowMillis int64) int32 {
if h.Campaigns == nil {
return baseStamina
}
return h.Campaigns.QuestStamina(t, h.campaignFilter(nowMillis)).Apply(baseStamina)
}
func (h *QuestHandler) appendBonusDrops(drops []RewardGrant, t campaign.QuestTarget, nowMillis int64) []RewardGrant {
if h.Campaigns == nil {
return drops
}
for _, bd := range h.Campaigns.QuestBonusDrops(t, h.campaignFilter(nowMillis)) {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bd.PossessionType),
PossessionId: bd.PossessionId,
Count: bd.Count,
})
}
return drops
}
+11 -5
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestCh
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForEvent(eventQuestChapterId, questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -42,18 +43,23 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForEvent(eventQuestChapterId, questId)
if !isRetired { var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
h.recordSideStoryLimitContentStatus(user, questId, nowMillis) h.recordSideStoryLimitContentStatus(user, questId, nowMillis)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.EventQuest.CurrentEventQuestChapterId = 0 user.EventQuest.CurrentEventQuestChapterId = 0
user.EventQuest.CurrentQuestId = 0 user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0 user.EventQuest.CurrentQuestSceneId = 0
+11 -5
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, use
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForExtra(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -40,17 +41,22 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForExtra(questId)
if !isRetired { var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.ExtraQuest.CurrentQuestId = 0 user.ExtraQuest.CurrentQuestId = 0
user.ExtraQuest.CurrentQuestSceneId = 0 user.ExtraQuest.CurrentQuestSceneId = 0
user.ExtraQuest.HeadQuestSceneId = 0 user.ExtraQuest.HeadQuestSceneId = 0
+53 -3
View File
@@ -1,6 +1,9 @@
package questflow package questflow
import ( import (
"sort"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -10,6 +13,7 @@ type RewardGrant struct {
PossessionType model.PossessionType PossessionType model.PossessionType
PossessionId int32 PossessionId int32
Count int32 Count int32
IsAutoSale bool
} }
type FinishOutcome struct { type FinishOutcome struct {
@@ -28,10 +32,12 @@ type QuestHandler struct {
Config *masterdata.GameConfig Config *masterdata.GameConfig
Granter *store.PossessionGranter Granter *store.PossessionGranter
SideStoryChapterByEventQuestId map[int32]int32 SideStoryChapterByEventQuestId map[int32]int32
Campaigns *campaign.Catalog
CharacterRebirth *masterdata.CharacterRebirthCatalog
} }
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog) *QuestHandler { func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog, characterRebirth *masterdata.CharacterRebirthCatalog) *QuestHandler {
granter := BuildGranter(catalog) granter := BuildGranter(catalog, config)
var sideStoryChapters map[int32]int32 var sideStoryChapters map[int32]int32
if sideStory != nil { if sideStory != nil {
sideStoryChapters = sideStory.ChapterByEventQuestId sideStoryChapters = sideStory.ChapterByEventQuestId
@@ -41,10 +47,12 @@ func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameCo
Config: config, Config: config,
Granter: granter, Granter: granter,
SideStoryChapterByEventQuestId: sideStoryChapters, SideStoryChapterByEventQuestId: sideStoryChapters,
Campaigns: campaigns,
CharacterRebirth: characterRebirth,
} }
} }
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { func BuildGranter(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *store.PossessionGranter {
costumeById := make(map[int32]store.CostumeRef, len(catalog.CostumeById)) costumeById := make(map[int32]store.CostumeRef, len(catalog.CostumeById))
for id, cm := range catalog.CostumeById { for id, cm := range catalog.CostumeById {
costumeById[id] = store.CostumeRef{CharacterId: cm.CharacterId} costumeById[id] = store.CostumeRef{CharacterId: cm.CharacterId}
@@ -70,12 +78,49 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
releaseConditions[groupId] = conds releaseConditions[groupId] = conds
} }
partsById := make(map[int32]store.PartsRef, len(catalog.PartsById)) partsById := make(map[int32]store.PartsRef, len(catalog.PartsById))
partsVariants := make(map[int32]map[int32][]int32)
for id, p := range catalog.PartsById { for id, p := range catalog.PartsById {
partsById[id] = store.PartsRef{ partsById[id] = store.PartsRef{
PartsGroupId: p.PartsGroupId, PartsGroupId: p.PartsGroupId,
RarityType: p.RarityType,
PartsInitialLotteryId: p.PartsInitialLotteryId,
PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId, PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId,
PartsStatusSubLotteryGroupId: p.PartsStatusSubLotteryGroupId,
}
if partsVariants[p.PartsGroupId] == nil {
partsVariants[p.PartsGroupId] = map[int32][]int32{}
}
partsVariants[p.PartsGroupId][p.RarityType] = append(partsVariants[p.PartsGroupId][p.RarityType], p.PartsId)
}
for _, byRarity := range partsVariants {
for _, ids := range byRarity {
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
} }
} }
partsSubDefs := make(map[int32]store.PartsStatusSubDef, len(catalog.PartsStatusMainById))
for id, d := range catalog.PartsStatusMainById {
var fn func(int32) int32
if f, ok := catalog.FuncResolver.Resolve(d.StatusNumericalFunctionId); ok {
fn = f.Evaluate
}
partsSubDefs[id] = store.PartsStatusSubDef{
StatusKindType: d.StatusKindType,
StatusCalculationType: d.StatusCalculationType,
StatusChangeInitialValue: d.StatusChangeInitialValue,
StatusFunc: fn,
}
}
partsSellPriceL1 := make(map[int32]int32, len(catalog.SellPriceByRarity))
for rarity, fn := range catalog.SellPriceByRarity {
partsSellPriceL1[int32(rarity)] = fn.Evaluate(1)
}
var goldItemId int32
if config != nil {
goldItemId = config.ConsumableItemIdForGold
}
return &store.PossessionGranter{ return &store.PossessionGranter{
CostumeById: costumeById, CostumeById: costumeById,
WeaponById: weaponById, WeaponById: weaponById,
@@ -84,5 +129,10 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
ReleaseConditions: releaseConditions, ReleaseConditions: releaseConditions,
PartsById: partsById, PartsById: partsById,
DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup, DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup,
PartsVariantsByGroupRarity: partsVariants,
PartsSubStatusPool: catalog.SubStatusPool,
PartsSubStatusDefs: partsSubDefs,
PartsSellPriceL1ByRarity: partsSellPriceL1,
GoldConsumableItemId: goldItemId,
} }
} }
+63 -36
View File
@@ -24,7 +24,14 @@ func (h *QuestHandler) initQuestState(user *store.UserState, questId int32) {
} }
func isMainQuestPlayable(quest masterdata.EntityMQuest) bool { func isMainQuestPlayable(quest masterdata.EntityMQuest) bool {
return !quest.IsRunInTheBackground && quest.IsCountedAsQuest if quest.IsRunInTheBackground {
// A background quest is still actively played — and must NOT be
// auto-cleared on start — when it carries battle content (a non-zero
// recommended deck power, e.g. quests 500/515/30515). Pure cutscene
// background quests have RecommendedDeckPower == 0.
return quest.RecommendedDeckPower > 0
}
return quest.IsCountedAsQuest
} }
func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) { func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) {
@@ -54,7 +61,8 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
h.initQuestState(user, questId) h.initQuestState(user, questId)
if quest.Stamina > 0 { if quest.Stamina > 0 {
store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
store.ConsumeStamina(user, stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -84,7 +92,7 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
} }
case isReplayFlow: case isReplayFlow:
h.applyReplayStart(user, questId, isBattleOnly, nowMillis) h.applyReplayStart(user, quest, questId, isBattleOnly, nowMillis)
return return
} }
@@ -131,9 +139,7 @@ func snapshotMainQuestIfNeeded(user *store.UserState) {
} }
} }
// Preserve CurrentQuestFlowType when HandleReplayFlowSceneProgress already func (h *QuestHandler) applyReplayStart(user *store.UserState, quest masterdata.EntityMQuest, questId int32, isBattleOnly bool, nowMillis int64) {
// set it: replay-variant ids (30000+) aren't in RouteIdByQuestId.
func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, isBattleOnly bool, nowMillis int64) {
flowType := h.replayFlowTypeFromQuestId(user, questId) flowType := h.replayFlowTypeFromQuestId(user, questId)
if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) { if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) {
flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType) flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType)
@@ -142,12 +148,26 @@ func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, is
user.MainQuest.LatestVersion = nowMillis user.MainQuest.LatestVersion = nowMillis
questState := user.Quests[questId] questState := user.Quests[questId]
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v current=%d head=%d", if isMainQuestPlayable(quest) {
questId, flowType, isBattleOnly, questState.QuestStateType = model.UserQuestStateTypeActive
user.Quests[questId] = questState
} else {
if questState.QuestStateType != model.UserQuestStateTypeCleared {
questState.QuestStateType = model.UserQuestStateTypeCleared
questState.ClearCount++
questState.DailyClearCount++
questState.LastClearDatetime = nowMillis
}
user.Quests[questId] = questState
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
h.advanceReplayFlowScene(user, sceneIds[0])
}
}
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v playable=%v current=%d head=%d",
questId, flowType, isBattleOnly, isMainQuestPlayable(quest),
user.MainQuest.ReplayFlowCurrentQuestSceneId, user.MainQuest.ReplayFlowCurrentQuestSceneId,
user.MainQuest.ReplayFlowHeadQuestSceneId) user.MainQuest.ReplayFlowHeadQuestSceneId)
} }
@@ -166,8 +186,8 @@ func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) { func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) {
questState := user.Quests[questId] questState := user.Quests[questId]
h.applyExpAndGoldRewards(user, questId, nowMillis)
if !questState.IsRewardGranted { if !questState.IsRewardGranted {
h.applyExpAndGoldRewards(user, questId, nowMillis)
if !wasReplay { if !wasReplay {
h.applyFirstClearItemRewards(user, questId, nowMillis) h.applyFirstClearItemRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
@@ -184,9 +204,8 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o
} }
questState.IsRewardGranted = true questState.IsRewardGranted = true
} }
for _, drop := range outcome.DropRewards { raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings)
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis) h.grantDropRewards(user, outcome.DropRewards, raritySet, rankSet, nowMillis)
}
for _, reward := range outcome.ReplayFlowFirstClearRewards { for _, reward := range outcome.ReplayFlowFirstClearRewards {
h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis) h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis)
} }
@@ -221,6 +240,17 @@ func (h *QuestHandler) finalizeChainPreviousQuest(user *store.UserState, questId
log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId) log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId)
} }
func restoreClearedAfterRetire(user *store.UserState, questId int32, isRetired bool) {
if !isRetired {
return
}
qs := user.Quests[questId]
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
qs.QuestStateType = model.UserQuestStateTypeCleared
user.Quests[questId] = qs
}
}
func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome { func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId] quest, ok := h.QuestById[questId]
if !ok { if !ok {
@@ -229,36 +259,33 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
h.initQuestState(user, questId) h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId)
wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
wasMenuReplay := user.MainQuest.SavedContext.Active wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired { var outcome FinishOutcome
if !isRetired && !isAnnihilated {
outcome = h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay) h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay)
if isMainQuestPlayable(quest) && !wasMenuReplay { // A replay-flow finish must NOT move the MainFlow scene pointer: the
// finished quest is a replay-variant (30000+) with no chapter, so a
// replay scene left in CurrentQuestSceneId makes the client world map's
// CalculatorWorldMap.GetCurrentSeasonId resolve chapter 0 and NRE. The
// replay's own position is tracked in ReplayFlowCurrentQuestSceneId.
if isMainQuestPlayable(quest) && !wasMenuReplay && !wasReplay {
lastSceneId := h.getLastMainFlowSceneId(questId) lastSceneId := h.getLastMainFlowSceneId(questId)
h.advanceMainFlowScene(user, questId, lastSceneId) h.advanceMainFlowScene(user, questId, lastSceneId)
} }
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
// On retire of a previously-cleared quest (cage Menu Pick replay or restoreClearedAfterRetire(user, questId, isRetired)
// Map Play replay), HandleQuestStart marked QuestStateType=Active for
// the run. With applyQuestVictory skipped on retire, that Active sticks
// and the cage UI shows the quest as locked. Restore Cleared.
if isRetired {
qs := user.Quests[questId]
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
qs.QuestStateType = model.UserQuestStateTypeCleared
user.Quests[questId] = qs
}
}
user.MainQuest.ProgressQuestSceneId = 0 user.MainQuest.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0
@@ -297,21 +324,21 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId))
} }
target := h.targetForMain(questId)
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, skipCount, maxMillis, nowMillis) perSkipStamina := h.staminaWithCampaign(questDef.Stamina, target, nowMillis)
store.ConsumeStamina(user, perSkipStamina*skipCount, maxMillis, nowMillis)
skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket
user.ConsumableItems[skipTicketId] -= skipCount user.ConsumableItems[skipTicketId] -= skipCount
if user.ConsumableItems[skipTicketId] < 0 { if user.ConsumableItems[skipTicketId] < 0 {
user.ConsumableItems[skipTicketId] = 0 user.ConsumableItems[skipTicketId] = 0
} }
raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings)
var allDrops []RewardGrant var allDrops []RewardGrant
for range skipCount { for range skipCount {
drops := h.computeDropRewards(questDef) drops := h.computeDropRewards(questDef, target, nowMillis)
for _, drop := range drops { h.grantDropRewards(user, drops, raritySet, rankSet, nowMillis)
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
allDrops = append(allDrops, drops...) allDrops = append(allDrops, drops...)
if questDef.Gold != 0 { if questDef.Gold != 0 {
+73 -24
View File
@@ -3,7 +3,10 @@ package questflow
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"strings"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
@@ -40,7 +43,7 @@ func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef m
return rewardGroupId return rewardGroupId
} }
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome { func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32, target campaign.QuestTarget, nowMillis int64) FinishOutcome {
outcome := FinishOutcome{} outcome := FinishOutcome{}
questState, ok := user.Quests[questId] questState, ok := user.Quests[questId]
if !ok { if !ok {
@@ -123,25 +126,76 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
} }
outcome.DropRewards = h.computeDropRewards(questDef) outcome.DropRewards = h.computeDropRewards(questDef, target, nowMillis)
return outcome return outcome
} }
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest) []RewardGrant { var autoSaleRarityTiers = map[int32]bool{10: true, 20: true, 30: true, 40: true, 50: true}
if questDef.QuestPickupRewardGroupId == 0 {
return nil // Rarity tiers (10..50) and ranks (1..5) are disjoint, so the delimited values
} // are classified by range — independent of the client's map key or delimiter.
var drops []RewardGrant func parseAutoSaleRules(settings map[int32]store.AutoSaleSettingState) (raritySet, rankSet map[int32]bool) {
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] { raritySet = map[int32]bool{}
if bdr, ok := h.BattleDropRewardById[dropId]; ok { rankSet = map[int32]bool{}
drops = append(drops, RewardGrant{ for _, s := range settings {
PossessionType: model.PossessionType(bdr.PossessionType), for _, n := range extractInts(s.PossessionAutoSaleItemValue) {
PossessionId: bdr.PossessionId, switch {
Count: bdr.Count, case autoSaleRarityTiers[n]:
}) raritySet[n] = true
case n >= 1 && n <= 5:
rankSet[n] = true
}
} }
} }
return drops return raritySet, rankSet
}
func extractInts(s string) []int32 {
fields := strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' })
out := make([]int32, 0, len(fields))
for _, f := range fields {
if v, err := strconv.Atoi(f); err == nil {
out = append(out, int32(v))
}
}
return out
}
func (h *QuestHandler) grantDropRewards(user *store.UserState, drops []RewardGrant, raritySet, rankSet map[int32]bool, nowMillis int64) {
for i := range drops {
d := drops[i]
if d.PossessionType == model.PossessionTypeParts || d.PossessionType == model.PossessionTypePartsEnhanced {
chosenId, sold := h.Granter.GrantOrSellPartsDrop(user, d.PossessionId, raritySet, rankSet, nowMillis)
if sold {
// Sold parts have no inventory row, so the popup needs the rolled
// variant id; kept parts read theirs from the parts table diff.
drops[i].PossessionId = chosenId
drops[i].IsAutoSale = true
}
continue
}
h.applyRewardPossession(user, d.PossessionType, d.PossessionId, d.Count, nowMillis)
}
}
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant {
var drops []RewardGrant
var dropRate campaign.DropRateMul
if h.Campaigns != nil {
dropRate = h.Campaigns.QuestDropRate(target, h.campaignFilter(nowMillis))
}
if questDef.QuestPickupRewardGroupId != 0 {
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] {
if bdr, ok := h.BattleDropRewardById[dropId]; ok {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bdr.PossessionType),
PossessionId: bdr.PossessionId,
Count: dropRate.Apply(bdr.Count),
})
}
}
}
return h.appendBonusDrops(drops, target, nowMillis)
} }
func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) { func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) {
@@ -193,8 +247,10 @@ func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, now
if !ok { if !ok {
continue continue
} }
var maxLevel int32
if maxLevelFunc, hasMax := h.CostumeMaxLevelByRarity[cm.RarityType]; hasMax { if maxLevelFunc, hasMax := h.CostumeMaxLevelByRarity[cm.RarityType]; hasMax {
maxLevel := maxLevelFunc.Evaluate(row.LimitBreakCount) maxLevel = maxLevelFunc.Evaluate(row.LimitBreakCount) +
h.CharacterRebirth.CostumeLevelLimitUp(cm.CharacterId, user.CharacterRebirths[cm.CharacterId].RebirthCount)
if row.Level >= maxLevel { if row.Level >= maxLevel {
log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): at max level %d, skipping", questId, row.CostumeId, key, row.Level) log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): at max level %d, skipping", questId, row.CostumeId, key, row.Level)
continue continue
@@ -202,14 +258,7 @@ func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, now
} }
row.Exp += questDef.CostumeExp row.Exp += questDef.CostumeExp
if thresholds, ok := h.CostumeExpByRarity[cm.RarityType]; ok { if thresholds, ok := h.CostumeExpByRarity[cm.RarityType]; ok {
row.Level, row.Exp = gameutil.LevelAndCap(row.Exp, thresholds) row.Level, row.Exp = gameutil.ApplyExpWithMaxLevel(row.Exp, thresholds, maxLevel)
if maxLevelFunc, hasMax := h.CostumeMaxLevelByRarity[cm.RarityType]; hasMax {
maxLevel := maxLevelFunc.Evaluate(row.LimitBreakCount)
if row.Level > maxLevel && int(maxLevel) < len(thresholds) {
row.Level = maxLevel
row.Exp = thresholds[maxLevel]
}
}
} }
user.Costumes[key] = row user.Costumes[key] = row
log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): +%d exp -> total=%d level=%d", questId, row.CostumeId, key, questDef.CostumeExp, row.Exp, row.Level) log.Printf("[applyExpRewards] questId=%d costume=%d (key=%s): +%d exp -> total=%d level=%d", questId, row.CostumeId, key, questDef.CostumeExp, row.Exp, row.Level)
+32 -20
View File
@@ -46,29 +46,39 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen
user.MainQuest.CurrentMainQuestRouteId = routeId user.MainQuest.CurrentMainQuestRouteId = routeId
if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok { if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
RecordSeasonRoute(user, seasonId, routeId, gametime.NowMillis())
} }
} }
} }
// Backs IUserMainQuestSeasonRoute: the client needs the history to load func (h *QuestHandler) advanceReplayFlowScene(user *store.UserState, sceneId int32) {
// scene metadata when cage menu-replay jumps to older chapters. if !h.isSceneAhead(sceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) {
if seasonId <= 0 || routeId <= 0 {
return return
} }
if user.MainQuestSeasonRoutes == nil { user.MainQuest.ReplayFlowCurrentQuestSceneId = sceneId
user.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry) user.MainQuest.ReplayFlowHeadQuestSceneId = sceneId
}
func (h *QuestHandler) SeasonRoutesFor(user *store.UserState) map[int32]int32 {
out := make(map[int32]int32)
for seasonId, routes := range h.RoutesBySeason {
if seasonId <= 1 {
continue
}
for _, routeId := range routes {
finalQuestId, ok := h.RouteCompletionQuestId[routeId]
if !ok {
continue
}
if q, ok := user.Quests[finalQuestId]; ok && q.ClearCount > 0 {
out[seasonId] = routeId
break
}
}
} }
key := store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId} if cur := user.MainQuest.MainQuestSeasonId; cur >= 2 && user.MainQuest.CurrentMainQuestRouteId > 0 {
if _, exists := user.MainQuestSeasonRoutes[key]; exists { out[cur] = user.MainQuest.CurrentMainQuestRouteId
return
}
user.MainQuestSeasonRoutes[key] = store.SeasonRouteEntry{
MainQuestSeasonId: seasonId,
MainQuestRouteId: routeId,
LatestVersion: nowMillis,
} }
return out
} }
func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
@@ -171,10 +181,9 @@ func (h *QuestHandler) replayFlowTypeForRoute(user *store.UserState, routeId int
if !ok { if !ok {
return model.QuestFlowTypeReplayFlow return model.QuestFlowTypeReplayFlow
} }
for key, entry := range user.MainQuestSeasonRoutes { pairs := h.SeasonRoutesFor(user)
if key.MainQuestSeasonId == seasonId && entry.MainQuestRouteId != routeId { if recorded, ok := pairs[seasonId]; ok && recorded != routeId {
return model.QuestFlowTypeAnotherRouteReplayFlow return model.QuestFlowTypeAnotherRouteReplayFlow
}
} }
return model.QuestFlowTypeReplayFlow return model.QuestFlowTypeReplayFlow
} }
@@ -221,7 +230,10 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow) user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
} }
} else { } else if !isReplay {
// Background/non-playable quest: advance the MainFlow pointer — but not
// during a replay, where the isReplay block below tracks the ReplayFlow
// scene and the MainFlow pointer must stay on real main-story progress.
user.MainQuest.CurrentQuestSceneId = questSceneId user.MainQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) { if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = questSceneId user.MainQuest.HeadQuestSceneId = questSceneId
+19 -8
View File
@@ -4,10 +4,12 @@ import (
"fmt" "fmt"
"log" "log"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb" "lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/userdata"
) )
// buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever // buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever
@@ -34,7 +36,18 @@ func buildCatalogs() (*Catalogs, error) {
return nil, fmt.Errorf("load quest catalog: %w", err) return nil, fmt.Errorf("load quest catalog: %w", err)
} }
sideStoryCatalog := masterdata.LoadSideStoryCatalog() sideStoryCatalog := masterdata.LoadSideStoryCatalog()
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog) campaignCatalog, err := campaign.Load()
if err != nil {
return nil, fmt.Errorf("load campaign catalog: %w", err)
}
log.Printf("campaign catalog loaded: %d enhance, %d quest", campaignCatalog.EnhanceCount(), campaignCatalog.QuestCount())
characterRebirthCatalog, err := masterdata.LoadCharacterRebirthCatalog()
if err != nil {
return nil, fmt.Errorf("load character rebirth catalog: %w", err)
}
log.Printf("character rebirth catalog loaded: %d characters", len(characterRebirthCatalog.StepGroupByCharacterId))
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog, campaignCatalog, characterRebirthCatalog)
userdata.SetQuestHandler(questHandler)
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog() gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
if err != nil { if err != nil {
@@ -114,7 +127,7 @@ func buildCatalogs() (*Catalogs, error) {
} }
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets)) log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver) gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver, cageOrnamentCatalog)
if err != nil { if err != nil {
return nil, fmt.Errorf("load gimmick catalog: %w", err) return nil, fmt.Errorf("load gimmick catalog: %w", err)
} }
@@ -125,12 +138,6 @@ func buildCatalogs() (*Catalogs, error) {
} }
log.Printf("character board catalog loaded: %d panels, %d boards", len(characterBoardCatalog.PanelById), len(characterBoardCatalog.BoardById)) log.Printf("character board catalog loaded: %d panels, %d boards", len(characterBoardCatalog.PanelById), len(characterBoardCatalog.BoardById))
characterRebirthCatalog, err := masterdata.LoadCharacterRebirthCatalog()
if err != nil {
return nil, fmt.Errorf("load character rebirth catalog: %w", err)
}
log.Printf("character rebirth catalog loaded: %d characters", len(characterRebirthCatalog.StepGroupByCharacterId))
companionCatalog, err := masterdata.LoadCompanionCatalog() companionCatalog, err := masterdata.LoadCompanionCatalog()
if err != nil { if err != nil {
return nil, fmt.Errorf("load companion catalog: %w", err) return nil, fmt.Errorf("load companion catalog: %w", err)
@@ -141,6 +148,8 @@ func buildCatalogs() (*Catalogs, error) {
towerCatalog := masterdata.LoadTowerCatalog() towerCatalog := masterdata.LoadTowerCatalog()
labyrinthCatalog := masterdata.LoadLabyrinthCatalog()
return &Catalogs{ return &Catalogs{
GameConfig: gameConfig, GameConfig: gameConfig,
Parts: partsCatalog, Parts: partsCatalog,
@@ -167,6 +176,8 @@ func buildCatalogs() (*Catalogs, error) {
SideStory: sideStoryCatalog, SideStory: sideStoryCatalog,
BigHunt: bigHuntCatalog, BigHunt: bigHuntCatalog,
Tower: towerCatalog, Tower: towerCatalog,
Labyrinth: labyrinthCatalog,
Campaign: campaignCatalog,
QuestHandler: questHandler, QuestHandler: questHandler,
GachaHandler: gachaHandler, GachaHandler: gachaHandler,
}, nil }, nil
+3 -15
View File
@@ -14,6 +14,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb" "lunar-tear/server/internal/masterdata/memorydb"
@@ -51,23 +52,18 @@ type Catalogs struct {
SideStory *masterdata.SideStoryCatalog SideStory *masterdata.SideStoryCatalog
BigHunt *masterdata.BigHuntCatalog BigHunt *masterdata.BigHuntCatalog
Tower *masterdata.TowerCatalog Tower *masterdata.TowerCatalog
Labyrinth *masterdata.LabyrinthCatalog
Campaign *campaign.Catalog
// Catalog-derived handlers must rebuild on every reload because they
// embed/cache pointers to specific catalog instances.
QuestHandler *questflow.QuestHandler QuestHandler *questflow.QuestHandler
GachaHandler *gacha.GachaHandler GachaHandler *gacha.GachaHandler
} }
// Holder owns the current *Catalogs and the bin.e path. Concurrent readers
// call Get(); the single-writer Reload() rebuilds and atomically publishes.
type Holder struct { type Holder struct {
binPath string binPath string
cur atomic.Pointer[Catalogs] cur atomic.Pointer[Catalogs]
} }
// NewHolder reads the binary at binPath, builds the initial catalogs, and
// returns a ready-to-use Holder. Subsequent calls to Reload() re-read the
// same path.
func NewHolder(binPath string) (*Holder, error) { func NewHolder(binPath string) (*Holder, error) {
h := &Holder{binPath: binPath} h := &Holder{binPath: binPath}
if err := h.Reload(); err != nil { if err := h.Reload(); err != nil {
@@ -76,9 +72,6 @@ func NewHolder(binPath string) (*Holder, error) {
return h, nil return h, nil
} }
// Reload re-reads the bin.e from disk, rebuilds every catalog and handler,
// atomically publishes the new snapshot, and bumps the bin.e mtime so client
// caches invalidate (see service/data.go GetLatestMasterDataVersion).
func (h *Holder) Reload() error { func (h *Holder) Reload() error {
if err := memorydb.Init(h.binPath); err != nil { if err := memorydb.Init(h.binPath); err != nil {
return fmt.Errorf("memorydb.Init: %w", err) return fmt.Errorf("memorydb.Init: %w", err)
@@ -90,16 +83,11 @@ func (h *Holder) Reload() error {
h.cur.Store(c) h.cur.Store(c)
now := time.Now() now := time.Now()
if err := os.Chtimes(h.binPath, now, now); err != nil { if err := os.Chtimes(h.binPath, now, now); err != nil {
// Non-fatal: the catalogs swapped fine in-memory; clients may take
// longer to invalidate their cached download but server-side state is
// already coherent.
log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err) log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err)
} }
return nil return nil
} }
// Get returns the current snapshot. Safe for concurrent callers; the returned
// pointer is stable for the duration of the caller's use.
func (h *Holder) Get() *Catalogs { func (h *Holder) Get() *Catalogs {
return h.cur.Load() return h.cur.Load()
} }
+13 -5
View File
@@ -27,10 +27,6 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
cat := s.holder.Get() cat := s.holder.Get()
reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId) reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId)
if !ok {
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -40,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
AcquisitionDatetime: nowMillis, AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis, LatestVersion: nowMillis,
} }
granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) if ok {
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
}
}) })
if !ok {
// "Fickle Black Birds" (type-1 gimmicks) tap into this RPC with CageOrnamentIds
// not present in m_cage_ornament_reward (their GimmickOrnamentViewIds are 101/103,
// not the 1002xxx-style ids the table uses). Record the access and return an empty
// reward so the client doesn't hang and the server doesn't crash.
log.Printf("[CageOrnamentService] ReceiveReward: no reward mapping for cageOrnamentId=%d, returning empty",
req.CageOrnamentId)
return &pb.ReceiveRewardResponse{}, nil
}
return &pb.ReceiveRewardResponse{ return &pb.ReceiveRewardResponse{
CageOrnamentReward: []*pb.CageOrnamentReward{ CageOrnamentReward: []*pb.CageOrnamentReward{
{ {
+14 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
@@ -50,6 +51,12 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
return return
} }
expBonus := cat.Campaign.CostumeExpBonus(campaign.CostumeTarget{
CostumeId: costume.CostumeId,
CharacterId: cm.CharacterId,
SkillfulWeaponType: cm.SkillfulWeaponType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
@@ -71,7 +78,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType { if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expBonus.Apply(expPerUnit * count)
} }
if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
@@ -83,7 +90,12 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
costume.Exp += totalExp costume.Exp += totalExp
if thresholds, ok := catalog.ExpByRarity[cm.RarityType]; ok { if thresholds, ok := catalog.ExpByRarity[cm.RarityType]; ok {
costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds) var maxLevel int32
if maxLevelFunc, hasMax := catalog.MaxLevelByRarity[cm.RarityType]; hasMax {
maxLevel = maxLevelFunc.Evaluate(costume.LimitBreakCount) +
cat.CharacterRebirth.CostumeLevelLimitUp(cm.CharacterId, user.CharacterRebirths[cm.CharacterId].RebirthCount)
}
costume.Level, costume.Exp = gameutil.ApplyExpWithMaxLevel(costume.Exp, thresholds, maxLevel)
} }
costume.LatestVersion = nowMillis costume.LatestVersion = nowMillis
+135 -7
View File
@@ -6,6 +6,8 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -43,6 +45,10 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d", log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType) req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
cat := s.holder.Get()
var ornamentRewards []*pb.GimmickReward
var sequenceCleared bool
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
progressKey := store.GimmickKey{ progressKey := store.GimmickKey{
@@ -53,7 +59,6 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
progress := user.Gimmick.Progress[progressKey] progress := user.Gimmick.Progress[progressKey]
progress.Key = progressKey progress.Key = progressKey
progress.StartDatetime = nowMillis progress.StartDatetime = nowMillis
user.Gimmick.Progress[progressKey] = progress
ornamentKey := store.GimmickOrnamentKey{ ornamentKey := store.GimmickOrnamentKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId, GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
@@ -66,28 +71,151 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
ornament.ProgressValueBit = req.ProgressValueBit ornament.ProgressValueBit = req.ProgressValueBit
ornament.BaseDatetime = nowMillis ornament.BaseDatetime = nowMillis
user.Gimmick.OrnamentProgress[ornamentKey] = ornament user.Gimmick.OrnamentProgress[ornamentKey] = ornament
// Per-type branches:
// * Report (type 9, "Hidden Stories") — mark gimmick + sequence
// cleared, grant SequenceRewards (ImportantItem type 3, library reads it).
// * MapOnlyCageTreasureHunt (type 7, "Hidden Black Birds") — same as Report
// but the per-tap reward also comes back from m_cage_ornament_reward via
// GimmickOrnamentViewId.
// * CageMemory (type 10, "Lost Archives") — resolve an ImportantItem
// (type 4) from the gimmick's monitor texture and grant it. IsGimmickCleared
// stays false (matches original userdata; only ornament progress flips).
// * CageTreasureHunt / CageIntervalDropItem* — stub per-tap material so
// the client's reward popup fires; real reward source still unmapped.
switch cat.Gimmick.GimmickType(req.GimmickId) {
case model.GimmickTypeReport:
progress.IsGimmickCleared = true
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
case model.GimmickTypeMapOnlyCageTreasureHunt:
r, ok := cat.Gimmick.HiddenBirdReward(req.GimmickId, req.GimmickOrnamentIndex)
if !ok {
log.Printf("[GimmickService] UpdateGimmickProgress: hidden-bird %d ornament %d has no reward mapping, skipping",
req.GimmickId, req.GimmickOrnamentIndex)
break
}
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
progress.IsGimmickCleared = true
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
case model.GimmickTypeCageMemory:
itemId, ok := cat.Gimmick.CageMemoryImportantItem(req.GimmickId)
if !ok {
log.Printf("[GimmickService] UpdateGimmickProgress: cage memory %d has no important-item mapping, skipping grant",
req.GimmickId)
break
}
if _, owned := user.ImportantItems[itemId]; owned {
break
}
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeImportantItem, itemId, 1, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: int32(model.PossessionTypeImportantItem),
PossessionId: itemId,
Count: 1,
})
case model.GimmickTypeCageTreasureHunt,
model.GimmickTypeCageIntervalDropItem,
model.GimmickTypeMapOnlyCageIntervalDrop:
// Per-tap drops with no per-gimmick reward in master data:
// * type 1 — "Fickle Black Birds" in the cage
// * type 2 — "Lost Items" in the cage
// * type 8 — Lost Items (map variant)
// Stub: grant 1 of Material 100004 (the most-common reward across
// m_cage_ornament_reward — 15 occurrences — likely a low-tier shard) per
// tap so the client's reward-popup path fires and the player accumulates
// something. Replace once a real per-gimmick mapping surfaces.
const stubMaterialId = int32(100004)
const stubMaterialCount = int32(1)
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeMaterial, stubMaterialId, stubMaterialCount, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: stubMaterialId,
Count: stubMaterialCount,
})
}
user.Gimmick.Progress[progressKey] = progress
}) })
var clearReward []*pb.GimmickReward
if sequenceCleared {
for _, r := range cat.Gimmick.SequenceRewards(req.GimmickSequenceId) {
clearReward = append(clearReward, &pb.GimmickReward{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
}
return &pb.UpdateGimmickProgressResponse{ return &pb.UpdateGimmickProgressResponse{
GimmickOrnamentReward: []*pb.GimmickReward{}, GimmickOrnamentReward: ornamentRewards,
IsSequenceCleared: false, IsSequenceCleared: sequenceCleared,
GimmickSequenceClearReward: []*pb.GimmickReward{}, GimmickSequenceClearReward: clearReward,
}, nil }, nil
} }
func markSequenceClearedOnce(user *store.UserState, cat *runtime.Catalogs, scheduleId, sequenceId int32, nowMillis int64) bool {
seqKey := store.GimmickSequenceKey{
GimmickSequenceScheduleId: scheduleId,
GimmickSequenceId: sequenceId,
}
sequence := user.Gimmick.Sequences[seqKey]
sequence.Key = seqKey
defer func() { user.Gimmick.Sequences[seqKey] = sequence }()
if sequence.IsGimmickSequenceCleared {
return false
}
sequence.IsGimmickSequenceCleared = true
sequence.ClearDatetime = nowMillis
for _, r := range cat.Gimmick.SequenceRewards(sequenceId) {
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
}
return true
}
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) { func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
log.Printf("[GimmickService] InitSequenceSchedule") log.Printf("[GimmickService] InitSequenceSchedule")
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis() now := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
eligible := s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now)
eligibleSet := make(map[store.GimmickSequenceKey]struct{}, len(eligible))
for _, key := range eligible {
eligibleSet[key] = struct{}{}
}
pruned := 0
for key, entry := range user.Gimmick.Sequences {
if _, ok := eligibleSet[key]; ok {
continue
}
if entry.IsGimmickSequenceCleared {
continue
}
delete(user.Gimmick.Sequences, key)
pruned++
}
added := 0 added := 0
for _, key := range s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now) { for _, key := range eligible {
if len(user.Gimmick.Sequences) >= masterdata.MaxUserGimmickRows {
break
}
if _, exists := user.Gimmick.Sequences[key]; !exists { if _, exists := user.Gimmick.Sequences[key]; !exists {
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key} user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
added++ added++
} }
} }
if added > 0 { if pruned > 0 || added > 0 {
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences)) log.Printf("[GimmickService] InitSequenceSchedule: pruned %d stale, added %d sequences (total %d, eligible %d, cap %d)",
pruned, added, len(user.Gimmick.Sequences), len(eligible), masterdata.MaxUserGimmickRows)
} }
}) })
return &pb.InitSequenceScheduleResponse{}, nil return &pb.InitSequenceScheduleResponse{}, nil
+138
View File
@@ -0,0 +1,138 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
type LabyrinthServiceServer struct {
pb.UnimplementedLabyrinthServiceServer
users store.UserRepository
sessions store.SessionRepository
holder *runtime.Holder
}
func NewLabyrinthServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LabyrinthServiceServer {
if holder == nil {
panic("runtime holder is required")
}
return &LabyrinthServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *LabyrinthServiceServer) ReceiveStageAccumulationReward(ctx context.Context, req *pb.ReceiveStageAccumulationRewardRequest) (*pb.ReceiveStageAccumulationRewardResponse, error) {
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d questMissionClearCount=%d",
req.EventQuestChapterId, req.StageOrder, req.QuestMissionClearCount)
cat := s.holder.Get()
laby := cat.Labyrinth
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
key := store.LabyrinthStageKey{
EventQuestChapterId: req.EventQuestChapterId,
StageOrder: req.StageOrder,
}
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.LabyrinthStages[key]
old := rec.AccumulationRewardReceivedQuestMissionCount
items, highest := laby.CollectAccumulationRewards(req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
if highest <= old {
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: nothing to grant for chapter=%d stage=%d (claimed=%d, target=%d)",
req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
return
}
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.StageOrder = req.StageOrder
rec.AccumulationRewardReceivedQuestMissionCount = highest
rec.LatestVersion = nowMillis
user.LabyrinthStages[key] = rec
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d granted %d item(s), claimed %d -> %d",
req.EventQuestChapterId, req.StageOrder, len(items), old, highest)
})
return &pb.ReceiveStageAccumulationRewardResponse{}, nil
}
func (s *LabyrinthServiceServer) ReceiveStageClearReward(ctx context.Context, req *pb.ReceiveStageClearRewardRequest) (*pb.ReceiveStageClearRewardResponse, error) {
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d",
req.EventQuestChapterId, req.StageOrder)
cat := s.holder.Get()
laby := cat.Labyrinth
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
key := store.LabyrinthStageKey{
EventQuestChapterId: req.EventQuestChapterId,
StageOrder: req.StageOrder,
}
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.LabyrinthStages[key]
if rec.IsReceivedStageClearReward {
log.Printf("[LabyrinthService] ReceiveStageClearReward: already claimed chapter=%d stage=%d",
req.EventQuestChapterId, req.StageOrder)
return
}
items := laby.StageClearReward(req.EventQuestChapterId, req.StageOrder)
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.StageOrder = req.StageOrder
rec.IsReceivedStageClearReward = true
rec.LatestVersion = nowMillis
user.LabyrinthStages[key] = rec
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d granted %d item(s)",
req.EventQuestChapterId, req.StageOrder, len(items))
})
return &pb.ReceiveStageClearRewardResponse{}, nil
}
func (s *LabyrinthServiceServer) UpdateSeasonData(ctx context.Context, req *pb.UpdateSeasonDataRequest) (*pb.UpdateSeasonDataResponse, error) {
laby := s.holder.Get().Labyrinth
var seasonResult []*pb.LabyrinthSeasonResult
for _, m := range laby.SeasonMilestones(req.EventQuestChapterId) {
rewards := make([]*pb.LabyrinthReward, 0, len(m.Rewards))
for _, it := range m.Rewards {
rewards = append(rewards, &pb.LabyrinthReward{
PossessionType: it.PossessionType,
PossessionId: it.PossessionId,
Count: it.Count,
})
}
seasonResult = append(seasonResult, &pb.LabyrinthSeasonResult{
EventQuestChapterId: req.EventQuestChapterId,
HeadQuestId: m.HeadQuestId,
SeasonReward: rewards,
HeadStageOrder: m.HeadStageOrder,
})
}
log.Printf("[LabyrinthService] UpdateSeasonData: chapter=%d -> %d milestone(s)",
req.EventQuestChapterId, len(seasonResult))
return &pb.UpdateSeasonDataResponse{SeasonResult: seasonResult}, nil
}
+12 -4
View File
@@ -7,8 +7,10 @@ import (
"math/rand" "math/rand"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -180,17 +182,23 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
successRate = r successRate = r
} }
} }
baseRate := successRate
successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{
PartsId: part.PartsId,
PartsGroupId: partDef.PartsGroupId,
Rarity: model.RarityType(partDef.RarityType),
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate)
if rand.Intn(1000) < int(successRate) { if rand.Intn(1000) < int(successRate) {
part.Level++ part.Level++
isSuccess = true isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost) part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost)
grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis) grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis)
} else { } else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost) part.PartsId, part.Level, successRate, baseRate, goldCost)
} }
part.LatestVersion = nowMillis part.LatestVersion = nowMillis
@@ -0,0 +1,83 @@
package service
import (
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
)
func startAutoOrbit(user *store.UserState, questType model.QuestType, chapterId, questId, maxCount int32, nowMillis int64) {
if maxCount <= 0 {
if user.QuestAutoOrbit.MaxAutoOrbitCount > 0 {
log.Printf("[autoOrbit] clear (start without max): prev questType=%d chapter=%d quest=%d cleared=%d/%d",
user.QuestAutoOrbit.QuestType, user.QuestAutoOrbit.ChapterId, user.QuestAutoOrbit.QuestId,
user.QuestAutoOrbit.ClearedAutoOrbitCount, user.QuestAutoOrbit.MaxAutoOrbitCount)
}
user.QuestAutoOrbit = store.QuestAutoOrbitState{}
return
}
s := user.QuestAutoOrbit
if s.MaxAutoOrbitCount > 0 &&
s.QuestType == int32(questType) && s.ChapterId == chapterId &&
s.QuestId == questId && s.MaxAutoOrbitCount == maxCount {
s.LatestVersion = nowMillis
user.QuestAutoOrbit = s
log.Printf("[autoOrbit] continue cleared=%d/%d", s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount)
return
}
log.Printf("[autoOrbit] start questType=%d chapter=%d quest=%d max=%d", questType, chapterId, questId, maxCount)
user.QuestAutoOrbit = store.QuestAutoOrbitState{
QuestType: int32(questType),
ChapterId: chapterId,
QuestId: questId,
MaxAutoOrbitCount: maxCount,
LatestVersion: nowMillis,
}
}
func finishAutoOrbit(user *store.UserState, isAutoOrbit, isRetired, isAnnihilated bool, questType model.QuestType, chapterId, questId int32, nowMillis int64, drops []questflow.RewardGrant) (endedDrops []store.AutoOrbitDropEntry, loopEnded bool) {
s := user.QuestAutoOrbit
if s.MaxAutoOrbitCount <= 0 {
return nil, false
}
if s.QuestType != int32(questType) || s.ChapterId != chapterId || s.QuestId != questId {
log.Printf("[autoOrbit] finish for other quest, ignored: tracked={qt=%d ch=%d q=%d} got={qt=%d ch=%d q=%d}",
s.QuestType, s.ChapterId, s.QuestId, int32(questType), chapterId, questId)
return nil, false
}
if !isRetired && !isAnnihilated {
added := 0
for _, d := range drops {
s.AccumulatedDrops = append(s.AccumulatedDrops, store.AutoOrbitDropEntry{
PossessionType: int32(d.PossessionType),
PossessionId: d.PossessionId,
Count: d.Count,
IsAutoSale: d.IsAutoSale,
})
added++
}
s.ClearedAutoOrbitCount++
log.Printf("[autoOrbit] iter cleared=%d/%d +%d drops (total=%d)",
s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount, added, len(s.AccumulatedDrops))
}
s.LastClearDatetime = nowMillis
s.LatestVersion = nowMillis
if !isAutoOrbit || isRetired || isAnnihilated || s.ClearedAutoOrbitCount >= s.MaxAutoOrbitCount {
log.Printf("[autoOrbit] loop end: cleared=%d/%d total drops=%d (returned in response, accumulator kept)",
s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount, len(s.AccumulatedDrops))
user.QuestAutoOrbit = store.QuestAutoOrbitState{AccumulatedDrops: s.AccumulatedDrops}
return s.AccumulatedDrops, true
}
user.QuestAutoOrbit = s
return nil, false
}
func consumeAutoOrbitRewards(user *store.UserState) []store.AutoOrbitDropEntry {
drops := user.QuestAutoOrbit.AccumulatedDrops
log.Printf("[autoOrbit] consume on FinishAutoOrbit: returning %d drops (loop status max=%d cleared=%d)",
len(drops), user.QuestAutoOrbit.MaxAutoOrbitCount, user.QuestAutoOrbit.ClearedAutoOrbitCount)
user.QuestAutoOrbit = store.QuestAutoOrbitState{}
return drops
}
+15 -2
View File
@@ -6,6 +6,7 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -13,13 +14,15 @@ import (
) )
func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) { func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) {
log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly) log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v maxAutoOrbitCount=%d",
req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.MaxAutoOrbitCount)
engine := s.holder.Get().QuestHandler engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
startAutoOrbit(user, model.QuestTypeEvent, req.EventQuestChapterId, req.QuestId, req.MaxAutoOrbitCount, nowMillis)
}) })
drops := engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
@@ -38,16 +41,25 @@ func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartE
} }
func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) { func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) {
log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated) log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v isAutoOrbit=%v",
req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, req.IsAutoOrbit)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome var outcome questflow.FinishOutcome
var endedDrops []store.AutoOrbitDropEntry
var loopEnded bool
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) outcome = engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
endedDrops, loopEnded = finishAutoOrbit(user, req.IsAutoOrbit, req.IsRetired, req.IsAnnihilated, model.QuestTypeEvent, req.EventQuestChapterId, req.QuestId, nowMillis, outcome.DropRewards)
}) })
autoOrbitReward := emptyAutoOrbitReward()
if loopEnded {
autoOrbitReward.DropReward = autoOrbitDropsToProto(endedDrops)
}
return &pb.FinishEventQuestResponse{ return &pb.FinishEventQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards), DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards), FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
@@ -57,6 +69,7 @@ func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.Finis
IsBigWin: outcome.IsBigWin, IsBigWin: outcome.IsBigWin,
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds, BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
UserStatusCampaignReward: []*pb.QuestReward{}, UserStatusCampaignReward: []*pb.QuestReward{},
AutoOrbitReward: autoOrbitReward,
}, nil }, nil
} }
+55 -5
View File
@@ -65,7 +65,8 @@ func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, r
} }
func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) { func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) {
log.Printf("[QuestService] StartMainQuest: %+v", req) log.Printf("[QuestService] StartMainQuest: questId=%d isMainFlow=%v isReplayFlow=%v isBattleOnly=%v maxAutoOrbitCount=%d",
req.QuestId, req.IsMainFlow, req.IsReplayFlow, req.IsBattleOnly, req.MaxAutoOrbitCount)
engine := s.holder.Get().QuestHandler engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
@@ -76,6 +77,7 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa
} else { } else {
engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.IsMainFlow, req.UserDeckNumber, nowMillis) engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.IsMainFlow, req.UserDeckNumber, nowMillis)
} }
startAutoOrbit(user, model.QuestTypeMain, 0, req.QuestId, req.MaxAutoOrbitCount, nowMillis)
}) })
drops := engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
@@ -93,6 +95,26 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa
}, nil }, nil
} }
func emptyAutoOrbitReward() *pb.QuestAutoOrbitResult {
return &pb.QuestAutoOrbitResult{
DropReward: []*pb.QuestReward{},
UserStatusCampaignReward: []*pb.QuestReward{},
}
}
func autoOrbitDropsToProto(drops []store.AutoOrbitDropEntry) []*pb.QuestReward {
out := make([]*pb.QuestReward, len(drops))
for i, d := range drops {
out[i] = &pb.QuestReward{
PossessionType: d.PossessionType,
PossessionId: d.PossessionId,
Count: d.Count,
IsAutoSale: d.IsAutoSale,
}
}
return out
}
func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward { func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward {
if len(grants) == 0 { if len(grants) == 0 {
return []*pb.QuestReward{} return []*pb.QuestReward{}
@@ -103,23 +125,32 @@ func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward {
PossessionType: int32(g.PossessionType), PossessionType: int32(g.PossessionType),
PossessionId: g.PossessionId, PossessionId: g.PossessionId,
Count: g.Count, Count: g.Count,
IsAutoSale: g.IsAutoSale,
} }
} }
return out return out
} }
func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) { func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) {
log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v storySkipType=%d", log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v isAutoOrbit=%v storySkipType=%d",
req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType) req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.IsAutoOrbit, req.StorySkipType)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome var outcome questflow.FinishOutcome
var endedDrops []store.AutoOrbitDropEntry
var loopEnded bool
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) outcome = engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
endedDrops, loopEnded = finishAutoOrbit(user, req.IsAutoOrbit, req.IsRetired, req.IsAnnihilated, model.QuestTypeMain, 0, req.QuestId, nowMillis, outcome.DropRewards)
}) })
autoOrbitReward := emptyAutoOrbitReward()
if loopEnded {
autoOrbitReward.DropReward = autoOrbitDropsToProto(endedDrops)
}
return &pb.FinishMainQuestResponse{ return &pb.FinishMainQuestResponse{
DropReward: toProtoRewards(outcome.DropRewards), DropReward: toProtoRewards(outcome.DropRewards),
FirstClearReward: toProtoRewards(outcome.FirstClearRewards), FirstClearReward: toProtoRewards(outcome.FirstClearRewards),
@@ -130,6 +161,7 @@ func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.Finish
BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds, BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds,
ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards), ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards),
UserStatusCampaignReward: []*pb.QuestReward{}, UserStatusCampaignReward: []*pb.QuestReward{},
AutoOrbitReward: autoOrbitReward,
}, nil }, nil
} }
@@ -162,7 +194,26 @@ func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.Resta
func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) { func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) {
log.Printf("[QuestService] FinishAutoOrbit") log.Printf("[QuestService] FinishAutoOrbit")
return &pb.FinishAutoOrbitResponse{}, nil userId := CurrentUserId(ctx, s.users, s.sessions)
var drops []store.AutoOrbitDropEntry
s.users.UpdateUser(userId, func(user *store.UserState) {
drops = consumeAutoOrbitRewards(user)
})
pbDrops := make([]*pb.QuestReward, len(drops))
for i, d := range drops {
pbDrops[i] = &pb.QuestReward{
PossessionType: d.PossessionType,
PossessionId: d.PossessionId,
Count: d.Count,
}
}
return &pb.FinishAutoOrbitResponse{
AutoOrbitResult: []*pb.QuestReward{},
AutoOrbitReward: &pb.QuestAutoOrbitResult{
DropReward: pbDrops,
UserStatusCampaignReward: []*pb.QuestReward{},
},
}, nil
} }
func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) { func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) {
@@ -198,7 +249,6 @@ func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteReque
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis())
} }
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
@@ -101,6 +101,9 @@ func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Con
scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction) scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction)
} else { } else {
scene, ok = sideStoryNextSceneAfterBattle(info, user) scene, ok = sideStoryNextSceneAfterBattle(info, user)
if !ok {
scene, ok = existing.HeadSideStoryQuestSceneId, true
}
} }
if !ok { if !ok {
return return
+21 -18
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
@@ -91,6 +92,12 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
return return
} }
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
@@ -112,7 +119,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType { if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expBonus.Apply(expPerUnit * count)
} }
if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
@@ -124,16 +131,11 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
weapon.Exp += totalExp weapon.Exp += totalExp
levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId] levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId]
if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok { if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) var maxLevel int32
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
cap := awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount)) maxLevel = awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount))
if weapon.Level > cap {
weapon.Level = cap
if int(cap) >= 0 && int(cap) < len(thresholds) {
weapon.Exp = thresholds[cap]
}
}
} }
weapon.Level, weapon.Exp = gameutil.ApplyExpWithMaxLevel(weapon.Exp, thresholds, maxLevel)
} }
note := user.WeaponNotes[weapon.WeaponId] note := user.WeaponNotes[weapon.WeaponId]
@@ -702,6 +704,12 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
return return
} }
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
consumedCount := int32(0) consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids { for _, uuid := range req.MaterialUserWeaponUuids {
@@ -722,7 +730,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType { if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000 baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += baseExp totalExp += expBonus.Apply(baseExp)
if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok { if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals { for itemId, count := range medals {
@@ -746,16 +754,11 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
weapon.Exp += totalExp weapon.Exp += totalExp
levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId] levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId]
if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok { if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) var maxLevel int32
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
cap := awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount)) maxLevel = awakenedLevelCap(catalog, user, weapon, req.UserWeaponUuid, maxFunc.Evaluate(weapon.LimitBreakCount))
if weapon.Level > cap {
weapon.Level = cap
if int(cap) >= 0 && int(cap) < len(thresholds) {
weapon.Exp = thresholds[cap]
}
}
} }
weapon.Level, weapon.Exp = gameutil.ApplyExpWithMaxLevel(weapon.Exp, thresholds, maxLevel)
} }
note := user.WeaponNotes[weapon.WeaponId] note := user.WeaponNotes[weapon.WeaponId]
+3
View File
@@ -27,6 +27,8 @@ func CloneUserState(u UserState) UserState {
} }
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards) out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards) out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards)
out.LabyrinthSeasons = maps.Clone(u.LabyrinthSeasons)
out.LabyrinthStages = maps.Clone(u.LabyrinthStages)
out.ConsumableItems = maps.Clone(u.ConsumableItems) out.ConsumableItems = maps.Clone(u.ConsumableItems)
out.Materials = maps.Clone(u.Materials) out.Materials = maps.Clone(u.Materials)
out.Parts = maps.Clone(u.Parts) out.Parts = maps.Clone(u.Parts)
@@ -83,6 +85,7 @@ func CloneUserState(u UserState) UserState {
out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending) out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending)
out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings) out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings)
out.CharacterRebirths = maps.Clone(u.CharacterRebirths) out.CharacterRebirths = maps.Clone(u.CharacterRebirths)
out.QuestAutoOrbit.AccumulatedDrops = append([]AutoOrbitDropEntry(nil), u.QuestAutoOrbit.AccumulatedDrops...)
return out return out
} }
+112 -11
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"fmt" "fmt"
"log" "log"
"math/rand"
"sort" "sort"
"github.com/google/uuid" "github.com/google/uuid"
@@ -102,7 +103,19 @@ type WeaponStoryReleaseCond struct {
type PartsRef struct { type PartsRef struct {
PartsGroupId int32 PartsGroupId int32
RarityType int32
PartsInitialLotteryId int32
PartsStatusMainLotteryGroupId int32 PartsStatusMainLotteryGroupId int32
PartsStatusSubLotteryGroupId int32
}
// PartsStatusSubDef carries the per-lottery-id sub-status shape needed at
// grant time. Held here so the store package does not import masterdata.
type PartsStatusSubDef struct {
StatusKindType int32
StatusCalculationType int32
StatusChangeInitialValue int32
StatusFunc func(level int32) int32
} }
type PossessionGranter struct { type PossessionGranter struct {
@@ -114,6 +127,12 @@ type PossessionGranter struct {
PartsById map[int32]PartsRef PartsById map[int32]PartsRef
DefaultPartsStatusMainByLotteryGroup map[int32]int32 DefaultPartsStatusMainByLotteryGroup map[int32]int32
PartsVariantsByGroupRarity map[int32]map[int32][]int32
PartsSubStatusPool map[int32][]int32
PartsSubStatusDefs map[int32]PartsStatusSubDef
PartsSellPriceL1ByRarity map[int32]int32
GoldConsumableItemId int32
LastChangedStoryWeaponIds []int32 LastChangedStoryWeaponIds []int32
} }
@@ -184,26 +203,108 @@ func (g *PossessionGranter) GrantCompanion(user *UserState, companionId int32, n
} }
} }
func (g *PossessionGranter) GrantParts(user *UserState, partsId int32, nowMillis int64) { func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) {
var mainStatId int32 chosenPartsId, chosenRef, ok := g.rollPartsVariant(requestedPartsId)
if ref, ok := g.PartsById[partsId]; ok { if !ok {
mainStatId = g.DefaultPartsStatusMainByLotteryGroup[ref.PartsStatusMainLotteryGroupId] g.grantBareParts(user, requestedPartsId, nowMillis)
if _, exists := user.PartsGroupNotes[ref.PartsGroupId]; !exists { return
user.PartsGroupNotes[ref.PartsGroupId] = PartsGroupNoteState{
PartsGroupId: ref.PartsGroupId,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
} }
g.createParts(user, chosenPartsId, chosenRef, nowMillis)
}
// The rolled variant sets both rarity and rank, so the auto-sale decision can
// only happen after the roll. Returns the rolled variant id and whether it sold.
func (g *PossessionGranter) GrantOrSellPartsDrop(user *UserState, requestedPartsId int32, raritySet, rankSet map[int32]bool, nowMillis int64) (int32, bool) {
chosenPartsId, chosenRef, ok := g.rollPartsVariant(requestedPartsId)
if !ok {
g.grantBareParts(user, requestedPartsId, nowMillis)
return requestedPartsId, false
}
rarity := chosenRef.RarityType
rank := chosenRef.PartsInitialLotteryId
if price, ok := g.PartsSellPriceL1ByRarity[rarity]; ok && raritySet[rarity] && rankSet[rank] {
user.ConsumableItems[g.GoldConsumableItemId] += price
log.Printf("[GrantParts] auto-sold chosen=%d rarity=%d rank=%d -> %d gold", chosenPartsId, rarity, rank, price)
return chosenPartsId, true
}
g.createParts(user, chosenPartsId, chosenRef, nowMillis)
return chosenPartsId, false
}
func (g *PossessionGranter) grantBareParts(user *UserState, partsId int32, nowMillis int64) {
key := uuid.New().String() key := uuid.New().String()
user.Parts[key] = PartsState{ user.Parts[key] = PartsState{
UserPartsUuid: key, UserPartsUuid: key,
PartsId: partsId, PartsId: partsId,
Level: 1, Level: 1,
AcquisitionDatetime: nowMillis,
}
log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", partsId)
}
// rollPartsVariant picks one of a parts group's 5 variants at random; the five
// carry distinct PartsInitialLotteryId 1..5, which is the part's rank.
func (g *PossessionGranter) rollPartsVariant(requestedPartsId int32) (int32, PartsRef, bool) {
ref, refOk := g.PartsById[requestedPartsId]
if !refOk {
return requestedPartsId, PartsRef{}, false
}
chosenPartsId := requestedPartsId
chosenRef := ref
if variants := g.PartsVariantsByGroupRarity[ref.PartsGroupId][ref.RarityType]; len(variants) == 5 {
chosenPartsId = variants[rand.Intn(len(variants))]
chosenRef = g.PartsById[chosenPartsId]
} else {
log.Printf("[GrantParts] no 5-variant set for group=%d rarity=%d (have %d), granting requested=%d", ref.PartsGroupId, ref.RarityType, len(variants), requestedPartsId)
}
return chosenPartsId, chosenRef, true
}
func (g *PossessionGranter) createParts(user *UserState, chosenPartsId int32, chosenRef PartsRef, nowMillis int64) {
mainStatId := g.DefaultPartsStatusMainByLotteryGroup[chosenRef.PartsStatusMainLotteryGroupId]
if _, exists := user.PartsGroupNotes[chosenRef.PartsGroupId]; !exists {
user.PartsGroupNotes[chosenRef.PartsGroupId] = PartsGroupNoteState{
PartsGroupId: chosenRef.PartsGroupId,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
key := uuid.New().String()
user.Parts[key] = PartsState{
UserPartsUuid: key,
PartsId: chosenPartsId,
Level: 1,
PartsStatusMainId: mainStatId, PartsStatusMainId: mainStatId,
AcquisitionDatetime: nowMillis, AcquisitionDatetime: nowMillis,
} }
initialCount := chosenRef.PartsInitialLotteryId
pool := g.PartsSubStatusPool[chosenRef.PartsStatusSubLotteryGroupId]
if initialCount > 1 && len(pool) > 0 {
for i := int32(0); i < initialCount-1; i++ {
pickId := pool[rand.Intn(len(pool))]
def, ok := g.PartsSubStatusDefs[pickId]
if !ok {
continue
}
val := def.StatusChangeInitialValue
if def.StatusFunc != nil {
val = def.StatusFunc(1)
}
user.PartsStatusSubs[PartsStatusSubKey{UserPartsUuid: key, StatusIndex: i + 1}] = PartsStatusSubState{
UserPartsUuid: key,
StatusIndex: i + 1,
PartsStatusSubLotteryId: pickId,
Level: 1,
StatusKindType: def.StatusKindType,
StatusCalculationType: def.StatusCalculationType,
StatusChangeValue: val,
LatestVersion: nowMillis,
}
}
}
log.Printf("[GrantParts] chosen=%d group=%d rarity=%d preUnlockedSubs=%d", chosenPartsId, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1)
} }
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) { func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
+3 -2
View File
@@ -8,7 +8,6 @@ const (
starterMissionId = int32(1) starterMissionId = int32(1)
starterMainQuestRouteId = int32(1) starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1) starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
defaultBirthYear = int32(2000) defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1) defaultBirthMonth = int32(1)
@@ -114,7 +113,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
starterMissionId: { starterMissionId: {
MissionId: starterMissionId, MissionId: starterMissionId,
StartDatetime: nowMillis, StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress, MissionProgressStatusType: int32(model.MissionProgressStatusTypeInProgress),
}, },
}, },
Gimmick: GimmickState{ Gimmick: GimmickState{
@@ -125,6 +124,8 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
}, },
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState), TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState),
LabyrinthSeasons: make(map[int32]LabyrinthSeasonState),
LabyrinthStages: make(map[LabyrinthStageKey]LabyrinthStageState),
ConsumableItems: make(map[int32]int32), ConsumableItems: make(map[int32]int32),
Materials: make(map[int32]int32), Materials: make(map[int32]int32),
Thoughts: make(map[string]ThoughtState), Thoughts: make(map[string]ThoughtState),
+30 -11
View File
@@ -2,6 +2,7 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
@@ -78,13 +79,14 @@ func initMaps(u *store.UserState) {
u.ExploreScores = make(map[int32]store.ExploreScoreState) u.ExploreScores = make(map[int32]store.ExploreScoreState)
u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState)
u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState) u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState)
u.LabyrinthSeasons = make(map[int32]store.LabyrinthSeasonState)
u.LabyrinthStages = make(map[store.LabyrinthStageKey]store.LabyrinthStageState)
u.CharacterBoards = make(map[int32]store.CharacterBoardState) u.CharacterBoards = make(map[int32]store.CharacterBoardState)
u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState)
u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState)
u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) u.CharacterRebirths = make(map[int32]store.CharacterRebirthState)
u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState)
u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress)
u.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry)
u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus)
u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore)
u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) u.BigHuntStatuses = make(map[int32]store.BigHuntStatus)
@@ -209,6 +211,17 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes, Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes,
&u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion) &u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion)
var accumulatedDropsJSON string
_ = db.QueryRow(`SELECT quest_type, chapter_id, quest_id, max_auto_orbit_count, cleared_auto_orbit_count, last_clear_datetime, latest_version, accumulated_drops_json
FROM user_quest_auto_orbit WHERE user_id=?`, uid).
Scan(&u.QuestAutoOrbit.QuestType, &u.QuestAutoOrbit.ChapterId, &u.QuestAutoOrbit.QuestId,
&u.QuestAutoOrbit.MaxAutoOrbitCount, &u.QuestAutoOrbit.ClearedAutoOrbitCount,
&u.QuestAutoOrbit.LastClearDatetime, &u.QuestAutoOrbit.LatestVersion,
&accumulatedDropsJSON)
if accumulatedDropsJSON != "" && accumulatedDropsJSON != "[]" {
_ = json.Unmarshal([]byte(accumulatedDropsJSON), &u.QuestAutoOrbit.AccumulatedDrops)
}
var isTicket int var isTicket int
_ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version _ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version
FROM user_explore WHERE user_id=?`, uid). FROM user_explore WHERE user_id=?`, uid).
@@ -376,16 +389,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
} }
}) })
queryRows(db, `SELECT main_quest_season_id, main_quest_route_id, latest_version
FROM user_main_quest_season_routes WHERE user_id=?`, uid, func(rows *sql.Rows) {
var seasonId, routeId int32
var lv int64
rows.Scan(&seasonId, &routeId, &lv)
u.MainQuestSeasonRoutes[store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId}] = store.SeasonRouteEntry{
MainQuestSeasonId: seasonId, MainQuestRouteId: routeId, LatestVersion: lv,
}
})
queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version 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) { FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
@@ -659,6 +662,22 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.TowerAccumulationRewards[v.EventQuestChapterId] = v u.TowerAccumulationRewards[v.EventQuestChapterId] = v
}) })
queryRows(db, `SELECT event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version
FROM user_event_quest_labyrinth_seasons WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.LabyrinthSeasonState
rows.Scan(&v.EventQuestChapterId, &v.LastJoinSeasonNumber, &v.LastSeasonRewardReceivedSeasonNumber, &v.LatestVersion)
u.LabyrinthSeasons[v.EventQuestChapterId] = v
})
queryRows(db, `SELECT event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version
FROM user_event_quest_labyrinth_stages WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.LabyrinthStageState
var rcvd int
rows.Scan(&v.EventQuestChapterId, &v.StageOrder, &rcvd, &v.AccumulationRewardReceivedQuestMissionCount, &v.LatestVersion)
v.IsReceivedStageClearReward = rcvd != 0
u.LabyrinthStages[store.LabyrinthStageKey{EventQuestChapterId: v.EventQuestChapterId, StageOrder: v.StageOrder}] = v
})
queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version 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) { FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserShopItemState var v store.UserShopItemState
+56 -17
View File
@@ -2,12 +2,24 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
func marshalAutoOrbitDrops(drops []store.AutoOrbitDropEntry) string {
if len(drops) == 0 {
return "[]"
}
b, err := json.Marshal(drops)
if err != nil {
return "[]"
}
return string(b)
}
func boolToInt(b bool) int { func boolToInt(b bool) int {
if b { if b {
return 1 return 1
@@ -109,6 +121,13 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
uid, u.GuerrillaFreeOpen.StartDatetime, u.GuerrillaFreeOpen.OpenMinutes, u.GuerrillaFreeOpen.DailyOpenedCount, u.GuerrillaFreeOpen.LatestVersion); err != nil { uid, u.GuerrillaFreeOpen.StartDatetime, u.GuerrillaFreeOpen.OpenMinutes, u.GuerrillaFreeOpen.DailyOpenedCount, u.GuerrillaFreeOpen.LatestVersion); err != nil {
return err return err
} }
if err := exec(`INSERT INTO user_quest_auto_orbit (user_id, quest_type, chapter_id, quest_id, max_auto_orbit_count, cleared_auto_orbit_count, last_clear_datetime, latest_version, accumulated_drops_json) VALUES (?,?,?,?,?,?,?,?,?)`,
uid, u.QuestAutoOrbit.QuestType, u.QuestAutoOrbit.ChapterId, u.QuestAutoOrbit.QuestId,
u.QuestAutoOrbit.MaxAutoOrbitCount, u.QuestAutoOrbit.ClearedAutoOrbitCount,
u.QuestAutoOrbit.LastClearDatetime, u.QuestAutoOrbit.LatestVersion,
marshalAutoOrbitDrops(u.QuestAutoOrbit.AccumulatedDrops)); 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 (?,?,?,?,?)`, 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 { uid, boolToInt(u.Explore.IsUseExploreTicket), u.Explore.PlayingExploreId, u.Explore.LatestPlayDatetime, u.Explore.LatestVersion); err != nil {
return err return err
@@ -224,12 +243,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for k, v := range u.MainQuestSeasonRoutes {
if err := exec(`INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`,
uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion); err != nil {
return err
}
}
for id, v := range u.QuestLimitContentStatus { 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 (?,?,?,?,?)`, 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 { uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil {
@@ -460,6 +473,18 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for _, v := range u.LabyrinthSeasons {
if err := exec(`INSERT INTO user_event_quest_labyrinth_seasons (user_id, event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version) VALUES (?,?,?,?,?)`,
uid, v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion); err != nil {
return err
}
}
for k, v := range u.LabyrinthStages {
if err := exec(`INSERT INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion); err != nil {
return err
}
}
for _, v := range u.ShopItems { 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 (?,?,?,?,?)`, 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 { uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil {
@@ -668,6 +693,15 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return err return err
} }
} }
if !before.QuestAutoOrbit.Equal(after.QuestAutoOrbit) {
if err := exec(`UPDATE user_quest_auto_orbit SET quest_type=?, chapter_id=?, quest_id=?, max_auto_orbit_count=?, cleared_auto_orbit_count=?, last_clear_datetime=?, latest_version=?, accumulated_drops_json=? WHERE user_id=?`,
after.QuestAutoOrbit.QuestType, after.QuestAutoOrbit.ChapterId, after.QuestAutoOrbit.QuestId,
after.QuestAutoOrbit.MaxAutoOrbitCount, after.QuestAutoOrbit.ClearedAutoOrbitCount,
after.QuestAutoOrbit.LastClearDatetime, after.QuestAutoOrbit.LatestVersion,
marshalAutoOrbitDrops(after.QuestAutoOrbit.AccumulatedDrops), uid); err != nil {
return err
}
}
if before.Explore != after.Explore { 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=?`, 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 { boolToInt(after.Explore.IsUseExploreTicket), after.Explore.PlayingExploreId, after.Explore.LatestPlayDatetime, after.Explore.LatestVersion, uid); err != nil {
@@ -786,17 +820,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} 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") }, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version")
for k, v := range after.MainQuestSeasonRoutes {
if old, ok := before.MainQuestSeasonRoutes[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`,
uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion)
}
}
for k := range before.MainQuestSeasonRoutes {
if _, ok := after.MainQuestSeasonRoutes[k]; !ok {
exec(`DELETE FROM user_main_quest_season_routes WHERE user_id=? AND main_quest_season_id=? AND main_quest_route_id=?`, uid, k.MainQuestSeasonId, k.MainQuestRouteId)
}
}
diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id", diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id",
func(v store.QuestLimitContentStatus) []any { func(v store.QuestLimitContentStatus) []any {
return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion}
@@ -1005,6 +1028,22 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion} return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion}
}, },
"event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version") "event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version")
diffMapInt32(tx, uid, before.LabyrinthSeasons, after.LabyrinthSeasons, "user_event_quest_labyrinth_seasons", "event_quest_chapter_id",
func(v store.LabyrinthSeasonState) []any {
return []any{v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion}
},
"event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version")
for k, v := range after.LabyrinthStages {
if old, ok := before.LabyrinthStages[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion)
}
}
for k := range before.LabyrinthStages {
if _, ok := after.LabyrinthStages[k]; !ok {
exec(`DELETE FROM user_event_quest_labyrinth_stages WHERE user_id=? AND event_quest_chapter_id=? AND stage_order=?`, uid, k.EventQuestChapterId, k.StageOrder)
}
}
diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id", diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id",
func(v store.UserShopItemState) []any { func(v store.UserShopItemState) []any {
return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion} return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion}
+3
View File
@@ -79,6 +79,8 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
// Child tables in reverse-dependency order (matches schema's goose Down). // Child tables in reverse-dependency order (matches schema's goose Down).
childTables := []string{ childTables := []string{
"user_event_quest_labyrinth_stages",
"user_event_quest_labyrinth_seasons",
"user_event_quest_tower_accumulation_rewards", "user_event_quest_tower_accumulation_rewards",
"user_cage_ornament_rewards", "user_cage_ornament_rewards",
"user_shop_replaceable_lineup", "user_shop_replaceable_lineup",
@@ -93,6 +95,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_viewed_movies", "user_viewed_movies",
"user_navi_cutin_played", "user_navi_cutin_played",
"user_auto_sale_settings", "user_auto_sale_settings",
"user_quest_auto_orbit",
"user_explore_scores", "user_explore_scores",
"user_tutorials", "user_tutorials",
"user_premium_items", "user_premium_items",
+69 -15
View File
@@ -41,7 +41,6 @@ type UserState struct {
LoginBonus UserLoginBonusState LoginBonus UserLoginBonusState
Tutorials map[int32]TutorialProgressState Tutorials map[int32]TutorialProgressState
MainQuest MainQuestState MainQuest MainQuestState
MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry
EventQuest EventQuestState EventQuest EventQuestState
ExtraQuest ExtraQuestState ExtraQuest ExtraQuestState
SideStoryQuests map[int32]SideStoryQuestProgress SideStoryQuests map[int32]SideStoryQuestProgress
@@ -78,6 +77,8 @@ type UserState struct {
Gimmick GimmickState Gimmick GimmickState
CageOrnamentRewards map[int32]CageOrnamentRewardState CageOrnamentRewards map[int32]CageOrnamentRewardState
TowerAccumulationRewards map[int32]TowerAccumulationRewardState TowerAccumulationRewards map[int32]TowerAccumulationRewardState
LabyrinthSeasons map[int32]LabyrinthSeasonState
LabyrinthStages map[LabyrinthStageKey]LabyrinthStageState
ConsumableItems map[int32]int32 ConsumableItems map[int32]int32
Materials map[int32]int32 Materials map[int32]int32
Parts map[string]PartsState Parts map[string]PartsState
@@ -118,6 +119,7 @@ type UserState struct {
CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid
AutoSaleSettings map[int32]AutoSaleSettingState AutoSaleSettings map[int32]AutoSaleSettingState
CharacterRebirths map[int32]CharacterRebirthState CharacterRebirths map[int32]CharacterRebirthState
QuestAutoOrbit QuestAutoOrbitState
} }
func (u *UserState) EnsureMaps() { func (u *UserState) EnsureMaps() {
@@ -160,9 +162,6 @@ func (u *UserState) EnsureMaps() {
if u.SideStoryQuests == nil { if u.SideStoryQuests == nil {
u.SideStoryQuests = make(map[int32]SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]SideStoryQuestProgress)
} }
if u.MainQuestSeasonRoutes == nil {
u.MainQuestSeasonRoutes = make(map[SeasonRouteKey]SeasonRouteEntry)
}
if u.QuestLimitContentStatus == nil { if u.QuestLimitContentStatus == nil {
u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus)
} }
@@ -196,6 +195,12 @@ func (u *UserState) EnsureMaps() {
if u.TowerAccumulationRewards == nil { if u.TowerAccumulationRewards == nil {
u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState) u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState)
} }
if u.LabyrinthSeasons == nil {
u.LabyrinthSeasons = make(map[int32]LabyrinthSeasonState)
}
if u.LabyrinthStages == nil {
u.LabyrinthStages = make(map[LabyrinthStageKey]LabyrinthStageState)
}
if u.ConsumableItems == nil { if u.ConsumableItems == nil {
u.ConsumableItems = make(map[int32]int32) u.ConsumableItems = make(map[int32]int32)
} }
@@ -327,6 +332,45 @@ type GuerrillaFreeOpenState struct {
LatestVersion int64 LatestVersion int64
} }
type AutoOrbitDropEntry struct {
PossessionType int32
PossessionId int32
Count int32
IsAutoSale bool
}
type QuestAutoOrbitState struct {
QuestType int32
ChapterId int32
QuestId int32
MaxAutoOrbitCount int32
ClearedAutoOrbitCount int32
LastClearDatetime int64
LatestVersion int64
AccumulatedDrops []AutoOrbitDropEntry
}
func (s QuestAutoOrbitState) Equal(other QuestAutoOrbitState) bool {
if s.QuestType != other.QuestType ||
s.ChapterId != other.ChapterId ||
s.QuestId != other.QuestId ||
s.MaxAutoOrbitCount != other.MaxAutoOrbitCount ||
s.ClearedAutoOrbitCount != other.ClearedAutoOrbitCount ||
s.LastClearDatetime != other.LastClearDatetime ||
s.LatestVersion != other.LatestVersion {
return false
}
if len(s.AccumulatedDrops) != len(other.AccumulatedDrops) {
return false
}
for i := range s.AccumulatedDrops {
if s.AccumulatedDrops[i] != other.AccumulatedDrops[i] {
return false
}
}
return true
}
type PortalCageStatusState struct { type PortalCageStatusState struct {
IsCurrentProgress bool IsCurrentProgress bool
DropItemStartDatetime int64 DropItemStartDatetime int64
@@ -582,17 +626,6 @@ type SideStoryActiveProgress struct {
LatestVersion int64 LatestVersion int64
} }
type SeasonRouteKey struct {
MainQuestSeasonId int32
MainQuestRouteId int32
}
type SeasonRouteEntry struct {
MainQuestSeasonId int32
MainQuestRouteId int32
LatestVersion int64
}
type QuestLimitContentStatus struct { type QuestLimitContentStatus struct {
LimitContentQuestStatusType int32 LimitContentQuestStatusType int32
EventQuestChapterId int32 EventQuestChapterId int32
@@ -878,6 +911,27 @@ type TowerAccumulationRewardState struct {
LatestVersion int64 LatestVersion int64
} }
type LabyrinthSeasonState struct {
EventQuestChapterId int32
LastJoinSeasonNumber int32
LastSeasonRewardReceivedSeasonNumber int32
LatestVersion int64
}
// LabyrinthStageKey is the composite key for UserState.LabyrinthStages.
type LabyrinthStageKey struct {
EventQuestChapterId int32
StageOrder int32
}
type LabyrinthStageState struct {
EventQuestChapterId int32
StageOrder int32
IsReceivedStageClearReward bool
AccumulationRewardReceivedQuestMissionCount int32
LatestVersion int64
}
type PartsState struct { type PartsState struct {
UserPartsUuid string UserPartsUuid string
PartsId int32 PartsId int32
+18 -4
View File
@@ -101,8 +101,9 @@ func ChangedTables(before, after *store.UserState) []string {
add("IUserMainQuestMainFlowStatus") add("IUserMainQuestMainFlowStatus")
add("IUserMainQuestProgressStatus") add("IUserMainQuestProgressStatus")
add("IUserMainQuestReplayFlowStatus") add("IUserMainQuestReplayFlowStatus")
} // IUserMainQuestSeasonRoute is derived from MainQuest + Quests at projection
if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) { // time (see proj_quest.go / questflow.QuestHandler.SeasonRoutesFor) — flag it
// whenever either of those upstream inputs changes.
add("IUserMainQuestSeasonRoute") add("IUserMainQuestSeasonRoute")
} }
if before.EventQuest != after.EventQuest { if before.EventQuest != after.EventQuest {
@@ -202,6 +203,7 @@ func ChangedTables(before, after *store.UserState) []string {
} }
if !mapsEqualStruct(before.Quests, after.Quests) { if !mapsEqualStruct(before.Quests, after.Quests) {
add("IUserQuest") add("IUserQuest")
add("IUserMainQuestSeasonRoute")
} }
if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) { if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) {
add("IUserQuestMission") add("IUserQuestMission")
@@ -266,6 +268,12 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) { if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) {
add("IUserEventQuestTowerAccumulationReward") add("IUserEventQuestTowerAccumulationReward")
} }
if !before.QuestAutoOrbit.Equal(after.QuestAutoOrbit) {
add("IUserQuestAutoOrbit")
}
if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) {
add("IUserEventQuestLabyrinthStage")
}
if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) { if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) {
add("IUserBigHuntMaxScore") add("IUserBigHuntMaxScore")
@@ -284,10 +292,12 @@ func ChangedTables(before, after *store.UserState) []string {
} }
if !gimmickStateEqual(before.Gimmick, after.Gimmick) { if !gimmickStateEqual(before.Gimmick, after.Gimmick) {
if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) { if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) ||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
add("IUserGimmick") add("IUserGimmick")
} }
if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) { if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) ||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
add("IUserGimmickOrnamentProgress") add("IUserGimmickOrnamentProgress")
} }
if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) { if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
@@ -431,6 +441,8 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "cageOrnamentId"} return []string{"userId", "cageOrnamentId"}
case "IUserEventQuestTowerAccumulationReward": case "IUserEventQuestTowerAccumulationReward":
return []string{"userId", "eventQuestChapterId"} return []string{"userId", "eventQuestChapterId"}
case "IUserEventQuestLabyrinthStage":
return []string{"userId", "eventQuestChapterId", "stageOrder"}
case "IUserAutoSaleSettingDetail": case "IUserAutoSaleSettingDetail":
return []string{"userId", "possessionAutoSaleItemType"} return []string{"userId", "possessionAutoSaleItemType"}
case "IUserCharacterRebirth": case "IUserCharacterRebirth":
@@ -467,6 +479,8 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "bigHuntWeeklyVersion"} return []string{"userId", "bigHuntWeeklyVersion"}
case "IUserDeckTypeNote": case "IUserDeckTypeNote":
return []string{"userId", "deckType"} return []string{"userId", "deckType"}
case "IUserQuestAutoOrbit":
return []string{"userId"}
default: default:
return nil return nil
} }
+129 -17
View File
@@ -2,11 +2,21 @@ package userdata
import ( import (
"sort" "sort"
"sync"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
var gimmickOrnamentRefs = sync.OnceValue(masterdata.LoadGimmickOrnamentRefs)
var gimmickSequenceChains = sync.OnceValue(masterdata.LoadGimmickSequenceChains)
var hiddenSequenceSet = sync.OnceValue(masterdata.LoadHiddenGimmickSequenceIDs)
var gimmickSequenceRanks = sync.OnceValue(masterdata.LoadGimmickSequenceRanks)
var birdGimmicks = sync.OnceValue(masterdata.LoadBirdGimmickIDs)
const birdDefaultBaseDatetime int64 = 1577836800000 // 2020-01-01 00:00:00 UTC in ms
func init() { func init() {
register("IUserGimmick", func(user store.UserState) string { register("IUserGimmick", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...)
@@ -26,9 +36,65 @@ func init() {
}) })
} }
func projectActiveChainOrnaments(
user store.UserState,
addKey func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef),
sizeFn func() int,
cap int,
) {
refs := gimmickOrnamentRefs()
chains := gimmickSequenceChains()
hiddenSeq := hiddenSequenceSet()
walkChain := func(seqKey store.GimmickSequenceKey) {
chain := chains[seqKey.GimmickSequenceId]
if len(chain) == 0 {
chain = []int32{seqKey.GimmickSequenceId}
}
for _, seqId := range chain {
for _, ref := range refs[seqId] {
addKey(seqKey, seqId, ref)
}
}
}
var nonHidden []store.GimmickSequenceKey
for seqKey := range user.Gimmick.Sequences {
if hiddenSeq[seqKey.GimmickSequenceId] {
walkChain(seqKey)
} else {
nonHidden = append(nonHidden, seqKey)
}
}
for _, seqKey := range nonHidden {
if sizeFn() >= cap {
break
}
walkChain(seqKey)
}
}
func sortedGimmickRecords(user store.UserState) []map[string]any { func sortedGimmickRecords(user store.UserState) []map[string]any {
keys := make([]store.GimmickKey, 0, len(user.Gimmick.Progress))
keySet := make(map[store.GimmickKey]struct{})
// Real progress rows (genuine user data) — always kept.
for key := range user.Gimmick.Progress { for key := range user.Gimmick.Progress {
keySet[key] = struct{}{}
}
projectActiveChainOrnaments(user,
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
keySet[store.GimmickKey{
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
GimmickSequenceId: seqId,
GimmickId: ref.GimmickId,
}] = struct{}{}
},
func() int { return len(keySet) },
masterdata.MaxUserGimmickRows,
)
keys := make([]store.GimmickKey, 0, len(keySet))
for key := range keySet {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
@@ -37,57 +103,103 @@ func sortedGimmickRecords(user store.UserState) []map[string]any {
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.Gimmick.Progress[key] isGimmickCleared := false
startDatetime := user.GameStartDatetime
latestVersion := user.GameStartDatetime
if row, ok := user.Gimmick.Progress[key]; ok {
isGimmickCleared = row.IsGimmickCleared
startDatetime = row.StartDatetime
latestVersion = row.LatestVersion
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
"gimmickSequenceId": row.Key.GimmickSequenceId, "gimmickSequenceId": key.GimmickSequenceId,
"gimmickId": row.Key.GimmickId, "gimmickId": key.GimmickId,
"isGimmickCleared": row.IsGimmickCleared, "isGimmickCleared": isGimmickCleared,
"startDatetime": row.StartDatetime, "startDatetime": startDatetime,
"latestVersion": row.LatestVersion, "latestVersion": latestVersion,
}) })
} }
return records return records
} }
func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any { func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any {
keys := make([]store.GimmickOrnamentKey, 0, len(user.Gimmick.OrnamentProgress))
keySet := make(map[store.GimmickOrnamentKey]struct{})
// Real progress rows (genuine user data) — always kept.
for key := range user.Gimmick.OrnamentProgress { for key := range user.Gimmick.OrnamentProgress {
keySet[key] = struct{}{}
}
projectActiveChainOrnaments(user,
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
keySet[store.GimmickOrnamentKey{
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
GimmickSequenceId: seqId,
GimmickId: ref.GimmickId,
GimmickOrnamentIndex: ref.OrnamentIndex,
}] = struct{}{}
},
func() int { return len(keySet) },
masterdata.MaxUserGimmickRows,
)
keys := make([]store.GimmickOrnamentKey, 0, len(keySet))
for key := range keySet {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
return compareGimmickOrnamentKey(keys[i], keys[j]) < 0 return compareGimmickOrnamentKey(keys[i], keys[j]) < 0
}) })
birdG := birdGimmicks()
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.Gimmick.OrnamentProgress[key] progressValueBit := int32(0)
baseDatetime := user.GameStartDatetime
latestVersion := user.GameStartDatetime
if row, ok := user.Gimmick.OrnamentProgress[key]; ok {
progressValueBit = row.ProgressValueBit
baseDatetime = row.BaseDatetime
latestVersion = row.LatestVersion
} else if birdG[key.GimmickId] {
baseDatetime = birdDefaultBaseDatetime
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
"gimmickSequenceId": row.Key.GimmickSequenceId, "gimmickSequenceId": key.GimmickSequenceId,
"gimmickId": row.Key.GimmickId, "gimmickId": key.GimmickId,
"gimmickOrnamentIndex": row.Key.GimmickOrnamentIndex, "gimmickOrnamentIndex": key.GimmickOrnamentIndex,
"progressValueBit": row.ProgressValueBit, "progressValueBit": progressValueBit,
"baseDatetime": row.BaseDatetime, "baseDatetime": baseDatetime,
"latestVersion": row.LatestVersion, "latestVersion": latestVersion,
}) })
} }
return records return records
} }
func sortedGimmickSequenceRecords(user store.UserState) []map[string]any { func sortedGimmickSequenceRecords(user store.UserState) []map[string]any {
ranks := gimmickSequenceRanks()
keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences)) keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences))
for key := range user.Gimmick.Sequences { for key := range user.Gimmick.Sequences {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
ri, rj := ranks[keys[i].GimmickSequenceId], ranks[keys[j].GimmickSequenceId]
if ri != rj {
return ri < rj
}
if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId { if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId {
return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId
} }
return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId
}) })
if len(keys) > masterdata.MaxUserGimmickRows {
keys = keys[:masterdata.MaxUserGimmickRows]
}
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
@@ -0,0 +1,72 @@
package userdata
import (
"sync"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
var labyrinthCatalog = sync.OnceValue(masterdata.LoadLabyrinthCatalog)
func init() {
register("IUserEventQuestLabyrinthSeason", func(user store.UserState) string {
chapters := labyrinthCatalog().ChaptersByOrder
records := make([]map[string]any, 0, len(chapters))
for _, ch := range chapters {
if st, ok := user.LabyrinthSeasons[ch.EventQuestChapterId]; ok {
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": st.EventQuestChapterId,
"lastJoinSeasonNumber": st.LastJoinSeasonNumber,
"lastSeasonRewardReceivedSeasonNumber": st.LastSeasonRewardReceivedSeasonNumber,
"latestVersion": st.LatestVersion,
})
continue
}
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": ch.EventQuestChapterId,
"lastJoinSeasonNumber": ch.LatestSeasonNumber,
"lastSeasonRewardReceivedSeasonNumber": 0,
"latestVersion": user.GameStartDatetime,
})
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
register("IUserEventQuestLabyrinthStage", func(user store.UserState) string {
records := make([]map[string]any, 0)
for _, ch := range labyrinthCatalog().ChaptersByOrder {
for _, stageOrder := range ch.StageOrders {
key := store.LabyrinthStageKey{
EventQuestChapterId: ch.EventQuestChapterId,
StageOrder: stageOrder,
}
if st, ok := user.LabyrinthStages[key]; ok {
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": st.EventQuestChapterId,
"stageOrder": st.StageOrder,
"isReceivedStageClearReward": st.IsReceivedStageClearReward,
"accumulationRewardReceivedQuestMissionCount": st.AccumulationRewardReceivedQuestMissionCount,
"latestVersion": st.LatestVersion,
})
continue
}
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": ch.EventQuestChapterId,
"stageOrder": stageOrder,
"isReceivedStageClearReward": false,
"accumulationRewardReceivedQuestMissionCount": 0,
"latestVersion": user.GameStartDatetime,
})
}
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
}
+74 -33
View File
@@ -3,6 +3,7 @@ package userdata
import ( import (
"sort" "sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
@@ -13,13 +14,30 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
ids = append(ids, int(id)) ids = append(ids, int(id))
} }
sort.Ints(ids) sort.Ints(ids)
var replayQuestId int32
if user.MainQuest.SavedContext.Active && questHandler != nil {
if scene, ok := questHandler.SceneById[user.MainQuest.ProgressQuestSceneId]; ok {
replayQuestId = scene.QuestId
}
}
records := make([]map[string]any, 0, len(ids)) records := make([]map[string]any, 0, len(ids))
for _, id := range ids { for _, id := range ids {
row := user.Quests[int32(id)] row := user.Quests[int32(id)]
stateType := row.QuestStateType
if replayQuestId != 0 {
switch {
case int32(id) == replayQuestId:
stateType = model.UserQuestStateTypeActive
case stateType == model.UserQuestStateTypeActive:
stateType = model.UserQuestStateTypeCleared
}
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"questId": row.QuestId, "questId": row.QuestId,
"questStateType": row.QuestStateType, "questStateType": stateType,
"isBattleOnly": row.IsBattleOnly, "isBattleOnly": row.IsBattleOnly,
"latestStartDatetime": row.LatestStartDatetime, "latestStartDatetime": row.LatestStartDatetime,
"clearCount": row.ClearCount, "clearCount": row.ClearCount,
@@ -33,8 +51,26 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
} }
func sortedQuestMissionRecords(user store.UserState) []map[string]any { func sortedQuestMissionRecords(user store.UserState) []map[string]any {
keys := make([]store.QuestMissionKey, 0, len(user.QuestMissions)) questMissions := make(map[store.QuestMissionKey]store.UserQuestMissionState, len(user.QuestMissions))
for key := range user.QuestMissions { for key, qm := range user.QuestMissions {
questMissions[key] = qm
}
// Force-clear hidden-story quest-missions so their report gimmicks unlock.
for _, key := range hiddenStoryRequirements().QuestMissions {
if existing, ok := questMissions[key]; ok && existing.IsClear {
continue
}
questMissions[key] = store.UserQuestMissionState{
QuestId: key.QuestId,
QuestMissionId: key.QuestMissionId,
IsClear: true,
LatestClearDatetime: user.GameStartDatetime,
LatestVersion: user.GameStartDatetime,
}
}
keys := make([]store.QuestMissionKey, 0, len(questMissions))
for key := range questMissions {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
@@ -45,7 +81,7 @@ func sortedQuestMissionRecords(user store.UserState) []map[string]any {
}) })
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.QuestMissions[key] row := questMissions[key]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"questId": row.QuestId, "questId": row.QuestId,
@@ -98,38 +134,29 @@ func init() {
return s return s
}) })
register("IUserMainQuestSeasonRoute", func(user store.UserState) string { register("IUserMainQuestSeasonRoute", func(user store.UserState) string {
if len(user.MainQuestSeasonRoutes) == 0 { if questHandler == nil {
// Fallback to current (season, route) for legacy saves with no history. return "[]"
s, _ := utils.EncodeJSONMaps(map[string]any{
"userId": user.UserId,
"mainQuestSeasonId": user.MainQuest.MainQuestSeasonId,
"mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId,
"latestVersion": user.MainQuest.LatestVersion,
})
return s
} }
keys := make([]store.SeasonRouteKey, 0, len(user.MainQuestSeasonRoutes)) pairs := questHandler.SeasonRoutesFor(&user)
for k := range user.MainQuestSeasonRoutes { if len(pairs) == 0 {
keys = append(keys, k) return "[]"
} }
sort.Slice(keys, func(i, j int) bool { seasons := make([]int32, 0, len(pairs))
if keys[i].MainQuestSeasonId != keys[j].MainQuestSeasonId { for s := range pairs {
return keys[i].MainQuestSeasonId < keys[j].MainQuestSeasonId seasons = append(seasons, s)
} }
return keys[i].MainQuestRouteId < keys[j].MainQuestRouteId sort.Slice(seasons, func(i, j int) bool { return seasons[i] < seasons[j] })
}) records := make([]map[string]any, 0, len(seasons))
records := make([]map[string]any, 0, len(keys)) for _, s := range seasons {
for _, k := range keys {
e := user.MainQuestSeasonRoutes[k]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"mainQuestSeasonId": e.MainQuestSeasonId, "mainQuestSeasonId": s,
"mainQuestRouteId": e.MainQuestRouteId, "mainQuestRouteId": pairs[s],
"latestVersion": e.LatestVersion, "latestVersion": user.MainQuest.LatestVersion,
}) })
} }
s, _ := utils.EncodeJSONMaps(records...) out, _ := utils.EncodeJSONMaps(records...)
return s return out
}) })
register("IUserEventQuestProgressStatus", func(user store.UserState) string { register("IUserEventQuestProgressStatus", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(map[string]any{ s, _ := utils.EncodeJSONMaps(map[string]any{
@@ -241,12 +268,26 @@ func init() {
s, _ := utils.EncodeJSONMaps(records...) s, _ := utils.EncodeJSONMaps(records...)
return s return s
}) })
register("IUserQuestAutoOrbit", func(user store.UserState) string {
s := user.QuestAutoOrbit
if s.MaxAutoOrbitCount <= 0 {
return "[]"
}
out, _ := utils.EncodeJSONMaps(map[string]any{
"userId": user.UserId,
"questType": s.QuestType,
"chapterId": s.ChapterId,
"questId": s.QuestId,
"maxAutoOrbitCount": s.MaxAutoOrbitCount,
"clearedAutoOrbitCount": s.ClearedAutoOrbitCount,
"lastClearDatetime": s.LastClearDatetime,
"latestVersion": s.LatestVersion,
})
return out
})
registerStatic( registerStatic(
"IUserEventQuestDailyGroupCompleteReward", "IUserEventQuestDailyGroupCompleteReward",
"IUserEventQuestLabyrinthSeason",
"IUserEventQuestLabyrinthStage",
"IUserQuestReplayFlowRewardGroup", "IUserQuestReplayFlowRewardGroup",
"IUserQuestAutoOrbit",
"IUserQuestSceneChoice", "IUserQuestSceneChoice",
"IUserQuestSceneChoiceHistory", "IUserQuestSceneChoiceHistory",
) )
+25 -3
View File
@@ -2,8 +2,11 @@ package userdata
import ( import (
"sort" "sort"
"sync"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
@@ -192,16 +195,35 @@ func sortedTutorialRecords(user store.UserState) []map[string]any {
return records return records
} }
var hiddenStoryRequirements = sync.OnceValue(masterdata.LoadHiddenStoryRequirements)
func sortedMissionRecords(user store.UserState) []map[string]any { func sortedMissionRecords(user store.UserState) []map[string]any {
ids := make([]int, 0, len(user.Missions)) missions := make(map[int32]store.UserMissionState, len(user.Missions))
for id := range user.Missions { for id, m := range user.Missions {
missions[id] = m
}
for _, missionId := range hiddenStoryRequirements().MissionIds {
if existing, ok := missions[missionId]; ok && existing.MissionProgressStatusType >= int32(model.MissionProgressStatusTypeClear) {
continue
}
missions[missionId] = store.UserMissionState{
MissionId: missionId,
StartDatetime: user.GameStartDatetime,
MissionProgressStatusType: int32(model.MissionProgressStatusTypeClear),
ClearDatetime: user.GameStartDatetime,
LatestVersion: user.GameStartDatetime,
}
}
ids := make([]int, 0, len(missions))
for id := range missions {
ids = append(ids, int(id)) ids = append(ids, int(id))
} }
sort.Ints(ids) sort.Ints(ids)
records := make([]map[string]any, 0, len(ids)) records := make([]map[string]any, 0, len(ids))
for _, id := range ids { for _, id := range ids {
row := user.Missions[int32(id)] row := missions[int32(id)]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"missionId": row.MissionId, "missionId": row.MissionId,
+7
View File
@@ -3,6 +3,7 @@ package userdata
import ( import (
"sort" "sort"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -10,6 +11,12 @@ type Projector func(user store.UserState) string
var projectors = make(map[string]Projector) var projectors = make(map[string]Projector)
var questHandler *questflow.QuestHandler
func SetQuestHandler(h *questflow.QuestHandler) {
questHandler = h
}
func register(tableName string, fn Projector) { func register(tableName string, fn Projector) {
projectors[tableName] = fn projectors[tableName] = fn
} }
@@ -0,0 +1,23 @@
-- +goose Up
CREATE TABLE user_event_quest_labyrinth_seasons (
user_id INTEGER NOT NULL REFERENCES users(user_id),
event_quest_chapter_id INTEGER NOT NULL,
last_join_season_number INTEGER NOT NULL DEFAULT 0,
last_season_reward_received_season_number INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, event_quest_chapter_id)
);
CREATE TABLE user_event_quest_labyrinth_stages (
user_id INTEGER NOT NULL REFERENCES users(user_id),
event_quest_chapter_id INTEGER NOT NULL,
stage_order INTEGER NOT NULL,
is_received_stage_clear_reward INTEGER NOT NULL DEFAULT 0,
accumulation_reward_received_quest_mission_count INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, event_quest_chapter_id, stage_order)
);
-- +goose Down
DROP TABLE IF EXISTS user_event_quest_labyrinth_stages;
DROP TABLE IF EXISTS user_event_quest_labyrinth_seasons;
@@ -0,0 +1,17 @@
-- +goose Up
CREATE TABLE user_quest_auto_orbit (
user_id INTEGER NOT NULL PRIMARY KEY REFERENCES users(user_id),
quest_type INTEGER NOT NULL DEFAULT 0,
chapter_id INTEGER NOT NULL DEFAULT 0,
quest_id INTEGER NOT NULL DEFAULT 0,
max_auto_orbit_count INTEGER NOT NULL DEFAULT 0,
cleared_auto_orbit_count INTEGER NOT NULL DEFAULT 0,
last_clear_datetime INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
accumulated_drops_json TEXT NOT NULL DEFAULT '[]'
);
INSERT INTO user_quest_auto_orbit (user_id) SELECT user_id FROM users;
-- +goose Down
DROP TABLE IF EXISTS user_quest_auto_orbit;
+21
View File
@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"embed"
"github.com/pressly/goose/v3"
)
//go:embed *.sql
var FS embed.FS
func Up(ctx context.Context, db *sql.DB) error {
goose.SetBaseFS(FS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.UpContext(ctx, db, ".", goose.WithAllowMissing())
}