40 Commits

Author SHA1 Message Date
Ilya Groshev dc7c1df4fd Add campaign bonuses; fix parts variant/sub-stat grants and menu-pick quest resume state
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-25 09:31:53 +03:00
Ilya Groshev 2d0c0d8ef0 Add cross-platform prebuilt release workflow
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-22 23:12:08 +03:00
Ilya Groshev 810adcf990 Derive main-quest season routes at projection time 2026-05-22 17:24:30 +03:00
Ilya Groshev ef69c54949 Pair gacha costume bonuses via curated lookup table
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-21 17:49:19 +03:00
Ilya Groshev b65c1c5fce Implement world-map entities
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-21 14:15:11 +03:00
Ilya Groshev ab5a999ffe Fix black screen re-entering a side story with no quest cleared
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-18 20:52:19 +03:00
Ilya Groshev 8520b67a8b Fix endless battle loop on background quests with battle scenes 2026-05-18 19:52:27 +03:00
Ilya Groshev 42ff8ec88f Fix replay flow corrupting the main-flow scene pointer
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-18 13:09:21 +03:00
Ilya Groshev 2cf0c153e1 Implement Fate Boards
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-17 18:21:57 +03:00
Ilya Groshev 956dbfaefd Fix retire wiping the cleared status of event and extra quests
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled
2026-05-17 11:29:16 +03:00
Ilya Groshev 0d46ee4557 Fix replay flow loop on background quests and another-route flow type 2026-05-17 11:02:40 +03:00
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
123 changed files with 11695 additions and 1540 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/dev
server/wizard
server/wizard-restore
server/.wizard.json
__pycache__/
+151 -66
View File
@@ -5,7 +5,14 @@ Discord server: https://discord.gg/MZAf5aVkJG
## 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+
- [goose](https://github.com/pressly/goose) migration tool
@@ -24,7 +31,13 @@ cd server
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
@@ -34,11 +47,13 @@ By default the wizard uses ports 8003 (gRPC), 8080 (CDN), and 3000 (auth). Overr
go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
```
| Flag | Default | Description |
| ------------- | ------- | ---------------- |
| `--grpc-port` | `8003` | gRPC server port |
| `--cdn-port` | `8080` | CDN server port |
| `--auth-port` | `3000` | Auth server port |
| Flag | Default | Description |
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `--prefer-saved` | `false` | Reuse saved config without prompting |
| `--grpc-port` | `8003` | gRPC server port |
| `--cdn-port` | `8080` | CDN 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.
@@ -66,6 +81,17 @@ mkdir -p db
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
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,9 +112,9 @@ go run ./cmd/import-snapshot \
| Flag | Default | Description |
| ------------ | ------------ | --------------------------------------------- |
| `--snapshot` | *(required)* | Path to JSON snapshot file |
| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) |
| `--db` | `db/game.db` | SQLite database path |
| `--snapshot` | _(required)_ | Path to JSON snapshot file |
| `--uuid` | _(required)_ | UUID to assign (must match the client's UUID) |
| `--db` | `db/game.db` | SQLite database path |
### Run
@@ -155,42 +181,68 @@ Or via `make`:
make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
```
| Flag | Default | Description |
| --------------------- | ------------------ | ---------------------------------------- |
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
| `--auth.db` | `db/auth.db` | auth-server SQLite database path |
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
| `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr |
| `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address |
| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
| `--grpc.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 |
| `--no-color` | `false` | disable colored output |
| Flag | Default | Description |
| -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
| `--auth.db` | `db/auth.db` | auth-server SQLite database path |
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
| `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr |
| `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address |
| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
| `--grpc.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 |
| `--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 |
### Ports
| Protocol | Port | Binary | Notes |
| -------- | ---- | ------------- | ----------------------------------------------------------- |
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
| Protocol | Port | Binary | Notes |
| -------- | ---- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
| 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`)
| Flag | Default | Description |
| --------------- | ----------------- | ---------------------------------------------------- |
| `--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 |
| `--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 |
| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) |
| Flag | Default | Description |
| ---------------- | ---------------- | --------------------------------------------------------------------------- |
| `--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 |
| `--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 |
| `--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`)
| Flag | Default | Description |
| --------------- | ----------------- | -------------------------------------------------------- |
| `--listen` | `0.0.0.0:8080` | local bind address |
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
| `--assets-dir` | `.` | root directory containing the `assets/` tree |
| Flag | Default | Description |
| --------------- | ---------------- | --------------------------------------------------------- |
| `--listen` | `0.0.0.0:8080` | local bind address |
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
| `--assets-dir` | `.` | root directory containing the `assets/` tree |
### Docker
@@ -205,32 +257,45 @@ 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:
| Service | Image | Default Port | Notes |
| -------- | --------------------------- | ------------ | ------------------------------ |
| `server` | `kretts/lunar-tear:latest` | 8003 | gRPC game server |
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
| Service | Image | Default Port | Notes |
| -------- | --------------------------- | ------------ | -------------------------------- |
| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
| `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
All targets run from the `server/` directory.
| Target | Description |
| -------------- | ------------------------------------------------------- |
| `make proto` | Regenerate protobuf stubs |
| `make build` | Build the game server binary |
| `make build-cdn` | Build the CDN binary |
| `make build-auth` | Build the auth server binary |
| `make build-dev` | Build the dev runner binary to `bin/` |
| `make build-all` | Build all service binaries to `bin/` |
| `make build-import` | Build the import-snapshot tool |
| `make build-claim-account` | Build the claim-account tool |
| `make clean` | Remove the `bin/` directory |
| `make dev` | Run all three services with one command |
| `make migrate` | Run goose migrations on `db/game.db` |
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
| Target | Description |
| ----------------------------- | ------------------------------------------------------ |
| `make proto` | Regenerate protobuf stubs |
| `make build` | Build the game server binary |
| `make build-cdn` | Build the CDN binary |
| `make build-auth` | Build the auth server binary |
| `make build-dev` | Build the dev runner binary to `bin/` |
| `make build-all` | Build all service binaries to `bin/` |
| `make build-import` | Build the import-snapshot 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 dev` | Run all three services with one command |
| `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) |
## Claim Account
@@ -243,10 +308,10 @@ cd server
go run ./cmd/claim-account --name "PlayerName" --db db/game.db
```
| Flag | Default | Description |
| -------- | ------------ | ---------------------------------------------------- |
| `--name` | *(required)* | In-game player name to claim |
| `--db` | `db/game.db` | SQLite database path |
| Flag | Default | Description |
| -------- | ------------ | ---------------------------- |
| `--name` | _(required)_ | In-game player name to claim |
| `--db` | `db/game.db` | SQLite database path |
## Auth Server
@@ -265,11 +330,31 @@ The `--secret` flag accepts a hex-encoded HMAC key. If omitted, a random key is
### Flags
| Flag | Default | Description |
| ---------- | --------------- | -------------------------------------------- |
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
| `--db` | `db/auth.db` | SQLite database path for auth users |
| `--secret` | *(generated)* | Hex-encoded HMAC secret for token signing |
| Flag | Default | Description |
| --------------- | -------------- | -------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
| `--db` | `db/auth.db` | SQLite database path for auth users |
| `--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
+54
View File
@@ -1,5 +1,59 @@
# 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
### Working
+8 -16
View File
@@ -30,38 +30,30 @@ build-auth:
build-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:
go build -o bin/dev$(EXE) ./cmd/dev
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/auth-server$(EXE) ./cmd/auth-server
go build -o bin/octo-cdn$(EXE) ./cmd/octo-cdn
go build -o bin/lunar-tear$(EXE) ./cmd/lunar-tear
clean:
ifeq ($(OS),Windows_NT)
if exist bin rmdir /s /q bin
else
rm -rf bin
endif
rm -rf bin/*
dev:
go run ./cmd/dev $(ARGS)
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
restore:
go run ./cmd/wizard-restore
import:
ifndef SNAPSHOT
$(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...)
@@ -71,4 +63,4 @@ ifndef UUID
endif
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"
"html/template"
"log"
"lunar-tear/server/internal/auth"
"net/http"
"net/url"
"strconv"
@@ -18,18 +19,40 @@ var loginFS embed.FS
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 {
store *AuthStore
tok *TokenService
store *auth.AuthStore
tok *auth.TokenService
noRegister bool
}
func NewHandlers(store *AuthStore, tok *TokenService) *Handlers {
return &Handlers{store: store, tok: tok}
func NewHandlers(store *auth.AuthStore, tok *auth.TokenService, noRegister bool) *Handlers {
return &Handlers{store: store, tok: tok, noRegister: noRegister}
}
type loginPageData struct {
RedirectURI string
State string
Scope string
Error string
Username string
}
@@ -77,6 +100,7 @@ func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
data := loginPageData{
RedirectURI: r.URL.Query().Get("redirect_uri"),
State: r.URL.Query().Get("state"),
Scope: r.URL.Query().Get("scope"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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")
redirectURI := r.FormValue("redirect_uri")
state := r.FormValue("state")
scope := r.FormValue("scope")
renderErr := func(msg string) {
data := loginPageData{
RedirectURI: redirectURI,
State: state,
Scope: scope,
Error: msg,
Username: username,
}
@@ -115,13 +141,18 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
return
}
var user AuthUser
var user auth.AuthUser
var err error
switch action {
case "register":
if h.noRegister {
renderErr("This server does not accept user registrations.")
return
}
user, err = h.store.CreateUser(username, password)
if err == ErrUserExists {
if err == auth.ErrUserExists {
renderErr("Username is already taken.")
return
}
@@ -134,7 +165,7 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
case "login":
user, err = h.store.VerifyUser(username, password)
if err == ErrInvalidCreds {
if err == auth.ErrInvalidCreds {
renderErr("Invalid username or password.")
return
}
@@ -158,20 +189,31 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
}
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
b64 := base64.RawURLEncoding.EncodeToString([]byte(payload))
fragment := url.Values{}
fragment.Set("access_token", token)
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)
// 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 != "" {
fragment.Set("state", state)
}
target := redirectURI + "?" + fragment.Encode()
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) {
+1
View File
@@ -128,6 +128,7 @@
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
+6 -3
View File
@@ -8,6 +8,8 @@ import (
"log"
"net/http"
"lunar-tear/server/internal/auth"
_ "modernc.org/sqlite"
)
@@ -15,6 +17,7 @@ func main() {
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")
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()
hmacSecret := []byte(*secret)
@@ -33,13 +36,13 @@ func main() {
}
defer db.Close()
store, err := NewAuthStore(db)
store, err := auth.NewAuthStore(db)
if err != nil {
log.Fatalf("init auth store: %v", err)
}
tok := NewTokenService(hmacSecret)
h := NewHandlers(store, tok)
tok := auth.NewTokenService(hmacSecret)
h := NewHandlers(store, tok, *noRegister)
mux := http.NewServeMux()
mux.HandleFunc("/", h.HandleOAuth)
+1
View File
@@ -40,6 +40,7 @@ var childTables = []string{
"user_big_hunt_max_scores",
"user_quest_limit_content_status",
"user_side_story_quests",
"user_main_quest_season_routes",
"user_missions",
"user_quest_missions",
"user_quests",
+44 -7
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)")
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")
flag.Parse()
if *grpcOctoURL == "" {
@@ -111,13 +120,22 @@ func main() {
colorCyan = ""
}
log.Println("building services...")
buildAll()
if _, err := os.Stat("go.mod"); err == nil {
log.Println("building services...")
buildAll()
} else {
log.Println("prebuilt mode: skipping build, using bin/ from archive")
}
ext := binExt()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
noreg_s := ""
if *noRegister {
noreg_s = "--no-register"
}
services := []service{
{
label: "auth",
@@ -125,6 +143,7 @@ func main() {
cmd: exec.CommandContext(ctx, filepath.Join("bin", "auth-server"+ext),
"--listen", *authListen,
"--db", *authDB,
noreg_s,
),
},
{
@@ -139,11 +158,7 @@ func main() {
label: "grpc",
color: colorYellow,
cmd: exec.CommandContext(ctx, filepath.Join("bin", "lunar-tear"+ext),
"--listen", *grpcListen,
"--public-addr", *grpcPublicAddr,
"--db", *grpcDB,
"--octo-url", *grpcOctoURL,
"--auth-url", *grpcAuthURL,
grpcArgs(*grpcListen, *grpcPublicAddr, *grpcDB, *grpcOctoURL, *grpcAuthURL, *adminListen, *noRegister)...,
),
},
}
@@ -200,3 +215,25 @@ func prefixLines(wg *sync.WaitGroup, prefix string, r io.Reader) {
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"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/interceptor"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/service"
"lunar-tear/server/internal/store"
@@ -40,27 +38,8 @@ func startGRPC(
store.UserRepository
store.SessionRepository
},
questEngine *questflow.QuestHandler,
gachaHandler *gacha.GachaHandler,
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,
holder *runtime.Holder,
noRegister bool,
) *grpc.Server {
lis, err := net.Listen("tcp", listenAddr)
if err != nil {
@@ -74,39 +53,17 @@ func startGRPC(
grpc.UnknownServiceHandler(interceptor.UnknownService),
)
registerServices(grpcServer,
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,
)
registerServices(grpcServer, publicAddr, octoURL, authURL, userStore, holder, noRegister)
reflection.Register(grpcServer)
log.Printf("gRPC server listening on %s", lis.Addr())
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() {
if err := grpcServer.Serve(lis); err != nil {
log.Printf("gRPC server stopped: %v", err)
@@ -124,66 +81,48 @@ func registerServices(
store.UserRepository
store.SessionRepository
},
questEngine *questflow.QuestHandler,
gachaHandler *gacha.GachaHandler,
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,
holder *runtime.Holder,
noRegister bool,
) {
pubHost, pubPortStr, _ := net.SplitHostPort(publicAddr)
pubPort, _ := strconv.Atoi(pubPortStr)
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(gachaEntries))
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, authURL))
pb.RegisterBannerServiceServer(srv, service.NewBannerServiceServer(holder))
pb.RegisterUserServiceServer(srv, service.NewUserServiceServer(userStore, userStore, holder, authURL, noRegister))
pb.RegisterBattleServiceServer(srv, service.NewBattleServiceServer(userStore, userStore))
pb.RegisterConfigServiceServer(srv, service.NewConfigServiceServer(pubHost, int32(pubPort), octoURL))
pb.RegisterDataServiceServer(srv, service.NewDataServiceServer(userStore, userStore))
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, questEngine))
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, gachaEntries, gachaHandler))
pb.RegisterTutorialServiceServer(srv, service.NewTutorialServiceServer(userStore, userStore, holder))
pb.RegisterGachaServiceServer(srv, service.NewGachaServiceServer(userStore, userStore, holder))
pb.RegisterGiftServiceServer(srv, service.NewGiftServiceServer(userStore, userStore))
pb.RegisterGamePlayServiceServer(srv, service.NewGameplayServiceServer())
pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, gimmickCatalog))
pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, questEngine))
pb.RegisterGimmickServiceServer(srv, service.NewGimmickServiceServer(userStore, userStore, holder))
pb.RegisterQuestServiceServer(srv, service.NewQuestServiceServer(userStore, userStore, holder))
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.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.RegisterContentsStoryServiceServer(srv, service.NewContentsStoryServiceServer(userStore, userStore))
pb.RegisterDokanServiceServer(srv, service.NewDokanServiceServer(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.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, shopCatalog, questEngine.Granter))
pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, costumeCatalog, gameConfig))
pb.RegisterShopServiceServer(srv, service.NewShopServiceServer(userStore, userStore, holder))
pb.RegisterCostumeServiceServer(srv, service.NewCostumeServiceServer(userStore, userStore, holder))
pb.RegisterMovieServiceServer(srv, service.NewMovieServiceServer(userStore, userStore))
pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, omikujiCatalog))
pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, weaponCatalog, gameConfig))
pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, exploreCatalog))
pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, characterBoardCatalog))
pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, partsCatalog, gameConfig))
pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, characterRebirthCatalog, gameConfig))
pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, companionCatalog, gameConfig))
pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, materialCatalog, gameConfig))
pb.RegisterConsumableItemServiceServer(srv, service.NewConsumableItemServiceServer(userStore, userStore, consumableItemCatalog, gameConfig))
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, sideStoryCatalog))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, bigHuntCatalog, questEngine))
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, bigHuntCatalog, questEngine.Granter))
pb.RegisterOmikujiServiceServer(srv, service.NewOmikujiServiceServer(userStore, userStore, holder))
pb.RegisterWeaponServiceServer(srv, service.NewWeaponServiceServer(userStore, userStore, holder))
pb.RegisterExploreServiceServer(srv, service.NewExploreServiceServer(userStore, userStore, holder))
pb.RegisterCharacterBoardServiceServer(srv, service.NewCharacterBoardServiceServer(userStore, userStore, holder))
pb.RegisterPartsServiceServer(srv, service.NewPartsServiceServer(userStore, userStore, holder))
pb.RegisterCharacterServiceServer(srv, service.NewCharacterServiceServer(userStore, userStore, holder))
pb.RegisterCompanionServiceServer(srv, service.NewCompanionServiceServer(userStore, userStore, holder))
pb.RegisterMaterialServiceServer(srv, service.NewMaterialServiceServer(userStore, userStore, holder))
pb.RegisterConsumableItemServiceServer(srv, service.NewConsumableItemServiceServer(userStore, userStore, holder))
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder))
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"
"lunar-tear/server/internal/database"
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store/sqlite"
)
const masterDataPath = "assets/release/20240404193219.bin.e"
func main() {
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")
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)")
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()
if *octoURL == "" {
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 {
log.Fatalf("load master data: %v", err)
holder, err := runtime.NewHolder(masterDataPath)
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)
defer stop()
@@ -44,158 +45,11 @@ func main() {
defer db.Close()
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)
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
if err != nil {
log.Fatalf("load gacha catalog: %v", err)
}
log.Printf("gacha catalog loaded: %d entries", len(gachaEntries))
grpcServer := startGRPC(*listen, *publicAddr, *octoURL, *authURL, userStore, holder, *noRegister)
gachaPool, err := masterdata.LoadGachaPool()
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,
)
startAdmin(*adminListen, holder)
<-ctx.Done()
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()))
}
}
+160 -44
View File
@@ -32,13 +32,14 @@ const (
)
type config struct {
IP string `json:"ip"`
Device string `json:"device"`
Detail string `json:"detail"`
Summary string `json:"summary"`
GRPCPort int `json:"grpc_port,omitempty"`
CDNPort int `json:"cdn_port,omitempty"`
AuthPort int `json:"auth_port,omitempty"`
IP string `json:"ip"`
Device string `json:"device"`
Detail string `json:"detail"`
Summary string `json:"summary"`
GRPCPort int `json:"grpc_port,omitempty"`
CDNPort int `json:"cdn_port,omitempty"`
AuthPort int `json:"auth_port,omitempty"`
AdminPort int `json:"admin_port,omitempty"`
}
const (
@@ -47,17 +48,22 @@ const (
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 {
GRPC int
CDN int
Auth int
GRPC int
CDN int
Auth int
Admin int
}
func main() {
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")
cdnPort := flag.Int("cdn-port", defaultCDNPort, "CDN 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()
flagSet := map[string]bool{}
@@ -68,21 +74,29 @@ func main() {
fmt.Print(banner)
sourceMode = isSourceCheckout()
if !*setupOnly {
validateAssets()
validateTools()
validateProtocIncludes()
runProtoc()
runMigrate()
downloadDeps()
if sourceMode {
validateTools()
validateProtocIncludes()
runProtoc()
backupGameDB()
runMigrate()
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)
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) {
os.Exit(0)
}
@@ -91,6 +105,7 @@ func main() {
cfg.GRPCPort = p.GRPC
cfg.CDNPort = p.CDN
cfg.AuthPort = p.Auth
cfg.AdminPort = p.Admin
saveConfig(cfg)
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("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)))
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()
if firstRun || *setupOnly {
@@ -121,23 +139,68 @@ type assetCheck struct {
var requiredAssets = []assetCheck{
{"assets", true},
{"assets/release/20240404193219.bin.e", false},
{"assets/revisions/0/list.bin", false},
{"assets/revisions/0/assetbundle", true},
{"assets/revisions/0/resources", true},
}
// 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/assetbundle", 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() {
var missing []string
for _, a := range requiredAssets {
info, err := os.Stat(a.path)
if err != nil {
missing = append(missing, a.path)
continue
if m, ok := checkAsset(a); !ok {
missing = append(missing, m)
}
if a.dir && !info.IsDir() {
missing = append(missing, a.path+string(filepath.Separator))
} else if !a.dir && info.IsDir() {
missing = append(missing, a.path)
}
var treeMissing [][]string
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(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
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("\n")
@@ -289,7 +354,11 @@ func runProtoc() {
func runMigrate() {
_ = 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()
}
@@ -311,12 +380,25 @@ func runQuiet(cmd *exec.Cmd, label string) {
}
}
func resolveIP() (string, config, bool) {
if cfg, err := loadConfig(); err == nil {
func resolveIP(preferSaved bool) (string, config, bool) {
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)
if done {
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()
@@ -412,6 +494,22 @@ func warnPortChange(old, new ports) bool {
}
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
b.WriteString("\n")
@@ -422,7 +520,12 @@ func warnPortChange(old, new ports) bool {
b.WriteString(portLine("CDN", old.CDN, new.CDN))
b.WriteString("\n")
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("\n\n")
fmt.Print(b.String())
@@ -756,7 +859,7 @@ func loadConfig() (config, error) {
}
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 {
p.GRPC = defaultGRPCPort
}
@@ -766,10 +869,11 @@ func portsFromConfig(cfg config) ports {
if p.Auth == 0 {
p.Auth = defaultAuthPort
}
// Admin is opt-in: leave 0 = disabled.
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 {
if flagSet[name] {
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),
CDN: resolve("cdn-port", cdnFlag, saved.CDNPort, defaultCDNPort),
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,21 +908,30 @@ func launchDev(ip string, p ports) {
}
devBin := filepath.Join("bin", "dev"+ext)
_ = spinner.New().Title(" Building services...").Action(func() {
if err := os.MkdirAll("bin", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
os.Exit(1)
}
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run()
if sourceMode {
_ = spinner.New().Title(" Building services...").Action(func() {
if err := os.MkdirAll("bin", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
os.Exit(1)
}
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run()
}
cmd := exec.Command(devBin,
devArgs := []string{
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
"--grpc.public-addr", fmt.Sprintf("%s:%d", ip, p.GRPC),
"--cdn.listen", fmt.Sprintf("0.0.0.0:%d", p.CDN),
"--cdn.public-addr", fmt.Sprintf("%s:%d", ip, p.CDN),
"--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.Stderr = os.Stderr
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_OCTO_URL: http://cdn:8080
LUNAR_AUTH_URL: http://auth:3000
LUNAR_ADMIN_LISTEN: 0.0.0.0:8082
LUNAR_ADMIN_TOKEN: ${LUNAR_ADMIN_TOKEN:-}
volumes:
- ./db:/opt/lunar-tear/db
- ./assets:/opt/lunar-tear/assets
ports:
- 8003:8003
- 127.0.0.1:8082:8082
depends_on:
- cdn
- auth
+7 -1
View File
@@ -9,8 +9,14 @@ if [ -n "${LUNAR_AUTH_URL}" ]; then
AUTH_FLAG="--auth-url ${LUNAR_AUTH_URL}"
fi
ADMIN_FLAG=""
if [ -n "${LUNAR_ADMIN_LISTEN}" ]; then
ADMIN_FLAG="--admin-listen ${LUNAR_ADMIN_LISTEN}"
fi
exec ./lunar-tear \
--listen "${LUNAR_LISTEN:-0.0.0.0:443}" \
--public-addr "${LUNAR_PUBLIC_ADDR}" \
--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/vmihailenco/msgpack/v5 v5.4.1
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/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
modernc.org/sqlite v1.48.2
modernc.org/sqlite v1.49.1
)
require (
@@ -34,21 +34,25 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/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/vmihailenco/tagparser/v2 v2.0.0 // 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/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
modernc.org/libc v1.70.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // 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/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.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/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/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
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/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/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.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.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/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
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/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.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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
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.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/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/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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/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.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/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/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
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/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -1,4 +1,4 @@
package main
package auth
import (
"database/sql"
@@ -1,4 +1,4 @@
package main
package auth
import (
"crypto/hmac"
@@ -10,7 +10,7 @@ import (
"time"
)
const tokenTTL = 24 * time.Hour
const TokenTTL = 24 * time.Hour
var (
ErrTokenInvalid = errors.New("invalid token")
@@ -38,7 +38,7 @@ func (t *TokenService) Generate(user AuthUser) (string, error) {
Sub: user.ID,
Name: user.Username,
Iat: now,
Exp: now + int64(tokenTTL.Seconds()),
Exp: now + int64(TokenTTL.Seconds()),
}
payload, err := json.Marshal(claims)
+170
View File
@@ -0,0 +1,170 @@
package campaign
import (
"fmt"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/utils"
)
type Catalog struct {
enhance []enhanceRow
quest []questRow
}
type enhanceRow struct {
effectType EnhanceCampaignEffectType
effectValue int32
targets []enhanceMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type enhanceMatch struct {
t EnhanceCampaignTargetType
v int32
}
type questRow struct {
effectType QuestCampaignEffectType
effectValue int32
bonusItems []BonusDrop
targets []questMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type questMatch struct {
t QuestCampaignTargetType
v int32
}
func Load() (*Catalog, error) {
enhance, err := loadEnhanceRows()
if err != nil {
return nil, fmt.Errorf("load enhance campaigns: %w", err)
}
quest, err := loadQuestRows()
if err != nil {
return nil, fmt.Errorf("load quest campaigns: %w", err)
}
return &Catalog{enhance: enhance, quest: quest}, nil
}
func (c *Catalog) EnhanceCount() int { return len(c.enhance) }
func (c *Catalog) QuestCount() int { return len(c.quest) }
func loadEnhanceRows() ([]enhanceRow, error) {
campaigns, err := utils.ReadTable[masterdata.EntityMEnhanceCampaign]("m_enhance_campaign")
if err != nil {
return nil, err
}
targets, err := utils.ReadTable[masterdata.EntityMEnhanceCampaignTargetGroup]("m_enhance_campaign_target_group")
if err != nil {
return nil, err
}
byGroup := make(map[int32][]enhanceMatch, len(targets))
for _, t := range targets {
byGroup[t.EnhanceCampaignTargetGroupId] = append(byGroup[t.EnhanceCampaignTargetGroupId], enhanceMatch{
t: EnhanceCampaignTargetType(t.EnhanceCampaignTargetType),
v: t.EnhanceCampaignTargetValue,
})
}
rows := make([]enhanceRow, 0, len(campaigns))
for _, c := range campaigns {
grp := byGroup[c.EnhanceCampaignTargetGroupId]
if len(grp) == 0 {
continue
}
rows = append(rows, enhanceRow{
effectType: EnhanceCampaignEffectType(c.EnhanceCampaignEffectType),
effectValue: c.EnhanceCampaignEffectValue,
targets: grp,
startMillis: c.StartDatetime,
endMillis: c.EndDatetime,
userStatus: TargetUserStatusType(c.TargetUserStatusType),
})
}
return rows, nil
}
func loadQuestRows() ([]questRow, error) {
campaigns, err := utils.ReadTable[masterdata.EntityMQuestCampaign]("m_quest_campaign")
if err != nil {
return nil, err
}
targets, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetGroup]("m_quest_campaign_target_group")
if err != nil {
return nil, err
}
effects, err := utils.ReadTable[masterdata.EntityMQuestCampaignEffectGroup]("m_quest_campaign_effect_group")
if err != nil {
return nil, err
}
itemGroups, err := utils.ReadTable[masterdata.EntityMQuestCampaignTargetItemGroup]("m_quest_campaign_target_item_group")
if err != nil {
return nil, err
}
targetsByGroup := make(map[int32][]questMatch, len(targets))
for _, t := range targets {
targetsByGroup[t.QuestCampaignTargetGroupId] = append(targetsByGroup[t.QuestCampaignTargetGroupId], questMatch{
t: QuestCampaignTargetType(t.QuestCampaignTargetType),
v: t.QuestCampaignTargetValue,
})
}
bonusByGroup := make(map[int32][]BonusDrop, len(itemGroups))
for _, ig := range itemGroups {
bonusByGroup[ig.QuestCampaignTargetItemGroupId] = append(bonusByGroup[ig.QuestCampaignTargetItemGroupId], BonusDrop{
PossessionType: ig.PossessionType,
PossessionId: ig.PossessionId,
Count: ig.Count,
})
}
effectByGroup := make(map[int32]masterdata.EntityMQuestCampaignEffectGroup, len(effects))
for _, e := range effects {
effectByGroup[e.QuestCampaignEffectGroupId] = e
}
rows := make([]questRow, 0, len(campaigns))
for _, c := range campaigns {
grp := targetsByGroup[c.QuestCampaignTargetGroupId]
if len(grp) == 0 {
continue
}
eff, ok := effectByGroup[c.QuestCampaignEffectGroupId]
if !ok {
continue
}
rows = append(rows, questRow{
effectType: QuestCampaignEffectType(eff.QuestCampaignEffectType),
effectValue: eff.QuestCampaignEffectValue,
bonusItems: bonusByGroup[eff.QuestCampaignTargetItemGroupId],
targets: grp,
startMillis: c.StartDatetime,
endMillis: c.EndDatetime,
userStatus: TargetUserStatusType(c.TargetUserStatusType),
})
}
return rows, nil
}
func (r enhanceRow) isActive(f Filter) bool {
if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis {
return false
}
return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus
}
func (r questRow) isActive(f Filter) bool {
if f.NowMillis < r.startMillis || f.NowMillis > r.endMillis {
return false
}
return r.userStatus == TargetUserStatusAll || r.userStatus == f.UserStatus
}
+113
View File
@@ -0,0 +1,113 @@
package campaign
func (c *Catalog) PartsRateBonus(t PartsTarget, f Filter) RateBonus {
var out RateBonus
for _, r := range c.enhance {
if !r.isActive(f) {
continue
}
if !matchesParts(r.targets, t) {
continue
}
out = applyEnhanceEffect(out, r)
}
return out
}
func (c *Catalog) CostumeExpBonus(t CostumeTarget, f Filter) ExpBonus {
var sum int32
for _, r := range c.enhance {
if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm {
continue
}
if matchesCostume(r.targets, t) {
sum += r.effectValue
}
}
return ExpBonus{bonusPermil: sum}
}
func (c *Catalog) WeaponExpBonus(t WeaponTarget, f Filter) ExpBonus {
var sum int32
for _, r := range c.enhance {
if !r.isActive(f) || r.effectType != EnhanceEffectAdditionalPerm {
continue
}
if matchesWeapon(r.targets, t) {
sum += r.effectValue
}
}
return ExpBonus{bonusPermil: sum}
}
func applyEnhanceEffect(b RateBonus, r enhanceRow) RateBonus {
switch r.effectType {
case EnhanceEffectProbability:
b.override = r.effectValue
case EnhanceEffectAdditionalPerm:
b.bonusPermil += r.effectValue
}
return b
}
func matchesParts(targets []enhanceMatch, t PartsTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetPartsAll:
return true
case EnhanceTargetPartsSeriesId:
if m.v == t.PartsGroupId {
return true
}
case EnhanceTargetPartsId:
if m.v == t.PartsId {
return true
}
}
}
return false
}
func matchesCostume(targets []enhanceMatch, t CostumeTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetCostumeAll:
return true
case EnhanceTargetCostumeCharacterId:
if m.v == t.CharacterId {
return true
}
case EnhanceTargetCostumeSkillfulWeapon:
if m.v == t.SkillfulWeaponType {
return true
}
case EnhanceTargetCostumeId:
if m.v == t.CostumeId {
return true
}
}
}
return false
}
func matchesWeapon(targets []enhanceMatch, t WeaponTarget) bool {
for _, m := range targets {
switch m.t {
case EnhanceTargetWeaponAll:
return true
case EnhanceTargetWeaponTypeId:
if m.v == t.WeaponType {
return true
}
case EnhanceTargetWeaponAttributeTypeId:
if m.v == t.AttributeType {
return true
}
case EnhanceTargetWeaponId:
if m.v == t.WeaponId {
return true
}
}
}
return false
}
+57
View File
@@ -0,0 +1,57 @@
package campaign
type RateBonus struct {
override int32
bonusPermil int32
}
func (b RateBonus) Apply(basePermil int32) int32 {
base := basePermil
if b.override > 0 {
base = b.override
}
return clampPermil(base + b.bonusPermil)
}
type ExpBonus struct {
bonusPermil int32
}
func (b ExpBonus) Apply(base int32) int32 {
return base * (1000 + b.bonusPermil) / 1000
}
type StaminaMul struct {
permil int32
}
func (m StaminaMul) Apply(base int32) int32 {
if m.permil == 1000 {
return base
}
return base * m.permil / 1000
}
type DropRateMul struct {
bonusPermil int32
}
func (m DropRateMul) Apply(base int32) int32 {
return (base*(1000+m.bonusPermil) + 999) / 1000
}
type BonusDrop struct {
PossessionType int32
PossessionId int32
Count int32
}
func clampPermil(v int32) int32 {
if v < 0 {
return 0
}
if v > 1000 {
return 1000
}
return v
}
+85
View File
@@ -0,0 +1,85 @@
package campaign
func (c *Catalog) QuestStamina(t QuestTarget, f Filter) StaminaMul {
return questPermilMin(c.quest, QuestEffectStaminaConsume, t, f)
}
func (c *Catalog) QuestDropRate(t QuestTarget, f Filter) DropRateMul {
var best int32
for _, r := range c.quest {
if !r.isActive(f) || r.effectType != QuestEffectDropRate {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
if r.effectValue > best {
best = r.effectValue
}
}
return DropRateMul{bonusPermil: best}
}
func (c *Catalog) QuestBonusDrops(t QuestTarget, f Filter) []BonusDrop {
var out []BonusDrop
for _, r := range c.quest {
if !r.isActive(f) || r.effectType != QuestEffectDropItemAdd {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
out = append(out, r.bonusItems...)
}
return out
}
func questPermilMin(rows []questRow, want QuestCampaignEffectType, t QuestTarget, f Filter) StaminaMul {
min := int32(1000)
for _, r := range rows {
if !r.isActive(f) || r.effectType != want {
continue
}
if !matchesQuest(r.targets, t) {
continue
}
if r.effectValue < min {
min = r.effectValue
}
}
return StaminaMul{permil: min}
}
func matchesQuest(targets []questMatch, t QuestTarget) bool {
for _, m := range targets {
switch m.t {
case QuestTargetWholeQuest:
return true
case QuestTargetQuestType:
if int32(t.QuestType) == m.v {
return true
}
case QuestTargetEventQuestType:
if t.QuestType == QuestTypeEventQuest && t.EventQuestType == m.v {
return true
}
case QuestTargetMainQuestChapterId:
if t.QuestType == QuestTypeMainQuest && t.ChapterId == m.v {
return true
}
case QuestTargetMainQuestQuestId:
if t.QuestType == QuestTypeMainQuest && t.QuestId == m.v {
return true
}
case QuestTargetSubQuestChapterId:
if t.QuestType == QuestTypeEventQuest && t.ChapterId == m.v {
return true
}
case QuestTargetSubQuestQuestId:
if t.QuestType == QuestTypeEventQuest && t.QuestId == m.v {
return true
}
}
}
return false
}
+101
View File
@@ -0,0 +1,101 @@
package campaign
import "lunar-tear/server/internal/model"
type EnhanceCampaignEffectType int32
const (
EnhanceEffectUnknown EnhanceCampaignEffectType = 0
EnhanceEffectProbability EnhanceCampaignEffectType = 1
EnhanceEffectAdditionalPerm EnhanceCampaignEffectType = 2
)
type EnhanceCampaignTargetType int32
const (
EnhanceTargetUnknown EnhanceCampaignTargetType = 0
EnhanceTargetCostumeAll EnhanceCampaignTargetType = 1
EnhanceTargetWeaponAll EnhanceCampaignTargetType = 2
EnhanceTargetPartsAll EnhanceCampaignTargetType = 3
EnhanceTargetCostumeCharacterId EnhanceCampaignTargetType = 11
EnhanceTargetCostumeSkillfulWeapon EnhanceCampaignTargetType = 12
EnhanceTargetCostumeId EnhanceCampaignTargetType = 13
EnhanceTargetWeaponTypeId EnhanceCampaignTargetType = 21
EnhanceTargetWeaponAttributeTypeId EnhanceCampaignTargetType = 22
EnhanceTargetWeaponId EnhanceCampaignTargetType = 23
EnhanceTargetPartsSeriesId EnhanceCampaignTargetType = 31
EnhanceTargetPartsId EnhanceCampaignTargetType = 32
)
type QuestCampaignEffectType int32
const (
QuestEffectUnknown QuestCampaignEffectType = 0
QuestEffectDropRate QuestCampaignEffectType = 1
QuestEffectDropCount QuestCampaignEffectType = 2
QuestEffectStaminaConsume QuestCampaignEffectType = 3
QuestEffectClearRewardGold QuestCampaignEffectType = 4
QuestEffectDropItemAdd QuestCampaignEffectType = 5
)
type QuestCampaignTargetType int32
const (
QuestTargetUnknown QuestCampaignTargetType = 0
QuestTargetWholeQuest QuestCampaignTargetType = 1
QuestTargetQuestType QuestCampaignTargetType = 2
QuestTargetEventQuestType QuestCampaignTargetType = 3
QuestTargetMainQuestChapterId QuestCampaignTargetType = 4
QuestTargetMainQuestQuestId QuestCampaignTargetType = 5
QuestTargetSubQuestChapterId QuestCampaignTargetType = 6
QuestTargetSubQuestQuestId QuestCampaignTargetType = 7
)
type QuestType int32
const (
QuestTypeUnknown QuestType = 0
QuestTypeMainQuest QuestType = 1
QuestTypeEventQuest QuestType = 2
QuestTypeExtraQuest QuestType = 3
QuestTypeBigHunt QuestType = 4
)
type TargetUserStatusType int32
const (
TargetUserStatusUnknown TargetUserStatusType = 0
TargetUserStatusAll TargetUserStatusType = 1
TargetUserStatusComeback TargetUserStatusType = 2
TargetUserStatusBeginner TargetUserStatusType = 3
)
type Filter struct {
NowMillis int64
UserStatus TargetUserStatusType
}
type PartsTarget struct {
PartsId int32
PartsGroupId int32
Rarity model.RarityType
}
type CostumeTarget struct {
CostumeId int32
CharacterId int32
SkillfulWeaponType int32
}
type WeaponTarget struct {
WeaponId int32
WeaponType int32
AttributeType int32
}
type QuestTarget struct {
QuestId int32
QuestType QuestType
EventQuestType int32
ChapterId int32
}
+27 -36
View File
@@ -49,22 +49,17 @@ type RewardItem struct {
Count int32
}
type BigHuntWeeklyRewardKey struct {
ScheduleId int32
AttributeType int32
}
type BigHuntCatalog struct {
BossQuestById map[int32]BigHuntBossQuestRow
QuestById map[int32]BigHuntQuestRow
ScoreCoefficients map[int32]int32
BossByBossId map[int32]BigHuntBossRow
GradeThresholds map[int32][]GradeThreshold
ActiveScheduleId int32
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
ScoreRewardThresholds map[int32][]ScoreRewardThreshold
RewardItems map[int32][]RewardItem
WeeklyRewardSchedules map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry
BossQuestById map[int32]BigHuntBossQuestRow
QuestById map[int32]BigHuntQuestRow
ScoreCoefficients map[int32]int32
BossByBossId map[int32]BigHuntBossRow
GradeThresholds map[int32][]GradeThreshold
ActiveScheduleId int32
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
ScoreRewardThresholds map[int32][]ScoreRewardThreshold
RewardItems map[int32][]RewardItem
WeeklyRewardSchedulesByAttr map[int32][]ScoreRewardScheduleEntry
}
func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 {
@@ -80,8 +75,8 @@ func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMi
return 0
}
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupId(key BigHuntWeeklyRewardKey, nowMillis int64) int32 {
entries := c.WeeklyRewardSchedules[key]
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupIdByAttr(attributeType int32, nowMillis int64) int32 {
entries := c.WeeklyRewardSchedulesByAttr[attributeType]
for _, e := range entries {
if nowMillis >= e.StartDatetime {
return e.BigHuntScoreRewardGroupId
@@ -264,20 +259,16 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
if err != nil {
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 {
key := BigHuntWeeklyRewardKey{
ScheduleId: r.BigHuntWeeklyAttributeScoreRewardGroupScheduleId,
AttributeType: r.AttributeType,
}
weeklyRewardSchedules[key] = append(weeklyRewardSchedules[key], ScoreRewardScheduleEntry{
weeklyRewardSchedulesByAttr[r.AttributeType] = append(weeklyRewardSchedulesByAttr[r.AttributeType], ScoreRewardScheduleEntry{
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
StartDatetime: r.StartDatetime,
})
}
for k := range weeklyRewardSchedules {
sort.Slice(weeklyRewardSchedules[k], func(i, j int) bool {
return weeklyRewardSchedules[k][i].StartDatetime > weeklyRewardSchedules[k][j].StartDatetime
for k := range weeklyRewardSchedulesByAttr {
sort.Slice(weeklyRewardSchedulesByAttr[k], func(i, j int) bool {
return weeklyRewardSchedulesByAttr[k][i].StartDatetime > weeklyRewardSchedulesByAttr[k][j].StartDatetime
})
}
@@ -285,15 +276,15 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
len(bossQuestById), len(questById), len(bossByBossId), len(scoreCoefficients), len(rewardItems), activeScheduleId)
return &BigHuntCatalog{
BossQuestById: bossQuestById,
QuestById: questById,
ScoreCoefficients: scoreCoefficients,
BossByBossId: bossByBossId,
GradeThresholds: gradeThresholds,
ActiveScheduleId: activeScheduleId,
ScoreRewardSchedules: scoreRewardSchedules,
ScoreRewardThresholds: scoreRewardThresholds,
RewardItems: rewardItems,
WeeklyRewardSchedules: weeklyRewardSchedules,
BossQuestById: bossQuestById,
QuestById: questById,
ScoreCoefficients: scoreCoefficients,
BossByBossId: bossByBossId,
GradeThresholds: gradeThresholds,
ActiveScheduleId: activeScheduleId,
ScoreRewardSchedules: scoreRewardSchedules,
ScoreRewardThresholds: scoreRewardThresholds,
RewardItems: rewardItems,
WeeklyRewardSchedulesByAttr: weeklyRewardSchedulesByAttr,
}
}
+11 -2
View File
@@ -7,7 +7,8 @@ import (
)
type ConsumableItemCatalog struct {
All map[int32]EntityMConsumableItem
All map[int32]EntityMConsumableItem
Effects map[int32][]EntityMConsumableItemEffect
}
func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
@@ -15,12 +16,20 @@ func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
if err != nil {
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{
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 {
catalog.All[row.ConsumableItemId] = row
}
for _, e := range effects {
catalog.Effects[e.ConsumableItemId] = append(catalog.Effects[e.ConsumableItemId], e)
}
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]
gachaId := groupId
gachaId := first.DestinationDomainId
medal := gachaToMedal[first.DestinationDomainId]
medalId := medal.GachaMedalId
@@ -154,7 +154,7 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
GachaDecorationType: model.GachaDecorationFestival,
SortOrder: first.SortOrderDesc,
BannerAssetName: first.BannerAssetName,
GroupId: groupId,
GroupId: gachaId,
CeilingCount: model.PityCeilingCount,
PricePhases: pricePhases,
MaxStepNumber: maxStep,
+230 -163
View File
@@ -3,7 +3,6 @@ package masterdata
import (
"fmt"
"log"
"slices"
"sort"
"lunar-tear/server/internal/model"
@@ -34,16 +33,31 @@ type ShopFeaturedEntry struct {
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 {
CostumesByRarity map[int32][]GachaPoolItem
WeaponsByRarity map[int32][]GachaPoolItem
Materials []GachaPoolItem
CostumeById map[int32]GachaPoolItem
WeaponById map[int32]GachaPoolItem
CostumeWeaponMap map[int32]int32 // costumeId -> paired weaponId
FeaturedByGacha map[int32]FeaturedSet
BannerPools map[int32]*BannerPool
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
CostumesByRarity map[int32][]GachaPoolItem
WeaponsByRarity map[int32][]GachaPoolItem
StandardCostumesByRarity map[int32][]GachaPoolItem
StandardWeaponsByRarity map[int32][]GachaPoolItem
Materials []GachaPoolItem
CostumeById map[int32]GachaPoolItem
WeaponById map[int32]GachaPoolItem
CostumeWeaponMap map[int32]int32 // costumeId -> paired weaponId
FeaturedByGacha map[int32]FeaturedSet
BannerPools map[int32]*BannerPool
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
TermById map[int32]*CatalogTerm
TermsByStartDatetime map[int64][]*CatalogTerm
}
func LoadGachaPool() (*GachaCatalog, error) {
@@ -73,42 +87,76 @@ func LoadGachaPool() (*GachaCatalog, error) {
}
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))
costumeTermId := make(map[int32]int32, len(catalogCostumes))
for _, c := range catalogCostumes {
catalogCostumeSet[c.CostumeId] = true
costumeTermId[c.CostumeId] = c.CatalogTermId
}
catalogWeaponSet := make(map[int32]bool, len(catalogWeapons))
for _, w := range catalogWeapons {
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)
for _, w := range weapons {
weaponTypeById[w.WeaponId] = w.WeaponType
weaponRarityById[w.WeaponId] = w.RarityType
if w.IsRestrictDiscard {
restrictedWeapons[w.WeaponId] = true
}
}
pool := &GachaCatalog{
CostumesByRarity: make(map[int32][]GachaPoolItem),
WeaponsByRarity: make(map[int32][]GachaPoolItem),
CostumeById: make(map[int32]GachaPoolItem),
WeaponById: make(map[int32]GachaPoolItem),
CostumeWeaponMap: make(map[int32]int32),
FeaturedByGacha: make(map[int32]FeaturedSet),
CostumesByRarity: make(map[int32][]GachaPoolItem),
WeaponsByRarity: make(map[int32][]GachaPoolItem),
CostumeById: make(map[int32]GachaPoolItem),
WeaponById: make(map[int32]GachaPoolItem),
CostumeWeaponMap: make(map[int32]int32),
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 {
if !catalogCostumeSet[c.CostumeId] {
continue
@@ -116,6 +164,10 @@ func LoadGachaPool() (*GachaCatalog, error) {
if c.RarityType < model.RaritySRare {
continue
}
if questGrantedCostumes[c.CostumeId] {
questGrantedCostumeCount++
continue
}
item := GachaPoolItem{
PossessionType: int32(model.PossessionTypeCostume),
PossessionId: c.CostumeId,
@@ -127,11 +179,18 @@ func LoadGachaPool() (*GachaCatalog, error) {
}
restrictedCount := 0
questGrantedWeaponCount := 0
evolvedFilteredCount := 0
for _, w := range weapons {
if !catalogWeaponSet[w.WeaponId] {
continue
}
if evolvedWeapons[w.WeaponId] {
evolvedFilteredCount++
continue
}
if questGrantedWeapons[w.WeaponId] {
questGrantedWeaponCount++
continue
}
item := GachaPoolItem{
@@ -147,62 +206,56 @@ func LoadGachaPool() (*GachaCatalog, error) {
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)
type weaponKey struct {
TermId int32
WeaponType int32
Rarity int32
// Bucket catalog items into their terms (uses the post-filter CostumeById/WeaponById).
for _, cc := range catalogCostumes {
ct := pool.TermById[cc.CatalogTermId]
if ct == nil {
continue
}
if item, ok := pool.CostumeById[cc.CostumeId]; ok {
ct.Costumes = append(ct.Costumes, item)
}
}
weaponsByKey := make(map[weaponKey][]int32)
for _, cw := range catalogWeapons {
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] {
ct := pool.TermById[cw.CatalogTermId]
if ct == nil || restrictedWeapons[cw.WeaponId] {
continue
}
wt := weaponTypeById[cw.WeaponId]
r := weaponRarityById[cw.WeaponId]
if wt == 0 || r < model.RaritySRare {
continue
if item, ok := pool.WeaponById[cw.WeaponId]; ok {
ct.Weapons = append(ct.Weapons, item)
}
k := weaponKey{TermId: cw.CatalogTermId, WeaponType: wt, Rarity: r}
weaponsByKey[k] = append(weaponsByKey[k], cw.WeaponId)
}
for k, ids := range weaponsByKey {
slices.Sort(ids)
weaponsByKey[k] = ids
}
exact, pattern, bestGuess := 0, 0, 0
for costumeId, item := range pool.CostumeById {
tid := costumeTermId[costumeId]
wt := costumeWeaponType[costumeId]
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType}
candidates := weaponsByKey[k]
if len(candidates) == 0 {
continue
// Standard pool: items in term 1 (the launch starter set, same on every banner).
pool.StandardCostumesByRarity = make(map[int32][]GachaPoolItem)
pool.StandardWeaponsByRarity = make(map[int32][]GachaPoolItem)
if std := pool.TermById[StandardPoolTermId]; std != nil {
for _, c := range std.Costumes {
pool.StandardCostumesByRarity[c.RarityType] = append(pool.StandardCostumesByRarity[c.RarityType], c)
}
if len(candidates) == 1 {
pool.CostumeWeaponMap[costumeId] = candidates[0]
exact++
continue
}
idPattern := costumeId*10 + 1
found := false
for _, wid := range candidates {
if wid == idPattern {
pool.CostumeWeaponMap[costumeId] = wid
pattern++
found = true
break
}
}
if !found {
pool.CostumeWeaponMap[costumeId] = candidates[0]
bestGuess++
for _, w := range std.Weapons {
pool.StandardWeaponsByRarity[w.RarityType] = append(pool.StandardWeaponsByRarity[w.RarityType], w)
}
}
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total",
exact, pattern, bestGuess, len(pool.CostumeWeaponMap))
stdCos, stdWea := 0, 0
for _, items := range pool.StandardCostumesByRarity {
stdCos += len(items)
}
for _, items := range pool.StandardWeaponsByRarity {
stdWea += len(items)
}
log.Printf("[GachaPool] catalog terms: %d, standard pool: %d costumes + %d weapons (term %d)",
len(pool.TermById), stdCos, stdWea, StandardPoolTermId)
log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons",
evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount)
for costumeId := range pool.CostumeById {
if wid, ok := costumeWeaponPairings[costumeId]; ok {
pool.CostumeWeaponMap[costumeId] = wid
}
}
log.Printf("[GachaPool] costume-weapon pairing: %d entries from lookup table", len(pool.CostumeWeaponMap))
for _, m := range materials {
pool.Materials = append(pool.Materials, GachaPoolItem{
@@ -217,7 +270,6 @@ func LoadGachaPool() (*GachaCatalog, error) {
func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry)
shopPairs := 0
for _, cells := range shop.ExchangeShopCells {
consumableId := shop.Items[cells[0].ShopItemId].PriceId
@@ -237,16 +289,12 @@ func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
continue
}
entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId})
if costumeId != 0 && weaponId != 0 {
pool.CostumeWeaponMap[costumeId] = weaponId
shopPairs++
}
}
if len(entries) > 0 {
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() {
@@ -269,119 +317,138 @@ func (pool *GachaCatalog) PruneUnpairedCostumes() {
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
fromShop := 0
gachaEligible := 0
for _, entry := range entries {
if entry.MedalConsumableItemId == 0 {
continue
}
shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]
if !ok || len(shopEntries) == 0 {
if entry.GachaLabelType == model.GachaLabelChapter {
continue
}
gachaEligible++
seenCostume := make(map[int32]bool)
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
}
}
costumes, weapons := pool.unionTermFeatured(entry.StartDatetime)
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
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
}
sort.Slice(costumes, func(i, j int) bool { return costumes[i].PossessionId < costumes[j].PossessionId })
sort.Slice(weapons, func(i, j int) bool { return weapons[i].PossessionId < weapons[j].PossessionId })
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
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) {
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)
for _, entry := range entries {
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
if !hasFeatured {
pool.BannerPools[entry.GachaId] = commonPool
continue
}
bannerCostumes := cloneRarityMap(pool.StandardCostumesByRarity)
bannerWeapons := cloneRarityMap(pool.StandardWeaponsByRarity)
var allFeatured []GachaPoolItem
bannerCostumes := make(map[int32][]GachaPoolItem)
for rarity, items := range commonCostumes {
bannerCostumes[rarity] = append(bannerCostumes[rarity], items...)
if hasFeatured {
for _, c := range fs.Costumes {
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
allFeatured = append(allFeatured, c)
if wid, ok := pool.CostumeWeaponMap[c.PossessionId]; ok {
if w, ok := pool.WeaponById[wid]; ok {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
}
}
for _, w := range fs.Weapons {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
}
bannerWeapons := make(map[int32][]GachaPoolItem)
for rarity, items := range commonWeapons {
bannerWeapons[rarity] = append(bannerWeapons[rarity], items...)
}
for _, c := range fs.Costumes {
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
allFeatured = append(allFeatured, c)
wid := pool.CostumeWeaponMap[c.PossessionId]
w := pool.WeaponById[wid]
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
for _, w := range fs.Weapons {
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
allFeatured = append(allFeatured, w)
}
pool.BannerPools[entry.GachaId] = &BannerPool{
CostumesByRarity: bannerCostumes,
WeaponsByRarity: bannerWeapons,
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",
len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons))
func cloneRarityMap(src map[int32][]GachaPoolItem) map[int32][]GachaPoolItem {
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 {
+539 -10
View File
@@ -3,57 +3,463 @@ package masterdata
import (
"fmt"
"log"
"sort"
"sync"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"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 {
ScheduleId int32
StartDatetime int64
EndDatetime int64
FirstSequenceId int32
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 {
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")
if err != nil {
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 {
entry := gimmickScheduleEntry{
ScheduleId: r.GimmickSequenceScheduleId,
StartDatetime: r.StartDatetime,
EndDatetime: r.EndDatetime,
FirstSequenceId: r.FirstGimmickSequenceId,
IsHidden: hiddenSeq[r.FirstGimmickSequenceId],
Rank: gimmickTypeRank(seqTypes[r.FirstGimmickSequenceId]),
}
if r.ReleaseEvaluateConditionId != 0 {
if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok {
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))
return &GimmickCatalog{schedules: entries}, nil
entries := make([]gimmickScheduleEntry, 0, len(bestBySeq))
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 {
var keys []store.GimmickSequenceKey
keys := make([]store.GimmickSequenceKey, 0, len(c.schedules))
for _, s := range c.schedules {
if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime {
continue
if nowMillis < s.StartDatetime {
continue // future schedules still skipped
}
if s.RequiredQuestId != 0 {
if !s.IsHidden && s.RequiredQuestId != 0 {
q, ok := user.Quests[s.RequiredQuestId]
if !ok || q.QuestStateType != model.UserQuestStateTypeCleared {
continue
@@ -66,3 +472,126 @@ func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int6
}
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
}
+49 -4
View File
@@ -2,6 +2,8 @@ package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/utils"
)
@@ -18,29 +20,72 @@ type LoginBonusReward 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) {
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
}
func (c *LoginBonusCatalog) TotalPageCount(loginBonusId int32) int32 {
return c.totalPages[loginBonusId]
}
func LoadLoginBonusCatalog() *LoginBonusCatalog {
stamps, err := utils.ReadTable[EntityMLoginBonusStamp]("m_login_bonus_stamp")
if err != nil {
log.Fatalf("load login bonus stamp table: %v", err)
}
cat := &LoginBonusCatalog{
stamps: make(map[loginBonusStampKey]LoginBonusReward, len(stamps)),
bonuses, err := utils.ReadTable[EntityMLoginBonus]("m_login_bonus")
if err != nil {
log.Fatalf("load login bonus table: %v", err)
}
cat := &LoginBonusCatalog{
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 {
cat.stamps[loginBonusStampKey{s.LoginBonusId, s.LowerPageNumber, s.StampNumber}] = LoginBonusReward{
PossessionType: s.RewardPossessionType,
PossessionId: s.RewardPossessionId,
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
}
+17 -4
View File
@@ -2,6 +2,7 @@ package masterdata
import (
"fmt"
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils"
@@ -32,8 +33,9 @@ func BuildExpThresholds(paramMapRows []EntityMNumericalParameterMap, mapId int32
}
type MaterialCatalog struct {
All map[int32]EntityMMaterial
ByType map[model.MaterialType]map[int32]EntityMMaterial
All map[int32]EntityMMaterial
ByType map[model.MaterialType]map[int32]EntityMMaterial
SaleObtain map[int32][]EntityMMaterialSaleObtainPossession
}
func LoadMaterialCatalog() (*MaterialCatalog, error) {
@@ -43,8 +45,9 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
}
catalog := &MaterialCatalog{
All: make(map[int32]EntityMMaterial, len(rows)),
ByType: make(map[model.MaterialType]map[int32]EntityMMaterial),
All: make(map[int32]EntityMMaterial, len(rows)),
ByType: make(map[model.MaterialType]map[int32]EntityMMaterial),
SaleObtain: make(map[int32][]EntityMMaterialSaleObtainPossession),
}
for _, row := range rows {
catalog.All[row.MaterialId] = row
@@ -54,5 +57,15 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
}
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
}
+4
View File
@@ -158,5 +158,9 @@ func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) {
id++
}
}
// Newer parts groups (PartsGroupId 401-490) use PartsStatusSubLotteryGroupId
// 11/12 for rarities 10/20 instead of 1/2. Same stat pools — alias them.
pool[11] = pool[1]
pool[12] = pool[2]
return defs, pool
}
+80
View File
@@ -34,6 +34,11 @@ type QuestCatalog struct {
TutorialUnlockConditions []EntityMTutorialUnlockCondition
ChapterLastSceneByQuestId map[int32]int32
SeasonIdByRouteId map[int32]int32
RoutesBySeason map[int32][]int32
RouteCompletionQuestId map[int32]int32
BattleOnlyTargetSceneByQuestId map[int32]int32
MainQuestChapterIdByQuestId map[int32]int32
EventQuestTypeByChapterId map[int32]int32
UserExpThresholds []int32
CharacterExpThresholds []int32
@@ -113,8 +118,53 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
return nil, fmt.Errorf("load main quest route table: %w", err)
}
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 {
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")
@@ -238,6 +288,15 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
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()
if err != nil {
return nil, err
@@ -325,12 +384,23 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter
}
routeIdByQuestId := make(map[int32]int32)
mainQuestChapterIdByQuestId := make(map[int32]int32)
for _, sequence := range sequences {
if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok {
routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId
mainQuestChapterIdByQuestId[sequence.QuestId] = chapter.MainQuestChapterId
}
}
eventChapters, err := utils.ReadTable[EntityMEventQuestChapter]("m_event_quest_chapter")
if err != nil {
return nil, fmt.Errorf("load event quest chapter table: %w", err)
}
eventQuestTypeByChapterId := make(map[int32]int32, len(eventChapters))
for _, ec := range eventChapters {
eventQuestTypeByChapterId[ec.EventQuestChapterId] = ec.EventQuestType
}
sortedChapters := make([]EntityMMainQuestChapter, len(chapters))
copy(sortedChapters, chapters)
sort.Slice(sortedChapters, func(i, j int) bool {
@@ -529,6 +599,11 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId,
RoutesBySeason: routesBySeason,
RouteCompletionQuestId: routeCompletionQuestId,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
MainQuestChapterIdByQuestId: mainQuestChapterIdByQuestId,
EventQuestTypeByChapterId: eventQuestTypeByChapterId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
@@ -545,3 +620,8 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
PartsCatalog: partsCatalog,
}, nil
}
func (q *QuestCatalog) BattleOnlyTargetSceneIdFor(questId int32) (int32, bool) {
v, ok := q.BattleOnlyTargetSceneByQuestId[questId]
return v, ok
}
+109 -9
View File
@@ -2,11 +2,35 @@ package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/model"
"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 {
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 {
@@ -14,14 +38,90 @@ func LoadSideStoryCatalog() *SideStoryCatalog {
if err != nil {
log.Fatalf("load side story quest scene table: %v", err)
}
firstScene := make(map[int32]int32, len(scenes)/7)
for _, s := range scenes {
if s.SortOrder == 1 {
firstScene[s.SideStoryQuestId] = s.SideStoryQuestSceneId
}
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)
}
log.Printf("side story catalog loaded: %d quests", len(firstScene))
return &SideStoryCatalog{FirstSceneByQuestId: firstScene}
seqRows := make(map[int32][]EntityMEventQuestSequence)
for _, s := range sequences {
seqRows[s.EventQuestSequenceId] = append(seqRows[s.EventQuestSequenceId], s)
}
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
}
}
info := &SideStoryQuestInfo{
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
ReleaseConditionsByGroupId map[int32][]EntityMWeaponStoryReleaseConditionGroup
LevelingEnhanceIdByWeaponId map[int32]int32
AwakenByWeaponId map[int32]EntityMWeaponAwaken
AwakenMaterialsByGroupId map[int32][]EntityMWeaponAwakenMaterialGroup
}
@@ -142,6 +144,8 @@ func LoadWeaponCatalog(matCatalog *MaterialCatalog) (*WeaponCatalog, error) {
BaseExpByEnhanceId: make(map[int32]int32, len(enhanceRows)),
ReleaseConditionsByGroupId: make(map[int32][]EntityMWeaponStoryReleaseConditionGroup),
LevelingEnhanceIdByWeaponId: make(map[int32]int32, len(weapons)),
AwakenByWeaponId: make(map[int32]EntityMWeaponAwaken, len(awakenRows)),
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)
// 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
}
@@ -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
)
func IsReplayQuestFlowType(t int32) bool {
return t == int32(QuestFlowTypeReplayFlow) ||
t == int32(QuestFlowTypeAnotherRouteReplayFlow)
}
func (t QuestFlowType) String() string {
switch t {
case QuestFlowTypeUnknown:
@@ -38,6 +43,15 @@ const (
QuestResultTypeFullResult QuestResultType = 3
)
type MissionProgressStatusType int32
const (
MissionProgressStatusTypeUnknown MissionProgressStatusType = 0
MissionProgressStatusTypeInProgress MissionProgressStatusType = 1
MissionProgressStatusTypeClear MissionProgressStatusType = 2
MissionProgressStatusTypeRewardReceived MissionProgressStatusType = 9
)
type QuestSceneType int32
const (
@@ -155,6 +169,19 @@ type SideStoryQuestStateType int32
const (
SideStoryQuestStateUnknown SideStoryQuestStateType = 0
SideStoryQuestStateActive SideStoryQuestStateType = 1
SideStoryQuestStateCleared SideStoryQuestStateType = 2
SideStoryQuestStateActive 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
)
+8 -5
View File
@@ -17,7 +17,8 @@ func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, u
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForBigHunt(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
@@ -33,13 +34,15 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
target := h.targetForBigHunt(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
+56
View File
@@ -0,0 +1,56 @@
package questflow
import (
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/model"
)
func (h *QuestHandler) targetForMain(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeMainQuest,
ChapterId: h.MainQuestChapterIdByQuestId[questId],
}
}
func (h *QuestHandler) targetForEvent(eventChapterId, questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeEventQuest,
EventQuestType: h.EventQuestTypeByChapterId[eventChapterId],
ChapterId: eventChapterId,
}
}
func (h *QuestHandler) targetForExtra(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeExtraQuest}
}
func (h *QuestHandler) targetForBigHunt(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeBigHunt}
}
func (h *QuestHandler) campaignFilter(nowMillis int64) campaign.Filter {
return campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}
}
func (h *QuestHandler) staminaWithCampaign(baseStamina int32, t campaign.QuestTarget, nowMillis int64) int32 {
if h.Campaigns == nil {
return baseStamina
}
return h.Campaigns.QuestStamina(t, h.campaignFilter(nowMillis)).Apply(baseStamina)
}
func (h *QuestHandler) appendBonusDrops(drops []RewardGrant, t campaign.QuestTarget, nowMillis int64) []RewardGrant {
if h.Campaigns == nil {
return drops
}
for _, bd := range h.Campaigns.QuestBonusDrops(t, h.campaignFilter(nowMillis)) {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bd.PossessionType),
PossessionId: bd.PossessionId,
Count: bd.Count,
})
}
return drops
}
+26 -11
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestCh
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForEvent(eventQuestChapterId, questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
@@ -42,26 +43,45 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
target := h.targetForEvent(eventQuestChapterId, questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
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 {
refund := quest.Stamina - 1
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
restoreClearedAfterRetire(user, questId, isRetired)
user.EventQuest.CurrentEventQuestChapterId = 0
user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0
user.EventQuest.HeadQuestSceneId = 0
user.EventQuest.LatestVersion = nowMillis
h.clearQuestMissions(user, questId, nowMillis)
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) {
h.HandleQuestRestart(user, questId, nowMillis)
@@ -70,8 +90,7 @@ func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuest
}
func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId]
if !ok {
if _, ok := h.SceneById[questSceneId]; !ok {
log.Printf("[HandleEventQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return
}
@@ -82,8 +101,4 @@ func (h *QuestHandler) HandleEventQuestSceneProgress(user *store.UserState, ques
}
h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
}
+11 -11
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, use
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForExtra(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
}
questState := user.Quests[questId]
@@ -40,17 +41,21 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
target := h.targetForExtra(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis)
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
restoreClearedAfterRetire(user, questId, isRetired)
user.ExtraQuest.CurrentQuestId = 0
user.ExtraQuest.CurrentQuestSceneId = 0
user.ExtraQuest.HeadQuestSceneId = 0
@@ -67,8 +72,7 @@ func (h *QuestHandler) HandleExtraQuestRestart(user *store.UserState, questId in
}
func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
scene, ok := h.SceneById[questSceneId]
if !ok {
if _, ok := h.SceneById[questSceneId]; !ok {
log.Printf("[HandleExtraQuestSceneProgress] unknown sceneId=%d, skipping", questSceneId)
return
}
@@ -79,8 +83,4 @@ func (h *QuestHandler) HandleExtraQuestSceneProgress(user *store.UserState, ques
}
h.applySceneGrants(user, questSceneId, nowMillis)
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
h.clearQuestMissions(user, scene.QuestId, nowMillis)
}
}
+50 -4
View File
@@ -1,6 +1,9 @@
package questflow
import (
"sort"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
@@ -25,13 +28,25 @@ type FinishOutcome struct {
type QuestHandler struct {
*masterdata.QuestCatalog
Config *masterdata.GameConfig
Granter *store.PossessionGranter
Config *masterdata.GameConfig
Granter *store.PossessionGranter
SideStoryChapterByEventQuestId map[int32]int32
Campaigns *campaign.Catalog
}
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *QuestHandler {
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog) *QuestHandler {
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,
Campaigns: campaigns,
}
}
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
@@ -60,12 +75,40 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
releaseConditions[groupId] = conds
}
partsById := make(map[int32]store.PartsRef, len(catalog.PartsById))
partsVariants := make(map[int32]map[int32][]int32)
for id, p := range catalog.PartsById {
partsById[id] = store.PartsRef{
PartsGroupId: p.PartsGroupId,
RarityType: p.RarityType,
PartsInitialLotteryId: p.PartsInitialLotteryId,
PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId,
PartsStatusSubLotteryGroupId: p.PartsStatusSubLotteryGroupId,
}
if partsVariants[p.PartsGroupId] == nil {
partsVariants[p.PartsGroupId] = map[int32][]int32{}
}
partsVariants[p.PartsGroupId][p.RarityType] = append(partsVariants[p.PartsGroupId][p.RarityType], p.PartsId)
}
for _, byRarity := range partsVariants {
for _, ids := range byRarity {
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
}
}
partsSubDefs := make(map[int32]store.PartsStatusSubDef, len(catalog.PartsStatusMainById))
for id, d := range catalog.PartsStatusMainById {
var fn func(int32) int32
if f, ok := catalog.FuncResolver.Resolve(d.StatusNumericalFunctionId); ok {
fn = f.Evaluate
}
partsSubDefs[id] = store.PartsStatusSubDef{
StatusKindType: d.StatusKindType,
StatusCalculationType: d.StatusCalculationType,
StatusChangeInitialValue: d.StatusChangeInitialValue,
StatusFunc: fn,
}
}
return &store.PossessionGranter{
CostumeById: costumeById,
WeaponById: weaponById,
@@ -74,5 +117,8 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
ReleaseConditions: releaseConditions,
PartsById: partsById,
DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup,
PartsVariantsByGroupRarity: partsVariants,
PartsSubStatusPool: catalog.SubStatusPool,
PartsSubStatusDefs: partsSubDefs,
}
}
+203 -56
View File
@@ -24,7 +24,14 @@ func (h *QuestHandler) initQuestState(user *store.UserState, questId int32) {
}
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) {
@@ -38,49 +45,64 @@ func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32,
}
}
func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, userDeckNumber, false, nowMillis)
func (h *QuestHandler) HandleQuestStart(user *store.UserState, questId int32, isBattleOnly, isMainFlow bool, userDeckNumber int32, nowMillis int64) {
h.handleQuestStartInternal(user, questId, isBattleOnly, isMainFlow, userDeckNumber, false, nowMillis)
}
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]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestStart", questId))
}
h.initQuestState(user, questId)
if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
store.ConsumeStamina(user, stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis)
}
questState := user.Quests[questId]
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.IsBattleOnly = isBattleOnly
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
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
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
}
case isReplayFlow:
h.applyReplayStart(user, quest, questId, isBattleOnly, nowMillis)
return
}
if isCleared {
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
return
}
questState.IsBattleOnly = isBattleOnly
questState.UserDeckNumber = userDeckNumber
if isMainQuestPlayable(quest) {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
questState.QuestStateType = model.UserQuestStateTypeActive
@@ -90,7 +112,6 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
questState.ClearCount = 1
questState.DailyClearCount = 1
questState.LastClearDatetime = nowMillis
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
firstSceneId := sceneIds[0]
prevSceneId := user.MainQuest.CurrentQuestSceneId
@@ -102,14 +123,85 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
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.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 {
h.applyQuestRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeFullResult, nowMillis)...)
if !wasReplay {
h.applyFirstClearItemRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
h.grantWeaponStoryUnlocksForQuestScene(user, questId, model.QuestResultTypeHalfResult, nowMillis)...)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
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
}
for _, drop := range outcome.DropRewards {
@@ -122,51 +214,103 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o
questState.ClearCount++
questState.DailyClearCount++
questState.LastClearDatetime = nowMillis
questState.IsBattleOnly = false
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 {
quest, ok := h.QuestById[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for HandleQuestFinish", questId))
}
outcome := h.evaluateFinishOutcome(user, questId)
wasReplay := user.MainQuest.CurrentQuestFlowType == int32(model.QuestFlowTypeReplayFlow)
h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
wasMenuReplay := user.MainQuest.SavedContext.Active
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)
h.advanceMainFlowScene(user, questId, lastSceneId)
}
}
if isRetired && !isAnnihilated && quest.Stamina > 1 {
refund := quest.Stamina - 1
consumed := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
}
restoreClearedAfterRetire(user, questId, isRetired)
user.MainQuest.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0
user.MainQuest.ProgressQuestFlowType = 0
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown)
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.CurrentQuestFlowType = int32(model.QuestFlowTypeUnknown)
}
if wasReplay {
if user.MainQuest.SavedCurrentQuestSceneId > 0 {
user.MainQuest.CurrentQuestSceneId = user.MainQuest.SavedCurrentQuestSceneId
}
if user.MainQuest.SavedHeadQuestSceneId > 0 {
user.MainQuest.HeadQuestSceneId = user.MainQuest.SavedHeadQuestSceneId
}
user.MainQuest.SavedCurrentQuestSceneId = 0
user.MainQuest.SavedHeadQuestSceneId = 0
user.MainQuest.ReplayFlowCurrentQuestSceneId = 0
user.MainQuest.ReplayFlowHeadQuestSceneId = 0
log.Printf("[HandleQuestFinish] replay flow ended for quest %d, restored scene=%d head=%d",
questId, user.MainQuest.CurrentQuestSceneId, user.MainQuest.HeadQuestSceneId)
if wasMenuReplay {
ctx := user.MainQuest.SavedContext
user.MainQuest.CurrentQuestSceneId = ctx.CurrentQuestSceneId
user.MainQuest.HeadQuestSceneId = ctx.HeadQuestSceneId
user.MainQuest.CurrentMainQuestRouteId = ctx.CurrentMainQuestRouteId
user.MainQuest.MainQuestSeasonId = ctx.MainQuestSeasonId
user.MainQuest.IsReachedLastQuestScene = ctx.IsReachedLastQuestScene
user.MainQuest.CurrentQuestFlowType = ctx.CurrentQuestFlowType
user.PortalCageStatus.IsCurrentProgress = ctx.PortalCageInProgress
user.PortalCageStatus.LatestVersion = nowMillis
user.MainQuest.SavedContext = store.SavedQuestContext{}
user.MainQuest.LatestVersion = nowMillis
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)
@@ -180,18 +324,19 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId))
}
target := h.targetForMain(questId)
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, skipCount, maxMillis, nowMillis)
perSkipStamina := h.staminaWithCampaign(questDef.Stamina, target, nowMillis)
store.ConsumeStamina(user, perSkipStamina*skipCount, maxMillis, nowMillis)
skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket
user.ConsumableItems[skipTicketId] -= skipCount
if user.ConsumableItems[skipTicketId] < 0 {
user.ConsumableItems[skipTicketId] = 0
}
var allDrops []RewardGrant
for range skipCount {
drops := h.computeDropRewards(questDef)
drops := h.computeDropRewards(questDef, target, nowMillis)
for _, drop := range drops {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
}
@@ -215,14 +360,16 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
func (h *QuestHandler) HandleQuestRestart(user *store.UserState, questId int32, nowMillis int64) {
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)
}
quest := user.Quests[questId]
quest.QuestId = questId
quest.QuestStateType = model.UserQuestStateTypeActive
quest.IsBattleOnly = false
quest.LatestStartDatetime = nowMillis
user.Quests[questId] = quest
+77 -51
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
@@ -13,7 +14,7 @@ import (
func (h *QuestHandler) isQuestCleared(user *store.UserState, questId int32) bool {
quest, ok := user.Quests[questId]
if !ok {
panic(fmt.Sprintf("unknown questId=%d for isQuestCleared", questId))
return false
}
return quest.QuestStateType == model.UserQuestStateTypeCleared
}
@@ -40,7 +41,7 @@ func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef m
return rewardGroupId
}
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome {
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32, target campaign.QuestTarget, nowMillis int64) FinishOutcome {
outcome := FinishOutcome{}
questState, ok := user.Quests[questId]
if !ok {
@@ -51,7 +52,9 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
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)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
outcome.FirstClearRewards = append(outcome.FirstClearRewards, RewardGrant{
@@ -62,7 +65,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] {
outcome.ReplayFlowFirstClearRewards = append(outcome.ReplayFlowFirstClearRewards, RewardGrant{
PossessionType: model.PossessionType(reward.PossessionType),
@@ -72,69 +75,77 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
}
}
pendingClearCount := 0
regularMissionCount := 0
for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId]
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete {
continue
}
regularMissionCount++
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
mission := user.QuestMissions[key]
if !mission.IsClear {
pendingClearCount++
outcome.MissionClearRewards = appendMissionRewards(
outcome.MissionClearRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId],
)
}
}
priorClearCount := regularMissionCount - pendingClearCount
// On our server every mission auto-clears, so priorClearCount + pendingClearCount
// always equals regularMissionCount. The two-variable form is kept to mirror the
// original game's intent where individual missions could fail their conditions.
allRegularWillClear := regularMissionCount > 0 && (priorClearCount+pendingClearCount) == regularMissionCount
if allRegularWillClear {
// 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
regularMissionCount := 0
for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId]
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) != model.QuestMissionConditionTypeComplete {
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) == model.QuestMissionConditionTypeComplete {
continue
}
regularMissionCount++
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
if !user.QuestMissions[key].IsClear {
outcome.MissionClearCompleteRewards = appendMissionRewards(
outcome.MissionClearCompleteRewards,
mission := user.QuestMissions[key]
if !mission.IsClear {
pendingClearCount++
outcome.MissionClearRewards = appendMissionRewards(
outcome.MissionClearRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId],
)
outcome.BigWinClearedQuestMissionIds = append(outcome.BigWinClearedQuestMissionIds, questMissionId)
}
}
outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0
priorClearCount := regularMissionCount - pendingClearCount
// On our server every mission auto-clears, so priorClearCount + pendingClearCount
// always equals regularMissionCount. The two-variable form is kept to mirror the
// original game's intent where individual missions could fail their conditions.
allRegularWillClear := regularMissionCount > 0 && (priorClearCount+pendingClearCount) == regularMissionCount
if allRegularWillClear {
for _, questMissionId := range h.MissionIdsByQuestId[questId] {
missionDef, ok := h.MissionById[questMissionId]
if !ok || model.QuestMissionConditionType(missionDef.QuestMissionConditionType) != model.QuestMissionConditionTypeComplete {
continue
}
key := store.QuestMissionKey{QuestId: questId, QuestMissionId: questMissionId}
if !user.QuestMissions[key].IsClear {
outcome.MissionClearCompleteRewards = appendMissionRewards(
outcome.MissionClearCompleteRewards,
h.MissionRewardsByMissionId[missionDef.QuestMissionRewardId],
)
outcome.BigWinClearedQuestMissionIds = append(outcome.BigWinClearedQuestMissionIds, questMissionId)
}
}
outcome.IsBigWin = len(outcome.BigWinClearedQuestMissionIds) > 0
}
}
outcome.DropRewards = h.computeDropRewards(questDef)
outcome.DropRewards = h.computeDropRewards(questDef, target, nowMillis)
return outcome
}
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest) []RewardGrant {
if questDef.QuestPickupRewardGroupId == 0 {
return nil
}
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant {
var drops []RewardGrant
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] {
if bdr, ok := h.BattleDropRewardById[dropId]; ok {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bdr.PossessionType),
PossessionId: bdr.PossessionId,
Count: bdr.Count,
})
var dropRate campaign.DropRateMul
if h.Campaigns != nil {
dropRate = h.Campaigns.QuestDropRate(target, h.campaignFilter(nowMillis))
}
if questDef.QuestPickupRewardGroupId != 0 {
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] {
if bdr, ok := h.BattleDropRewardById[dropId]; ok {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bdr.PossessionType),
PossessionId: bdr.PossessionId,
Count: dropRate.Apply(bdr.Count),
})
}
}
}
return drops
return h.appendBonusDrops(drops, target, nowMillis)
}
func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) {
@@ -159,6 +170,10 @@ func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, now
return
}
if questDef.CharacterExp == 0 && questDef.CostumeExp == 0 {
return
}
deckCostumeUuids, deckCharacterIds := h.resolveDeckUnits(user, questId)
if deckCostumeUuids == nil {
log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (deck not resolved)", questId)
@@ -240,7 +255,7 @@ func (h *QuestHandler) resolveDeckUnits(user *store.UserState, questId int32) (c
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]
if !ok {
return
@@ -252,13 +267,24 @@ func (h *QuestHandler) applyQuestRewards(user *store.UserState, questId int32, n
user.ConsumableItems[h.Config.ConsumableItemIdForGold] += questDef.Gold
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)
for _, reward := range h.FirstClearRewardsByGroupId[rewardGroupId] {
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) {
h.Granter.GrantFull(user, possType, possId, count, nowMillis)
}
+115 -13
View File
@@ -1,5 +1,11 @@
package questflow
// MainQuest scene-field families mirror three client entity tables:
//
// MainFlow* — EntityIUserMainQuestMainFlowStatus (#11443)
// Progress* — EntityIUserMainQuestProgressStatus (#11444)
// ReplayFlow* — EntityIUserMainQuestReplayFlowStatus (#11445)
import (
"fmt"
"log"
@@ -27,10 +33,11 @@ func (h *QuestHandler) isSceneAhead(newSceneId, currentHeadId int32) bool {
}
func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, sceneId int32) {
user.MainQuest.CurrentQuestSceneId = sceneId
if h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = sceneId
if !h.isSceneAhead(sceneId, user.MainQuest.HeadQuestSceneId) {
return
}
user.MainQuest.CurrentQuestSceneId = sceneId
user.MainQuest.HeadQuestSceneId = sceneId
lastSceneId := h.getChapterLastSceneId(questId)
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) {
scene, ok := h.SceneById[questSceneId]
if !ok {
@@ -109,11 +147,53 @@ func (h *QuestHandler) getChapterLastSceneId(questId int32) int32 {
func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
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
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) {
@@ -127,19 +207,33 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
panic(fmt.Sprintf("unknown questId=%d for HandleMainQuestSceneProgress", questSceneId))
}
if isMainQuestPlayable(quest) {
if model.QuestResultType(scene.QuestResultType) == model.QuestResultTypeHalfResult {
nowMillis := gametime.NowMillis()
h.clearQuestMissions(user, quest.QuestId, nowMillis)
if prevSceneId := user.MainQuest.ProgressQuestSceneId; prevSceneId != 0 {
if prevScene, ok := h.SceneById[prevSceneId]; ok && prevScene.QuestId != quest.QuestId {
// Skip if the previous quest is playable — it has its own FinishMainQuest;
// 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
if h.isSceneAhead(questSceneId, user.MainQuest.ProgressHeadQuestSceneId) {
user.MainQuest.ProgressHeadQuestSceneId = questSceneId
}
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
} else {
if isReplay {
user.MainQuest.ProgressQuestFlowType = user.MainQuest.CurrentQuestFlowType
} else {
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
}
} 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
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = questSceneId
@@ -147,4 +241,12 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
lastSceneId := h.getChapterLastSceneId(quest.QuestId)
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()
}
}
+185
View File
@@ -0,0 +1,185 @@
package runtime
import (
"fmt"
"log"
"lunar-tear/server/internal/campaign"
"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()
campaignCatalog, err := campaign.Load()
if err != nil {
return nil, fmt.Errorf("load campaign catalog: %w", err)
}
log.Printf("campaign catalog loaded: %d enhance, %d quest", campaignCatalog.EnhanceCount(), campaignCatalog.QuestCount())
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog, campaignCatalog)
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,
Campaign: campaignCatalog,
QuestHandler: questHandler,
GachaHandler: gachaHandler,
}, nil
}
+93
View File
@@ -0,0 +1,93 @@
// 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/campaign"
"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
Campaign *campaign.Catalog
QuestHandler *questflow.QuestHandler
GachaHandler *gacha.GachaHandler
}
type Holder struct {
binPath string
cur atomic.Pointer[Catalogs]
}
func NewHolder(binPath string) (*Holder, error) {
h := &Holder{binPath: binPath}
if err := h.Reload(); err != nil {
return nil, err
}
return h, nil
}
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 {
log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err)
}
return nil
}
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
}
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) {
func (r *assetResolver) Resolve(objectId, assetType, activeRevision, platform string) (assetResolution, bool) {
start := time.Now()
resolution := assetResolution{ActiveRevision: 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 {
resolution.ListRevision = revision
resolution.ListSize = listSize
resolution.Candidates = candidates
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
}
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
}
func (r *assetResolver) Prewarm(activeRevision string) {
func (r *assetResolver) Prewarm(activeRevision, platform string) {
if activeRevision == "" {
return
}
_, _ = loadListBinIndex(r.baseDir, activeRevision)
_ = loadInfoIndex(r.baseDir, activeRevision)
_, _ = loadListBinIndex(r.baseDir, activeRevision, platform)
_ = loadInfoIndex(r.baseDir, activeRevision, platform)
}
+10 -5
View File
@@ -4,24 +4,29 @@ import (
"context"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/runtime"
)
type BannerServiceServer struct {
pb.UnimplementedBannerServiceServer
catalog []store.GachaCatalogEntry
holder *runtime.Holder
}
func NewBannerServiceServer(catalog []store.GachaCatalogEntry) *BannerServiceServer {
return &BannerServiceServer{catalog: catalog}
func NewBannerServiceServer(holder *runtime.Holder) *BannerServiceServer {
return &BannerServiceServer{holder: holder}
}
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 latestChapter *pb.GachaBanner
for _, entry := range catalog {
if !gachaActiveAt(entry, nowMillis) {
continue
}
if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle {
continue
}
+19 -10
View File
@@ -6,8 +6,8 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -15,21 +15,18 @@ type CageOrnamentServiceServer struct {
pb.UnimplementedCageOrnamentServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CageOrnamentCatalog
granter *store.PossessionGranter
holder *runtime.Holder
}
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CageOrnamentCatalog, granter *store.PossessionGranter) *CageOrnamentServiceServer {
return &CageOrnamentServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
func NewCageOrnamentServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CageOrnamentServiceServer {
return &CageOrnamentServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.ReceiveRewardRequest) (*pb.ReceiveRewardResponse, error) {
log.Printf("[CageOrnamentService] ReceiveReward: cageOrnamentId=%d", req.CageOrnamentId)
reward, ok := s.catalog.LookupReward(req.CageOrnamentId)
if !ok {
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
cat := s.holder.Get()
reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -39,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
AcquisitionDatetime: 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{
CageOrnamentReward: []*pb.CageOrnamentReward{
{
+13 -10
View File
@@ -7,6 +7,7 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -14,21 +15,23 @@ type CharacterServiceServer struct {
pb.UnimplementedCharacterServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterRebirthCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterRebirthCatalog, config *masterdata.GameConfig) *CharacterServiceServer {
return &CharacterServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewCharacterServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterServiceServer {
return &CharacterServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
cat := s.holder.Get()
catalog := cat.CharacterRebirth
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
stepGroupId, ok := s.catalog.StepGroupByCharacterId[req.CharacterId]
stepGroupId, ok := catalog.StepGroupByCharacterId[req.CharacterId]
if !ok {
log.Printf("[CharacterService] Rebirth: no step group for characterId=%d", req.CharacterId)
return &pb.RebirthResponse{}, nil
@@ -40,17 +43,17 @@ func (s *CharacterServiceServer) Rebirth(ctx context.Context, req *pb.RebirthReq
targetCount := currentCount + req.RebirthCount
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 {
log.Printf("[CharacterService] Rebirth: no step row for groupId=%d beforeCount=%d", stepGroupId, count)
return
}
goldId := s.config.ConsumableItemIdForGold
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-s.config.CharacterRebirthConsumeGold, 0)
log.Printf("[CharacterService] Rebirth: consumed gold=%d", s.config.CharacterRebirthConsumeGold)
goldId := config.ConsumableItemIdForGold
user.ConsumableItems[goldId] = max(user.ConsumableItems[goldId]-config.CharacterRebirthConsumeGold, 0)
log.Printf("[CharacterService] Rebirth: consumed gold=%d", config.CharacterRebirthConsumeGold)
materials := s.catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
materials := catalog.MaterialsByGroupId[step.CharacterRebirthMaterialGroupId]
for _, mat := range materials {
user.Materials[mat.MaterialId] -= mat.Count
if user.Materials[mat.MaterialId] <= 0 {
+25 -23
View File
@@ -7,6 +7,7 @@ import (
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"
)
@@ -14,43 +15,44 @@ type CharacterBoardServiceServer struct {
pb.UnimplementedCharacterBoardServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterBoardCatalog
holder *runtime.Holder
}
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterBoardCatalog) *CharacterBoardServiceServer {
return &CharacterBoardServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewCharacterBoardServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterBoardServiceServer {
return &CharacterBoardServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *CharacterBoardServiceServer) ReleasePanel(ctx context.Context, req *pb.ReleasePanelRequest) (*pb.ReleasePanelResponse, error) {
log.Printf("[CharacterBoardService] ReleasePanel: panelIds=%v", req.CharacterBoardPanelId)
catalog := s.holder.Get().CharacterBoard
userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
for _, panelId := range req.CharacterBoardPanelId {
panel, ok := s.catalog.PanelById[panelId]
panel, ok := catalog.PanelById[panelId]
if !ok {
log.Printf("[CharacterBoardService] unknown panelId=%d, skipping", panelId)
continue
}
s.consumeCosts(user, panel)
s.setReleaseBit(user, panel)
s.applyEffects(user, panel)
consumeBoardCosts(catalog, user, panel)
setBoardReleaseBit(user, panel)
applyBoardEffects(catalog, user, panel)
}
})
return &pb.ReleasePanelResponse{}, nil
}
func (s *CharacterBoardServiceServer) consumeCosts(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
costs := s.catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
func consumeBoardCosts(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
costs := catalog.ReleaseCostsByGroupId[panel.CharacterBoardPanelReleasePossessionGroupId]
for _, cost := range costs {
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
board := user.CharacterBoards[boardId]
board.CharacterBoardId = boardId
@@ -73,26 +75,26 @@ func (s *CharacterBoardServiceServer) setReleaseBit(user *store.UserState, panel
user.CharacterBoards[boardId] = board
}
func (s *CharacterBoardServiceServer) applyEffects(user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
effects := s.catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
func applyBoardEffects(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, panel masterdata.EntityMCharacterBoardPanel) {
effects := catalog.ReleaseEffectsByGroupId[panel.CharacterBoardPanelReleaseEffectGroupId]
for _, eff := range effects {
switch model.CharacterBoardEffectType(eff.CharacterBoardEffectType) {
case model.CharacterBoardEffectTypeAbility:
s.applyAbilityEffect(user, eff)
applyBoardAbilityEffect(catalog, user, eff)
case model.CharacterBoardEffectTypeStatusUp:
s.applyStatusUpEffect(user, eff)
applyBoardStatusUpEffect(catalog, user, eff)
}
}
}
func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
ability, ok := s.catalog.AbilityById[eff.CharacterBoardEffectId]
func applyBoardAbilityEffect(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
ability, ok := catalog.AbilityById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown abilityId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(ability.CharacterBoardEffectTargetGroupId)
characterId := resolveBoardCharacterId(catalog, ability.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
@@ -103,21 +105,21 @@ func (s *CharacterBoardServiceServer) applyAbilityEffect(user *store.UserState,
state.AbilityId = ability.AbilityId
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
}
user.CharacterBoardAbilities[key] = state
}
func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
statusUp, ok := s.catalog.StatusUpById[eff.CharacterBoardEffectId]
func applyBoardStatusUpEffect(catalog *masterdata.CharacterBoardCatalog, user *store.UserState, eff masterdata.EntityMCharacterBoardPanelReleaseEffectGroup) {
statusUp, ok := catalog.StatusUpById[eff.CharacterBoardEffectId]
if !ok {
log.Printf("[CharacterBoardService] unknown statusUpId=%d", eff.CharacterBoardEffectId)
return
}
characterId := s.resolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId)
characterId := resolveBoardCharacterId(catalog, statusUp.CharacterBoardEffectTargetGroupId)
if characterId == 0 {
return
}
@@ -151,8 +153,8 @@ func (s *CharacterBoardServiceServer) applyStatusUpEffect(user *store.UserState,
user.CharacterBoardStatusUps[key] = state
}
func (s *CharacterBoardServiceServer) resolveCharacterId(targetGroupId int32) int32 {
targets := s.catalog.EffectTargetsByGroupId[targetGroupId]
func resolveBoardCharacterId(catalog *masterdata.CharacterBoardCatalog, targetGroupId int32) int32 {
targets := catalog.EffectTargetsByGroupId[targetGroupId]
for _, t := range targets {
if t.TargetValue != 0 {
return t.TargetValue
+5 -5
View File
@@ -6,7 +6,7 @@ import (
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb"
@@ -16,11 +16,11 @@ type CharacterViewerServiceServer struct {
pb.UnimplementedCharacterViewerServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CharacterViewerCatalog
holder *runtime.Holder
}
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CharacterViewerCatalog) *CharacterViewerServiceServer {
return &CharacterViewerServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewCharacterViewerServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CharacterViewerServiceServer {
return &CharacterViewerServiceServer{users: users, sessions: sessions, holder: holder}
}
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))
}
released := s.catalog.ReleasedFieldIds(user)
released := s.holder.Get().CharacterViewer.ReleasedFieldIds(user)
log.Printf("[CharacterViewerService] released %d fields for user %d", len(released), userId)
return &pb.CharacterViewerTopResponse{
+11 -8
View File
@@ -8,6 +8,7 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -17,17 +18,19 @@ type CompanionServiceServer struct {
pb.UnimplementedCompanionServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CompanionCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CompanionCatalog, config *masterdata.GameConfig) *CompanionServiceServer {
return &CompanionServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewCompanionServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CompanionServiceServer {
return &CompanionServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
cat := s.holder.Get()
catalog := cat.Companion
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -38,7 +41,7 @@ func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionE
return
}
compDef, ok := s.catalog.CompanionById[companion.CompanionId]
compDef, ok := catalog.CompanionById[companion.CompanionId]
if !ok {
log.Printf("[CompanionService] Enhance: companion master id=%d not found", companion.CompanionId)
return
@@ -50,13 +53,13 @@ func (s *CompanionServiceServer) Enhance(ctx context.Context, req *pb.CompanionE
}
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
}
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
}
}
+54 -7
View File
@@ -6,7 +6,9 @@ import (
"log"
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"
)
@@ -14,23 +16,68 @@ type ConsumableItemServiceServer struct {
pb.UnimplementedConsumableItemServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ConsumableItemCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewConsumableItemServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ConsumableItemCatalog, config *masterdata.GameConfig) *ConsumableItemServiceServer {
return &ConsumableItemServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewConsumableItemServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ConsumableItemServiceServer {
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) {
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)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, item := range req.ConsumableItemPossession {
row, ok := s.catalog.All[item.ConsumableItemId]
row, ok := catalog.All[item.ConsumableItemId]
if !ok {
log.Printf("[ConsumableItemService] Sell: unknown consumableItemId=%d, skipping", item.ConsumableItemId)
continue
@@ -53,7 +100,7 @@ func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.Consumab
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[ConsumableItemService] Sell: total gold +%d", totalGold)
}
})
+63 -38
View File
@@ -9,10 +9,12 @@ import (
"github.com/google/uuid"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -20,17 +22,19 @@ type CostumeServiceServer struct {
pb.UnimplementedCostumeServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.CostumeCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.CostumeCatalog, config *masterdata.GameConfig) *CostumeServiceServer {
return &CostumeServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewCostumeServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *CostumeServiceServer {
return &CostumeServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -41,16 +45,22 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
cm, ok := catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Enhance: costume master id=%d not found", costume.CostumeId)
return
}
expBonus := cat.Campaign.CostumeExpBonus(campaign.CostumeTarget{
CostumeId: costume.CostumeId,
CharacterId: cm.CharacterId,
SkillfulWeaponType: cm.SkillfulWeaponType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
mat, ok := catalog.Materials[materialId]
if !ok {
log.Printf("[CostumeService] Enhance: material id=%d not found, skipping", materialId)
continue
@@ -66,20 +76,20 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
totalExp += expBonus.Apply(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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] Enhance: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
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)
}
@@ -100,6 +110,9 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -110,7 +123,7 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
return
}
awakenRow, ok := s.catalog.AwakenByCostumeId[costume.CostumeId]
awakenRow, ok := catalog.AwakenByCostumeId[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] Awaken: no awaken data for costumeId=%d", costume.CostumeId)
return
@@ -118,8 +131,8 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
nextStep := costume.AwakenCount + 1
if gold, ok := s.catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= gold
if gold, ok := catalog.AwakenPriceByGroup[awakenRow.CostumeAwakenPriceGroupId]; ok {
user.ConsumableItems[config.ConsumableItemIdForGold] -= gold
log.Printf("[CostumeService] Awaken: gold cost=%d", gold)
}
@@ -137,7 +150,7 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
user.Costumes[req.UserCostumeUuid] = costume
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 {
return
}
@@ -148,11 +161,11 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
switch model.CostumeAwakenEffectType(effect.CostumeAwakenEffectType) {
case model.CostumeAwakenEffectTypeStatusUp:
s.applyAwakenStatusUp(user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
applyCostumeAwakenStatusUp(catalog, user, req.UserCostumeUuid, effect.CostumeAwakenEffectId, nowMillis)
case model.CostumeAwakenEffectTypeAbility:
log.Printf("[CostumeService] Awaken: ability effect id=%d (client-resolved)", effect.CostumeAwakenEffectId)
case model.CostumeAwakenEffectTypeItemAcquire:
s.applyAwakenItemAcquire(user, effect.CostumeAwakenEffectId, nowMillis)
applyCostumeAwakenItemAcquire(catalog, user, effect.CostumeAwakenEffectId, nowMillis)
default:
log.Printf("[CostumeService] Awaken: unknown effect type=%d", effect.CostumeAwakenEffectType)
}
@@ -164,8 +177,8 @@ func (s *CostumeServiceServer) Awaken(ctx context.Context, req *pb.AwakenRequest
return &pb.AwakenResponse{}, nil
}
func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
rows, ok := s.catalog.AwakenStatusUpByGroup[statusUpGroupId]
func applyCostumeAwakenStatusUp(catalog *masterdata.CostumeCatalog, user *store.UserState, costumeUuid string, statusUpGroupId int32, nowMillis int64) {
rows, ok := catalog.AwakenStatusUpByGroup[statusUpGroupId]
if !ok {
log.Printf("[CostumeService] Awaken: status up group %d not found", statusUpGroupId)
return
@@ -201,8 +214,8 @@ func (s *CostumeServiceServer) applyAwakenStatusUp(user *store.UserState, costum
}
}
func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, itemAcquireId int32, nowMillis int64) {
acq, ok := s.catalog.AwakenItemAcquireById[itemAcquireId]
func applyCostumeAwakenItemAcquire(catalog *masterdata.CostumeCatalog, user *store.UserState, itemAcquireId int32, nowMillis int64) {
acq, ok := catalog.AwakenItemAcquireById[itemAcquireId]
if !ok {
log.Printf("[CostumeService] Awaken: item acquire id=%d not found", itemAcquireId)
return
@@ -226,6 +239,9 @@ func (s *CostumeServiceServer) applyAwakenItemAcquire(user *store.UserState, ite
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -236,13 +252,13 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
cm, ok := catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: costume master id=%d not found", costume.CostumeId)
return
}
groupRows := s.catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
groupRows := catalog.ActiveSkillGroupsByGroupId[cm.CostumeActiveSkillGroupId]
enhanceMatId := int32(-1)
for _, g := range groupRows {
if g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount {
@@ -259,7 +275,7 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
skill := user.CostumeActiveSkills[req.UserCostumeUuid]
currentLevel := skill.Level
maxLevelFunc, ok := s.catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
maxLevelFunc, ok := catalog.ActiveSkillMaxLevelByRarity[cm.RarityType]
if !ok {
log.Printf("[CostumeService] EnhanceActiveSkill: no max level func for rarity=%d", cm.RarityType)
return
@@ -277,7 +293,7 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.ActiveSkillEnhanceMats[key]
mats := catalog.ActiveSkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -288,9 +304,9 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
}
}
@@ -310,6 +326,9 @@ func (s *CostumeServiceServer) EnhanceActiveSkill(ctx context.Context, req *pb.E
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -320,12 +339,12 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
return
}
if costume.LimitBreakCount >= s.config.CostumeLimitBreakAvailableCount {
if costume.LimitBreakCount >= config.CostumeLimitBreakAvailableCount {
log.Printf("[CostumeService] LimitBreak: already at max limit break %d", costume.LimitBreakCount)
return
}
cm, ok := s.catalog.Costumes[costume.CostumeId]
cm, ok := catalog.Costumes[costume.CostumeId]
if !ok {
log.Printf("[CostumeService] LimitBreak: costume master id=%d not found", costume.CostumeId)
return
@@ -342,9 +361,9 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[CostumeService] LimitBreak: gold cost=%d", goldCost)
}
@@ -363,6 +382,9 @@ func (s *CostumeServiceServer) LimitBreak(ctx context.Context, req *pb.LimitBrea
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -373,15 +395,15 @@ func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req
return
}
effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
if !ok {
log.Printf("[CostumeService] UnlockLotteryEffectSlot: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
return
}
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectUnlockSlotConsumeGold
user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectUnlockSlotConsumeGold
mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId]
mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectUnlockMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -418,6 +440,9 @@ func (s *CostumeServiceServer) UnlockLotteryEffectSlot(ctx context.Context, req
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)
cat := s.holder.Get()
catalog := cat.Costume
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -428,21 +453,21 @@ func (s *CostumeServiceServer) DrawLotteryEffect(ctx context.Context, req *pb.Dr
return
}
effectRow, ok := s.catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
effectRow, ok := catalog.LotteryEffects[[2]int32{costume.CostumeId, req.SlotNumber}]
if !ok {
log.Printf("[CostumeService] DrawLotteryEffect: no lottery effect for costumeId=%d slot=%d", costume.CostumeId, req.SlotNumber)
return
}
oddsPool := s.catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId]
oddsPool := catalog.LotteryEffectOdds[effectRow.CostumeLotteryEffectOddsGroupId]
if len(oddsPool) == 0 {
log.Printf("[CostumeService] DrawLotteryEffect: empty odds pool for groupId=%d", effectRow.CostumeLotteryEffectOddsGroupId)
return
}
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= s.config.CostumeLotteryEffectDrawSlotConsumeGold
user.ConsumableItems[config.ConsumableItemIdForGold] -= config.CostumeLotteryEffectDrawSlotConsumeGold
mats := s.catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId]
mats := catalog.LotteryEffectMats[effectRow.CostumeLotteryEffectDrawMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
+19 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/store"
@@ -12,6 +13,16 @@ import (
"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 {
pb.UnimplementedDataServiceServer
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) {
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{
LatestMasterDataVersion: "20240404193219",
LatestMasterDataVersion: version,
}, 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)
}
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
}
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) {
log.Printf("[DeckService] ReplaceMultiDeck: %d entries", len(req.DeckDetail))
userId := CurrentUserId(ctx, s.users, s.sessions)
+10 -8
View File
@@ -7,8 +7,8 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -22,17 +22,18 @@ type ExploreServiceServer struct {
pb.UnimplementedExploreServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ExploreCatalog
holder *runtime.Holder
}
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ExploreCatalog) *ExploreServiceServer {
return &ExploreServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewExploreServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ExploreServiceServer {
return &ExploreServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
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)
}
@@ -40,7 +41,7 @@ func (s *ExploreServiceServer) StartExplore(ctx context.Context, req *pb.StartEx
nowMillis := gametime.NowMillis()
_, 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 {
cur := user.ConsumableItems[req.UseConsumableItemId]
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) {
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 {
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)
nowMillis := gametime.NowMillis()
+59 -38
View File
@@ -10,6 +10,7 @@ import (
"lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -20,34 +21,33 @@ type GachaServiceServer struct {
pb.UnimplementedGachaServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog []store.GachaCatalogEntry
handler *gacha.GachaHandler
holder *runtime.Holder
}
func NewGachaServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
catalog []store.GachaCatalogEntry,
handler *gacha.GachaHandler,
holder *runtime.Holder,
) *GachaServiceServer {
return &GachaServiceServer{
users: users,
sessions: sessions,
catalog: catalog,
handler: handler,
holder: holder,
}
}
func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) {
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)
nowMillis := gametime.NowMillis()
user, err := s.users.UpdateUser(userId, func(user *store.UserState) {
user.EnsureMaps()
s.autoConvertExpiredMedals(user, catalog, nowMillis)
autoConvertExpiredMedals(user, catalog, handler, nowMillis)
})
if err != nil {
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))
for _, entry := range catalog {
if !gachaActiveAt(entry, nowMillis) {
continue
}
if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) {
continue
}
@@ -71,7 +74,7 @@ func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaL
}, 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 {
if entry.GachaMedalId == 0 || entry.EndDatetime == 0 {
continue
@@ -84,7 +87,7 @@ func (s *GachaServiceServer) autoConvertExpiredMedals(user *store.UserState, cat
continue
}
medalInfo, ok := s.handler.MedalInfo[entry.GachaId]
medalInfo, ok := handler.MedalInfo[entry.GachaId]
if !ok {
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) {
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)
user, err := s.users.LoadUser(userId)
@@ -128,11 +132,15 @@ func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaReque
byId := make(map[int32]*pb.Gacha, len(req.GachaId))
for _, wantedId := range req.GachaId {
for _, entry := range catalog {
if entry.GachaId == wantedId {
bs := user.Gacha.BannerStates[entry.GachaId]
byId[wantedId] = toProtoGacha(entry, &bs)
if entry.GachaId != wantedId {
continue
}
if !gachaActiveAt(entry, nowMillis) {
break
}
bs := user.Gacha.BannerStates[entry.GachaId]
byId[wantedId] = toProtoGacha(entry, &bs)
break
}
}
@@ -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) {
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 {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
handler := cat.GachaHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
execCount := req.ExecCount
@@ -156,9 +166,17 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
}
var drawResult *gacha.DrawResult
ownedCostumes := map[int32]bool{}
ownedWeapons := map[int32]bool{}
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
drawResult, drawErr = s.handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
if drawErr != nil {
log.Printf("[GachaService] Draw error: %v", drawErr)
drawResult = &gacha.DrawResult{}
@@ -193,15 +211,6 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
weaponPT := int32(model.PossessionTypeWeapon)
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 {
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) {
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 {
return nil, fmt.Errorf("gacha %d not found", req.GachaId)
}
handler := cat.GachaHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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)
}
})
@@ -315,7 +326,7 @@ func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Em
return nil, fmt.Errorf("snapshot user: %w", err)
}
maxCount := s.handler.Config.RewardGachaDailyMaxCount
maxCount := s.holder.Get().GachaHandler.Config.RewardGachaDailyMaxCount
if maxCount <= 0 {
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)
userId := CurrentUserId(ctx, s.users, s.sessions)
handler := s.holder.Get().GachaHandler
var items []gacha.DrawnItem
ownedCostumes := map[int32]bool{}
ownedWeapons := map[int32]bool{}
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
items, drawErr = s.handler.HandleRewardDraw(user, 1)
items, drawErr = handler.HandleRewardDraw(user, 1)
if drawErr != nil {
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)
}
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))
for _, item := range items {
results = append(results, &pb.RewardGachaItem{
@@ -395,6 +406,16 @@ func matchesGachaLabel(labels []int32, label int32) bool {
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 {
g := &pb.Gacha{
GachaId: entry.GachaId,
+140 -12
View File
@@ -7,6 +7,8 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -14,13 +16,13 @@ import (
type GimmickServiceServer struct {
pb.UnimplementedGimmickServiceServer
users store.UserRepository
sessions store.SessionRepository
gimmickCatalog *masterdata.GimmickCatalog
users store.UserRepository
sessions store.SessionRepository
holder *runtime.Holder
}
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, gimmickCatalog *masterdata.GimmickCatalog) *GimmickServiceServer {
return &GimmickServiceServer{users: users, sessions: sessions, gimmickCatalog: gimmickCatalog}
func NewGimmickServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *GimmickServiceServer {
return &GimmickServiceServer{users: users, sessions: sessions, holder: holder}
}
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",
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
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) {
nowMillis := gametime.NowMillis()
progressKey := store.GimmickKey{
@@ -53,7 +59,6 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
progress := user.Gimmick.Progress[progressKey]
progress.Key = progressKey
progress.StartDatetime = nowMillis
user.Gimmick.Progress[progressKey] = progress
ornamentKey := store.GimmickOrnamentKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
@@ -66,28 +71,151 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
ornament.ProgressValueBit = req.ProgressValueBit
ornament.BaseDatetime = nowMillis
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{
GimmickOrnamentReward: []*pb.GimmickReward{},
IsSequenceCleared: false,
GimmickSequenceClearReward: []*pb.GimmickReward{},
GimmickOrnamentReward: ornamentRewards,
IsSequenceCleared: sequenceCleared,
GimmickSequenceClearReward: clearReward,
}, 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) {
log.Printf("[GimmickService] InitSequenceSchedule")
userId := CurrentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis()
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
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 {
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
added++
}
}
if added > 0 {
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences))
if pruned > 0 || added > 0 {
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
+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
}
+98 -67
View File
@@ -45,14 +45,21 @@ type infoLoad struct {
}
var (
listBinCache = make(map[string]listBinIndex) // revision → index
listBinCache = make(map[string]listBinIndex) // revision/platform → index
listBinInflight = make(map[string]*listBinLoad)
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)
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).
type infoJSONEntry struct {
FromName string `json:"from-name"`
@@ -208,33 +215,34 @@ func parseListBin(data []byte) listBinIndex {
return idx
}
func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) {
key := cacheKey(revision, platform)
listBinCacheMu.RLock()
idx, ok := listBinCache[revision]
idx, ok := listBinCache[key]
listBinCacheMu.RUnlock()
if ok {
return idx, true
}
listBinCacheMu.Lock()
if idx, ok := listBinCache[revision]; ok {
if idx, ok := listBinCache[key]; ok {
listBinCacheMu.Unlock()
return idx, true
}
if load := listBinInflight[revision]; load != nil {
if load := listBinInflight[key]; load != nil {
listBinCacheMu.Unlock()
<-load.done
return load.idx, load.ok
}
load := &listBinLoad{done: make(chan struct{})}
listBinInflight[revision] = load
listBinInflight[key] = load
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)
if err != nil {
listBinCacheMu.Lock()
delete(listBinInflight, revision)
delete(listBinInflight, key)
close(load.done)
listBinCacheMu.Unlock()
return nil, false
@@ -243,41 +251,42 @@ func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
load.idx = idx
load.ok = true
listBinCacheMu.Lock()
listBinCache[revision] = idx
delete(listBinInflight, revision)
listBinCache[key] = idx
delete(listBinInflight, key)
close(load.done)
listBinCacheMu.Unlock()
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()
m, ok := infoCache[revision]
m, ok := infoCache[key]
infoCacheMu.RUnlock()
if ok {
return m
}
infoCacheMu.Lock()
if m, ok := infoCache[revision]; ok {
if m, ok := infoCache[key]; ok {
infoCacheMu.Unlock()
return m
}
if load := infoInflight[revision]; load != nil {
if load := infoInflight[key]; load != nil {
infoCacheMu.Unlock()
<-load.done
return load.m
}
load := &infoLoad{done: make(chan struct{})}
infoInflight[revision] = load
infoInflight[key] = load
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)
if err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
infoCache[key] = nil
delete(infoInflight, key)
close(load.done)
infoCacheMu.Unlock()
return nil
@@ -285,8 +294,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
var entries []infoJSONEntry
if err := json.Unmarshal(data, &entries); err != nil {
infoCacheMu.Lock()
infoCache[revision] = nil
delete(infoInflight, revision)
infoCache[key] = nil
delete(infoInflight, key)
close(load.done)
infoCacheMu.Unlock()
return nil
@@ -307,8 +316,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
}
load.m = m
infoCacheMu.Lock()
infoCache[revision] = m
delete(infoInflight, revision)
infoCache[key] = m
delete(infoInflight, key)
close(load.done)
infoCacheMu.Unlock()
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).
// For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII
// 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, ")", "/")
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
return nil
@@ -402,7 +411,7 @@ func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCand
if strings.Contains(pathStr, ")ko)") {
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
seen := make(map[string]bool)
for _, e := range entries {
@@ -434,64 +443,86 @@ func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, ca
return append(candidates, candidate)
}
func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType)
func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string {
root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType)
rel, err := filepath.Rel(root, candidate.Path)
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
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
// (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
// (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.
func objectIdToFilePathCandidates(baseDir, revision, 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
}
func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
seen := make(map[string]bool)
for _, pc := range paths {
md5 := entry.MD5
if pc.IsLocaleFallback {
md5 = ""
var firstSize int64
var anyHit bool
appendForPlatform := func(p, label string) {
idx, idxOk := loadListBinIndex(baseDir, revision, p)
if !idxOk || idx == nil {
return
}
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: pc.Path,
Revision: revision,
Source: "list.bin",
ExpectedMD5: md5,
})
}
infoIndex := loadInfoIndex(baseDir, revision)
if len(infoIndex) > 0 {
for _, c := range candidates {
alias, ok := infoIndex[filepath.Base(c.Path)]
if !ok || alias.ToName == "" {
continue
}
alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName)
if alt == "" {
continue
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 {
md5 := entry.MD5
if pc.IsLocaleFallback {
md5 = ""
}
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: alt,
Revision: alias.ToRevision,
Source: "info.json redirect",
ExpectedMD5: alias.MD5,
Path: pc.Path,
Revision: revision,
Source: "list.bin (" + label + ")",
ExpectedMD5: md5,
})
}
infoIndex := loadInfoIndex(baseDir, revision, p)
if len(infoIndex) > 0 {
tierCandidates := candidates[tierStart:]
for _, c := range tierCandidates {
alias, aliasOk := infoIndex[filepath.Base(c.Path)]
if !aliasOk || alias.ToName == "" {
continue
}
alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName)
if alt == "" {
continue
}
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
Path: alt,
Revision: alias.ToRevision,
Source: "info.json redirect (" + label + ")",
ExpectedMD5: alias.MD5,
})
}
}
if !anyHit {
firstSize = entry.Size
anyHit = true
}
}
return candidates, entry.Size, true
appendForPlatform(platform, platform)
appendForPlatform("", "shared")
if !anyHit {
return nil, 0, false
}
return candidates, firstSize, true
}
+61 -24
View File
@@ -2,14 +2,18 @@ package service
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -19,35 +23,37 @@ type LoginBonusServiceServer struct {
pb.UnimplementedLoginBonusServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.LoginBonusCatalog
holder *runtime.Holder
}
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.LoginBonusCatalog) *LoginBonusServiceServer {
return &LoginBonusServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewLoginBonusServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LoginBonusServiceServer {
return &LoginBonusServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *LoginBonusServiceServer) ReceiveStamp(ctx context.Context, req *emptypb.Empty) (*pb.ReceiveStampResponse, error) {
log.Printf("[LoginBonusService] ReceiveStamp")
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)
if err != nil {
return nil, fmt.Errorf("load user: %w", err)
}
nextPage, nextStamp, reward, err := resolveNextStamp(catalog, user.LoginBonus)
if err != nil {
return nil, err
}
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()
nextStamp := user.LoginBonus.CurrentStampNumber + 1
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)",
nextStamp, reward.PossessionType, reward.PossessionId, reward.Count)
user.Gifts.NotReceived = append(user.Gifts.NotReceived, store.NotReceivedGiftState{
u.Gifts.NotReceived = append(u.Gifts.NotReceived, store.NotReceivedGiftState{
GiftCommon: store.GiftCommonState{
PossessionType: reward.PossessionType,
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),
UserGiftUuid: uuid.New().String(),
})
user.Notifications.GiftNotReceiveCount = int32(len(user.Gifts.NotReceived))
user.LoginBonus.CurrentStampNumber = nextStamp
user.LoginBonus.LatestRewardReceiveDatetime = now
user.LoginBonus.LatestVersion = now
u.Notifications.GiftNotReceiveCount = int32(len(u.Gifts.NotReceived))
u.LoginBonus.CurrentPageNumber = nextPage
u.LoginBonus.CurrentStampNumber = nextStamp
u.LoginBonus.LatestRewardReceiveDatetime = now
u.LoginBonus.LatestVersion = now
})
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"
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"
)
@@ -14,23 +15,25 @@ type MaterialServiceServer struct {
pb.UnimplementedMaterialServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.MaterialCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.MaterialCatalog, config *masterdata.GameConfig) *MaterialServiceServer {
return &MaterialServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewMaterialServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *MaterialServiceServer {
return &MaterialServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRequest) (*pb.MaterialSellResponse, error) {
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)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
totalGold := int32(0)
for _, item := range req.MaterialPossession {
mat, ok := s.catalog.All[item.MaterialId]
mat, ok := catalog.All[item.MaterialId]
if !ok {
log.Printf("[MaterialService] Sell: unknown materialId=%d, skipping", item.MaterialId)
continue
@@ -50,10 +53,18 @@ func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRe
gold := mat.SellPrice * item.Count
totalGold += 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 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[MaterialService] Sell: total gold +%d", totalGold)
}
})
+72 -39
View File
@@ -44,6 +44,30 @@ const informationPage = `<!DOCTYPE html>
</body>
</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.
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
@@ -133,7 +157,9 @@ func NewOctoHTTPServer(resourcesBaseURL, baseDir string) *OctoHTTPServer {
revisions: newRevisionTracker(),
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)
return s
}
@@ -142,12 +168,24 @@ func (s *OctoHTTPServer) Handler() http.Handler {
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) {
path := r.URL.Path
platform := platformFromUserAgent(r)
isAssetRequest := strings.Contains(path, "/unso-")
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
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 {
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
if strings.HasPrefix(path, "/v2/") {
s.handleOctoV2(w, r, path)
s.handleOctoV2(w, r, path, platform)
return
}
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
if strings.HasPrefix(path, "/v1/list/") {
s.serveOctoV1List(w, r, path)
s.serveOctoV1List(w, r, path, platform)
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)
if strings.Contains(path, "/unso-") {
s.serveUnsoAsset(w, r, path)
s.serveUnsoAsset(w, r, path, platform)
return
}
@@ -218,8 +256,8 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
w.Write([]byte{})
}
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
log.Printf("[OctoV2] %s %s", r.Method, path)
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) {
log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform)
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
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]
if requestedRevision != "" {
revision := "0"
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
filePath := s.listBinPath(revision, platform)
if 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)
go s.resolver.Prewarm(revision)
go s.resolver.Prewarm(revision, platform)
s.serveListBin(w, filePath)
return
}
@@ -259,8 +297,8 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa
w.WriteHeader(200)
}
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
// 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, platform string) {
parts := strings.Split(strings.Trim(path, "/"), "/")
// ["v1", "list", "300116832", "0"] -> revision = last segment
requestedRevision := "0"
@@ -268,18 +306,18 @@ func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request,
requestedRevision = parts[len(parts)-1]
}
revision := "0"
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
filePath := s.listBinPath(revision, platform)
if 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)
go s.resolver.Prewarm(revision)
go s.resolver.Prewarm(revision, platform)
s.serveListBin(w, filePath)
}
// 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, "/"), "/")
var segment, objectId string
for i, p := range parts {
@@ -311,9 +349,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
return
}
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 {
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.WriteHeader(http.StatusNotFound)
return
@@ -354,7 +392,7 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
}
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
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()
continue
}
@@ -366,9 +404,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
return
}
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.WriteHeader(http.StatusNotFound)
}
@@ -400,24 +438,12 @@ func (s *OctoHTTPServer) serveListBin(w http.ResponseWriter, filePath string) {
w.Write(data)
}
// serveDatabaseBinE serves MasterMemory database: /assets/release/{version}/database.bin.e
// -> assets/release/{version}.bin.e (or assets/release/database.bin.e fallback).
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, path string) {
parts := strings.Split(path, "/")
var version string
for i, p := range parts {
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
}
}
// serveDatabaseBinE serves the master data binary. The URL's {version} segment
// is a cache key (it changes whenever the file's mtime changes, see
// DataService.GetLatestMasterDataVersion) but does not select a different file —
// there's only ever one bin.e on disk.
func (s *OctoHTTPServer) serveDatabaseBinE(w http.ResponseWriter, r *http.Request, _ string) {
filePath := filepath.Join(s.BaseDir, "assets", "release", "20240404193219.bin.e")
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, filePath)
}
@@ -454,6 +480,13 @@ func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, pa
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.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
+5 -5
View File
@@ -7,7 +7,7 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -15,11 +15,11 @@ type OmikujiServiceServer struct {
pb.UnimplementedOmikujiServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.OmikujiCatalog
holder *runtime.Holder
}
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.OmikujiCatalog) *OmikujiServiceServer {
return &OmikujiServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewOmikujiServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *OmikujiServiceServer {
return &OmikujiServiceServer{users: users, sessions: sessions, holder: holder}
}
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{
OmikujiResultAssetId: s.catalog.LookupAssetId(req.OmikujiId),
OmikujiResultAssetId: s.holder.Get().Omikuji.LookupAssetId(req.OmikujiId),
OmikujiItem: []*pb.OmikujiItem{},
}, nil
}
+180 -23
View File
@@ -7,8 +7,11 @@ import (
"math/rand"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -18,17 +21,63 @@ type PartsServiceServer struct {
pb.UnimplementedPartsServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.PartsCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.PartsCatalog, config *masterdata.GameConfig) *PartsServiceServer {
return &PartsServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *PartsServiceServer {
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) {
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)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
@@ -44,13 +93,13 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
continue
}
partDef, ok := s.catalog.PartsById[part.PartsId]
partDef, ok := catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Sell: partsId=%d not in catalog, skipping", part.PartsId)
continue
}
sellFunc, ok := s.catalog.SellPriceByRarity[partDef.RarityType]
sellFunc, ok := catalog.SellPriceByRarity[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Sell: no sell price func for rarity=%d, skipping", partDef.RarityType)
continue
@@ -68,7 +117,7 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[PartsService] Sell: total gold +%d", totalGold)
}
})
@@ -82,6 +131,9 @@ func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest)
func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRequest) (*pb.PartsEnhanceResponse, error) {
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)
nowMillis := gametime.NowMillis()
@@ -99,48 +151,54 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
return
}
partDef, ok := s.catalog.PartsById[part.PartsId]
partDef, ok := catalog.PartsById[part.PartsId]
if !ok {
log.Printf("[PartsService] Enhance: part master id=%d not found", part.PartsId)
return
}
rarity, ok := s.catalog.RarityByRarityType[partDef.RarityType]
rarity, ok := catalog.RarityByRarityType[partDef.RarityType]
if !ok {
log.Printf("[PartsService] Enhance: rarity type=%d not found", partDef.RarityType)
return
}
goldCost := int32(0)
if prices, ok := s.catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
if prices, ok := catalog.PriceByGroupAndLevel[rarity.PartsLevelUpPriceGroupId]; ok {
goldCost = prices[part.Level]
}
currentGold := user.ConsumableItems[s.config.ConsumableItemIdForGold]
currentGold := user.ConsumableItems[config.ConsumableItemIdForGold]
if currentGold < goldCost {
log.Printf("[PartsService] Enhance: insufficient gold have=%d need=%d", currentGold, goldCost)
return
}
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
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 {
successRate = r
}
}
baseRate := successRate
successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{
PartsId: part.PartsId,
PartsGroupId: partDef.PartsGroupId,
Rarity: model.RarityType(partDef.RarityType),
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate)
if rand.Intn(1000) < int(successRate) {
part.Level++
isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost)
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost)
s.grantSubStatuses(user, req.UserPartsUuid, part, partDef, nowMillis)
grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis)
} else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost)
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, baseRate, goldCost)
}
part.LatestVersion = nowMillis
@@ -155,9 +213,9 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
}, nil
}
func (s *PartsServiceServer) grantSubStatuses(user *store.UserState, uuid string, part store.PartsState, partDef masterdata.EntityMParts, nowMillis int64) {
unlockLevels := s.catalog.SubStatusUnlockLvls[partDef.RarityType]
pool := s.catalog.SubStatusPool[partDef.PartsStatusSubLotteryGroupId]
func grantPartsSubStatuses(catalog *masterdata.PartsCatalog, user *store.UserState, uuid string, part store.PartsState, partDef masterdata.EntityMParts, nowMillis int64) {
unlockLevels := catalog.SubStatusUnlockLvls[partDef.RarityType]
pool := catalog.SubStatusPool[partDef.PartsStatusSubLotteryGroupId]
if len(pool) == 0 {
return
}
@@ -173,13 +231,13 @@ func (s *PartsServiceServer) grantSubStatuses(user *store.UserState, uuid string
}
pick := pool[rand.Intn(len(pool))]
def, ok := s.catalog.PartsStatusMainById[pick]
def, ok := catalog.PartsStatusMainById[pick]
if !ok {
continue
}
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)
}
@@ -220,3 +278,102 @@ func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsRep
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"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
@@ -27,6 +28,18 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = true
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
}
+189 -44
View File
@@ -8,7 +8,7 @@ import (
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -18,34 +18,37 @@ type BigHuntServiceServer struct {
pb.UnimplementedBigHuntServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.BigHuntCatalog
engine *questflow.QuestHandler
holder *runtime.Holder
}
func NewBigHuntServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
catalog *masterdata.BigHuntCatalog,
engine *questflow.QuestHandler,
holder *runtime.Holder,
) *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) {
log.Printf("[BigHuntService] StartBigHuntQuest: bossQuestId=%d questId=%d deckNumber=%d isDryRun=%v",
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)
nowMillis := gametime.NowMillis()
bhQuest, ok := s.catalog.QuestById[req.BigHuntQuestId]
bhQuest, ok := catalog.QuestById[req.BigHuntQuestId]
if !ok {
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
}
today := gametime.StartOfDayMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
if ok {
s.engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
}
user.BigHuntProgress = store.BigHuntProgress{
@@ -59,6 +62,9 @@ func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.St
user.BigHuntDeckNumber = req.UserDeckNumber
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount++
st.LatestChallengeDatetime = 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",
req.BigHuntBossQuestId, req.BigHuntQuestId, req.IsRetired)
cat := s.holder.Get()
catalog := cat.BigHunt
engine := cat.QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
bossQuest := s.catalog.BossQuestById[req.BigHuntBossQuestId]
boss := s.catalog.BossByBossId[bossQuest.BigHuntBossId]
bhQuest := catalog.QuestById[req.BigHuntQuestId]
bossQuest := catalog.BossQuestById[req.BigHuntBossQuestId]
boss := catalog.BossByBossId[bossQuest.BigHuntBossId]
var scoreInfo *pb.BigHuntScoreInfo
var scoreRewards []*pb.BigHuntReward
var battleReportWaves []*pb.BigHuntBattleReportWave
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 {
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
user.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
return
}
@@ -108,7 +120,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
baseScore := totalDamage
difficultyBonusPermil := int32(0)
if coeff, ok := s.catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
if coeff, ok := catalog.ScoreCoefficients[bhQuest.BigHuntQuestScoreCoefficientId]; ok {
difficultyBonusPermil = coeff
}
@@ -125,11 +137,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
isHighScore := false
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
oldMax := oldMaxBoss.MaxScore
if userScore > oldMax {
isHighScore = true
if userScore > user.BigHuntMaxScores[bossQuest.BigHuntBossId].MaxScore {
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
MaxScore: userScore,
MaxScoreUpdateDatetime: nowMillis,
@@ -138,11 +146,12 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
}
schedKey := store.BigHuntScheduleScoreKey{
BigHuntScheduleId: s.catalog.ActiveScheduleId,
BigHuntScheduleId: catalog.ActiveScheduleId,
BigHuntBossId: bossQuest.BigHuntBossId,
}
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
if userScore > oldSchedMax {
isHighScore := userScore > oldSchedMax
if isHighScore {
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
MaxScore: userScore,
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{
UserScore: userScore,
@@ -177,12 +186,12 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
}
if isHighScore {
rewardGroupId := s.catalog.ResolveActiveScoreRewardGroupId(
rewardGroupId := catalog.ResolveActiveScoreRewardGroupId(
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
if rewardGroupId > 0 {
newItems := s.catalog.CollectNewRewards(rewardGroupId, oldMax, userScore)
newItems := catalog.CollectNewRewards(rewardGroupId, oldSchedMax, userScore)
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{
PossessionType: item.PossessionType,
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.BigHuntBattleBinary = nil
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
@@ -204,33 +238,46 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
scoreRewards = []*pb.BigHuntReward{}
}
if battleReportWaves == nil {
battleReportWaves = []*pb.BigHuntBattleReportWave{}
}
battleReport := &pb.BigHuntBattleReport{
BattleReportWave: battleReportWaves,
}
return &pb.FinishBigHuntQuestResponse{
ScoreInfo: scoreInfo,
ScoreReward: scoreRewards,
BattleReport: &pb.BigHuntBattleReport{
BattleReportWave: []*pb.BigHuntBattleReportWave{},
},
ScoreInfo: scoreInfo,
ScoreReward: scoreRewards,
BattleReport: battleReport,
}, nil
}
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)
cat := s.holder.Get()
catalog := cat.BigHunt
engine := cat.QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
bhQuest := s.catalog.QuestById[req.BigHuntQuestId]
bhQuest := catalog.QuestById[req.BigHuntQuestId]
var battleBinary []byte
var deckNumber int32
today := gametime.StartOfDayMillis()
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.LatestVersion = nowMillis
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount++
st.LatestChallengeDatetime = 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) {
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)
nowMillis := gametime.NowMillis()
today := gametime.StartOfDayMillis()
bossQuest, hasBossQuest := catalog.BossQuestById[req.BigHuntBossQuestId]
var scoreRewards []*pb.BigHuntReward
s.users.UpdateUser(userId, func(user *store.UserState) {
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
if st.LatestChallengeDatetime < today {
st.DailyChallengeCount = 0
}
st.DailyChallengeCount += req.SkipCount
st.LatestChallengeDatetime = nowMillis
st.LatestVersion = nowMillis
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{
ScoreReward: []*pb.BigHuntReward{},
ScoreReward: scoreRewards,
}, nil
}
@@ -284,12 +370,35 @@ func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *p
user.BigHuntBattleBinary = req.BattleBinary
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{
DeckType: req.BigHuntBattleDetail.DeckType,
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
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) {
log.Printf("[BigHuntService] GetBigHuntTopData")
catalog := s.holder.Get().BigHunt
userId := CurrentUserId(ctx, s.users, s.sessions)
user, _ := s.users.LoadUser(userId)
@@ -309,13 +419,13 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
weeklyVersion := gametime.WeeklyVersion(nowMillis)
var weeklyScoreResults []*pb.WeeklyScoreResult
for _, boss := range s.catalog.BossByBossId {
for _, boss := range catalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
ws := user.BigHuntWeeklyMaxScores[key]
gradeIconId := s.catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
gradeIconId := catalog.ResolveGradeIconId(boss.BigHuntBossId, ws.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
@@ -330,10 +440,10 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
weeklyRewards := s.resolveWeeklyRewards(user, weeklyVersion, nowMillis)
weeklyRewards := resolveBigHuntWeeklyRewards(catalog, user, weeklyVersion, nowMillis)
lastWeekVersion := weeklyVersion - 7*24*60*60*1000
lastWeekRewards := s.resolveWeeklyRewards(user, lastWeekVersion, nowMillis)
lastWeekRewards := resolveBigHuntWeeklyRewards(catalog, user, lastWeekVersion, nowMillis)
return &pb.GetBigHuntTopDataResponse{
WeeklyScoreResult: weeklyScoreResults,
@@ -343,14 +453,49 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
}, nil
}
func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
var rewards []*pb.BigHuntReward
for _, boss := range s.catalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
func bigHuntWaveCount(infos []store.BigHuntCostumeBattleInfo) int {
if len(infos) == 0 {
return 0
}
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
}
rewardGroupId := s.catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
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 {
continue
}
@@ -359,7 +504,7 @@ func (s *BigHuntServiceServer) resolveWeeklyRewards(user store.UserState, weekly
AttributeType: boss.AttributeType,
}
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{
PossessionType: item.PossessionType,
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) {
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)
nowMillis := gametime.NowMillis()
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))
for i, d := range drops {
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)
nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
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{
@@ -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) {
log.Printf("[QuestService] RestartEventQuest: chapterId=%d questId=%d", req.EventQuestChapterId, req.QuestId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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{
@@ -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) {
log.Printf("[QuestService] UpdateEventQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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
+10 -6
View File
@@ -13,13 +13,14 @@ import (
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)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
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))
for i, d := range drops {
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)
nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
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{
@@ -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) {
log.Printf("[QuestService] RestartExtraQuest: questId=%d", req.QuestId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var deckNumber int32
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
})
drops := s.engine.BattleDropRewards(req.QuestId)
drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
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) {
log.Printf("[QuestService] UpdateExtraQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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
+30 -16
View File
@@ -8,6 +8,7 @@ import (
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -17,22 +18,23 @@ type QuestServiceServer struct {
pb.UnimplementedQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
holder *runtime.Holder
}
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *QuestServiceServer {
if engine == nil {
panic("quest handler is required")
func NewQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *QuestServiceServer {
if holder == nil {
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) {
log.Printf("[QuestService] UpdateMainFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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
@@ -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) {
log.Printf("[QuestService] UpdateReplayFlowSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
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
@@ -52,9 +55,10 @@ func (s *QuestServiceServer) UpdateReplayFlowSceneProgress(ctx context.Context,
func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, req *pb.UpdateMainQuestSceneProgressRequest) (*pb.UpdateMainQuestSceneProgressResponse, error) {
log.Printf("[QuestService] UpdateMainQuestSceneProgress: questSceneId=%d", req.QuestSceneId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
s.engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
engine.HandleMainQuestSceneProgress(user, req.QuestSceneId)
})
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) {
log.Printf("[QuestService] StartMainQuest: %+v", req)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
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 {
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))
for i, d := range drops {
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)
nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
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{
@@ -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) {
log.Printf("[QuestService] RestartMainQuest: questId=%d isMainFlow=%v", req.QuestId, req.IsMainFlow)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var deckNumber int32
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
})
drops := s.engine.BattleDropRewards(req.QuestId)
drops := engine.BattleDropRewards(req.QuestId)
pbDrops := make([]*pb.BattleDropReward, len(drops))
for i, d := range drops {
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))
nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
var outcome questflow.FinishOutcome
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
}
}
outcome = s.engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
outcome = engine.HandleQuestSkip(user, req.QuestId, req.SkipCount, nowMillis)
})
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) {
log.Printf("[QuestService] SetRoute: mainQuestRouteId=%d", req.MainQuestRouteId)
engine := s.holder.Get().QuestHandler
userId := CurrentUserId(ctx, s.users, s.sessions)
s.users.UpdateUser(userId, func(user *store.UserState) {
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
}
now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = now
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: now,
}
}
})
return &pb.SetRouteResponse{}, nil
+81 -28
View File
@@ -8,6 +8,7 @@ import (
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -15,11 +16,68 @@ type SideStoryQuestServiceServer struct {
pb.UnimplementedSideStoryQuestServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.SideStoryCatalog
holder *runtime.Holder
}
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.SideStoryCatalog) *SideStoryQuestServiceServer {
return &SideStoryQuestServiceServer{users: users, sessions: sessions, catalog: catalog}
func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *SideStoryQuestServiceServer {
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) {
@@ -27,29 +85,30 @@ func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Con
userId := CurrentUserId(ctx, s.users, s.sessions)
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) {
if info == nil || len(info.Quests) == 0 {
log.Printf("[SideStoryQuestService] unknown sideStoryQuestId=%d, skipping", req.SideStoryQuestId)
return
}
existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
var sceneId int32
if exists && existing.HeadSideStoryQuestSceneId > 0 {
sceneId = existing.HeadSideStoryQuestSceneId
var scene int32
var ok bool
if !exists || existing.HeadSideStoryQuestSceneId == 0 {
scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction)
} else {
sceneId = firstSceneId
}
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,
scene, ok = sideStoryNextSceneAfterBattle(info, user)
if !ok {
scene, ok = existing.HeadSideStoryQuestSceneId, true
}
}
if !ok {
return
}
setSideStoryScene(user, info, req.SideStoryQuestId, scene, nowMillis)
})
return &pb.MoveSideStoryQuestResponse{}, nil
@@ -61,16 +120,10 @@ func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx cont
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
progress := user.SideStoryQuests[req.SideStoryQuestId]
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId {
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
}
progress.LatestVersion = nowMillis
user.SideStoryQuests[req.SideStoryQuestId] = progress
s.users.UpdateUser(userId, func(user *store.UserState) {
setSideStoryScene(user, info, req.SideStoryQuestId, req.SideStoryQuestSceneId, nowMillis)
})
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
}
+45 -18
View File
@@ -6,8 +6,8 @@ import (
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@@ -15,43 +15,74 @@ import (
type RewardServiceServer struct {
pb.UnimplementedRewardServiceServer
users store.UserRepository
sessions store.SessionRepository
bhCatalog *masterdata.BigHuntCatalog
granter *store.PossessionGranter
users store.UserRepository
sessions store.SessionRepository
holder *runtime.Holder
}
func NewRewardServiceServer(
users store.UserRepository,
sessions store.SessionRepository,
bhCatalog *masterdata.BigHuntCatalog,
granter *store.PossessionGranter,
holder *runtime.Holder,
) *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) {
log.Printf("[RewardService] ReceiveBigHuntReward")
cat := s.holder.Get()
bhCatalog := cat.BigHunt
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
weeklyVersion := gametime.WeeklyVersion(nowMillis)
today := gametime.StartOfDayMillis()
var weeklyScoreResults []*pb.WeeklyScoreResult
var weeklyRewards []*pb.BigHuntReward
isReceived := false
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]
isReceived = ws.IsReceivedWeeklyReward
for _, boss := range s.bhCatalog.BossByBossId {
for _, boss := range bhCatalog.BossByBossId {
key := store.BigHuntWeeklyScoreKey{
BigHuntWeeklyVersion: weeklyVersion,
AttributeType: boss.AttributeType,
}
wms := user.BigHuntWeeklyMaxScores[key]
gradeIcon := s.bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
gradeIcon := bhCatalog.ResolveGradeIconId(boss.BigHuntBossId, wms.MaxScore)
weeklyScoreResults = append(weeklyScoreResults, &pb.WeeklyScoreResult{
AttributeType: boss.AttributeType,
BeforeMaxScore: wms.MaxScore,
@@ -64,12 +95,8 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
}
if !isReceived {
for _, boss := range s.bhCatalog.BossByBossId {
rewardKey := masterdata.BigHuntWeeklyRewardKey{
ScheduleId: 1,
AttributeType: boss.AttributeType,
}
rewardGroupId := s.bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
for _, boss := range bhCatalog.BossByBossId {
rewardGroupId := bhCatalog.ResolveActiveWeeklyRewardGroupIdByAttr(boss.AttributeType, nowMillis)
if rewardGroupId == 0 {
continue
}
@@ -80,9 +107,9 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
}
maxScore := user.BigHuntWeeklyMaxScores[weekKey].MaxScore
items := s.bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
items := bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
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{
PossessionType: item.PossessionType,
PossessionId: item.PossessionId,
+27 -32
View File
@@ -9,6 +9,7 @@ import (
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
"google.golang.org/protobuf/types/known/emptypb"
@@ -18,23 +19,25 @@ type ShopServiceServer struct {
pb.UnimplementedShopServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.ShopCatalog
granter *store.PossessionGranter
holder *runtime.Holder
}
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.ShopCatalog, granter *store.PossessionGranter) *ShopServiceServer {
return &ShopServiceServer{users: users, sessions: sessions, catalog: catalog, granter: granter}
func NewShopServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *ShopServiceServer {
return &ShopServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
cat := s.holder.Get()
catalog := cat.Shop
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
for shopItemId, qty := range req.ShopItems {
item, ok := s.catalog.Items[shopItemId]
item, ok := catalog.Items[shopItemId]
if !ok {
log.Printf("[ShopService] Buy: unknown shopItemId=%d, skipping", shopItemId)
continue
@@ -46,8 +49,8 @@ func (s *ShopServiceServer) Buy(ctx context.Context, req *pb.BuyRequest) (*pb.Bu
continue
}
for _, content := range s.catalog.Contents[shopItemId] {
s.granter.GrantFull(user,
for _, content := range catalog.Contents[shopItemId] {
granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
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.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) {
log.Printf("[ShopService] RefreshUserData: isGemUsed=%v", req.IsGemUsed)
catalog := s.holder.Get().Shop
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if len(user.ShopReplaceableLineup) == 0 && len(s.catalog.ItemShopPool) > 0 {
for i, itemId := range s.catalog.ItemShopPool {
if len(user.ShopReplaceableLineup) == 0 && len(catalog.ItemShopPool) > 0 {
for i, itemId := range catalog.ItemShopPool {
slot := int32(i + 1)
user.ShopReplaceableLineup[slot] = store.UserShopReplaceableLineupState{
SlotNumber: slot,
@@ -93,7 +97,7 @@ func (s *ShopServiceServer) RefreshUserData(ctx context.Context, req *pb.Refresh
if req.IsGemUsed {
user.ShopReplaceable.LineupUpdateCount++
user.ShopReplaceable.LatestLineupUpdateDatetime = nowMillis
for _, itemId := range s.catalog.ItemShopPool {
for _, itemId := range catalog.ItemShopPool {
if si, ok := user.ShopItems[itemId]; ok {
si.BoughtCount = 0
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",
req.ShopId, req.ShopItemId, req.ProductId)
cat := s.holder.Get()
catalog := cat.Shop
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
item, ok := s.catalog.Items[req.ShopItemId]
item, ok := catalog.Items[req.ShopItemId]
if !ok {
log.Printf("[ShopService] CreatePurchaseTransaction: unknown shopItemId=%d", req.ShopItemId)
return
@@ -134,8 +141,8 @@ func (s *ShopServiceServer) CreatePurchaseTransaction(ctx context.Context, req *
log.Printf("[ShopService] CreatePurchaseTransaction: deduct failed: %v", err)
}
for _, content := range s.catalog.Contents[req.ShopItemId] {
s.granter.GrantFull(user,
for _, content := range catalog.Contents[req.ShopItemId] {
granter.GrantFull(user,
model.PossessionType(content.PossessionType),
content.PossessionId,
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.ShopItemId = req.ShopItemId
si.BoughtCount++
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
}
}
@@ -182,27 +189,15 @@ func (s *ShopServiceServer) PurchaseGooglePlayStoreProduct(ctx context.Context,
}, nil
}
func (s *ShopServiceServer) applyContentEffects(user *store.UserState, shopItemId, qty int32, nowMillis int64) {
for _, effect := range s.catalog.Effects[shopItemId] {
func applyShopContentEffects(catalog *masterdata.ShopCatalog, user *store.UserState, shopItemId, qty int32, nowMillis int64) {
for _, effect := range catalog.Effects[shopItemId] {
switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery:
maxMillis := s.catalog.MaxStaminaMillis[user.Status.Level]
millis := s.resolveEffectMillis(effect.EffectValueType, effect.EffectValue, user.Status.Level)
maxMillis := catalog.MaxStaminaMillis[user.Status.Level]
millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxMillis)
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
default:
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/model"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -15,17 +16,18 @@ type TutorialServiceServer struct {
pb.UnimplementedTutorialServiceServer
users store.UserRepository
sessions store.SessionRepository
engine *questflow.QuestHandler
holder *runtime.Holder
}
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, engine *questflow.QuestHandler) *TutorialServiceServer {
return &TutorialServiceServer{users: users, sessions: sessions, engine: engine}
func NewTutorialServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *TutorialServiceServer {
return &TutorialServiceServer{users: users, sessions: sessions, holder: holder}
}
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)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
engine := s.holder.Get().QuestHandler
var grants []questflow.RewardGrant
s.users.UpdateUser(userId, func(user *store.UserState) {
existing, exists := user.Tutorials[req.TutorialType]
@@ -36,7 +38,7 @@ func (s *TutorialServiceServer) SetTutorialProgress(ctx context.Context, req *pb
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 {
store.EnsureDefaultDeck(user, nowMillis)
}
+23 -6
View File
@@ -12,30 +12,44 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
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 UserServiceServer struct {
pb.UnimplementedUserServiceServer
users store.UserRepository
sessions store.SessionRepository
authURL string
users store.UserRepository
sessions store.SessionRepository
holder *runtime.Holder
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, "://") {
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) {
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)
userId, err := s.users.CreateUser(req.Uuid, platform)
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) {
platform := model.ClientPlatformFromContext(ctx)
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 {
return nil, fmt.Errorf("create user: %w", err)
}
return &pb.TransferUserResponse{
UserId: userId,
Signature: "transferred-sig",
+197 -67
View File
@@ -6,10 +6,12 @@ import (
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
@@ -17,12 +19,11 @@ type WeaponServiceServer struct {
pb.UnimplementedWeaponServiceServer
users store.UserRepository
sessions store.SessionRepository
catalog *masterdata.WeaponCatalog
config *masterdata.GameConfig
holder *runtime.Holder
}
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, catalog *masterdata.WeaponCatalog, config *masterdata.GameConfig) *WeaponServiceServer {
return &WeaponServiceServer{users: users, sessions: sessions, catalog: catalog, config: config}
func NewWeaponServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *WeaponServiceServer {
return &WeaponServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *WeaponServiceServer) Protect(ctx context.Context, req *pb.ProtectRequest) (*pb.ProtectResponse, error) {
@@ -72,6 +73,9 @@ func (s *WeaponServiceServer) Unprotect(ctx context.Context, req *pb.UnprotectRe
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -82,16 +86,22 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0)
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
mat, ok := s.catalog.Materials[materialId]
mat, ok := catalog.Materials[materialId]
if !ok {
log.Printf("[WeaponService] EnhanceByMaterial: material id=%d not found, skipping", materialId)
continue
@@ -107,27 +117,45 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
expPerUnit := mat.EffectValue
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += expPerUnit * count
totalExp += expBonus.Apply(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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByMaterial: gold cost=%d (materials=%d)", goldCost, totalMaterialCount)
}
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)
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
user.Weapons[req.UserWeaponUuid] = weapon
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 {
return nil, fmt.Errorf("weapon enhance by material: %w", err)
@@ -142,6 +170,9 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*pb.SellResponse, error) {
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)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
@@ -153,17 +184,17 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p
continue
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Sell: weapon master id=%d not found, skipping", weapon.WeaponId)
continue
}
if sellFunc, ok := s.catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
if sellFunc, ok := catalog.SellPriceByEnhanceId[wm.WeaponSpecificEnhanceId]; ok {
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 {
user.ConsumableItems[itemId] += count
}
@@ -176,7 +207,7 @@ func (s *WeaponServiceServer) Sell(ctx context.Context, req *pb.SellRequest) (*p
}
if totalGold > 0 {
user.ConsumableItems[s.config.ConsumableItemIdForGold] += totalGold
user.ConsumableItems[config.ConsumableItemIdForGold] += totalGold
log.Printf("[WeaponService] Sell: granted %d gold", totalGold)
}
})
@@ -190,6 +221,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) {
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)
nowMillis := gametime.NowMillis()
@@ -200,20 +234,20 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: weapon master id=%d not found", weapon.WeaponId)
return
}
evolvedId, ok := s.catalog.EvolutionNextWeaponId[weapon.WeaponId]
evolvedId, ok := catalog.EvolutionNextWeaponId[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Evolve: no evolution for weaponId=%d", weapon.WeaponId)
return
}
totalMaterialCount := int32(0)
mats := s.catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
mats := catalog.EvolutionMaterials[wm.WeaponEvolutionMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -225,19 +259,61 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] Evolve: gold cost=%d", goldCost)
}
oldWeaponId := wm.WeaponId
weapon.WeaponId = evolvedId
weapon.LatestVersion = nowMillis
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 slots, ok := s.catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
if slots, ok := catalog.AbilitySlots[evolvedMaster.WeaponAbilityGroupId]; ok {
abilities := make([]store.WeaponAbilityState, len(slots))
for i, slot := range slots {
abilities[i] = store.WeaponAbilityState{
@@ -250,9 +326,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 {
return nil, fmt.Errorf("weapon evolve: %w", err)
@@ -264,6 +340,9 @@ func (s *WeaponServiceServer) Evolve(ctx context.Context, req *pb.EvolveRequest)
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -274,13 +353,13 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
groupRows := catalog.SkillGroupsByGroupId[wm.WeaponSkillGroupId]
var skillGroup *masterdata.EntityMWeaponSkillGroup
for i := range groupRows {
if groupRows[i].SkillId == req.SkillId {
@@ -306,7 +385,7 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
return
}
maxLevelFunc, ok := s.catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
maxLevelFunc, ok := catalog.SkillMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceSkill: no max skill level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
@@ -326,7 +405,7 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
enhanceMatId := skillGroup.WeaponSkillEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.SkillEnhanceMats[key]
mats := catalog.SkillEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -337,9 +416,9 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
}
}
@@ -360,6 +439,9 @@ func (s *WeaponServiceServer) EnhanceSkill(ctx context.Context, req *pb.EnhanceS
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -370,13 +452,13 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: weapon master id=%d not found", weapon.WeaponId)
return
}
groupRows := s.catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
groupRows := catalog.AbilityGroupsByGroupId[wm.WeaponAbilityGroupId]
var abilityGroup *masterdata.EntityMWeaponAbilityGroup
for i := range groupRows {
if groupRows[i].AbilityId == req.AbilityId {
@@ -402,7 +484,7 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
return
}
maxLevelFunc, ok := s.catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
maxLevelFunc, ok := catalog.AbilityMaxLevelByEnhanceId[wm.WeaponSpecificEnhanceId]
if !ok {
log.Printf("[WeaponService] EnhanceAbility: no max ability level func for enhanceId=%d", wm.WeaponSpecificEnhanceId)
return
@@ -422,7 +504,7 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
enhanceMatId := abilityGroup.WeaponAbilityEnhancementMaterialId
for lvl := currentLevel; lvl < currentLevel+addCount; lvl++ {
key := [2]int32{enhanceMatId, lvl}
mats := s.catalog.AbilityEnhanceMats[key]
mats := catalog.AbilityEnhanceMats[key]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -433,9 +515,9 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
}
}
@@ -456,6 +538,9 @@ func (s *WeaponServiceServer) EnhanceAbility(ctx context.Context, req *pb.Enhanc
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -466,18 +551,18 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
if weapon.LimitBreakCount >= config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByMaterial: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByMaterial: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
remaining := config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
totalMaterialCount := int32(0)
for materialId, count := range req.Materials {
@@ -496,9 +581,9 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByMaterial: gold cost=%d", goldCost)
}
@@ -525,6 +610,9 @@ func (s *WeaponServiceServer) LimitBreakByMaterial(ctx context.Context, req *pb.
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -535,18 +623,18 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
return
}
if weapon.LimitBreakCount >= s.config.WeaponLimitBreakAvailableCount {
if weapon.LimitBreakCount >= config.WeaponLimitBreakAvailableCount {
log.Printf("[WeaponService] LimitBreakByWeapon: already at max limit break %d", weapon.LimitBreakCount)
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] LimitBreakByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
remaining := s.config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
remaining := config.WeaponLimitBreakAvailableCount - weapon.LimitBreakCount
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
@@ -560,7 +648,7 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
continue
}
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
@@ -573,9 +661,9 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] LimitBreakByWeapon: gold cost=%d", goldCost)
}
@@ -602,6 +690,9 @@ func (s *WeaponServiceServer) LimitBreakByWeapon(ctx context.Context, req *pb.Li
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)
cat := s.holder.Get()
catalog := cat.Weapon
config := cat.GameConfig
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
@@ -612,12 +703,18 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
return
}
wm, ok := s.catalog.Weapons[weapon.WeaponId]
wm, ok := catalog.Weapons[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: weapon master id=%d not found", weapon.WeaponId)
return
}
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0)
consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids {
@@ -627,19 +724,20 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
continue
}
matMaster, ok := s.catalog.Weapons[matWeapon.WeaponId]
matMaster, ok := catalog.Weapons[matWeapon.WeaponId]
if !ok {
log.Printf("[WeaponService] EnhanceByWeapon: material weapon master id=%d not found, skipping", matWeapon.WeaponId)
continue
}
baseExp := s.catalog.BaseExpByEnhanceId[matMaster.WeaponSpecificEnhanceId]
matLevelingEnhanceId := catalog.LevelingEnhanceIdByWeaponId[matWeapon.WeaponId]
baseExp := catalog.BaseExpByEnhanceId[matLevelingEnhanceId]
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * s.config.MaterialSameWeaponExpCoefficientPermil / 1000
baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000
}
totalExp += baseExp
totalExp += expBonus.Apply(baseExp)
if medals, ok := s.catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals {
user.ConsumableItems[itemId] += count
}
@@ -652,22 +750,40 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
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)
user.ConsumableItems[s.config.ConsumableItemIdForGold] -= goldCost
user.ConsumableItems[config.ConsumableItemIdForGold] -= goldCost
log.Printf("[WeaponService] EnhanceByWeapon: gold cost=%d (weapons=%d)", goldCost, consumedCount)
}
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)
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
user.Weapons[req.UserWeaponUuid] = weapon
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 {
return nil, fmt.Errorf("weapon enhance by weapon: %w", err)
@@ -679,13 +795,13 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
}, nil
}
func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, weaponId, level int32, nowMillis int64) {
wm, ok := s.catalog.Weapons[weaponId]
func checkWeaponStoryUnlocks(catalog *masterdata.WeaponCatalog, user *store.UserState, weaponId, level int32, nowMillis int64) {
wm, ok := catalog.Weapons[weaponId]
if !ok || wm.WeaponStoryReleaseConditionGroupId == 0 {
return
}
evoOrder, hasEvo := s.catalog.EvolutionOrder[weaponId]
conditions := s.catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
evoOrder, hasEvo := catalog.EvolutionOrder[weaponId]
conditions := catalog.ReleaseConditionsByGroupId[wm.WeaponStoryReleaseConditionGroupId]
for _, cond := range conditions {
switch model.WeaponStoryReleaseConditionType(cond.WeaponStoryReleaseConditionType) {
@@ -696,14 +812,14 @@ func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, wea
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
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) {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
}
case model.WeaponStoryReleaseConditionTypeReachOnceEvolvedMaxLevel:
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) {
store.GrantWeaponStoryUnlock(user, weaponId, cond.StoryIndex, nowMillis)
}
@@ -720,6 +836,9 @@ func (s *WeaponServiceServer) checkWeaponStoryUnlocks(user *store.UserState, wea
func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRequest) (*pb.WeaponAwakenResponse, error) {
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)
nowMillis := gametime.NowMillis()
@@ -730,7 +849,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
return
}
awakenRow, ok := s.catalog.AwakenByWeaponId[weapon.WeaponId]
awakenRow, ok := catalog.AwakenByWeaponId[weapon.WeaponId]
if !ok {
log.Printf("[WeaponService] Awaken: no awaken data for weaponId=%d", weapon.WeaponId)
return
@@ -741,7 +860,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
return
}
mats := s.catalog.AwakenMaterialsByGroupId[awakenRow.WeaponAwakenMaterialGroupId]
mats := catalog.AwakenMaterialsByGroupId[awakenRow.WeaponAwakenMaterialGroupId]
for _, mat := range mats {
cur := user.Materials[mat.MaterialId]
cost := mat.Count
@@ -753,7 +872,7 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
}
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)
}
@@ -772,3 +891,14 @@ func (s *WeaponServiceServer) Awaken(ctx context.Context, req *pb.WeaponAwakenRe
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.DeckParts = cloneSliceMap(u.DeckParts)
out.Decks = maps.Clone(u.Decks)
out.TripleDecks = maps.Clone(u.TripleDecks)
out.Quests = maps.Clone(u.Quests)
out.QuestMissions = maps.Clone(u.QuestMissions)
out.WeaponStories = maps.Clone(u.WeaponStories)
@@ -25,11 +26,15 @@ func CloneUserState(u UserState) UserState {
Unlocks: maps.Clone(u.Gimmick.Unlocks),
}
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.Materials = maps.Clone(u.Materials)
out.Parts = maps.Clone(u.Parts)
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
out.PartsPresets = maps.Clone(u.PartsPresets)
out.PartsPresetTags = maps.Clone(u.PartsPresetTags)
out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs)
out.ImportantItems = maps.Clone(u.ImportantItems)
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
+74 -11
View File
@@ -3,6 +3,7 @@ package store
import (
"fmt"
"log"
"math/rand"
"sort"
"github.com/google/uuid"
@@ -102,7 +103,19 @@ type WeaponStoryReleaseCond struct {
type PartsRef struct {
PartsGroupId int32
RarityType int32
PartsInitialLotteryId int32
PartsStatusMainLotteryGroupId int32
PartsStatusSubLotteryGroupId int32
}
// PartsStatusSubDef carries the per-lottery-id sub-status shape needed at
// grant time. Held here so the store package does not import masterdata.
type PartsStatusSubDef struct {
StatusKindType int32
StatusCalculationType int32
StatusChangeInitialValue int32
StatusFunc func(level int32) int32
}
type PossessionGranter struct {
@@ -114,6 +127,9 @@ type PossessionGranter struct {
PartsById map[int32]PartsRef
DefaultPartsStatusMainByLotteryGroup map[int32]int32
PartsVariantsByGroupRarity map[int32]map[int32][]int32
PartsSubStatusPool map[int32][]int32
PartsSubStatusDefs map[int32]PartsStatusSubDef
LastChangedStoryWeaponIds []int32
}
@@ -184,26 +200,73 @@ func (g *PossessionGranter) GrantCompanion(user *UserState, companionId int32, n
}
}
func (g *PossessionGranter) GrantParts(user *UserState, partsId int32, nowMillis int64) {
var mainStatId int32
if ref, ok := g.PartsById[partsId]; ok {
mainStatId = g.DefaultPartsStatusMainByLotteryGroup[ref.PartsStatusMainLotteryGroupId]
if _, exists := user.PartsGroupNotes[ref.PartsGroupId]; !exists {
user.PartsGroupNotes[ref.PartsGroupId] = PartsGroupNoteState{
PartsGroupId: ref.PartsGroupId,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) {
ref, refOk := g.PartsById[requestedPartsId]
if !refOk {
key := uuid.New().String()
user.Parts[key] = PartsState{
UserPartsUuid: key,
PartsId: requestedPartsId,
Level: 1,
AcquisitionDatetime: nowMillis,
}
log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", requestedPartsId)
return
}
chosenPartsId := requestedPartsId
chosenRef := ref
if variants := g.PartsVariantsByGroupRarity[ref.PartsGroupId][ref.RarityType]; len(variants) == 5 {
chosenPartsId = variants[rand.Intn(len(variants))]
chosenRef = g.PartsById[chosenPartsId]
} else {
log.Printf("[GrantParts] no 5-variant set for group=%d rarity=%d (have %d), granting requested=%d", ref.PartsGroupId, ref.RarityType, len(variants), requestedPartsId)
}
mainStatId := g.DefaultPartsStatusMainByLotteryGroup[chosenRef.PartsStatusMainLotteryGroupId]
if _, exists := user.PartsGroupNotes[chosenRef.PartsGroupId]; !exists {
user.PartsGroupNotes[chosenRef.PartsGroupId] = PartsGroupNoteState{
PartsGroupId: chosenRef.PartsGroupId,
FirstAcquisitionDatetime: nowMillis,
LatestVersion: nowMillis,
}
}
key := uuid.New().String()
user.Parts[key] = PartsState{
UserPartsUuid: key,
PartsId: partsId,
PartsId: chosenPartsId,
Level: 1,
PartsStatusMainId: mainStatId,
AcquisitionDatetime: nowMillis,
}
initialCount := chosenRef.PartsInitialLotteryId
pool := g.PartsSubStatusPool[chosenRef.PartsStatusSubLotteryGroupId]
if initialCount > 1 && len(pool) > 0 {
for i := int32(0); i < initialCount-1; i++ {
pickId := pool[rand.Intn(len(pool))]
def, ok := g.PartsSubStatusDefs[pickId]
if !ok {
continue
}
val := def.StatusChangeInitialValue
if def.StatusFunc != nil {
val = def.StatusFunc(1)
}
user.PartsStatusSubs[PartsStatusSubKey{UserPartsUuid: key, StatusIndex: i + 1}] = PartsStatusSubState{
UserPartsUuid: key,
StatusIndex: i + 1,
PartsStatusSubLotteryId: pickId,
Level: 1,
StatusKindType: def.StatusKindType,
StatusCalculationType: def.StatusCalculationType,
StatusChangeValue: val,
LatestVersion: nowMillis,
}
}
}
log.Printf("[GrantParts] requested=%d chosen=%d variant=%d group=%d rarity=%d preUnlockedSubs=%d", requestedPartsId, chosenPartsId, initialCount, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1)
}
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
+29 -25
View File
@@ -8,7 +8,6 @@ const (
starterMissionId = int32(1)
starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1)
@@ -97,6 +96,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
Companions: make(map[string]CompanionState),
DeckCharacters: make(map[string]DeckCharacterState),
Decks: make(map[DeckKey]DeckState),
TripleDecks: make(map[DeckKey]TripleDeckState),
DeckSubWeapons: make(map[string][]string),
DeckParts: make(map[string][]string),
Quests: make(map[int32]UserQuestState),
@@ -113,7 +113,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
starterMissionId: {
MissionId: starterMissionId,
StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress,
MissionProgressStatusType: int32(model.MissionProgressStatusTypeInProgress),
},
},
Gimmick: GimmickState{
@@ -122,29 +122,33 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
Sequences: make(map[GimmickSequenceKey]GimmickSequenceState),
Unlocks: make(map[GimmickKey]GimmickUnlockState),
},
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
ConsumableItems: make(map[int32]int32),
Materials: make(map[int32]int32),
Thoughts: make(map[string]ThoughtState),
Parts: make(map[string]PartsState),
PartsGroupNotes: make(map[int32]PartsGroupNoteState),
PartsPresets: make(map[int32]PartsPresetState),
PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState),
ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
WeaponSkills: make(map[string][]WeaponSkillState),
WeaponAbilities: make(map[string][]WeaponAbilityState),
DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState),
WeaponNotes: make(map[int32]WeaponNoteState),
NaviCutInPlayed: make(map[int32]bool),
ViewedMovies: make(map[int32]int64),
ContentsStories: make(map[int32]int64),
DrawnOmikuji: make(map[int32]int64),
PremiumItems: make(map[int32]int64),
DokanConfirmed: make(map[int32]bool),
ShopItems: make(map[int32]UserShopItemState),
ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState),
ExploreScores: make(map[int32]ExploreScoreState),
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),
Materials: make(map[int32]int32),
Thoughts: make(map[string]ThoughtState),
Parts: make(map[string]PartsState),
PartsGroupNotes: make(map[int32]PartsGroupNoteState),
PartsPresets: make(map[int32]PartsPresetState),
PartsPresetTags: make(map[int32]PartsPresetTagState),
PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState),
ImportantItems: make(map[int32]int32),
CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
WeaponSkills: make(map[string][]WeaponSkillState),
WeaponAbilities: make(map[string][]WeaponAbilityState),
DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState),
WeaponNotes: make(map[int32]WeaponNoteState),
NaviCutInPlayed: make(map[int32]bool),
ViewedMovies: make(map[int32]int64),
ContentsStories: make(map[int32]int64),
DrawnOmikuji: make(map[int32]int64),
PremiumItems: make(map[int32]int64),
DokanConfirmed: make(map[int32]bool),
ShopItems: make(map[int32]UserShopItemState),
ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState),
ExploreScores: make(map[int32]ExploreScoreState),
CharacterBoards: make(map[int32]CharacterBoardState),
CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState),
+67 -11
View File
@@ -43,6 +43,7 @@ func initMaps(u *store.UserState) {
u.Thoughts = make(map[string]store.ThoughtState)
u.DeckCharacters = make(map[string]store.DeckCharacterState)
u.Decks = make(map[store.DeckKey]store.DeckState)
u.TripleDecks = make(map[store.DeckKey]store.TripleDeckState)
u.DeckSubWeapons = make(map[string][]string)
u.DeckParts = make(map[string][]string)
u.Quests = make(map[int32]store.UserQuestState)
@@ -60,6 +61,7 @@ func initMaps(u *store.UserState) {
u.Parts = make(map[string]store.PartsState)
u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState)
u.PartsPresets = make(map[int32]store.PartsPresetState)
u.PartsPresetTags = make(map[int32]store.PartsPresetTagState)
u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState)
u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState)
u.ConsumableItems = make(map[int32]int32)
@@ -75,6 +77,9 @@ func initMaps(u *store.UserState) {
u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState)
u.ExploreScores = make(map[int32]store.ExploreScoreState)
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.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState)
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,
&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,
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
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
FROM user_main_quest WHERE user_id=?`, uid).
Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId,
&u.MainQuest.HeadQuestSceneId, &b, &u.MainQuest.ProgressQuestSceneId, &u.MainQuest.ProgressHeadQuestSceneId,
&u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion,
&u.MainQuest.SavedCurrentQuestSceneId, &u.MainQuest.SavedHeadQuestSceneId,
&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.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,
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.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
_ = 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
@@ -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
})
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
FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid,
func(rows *sql.Rows) {
@@ -454,6 +485,14 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
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,
status_kind_type, status_calculation_type, status_change_value, latest_version
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
})
// Gifts
queryRows(db, `SELECT user_gift_uuid, is_received, possession_type, possession_id, count, grant_datetime,
description_gift_text_id, equipment_data, expiration_datetime, received_datetime
FROM user_gifts WHERE user_id=?`, uid, func(rows *sql.Rows) {
@@ -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,
func(rows *sql.Rows) {
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)
})
// Gacha banners
queryRows(db, `SELECT gacha_id, medal_count, step_number, loop_count, draw_count, box_number
FROM user_gacha_banners WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.GachaBannerState
@@ -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,
panel_release_bit4, latest_version FROM user_character_boards WHERE user_id=?`, uid,
func(rows *sql.Rows) {
@@ -608,6 +643,29 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
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
FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserShopItemState
@@ -622,7 +680,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.ShopReplaceableLineup[v.SlotNumber] = v
})
// Gimmick tables
queryRows(db, `SELECT gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id,
is_gimmick_cleared, start_datetime, latest_version FROM user_gimmick_progress WHERE user_id=?`, uid,
func(rows *sql.Rows) {
@@ -665,7 +722,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.Gimmick.Unlocks[v.Key] = v
})
// Big hunt maps
queryRows(db, `SELECT big_hunt_boss_id, max_score, max_score_update_datetime, latest_version
FROM user_big_hunt_max_scores WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32
@@ -674,11 +730,11 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
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) {
var id int32
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
})
+123 -35
View File
@@ -15,8 +15,7 @@ func boolToInt(b bool) int {
return 0
}
// writeUserState inserts all child table rows for a newly created user.
// The users row must already exist.
// Precondition: the users row must already exist.
func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...)
@@ -50,11 +49,17 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil {
return err
}
if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_current_quest_scene_id, saved_head_quest_scene_id, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
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,
u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId,
u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId,
u.MainQuest.LatestVersion, u.MainQuest.SavedCurrentQuestSceneId, u.MainQuest.SavedHeadQuestSceneId,
u.MainQuest.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 {
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 {
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 (?,?,?,?,?,?,?,?,?,?)`,
uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt,
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
}
// Map tables
for _, v := range u.Characters {
if err := exec(`INSERT INTO user_characters (user_id, character_id, level, exp, latest_version) VALUES (?,?,?,?,?)`,
uid, v.CharacterId, v.Level, v.Exp, v.LatestVersion); err != nil {
@@ -161,6 +171,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
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 i, uuid := range uuids {
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
}
}
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 {
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 {
@@ -426,6 +448,24 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
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 {
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 {
@@ -469,8 +509,8 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
}
}
for id, v := range u.BigHuntStatuses {
if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version) VALUES (?,?,?,?,?)`,
uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion); err != nil {
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.LastDailyRewardReceivedDayVersion, v.LatestVersion); err != nil {
return err
}
}
@@ -496,18 +536,13 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return nil
}
// diffAndSave compares before/after UserState and writes only changed rows.
// For 1:1 tables, it UPDATEs if any field changed.
// For map tables, it uses INSERT OR REPLACE for added/modified entries and DELETE for removed ones.
// For slice-based data (gifts, medals, deck sub-weapons/parts, weapon skills/abilities),
// it does DELETE-all then INSERT-all for simplicity.
// 1:1 tables update on field-change; maps INSERT OR REPLACE + DELETE; slice tables DELETE-all then INSERT-all.
func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
exec := func(query string, args ...any) error {
_, err := tx.Exec(query, args...)
return err
}
// users table
if before.PlayerId != after.PlayerId || before.OsType != after.OsType || before.PlatformType != after.PlatformType ||
before.UserRestrictionType != after.UserRestrictionType || before.RegisterDatetime != after.RegisterDatetime ||
before.GameStartDatetime != after.GameStartDatetime || before.LatestVersion != after.LatestVersion ||
@@ -562,11 +597,17 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
}
}
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.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId,
after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId,
after.MainQuest.LatestVersion, after.MainQuest.SavedCurrentQuestSceneId, after.MainQuest.SavedHeadQuestSceneId,
after.MainQuest.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 {
return err
}
@@ -589,7 +630,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
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=?`,
after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId,
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 {
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 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 ||
before.Gacha.DailyMaxCount != after.Gacha.DailyMaxCount || before.Gacha.LastRewardDrawDate != after.Gacha.LastRewardDrawDate {
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",
func(v store.CharacterState) []any { return []any{v.CharacterId, v.Level, v.Exp, v.LatestVersion} },
"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}
}, "user_deck_character_uuid, user_costume_uuid, main_user_weapon_uuid, user_companion_uuid, power, user_thought_uuid, dressup_costume_id, latest_version")
// Decks (composite key)
for k, v := range after.Decks {
if old, ok := before.Decks[k]; !ok || old != v {
exec(fmt.Sprintf(`INSERT OR REPLACE INTO user_decks (user_id, deck_type, user_deck_number, user_deck_character_uuid01, user_deck_character_uuid02, user_deck_character_uuid03, name, power, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`),
@@ -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) {
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)
@@ -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}
}, "quest_id, quest_state_type, is_battle_only, user_deck_number, latest_start_datetime, clear_count, daily_clear_count, last_clear_datetime, shortest_clear_frames, is_reward_granted, latest_version")
// Quest missions (composite key)
for k, v := range after.QuestMissions {
if old, ok := before.QuestMissions[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_quest_missions (user_id, quest_id, quest_mission_id, progress_value, is_clear, latest_clear_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`,
@@ -734,6 +791,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.SideStoryQuestProgress) []any {
return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion}
}, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version")
diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id",
func(v store.QuestLimitContentStatus) []any {
return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion}
@@ -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}
}, "weapon_id, max_level, max_limit_break_count, first_acquisition_datetime, latest_version")
// Weapon skills/abilities: slice-based, delete+reinsert
exec(`DELETE FROM user_weapon_skills WHERE user_id=?`, uid)
for _, skills := range after.WeaponSkills {
for _, v := range skills {
@@ -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}
}, "user_costume_uuid, level, acquisition_datetime, latest_version")
// Costume awaken status ups (composite key)
for k, v := range after.CostumeAwakenStatusUps {
if old, ok := before.CostumeAwakenStatusUps[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_costume_awaken_status_ups (user_id, user_costume_uuid, status_calculation_type, hp, attack, vitality, agility, critical_ratio, critical_attack, latest_version) VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -813,6 +869,10 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.PartsPresetState) []any {
return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion}
}, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version")
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 {
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 {
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 (?,?,?,?)`,
@@ -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")
diffBoolMap(tx, uid, before.DokanConfirmed, after.DokanConfirmed, "user_dokan_confirmed", "dokan_id")
// Gifts: delete all + reinsert
exec(`DELETE FROM user_gifts WHERE user_id=?`, uid)
for _, g := range after.Gifts.NotReceived {
var expDt sql.NullInt64
@@ -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)
}
// Gacha converted medals: delete+reinsert
exec(`DELETE FROM user_gacha_converted_medals WHERE user_id=?`, uid)
for i, v := range after.Gacha.ConvertedGachaMedal.ConvertedMedalPossession {
exec(`INSERT INTO user_gacha_converted_medals (user_id, ordinal, consumable_item_id, count) VALUES (?,?,?,?)`, uid, i, v.ConsumableItemId, v.Count)
}
// Gacha banners
for id, v := range after.Gacha.BannerStates {
if old, ok := before.Gacha.BannerStates[id]; !ok || old.MedalCount != v.MedalCount || old.StepNumber != v.StepNumber || old.LoopCount != v.LoopCount || old.DrawCount != v.DrawCount || old.BoxNumber != v.BoxNumber {
exec(`INSERT OR REPLACE INTO user_gacha_banners (user_id, gacha_id, medal_count, step_number, loop_count, draw_count, box_number) VALUES (?,?,?,?,?,?,?)`,
uid, v.GachaId, v.MedalCount, v.StepNumber, v.LoopCount, v.DrawCount, v.BoxNumber)
}
// Box drew counts: always delete+reinsert for this gacha
exec(`DELETE FROM user_gacha_banner_box_drew_counts WHERE user_id=? AND gacha_id=?`, uid, id)
for itemId, count := range v.BoxDrewCounts {
exec(`INSERT INTO user_gacha_banner_box_drew_counts (user_id, gacha_id, box_item_id, count) VALUES (?,?,?,?)`, uid, id, itemId, count)
@@ -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}
}, "character_board_id, panel_release_bit1, panel_release_bit2, panel_release_bit3, panel_release_bit4, latest_version")
// Character board abilities (composite key)
for k, v := range after.CharacterBoardAbilities {
if old, ok := before.CharacterBoardAbilities[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_character_board_abilities (user_id, character_id, ability_id, level, latest_version) VALUES (?,?,?,?,?)`,
@@ -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 {
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 (?,?,?,?,?,?,?,?,?,?)`,
@@ -942,6 +995,27 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion}
},
"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",
func(v store.UserShopItemState) []any {
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")
// Gimmick tables (composite keys)
for k, v := range after.Gimmick.Progress {
if old, ok := before.Gimmick.Progress[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_gimmick_progress (user_id, gimmick_sequence_schedule_id, gimmick_sequence_id, gimmick_id, is_gimmick_cleared, start_datetime, latest_version) VALUES (?,?,?,?,?,?,?)`,
@@ -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",
func(v store.BigHuntMaxScore) []any {
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")
diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id",
func(v store.BigHuntStatus) []any {
return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion}
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 {
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
}
// 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) {
for k, v := range after {
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()
var existingId int64
err = tx.QueryRow(`SELECT user_id FROM users WHERE uuid = ?`, uuid).Scan(&existingId)
if err == nil {
return existingId, nil
}
nowMillis := s.clock().UnixMilli()
res, err := tx.Exec(`INSERT INTO users (uuid, player_id, os_type, platform_type, user_restriction_type,
@@ -85,6 +79,9 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
// Child tables in reverse-dependency order (matches schema's goose Down).
childTables := []string{
"user_event_quest_labyrinth_stages",
"user_event_quest_labyrinth_seasons",
"user_event_quest_tower_accumulation_rewards",
"user_cage_ornament_rewards",
"user_shop_replaceable_lineup",
"user_shop_items",
@@ -115,6 +112,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_big_hunt_max_scores",
"user_quest_limit_content_status",
"user_side_story_quests",
"user_main_quest_season_routes",
"user_missions",
"user_quest_missions",
"user_quests",
@@ -124,6 +122,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_decks",
"user_deck_characters",
"user_parts_status_subs",
"user_parts_preset_tags",
"user_parts_presets",
"user_parts_group_notes",
"user_parts",
+16 -1
View File
@@ -1,6 +1,10 @@
package store
import "log"
import (
"log"
"lunar-tear/server/internal/model"
)
const StaminaRecoveryDivisor int64 = 180
@@ -39,3 +43,14 @@ func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64)
user.Status.StaminaUpdateDatetime = nowMillis
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
}
}

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