39 Commits

Author SHA1 Message Date
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
Ilya Groshev fa2a124d47 Implement panel missions as static unlock-all
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-16 22:16:31 +03:00
Ilya Groshev 25cbe8635f Implement Tower accumulation reward claiming 2026-05-16 20:51:44 +03:00
Ilya Groshev 1dc5b8fd7c Clear stale side story pointer on mama's room and main-quest transitions 2026-05-16 19:36:07 +03:00
Ilya Groshev c9a1929279 Implement remaining Memoirs preset management RPCs 2026-05-16 19:05:22 +03:00
Ilya Groshev fb111cf1ec Update CHANGELOG with new features and fixes for 2026-05-16 2026-05-16 14:48:29 +03:00
Ilya Groshev 26c10ac429 Implement Recollections of Dusk 2026-05-16 14:35:47 +03:00
Ilya Groshev 15beefb5b8 Implement Memoirs Protect/Unprotect and ConsumableItemService.UseEffectItem
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-14 21:00:28 +03:00
Ilya Groshev dd00cadc18 Award MaterialSaleObtainPossession items on material sell
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-14 11:56:40 +03:00
Ilya Groshev ae884b4060 Persist Subjugation Quests triple-deck presets 2026-05-14 11:39:03 +03:00
Ilya Groshev fa5d023f58 Fix menu-pick for normal difficulty quests
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-13 19:43:50 +03:00
Ilya Groshev 00817684ef Implement Subjugation Quests rewards and battle report
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-13 11:30:56 +03:00
Ilya Groshev 44a03d222b Fix panic when quest finish-reward switch checks an unstarted quest 2026-05-13 08:25:42 +03:00
Ilya Groshev 23f0d26fcd Gacha pool overhaul and DB backup wizard
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-12 20:47:24 +03:00
Ilya Groshev cc9dc4f1c5 Fix menu pick black screen for quests without a difficulty relation
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-12 11:21:19 +03:00
Ilya Groshev 479ace5c8e Fix menu pick from replay flow returning to wrong state 2026-05-12 09:27:28 +03:00
Ilya Groshev 6c9e3c45f0 Fix map replay flow and quest mission rewards
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-11 20:21:55 +03:00
Ilya Groshev 9a2cc92a6f Fix Main Quests replay and weapon awaken level cap
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-09 17:18:48 +03:00
Ilya Groshev 60e0402525 Fix login bonus
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-06 10:16:03 +03:00
AnyUnion 5645740099 Add --no-register flag and register-account CLI
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
Author: https://github.com/REUSS-dev
2026-05-04 17:14:27 +03:00
Ilya Groshev b414db7339 Update CHANGELOG with new features and fixes for 2026-05-02
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-02 09:11:42 +03:00
Ilya Groshev 20d8e4d3df Fix stale story-unlock and post-evolve weapon state 2026-05-01 16:22:24 +03:00
Ilya Groshev 3fe564cb1d Add admin API for content reload 2026-04-28 21:22:28 +03:00
Ilya Groshev 9be0df4c30 Wizard: accept un-split revision-0 asset tree 2026-04-27 21:27:08 +03:00
Ilya Groshev 9001b52b90 Multi platform support 2026-04-27 15:57:12 +03:00
Ilya Groshev f96bd7a88b Add .gitattributes 2026-04-25 20:48:40 +03:00
Ilya Groshev fc3836d502 Update CHANGELOG with new features and fixes for 2026-04-25 2026-04-25 10:37:13 +03:00
Ilya Groshev 8abd5d007e Move directory creation from Makefile to wizard 2026-04-24 20:09:18 +03:00
Ilya Groshev 0c556563c6 Update Makefile for Windows compatibility 2026-04-24 19:32:27 +03:00
Ilya Groshev 7563087bef Enhance wizard CLI with support for --prefer-saved flag to skip confirmation prompts and reuse saved configurations 2026-04-24 19:08:56 +03:00
115 changed files with 10888 additions and 1489 deletions
+6
View File
@@ -0,0 +1,6 @@
* text=auto eol=lf
*.go text eol=lf
*.sh text eol=lf
*.html text eol=lf
*.png binary
*.jpg binary
+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
+1
View File
@@ -11,6 +11,7 @@ server/claim-account
server/octo-cdn server/octo-cdn
server/dev server/dev
server/wizard server/wizard
server/wizard-restore
server/.wizard.json server/.wizard.json
__pycache__/ __pycache__/
+104 -19
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
@@ -24,7 +31,13 @@ cd server
go run ./cmd/wizard go run ./cmd/wizard
``` ```
Your choices are saved so next time you just press Enter to relaunch with the same settings. Your choices are saved so next time you just press Enter to relaunch with the same settings. To skip the confirmation prompt entirely (useful for scripts or quick relaunches), pass `--prefer-saved`:
```bash
go run ./cmd/wizard --prefer-saved
```
If no saved config exists, the flag prints an error and exits.
#### Custom Ports #### Custom Ports
@@ -35,10 +48,12 @@ go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ------------- | ------- | ---------------- | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `--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. |
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.
@@ -66,6 +81,17 @@ mkdir -p db
goose -dir migrations -allow-missing sqlite3 db/game.db up goose -dir migrations -allow-missing sqlite3 db/game.db up
``` ```
### Backups & Restore
The wizard backs up your save every time you launch it. To roll back to an earlier save:
```bash
cd server
make restore
```
Pick a backup from the list and confirm.
### Importing a Snapshot ### Importing a Snapshot
To import a JSON snapshot into the database, use the import tool. The `--uuid` flag must match the UUID your game client sends during authentication: To import a JSON snapshot into the database, use the import tool. The `--uuid` flag must match the UUID your game client sends during authentication:
@@ -86,8 +112,8 @@ 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
@@ -156,7 +182,7 @@ 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 |
@@ -165,29 +191,55 @@ make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
| `--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). |
| `--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 | 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. |
| `--no-register` | `false` | Disable new user registrations (only already registered users can connect). |
### Live Master Data Reload
The game server reads its master data from `assets/release/20240404193219.bin.e` at startup. To swap in updated content **without restarting** the server:
1. Replace `assets/release/20240404193219.bin.e` on disk with your edited copy.
2. POST to the admin webhook with a Bearer token matching `LUNAR_ADMIN_TOKEN`:
```bash
curl -X POST -H "Authorization: Bearer ${LUNAR_ADMIN_TOKEN}" \
http://127.0.0.1:8082/api/admin/master-data/reload
```
The server re-reads the file, atomically swaps every in-memory catalog and derived handler, and bumps the file's mtime. The mtime is folded into `GetLatestMasterDataVersion`, so connected clients see a new version string and re-download the file from the CDN on their next poll.
Security defaults are fail-closed:
- `LUNAR_ADMIN_TOKEN` **must** be set in the environment, or the webhook listener never binds.
- `--admin-listen` defaults to `127.0.0.1:8082` (loopback only). Bind to `0.0.0.0` only if you intend to expose it.
- Authentication uses constant-time Bearer-token comparison.
### 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 |
@@ -206,19 +258,30 @@ 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 | gRPC game server | | `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: `LUNAR_LISTEN` (bind address), `LUNAR_PUBLIC_ADDR` (client-facing address), `LUNAR_OCTO_URL`, and `LUNAR_AUTH_URL`. Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The game server is configured via environment variables in the compose file:
| Env var | Description |
| -------------------- | ------------------------------------------------------------------------------------- |
| `LUNAR_LISTEN` | gRPC bind address |
| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
| `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_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.
### Makefile Targets ### Makefile Targets
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 |
@@ -227,9 +290,11 @@ All targets run from the `server/` directory.
| `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 clean` | Remove the `bin/` directory | | `make clean` | Remove the `bin/` directory |
| `make dev` | Run all three services with one command | | `make dev` | Run all three services with one command |
| `make migrate` | Run goose migrations on `db/game.db` | | `make migrate` | Run goose migrations on `db/game.db` |
| `make restore` | Interactive restore of `db/game.db` from `db/backups/` |
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
## Claim Account ## Claim Account
@@ -244,8 +309,8 @@ 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
@@ -266,10 +331,30 @@ 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). |
## Create account
This tool creates a fresh account in main db and new account in Auth Server store with given name & password and automatically binds them together.
A primary mean of registering new accounts when `--no-register` flag is passed to lunar-tear for controlled server access.
```bash
go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android"
```
| Flag | Default | Description |
| ------------ | ------------ | ------------------------------------------------- |
| `--name` | _(required)_ | Auth Server account nickname to be registered |
| `--password` | _(required)_ | Auth Server account password to be registered |
| `--platform` | `android` | Platform of new user account (`android` or `ios`) |
| `--db` | `db/game.db` | SQLite main 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!
## ⚠️ Legal Disclaimer ## ⚠️ Legal Disclaimer
+54
View File
@@ -1,5 +1,59 @@
# Changelog # Changelog
## 2026-05-16
### Working
- `--no-register` flag and `register-account` CLI tool
- Database backup support in the wizard
- Subjugation Quests — rewards, battle report, and triple-deck preset persistence
- `MaterialSaleObtainPossession` item grants when selling materials
- Memoir protect / unprotect
- Effect item usage (`UseEffectItem`)
- Recollections of Dusk
### Fixed
- Login bonus
- Main quest replay (Map replay)
- Weapon awaken level cap
- Gacha pool overhaul
- Quest mission rewards
- Menu-pick quest start — wrong state from replay flow, black screen on quests with no difficulty, and normal-difficulty handling
## 2026-05-02
### Working
- Cross-platform asset/CDN pipeline (iOS and Android)
- Master data hot-reload: loopback admin webhook (`/api/admin/master-data/reload`) with `LUNAR_ADMIN_TOKEN`; runtime build registry; wizard, dev runner, and Docker wiring
- Scripts repo: Octo **`list.bin` patcher** (`assetbundles/patch_listbin.py`) — refresh catalog `size` / `md5` / `crc` after swapping bundles or resources
- Scripts repo: **asset bundle encrypt/decrypt** for Octo-masked UnityFS (`assetbundles/encrypt_assetbundle.py`, `decrypt_assetbundle.py`, `assetbundle_crypt`)
### Fixed
- Stale weapon story unlocks and incorrect post-evolution weapon state
## 2026-04-25
### Working
- SQLite persistence layer with snapshot import tool
- Authentication server with login UI
- Wizard CLI for guided first-time setup
- Dev runner (`make dev`) with automatic service builds
- Memoir sub-status system with level-based unlocks
- Companion and parts granting from the shop
- `CopyDeck` / `RemoveDeck` deck management
- Karma functionality
- Docker multi-service orchestration (auth, CDN, gRPC) with cross-platform improvements
- `--grpc-port` CLI flag
### Fixed
- Gate desync on quest-finish crash — scene now advances atomically
- Equipment duplication in deck management
## 2026-04-18 ## 2026-04-18
### Working ### Working
+8 -16
View File
@@ -30,38 +30,30 @@ build-auth:
build-claim-account: build-claim-account:
go build -o claim-account$(EXE) ./cmd/claim-account go build -o claim-account$(EXE) ./cmd/claim-account
build-register-account:
go build -o register-account$(EXE) ./cmd/register-account
build-dev: build-dev:
go build -o bin/dev$(EXE) ./cmd/dev go build -o bin/dev$(EXE) ./cmd/dev
build-all: build-all:
ifeq ($(OS),Windows_NT)
if not exist bin mkdir bin
else
mkdir -p bin
endif
go build -o bin/dev$(EXE) ./cmd/dev go build -o bin/dev$(EXE) ./cmd/dev
go build -o bin/auth-server$(EXE) ./cmd/auth-server go build -o bin/auth-server$(EXE) ./cmd/auth-server
go build -o bin/octo-cdn$(EXE) ./cmd/octo-cdn go build -o bin/octo-cdn$(EXE) ./cmd/octo-cdn
go build -o bin/lunar-tear$(EXE) ./cmd/lunar-tear go build -o bin/lunar-tear$(EXE) ./cmd/lunar-tear
clean: clean:
ifeq ($(OS),Windows_NT) rm -rf bin/*
if exist bin rmdir /s /q bin
else
rm -rf bin
endif
dev: dev:
go run ./cmd/dev $(ARGS) go run ./cmd/dev $(ARGS)
migrate: migrate:
ifeq ($(OS),Windows_NT)
if not exist db mkdir db
else
mkdir -p db
endif
$(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up $(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up
restore:
go run ./cmd/wizard-restore
import: import:
ifndef SNAPSHOT ifndef SNAPSHOT
$(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...) $(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...)
@@ -71,4 +63,4 @@ ifndef UUID
endif endif
go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID) go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID)
.PHONY: proto build build-cdn build-auth build-import build-claim-account build-dev build-all clean dev migrate import .PHONY: proto build build-cdn build-auth build-import build-claim-account build-register-account build-dev build-all clean dev migrate restore import
+52 -10
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"lunar-tear/server/internal/auth"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -18,18 +19,40 @@ var loginFS embed.FS
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html")) var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
// oauthRedirectTmpl drives the fbconnect:// hand-off via a renderer-initiated
// navigation instead of a server-side 302. Android WebView does NOT invoke
// WebViewClient.shouldOverrideUrlLoading for 302 redirects from POST form
// submissions to non-http schemes (documented Chromium WebView limitation,
// Stack Overflow #6738328 / Google issuetracker #36918490). Returning a 200
// HTML page with both <meta http-equiv="refresh"> and window.location.replace()
// makes the cross-scheme navigation renderer-initiated, which DOES invoke
// shouldOverrideUrlLoading, so the FB SDK can extract access_token from the
// URL fragment and complete its login flow. html/template auto-escapes {{.}}
// correctly for the meta URL-attribute context and the JS string-literal
// context inside <script>.
var oauthRedirectTmpl = template.Must(template.New("oauthRedirect").Parse(
`<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="refresh" content="0;url={{.}}">
<script>window.location.replace({{.}});</script>
</head><body>
<noscript><a href="{{.}}">Continue</a></noscript>
</body></html>
`))
type Handlers struct { type Handlers struct {
store *AuthStore store *auth.AuthStore
tok *TokenService tok *auth.TokenService
noRegister bool
} }
func NewHandlers(store *AuthStore, tok *TokenService) *Handlers { func NewHandlers(store *auth.AuthStore, tok *auth.TokenService, noRegister bool) *Handlers {
return &Handlers{store: store, tok: tok} return &Handlers{store: store, tok: tok, noRegister: noRegister}
} }
type loginPageData struct { type loginPageData struct {
RedirectURI string RedirectURI string
State string State string
Scope string
Error string Error string
Username string Username string
} }
@@ -77,6 +100,7 @@ func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
data := loginPageData{ data := loginPageData{
RedirectURI: r.URL.Query().Get("redirect_uri"), RedirectURI: r.URL.Query().Get("redirect_uri"),
State: r.URL.Query().Get("state"), State: r.URL.Query().Get("state"),
Scope: r.URL.Query().Get("scope"),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := loginTmpl.Execute(w, data); err != nil { if err := loginTmpl.Execute(w, data); err != nil {
@@ -95,11 +119,13 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
action := r.FormValue("action") action := r.FormValue("action")
redirectURI := r.FormValue("redirect_uri") redirectURI := r.FormValue("redirect_uri")
state := r.FormValue("state") state := r.FormValue("state")
scope := r.FormValue("scope")
renderErr := func(msg string) { renderErr := func(msg string) {
data := loginPageData{ data := loginPageData{
RedirectURI: redirectURI, RedirectURI: redirectURI,
State: state, State: state,
Scope: scope,
Error: msg, Error: msg,
Username: username, Username: username,
} }
@@ -115,13 +141,18 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
return return
} }
var user AuthUser var user auth.AuthUser
var err error var err error
switch action { switch action {
case "register": case "register":
if h.noRegister {
renderErr("This server does not accept user registrations.")
return
}
user, err = h.store.CreateUser(username, password) user, err = h.store.CreateUser(username, password)
if err == ErrUserExists { if err == auth.ErrUserExists {
renderErr("Username is already taken.") renderErr("Username is already taken.")
return return
} }
@@ -134,7 +165,7 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
case "login": case "login":
user, err = h.store.VerifyUser(username, password) user, err = h.store.VerifyUser(username, password)
if err == ErrInvalidCreds { if err == auth.ErrInvalidCreds {
renderErr("Invalid username or password.") renderErr("Invalid username or password.")
return return
} }
@@ -158,20 +189,31 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
} }
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID) payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
b64 := base64.StdEncoding.EncodeToString([]byte(payload)) b64 := base64.RawURLEncoding.EncodeToString([]byte(payload))
fragment := url.Values{} fragment := url.Values{}
fragment.Set("access_token", token) fragment.Set("access_token", token)
fragment.Set("token_type", "bearer") fragment.Set("token_type", "bearer")
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10)) fragment.Set("expires_in", strconv.FormatInt(int64(auth.TokenTTL.Seconds()), 10))
fragment.Set("signed_request", "0."+b64) fragment.Set("signed_request", "0."+b64)
// iOS FBSDKLoginManager treats an empty granted_scopes set as a cancelled login
// (LoginManager.swift -> getSuccessResult -> getCancelledResult). Echo back the
// scope the SDK sent so parameters.permissions is non-empty and the SDK fires
// its success path. Android tolerates either way.
if scope != "" {
fragment.Set("granted_scopes", scope)
fragment.Set("denied_scopes", "")
}
if state != "" { if state != "" {
fragment.Set("state", state) fragment.Set("state", state)
} }
target := redirectURI + "?" + fragment.Encode() target := redirectURI + "?" + fragment.Encode()
log.Printf("redirecting to %s", target) log.Printf("redirecting to %s", target)
http.Redirect(w, r, target, http.StatusFound) w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := oauthRedirectTmpl.Execute(w, target); err != nil {
log.Printf("render oauth redirect: %v", err)
}
} }
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -128,6 +128,7 @@
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}"> <input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="hidden" name="state" value="{{.State}}"> <input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required> <input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
+6 -3
View File
@@ -8,6 +8,8 @@ import (
"log" "log"
"net/http" "net/http"
"lunar-tear/server/internal/auth"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -15,6 +17,7 @@ func main() {
listen := flag.String("listen", "0.0.0.0:3000", "HTTP listen address (host:port)") listen := flag.String("listen", "0.0.0.0:3000", "HTTP listen address (host:port)")
dbPath := flag.String("db", "db/auth.db", "SQLite database path for auth users") dbPath := flag.String("db", "db/auth.db", "SQLite database path for auth users")
secret := flag.String("secret", "", "HMAC secret for tokens (auto-generated if empty)") secret := flag.String("secret", "", "HMAC secret for tokens (auto-generated if empty)")
noRegister := flag.Bool("no-register", false, "Disallow new account registrations for clients, when present. Default = false")
flag.Parse() flag.Parse()
hmacSecret := []byte(*secret) hmacSecret := []byte(*secret)
@@ -33,13 +36,13 @@ func main() {
} }
defer db.Close() defer db.Close()
store, err := NewAuthStore(db) store, err := auth.NewAuthStore(db)
if err != nil { if err != nil {
log.Fatalf("init auth store: %v", err) log.Fatalf("init auth store: %v", err)
} }
tok := NewTokenService(hmacSecret) tok := auth.NewTokenService(hmacSecret)
h := NewHandlers(store, tok) h := NewHandlers(store, tok, *noRegister)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", h.HandleOAuth) mux.HandleFunc("/", h.HandleOAuth)
+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",
+42 -5
View File
@@ -93,7 +93,16 @@ func main() {
grpcOctoURL := flag.String("grpc.octo-url", "", "Octo CDN base URL passed to lunar-tear (default: derived from cdn.public-addr)") grpcOctoURL := flag.String("grpc.octo-url", "", "Octo CDN base URL passed to lunar-tear (default: derived from cdn.public-addr)")
grpcAuthURL := flag.String("grpc.auth-url", "", "auth server base URL passed to lunar-tear (default: derived from auth.listen)") grpcAuthURL := flag.String("grpc.auth-url", "", "auth server base URL passed to lunar-tear (default: derived from auth.listen)")
// admin webhook is opt-in; empty leaves lunar-tear's own default in place
// (the listener still only binds if LUNAR_ADMIN_TOKEN is set in the env).
adminListen := flag.String("admin.listen", "", "lunar-tear admin webhook listen address (host:port). Empty = leave default; webhook only binds when LUNAR_ADMIN_TOKEN is set in the env.")
// Controlled server access
noRegister := flag.Bool("no-register", false, "Disallow new account registrations for clients, when present. Default = false")
// dev utility output config
noColor := flag.Bool("no-color", false, "disable colored output") noColor := flag.Bool("no-color", false, "disable colored output")
flag.Parse() flag.Parse()
if *grpcOctoURL == "" { if *grpcOctoURL == "" {
@@ -111,13 +120,22 @@ func main() {
colorCyan = "" colorCyan = ""
} }
if _, err := os.Stat("go.mod"); err == nil {
log.Println("building services...") log.Println("building services...")
buildAll() 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)
defer stop() defer stop()
noreg_s := ""
if *noRegister {
noreg_s = "--no-register"
}
services := []service{ services := []service{
{ {
label: "auth", label: "auth",
@@ -125,6 +143,7 @@ func main() {
cmd: exec.CommandContext(ctx, filepath.Join("bin", "auth-server"+ext), cmd: exec.CommandContext(ctx, filepath.Join("bin", "auth-server"+ext),
"--listen", *authListen, "--listen", *authListen,
"--db", *authDB, "--db", *authDB,
noreg_s,
), ),
}, },
{ {
@@ -139,11 +158,7 @@ func main() {
label: "grpc", label: "grpc",
color: colorYellow, color: colorYellow,
cmd: exec.CommandContext(ctx, filepath.Join("bin", "lunar-tear"+ext), cmd: exec.CommandContext(ctx, filepath.Join("bin", "lunar-tear"+ext),
"--listen", *grpcListen, grpcArgs(*grpcListen, *grpcPublicAddr, *grpcDB, *grpcOctoURL, *grpcAuthURL, *adminListen, *noRegister)...,
"--public-addr", *grpcPublicAddr,
"--db", *grpcDB,
"--octo-url", *grpcOctoURL,
"--auth-url", *grpcAuthURL,
), ),
}, },
} }
@@ -200,3 +215,25 @@ func prefixLines(wg *sync.WaitGroup, prefix string, r io.Reader) {
fmt.Printf("%s%s\n", prefix, scanner.Text()) fmt.Printf("%s%s\n", prefix, scanner.Text())
} }
} }
// grpcArgs assembles the argv for the lunar-tear subprocess. The admin flag
// is appended only when --admin.listen was supplied so we don't override
// lunar-tear's own default when the operator hasn't opted in.
func grpcArgs(listen, publicAddr, db, octoURL, authURL, adminListen string, noRegister bool) []string {
args := []string{
"--listen", listen,
"--public-addr", publicAddr,
"--db", db,
"--octo-url", octoURL,
"--auth-url", authURL,
}
if adminListen != "" {
args = append(args, "--admin-listen", adminListen)
}
if noRegister {
args = append(args, "--no-register")
}
return args
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"crypto/subtle"
"log"
"net/http"
"os"
"lunar-tear/server/internal/runtime"
)
// startAdmin spins up the admin webhook used by external content tools to
// trigger an in-place re-read of assets/release/20240404193219.bin.e.
//
// Authentication: Bearer token via the LUNAR_ADMIN_TOKEN environment variable.
// If LUNAR_ADMIN_TOKEN is unset or empty the listener does not bind at all
// (fail closed), so a fresh deploy never exposes an unauthenticated endpoint.
//
// The default --admin-listen is 127.0.0.1:8082 so the webhook is only
// reachable via loopback unless the operator opts in by binding to 0.0.0.0.
func startAdmin(listen string, holder *runtime.Holder) {
token := os.Getenv("LUNAR_ADMIN_TOKEN")
if token == "" {
log.Println("[admin] disabled (no LUNAR_ADMIN_TOKEN set)")
return
}
expected := []byte("Bearer " + token)
mux := http.NewServeMux()
mux.HandleFunc("/api/admin/master-data/reload", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
got := []byte(r.Header.Get("Authorization"))
if len(got) != len(expected) || subtle.ConstantTimeCompare(got, expected) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if err := holder.Reload(); err != nil {
log.Printf("[admin] master-data reload failed: %v", err)
http.Error(w, "master-data reload failed", http.StatusInternalServerError)
return
}
log.Printf("[admin] master-data reloaded by %s", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
})
log.Printf("[admin] webhook listener on %s (token-gated)", listen)
go func() {
if err := http.ListenAndServe(listen, mux); err != nil {
log.Printf("[admin] webhook listener failed: %v", err)
}
}()
}
+34 -95
View File
@@ -6,10 +6,8 @@ import (
"strconv" "strconv"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/interceptor" "lunar-tear/server/internal/interceptor"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/service" "lunar-tear/server/internal/service"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -40,27 +38,8 @@ func startGRPC(
store.UserRepository store.UserRepository
store.SessionRepository store.SessionRepository
}, },
questEngine *questflow.QuestHandler, holder *runtime.Holder,
gachaHandler *gacha.GachaHandler, noRegister bool,
gachaEntries []store.GachaCatalogEntry,
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
loginBonusCatalog *masterdata.LoginBonusCatalog,
characterViewerCatalog *masterdata.CharacterViewerCatalog,
shopCatalog *masterdata.ShopCatalog,
costumeCatalog *masterdata.CostumeCatalog,
omikujiCatalog *masterdata.OmikujiCatalog,
weaponCatalog *masterdata.WeaponCatalog,
exploreCatalog *masterdata.ExploreCatalog,
gimmickCatalog *masterdata.GimmickCatalog,
characterBoardCatalog *masterdata.CharacterBoardCatalog,
partsCatalog *masterdata.PartsCatalog,
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
companionCatalog *masterdata.CompanionCatalog,
materialCatalog *masterdata.MaterialCatalog,
consumableItemCatalog *masterdata.ConsumableItemCatalog,
gameConfig *masterdata.GameConfig,
sideStoryCatalog *masterdata.SideStoryCatalog,
bigHuntCatalog *masterdata.BigHuntCatalog,
) *grpc.Server { ) *grpc.Server {
lis, err := net.Listen("tcp", listenAddr) lis, err := net.Listen("tcp", listenAddr)
if err != nil { if err != nil {
@@ -74,39 +53,17 @@ func startGRPC(
grpc.UnknownServiceHandler(interceptor.UnknownService), grpc.UnknownServiceHandler(interceptor.UnknownService),
) )
registerServices(grpcServer, registerServices(grpcServer, publicAddr, octoURL, authURL, userStore, holder, noRegister)
publicAddr,
octoURL,
authURL,
userStore,
questEngine,
gachaHandler,
gachaEntries,
cageOrnamentCatalog,
loginBonusCatalog,
characterViewerCatalog,
shopCatalog,
costumeCatalog,
omikujiCatalog,
weaponCatalog,
exploreCatalog,
gimmickCatalog,
characterBoardCatalog,
partsCatalog,
characterRebirthCatalog,
companionCatalog,
materialCatalog,
consumableItemCatalog,
gameConfig,
sideStoryCatalog,
bigHuntCatalog,
)
reflection.Register(grpcServer) reflection.Register(grpcServer)
log.Printf("gRPC server listening on %s", lis.Addr()) log.Printf("gRPC server listening on %s", lis.Addr())
log.Printf("public address: %s", publicAddr) log.Printf("public address: %s", publicAddr)
if noRegister {
log.Print("[!!WARNING!!] The gRPC server is running in NO-REGISTER mode. All new user registrations are denied, only existing accounts and auth-server logins are permitted.")
}
go func() { go func() {
if err := grpcServer.Serve(lis); err != nil { if err := grpcServer.Serve(lis); err != nil {
log.Printf("gRPC server stopped: %v", err) log.Printf("gRPC server stopped: %v", err)
@@ -124,66 +81,48 @@ func registerServices(
store.UserRepository store.UserRepository
store.SessionRepository store.SessionRepository
}, },
questEngine *questflow.QuestHandler, holder *runtime.Holder,
gachaHandler *gacha.GachaHandler, noRegister bool,
gachaEntries []store.GachaCatalogEntry,
cageOrnamentCatalog *masterdata.CageOrnamentCatalog,
loginBonusCatalog *masterdata.LoginBonusCatalog,
characterViewerCatalog *masterdata.CharacterViewerCatalog,
shopCatalog *masterdata.ShopCatalog,
costumeCatalog *masterdata.CostumeCatalog,
omikujiCatalog *masterdata.OmikujiCatalog,
weaponCatalog *masterdata.WeaponCatalog,
exploreCatalog *masterdata.ExploreCatalog,
gimmickCatalog *masterdata.GimmickCatalog,
characterBoardCatalog *masterdata.CharacterBoardCatalog,
partsCatalog *masterdata.PartsCatalog,
characterRebirthCatalog *masterdata.CharacterRebirthCatalog,
companionCatalog *masterdata.CompanionCatalog,
materialCatalog *masterdata.MaterialCatalog,
consumableItemCatalog *masterdata.ConsumableItemCatalog,
gameConfig *masterdata.GameConfig,
sideStoryCatalog *masterdata.SideStoryCatalog,
bigHuntCatalog *masterdata.BigHuntCatalog,
) { ) {
pubHost, pubPortStr, _ := net.SplitHostPort(publicAddr) pubHost, pubPortStr, _ := net.SplitHostPort(publicAddr)
pubPort, _ := strconv.Atoi(pubPortStr) pubPort, _ := strconv.Atoi(pubPortStr)
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(gachaEntries)) pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(holder))
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, authURL)) pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, holder, authURL, noRegister))
pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore)) pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL)) pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL))
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore)) pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine)) pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, holder))
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, gachaEntries, gachaHandler)) pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, holder))
pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore)) pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore))
pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer()) pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer())
pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog)) pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, holder))
pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, questEngine)) pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, holder))
pb.RegisterNotificationServiceServer(srv, service.NewNotificationServiceServer(userStore, userStore)) pb.RegisterNotificationServiceServer(srv, service.NewNotificationServiceServer(userStore, userStore))
pb.RegisterCageOrnamentServiceServer(srv, service.NewCageOrnamentServiceServer(userStore, userStore, cageOrnamentCatalog, questEngine.Granter)) pb.RegisterCageOrnamentServiceServer(srv, service.NewCageOrnamentServiceServer(userStore, userStore, holder))
pb.RegisterDeckServiceServer(srv, service.NewDeckServiceServer(userStore, userStore)) pb.RegisterDeckServiceServer(srv, service.NewDeckServiceServer(userStore, userStore))
pb.RegisterFriendServiceServer(srv, service.NewFriendServiceServer(userStore, userStore)) pb.RegisterFriendServiceServer(srv, service.NewFriendServiceServer(userStore, userStore))
pb.RegisterLoginBonusServiceServer(srv, service.NewLoginBonusServiceServer(userStore, userStore, loginBonusCatalog)) pb.RegisterLoginBonusServiceServer(srv, service.NewLoginBonusServiceServer(userStore, userStore, holder))
pb.RegisterNaviCutInServiceServer(srv, service.NewNaviCutInServiceServer(userStore, userStore)) pb.RegisterNaviCutInServiceServer(srv, service.NewNaviCutInServiceServer(userStore, userStore))
pb.RegisterContentsStoryServiceServer(srv, service.NewContentsStoryServiceServer(userStore, userStore)) pb.RegisterContentsStoryServiceServer(srv, service.NewContentsStoryServiceServer(userStore, userStore))
pb.RegisterDokanServiceServer(srv, service.NewDokanServiceServer(userStore, userStore)) pb.RegisterDokanServiceServer(srv, service.NewDokanServiceServer(userStore, userStore))
pb.RegisterPortalCageServiceServer(srv, service.NewPortalCageServiceServer(userStore, userStore)) pb.RegisterPortalCageServiceServer(srv, service.NewPortalCageServiceServer(userStore, userStore))
pb.RegisterCharacterViewerServiceServer(srv, service.NewCharacterViewerServiceServer(userStore, userStore, characterViewerCatalog)) pb.RegisterCharacterViewerServiceServer(srv, service.NewCharacterViewerServiceServer(userStore, userStore, holder))
pb.RegisterMissionServiceServer(srv, service.NewMissionServiceServer(userStore, userStore)) pb.RegisterMissionServiceServer(srv, service.NewMissionServiceServer(userStore, userStore))
pb.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, shopCatalog, questEngine.Granter)) pb.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, holder))
pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, costumeCatalog, gameConfig)) pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, holder))
pb.RegisterMovieServiceServer(srv, service.NewMovieServiceServer(userStore, userStore)) pb.RegisterMovieServiceServer(srv, service.NewMovieServiceServer(userStore, userStore))
pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, omikujiCatalog)) pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, holder))
pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, weaponCatalog, gameConfig)) pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, holder))
pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, exploreCatalog)) pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, holder))
pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, characterBoardCatalog)) pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, holder))
pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, partsCatalog, gameConfig)) pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, holder))
pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, characterRebirthCatalog, gameConfig)) pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, holder))
pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, companionCatalog, gameConfig)) pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, holder))
pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, materialCatalog, gameConfig)) pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, holder))
pb.RegisterConsumableItemServiceServer(srv, service.NewConsumableItemServiceServer(userStore, userStore, consumableItemCatalog, gameConfig)) pb.RegisterConsumableItemServiceServer(srv, service.NewConsumableItemServiceServer(userStore, userStore, holder))
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, sideStoryCatalog)) pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, bigHuntCatalog, questEngine)) pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder))
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, bigHuntCatalog, questEngine.Granter)) pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder))
pb.RegisterLabyrinthServiceServer(srv, service.NewLabyrinthServiceServer(userStore, userStore, holder))
} }
+10 -156
View File
@@ -9,30 +9,31 @@ import (
"syscall" "syscall"
"lunar-tear/server/internal/database" "lunar-tear/server/internal/database"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store/sqlite" "lunar-tear/server/internal/store/sqlite"
) )
const masterDataPath = "assets/release/20240404193219.bin.e"
func main() { func main() {
listen := flag.String("listen", "0.0.0.0:443", "gRPC listen address (host:port)") listen := flag.String("listen", "0.0.0.0:443", "gRPC listen address (host:port)")
publicAddr := flag.String("public-addr", "127.0.0.1:443", "externally-reachable host:port advertised to clients") publicAddr := flag.String("public-addr", "127.0.0.1:443", "externally-reachable host:port advertised to clients")
dbPath := flag.String("db", "db/game.db", "SQLite database path") dbPath := flag.String("db", "db/game.db", "SQLite database path")
octoURL := flag.String("octo-url", "", "Octo CDN base URL the client will use for assets (e.g. http://10.0.2.2:8080)") octoURL := flag.String("octo-url", "", "Octo CDN base URL the client will use for assets (e.g. http://10.0.2.2:8080)")
authURL := flag.String("auth-url", "", "Auth server base URL for Facebook token validation (e.g. http://localhost:3000)") authURL := flag.String("auth-url", "", "Auth server base URL for Facebook token validation (e.g. http://localhost:3000)")
adminListen := flag.String("admin-listen", "127.0.0.1:8082", "admin webhook listen address (host:port). Loopback by default; only binds when LUNAR_ADMIN_TOKEN is set.")
noRegister := flag.Bool("no-register", false, "Disallow new account registrations for clients, when present. Default = false")
flag.Parse() flag.Parse()
if *octoURL == "" { if *octoURL == "" {
log.Fatalf("--octo-url is required (e.g. http://10.0.2.2:8080)") log.Fatalf("--octo-url is required (e.g. http://10.0.2.2:8080)")
} }
if err := memorydb.Init("assets/release/20240404193219.bin.e"); err != nil { holder, err := runtime.NewHolder(masterDataPath)
log.Fatalf("load master data: %v", err) if err != nil {
log.Fatalf("init master data: %v", err)
} }
log.Printf("master data loaded (%d tables)", memorydb.TableCount())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -44,158 +45,11 @@ func main() {
defer db.Close() defer db.Close()
log.Printf("database opened: %s", *dbPath) log.Printf("database opened: %s", *dbPath)
gameConfig, err := masterdata.LoadGameConfig()
if err != nil {
log.Fatalf("load game config: %v", err)
}
log.Printf("game config loaded (goldId=%d, skipTicketId=%d, rebirthGold=%d)",
gameConfig.ConsumableItemIdForGold, gameConfig.ConsumableItemIdForQuestSkipTicket, gameConfig.CharacterRebirthConsumeGold)
partsCatalog, err := masterdata.LoadPartsCatalog()
if err != nil {
log.Fatalf("load parts catalog: %v", err)
}
log.Printf("parts catalog loaded: %d parts, %d rarities", len(partsCatalog.PartsById), len(partsCatalog.RarityByRarityType))
questCatalog, err := masterdata.LoadQuestCatalog(partsCatalog)
if err != nil {
log.Fatalf("load quest catalog: %v", err)
}
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig)
userStore := sqlite.New(db, gametime.Now) userStore := sqlite.New(db, gametime.Now)
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog() grpcServer := startGRPC(*listen, *publicAddr, *octoURL, *authURL, userStore, holder, *noRegister)
if err != nil {
log.Fatalf("load gacha catalog: %v", err)
}
log.Printf("gacha catalog loaded: %d entries", len(gachaEntries))
gachaPool, err := masterdata.LoadGachaPool() startAdmin(*adminListen, holder)
if err != nil {
log.Fatalf("load gacha pool: %v", err)
}
log.Printf("gacha pool loaded: costumes=%d rarities, weapons=%d rarities, materials=%d",
len(gachaPool.CostumesByRarity), len(gachaPool.WeaponsByRarity), len(gachaPool.Materials))
shopCatalog, err := masterdata.LoadShopCatalog()
if err != nil {
log.Fatalf("load shop catalog: %v", err)
}
log.Printf("shop catalog loaded: %d items, %d content groups, %d exchange shops",
len(shopCatalog.Items), len(shopCatalog.Contents), len(shopCatalog.ExchangeShopCells))
gachaPool.BuildShopFeatured(shopCatalog)
gachaPool.PruneUnpairedCostumes()
gachaPool.BuildFeaturedMapping(gachaEntries)
gachaPool.BuildBannerPools(gachaEntries)
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
dupExchange, err := masterdata.LoadDupExchange()
if err != nil {
log.Fatalf("load dup exchange: %v", err)
}
dupAdded, err := masterdata.EnrichDupExchange(dupExchange, gachaPool)
if err != nil {
log.Fatalf("enrich dup exchange: %v", err)
}
log.Printf("dup exchange loaded: %d entries (%d derived from limit-break materials)", len(dupExchange), dupAdded)
gachaHandler := gacha.NewGachaHandler(gachaPool, gameConfig, questHandler.Granter, medalInfo, dupExchange)
conditionResolver, err := masterdata.LoadConditionResolver()
if err != nil {
log.Fatalf("load condition resolver: %v", err)
}
cageOrnamentCatalog := masterdata.LoadCageOrnamentCatalog()
loginBonusCatalog := masterdata.LoadLoginBonusCatalog()
characterViewerCatalog := masterdata.LoadCharacterViewerCatalog(conditionResolver)
omikujiCatalog := masterdata.LoadOmikujiCatalog()
materialCatalog, err := masterdata.LoadMaterialCatalog()
if err != nil {
log.Fatalf("load material catalog: %v", err)
}
log.Printf("material catalog loaded: %d materials", len(materialCatalog.All))
consumableItemCatalog, err := masterdata.LoadConsumableItemCatalog()
if err != nil {
log.Fatalf("load consumable item catalog: %v", err)
}
log.Printf("consumable item catalog loaded: %d items", len(consumableItemCatalog.All))
costumeCatalog, err := masterdata.LoadCostumeCatalog(materialCatalog)
if err != nil {
log.Fatalf("load costume catalog: %v", err)
}
log.Printf("costume catalog loaded: %d costumes, %d materials, %d rarity curves", len(costumeCatalog.Costumes), len(costumeCatalog.Materials), len(costumeCatalog.ExpByRarity))
weaponCatalog, err := masterdata.LoadWeaponCatalog(materialCatalog)
if err != nil {
log.Fatalf("load weapon catalog: %v", err)
}
log.Printf("weapon catalog loaded: %d weapons, %d materials, %d enhance configs", len(weaponCatalog.Weapons), len(weaponCatalog.Materials), len(weaponCatalog.ExpByEnhanceId))
exploreCatalog, err := masterdata.LoadExploreCatalog()
if err != nil {
log.Fatalf("load explore catalog: %v", err)
}
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver)
if err != nil {
log.Fatalf("load gimmick catalog: %v", err)
}
characterBoardCatalog, err := masterdata.LoadCharacterBoardCatalog()
if err != nil {
log.Fatalf("load character board catalog: %v", err)
}
log.Printf("character board catalog loaded: %d panels, %d boards", len(characterBoardCatalog.PanelById), len(characterBoardCatalog.BoardById))
characterRebirthCatalog, err := masterdata.LoadCharacterRebirthCatalog()
if err != nil {
log.Fatalf("load character rebirth catalog: %v", err)
}
log.Printf("character rebirth catalog loaded: %d characters", len(characterRebirthCatalog.StepGroupByCharacterId))
companionCatalog, err := masterdata.LoadCompanionCatalog()
if err != nil {
log.Fatalf("load companion catalog: %v", err)
}
log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory))
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
bigHuntCatalog := masterdata.LoadBigHuntCatalog()
grpcServer := startGRPC(
*listen,
*publicAddr,
*octoURL,
*authURL,
userStore,
questHandler,
gachaHandler,
gachaEntries,
cageOrnamentCatalog,
loginBonusCatalog,
characterViewerCatalog,
shopCatalog,
costumeCatalog,
omikujiCatalog,
weaponCatalog,
exploreCatalog,
gimmickCatalog,
characterBoardCatalog,
partsCatalog,
characterRebirthCatalog,
companionCatalog,
materialCatalog,
consumableItemCatalog,
gameConfig,
sideStoryCatalog,
bigHuntCatalog,
)
<-ctx.Done() <-ctx.Done()
log.Println("shutting down...") log.Println("shutting down...")
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"flag"
"log"
"github.com/google/uuid"
"lunar-tear/server/internal/auth"
"lunar-tear/server/internal/database"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store/sqlite"
)
func main() {
dbPath := flag.String("db", "db/game.db", "SQLite database path")
authdbPath := flag.String("auth-db", "db/auth.db", "SQLite auth server database path")
name := flag.String("name", "", "Nickname of the new account to-be")
password := flag.String("password", "", "Password of the new account to-be")
platform := flag.String("platform", "android", "Platform of the user. Can be: \"android\", \"ios\"")
flag.Parse()
if *name == "" {
log.Fatal("--name flag is required")
}
if *password == "" {
log.Fatal("--password flag is required")
}
if (*platform != "android") && (*platform != "ios") {
log.Fatal("--platform can be either \"android\" or \"ios\"")
}
db, err := database.Open(*dbPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer db.Close()
userStore := sqlite.New(db, nil)
authDb, err := database.Open(*authdbPath)
if err != nil {
log.Fatalf("open auth database: %v", err)
}
defer authDb.Close()
authStore, err := auth.NewAuthStore(authDb)
if err != nil {
log.Fatalf("init auth store: %v", err)
}
userExists := authStore.UserExists(*name)
if userExists {
log.Fatal("Username is already taken")
}
var userPlatform model.ClientPlatform
if *platform == "android" {
userPlatform.OsType = model.OsTypeAndroid
userPlatform.PlatformType = model.PlatformTypeGooglePlayStore
} else {
userPlatform.OsType = model.OsTypeIOS
userPlatform.PlatformType = model.PlatformTypeAppStore
}
userUuid := uuid.New().String()
id, err := userStore.CreateUser(userUuid, userPlatform)
if err == nil {
log.Printf("Registered user %d in database successfully", id)
} else {
log.Fatalf("Register user in database: %v", err)
}
authUser, err := authStore.CreateUser(*name, *password)
if err != nil {
log.Fatalf("Register auth account: %v", err)
}
err = userStore.SetFacebookId(id, authUser.ID)
if err == nil {
log.Printf("Bound user %d with facebook account %v", id, authUser.Username)
} else {
log.Fatalf("failed to bind user with facebook account: %v", err)
}
log.Printf("Account %v created successfully.", *name)
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
)
const (
gameDBPath = "db/game.db"
backupDir = "db/backups"
backupSuffix = ".bak"
)
const banner = `
_ _____
| | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _
| |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_|
|____\_,_|_||_\__,_|_| |_|\___|\__,_|_|
╭──────────────────────────────╮
│ RESTORE │
╰──────────────────────────────╯
`
func main() {
lipgloss.EnableLegacyWindowsANSI(os.Stdout)
lipgloss.EnableLegacyWindowsANSI(os.Stderr)
fmt.Print(banner)
chosen, ok := pickBackup()
if !ok {
return
}
if !confirmOverwrite(chosen) {
fmt.Println(" cancelled — nothing changed")
return
}
if err := doRestore(chosen); err != nil {
fmt.Fprintf(os.Stderr, " restore failed: %v\n", err)
os.Exit(1)
}
fmt.Printf(" restored %s from %s\n", gameDBPath, chosen)
}
func pickBackup() (string, bool) {
entries, err := os.ReadDir(backupDir)
if err != nil {
fmt.Fprintln(os.Stderr, " no backups found in", backupDir)
return "", false
}
var backups []string
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) {
backups = append(backups, e.Name())
}
}
if len(backups) == 0 {
fmt.Fprintln(os.Stderr, " no backups found in", backupDir)
return "", false
}
sort.Slice(backups, func(i, j int) bool { return backups[i] > backups[j] })
options := make([]huh.Option[string], 0, len(backups)+1)
for _, name := range backups {
options = append(options, huh.NewOption(name, name))
}
options = append(options, huh.NewOption("Cancel", ""))
var chosen string
if err := huh.NewSelect[string]().
Title("Pick a backup to restore").
Description("db/game.db will be replaced by the chosen file.").
Options(options...).
Value(&chosen).
Run(); err != nil || chosen == "" {
return "", false
}
return chosen, true
}
func confirmOverwrite(chosen string) bool {
confirm := false
if err := huh.NewConfirm().
Title("Overwrite db/game.db?").
Description(fmt.Sprintf(
"This will REPLACE db/game.db with %s.\n"+
"Any progress since that backup will be lost.\n"+
"(A fresh backup will be taken on the next ./wizard launch.)",
chosen)).
Affirmative("Yes, restore").
Negative("Cancel").
Value(&confirm).
Run(); err != nil {
return false
}
return confirm
}
func doRestore(chosen string) error {
src := filepath.Join(backupDir, chosen)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s no longer exists", src)
}
if err := copyFile(src, gameDBPath); err != nil {
return err
}
_ = os.Remove(gameDBPath + "-wal")
_ = os.Remove(gameDBPath + "-shm")
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"charm.land/huh/v2/spinner"
_ "modernc.org/sqlite"
)
const (
gameDBPath = "db/game.db"
backupDir = "db/backups"
backupSuffix = ".bak"
backupRetainN = 10
)
func backupGameDB() {
if _, err := os.Stat(gameDBPath); errors.Is(err, os.ErrNotExist) {
return
}
if !sourceMode {
fmt.Println(" Backing up db/game.db...")
doBackupGameDB()
return
}
_ = spinner.New().Title(" Backing up db/game.db...").Action(doBackupGameDB).Run()
}
func doBackupGameDB() {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err)
return
}
ts := time.Now().UTC().Format("20060102T150405Z")
dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix))
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
if err != nil {
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err)
return
}
defer db.Close()
escaped := strings.ReplaceAll(dest, "'", "''")
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
_ = os.Remove(dest)
return
}
pruneOldBackups()
}
func pruneOldBackups() {
entries, err := os.ReadDir(backupDir)
if err != nil {
return
}
var backups []os.DirEntry
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) {
backups = append(backups, e)
}
}
if len(backups) <= backupRetainN {
return
}
sort.Slice(backups, func(i, j int) bool { return backups[i].Name() < backups[j].Name() })
for _, old := range backups[:len(backups)-backupRetainN] {
_ = os.Remove(filepath.Join(backupDir, old.Name()))
}
}
+135 -19
View File
@@ -39,6 +39,7 @@ type config struct {
GRPCPort int `json:"grpc_port,omitempty"` GRPCPort int `json:"grpc_port,omitempty"`
CDNPort int `json:"cdn_port,omitempty"` CDNPort int `json:"cdn_port,omitempty"`
AuthPort int `json:"auth_port,omitempty"` AuthPort int `json:"auth_port,omitempty"`
AdminPort int `json:"admin_port,omitempty"`
} }
const ( const (
@@ -47,17 +48,22 @@ const (
defaultAuthPort = 3000 defaultAuthPort = 3000
) )
// ports.Admin is opt-in: 0 means the admin webhook is not configured by the
// wizard at all. Other ports always get a default if unset.
type ports struct { type ports struct {
GRPC int GRPC int
CDN int CDN int
Auth int Auth int
Admin int
} }
func main() { func main() {
setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching") setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching")
preferSaved := flag.Bool("prefer-saved", false, "reuse saved config without prompting")
grpcPort := flag.Int("grpc-port", defaultGRPCPort, "gRPC server port") grpcPort := flag.Int("grpc-port", defaultGRPCPort, "gRPC server port")
cdnPort := flag.Int("cdn-port", defaultCDNPort, "CDN server port") cdnPort := flag.Int("cdn-port", defaultCDNPort, "CDN server port")
authPort := flag.Int("auth-port", defaultAuthPort, "auth server port") authPort := flag.Int("auth-port", defaultAuthPort, "auth server port")
adminPort := flag.Int("admin-port", 0, "admin webhook port (0 = disabled). Bound on 127.0.0.1; only takes effect when LUNAR_ADMIN_TOKEN is set.")
flag.Parse() flag.Parse()
flagSet := map[string]bool{} flagSet := map[string]bool{}
@@ -68,21 +74,29 @@ func main() {
fmt.Print(banner) fmt.Print(banner)
sourceMode = isSourceCheckout()
if !*setupOnly { if !*setupOnly {
validateAssets() validateAssets()
if sourceMode {
validateTools() validateTools()
validateProtocIncludes() validateProtocIncludes()
runProtoc() runProtoc()
backupGameDB()
runMigrate() runMigrate()
downloadDeps() downloadDeps()
} else {
backupGameDB()
runMigrateEmbedded()
}
} }
ip, cfg, firstRun := resolveIP() ip, cfg, firstRun := resolveIP(*preferSaved)
p := resolvePorts(flagSet, *grpcPort, *cdnPort, *authPort, cfg) p := resolvePorts(flagSet, *grpcPort, *cdnPort, *authPort, *adminPort, cfg)
savedPorts := portsFromConfig(cfg) savedPorts := portsFromConfig(cfg)
if !firstRun && (p.GRPC != savedPorts.GRPC || p.CDN != savedPorts.CDN || p.Auth != savedPorts.Auth) { if !firstRun && (p.GRPC != savedPorts.GRPC || p.CDN != savedPorts.CDN || p.Auth != savedPorts.Auth || p.Admin != savedPorts.Admin) {
if !warnPortChange(savedPorts, p) { if !warnPortChange(savedPorts, p) {
os.Exit(0) os.Exit(0)
} }
@@ -91,6 +105,7 @@ func main() {
cfg.GRPCPort = p.GRPC cfg.GRPCPort = p.GRPC
cfg.CDNPort = p.CDN cfg.CDNPort = p.CDN
cfg.AuthPort = p.Auth cfg.AuthPort = p.Auth
cfg.AdminPort = p.Admin
saveConfig(cfg) saveConfig(cfg)
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14) labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14)
@@ -100,6 +115,9 @@ func main() {
fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.GRPC))) fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.GRPC)))
fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.CDN))) fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.CDN)))
fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.Auth))) fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.Auth)))
if p.Admin > 0 {
fmt.Printf(" %s %s\n", labelStyle.Render("Admin webhook:"), addrStyle.Render(fmt.Sprintf("127.0.0.1:%d", p.Admin)))
}
fmt.Println() fmt.Println()
if firstRun || *setupOnly { if firstRun || *setupOnly {
@@ -121,23 +139,68 @@ type assetCheck struct {
var requiredAssets = []assetCheck{ var requiredAssets = []assetCheck{
{"assets", true}, {"assets", true},
{"assets/release/20240404193219.bin.e", false}, {"assets/release/20240404193219.bin.e", false},
}
// assetTrees lists every rev-0 layout the CDN accepts. At least one full set
// must be present; users may have only the un-split shared tree, only Android,
// only iOS, or any combination.
var assetTrees = [][]assetCheck{
{
{"assets/revisions/0/list.bin", false}, {"assets/revisions/0/list.bin", false},
{"assets/revisions/0/assetbundle", true}, {"assets/revisions/0/assetbundle", true},
{"assets/revisions/0/resources", true}, {"assets/revisions/0/resources", true},
},
{
{"assets/revisions/0/android/list.bin", false},
{"assets/revisions/0/android/assetbundle", true},
{"assets/revisions/0/android/resources", true},
},
{
{"assets/revisions/0/ios/list.bin", false},
{"assets/revisions/0/ios/assetbundle", true},
{"assets/revisions/0/ios/resources", true},
},
}
func checkAsset(a assetCheck) (missing string, ok bool) {
info, err := os.Stat(a.path)
if err != nil {
return a.path, false
}
if a.dir && !info.IsDir() {
return a.path + string(filepath.Separator), false
}
if !a.dir && info.IsDir() {
return a.path, false
}
return "", true
} }
func validateAssets() { func validateAssets() {
var missing []string var missing []string
for _, a := range requiredAssets { for _, a := range requiredAssets {
info, err := os.Stat(a.path) if m, ok := checkAsset(a); !ok {
if err != nil { missing = append(missing, m)
missing = append(missing, a.path)
continue
} }
if a.dir && !info.IsDir() { }
missing = append(missing, a.path+string(filepath.Separator))
} else if !a.dir && info.IsDir() { var treeMissing [][]string
missing = append(missing, a.path) anyTreeOK := false
for _, group := range assetTrees {
var groupMissing []string
for _, a := range group {
if m, ok := checkAsset(a); !ok {
groupMissing = append(groupMissing, m)
}
}
if len(groupMissing) == 0 {
anyTreeOK = true
}
treeMissing = append(treeMissing, groupMissing)
}
if !anyTreeOK {
for _, gm := range treeMissing {
missing = append(missing, gm...)
} }
} }
@@ -160,6 +223,8 @@ func validateAssets() {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again.")) b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(dimStyle.Render(" At least one of assets/revisions/0/, assets/revisions/0/android/, or assets/revisions/0/ios/ must be fully present."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG")) b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG"))
b.WriteString("\n") b.WriteString("\n")
@@ -289,7 +354,11 @@ func runProtoc() {
func runMigrate() { func runMigrate() {
_ = spinner.New().Title(" Running migrations...").Action(func() { _ = spinner.New().Title(" Running migrations...").Action(func() {
runQuiet(exec.Command(toolPaths["make"], "migrate", "GOOSE="+toolPaths["goose"]), "database migration") if err := os.MkdirAll("db", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create db/: %v\n", err)
os.Exit(1)
}
runQuiet(exec.Command(toolPaths["goose"], "-dir", "migrations", "-allow-missing", "sqlite3", "db/game.db", "up"), "database migration")
}).Run() }).Run()
} }
@@ -311,12 +380,25 @@ func runQuiet(cmd *exec.Cmd, label string) {
} }
} }
func resolveIP() (string, config, bool) { func resolveIP(preferSaved bool) (string, config, bool) {
if cfg, err := loadConfig(); err == nil { cfg, err := loadConfig()
if err == nil {
if preferSaved {
if isLANBased(cfg) {
if ip, updated, ok := recheckLANIP(cfg); ok {
return ip, updated, false
}
}
return cfg.IP, cfg, false
}
ip, cfg, done := handleSavedConfig(cfg) ip, cfg, done := handleSavedConfig(cfg)
if done { if done {
return ip, cfg, false return ip, cfg, false
} }
} else if preferSaved {
fmt.Fprintln(os.Stderr, " --prefer-saved: no saved config found; run without the flag first.")
os.Exit(1)
} }
ip, cfg := runWizard() ip, cfg := runWizard()
@@ -412,6 +494,22 @@ func warnPortChange(old, new ports) bool {
} }
return hlStyle.Render(fmt.Sprintf(" %-7s %d → %d", label+":", oldP, newP)) return hlStyle.Render(fmt.Sprintf(" %-7s %d → %d", label+":", oldP, newP))
} }
// Admin formatting handles the disabled (0) state since the port is
// opt-in and we don't want to display "0" to the user.
adminLine := func(oldP, newP int) (string, bool) {
switch {
case oldP == 0 && newP == 0:
return "", false
case oldP == 0 && newP != 0:
return hlStyle.Render(fmt.Sprintf(" %-7s disabled → %d", "Admin:", newP)), true
case oldP != 0 && newP == 0:
return hlStyle.Render(fmt.Sprintf(" %-7s %d → disabled", "Admin:", oldP)), true
case oldP == newP:
return dimStyle.Render(fmt.Sprintf(" %-7s %d (unchanged)", "Admin:", oldP)), true
default:
return hlStyle.Render(fmt.Sprintf(" %-7s %d → %d", "Admin:", oldP, newP)), true
}
}
var b strings.Builder var b strings.Builder
b.WriteString("\n") b.WriteString("\n")
@@ -422,7 +520,12 @@ func warnPortChange(old, new ports) bool {
b.WriteString(portLine("CDN", old.CDN, new.CDN)) b.WriteString(portLine("CDN", old.CDN, new.CDN))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(portLine("Auth", old.Auth, new.Auth)) b.WriteString(portLine("Auth", old.Auth, new.Auth))
b.WriteString("\n\n") b.WriteString("\n")
if line, show := adminLine(old.Admin, new.Admin); show {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Your APK was patched for the old ports. You may need to re-patch.")) b.WriteString(dimStyle.Render(" Your APK was patched for the old ports. You may need to re-patch."))
b.WriteString("\n\n") b.WriteString("\n\n")
fmt.Print(b.String()) fmt.Print(b.String())
@@ -756,7 +859,7 @@ func loadConfig() (config, error) {
} }
func portsFromConfig(cfg config) ports { func portsFromConfig(cfg config) ports {
p := ports{GRPC: cfg.GRPCPort, CDN: cfg.CDNPort, Auth: cfg.AuthPort} p := ports{GRPC: cfg.GRPCPort, CDN: cfg.CDNPort, Auth: cfg.AuthPort, Admin: cfg.AdminPort}
if p.GRPC == 0 { if p.GRPC == 0 {
p.GRPC = defaultGRPCPort p.GRPC = defaultGRPCPort
} }
@@ -766,10 +869,11 @@ func portsFromConfig(cfg config) ports {
if p.Auth == 0 { if p.Auth == 0 {
p.Auth = defaultAuthPort p.Auth = defaultAuthPort
} }
// Admin is opt-in: leave 0 = disabled.
return p return p
} }
func resolvePorts(flagSet map[string]bool, grpcFlag, cdnFlag, authFlag int, saved config) ports { func resolvePorts(flagSet map[string]bool, grpcFlag, cdnFlag, authFlag, adminFlag int, saved config) ports {
resolve := func(name string, flagVal, savedVal, defaultVal int) int { resolve := func(name string, flagVal, savedVal, defaultVal int) int {
if flagSet[name] { if flagSet[name] {
return flagVal return flagVal
@@ -783,6 +887,9 @@ func resolvePorts(flagSet map[string]bool, grpcFlag, cdnFlag, authFlag int, save
GRPC: resolve("grpc-port", grpcFlag, saved.GRPCPort, defaultGRPCPort), GRPC: resolve("grpc-port", grpcFlag, saved.GRPCPort, defaultGRPCPort),
CDN: resolve("cdn-port", cdnFlag, saved.CDNPort, defaultCDNPort), CDN: resolve("cdn-port", cdnFlag, saved.CDNPort, defaultCDNPort),
Auth: resolve("auth-port", authFlag, saved.AuthPort, defaultAuthPort), Auth: resolve("auth-port", authFlag, saved.AuthPort, defaultAuthPort),
// defaultVal=0 keeps admin opt-in: never enabled unless --admin-port
// is passed or a non-zero value was previously saved.
Admin: resolve("admin-port", adminFlag, saved.AdminPort, 0),
} }
} }
@@ -801,6 +908,7 @@ func launchDev(ip string, p ports) {
} }
devBin := filepath.Join("bin", "dev"+ext) devBin := filepath.Join("bin", "dev"+ext)
if sourceMode {
_ = spinner.New().Title(" Building services...").Action(func() { _ = spinner.New().Title(" Building services...").Action(func() {
if err := os.MkdirAll("bin", 0755); err != nil { if err := os.MkdirAll("bin", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err) fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
@@ -808,14 +916,22 @@ func launchDev(ip string, p ports) {
} }
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev") runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run() }).Run()
}
cmd := exec.Command(devBin, devArgs := []string{
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC), "--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
"--grpc.public-addr", fmt.Sprintf("%s:%d", ip, p.GRPC), "--grpc.public-addr", fmt.Sprintf("%s:%d", ip, p.GRPC),
"--cdn.listen", fmt.Sprintf("0.0.0.0:%d", p.CDN), "--cdn.listen", fmt.Sprintf("0.0.0.0:%d", p.CDN),
"--cdn.public-addr", fmt.Sprintf("%s:%d", ip, p.CDN), "--cdn.public-addr", fmt.Sprintf("%s:%d", ip, p.CDN),
"--auth.listen", fmt.Sprintf("0.0.0.0:%d", p.Auth), "--auth.listen", fmt.Sprintf("0.0.0.0:%d", p.Auth),
) }
// Bind admin on loopback only — the wizard is for local dev, and the
// webhook should never be exposed to the LAN by accident. Operators who
// want a different bind can run cmd/dev directly with --admin.listen.
if p.Admin > 0 {
devArgs = append(devArgs, "--admin.listen", fmt.Sprintf("127.0.0.1:%d", p.Admin))
}
cmd := exec.Command(devBin, devArgs...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
+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)
}
}
+3
View File
@@ -10,11 +10,14 @@ services:
LUNAR_PUBLIC_ADDR: 127.0.0.1:8003 LUNAR_PUBLIC_ADDR: 127.0.0.1:8003
LUNAR_OCTO_URL: http://cdn:8080 LUNAR_OCTO_URL: http://cdn:8080
LUNAR_AUTH_URL: http://auth:3000 LUNAR_AUTH_URL: http://auth:3000
LUNAR_ADMIN_LISTEN: 0.0.0.0:8082
LUNAR_ADMIN_TOKEN: ${LUNAR_ADMIN_TOKEN:-}
volumes: volumes:
- ./db:/opt/lunar-tear/db - ./db:/opt/lunar-tear/db
- ./assets:/opt/lunar-tear/assets - ./assets:/opt/lunar-tear/assets
ports: ports:
- 8003:8003 - 8003:8003
- 127.0.0.1:8082:8082
depends_on: depends_on:
- cdn - cdn
- auth - auth
+7 -1
View File
@@ -9,8 +9,14 @@ if [ -n "${LUNAR_AUTH_URL}" ]; then
AUTH_FLAG="--auth-url ${LUNAR_AUTH_URL}" AUTH_FLAG="--auth-url ${LUNAR_AUTH_URL}"
fi fi
ADMIN_FLAG=""
if [ -n "${LUNAR_ADMIN_LISTEN}" ]; then
ADMIN_FLAG="--admin-listen ${LUNAR_ADMIN_LISTEN}"
fi
exec ./lunar-tear \ exec ./lunar-tear \
--listen "${LUNAR_LISTEN:-0.0.0.0:443}" \ --listen "${LUNAR_LISTEN:-0.0.0.0:443}" \
--public-addr "${LUNAR_PUBLIC_ADDR}" \ --public-addr "${LUNAR_PUBLIC_ADDR}" \
--octo-url "${LUNAR_OCTO_URL}" \ --octo-url "${LUNAR_OCTO_URL}" \
${AUTH_FLAG} ${AUTH_FLAG} \
${ADMIN_FLAG}
+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=
@@ -1,4 +1,4 @@
package main package auth
import ( import (
"database/sql" "database/sql"
@@ -1,4 +1,4 @@
package main package auth
import ( import (
"crypto/hmac" "crypto/hmac"
@@ -10,7 +10,7 @@ import (
"time" "time"
) )
const tokenTTL = 24 * time.Hour const TokenTTL = 24 * time.Hour
var ( var (
ErrTokenInvalid = errors.New("invalid token") ErrTokenInvalid = errors.New("invalid token")
@@ -38,7 +38,7 @@ func (t *TokenService) Generate(user AuthUser) (string, error) {
Sub: user.ID, Sub: user.ID,
Name: user.Username, Name: user.Username,
Iat: now, Iat: now,
Exp: now + int64(tokenTTL.Seconds()), Exp: now + int64(TokenTTL.Seconds()),
} }
payload, err := json.Marshal(claims) payload, err := json.Marshal(claims)
+9 -18
View File
@@ -49,11 +49,6 @@ type RewardItem struct {
Count int32 Count int32
} }
type BigHuntWeeklyRewardKey struct {
ScheduleId int32
AttributeType int32
}
type BigHuntCatalog struct { type BigHuntCatalog struct {
BossQuestById map[int32]BigHuntBossQuestRow BossQuestById map[int32]BigHuntBossQuestRow
QuestById map[int32]BigHuntQuestRow QuestById map[int32]BigHuntQuestRow
@@ -64,7 +59,7 @@ type BigHuntCatalog struct {
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
ScoreRewardThresholds map[int32][]ScoreRewardThreshold ScoreRewardThresholds map[int32][]ScoreRewardThreshold
RewardItems map[int32][]RewardItem RewardItems map[int32][]RewardItem
WeeklyRewardSchedules map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry WeeklyRewardSchedulesByAttr map[int32][]ScoreRewardScheduleEntry
} }
func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 { func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 {
@@ -80,8 +75,8 @@ func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMi
return 0 return 0
} }
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupId(key BigHuntWeeklyRewardKey, nowMillis int64) int32 { func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupIdByAttr(attributeType int32, nowMillis int64) int32 {
entries := c.WeeklyRewardSchedules[key] entries := c.WeeklyRewardSchedulesByAttr[attributeType]
for _, e := range entries { for _, e := range entries {
if nowMillis >= e.StartDatetime { if nowMillis >= e.StartDatetime {
return e.BigHuntScoreRewardGroupId return e.BigHuntScoreRewardGroupId
@@ -264,20 +259,16 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
if err != nil { if err != nil {
log.Fatalf("load big hunt weekly attribute score reward group schedule table: %v", err) log.Fatalf("load big hunt weekly attribute score reward group schedule table: %v", err)
} }
weeklyRewardSchedules := make(map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry) weeklyRewardSchedulesByAttr := make(map[int32][]ScoreRewardScheduleEntry)
for _, r := range weeklySchedRows { for _, r := range weeklySchedRows {
key := BigHuntWeeklyRewardKey{ weeklyRewardSchedulesByAttr[r.AttributeType] = append(weeklyRewardSchedulesByAttr[r.AttributeType], ScoreRewardScheduleEntry{
ScheduleId: r.BigHuntWeeklyAttributeScoreRewardGroupScheduleId,
AttributeType: r.AttributeType,
}
weeklyRewardSchedules[key] = append(weeklyRewardSchedules[key], ScoreRewardScheduleEntry{
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId, BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
StartDatetime: r.StartDatetime, StartDatetime: r.StartDatetime,
}) })
} }
for k := range weeklyRewardSchedules { for k := range weeklyRewardSchedulesByAttr {
sort.Slice(weeklyRewardSchedules[k], func(i, j int) bool { sort.Slice(weeklyRewardSchedulesByAttr[k], func(i, j int) bool {
return weeklyRewardSchedules[k][i].StartDatetime > weeklyRewardSchedules[k][j].StartDatetime return weeklyRewardSchedulesByAttr[k][i].StartDatetime > weeklyRewardSchedulesByAttr[k][j].StartDatetime
}) })
} }
@@ -294,6 +285,6 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
ScoreRewardSchedules: scoreRewardSchedules, ScoreRewardSchedules: scoreRewardSchedules,
ScoreRewardThresholds: scoreRewardThresholds, ScoreRewardThresholds: scoreRewardThresholds,
RewardItems: rewardItems, RewardItems: rewardItems,
WeeklyRewardSchedules: weeklyRewardSchedules, WeeklyRewardSchedulesByAttr: weeklyRewardSchedulesByAttr,
} }
} }
@@ -8,6 +8,7 @@ import (
type ConsumableItemCatalog struct { type ConsumableItemCatalog struct {
All map[int32]EntityMConsumableItem All map[int32]EntityMConsumableItem
Effects map[int32][]EntityMConsumableItemEffect
} }
func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) { func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
@@ -15,12 +16,20 @@ func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("load consumable item table: %w", err) return nil, fmt.Errorf("load consumable item table: %w", err)
} }
effects, err := utils.ReadTable[EntityMConsumableItemEffect]("m_consumable_item_effect")
if err != nil {
return nil, fmt.Errorf("load consumable item effect table: %w", err)
}
catalog := &ConsumableItemCatalog{ catalog := &ConsumableItemCatalog{
All: make(map[int32]EntityMConsumableItem, len(rows)), All: make(map[int32]EntityMConsumableItem, len(rows)),
Effects: make(map[int32][]EntityMConsumableItemEffect, len(effects)),
} }
for _, row := range rows { for _, row := range rows {
catalog.All[row.ConsumableItemId] = row catalog.All[row.ConsumableItemId] = row
} }
for _, e := range effects {
catalog.Effects[e.ConsumableItemId] = append(catalog.Effects[e.ConsumableItemId], e)
}
return catalog, nil return catalog, 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,
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -124,9 +124,9 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
}) })
} }
for groupId, steps := range stepupSteps { for _, steps := range stepupSteps {
first := steps[0] first := steps[0]
gachaId := groupId gachaId := first.DestinationDomainId
medal := gachaToMedal[first.DestinationDomainId] medal := gachaToMedal[first.DestinationDomainId]
medalId := medal.GachaMedalId medalId := medal.GachaMedalId
@@ -154,7 +154,7 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
GachaDecorationType: model.GachaDecorationFestival, GachaDecorationType: model.GachaDecorationFestival,
SortOrder: first.SortOrderDesc, SortOrder: first.SortOrderDesc,
BannerAssetName: first.BannerAssetName, BannerAssetName: first.BannerAssetName,
GroupId: groupId, GroupId: gachaId,
CeilingCount: model.PityCeilingCount, CeilingCount: model.PityCeilingCount,
PricePhases: pricePhases, PricePhases: pricePhases,
MaxStepNumber: maxStep, MaxStepNumber: maxStep,
+207 -140
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"
@@ -34,9 +33,22 @@ type ShopFeaturedEntry struct {
WeaponId int32 WeaponId int32
} }
type CatalogTerm struct {
TermId int32
StartDatetime int64
Costumes []GachaPoolItem
Weapons []GachaPoolItem
}
// StandardPoolTermId is the catalog term whose items form the cross-banner
// standard pool (term 1 holds the launch starter set).
const StandardPoolTermId int32 = 1
type GachaCatalog struct { type GachaCatalog struct {
CostumesByRarity map[int32][]GachaPoolItem CostumesByRarity map[int32][]GachaPoolItem
WeaponsByRarity map[int32][]GachaPoolItem WeaponsByRarity map[int32][]GachaPoolItem
StandardCostumesByRarity map[int32][]GachaPoolItem
StandardWeaponsByRarity map[int32][]GachaPoolItem
Materials []GachaPoolItem Materials []GachaPoolItem
CostumeById map[int32]GachaPoolItem CostumeById map[int32]GachaPoolItem
WeaponById map[int32]GachaPoolItem WeaponById map[int32]GachaPoolItem
@@ -44,6 +56,8 @@ type GachaCatalog struct {
FeaturedByGacha map[int32]FeaturedSet FeaturedByGacha map[int32]FeaturedSet
BannerPools map[int32]*BannerPool BannerPools map[int32]*BannerPool
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
TermById map[int32]*CatalogTerm
TermsByStartDatetime map[int64][]*CatalogTerm
} }
func LoadGachaPool() (*GachaCatalog, error) { func LoadGachaPool() (*GachaCatalog, error) {
@@ -73,28 +87,54 @@ func LoadGachaPool() (*GachaCatalog, error) {
} }
evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows) evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows)
terms, err := utils.ReadTable[EntityMCatalogTerm]("m_catalog_term")
if err != nil {
return nil, fmt.Errorf("load catalog term table: %w", err)
}
firstClearRewards, err := utils.ReadTable[EntityMQuestFirstClearRewardGroup]("m_quest_first_clear_reward_group")
if err != nil {
return nil, fmt.Errorf("load quest first clear reward group table: %w", err)
}
sceneGrants, err := utils.ReadTable[EntityMUserQuestSceneGrantPossession]("m_user_quest_scene_grant_possession")
if err != nil {
return nil, fmt.Errorf("load user quest scene grant possession table: %w", err)
}
missionRewardRows, err := utils.ReadTable[EntityMMissionReward]("m_mission_reward")
if err != nil {
return nil, fmt.Errorf("load mission reward table: %w", err)
}
questGrantedCostumes := make(map[int32]bool)
questGrantedWeapons := make(map[int32]bool)
collectGrant := func(possType, possId int32) {
switch possType {
case int32(model.PossessionTypeCostume):
questGrantedCostumes[possId] = true
case int32(model.PossessionTypeWeapon):
questGrantedWeapons[possId] = true
}
}
for _, r := range firstClearRewards {
collectGrant(r.PossessionType, r.PossessionId)
}
for _, r := range sceneGrants {
collectGrant(r.PossessionType, r.PossessionId)
}
for _, r := range missionRewardRows {
collectGrant(r.PossessionType, r.PossessionId)
}
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes)) catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
costumeTermId := make(map[int32]int32, len(catalogCostumes))
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
} }
@@ -107,8 +147,16 @@ func LoadGachaPool() (*GachaCatalog, error) {
WeaponById: make(map[int32]GachaPoolItem), WeaponById: make(map[int32]GachaPoolItem),
CostumeWeaponMap: make(map[int32]int32), CostumeWeaponMap: make(map[int32]int32),
FeaturedByGacha: make(map[int32]FeaturedSet), FeaturedByGacha: make(map[int32]FeaturedSet),
TermById: make(map[int32]*CatalogTerm),
TermsByStartDatetime: make(map[int64][]*CatalogTerm),
}
for _, t := range terms {
ct := &CatalogTerm{TermId: t.CatalogTermId, StartDatetime: t.StartDatetime}
pool.TermById[t.CatalogTermId] = ct
pool.TermsByStartDatetime[t.StartDatetime] = append(pool.TermsByStartDatetime[t.StartDatetime], ct)
} }
questGrantedCostumeCount := 0
for _, c := range costumes { for _, c := range costumes {
if !catalogCostumeSet[c.CostumeId] { if !catalogCostumeSet[c.CostumeId] {
continue continue
@@ -116,6 +164,10 @@ func LoadGachaPool() (*GachaCatalog, error) {
if c.RarityType < model.RaritySRare { if c.RarityType < model.RaritySRare {
continue continue
} }
if questGrantedCostumes[c.CostumeId] {
questGrantedCostumeCount++
continue
}
item := GachaPoolItem{ item := GachaPoolItem{
PossessionType: int32(model.PossessionTypeCostume), PossessionType: int32(model.PossessionTypeCostume),
PossessionId: c.CostumeId, PossessionId: c.CostumeId,
@@ -127,11 +179,18 @@ func LoadGachaPool() (*GachaCatalog, error) {
} }
restrictedCount := 0 restrictedCount := 0
questGrantedWeaponCount := 0
evolvedFilteredCount := 0
for _, w := range weapons { for _, w := range weapons {
if !catalogWeaponSet[w.WeaponId] { if !catalogWeaponSet[w.WeaponId] {
continue continue
} }
if evolvedWeapons[w.WeaponId] { if evolvedWeapons[w.WeaponId] {
evolvedFilteredCount++
continue
}
if questGrantedWeapons[w.WeaponId] {
questGrantedWeaponCount++
continue continue
} }
item := GachaPoolItem{ item := GachaPoolItem{
@@ -147,62 +206,56 @@ func LoadGachaPool() (*GachaCatalog, error) {
pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item) pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item)
} }
log.Printf("[GachaPool] excluded %d evolved weapons, %d restricted weapons from pool", len(evolvedWeapons), restrictedCount) // Bucket catalog items into their terms (uses the post-filter CostumeById/WeaponById).
for _, cc := range catalogCostumes {
type weaponKey struct { ct := pool.TermById[cc.CatalogTermId]
TermId int32 if ct == nil {
WeaponType int32 continue
Rarity int32 }
if item, ok := pool.CostumeById[cc.CostumeId]; ok {
ct.Costumes = append(ct.Costumes, item)
}
} }
weaponsByKey := make(map[weaponKey][]int32)
for _, cw := range catalogWeapons { for _, cw := range catalogWeapons {
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] { ct := pool.TermById[cw.CatalogTermId]
if ct == nil || restrictedWeapons[cw.WeaponId] {
continue continue
} }
wt := weaponTypeById[cw.WeaponId] if item, ok := pool.WeaponById[cw.WeaponId]; ok {
r := weaponRarityById[cw.WeaponId] ct.Weapons = append(ct.Weapons, item)
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 // Standard pool: items in term 1 (the launch starter set, same on every banner).
for costumeId, item := range pool.CostumeById { pool.StandardCostumesByRarity = make(map[int32][]GachaPoolItem)
tid := costumeTermId[costumeId] pool.StandardWeaponsByRarity = make(map[int32][]GachaPoolItem)
wt := costumeWeaponType[costumeId] if std := pool.TermById[StandardPoolTermId]; std != nil {
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType} for _, c := range std.Costumes {
candidates := weaponsByKey[k] pool.StandardCostumesByRarity[c.RarityType] = append(pool.StandardCostumesByRarity[c.RarityType], c)
if len(candidates) == 0 {
continue
} }
if len(candidates) == 1 { for _, w := range std.Weapons {
pool.CostumeWeaponMap[costumeId] = candidates[0] pool.StandardWeaponsByRarity[w.RarityType] = append(pool.StandardWeaponsByRarity[w.RarityType], w)
exact++
continue
} }
idPattern := costumeId*10 + 1 }
found := false stdCos, stdWea := 0, 0
for _, wid := range candidates { for _, items := range pool.StandardCostumesByRarity {
if wid == idPattern { stdCos += len(items)
}
for _, items := range pool.StandardWeaponsByRarity {
stdWea += len(items)
}
log.Printf("[GachaPool] catalog terms: %d, standard pool: %d costumes + %d weapons (term %d)",
len(pool.TermById), stdCos, stdWea, StandardPoolTermId)
log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons",
evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount)
for costumeId := range pool.CostumeById {
if wid, ok := costumeWeaponPairings[costumeId]; ok {
pool.CostumeWeaponMap[costumeId] = wid pool.CostumeWeaponMap[costumeId] = wid
pattern++
found = true
break
} }
} }
if !found { log.Printf("[GachaPool] costume-weapon pairing: %d entries from lookup table", len(pool.CostumeWeaponMap))
pool.CostumeWeaponMap[costumeId] = candidates[0]
bestGuess++
}
}
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total",
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{
@@ -217,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
@@ -237,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() {
@@ -269,119 +317,138 @@ func (pool *GachaCatalog) PruneUnpairedCostumes() {
log.Printf("[GachaPool] pruned %d unpaired costumes", pruned) log.Printf("[GachaPool] pruned %d unpaired costumes", pruned)
} }
func (pool *GachaCatalog) BuildFeaturedMapping(entries []store.GachaCatalogEntry) { // BuildFeaturedFromTerms derives a featured set for each non-chapter banner by
// unioning items from catalog terms that started on the banner's StartDatetime
// (excluding term 1 — the standard pool). Falls back to medal-exchange shop
// contents for banners whose StartDatetime doesn't line up with a term.
func (pool *GachaCatalog) BuildFeaturedFromTerms(entries []store.GachaCatalogEntry) {
matched := 0 matched := 0
fromShop := 0
gachaEligible := 0
for _, entry := range entries { for _, entry := range entries {
if entry.MedalConsumableItemId == 0 { if entry.GachaLabelType == model.GachaLabelChapter {
continue continue
} }
shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId] gachaEligible++
if !ok || len(shopEntries) == 0 {
costumes, weapons := pool.unionTermFeatured(entry.StartDatetime)
if len(costumes) == 0 && len(weapons) == 0 && entry.MedalConsumableItemId != 0 {
if shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]; ok {
costumes, weapons = pool.featuredFromShop(shopEntries)
if len(costumes) > 0 || len(weapons) > 0 {
fromShop++
}
}
}
if len(costumes) == 0 && len(weapons) == 0 {
continue continue
} }
sort.Slice(costumes, func(i, j int) bool { return costumes[i].PossessionId < costumes[j].PossessionId })
seenCostume := make(map[int32]bool) sort.Slice(weapons, func(i, j int) bool { return weapons[i].PossessionId < weapons[j].PossessionId })
linkedWeapons := make(map[int32]bool)
var costumes []GachaPoolItem
for _, se := range shopEntries {
if se.CostumeId != 0 && !seenCostume[se.CostumeId] {
costumes = append(costumes, pool.CostumeById[se.CostumeId])
seenCostume[se.CostumeId] = true
linkedWeapons[se.WeaponId] = true
}
}
seenWeapon := make(map[int32]bool)
var weapons []GachaPoolItem
for _, se := range shopEntries {
if se.WeaponId != 0 && !linkedWeapons[se.WeaponId] && !seenWeapon[se.WeaponId] {
if item, ok := pool.WeaponById[se.WeaponId]; ok {
weapons = append(weapons, item)
seenWeapon[se.WeaponId] = true
}
}
}
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons} pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
matched++ matched++
} }
log.Printf("[GachaPool] featured mapping: %d/%d banners matched via shop", matched, len(entries)) log.Printf("[GachaPool] featured per banner: %d/%d (term-match + %d from shop-fallback)",
matched, gachaEligible, fromShop)
}
func (pool *GachaCatalog) unionTermFeatured(startDatetime int64) (costumes, weapons []GachaPoolItem) {
coTerms := pool.TermsByStartDatetime[startDatetime]
if len(coTerms) == 0 {
return nil, nil
}
seenCostume := make(map[int32]bool)
seenWeapon := make(map[int32]bool)
for _, t := range coTerms {
if t.TermId == StandardPoolTermId {
continue
}
for _, c := range t.Costumes {
if c.RarityType < model.RaritySRare || seenCostume[c.PossessionId] {
continue
}
costumes = append(costumes, c)
seenCostume[c.PossessionId] = true
}
for _, w := range t.Weapons {
if w.RarityType < model.RaritySRare || seenWeapon[w.PossessionId] {
continue
}
weapons = append(weapons, w)
seenWeapon[w.PossessionId] = true
}
}
return costumes, weapons
}
func (pool *GachaCatalog) featuredFromShop(shopEntries []ShopFeaturedEntry) (costumes, weapons []GachaPoolItem) {
seenCostume := make(map[int32]bool)
seenWeapon := make(map[int32]bool)
linkedWeapons := make(map[int32]bool)
for _, se := range shopEntries {
if se.CostumeId == 0 || seenCostume[se.CostumeId] {
continue
}
if item, ok := pool.CostumeById[se.CostumeId]; ok && item.RarityType >= model.RaritySRare {
costumes = append(costumes, item)
seenCostume[se.CostumeId] = true
linkedWeapons[se.WeaponId] = true
}
}
for _, se := range shopEntries {
if se.WeaponId == 0 || linkedWeapons[se.WeaponId] || seenWeapon[se.WeaponId] {
continue
}
if item, ok := pool.WeaponById[se.WeaponId]; ok && item.RarityType >= model.RaritySRare {
weapons = append(weapons, item)
seenWeapon[se.WeaponId] = true
}
}
return costumes, weapons
} }
func (pool *GachaCatalog) BuildBannerPools(entries []store.GachaCatalogEntry) { func (pool *GachaCatalog) BuildBannerPools(entries []store.GachaCatalogEntry) {
allFeaturedCostumes := make(map[int32]bool)
allFeaturedWeapons := make(map[int32]bool)
for _, fs := range pool.FeaturedByGacha {
for _, c := range fs.Costumes {
allFeaturedCostumes[c.PossessionId] = true
allFeaturedWeapons[pool.CostumeWeaponMap[c.PossessionId]] = true
}
for _, w := range fs.Weapons {
allFeaturedWeapons[w.PossessionId] = true
}
}
commonCostumes := make(map[int32][]GachaPoolItem)
for rarity, items := range pool.CostumesByRarity {
for _, item := range items {
if !allFeaturedCostumes[item.PossessionId] {
commonCostumes[rarity] = append(commonCostumes[rarity], item)
}
}
}
commonWeapons := make(map[int32][]GachaPoolItem)
for rarity, items := range pool.WeaponsByRarity {
for _, item := range items {
if !allFeaturedWeapons[item.PossessionId] {
commonWeapons[rarity] = append(commonWeapons[rarity], item)
}
}
}
commonPool := &BannerPool{
CostumesByRarity: commonCostumes,
WeaponsByRarity: commonWeapons,
}
pool.BannerPools = make(map[int32]*BannerPool) pool.BannerPools = make(map[int32]*BannerPool)
for _, entry := range entries { for _, entry := range entries {
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId] fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
if !hasFeatured {
pool.BannerPools[entry.GachaId] = commonPool bannerCostumes := cloneRarityMap(pool.StandardCostumesByRarity)
continue bannerWeapons := cloneRarityMap(pool.StandardWeaponsByRarity)
}
var allFeatured []GachaPoolItem var allFeatured []GachaPoolItem
bannerCostumes := make(map[int32][]GachaPoolItem) if hasFeatured {
for rarity, items := range commonCostumes {
bannerCostumes[rarity] = append(bannerCostumes[rarity], items...)
}
bannerWeapons := make(map[int32][]GachaPoolItem)
for rarity, items := range commonWeapons {
bannerWeapons[rarity] = append(bannerWeapons[rarity], items...)
}
for _, c := range fs.Costumes { for _, c := range fs.Costumes {
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c) bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
allFeatured = append(allFeatured, c) allFeatured = append(allFeatured, c)
wid := pool.CostumeWeaponMap[c.PossessionId] if wid, ok := pool.CostumeWeaponMap[c.PossessionId]; ok {
w := pool.WeaponById[wid] if w, ok := pool.WeaponById[wid]; ok {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w) allFeatured = append(allFeatured, w)
} }
}
}
for _, w := range fs.Weapons { for _, w := range fs.Weapons {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w) bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w) allFeatured = append(allFeatured, w)
} }
}
pool.BannerPools[entry.GachaId] = &BannerPool{ pool.BannerPools[entry.GachaId] = &BannerPool{
CostumesByRarity: bannerCostumes, CostumesByRarity: bannerCostumes,
WeaponsByRarity: bannerWeapons, WeaponsByRarity: bannerWeapons,
Featured: allFeatured, Featured: allFeatured,
} }
} }
log.Printf("[GachaPool] banner pools: %d banners built from standard pool + per-banner featured", len(pool.BannerPools))
}
log.Printf("[GachaPool] banner pools: %d banners, %d featured costumes stripped, %d featured weapons stripped", func cloneRarityMap(src map[int32][]GachaPoolItem) map[int32][]GachaPoolItem {
len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons)) dst := make(map[int32][]GachaPoolItem, len(src))
for k, v := range src {
dst[k] = append([]GachaPoolItem(nil), v...)
}
return dst
} }
func buildEvolvedWeaponSet(rows []EntityMWeaponEvolutionGroup) map[int32]bool { func buildEvolvedWeaponSet(rows []EntityMWeaponEvolutionGroup) map[int32]bool {
+538 -9
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
}
+46 -1
View File
@@ -2,6 +2,8 @@ package masterdata
import ( import (
"log" "log"
"sort"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
@@ -19,28 +21,71 @@ type LoginBonusReward struct {
type LoginBonusCatalog struct { type LoginBonusCatalog struct {
stamps map[loginBonusStampKey]LoginBonusReward stamps map[loginBonusStampKey]LoginBonusReward
bonusPages map[int32][]int32
totalPages map[int32]int32
} }
func (c *LoginBonusCatalog) LookupStampReward(loginBonusId, pageNumber, stampNumber int32) (LoginBonusReward, bool) { func (c *LoginBonusCatalog) LookupStampReward(loginBonusId, pageNumber, stampNumber int32) (LoginBonusReward, bool) {
entry, ok := c.stamps[loginBonusStampKey{loginBonusId, pageNumber, stampNumber}] pages := c.bonusPages[loginBonusId]
lower := int32(-1)
for _, p := range pages {
if p <= pageNumber {
lower = p
} else {
break
}
}
if lower < 0 {
return LoginBonusReward{}, false
}
entry, ok := c.stamps[loginBonusStampKey{loginBonusId, lower, stampNumber}]
return entry, ok return entry, ok
} }
func (c *LoginBonusCatalog) TotalPageCount(loginBonusId int32) int32 {
return c.totalPages[loginBonusId]
}
func LoadLoginBonusCatalog() *LoginBonusCatalog { func LoadLoginBonusCatalog() *LoginBonusCatalog {
stamps, err := utils.ReadTable[EntityMLoginBonusStamp]("m_login_bonus_stamp") stamps, err := utils.ReadTable[EntityMLoginBonusStamp]("m_login_bonus_stamp")
if err != nil { if err != nil {
log.Fatalf("load login bonus stamp table: %v", err) log.Fatalf("load login bonus stamp table: %v", err)
} }
bonuses, err := utils.ReadTable[EntityMLoginBonus]("m_login_bonus")
if err != nil {
log.Fatalf("load login bonus table: %v", err)
}
cat := &LoginBonusCatalog{ cat := &LoginBonusCatalog{
stamps: make(map[loginBonusStampKey]LoginBonusReward, len(stamps)), stamps: make(map[loginBonusStampKey]LoginBonusReward, len(stamps)),
bonusPages: make(map[int32][]int32),
totalPages: make(map[int32]int32, len(bonuses)),
} }
for _, b := range bonuses {
cat.totalPages[b.LoginBonusId] = b.TotalPageCount
}
seenPages := make(map[loginBonusStampKey]struct{})
for _, s := range stamps { for _, s := range stamps {
cat.stamps[loginBonusStampKey{s.LoginBonusId, s.LowerPageNumber, s.StampNumber}] = LoginBonusReward{ cat.stamps[loginBonusStampKey{s.LoginBonusId, s.LowerPageNumber, s.StampNumber}] = LoginBonusReward{
PossessionType: s.RewardPossessionType, PossessionType: s.RewardPossessionType,
PossessionId: s.RewardPossessionId, PossessionId: s.RewardPossessionId,
Count: s.RewardCount, Count: s.RewardCount,
} }
dedup := loginBonusStampKey{LoginBonusId: s.LoginBonusId, LowerPageNumber: s.LowerPageNumber}
if _, exists := seenPages[dedup]; !exists {
seenPages[dedup] = struct{}{}
cat.bonusPages[s.LoginBonusId] = append(cat.bonusPages[s.LoginBonusId], s.LowerPageNumber)
} }
}
for id := range cat.bonusPages {
sort.Slice(cat.bonusPages[id], func(i, j int) bool {
return cat.bonusPages[id][i] < cat.bonusPages[id][j]
})
}
return cat return cat
} }
+13
View File
@@ -2,6 +2,7 @@ package masterdata
import ( import (
"fmt" "fmt"
"log"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
@@ -34,6 +35,7 @@ func BuildExpThresholds(paramMapRows []EntityMNumericalParameterMap, mapId int32
type MaterialCatalog struct { type MaterialCatalog struct {
All map[int32]EntityMMaterial All map[int32]EntityMMaterial
ByType map[model.MaterialType]map[int32]EntityMMaterial ByType map[model.MaterialType]map[int32]EntityMMaterial
SaleObtain map[int32][]EntityMMaterialSaleObtainPossession
} }
func LoadMaterialCatalog() (*MaterialCatalog, error) { func LoadMaterialCatalog() (*MaterialCatalog, error) {
@@ -45,6 +47,7 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
catalog := &MaterialCatalog{ catalog := &MaterialCatalog{
All: make(map[int32]EntityMMaterial, len(rows)), All: make(map[int32]EntityMMaterial, len(rows)),
ByType: make(map[model.MaterialType]map[int32]EntityMMaterial), ByType: make(map[model.MaterialType]map[int32]EntityMMaterial),
SaleObtain: make(map[int32][]EntityMMaterialSaleObtainPossession),
} }
for _, row := range rows { for _, row := range rows {
catalog.All[row.MaterialId] = row catalog.All[row.MaterialId] = row
@@ -54,5 +57,15 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
} }
catalog.ByType[mt][row.MaterialId] = row catalog.ByType[mt][row.MaterialId] = row
} }
saleRows, err := utils.ReadTable[EntityMMaterialSaleObtainPossession]("m_material_sale_obtain_possession")
if err != nil {
log.Printf("material catalog: sale-obtain table unavailable, side rewards on sell will be skipped: %v", err)
} else {
for _, row := range saleRows {
catalog.SaleObtain[row.MaterialSaleObtainPossessionId] = append(catalog.SaleObtain[row.MaterialSaleObtainPossessionId], row)
}
}
return catalog, nil return catalog, nil
} }
+65
View File
@@ -34,6 +34,9 @@ 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
UserExpThresholds []int32 UserExpThresholds []int32
CharacterExpThresholds []int32 CharacterExpThresholds []int32
@@ -113,8 +116,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")
@@ -238,6 +286,15 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
return nil, fmt.Errorf("load tutorial unlock condition table: %w", err) return nil, fmt.Errorf("load tutorial unlock condition table: %w", err)
} }
battleOnlyTargetSceneByQuestId := make(map[int32]int32)
for _, scene := range scenes {
if scene.IsBattleOnlyTarget {
if _, exists := battleOnlyTargetSceneByQuestId[scene.QuestId]; !exists {
battleOnlyTargetSceneByQuestId[scene.QuestId] = scene.QuestSceneId
}
}
}
paramMapRows, err := LoadParameterMap() paramMapRows, err := LoadParameterMap()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -529,6 +586,9 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds, TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId, ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId, SeasonIdByRouteId: seasonIdByRouteId,
RoutesBySeason: routesBySeason,
RouteCompletionQuestId: routeCompletionQuestId,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1), UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
@@ -545,3 +605,8 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
PartsCatalog: partsCatalog, PartsCatalog: partsCatalog,
}, nil }, nil
} }
func (q *QuestCatalog) BattleOnlyTargetSceneIdFor(questId int32) (int32, bool) {
v, ok := q.BattleOnlyTargetSceneByQuestId[questId]
return v, ok
}
+107 -7
View File
@@ -2,11 +2,35 @@ package masterdata
import ( import (
"log" "log"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
type SideStorySceneInfo struct {
SceneId int32
Type model.SideStorySceneIdType
}
type SideStoryQuestInfo struct {
SideStoryQuestId int32
Scenes []SideStorySceneInfo // the 7 scenes, one per type
Quests []int32 // ordered event quests (the chapter+difficulty sequence)
}
type SideStoryCatalog struct { type SideStoryCatalog struct {
FirstSceneByQuestId map[int32]int32 QuestById map[int32]*SideStoryQuestInfo
ChapterByEventQuestId map[int32]int32 // event quest id -> side story chapter id
}
func (q *SideStoryQuestInfo) SceneIdByType(t model.SideStorySceneIdType) (int32, bool) {
for _, s := range q.Scenes {
if s.Type == t {
return s.SceneId, true
}
}
return 0, false
} }
func LoadSideStoryCatalog() *SideStoryCatalog { func LoadSideStoryCatalog() *SideStoryCatalog {
@@ -14,14 +38,90 @@ func LoadSideStoryCatalog() *SideStoryCatalog {
if err != nil { if err != nil {
log.Fatalf("load side story quest scene table: %v", err) log.Fatalf("load side story quest scene table: %v", err)
} }
limitContents, err := utils.ReadTable[EntityMSideStoryQuestLimitContent]("m_side_story_quest_limit_content")
if err != nil {
log.Fatalf("load side story quest limit content table: %v", err)
}
seqGroups, err := utils.ReadTable[EntityMEventQuestSequenceGroup]("m_event_quest_sequence_group")
if err != nil {
log.Fatalf("load event quest sequence group table: %v", err)
}
sequences, err := utils.ReadTable[EntityMEventQuestSequence]("m_event_quest_sequence")
if err != nil {
log.Fatalf("load event quest sequence table: %v", err)
}
firstScene := make(map[int32]int32, len(scenes)/7) seqRows := make(map[int32][]EntityMEventQuestSequence)
for _, s := range scenes { for _, s := range sequences {
if s.SortOrder == 1 { seqRows[s.EventQuestSequenceId] = append(seqRows[s.EventQuestSequenceId], s)
firstScene[s.SideStoryQuestId] = s.SideStoryQuestSceneId }
orderedQuestIds := make(map[int32][]int32, len(seqRows))
for seqId, rows := range seqRows {
sort.Slice(rows, func(i, j int) bool { return rows[i].SortOrder < rows[j].SortOrder })
ids := make([]int32, len(rows))
for i, r := range rows {
ids[i] = r.QuestId
}
orderedQuestIds[seqId] = ids
}
// (chapterId, difficulty) -> sequenceId. Sequence group id == chapter id.
type chapDiff struct{ chapter, difficulty int32 }
sequenceByChapterDiff := make(map[chapDiff]int32, len(seqGroups))
for _, g := range seqGroups {
sequenceByChapterDiff[chapDiff{g.EventQuestSequenceGroupId, g.DifficultyType}] = g.EventQuestSequenceId
}
// sideStoryQuestId -> limit content row. Limit content id == side story quest id.
limitByQuest := make(map[int32]EntityMSideStoryQuestLimitContent, len(limitContents))
for _, lc := range limitContents {
limitByQuest[lc.SideStoryQuestLimitContentId] = lc
}
// sideStoryQuestId -> scene rows
scenesByQuest := make(map[int32][]EntityMSideStoryQuestScene)
for _, sc := range scenes {
scenesByQuest[sc.SideStoryQuestId] = append(scenesByQuest[sc.SideStoryQuestId], sc)
}
questById := make(map[int32]*SideStoryQuestInfo, len(scenesByQuest))
chapterByEventQuest := make(map[int32]int32)
for ssqId, rows := range scenesByQuest {
sort.Slice(rows, func(i, j int) bool { return rows[i].SortOrder < rows[j].SortOrder })
var orderedQuests []int32
var chapterId, difficulty int32
if lc, ok := limitByQuest[ssqId]; ok {
chapterId = lc.EventQuestChapterId
difficulty = lc.DifficultyType
if seqId, ok := sequenceByChapterDiff[chapDiff{chapterId, difficulty}]; ok {
orderedQuests = orderedQuestIds[seqId]
}
}
if chapterId != 0 {
for _, questId := range orderedQuests {
chapterByEventQuest[questId] = chapterId
} }
} }
log.Printf("side story catalog loaded: %d quests", len(firstScene)) info := &SideStoryQuestInfo{
return &SideStoryCatalog{FirstSceneByQuestId: firstScene} SideStoryQuestId: ssqId,
Scenes: make([]SideStorySceneInfo, 0, len(rows)),
Quests: orderedQuests,
}
for _, sc := range rows {
info.Scenes = append(info.Scenes, SideStorySceneInfo{
SceneId: sc.SideStoryQuestSceneId,
Type: model.SideStorySceneIdType(sc.SortOrder),
})
}
questById[ssqId] = info
}
log.Printf("side story catalog loaded: %d quests, %d scenes", len(questById), len(scenes))
return &SideStoryCatalog{
QuestById: questById,
ChapterByEventQuestId: chapterByEventQuest,
}
} }
+84
View File
@@ -0,0 +1,84 @@
package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/utils"
)
type TowerTier struct {
QuestMissionClearCount int32
Rewards []RewardItem
}
type TowerCatalog struct {
TiersByChapter map[int32][]TowerTier
}
func (c *TowerCatalog) CollectRewards(chapterId, oldCount, targetCount int32) ([]RewardItem, int32) {
var items []RewardItem
highest := int32(0)
for _, t := range c.TiersByChapter[chapterId] {
if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount {
items = append(items, t.Rewards...)
if t.QuestMissionClearCount > highest {
highest = t.QuestMissionClearCount
}
}
}
return items, highest
}
func LoadTowerCatalog() *TowerCatalog {
// chapterId -> accumulation reward group id
accumRewardRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationReward]("m_event_quest_tower_accumulation_reward")
if err != nil {
log.Fatalf("load event quest tower accumulation reward table: %v", err)
}
groupByChapter := make(map[int32]int32, len(accumRewardRows))
for _, r := range accumRewardRows {
groupByChapter[r.EventQuestChapterId] = r.EventQuestTowerAccumulationRewardGroupId
}
// reward group id -> reward items
rewardGroupRows, err := utils.ReadTable[EntityMEventQuestTowerRewardGroup]("m_event_quest_tower_reward_group")
if err != nil {
log.Fatalf("load event quest tower reward group table: %v", err)
}
itemsByRewardGroup := make(map[int32][]RewardItem)
for _, r := range rewardGroupRows {
itemsByRewardGroup[r.EventQuestTowerRewardGroupId] = append(itemsByRewardGroup[r.EventQuestTowerRewardGroupId], RewardItem{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
// accumulation group id -> tiers (threshold + resolved reward items)
accumGroupRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationRewardGroup]("m_event_quest_tower_accumulation_reward_group")
if err != nil {
log.Fatalf("load event quest tower accumulation reward group table: %v", err)
}
tiersByGroup := make(map[int32][]TowerTier)
for _, r := range accumGroupRows {
tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId], TowerTier{
QuestMissionClearCount: r.QuestMissionClearCount,
Rewards: itemsByRewardGroup[r.EventQuestTowerRewardGroupId],
})
}
// resolve per-chapter, sorted ascending by threshold
tiersByChapter := make(map[int32][]TowerTier, len(groupByChapter))
for chapterId, groupId := range groupByChapter {
tiers := tiersByGroup[groupId]
sort.Slice(tiers, func(i, j int) bool {
return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount
})
tiersByChapter[chapterId] = tiers
}
log.Printf("tower catalog loaded: %d chapters", len(tiersByChapter))
return &TowerCatalog{TiersByChapter: tiersByChapter}
}
+27
View File
@@ -42,6 +42,8 @@ type WeaponCatalog struct {
BaseExpByEnhanceId map[int32]int32 BaseExpByEnhanceId map[int32]int32
ReleaseConditionsByGroupId map[int32][]EntityMWeaponStoryReleaseConditionGroup ReleaseConditionsByGroupId map[int32][]EntityMWeaponStoryReleaseConditionGroup
LevelingEnhanceIdByWeaponId map[int32]int32
AwakenByWeaponId map[int32]EntityMWeaponAwaken AwakenByWeaponId map[int32]EntityMWeaponAwaken
AwakenMaterialsByGroupId map[int32][]EntityMWeaponAwakenMaterialGroup AwakenMaterialsByGroupId map[int32][]EntityMWeaponAwakenMaterialGroup
} }
@@ -142,6 +144,8 @@ func LoadWeaponCatalog(matCatalog *MaterialCatalog) (*WeaponCatalog, error) {
BaseExpByEnhanceId: make(map[int32]int32, len(enhanceRows)), BaseExpByEnhanceId: make(map[int32]int32, len(enhanceRows)),
ReleaseConditionsByGroupId: make(map[int32][]EntityMWeaponStoryReleaseConditionGroup), ReleaseConditionsByGroupId: make(map[int32][]EntityMWeaponStoryReleaseConditionGroup),
LevelingEnhanceIdByWeaponId: make(map[int32]int32, len(weapons)),
AwakenByWeaponId: make(map[int32]EntityMWeaponAwaken, len(awakenRows)), AwakenByWeaponId: make(map[int32]EntityMWeaponAwaken, len(awakenRows)),
AwakenMaterialsByGroupId: make(map[int32][]EntityMWeaponAwakenMaterialGroup), AwakenMaterialsByGroupId: make(map[int32][]EntityMWeaponAwakenMaterialGroup),
} }
@@ -342,5 +346,28 @@ func LoadWeaponCatalog(matCatalog *MaterialCatalog) (*WeaponCatalog, error) {
} }
log.Printf("[WeaponCatalog] rarity fallback: assigned synthetic enhance IDs to %d weapons", fallbackCount) log.Printf("[WeaponCatalog] rarity fallback: assigned synthetic enhance IDs to %d weapons", fallbackCount)
// Build LevelingEnhanceIdByWeaponId: every member of an evolution chain
// inherits the chain root's (post-rarity-fallback) WeaponSpecificEnhanceId
// so cumulative exp stays consistent across Evolve. Weapons not in any
// chain map to their own enhance id.
for _, rows := range grouped {
if len(rows) == 0 {
continue
}
rootMaster, ok := catalog.Weapons[rows[0].WeaponId]
if !ok {
continue
}
rootEnhanceId := rootMaster.WeaponSpecificEnhanceId
for _, row := range rows {
catalog.LevelingEnhanceIdByWeaponId[row.WeaponId] = rootEnhanceId
}
}
for wid, w := range catalog.Weapons {
if _, exists := catalog.LevelingEnhanceIdByWeaponId[wid]; !exists {
catalog.LevelingEnhanceIdByWeaponId[wid] = w.WeaponSpecificEnhanceId
}
}
return catalog, nil return catalog, nil
} }
@@ -0,0 +1,26 @@
package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/utils"
)
type WebviewPanelMissionCatalog struct {
PageIds []int32 // every WebviewPanelMissionPageId, sorted ascending
}
func LoadWebviewPanelMissionCatalog() *WebviewPanelMissionCatalog {
rows, err := utils.ReadTable[EntityMWebviewPanelMissionPage]("m_webview_panel_mission_page")
if err != nil {
log.Printf("load webview panel mission page table: %v", err)
return &WebviewPanelMissionCatalog{}
}
ids := make([]int32, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.WebviewPanelMissionPageId)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return &WebviewPanelMissionCatalog{PageIds: ids}
}
+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)
)
+29 -2
View File
@@ -12,6 +12,11 @@ const (
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4
) )
func IsReplayQuestFlowType(t int32) bool {
return t == int32(QuestFlowTypeReplayFlow) ||
t == int32(QuestFlowTypeAnotherRouteReplayFlow)
}
func (t QuestFlowType) String() string { func (t QuestFlowType) String() string {
switch t { switch t {
case QuestFlowTypeUnknown: case QuestFlowTypeUnknown:
@@ -38,6 +43,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 (
@@ -155,6 +169,19 @@ type SideStoryQuestStateType int32
const ( const (
SideStoryQuestStateUnknown SideStoryQuestStateType = 0 SideStoryQuestStateUnknown SideStoryQuestStateType = 0
SideStoryQuestStateActive SideStoryQuestStateType = 1 SideStoryQuestStateActive SideStoryQuestStateType = 2
SideStoryQuestStateCleared SideStoryQuestStateType = 2 SideStoryQuestStateCleared SideStoryQuestStateType = 3
)
type SideStorySceneIdType int32
const (
SideStorySceneInvalid SideStorySceneIdType = 0
SideStorySceneIntroduction SideStorySceneIdType = 1
SideStoryScenePlayGeneralQuest SideStorySceneIdType = 2
SideStorySceneUnlockLastQuest SideStorySceneIdType = 3
SideStoryScenePlayLastQuest SideStorySceneIdType = 4
SideStorySceneOutroduction SideStorySceneIdType = 5
SideStorySceneShowCostumeAcquisition SideStorySceneIdType = 6
SideStoryScenePlayFreeQuest SideStorySceneIdType = 7
) )
+1 -1
View File
@@ -35,7 +35,7 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { if isRetired && !isAnnihilated && quest.Stamina > 1 {
+19 -7
View File
@@ -44,7 +44,8 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
h.recordSideStoryLimitContentStatus(user, questId, nowMillis)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { if isRetired && !isAnnihilated && quest.Stamina > 1 {
@@ -53,15 +54,31 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.EventQuest.CurrentEventQuestChapterId = 0
user.EventQuest.CurrentQuestId = 0 user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0 user.EventQuest.CurrentQuestSceneId = 0
user.EventQuest.HeadQuestSceneId = 0 user.EventQuest.HeadQuestSceneId = 0
user.EventQuest.LatestVersion = nowMillis
h.clearQuestMissions(user, questId, nowMillis) h.clearQuestMissions(user, questId, nowMillis)
return outcome return outcome
} }
func (h *QuestHandler) recordSideStoryLimitContentStatus(user *store.UserState, questId int32, nowMillis int64) {
chapterId, ok := h.SideStoryChapterByEventQuestId[questId]
if !ok {
return
}
st := user.QuestLimitContentStatus[questId]
st.LimitContentQuestStatusType = 1
st.EventQuestChapterId = chapterId
st.LatestVersion = nowMillis
user.QuestLimitContentStatus[questId] = st
}
func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) { func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) {
h.HandleQuestRestart(user, questId, nowMillis) h.HandleQuestRestart(user, questId, nowMillis)
@@ -70,8 +87,7 @@ func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuest
} }
func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] if _, ok := h.SceneById[questSceneId]; !ok {
if !ok {
log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return return
} }
@@ -82,8 +98,4 @@ func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, ques
} }
h.applySceneGrants(user, questSceneId, nowMillis) h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
} }
+4 -7
View File
@@ -42,7 +42,7 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { if isRetired && !isAnnihilated && quest.Stamina > 1 {
@@ -51,6 +51,8 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
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
@@ -67,8 +69,7 @@ func (h *QuestHandler) HandleExtraQuestRestart(user *store.UserState, questId in
} }
func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] if _, ok := h.SceneById[questSceneId]; !ok {
if !ok {
log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId) log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return return
} }
@@ -79,8 +80,4 @@ func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, ques
} }
h.applySceneGrants(user, questSceneId, nowMillis) h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
} }
+12 -2
View File
@@ -27,11 +27,21 @@ type QuestHandler struct {
*masterdata.QuestCatalog *masterdata.QuestCatalog
Config *masterdata.GameConfig Config *masterdata.GameConfig
Granter *store.PossessionGranter Granter *store.PossessionGranter
SideStoryChapterByEventQuestId map[int32]int32
} }
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *QuestHandler { func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog) *QuestHandler {
granter := BuildGranter(catalog) granter := BuildGranter(catalog)
return &QuestHandler{QuestCatalog: catalog, Config: config, Granter: granter} var sideStoryChapters map[int32]int32
if sideStory != nil {
sideStoryChapters = sideStory.ChapterByEventQuestId
}
return &QuestHandler{
QuestCatalog: catalog,
Config: config,
Granter: granter,
SideStoryChapterByEventQuestId: sideStoryChapters,
}
} }
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
+188 -44
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) {
@@ -38,49 +45,63 @@ func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32,
} }
} }
func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) { func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, false, nowMillis) h.handleQuestStartInternal(user, questId, isBattleOnly, isMainFlow, userDeckNumber, false, nowMillis)
} }
func (h *QuestHandler) HandleQuestStartReplay(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) { func (h *QuestHandler) HandleQuestStartReplay(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, true, nowMillis) h.handleQuestStartInternal(user, questId, isBattleOnly, false, userDeckNumber, true, nowMillis)
} }
func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, isReplayFlow bool, nowMillis int64) { func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, isReplayFlow bool, nowMillis int64) {
quest, ok := h.QuestById[questId] quest, ok := h.QuestById[questId]
if !ok { if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId))
} }
h.initQuestState(user, questId) h.initQuestState(user, questId)
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis)
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
if questState.QuestStateType == model.UserQuestStateTypeCleared {
if isReplayFlow {
user.MainQuest.SavedCurrentQuestSceneId = user.MainQuest.CurrentQuestSceneId
user.MainQuest.SavedHeadQuestSceneId = user.MainQuest.HeadQuestSceneId
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeReplayFlow)
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0
user.MainQuest.LatestVersion = nowMillis
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
questState.IsBattleOnly = isBattleOnly questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber questState.UserDeckNumber = userDeckNumber
isCleared := questState.QuestStateType == model.UserQuestStateTypeCleared
isMenuPick := !isReplayFlow && !isMainFlow
switch {
case isMenuPick:
snapshotMainQuestIfNeeded(user)
sceneId := h.menuPickSceneId(questId, isBattleOnly)
user.MainQuest.ProgressQuestSceneId = sceneId
user.MainQuest.ProgressHeadQuestSceneId = sceneId
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeMainFlow)
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = nowMillis
user.SideStoryActiveProgress = store.SideStoryActiveProgress{LatestVersion: nowMillis}
user.MainQuest.LatestVersion = nowMillis
log.Printf("[HandleQuestStart] QuestMenuPick quest=%d isBattleOnly=%v scene=%d cleared=%v",
questId, isBattleOnly, sceneId, isCleared)
if isCleared {
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
return
}
case isReplayFlow:
h.applyReplayStart(user, quest, questId, isBattleOnly, nowMillis)
return
}
if isCleared {
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState user.Quests[questId] = questState
log.Printf("[HandleQuestStart] replay flow started for quest %d, saved scene=%d head=%d",
questId, user.MainQuest.SavedCurrentQuestSceneId, user.MainQuest.SavedHeadQuestSceneId)
}
return return
} }
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
if isMainQuestPlayable(quest) { if isMainQuestPlayable(quest) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
questState.QuestStateType = model.UserQuestStateTypeActive questState.QuestStateType = model.UserQuestStateTypeActive
@@ -90,7 +111,6 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
questState.ClearCount = 1 questState.ClearCount = 1
questState.DailyClearCount = 1 questState.DailyClearCount = 1
questState.LastClearDatetime = nowMillis questState.LastClearDatetime = nowMillis
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 { if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
firstSceneId := sceneIds[0] firstSceneId := sceneIds[0]
prevSceneId := user.MainQuest.CurrentQuestSceneId prevSceneId := user.MainQuest.CurrentQuestSceneId
@@ -102,14 +122,85 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
user.Quests[questId] = questState user.Quests[questId] = questState
} }
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64) { func snapshotMainQuestIfNeeded(user *store.UserState) {
if user.MainQuest.SavedContext.Active {
return
}
user.MainQuest.SavedContext = store.SavedQuestContext{
Active: true,
CurrentQuestSceneId: user.MainQuest.CurrentQuestSceneId,
HeadQuestSceneId: user.MainQuest.HeadQuestSceneId,
CurrentMainQuestRouteId: user.MainQuest.CurrentMainQuestRouteId,
MainQuestSeasonId: user.MainQuest.MainQuestSeasonId,
IsReachedLastQuestScene: user.MainQuest.IsReachedLastQuestScene,
PortalCageInProgress: user.PortalCageStatus.IsCurrentProgress,
CurrentQuestFlowType: user.MainQuest.CurrentQuestFlowType,
}
}
func (h *QuestHandler) applyReplayStart(user *store.UserState, quest masterdata.EntityMQuest, questId int32, isBattleOnly bool, nowMillis int64) {
flowType := h.replayFlowTypeFromQuestId(user, questId)
if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) {
flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType)
}
user.MainQuest.CurrentQuestFlowType = int32(flowType)
user.MainQuest.LatestVersion = nowMillis
questState := user.Quests[questId] questState := user.Quests[questId]
questState.LatestStartDatetime = nowMillis
if isMainQuestPlayable(quest) {
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.ReplayFlowHeadQuestSceneId)
}
func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
if isBattleOnly {
if v, ok := h.BattleOnlyTargetSceneIdFor(questId); ok {
return v
}
}
if scenes := h.SceneIdsByQuestId[questId]; len(scenes) > 0 {
return scenes[0]
}
return 0
}
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) {
questState := user.Quests[questId]
h.applyExpAndGoldRewards(user, questId, nowMillis)
if !questState.IsRewardGranted { if !questState.IsRewardGranted {
h.applyQuestRewards(user, questId, nowMillis) if !wasReplay {
h.applyFirstClearItemRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...) h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...) h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...)
}
for _, r := range outcome.MissionClearRewards {
h.applyRewardPossession(user, r.PossessionType, r.PossessionId, r.Count, nowMillis)
}
for _, r := range outcome.MissionClearCompleteRewards {
h.applyRewardPossession(user, r.PossessionType, r.PossessionId, r.Count, nowMillis)
}
questState.IsRewardGranted = true questState.IsRewardGranted = true
} }
for _, drop := range outcome.DropRewards { for _, drop := range outcome.DropRewards {
@@ -122,22 +213,65 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o
questState.ClearCount++ questState.ClearCount++
questState.DailyClearCount++ questState.DailyClearCount++
questState.LastClearDatetime = nowMillis questState.LastClearDatetime = nowMillis
questState.IsBattleOnly = false
user.Quests[questId] = questState user.Quests[questId] = questState
} }
func (h *QuestHandler) finalizeChainPreviousQuest(user *store.UserState, questId int32, nowMillis int64) {
if _, ok := h.QuestById[questId]; !ok {
return
}
h.initQuestState(user, questId)
questState := user.Quests[questId]
if questState.QuestStateType == model.UserQuestStateTypeCleared {
return
}
if !questState.IsRewardGranted {
h.applyQuestRewards(user, questId, nowMillis)
questState.IsRewardGranted = true
}
questState.QuestStateType = model.UserQuestStateTypeCleared
questState.ClearCount++
questState.DailyClearCount++
questState.LastClearDatetime = nowMillis
questState.IsBattleOnly = false
user.Quests[questId] = questState
h.clearQuestMissions(user, questId, nowMillis)
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 {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId))
} }
h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId)
wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay)
if isMainQuestPlayable(quest) && !wasReplay { // 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)
} }
@@ -149,24 +283,32 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.MainQuest.ProgressQuestSceneId = 0 user.MainQuest.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0
if !wasReplay {
// Keep replay flow types on replay finish so the client's
// Story.ApplyNewestPlayingScene keeps _isReplayed=true (popup result UI).
user.MainQuest.ProgressQuestFlowType = 0 user.MainQuest.ProgressQuestFlowType = 0
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown)
}
if wasReplay { if wasMenuReplay {
if user.MainQuest.SavedCurrentQuestSceneId > 0 { ctx := user.MainQuest.SavedContext
user.MainQuest.CurrentQuestSceneId = user.MainQuest.SavedCurrentQuestSceneId user.MainQuest.CurrentQuestSceneId = ctx.CurrentQuestSceneId
} user.MainQuest.HeadQuestSceneId = ctx.HeadQuestSceneId
if user.MainQuest.SavedHeadQuestSceneId > 0 { user.MainQuest.CurrentMainQuestRouteId = ctx.CurrentMainQuestRouteId
user.MainQuest.HeadQuestSceneId = user.MainQuest.SavedHeadQuestSceneId user.MainQuest.MainQuestSeasonId = ctx.MainQuestSeasonId
} user.MainQuest.IsReachedLastQuestScene = ctx.IsReachedLastQuestScene
user.MainQuest.SavedCurrentQuestSceneId = 0 user.MainQuest.CurrentQuestFlowType = ctx.CurrentQuestFlowType
user.MainQuest.SavedHeadQuestSceneId = 0 user.PortalCageStatus.IsCurrentProgress = ctx.PortalCageInProgress
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0 user.PortalCageStatus.LatestVersion = nowMillis
user.MainQuest.ReplayFlowHeadQuestSceneId = 0 user.MainQuest.SavedContext = store.SavedQuestContext{}
log.Printf("[HandleQuestFinish] replay flow ended for quest %d, restored scene=%d head=%d", user.MainQuest.LatestVersion = nowMillis
questId, user.MainQuest.CurrentQuestSceneId, user.MainQuest.HeadQuestSceneId) log.Printf("[HandleQuestFinish] restored snapshot for quest %d (route=%d season=%d scene=%d head=%d cage=%v flow=%d)",
questId, ctx.CurrentMainQuestRouteId, ctx.MainQuestSeasonId,
ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress, ctx.CurrentQuestFlowType)
} }
h.clearQuestMissions(user, questId, nowMillis) h.clearQuestMissions(user, questId, nowMillis)
@@ -215,14 +357,16 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) { func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId] questDef, ok := h.QuestById[questId]
if ok && isMainQuestPlayable(questDef) { // Only seed CurrentQuestFlowType when it's not already set (initial
// natural progression). Don't clobber an in-flight ReplayFlow (Map Play
// resume).
if ok && isMainQuestPlayable(questDef) && user.MainQuest.CurrentQuestFlowType == 0 {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
} }
quest := user.Quests[questId] quest := user.Quests[questId]
quest.QuestId = questId quest.QuestId = questId
quest.QuestStateType = model.UserQuestStateTypeActive quest.QuestStateType = model.UserQuestStateTypeActive
quest.IsBattleOnly = false
quest.LatestStartDatetime = nowMillis quest.LatestStartDatetime = nowMillis
user.Quests[questId] = quest user.Quests[questId] = quest
+26 -4
View File
@@ -13,7 +13,7 @@ import (
func (h *QuestHandler) isQuestCleared(user *store.UserState, questId int32) bool { func (h *QuestHandler) isQuestCleared(user *store.UserState, questId int32) bool {
quest, ok := user.Quests[questId] quest, ok := user.Quests[questId]
if !ok { if !ok {
panic(fmt.Sprintf("unknown questId=%d for isQuestCleared", questId)) return false
} }
return quest.QuestStateType == model.UserQuestStateTypeCleared return quest.QuestStateType == model.UserQuestStateTypeCleared
} }
@@ -51,7 +51,9 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId)) panic(fmt.Sprintf("unknown questId=%d for evaluateFinishOutcome", questId))
} }
if !questState.IsRewardGranted { isReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
if !questState.IsRewardGranted && !isReplay {
rewardGroupId := h.firstClearRewardGroupId(user, questDef) rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{ outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{
@@ -62,7 +64,7 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
} }
if user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow) && questDef.QuestReplayFlowRewardGroupId > 0 { if isReplay && questDef.QuestReplayFlowRewardGroupId > 0 {
for _, reward := range h.ReplayFlowRewardsByGroupId[questDef.QuestReplayFlowRewardGroupId] { for _, reward := range h.ReplayFlowRewardsByGroupId[questDef.QuestReplayFlowRewardGroupId] {
outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{ outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{
PossessionType: model.PossessionType(reward.PossessionType), PossessionType: model.PossessionType(reward.PossessionType),
@@ -72,6 +74,10 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
} }
// Mission rewards / BigWin are first-clear concepts. Reference
// IUserQuestMissionTable has no rows for replay-variant ids (30000+):
// the popup is empty on replay in the original game.
if !isReplay {
pendingClearCount := 0 pendingClearCount := 0
regularMissionCount := 0 regularMissionCount := 0
for _, questMissionId := range h.MissionIdsByQuestId[questId] { for _, questMissionId := range h.MissionIdsByQuestId[questId] {
@@ -115,6 +121,7 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0 outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0
} }
}
outcome.DropRewards = h.computeDropRewards(questDef) outcome.DropRewards = h.computeDropRewards(questDef)
return outcome return outcome
@@ -159,6 +166,10 @@ func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, now
return return
} }
if questDef.CharacterExp == 0 && questDef.CostumeExp == 0 {
return
}
deckCostumeUuids, deckCharacterIds := h.resolveDeckUnits(user, questId) deckCostumeUuids, deckCharacterIds := h.resolveDeckUnits(user, questId)
if deckCostumeUuids == nil { if deckCostumeUuids == nil {
log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (deck not resolved)", questId) log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (deck not resolved)", questId)
@@ -240,7 +251,7 @@ func (h *QuestHandler) resolveDeckUnits(user *store.UserState, questId int32) (c
return costumeUuids, characterIds return costumeUuids, characterIds
} }
func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, nowMillis int64) { func (h *QuestHandler) applyExpAndGoldRewards(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId] questDef, ok := h.QuestById[questId]
if !ok { if !ok {
return return
@@ -252,13 +263,24 @@ func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, n
user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold
log.Printf("[applyQuestRewards] questId=%d gold: +%d -> total=%d", questId, questDef.Gold, user.ConsumableItems[h.Config.ConsumableItemIdForGold]) log.Printf("[applyQuestRewards] questId=%d gold: +%d -> total=%d", questId, questDef.Gold, user.ConsumableItems[h.Config.ConsumableItemIdForGold])
} }
}
func (h *QuestHandler) applyFirstClearItemRewards(user *store.UserState, questId int32, nowMillis int64) {
questDef, ok := h.QuestById[questId]
if !ok {
return
}
rewardGroupId := h.firstClearRewardGroupId(user, questDef) rewardGroupId := h.firstClearRewardGroupId(user, questDef)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] { for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
h.applyRewardPossession(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) h.applyRewardPossession(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
} }
} }
func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, nowMillis int64) {
h.applyExpAndGoldRewards(user, questId, nowMillis)
h.applyFirstClearItemRewards(user, questId, nowMillis)
}
func (h *QuestHandler) applyRewardPossession(user *store.UserState, possType model.PossessionType, possId, count int32, nowMillis int64) { func (h *QuestHandler) applyRewardPossession(user *store.UserState, possType model.PossessionType, possId, count int32, nowMillis int64) {
h.Granter.GrantFull(user, possType, possId, count, nowMillis) h.Granter.GrantFull(user, possType, possId, count, nowMillis)
} }
+112 -10
View File
@@ -1,5 +1,11 @@
package questflow package questflow
// MainQuest scene-field families mirror three client entity tables:
//
// MainFlow* — EntityIUserMainQuestMainFlowStatus (#11443)
// Progress* — EntityIUserMainQuestProgressStatus (#11444)
// ReplayFlow* — EntityIUserMainQuestReplayFlowStatus (#11445)
import ( import (
"fmt" "fmt"
"log" "log"
@@ -27,10 +33,11 @@ func (h *QuestHandler) isSceneAhead(newSceneId, currentHeadId int32) bool {
} }
func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) { func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) {
user.MainQuest.CurrentQuestSceneId = sceneId if !h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) {
if h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) { return
user.MainQuest.HeadQuestSceneId = sceneId
} }
user.MainQuest.CurrentQuestSceneId = sceneId
user.MainQuest.HeadQuestSceneId = sceneId
lastSceneId := h.getChapterLastSceneId(questId) lastSceneId := h.getChapterLastSceneId(questId)
user.MainQuest.IsReachedLastQuestScene = sceneId == lastSceneId user.MainQuest.IsReachedLastQuestScene = sceneId == lastSceneId
@@ -43,6 +50,37 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen
} }
} }
func (h *QuestHandler) advanceReplayFlowScene(user *store.UserState, sceneId int32) {
if !h.isSceneAhead(sceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
return
}
user.MainQuest.ReplayFlowCurrentQuestSceneId = sceneId
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
}
}
}
if cur := user.MainQuest.MainQuestSeasonId; cur >= 2 && user.MainQuest.CurrentMainQuestRouteId > 0 {
out[cur] = user.MainQuest.CurrentMainQuestRouteId
}
return out
}
func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId] scene, ok := h.SceneById[questSceneId]
if !ok { if !ok {
@@ -109,11 +147,53 @@ func (h *QuestHandler) getChapterLastSceneId(questId int32) int32 {
func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId
if user.MainQuest.ReplayFlowHeadQuestSceneId == 0 || h.isSceneAhead(questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = nowMillis
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: nowMillis,
} }
}
flowType := h.replayFlowType(user, questSceneId)
user.MainQuest.CurrentQuestFlowType = int32(flowType)
user.MainQuest.LatestVersion = nowMillis user.MainQuest.LatestVersion = nowMillis
log.Printf("[HandleReplayFlowSceneProgress] sceneId=%d replayHead=%d", questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) log.Printf("[HandleReplayFlowSceneProgress] sceneId=%d flowType=%s", questSceneId, flowType)
}
func (h *QuestHandler) replayFlowType(user *store.UserState, questSceneId int32) model.QuestFlowType {
scene, ok := h.SceneById[questSceneId]
if !ok {
return model.QuestFlowTypeReplayFlow
}
routeId, ok := h.RouteIdByQuestId[scene.QuestId]
if !ok {
return model.QuestFlowTypeReplayFlow
}
return h.replayFlowTypeForRoute(user, routeId)
}
func (h *QuestHandler) replayFlowTypeForRoute(user *store.UserState, routeId int32) model.QuestFlowType {
seasonId, ok := h.SeasonIdByRouteId[routeId]
if !ok {
return model.QuestFlowTypeReplayFlow
}
pairs := h.SeasonRoutesFor(user)
if recorded, ok := pairs[seasonId]; ok && recorded != routeId {
return model.QuestFlowTypeAnotherRouteReplayFlow
}
return model.QuestFlowTypeReplayFlow
}
func (h *QuestHandler) replayFlowTypeFromQuestId(user *store.UserState, questId int32) model.QuestFlowType {
routeId, ok := h.RouteIdByQuestId[questId]
if !ok {
return model.QuestFlowTypeReplayFlow
}
return h.replayFlowTypeForRoute(user, routeId)
} }
func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, questSceneId int32) { func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, questSceneId int32) {
@@ -127,19 +207,33 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId)) panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId))
} }
if isMainQuestPlayable(quest) { if prevSceneId := user.MainQuest.ProgressQuestSceneId; prevSceneId != 0 {
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult { if prevScene, ok := h.SceneById[prevSceneId]; ok && prevScene.QuestId != quest.QuestId {
nowMillis := gametime.NowMillis() // Skip if the previous quest is playable — it has its own FinishMainQuest;
h.clearQuestMissions(user, quest.QuestId, nowMillis) // chain-finalizing here would double-increment ClearCount.
if prevQuest, ok := h.QuestById[prevScene.QuestId]; ok && !isMainQuestPlayable(prevQuest) {
h.finalizeChainPreviousQuest(user, prevScene.QuestId, gametime.NowMillis())
}
}
} }
isReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
if isMainQuestPlayable(quest) {
user.MainQuest.ProgressQuestSceneId = questSceneId user.MainQuest.ProgressQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) { if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) {
user.MainQuest.ProgressHeadQuestSceneId = questSceneId user.MainQuest.ProgressHeadQuestSceneId = questSceneId
} }
if isReplay {
user.MainQuest.ProgressQuestFlowType = user.MainQuest.CurrentQuestFlowType
} else {
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
@@ -147,4 +241,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
lastSceneId := h.getChapterLastSceneId(quest.QuestId) lastSceneId := h.getChapterLastSceneId(quest.QuestId)
user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId user.MainQuest.IsReachedLastQuestScene = questSceneId == lastSceneId
} }
if isReplay {
user.MainQuest.ReplayFlowCurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
user.MainQuest.ReplayFlowHeadQuestSceneId = questSceneId
}
user.MainQuest.LatestVersion = gametime.NowMillis()
}
} }
+178
View File
@@ -0,0 +1,178 @@
package runtime
import (
"fmt"
"log"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/userdata"
)
// buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever
// memorydb currently holds and returns a fully populated *Catalogs. Called
// once at startup and again on every reload.
func buildCatalogs() (*Catalogs, error) {
log.Printf("master data loaded (%d tables)", memorydb.TableCount())
gameConfig, err := masterdata.LoadGameConfig()
if err != nil {
return nil, fmt.Errorf("load game config: %w", err)
}
log.Printf("game config loaded (goldId=%d, skipTicketId=%d, rebirthGold=%d)",
gameConfig.ConsumableItemIdForGold, gameConfig.ConsumableItemIdForQuestSkipTicket, gameConfig.CharacterRebirthConsumeGold)
partsCatalog, err := masterdata.LoadPartsCatalog()
if err != nil {
return nil, fmt.Errorf("load parts catalog: %w", err)
}
log.Printf("parts catalog loaded: %d parts, %d rarities", len(partsCatalog.PartsById), len(partsCatalog.RarityByRarityType))
questCatalog, err := masterdata.LoadQuestCatalog(partsCatalog)
if err != nil {
return nil, fmt.Errorf("load quest catalog: %w", err)
}
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog)
userdata.SetQuestHandler(questHandler)
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
if err != nil {
return nil, fmt.Errorf("load gacha catalog: %w", err)
}
log.Printf("gacha catalog loaded: %d entries", len(gachaEntries))
gachaPool, err := masterdata.LoadGachaPool()
if err != nil {
return nil, fmt.Errorf("load gacha pool: %w", err)
}
log.Printf("gacha pool loaded: costumes=%d rarities, weapons=%d rarities, materials=%d",
len(gachaPool.CostumesByRarity), len(gachaPool.WeaponsByRarity), len(gachaPool.Materials))
shopCatalog, err := masterdata.LoadShopCatalog()
if err != nil {
return nil, fmt.Errorf("load shop catalog: %w", err)
}
log.Printf("shop catalog loaded: %d items, %d content groups, %d exchange shops",
len(shopCatalog.Items), len(shopCatalog.Contents), len(shopCatalog.ExchangeShopCells))
gachaPool.BuildShopFeatured(shopCatalog)
gachaPool.PruneUnpairedCostumes()
gachaPool.BuildFeaturedFromTerms(gachaEntries)
gachaPool.BuildBannerPools(gachaEntries)
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
dupExchange, err := masterdata.LoadDupExchange()
if err != nil {
return nil, fmt.Errorf("load dup exchange: %w", err)
}
dupAdded, err := masterdata.EnrichDupExchange(dupExchange, gachaPool)
if err != nil {
return nil, fmt.Errorf("enrich dup exchange: %w", err)
}
log.Printf("dup exchange loaded: %d entries (%d derived from limit-break materials)", len(dupExchange), dupAdded)
gachaHandler := gacha.NewGachaHandler(gachaPool, gameConfig, questHandler.Granter, medalInfo, dupExchange)
conditionResolver, err := masterdata.LoadConditionResolver()
if err != nil {
return nil, fmt.Errorf("load condition resolver: %w", err)
}
cageOrnamentCatalog := masterdata.LoadCageOrnamentCatalog()
loginBonusCatalog := masterdata.LoadLoginBonusCatalog()
characterViewerCatalog := masterdata.LoadCharacterViewerCatalog(conditionResolver)
omikujiCatalog := masterdata.LoadOmikujiCatalog()
materialCatalog, err := masterdata.LoadMaterialCatalog()
if err != nil {
return nil, fmt.Errorf("load material catalog: %w", err)
}
log.Printf("material catalog loaded: %d materials", len(materialCatalog.All))
consumableItemCatalog, err := masterdata.LoadConsumableItemCatalog()
if err != nil {
return nil, fmt.Errorf("load consumable item catalog: %w", err)
}
log.Printf("consumable item catalog loaded: %d items", len(consumableItemCatalog.All))
costumeCatalog, err := masterdata.LoadCostumeCatalog(materialCatalog)
if err != nil {
return nil, fmt.Errorf("load costume catalog: %w", err)
}
log.Printf("costume catalog loaded: %d costumes, %d materials, %d rarity curves", len(costumeCatalog.Costumes), len(costumeCatalog.Materials), len(costumeCatalog.ExpByRarity))
weaponCatalog, err := masterdata.LoadWeaponCatalog(materialCatalog)
if err != nil {
return nil, fmt.Errorf("load weapon catalog: %w", err)
}
log.Printf("weapon catalog loaded: %d weapons, %d materials, %d enhance configs", len(weaponCatalog.Weapons), len(weaponCatalog.Materials), len(weaponCatalog.ExpByEnhanceId))
exploreCatalog, err := masterdata.LoadExploreCatalog()
if err != nil {
return nil, fmt.Errorf("load explore catalog: %w", err)
}
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver, cageOrnamentCatalog)
if err != nil {
return nil, fmt.Errorf("load gimmick catalog: %w", err)
}
characterBoardCatalog, err := masterdata.LoadCharacterBoardCatalog()
if err != nil {
return nil, fmt.Errorf("load character board catalog: %w", err)
}
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()
if err != nil {
return nil, fmt.Errorf("load companion catalog: %w", err)
}
log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory))
bigHuntCatalog := masterdata.LoadBigHuntCatalog()
towerCatalog := masterdata.LoadTowerCatalog()
labyrinthCatalog := masterdata.LoadLabyrinthCatalog()
return &Catalogs{
GameConfig: gameConfig,
Parts: partsCatalog,
Quest: questCatalog,
GachaEntries: gachaEntries,
GachaMedals: medalInfo,
GachaPool: gachaPool,
Shop: shopCatalog,
DupExchange: dupExchange,
ConditionResolver: conditionResolver,
CageOrnament: cageOrnamentCatalog,
LoginBonus: loginBonusCatalog,
CharacterViewer: characterViewerCatalog,
Omikuji: omikujiCatalog,
Material: materialCatalog,
ConsumableItem: consumableItemCatalog,
Costume: costumeCatalog,
Weapon: weaponCatalog,
Explore: exploreCatalog,
Gimmick: gimmickCatalog,
CharacterBoard: characterBoardCatalog,
CharacterRebirth: characterRebirthCatalog,
Companion: companionCatalog,
SideStory: sideStoryCatalog,
BigHunt: bigHuntCatalog,
Tower: towerCatalog,
Labyrinth: labyrinthCatalog,
QuestHandler: questHandler,
GachaHandler: gachaHandler,
}, nil
}
+106
View File
@@ -0,0 +1,106 @@
// Package runtime owns the live, hot-swappable view of master data.
//
// The Holder atomically swaps a *Catalogs aggregate every time the operator
// asks the server to re-read assets/release/20240404193219.bin.e (typically via
// the admin webhook in cmd/lunar-tear/admin.go). gRPC services hold a *Holder
// and call Get() at the start of each RPC, so they always see a consistent
// snapshot.
package runtime
import (
"fmt"
"log"
"os"
"sync/atomic"
"time"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store"
)
// Catalogs is an immutable snapshot of every catalog and catalog-derived
// handler the server needs at runtime. A new *Catalogs is built from scratch
// on every reload and atomically published via Holder.
type Catalogs struct {
GameConfig *masterdata.GameConfig
Parts *masterdata.PartsCatalog
Quest *masterdata.QuestCatalog
GachaEntries []store.GachaCatalogEntry
GachaMedals map[int32]masterdata.GachaMedalInfo
GachaPool *masterdata.GachaCatalog
Shop *masterdata.ShopCatalog
DupExchange map[int32][]model.DupExchangeEntry
ConditionResolver *masterdata.ConditionResolver
CageOrnament *masterdata.CageOrnamentCatalog
LoginBonus *masterdata.LoginBonusCatalog
CharacterViewer *masterdata.CharacterViewerCatalog
Omikuji *masterdata.OmikujiCatalog
Material *masterdata.MaterialCatalog
ConsumableItem *masterdata.ConsumableItemCatalog
Costume *masterdata.CostumeCatalog
Weapon *masterdata.WeaponCatalog
Explore *masterdata.ExploreCatalog
Gimmick *masterdata.GimmickCatalog
CharacterBoard *masterdata.CharacterBoardCatalog
CharacterRebirth *masterdata.CharacterRebirthCatalog
Companion *masterdata.CompanionCatalog
SideStory *masterdata.SideStoryCatalog
BigHunt *masterdata.BigHuntCatalog
Tower *masterdata.TowerCatalog
Labyrinth *masterdata.LabyrinthCatalog
// Catalog-derived handlers must rebuild on every reload because they
// embed/cache pointers to specific catalog instances.
QuestHandler *questflow.QuestHandler
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 {
binPath string
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) {
h := &Holder{binPath: binPath}
if err := h.Reload(); err != nil {
return nil, err
}
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 {
if err := memorydb.Init(h.binPath); err != nil {
return fmt.Errorf("memorydb.Init: %w", err)
}
c, err := buildCatalogs()
if err != nil {
return fmt.Errorf("buildCatalogs: %w", err)
}
h.cur.Store(c)
now := time.Now()
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)
}
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 {
return h.cur.Load()
}
+7 -7
View File
@@ -70,32 +70,32 @@ func (t *revisionTracker) Active(clientAddr string) string {
return revision return revision
} }
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) { func (r *assetResolver) Resolve(objectId, assetType, activeRevision, platform string) (assetResolution, bool) {
start := time.Now() start := time.Now()
resolution := assetResolution{ActiveRevision: activeRevision} resolution := assetResolution{ActiveRevision: activeRevision}
revision := activeRevision revision := activeRevision
candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, assetType, objectId) candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, platform, assetType, objectId)
if ok && len(candidates) > 0 { if ok && len(candidates) > 0 {
resolution.ListRevision = revision resolution.ListRevision = revision
resolution.ListSize = listSize resolution.ListSize = listSize
resolution.Candidates = candidates resolution.Candidates = candidates
if elapsed := time.Since(start); elapsed > 100*time.Millisecond { if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed) log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, revision, elapsed)
} }
return resolution, true return resolution, true
} }
if elapsed := time.Since(start); elapsed > 100*time.Millisecond { if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed) log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s platform=%s active_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, elapsed)
} }
return resolution, false return resolution, false
} }
func (r *assetResolver) Prewarm(activeRevision string) { func (r *assetResolver) Prewarm(activeRevision, platform string) {
if activeRevision == "" { if activeRevision == "" {
return return
} }
_, _ = loadListBinIndex(r.baseDir, activeRevision) _, _ = loadListBinIndex(r.baseDir, activeRevision, platform)
_ = loadInfoIndex(r.baseDir, activeRevision) _ = loadInfoIndex(r.baseDir, activeRevision, platform)
} }
+10 -5
View File
@@ -4,24 +4,29 @@ import (
"context" "context"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/runtime"
) )
type BannerServiceServer struct { type BannerServiceServer struct {
pb.UnimplementedBannerServiceServer pb.UnimplementedBannerServiceServer
catalog []store.GachaCatalogEntry holder *runtime.Holder
} }
func NewBannerServiceServer(catalog []store.GachaCatalogEntry) *BannerServiceServer { func NewBannerServiceServer(holder *runtime.Holder) *BannerServiceServer {
return &BannerServiceServer{catalog: catalog} return &BannerServiceServer{holder: holder}
} }
func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) { func (s *BannerServiceServer) GetMamaBanner(ctx context.Context, req *pb.GetMamaBannerRequest) (*pb.GetMamaBannerResponse, error) {
catalog := s.catalog catalog := s.holder.Get().GachaEntries
nowMillis := gametime.NowMillis()
var termLimited []*pb.GachaBanner var termLimited []*pb.GachaBanner
var latestChapter *pb.GachaBanner var latestChapter *pb.GachaBanner
for _, entry := range catalog { for _, entry := range catalog {
if !gachaActiveAt(entry, nowMillis) {
continue
}
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle { if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue continue
} }
+19 -10
View File
@@ -6,8 +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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -15,21 +15,18 @@ type CageOrnamentServiceServer struct {
pb.UnimplementedCageOrnamentServiceServer pb.UnimplementedCageOrnamentServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CageOrnamentCatalog holder *runtime.Holder
granter *store.PossessionGranter
} }
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CageOrnamentCatalog, granter *store.PossessionGranter) *CageOrnamentServiceServer { func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CageOrnamentServiceServer {
return &CageOrnamentServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter} return &CageOrnamentServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) { func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) {
log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId) log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId)
reward, ok := s.catalog.LookupReward(req.CageOrnamentId) cat := s.holder.Get()
if !ok { reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId)
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -39,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
AcquisitionDatetime: nowMillis, AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis, LatestVersion: nowMillis,
} }
s.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{
{ {
+13 -10
View File
@@ -7,6 +7,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/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -14,21 +15,23 @@ type CharacterServiceServer struct {
pb.UnimplementedCharacterServiceServer pb.UnimplementedCharacterServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CharacterRebirthCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterRebirthCatalog, config *masterdata.GameConfig) *CharacterServiceServer { func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterServiceServer {
return &CharacterServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &CharacterServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthRequest) (*pb.RebirthResponse, error) { func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthRequest) (*pb.RebirthResponse, error) {
log.Printf("[CharacterService] Rebirth: characterId=%d rebirthCount=%d", req.CharacterId, req.RebirthCount) log.Printf("[CharacterService] Rebirth: characterId=%d rebirthCount=%d", req.CharacterId, req.RebirthCount)
cat := s.holder.Get()
catalog := cat.CharacterRebirth
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
stepGroupId, ok := s.catalog.StepGroupByCharacterId[req.CharacterId] stepGroupId, ok := catalog.StepGroupByCharacterId[req.CharacterId]
if !ok { if !ok {
log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId) log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId)
return &pb.RebirthResponse{}, nil return &pb.RebirthResponse{}, nil
@@ -40,17 +43,17 @@ func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthReq
targetCount := currentCount + req.RebirthCount targetCount := currentCount + req.RebirthCount
for count := currentCount; count < targetCount; count++ { for count := currentCount; count < targetCount; count++ {
step, ok := s.catalog.StepByGroupAndCount[masterdata.StepKey{GroupId: stepGroupId, BeforeRebirthCount: count}] step, ok := catalog.StepByGroupAndCount[masterdata.StepKey{GroupId: stepGroupId, BeforeRebirthCount: count}]
if !ok { if !ok {
log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count) log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count)
return return
} }
goldId := s.config.ConsumableItemIdForGold goldId := config.ConsumableItemIdForGold
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-s.config.CharacterRebirthConsumeGold, 0) user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-config.CharacterRebirthConsumeGold, 0)
log.Printf("[CharacterService] Rebirth: consumed gold=%d", s.config.CharacterRebirthConsumeGold) log.Printf("[CharacterService] Rebirth: consumed gold=%d", config.CharacterRebirthConsumeGold)
materials := s.catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId] materials := catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
for _, mat := range materials { for _, mat := range materials {
user.Materials[mat.MaterialId] -= mat.Count user.Materials[mat.MaterialId] -= mat.Count
if user.Materials[mat.MaterialId] <= 0 { if user.Materials[mat.MaterialId] <= 0 {
+25 -23
View File
@@ -7,6 +7,7 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -14,43 +15,44 @@ type CharacterBoardServiceServer struct {
pb.UnimplementedCharacterBoardServiceServer pb.UnimplementedCharacterBoardServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CharacterBoardCatalog holder *runtime.Holder
} }
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterBoardCatalog) *CharacterBoardServiceServer { func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterBoardServiceServer {
return &CharacterBoardServiceServer{users: users, sessions: sessions, catalog: catalog} return &CharacterBoardServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) { func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) {
log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId) log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId)
catalog := s.holder.Get().CharacterBoard
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
for _, panelId := range req.CharacterBoardPanelId { for _, panelId := range req.CharacterBoardPanelId {
panel, ok := s.catalog.PanelById[panelId] panel, ok := catalog.PanelById[panelId]
if !ok { if !ok {
log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId) log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId)
continue continue
} }
s.consumeCosts(user, panel) consumeBoardCosts(catalog, user, panel)
s.setReleaseBit(user, panel) setBoardReleaseBit(user, panel)
s.applyEffects(user, panel) applyBoardEffects(catalog, user, panel)
} }
}) })
return &pb.ReleasePanelResponse{}, nil return &pb.ReleasePanelResponse{}, nil
} }
func (s *CharacterBoardServiceServer) consumeCosts(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) { func consumeBoardCosts(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
costs := s.catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId] costs := catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
for _, cost := range costs { for _, cost := range costs {
store.DeductPossession(user, model.PossessionType(cost.PossessionType), cost.PossessionId, cost.Count) store.DeductPossession(user, model.PossessionType(cost.PossessionType), cost.PossessionId, cost.Count)
} }
} }
func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) { func setBoardReleaseBit(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
boardId := panel.CharacterBoardId boardId := panel.CharacterBoardId
board := user.CharacterBoards[boardId] board := user.CharacterBoards[boardId]
board.CharacterBoardId = boardId board.CharacterBoardId = boardId
@@ -73,26 +75,26 @@ func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel
user.CharacterBoards[boardId] = board user.CharacterBoards[boardId] = board
} }
func (s *CharacterBoardServiceServer) applyEffects(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) { func applyBoardEffects(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
effects := s.catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId] effects := catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
for _, eff := range effects { for _, eff := range effects {
switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) { switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) {
case model.CharacterBoardEffectTypeAbility: case model.CharacterBoardEffectTypeAbility:
s.applyAbilityEffect(user, eff) applyBoardAbilityEffect(catalog, user, eff)
case model.CharacterBoardEffectTypeStatusUp: case model.CharacterBoardEffectTypeStatusUp:
s.applyStatusUpEffect(user, eff) applyBoardStatusUpEffect(catalog, user, eff)
} }
} }
} }
func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) { func applyBoardAbilityEffect(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
ability, ok := s.catalog.AbilityById[eff.CharacterBoardEffectId] ability, ok := catalog.AbilityById[eff.CharacterBoardEffectId]
if !ok { if !ok {
log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId) log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId)
return return
} }
characterId := s.resolveCharacterId(ability.CharacterBoardEffectTargetGroupId) characterId := resolveBoardCharacterId(catalog, ability.CharacterBoardEffectTargetGroupId)
if characterId == 0 { if characterId == 0 {
return return
} }
@@ -103,21 +105,21 @@ func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState,
state.AbilityId = ability.AbilityId state.AbilityId = ability.AbilityId
state.Level += eff.EffectValue state.Level += eff.EffectValue
if maxLvl, ok := s.catalog.AbilityMaxLevel[key]; ok && state.Level > maxLvl { if maxLvl, ok := catalog.AbilityMaxLevel[key]; ok && state.Level > maxLvl {
state.Level = maxLvl state.Level = maxLvl
} }
user.CharacterBoardAbilities[key] = state user.CharacterBoardAbilities[key] = state
} }
func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) { func applyBoardStatusUpEffect(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
statusUp, ok := s.catalog.StatusUpById[eff.CharacterBoardEffectId] statusUp, ok := catalog.StatusUpById[eff.CharacterBoardEffectId]
if !ok { if !ok {
log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId) log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId)
return return
} }
characterId := s.resolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId) characterId := resolveBoardCharacterId(catalog, statusUp.CharacterBoardEffectTargetGroupId)
if characterId == 0 { if characterId == 0 {
return return
} }
@@ -151,8 +153,8 @@ func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState,
user.CharacterBoardStatusUps[key] = state user.CharacterBoardStatusUps[key] = state
} }
func (s *CharacterBoardServiceServer) resolveCharacterId(targetGroupId int32) int32 { func resolveBoardCharacterId(catalog *masterdata.CharacterBoardCatalog, targetGroupId int32) int32 {
targets := s.catalog.EffectTargetsByGroupId[targetGroupId] targets := catalog.EffectTargetsByGroupId[targetGroupId]
for _, t := range targets { for _, t := range targets {
if t.TargetValue != 0 { if t.TargetValue != 0 {
return t.TargetValue return t.TargetValue
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -16,11 +16,11 @@ type CharacterViewerServiceServer struct {
pb.UnimplementedCharacterViewerServiceServer pb.UnimplementedCharacterViewerServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CharacterViewerCatalog holder *runtime.Holder
} }
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterViewerCatalog) *CharacterViewerServiceServer { func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterViewerServiceServer {
return &CharacterViewerServiceServer{users: users, sessions: sessions, catalog: catalog} return &CharacterViewerServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ *emptypb.Empty) (*pb.CharacterViewerTopResponse, error) { func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _ *emptypb.Empty) (*pb.CharacterViewerTopResponse, error) {
@@ -32,7 +32,7 @@ func (s *CharacterViewerServiceServer) CharacterViewerTop(ctx context.Context, _
panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err)) panic(fmt.Sprintf("CharacterViewerTop: no user for userId=%d: %v", userId, err))
} }
released := s.catalog.ReleasedFieldIds(user) released := s.holder.Get().CharacterViewer.ReleasedFieldIds(user)
log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId) log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId)
return &pb.CharacterViewerTopResponse{ return &pb.CharacterViewerTopResponse{
+11 -8
View File
@@ -8,6 +8,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/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -17,17 +18,19 @@ type CompanionServiceServer struct {
pb.UnimplementedCompanionServiceServer pb.UnimplementedCompanionServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CompanionCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CompanionCatalog, config *masterdata.GameConfig) *CompanionServiceServer { func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CompanionServiceServer {
return &CompanionServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &CompanionServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionEnhanceRequest) (*pb.CompanionEnhanceResponse, error) { func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionEnhanceRequest) (*pb.CompanionEnhanceResponse, error) {
log.Printf("[CompanionService] Enhance: uuid=%s addLevel=%d", req.UserCompanionUuid, req.AddLevelCount) log.Printf("[CompanionService] Enhance: uuid=%s addLevel=%d", req.UserCompanionUuid, req.AddLevelCount)
cat := s.holder.Get()
catalog := cat.Companion
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -38,7 +41,7 @@ func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionE
return return
} }
compDef, ok := s.catalog.CompanionById[companion.CompanionId] compDef, ok := catalog.CompanionById[companion.CompanionId]
if !ok { if !ok {
log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId) log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId)
return return
@@ -50,13 +53,13 @@ func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionE
} }
for lvl := companion.Level; lvl < targetLevel; lvl++ { for lvl := companion.Level; lvl < targetLevel; lvl++ {
if costFunc, ok := s.catalog.GoldCostByCategory[compDef.CompanionCategoryType]; ok { if costFunc, ok := catalog.GoldCostByCategory[compDef.CompanionCategoryType]; ok {
goldCost := costFunc.Evaluate(lvl) goldCost := costFunc.Evaluate(lvl)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
} }
matKey := masterdata.CompanionLevelKey{CategoryType: compDef.CompanionCategoryType, Level: lvl} matKey := masterdata.CompanionLevelKey{CategoryType: compDef.CompanionCategoryType, Level: lvl}
if mat, ok := s.catalog.MaterialsByKey[matKey]; ok { if mat, ok := catalog.MaterialsByKey[matKey]; ok {
user.Materials[mat.MaterialId] -= mat.Count user.Materials[mat.MaterialId] -= mat.Count
} }
} }
+54 -7
View File
@@ -6,7 +6,9 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -14,23 +16,68 @@ type ConsumableItemServiceServer struct {
pb.UnimplementedConsumableItemServiceServer pb.UnimplementedConsumableItemServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.ConsumableItemCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewConsumableItemServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ConsumableItemCatalog, config *masterdata.GameConfig) *ConsumableItemServiceServer { func NewConsumableItemServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ConsumableItemServiceServer {
return &ConsumableItemServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &ConsumableItemServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *ConsumableItemServiceServer) UseEffectItem(ctx context.Context, req *pb.ConsumableItemUseEffectItemRequest) (*pb.ConsumableItemUseEffectItemResponse, error) {
log.Printf("[ConsumableItemService] UseEffectItem: consumableItemId=%d count=%d", req.ConsumableItemId, req.Count)
cat := s.holder.Get()
catalog := cat.ConsumableItem
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, ok := catalog.All[req.ConsumableItemId]; !ok {
log.Printf("[ConsumableItemService] UseEffectItem: unknown consumableItemId=%d", req.ConsumableItemId)
return
}
cur := user.ConsumableItems[req.ConsumableItemId]
if cur < req.Count {
log.Printf("[ConsumableItemService] UseEffectItem: insufficient consumableItemId=%d have=%d need=%d", req.ConsumableItemId, cur, req.Count)
return
}
user.ConsumableItems[req.ConsumableItemId] -= req.Count
if user.ConsumableItems[req.ConsumableItemId] <= 0 {
delete(user.ConsumableItems, req.ConsumableItemId)
}
maxStaminaMillis := cat.Shop.MaxStaminaMillis[user.Status.Level]
for _, effect := range catalog.Effects[req.ConsumableItemId] {
switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery:
millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxStaminaMillis)
store.RecoverStamina(user, millis*req.Count, maxStaminaMillis, nowMillis)
default:
log.Printf("[ConsumableItemService] UseEffectItem: unhandled effect targetType=%d valueType=%d value=%d itemId=%d",
effect.EffectTargetType, effect.EffectValueType, effect.EffectValue, req.ConsumableItemId)
}
}
})
if err != nil {
return nil, fmt.Errorf("consumable item use effect item: %w", err)
}
return &pb.ConsumableItemUseEffectItemResponse{}, nil
} }
func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.ConsumableItemSellRequest) (*pb.ConsumableItemSellResponse, error) { func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.ConsumableItemSellRequest) (*pb.ConsumableItemSellResponse, error) {
log.Printf("[ConsumableItemService] Sell: %d item(s)", len(req.ConsumableItemPossession)) log.Printf("[ConsumableItemService] Sell: %d item(s)", len(req.ConsumableItemPossession))
cat := s.holder.Get()
catalog := cat.ConsumableItem
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0) totalGold := int32(0)
for _, item := range req.ConsumableItemPossession { for _, item := range req.ConsumableItemPossession {
row, ok := s.catalog.All[item.ConsumableItemId] row, ok := catalog.All[item.ConsumableItemId]
if !ok { if !ok {
log.Printf("[ConsumableItemService] Sell: unknown consumableItemId=%d, skipping", item.ConsumableItemId) log.Printf("[ConsumableItemService] Sell: unknown consumableItemId=%d, skipping", item.ConsumableItemId)
continue continue
@@ -53,7 +100,7 @@ func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.Consumab
} }
if totalGold > 0 { if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[ConsumableItemService] Sell: total gold +%d", totalGold) log.Printf("[ConsumableItemService] Sell: total gold +%d", totalGold)
} }
}) })
+55 -37
View File
@@ -13,6 +13,7 @@ import (
"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"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -20,17 +21,19 @@ type CostumeServiceServer struct {
pb.UnimplementedCostumeServiceServer pb.UnimplementedCostumeServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.CostumeCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer { func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CostumeServiceServer {
return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &CostumeServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) { func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceRequest) (*pb.EnhanceResponse, error) {
log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials) log.Printf("[CostumeService] Enhance: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -41,7 +44,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
return return
} }
cm, ok := s.catalog.Costumes[costume.CostumeId] cm, ok := catalog.Costumes[costume.CostumeId]
if !ok { if !ok {
log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId) log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
return return
@@ -50,7 +53,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId] mat, ok := catalog.Materials[materialId]
if !ok { if !ok {
log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId) log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
continue continue
@@ -66,20 +69,20 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
expPerUnit := mat.EffectValue expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType { if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expPerUnit * count
} }
if costFunc, ok := s.catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount) goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount) log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
} }
costume.Exp += totalExp costume.Exp += totalExp
if thresholds, ok := s.catalog.ExpByRarity[cm.RarityType]; ok { if thresholds, ok := catalog.ExpByRarity[cm.RarityType]; ok {
costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds) costume.Level, costume.Exp = gameutil.LevelAndCap(costume.Exp, thresholds)
} }
@@ -100,6 +103,9 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) { func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest) (*pb.AwakenResponse, error) {
log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials) log.Printf("[CostumeService] Awaken: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -110,7 +116,7 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
return return
} }
awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId] awakenRow, ok := catalog.AwakenByCostumeId[costume.CostumeId]
if !ok { if !ok {
log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId) log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
return return
@@ -118,8 +124,8 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
nextStep := costume.AwakenCount + 1 nextStep := costume.AwakenCount + 1
if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok { if gold, ok := catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold user.ConsumableItems[config.ConsumableItemIdForGold] -= gold
log.Printf("[CostumeService] Awaken: gold cost=%d", gold) log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
} }
@@ -137,7 +143,7 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
user.Costumes[req.UserCostumeUuid] = costume user.Costumes[req.UserCostumeUuid] = costume
log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep) log.Printf("[CostumeService] Awaken: costumeId=%d awakenCount=%d", costume.CostumeId, nextStep)
effectSteps, ok := s.catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId] effectSteps, ok := catalog.AwakenEffectsByGroupAndStep[awakenRow.CostumeAwakenEffectGroupId]
if !ok { if !ok {
return return
} }
@@ -148,11 +154,11 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) { switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
case model.CostumeAwakenEffectTypeStatusUp: case model.CostumeAwakenEffectTypeStatusUp:
s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis) applyCostumeAwakenStatusUp(catalog, user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
case model.CostumeAwakenEffectTypeAbility: case model.CostumeAwakenEffectTypeAbility:
log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId) log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
case model.CostumeAwakenEffectTypeItemAcquire: case model.CostumeAwakenEffectTypeItemAcquire:
s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis) applyCostumeAwakenItemAcquire(catalog, user, effect.CostumeAwakenEffectId, nowMillis)
default: default:
log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType) log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
} }
@@ -164,8 +170,8 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
return &pb.AwakenResponse{}, nil return &pb.AwakenResponse{}, nil
} }
func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) { func applyCostumeAwakenStatusUp(catalog *masterdata.CostumeCatalog, user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId] rows, ok := catalog.AwakenStatusUpByGroup[statusUpGroupId]
if !ok { if !ok {
log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId) log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
return return
@@ -201,8 +207,8 @@ func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costum
} }
} }
func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) { func applyCostumeAwakenItemAcquire(catalog *masterdata.CostumeCatalog, user *store.UserState, itemAcquireId int32, nowMillis int64) {
acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId] acq, ok := catalog.AwakenItemAcquireById[itemAcquireId]
if !ok { if !ok {
log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId) log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
return return
@@ -226,6 +232,9 @@ func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, ite
func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) { func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.EnhanceActiveSkillRequest) (*pb.EnhanceActiveSkillResponse, error) {
log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount) log.Printf("[CostumeService] EnhanceActiveSkill: uuid=%s addLevel=%d", req.UserCostumeUuid, req.AddLevelCount)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -236,13 +245,13 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
return return
} }
cm, ok := s.catalog.Costumes[costume.CostumeId] cm, ok := catalog.Costumes[costume.CostumeId]
if !ok { if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId) log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
return return
} }
groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId] groupRows := catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
enhanceMatId := int32(-1) enhanceMatId := int32(-1)
for _, g := range groupRows { for _, g := range groupRows {
if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount { if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
@@ -259,7 +268,7 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
skill := user.CostumeActiveSkills[req.UserCostumeUuid] skill := user.CostumeActiveSkills[req.UserCostumeUuid]
currentLevel := skill.Level currentLevel := skill.Level
maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType] maxLevelFunc, ok := catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
if !ok { if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType) log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
return return
@@ -277,7 +286,7 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ { for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl} key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.ActiveSkillEnhanceMats[key] mats := catalog.ActiveSkillEnhanceMats[key]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -288,9 +297,9 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
user.Materials[mat.MaterialId] = cur - cost user.Materials[mat.MaterialId] = cur - cost
} }
if costFunc, ok := s.catalog.ActiveSkillCostByRarity[cm.RarityType]; ok { if costFunc, ok := catalog.ActiveSkillCostByRarity[cm.RarityType]; ok {
goldCost := costFunc.Evaluate(lvl + 1) goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
} }
} }
@@ -310,6 +319,9 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) { func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBreakRequest) (*pb.LimitBreakResponse, error) {
log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials) log.Printf("[CostumeService] LimitBreak: uuid=%s materials=%v", req.UserCostumeUuid, req.Materials)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -320,12 +332,12 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
return return
} }
if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount { if costume.LimitBreakCount >= config.CostumeLimitBreakAvailableCount {
log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount) log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
return return
} }
cm, ok := s.catalog.Costumes[costume.CostumeId] cm, ok := catalog.Costumes[costume.CostumeId]
if !ok { if !ok {
log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId) log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
return return
@@ -342,9 +354,9 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
totalMaterialCount += count totalMaterialCount += count
} }
if costFunc, ok := s.catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.LimitBreakCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount) goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost) log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
} }
@@ -363,6 +375,9 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req *pb.UnlockLotteryEffectSlotRequest) (*pb.UnlockLotteryEffectSlotResponse, error) { func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req *pb.UnlockLotteryEffectSlotRequest) (*pb.UnlockLotteryEffectSlotResponse, error) {
log.Printf("[CostumeService] UnlockLotteryEffectSlot: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) log.Printf("[CostumeService] UnlockLotteryEffectSlot: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -373,15 +388,15 @@ func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req
return return
} }
effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
if !ok { if !ok {
log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
return return
} }
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectUnlockSlotConsumeGold user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectUnlockSlotConsumeGold
mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId] mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -418,6 +433,9 @@ func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req
func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.DrawLotteryEffectRequest) (*pb.DrawLotteryEffectResponse, error) { func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.DrawLotteryEffectRequest) (*pb.DrawLotteryEffectResponse, error) {
log.Printf("[CostumeService] DrawLotteryEffect: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber) log.Printf("[CostumeService] DrawLotteryEffect: uuid=%s slot=%d", req.UserCostumeUuid, req.SlotNumber)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -428,21 +446,21 @@ func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.Dr
return return
} }
effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}] effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
if !ok { if !ok {
log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber) log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
return return
} }
oddsPool := s.catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId] oddsPool := catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId]
if len(oddsPool) == 0 { if len(oddsPool) == 0 {
log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId) log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId)
return return
} }
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectDrawSlotConsumeGold user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectDrawSlotConsumeGold
mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId] mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
+19 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -12,6 +13,16 @@ import (
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
) )
// masterDataBinPath is the canonical location of the encrypted master data
// file. The mtime of this file is folded into the version string so the
// client invalidates its cache as soon as an admin reload swaps it in.
const masterDataBinPath = "assets/release/20240404193219.bin.e"
// masterDataBaseVersion preserves the historical "yyyymmddHHMMSS" value the
// client has always seen; we suffix it with the file mtime to force a
// re-download when content changes.
const masterDataBaseVersion = "20240404193219"
type DataServiceServer struct { type DataServiceServer struct {
pb.UnimplementedDataServiceServer pb.UnimplementedDataServiceServer
users store.UserRepository users store.UserRepository
@@ -23,9 +34,15 @@ func NewDataServiceServer(users store.UserRepository, sessions store.SessionRepo
} }
func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) { func (s *DataServiceServer) GetLatestMasterDataVersion(ctx context.Context, _ *emptypb.Empty) (*pb.MasterDataGetLatestVersionResponse, error) {
log.Printf("[DataService] GetLatestMasterDataVersion") version := masterDataBaseVersion
if info, err := os.Stat(masterDataBinPath); err == nil {
version = fmt.Sprintf("%s_%d", masterDataBaseVersion, info.ModTime().UnixMilli())
} else {
log.Printf("[DataService] stat %s: %v (falling back to base version)", masterDataBinPath, err)
}
log.Printf("[DataService] GetLatestMasterDataVersion -> %s", version)
return &pb.MasterDataGetLatestVersionResponse{ return &pb.MasterDataGetLatestVersionResponse{
LatestMasterDataVersion: "20240404193219", LatestMasterDataVersion: version,
}, nil }, nil
} }
+34
View File
@@ -191,11 +191,45 @@ func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.Repla
} }
store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis) store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis)
} }
key := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
td := user.TripleDecks[key]
td.DeckType = model.DeckType(req.DeckType)
td.UserDeckNumber = req.UserDeckNumber
td.DeckNumber01 = innerDeckNumber(req.DeckDetail01)
td.DeckNumber02 = innerDeckNumber(req.DeckDetail02)
td.DeckNumber03 = innerDeckNumber(req.DeckDetail03)
td.LatestVersion = nowMillis
user.TripleDecks[key] = td
}) })
return &pb.ReplaceTripleDeckResponse{}, nil return &pb.ReplaceTripleDeckResponse{}, nil
} }
func innerDeckNumber(d *pb.DeckDetail) int32 {
if d == nil {
return 0
}
return d.UserDeckNumber
}
func (s *DeckServiceServer) UpdateTripleDeckName(ctx context.Context, req *pb.UpdateTripleDeckNameRequest) (*pb.UpdateTripleDeckNameResponse, error) {
log.Printf("[DeckService] UpdateTripleDeckName: deckType=%d deckNumber=%d name=%q", req.DeckType, req.UserDeckNumber, req.Name)
userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
key := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
td := user.TripleDecks[key]
td.DeckType = model.DeckType(req.DeckType)
td.UserDeckNumber = req.UserDeckNumber
td.Name = req.Name
td.LatestVersion = gametime.NowMillis()
user.TripleDecks[key] = td
})
return &pb.UpdateTripleDeckNameResponse{}, nil
}
func (s *DeckServiceServer) ReplaceMultiDeck(ctx context.Context, req *pb.ReplaceMultiDeckRequest) (*pb.ReplaceMultiDeckResponse, error) { func (s *DeckServiceServer) ReplaceMultiDeck(ctx context.Context, req *pb.ReplaceMultiDeckRequest) (*pb.ReplaceMultiDeckResponse, error) {
log.Printf("[DeckService] ReplaceMultiDeck: %d entries", len(req.DeckDetail)) log.Printf("[DeckService] ReplaceMultiDeck: %d entries", len(req.DeckDetail))
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
+10 -8
View File
@@ -7,8 +7,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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -22,17 +22,18 @@ type ExploreServiceServer struct {
pb.UnimplementedExploreServiceServer pb.UnimplementedExploreServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.ExploreCatalog holder *runtime.Holder
} }
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ExploreCatalog) *ExploreServiceServer { func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ExploreServiceServer {
return &ExploreServiceServer{users: users, sessions: sessions, catalog: catalog} return &ExploreServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartExploreRequest) (*pb.StartExploreResponse, error) { func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartExploreRequest) (*pb.StartExploreResponse, error) {
log.Printf("[ExploreService] StartExplore: exploreId=%d useConsumableItemId=%d", req.ExploreId, req.UseConsumableItemId) log.Printf("[ExploreService] StartExplore: exploreId=%d useConsumableItemId=%d", req.ExploreId, req.UseConsumableItemId)
if _, ok := s.catalog.Explores[req.ExploreId]; !ok { catalog := s.holder.Get().Explore
if _, ok := catalog.Explores[req.ExploreId]; !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId) return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
} }
@@ -40,7 +41,7 @@ func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartEx
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
explore := s.catalog.Explores[req.ExploreId] explore := catalog.Explores[req.ExploreId]
if req.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0 { if req.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0 {
cur := user.ConsumableItems[req.UseConsumableItemId] cur := user.ConsumableItems[req.UseConsumableItemId]
user.ConsumableItems[req.UseConsumableItemId] = cur - explore.ConsumeItemCount user.ConsumableItems[req.UseConsumableItemId] = cur - explore.ConsumeItemCount
@@ -64,12 +65,13 @@ func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartEx
func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.FinishExploreRequest) (*pb.FinishExploreResponse, error) { func (s *ExploreServiceServer) FinishExplore(ctx context.Context, req *pb.FinishExploreRequest) (*pb.FinishExploreResponse, error) {
log.Printf("[ExploreService] FinishExplore: exploreId=%d score=%d", req.ExploreId, req.Score) log.Printf("[ExploreService] FinishExplore: exploreId=%d score=%d", req.ExploreId, req.Score)
explore, ok := s.catalog.Explores[req.ExploreId] catalog := s.holder.Get().Explore
explore, ok := catalog.Explores[req.ExploreId]
if !ok { if !ok {
return nil, fmt.Errorf("explore id=%d not found", req.ExploreId) return nil, fmt.Errorf("explore id=%d not found", req.ExploreId)
} }
assetGradeIconId := s.catalog.GradeForScore(req.ExploreId, req.Score) assetGradeIconId := catalog.GradeForScore(req.ExploreId, req.Score)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
+58 -37
View File
@@ -10,6 +10,7 @@ import (
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -20,34 +21,33 @@ type GachaServiceServer struct {
pb.UnimplementedGachaServiceServer pb.UnimplementedGachaServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog []store.GachaCatalogEntry holder *runtime.Holder
handler *gacha.GachaHandler
} }
func NewGachaServiceServer( func NewGachaServiceServer(
users store.UserRepository, users store.UserRepository,
sessions store.SessionRepository, sessions store.SessionRepository,
catalog []store.GachaCatalogEntry, holder *runtime.Holder,
handler *gacha.GachaHandler,
) *GachaServiceServer { ) *GachaServiceServer {
return &GachaServiceServer{ return &GachaServiceServer{
users: users, users: users,
sessions: sessions, sessions: sessions,
catalog: catalog, holder: holder,
handler: handler,
} }
} }
func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) { func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) {
log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType) log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType)
catalog := s.catalog cat := s.holder.Get()
catalog := cat.GachaEntries
handler := cat.GachaHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
user, err := s.users.UpdateUser(userId, func(user *store.UserState) { user, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.EnsureMaps() user.EnsureMaps()
s.autoConvertExpiredMedals(user, catalog, nowMillis) autoConvertExpiredMedals(user, catalog, handler, nowMillis)
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("update user: %w", err) return nil, fmt.Errorf("update user: %w", err)
@@ -55,6 +55,9 @@ func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaL
gachaList := make([]*pb.Gacha, 0, len(catalog)) gachaList := make([]*pb.Gacha, 0, len(catalog))
for _, entry := range catalog { for _, entry := range catalog {
if !gachaActiveAt(entry, nowMillis) {
continue
}
if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) { if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) {
continue continue
} }
@@ -71,7 +74,7 @@ func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaL
}, nil }, nil
} }
func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, nowMillis int64) { func autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, handler *gacha.GachaHandler, nowMillis int64) {
for _, entry := range catalog { for _, entry := range catalog {
if entry.GachaMedalId == 0 || entry.EndDatetime == 0 { if entry.GachaMedalId == 0 || entry.EndDatetime == 0 {
continue continue
@@ -84,7 +87,7 @@ func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, cat
continue continue
} }
medalInfo, ok := s.handler.MedalInfo[entry.GachaId] medalInfo, ok := handler.MedalInfo[entry.GachaId]
if !ok { if !ok {
continue continue
} }
@@ -117,7 +120,8 @@ func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, cat
func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) { func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) {
log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId) log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId)
catalog := s.catalog catalog := s.holder.Get().GachaEntries
nowMillis := gametime.NowMillis()
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
user, err := s.users.LoadUser(userId) user, err := s.users.LoadUser(userId)
@@ -128,13 +132,17 @@ func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaReque
byId := make(map[int32]*pb.Gacha, len(req.GachaId)) byId := make(map[int32]*pb.Gacha, len(req.GachaId))
for _, wantedId := range req.GachaId { for _, wantedId := range req.GachaId {
for _, entry := range catalog { for _, entry := range catalog {
if entry.GachaId == wantedId { if entry.GachaId != wantedId {
continue
}
if !gachaActiveAt(entry, nowMillis) {
break
}
bs := user.Gacha.BannerStates[entry.GachaId] bs := user.Gacha.BannerStates[entry.GachaId]
byId[wantedId] = toProtoGacha(entry, &bs) byId[wantedId] = toProtoGacha(entry, &bs)
break break
} }
} }
}
return &pb.GetGachaResponse{ return &pb.GetGachaResponse{
Gacha: byId, Gacha: byId,
@@ -144,10 +152,12 @@ func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaReque
func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) { func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) {
log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount) log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount)
entry := findCatalogEntry(s.catalog, req.GachaId) cat := s.holder.Get()
entry := findCatalogEntry(cat.GachaEntries, req.GachaId)
if entry == nil { if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId) return nil, fmt.Errorf("gacha %d not found", req.GachaId)
} }
handler := cat.GachaHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
execCount := req.ExecCount execCount := req.ExecCount
@@ -156,9 +166,17 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
} }
var drawResult *gacha.DrawResult var drawResult *gacha.DrawResult
ownedCostumes := map[int32]bool{}
ownedWeapons := map[int32]bool{}
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, c := range user.Costumes {
ownedCostumes[c.CostumeId] = true
}
for _, w := range user.Weapons {
ownedWeapons[w.WeaponId] = true
}
var drawErr error var drawErr error
drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount) drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
if drawErr != nil { if drawErr != nil {
log.Printf("[GachaService] Draw error: %v", drawErr) log.Printf("[GachaService] Draw error: %v", drawErr)
drawResult = &gacha.DrawResult{} drawResult = &gacha.DrawResult{}
@@ -193,15 +211,6 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
weaponPT := int32(model.PossessionTypeWeapon) weaponPT := int32(model.PossessionTypeWeapon)
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType) isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
for i, item := range drawResult.Items { for i, item := range drawResult.Items {
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser) isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
@@ -285,14 +294,16 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) { func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) {
log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId) log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId)
entry := findCatalogEntry(s.catalog, req.GachaId) cat := s.holder.Get()
entry := findCatalogEntry(cat.GachaEntries, req.GachaId)
if entry == nil { if entry == nil {
return nil, fmt.Errorf("gacha %d not found", req.GachaId) return nil, fmt.Errorf("gacha %d not found", req.GachaId)
} }
handler := cat.GachaHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if resetErr := s.handler.HandleResetBox(user, *entry); resetErr != nil { if resetErr := handler.HandleResetBox(user, *entry); resetErr != nil {
log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr) log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr)
} }
}) })
@@ -315,7 +326,7 @@ func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Em
return nil, fmt.Errorf("snapshot user: %w", err) return nil, fmt.Errorf("snapshot user: %w", err)
} }
maxCount := s.handler.Config.RewardGachaDailyMaxCount maxCount := s.holder.Get().GachaHandler.Config.RewardGachaDailyMaxCount
if maxCount <= 0 { if maxCount <= 0 {
maxCount = model.DefaultDailyDrawLimit maxCount = model.DefaultDailyDrawLimit
} }
@@ -337,11 +348,20 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR
log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount) log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
handler := s.holder.Get().GachaHandler
var items []gacha.DrawnItem var items []gacha.DrawnItem
ownedCostumes := map[int32]bool{}
ownedWeapons := map[int32]bool{}
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for _, c := range user.Costumes {
ownedCostumes[c.CostumeId] = true
}
for _, w := range user.Weapons {
ownedWeapons[w.WeaponId] = true
}
var drawErr error var drawErr error
items, drawErr = s.handler.HandleRewardDraw(user, 1) items, drawErr = handler.HandleRewardDraw(user, 1)
if drawErr != nil { if drawErr != nil {
log.Printf("[GachaService] RewardDraw error: %v", drawErr) log.Printf("[GachaService] RewardDraw error: %v", drawErr)
} }
@@ -350,15 +370,6 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR
return nil, fmt.Errorf("update user: %w", err) return nil, fmt.Errorf("update user: %w", err)
} }
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
for _, c := range updatedUser.Costumes {
ownedCostumes[c.CostumeId] = true
}
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
for _, w := range updatedUser.Weapons {
ownedWeapons[w.WeaponId] = true
}
results := make([]*pb.RewardGachaItem, 0, len(items)) results := make([]*pb.RewardGachaItem, 0, len(items))
for _, item := range items { for _, item := range items {
results = append(results, &pb.RewardGachaItem{ results = append(results, &pb.RewardGachaItem{
@@ -395,6 +406,16 @@ func matchesGachaLabel(labels []int32, label int32) bool {
return false return false
} }
func gachaActiveAt(entry store.GachaCatalogEntry, nowMillis int64) bool {
if entry.StartDatetime != 0 && nowMillis < entry.StartDatetime {
return false
}
if entry.EndDatetime != 0 && nowMillis >= entry.EndDatetime {
return false
}
return true
}
func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha { func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha {
g := &pb.Gacha{ g := &pb.Gacha{
GachaId: entry.GachaId, GachaId: entry.GachaId,
+138 -10
View File
@@ -7,6 +7,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/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -16,11 +18,11 @@ type GimmickServiceServer struct {
pb.UnimplementedGimmickServiceServer pb.UnimplementedGimmickServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
gimmickCatalog *masterdata.GimmickCatalog holder *runtime.Holder
} }
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, gimmickCatalog *masterdata.GimmickCatalog) *GimmickServiceServer { func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *GimmickServiceServer {
return &GimmickServiceServer{users: users, sessions: sessions, gimmickCatalog: gimmickCatalog} return &GimmickServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.UpdateSequenceRequest) (*pb.UpdateSequenceResponse, error) { func (s *GimmickServiceServer) UpdateSequence(ctx context.Context, req *pb.UpdateSequenceRequest) (*pb.UpdateSequenceResponse, error) {
@@ -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.gimmickCatalog.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
}
+80 -49
View File
@@ -45,14 +45,21 @@ type infoLoad struct {
} }
var ( var (
listBinCache = make(map[string]listBinIndex) // revision → index listBinCache = make(map[string]listBinIndex) // revision/platform → index
listBinInflight = make(map[string]*listBinLoad) listBinInflight = make(map[string]*listBinLoad)
listBinCacheMu sync.RWMutex listBinCacheMu sync.RWMutex
infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target infoCache = make(map[string]map[string]infoAlias) // revision/platform → from-name → duplicate target
infoInflight = make(map[string]*infoLoad) infoInflight = make(map[string]*infoLoad)
infoCacheMu sync.RWMutex infoCacheMu sync.RWMutex
) )
func cacheKey(revision, platform string) string {
if platform == "" {
return revision + "/_shared"
}
return revision + "/" + platform
}
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name). // infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
type infoJSONEntry struct { type infoJSONEntry struct {
FromName string `json:"from-name"` FromName string `json:"from-name"`
@@ -208,33 +215,34 @@ func parseListBin(data []byte) listBinIndex {
return idx return idx
} }
func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) { func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) {
key := cacheKey(revision, platform)
listBinCacheMu.RLock() listBinCacheMu.RLock()
idx, ok := listBinCache[revision] idx, ok := listBinCache[key]
listBinCacheMu.RUnlock() listBinCacheMu.RUnlock()
if ok { if ok {
return idx, true return idx, true
} }
listBinCacheMu.Lock() listBinCacheMu.Lock()
if idx, ok := listBinCache[revision]; ok { if idx, ok := listBinCache[key]; ok {
listBinCacheMu.Unlock() listBinCacheMu.Unlock()
return idx, true return idx, true
} }
if load := listBinInflight[revision]; load != nil { if load := listBinInflight[key]; load != nil {
listBinCacheMu.Unlock() listBinCacheMu.Unlock()
<-load.done <-load.done
return load.idx, load.ok return load.idx, load.ok
} }
load := &listBinLoad{done: make(chan struct{})} load := &listBinLoad{done: make(chan struct{})}
listBinInflight[revision] = load listBinInflight[key] = load
listBinCacheMu.Unlock() listBinCacheMu.Unlock()
filePath := filepath.Join(baseDir, "assets", "revisions", revision, "list.bin") filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "list.bin")
data, err := os.ReadFile(filePath) data, err := os.ReadFile(filePath)
if err != nil { if err != nil {
listBinCacheMu.Lock() listBinCacheMu.Lock()
delete(listBinInflight, revision) delete(listBinInflight, key)
close(load.done) close(load.done)
listBinCacheMu.Unlock() listBinCacheMu.Unlock()
return nil, false return nil, false
@@ -243,41 +251,42 @@ func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
load.idx = idx load.idx = idx
load.ok = true load.ok = true
listBinCacheMu.Lock() listBinCacheMu.Lock()
listBinCache[revision] = idx listBinCache[key] = idx
delete(listBinInflight, revision) delete(listBinInflight, key)
close(load.done) close(load.done)
listBinCacheMu.Unlock() listBinCacheMu.Unlock()
return idx, true return idx, true
} }
func loadInfoIndex(baseDir, revision string) map[string]infoAlias { func loadInfoIndex(baseDir, revision, platform string) map[string]infoAlias {
key := cacheKey(revision, platform)
infoCacheMu.RLock() infoCacheMu.RLock()
m, ok := infoCache[revision] m, ok := infoCache[key]
infoCacheMu.RUnlock() infoCacheMu.RUnlock()
if ok { if ok {
return m return m
} }
infoCacheMu.Lock() infoCacheMu.Lock()
if m, ok := infoCache[revision]; ok { if m, ok := infoCache[key]; ok {
infoCacheMu.Unlock() infoCacheMu.Unlock()
return m return m
} }
if load := infoInflight[revision]; load != nil { if load := infoInflight[key]; load != nil {
infoCacheMu.Unlock() infoCacheMu.Unlock()
<-load.done <-load.done
return load.m return load.m
} }
load := &infoLoad{done: make(chan struct{})} load := &infoLoad{done: make(chan struct{})}
infoInflight[revision] = load infoInflight[key] = load
infoCacheMu.Unlock() infoCacheMu.Unlock()
filePath := filepath.Join(baseDir, "assets", "revisions", revision, "info.json") filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "info.json")
data, err := os.ReadFile(filePath) data, err := os.ReadFile(filePath)
if err != nil { if err != nil {
infoCacheMu.Lock() infoCacheMu.Lock()
infoCache[revision] = nil infoCache[key] = nil
delete(infoInflight, revision) delete(infoInflight, key)
close(load.done) close(load.done)
infoCacheMu.Unlock() infoCacheMu.Unlock()
return nil return nil
@@ -285,8 +294,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
var entries []infoJSONEntry var entries []infoJSONEntry
if err := json.Unmarshal(data, &entries); err != nil { if err := json.Unmarshal(data, &entries); err != nil {
infoCacheMu.Lock() infoCacheMu.Lock()
infoCache[revision] = nil infoCache[key] = nil
delete(infoInflight, revision) delete(infoInflight, key)
close(load.done) close(load.done)
infoCacheMu.Unlock() infoCacheMu.Unlock()
return nil return nil
@@ -307,8 +316,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
} }
load.m = m load.m = m
infoCacheMu.Lock() infoCacheMu.Lock()
infoCache[revision] = m infoCache[key] = m
delete(infoInflight, revision) delete(infoInflight, key)
close(load.done) close(load.done)
infoCacheMu.Unlock() infoCacheMu.Unlock()
return m return m
@@ -378,7 +387,7 @@ func hasNonASCII(s string) bool {
// an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation). // an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation).
// For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII // For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII
// variants are also tried. // variants are also tried.
func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCandidate { func pathStrToFullPaths(baseDir, revision, platform, assetType, pathStr string) []pathCandidate {
fsPath := strings.ReplaceAll(pathStr, ")", "/") fsPath := strings.ReplaceAll(pathStr, ")", "/")
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") { if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
return nil return nil
@@ -402,7 +411,7 @@ func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCand
if strings.Contains(pathStr, ")ko)") { if strings.Contains(pathStr, ")ko)") {
entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true}) entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true})
} }
base := filepath.Join(baseDir, "assets", "revisions", revision) base := filepath.Join(baseDir, "assets", "revisions", revision, platform)
var out []pathCandidate var out []pathCandidate
seen := make(map[string]bool) seen := make(map[string]bool)
for _, e := range entries { for _, e := range entries {
@@ -434,34 +443,43 @@ func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, ca
return append(candidates, candidate) return append(candidates, candidate)
} }
func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string { func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string {
root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType) root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType)
rel, err := filepath.Rel(root, candidate.Path) rel, err := filepath.Rel(root, candidate.Path)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "" return ""
} }
return filepath.Join(baseDir, "assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName) return filepath.Join(baseDir, "assets", "revisions", targetRevision, platform, assetType, filepath.Dir(rel), targetBaseName)
} }
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks // objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision). // (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
// The original locale path is tried first (with MD5 validation); locale fallbacks are tried after // The original locale path is tried first (with MD5 validation); locale fallbacks are tried after
// (without MD5 validation, since the hash in list.bin refers to the original locale's content). // (without MD5 validation, since the hash in list.bin refers to the original locale's content).
//
// Two tiers are searched in order: the requested platform tree (e.g. revisions/0/ios/...) and then
// the un-split shared tree (revisions/0/...) which acts as a fallback for operators who deploy a
// single unified asset dump. Each tier carries its own list.bin md5 so corruption is still detected.
// Callers should try each path until one exists on disk. // Callers should try each path until one exists on disk.
func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) { func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
idx, ok := loadListBinIndex(baseDir, revision)
if !ok || idx == nil {
return nil, 0, false
}
entry, ok := idx[objectId]
if !ok || entry.Path == "" {
return nil, 0, false
}
paths := pathStrToFullPaths(baseDir, revision, assetType, entry.Path)
if len(paths) == 0 {
return nil, 0, false
}
seen := make(map[string]bool) seen := make(map[string]bool)
var firstSize int64
var anyHit bool
appendForPlatform := func(p, label string) {
idx, idxOk := loadListBinIndex(baseDir, revision, p)
if !idxOk || idx == nil {
return
}
entry, entryOk := idx[objectId]
if !entryOk || entry.Path == "" {
return
}
paths := pathStrToFullPaths(baseDir, revision, p, assetType, entry.Path)
if len(paths) == 0 {
return
}
tierStart := len(candidates)
for _, pc := range paths { for _, pc := range paths {
md5 := entry.MD5 md5 := entry.MD5
if pc.IsLocaleFallback { if pc.IsLocaleFallback {
@@ -470,28 +488,41 @@ func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string)
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: pc.Path, Path: pc.Path,
Revision: revision, Revision: revision,
Source: "list.bin", Source: "list.bin (" + label + ")",
ExpectedMD5: md5, ExpectedMD5: md5,
}) })
} }
infoIndex := loadInfoIndex(baseDir, revision) infoIndex := loadInfoIndex(baseDir, revision, p)
if len(infoIndex) > 0 { if len(infoIndex) > 0 {
for _, c := range candidates { tierCandidates := candidates[tierStart:]
alias, ok := infoIndex[filepath.Base(c.Path)] for _, c := range tierCandidates {
if !ok || alias.ToName == "" { alias, aliasOk := infoIndex[filepath.Base(c.Path)]
if !aliasOk || alias.ToName == "" {
continue continue
} }
alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName) alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName)
if alt == "" { if alt == "" {
continue continue
} }
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{ candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: alt, Path: alt,
Revision: alias.ToRevision, Revision: alias.ToRevision,
Source: "info.json redirect", Source: "info.json redirect (" + label + ")",
ExpectedMD5: alias.MD5, ExpectedMD5: alias.MD5,
}) })
} }
} }
return candidates, entry.Size, true if !anyHit {
firstSize = entry.Size
anyHit = true
}
}
appendForPlatform(platform, platform)
appendForPlatform("", "shared")
if !anyHit {
return nil, 0, false
}
return candidates, firstSize, true
} }
+59 -22
View File
@@ -2,14 +2,18 @@ package service
import ( import (
"context" "context"
"fmt"
"log" "log"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
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/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -19,35 +23,37 @@ type LoginBonusServiceServer struct {
pb.UnimplementedLoginBonusServiceServer pb.UnimplementedLoginBonusServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.LoginBonusCatalog holder *runtime.Holder
} }
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.LoginBonusCatalog) *LoginBonusServiceServer { func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LoginBonusServiceServer {
return &LoginBonusServiceServer{users: users, sessions: sessions, catalog: catalog} return &LoginBonusServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) { func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) {
log.Printf("[LoginBonusService] ReceiveStamp") log.Printf("[LoginBonusService] ReceiveStamp")
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
catalog := s.holder.Get().LoginBonus
s.users.UpdateUser(userId, func(user *store.UserState) { user, err := s.users.LoadUser(userId)
now := gametime.NowMillis() if err != nil {
nextStamp := user.LoginBonus.CurrentStampNumber + 1 return nil, fmt.Errorf("load user: %w", err)
reward, ok := s.catalog.LookupStampReward(
user.LoginBonus.LoginBonusId,
user.LoginBonus.CurrentPageNumber,
nextStamp,
)
if !ok {
log.Fatalf("[LoginBonusService] no reward found for bonusId=%d page=%d stamp=%d",
user.LoginBonus.LoginBonusId, user.LoginBonus.CurrentPageNumber, nextStamp)
} }
log.Printf("[LoginBonusService] stamp %d -> possType=%d possId=%d count=%d (-> gift box)", nextPage, nextStamp, reward, err := resolveNextStamp(catalog, user.LoginBonus)
nextStamp, reward.PossessionType, reward.PossessionId, reward.Count) if err != nil {
return nil, err
}
user.Gifts.NotReceived = append(user.Gifts.NotReceived, store.NotReceivedGiftState{ log.Printf("[LoginBonusService] bonusId=%d page %d->%d stamp %d->%d possType=%d possId=%d count=%d (-> gift box)",
user.LoginBonus.LoginBonusId,
user.LoginBonus.CurrentPageNumber, nextPage,
user.LoginBonus.CurrentStampNumber, nextStamp,
reward.PossessionType, reward.PossessionId, reward.Count)
s.users.UpdateUser(userId, func(u *store.UserState) {
now := gametime.NowMillis()
u.Gifts.NotReceived = append(u.Gifts.NotReceived, store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{ GiftCommon: store.GiftCommonState{
PossessionType: reward.PossessionType, PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId, PossessionId: reward.PossessionId,
@@ -57,11 +63,42 @@ func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb
ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond), ExpirationDatetime: now + int64(30*24*time.Hour/time.Millisecond),
UserGiftUuid: uuid.New().String(), UserGiftUuid: uuid.New().String(),
}) })
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived)) u.Notifications.GiftNotReceiveCount = int32(len(u.Gifts.NotReceived))
user.LoginBonus.CurrentStampNumber = nextStamp u.LoginBonus.CurrentPageNumber = nextPage
user.LoginBonus.LatestRewardReceiveDatetime = now u.LoginBonus.CurrentStampNumber = nextStamp
user.LoginBonus.LatestVersion = now u.LoginBonus.LatestRewardReceiveDatetime = now
u.LoginBonus.LatestVersion = now
}) })
return &pb.ReceiveStampResponse{}, nil return &pb.ReceiveStampResponse{}, nil
} }
func resolveNextStamp(catalog *masterdata.LoginBonusCatalog, lb store.UserLoginBonusState) (nextPage, nextStamp int32, reward masterdata.LoginBonusReward, err error) {
bonusId := lb.LoginBonusId
curPage := lb.CurrentPageNumber
curStamp := lb.CurrentStampNumber
nextPage = curPage
nextStamp = curStamp + 1
var ok bool
reward, ok = catalog.LookupStampReward(bonusId, nextPage, nextStamp)
if !ok {
nextPage = curPage + 1
nextStamp = 1
total := catalog.TotalPageCount(bonusId)
if total > 0 && nextPage > total {
err = status.Errorf(codes.FailedPrecondition,
"login bonus %d exhausted (page %d stamp %d is the last)",
bonusId, curPage, curStamp)
return
}
reward, ok = catalog.LookupStampReward(bonusId, nextPage, nextStamp)
if !ok {
err = status.Errorf(codes.FailedPrecondition,
"no reward found for login bonus %d page %d stamp %d",
bonusId, nextPage, nextStamp)
return
}
}
return
}
+18 -7
View File
@@ -6,7 +6,8 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -14,23 +15,25 @@ type MaterialServiceServer struct {
pb.UnimplementedMaterialServiceServer pb.UnimplementedMaterialServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.MaterialCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.MaterialCatalog, config *masterdata.GameConfig) *MaterialServiceServer { func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *MaterialServiceServer {
return &MaterialServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &MaterialServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) { func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) {
log.Printf("[MaterialService] Sell: %d item(s)", len(req.MaterialPossession)) log.Printf("[MaterialService] Sell: %d item(s)", len(req.MaterialPossession))
cat := s.holder.Get()
catalog := cat.Material
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0) totalGold := int32(0)
for _, item := range req.MaterialPossession { for _, item := range req.MaterialPossession {
mat, ok := s.catalog.All[item.MaterialId] mat, ok := catalog.All[item.MaterialId]
if !ok { if !ok {
log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId) log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId)
continue continue
@@ -50,10 +53,18 @@ func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRe
gold := mat.SellPrice * item.Count gold := mat.SellPrice * item.Count
totalGold += gold totalGold += gold
log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold) log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold)
if mat.MaterialSaleObtainPossessionId != 0 {
for _, row := range catalog.SaleObtain[mat.MaterialSaleObtainPossessionId] {
grantCount := row.Count * item.Count
store.GrantPossession(user, model.PossessionType(row.PossessionType), row.PossessionId, grantCount)
log.Printf("[MaterialService] Sell: materialId=%d x%d -> SaleObtain type=%d id=%d +%d", item.MaterialId, item.Count, row.PossessionType, row.PossessionId, grantCount)
}
}
} }
if totalGold > 0 { if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[MaterialService] Sell: total gold +%d", totalGold) log.Printf("[MaterialService] Sell: total gold +%d", totalGold)
} }
}) })
+72 -39
View File
@@ -44,6 +44,30 @@ const informationPage = `<!DOCTYPE html>
</body> </body>
</html>` </html>`
const panelMissionPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel Missions</title>
<style>
body { margin:0; padding:48px 20px; font-family:"Noto Sans",sans-serif;
background:#0a0a0f; color:#d4cfc6; text-align:center; }
h1 { font-size:1.3em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:6px; }
.sub { font-size:.75em; color:#888; margin-bottom:28px; }
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
p { font-size:.85em; line-height:1.6; color:#999; max-width:340px; margin:0 auto; }
</style>
</head>
<body>
<h1>PANEL MISSIONS</h1>
<div class="sub">Card Stories</div>
<hr class="sep">
<p>All panel missions are cleared.</p>
<p>Their Card Stories are available in Library &rsaquo; Extra Stories.</p>
</body>
</html>`
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting. // resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com" const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
@@ -133,7 +157,9 @@ func NewOctoHTTPServer(resourcesBaseURL, baseDir string) *OctoHTTPServer {
revisions: newRevisionTracker(), revisions: newRevisionTracker(),
resolver: newAssetResolver(baseDir), resolver: newAssetResolver(baseDir),
} }
s.resolver.Prewarm("0") s.resolver.Prewarm("0", platformAndroid)
s.resolver.Prewarm("0", platformIOS)
s.resolver.Prewarm("0", "")
s.mux.HandleFunc("/", s.handleAll) s.mux.HandleFunc("/", s.handleAll)
return s return s
} }
@@ -142,12 +168,24 @@ func (s *OctoHTTPServer) Handler() http.Handler {
return s.mux return s.mux
} }
// listBinPath prefers the platform-split list.bin and falls back to the un-split shared tree
// when the platform-specific file is missing, so operators with a single unified asset dump
// keep working.
func (s *OctoHTTPServer) listBinPath(revision, platform string) string {
p := filepath.Join(s.BaseDir, "assets", "revisions", revision, platform, "list.bin")
if _, err := os.Stat(p); err == nil {
return p
}
return filepath.Join(s.BaseDir, "assets", "revisions", revision, "list.bin")
}
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) { func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
platform := platformFromUserAgent(r)
isAssetRequest := strings.Contains(path, "/unso-") isAssetRequest := strings.Contains(path, "/unso-")
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin") isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
if !isAssetRequest && !isMasterDataRequest { if !isAssetRequest && !isMasterDataRequest {
log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host) log.Printf("[HTTP] %s %s (Host: %s, platform: %s)", r.Method, r.URL.String(), r.Host, platform)
for k, v := range r.Header { for k, v := range r.Header {
log.Printf("[HTTP] %s: %s", k, v) log.Printf("[HTTP] %s: %s", k, v)
} }
@@ -155,13 +193,13 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
// Octo v2 API — asset bundle management // Octo v2 API — asset bundle management
if strings.HasPrefix(path, "/v2/") { if strings.HasPrefix(path, "/v2/") {
s.handleOctoV2(w, r, path) s.handleOctoV2(w, r, path, platform)
return return
} }
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision // Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
if strings.HasPrefix(path, "/v1/list/") { if strings.HasPrefix(path, "/v1/list/") {
s.serveOctoV1List(w, r, path) s.serveOctoV1List(w, r, path, platform)
return return
} }
@@ -188,7 +226,7 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media) // Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
if strings.Contains(path, "/unso-") { if strings.Contains(path, "/unso-") {
s.serveUnsoAsset(w, r, path) s.serveUnsoAsset(w, r, path, platform)
return return
} }
@@ -218,8 +256,8 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
w.Write([]byte{}) w.Write([]byte{})
} }
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) { func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) {
log.Printf("[OctoV2] %s %s", r.Method, path) log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform)
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing // /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
if strings.Contains(path, "/list/") { if strings.Contains(path, "/list/") {
@@ -228,13 +266,13 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa
requestedRevision := parts[len(parts)-1] requestedRevision := parts[len(parts)-1]
if requestedRevision != "" { if requestedRevision != "" {
revision := "0" revision := "0"
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") filePath := s.listBinPath(revision, platform)
if requestedRevision != revision { if requestedRevision != revision {
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision) log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
} }
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision) log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", filePath, requestedRevision, revision, platform)
s.revisions.Remember(r.RemoteAddr, revision) s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision) go s.resolver.Prewarm(revision, platform)
s.serveListBin(w, filePath) s.serveListBin(w, filePath)
return return
} }
@@ -259,8 +297,8 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa
w.WriteHeader(200) w.WriteHeader(200)
} }
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin. // serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/{platform}/list.bin.
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) { func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path, platform string) {
parts := strings.Split(strings.Trim(path, "/"), "/") parts := strings.Split(strings.Trim(path, "/"), "/")
// ["v1", "list", "300116832", "0"] -> revision = last segment // ["v1", "list", "300116832", "0"] -> revision = last segment
requestedRevision := "0" requestedRevision := "0"
@@ -268,18 +306,18 @@ func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request,
requestedRevision = parts[len(parts)-1] requestedRevision = parts[len(parts)-1]
} }
revision := "0" revision := "0"
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin") filePath := s.listBinPath(revision, platform)
if requestedRevision != revision { if requestedRevision != revision {
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision) log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
} }
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision) log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", r.Method, path, filePath, requestedRevision, revision, platform)
s.revisions.Remember(r.RemoteAddr, revision) s.revisions.Remember(r.RemoteAddr, revision)
go s.resolver.Prewarm(revision) go s.resolver.Prewarm(revision, platform)
s.serveListBin(w, filePath) s.serveListBin(w, filePath)
} }
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}. // serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) { func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path, platform string) {
parts := strings.Split(strings.Trim(path, "/"), "/") parts := strings.Split(strings.Trim(path, "/"), "/")
var segment, objectId string var segment, objectId string
for i, p := range parts { for i, p := range parts {
@@ -311,9 +349,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
return return
} }
activeRevision := s.revisions.Active(r.RemoteAddr) activeRevision := s.revisions.Active(r.RemoteAddr)
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision) resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision, platform)
if !ok { if !ok {
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision) log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s) no candidates", path, objectId, assetType, platform, activeRevision)
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
@@ -354,7 +392,7 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
} }
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) { if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5) md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source) log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s platform=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, platform, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
f.Close() f.Close()
continue continue
} }
@@ -366,9 +404,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
return return
} }
if len(md5Mismatches) > 0 { if len(md5Mismatches) > 0 {
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches) log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
} }
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths) log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@@ -400,24 +438,12 @@ func (s *OctoHTTPServer) serveListBin(w http.ResponseWriter, filePath string) {
w.Write(data) w.Write(data)
} }
// serveDatabaseBinE serves MasterMemory database: /assets/release/{version}/database.bin.e // serveDatabaseBinE serves the master data binary. The URL's {version} segment
// -> assets/release/{version}.bin.e (or assets/release/database.bin.e fallback). // is a cache key (it changes whenever the file's mtime changes, see
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, path string) { // DataService.GetLatestMasterDataVersion) but does not select a different file —
parts := strings.Split(path, "/") // there's only ever one bin.e on disk.
var version string func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, _ string) {
for i, p := range parts { filePath := filepath.Join(s.BaseDir, "assets", "release", "20240404193219.bin.e")
if p == "release" && i+1 < len(parts) {
version = parts[i+1]
break
}
}
filePath := filepath.Join(s.BaseDir, "assets", "release", "database.bin.e")
if version != "" {
vPath := filepath.Join(s.BaseDir, "assets", "release", version+".bin.e")
if _, err := os.Stat(vPath); err == nil {
filePath = vPath
}
}
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, filePath) http.ServeFile(w, r, filePath)
} }
@@ -454,6 +480,13 @@ func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, pa
return return
} }
if strings.Contains(path, "panelmission") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(panelMissionPage))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`)) w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
+5 -5
View File
@@ -7,7 +7,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/masterdata" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -15,11 +15,11 @@ type OmikujiServiceServer struct {
pb.UnimplementedOmikujiServiceServer pb.UnimplementedOmikujiServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.OmikujiCatalog holder *runtime.Holder
} }
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.OmikujiCatalog) *OmikujiServiceServer { func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *OmikujiServiceServer {
return &OmikujiServiceServer{users: users, sessions: sessions, catalog: catalog} return &OmikujiServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiDrawRequest) (*pb.OmikujiDrawResponse, error) { func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiDrawRequest) (*pb.OmikujiDrawResponse, error) {
@@ -36,7 +36,7 @@ func (s *OmikujiServiceServer) OmikujiDraw(ctx context.Context, req *pb.OmikujiD
} }
return &pb.OmikujiDrawResponse{ return &pb.OmikujiDrawResponse{
OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId), OmikujiResultAssetId: s.holder.Get().Omikuji.LookupAssetId(req.OmikujiId),
OmikujiItem: []*pb.OmikujiItem{}, OmikujiItem: []*pb.OmikujiItem{},
}, nil }, nil
} }
+168 -19
View File
@@ -9,6 +9,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/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -18,17 +19,63 @@ type PartsServiceServer struct {
pb.UnimplementedPartsServiceServer pb.UnimplementedPartsServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.PartsCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.PartsCatalog, config *masterdata.GameConfig) *PartsServiceServer { func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *PartsServiceServer {
return &PartsServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &PartsServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *PartsServiceServer) Protect(ctx context.Context, req *pb.PartsProtectRequest) (*pb.PartsProtectResponse, error) {
log.Printf("[PartsService] Protect: uuids=%v", req.UserPartsUuid)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Protect: part uuid=%s not found", uuid)
continue
}
part.IsProtected = true
part.LatestVersion = nowMillis
user.Parts[uuid] = part
}
})
return &pb.PartsProtectResponse{}, nil
}
func (s *PartsServiceServer) Unprotect(ctx context.Context, req *pb.PartsUnprotectRequest) (*pb.PartsUnprotectResponse, error) {
log.Printf("[PartsService] Unprotect: uuids=%v", req.UserPartsUuid)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Unprotect: part uuid=%s not found", uuid)
continue
}
part.IsProtected = false
part.LatestVersion = nowMillis
user.Parts[uuid] = part
}
})
return &pb.PartsUnprotectResponse{}, nil
} }
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) { func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid)) log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
cat := s.holder.Get()
catalog := cat.Parts
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
@@ -44,13 +91,13 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
continue continue
} }
partDef, ok := s.catalog.PartsById[part.PartsId] partDef, ok := catalog.PartsById[part.PartsId]
if !ok { if !ok {
log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId) log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId)
continue continue
} }
sellFunc, ok := s.catalog.SellPriceByRarity[partDef.RarityType] sellFunc, ok := catalog.SellPriceByRarity[partDef.RarityType]
if !ok { if !ok {
log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType) log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType)
continue continue
@@ -68,7 +115,7 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
} }
if totalGold > 0 { if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[PartsService] Sell: total gold +%d", totalGold) log.Printf("[PartsService] Sell: total gold +%d", totalGold)
} }
}) })
@@ -82,6 +129,9 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) { func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) {
log.Printf("[PartsService] Enhance: uuid=%s", req.UserPartsUuid) log.Printf("[PartsService] Enhance: uuid=%s", req.UserPartsUuid)
cat := s.holder.Get()
catalog := cat.Parts
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -99,33 +149,33 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
return return
} }
partDef, ok := s.catalog.PartsById[part.PartsId] partDef, ok := catalog.PartsById[part.PartsId]
if !ok { if !ok {
log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId) log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId)
return return
} }
rarity, ok := s.catalog.RarityByRarityType[partDef.RarityType] rarity, ok := catalog.RarityByRarityType[partDef.RarityType]
if !ok { if !ok {
log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType) log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType)
return return
} }
goldCost := int32(0) goldCost := int32(0)
if prices, ok := s.catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok { if prices, ok := catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
goldCost = prices[part.Level] goldCost = prices[part.Level]
} }
currentGold := user.ConsumableItems[s.config.ConsumableItemIdForGold] currentGold := user.ConsumableItems[config.ConsumableItemIdForGold]
if currentGold < goldCost { if currentGold < goldCost {
log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost) log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost)
return return
} }
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
successRate := int32(1000) successRate := int32(1000)
if rates, ok := s.catalog.RateByGroupAndLevel[rarity.PartsLevelUpRateGroupId]; ok { if rates, ok := catalog.RateByGroupAndLevel[rarity.PartsLevelUpRateGroupId]; ok {
if r, ok := rates[part.Level]; ok { if r, ok := rates[part.Level]; ok {
successRate = r successRate = r
} }
@@ -137,7 +187,7 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
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‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost) part.PartsId, part.Level-1, part.Level, successRate, goldCost)
s.grantSubStatuses(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‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost) part.PartsId, part.Level, successRate, goldCost)
@@ -155,9 +205,9 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
}, nil }, nil
} }
func (s *PartsServiceServer) grantSubStatuses(user *store.UserState, uuid string, part store.PartsState, partDef masterdata.EntityMParts, nowMillis int64) { func grantPartsSubStatuses(catalog *masterdata.PartsCatalog, user *store.UserState, uuid string, part store.PartsState, partDef masterdata.EntityMParts, nowMillis int64) {
unlockLevels := s.catalog.SubStatusUnlockLvls[partDef.RarityType] unlockLevels := catalog.SubStatusUnlockLvls[partDef.RarityType]
pool := s.catalog.SubStatusPool[partDef.PartsStatusSubLotteryGroupId] pool := catalog.SubStatusPool[partDef.PartsStatusSubLotteryGroupId]
if len(pool) == 0 { if len(pool) == 0 {
return return
} }
@@ -173,13 +223,13 @@ func (s *PartsServiceServer) grantSubStatuses(user *store.UserState, uuid string
} }
pick := pool[rand.Intn(len(pool))] pick := pool[rand.Intn(len(pool))]
def, ok := s.catalog.PartsStatusMainById[pick] def, ok := catalog.PartsStatusMainById[pick]
if !ok { if !ok {
continue continue
} }
statusValue := def.StatusChangeInitialValue statusValue := def.StatusChangeInitialValue
if f, ok := s.catalog.FuncResolver.Resolve(def.StatusNumericalFunctionId); ok { if f, ok := catalog.FuncResolver.Resolve(def.StatusNumericalFunctionId); ok {
statusValue = f.Evaluate(part.Level) statusValue = f.Evaluate(part.Level)
} }
@@ -220,3 +270,102 @@ func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsRep
return &pb.PartsReplacePresetResponse{}, nil return &pb.PartsReplacePresetResponse{}, nil
} }
func (s *PartsServiceServer) UpdatePresetName(ctx context.Context, req *pb.PartsUpdatePresetNameRequest) (*pb.PartsUpdatePresetNameResponse, error) {
log.Printf("[PartsService] UpdatePresetName: preset=%d name=%q", req.UserPartsPresetNumber, req.Name)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.Name = req.Name
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts update preset name: %w", err)
}
return &pb.PartsUpdatePresetNameResponse{}, nil
}
func (s *PartsServiceServer) UpdatePresetTagNumber(ctx context.Context, req *pb.PartsUpdatePresetTagNumberRequest) (*pb.PartsUpdatePresetTagNumberResponse, error) {
log.Printf("[PartsService] UpdatePresetTagNumber: preset=%d tag=%d", req.UserPartsPresetNumber, req.UserPartsPresetTagNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts update preset tag number: %w", err)
}
return &pb.PartsUpdatePresetTagNumberResponse{}, nil
}
func (s *PartsServiceServer) UpdatePresetTagName(ctx context.Context, req *pb.PartsUpdatePresetTagNameRequest) (*pb.PartsUpdatePresetTagNameResponse, error) {
log.Printf("[PartsService] UpdatePresetTagName: tag=%d name=%q", req.UserPartsPresetTagNumber, req.Name)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
tag := user.PartsPresetTags[req.UserPartsPresetTagNumber]
tag.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
tag.Name = req.Name
tag.LatestVersion = nowMillis
user.PartsPresetTags[req.UserPartsPresetTagNumber] = tag
})
if err != nil {
return nil, fmt.Errorf("parts update preset tag name: %w", err)
}
return &pb.PartsUpdatePresetTagNameResponse{}, nil
}
func (s *PartsServiceServer) CopyPreset(ctx context.Context, req *pb.PartsCopyPresetRequest) (*pb.PartsCopyPresetResponse, error) {
log.Printf("[PartsService] CopyPreset: from=%d to=%d", req.FromUserPartsPresetNumber, req.ToUserPartsPresetNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
from, ok := user.PartsPresets[req.FromUserPartsPresetNumber]
if !ok {
log.Printf("[PartsService] CopyPreset: source preset=%d not found, skipping", req.FromUserPartsPresetNumber)
return
}
to := from
to.UserPartsPresetNumber = req.ToUserPartsPresetNumber
to.LatestVersion = nowMillis
user.PartsPresets[req.ToUserPartsPresetNumber] = to
})
if err != nil {
return nil, fmt.Errorf("parts copy preset: %w", err)
}
return &pb.PartsCopyPresetResponse{}, nil
}
func (s *PartsServiceServer) RemovePreset(ctx context.Context, req *pb.PartsRemovePresetRequest) (*pb.PartsRemovePresetResponse, error) {
log.Printf("[PartsService] RemovePreset: preset=%d", req.UserPartsPresetNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
delete(user.PartsPresets, req.UserPartsPresetNumber)
})
if err != nil {
return nil, fmt.Errorf("parts remove preset: %w", err)
}
return &pb.PartsRemovePresetResponse{}, nil
}
+24
View File
@@ -0,0 +1,24 @@
package service
import (
"net/http"
"strings"
)
const (
platformAndroid = "android"
platformIOS = "ios"
)
// platformFromUserAgent classifies an HTTP request as iOS vs Android based on
// the User-Agent header. Unity's UnityWebRequest does not set a UA on iOS, so
// CFNetwork's default ("<bundle>/<build> CFNetwork/x Darwin/x") is what arrives;
// on Android Unity sets "UnityPlayer/... (UnityWebRequest/...)". Any other UA
// (or none) is treated as Android, matching model.DefaultPlatform.
func platformFromUserAgent(r *http.Request) string {
ua := r.Header.Get("User-Agent")
if strings.Contains(ua, "Darwin/") || strings.Contains(ua, "CFNetwork/") {
return platformIOS
}
return platformAndroid
}
+13
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/store" "lunar-tear/server/internal/store"
) )
@@ -27,6 +28,18 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = true user.PortalCageStatus.IsCurrentProgress = true
user.PortalCageStatus.LatestVersion = now user.PortalCageStatus.LatestVersion = now
// Mama's Room ends any active replay — flip flow type to MainFlow;
// ReplayFlow* stay sticky (matches original userdata snapshot).
if user.MainQuest.CurrentQuestFlowType != int32(model.QuestFlowTypeMainFlow) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
user.MainQuest.LatestVersion = now
}
// Returning to Mama's Room also ends any active side story.
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: now,
}
}
}) })
return &pb.UpdatePortalCageSceneProgressResponse{}, nil return &pb.UpdatePortalCageSceneProgressResponse{}, nil
} }
+187 -42
View File
@@ -8,7 +8,7 @@ import (
"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/model"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -18,34 +18,37 @@ type BigHuntServiceServer struct {
pb.UnimplementedBigHuntServiceServer pb.UnimplementedBigHuntServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.BigHuntCatalog holder *runtime.Holder
engine *questflow.QuestHandler
} }
func NewBigHuntServiceServer( func NewBigHuntServiceServer(
users store.UserRepository, users store.UserRepository,
sessions store.SessionRepository, sessions store.SessionRepository,
catalog *masterdata.BigHuntCatalog, holder *runtime.Holder,
engine *questflow.QuestHandler,
) *BigHuntServiceServer { ) *BigHuntServiceServer {
return &BigHuntServiceServer{users: users, sessions: sessions, catalog: catalog, engine: engine} return &BigHuntServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.StartBigHuntQuestRequest) (*pb.StartBigHuntQuestResponse, error) { func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.StartBigHuntQuestRequest) (*pb.StartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v", log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.UserDeckNumber, req.IsDryRun) req.BigHuntBossQuestId, req.BigHuntQuestId, req.UserDeckNumber, req.IsDryRun)
cat := s.holder.Get()
catalog := cat.BigHunt
engine := cat.QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
bhQuest, ok := s.catalog.QuestById[req.BigHuntQuestId] bhQuest, ok := catalog.QuestById[req.BigHuntQuestId]
if !ok { if !ok {
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId) log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
} }
today := gametime.StartOfDayMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
if ok { if ok {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis) engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
} }
user.BigHuntProgress = store.BigHuntProgress{ user.BigHuntProgress = store.BigHuntProgress{
@@ -59,6 +62,9 @@ func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.St
user.BigHuntDeckNumber = req.UserDeckNumber user.BigHuntDeckNumber = req.UserDeckNumber
st := user.BigHuntStatuses[req.BigHuntBossQuestId] st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount++ st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis st.LatestVersion = nowMillis
@@ -85,21 +91,27 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
log.Printf("[BigHuntService] FinishBigHuntQuest: bossQuestId=%d questId=%d isRetired=%v", log.Printf("[BigHuntService] FinishBigHuntQuest: bossQuestId=%d questId=%d isRetired=%v",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired) req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired)
cat := s.holder.Get()
catalog := cat.BigHunt
engine := cat.QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId] bhQuest := catalog.QuestById[req.BigHuntQuestId]
bossQuest := s.catalog.BossQuestById[req.BigHuntBossQuestId] bossQuest := catalog.BossQuestById[req.BigHuntBossQuestId]
boss := s.catalog.BossByBossId[bossQuest.BigHuntBossId] boss := catalog.BossByBossId[bossQuest.BigHuntBossId]
var scoreInfo *pb.BigHuntScoreInfo var scoreInfo *pb.BigHuntScoreInfo
var scoreRewards []*pb.BigHuntReward var scoreRewards []*pb.BigHuntReward
var battleReportWaves []*pb.BigHuntBattleReportWave
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis) engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis)
if req.IsRetired || user.BigHuntProgress.IsDryRun { if req.IsRetired || user.BigHuntProgress.IsDryRun {
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis} user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
user.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
return return
} }
@@ -108,7 +120,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
baseScore := totalDamage baseScore := totalDamage
difficultyBonusPermil := int32(0) difficultyBonusPermil := int32(0)
if coeff, ok := s.catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok { if coeff, ok := catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
difficultyBonusPermil = coeff difficultyBonusPermil = coeff
} }
@@ -125,11 +137,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000 userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
isHighScore := false if userScore > user.BigHuntMaxScores[bossQuest.BigHuntBossId].MaxScore {
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
oldMax := oldMaxBoss.MaxScore
if userScore > oldMax {
isHighScore = true
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{ user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
MaxScore: userScore, MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis, MaxScoreUpdateDatetime: nowMillis,
@@ -138,11 +146,12 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
} }
schedKey := store.BigHuntScheduleScoreKey{ schedKey := store.BigHuntScheduleScoreKey{
BigHuntScheduleId: s.catalog.ActiveScheduleId, BigHuntScheduleId: catalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId, BigHuntBossId: bossQuest.BigHuntBossId,
} }
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
if userScore > oldSchedMax { isHighScore := userScore > oldSchedMax
if isHighScore {
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{ user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
MaxScore: userScore, MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis, MaxScoreUpdateDatetime: nowMillis,
@@ -163,7 +172,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
} }
} }
assetGradeIconId := s.catalog.ResolveGradeIconId(bossQuest.BigHuntBossId, userScore) assetGradeIconId := catalog.ResolveGradeIconId(bossQuest.BigHuntBossId, userScore)
scoreInfo = &pb.BigHuntScoreInfo{ scoreInfo = &pb.BigHuntScoreInfo{
UserScore: userScore, UserScore: userScore,
@@ -177,12 +186,12 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
} }
if isHighScore { if isHighScore {
rewardGroupId := s.catalog.ResolveActiveScoreRewardGroupId( rewardGroupId := catalog.ResolveActiveScoreRewardGroupId(
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis) bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId > 0 { if rewardGroupId > 0 {
newItems := s.catalog.CollectNewRewards(rewardGroupId, oldMax, userScore) newItems := catalog.CollectNewRewards(rewardGroupId, oldSchedMax, userScore)
for _, item := range newItems { for _, item := range newItems {
s.engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis) engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
scoreRewards = append(scoreRewards, &pb.BigHuntReward{ scoreRewards = append(scoreRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType, PossessionType: item.PossessionType,
PossessionId: item.PossessionId, PossessionId: item.PossessionId,
@@ -192,6 +201,31 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
} }
} }
if len(detail.CostumeBattleInfo) > 0 {
wavesByIndex := map[int32]*pb.BigHuntBattleReportWave{}
var waveOrder []int32
for _, ci := range detail.CostumeBattleInfo {
wave, ok := wavesByIndex[ci.WaveIndex]
if !ok {
wave = &pb.BigHuntBattleReportWave{}
wavesByIndex[ci.WaveIndex] = wave
waveOrder = append(waveOrder, ci.WaveIndex)
}
wave.BattleReportCostume = append(wave.BattleReportCostume, &pb.BigHuntBattleReportCostume{
CostumeId: ci.CostumeId,
TotalDamage: ci.TotalDamage,
HitCount: ci.HitCount,
BattleReportRandomDisplay: &pb.BattleReportRandomDisplay{
RandomDisplayValueType: ci.RandomDisplayValueType,
RandomDisplayValue: ci.RandomDisplayValue,
},
})
}
for _, idx := range waveOrder {
battleReportWaves = append(battleReportWaves, wavesByIndex[idx])
}
}
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis} user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
user.BigHuntBattleBinary = nil user.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{} user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
@@ -204,33 +238,46 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
scoreRewards = []*pb.BigHuntReward{} scoreRewards = []*pb.BigHuntReward{}
} }
if battleReportWaves == nil {
battleReportWaves = []*pb.BigHuntBattleReportWave{}
}
battleReport := &pb.BigHuntBattleReport{
BattleReportWave: battleReportWaves,
}
return &pb.FinishBigHuntQuestResponse{ return &pb.FinishBigHuntQuestResponse{
ScoreInfo: scoreInfo, ScoreInfo: scoreInfo,
ScoreReward: scoreRewards, ScoreReward: scoreRewards,
BattleReport: &pb.BigHuntBattleReport{ BattleReport: battleReport,
BattleReportWave: []*pb.BigHuntBattleReportWave{},
},
}, nil }, nil
} }
func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.RestartBigHuntQuestRequest) (*pb.RestartBigHuntQuestResponse, error) { func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.RestartBigHuntQuestRequest) (*pb.RestartBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] RestartBigHuntQuest: bossQuestId=%d questId=%d", req.BigHuntBossQuestId, req.BigHuntQuestId) log.Printf("[BigHuntService] RestartBigHuntQuest: bossQuestId=%d questId=%d", req.BigHuntBossQuestId, req.BigHuntQuestId)
cat := s.holder.Get()
catalog := cat.BigHunt
engine := cat.QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId] bhQuest := catalog.QuestById[req.BigHuntQuestId]
var battleBinary []byte var battleBinary []byte
var deckNumber int32 var deckNumber int32
today := gametime.StartOfDayMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis) engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis)
user.BigHuntProgress.CurrentQuestSceneId = 0 user.BigHuntProgress.CurrentQuestSceneId = 0
user.BigHuntProgress.LatestVersion = nowMillis user.BigHuntProgress.LatestVersion = nowMillis
st := user.BigHuntStatuses[req.BigHuntBossQuestId] st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount++ st.DailyChallengeCount++
st.LatestChallengeDatetime = nowMillis st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis st.LatestVersion = nowMillis
@@ -249,19 +296,58 @@ func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.
func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) { func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) {
log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount) log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount)
cat := s.holder.Get()
catalog := cat.BigHunt
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
today := gametime.StartOfDayMillis()
bossQuest, hasBossQuest := catalog.BossQuestById[req.BigHuntBossQuestId]
var scoreRewards []*pb.BigHuntReward
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
st := user.BigHuntStatuses[req.BigHuntBossQuestId] st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount += req.SkipCount st.DailyChallengeCount += req.SkipCount
st.LatestChallengeDatetime = nowMillis st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis st.LatestVersion = nowMillis
user.BigHuntStatuses[req.BigHuntBossQuestId] = st user.BigHuntStatuses[req.BigHuntBossQuestId] = st
if !hasBossQuest || req.SkipCount <= 0 {
return
}
rewardGroupId := catalog.ResolveActiveScoreRewardGroupId(bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId == 0 {
return
}
maxScore := user.BigHuntScheduleMaxScores[store.BigHuntScheduleScoreKey{
BigHuntScheduleId: catalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId,
}].MaxScore
if maxScore <= 0 {
return
}
items := catalog.CollectNewRewards(rewardGroupId, 0, maxScore)
for n := int32(0); n < req.SkipCount; n++ {
for _, item := range items {
granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
Count: item.Count,
})
}
}
}) })
if scoreRewards == nil {
scoreRewards = []*pb.BigHuntReward{}
}
return &pb.SkipBigHuntQuestResponse{ return &pb.SkipBigHuntQuestResponse{
ScoreReward: []*pb.BigHuntReward{}, ScoreReward: scoreRewards,
}, nil }, nil
} }
@@ -284,12 +370,35 @@ func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *p
user.BigHuntBattleBinary = req.BattleBinary user.BigHuntBattleBinary = req.BattleBinary
if req.BigHuntBattleDetail != nil { if req.BigHuntBattleDetail != nil {
existingCostumes := user.BigHuntBattleDetail.CostumeBattleInfo
nextWaveIndex := int32(bigHuntWaveCount(existingCostumes))
newCostumes := make([]store.BigHuntCostumeBattleInfo, 0, len(req.BigHuntBattleDetail.CostumeBattleInfo))
for _, ci := range req.BigHuntBattleDetail.CostumeBattleInfo {
if ci == nil {
continue
}
var rdType int32
var rdValue int64
if rd := ci.BattleReportRandomDisplay; rd != nil {
rdType = rd.RandomDisplayValueType
rdValue = rd.RandomDisplayValue
}
newCostumes = append(newCostumes, store.BigHuntCostumeBattleInfo{
WaveIndex: nextWaveIndex,
CostumeId: resolveBigHuntCostumeId(user, ci.UserDeckNumber, ci.DeckCharacterNumber),
TotalDamage: ci.TotalDamage,
HitCount: ci.HitCount,
RandomDisplayValueType: rdType,
RandomDisplayValue: rdValue,
})
}
user.BigHuntBattleDetail = store.BigHuntBattleDetail{ user.BigHuntBattleDetail = store.BigHuntBattleDetail{
DeckType: req.BigHuntBattleDetail.DeckType, DeckType: req.BigHuntBattleDetail.DeckType,
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber, UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount, BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount, MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
TotalDamage: totalDamage, TotalDamage: totalDamage,
CostumeBattleInfo: append(existingCostumes, newCostumes...),
} }
} }
@@ -302,6 +411,7 @@ func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *p
func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb.Empty) (*pb.GetBigHuntTopDataResponse, error) { func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb.Empty) (*pb.GetBigHuntTopDataResponse, error) {
log.Printf("[BigHuntService] GetBigHuntTopData") log.Printf("[BigHuntService] GetBigHuntTopData")
catalog := s.holder.Get().BigHunt
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
user, _ := s.users.LoadUser(userId) user, _ := s.users.LoadUser(userId)
@@ -309,13 +419,13 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
weeklyVersion := gametime.WeeklyVersion(nowMillis) weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult var weeklyScoreResults []*pb.WeeklyScoreResult
for _, boss := range s.catalog.BossByBossId { for _, boss := range catalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{ key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion, BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType, AttributeType: boss.AttributeType,
} }
ws := user.BigHuntWeeklyMaxScores[key] ws := user.BigHuntWeeklyMaxScores[key]
gradeIconId := s.catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore) gradeIconId := catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{ weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType, AttributeType: boss.AttributeType,
@@ -330,10 +440,10 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
ws := user.BigHuntWeeklyStatuses[weeklyVersion] ws := user.BigHuntWeeklyStatuses[weeklyVersion]
weeklyRewards := s.resolveWeeklyRewards(user, weeklyVersion, nowMillis) weeklyRewards := resolveBigHuntWeeklyRewards(catalog, user, weeklyVersion, nowMillis)
lastWeekVersion := weeklyVersion - 7*24*60*60*1000 lastWeekVersion := weeklyVersion - 7*24*60*60*1000
lastWeekRewards := s.resolveWeeklyRewards(user, lastWeekVersion, nowMillis) lastWeekRewards := resolveBigHuntWeeklyRewards(catalog, user, lastWeekVersion, nowMillis)
return &pb.GetBigHuntTopDataResponse{ return &pb.GetBigHuntTopDataResponse{
WeeklyScoreResult: weeklyScoreResults, WeeklyScoreResult: weeklyScoreResults,
@@ -343,14 +453,49 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
}, nil }, nil
} }
func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward { func bigHuntWaveCount(infos []store.BigHuntCostumeBattleInfo) int {
var rewards []*pb.BigHuntReward if len(infos) == 0 {
for _, boss := range s.catalog.BossByBossId { return 0
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
} }
rewardGroupId := s.catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis) return int(infos[len(infos)-1].WaveIndex) + 1
}
func resolveBigHuntCostumeId(user *store.UserState, userDeckNumber, deckCharacterNumber int32) int32 {
if userDeckNumber == 0 {
userDeckNumber = user.BigHuntDeckNumber
}
for _, dt := range []model.DeckType{model.DeckTypeBigHunt, model.DeckTypeQuest} {
deck, ok := user.Decks[store.DeckKey{DeckType: dt, UserDeckNumber: userDeckNumber}]
if !ok {
continue
}
var dcUuid string
switch deckCharacterNumber {
case 1:
dcUuid = deck.UserDeckCharacterUuid01
case 2:
dcUuid = deck.UserDeckCharacterUuid02
case 3:
dcUuid = deck.UserDeckCharacterUuid03
}
if dcUuid == "" {
continue
}
dc, ok := user.DeckCharacters[dcUuid]
if !ok || dc.UserCostumeUuid == "" {
continue
}
if costume, ok := user.Costumes[dc.UserCostumeUuid]; ok {
return costume.CostumeId
}
}
return 0
}
func resolveBigHuntWeeklyRewards(catalog *masterdata.BigHuntCatalog, user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
var rewards []*pb.BigHuntReward
for _, boss := range catalog.BossByBossId {
rewardGroupId := catalog.ResolveActiveWeeklyRewardGroupIdByAttr(boss.AttributeType, nowMillis)
if rewardGroupId == 0 { if rewardGroupId == 0 {
continue continue
} }
@@ -359,7 +504,7 @@ func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weekly
AttributeType: boss.AttributeType, AttributeType: boss.AttributeType,
} }
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
for _, item := range s.catalog.CollectNewRewards(rewardGroupId, 0, maxScore) { for _, item := range catalog.CollectNewRewards(rewardGroupId, 0, maxScore) {
rewards = append(rewards, &pb.BigHuntReward{ rewards = append(rewards, &pb.BigHuntReward{
PossessionType: item.PossessionType, PossessionType: item.PossessionType,
PossessionId: item.PossessionId, PossessionId: item.PossessionId,
+9 -5
View File
@@ -15,13 +15,14 @@ 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", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly)
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) {
s.engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
}) })
drops := s.engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops)) pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops { for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{ pbDrops[i] = &pb.BattleDropReward{
@@ -40,10 +41,11 @@ func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.Finis
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", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
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
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.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)
}) })
return &pb.FinishEventQuestResponse{ return &pb.FinishEventQuestResponse{
@@ -61,9 +63,10 @@ func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.Finis
func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.RestartEventQuestRequest) (*pb.RestartEventQuestResponse, error) { func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.RestartEventQuestRequest) (*pb.RestartEventQuestResponse, error) {
log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId) log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestRestart(user, req.EventQuestChapterId, req.QuestId, gametime.NowMillis()) engine.HandleEventQuestRestart(user, req.EventQuestChapterId, req.QuestId, gametime.NowMillis())
}) })
return &pb.RestartEventQuestResponse{ return &pb.RestartEventQuestResponse{
@@ -74,9 +77,10 @@ func (s *QuestServiceServer) RestartEventQuest(ctx context.Context, req *pb.Rest
func (s *QuestServiceServer) UpdateEventQuestSceneProgress(ctx context.Context, req *pb.UpdateEventQuestSceneProgressRequest) (*pb.UpdateEventQuestSceneProgressResponse, error) { func (s *QuestServiceServer) UpdateEventQuestSceneProgress(ctx context.Context, req *pb.UpdateEventQuestSceneProgressRequest) (*pb.UpdateEventQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId) log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleEventQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis()) engine.HandleEventQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
}) })
return &pb.UpdateEventQuestSceneProgressResponse{}, nil return &pb.UpdateEventQuestSceneProgressResponse{}, nil
+10 -6
View File
@@ -13,13 +13,14 @@ import (
func (s *QuestServiceServer) StartExtraQuest(ctx context.Context, req *pb.StartExtraQuestRequest) (*pb.StartExtraQuestResponse, error) { func (s *QuestServiceServer) StartExtraQuest(ctx context.Context, req *pb.StartExtraQuestRequest) (*pb.StartExtraQuestResponse, error) {
log.Printf("[QuestService] StartExtraQuest: questId=%d deckNumber=%d", req.QuestId, req.UserDeckNumber) log.Printf("[QuestService] StartExtraQuest: questId=%d deckNumber=%d", req.QuestId, req.UserDeckNumber)
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) {
s.engine.HandleExtraQuestStart(user, req.QuestId, req.UserDeckNumber, nowMillis) engine.HandleExtraQuestStart(user, req.QuestId, req.UserDeckNumber, nowMillis)
}) })
drops := s.engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops)) pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops { for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{ pbDrops[i] = &pb.BattleDropReward{
@@ -38,10 +39,11 @@ func (s *QuestServiceServer) FinishExtraQuest(ctx context.Context, req *pb.Finis
log.Printf("[QuestService] FinishExtraQuest: questId=%d isRetired=%v isAnnihilated=%v", req.QuestId, req.IsRetired, req.IsAnnihilated) log.Printf("[QuestService] FinishExtraQuest: questId=%d isRetired=%v isAnnihilated=%v", req.QuestId, req.IsRetired, req.IsAnnihilated)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
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
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleExtraQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) outcome = engine.HandleExtraQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
}) })
return &pb.FinishExtraQuestResponse{ return &pb.FinishExtraQuestResponse{
@@ -58,14 +60,15 @@ func (s *QuestServiceServer) FinishExtraQuest(ctx context.Context, req *pb.Finis
func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.RestartExtraQuestRequest) (*pb.RestartExtraQuestResponse, error) { func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.RestartExtraQuestRequest) (*pb.RestartExtraQuestResponse, error) {
log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId) log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
var deckNumber int32 var deckNumber int32
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestRestart(user, req.QuestId, gametime.NowMillis()) engine.HandleExtraQuestRestart(user, req.QuestId, gametime.NowMillis())
deckNumber = user.Quests[req.QuestId].UserDeckNumber deckNumber = user.Quests[req.QuestId].UserDeckNumber
}) })
drops := s.engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops)) pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops { for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{ pbDrops[i] = &pb.BattleDropReward{
@@ -84,9 +87,10 @@ func (s *QuestServiceServer) RestartExtraQuest(ctx context.Context, req *pb.Rest
func (s *QuestServiceServer) UpdateExtraQuestSceneProgress(ctx context.Context, req *pb.UpdateExtraQuestSceneProgressRequest) (*pb.UpdateExtraQuestSceneProgressResponse, error) { func (s *QuestServiceServer) UpdateExtraQuestSceneProgress(ctx context.Context, req *pb.UpdateExtraQuestSceneProgressRequest) (*pb.UpdateExtraQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId) log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleExtraQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis()) engine.HandleExtraQuestSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
}) })
return &pb.UpdateExtraQuestSceneProgressResponse{}, nil return &pb.UpdateExtraQuestSceneProgressResponse{}, nil
+30 -16
View File
@@ -8,6 +8,7 @@ import (
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -17,22 +18,23 @@ type QuestServiceServer struct {
pb.UnimplementedQuestServiceServer pb.UnimplementedQuestServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
engine *questflow.QuestHandler holder *runtime.Holder
} }
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *QuestServiceServer { func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *QuestServiceServer {
if engine == nil { if holder == nil {
panic("quest handler is required") panic("runtime holder is required")
} }
return &QuestServiceServer{users: users, sessions: sessions, engine: engine} return &QuestServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, req *pb.UpdateMainFlowSceneProgressRequest) (*pb.UpdateMainFlowSceneProgressResponse, error) { func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, req *pb.UpdateMainFlowSceneProgressRequest) (*pb.UpdateMainFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId) log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis()) engine.HandleMainFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
}) })
return &pb.UpdateMainFlowSceneProgressResponse{}, nil return &pb.UpdateMainFlowSceneProgressResponse{}, nil
@@ -41,9 +43,10 @@ func (s *QuestServiceServer) UpdateMainFlowSceneProgress(ctx context.Context, re
func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context, req *pb.UpdateReplayFlowSceneProgressRequest) (*pb.UpdateReplayFlowSceneProgressResponse, error) { func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context, req *pb.UpdateReplayFlowSceneProgressRequest) (*pb.UpdateReplayFlowSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId) log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleReplayFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis()) engine.HandleReplayFlowSceneProgress(user, req.QuestSceneId, gametime.NowMillis())
}) })
return &pb.UpdateReplayFlowSceneProgressResponse{}, nil return &pb.UpdateReplayFlowSceneProgressResponse{}, nil
@@ -52,9 +55,10 @@ func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context,
func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) { func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId) log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainQuestSceneProgress(user, req.QuestSceneId) engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
}) })
return &pb.UpdateMainQuestSceneProgressResponse{}, nil return &pb.UpdateMainQuestSceneProgressResponse{}, nil
@@ -63,17 +67,18 @@ 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: %+v", req)
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) {
if req.IsReplayFlow { if req.IsReplayFlow {
s.engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) engine.HandleQuestStartReplay(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis)
} else { } else {
s.engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.IsMainFlow, req.UserDeckNumber, nowMillis)
} }
}) })
drops := s.engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops)) pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops { for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{ pbDrops[i] = &pb.BattleDropReward{
@@ -108,10 +113,11 @@ func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.Finish
req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType) req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
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
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
outcome = s.engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) outcome = engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis)
}) })
return &pb.FinishMainQuestResponse{ return &pb.FinishMainQuestResponse{
@@ -130,14 +136,15 @@ func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.Finish
func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.RestartMainQuestRequest) (*pb.RestartMainQuestResponse, error) { func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.RestartMainQuestRequest) (*pb.RestartMainQuestResponse, error) {
log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow) log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
var deckNumber int32 var deckNumber int32
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleQuestRestart(user, req.QuestId, gametime.NowMillis()) engine.HandleQuestRestart(user, req.QuestId, gametime.NowMillis())
deckNumber = user.Quests[req.QuestId].UserDeckNumber deckNumber = user.Quests[req.QuestId].UserDeckNumber
}) })
drops := s.engine.BattleDropRewards(req.QuestId) drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops)) pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops { for i, d := range drops {
pbDrops[i] = &pb.BattleDropReward{ pbDrops[i] = &pb.BattleDropReward{
@@ -162,6 +169,7 @@ func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestReq
log.Printf("[QuestService] SkipQuest: questId=%d skipCount=%d useEffectItems=%d", req.QuestId, req.SkipCount, len(req.UseEffectItem)) log.Printf("[QuestService] SkipQuest: questId=%d skipCount=%d useEffectItems=%d", req.QuestId, req.SkipCount, len(req.UseEffectItem))
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
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
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
@@ -172,7 +180,7 @@ func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestReq
user.ConsumableItems[item.ConsumableItemId] = 0 user.ConsumableItems[item.ConsumableItemId] = 0
} }
} }
outcome = s.engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis) outcome = engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
}) })
return &pb.SkipQuestResponse{ return &pb.SkipQuestResponse{
@@ -184,15 +192,21 @@ func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestReq
func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteRequest) (*pb.SetRouteResponse, error) { func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteRequest) (*pb.SetRouteResponse, error) {
log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId) log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := s.engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
} }
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = now user.PortalCageStatus.LatestVersion = now
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: now,
}
}
}) })
return &pb.SetRouteResponse{}, nil return &pb.SetRouteResponse{}, nil
+81 -28
View File
@@ -8,6 +8,7 @@ import (
"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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -15,11 +16,68 @@ type SideStoryQuestServiceServer struct {
pb.UnimplementedSideStoryQuestServiceServer pb.UnimplementedSideStoryQuestServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.SideStoryCatalog holder *runtime.Holder
} }
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.SideStoryCatalog) *SideStoryQuestServiceServer { func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *SideStoryQuestServiceServer {
return &SideStoryQuestServiceServer{users: users, sessions: sessions, catalog: catalog} return &SideStoryQuestServiceServer{users: users, sessions: sessions, holder: holder}
}
func sideStoryClearedCount(info *masterdata.SideStoryQuestInfo, user *store.UserState) int {
cleared := 0
for _, questId := range info.Quests {
if user.QuestLimitContentStatus[questId].LimitContentQuestStatusType == 1 {
cleared++
}
}
return cleared
}
func sideStoryQuestCleared(info *masterdata.SideStoryQuestInfo, user *store.UserState) bool {
return info != nil && len(info.Quests) > 0 && sideStoryClearedCount(info, user) == len(info.Quests)
}
func sideStoryNextSceneAfterBattle(info *masterdata.SideStoryQuestInfo, user *store.UserState) (int32, bool) {
cleared := sideStoryClearedCount(info, user)
if cleared == 0 {
return 0, false
}
total := len(info.Quests)
var sceneType model.SideStorySceneIdType
switch {
case cleared >= total:
sceneType = model.SideStorySceneOutroduction
case cleared == total-1:
sceneType = model.SideStorySceneUnlockLastQuest
default:
sceneType = model.SideStoryScenePlayLastQuest
}
return info.SceneIdByType(sceneType)
}
func applySideStoryProgressState(progress *store.SideStoryQuestProgress, info *masterdata.SideStoryQuestInfo, user *store.UserState) {
if sideStoryQuestCleared(info, user) {
progress.SideStoryQuestStateType = model.SideStoryQuestStateCleared
} else if progress.SideStoryQuestStateType == model.SideStoryQuestStateUnknown {
progress.SideStoryQuestStateType = model.SideStoryQuestStateActive
}
}
func setSideStoryActive(user *store.UserState, questId, sceneId int32, nowMillis int64) {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
CurrentSideStoryQuestId: questId,
CurrentSideStoryQuestSceneId: sceneId,
LatestVersion: nowMillis,
}
}
func setSideStoryScene(user *store.UserState, info *masterdata.SideStoryQuestInfo, questId, sceneId int32, nowMillis int64) {
progress := user.SideStoryQuests[questId]
progress.HeadSideStoryQuestSceneId = sceneId
applySideStoryProgressState(&progress, info, user)
progress.LatestVersion = nowMillis
user.SideStoryQuests[questId] = progress
setSideStoryActive(user, questId, sceneId, nowMillis)
} }
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) { func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
@@ -27,29 +85,30 @@ func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Con
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
firstSceneId := s.catalog.FirstSceneByQuestId[req.SideStoryQuestId] info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
if info == nil || len(info.Quests) == 0 {
log.Printf("[SideStoryQuestService] unknown sideStoryQuestId=%d, skipping", req.SideStoryQuestId)
return
}
existing, exists := user.SideStoryQuests[req.SideStoryQuestId] existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
var sceneId int32 var scene int32
if exists && existing.HeadSideStoryQuestSceneId > 0 { var ok bool
sceneId = existing.HeadSideStoryQuestSceneId if !exists || existing.HeadSideStoryQuestSceneId == 0 {
scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction)
} else { } else {
sceneId = firstSceneId scene, ok = sideStoryNextSceneAfterBattle(info, user)
} if !ok {
scene, ok = existing.HeadSideStoryQuestSceneId, true
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
if !exists {
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: firstSceneId,
SideStoryQuestStateType: model.SideStoryQuestStateActive,
LatestVersion: nowMillis,
} }
} }
if !ok {
return
}
setSideStoryScene(user, info, req.SideStoryQuestId, scene, nowMillis)
}) })
return &pb.MoveSideStoryQuestResponse{}, nil return &pb.MoveSideStoryQuestResponse{}, nil
@@ -61,16 +120,10 @@ func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx cont
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) { info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
progress := user.SideStoryQuests[req.SideStoryQuestId] s.users.UpdateUser(userId, func(user *store.UserState) {
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId { setSideStoryScene(user, info, req.SideStoryQuestId, req.SideStoryQuestSceneId, nowMillis)
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
}
progress.LatestVersion = nowMillis
user.SideStoryQuests[req.SideStoryQuestId] = progress
}) })
return &pb.UpdateSideStoryQuestSceneProgressResponse{}, nil return &pb.UpdateSideStoryQuestSceneProgressResponse{}, nil
+49
View File
@@ -0,0 +1,49 @@
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/store"
)
func (s *QuestServiceServer) ReceiveTowerAccumulationReward(ctx context.Context, req *pb.ReceiveTowerAccumulationRewardRequest) (*pb.ReceiveTowerAccumulationRewardResponse, error) {
log.Printf("[QuestService] ReceiveTowerAccumulationReward: eventQuestChapterId=%d targetMissionClearCount=%d",
req.EventQuestChapterId, req.TargetMissionClearCount)
cat := s.holder.Get()
tower := cat.Tower
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.TowerAccumulationRewards[req.EventQuestChapterId]
old := rec.LatestRewardReceiveQuestMissionClearCount
items, highest := tower.CollectRewards(req.EventQuestChapterId, old, req.TargetMissionClearCount)
if highest <= old {
log.Printf("[QuestService] ReceiveTowerAccumulationReward: nothing to grant for chapter=%d (claimed=%d, target=%d)",
req.EventQuestChapterId, old, req.TargetMissionClearCount)
return
}
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.LatestRewardReceiveQuestMissionClearCount = highest
rec.LatestVersion = nowMillis
user.TowerAccumulationRewards[req.EventQuestChapterId] = rec
log.Printf("[QuestService] ReceiveTowerAccumulationReward: chapter=%d granted %d item(s), claimed %d -> %d",
req.EventQuestChapterId, len(items), old, highest)
})
return &pb.ReceiveTowerAccumulationRewardResponse{}, nil
}
+43 -16
View File
@@ -6,8 +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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb" emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -17,41 +17,72 @@ type RewardServiceServer struct {
pb.UnimplementedRewardServiceServer pb.UnimplementedRewardServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
bhCatalog *masterdata.BigHuntCatalog holder *runtime.Holder
granter *store.PossessionGranter
} }
func NewRewardServiceServer( func NewRewardServiceServer(
users store.UserRepository, users store.UserRepository,
sessions store.SessionRepository, sessions store.SessionRepository,
bhCatalog *masterdata.BigHuntCatalog, holder *runtime.Holder,
granter *store.PossessionGranter,
) *RewardServiceServer { ) *RewardServiceServer {
return &RewardServiceServer{users: users, sessions: sessions, bhCatalog: bhCatalog, granter: granter} return &RewardServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveBigHuntRewardResponse, error) { func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *emptypb.Empty) (*pb.ReceiveBigHuntRewardResponse, error) {
log.Printf("[RewardService] ReceiveBigHuntReward") log.Printf("[RewardService] ReceiveBigHuntReward")
cat := s.holder.Get()
bhCatalog := cat.BigHunt
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis) weeklyVersion := gametime.WeeklyVersion(nowMillis)
today := gametime.StartOfDayMillis()
var weeklyScoreResults []*pb.WeeklyScoreResult var weeklyScoreResults []*pb.WeeklyScoreResult
var weeklyRewards []*pb.BigHuntReward var weeklyRewards []*pb.BigHuntReward
isReceived := false isReceived := false
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
for bossQuestId, bossQuest := range bhCatalog.BossQuestById {
st := user.BigHuntStatuses[bossQuestId]
if st.LastDailyRewardReceivedDayVersion >= today {
continue
}
rewardGroupId := bhCatalog.ResolveActiveScoreRewardGroupId(bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId == 0 {
continue
}
maxScore := user.BigHuntScheduleMaxScores[store.BigHuntScheduleScoreKey{
BigHuntScheduleId: bhCatalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId,
}].MaxScore
if maxScore <= 0 {
continue
}
items := bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
for _, item := range items {
granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
}
if len(items) > 0 {
log.Printf("[RewardService] ReceiveBigHuntReward: bossQuestId=%d granted %d daily rewards (maxScore=%d, group=%d)",
bossQuestId, len(items), maxScore, rewardGroupId)
}
st.LastDailyRewardReceivedDayVersion = today
st.LatestVersion = nowMillis
user.BigHuntStatuses[bossQuestId] = st
}
ws := user.BigHuntWeeklyStatuses[weeklyVersion] ws := user.BigHuntWeeklyStatuses[weeklyVersion]
isReceived = ws.IsReceivedWeeklyReward isReceived = ws.IsReceivedWeeklyReward
for _, boss := range s.bhCatalog.BossByBossId { for _, boss := range bhCatalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{ key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion, BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType, AttributeType: boss.AttributeType,
} }
wms := user.BigHuntWeeklyMaxScores[key] wms := user.BigHuntWeeklyMaxScores[key]
gradeIcon := s.bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore) gradeIcon := bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{ weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType, AttributeType: boss.AttributeType,
BeforeMaxScore: wms.MaxScore, BeforeMaxScore: wms.MaxScore,
@@ -64,12 +95,8 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
} }
if !isReceived { if !isReceived {
for _, boss := range s.bhCatalog.BossByBossId { for _, boss := range bhCatalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{ rewardGroupId := bhCatalog.ResolveActiveWeeklyRewardGroupIdByAttr(boss.AttributeType, nowMillis)
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
if rewardGroupId == 0 { if rewardGroupId == 0 {
continue continue
} }
@@ -80,9 +107,9 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
} }
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
items := s.bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore) items := bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
for _, item := range items { for _, item := range items {
s.granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis) granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
weeklyRewards = append(weeklyRewards, &pb.BigHuntReward{ weeklyRewards = append(weeklyRewards, &pb.BigHuntReward{
PossessionType: item.PossessionType, PossessionType: item.PossessionType,
PossessionId: item.PossessionId, PossessionId: item.PossessionId,
+27 -32
View File
@@ -9,6 +9,7 @@ import (
"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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@@ -18,23 +19,25 @@ type ShopServiceServer struct {
pb.UnimplementedShopServiceServer pb.UnimplementedShopServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.ShopCatalog holder *runtime.Holder
granter *store.PossessionGranter
} }
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ShopCatalog, granter *store.PossessionGranter) *ShopServiceServer { func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ShopServiceServer {
return &ShopServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter} return &ShopServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.BuyResponse, error) { func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.BuyResponse, error) {
log.Printf("[ShopService] Buy: shopId=%d items=%v", req.ShopId, req.ShopItems) log.Printf("[ShopService] Buy: shopId=%d items=%v", req.ShopId, req.ShopItems)
cat := s.holder.Get()
catalog := cat.Shop
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for shopItemId, qty := range req.ShopItems { for shopItemId, qty := range req.ShopItems {
item, ok := s.catalog.Items[shopItemId] item, ok := catalog.Items[shopItemId]
if !ok { if !ok {
log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId) log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId)
continue continue
@@ -46,8 +49,8 @@ func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.Bu
continue continue
} }
for _, content := range s.catalog.Contents[shopItemId] { for _, content := range catalog.Contents[shopItemId] {
s.granter.GrantFull(user, granter.GrantFull(user,
model.PossessionType(content.PossessionType), model.PossessionType(content.PossessionType),
content.PossessionId, content.PossessionId,
content.Count*qty, content.Count*qty,
@@ -55,7 +58,7 @@ func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.Bu
) )
} }
s.applyContentEffects(user, shopItemId, qty, nowMillis) applyShopContentEffects(catalog, user, shopItemId, qty, nowMillis)
si := user.ShopItems[shopItemId] si := user.ShopItems[shopItemId]
si.ShopItemId = shopItemId si.ShopItemId = shopItemId
@@ -76,12 +79,13 @@ func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.Bu
func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) { func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed) log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed)
catalog := s.holder.Get().Shop
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if len(user.ShopReplaceableLineup) == 0 && len(s.catalog.ItemShopPool) > 0 { if len(user.ShopReplaceableLineup) == 0 && len(catalog.ItemShopPool) > 0 {
for i, itemId := range s.catalog.ItemShopPool { for i, itemId := range catalog.ItemShopPool {
slot := int32(i + 1) slot := int32(i + 1)
user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{ user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{
SlotNumber: slot, SlotNumber: slot,
@@ -93,7 +97,7 @@ func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.Refresh
if req.IsGemUsed { if req.IsGemUsed {
user.ShopReplaceable.LineupUpdateCount++ user.ShopReplaceable.LineupUpdateCount++
user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis
for _, itemId := range s.catalog.ItemShopPool { for _, itemId := range catalog.ItemShopPool {
if si, ok := user.ShopItems[itemId]; ok { if si, ok := user.ShopItems[itemId]; ok {
si.BoughtCount = 0 si.BoughtCount = 0
si.LatestVersion = nowMillis si.LatestVersion = nowMillis
@@ -120,11 +124,14 @@ func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *
log.Printf("[ShopService] CreatePurchaseTransaction: shopId=%d shopItemId=%d productId=%s", log.Printf("[ShopService] CreatePurchaseTransaction: shopId=%d shopItemId=%d productId=%s",
req.ShopId, req.ShopItemId, req.ProductId) req.ShopId, req.ShopItemId, req.ProductId)
cat := s.holder.Get()
catalog := cat.Shop
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
item, ok := s.catalog.Items[req.ShopItemId] item, ok := catalog.Items[req.ShopItemId]
if !ok { if !ok {
log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId) log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId)
return return
@@ -134,8 +141,8 @@ func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *
log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err) log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err)
} }
for _, content := range s.catalog.Contents[req.ShopItemId] { for _, content := range catalog.Contents[req.ShopItemId] {
s.granter.GrantFull(user, granter.GrantFull(user,
model.PossessionType(content.PossessionType), model.PossessionType(content.PossessionType),
content.PossessionId, content.PossessionId,
content.Count, content.Count,
@@ -143,13 +150,13 @@ func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *
) )
} }
s.applyContentEffects(user, req.ShopItemId, 1, nowMillis) applyShopContentEffects(catalog, user, req.ShopItemId, 1, nowMillis)
si := user.ShopItems[req.ShopItemId] si := user.ShopItems[req.ShopItemId]
si.ShopItemId = req.ShopItemId si.ShopItemId = req.ShopItemId
si.BoughtCount++ si.BoughtCount++
if item.ShopItemLimitedStockId > 0 { if item.ShopItemLimitedStockId > 0 {
if maxCount, ok := s.catalog.LimitedStock[item.ShopItemLimitedStockId]; ok && si.BoughtCount >= maxCount { if maxCount, ok := catalog.LimitedStock[item.ShopItemLimitedStockId]; ok && si.BoughtCount >= maxCount {
si.BoughtCount = 0 si.BoughtCount = 0
} }
} }
@@ -182,27 +189,15 @@ func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context,
}, nil }, nil
} }
func (s *ShopServiceServer) applyContentEffects(user *store.UserState, shopItemId, qty int32, nowMillis int64) { func applyShopContentEffects(catalog *masterdata.ShopCatalog, user *store.UserState, shopItemId, qty int32, nowMillis int64) {
for _, effect := range s.catalog.Effects[shopItemId] { for _, effect := range catalog.Effects[shopItemId] {
switch effect.EffectTargetType { switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery: case model.EffectTargetStaminaRecovery:
maxMillis := s.catalog.MaxStaminaMillis[user.Status.Level] maxMillis := catalog.MaxStaminaMillis[user.Status.Level]
millis := s.resolveEffectMillis(effect.EffectValueType, effect.EffectValue, user.Status.Level) millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxMillis)
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis) store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
default: default:
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType) log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
} }
} }
} }
func (s *ShopServiceServer) resolveEffectMillis(effectValueType, effectValue, userLevel int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue
case model.EffectValuePermil:
maxMillis := s.catalog.MaxStaminaMillis[userLevel]
return effectValue * maxMillis / 1000
default:
return 0
}
}
+6 -4
View File
@@ -8,6 +8,7 @@ import (
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -15,17 +16,18 @@ type TutorialServiceServer struct {
pb.UnimplementedTutorialServiceServer pb.UnimplementedTutorialServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
engine *questflow.QuestHandler holder *runtime.Holder
} }
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *TutorialServiceServer { func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *TutorialServiceServer {
return &TutorialServiceServer{users: users, sessions: sessions, engine: engine} return &TutorialServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb.SetTutorialProgressRequest) (*pb.SetTutorialProgressResponse, error) { func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb.SetTutorialProgressRequest) (*pb.SetTutorialProgressResponse, error) {
log.Printf("[TutorialService] SetTutorialProgress: type=%d phase=%d choice=%d", req.TutorialType, req.ProgressPhase, req.ChoiceId) log.Printf("[TutorialService] SetTutorialProgress: type=%d phase=%d choice=%d", req.TutorialType, req.ProgressPhase, req.ChoiceId)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
var grants []questflow.RewardGrant var grants []questflow.RewardGrant
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.Tutorials[req.TutorialType] existing, exists := user.Tutorials[req.TutorialType]
@@ -36,7 +38,7 @@ func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb
ChoiceId: req.ChoiceId, ChoiceId: req.ChoiceId,
} }
} }
grants = s.engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis) grants = engine.ApplyTutorialReward(user, model.TutorialType(req.TutorialType), req.ChoiceId, nowMillis)
if req.TutorialType == int32(model.TutorialTypeMenuFirst) && req.ProgressPhase == 20 { if req.TutorialType == int32(model.TutorialTypeMenuFirst) && req.ProgressPhase == 20 {
store.EnsureDefaultDeck(user, nowMillis) store.EnsureDefaultDeck(user, nowMillis)
} }
+20 -3
View File
@@ -12,12 +12,14 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
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/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -25,17 +27,29 @@ type UserServiceServer struct {
pb.UnimplementedUserServiceServer pb.UnimplementedUserServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
holder *runtime.Holder
authURL string authURL string
noRegister bool
} }
func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository, authURL string) *UserServiceServer { func NewUserServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder, authURL string, noRegister bool) *UserServiceServer {
if authURL != "" && !strings.Contains(authURL, "://") { if authURL != "" && !strings.Contains(authURL, "://") {
authURL = "http://" + authURL authURL = "http://" + authURL
} }
return &UserServiceServer{users: users, sessions: sessions, authURL: authURL} return &UserServiceServer{users: users, sessions: sessions, holder: holder, authURL: authURL, noRegister: noRegister}
} }
func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) { func (s *UserServiceServer) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) {
if s.noRegister {
ip := "invalid"
if p, ok := peer.FromContext(ctx); ok {
ip = p.Addr.String()
}
return nil, fmt.Errorf("Denied user registration: ip=%s uuid=%s", ip, req.Uuid)
}
platform := model.ClientPlatformFromContext(ctx) platform := model.ClientPlatformFromContext(ctx)
userId, err := s.users.CreateUser(req.Uuid, platform) userId, err := s.users.CreateUser(req.Uuid, platform)
if err != nil { if err != nil {
@@ -89,11 +103,14 @@ func (s *UserServiceServer) GameStart(ctx context.Context, _ *emptypb.Empty) (*p
func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) { func (s *UserServiceServer) TransferUser(ctx context.Context, req *pb.TransferUserRequest) (*pb.TransferUserResponse, error) {
platform := model.ClientPlatformFromContext(ctx) platform := model.ClientPlatformFromContext(ctx)
log.Printf("[UserService] TransferUser: platform=%s", platform) log.Printf("[UserService] TransferUser: platform=%s", platform)
userId, err := s.users.CreateUser(req.Uuid, platform)
userId, err := s.users.GetUserByUUID(req.Uuid)
if err != nil { if err != nil {
return nil, fmt.Errorf("create user: %w", err) return nil, fmt.Errorf("create user: %w", err)
} }
return &pb.TransferUserResponse{ return &pb.TransferUserResponse{
UserId: userId, UserId: userId,
Signature: "transferred-sig", Signature: "transferred-sig",
+182 -65
View File
@@ -10,6 +10,7 @@ import (
"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"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -17,12 +18,11 @@ type WeaponServiceServer struct {
pb.UnimplementedWeaponServiceServer pb.UnimplementedWeaponServiceServer
users store.UserRepository users store.UserRepository
sessions store.SessionRepository sessions store.SessionRepository
catalog *masterdata.WeaponCatalog holder *runtime.Holder
config *masterdata.GameConfig
} }
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.WeaponCatalog, config *masterdata.GameConfig) *WeaponServiceServer { func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *WeaponServiceServer {
return &WeaponServiceServer{users: users, sessions: sessions, catalog: catalog, config: config} return &WeaponServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) { func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) {
@@ -72,6 +72,9 @@ func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRe
func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.EnhanceByMaterialRequest) (*pb.EnhanceByMaterialResponse, error) { func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.EnhanceByMaterialRequest) (*pb.EnhanceByMaterialResponse, error) {
log.Printf("[WeaponService] EnhanceByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials) log.Printf("[WeaponService] EnhanceByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -82,7 +85,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId)
return return
@@ -91,7 +94,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId] mat, ok := catalog.Materials[materialId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId) log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId)
continue continue
@@ -107,27 +110,45 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
expPerUnit := mat.EffectValue expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType { if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expPerUnit * count
} }
if costFunc, ok := s.catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount) goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount) log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
} }
weapon.Exp += totalExp weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId]
if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
cap := 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]
}
}
}
}
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLevel < weapon.Level {
note.WeaponId = weapon.WeaponId
note.MaxLevel = weapon.Level
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
} }
weapon.LatestVersion = nowMillis weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByMaterial: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level) log.Printf("[WeaponService] EnhanceByMaterial: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
s.checkWeaponStoryUnlocks(user, weapon.WeaponId, weapon.Level, nowMillis) checkWeaponStoryUnlocks(catalog, user, weapon.WeaponId, weapon.Level, nowMillis)
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("weapon enhance by material: %w", err) return nil, fmt.Errorf("weapon enhance by material: %w", err)
@@ -142,6 +163,9 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) { func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) {
log.Printf("[WeaponService] Sell: uuids=%v", req.UserWeaponUuid) log.Printf("[WeaponService] Sell: uuids=%v", req.UserWeaponUuid)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) { _, err := s.users.UpdateUser(userId, func(user *store.UserState) {
@@ -153,17 +177,17 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p
continue continue
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId) log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId)
continue continue
} }
if sellFunc, ok := s.catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if sellFunc, ok := catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
totalGold += sellFunc.Evaluate(weapon.Level) totalGold += sellFunc.Evaluate(weapon.Level)
} }
if medals, ok := s.catalog.MedalsByWeaponId[weapon.WeaponId]; ok { if medals, ok := catalog.MedalsByWeaponId[weapon.WeaponId]; ok {
for itemId, count := range medals { for itemId, count := range medals {
user.ConsumableItems[itemId] += count user.ConsumableItems[itemId] += count
} }
@@ -176,7 +200,7 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p
} }
if totalGold > 0 { if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[WeaponService] Sell: granted %d gold", totalGold) log.Printf("[WeaponService] Sell: granted %d gold", totalGold)
} }
}) })
@@ -190,6 +214,9 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p
func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) (*pb.EvolveResponse, error) { func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest) (*pb.EvolveResponse, error) {
log.Printf("[WeaponService] Evolve: uuid=%s", req.UserWeaponUuid) log.Printf("[WeaponService] Evolve: uuid=%s", req.UserWeaponUuid)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -200,20 +227,20 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId)
return return
} }
evolvedId, ok := s.catalog.EvolutionNextWeaponId[weapon.WeaponId] evolvedId, ok := catalog.EvolutionNextWeaponId[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId) log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId)
return return
} }
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
mats := s.catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId] mats := catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -225,19 +252,61 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
totalMaterialCount += cost totalMaterialCount += cost
} }
if costFunc, ok := s.catalog.EvolutionCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.EvolutionCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount) goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost) log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost)
} }
oldWeaponId := wm.WeaponId
weapon.WeaponId = evolvedId weapon.WeaponId = evolvedId
weapon.LatestVersion = nowMillis weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon user.Weapons[req.UserWeaponUuid] = weapon
evolvedMaster, ok := s.catalog.Weapons[evolvedId] note, hasNote := user.WeaponNotes[evolvedId]
if !hasNote {
note = store.WeaponNoteState{
WeaponId: evolvedId,
MaxLevel: weapon.Level,
MaxLimitBreakCount: weapon.LimitBreakCount,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
user.WeaponNotes[evolvedId] = note
} else {
changed := false
if note.MaxLevel < weapon.Level {
note.MaxLevel = weapon.Level
changed = true
}
if note.MaxLimitBreakCount < weapon.LimitBreakCount {
note.MaxLimitBreakCount = weapon.LimitBreakCount
changed = true
}
if changed {
note.WeaponId = evolvedId
note.LatestVersion = nowMillis
user.WeaponNotes[evolvedId] = note
}
}
if oldStory, hasOldStory := user.WeaponStories[oldWeaponId]; hasOldStory {
newStory, hasNewStory := user.WeaponStories[evolvedId]
if !hasNewStory || newStory.ReleasedMaxStoryIndex < oldStory.ReleasedMaxStoryIndex {
if user.WeaponStories == nil {
user.WeaponStories = make(map[int32]store.WeaponStoryState)
}
user.WeaponStories[evolvedId] = store.WeaponStoryState{
WeaponId: evolvedId,
ReleasedMaxStoryIndex: oldStory.ReleasedMaxStoryIndex,
LatestVersion: nowMillis,
}
}
}
evolvedMaster, ok := catalog.Weapons[evolvedId]
if ok { if ok {
if slots, ok := s.catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok { if slots, ok := catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
abilities := make([]store.WeaponAbilityState, len(slots)) abilities := make([]store.WeaponAbilityState, len(slots))
for i, slot := range slots { for i, slot := range slots {
abilities[i] = store.WeaponAbilityState{ abilities[i] = store.WeaponAbilityState{
@@ -250,9 +319,9 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
} }
} }
log.Printf("[WeaponService] Evolve: weaponId %d -> %d", wm.WeaponId, evolvedId) log.Printf("[WeaponService] Evolve: weaponId %d -> %d", oldWeaponId, evolvedId)
s.checkWeaponStoryUnlocks(user, evolvedId, weapon.Level, nowMillis) checkWeaponStoryUnlocks(catalog, user, evolvedId, weapon.Level, nowMillis)
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("weapon evolve: %w", err) return nil, fmt.Errorf("weapon evolve: %w", err)
@@ -264,6 +333,9 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceSkillRequest) (*pb.EnhanceSkillResponse, error) { func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceSkillRequest) (*pb.EnhanceSkillResponse, error) {
log.Printf("[WeaponService] EnhanceSkill: uuid=%s skillId=%d addLevel=%d", req.UserWeaponUuid, req.SkillId, req.AddLevelCount) log.Printf("[WeaponService] EnhanceSkill: uuid=%s skillId=%d addLevel=%d", req.UserWeaponUuid, req.SkillId, req.AddLevelCount)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -274,13 +346,13 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId)
return return
} }
groupRows := s.catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId] groupRows := catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
var skillGroup *masterdata.EntityMWeaponSkillGroup var skillGroup *masterdata.EntityMWeaponSkillGroup
for i := range groupRows { for i := range groupRows {
if groupRows[i].SkillId == req.SkillId { if groupRows[i].SkillId == req.SkillId {
@@ -306,7 +378,7 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
return return
} }
maxLevelFunc, ok := s.catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId] maxLevelFunc, ok := catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId) log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return return
@@ -326,7 +398,7 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ { for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl} key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.SkillEnhanceMats[key] mats := catalog.SkillEnhanceMats[key]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -337,9 +409,9 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
user.Materials[mat.MaterialId] = cur - cost user.Materials[mat.MaterialId] = cur - cost
} }
if costFunc, ok := s.catalog.SkillCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if costFunc, ok := catalog.SkillCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1) goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
} }
} }
@@ -360,6 +432,9 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.EnhanceAbilityRequest) (*pb.EnhanceAbilityResponse, error) { func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.EnhanceAbilityRequest) (*pb.EnhanceAbilityResponse, error) {
log.Printf("[WeaponService] EnhanceAbility: uuid=%s abilityId=%d addLevel=%d", req.UserWeaponUuid, req.AbilityId, req.AddLevelCount) log.Printf("[WeaponService] EnhanceAbility: uuid=%s abilityId=%d addLevel=%d", req.UserWeaponUuid, req.AbilityId, req.AddLevelCount)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -370,13 +445,13 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId)
return return
} }
groupRows := s.catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId] groupRows := catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
var abilityGroup *masterdata.EntityMWeaponAbilityGroup var abilityGroup *masterdata.EntityMWeaponAbilityGroup
for i := range groupRows { for i := range groupRows {
if groupRows[i].AbilityId == req.AbilityId { if groupRows[i].AbilityId == req.AbilityId {
@@ -402,7 +477,7 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
return return
} }
maxLevelFunc, ok := s.catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId] maxLevelFunc, ok := catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId) log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return return
@@ -422,7 +497,7 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ { for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl} key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.AbilityEnhanceMats[key] mats := catalog.AbilityEnhanceMats[key]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -433,9 +508,9 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
user.Materials[mat.MaterialId] = cur - cost user.Materials[mat.MaterialId] = cur - cost
} }
if costFunc, ok := s.catalog.AbilityCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if costFunc, ok := catalog.AbilityCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
goldCost := costFunc.Evaluate(lvl + 1) goldCost := costFunc.Evaluate(lvl + 1)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
} }
} }
@@ -456,6 +531,9 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.LimitBreakByMaterialRequest) (*pb.LimitBreakByMaterialResponse, error) { func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.LimitBreakByMaterialRequest) (*pb.LimitBreakByMaterialResponse, error) {
log.Printf("[WeaponService] LimitBreakByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials) log.Printf("[WeaponService] LimitBreakByMaterial: uuid=%s materials=%v", req.UserWeaponUuid, req.Materials)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -466,18 +544,18 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
return return
} }
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount { if weapon.LimitBreakCount >= config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount) log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount)
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId)
return return
} }
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount remaining := config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
@@ -496,9 +574,9 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
totalMaterialCount += count totalMaterialCount += count
} }
if costFunc, ok := s.catalog.LimitBreakCostByMaterialByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.LimitBreakCostByMaterialByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
goldCost := costFunc.Evaluate(totalMaterialCount) goldCost := costFunc.Evaluate(totalMaterialCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost) log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost)
} }
@@ -525,6 +603,9 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.LimitBreakByWeaponRequest) (*pb.LimitBreakByWeaponResponse, error) { func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.LimitBreakByWeaponRequest) (*pb.LimitBreakByWeaponResponse, error) {
log.Printf("[WeaponService] LimitBreakByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids) log.Printf("[WeaponService] LimitBreakByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -535,18 +616,18 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
return return
} }
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount { if weapon.LimitBreakCount >= config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount) log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount)
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId)
return return
} }
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount remaining := config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
consumedCount := int32(0) consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids { for _, uuid := range req.MaterialUserWeaponUuids {
@@ -560,7 +641,7 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
continue continue
} }
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok { if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals { for itemId, count := range medals {
user.ConsumableItems[itemId] += count user.ConsumableItems[itemId] += count
} }
@@ -573,9 +654,9 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
consumedCount++ consumedCount++
} }
if costFunc, ok := s.catalog.LimitBreakCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 { if costFunc, ok := catalog.LimitBreakCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount) goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost) log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost)
} }
@@ -602,6 +683,9 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.EnhanceByWeaponRequest) (*pb.EnhanceByWeaponResponse, error) { func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.EnhanceByWeaponRequest) (*pb.EnhanceByWeaponResponse, error) {
log.Printf("[WeaponService] EnhanceByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids) log.Printf("[WeaponService] EnhanceByWeapon: uuid=%s materialUuids=%v", req.UserWeaponUuid, req.MaterialUserWeaponUuids)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -612,7 +696,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
return return
} }
wm, ok := s.catalog.Weapons[weapon.WeaponId] wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId) log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId)
return return
@@ -627,19 +711,20 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
continue continue
} }
matMaster, ok := s.catalog.Weapons[matWeapon.WeaponId] matMaster, ok := catalog.Weapons[matWeapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId) log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId)
continue continue
} }
baseExp := s.catalog.BaseExpByEnhanceId[matMaster.WeaponSpecificEnhanceId] matLevelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[matWeapon.WeaponId]
baseExp := catalog.BaseExpByEnhanceId[matLevelingEnhanceId]
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType { if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * s.config.MaterialSameWeaponExpCoefficientPermil / 1000 baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += baseExp totalExp += baseExp
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok { if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals { for itemId, count := range medals {
user.ConsumableItems[itemId] += count user.ConsumableItems[itemId] += count
} }
@@ -652,22 +737,40 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
consumedCount++ consumedCount++
} }
if costFunc, ok := s.catalog.EnhanceCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 { if costFunc, ok := catalog.EnhanceCostByWeaponByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && consumedCount > 0 {
goldCost := costFunc.Evaluate(consumedCount) goldCost := costFunc.Evaluate(consumedCount)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount) log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount)
} }
weapon.Exp += totalExp weapon.Exp += totalExp
if thresholds, ok := s.catalog.ExpByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { levelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[weapon.WeaponId]
if thresholds, ok := catalog.ExpByEnhanceId[levelingEnhanceId]; ok {
weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds) weapon.Level, weapon.Exp = gameutil.LevelAndCap(weapon.Exp, thresholds)
if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
cap := 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]
}
}
}
}
note := user.WeaponNotes[weapon.WeaponId]
if note.MaxLevel < weapon.Level {
note.WeaponId = weapon.WeaponId
note.MaxLevel = weapon.Level
note.LatestVersion = nowMillis
user.WeaponNotes[weapon.WeaponId] = note
} }
weapon.LatestVersion = nowMillis weapon.LatestVersion = nowMillis
user.Weapons[req.UserWeaponUuid] = weapon user.Weapons[req.UserWeaponUuid] = weapon
log.Printf("[WeaponService] EnhanceByWeapon: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level) log.Printf("[WeaponService] EnhanceByWeapon: weaponId=%d +%d exp -> total=%d level=%d", weapon.WeaponId, totalExp, weapon.Exp, weapon.Level)
s.checkWeaponStoryUnlocks(user, weapon.WeaponId, weapon.Level, nowMillis) checkWeaponStoryUnlocks(catalog, user, weapon.WeaponId, weapon.Level, nowMillis)
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("weapon enhance by weapon: %w", err) return nil, fmt.Errorf("weapon enhance by weapon: %w", err)
@@ -679,13 +782,13 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
}, nil }, nil
} }
func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, weaponId, level int32, nowMillis int64) { func checkWeaponStoryUnlocks(catalog *masterdata.WeaponCatalog, user *store.UserState, weaponId, level int32, nowMillis int64) {
wm, ok := s.catalog.Weapons[weaponId] wm, ok := catalog.Weapons[weaponId]
if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 { if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 {
return return
} }
evoOrder, hasEvo := s.catalog.EvolutionOrder[weaponId] evoOrder, hasEvo := catalog.EvolutionOrder[weaponId]
conditions := s.catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId] conditions := catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
for _, cond := range conditions { for _, cond := range conditions {
switch model.WeaponStoryReleaseConditionType(cond.WeaponStoryReleaseConditionType) { switch model.WeaponStoryReleaseConditionType(cond.WeaponStoryReleaseConditionType) {
@@ -696,14 +799,14 @@ func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, wea
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis) store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
} }
case model.WeaponStoryReleaseConditionTypeReachInitialMaxLevel: case model.WeaponStoryReleaseConditionTypeReachInitialMaxLevel:
if maxFunc, ok := s.catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
if level >= maxFunc.Evaluate(0) { if level >= maxFunc.Evaluate(0) {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis) store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
} }
} }
case model.WeaponStoryReleaseConditionTypeReachOnceEvolvedMaxLevel: case model.WeaponStoryReleaseConditionTypeReachOnceEvolvedMaxLevel:
if hasEvo && evoOrder >= 1 { if hasEvo && evoOrder >= 1 {
if maxFunc, ok := s.catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok { if maxFunc, ok := catalog.MaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
if level >= maxFunc.Evaluate(0) { if level >= maxFunc.Evaluate(0) {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis) store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
} }
@@ -720,6 +823,9 @@ func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, wea
func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRequest) (*pb.WeaponAwakenResponse, error) { func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRequest) (*pb.WeaponAwakenResponse, error) {
log.Printf("[WeaponService] Awaken: uuid=%s", req.UserWeaponUuid) log.Printf("[WeaponService] Awaken: uuid=%s", req.UserWeaponUuid)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -730,7 +836,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
return return
} }
awakenRow, ok := s.catalog.AwakenByWeaponId[weapon.WeaponId] awakenRow, ok := catalog.AwakenByWeaponId[weapon.WeaponId]
if !ok { if !ok {
log.Printf("[WeaponService] Awaken: no awaken data for weaponId=%d", weapon.WeaponId) log.Printf("[WeaponService] Awaken: no awaken data for weaponId=%d", weapon.WeaponId)
return return
@@ -741,7 +847,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
return return
} }
mats := s.catalog.AwakenMaterialsByGroupId[awakenRow.WeaponAwakenMaterialGroupId] mats := catalog.AwakenMaterialsByGroupId[awakenRow.WeaponAwakenMaterialGroupId]
for _, mat := range mats { for _, mat := range mats {
cur := user.Materials[mat.MaterialId] cur := user.Materials[mat.MaterialId]
cost := mat.Count cost := mat.Count
@@ -753,7 +859,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
} }
if awakenRow.ConsumeGold > 0 { if awakenRow.ConsumeGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= awakenRow.ConsumeGold user.ConsumableItems[config.ConsumableItemIdForGold] -= awakenRow.ConsumeGold
log.Printf("[WeaponService] Awaken: gold cost=%d", awakenRow.ConsumeGold) log.Printf("[WeaponService] Awaken: gold cost=%d", awakenRow.ConsumeGold)
} }
@@ -772,3 +878,14 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
return &pb.WeaponAwakenResponse{}, nil return &pb.WeaponAwakenResponse{}, nil
} }
func awakenedLevelCap(catalog *masterdata.WeaponCatalog, user *store.UserState, weapon store.WeaponState, weaponUuid string, baseCap int32) int32 {
if _, awoken := user.WeaponAwakens[weaponUuid]; !awoken {
return baseCap
}
row, ok := catalog.AwakenByWeaponId[weapon.WeaponId]
if !ok {
return baseCap
}
return baseCap + row.LevelLimitUp
}
+5
View File
@@ -14,6 +14,7 @@ func CloneUserState(u UserState) UserState {
out.DeckSubWeapons = cloneSliceMap(u.DeckSubWeapons) out.DeckSubWeapons = cloneSliceMap(u.DeckSubWeapons)
out.DeckParts = cloneSliceMap(u.DeckParts) out.DeckParts = cloneSliceMap(u.DeckParts)
out.Decks = maps.Clone(u.Decks) out.Decks = maps.Clone(u.Decks)
out.TripleDecks = maps.Clone(u.TripleDecks)
out.Quests = maps.Clone(u.Quests) out.Quests = maps.Clone(u.Quests)
out.QuestMissions = maps.Clone(u.QuestMissions) out.QuestMissions = maps.Clone(u.QuestMissions)
out.WeaponStories = maps.Clone(u.WeaponStories) out.WeaponStories = maps.Clone(u.WeaponStories)
@@ -25,11 +26,15 @@ func CloneUserState(u UserState) UserState {
Unlocks: maps.Clone(u.Gimmick.Unlocks), Unlocks: maps.Clone(u.Gimmick.Unlocks),
} }
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards) out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
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)
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes) out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
out.PartsPresets = maps.Clone(u.PartsPresets) out.PartsPresets = maps.Clone(u.PartsPresets)
out.PartsPresetTags = maps.Clone(u.PartsPresetTags)
out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs) out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs)
out.ImportantItems = maps.Clone(u.ImportantItems) out.ImportantItems = maps.Clone(u.ImportantItems)
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills) out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
+6 -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)
@@ -97,6 +96,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
Companions: make(map[string]CompanionState), Companions: make(map[string]CompanionState),
DeckCharacters: make(map[string]DeckCharacterState), DeckCharacters: make(map[string]DeckCharacterState),
Decks: make(map[DeckKey]DeckState), Decks: make(map[DeckKey]DeckState),
TripleDecks: make(map[DeckKey]TripleDeckState),
DeckSubWeapons: make(map[string][]string), DeckSubWeapons: make(map[string][]string),
DeckParts: make(map[string][]string), DeckParts: make(map[string][]string),
Quests: make(map[int32]UserQuestState), Quests: make(map[int32]UserQuestState),
@@ -113,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{
@@ -123,12 +123,16 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
Unlocks: make(map[GimmickKey]GimmickUnlockState), Unlocks: make(map[GimmickKey]GimmickUnlockState),
}, },
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
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),
Parts: make(map[string]PartsState), Parts: make(map[string]PartsState),
PartsGroupNotes: make(map[int32]PartsGroupNoteState), PartsGroupNotes: make(map[int32]PartsGroupNoteState),
PartsPresets: make(map[int32]PartsPresetState), PartsPresets: make(map[int32]PartsPresetState),
PartsPresetTags: make(map[int32]PartsPresetTagState),
PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState), PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState),
ImportantItems: make(map[int32]int32), ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]CostumeActiveSkillState), CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
+67 -11
View File
@@ -43,6 +43,7 @@ func initMaps(u *store.UserState) {
u.Thoughts = make(map[string]store.ThoughtState) u.Thoughts = make(map[string]store.ThoughtState)
u.DeckCharacters = make(map[string]store.DeckCharacterState) u.DeckCharacters = make(map[string]store.DeckCharacterState)
u.Decks = make(map[store.DeckKey]store.DeckState) u.Decks = make(map[store.DeckKey]store.DeckState)
u.TripleDecks = make(map[store.DeckKey]store.TripleDeckState)
u.DeckSubWeapons = make(map[string][]string) u.DeckSubWeapons = make(map[string][]string)
u.DeckParts = make(map[string][]string) u.DeckParts = make(map[string][]string)
u.Quests = make(map[int32]store.UserQuestState) u.Quests = make(map[int32]store.UserQuestState)
@@ -60,6 +61,7 @@ func initMaps(u *store.UserState) {
u.Parts = make(map[string]store.PartsState) u.Parts = make(map[string]store.PartsState)
u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState) u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState)
u.PartsPresets = make(map[int32]store.PartsPresetState) u.PartsPresets = make(map[int32]store.PartsPresetState)
u.PartsPresetTags = make(map[int32]store.PartsPresetTagState)
u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState) u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState)
u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState) u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState)
u.ConsumableItems = make(map[int32]int32) u.ConsumableItems = make(map[int32]int32)
@@ -75,6 +77,9 @@ func initMaps(u *store.UserState) {
u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState) u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState)
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.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)
@@ -124,17 +129,26 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber, Scan(&u.LoginBonus.LoginBonusId, &u.LoginBonus.CurrentPageNumber, &u.LoginBonus.CurrentStampNumber,
&u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion) &u.LoginBonus.LatestRewardReceiveDatetime, &u.LoginBonus.LatestVersion)
var ctxActive, ctxIsLast, ctxCage int
_ = db.QueryRow(`SELECT current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, _ = db.QueryRow(`SELECT current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id,
head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id,
progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version,
saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id,
saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id,
saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, saved_ctx_current_quest_flow_type,
replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id
FROM user_main_quest WHERE user_id=?`, uid). FROM user_main_quest WHERE user_id=?`, uid).
Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId, Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId,
&u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId, &u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId,
&u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion, &u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion,
&u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId, &ctxActive, &u.MainQuest.SavedContext.CurrentQuestSceneId, &u.MainQuest.SavedContext.HeadQuestSceneId,
&u.MainQuest.SavedContext.CurrentMainQuestRouteId, &u.MainQuest.SavedContext.MainQuestSeasonId,
&ctxIsLast, &ctxCage, &u.MainQuest.SavedContext.CurrentQuestFlowType,
&u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId) &u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId)
u.MainQuest.IsReachedLastQuestScene = b != 0 u.MainQuest.IsReachedLastQuestScene = b != 0
u.MainQuest.SavedContext.Active = ctxActive != 0
u.MainQuest.SavedContext.IsReachedLastQuestScene = ctxIsLast != 0
u.MainQuest.SavedContext.PortalCageInProgress = ctxCage != 0
_ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id, _ = db.QueryRow(`SELECT current_event_quest_chapter_id, current_quest_id, current_quest_scene_id,
head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid). head_quest_scene_id, latest_version FROM user_event_quest WHERE user_id=?`, uid).
@@ -163,6 +177,13 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
&u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary) &u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary)
u.BigHuntProgress.IsDryRun = isDryRun != 0 u.BigHuntProgress.IsDryRun = isDryRun != 0
queryRows(db, `SELECT wave_index, costume_id, total_damage, hit_count, random_display_value_type, random_display_value
FROM user_big_hunt_costume_battle_infos WHERE user_id=? ORDER BY wave_index, sort_order`, uid, func(rows *sql.Rows) {
var ci store.BigHuntCostumeBattleInfo
rows.Scan(&ci.WaveIndex, &ci.CostumeId, &ci.TotalDamage, &ci.HitCount, &ci.RandomDisplayValueType, &ci.RandomDisplayValue)
u.BigHuntBattleDetail.CostumeBattleInfo = append(u.BigHuntBattleDetail.CostumeBattleInfo, ci)
})
var isActive, isUnread int var isActive, isUnread int
_ = db.QueryRow(`SELECT is_active, start_count, finish_count, last_started_at, last_finished_at, _ = db.QueryRow(`SELECT is_active, start_count, finish_count, last_started_at, last_finished_at,
last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count
@@ -282,6 +303,16 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v
}) })
queryRows(db, `SELECT deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version
FROM user_triple_decks WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.TripleDeckState
var dt int32
rows.Scan(&dt, &v.UserDeckNumber, &v.Name, &v.DeckNumber01, &v.DeckNumber02, &v.DeckNumber03, &v.LatestVersion)
v.DeckType = model.DeckType(dt)
u.TripleDecks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v
})
queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid
FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid, FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
@@ -454,6 +485,14 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.PartsPresets[v.UserPartsPresetNumber] = v u.PartsPresets[v.UserPartsPresetNumber] = v
}) })
queryRows(db, `SELECT user_parts_preset_tag_number, name, latest_version
FROM user_parts_preset_tags WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.PartsPresetTagState
rows.Scan(&v.UserPartsPresetTagNumber, &v.Name, &v.LatestVersion)
u.PartsPresetTags[v.UserPartsPresetTagNumber] = v
})
queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level, queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level,
status_kind_type, status_calculation_type, status_change_value, latest_version status_kind_type, status_calculation_type, status_change_value, latest_version
FROM user_parts_status_subs WHERE user_id=?`, uid, FROM user_parts_status_subs WHERE user_id=?`, uid,
@@ -517,7 +556,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.DokanConfirmed[id] = true u.DokanConfirmed[id] = true
}) })
// Gifts
queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime, queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime,
description_gift_text_id, equipment_data, expiration_datetime, received_datetime description_gift_text_id, equipment_data, expiration_datetime, received_datetime
FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) {
@@ -540,7 +578,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
} }
}) })
// Gacha converted medals
queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid, queryRows(db, `SELECT consumable_item_id, count FROM user_gacha_converted_medals WHERE user_id=? ORDER BY ordinal`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
var v store.ConsumableItemState var v store.ConsumableItemState
@@ -548,7 +585,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v) u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append(u.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, v)
}) })
// Gacha banners
queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number
FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.GachaBannerState var v store.GachaBannerState
@@ -566,7 +602,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
} }
}) })
// Character boards
queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, queryRows(db, `SELECT character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3,
panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid, panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
@@ -608,6 +643,29 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.CageOrnamentRewards[v.CageOrnamentId] = v u.CageOrnamentRewards[v.CageOrnamentId] = v
}) })
queryRows(db, `SELECT event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version
FROM user_event_quest_tower_accumulation_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.TowerAccumulationRewardState
rows.Scan(&v.EventQuestChapterId, &v.LatestRewardReceiveQuestMissionClearCount, &v.LatestVersion)
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
@@ -622,7 +680,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.ShopReplaceableLineup[v.SlotNumber] = v u.ShopReplaceableLineup[v.SlotNumber] = v
}) })
// Gimmick tables
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id,
is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid, is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid,
func(rows *sql.Rows) { func(rows *sql.Rows) {
@@ -665,7 +722,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.Gimmick.Unlocks[v.Key] = v u.Gimmick.Unlocks[v.Key] = v
}) })
// Big hunt maps
queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version
FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
@@ -674,11 +730,11 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.BigHuntMaxScores[id] = v u.BigHuntMaxScores[id] = v
}) })
queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version
FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
var v store.BigHuntStatus var v store.BigHuntStatus
rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LatestVersion) rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LastDailyRewardReceivedDayVersion, &v.LatestVersion)
u.BigHuntStatuses[id] = v u.BigHuntStatuses[id] = v
}) })
+123 -35
View File
@@ -15,8 +15,7 @@ func boolToInt(b bool) int {
return 0 return 0
} }
// writeUserState inserts all child table rows for a newly created user. // Precondition: the users row must already exist.
// The users row must already exist.
func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
exec := func(query string, args ...any) error { exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...) _, err := tx.Exec(query, args...)
@@ -50,11 +49,17 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil { u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil {
return err return err
} }
if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id, saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id, saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, saved_ctx_current_quest_flow_type, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId, uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId,
u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId, u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId,
u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId, u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId,
u.MainQuest.LatestVersion, u.MainQuest.SavedCurrentQuestSceneId, u.MainQuest.SavedHeadQuestSceneId, u.MainQuest.LatestVersion,
boolToInt(u.MainQuest.SavedContext.Active),
u.MainQuest.SavedContext.CurrentQuestSceneId, u.MainQuest.SavedContext.HeadQuestSceneId,
u.MainQuest.SavedContext.CurrentMainQuestRouteId, u.MainQuest.SavedContext.MainQuestSeasonId,
boolToInt(u.MainQuest.SavedContext.IsReachedLastQuestScene),
boolToInt(u.MainQuest.SavedContext.PortalCageInProgress),
u.MainQuest.SavedContext.CurrentQuestFlowType,
u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil { u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil {
return err return err
} }
@@ -78,6 +83,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
u.BigHuntBattleDetail.MaxComboCount, u.BigHuntBattleDetail.TotalDamage, u.BigHuntDeckNumber, u.BigHuntBattleBinary); err != nil { u.BigHuntBattleDetail.MaxComboCount, u.BigHuntBattleDetail.TotalDamage, u.BigHuntDeckNumber, u.BigHuntBattleBinary); err != nil {
return err return err
} }
for i, ci := range u.BigHuntBattleDetail.CostumeBattleInfo {
if err := exec(`INSERT INTO user_big_hunt_costume_battle_infos (user_id, wave_index, sort_order, costume_id, total_damage, hit_count, random_display_value_type, random_display_value) VALUES (?,?,?,?,?,?,?,?)`,
uid, ci.WaveIndex, i, ci.CostumeId, ci.TotalDamage, ci.HitCount, ci.RandomDisplayValueType, ci.RandomDisplayValue); err != nil {
return err
}
}
if err := exec(`INSERT INTO user_battle (user_id, is_active, start_count, finish_count, last_started_at, last_finished_at, last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count) VALUES (?,?,?,?,?,?,?,?,?,?)`, if err := exec(`INSERT INTO user_battle (user_id, is_active, start_count, finish_count, last_started_at, last_finished_at, last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count) VALUES (?,?,?,?,?,?,?,?,?,?)`,
uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt, uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt,
u.Battle.LastFinishedAt, u.Battle.LastUserPartyCount, u.Battle.LastNpcPartyCount, u.Battle.LastFinishedAt, u.Battle.LastUserPartyCount, u.Battle.LastNpcPartyCount,
@@ -118,7 +129,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
// Map tables
for _, v := range u.Characters { for _, v := range u.Characters {
if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`, if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`,
uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil { uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil {
@@ -161,6 +171,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for k, v := range u.TripleDecks {
if err := exec(`INSERT INTO user_triple_decks (user_id, deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version) VALUES (?,?,?,?,?,?,?,?)`,
uid, int32(k.DeckType), k.UserDeckNumber, v.Name, v.DeckNumber01, v.DeckNumber02, v.DeckNumber03, v.LatestVersion); err != nil {
return err
}
}
for key, uuids := range u.DeckSubWeapons { for key, uuids := range u.DeckSubWeapons {
for i, uuid := range uuids { for i, uuid := range uuids {
if err := exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, if err := exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`,
@@ -290,6 +306,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for _, v := range u.PartsPresetTags {
if err := exec(`INSERT INTO user_parts_preset_tags (user_id, user_parts_preset_tag_number, name, latest_version) VALUES (?,?,?,?)`,
uid, v.UserPartsPresetTagNumber, v.Name, v.LatestVersion); err != nil {
return err
}
}
for _, v := range u.PartsStatusSubs { for _, v := range u.PartsStatusSubs {
if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`,
uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil { uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil {
@@ -426,6 +448,24 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for _, v := range u.TowerAccumulationRewards {
if err := exec(`INSERT INTO user_event_quest_tower_accumulation_rewards (user_id, event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version) VALUES (?,?,?,?)`,
uid, v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion); err != nil {
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 {
@@ -469,8 +509,8 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
} }
} }
for id, v := range u.BigHuntStatuses { for id, v := range u.BigHuntStatuses {
if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version) VALUES (?,?,?,?,?)`, if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version) VALUES (?,?,?,?,?,?)`,
uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion); err != nil { uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LastDailyRewardReceivedDayVersion, v.LatestVersion); err != nil {
return err return err
} }
} }
@@ -496,18 +536,13 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return nil return nil
} }
// diffAndSave compares before/after UserState and writes only changed rows. // 1:1 tables update on field-change; maps INSERT OR REPLACE + DELETE; slice tables DELETE-all then INSERT-all.
// For 1:1 tables, it UPDATEs if any field changed.
// For map tables, it uses INSERT OR REPLACE for added/modified entries and DELETE for removed ones.
// For slice-based data (gifts, medals, deck sub-weapons/parts, weapon skills/abilities),
// it does DELETE-all then INSERT-all for simplicity.
func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
exec := func(query string, args ...any) error { exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...) _, err := tx.Exec(query, args...)
return err return err
} }
// users table
if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType || if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType ||
before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime || before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime ||
before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion || before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion ||
@@ -562,11 +597,17 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
if before.MainQuest != after.MainQuest { if before.MainQuest != after.MainQuest {
if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_current_quest_scene_id=?, saved_head_quest_scene_id=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`, if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_ctx_active=?, saved_ctx_current_quest_scene_id=?, saved_ctx_head_quest_scene_id=?, saved_ctx_current_main_quest_route_id=?, saved_ctx_main_quest_season_id=?, saved_ctx_is_reached_last_quest_scene=?, saved_ctx_portal_cage_in_progress=?, saved_ctx_current_quest_flow_type=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`,
after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId, after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId,
after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId, after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId,
after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId, after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId,
after.MainQuest.LatestVersion, after.MainQuest.SavedCurrentQuestSceneId, after.MainQuest.SavedHeadQuestSceneId, after.MainQuest.LatestVersion,
boolToInt(after.MainQuest.SavedContext.Active),
after.MainQuest.SavedContext.CurrentQuestSceneId, after.MainQuest.SavedContext.HeadQuestSceneId,
after.MainQuest.SavedContext.CurrentMainQuestRouteId, after.MainQuest.SavedContext.MainQuestSeasonId,
boolToInt(after.MainQuest.SavedContext.IsReachedLastQuestScene),
boolToInt(after.MainQuest.SavedContext.PortalCageInProgress),
after.MainQuest.SavedContext.CurrentQuestFlowType,
after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil { after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil {
return err return err
} }
@@ -589,7 +630,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return err return err
} }
} }
if before.BigHuntProgress != after.BigHuntProgress || before.BigHuntBattleDetail != after.BigHuntBattleDetail || before.BigHuntDeckNumber != after.BigHuntDeckNumber { if before.BigHuntProgress != after.BigHuntProgress || !bigHuntBattleDetailEqual(before.BigHuntBattleDetail, after.BigHuntBattleDetail) || before.BigHuntDeckNumber != after.BigHuntDeckNumber {
if err := exec(`UPDATE user_big_hunt_state SET current_big_hunt_boss_quest_id=?, current_big_hunt_quest_id=?, current_quest_scene_id=?, is_dry_run=?, latest_version=?, deck_type=?, user_triple_deck_number=?, boss_knock_down_count=?, max_combo_count=?, total_damage=?, deck_number=?, battle_binary=? WHERE user_id=?`, if err := exec(`UPDATE user_big_hunt_state SET current_big_hunt_boss_quest_id=?, current_big_hunt_quest_id=?, current_quest_scene_id=?, is_dry_run=?, latest_version=?, deck_type=?, user_triple_deck_number=?, boss_knock_down_count=?, max_combo_count=?, total_damage=?, deck_number=?, battle_binary=? WHERE user_id=?`,
after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId, after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId,
after.BigHuntProgress.CurrentQuestSceneId, boolToInt(after.BigHuntProgress.IsDryRun), after.BigHuntProgress.LatestVersion, after.BigHuntProgress.CurrentQuestSceneId, boolToInt(after.BigHuntProgress.IsDryRun), after.BigHuntProgress.LatestVersion,
@@ -597,6 +638,15 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
after.BigHuntBattleDetail.MaxComboCount, after.BigHuntBattleDetail.TotalDamage, after.BigHuntDeckNumber, after.BigHuntBattleBinary, uid); err != nil { after.BigHuntBattleDetail.MaxComboCount, after.BigHuntBattleDetail.TotalDamage, after.BigHuntDeckNumber, after.BigHuntBattleBinary, uid); err != nil {
return err return err
} }
if err := exec(`DELETE FROM user_big_hunt_costume_battle_infos WHERE user_id=?`, uid); err != nil {
return err
}
for i, ci := range after.BigHuntBattleDetail.CostumeBattleInfo {
if err := exec(`INSERT INTO user_big_hunt_costume_battle_infos (user_id, wave_index, sort_order, costume_id, total_damage, hit_count, random_display_value_type, random_display_value) VALUES (?,?,?,?,?,?,?,?)`,
uid, ci.WaveIndex, i, ci.CostumeId, ci.TotalDamage, ci.HitCount, ci.RandomDisplayValueType, ci.RandomDisplayValue); err != nil {
return err
}
}
} }
if before.Battle != after.Battle { if before.Battle != after.Battle {
if err := exec(`UPDATE user_battle SET is_active=?, start_count=?, finish_count=?, last_started_at=?, last_finished_at=?, last_user_party_count=?, last_npc_party_count=?, last_battle_binary_size=?, last_elapsed_frame_count=? WHERE user_id=?`, if err := exec(`UPDATE user_battle SET is_active=?, start_count=?, finish_count=?, last_started_at=?, last_finished_at=?, last_user_party_count=?, last_npc_party_count=?, last_battle_binary_size=?, last_elapsed_frame_count=? WHERE user_id=?`,
@@ -637,7 +687,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Gacha scalar
if before.Gacha.RewardAvailable != after.Gacha.RewardAvailable || before.Gacha.TodaysCurrentDrawCount != after.Gacha.TodaysCurrentDrawCount || if before.Gacha.RewardAvailable != after.Gacha.RewardAvailable || before.Gacha.TodaysCurrentDrawCount != after.Gacha.TodaysCurrentDrawCount ||
before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate { before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate {
var obtainItemId, obtainCount sql.NullInt64 var obtainItemId, obtainCount sql.NullInt64
@@ -652,7 +701,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Map tables — use generic diff helpers
diffMapInt32(tx, uid, before.Characters, after.Characters, "user_characters", "character_id", diffMapInt32(tx, uid, before.Characters, after.Characters, "user_characters", "character_id",
func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} }, func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} },
"character_id, level, exp, latest_version") "character_id, level, exp, latest_version")
@@ -677,7 +725,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion} return []any{v.UserDeckCharacterUuid, v.UserCostumeUuid, v.MainUserWeaponUuid, v.UserCompanionUuid, v.Power, v.UserThoughtUuid, v.DressupCostumeId, v.LatestVersion}
}, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version") }, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version")
// Decks (composite key)
for k, v := range after.Decks { for k, v := range after.Decks {
if old, ok := before.Decks[k]; !ok || old != v { if old, ok := before.Decks[k]; !ok || old != v {
exec(fmt.Sprintf(`INSERT OR REPLACE INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`), exec(fmt.Sprintf(`INSERT OR REPLACE INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`),
@@ -690,7 +737,18 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Slice-based tables: delete all + reinsert for k, v := range after.TripleDecks {
if old, ok := before.TripleDecks[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_triple_decks (user_id, deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version) VALUES (?,?,?,?,?,?,?,?)`,
uid, int32(k.DeckType), k.UserDeckNumber, v.Name, v.DeckNumber01, v.DeckNumber02, v.DeckNumber03, v.LatestVersion)
}
}
for k := range before.TripleDecks {
if _, ok := after.TripleDecks[k]; !ok {
exec(`DELETE FROM user_triple_decks WHERE user_id=? AND deck_type=? AND user_deck_number=?`, uid, int32(k.DeckType), k.UserDeckNumber)
}
}
replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) { replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) {
for i, uuid := range uuids { for i, uuid := range uuids {
exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid) exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid)
@@ -707,7 +765,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.QuestId, int32(v.QuestStateType), boolToInt(v.IsBattleOnly), v.UserDeckNumber, v.LatestStartDatetime, v.ClearCount, v.DailyClearCount, v.LastClearDatetime, v.ShortestClearFrames, boolToInt(v.IsRewardGranted), v.LatestVersion} return []any{v.QuestId, int32(v.QuestStateType), boolToInt(v.IsBattleOnly), v.UserDeckNumber, v.LatestStartDatetime, v.ClearCount, v.DailyClearCount, v.LastClearDatetime, v.ShortestClearFrames, boolToInt(v.IsRewardGranted), v.LatestVersion}
}, "quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version") }, "quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version")
// Quest missions (composite key)
for k, v := range after.QuestMissions { for k, v := range after.QuestMissions {
if old, ok := before.QuestMissions[k]; !ok || old != v { if old, ok := before.QuestMissions[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`,
@@ -734,6 +791,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.SideStoryQuestProgress) []any { func(v store.SideStoryQuestProgress) []any {
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")
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}
@@ -748,7 +806,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion} return []any{v.WeaponId, v.MaxLevel, v.MaxLimitBreakCount, v.FirstAcquisitionDatetime, v.LatestVersion}
}, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version") }, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version")
// Weapon skills/abilities: slice-based, delete+reinsert
exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid) exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid)
for _, skills := range after.WeaponSkills { for _, skills := range after.WeaponSkills {
for _, v := range skills { for _, v := range skills {
@@ -770,7 +827,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion} return []any{v.UserCostumeUuid, v.Level, v.AcquisitionDatetime, v.LatestVersion}
}, "user_costume_uuid, level, acquisition_datetime, latest_version") }, "user_costume_uuid, level, acquisition_datetime, latest_version")
// Costume awaken status ups (composite key)
for k, v := range after.CostumeAwakenStatusUps { for k, v := range after.CostumeAwakenStatusUps {
if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v { if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -813,6 +869,10 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.PartsPresetState) []any { func(v store.PartsPresetState) []any {
return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion} return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion}
}, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version") }, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version")
diffMapInt32(tx, uid, before.PartsPresetTags, after.PartsPresetTags, "user_parts_preset_tags", "user_parts_preset_tag_number",
func(v store.PartsPresetTagState) []any {
return []any{v.UserPartsPresetTagNumber, v.Name, v.LatestVersion}
}, "user_parts_preset_tag_number, name, latest_version")
for k, v := range after.PartsStatusSubs { for k, v := range after.PartsStatusSubs {
if old, ok := before.PartsStatusSubs[k]; !ok || old != v { if old, ok := before.PartsStatusSubs[k]; !ok || old != v {
@@ -826,7 +886,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Deck type notes (key is model.DeckType which is int32-based)
for k, v := range after.DeckTypeNotes { for k, v := range after.DeckTypeNotes {
if old, ok := before.DeckTypeNotes[k]; !ok || old != v { if old, ok := before.DeckTypeNotes[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_deck_type_notes (user_id, deck_type, max_deck_power, latest_version) VALUES (?,?,?,?)`,
@@ -860,7 +919,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id") diffTimestampMap(tx, uid, before.DrawnOmikuji, after.DrawnOmikuji, "user_drawn_omikuji", "omikuji_id")
diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id") diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id")
// Gifts: delete all + reinsert
exec(`DELETE FROM user_gifts WHERE user_id=?`, uid) exec(`DELETE FROM user_gifts WHERE user_id=?`, uid)
for _, g := range after.Gifts.NotReceived { for _, g := range after.Gifts.NotReceived {
var expDt sql.NullInt64 var expDt sql.NullInt64
@@ -876,19 +934,16 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime) uid, uuid, g.GiftCommon.PossessionType, g.GiftCommon.PossessionId, g.GiftCommon.Count, g.GiftCommon.GrantDatetime, g.GiftCommon.DescriptionGiftTextId, g.GiftCommon.EquipmentData, g.ReceivedDatetime)
} }
// Gacha converted medals: delete+reinsert
exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid) exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid)
for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession { for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession {
exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count) exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count)
} }
// Gacha banners
for id, v := range after.Gacha.BannerStates { for id, v := range after.Gacha.BannerStates {
if old, ok := before.Gacha.BannerStates[id]; !ok || old.MedalCount != v.MedalCount || old.StepNumber != v.StepNumber || old.LoopCount != v.LoopCount || old.DrawCount != v.DrawCount || old.BoxNumber != v.BoxNumber { if old, ok := before.Gacha.BannerStates[id]; !ok || old.MedalCount != v.MedalCount || old.StepNumber != v.StepNumber || old.LoopCount != v.LoopCount || old.DrawCount != v.DrawCount || old.BoxNumber != v.BoxNumber {
exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`,
uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber) uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber)
} }
// Box drew counts: always delete+reinsert for this gacha
exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id) exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id)
for itemId, count := range v.BoxDrewCounts { for itemId, count := range v.BoxDrewCounts {
exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count) exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count)
@@ -906,7 +961,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion} return []any{v.CharacterBoardId, v.PanelReleaseBit1, v.PanelReleaseBit2, v.PanelReleaseBit3, v.PanelReleaseBit4, v.LatestVersion}
}, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version") }, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version")
// Character board abilities (composite key)
for k, v := range after.CharacterBoardAbilities { for k, v := range after.CharacterBoardAbilities {
if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v { if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`,
@@ -919,7 +973,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Character board status ups (composite key)
for k, v := range after.CharacterBoardStatusUps { for k, v := range after.CharacterBoardStatusUps {
if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v { if old, ok := before.CharacterBoardStatusUps[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_character_board_status_ups (user_id, character_id, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_character_board_status_ups (user_id, character_id, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -942,6 +995,27 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion} return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion}
}, },
"cage_ornament_id, acquisition_datetime, latest_version") "cage_ornament_id, acquisition_datetime, latest_version")
diffMapInt32(tx, uid, before.TowerAccumulationRewards, after.TowerAccumulationRewards, "user_event_quest_tower_accumulation_rewards", "event_quest_chapter_id",
func(v store.TowerAccumulationRewardState) []any {
return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion}
},
"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}
@@ -952,7 +1026,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
}, },
"slot_number, shop_item_id, latest_version") "slot_number, shop_item_id, latest_version")
// Gimmick tables (composite keys)
for k, v := range after.Gimmick.Progress { for k, v := range after.Gimmick.Progress {
if old, ok := before.Gimmick.Progress[k]; !ok || old != v { if old, ok := before.Gimmick.Progress[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`, exec(`INSERT OR REPLACE INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`,
@@ -1002,7 +1075,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
} }
} }
// Big hunt maps
diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id", diffMapInt32(tx, uid, before.BigHuntMaxScores, after.BigHuntMaxScores, "user_big_hunt_max_scores", "big_hunt_boss_id",
func(v store.BigHuntMaxScore) []any { func(v store.BigHuntMaxScore) []any {
return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion} return []any{0, v.MaxScore, v.MaxScoreUpdateDatetime, v.LatestVersion}
@@ -1010,9 +1082,9 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
"big_hunt_boss_id, max_score, max_score_update_datetime, latest_version") "big_hunt_boss_id, max_score, max_score_update_datetime, latest_version")
diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id", diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id",
func(v store.BigHuntStatus) []any { func(v store.BigHuntStatus) []any {
return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion} return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LastDailyRewardReceivedDayVersion, v.LatestVersion}
}, },
"big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version") "big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version")
for k, v := range after.BigHuntScheduleMaxScores { for k, v := range after.BigHuntScheduleMaxScores {
if old, ok := before.BigHuntScheduleMaxScores[k]; !ok || old != v { if old, ok := before.BigHuntScheduleMaxScores[k]; !ok || old != v {
@@ -1051,7 +1123,23 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return nil return nil
} }
// Generic diff helpers for map tables with int32 keys func bigHuntBattleDetailEqual(a, b store.BigHuntBattleDetail) bool {
if a.DeckType != b.DeckType || a.UserTripleDeckNumber != b.UserTripleDeckNumber ||
a.BossKnockDownCount != b.BossKnockDownCount || a.MaxComboCount != b.MaxComboCount ||
a.TotalDamage != b.TotalDamage {
return false
}
if len(a.CostumeBattleInfo) != len(b.CostumeBattleInfo) {
return false
}
for i := range a.CostumeBattleInfo {
if a.CostumeBattleInfo[i] != b.CostumeBattleInfo[i] {
return false
}
}
return true
}
func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) { func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) {
for k, v := range after { for k, v := range after {
if old, ok := before[k]; !ok || old != v { if old, ok := before[k]; !ok || old != v {
+5 -6
View File
@@ -15,12 +15,6 @@ func (s *SQLiteStore) CreateUser(uuid string, platform model.ClientPlatform) (in
} }
defer tx.Rollback() defer tx.Rollback()
var existingId int64
err = tx.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&existingId)
if err == nil {
return existingId, nil
}
nowMillis := s.clock().UnixMilli() nowMillis := s.clock().UnixMilli()
res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type, res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type,
@@ -85,6 +79,9 @@ 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_cage_ornament_rewards", "user_cage_ornament_rewards",
"user_shop_replaceable_lineup", "user_shop_replaceable_lineup",
"user_shop_items", "user_shop_items",
@@ -115,6 +112,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"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",
@@ -124,6 +122,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_decks", "user_decks",
"user_deck_characters", "user_deck_characters",
"user_parts_status_subs", "user_parts_status_subs",
"user_parts_preset_tags",
"user_parts_presets", "user_parts_presets",
"user_parts_group_notes", "user_parts_group_notes",
"user_parts", "user_parts",
+16 -1
View File
@@ -1,6 +1,10 @@
package store package store
import "log" import (
"log"
"lunar-tear/server/internal/model"
)
const StaminaRecoveryDivisor int64 = 180 const StaminaRecoveryDivisor int64 = 180
@@ -39,3 +43,14 @@ func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64)
user.Status.StaminaUpdateDatetime = nowMillis user.Status.StaminaUpdateDatetime = nowMillis
log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis) log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis)
} }
func ResolveStaminaEffectMillis(effectValueType, effectValue, maxStaminaMillis int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue * 1000
case model.EffectValuePermil:
return effectValue * maxStaminaMillis / 1000
default:
return 0
}
}
+89 -2
View File
@@ -69,17 +69,22 @@ type UserState struct {
Thoughts map[string]ThoughtState Thoughts map[string]ThoughtState
DeckCharacters map[string]DeckCharacterState DeckCharacters map[string]DeckCharacterState
Decks map[DeckKey]DeckState Decks map[DeckKey]DeckState
TripleDecks map[DeckKey]TripleDeckState
Quests map[int32]UserQuestState Quests map[int32]UserQuestState
QuestMissions map[QuestMissionKey]UserQuestMissionState QuestMissions map[QuestMissionKey]UserQuestMissionState
Missions map[int32]UserMissionState Missions map[int32]UserMissionState
WeaponStories map[int32]WeaponStoryState WeaponStories map[int32]WeaponStoryState
Gimmick GimmickState Gimmick GimmickState
CageOrnamentRewards map[int32]CageOrnamentRewardState CageOrnamentRewards map[int32]CageOrnamentRewardState
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
PartsGroupNotes map[int32]PartsGroupNoteState PartsGroupNotes map[int32]PartsGroupNoteState
PartsPresets map[int32]PartsPresetState PartsPresets map[int32]PartsPresetState
PartsPresetTags map[int32]PartsPresetTagState
PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState
ImportantItems map[int32]int32 ImportantItems map[int32]int32
CostumeActiveSkills map[string]CostumeActiveSkillState CostumeActiveSkills map[string]CostumeActiveSkillState
@@ -141,6 +146,9 @@ func (u *UserState) EnsureMaps() {
if u.Decks == nil { if u.Decks == nil {
u.Decks = make(map[DeckKey]DeckState) u.Decks = make(map[DeckKey]DeckState)
} }
if u.TripleDecks == nil {
u.TripleDecks = make(map[DeckKey]TripleDeckState)
}
if u.DeckSubWeapons == nil { if u.DeckSubWeapons == nil {
u.DeckSubWeapons = make(map[string][]string) u.DeckSubWeapons = make(map[string][]string)
} }
@@ -183,6 +191,15 @@ func (u *UserState) EnsureMaps() {
if u.CageOrnamentRewards == nil { if u.CageOrnamentRewards == nil {
u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState) u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState)
} }
if u.TowerAccumulationRewards == nil {
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)
} }
@@ -198,6 +215,9 @@ func (u *UserState) EnsureMaps() {
if u.PartsPresets == nil { if u.PartsPresets == nil {
u.PartsPresets = make(map[int32]PartsPresetState) u.PartsPresets = make(map[int32]PartsPresetState)
} }
if u.PartsPresetTags == nil {
u.PartsPresetTags = make(map[int32]PartsPresetTagState)
}
if u.PartsStatusSubs == nil { if u.PartsStatusSubs == nil {
u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState) u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState)
} }
@@ -451,6 +471,16 @@ type DeckState struct {
LatestVersion int64 LatestVersion int64
} }
type TripleDeckState struct {
DeckType model.DeckType
UserDeckNumber int32
Name string
DeckNumber01 int32
DeckNumber02 int32
DeckNumber03 int32
LatestVersion int64
}
type DeckCharacterInput struct { type DeckCharacterInput struct {
UserCostumeUuid string UserCostumeUuid string
MainUserWeaponUuid string MainUserWeaponUuid string
@@ -510,12 +540,25 @@ type MainQuestState struct {
MainQuestSeasonId int32 MainQuestSeasonId int32
LatestVersion int64 LatestVersion int64
SavedCurrentQuestSceneId int32 SavedContext SavedQuestContext
SavedHeadQuestSceneId int32
ReplayFlowCurrentQuestSceneId int32 ReplayFlowCurrentQuestSceneId int32
ReplayFlowHeadQuestSceneId int32 ReplayFlowHeadQuestSceneId int32
} }
// SavedQuestContext snapshots player state when entering a menu-replay (cleared
// quest started from the Main Quest List menu). On finish, every field is
// restored atomically so the player returns to the exact pre-replay state.
type SavedQuestContext struct {
Active bool
CurrentQuestSceneId int32
HeadQuestSceneId int32
CurrentMainQuestRouteId int32
MainQuestSeasonId int32
IsReachedLastQuestScene bool
PortalCageInProgress bool
CurrentQuestFlowType int32
}
type EventQuestState struct { type EventQuestState struct {
CurrentEventQuestChapterId int32 CurrentEventQuestChapterId int32
CurrentQuestId int32 CurrentQuestId int32
@@ -566,6 +609,7 @@ type BigHuntMaxScore struct {
type BigHuntStatus struct { type BigHuntStatus struct {
DailyChallengeCount int32 DailyChallengeCount int32
LatestChallengeDatetime int64 LatestChallengeDatetime int64
LastDailyRewardReceivedDayVersion int64
LatestVersion int64 LatestVersion int64
} }
@@ -629,6 +673,16 @@ type BigHuntBattleDetail struct {
BossKnockDownCount int32 BossKnockDownCount int32
MaxComboCount int32 MaxComboCount int32
TotalDamage int64 TotalDamage int64
CostumeBattleInfo []BigHuntCostumeBattleInfo
}
type BigHuntCostumeBattleInfo struct {
WaveIndex int32
CostumeId int32
TotalDamage int64
HitCount int32
RandomDisplayValueType int32
RandomDisplayValue int64
} }
type BattleState struct { type BattleState struct {
@@ -811,6 +865,33 @@ type CageOrnamentRewardState struct {
LatestVersion int64 LatestVersion int64
} }
type TowerAccumulationRewardState struct {
EventQuestChapterId int32
LatestRewardReceiveQuestMissionClearCount int32
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
@@ -837,6 +918,12 @@ type PartsPresetState struct {
LatestVersion int64 LatestVersion int64
} }
type PartsPresetTagState struct {
UserPartsPresetTagNumber int32
Name string
LatestVersion int64
}
type PartsStatusSubKey struct { type PartsStatusSubKey struct {
UserPartsUuid string UserPartsUuid string
StatusIndex int32 StatusIndex int32
+37 -10
View File
@@ -100,8 +100,11 @@ func ChangedTables(before, after *store.UserState) []string {
add("IUserMainQuestFlowStatus") add("IUserMainQuestFlowStatus")
add("IUserMainQuestMainFlowStatus") add("IUserMainQuestMainFlowStatus")
add("IUserMainQuestProgressStatus") add("IUserMainQuestProgressStatus")
add("IUserMainQuestSeasonRoute")
add("IUserMainQuestReplayFlowStatus") add("IUserMainQuestReplayFlowStatus")
// IUserMainQuestSeasonRoute is derived from MainQuest + Quests at projection
// time (see proj_quest.go / questflow.QuestHandler.SeasonRoutesFor) — flag it
// whenever either of those upstream inputs changes.
add("IUserMainQuestSeasonRoute")
} }
if before.EventQuest != after.EventQuest { if before.EventQuest != after.EventQuest {
add("IUserEventQuestProgressStatus") add("IUserEventQuestProgressStatus")
@@ -161,6 +164,9 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) { if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) {
add("IUserPartsPreset") add("IUserPartsPreset")
} }
if !mapsEqualStruct(before.PartsPresetTags, after.PartsPresetTags) {
add("IUserPartsPresetTag")
}
if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) { if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) {
add("IUserPartsStatusSub") add("IUserPartsStatusSub")
} }
@@ -186,6 +192,9 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.Decks, after.Decks) { if !mapsEqualStruct(before.Decks, after.Decks) {
add("IUserDeck") add("IUserDeck")
} }
if !mapsEqualStruct(before.TripleDecks, after.TripleDecks) {
add("IUserTripleDeck")
}
if !mapsEqualSliceValues(before.DeckSubWeapons, after.DeckSubWeapons) { if !mapsEqualSliceValues(before.DeckSubWeapons, after.DeckSubWeapons) {
add("IUserDeckSubWeaponGroup") add("IUserDeckSubWeaponGroup")
} }
@@ -194,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")
@@ -255,6 +265,12 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) { if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) {
add("IUserCageOrnamentReward") add("IUserCageOrnamentReward")
} }
if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) {
add("IUserEventQuestTowerAccumulationReward")
}
if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) {
add("IUserEventQuestLabyrinthStage")
}
if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) { if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) {
add("IUserBigHuntMaxScore") add("IUserBigHuntMaxScore")
@@ -273,10 +289,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) {
@@ -294,17 +312,16 @@ func ComputeDelta(before, after *store.UserState, changedTables []string) map[st
diff := make(map[string]*pb.DiffData, len(changedTables)) diff := make(map[string]*pb.DiffData, len(changedTables))
for _, table := range changedTables { for _, table := range changedTables {
afterJSON := projectTable(table, *after) afterJSON := projectTable(table, *after)
updates := afterJSON
deleteKeys := "[]" deleteKeys := "[]"
if kf := keyFieldsForTable(table); len(kf) > 0 { if kf := keyFieldsForTable(table); len(kf) > 0 {
beforeJSON := projectTable(table, *before) beforeRecs := parseJSONRecords(projectTable(table, *before))
deleteKeys = ComputeDeleteKeys( afterRecs := parseJSONRecords(afterJSON)
parseJSONRecords(beforeJSON), updates = ComputeUpdateRecords(beforeRecs, afterRecs, kf)
parseJSONRecords(afterJSON), deleteKeys = ComputeDeleteKeys(beforeRecs, afterRecs, kf)
kf,
)
} }
diff[table] = &pb.DiffData{ diff[table] = &pb.DiffData{
UpdateRecordsJson: afterJSON, UpdateRecordsJson: updates,
DeleteKeysJson: deleteKeys, DeleteKeysJson: deleteKeys,
} }
} }
@@ -357,6 +374,8 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "userDeckCharacterUuid"} return []string{"userId", "userDeckCharacterUuid"}
case "IUserDeck": case "IUserDeck":
return []string{"userId", "deckType", "userDeckNumber"} return []string{"userId", "deckType", "userDeckNumber"}
case "IUserTripleDeck":
return []string{"userId", "deckType", "userDeckNumber"}
case "IUserDeckSubWeaponGroup": case "IUserDeckSubWeaponGroup":
return []string{"userId", "userDeckCharacterUuid", "sortOrder"} return []string{"userId", "userDeckCharacterUuid", "sortOrder"}
case "IUserDeckPartsGroup": case "IUserDeckPartsGroup":
@@ -413,8 +432,14 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "partsGroupId"} return []string{"userId", "partsGroupId"}
case "IUserPartsPreset": case "IUserPartsPreset":
return []string{"userId", "userPartsPresetNumber"} return []string{"userId", "userPartsPresetNumber"}
case "IUserPartsPresetTag":
return []string{"userId", "userPartsPresetTagNumber"}
case "IUserCageOrnamentReward": case "IUserCageOrnamentReward":
return []string{"userId", "cageOrnamentId"} return []string{"userId", "cageOrnamentId"}
case "IUserEventQuestTowerAccumulationReward":
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":
@@ -435,6 +460,8 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "dokanId"} return []string{"userId", "dokanId"}
case "IUserSideStoryQuest": case "IUserSideStoryQuest":
return []string{"userId", "sideStoryQuestId"} return []string{"userId", "sideStoryQuestId"}
case "IUserMainQuestSeasonRoute":
return []string{"userId", "mainQuestSeasonId", "mainQuestRouteId"}
case "IUserQuestLimitContentStatus": case "IUserQuestLimitContentStatus":
return []string{"userId", "questId"} return []string{"userId", "questId"}
case "IUserBigHuntMaxScore": case "IUserBigHuntMaxScore":
+29
View File
@@ -3,6 +3,7 @@ package userdata
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect"
"strings" "strings"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
@@ -120,6 +121,34 @@ func ComputeDeleteKeys(oldRecords, newRecords []map[string]any, keyFields []stri
return string(b) return string(b)
} }
func ComputeUpdateRecords(oldRecords, newRecords []map[string]any, keyFields []string) string {
if len(newRecords) == 0 {
return "[]"
}
oldByKey := make(map[string]map[string]any, len(oldRecords))
for _, r := range oldRecords {
oldByKey[compositeKey(r, keyFields)] = r
}
var changed []map[string]any
for _, r := range newRecords {
prev, exists := oldByKey[compositeKey(r, keyFields)]
if !exists || !reflect.DeepEqual(prev, r) {
changed = append(changed, r)
}
}
if len(changed) == 0 {
return "[]"
}
b, err := json.Marshal(changed)
if err != nil {
return "[]"
}
return string(b)
}
func compositeKey(record map[string]any, fields []string) string { func compositeKey(record map[string]any, fields []string) string {
var sb strings.Builder var sb strings.Builder
for i, f := range fields { for i, f := range fields {
+1
View File
@@ -61,6 +61,7 @@ func init() {
"bigHuntBossQuestId": int32(id), "bigHuntBossQuestId": int32(id),
"dailyChallengeCount": st.DailyChallengeCount, "dailyChallengeCount": st.DailyChallengeCount,
"latestChallengeDatetime": st.LatestChallengeDatetime, "latestChallengeDatetime": st.LatestChallengeDatetime,
"lastDailyRewardReceivedDayVersion": st.LastDailyRewardReceivedDayVersion,
"latestVersion": st.LatestVersion, "latestVersion": st.LatestVersion,
}) })
} }
+33
View File
@@ -13,6 +13,10 @@ func init() {
s, _ := utils.EncodeJSONMaps(sortedDeckRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedDeckRecords(user)...)
return s return s
}) })
register("IUserTripleDeck", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedTripleDeckRecords(user)...)
return s
})
register("IUserDeckCharacter", func(user store.UserState) string { register("IUserDeckCharacter", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedDeckCharacterRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedDeckCharacterRecords(user)...)
return s return s
@@ -68,6 +72,35 @@ func sortedDeckRecords(user store.UserState) []map[string]any {
return records return records
} }
func sortedTripleDeckRecords(user store.UserState) []map[string]any {
keys := make([]store.DeckKey, 0, len(user.TripleDecks))
for key := range user.TripleDecks {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].DeckType != keys[j].DeckType {
return keys[i].DeckType < keys[j].DeckType
}
return keys[i].UserDeckNumber < keys[j].UserDeckNumber
})
records := make([]map[string]any, 0, len(keys))
for _, key := range keys {
row := user.TripleDecks[key]
records = append(records, map[string]any{
"userId": user.UserId,
"deckType": int32(row.DeckType),
"userDeckNumber": row.UserDeckNumber,
"name": row.Name,
"deckNumber01": row.DeckNumber01,
"deckNumber02": row.DeckNumber02,
"deckNumber03": row.DeckNumber03,
"latestVersion": row.LatestVersion,
})
}
return records
}
func sortedDeckCharacterRecords(user store.UserState) []map[string]any { func sortedDeckCharacterRecords(user store.UserState) []map[string]any {
keys := sortedStringKeys(user.DeckCharacters) keys := sortedStringKeys(user.DeckCharacters)
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
+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 {
+23 -1
View File
@@ -86,6 +86,10 @@ func init() {
s, _ := utils.EncodeJSONMaps(sortedPartsPresetRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedPartsPresetRecords(user)...)
return s return s
}) })
register("IUserPartsPresetTag", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedPartsPresetTagRecords(user)...)
return s
})
register("IUserCostumeAwakenStatusUp", func(user store.UserState) string { register("IUserCostumeAwakenStatusUp", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...)
return s return s
@@ -122,7 +126,6 @@ func init() {
"IUserCostumeLevelBonusReleaseStatus", "IUserCostumeLevelBonusReleaseStatus",
"IUserCostumeLotteryEffectAbility", "IUserCostumeLotteryEffectAbility",
"IUserCostumeLotteryEffectStatusUp", "IUserCostumeLotteryEffectStatusUp",
"IUserPartsPresetTag",
) )
} }
@@ -496,6 +499,25 @@ func sortedPartsPresetRecords(user store.UserState) []map[string]any {
return records return records
} }
func sortedPartsPresetTagRecords(user store.UserState) []map[string]any {
ids := make([]int, 0, len(user.PartsPresetTags))
for id := range user.PartsPresetTags {
ids = append(ids, int(id))
}
sort.Ints(ids)
records := make([]map[string]any, 0, len(ids))
for _, id := range ids {
row := user.PartsPresetTags[int32(id)]
records = append(records, map[string]any{
"userId": user.UserId,
"userPartsPresetTagNumber": row.UserPartsPresetTagNumber,
"name": row.Name,
"latestVersion": row.LatestVersion,
})
}
return records
}
func sortedPartsStatusSubRecords(user store.UserState) []map[string]any { func sortedPartsStatusSubRecords(user store.UserState) []map[string]any {
keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs)) keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs))
for k := range user.PartsStatusSubs { for k := range user.PartsStatusSubs {
@@ -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
})
}

Some files were not shown because too many files have changed in this diff Show More