18 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
73 changed files with 4104 additions and 560 deletions
+117
View File
@@ -0,0 +1,117 @@
name: Build and Publish Release Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { goos: linux, goarch: amd64, archive: tar.gz }
- { goos: linux, goarch: arm64, archive: tar.gz }
- { goos: darwin, goarch: amd64, archive: tar.gz }
- { goos: darwin, goarch: arm64, archive: tar.gz }
- { goos: windows, goarch: amd64, archive: zip }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Install protoc
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: Install protoc-gen-go plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Generate proto stubs
working-directory: server
run: make proto
- name: Cross-compile binaries
working-directory: server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
set -euo pipefail
stage="staging"
mkdir -p "$stage/bin"
ext=""
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
build() {
local name="$1" dest="$2"
go build -trimpath -ldflags="-s -w" -o "$dest/${name}${ext}" "./cmd/${name}"
}
# Wizard sits at the root so end-users see it immediately.
build wizard "$stage"
# Sub-services and admin tools go in bin/ to match the runtime layout.
for name in dev lunar-tear octo-cdn auth-server import-snapshot claim-account register-account wizard-restore; do
build "$name" "$stage/bin"
done
- name: Stage docs
working-directory: server
run: |
cp ../README.md staging/README.md
cp ../LICENSE staging/LICENSE
- name: Archive
working-directory: server
run: |
set -euo pipefail
name="lunar-tear-server-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}"
mv staging "$name"
if [ "${{ matrix.archive }}" = "zip" ]; then
zip -r "${name}.zip" "$name"
else
tar -czf "${name}.tar.gz" "$name"
fi
ls -lh "${name}".*
- uses: actions/upload-artifact@v4
with:
name: lunar-tear-server-${{ matrix.goos }}-${{ matrix.goarch }}
path: server/lunar-tear-server-*.${{ matrix.archive }}
if-no-files-found: error
release:
name: Attach archives to GitHub Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List downloaded artifacts
run: ls -lh artifacts/
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
draft: true
+94 -87
View File
@@ -5,7 +5,14 @@ Discord server: https://discord.gg/MZAf5aVkJG
## How To Launch The Server ## How To Launch The Server
### Prerequisites ### Download & Run (no setup)
Prebuilt binaries are published for Linux, macOS, and Windows on the [Releases page](https://github.com/Walter-Sparrow/lunar-tear/releases).
1. Download the archive for your OS/arch (`lunar-tear-server-<version>-<os>-<arch>.{tar.gz,zip}`).
2. Run `./wizard` (macOS/Linux) or double-click `wizard.exe` (Windows).
### Prerequisites (build from source)
- Go 1.25+ - Go 1.25+
- [goose](https://github.com/pressly/goose) migration tool - [goose](https://github.com/pressly/goose) migration tool
@@ -40,12 +47,12 @@ By default the wizard uses ports 8003 (gRPC), 8080 (CDN), and 3000 (auth). Overr
go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080 go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ------- | ---------------------------------- | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `--prefer-saved` | `false` | Reuse saved config without prompting | | `--prefer-saved` | `false` | Reuse saved config without prompting |
| `--grpc-port` | `8003` | gRPC server port | | `--grpc-port` | `8003` | gRPC server port |
| `--cdn-port` | `8080` | CDN server port | | `--cdn-port` | `8080` | CDN server port |
| `--auth-port` | `3000` | Auth server port | | `--auth-port` | `3000` | Auth server port |
| `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. |
Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing. Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing.
@@ -105,9 +112,9 @@ go run ./cmd/import-snapshot \
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | --------------------------------------------- | | ------------ | ------------ | --------------------------------------------- |
| `--snapshot` | *(required)* | Path to JSON snapshot file | | `--snapshot` | _(required)_ | Path to JSON snapshot file |
| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) | | `--uuid` | _(required)_ | UUID to assign (must match the client's UUID) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
### Run ### Run
@@ -174,40 +181,40 @@ Or via `make`:
make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000" make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| --------------------- | ------------------ | ---------------------------------------- | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address | | `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
| `--auth.db` | `db/auth.db` | auth-server SQLite database path | | `--auth.db` | `db/auth.db` | auth-server SQLite database path |
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address | | `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
| `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr | | `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr |
| `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address | | `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address |
| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr | | `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
| `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear | | `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear |
| `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear | | `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear |
| `--no-register` | `false` | disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | disable new user registrations (only already registered users can connect). |
| `--admin.listen` | *(empty)* | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin.listen` | _(empty)_ | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
| `--no-color` | `false` | disable colored output | | `--no-color` | `false` | disable colored output |
### Ports ### Ports
| Protocol | Port | Binary | Notes | | Protocol | Port | Binary | Notes |
| -------- | ---- | ------------- | ----------------------------------------------------------- | | -------- | ---- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) | | gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages | | HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
| HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set | | HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set |
| HTTP | 3000 | `auth-server` | account registration and login | | HTTP | 3000 | `auth-server` | account registration and login |
### Game Server Flags (`lunar-tear`) ### Game Server Flags (`lunar-tear`)
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ----------------- | ---------------------------------------------------- | | ---------------- | ---------------- | --------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) | | `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients | | `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) | | `--octo-url` | _(required)_ | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) | | `--auth-url` | _(empty)_ | Auth server base URL (e.g. `http://localhost:3000`) |
| `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. | | `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. |
| `--no-register` | `false` | Disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | Disable new user registrations (only already registered users can connect). |
### Live Master Data Reload ### Live Master Data Reload
@@ -231,11 +238,11 @@ Security defaults are fail-closed:
### CDN Flags (`octo-cdn`) ### CDN Flags (`octo-cdn`)
| Flag | Default | Description | | Flag | Default | Description |
| --------------- | ----------------- | -------------------------------------------------------- | | --------------- | ---------------- | --------------------------------------------------------- |
| `--listen` | `0.0.0.0:8080` | local bind address | | `--listen` | `0.0.0.0:8080` | local bind address |
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) | | `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
| `--assets-dir` | `.` | root directory containing the `assets/` tree | | `--assets-dir` | `.` | root directory containing the `assets/` tree |
### Docker ### Docker
@@ -250,22 +257,22 @@ The `db/` directory is mounted as a volume so both `game.db` and `auth.db` persi
Each service has its own image and can be deployed independently: Each service has its own image and can be deployed independently:
| Service | Image | Default Port | Notes | | Service | Image | Default Port | Notes |
| -------- | --------------------------- | ------------ | ------------------------------ | | -------- | --------------------------- | ------------ | -------------------------------- |
| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook | | `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN | | `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login | | `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
The game server is configured via environment variables in the compose file: The game server is configured via environment variables in the compose file:
| Env var | Description | | Env var | Description |
| --------------------- | -------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------- |
| `LUNAR_LISTEN` | gRPC bind address | | `LUNAR_LISTEN` | gRPC bind address |
| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game | | `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets | | `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
| `LUNAR_AUTH_URL` | Auth server base URL (optional) | | `LUNAR_AUTH_URL` | Auth server base URL (optional) |
| `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) | | `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) |
| `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** | | `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** |
Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up. Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up.
@@ -273,22 +280,22 @@ Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without
All targets run from the `server/` directory. All targets run from the `server/` directory.
| Target | Description | | Target | Description |
| -------------- | ------------------------------------------------------- | | ----------------------------- | ------------------------------------------------------ |
| `make proto` | Regenerate protobuf stubs | | `make proto` | Regenerate protobuf stubs |
| `make build` | Build the game server binary | | `make build` | Build the game server binary |
| `make build-cdn` | Build the CDN binary | | `make build-cdn` | Build the CDN binary |
| `make build-auth` | Build the auth server binary | | `make build-auth` | Build the auth server binary |
| `make build-dev` | Build the dev runner binary to `bin/` | | `make build-dev` | Build the dev runner binary to `bin/` |
| `make build-all` | Build all service binaries to `bin/` | | `make build-all` | Build all service binaries to `bin/` |
| `make build-import` | Build the import-snapshot tool | | `make build-import` | Build the import-snapshot tool |
| `make build-claim-account` | Build the claim-account tool | | `make build-claim-account` | Build the claim-account tool |
| `make build-register-account` | Build the register-account tool | | `make build-register-account` | Build the register-account tool |
| `make clean` | Remove the `bin/` directory | | `make clean` | Remove the `bin/` directory |
| `make dev` | Run all three services with one command | | `make dev` | Run all three services with one command |
| `make migrate` | Run goose migrations on `db/game.db` | | `make migrate` | Run goose migrations on `db/game.db` |
| `make restore` | Interactive restore of `db/game.db` from `db/backups/` | | `make restore` | Interactive restore of `db/game.db` from `db/backups/` |
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
## Claim Account ## Claim Account
@@ -301,10 +308,10 @@ cd server
go run ./cmd/claim-account --name "PlayerName" --db db/game.db go run ./cmd/claim-account --name "PlayerName" --db db/game.db
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| -------- | ------------ | ---------------------------------------------------- | | -------- | ------------ | ---------------------------- |
| `--name` | *(required)* | In-game player name to claim | | `--name` | _(required)_ | In-game player name to claim |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
## Auth Server ## Auth Server
@@ -323,12 +330,12 @@ The `--secret` flag accepts a hex-encoded HMAC key. If omitted, a random key is
### Flags ### Flags
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | --------------- | -------------------------------------------- | | --------------- | -------------- | -------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) | | `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
| `--db` | `db/auth.db` | SQLite database path for auth users | | `--db` | `db/auth.db` | SQLite database path for auth users |
| `--secret` | *(generated)* | Hex-encoded HMAC secret for token signing | | `--secret` | _(generated)_ | Hex-encoded HMAC secret for token signing |
| `--no-register` | `false` | Disable new user registrations (only already registered users can log in). | | `--no-register` | `false` | Disable new user registrations (only already registered users can log in). |
## Create account ## Create account
@@ -339,13 +346,13 @@ A primary mean of registering new accounts when `--no-register` flag is passed t
go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android" go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | ------------------------------------------------------------ | | ------------ | ------------ | ------------------------------------------------- |
| `--name` | *(required)* | Auth Server account nickname to be registered | | `--name` | _(required)_ | Auth Server account nickname to be registered |
| `--password` | *(required)* | Auth Server account password to be registered | | `--password` | _(required)_ | Auth Server account password to be registered |
| `--platform` | `android` | Platform of new user account (`android` or `ios`) | | `--platform` | `android` | Platform of new user account (`android` or `ios`) |
| `--db` | `db/game.db` | SQLite main database path | | `--db` | `db/game.db` | SQLite main database path |
| `--auth-db` | `db/auth.db` | SQLite Auth Server database path | | `--auth-db` | `db/auth.db` | SQLite Auth Server database path |
This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login! This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login!
+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## 2026-05-16
### Working
- `--no-register` flag and `register-account` CLI tool
- Database backup support in the wizard
- Subjugation Quests — rewards, battle report, and triple-deck preset persistence
- `MaterialSaleObtainPossession` item grants when selling materials
- Memoir protect / unprotect
- Effect item usage (`UseEffectItem`)
- Recollections of Dusk
### Fixed
- Login bonus
- Main quest replay (Map replay)
- Weapon awaken level cap
- Gacha pool overhaul
- Quest mission rewards
- Menu-pick quest start — wrong state from replay flow, black screen on quests with no difficulty, and normal-difficulty handling
## 2026-05-02 ## 2026-05-02
### Working ### Working
+1
View File
@@ -40,6 +40,7 @@ var childTables = []string{
"user_big_hunt_max_scores", "user_big_hunt_max_scores",
"user_quest_limit_content_status", "user_quest_limit_content_status",
"user_side_story_quests", "user_side_story_quests",
"user_main_quest_season_routes",
"user_missions", "user_missions",
"user_quest_missions", "user_quest_missions",
"user_quests", "user_quests",
+6 -2
View File
@@ -120,8 +120,12 @@ func main() {
colorCyan = "" colorCyan = ""
} }
log.Println("building services...") if _, err := os.Stat("go.mod"); err == nil {
buildAll() log.Println("building services...")
buildAll()
} else {
log.Println("prebuilt mode: skipping build, using bin/ from archive")
}
ext := binExt() ext := binExt()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+1
View File
@@ -124,4 +124,5 @@ func registerServices(
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder)) pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder))
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder)) pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder))
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder)) pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder))
pb.RegisterLabyrinthServiceServer(srv, service.NewLabyrinthServiceServer(userStore, userStore, holder))
} }
+29 -21
View File
@@ -27,31 +27,39 @@ func backupGameDB() {
return return
} }
_ = spinner.New().Title(" Backing up db/game.db...").Action(func() { if !sourceMode {
if err := os.MkdirAll(backupDir, 0o755); err != nil { fmt.Println(" Backing up db/game.db...")
fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err) doBackupGameDB()
return return
} }
ts := time.Now().UTC().Format("20060102T150405Z") _ = spinner.New().Title(" Backing up db/game.db...").Action(doBackupGameDB).Run()
dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix)) }
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)") func doBackupGameDB() {
if err != nil { if err := os.MkdirAll(backupDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err) fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err)
return return
} }
defer db.Close()
escaped := strings.ReplaceAll(dest, "'", "''") ts := time.Now().UTC().Format("20060102T150405Z")
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil { dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix))
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
_ = os.Remove(dest)
return
}
pruneOldBackups() db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
}).Run() if err != nil {
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err)
return
}
defer db.Close()
escaped := strings.ReplaceAll(dest, "'", "''")
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
_ = os.Remove(dest)
return
}
pruneOldBackups()
} }
func pruneOldBackups() { func pruneOldBackups() {
+22 -13
View File
@@ -74,14 +74,21 @@ func main() {
fmt.Print(banner) fmt.Print(banner)
sourceMode = isSourceCheckout()
if !*setupOnly { if !*setupOnly {
validateAssets() validateAssets()
validateTools() if sourceMode {
validateProtocIncludes() validateTools()
runProtoc() validateProtocIncludes()
backupGameDB() runProtoc()
runMigrate() backupGameDB()
downloadDeps() runMigrate()
downloadDeps()
} else {
backupGameDB()
runMigrateEmbedded()
}
} }
ip, cfg, firstRun := resolveIP(*preferSaved) ip, cfg, firstRun := resolveIP(*preferSaved)
@@ -901,13 +908,15 @@ func launchDev(ip string, p ports) {
} }
devBin := filepath.Join("bin", "dev"+ext) devBin := filepath.Join("bin", "dev"+ext)
_ = spinner.New().Title(" Building services...").Action(func() { if sourceMode {
if err := os.MkdirAll("bin", 0755); err != nil { _ = spinner.New().Title(" Building services...").Action(func() {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err) if err := os.MkdirAll("bin", 0755); err != nil {
os.Exit(1) fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
} os.Exit(1)
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev") }
}).Run() runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run()
}
devArgs := []string{ devArgs := []string{
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC), "--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"lunar-tear/server/migrations"
)
var sourceMode bool
func isSourceCheckout() bool {
if _, err := os.Stat("go.mod"); err != nil {
return false
}
if _, err := os.Stat("proto"); err != nil {
return false
}
return true
}
func runMigrateEmbedded() {
fmt.Println(" Running migrations...")
if err := os.MkdirAll("db", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create db/: %v\n", err)
os.Exit(1)
}
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
if err != nil {
fmt.Fprintf(os.Stderr, " open db: %v\n", err)
os.Exit(1)
}
defer db.Close()
if err := migrations.Up(context.Background(), db); err != nil {
fmt.Fprintf(os.Stderr, " migration failed: %v\n", err)
os.Exit(1)
}
}
+11 -7
View File
@@ -7,12 +7,12 @@ require (
github.com/pierrec/lz4/v4 v4.1.26 github.com/pierrec/lz4/v4 v4.1.26
github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/vmihailenco/msgpack/v5 v5.4.1
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/net v0.52.0 golang.org/x/net v0.53.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0 golang.org/x/term v0.42.0
google.golang.org/grpc v1.79.1 google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.48.2 modernc.org/sqlite v1.49.1
) )
require ( require (
@@ -34,21 +34,25 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
+28
View File
@@ -54,8 +54,12 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -66,10 +70,14 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
@@ -82,20 +90,28 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -109,18 +125,25 @@ golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -131,16 +154,21 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+170
View File
@@ -0,0 +1,170 @@
package campaign
import (
"fmt"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/utils"
)
type Catalog struct {
enhance []enhanceRow
quest []questRow
}
type enhanceRow struct {
effectType EnhanceCampaignEffectType
effectValue int32
targets []enhanceMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type enhanceMatch struct {
t EnhanceCampaignTargetType
v int32
}
type questRow struct {
effectType QuestCampaignEffectType
effectValue int32
bonusItems []BonusDrop
targets []questMatch
startMillis int64
endMillis int64
userStatus TargetUserStatusType
}
type questMatch struct {
t QuestCampaignTargetType
v int32
}
func Load() (*Catalog, error) {
enhance, err := loadEnhanceRows()
if err != nil {
return nil, fmt.Errorf("load enhance campaigns: %w", err)
}
quest, err := loadQuestRows()
if err != nil {
return nil, fmt.Errorf("load quest campaigns: %w", err)
}
return &Catalog{enhance: enhance, quest: quest}, nil
}
func (c *Catalog) EnhanceCount() int { return len(c.enhance) }
func (c *Catalog) QuestCount() int { return len(c.quest) }
func loadEnhanceRows() ([]enhanceRow, error) {
campaigns, err := utils.ReadTable[masterdata.EntityMEnhanceCampaign]("m_enhance_campaign")
if err != nil {
return nil, err
}
targets, err := utils.ReadTable[masterdata.EntityMEnhanceCampaignTargetGroup]("m_enhance_campaign_target_group")
if err != nil {
return nil, err
}
byGroup := make(map[int32][]enhanceMatch, len(targets))
for _, t := range targets {
byGroup[t.EnhanceCampaignTargetGroupId] = append(byGroup[t.EnhanceCampaignTargetGroupId], enhanceMatch{
t: EnhanceCampaignTargetType(t.EnhanceCampaignTargetType),
v: t.EnhanceCampaignTargetValue,
})
}
rows := make([]enhanceRow, 0, len(campaigns))
for _, c := range campaigns {
grp := byGroup[c.EnhanceCampaignTargetGroupId]
if len(grp) == 0 {
continue
}
rows = append(rows, enhanceRow{
effectType: EnhanceCampaignEffectType(c.EnhanceCampaignEffectType),
effectValue: c.EnhanceCampaignEffectValue,
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
}
+11 -2
View File
@@ -7,7 +7,8 @@ import (
) )
type ConsumableItemCatalog struct { type ConsumableItemCatalog struct {
All map[int32]EntityMConsumableItem All map[int32]EntityMConsumableItem
Effects map[int32][]EntityMConsumableItemEffect
} }
func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) { func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
@@ -15,12 +16,20 @@ func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("load consumable item table: %w", err) return nil, fmt.Errorf("load consumable item table: %w", err)
} }
effects, err := utils.ReadTable[EntityMConsumableItemEffect]("m_consumable_item_effect")
if err != nil {
return nil, fmt.Errorf("load consumable item effect table: %w", err)
}
catalog := &ConsumableItemCatalog{ catalog := &ConsumableItemCatalog{
All: make(map[int32]EntityMConsumableItem, len(rows)), All: make(map[int32]EntityMConsumableItem, len(rows)),
Effects: make(map[int32][]EntityMConsumableItemEffect, len(effects)),
} }
for _, row := range rows { for _, row := range rows {
catalog.All[row.ConsumableItemId] = row catalog.All[row.ConsumableItemId] = row
} }
for _, e := range effects {
catalog.Effects[e.ConsumableItemId] = append(catalog.Effects[e.ConsumableItemId], e)
}
return catalog, nil return catalog, nil
} }
@@ -0,0 +1,286 @@
package masterdata
// Source: based on public_costumes_link_export_2026-05-21_142314.csv by @Keziah,
// with each weapon resolved to its m_weapon_evolution_group EvolutionOrder=1 root
var costumeWeaponPairings = map[int32]int32{
10100: 101001,
10101: 101011,
10102: 101021,
10103: 101031,
10104: 101041,
10105: 101051,
10106: 101061,
10107: 101071,
10108: 101081,
10109: 101091,
10110: 101101,
10111: 101121,
10112: 101111,
10113: 101131,
10114: 101141,
10115: 101151,
10116: 101161,
10117: 101171,
10118: 101181,
10119: 101191,
10120: 101201,
10121: 101211,
21000: 210161,
21001: 210031,
21002: 210171,
21003: 210181,
21004: 210191,
21005: 210271,
22000: 220081,
22001: 220021,
22002: 220051,
22003: 220061,
22004: 220141,
22005: 220161,
22006: 220181,
22007: 220191,
22008: 220211,
22009: 220231,
22010: 220241,
23000: 230001,
23001: 230021,
23004: 230051,
23005: 230151,
23006: 230261,
23007: 230271,
23008: 230281,
24000: 240091,
24001: 240121,
24002: 240131,
24003: 240011,
24004: 240081,
24005: 240201,
24006: 240221,
24007: 240241,
24008: 240271,
24009: 240311,
25000: 250121,
25001: 250071,
25002: 250011,
25003: 250151,
25005: 250021,
25006: 250171,
25007: 250221,
25008: 250231,
25009: 250261,
31000: 310081,
31001: 310061,
31002: 310021,
31004: 310191,
31005: 310211,
31008: 310221,
31009: 310241,
31010: 310261,
31011: 310291,
31013: 310321,
31014: 310331,
31015: 310371,
31016: 310401,
31017: 310411,
31018: 310421,
31019: 310431,
31020: 310461,
31021: 310471,
31022: 310481,
31023: 310511,
31024: 310531,
31025: 310541,
31026: 310551,
31027: 310571,
31028: 310591,
31029: 310621,
31030: 310641,
31031: 310661,
31032: 310691,
31033: 310701,
31034: 310711,
32000: 320081,
32001: 320041,
32002: 320011,
32003: 320111,
32004: 320051,
32005: 320141,
32006: 320151,
32007: 320171,
32008: 320181,
32009: 320201,
32011: 320231,
32012: 320241,
32013: 320271,
32014: 320281,
32015: 320301,
32016: 320331,
32017: 320351,
32018: 320371,
32019: 320381,
32020: 320391,
32021: 320421,
32022: 320431,
32023: 320441,
32024: 320451,
32025: 320461,
32026: 320471,
32027: 320501,
32028: 320531,
32029: 320541,
32030: 320551,
32031: 320561,
32032: 320581,
32033: 320601,
32034: 320611,
32035: 320621,
32036: 320641,
33000: 330001,
33001: 330121,
33002: 330011,
33003: 330021,
33005: 330161,
33006: 330171,
33007: 330191,
33009: 330211,
33010: 330231,
33011: 330261,
33012: 330281,
33013: 330321,
33014: 330341,
33015: 330381,
33016: 330401,
33017: 330421,
33018: 330451,
33019: 330471,
33020: 330501,
33021: 330521,
33022: 330541,
33023: 330551,
33024: 330561,
33025: 330571,
33026: 330581,
33027: 330591,
33028: 330601,
33029: 330631,
33030: 330641,
33031: 330671,
33032: 330691,
33033: 330701,
34000: 340011,
34001: 340121,
34002: 340151,
34003: 340161,
34004: 340131,
34005: 340071,
34009: 340231,
34010: 340241,
34011: 340251,
34012: 340261,
34013: 340281,
34014: 340291,
34015: 340301,
34016: 340321,
34017: 340341,
34018: 340351,
34019: 340361,
34020: 340381,
34021: 340391,
34022: 340411,
34023: 340421,
34024: 340441,
34025: 340451,
34026: 340461,
34027: 340491,
34028: 340501,
34029: 340521,
34030: 340531,
34031: 340541,
34032: 340571,
34033: 340601,
34034: 340611,
34035: 340621,
34036: 340631,
34037: 340651,
34038: 340681,
34039: 340701,
34040: 340721,
34041: 340731,
34042: 340751,
34043: 340761,
34044: 340781,
34045: 340801,
34046: 340831,
34047: 340861,
34048: 340871,
35000: 350011,
35001: 350161,
35002: 350141,
35003: 350061,
35005: 350081,
35006: 350121,
35008: 350181,
35009: 350191,
35010: 350221,
35011: 350231,
35012: 350261,
35013: 350271,
35014: 350301,
35015: 350321,
35016: 350341,
35017: 350361,
35018: 350391,
35019: 350401,
35020: 350411,
35021: 350431,
35022: 350441,
35023: 350451,
35024: 350461,
35025: 350491,
35026: 350501,
35027: 350511,
35028: 350531,
35029: 350551,
35030: 350601,
35031: 350621,
35032: 350631,
35033: 350641,
35034: 350661,
35035: 350681,
35036: 350691,
35037: 350701,
35038: 350711,
41000: 410031,
41001: 410071,
41002: 410111,
41003: 410151,
42000: 420031,
42001: 420071,
42002: 420111,
42003: 420151,
43000: 430031,
43001: 430071,
43002: 430111,
43003: 430151,
44000: 440031,
44001: 440071,
44002: 440111,
44003: 440151,
44004: 440191,
45000: 450031,
45001: 450071,
45002: 450111,
45003: 450151,
51001: 510011,
51002: 510021,
51003: 510031,
52001: 520011,
52002: 520021,
53001: 530011,
53002: 530021,
54001: 540011,
54002: 540021,
55001: 550011,
55002: 550021,
55003: 550031,
}
+5 -70
View File
@@ -3,7 +3,6 @@ package masterdata
import ( import (
"fmt" "fmt"
"log" "log"
"slices"
"sort" "sort"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
@@ -126,27 +125,16 @@ func LoadGachaPool() (*GachaCatalog, error) {
} }
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes)) catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
costumeTermId := make(map[int32]int32, len(catalogCostumes))
for _, c := range catalogCostumes { for _, c := range catalogCostumes {
catalogCostumeSet[c.CostumeId] = true catalogCostumeSet[c.CostumeId] = true
costumeTermId[c.CostumeId] = c.CatalogTermId
} }
catalogWeaponSet := make(map[int32]bool, len(catalogWeapons)) catalogWeaponSet := make(map[int32]bool, len(catalogWeapons))
for _, w := range catalogWeapons { for _, w := range catalogWeapons {
catalogWeaponSet[w.WeaponId] = true catalogWeaponSet[w.WeaponId] = true
} }
costumeWeaponType := make(map[int32]int32, len(costumes))
for _, c := range costumes {
costumeWeaponType[c.CostumeId] = c.SkillfulWeaponType
}
weaponTypeById := make(map[int32]int32, len(weapons))
weaponRarityById := make(map[int32]int32, len(weapons))
restrictedWeapons := make(map[int32]bool) restrictedWeapons := make(map[int32]bool)
for _, w := range weapons { for _, w := range weapons {
weaponTypeById[w.WeaponId] = w.WeaponType
weaponRarityById[w.WeaponId] = w.RarityType
if w.IsRestrictDiscard { if w.IsRestrictDiscard {
restrictedWeapons[w.WeaponId] = true restrictedWeapons[w.WeaponId] = true
} }
@@ -262,60 +250,12 @@ func LoadGachaPool() (*GachaCatalog, error) {
log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons", log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons",
evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount) evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount)
type weaponKey struct { for costumeId := range pool.CostumeById {
TermId int32 if wid, ok := costumeWeaponPairings[costumeId]; ok {
WeaponType int32 pool.CostumeWeaponMap[costumeId] = wid
Rarity int32
}
weaponsByKey := make(map[weaponKey][]int32)
for _, cw := range catalogWeapons {
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] {
continue
}
wt := weaponTypeById[cw.WeaponId]
r := weaponRarityById[cw.WeaponId]
if wt == 0 || r < model.RaritySRare {
continue
}
k := weaponKey{TermId: cw.CatalogTermId, WeaponType: wt, Rarity: r}
weaponsByKey[k] = append(weaponsByKey[k], cw.WeaponId)
}
for k, ids := range weaponsByKey {
slices.Sort(ids)
weaponsByKey[k] = ids
}
exact, pattern, bestGuess := 0, 0, 0
for costumeId, item := range pool.CostumeById {
tid := costumeTermId[costumeId]
wt := costumeWeaponType[costumeId]
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType}
candidates := weaponsByKey[k]
if len(candidates) == 0 {
continue
}
if len(candidates) == 1 {
pool.CostumeWeaponMap[costumeId] = candidates[0]
exact++
continue
}
idPattern := costumeId*10 + 1
found := false
for _, wid := range candidates {
if wid == idPattern {
pool.CostumeWeaponMap[costumeId] = wid
pattern++
found = true
break
}
}
if !found {
pool.CostumeWeaponMap[costumeId] = candidates[0]
bestGuess++
} }
} }
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total", log.Printf("[GachaPool] costume-weapon pairing: %d entries from lookup table", len(pool.CostumeWeaponMap))
exact, pattern, bestGuess, len(pool.CostumeWeaponMap))
for _, m := range materials { for _, m := range materials {
pool.Materials = append(pool.Materials, GachaPoolItem{ pool.Materials = append(pool.Materials, GachaPoolItem{
@@ -330,7 +270,6 @@ func LoadGachaPool() (*GachaCatalog, error) {
func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) { func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry) pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry)
shopPairs := 0
for _, cells := range shop.ExchangeShopCells { for _, cells := range shop.ExchangeShopCells {
consumableId := shop.Items[cells[0].ShopItemId].PriceId consumableId := shop.Items[cells[0].ShopItemId].PriceId
@@ -350,16 +289,12 @@ func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
continue continue
} }
entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId}) entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId})
if costumeId != 0 && weaponId != 0 {
pool.CostumeWeaponMap[costumeId] = weaponId
shopPairs++
}
} }
if len(entries) > 0 { if len(entries) > 0 {
pool.ShopFeaturedByMedal[consumableId] = entries pool.ShopFeaturedByMedal[consumableId] = entries
} }
} }
log.Printf("[GachaPool] shop featured: %d consumables, %d costume-weapon pairs overridden", len(pool.ShopFeaturedByMedal), shopPairs) log.Printf("[GachaPool] shop featured: %d consumables", len(pool.ShopFeaturedByMedal))
} }
func (pool *GachaCatalog) PruneUnpairedCostumes() { func (pool *GachaCatalog) PruneUnpairedCostumes() {
+539 -10
View File
@@ -3,57 +3,463 @@ package masterdata
import ( import (
"fmt" "fmt"
"log" "log"
"sort"
"sync"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
// MaxUserGimmickRows is the server's per-table cap for gimmick projections and the
// in-memory user.Gimmick.Sequences map. The client hard-caps each of its
// IUserGimmick* tables at 1024 rows; we project up to this many with a small
// safety margin so we never overflow client buffers. Shared by InitSequenceSchedule
// (limits user.Gimmick.Sequences map size) and the IUserGimmick / Ornament /
// Sequence projections.
const MaxUserGimmickRows = 1000
type gimmickScheduleEntry struct { type gimmickScheduleEntry struct {
ScheduleId int32 ScheduleId int32
StartDatetime int64 StartDatetime int64
EndDatetime int64 EndDatetime int64
FirstSequenceId int32 FirstSequenceId int32
RequiredQuestId int32 // 0 = always active RequiredQuestId int32 // 0 = always active
IsHidden bool // hidden-story or cage-memory gimmick: bypasses the quest gate
Rank int // trim priority — see gimmickTypeRank
}
func readGimmickTable[T any](name, what string) ([]T, bool) {
rows, err := utils.ReadTable[T](name)
if err != nil {
log.Printf("[gimmick] %s unavailable, %s empty: %v", name, what, err)
return nil, false
}
return rows, true
}
func gimmickTypeRank(t model.GimmickType) int {
switch t {
case model.GimmickTypeReport: // hidden missions / stories
return 0
case model.GimmickTypeCageMemory: // lost archives
return 1
case model.GimmickTypeCageTreasureHunt: // treasure
return 2
case model.GimmickTypeBrokenObelisk, model.GimmickTypeFirstBrokenObelisk:
return 3
case model.GimmickTypeIronGrill:
return 4
case model.GimmickTypeRadioMessage:
return 5
case model.GimmickTypeMapOnlyCageTreasureHunt, model.GimmickTypeMapOnlyHideObelisk:
return 6
case model.GimmickTypeCageIntervalDropItem, model.GimmickTypeMapOnlyCageIntervalDrop:
return 7 // birds — bottom
}
return 8
}
type gimmickTypeTables struct {
byGimmick map[int32]model.GimmickType
bySequence map[int32]model.GimmickType
}
var gimmickTypes = sync.OnceValue(loadGimmickTypes)
func loadGimmickTypes() gimmickTypeTables {
empty := gimmickTypeTables{
byGimmick: map[int32]model.GimmickType{},
bySequence: map[int32]model.GimmickType{},
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "type tables")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "type tables")
if !ok {
return empty
}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "type tables")
if !ok {
return empty
}
byGimmick := make(map[int32]model.GimmickType, len(gimmicks))
for _, g := range gimmicks {
byGimmick[g.GimmickId] = model.GimmickType(g.GimmickType)
}
typeByGroup := make(map[int32]model.GimmickType, len(groups))
for _, grp := range groups {
if _, seen := typeByGroup[grp.GimmickGroupId]; seen {
continue
}
if t, ok := byGimmick[grp.GimmickId]; ok {
typeByGroup[grp.GimmickGroupId] = t
}
}
bySequence := make(map[int32]model.GimmickType, len(sequences))
for _, seq := range sequences {
if t, ok := typeByGroup[seq.GimmickGroupId]; ok {
bySequence[seq.GimmickSequenceId] = t
}
}
return gimmickTypeTables{byGimmick: byGimmick, bySequence: bySequence}
}
func gimmickSequenceTypes() map[int32]model.GimmickType {
return gimmickTypes().bySequence
}
func LoadGimmickSequenceRanks() map[int32]int {
types := gimmickSequenceTypes()
out := make(map[int32]int, len(types))
for sid, t := range types {
out[sid] = gimmickTypeRank(t)
}
return out
}
type SequenceReward struct {
PossessionType int32
PossessionId int32
Count int32
} }
type GimmickCatalog struct { type GimmickCatalog struct {
schedules []gimmickScheduleEntry schedules []gimmickScheduleEntry
hiddenSequences map[int32]bool // GimmickSequenceId -> report/cage-memory
sequenceRewards map[int32][]SequenceReward // GimmickSequenceId -> clear rewards
gimmickTypes map[int32]model.GimmickType
cageMemoryItems map[int32]int32 // CageMemory GimmickId -> ImportantItemId (type 4)
hiddenBirdRewards map[GimmickOrnamentRef]SequenceReward
} }
func LoadGimmickCatalog(resolver *ConditionResolver) (*GimmickCatalog, error) { func LoadGimmickCatalog(resolver *ConditionResolver, cageOrnaments *CageOrnamentCatalog) (*GimmickCatalog, error) {
rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule") rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule")
if err != nil { if err != nil {
return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err) return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err)
} }
entries := make([]gimmickScheduleEntry, 0, len(rows)) seqTypes := gimmickSequenceTypes()
hiddenSeq := make(map[int32]bool, len(seqTypes))
for sid, t := range seqTypes {
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory {
hiddenSeq[sid] = true
}
}
// Pick rule: prefer schedules with EndDatetime in the future (so the client's
// in-cage filter doesn't drop them), then by lowest StartDatetime (longest
// historical validity, tends to carry the simplest RequiredQuestId), tie-broken
// by lowest ScheduleId for determinism. The future-end preference matters for
// "Fickle Black Birds" (type 1) where real expiry dates vary; other types have
// EndDatetime = 9999-03-31 so the preference is a no-op.
now := gametime.NowMillis()
bestBySeq := make(map[int32]gimmickScheduleEntry, len(rows))
for _, r := range rows { for _, r := range rows {
entry := gimmickScheduleEntry{ entry := gimmickScheduleEntry{
ScheduleId: r.GimmickSequenceScheduleId, ScheduleId: r.GimmickSequenceScheduleId,
StartDatetime: r.StartDatetime, StartDatetime: r.StartDatetime,
EndDatetime: r.EndDatetime, EndDatetime: r.EndDatetime,
FirstSequenceId: r.FirstGimmickSequenceId, FirstSequenceId: r.FirstGimmickSequenceId,
IsHidden: hiddenSeq[r.FirstGimmickSequenceId],
Rank: gimmickTypeRank(seqTypes[r.FirstGimmickSequenceId]),
} }
if r.ReleaseEvaluateConditionId != 0 { if r.ReleaseEvaluateConditionId != 0 {
if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok { if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok {
entry.RequiredQuestId = qid entry.RequiredQuestId = qid
} }
} }
entries = append(entries, entry) if existing, ok := bestBySeq[entry.FirstSequenceId]; ok {
existingFuture := existing.EndDatetime > now
entryFuture := entry.EndDatetime > now
if existingFuture != entryFuture {
// Future-end schedule wins over expired one.
if existingFuture {
continue
}
} else if existing.StartDatetime < entry.StartDatetime ||
(existing.StartDatetime == entry.StartDatetime && existing.ScheduleId <= entry.ScheduleId) {
continue
}
}
bestBySeq[entry.FirstSequenceId] = entry
} }
log.Printf("gimmick catalog loaded: %d schedules", len(entries)) entries := make([]gimmickScheduleEntry, 0, len(bestBySeq))
return &GimmickCatalog{schedules: entries}, nil hiddenCount := 0
for _, entry := range bestBySeq {
if entry.IsHidden {
hiddenCount++
}
entries = append(entries, entry)
}
dedupedCount := len(rows) - len(entries)
// Sort by (Rank, ScheduleId) so ActiveScheduleKeys returns the priority order
// directly: treasure/lost-archives/hidden-missions first, birds last. This is
// what lets InitSequenceSchedule's 1000-row cap trim from the bottom.
sort.Slice(entries, func(i, j int) bool {
if entries[i].Rank != entries[j].Rank {
return entries[i].Rank < entries[j].Rank
}
return entries[i].ScheduleId < entries[j].ScheduleId
})
sequenceRewards := loadGimmickSequenceRewards()
cageMemoryItems := loadCageMemoryImportantItems(gimmickTypes().byGimmick)
hiddenBirdRewards := loadHiddenBirdRewards(cageOrnaments)
log.Printf("gimmick catalog loaded: %d schedules (%d hidden-content, %d duplicates dropped), %d reward sequences, %d cage-memory items, %d hidden-bird rewards",
len(entries), hiddenCount, dedupedCount, len(sequenceRewards), len(cageMemoryItems), len(hiddenBirdRewards))
return &GimmickCatalog{
schedules: entries,
hiddenSequences: hiddenSeq,
sequenceRewards: sequenceRewards,
gimmickTypes: gimmickTypes().byGimmick,
cageMemoryItems: cageMemoryItems,
hiddenBirdRewards: hiddenBirdRewards,
}, nil
}
// HiddenBirdReward returns the per-tap reward for a MAP_ONLY_CAGE_TREASURE_HUNT
// ("Hidden Black Birds", type 7) ornament. Returns false if there's no mapping
// (e.g. the ornament view has no corresponding cage-ornament-reward entry).
func (c *GimmickCatalog) HiddenBirdReward(gimmickId, ornamentIndex int32) (SequenceReward, bool) {
r, ok := c.hiddenBirdRewards[GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex}]
return r, ok
}
// loadHiddenBirdRewards resolves (GimmickId, OrnamentIndex) -> CageOrnamentReward for
// every type-7 ("Hidden Black Birds") gimmick. The mapping is structural:
//
// m_gimmick (GimmickType == 7) -> GimmickOrnamentGroupId
// m_gimmick_ornament (matching group) -> GimmickOrnamentViewId
// m_cage_ornament (CageOrnamentId == ViewId) -> CageOrnamentRewardId
// m_cage_ornament_reward (matching id) -> PossessionType / PossessionId / Count
//
// 110 of 114 type-7 ornaments have a matching m_cage_ornament row in the current
// data; the rest log a warning and are silently skipped so the player just gets
// no reward on those (no crash).
func loadHiddenBirdRewards(cageOrnaments *CageOrnamentCatalog) map[GimmickOrnamentRef]SequenceReward {
empty := map[GimmickOrnamentRef]SequenceReward{}
if cageOrnaments == nil {
return empty
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "hidden-bird rewards")
if !ok {
return empty
}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "hidden-bird rewards")
if !ok {
return empty
}
gimmicksByGroup := make(map[int32][]int32)
for _, g := range gimmicks {
if model.GimmickType(g.GimmickType) == model.GimmickTypeMapOnlyCageTreasureHunt {
gimmicksByGroup[g.GimmickOrnamentGroupId] = append(gimmicksByGroup[g.GimmickOrnamentGroupId], g.GimmickId)
}
}
out := make(map[GimmickOrnamentRef]SequenceReward)
missing := 0
for _, o := range ornaments {
gids, ok := gimmicksByGroup[o.GimmickOrnamentGroupId]
if !ok {
continue
}
reward, ok := cageOrnaments.LookupReward(o.GimmickOrnamentViewId)
if !ok {
missing++
continue
}
entry := SequenceReward{
PossessionType: reward.PossessionType,
PossessionId: reward.PossessionId,
Count: reward.Count,
}
for _, gid := range gids {
out[GimmickOrnamentRef{GimmickId: gid, OrnamentIndex: o.GimmickOrnamentIndex}] = entry
}
}
if missing > 0 {
log.Printf("[gimmick] %d hidden-bird ornaments had no m_cage_ornament_reward row", missing)
}
return out
}
func (c *GimmickCatalog) GimmickType(gimmickId int32) model.GimmickType {
return c.gimmickTypes[gimmickId]
}
// CageMemoryImportantItem returns the ImportantItemId (type 4) that the library uses
// to mark a tapped cage memory as collected, given the world-gimmick id. The mapping
// is derived from m_gimmick_additional_asset texture suffixes — see
// loadCageMemoryImportantItems.
func (c *GimmickCatalog) CageMemoryImportantItem(gimmickId int32) (int32, bool) {
id, ok := c.cageMemoryItems[gimmickId]
return id, ok
}
// importantItemTypeCageMemory mirrors EntityMImportantItem.ImportantItemType==4 — the
// CageMemory entry that the library's HasCageMemory check resolves to.
const importantItemTypeCageMemory int32 = 4
func loadCageMemoryImportantItems(typeByGimmick map[int32]model.GimmickType) map[int32]int32 {
empty := map[int32]int32{}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "cage-memory items")
if !ok {
return empty
}
chapters, ok := readGimmickTable[EntityMMainQuestChapter]("m_main_quest_chapter", "cage-memory items")
if !ok {
return empty
}
routes, ok := readGimmickTable[EntityMMainQuestRoute]("m_main_quest_route", "cage-memory items")
if !ok {
return empty
}
cageMemories, ok := readGimmickTable[EntityMCageMemory]("m_cage_memory", "cage-memory items")
if !ok {
return empty
}
items, ok := readGimmickTable[EntityMImportantItem]("m_important_item", "cage-memory items")
if !ok {
return empty
}
chapterByOrnamentGroup := make(map[int32]int32, len(ornaments))
for _, o := range ornaments {
if _, seen := chapterByOrnamentGroup[o.GimmickOrnamentGroupId]; seen {
continue
}
chapterByOrnamentGroup[o.GimmickOrnamentGroupId] = o.ChapterId
}
routeByChapter := make(map[int32]int32, len(chapters))
for _, c := range chapters {
routeByChapter[c.MainQuestChapterId] = c.MainQuestRouteId
}
seasonByRoute := make(map[int32]int32, len(routes))
for _, r := range routes {
seasonByRoute[r.MainQuestRouteId] = r.MainQuestSeasonId
}
cmsBySeason := make(map[int32][]int32)
for _, c := range cageMemories {
cmsBySeason[c.MainQuestSeasonId] = append(cmsBySeason[c.MainQuestSeasonId], c.CageMemoryId)
}
for s := range cmsBySeason {
sort.Slice(cmsBySeason[s], func(i, j int) bool { return cmsBySeason[s][i] < cmsBySeason[s][j] })
}
itemByCageMemory := make(map[int32]int32)
for _, it := range items {
if it.ImportantItemType == importantItemTypeCageMemory && it.CageMemoryId != 0 {
itemByCageMemory[it.CageMemoryId] = it.ImportantItemId
}
}
gimmicksByRoute := make(map[int32][]int32)
for gid, t := range typeByGimmick {
if t != model.GimmickTypeCageMemory {
continue
}
chapter, ok := chapterByOrnamentGroup[gid]
if !ok {
log.Printf("[gimmick] cage-memory %d has no ornament row, skipping mapping", gid)
continue
}
route, ok := routeByChapter[chapter]
if !ok {
log.Printf("[gimmick] cage-memory %d chapter %d has no route, skipping mapping", gid, chapter)
continue
}
gimmicksByRoute[route] = append(gimmicksByRoute[route], gid)
}
for r := range gimmicksByRoute {
sort.Slice(gimmicksByRoute[r], func(i, j int) bool { return gimmicksByRoute[r][i] < gimmicksByRoute[r][j] })
}
out := make(map[int32]int32)
for route, gids := range gimmicksByRoute {
season, ok := seasonByRoute[route]
if !ok {
log.Printf("[gimmick] route %d has no season, skipping %d cage-memory gimmicks", route, len(gids))
continue
}
seasonCms := cmsBySeason[season]
for i, gid := range gids {
if i >= len(seasonCms) {
log.Printf("[gimmick] route %d (season %d) has %d cage-memory gimmicks but only %d cage memories; gimmick %d skipped",
route, season, len(gids), len(seasonCms), gid)
continue
}
cageMemoryId := seasonCms[i]
itemId, ok := itemByCageMemory[cageMemoryId]
if !ok {
log.Printf("[gimmick] cage memory %d (gimmick %d) has no m_important_item row (type 4), skipping",
cageMemoryId, gid)
continue
}
out[gid] = itemId
}
}
return out
}
func loadGimmickSequenceRewards() map[int32][]SequenceReward {
empty := map[int32][]SequenceReward{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence rewards")
if !ok {
return empty
}
rewardGroups, ok := readGimmickTable[EntityMGimmickSequenceRewardGroup]("m_gimmick_sequence_reward_group", "sequence rewards")
if !ok {
return empty
}
rewardsByGroup := make(map[int32][]SequenceReward)
for _, rg := range rewardGroups {
if rg.PossessionType == 0 || rg.PossessionId == 0 {
continue
}
rewardsByGroup[rg.GimmickSequenceRewardGroupId] = append(
rewardsByGroup[rg.GimmickSequenceRewardGroupId], SequenceReward{
PossessionType: rg.PossessionType,
PossessionId: rg.PossessionId,
Count: rg.Count,
})
}
rewardsBySequence := make(map[int32][]SequenceReward, len(sequences))
for _, seq := range sequences {
if rewards := rewardsByGroup[seq.GimmickSequenceRewardGroupId]; len(rewards) > 0 {
rewardsBySequence[seq.GimmickSequenceId] = rewards
}
}
return rewardsBySequence
}
func (c *GimmickCatalog) IsHiddenSequence(sequenceId int32) bool {
return c.hiddenSequences[sequenceId]
}
func (c *GimmickCatalog) SequenceRewards(sequenceId int32) []SequenceReward {
return c.sequenceRewards[sequenceId]
} }
func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey { func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey {
var keys []store.GimmickSequenceKey keys := make([]store.GimmickSequenceKey, 0, len(c.schedules))
for _, s := range c.schedules { for _, s := range c.schedules {
if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime { if nowMillis < s.StartDatetime {
continue continue // future schedules still skipped
} }
if s.RequiredQuestId != 0 { if !s.IsHidden && s.RequiredQuestId != 0 {
q, ok := user.Quests[s.RequiredQuestId] q, ok := user.Quests[s.RequiredQuestId]
if !ok || q.QuestStateType != model.UserQuestStateTypeCleared { if !ok || q.QuestStateType != model.UserQuestStateTypeCleared {
continue continue
@@ -66,3 +472,126 @@ func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int6
} }
return keys return keys
} }
type GimmickOrnamentRef struct {
GimmickId int32
OrnamentIndex int32
}
func LoadGimmickOrnamentRefs() map[int32][]GimmickOrnamentRef {
empty := map[int32][]GimmickOrnamentRef{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "ornament refs")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "ornament refs")
if !ok {
return empty
}
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "ornament refs")
if !ok {
return empty
}
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "ornament refs")
if !ok {
return empty
}
indicesByOrnamentGroup := make(map[int32][]int32)
for _, o := range ornaments {
indicesByOrnamentGroup[o.GimmickOrnamentGroupId] = append(
indicesByOrnamentGroup[o.GimmickOrnamentGroupId], o.GimmickOrnamentIndex)
}
ornamentGroupByGimmick := make(map[int32]int32, len(gimmicks))
for _, g := range gimmicks {
ornamentGroupByGimmick[g.GimmickId] = g.GimmickOrnamentGroupId
}
gimmicksByGroup := make(map[int32][]int32)
for _, grp := range groups {
gimmicksByGroup[grp.GimmickGroupId] = append(gimmicksByGroup[grp.GimmickGroupId], grp.GimmickId)
}
refsBySequence := make(map[int32][]GimmickOrnamentRef, len(sequences))
for _, seq := range sequences {
var refs []GimmickOrnamentRef
for _, gimmickId := range gimmicksByGroup[seq.GimmickGroupId] {
for _, ornamentIndex := range indicesByOrnamentGroup[ornamentGroupByGimmick[gimmickId]] {
refs = append(refs, GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex})
}
}
if len(refs) > 0 {
refsBySequence[seq.GimmickSequenceId] = refs
}
}
log.Printf("gimmick ornament refs loaded: %d sequences", len(refsBySequence))
return refsBySequence
}
func LoadHiddenGimmickSequenceIDs() map[int32]bool {
types := gimmickSequenceTypes()
out := make(map[int32]bool, len(types))
for sid, t := range types {
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory || t == model.GimmickTypeMapOnlyCageTreasureHunt {
out[sid] = true
}
}
return out
}
func LoadBirdGimmickIDs() map[int32]bool {
byGimmick := gimmickTypes().byGimmick
out := make(map[int32]bool, len(byGimmick))
for gid, t := range byGimmick {
if t == model.GimmickTypeCageIntervalDropItem || t == model.GimmickTypeMapOnlyCageIntervalDrop {
out[gid] = true
}
}
return out
}
func LoadGimmickSequenceChains() map[int32][]int32 {
empty := map[int32][]int32{}
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence chains")
if !ok {
return empty
}
groups, ok := readGimmickTable[EntityMGimmickSequenceGroup]("m_gimmick_sequence_group", "sequence chains")
if !ok {
return empty
}
membersByGroup := make(map[int32][]int32)
for _, g := range groups {
membersByGroup[g.GimmickSequenceGroupId] = append(membersByGroup[g.GimmickSequenceGroupId], g.GimmickSequenceId)
}
nextGroupBySequence := make(map[int32]int32, len(sequences))
for _, seq := range sequences {
nextGroupBySequence[seq.GimmickSequenceId] = seq.NextGimmickSequenceGroupId
}
chains := make(map[int32][]int32, len(sequences))
for _, seq := range sequences {
start := seq.GimmickSequenceId
seen := map[int32]bool{start: true}
chain := []int32{start}
for queue := []int32{start}; len(queue) > 0; {
cur := queue[0]
queue = queue[1:]
nextGroup := nextGroupBySequence[cur]
if nextGroup == 0 {
continue
}
for _, member := range membersByGroup[nextGroup] {
if !seen[member] {
seen[member] = true
chain = append(chain, member)
queue = append(queue, member)
}
}
}
chains[start] = chain
}
return chains
}
+103
View File
@@ -0,0 +1,103 @@
package masterdata
import (
"log"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
type HiddenStoryRequirements struct {
MissionIds []int32
QuestMissions []store.QuestMissionKey
}
func LoadHiddenStoryRequirements() HiddenStoryRequirements {
var empty HiddenStoryRequirements
gimmicks, err := utils.ReadTable[EntityMGimmick]("m_gimmick")
if err != nil {
log.Printf("[hiddenstory] m_gimmick unavailable: %v", err)
return empty
}
conditions, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
if err != nil {
log.Printf("[hiddenstory] m_evaluate_condition unavailable: %v", err)
return empty
}
valueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
if err != nil {
log.Printf("[hiddenstory] m_evaluate_condition_value_group unavailable: %v", err)
return empty
}
condById := make(map[int32]EntityMEvaluateCondition, len(conditions))
for _, c := range conditions {
condById[c.EvaluateConditionId] = c
}
valuesByGroup := make(map[int32]map[int32]int64)
for _, vg := range valueGroups {
g := valuesByGroup[vg.EvaluateConditionValueGroupId]
if g == nil {
g = make(map[int32]int64)
valuesByGroup[vg.EvaluateConditionValueGroupId] = g
}
g[vg.GroupIndex] = vg.Value
}
missionSet := make(map[int32]struct{})
questMissionSet := make(map[store.QuestMissionKey]struct{})
seen := make(map[int32]bool)
var resolve func(conditionId int32, depth int)
resolve = func(conditionId int32, depth int) {
if conditionId == 0 || depth > 16 || seen[conditionId] {
return
}
seen[conditionId] = true
c, ok := condById[conditionId]
if !ok {
return
}
group := valuesByGroup[c.EvaluateConditionValueGroupId]
switch model.EvaluateConditionFunctionType(c.EvaluateConditionFunctionType) {
case model.EvaluateConditionFunctionTypeRecursion:
// Value-group entries are sub-condition ids; satisfying all leaves makes
// both AND and OR recursion conditions evaluate true.
for _, sub := range group {
resolve(int32(sub), depth+1)
}
case model.EvaluateConditionFunctionTypeMissionClear:
if v, ok := group[defaultGroupIndex]; ok {
missionSet[int32(v)] = struct{}{}
}
case model.EvaluateConditionFunctionTypeQuestMissionClear:
questId, ok1 := group[1]
questMissionId, ok2 := group[2]
if ok1 && ok2 {
questMissionSet[store.QuestMissionKey{
QuestId: int32(questId),
QuestMissionId: int32(questMissionId),
}] = struct{}{}
}
}
}
for _, g := range gimmicks {
switch model.GimmickType(g.GimmickType) {
case model.GimmickTypeReport, model.GimmickTypeCageMemory:
resolve(g.ClearEvaluateConditionId, 0)
}
}
req := HiddenStoryRequirements{}
for id := range missionSet {
req.MissionIds = append(req.MissionIds, id)
}
for key := range questMissionSet {
req.QuestMissions = append(req.QuestMissions, key)
}
log.Printf("hidden-story requirements: %d missions, %d quest-missions", len(req.MissionIds), len(req.QuestMissions))
return req
}
+225
View File
@@ -0,0 +1,225 @@
package masterdata
import (
"log"
"sort"
"lunar-tear/server/internal/utils"
)
type LabyrinthChapter struct {
EventQuestChapterId int32
LatestSeasonNumber int32
StageOrders []int32
}
type LabyrinthStageTier struct {
QuestMissionClearCount int32
Rewards []RewardItem
}
type LabyrinthSeasonMilestone struct {
HeadQuestId int32
HeadStageOrder int32
Rewards []RewardItem
}
type labyrinthStageKey struct {
ChapterId int32
StageOrder int32
}
type LabyrinthCatalog struct {
ChaptersByOrder []LabyrinthChapter
ClearRewardsByStage map[labyrinthStageKey][]RewardItem
AccumTiersByStage map[labyrinthStageKey][]LabyrinthStageTier
SeasonMilestonesByChapter map[int32][]LabyrinthSeasonMilestone
}
func (c *LabyrinthCatalog) StageClearReward(chapterId, stageOrder int32) []RewardItem {
return c.ClearRewardsByStage[labyrinthStageKey{chapterId, stageOrder}]
}
func (c *LabyrinthCatalog) CollectAccumulationRewards(chapterId, stageOrder, oldCount, targetCount int32) ([]RewardItem, int32) {
var items []RewardItem
highest := int32(0)
for _, t := range c.AccumTiersByStage[labyrinthStageKey{chapterId, stageOrder}] {
if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount {
items = append(items, t.Rewards...)
if t.QuestMissionClearCount > highest {
highest = t.QuestMissionClearCount
}
}
}
return items, highest
}
func (c *LabyrinthCatalog) SeasonMilestones(chapterId int32) []LabyrinthSeasonMilestone {
return c.SeasonMilestonesByChapter[chapterId]
}
func LoadLabyrinthCatalog() *LabyrinthCatalog {
seasonRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeason]("m_event_quest_labyrinth_season")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_season unavailable, labyrinth disabled: %v", err)
return &LabyrinthCatalog{}
}
stageRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStage]("m_event_quest_labyrinth_stage")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_stage unavailable, labyrinth disabled: %v", err)
return &LabyrinthCatalog{}
}
// chapterId -> highest SeasonNumber
latestSeason := make(map[int32]int32)
for _, r := range seasonRows {
if r.SeasonNumber > latestSeason[r.EventQuestChapterId] {
latestSeason[r.EventQuestChapterId] = r.SeasonNumber
}
}
// chapterId -> stage orders
stagesByChapter := make(map[int32][]int32)
for _, r := range stageRows {
stagesByChapter[r.EventQuestChapterId] = append(stagesByChapter[r.EventQuestChapterId], r.StageOrder)
}
chapters := make([]LabyrinthChapter, 0, len(latestSeason))
for chapterId, season := range latestSeason {
stages := stagesByChapter[chapterId]
sort.Slice(stages, func(i, j int) bool { return stages[i] < stages[j] })
chapters = append(chapters, LabyrinthChapter{
EventQuestChapterId: chapterId,
LatestSeasonNumber: season,
StageOrders: stages,
})
}
sort.Slice(chapters, func(i, j int) bool {
return chapters[i].EventQuestChapterId < chapters[j].EventQuestChapterId
})
clearRewards, accumTiers, seasonMilestones := loadLabyrinthRewards(seasonRows, stageRows)
log.Printf("labyrinth catalog loaded: %d chapters, %d stages with clear rewards, %d with accumulation rewards, %d chapters with season rewards",
len(chapters), len(clearRewards), len(accumTiers), len(seasonMilestones))
return &LabyrinthCatalog{
ChaptersByOrder: chapters,
ClearRewardsByStage: clearRewards,
AccumTiersByStage: accumTiers,
SeasonMilestonesByChapter: seasonMilestones,
}
}
func loadLabyrinthRewards(seasonRows []EntityMEventQuestLabyrinthSeason, stageRows []EntityMEventQuestLabyrinthStage) (
clearRewards map[labyrinthStageKey][]RewardItem,
accumTiers map[labyrinthStageKey][]LabyrinthStageTier,
seasonMilestones map[int32][]LabyrinthSeasonMilestone,
) {
rewardGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthRewardGroup]("m_event_quest_labyrinth_reward_group")
if err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_reward_group unavailable, rewards disabled: %v", err)
return nil, nil, nil
}
// reward group id -> reward items
itemsByRewardGroup := make(map[int32][]RewardItem)
for _, r := range rewardGroupRows {
itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = append(itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], RewardItem{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
// per-stage one-time clear reward
clearRewards = make(map[labyrinthStageKey][]RewardItem)
for _, r := range stageRows {
if r.StageClearRewardGroupId == 0 {
continue
}
if items := itemsByRewardGroup[r.StageClearRewardGroupId]; len(items) > 0 {
clearRewards[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = items
}
}
if accumGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStageAccumulationRewardGroup]("m_event_quest_labyrinth_stage_accumulation_reward_group"); err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_stage_accumulation_reward_group unavailable, accumulation rewards disabled: %v", err)
} else {
// accumulation group id -> tiers (threshold + resolved reward items)
tiersByGroup := make(map[int32][]LabyrinthStageTier)
for _, r := range accumGroupRows {
tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId], LabyrinthStageTier{
QuestMissionClearCount: r.QuestMissionClearCount,
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
})
}
accumTiers = make(map[labyrinthStageKey][]LabyrinthStageTier)
for _, r := range stageRows {
if r.StageAccumulationRewardGroupId == 0 {
continue
}
tiers := tiersByGroup[r.StageAccumulationRewardGroupId]
sort.Slice(tiers, func(i, j int) bool {
return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount
})
accumTiers[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = tiers
}
}
// per-chapter season-reward milestones
if seasonRewardRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeasonRewardGroup]("m_event_quest_labyrinth_season_reward_group"); err != nil {
log.Printf("[labyrinth] m_event_quest_labyrinth_season_reward_group unavailable, season rewards disabled: %v", err)
} else {
seasonMilestones = buildLabyrinthSeasonMilestones(seasonRows, seasonRewardRows, itemsByRewardGroup)
}
return clearRewards, accumTiers, seasonMilestones
}
func buildLabyrinthSeasonMilestones(
seasonRows []EntityMEventQuestLabyrinthSeason,
seasonRewardRows []EntityMEventQuestLabyrinthSeasonRewardGroup,
itemsByRewardGroup map[int32][]RewardItem,
) map[int32][]LabyrinthSeasonMilestone {
// chapter -> SeasonRewardGroupId (all seasons of a chapter share one)
groupByChapter := make(map[int32]int32)
for _, r := range seasonRows {
groupByChapter[r.EventQuestChapterId] = r.SeasonRewardGroupId
}
// SeasonRewardGroupId -> its rows, in table order
rowsByGroup := make(map[int32][]EntityMEventQuestLabyrinthSeasonRewardGroup)
for _, r := range seasonRewardRows {
rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId] = append(rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId], r)
}
milestones := make(map[int32][]LabyrinthSeasonMilestone)
for chapterId, seasonGroupId := range groupByChapter {
rows := rowsByGroup[seasonGroupId]
if len(rows) == 0 {
continue
}
// rank distinct reward-group ids ascending -> 1-based head stage order
stageByRewardGroup := make(map[int32]int32)
var distinct []int32
for _, r := range rows {
if _, seen := stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId]; !seen {
stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = 0
distinct = append(distinct, r.EventQuestLabyrinthRewardGroupId)
}
}
sort.Slice(distinct, func(i, j int) bool { return distinct[i] < distinct[j] })
for i, gid := range distinct {
stageByRewardGroup[gid] = int32(i + 1)
}
list := make([]LabyrinthSeasonMilestone, 0, len(rows))
for _, r := range rows {
list = append(list, LabyrinthSeasonMilestone{
HeadQuestId: r.HeadQuestId,
HeadStageOrder: stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
})
}
milestones[chapterId] = list
}
return milestones
}
+4
View File
@@ -158,5 +158,9 @@ func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) {
id++ id++
} }
} }
// Newer parts groups (PartsGroupId 401-490) use PartsStatusSubLotteryGroupId
// 11/12 for rarities 10/20 instead of 1/2. Same stat pools — alias them.
pool[11] = pool[1]
pool[12] = pool[2]
return defs, pool return defs, pool
} }
+64
View File
@@ -34,7 +34,11 @@ type QuestCatalog struct {
TutorialUnlockConditions []EntityMTutorialUnlockCondition TutorialUnlockConditions []EntityMTutorialUnlockCondition
ChapterLastSceneByQuestId map[int32]int32 ChapterLastSceneByQuestId map[int32]int32
SeasonIdByRouteId map[int32]int32 SeasonIdByRouteId map[int32]int32
RoutesBySeason map[int32][]int32
RouteCompletionQuestId map[int32]int32
BattleOnlyTargetSceneByQuestId map[int32]int32 BattleOnlyTargetSceneByQuestId map[int32]int32
MainQuestChapterIdByQuestId map[int32]int32
EventQuestTypeByChapterId map[int32]int32
UserExpThresholds []int32 UserExpThresholds []int32
CharacterExpThresholds []int32 CharacterExpThresholds []int32
@@ -114,8 +118,53 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
return nil, fmt.Errorf("load main quest route table: %w", err) return nil, fmt.Errorf("load main quest route table: %w", err)
} }
seasonIdByRouteId := make(map[int32]int32, len(routes)) seasonIdByRouteId := make(map[int32]int32, len(routes))
routesBySeason := make(map[int32][]int32, len(routes))
sortOrderByRoute := make(map[int32]int32, len(routes))
for _, r := range routes { for _, r := range routes {
seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId
routesBySeason[r.MainQuestSeasonId] = append(routesBySeason[r.MainQuestSeasonId], r.MainQuestRouteId)
sortOrderByRoute[r.MainQuestRouteId] = r.SortOrder
}
for seasonId, ids := range routesBySeason {
s := ids
sort.Slice(s, func(i, j int) bool { return sortOrderByRoute[s[i]] > sortOrderByRoute[s[j]] })
routesBySeason[seasonId] = s
}
anotherReplayConds, err := utils.ReadTable[EntityMMainQuestRouteAnotherReplayFlowUnlockCondition]("m_main_quest_route_another_replay_flow_unlock_condition")
if err != nil {
return nil, fmt.Errorf("load main quest route another replay flow unlock condition table: %w", err)
}
evaluateConds, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
if err != nil {
return nil, fmt.Errorf("load evaluate condition table: %w", err)
}
valueGroupByConditionId := make(map[int32]int32, len(evaluateConds))
for _, c := range evaluateConds {
valueGroupByConditionId[c.EvaluateConditionId] = c.EvaluateConditionValueGroupId
}
evaluateValueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
if err != nil {
return nil, fmt.Errorf("load evaluate condition value group table: %w", err)
}
valueByGroupId := make(map[int32]int32, len(evaluateValueGroups))
for _, vg := range evaluateValueGroups {
if _, exists := valueByGroupId[vg.EvaluateConditionValueGroupId]; exists {
continue
}
valueByGroupId[vg.EvaluateConditionValueGroupId] = int32(vg.Value)
}
routeCompletionQuestId := make(map[int32]int32, len(anotherReplayConds))
for _, c := range anotherReplayConds {
valueGroupId, ok := valueGroupByConditionId[c.UnlockEvaluateConditionId]
if !ok {
continue
}
questId, ok := valueByGroupId[valueGroupId]
if !ok {
continue
}
routeCompletionQuestId[c.MainQuestRouteId] = questId
} }
firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch") firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch")
@@ -335,12 +384,23 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter
} }
routeIdByQuestId := make(map[int32]int32) routeIdByQuestId := make(map[int32]int32)
mainQuestChapterIdByQuestId := make(map[int32]int32)
for _, sequence := range sequences { for _, sequence := range sequences {
if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok { if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok {
routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId
mainQuestChapterIdByQuestId[sequence.QuestId] = chapter.MainQuestChapterId
} }
} }
eventChapters, err := utils.ReadTable[EntityMEventQuestChapter]("m_event_quest_chapter")
if err != nil {
return nil, fmt.Errorf("load event quest chapter table: %w", err)
}
eventQuestTypeByChapterId := make(map[int32]int32, len(eventChapters))
for _, ec := range eventChapters {
eventQuestTypeByChapterId[ec.EventQuestChapterId] = ec.EventQuestType
}
sortedChapters := make([]EntityMMainQuestChapter, len(chapters)) sortedChapters := make([]EntityMMainQuestChapter, len(chapters))
copy(sortedChapters, chapters) copy(sortedChapters, chapters)
sort.Slice(sortedChapters, func(i, j int) bool { sort.Slice(sortedChapters, func(i, j int) bool {
@@ -539,7 +599,11 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
TutorialUnlockConditions: tutorialUnlockConds, TutorialUnlockConditions: tutorialUnlockConds,
ChapterLastSceneByQuestId: chapterLastSceneByQuestId, ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
SeasonIdByRouteId: seasonIdByRouteId, SeasonIdByRouteId: seasonIdByRouteId,
RoutesBySeason: routesBySeason,
RouteCompletionQuestId: routeCompletionQuestId,
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId, BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
MainQuestChapterIdByQuestId: mainQuestChapterIdByQuestId,
EventQuestTypeByChapterId: eventQuestTypeByChapterId,
UserExpThresholds: BuildExpThresholds(paramMapRows, 1), UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31), CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
+109 -9
View File
@@ -2,11 +2,35 @@ package masterdata
import ( import (
"log" "log"
"sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
type SideStorySceneInfo struct {
SceneId int32
Type model.SideStorySceneIdType
}
type SideStoryQuestInfo struct {
SideStoryQuestId int32
Scenes []SideStorySceneInfo // the 7 scenes, one per type
Quests []int32 // ordered event quests (the chapter+difficulty sequence)
}
type SideStoryCatalog struct { type SideStoryCatalog struct {
FirstSceneByQuestId map[int32]int32 QuestById map[int32]*SideStoryQuestInfo
ChapterByEventQuestId map[int32]int32 // event quest id -> side story chapter id
}
func (q *SideStoryQuestInfo) SceneIdByType(t model.SideStorySceneIdType) (int32, bool) {
for _, s := range q.Scenes {
if s.Type == t {
return s.SceneId, true
}
}
return 0, false
} }
func LoadSideStoryCatalog() *SideStoryCatalog { func LoadSideStoryCatalog() *SideStoryCatalog {
@@ -14,14 +38,90 @@ func LoadSideStoryCatalog() *SideStoryCatalog {
if err != nil { if err != nil {
log.Fatalf("load side story quest scene table: %v", err) log.Fatalf("load side story quest scene table: %v", err)
} }
limitContents, err := utils.ReadTable[EntityMSideStoryQuestLimitContent]("m_side_story_quest_limit_content")
firstScene := make(map[int32]int32, len(scenes)/7) if err != nil {
for _, s := range scenes { log.Fatalf("load side story quest limit content table: %v", err)
if s.SortOrder == 1 { }
firstScene[s.SideStoryQuestId] = s.SideStoryQuestSceneId 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)) seqRows := make(map[int32][]EntityMEventQuestSequence)
return &SideStoryCatalog{FirstSceneByQuestId: firstScene} 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}
}
@@ -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)
)
+24 -6
View File
@@ -12,10 +12,6 @@ const (
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4 QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4
) )
// IsReplayQuestFlowType reports whether the flow type indicates an active
// replay session — either same-route REPLAY_FLOW or cross-route
// ANOTHER_ROUTE_REPLAY_FLOW. Mirrors the client's Story.IsReplayQuestFlowType
// predicate (dump.cs:768202).
func IsReplayQuestFlowType(t int32) bool { func IsReplayQuestFlowType(t int32) bool {
return t == int32(QuestFlowTypeReplayFlow) || return t == int32(QuestFlowTypeReplayFlow) ||
t == int32(QuestFlowTypeAnotherRouteReplayFlow) t == int32(QuestFlowTypeAnotherRouteReplayFlow)
@@ -47,6 +43,15 @@ const (
QuestResultTypeFullResult QuestResultType = 3 QuestResultTypeFullResult QuestResultType = 3
) )
type MissionProgressStatusType int32
const (
MissionProgressStatusTypeUnknown MissionProgressStatusType = 0
MissionProgressStatusTypeInProgress MissionProgressStatusType = 1
MissionProgressStatusTypeClear MissionProgressStatusType = 2
MissionProgressStatusTypeRewardReceived MissionProgressStatusType = 9
)
type QuestSceneType int32 type QuestSceneType int32
const ( const (
@@ -164,6 +169,19 @@ type SideStoryQuestStateType int32
const ( const (
SideStoryQuestStateUnknown SideStoryQuestStateType = 0 SideStoryQuestStateUnknown SideStoryQuestStateType = 0
SideStoryQuestStateActive SideStoryQuestStateType = 1 SideStoryQuestStateActive SideStoryQuestStateType = 2
SideStoryQuestStateCleared SideStoryQuestStateType = 2 SideStoryQuestStateCleared SideStoryQuestStateType = 3
)
type SideStorySceneIdType int32
const (
SideStorySceneInvalid SideStorySceneIdType = 0
SideStorySceneIntroduction SideStorySceneIdType = 1
SideStoryScenePlayGeneralQuest SideStorySceneIdType = 2
SideStorySceneUnlockLastQuest SideStorySceneIdType = 3
SideStoryScenePlayLastQuest SideStorySceneIdType = 4
SideStorySceneOutroduction SideStorySceneIdType = 5
SideStorySceneShowCostumeAcquisition SideStorySceneIdType = 6
SideStoryScenePlayFreeQuest SideStorySceneIdType = 7
) )
+7 -4
View File
@@ -17,7 +17,8 @@ func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, u
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForBigHunt(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -33,13 +34,15 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForBigHunt(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
+56
View File
@@ -0,0 +1,56 @@
package questflow
import (
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/model"
)
func (h *QuestHandler) targetForMain(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeMainQuest,
ChapterId: h.MainQuestChapterIdByQuestId[questId],
}
}
func (h *QuestHandler) targetForEvent(eventChapterId, questId int32) campaign.QuestTarget {
return campaign.QuestTarget{
QuestId: questId,
QuestType: campaign.QuestTypeEventQuest,
EventQuestType: h.EventQuestTypeByChapterId[eventChapterId],
ChapterId: eventChapterId,
}
}
func (h *QuestHandler) targetForExtra(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeExtraQuest}
}
func (h *QuestHandler) targetForBigHunt(questId int32) campaign.QuestTarget {
return campaign.QuestTarget{QuestId: questId, QuestType: campaign.QuestTypeBigHunt}
}
func (h *QuestHandler) campaignFilter(nowMillis int64) campaign.Filter {
return campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}
}
func (h *QuestHandler) staminaWithCampaign(baseStamina int32, t campaign.QuestTarget, nowMillis int64) int32 {
if h.Campaigns == nil {
return baseStamina
}
return h.Campaigns.QuestStamina(t, h.campaignFilter(nowMillis)).Apply(baseStamina)
}
func (h *QuestHandler) appendBonusDrops(drops []RewardGrant, t campaign.QuestTarget, nowMillis int64) []RewardGrant {
if h.Campaigns == nil {
return drops
}
for _, bd := range h.Campaigns.QuestBonusDrops(t, h.campaignFilter(nowMillis)) {
drops = append(drops, RewardGrant{
PossessionType: model.PossessionType(bd.PossessionType),
PossessionId: bd.PossessionId,
Count: bd.Count,
})
}
return drops
}
+22 -4
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestCh
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForEvent(eventQuestChapterId, questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -42,17 +43,22 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForEvent(eventQuestChapterId, questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
h.recordSideStoryLimitContentStatus(user, questId, nowMillis)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.EventQuest.CurrentEventQuestChapterId = 0 user.EventQuest.CurrentEventQuestChapterId = 0
user.EventQuest.CurrentQuestId = 0 user.EventQuest.CurrentQuestId = 0
user.EventQuest.CurrentQuestSceneId = 0 user.EventQuest.CurrentQuestSceneId = 0
@@ -64,6 +70,18 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
return outcome return outcome
} }
func (h *QuestHandler) recordSideStoryLimitContentStatus(user *store.UserState, questId int32, nowMillis int64) {
chapterId, ok := h.SideStoryChapterByEventQuestId[questId]
if !ok {
return
}
st := user.QuestLimitContentStatus[questId]
st.LimitContentQuestStatusType = 1
st.EventQuestChapterId = chapterId
st.LatestVersion = nowMillis
user.QuestLimitContentStatus[questId] = st
}
func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) { func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) {
h.HandleQuestRestart(user, questId, nowMillis) h.HandleQuestRestart(user, questId, nowMillis)
+9 -4
View File
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, use
if quest.Stamina > 0 { if quest.Stamina > 0 {
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForExtra(questId), nowMillis)
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -40,17 +41,21 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId))
} }
outcome := h.evaluateFinishOutcome(user, questId) target := h.targetForExtra(questId)
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
restoreClearedAfterRetire(user, questId, isRetired)
user.ExtraQuest.CurrentQuestId = 0 user.ExtraQuest.CurrentQuestId = 0
user.ExtraQuest.CurrentQuestSceneId = 0 user.ExtraQuest.CurrentQuestSceneId = 0
user.ExtraQuest.HeadQuestSceneId = 0 user.ExtraQuest.HeadQuestSceneId = 0
+50 -4
View File
@@ -1,6 +1,9 @@
package questflow package questflow
import ( import (
"sort"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -25,13 +28,25 @@ type FinishOutcome struct {
type QuestHandler struct { type QuestHandler struct {
*masterdata.QuestCatalog *masterdata.QuestCatalog
Config *masterdata.GameConfig Config *masterdata.GameConfig
Granter *store.PossessionGranter 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) 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 { func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
@@ -60,12 +75,40 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
releaseConditions[groupId] = conds releaseConditions[groupId] = conds
} }
partsById := make(map[int32]store.PartsRef, len(catalog.PartsById)) partsById := make(map[int32]store.PartsRef, len(catalog.PartsById))
partsVariants := make(map[int32]map[int32][]int32)
for id, p := range catalog.PartsById { for id, p := range catalog.PartsById {
partsById[id] = store.PartsRef{ partsById[id] = store.PartsRef{
PartsGroupId: p.PartsGroupId, PartsGroupId: p.PartsGroupId,
RarityType: p.RarityType,
PartsInitialLotteryId: p.PartsInitialLotteryId,
PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId, PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId,
PartsStatusSubLotteryGroupId: p.PartsStatusSubLotteryGroupId,
}
if partsVariants[p.PartsGroupId] == nil {
partsVariants[p.PartsGroupId] = map[int32][]int32{}
}
partsVariants[p.PartsGroupId][p.RarityType] = append(partsVariants[p.PartsGroupId][p.RarityType], p.PartsId)
}
for _, byRarity := range partsVariants {
for _, ids := range byRarity {
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
} }
} }
partsSubDefs := make(map[int32]store.PartsStatusSubDef, len(catalog.PartsStatusMainById))
for id, d := range catalog.PartsStatusMainById {
var fn func(int32) int32
if f, ok := catalog.FuncResolver.Resolve(d.StatusNumericalFunctionId); ok {
fn = f.Evaluate
}
partsSubDefs[id] = store.PartsStatusSubDef{
StatusKindType: d.StatusKindType,
StatusCalculationType: d.StatusCalculationType,
StatusChangeInitialValue: d.StatusChangeInitialValue,
StatusFunc: fn,
}
}
return &store.PossessionGranter{ return &store.PossessionGranter{
CostumeById: costumeById, CostumeById: costumeById,
WeaponById: weaponById, WeaponById: weaponById,
@@ -74,5 +117,8 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
ReleaseConditions: releaseConditions, ReleaseConditions: releaseConditions,
PartsById: partsById, PartsById: partsById,
DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup, DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup,
PartsVariantsByGroupRarity: partsVariants,
PartsSubStatusPool: catalog.SubStatusPool,
PartsSubStatusDefs: partsSubDefs,
} }
} }
+57 -29
View File
@@ -24,7 +24,14 @@ func (h *QuestHandler) initQuestState(user *store.UserState, questId int32) {
} }
func isMainQuestPlayable(quest masterdata.EntityMQuest) bool { func isMainQuestPlayable(quest masterdata.EntityMQuest) bool {
return !quest.IsRunInTheBackground && quest.IsCountedAsQuest if quest.IsRunInTheBackground {
// A background quest is still actively played — and must NOT be
// auto-cleared on start — when it carries battle content (a non-zero
// recommended deck power, e.g. quests 500/515/30515). Pure cutscene
// background quests have RecommendedDeckPower == 0.
return quest.RecommendedDeckPower > 0
}
return quest.IsCountedAsQuest
} }
func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) { func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) {
@@ -54,7 +61,8 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
h.initQuestState(user, questId) h.initQuestState(user, questId)
if quest.Stamina > 0 { if quest.Stamina > 0 {
store.ConsumeStamina(user, quest.Stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis) stamina := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
store.ConsumeStamina(user, stamina, h.MaxStaminaByLevel[user.Status.Level]*1000, nowMillis)
} }
questState := user.Quests[questId] questState := user.Quests[questId]
@@ -84,7 +92,7 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
} }
case isReplayFlow: case isReplayFlow:
h.applyReplayStart(user, questId, isBattleOnly, nowMillis) h.applyReplayStart(user, quest, questId, isBattleOnly, nowMillis)
return return
} }
@@ -131,9 +139,7 @@ func snapshotMainQuestIfNeeded(user *store.UserState) {
} }
} }
// Preserve CurrentQuestFlowType when HandleReplayFlowSceneProgress already func (h *QuestHandler) applyReplayStart(user *store.UserState, quest masterdata.EntityMQuest, questId int32, isBattleOnly bool, nowMillis int64) {
// set it: replay-variant ids (30000+) aren't in RouteIdByQuestId.
func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, isBattleOnly bool, nowMillis int64) {
flowType := h.replayFlowTypeFromQuestId(user, questId) flowType := h.replayFlowTypeFromQuestId(user, questId)
if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) { if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) {
flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType) flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType)
@@ -142,12 +148,26 @@ func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, is
user.MainQuest.LatestVersion = nowMillis user.MainQuest.LatestVersion = nowMillis
questState := user.Quests[questId] questState := user.Quests[questId]
questState.QuestStateType = model.UserQuestStateTypeActive
questState.LatestStartDatetime = nowMillis questState.LatestStartDatetime = nowMillis
user.Quests[questId] = questState
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v current=%d head=%d", if isMainQuestPlayable(quest) {
questId, flowType, isBattleOnly, questState.QuestStateType = model.UserQuestStateTypeActive
user.Quests[questId] = questState
} else {
if questState.QuestStateType != model.UserQuestStateTypeCleared {
questState.QuestStateType = model.UserQuestStateTypeCleared
questState.ClearCount++
questState.DailyClearCount++
questState.LastClearDatetime = nowMillis
}
user.Quests[questId] = questState
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
h.advanceReplayFlowScene(user, sceneIds[0])
}
}
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v playable=%v current=%d head=%d",
questId, flowType, isBattleOnly, isMainQuestPlayable(quest),
user.MainQuest.ReplayFlowCurrentQuestSceneId, user.MainQuest.ReplayFlowCurrentQuestSceneId,
user.MainQuest.ReplayFlowHeadQuestSceneId) user.MainQuest.ReplayFlowHeadQuestSceneId)
} }
@@ -166,8 +186,8 @@ func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) { func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) {
questState := user.Quests[questId] questState := user.Quests[questId]
h.applyExpAndGoldRewards(user, questId, nowMillis)
if !questState.IsRewardGranted { if !questState.IsRewardGranted {
h.applyExpAndGoldRewards(user, questId, nowMillis)
if !wasReplay { if !wasReplay {
h.applyFirstClearItemRewards(user, questId, nowMillis) h.applyFirstClearItemRewards(user, questId, nowMillis)
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds, outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
@@ -221,6 +241,17 @@ func (h *QuestHandler) finalizeChainPreviousQuest(user *store.UserState, questId
log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId) log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId)
} }
func restoreClearedAfterRetire(user *store.UserState, questId int32, isRetired bool) {
if !isRetired {
return
}
qs := user.Quests[questId]
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
qs.QuestStateType = model.UserQuestStateTypeCleared
user.Quests[questId] = qs
}
}
func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome { func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
quest, ok := h.QuestById[questId] quest, ok := h.QuestById[questId]
if !ok { if !ok {
@@ -229,36 +260,32 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
h.initQuestState(user, questId) h.initQuestState(user, questId)
outcome := h.evaluateFinishOutcome(user, questId) outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
wasMenuReplay := user.MainQuest.SavedContext.Active wasMenuReplay := user.MainQuest.SavedContext.Active
if !isRetired { if !isRetired {
h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay) h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay)
if isMainQuestPlayable(quest) && !wasMenuReplay { // A replay-flow finish must NOT move the MainFlow scene pointer: the
// finished quest is a replay-variant (30000+) with no chapter, so a
// replay scene left in CurrentQuestSceneId makes the client world map's
// CalculatorWorldMap.GetCurrentSeasonId resolve chapter 0 and NRE. The
// replay's own position is tracked in ReplayFlowCurrentQuestSceneId.
if isMainQuestPlayable(quest) && !wasMenuReplay && !wasReplay {
lastSceneId := h.getLastMainFlowSceneId(questId) lastSceneId := h.getLastMainFlowSceneId(questId)
h.advanceMainFlowScene(user, questId, lastSceneId) h.advanceMainFlowScene(user, questId, lastSceneId)
} }
} }
if isRetired && !isAnnihilated && quest.Stamina > 1 { consumed := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
refund := quest.Stamina - 1 if isRetired && !isAnnihilated && consumed > 1 {
refund := consumed - 1
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis) store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
} }
// On retire of a previously-cleared quest (cage Menu Pick replay or restoreClearedAfterRetire(user, questId, isRetired)
// Map Play replay), HandleQuestStart marked QuestStateType=Active for
// the run. With applyQuestVictory skipped on retire, that Active sticks
// and the cage UI shows the quest as locked. Restore Cleared.
if isRetired {
qs := user.Quests[questId]
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
qs.QuestStateType = model.UserQuestStateTypeCleared
user.Quests[questId] = qs
}
}
user.MainQuest.ProgressQuestSceneId = 0 user.MainQuest.ProgressQuestSceneId = 0
user.MainQuest.ProgressHeadQuestSceneId = 0 user.MainQuest.ProgressHeadQuestSceneId = 0
@@ -297,18 +324,19 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId)) panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId))
} }
target := h.targetForMain(questId)
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000 maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
store.ConsumeStamina(user, skipCount, maxMillis, nowMillis) perSkipStamina := h.staminaWithCampaign(questDef.Stamina, target, nowMillis)
store.ConsumeStamina(user, perSkipStamina*skipCount, maxMillis, nowMillis)
skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket
user.ConsumableItems[skipTicketId] -= skipCount user.ConsumableItems[skipTicketId] -= skipCount
if user.ConsumableItems[skipTicketId] < 0 { if user.ConsumableItems[skipTicketId] < 0 {
user.ConsumableItems[skipTicketId] = 0 user.ConsumableItems[skipTicketId] = 0
} }
var allDrops []RewardGrant var allDrops []RewardGrant
for range skipCount { for range skipCount {
drops := h.computeDropRewards(questDef) drops := h.computeDropRewards(questDef, target, nowMillis)
for _, drop := range drops { for _, drop := range drops {
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis) h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
} }
+18 -14
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
@@ -40,7 +41,7 @@ func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef m
return rewardGroupId return rewardGroupId
} }
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome { func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32, target campaign.QuestTarget, nowMillis int64) FinishOutcome {
outcome := FinishOutcome{} outcome := FinishOutcome{}
questState, ok := user.Quests[questId] questState, ok := user.Quests[questId]
if !ok { if !ok {
@@ -123,25 +124,28 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3
} }
} }
outcome.DropRewards = h.computeDropRewards(questDef) outcome.DropRewards = h.computeDropRewards(questDef, target, nowMillis)
return outcome return outcome
} }
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest) []RewardGrant { func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant {
if questDef.QuestPickupRewardGroupId == 0 {
return nil
}
var drops []RewardGrant var drops []RewardGrant
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] { var dropRate campaign.DropRateMul
if bdr, ok := h.BattleDropRewardById[dropId]; ok { if h.Campaigns != nil {
drops = append(drops, RewardGrant{ dropRate = h.Campaigns.QuestDropRate(target, h.campaignFilter(nowMillis))
PossessionType: model.PossessionType(bdr.PossessionType), }
PossessionId: bdr.PossessionId, if questDef.QuestPickupRewardGroupId != 0 {
Count: bdr.Count, 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) { func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) {
+38 -20
View File
@@ -46,29 +46,39 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen
user.MainQuest.CurrentMainQuestRouteId = routeId user.MainQuest.CurrentMainQuestRouteId = routeId
if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok { if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
RecordSeasonRoute(user, seasonId, routeId, gametime.NowMillis())
} }
} }
} }
// Backs IUserMainQuestSeasonRoute: the client needs the history to load func (h *QuestHandler) advanceReplayFlowScene(user *store.UserState, sceneId int32) {
// scene metadata when cage menu-replay jumps to older chapters. if !h.isSceneAhead(sceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) {
if seasonId <= 0 || routeId <= 0 {
return return
} }
if user.MainQuestSeasonRoutes == nil { user.MainQuest.ReplayFlowCurrentQuestSceneId = sceneId
user.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry) user.MainQuest.ReplayFlowHeadQuestSceneId = sceneId
}
func (h *QuestHandler) SeasonRoutesFor(user *store.UserState) map[int32]int32 {
out := make(map[int32]int32)
for seasonId, routes := range h.RoutesBySeason {
if seasonId <= 1 {
continue
}
for _, routeId := range routes {
finalQuestId, ok := h.RouteCompletionQuestId[routeId]
if !ok {
continue
}
if q, ok := user.Quests[finalQuestId]; ok && q.ClearCount > 0 {
out[seasonId] = routeId
break
}
}
} }
key := store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId} if cur := user.MainQuest.MainQuestSeasonId; cur >= 2 && user.MainQuest.CurrentMainQuestRouteId > 0 {
if _, exists := user.MainQuestSeasonRoutes[key]; exists { out[cur] = user.MainQuest.CurrentMainQuestRouteId
return
}
user.MainQuestSeasonRoutes[key] = store.SeasonRouteEntry{
MainQuestSeasonId: seasonId,
MainQuestRouteId: routeId,
LatestVersion: nowMillis,
} }
return out
} }
func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) { func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
@@ -142,6 +152,12 @@ func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, ques
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = nowMillis user.PortalCageStatus.LatestVersion = nowMillis
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: nowMillis,
}
}
flowType := h.replayFlowType(user, questSceneId) flowType := h.replayFlowType(user, questSceneId)
user.MainQuest.CurrentQuestFlowType = int32(flowType) user.MainQuest.CurrentQuestFlowType = int32(flowType)
user.MainQuest.LatestVersion = nowMillis user.MainQuest.LatestVersion = nowMillis
@@ -165,10 +181,9 @@ func (h *QuestHandler) replayFlowTypeForRoute(user *store.UserState, routeId int
if !ok { if !ok {
return model.QuestFlowTypeReplayFlow return model.QuestFlowTypeReplayFlow
} }
for key, entry := range user.MainQuestSeasonRoutes { pairs := h.SeasonRoutesFor(user)
if key.MainQuestSeasonId == seasonId && entry.MainQuestRouteId != routeId { if recorded, ok := pairs[seasonId]; ok && recorded != routeId {
return model.QuestFlowTypeAnotherRouteReplayFlow return model.QuestFlowTypeAnotherRouteReplayFlow
}
} }
return model.QuestFlowTypeReplayFlow return model.QuestFlowTypeReplayFlow
} }
@@ -215,7 +230,10 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow) user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
} }
} else { } else if !isReplay {
// Background/non-playable quest: advance the MainFlow pointer — but not
// during a replay, where the isReplay block below tracks the ReplayFlow
// scene and the MainFlow pointer must stay on real main-story progress.
user.MainQuest.CurrentQuestSceneId = questSceneId user.MainQuest.CurrentQuestSceneId = questSceneId
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) { if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
user.MainQuest.HeadQuestSceneId = questSceneId user.MainQuest.HeadQuestSceneId = questSceneId
+18 -3
View File
@@ -4,10 +4,12 @@ import (
"fmt" "fmt"
"log" "log"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb" "lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/userdata"
) )
// buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever // buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever
@@ -33,7 +35,14 @@ func buildCatalogs() (*Catalogs, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("load quest catalog: %w", err) return nil, fmt.Errorf("load quest catalog: %w", err)
} }
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig) 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() gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
if err != nil { if err != nil {
@@ -113,7 +122,7 @@ func buildCatalogs() (*Catalogs, error) {
} }
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets)) log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver) gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver, cageOrnamentCatalog)
if err != nil { if err != nil {
return nil, fmt.Errorf("load gimmick catalog: %w", err) return nil, fmt.Errorf("load gimmick catalog: %w", err)
} }
@@ -136,9 +145,12 @@ func buildCatalogs() (*Catalogs, error) {
} }
log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory)) log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory))
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
bigHuntCatalog := masterdata.LoadBigHuntCatalog() bigHuntCatalog := masterdata.LoadBigHuntCatalog()
towerCatalog := masterdata.LoadTowerCatalog()
labyrinthCatalog := masterdata.LoadLabyrinthCatalog()
return &Catalogs{ return &Catalogs{
GameConfig: gameConfig, GameConfig: gameConfig,
Parts: partsCatalog, Parts: partsCatalog,
@@ -164,6 +176,9 @@ func buildCatalogs() (*Catalogs, error) {
Companion: companionCatalog, Companion: companionCatalog,
SideStory: sideStoryCatalog, SideStory: sideStoryCatalog,
BigHunt: bigHuntCatalog, BigHunt: bigHuntCatalog,
Tower: towerCatalog,
Labyrinth: labyrinthCatalog,
Campaign: campaignCatalog,
QuestHandler: questHandler, QuestHandler: questHandler,
GachaHandler: gachaHandler, GachaHandler: gachaHandler,
}, nil }, nil
+4 -15
View File
@@ -14,6 +14,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb" "lunar-tear/server/internal/masterdata/memorydb"
@@ -50,23 +51,19 @@ type Catalogs struct {
Companion *masterdata.CompanionCatalog Companion *masterdata.CompanionCatalog
SideStory *masterdata.SideStoryCatalog SideStory *masterdata.SideStoryCatalog
BigHunt *masterdata.BigHuntCatalog BigHunt *masterdata.BigHuntCatalog
Tower *masterdata.TowerCatalog
Labyrinth *masterdata.LabyrinthCatalog
Campaign *campaign.Catalog
// Catalog-derived handlers must rebuild on every reload because they
// embed/cache pointers to specific catalog instances.
QuestHandler *questflow.QuestHandler QuestHandler *questflow.QuestHandler
GachaHandler *gacha.GachaHandler GachaHandler *gacha.GachaHandler
} }
// Holder owns the current *Catalogs and the bin.e path. Concurrent readers
// call Get(); the single-writer Reload() rebuilds and atomically publishes.
type Holder struct { type Holder struct {
binPath string binPath string
cur atomic.Pointer[Catalogs] cur atomic.Pointer[Catalogs]
} }
// NewHolder reads the binary at binPath, builds the initial catalogs, and
// returns a ready-to-use Holder. Subsequent calls to Reload() re-read the
// same path.
func NewHolder(binPath string) (*Holder, error) { func NewHolder(binPath string) (*Holder, error) {
h := &Holder{binPath: binPath} h := &Holder{binPath: binPath}
if err := h.Reload(); err != nil { if err := h.Reload(); err != nil {
@@ -75,9 +72,6 @@ func NewHolder(binPath string) (*Holder, error) {
return h, nil return h, nil
} }
// Reload re-reads the bin.e from disk, rebuilds every catalog and handler,
// atomically publishes the new snapshot, and bumps the bin.e mtime so client
// caches invalidate (see service/data.go GetLatestMasterDataVersion).
func (h *Holder) Reload() error { func (h *Holder) Reload() error {
if err := memorydb.Init(h.binPath); err != nil { if err := memorydb.Init(h.binPath); err != nil {
return fmt.Errorf("memorydb.Init: %w", err) return fmt.Errorf("memorydb.Init: %w", err)
@@ -89,16 +83,11 @@ func (h *Holder) Reload() error {
h.cur.Store(c) h.cur.Store(c)
now := time.Now() now := time.Now()
if err := os.Chtimes(h.binPath, now, now); err != nil { if err := os.Chtimes(h.binPath, now, now); err != nil {
// Non-fatal: the catalogs swapped fine in-memory; clients may take
// longer to invalidate their cached download but server-side state is
// already coherent.
log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err) log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err)
} }
return nil return nil
} }
// Get returns the current snapshot. Safe for concurrent callers; the returned
// pointer is stable for the duration of the caller's use.
func (h *Holder) Get() *Catalogs { func (h *Holder) Get() *Catalogs {
return h.cur.Load() return h.cur.Load()
} }
+13 -5
View File
@@ -27,10 +27,6 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
cat := s.holder.Get() cat := s.holder.Get()
reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId) reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId)
if !ok {
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
}
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
@@ -40,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
AcquisitionDatetime: nowMillis, AcquisitionDatetime: nowMillis,
LatestVersion: nowMillis, LatestVersion: nowMillis,
} }
granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis) if ok {
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
}
}) })
if !ok {
// "Fickle Black Birds" (type-1 gimmicks) tap into this RPC with CageOrnamentIds
// not present in m_cage_ornament_reward (their GimmickOrnamentViewIds are 101/103,
// not the 1002xxx-style ids the table uses). Record the access and return an empty
// reward so the client doesn't hang and the server doesn't crash.
log.Printf("[CageOrnamentService] ReceiveReward: no reward mapping for cageOrnamentId=%d, returning empty",
req.CageOrnamentId)
return &pb.ReceiveRewardResponse{}, nil
}
return &pb.ReceiveRewardResponse{ return &pb.ReceiveRewardResponse{
CageOrnamentReward: []*pb.CageOrnamentReward{ CageOrnamentReward: []*pb.CageOrnamentReward{
{ {
+45
View File
@@ -6,6 +6,8 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -21,6 +23,49 @@ func NewConsumableItemServiceServer(users store.UserRepository, sessions store.S
return &ConsumableItemServiceServer{users: users, sessions: sessions, holder: holder} return &ConsumableItemServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *ConsumableItemServiceServer) UseEffectItem(ctx context.Context, req *pb.ConsumableItemUseEffectItemRequest) (*pb.ConsumableItemUseEffectItemResponse, error) {
log.Printf("[ConsumableItemService] UseEffectItem: consumableItemId=%d count=%d", req.ConsumableItemId, req.Count)
cat := s.holder.Get()
catalog := cat.ConsumableItem
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
if _, ok := catalog.All[req.ConsumableItemId]; !ok {
log.Printf("[ConsumableItemService] UseEffectItem: unknown consumableItemId=%d", req.ConsumableItemId)
return
}
cur := user.ConsumableItems[req.ConsumableItemId]
if cur < req.Count {
log.Printf("[ConsumableItemService] UseEffectItem: insufficient consumableItemId=%d have=%d need=%d", req.ConsumableItemId, cur, req.Count)
return
}
user.ConsumableItems[req.ConsumableItemId] -= req.Count
if user.ConsumableItems[req.ConsumableItemId] <= 0 {
delete(user.ConsumableItems, req.ConsumableItemId)
}
maxStaminaMillis := cat.Shop.MaxStaminaMillis[user.Status.Level]
for _, effect := range catalog.Effects[req.ConsumableItemId] {
switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery:
millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxStaminaMillis)
store.RecoverStamina(user, millis*req.Count, maxStaminaMillis, nowMillis)
default:
log.Printf("[ConsumableItemService] UseEffectItem: unhandled effect targetType=%d valueType=%d value=%d itemId=%d",
effect.EffectTargetType, effect.EffectValueType, effect.EffectValue, req.ConsumableItemId)
}
}
})
if err != nil {
return nil, fmt.Errorf("consumable item use effect item: %w", err)
}
return &pb.ConsumableItemUseEffectItemResponse{}, nil
}
func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.ConsumableItemSellRequest) (*pb.ConsumableItemSellResponse, error) { func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.ConsumableItemSellRequest) (*pb.ConsumableItemSellResponse, error) {
log.Printf("[ConsumableItemService] Sell: %d item(s)", len(req.ConsumableItemPossession)) log.Printf("[ConsumableItemService] Sell: %d item(s)", len(req.ConsumableItemPossession))
+8 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
@@ -50,6 +51,12 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
return return
} }
expBonus := cat.Campaign.CostumeExpBonus(campaign.CostumeTarget{
CostumeId: costume.CostumeId,
CharacterId: cm.CharacterId,
SkillfulWeaponType: cm.SkillfulWeaponType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
@@ -71,7 +78,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType { if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expBonus.Apply(expPerUnit * count)
} }
if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
+135 -7
View File
@@ -6,6 +6,8 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -43,6 +45,10 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d", log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType) req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
cat := s.holder.Get()
var ornamentRewards []*pb.GimmickReward
var sequenceCleared bool
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
progressKey := store.GimmickKey{ progressKey := store.GimmickKey{
@@ -53,7 +59,6 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
progress := user.Gimmick.Progress[progressKey] progress := user.Gimmick.Progress[progressKey]
progress.Key = progressKey progress.Key = progressKey
progress.StartDatetime = nowMillis progress.StartDatetime = nowMillis
user.Gimmick.Progress[progressKey] = progress
ornamentKey := store.GimmickOrnamentKey{ ornamentKey := store.GimmickOrnamentKey{
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId, GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
@@ -66,28 +71,151 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
ornament.ProgressValueBit = req.ProgressValueBit ornament.ProgressValueBit = req.ProgressValueBit
ornament.BaseDatetime = nowMillis ornament.BaseDatetime = nowMillis
user.Gimmick.OrnamentProgress[ornamentKey] = ornament user.Gimmick.OrnamentProgress[ornamentKey] = ornament
// Per-type branches:
// * Report (type 9, "Hidden Stories") — mark gimmick + sequence
// cleared, grant SequenceRewards (ImportantItem type 3, library reads it).
// * MapOnlyCageTreasureHunt (type 7, "Hidden Black Birds") — same as Report
// but the per-tap reward also comes back from m_cage_ornament_reward via
// GimmickOrnamentViewId.
// * CageMemory (type 10, "Lost Archives") — resolve an ImportantItem
// (type 4) from the gimmick's monitor texture and grant it. IsGimmickCleared
// stays false (matches original userdata; only ornament progress flips).
// * CageTreasureHunt / CageIntervalDropItem* — stub per-tap material so
// the client's reward popup fires; real reward source still unmapped.
switch cat.Gimmick.GimmickType(req.GimmickId) {
case model.GimmickTypeReport:
progress.IsGimmickCleared = true
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
case model.GimmickTypeMapOnlyCageTreasureHunt:
r, ok := cat.Gimmick.HiddenBirdReward(req.GimmickId, req.GimmickOrnamentIndex)
if !ok {
log.Printf("[GimmickService] UpdateGimmickProgress: hidden-bird %d ornament %d has no reward mapping, skipping",
req.GimmickId, req.GimmickOrnamentIndex)
break
}
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
progress.IsGimmickCleared = true
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
case model.GimmickTypeCageMemory:
itemId, ok := cat.Gimmick.CageMemoryImportantItem(req.GimmickId)
if !ok {
log.Printf("[GimmickService] UpdateGimmickProgress: cage memory %d has no important-item mapping, skipping grant",
req.GimmickId)
break
}
if _, owned := user.ImportantItems[itemId]; owned {
break
}
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeImportantItem, itemId, 1, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: int32(model.PossessionTypeImportantItem),
PossessionId: itemId,
Count: 1,
})
case model.GimmickTypeCageTreasureHunt,
model.GimmickTypeCageIntervalDropItem,
model.GimmickTypeMapOnlyCageIntervalDrop:
// Per-tap drops with no per-gimmick reward in master data:
// * type 1 — "Fickle Black Birds" in the cage
// * type 2 — "Lost Items" in the cage
// * type 8 — Lost Items (map variant)
// Stub: grant 1 of Material 100004 (the most-common reward across
// m_cage_ornament_reward — 15 occurrences — likely a low-tier shard) per
// tap so the client's reward-popup path fires and the player accumulates
// something. Replace once a real per-gimmick mapping surfaces.
const stubMaterialId = int32(100004)
const stubMaterialCount = int32(1)
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeMaterial, stubMaterialId, stubMaterialCount, nowMillis)
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
PossessionType: int32(model.PossessionTypeMaterial),
PossessionId: stubMaterialId,
Count: stubMaterialCount,
})
}
user.Gimmick.Progress[progressKey] = progress
}) })
var clearReward []*pb.GimmickReward
if sequenceCleared {
for _, r := range cat.Gimmick.SequenceRewards(req.GimmickSequenceId) {
clearReward = append(clearReward, &pb.GimmickReward{
PossessionType: r.PossessionType,
PossessionId: r.PossessionId,
Count: r.Count,
})
}
}
return &pb.UpdateGimmickProgressResponse{ return &pb.UpdateGimmickProgressResponse{
GimmickOrnamentReward: []*pb.GimmickReward{}, GimmickOrnamentReward: ornamentRewards,
IsSequenceCleared: false, IsSequenceCleared: sequenceCleared,
GimmickSequenceClearReward: []*pb.GimmickReward{}, GimmickSequenceClearReward: clearReward,
}, nil }, nil
} }
func markSequenceClearedOnce(user *store.UserState, cat *runtime.Catalogs, scheduleId, sequenceId int32, nowMillis int64) bool {
seqKey := store.GimmickSequenceKey{
GimmickSequenceScheduleId: scheduleId,
GimmickSequenceId: sequenceId,
}
sequence := user.Gimmick.Sequences[seqKey]
sequence.Key = seqKey
defer func() { user.Gimmick.Sequences[seqKey] = sequence }()
if sequence.IsGimmickSequenceCleared {
return false
}
sequence.IsGimmickSequenceCleared = true
sequence.ClearDatetime = nowMillis
for _, r := range cat.Gimmick.SequenceRewards(sequenceId) {
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
}
return true
}
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) { func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
log.Printf("[GimmickService] InitSequenceSchedule") log.Printf("[GimmickService] InitSequenceSchedule")
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
now := gametime.NowMillis() now := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
eligible := s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now)
eligibleSet := make(map[store.GimmickSequenceKey]struct{}, len(eligible))
for _, key := range eligible {
eligibleSet[key] = struct{}{}
}
pruned := 0
for key, entry := range user.Gimmick.Sequences {
if _, ok := eligibleSet[key]; ok {
continue
}
if entry.IsGimmickSequenceCleared {
continue
}
delete(user.Gimmick.Sequences, key)
pruned++
}
added := 0 added := 0
for _, key := range s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now) { for _, key := range eligible {
if len(user.Gimmick.Sequences) >= masterdata.MaxUserGimmickRows {
break
}
if _, exists := user.Gimmick.Sequences[key]; !exists { if _, exists := user.Gimmick.Sequences[key]; !exists {
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key} user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
added++ added++
} }
} }
if added > 0 { if pruned > 0 || added > 0 {
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences)) log.Printf("[GimmickService] InitSequenceSchedule: pruned %d stale, added %d sequences (total %d, eligible %d, cap %d)",
pruned, added, len(user.Gimmick.Sequences), len(eligible), masterdata.MaxUserGimmickRows)
} }
}) })
return &pb.InitSequenceScheduleResponse{}, nil return &pb.InitSequenceScheduleResponse{}, nil
+138
View File
@@ -0,0 +1,138 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store"
)
type LabyrinthServiceServer struct {
pb.UnimplementedLabyrinthServiceServer
users store.UserRepository
sessions store.SessionRepository
holder *runtime.Holder
}
func NewLabyrinthServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LabyrinthServiceServer {
if holder == nil {
panic("runtime holder is required")
}
return &LabyrinthServiceServer{users: users, sessions: sessions, holder: holder}
}
func (s *LabyrinthServiceServer) ReceiveStageAccumulationReward(ctx context.Context, req *pb.ReceiveStageAccumulationRewardRequest) (*pb.ReceiveStageAccumulationRewardResponse, error) {
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d questMissionClearCount=%d",
req.EventQuestChapterId, req.StageOrder, req.QuestMissionClearCount)
cat := s.holder.Get()
laby := cat.Labyrinth
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
key := store.LabyrinthStageKey{
EventQuestChapterId: req.EventQuestChapterId,
StageOrder: req.StageOrder,
}
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.LabyrinthStages[key]
old := rec.AccumulationRewardReceivedQuestMissionCount
items, highest := laby.CollectAccumulationRewards(req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
if highest <= old {
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: nothing to grant for chapter=%d stage=%d (claimed=%d, target=%d)",
req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
return
}
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.StageOrder = req.StageOrder
rec.AccumulationRewardReceivedQuestMissionCount = highest
rec.LatestVersion = nowMillis
user.LabyrinthStages[key] = rec
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d granted %d item(s), claimed %d -> %d",
req.EventQuestChapterId, req.StageOrder, len(items), old, highest)
})
return &pb.ReceiveStageAccumulationRewardResponse{}, nil
}
func (s *LabyrinthServiceServer) ReceiveStageClearReward(ctx context.Context, req *pb.ReceiveStageClearRewardRequest) (*pb.ReceiveStageClearRewardResponse, error) {
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d",
req.EventQuestChapterId, req.StageOrder)
cat := s.holder.Get()
laby := cat.Labyrinth
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
key := store.LabyrinthStageKey{
EventQuestChapterId: req.EventQuestChapterId,
StageOrder: req.StageOrder,
}
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.LabyrinthStages[key]
if rec.IsReceivedStageClearReward {
log.Printf("[LabyrinthService] ReceiveStageClearReward: already claimed chapter=%d stage=%d",
req.EventQuestChapterId, req.StageOrder)
return
}
items := laby.StageClearReward(req.EventQuestChapterId, req.StageOrder)
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.StageOrder = req.StageOrder
rec.IsReceivedStageClearReward = true
rec.LatestVersion = nowMillis
user.LabyrinthStages[key] = rec
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d granted %d item(s)",
req.EventQuestChapterId, req.StageOrder, len(items))
})
return &pb.ReceiveStageClearRewardResponse{}, nil
}
func (s *LabyrinthServiceServer) UpdateSeasonData(ctx context.Context, req *pb.UpdateSeasonDataRequest) (*pb.UpdateSeasonDataResponse, error) {
laby := s.holder.Get().Labyrinth
var seasonResult []*pb.LabyrinthSeasonResult
for _, m := range laby.SeasonMilestones(req.EventQuestChapterId) {
rewards := make([]*pb.LabyrinthReward, 0, len(m.Rewards))
for _, it := range m.Rewards {
rewards = append(rewards, &pb.LabyrinthReward{
PossessionType: it.PossessionType,
PossessionId: it.PossessionId,
Count: it.Count,
})
}
seasonResult = append(seasonResult, &pb.LabyrinthSeasonResult{
EventQuestChapterId: req.EventQuestChapterId,
HeadQuestId: m.HeadQuestId,
SeasonReward: rewards,
HeadStageOrder: m.HeadStageOrder,
})
}
log.Printf("[LabyrinthService] UpdateSeasonData: chapter=%d -> %d milestone(s)",
req.EventQuestChapterId, len(seasonResult))
return &pb.UpdateSeasonDataResponse{SeasonResult: seasonResult}, nil
}
+31
View File
@@ -44,6 +44,30 @@ const informationPage = `<!DOCTYPE html>
</body> </body>
</html>` </html>`
const panelMissionPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel Missions</title>
<style>
body { margin:0; padding:48px 20px; font-family:"Noto Sans",sans-serif;
background:#0a0a0f; color:#d4cfc6; text-align:center; }
h1 { font-size:1.3em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:6px; }
.sub { font-size:.75em; color:#888; margin-bottom:28px; }
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
p { font-size:.85em; line-height:1.6; color:#999; max-width:340px; margin:0 auto; }
</style>
</head>
<body>
<h1>PANEL MISSIONS</h1>
<div class="sub">Card Stories</div>
<hr class="sep">
<p>All panel missions are cleared.</p>
<p>Their Card Stories are available in Library &rsaquo; Extra Stories.</p>
</body>
</html>`
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting. // resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com" const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
@@ -456,6 +480,13 @@ func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, pa
return return
} }
if strings.Contains(path, "panelmission") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte(panelMissionPage))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`)) w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
+155 -4
View File
@@ -7,8 +7,10 @@ import (
"math/rand" "math/rand"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -26,6 +28,50 @@ func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRep
return &PartsServiceServer{users: users, sessions: sessions, holder: holder} return &PartsServiceServer{users: users, sessions: sessions, holder: holder}
} }
func (s *PartsServiceServer) Protect(ctx context.Context, req *pb.PartsProtectRequest) (*pb.PartsProtectResponse, error) {
log.Printf("[PartsService] Protect: uuids=%v", req.UserPartsUuid)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Protect: part uuid=%s not found", uuid)
continue
}
part.IsProtected = true
part.LatestVersion = nowMillis
user.Parts[uuid] = part
}
})
return &pb.PartsProtectResponse{}, nil
}
func (s *PartsServiceServer) Unprotect(ctx context.Context, req *pb.PartsUnprotectRequest) (*pb.PartsUnprotectResponse, error) {
log.Printf("[PartsService] Unprotect: uuids=%v", req.UserPartsUuid)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
for _, uuid := range req.UserPartsUuid {
part, ok := user.Parts[uuid]
if !ok {
log.Printf("[PartsService] Unprotect: part uuid=%s not found", uuid)
continue
}
part.IsProtected = false
part.LatestVersion = nowMillis
user.Parts[uuid] = part
}
})
return &pb.PartsUnprotectResponse{}, nil
}
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) { func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid)) log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
@@ -136,17 +182,23 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
successRate = r successRate = r
} }
} }
baseRate := successRate
successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{
PartsId: part.PartsId,
PartsGroupId: partDef.PartsGroupId,
Rarity: model.RarityType(partDef.RarityType),
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate)
if rand.Intn(1000) < int(successRate) { if rand.Intn(1000) < int(successRate) {
part.Level++ part.Level++
isSuccess = true isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost) part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost)
grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis) grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis)
} else { } else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost) part.PartsId, part.Level, successRate, baseRate, goldCost)
} }
part.LatestVersion = nowMillis part.LatestVersion = nowMillis
@@ -226,3 +278,102 @@ func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsRep
return &pb.PartsReplacePresetResponse{}, nil return &pb.PartsReplacePresetResponse{}, nil
} }
func (s *PartsServiceServer) UpdatePresetName(ctx context.Context, req *pb.PartsUpdatePresetNameRequest) (*pb.PartsUpdatePresetNameResponse, error) {
log.Printf("[PartsService] UpdatePresetName: preset=%d name=%q", req.UserPartsPresetNumber, req.Name)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.Name = req.Name
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts update preset name: %w", err)
}
return &pb.PartsUpdatePresetNameResponse{}, nil
}
func (s *PartsServiceServer) UpdatePresetTagNumber(ctx context.Context, req *pb.PartsUpdatePresetTagNumberRequest) (*pb.PartsUpdatePresetTagNumberResponse, error) {
log.Printf("[PartsService] UpdatePresetTagNumber: preset=%d tag=%d", req.UserPartsPresetNumber, req.UserPartsPresetTagNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
preset := user.PartsPresets[req.UserPartsPresetNumber]
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
preset.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
preset.LatestVersion = nowMillis
user.PartsPresets[req.UserPartsPresetNumber] = preset
})
if err != nil {
return nil, fmt.Errorf("parts update preset tag number: %w", err)
}
return &pb.PartsUpdatePresetTagNumberResponse{}, nil
}
func (s *PartsServiceServer) UpdatePresetTagName(ctx context.Context, req *pb.PartsUpdatePresetTagNameRequest) (*pb.PartsUpdatePresetTagNameResponse, error) {
log.Printf("[PartsService] UpdatePresetTagName: tag=%d name=%q", req.UserPartsPresetTagNumber, req.Name)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
tag := user.PartsPresetTags[req.UserPartsPresetTagNumber]
tag.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
tag.Name = req.Name
tag.LatestVersion = nowMillis
user.PartsPresetTags[req.UserPartsPresetTagNumber] = tag
})
if err != nil {
return nil, fmt.Errorf("parts update preset tag name: %w", err)
}
return &pb.PartsUpdatePresetTagNameResponse{}, nil
}
func (s *PartsServiceServer) CopyPreset(ctx context.Context, req *pb.PartsCopyPresetRequest) (*pb.PartsCopyPresetResponse, error) {
log.Printf("[PartsService] CopyPreset: from=%d to=%d", req.FromUserPartsPresetNumber, req.ToUserPartsPresetNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
from, ok := user.PartsPresets[req.FromUserPartsPresetNumber]
if !ok {
log.Printf("[PartsService] CopyPreset: source preset=%d not found, skipping", req.FromUserPartsPresetNumber)
return
}
to := from
to.UserPartsPresetNumber = req.ToUserPartsPresetNumber
to.LatestVersion = nowMillis
user.PartsPresets[req.ToUserPartsPresetNumber] = to
})
if err != nil {
return nil, fmt.Errorf("parts copy preset: %w", err)
}
return &pb.PartsCopyPresetResponse{}, nil
}
func (s *PartsServiceServer) RemovePreset(ctx context.Context, req *pb.PartsRemovePresetRequest) (*pb.PartsRemovePresetResponse, error) {
log.Printf("[PartsService] RemovePreset: preset=%d", req.UserPartsPresetNumber)
userId := CurrentUserId(ctx, s.users, s.sessions)
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
delete(user.PartsPresets, req.UserPartsPresetNumber)
})
if err != nil {
return nil, fmt.Errorf("parts remove preset: %w", err)
}
return &pb.PartsRemovePresetResponse{}, nil
}
+6
View File
@@ -34,6 +34,12 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow) user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
user.MainQuest.LatestVersion = now user.MainQuest.LatestVersion = now
} }
// Returning to Mama's Room also ends any active side story.
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: now,
}
}
}) })
return &pb.UpdatePortalCageSceneProgressResponse{}, nil return &pb.UpdatePortalCageSceneProgressResponse{}, nil
} }
+5 -1
View File
@@ -198,11 +198,15 @@ func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteReque
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis())
} }
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
user.PortalCageStatus.LatestVersion = now user.PortalCageStatus.LatestVersion = now
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
LatestVersion: now,
}
}
}) })
return &pb.SetRouteResponse{}, nil return &pb.SetRouteResponse{}, nil
+78 -25
View File
@@ -6,6 +6,7 @@ import (
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model" "lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
@@ -22,34 +23,92 @@ func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.S
return &SideStoryQuestServiceServer{users: users, sessions: sessions, holder: holder} return &SideStoryQuestServiceServer{users: users, sessions: sessions, holder: holder}
} }
func sideStoryClearedCount(info *masterdata.SideStoryQuestInfo, user *store.UserState) int {
cleared := 0
for _, questId := range info.Quests {
if user.QuestLimitContentStatus[questId].LimitContentQuestStatusType == 1 {
cleared++
}
}
return cleared
}
func sideStoryQuestCleared(info *masterdata.SideStoryQuestInfo, user *store.UserState) bool {
return info != nil && len(info.Quests) > 0 && sideStoryClearedCount(info, user) == len(info.Quests)
}
func sideStoryNextSceneAfterBattle(info *masterdata.SideStoryQuestInfo, user *store.UserState) (int32, bool) {
cleared := sideStoryClearedCount(info, user)
if cleared == 0 {
return 0, false
}
total := len(info.Quests)
var sceneType model.SideStorySceneIdType
switch {
case cleared >= total:
sceneType = model.SideStorySceneOutroduction
case cleared == total-1:
sceneType = model.SideStorySceneUnlockLastQuest
default:
sceneType = model.SideStoryScenePlayLastQuest
}
return info.SceneIdByType(sceneType)
}
func applySideStoryProgressState(progress *store.SideStoryQuestProgress, info *masterdata.SideStoryQuestInfo, user *store.UserState) {
if sideStoryQuestCleared(info, user) {
progress.SideStoryQuestStateType = model.SideStoryQuestStateCleared
} else if progress.SideStoryQuestStateType == model.SideStoryQuestStateUnknown {
progress.SideStoryQuestStateType = model.SideStoryQuestStateActive
}
}
func setSideStoryActive(user *store.UserState, questId, sceneId int32, nowMillis int64) {
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
CurrentSideStoryQuestId: questId,
CurrentSideStoryQuestSceneId: sceneId,
LatestVersion: nowMillis,
}
}
func setSideStoryScene(user *store.UserState, info *masterdata.SideStoryQuestInfo, questId, sceneId int32, nowMillis int64) {
progress := user.SideStoryQuests[questId]
progress.HeadSideStoryQuestSceneId = sceneId
applySideStoryProgressState(&progress, info, user)
progress.LatestVersion = nowMillis
user.SideStoryQuests[questId] = progress
setSideStoryActive(user, questId, sceneId, nowMillis)
}
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) { func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId) log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId)
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
firstSceneId := s.holder.Get().SideStory.FirstSceneByQuestId[req.SideStoryQuestId] info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
s.users.UpdateUser(userId, func(user *store.UserState) { s.users.UpdateUser(userId, func(user *store.UserState) {
if info == nil || len(info.Quests) == 0 {
log.Printf("[SideStoryQuestService] unknown sideStoryQuestId=%d, skipping", req.SideStoryQuestId)
return
}
existing, exists := user.SideStoryQuests[req.SideStoryQuestId] existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
var sceneId int32 var scene int32
if exists && existing.HeadSideStoryQuestSceneId > 0 { var ok bool
sceneId = existing.HeadSideStoryQuestSceneId if !exists || existing.HeadSideStoryQuestSceneId == 0 {
scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction)
} else { } else {
sceneId = firstSceneId scene, ok = sideStoryNextSceneAfterBattle(info, user)
} if !ok {
scene, ok = existing.HeadSideStoryQuestSceneId, true
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
if !exists {
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
HeadSideStoryQuestSceneId: firstSceneId,
SideStoryQuestStateType: model.SideStoryQuestStateActive,
LatestVersion: nowMillis,
} }
} }
if !ok {
return
}
setSideStoryScene(user, info, req.SideStoryQuestId, scene, nowMillis)
}) })
return &pb.MoveSideStoryQuestResponse{}, nil return &pb.MoveSideStoryQuestResponse{}, nil
@@ -61,16 +120,10 @@ func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx cont
userId := CurrentUserId(ctx, s.users, s.sessions) userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis() nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) { info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
user.SideStoryActiveProgress.LatestVersion = nowMillis
progress := user.SideStoryQuests[req.SideStoryQuestId] s.users.UpdateUser(userId, func(user *store.UserState) {
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId { setSideStoryScene(user, info, req.SideStoryQuestId, req.SideStoryQuestSceneId, nowMillis)
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
}
progress.LatestVersion = nowMillis
user.SideStoryQuests[req.SideStoryQuestId] = progress
}) })
return &pb.UpdateSideStoryQuestSceneProgressResponse{}, nil return &pb.UpdateSideStoryQuestSceneProgressResponse{}, nil
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"log"
pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store"
)
func (s *QuestServiceServer) ReceiveTowerAccumulationReward(ctx context.Context, req *pb.ReceiveTowerAccumulationRewardRequest) (*pb.ReceiveTowerAccumulationRewardResponse, error) {
log.Printf("[QuestService] ReceiveTowerAccumulationReward: eventQuestChapterId=%d targetMissionClearCount=%d",
req.EventQuestChapterId, req.TargetMissionClearCount)
cat := s.holder.Get()
tower := cat.Tower
granter := cat.QuestHandler.Granter
userId := CurrentUserId(ctx, s.users, s.sessions)
nowMillis := gametime.NowMillis()
s.users.UpdateUser(userId, func(user *store.UserState) {
rec := user.TowerAccumulationRewards[req.EventQuestChapterId]
old := rec.LatestRewardReceiveQuestMissionClearCount
items, highest := tower.CollectRewards(req.EventQuestChapterId, old, req.TargetMissionClearCount)
if highest <= old {
log.Printf("[QuestService] ReceiveTowerAccumulationReward: nothing to grant for chapter=%d (claimed=%d, target=%d)",
req.EventQuestChapterId, old, req.TargetMissionClearCount)
return
}
for _, it := range items {
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
}
rec.EventQuestChapterId = req.EventQuestChapterId
rec.LatestRewardReceiveQuestMissionClearCount = highest
rec.LatestVersion = nowMillis
user.TowerAccumulationRewards[req.EventQuestChapterId] = rec
log.Printf("[QuestService] ReceiveTowerAccumulationReward: chapter=%d granted %d item(s), claimed %d -> %d",
req.EventQuestChapterId, len(items), old, highest)
})
return &pb.ReceiveTowerAccumulationRewardResponse{}, nil
}
+1 -13
View File
@@ -194,22 +194,10 @@ func applyShopContentEffects(catalog *masterdata.ShopCatalog, user *store.UserSt
switch effect.EffectTargetType { switch effect.EffectTargetType {
case model.EffectTargetStaminaRecovery: case model.EffectTargetStaminaRecovery:
maxMillis := catalog.MaxStaminaMillis[user.Status.Level] maxMillis := catalog.MaxStaminaMillis[user.Status.Level]
millis := resolveShopEffectMillis(catalog, effect.EffectValueType, effect.EffectValue, user.Status.Level) millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxMillis)
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis) store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
default: default:
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType) log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
} }
} }
} }
func resolveShopEffectMillis(catalog *masterdata.ShopCatalog, effectValueType, effectValue, userLevel int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue
case model.EffectValuePermil:
maxMillis := catalog.MaxStaminaMillis[userLevel]
return effectValue * maxMillis / 1000
default:
return 0
}
}
+15 -2
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/gameutil" "lunar-tear/server/internal/gameutil"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
@@ -91,6 +92,12 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
return return
} }
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
totalMaterialCount := int32(0) totalMaterialCount := int32(0)
for materialId, count := range req.Materials { for materialId, count := range req.Materials {
@@ -112,7 +119,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType { if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000 expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += expPerUnit * count totalExp += expBonus.Apply(expPerUnit * count)
} }
if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 { if costFunc, ok := catalog.GoldCostByEnhanceId[wm.WeaponSpecificEnhanceId]; ok && totalMaterialCount > 0 {
@@ -702,6 +709,12 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
return return
} }
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
WeaponId: weapon.WeaponId,
WeaponType: wm.WeaponType,
AttributeType: wm.AttributeType,
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
totalExp := int32(0) totalExp := int32(0)
consumedCount := int32(0) consumedCount := int32(0)
for _, uuid := range req.MaterialUserWeaponUuids { for _, uuid := range req.MaterialUserWeaponUuids {
@@ -722,7 +735,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType { if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000 baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000
} }
totalExp += baseExp totalExp += expBonus.Apply(baseExp)
if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok { if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
for itemId, count := range medals { for itemId, count := range medals {
+4
View File
@@ -26,11 +26,15 @@ func CloneUserState(u UserState) UserState {
Unlocks: maps.Clone(u.Gimmick.Unlocks), Unlocks: maps.Clone(u.Gimmick.Unlocks),
} }
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards) out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards)
out.LabyrinthSeasons = maps.Clone(u.LabyrinthSeasons)
out.LabyrinthStages = maps.Clone(u.LabyrinthStages)
out.ConsumableItems = maps.Clone(u.ConsumableItems) out.ConsumableItems = maps.Clone(u.ConsumableItems)
out.Materials = maps.Clone(u.Materials) out.Materials = maps.Clone(u.Materials)
out.Parts = maps.Clone(u.Parts) out.Parts = maps.Clone(u.Parts)
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes) out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
out.PartsPresets = maps.Clone(u.PartsPresets) out.PartsPresets = maps.Clone(u.PartsPresets)
out.PartsPresetTags = maps.Clone(u.PartsPresetTags)
out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs) out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs)
out.ImportantItems = maps.Clone(u.ImportantItems) out.ImportantItems = maps.Clone(u.ImportantItems)
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills) out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
+74 -11
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"fmt" "fmt"
"log" "log"
"math/rand"
"sort" "sort"
"github.com/google/uuid" "github.com/google/uuid"
@@ -102,7 +103,19 @@ type WeaponStoryReleaseCond struct {
type PartsRef struct { type PartsRef struct {
PartsGroupId int32 PartsGroupId int32
RarityType int32
PartsInitialLotteryId int32
PartsStatusMainLotteryGroupId int32 PartsStatusMainLotteryGroupId int32
PartsStatusSubLotteryGroupId int32
}
// PartsStatusSubDef carries the per-lottery-id sub-status shape needed at
// grant time. Held here so the store package does not import masterdata.
type PartsStatusSubDef struct {
StatusKindType int32
StatusCalculationType int32
StatusChangeInitialValue int32
StatusFunc func(level int32) int32
} }
type PossessionGranter struct { type PossessionGranter struct {
@@ -114,6 +127,9 @@ type PossessionGranter struct {
PartsById map[int32]PartsRef PartsById map[int32]PartsRef
DefaultPartsStatusMainByLotteryGroup map[int32]int32 DefaultPartsStatusMainByLotteryGroup map[int32]int32
PartsVariantsByGroupRarity map[int32]map[int32][]int32
PartsSubStatusPool map[int32][]int32
PartsSubStatusDefs map[int32]PartsStatusSubDef
LastChangedStoryWeaponIds []int32 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) { func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) {
var mainStatId int32 ref, refOk := g.PartsById[requestedPartsId]
if ref, ok := g.PartsById[partsId]; ok { if !refOk {
mainStatId = g.DefaultPartsStatusMainByLotteryGroup[ref.PartsStatusMainLotteryGroupId] key := uuid.New().String()
if _, exists := user.PartsGroupNotes[ref.PartsGroupId]; !exists { user.Parts[key] = PartsState{
user.PartsGroupNotes[ref.PartsGroupId] = PartsGroupNoteState{ UserPartsUuid: key,
PartsGroupId: ref.PartsGroupId, PartsId: requestedPartsId,
FirstAcquisitionDatetime: nowMillis, Level: 1,
LatestVersion: nowMillis, 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() key := uuid.New().String()
user.Parts[key] = PartsState{ user.Parts[key] = PartsState{
UserPartsUuid: key, UserPartsUuid: key,
PartsId: partsId, PartsId: chosenPartsId,
Level: 1, Level: 1,
PartsStatusMainId: mainStatId, PartsStatusMainId: mainStatId,
AcquisitionDatetime: nowMillis, AcquisitionDatetime: nowMillis,
} }
initialCount := chosenRef.PartsInitialLotteryId
pool := g.PartsSubStatusPool[chosenRef.PartsStatusSubLotteryGroupId]
if initialCount > 1 && len(pool) > 0 {
for i := int32(0); i < initialCount-1; i++ {
pickId := pool[rand.Intn(len(pool))]
def, ok := g.PartsSubStatusDefs[pickId]
if !ok {
continue
}
val := def.StatusChangeInitialValue
if def.StatusFunc != nil {
val = def.StatusFunc(1)
}
user.PartsStatusSubs[PartsStatusSubKey{UserPartsUuid: key, StatusIndex: i + 1}] = PartsStatusSubState{
UserPartsUuid: key,
StatusIndex: i + 1,
PartsStatusSubLotteryId: pickId,
Level: 1,
StatusKindType: def.StatusKindType,
StatusCalculationType: def.StatusCalculationType,
StatusChangeValue: val,
LatestVersion: nowMillis,
}
}
}
log.Printf("[GrantParts] 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) { func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
+28 -25
View File
@@ -8,7 +8,6 @@ const (
starterMissionId = int32(1) starterMissionId = int32(1)
starterMainQuestRouteId = int32(1) starterMainQuestRouteId = int32(1)
starterMainQuestSeasonId = int32(1) starterMainQuestSeasonId = int32(1)
missionInProgress = int32(1)
defaultBirthYear = int32(2000) defaultBirthYear = int32(2000)
defaultBirthMonth = int32(1) defaultBirthMonth = int32(1)
@@ -114,7 +113,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
starterMissionId: { starterMissionId: {
MissionId: starterMissionId, MissionId: starterMissionId,
StartDatetime: nowMillis, StartDatetime: nowMillis,
MissionProgressStatusType: missionInProgress, MissionProgressStatusType: int32(model.MissionProgressStatusTypeInProgress),
}, },
}, },
Gimmick: GimmickState{ Gimmick: GimmickState{
@@ -123,29 +122,33 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
Sequences: make(map[GimmickSequenceKey]GimmickSequenceState), Sequences: make(map[GimmickSequenceKey]GimmickSequenceState),
Unlocks: make(map[GimmickKey]GimmickUnlockState), Unlocks: make(map[GimmickKey]GimmickUnlockState),
}, },
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState), CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
ConsumableItems: make(map[int32]int32), TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState),
Materials: make(map[int32]int32), LabyrinthSeasons: make(map[int32]LabyrinthSeasonState),
Thoughts: make(map[string]ThoughtState), LabyrinthStages: make(map[LabyrinthStageKey]LabyrinthStageState),
Parts: make(map[string]PartsState), ConsumableItems: make(map[int32]int32),
PartsGroupNotes: make(map[int32]PartsGroupNoteState), Materials: make(map[int32]int32),
PartsPresets: make(map[int32]PartsPresetState), Thoughts: make(map[string]ThoughtState),
PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState), Parts: make(map[string]PartsState),
ImportantItems: make(map[int32]int32), PartsGroupNotes: make(map[int32]PartsGroupNoteState),
CostumeActiveSkills: make(map[string]CostumeActiveSkillState), PartsPresets: make(map[int32]PartsPresetState),
WeaponSkills: make(map[string][]WeaponSkillState), PartsPresetTags: make(map[int32]PartsPresetTagState),
WeaponAbilities: make(map[string][]WeaponAbilityState), PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState),
DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState), ImportantItems: make(map[int32]int32),
WeaponNotes: make(map[int32]WeaponNoteState), CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
NaviCutInPlayed: make(map[int32]bool), WeaponSkills: make(map[string][]WeaponSkillState),
ViewedMovies: make(map[int32]int64), WeaponAbilities: make(map[string][]WeaponAbilityState),
ContentsStories: make(map[int32]int64), DeckTypeNotes: make(map[model.DeckType]DeckTypeNoteState),
DrawnOmikuji: make(map[int32]int64), WeaponNotes: make(map[int32]WeaponNoteState),
PremiumItems: make(map[int32]int64), NaviCutInPlayed: make(map[int32]bool),
DokanConfirmed: make(map[int32]bool), ViewedMovies: make(map[int32]int64),
ShopItems: make(map[int32]UserShopItemState), ContentsStories: make(map[int32]int64),
ShopReplaceableLineup: make(map[int32]UserShopReplaceableLineupState), DrawnOmikuji: make(map[int32]int64),
ExploreScores: make(map[int32]ExploreScoreState), 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), CharacterBoards: make(map[int32]CharacterBoardState),
CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState), CharacterBoardAbilities: make(map[CharacterBoardAbilityKey]CharacterBoardAbilityState),
+35 -11
View File
@@ -61,6 +61,7 @@ func initMaps(u *store.UserState) {
u.Parts = make(map[string]store.PartsState) u.Parts = make(map[string]store.PartsState)
u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState) u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState)
u.PartsPresets = make(map[int32]store.PartsPresetState) u.PartsPresets = make(map[int32]store.PartsPresetState)
u.PartsPresetTags = make(map[int32]store.PartsPresetTagState)
u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState) u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState)
u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState) u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState)
u.ConsumableItems = make(map[int32]int32) u.ConsumableItems = make(map[int32]int32)
@@ -76,13 +77,15 @@ func initMaps(u *store.UserState) {
u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState) u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState)
u.ExploreScores = make(map[int32]store.ExploreScoreState) u.ExploreScores = make(map[int32]store.ExploreScoreState)
u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState) u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState)
u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState)
u.LabyrinthSeasons = make(map[int32]store.LabyrinthSeasonState)
u.LabyrinthStages = make(map[store.LabyrinthStageKey]store.LabyrinthStageState)
u.CharacterBoards = make(map[int32]store.CharacterBoardState) u.CharacterBoards = make(map[int32]store.CharacterBoardState)
u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState) u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState)
u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState) u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState)
u.CharacterRebirths = make(map[int32]store.CharacterRebirthState) u.CharacterRebirths = make(map[int32]store.CharacterRebirthState)
u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState) u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState)
u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress)
u.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry)
u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus)
u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore) u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore)
u.BigHuntStatuses = make(map[int32]store.BigHuntStatus) u.BigHuntStatuses = make(map[int32]store.BigHuntStatus)
@@ -374,16 +377,6 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
} }
}) })
queryRows(db, `SELECT main_quest_season_id, main_quest_route_id, latest_version
FROM user_main_quest_season_routes WHERE user_id=?`, uid, func(rows *sql.Rows) {
var seasonId, routeId int32
var lv int64
rows.Scan(&seasonId, &routeId, &lv)
u.MainQuestSeasonRoutes[store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId}] = store.SeasonRouteEntry{
MainQuestSeasonId: seasonId, MainQuestRouteId: routeId, LatestVersion: lv,
}
})
queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version queryRows(db, `SELECT limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version
FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) {
var id int32 var id int32
@@ -492,6 +485,14 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.PartsPresets[v.UserPartsPresetNumber] = v u.PartsPresets[v.UserPartsPresetNumber] = v
}) })
queryRows(db, `SELECT user_parts_preset_tag_number, name, latest_version
FROM user_parts_preset_tags WHERE user_id=?`, uid,
func(rows *sql.Rows) {
var v store.PartsPresetTagState
rows.Scan(&v.UserPartsPresetTagNumber, &v.Name, &v.LatestVersion)
u.PartsPresetTags[v.UserPartsPresetTagNumber] = v
})
queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level, queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level,
status_kind_type, status_calculation_type, status_change_value, latest_version status_kind_type, status_calculation_type, status_change_value, latest_version
FROM user_parts_status_subs WHERE user_id=?`, uid, FROM user_parts_status_subs WHERE user_id=?`, uid,
@@ -642,6 +643,29 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
u.CageOrnamentRewards[v.CageOrnamentId] = v u.CageOrnamentRewards[v.CageOrnamentId] = v
}) })
queryRows(db, `SELECT event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version
FROM user_event_quest_tower_accumulation_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.TowerAccumulationRewardState
rows.Scan(&v.EventQuestChapterId, &v.LatestRewardReceiveQuestMissionClearCount, &v.LatestVersion)
u.TowerAccumulationRewards[v.EventQuestChapterId] = v
})
queryRows(db, `SELECT event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version
FROM user_event_quest_labyrinth_seasons WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.LabyrinthSeasonState
rows.Scan(&v.EventQuestChapterId, &v.LastJoinSeasonNumber, &v.LastSeasonRewardReceivedSeasonNumber, &v.LatestVersion)
u.LabyrinthSeasons[v.EventQuestChapterId] = v
})
queryRows(db, `SELECT event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version
FROM user_event_quest_labyrinth_stages WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.LabyrinthStageState
var rcvd int
rows.Scan(&v.EventQuestChapterId, &v.StageOrder, &rcvd, &v.AccumulationRewardReceivedQuestMissionCount, &v.LatestVersion)
v.IsReceivedStageClearReward = rcvd != 0
u.LabyrinthStages[store.LabyrinthStageKey{EventQuestChapterId: v.EventQuestChapterId, StageOrder: v.StageOrder}] = v
})
queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version
FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) { FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) {
var v store.UserShopItemState var v store.UserShopItemState
+49 -17
View File
@@ -224,12 +224,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for k, v := range u.MainQuestSeasonRoutes {
if err := exec(`INSERT INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`,
uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion); err != nil {
return err
}
}
for id, v := range u.QuestLimitContentStatus { for id, v := range u.QuestLimitContentStatus {
if err := exec(`INSERT INTO user_quest_limit_content_status (user_id, limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version) VALUES (?,?,?,?,?)`, if err := exec(`INSERT INTO user_quest_limit_content_status (user_id, limit_content_id, limit_content_quest_status_type, event_quest_chapter_id, latest_version) VALUES (?,?,?,?,?)`,
uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil { uid, id, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion); err != nil {
@@ -312,6 +306,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for _, v := range u.PartsPresetTags {
if err := exec(`INSERT INTO user_parts_preset_tags (user_id, user_parts_preset_tag_number, name, latest_version) VALUES (?,?,?,?)`,
uid, v.UserPartsPresetTagNumber, v.Name, v.LatestVersion); err != nil {
return err
}
}
for _, v := range u.PartsStatusSubs { for _, v := range u.PartsStatusSubs {
if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`, if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`,
uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil { uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil {
@@ -448,6 +448,24 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
return err return err
} }
} }
for _, v := range u.TowerAccumulationRewards {
if err := exec(`INSERT INTO user_event_quest_tower_accumulation_rewards (user_id, event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version) VALUES (?,?,?,?)`,
uid, v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion); err != nil {
return err
}
}
for _, v := range u.LabyrinthSeasons {
if err := exec(`INSERT INTO user_event_quest_labyrinth_seasons (user_id, event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version) VALUES (?,?,?,?,?)`,
uid, v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion); err != nil {
return err
}
}
for k, v := range u.LabyrinthStages {
if err := exec(`INSERT INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion); err != nil {
return err
}
}
for _, v := range u.ShopItems { for _, v := range u.ShopItems {
if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`, if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`,
uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil { uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil {
@@ -774,17 +792,6 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion} return []any{0, v.HeadSideStoryQuestSceneId, int32(v.SideStoryQuestStateType), v.LatestVersion}
}, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version") }, "side_story_quest_id, head_side_story_quest_scene_id, side_story_quest_state_type, latest_version")
for k, v := range after.MainQuestSeasonRoutes {
if old, ok := before.MainQuestSeasonRoutes[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_main_quest_season_routes (user_id, main_quest_season_id, main_quest_route_id, latest_version) VALUES (?,?,?,?)`,
uid, k.MainQuestSeasonId, k.MainQuestRouteId, v.LatestVersion)
}
}
for k := range before.MainQuestSeasonRoutes {
if _, ok := after.MainQuestSeasonRoutes[k]; !ok {
exec(`DELETE FROM user_main_quest_season_routes WHERE user_id=? AND main_quest_season_id=? AND main_quest_route_id=?`, uid, k.MainQuestSeasonId, k.MainQuestRouteId)
}
}
diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id", diffMapInt32(tx, uid, before.QuestLimitContentStatus, after.QuestLimitContentStatus, "user_quest_limit_content_status", "limit_content_id",
func(v store.QuestLimitContentStatus) []any { func(v store.QuestLimitContentStatus) []any {
return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion} return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion}
@@ -862,6 +869,10 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
func(v store.PartsPresetState) []any { func(v store.PartsPresetState) []any {
return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion} return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion}
}, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version") }, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version")
diffMapInt32(tx, uid, before.PartsPresetTags, after.PartsPresetTags, "user_parts_preset_tags", "user_parts_preset_tag_number",
func(v store.PartsPresetTagState) []any {
return []any{v.UserPartsPresetTagNumber, v.Name, v.LatestVersion}
}, "user_parts_preset_tag_number, name, latest_version")
for k, v := range after.PartsStatusSubs { for k, v := range after.PartsStatusSubs {
if old, ok := before.PartsStatusSubs[k]; !ok || old != v { if old, ok := before.PartsStatusSubs[k]; !ok || old != v {
@@ -984,6 +995,27 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion} return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion}
}, },
"cage_ornament_id, acquisition_datetime, latest_version") "cage_ornament_id, acquisition_datetime, latest_version")
diffMapInt32(tx, uid, before.TowerAccumulationRewards, after.TowerAccumulationRewards, "user_event_quest_tower_accumulation_rewards", "event_quest_chapter_id",
func(v store.TowerAccumulationRewardState) []any {
return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion}
},
"event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version")
diffMapInt32(tx, uid, before.LabyrinthSeasons, after.LabyrinthSeasons, "user_event_quest_labyrinth_seasons", "event_quest_chapter_id",
func(v store.LabyrinthSeasonState) []any {
return []any{v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion}
},
"event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version")
for k, v := range after.LabyrinthStages {
if old, ok := before.LabyrinthStages[k]; !ok || old != v {
exec(`INSERT OR REPLACE INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion)
}
}
for k := range before.LabyrinthStages {
if _, ok := after.LabyrinthStages[k]; !ok {
exec(`DELETE FROM user_event_quest_labyrinth_stages WHERE user_id=? AND event_quest_chapter_id=? AND stage_order=?`, uid, k.EventQuestChapterId, k.StageOrder)
}
}
diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id", diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id",
func(v store.UserShopItemState) []any { func(v store.UserShopItemState) []any {
return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion} return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion}
+4
View File
@@ -79,6 +79,9 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
// Child tables in reverse-dependency order (matches schema's goose Down). // Child tables in reverse-dependency order (matches schema's goose Down).
childTables := []string{ childTables := []string{
"user_event_quest_labyrinth_stages",
"user_event_quest_labyrinth_seasons",
"user_event_quest_tower_accumulation_rewards",
"user_cage_ornament_rewards", "user_cage_ornament_rewards",
"user_shop_replaceable_lineup", "user_shop_replaceable_lineup",
"user_shop_items", "user_shop_items",
@@ -119,6 +122,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
"user_decks", "user_decks",
"user_deck_characters", "user_deck_characters",
"user_parts_status_subs", "user_parts_status_subs",
"user_parts_preset_tags",
"user_parts_presets", "user_parts_presets",
"user_parts_group_notes", "user_parts_group_notes",
"user_parts", "user_parts",
+16 -1
View File
@@ -1,6 +1,10 @@
package store package store
import "log" import (
"log"
"lunar-tear/server/internal/model"
)
const StaminaRecoveryDivisor int64 = 180 const StaminaRecoveryDivisor int64 = 180
@@ -39,3 +43,14 @@ func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64)
user.Status.StaminaUpdateDatetime = nowMillis user.Status.StaminaUpdateDatetime = nowMillis
log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis) log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis)
} }
func ResolveStaminaEffectMillis(effectValueType, effectValue, maxStaminaMillis int32) int32 {
switch effectValueType {
case model.EffectValueFixed:
return effectValue * 1000
case model.EffectValuePermil:
return effectValue * maxStaminaMillis / 1000
default:
return 0
}
}
+89 -55
View File
@@ -41,7 +41,6 @@ type UserState struct {
LoginBonus UserLoginBonusState LoginBonus UserLoginBonusState
Tutorials map[int32]TutorialProgressState Tutorials map[int32]TutorialProgressState
MainQuest MainQuestState MainQuest MainQuestState
MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry
EventQuest EventQuestState EventQuest EventQuestState
ExtraQuest ExtraQuestState ExtraQuest ExtraQuestState
SideStoryQuests map[int32]SideStoryQuestProgress SideStoryQuests map[int32]SideStoryQuestProgress
@@ -63,46 +62,50 @@ type UserState struct {
Gacha GachaState Gacha GachaState
Notifications NotificationState Notifications NotificationState
Characters map[int32]CharacterState Characters map[int32]CharacterState
Costumes map[string]CostumeState Costumes map[string]CostumeState
Weapons map[string]WeaponState Weapons map[string]WeaponState
Companions map[string]CompanionState Companions map[string]CompanionState
Thoughts map[string]ThoughtState Thoughts map[string]ThoughtState
DeckCharacters map[string]DeckCharacterState DeckCharacters map[string]DeckCharacterState
Decks map[DeckKey]DeckState Decks map[DeckKey]DeckState
TripleDecks map[DeckKey]TripleDeckState TripleDecks map[DeckKey]TripleDeckState
Quests map[int32]UserQuestState Quests map[int32]UserQuestState
QuestMissions map[QuestMissionKey]UserQuestMissionState QuestMissions map[QuestMissionKey]UserQuestMissionState
Missions map[int32]UserMissionState Missions map[int32]UserMissionState
WeaponStories map[int32]WeaponStoryState WeaponStories map[int32]WeaponStoryState
Gimmick GimmickState Gimmick GimmickState
CageOrnamentRewards map[int32]CageOrnamentRewardState CageOrnamentRewards map[int32]CageOrnamentRewardState
ConsumableItems map[int32]int32 TowerAccumulationRewards map[int32]TowerAccumulationRewardState
Materials map[int32]int32 LabyrinthSeasons map[int32]LabyrinthSeasonState
Parts map[string]PartsState LabyrinthStages map[LabyrinthStageKey]LabyrinthStageState
PartsGroupNotes map[int32]PartsGroupNoteState ConsumableItems map[int32]int32
PartsPresets map[int32]PartsPresetState Materials map[int32]int32
PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState Parts map[string]PartsState
ImportantItems map[int32]int32 PartsGroupNotes map[int32]PartsGroupNoteState
CostumeActiveSkills map[string]CostumeActiveSkillState PartsPresets map[int32]PartsPresetState
WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid PartsPresetTags map[int32]PartsPresetTagState
WeaponAbilities map[string][]WeaponAbilityState // key: userWeaponUuid PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState
WeaponAwakens map[string]WeaponAwakenState // key: userWeaponUuid ImportantItems map[int32]int32
DeckTypeNotes map[model.DeckType]DeckTypeNoteState CostumeActiveSkills map[string]CostumeActiveSkillState
WeaponNotes map[int32]WeaponNoteState WeaponSkills map[string][]WeaponSkillState // key: userWeaponUuid
DeckSubWeapons map[string][]string WeaponAbilities map[string][]WeaponAbilityState // key: userWeaponUuid
DeckParts map[string][]string WeaponAwakens map[string]WeaponAwakenState // key: userWeaponUuid
NaviCutInPlayed map[int32]bool DeckTypeNotes map[model.DeckType]DeckTypeNoteState
ViewedMovies map[int32]int64 WeaponNotes map[int32]WeaponNoteState
ContentsStories map[int32]int64 DeckSubWeapons map[string][]string
DrawnOmikuji map[int32]int64 DeckParts map[string][]string
PremiumItems map[int32]int64 NaviCutInPlayed map[int32]bool
DokanConfirmed map[int32]bool ViewedMovies map[int32]int64
PortalCageStatus PortalCageStatusState ContentsStories map[int32]int64
GuerrillaFreeOpen GuerrillaFreeOpenState DrawnOmikuji map[int32]int64
ShopItems map[int32]UserShopItemState PremiumItems map[int32]int64
ShopReplaceable UserShopReplaceableState DokanConfirmed map[int32]bool
ShopReplaceableLineup map[int32]UserShopReplaceableLineupState PortalCageStatus PortalCageStatusState
GuerrillaFreeOpen GuerrillaFreeOpenState
ShopItems map[int32]UserShopItemState
ShopReplaceable UserShopReplaceableState
ShopReplaceableLineup map[int32]UserShopReplaceableLineupState
Explore ExploreState Explore ExploreState
ExploreScores map[int32]ExploreScoreState ExploreScores map[int32]ExploreScoreState
@@ -158,9 +161,6 @@ func (u *UserState) EnsureMaps() {
if u.SideStoryQuests == nil { if u.SideStoryQuests == nil {
u.SideStoryQuests = make(map[int32]SideStoryQuestProgress) u.SideStoryQuests = make(map[int32]SideStoryQuestProgress)
} }
if u.MainQuestSeasonRoutes == nil {
u.MainQuestSeasonRoutes = make(map[SeasonRouteKey]SeasonRouteEntry)
}
if u.QuestLimitContentStatus == nil { if u.QuestLimitContentStatus == nil {
u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus) u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus)
} }
@@ -191,6 +191,15 @@ func (u *UserState) EnsureMaps() {
if u.CageOrnamentRewards == nil { if u.CageOrnamentRewards == nil {
u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState) u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState)
} }
if u.TowerAccumulationRewards == nil {
u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState)
}
if u.LabyrinthSeasons == nil {
u.LabyrinthSeasons = make(map[int32]LabyrinthSeasonState)
}
if u.LabyrinthStages == nil {
u.LabyrinthStages = make(map[LabyrinthStageKey]LabyrinthStageState)
}
if u.ConsumableItems == nil { if u.ConsumableItems == nil {
u.ConsumableItems = make(map[int32]int32) u.ConsumableItems = make(map[int32]int32)
} }
@@ -206,6 +215,9 @@ func (u *UserState) EnsureMaps() {
if u.PartsPresets == nil { if u.PartsPresets == nil {
u.PartsPresets = make(map[int32]PartsPresetState) u.PartsPresets = make(map[int32]PartsPresetState)
} }
if u.PartsPresetTags == nil {
u.PartsPresetTags = make(map[int32]PartsPresetTagState)
}
if u.PartsStatusSubs == nil { if u.PartsStatusSubs == nil {
u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState) u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState)
} }
@@ -574,17 +586,6 @@ type SideStoryActiveProgress struct {
LatestVersion int64 LatestVersion int64
} }
type SeasonRouteKey struct {
MainQuestSeasonId int32
MainQuestRouteId int32
}
type SeasonRouteEntry struct {
MainQuestSeasonId int32
MainQuestRouteId int32
LatestVersion int64
}
type QuestLimitContentStatus struct { type QuestLimitContentStatus struct {
LimitContentQuestStatusType int32 LimitContentQuestStatusType int32
EventQuestChapterId int32 EventQuestChapterId int32
@@ -864,6 +865,33 @@ type CageOrnamentRewardState struct {
LatestVersion int64 LatestVersion int64
} }
type TowerAccumulationRewardState struct {
EventQuestChapterId int32
LatestRewardReceiveQuestMissionClearCount int32
LatestVersion int64
}
type LabyrinthSeasonState struct {
EventQuestChapterId int32
LastJoinSeasonNumber int32
LastSeasonRewardReceivedSeasonNumber int32
LatestVersion int64
}
// LabyrinthStageKey is the composite key for UserState.LabyrinthStages.
type LabyrinthStageKey struct {
EventQuestChapterId int32
StageOrder int32
}
type LabyrinthStageState struct {
EventQuestChapterId int32
StageOrder int32
IsReceivedStageClearReward bool
AccumulationRewardReceivedQuestMissionCount int32
LatestVersion int64
}
type PartsState struct { type PartsState struct {
UserPartsUuid string UserPartsUuid string
PartsId int32 PartsId int32
@@ -890,6 +918,12 @@ type PartsPresetState struct {
LatestVersion int64 LatestVersion int64
} }
type PartsPresetTagState struct {
UserPartsPresetTagNumber int32
Name string
LatestVersion int64
}
type PartsStatusSubKey struct { type PartsStatusSubKey struct {
UserPartsUuid string UserPartsUuid string
StatusIndex int32 StatusIndex int32
+23 -4
View File
@@ -101,8 +101,9 @@ func ChangedTables(before, after *store.UserState) []string {
add("IUserMainQuestMainFlowStatus") add("IUserMainQuestMainFlowStatus")
add("IUserMainQuestProgressStatus") add("IUserMainQuestProgressStatus")
add("IUserMainQuestReplayFlowStatus") add("IUserMainQuestReplayFlowStatus")
} // IUserMainQuestSeasonRoute is derived from MainQuest + Quests at projection
if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) { // time (see proj_quest.go / questflow.QuestHandler.SeasonRoutesFor) — flag it
// whenever either of those upstream inputs changes.
add("IUserMainQuestSeasonRoute") add("IUserMainQuestSeasonRoute")
} }
if before.EventQuest != after.EventQuest { if before.EventQuest != after.EventQuest {
@@ -163,6 +164,9 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) { if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) {
add("IUserPartsPreset") add("IUserPartsPreset")
} }
if !mapsEqualStruct(before.PartsPresetTags, after.PartsPresetTags) {
add("IUserPartsPresetTag")
}
if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) { if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) {
add("IUserPartsStatusSub") add("IUserPartsStatusSub")
} }
@@ -199,6 +203,7 @@ func ChangedTables(before, after *store.UserState) []string {
} }
if !mapsEqualStruct(before.Quests, after.Quests) { if !mapsEqualStruct(before.Quests, after.Quests) {
add("IUserQuest") add("IUserQuest")
add("IUserMainQuestSeasonRoute")
} }
if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) { if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) {
add("IUserQuestMission") add("IUserQuestMission")
@@ -260,6 +265,12 @@ func ChangedTables(before, after *store.UserState) []string {
if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) { if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) {
add("IUserCageOrnamentReward") add("IUserCageOrnamentReward")
} }
if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) {
add("IUserEventQuestTowerAccumulationReward")
}
if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) {
add("IUserEventQuestLabyrinthStage")
}
if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) { if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) {
add("IUserBigHuntMaxScore") add("IUserBigHuntMaxScore")
@@ -278,10 +289,12 @@ func ChangedTables(before, after *store.UserState) []string {
} }
if !gimmickStateEqual(before.Gimmick, after.Gimmick) { if !gimmickStateEqual(before.Gimmick, after.Gimmick) {
if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) { if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) ||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
add("IUserGimmick") add("IUserGimmick")
} }
if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) { if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) ||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
add("IUserGimmickOrnamentProgress") add("IUserGimmickOrnamentProgress")
} }
if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) { if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
@@ -419,8 +432,14 @@ func keyFieldsForTable(table string) []string {
return []string{"userId", "partsGroupId"} return []string{"userId", "partsGroupId"}
case "IUserPartsPreset": case "IUserPartsPreset":
return []string{"userId", "userPartsPresetNumber"} return []string{"userId", "userPartsPresetNumber"}
case "IUserPartsPresetTag":
return []string{"userId", "userPartsPresetTagNumber"}
case "IUserCageOrnamentReward": case "IUserCageOrnamentReward":
return []string{"userId", "cageOrnamentId"} return []string{"userId", "cageOrnamentId"}
case "IUserEventQuestTowerAccumulationReward":
return []string{"userId", "eventQuestChapterId"}
case "IUserEventQuestLabyrinthStage":
return []string{"userId", "eventQuestChapterId", "stageOrder"}
case "IUserAutoSaleSettingDetail": case "IUserAutoSaleSettingDetail":
return []string{"userId", "possessionAutoSaleItemType"} return []string{"userId", "possessionAutoSaleItemType"}
case "IUserCharacterRebirth": case "IUserCharacterRebirth":
+129 -17
View File
@@ -2,11 +2,21 @@ package userdata
import ( import (
"sort" "sort"
"sync"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
var gimmickOrnamentRefs = sync.OnceValue(masterdata.LoadGimmickOrnamentRefs)
var gimmickSequenceChains = sync.OnceValue(masterdata.LoadGimmickSequenceChains)
var hiddenSequenceSet = sync.OnceValue(masterdata.LoadHiddenGimmickSequenceIDs)
var gimmickSequenceRanks = sync.OnceValue(masterdata.LoadGimmickSequenceRanks)
var birdGimmicks = sync.OnceValue(masterdata.LoadBirdGimmickIDs)
const birdDefaultBaseDatetime int64 = 1577836800000 // 2020-01-01 00:00:00 UTC in ms
func init() { func init() {
register("IUserGimmick", func(user store.UserState) string { register("IUserGimmick", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...)
@@ -26,9 +36,65 @@ func init() {
}) })
} }
func projectActiveChainOrnaments(
user store.UserState,
addKey func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef),
sizeFn func() int,
cap int,
) {
refs := gimmickOrnamentRefs()
chains := gimmickSequenceChains()
hiddenSeq := hiddenSequenceSet()
walkChain := func(seqKey store.GimmickSequenceKey) {
chain := chains[seqKey.GimmickSequenceId]
if len(chain) == 0 {
chain = []int32{seqKey.GimmickSequenceId}
}
for _, seqId := range chain {
for _, ref := range refs[seqId] {
addKey(seqKey, seqId, ref)
}
}
}
var nonHidden []store.GimmickSequenceKey
for seqKey := range user.Gimmick.Sequences {
if hiddenSeq[seqKey.GimmickSequenceId] {
walkChain(seqKey)
} else {
nonHidden = append(nonHidden, seqKey)
}
}
for _, seqKey := range nonHidden {
if sizeFn() >= cap {
break
}
walkChain(seqKey)
}
}
func sortedGimmickRecords(user store.UserState) []map[string]any { func sortedGimmickRecords(user store.UserState) []map[string]any {
keys := make([]store.GimmickKey, 0, len(user.Gimmick.Progress))
keySet := make(map[store.GimmickKey]struct{})
// Real progress rows (genuine user data) — always kept.
for key := range user.Gimmick.Progress { for key := range user.Gimmick.Progress {
keySet[key] = struct{}{}
}
projectActiveChainOrnaments(user,
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
keySet[store.GimmickKey{
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
GimmickSequenceId: seqId,
GimmickId: ref.GimmickId,
}] = struct{}{}
},
func() int { return len(keySet) },
masterdata.MaxUserGimmickRows,
)
keys := make([]store.GimmickKey, 0, len(keySet))
for key := range keySet {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
@@ -37,57 +103,103 @@ func sortedGimmickRecords(user store.UserState) []map[string]any {
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.Gimmick.Progress[key] isGimmickCleared := false
startDatetime := user.GameStartDatetime
latestVersion := user.GameStartDatetime
if row, ok := user.Gimmick.Progress[key]; ok {
isGimmickCleared = row.IsGimmickCleared
startDatetime = row.StartDatetime
latestVersion = row.LatestVersion
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
"gimmickSequenceId": row.Key.GimmickSequenceId, "gimmickSequenceId": key.GimmickSequenceId,
"gimmickId": row.Key.GimmickId, "gimmickId": key.GimmickId,
"isGimmickCleared": row.IsGimmickCleared, "isGimmickCleared": isGimmickCleared,
"startDatetime": row.StartDatetime, "startDatetime": startDatetime,
"latestVersion": row.LatestVersion, "latestVersion": latestVersion,
}) })
} }
return records return records
} }
func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any { func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any {
keys := make([]store.GimmickOrnamentKey, 0, len(user.Gimmick.OrnamentProgress))
keySet := make(map[store.GimmickOrnamentKey]struct{})
// Real progress rows (genuine user data) — always kept.
for key := range user.Gimmick.OrnamentProgress { for key := range user.Gimmick.OrnamentProgress {
keySet[key] = struct{}{}
}
projectActiveChainOrnaments(user,
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
keySet[store.GimmickOrnamentKey{
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
GimmickSequenceId: seqId,
GimmickId: ref.GimmickId,
GimmickOrnamentIndex: ref.OrnamentIndex,
}] = struct{}{}
},
func() int { return len(keySet) },
masterdata.MaxUserGimmickRows,
)
keys := make([]store.GimmickOrnamentKey, 0, len(keySet))
for key := range keySet {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
return compareGimmickOrnamentKey(keys[i], keys[j]) < 0 return compareGimmickOrnamentKey(keys[i], keys[j]) < 0
}) })
birdG := birdGimmicks()
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.Gimmick.OrnamentProgress[key] progressValueBit := int32(0)
baseDatetime := user.GameStartDatetime
latestVersion := user.GameStartDatetime
if row, ok := user.Gimmick.OrnamentProgress[key]; ok {
progressValueBit = row.ProgressValueBit
baseDatetime = row.BaseDatetime
latestVersion = row.LatestVersion
} else if birdG[key.GimmickId] {
baseDatetime = birdDefaultBaseDatetime
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId, "gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
"gimmickSequenceId": row.Key.GimmickSequenceId, "gimmickSequenceId": key.GimmickSequenceId,
"gimmickId": row.Key.GimmickId, "gimmickId": key.GimmickId,
"gimmickOrnamentIndex": row.Key.GimmickOrnamentIndex, "gimmickOrnamentIndex": key.GimmickOrnamentIndex,
"progressValueBit": row.ProgressValueBit, "progressValueBit": progressValueBit,
"baseDatetime": row.BaseDatetime, "baseDatetime": baseDatetime,
"latestVersion": row.LatestVersion, "latestVersion": latestVersion,
}) })
} }
return records return records
} }
func sortedGimmickSequenceRecords(user store.UserState) []map[string]any { func sortedGimmickSequenceRecords(user store.UserState) []map[string]any {
ranks := gimmickSequenceRanks()
keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences)) keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences))
for key := range user.Gimmick.Sequences { for key := range user.Gimmick.Sequences {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
ri, rj := ranks[keys[i].GimmickSequenceId], ranks[keys[j].GimmickSequenceId]
if ri != rj {
return ri < rj
}
if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId { if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId {
return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId
} }
return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId
}) })
if len(keys) > masterdata.MaxUserGimmickRows {
keys = keys[:masterdata.MaxUserGimmickRows]
}
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
+23 -1
View File
@@ -86,6 +86,10 @@ func init() {
s, _ := utils.EncodeJSONMaps(sortedPartsPresetRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedPartsPresetRecords(user)...)
return s return s
}) })
register("IUserPartsPresetTag", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedPartsPresetTagRecords(user)...)
return s
})
register("IUserCostumeAwakenStatusUp", func(user store.UserState) string { register("IUserCostumeAwakenStatusUp", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...) s, _ := utils.EncodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...)
return s return s
@@ -122,7 +126,6 @@ func init() {
"IUserCostumeLevelBonusReleaseStatus", "IUserCostumeLevelBonusReleaseStatus",
"IUserCostumeLotteryEffectAbility", "IUserCostumeLotteryEffectAbility",
"IUserCostumeLotteryEffectStatusUp", "IUserCostumeLotteryEffectStatusUp",
"IUserPartsPresetTag",
) )
} }
@@ -496,6 +499,25 @@ func sortedPartsPresetRecords(user store.UserState) []map[string]any {
return records return records
} }
func sortedPartsPresetTagRecords(user store.UserState) []map[string]any {
ids := make([]int, 0, len(user.PartsPresetTags))
for id := range user.PartsPresetTags {
ids = append(ids, int(id))
}
sort.Ints(ids)
records := make([]map[string]any, 0, len(ids))
for _, id := range ids {
row := user.PartsPresetTags[int32(id)]
records = append(records, map[string]any{
"userId": user.UserId,
"userPartsPresetTagNumber": row.UserPartsPresetTagNumber,
"name": row.Name,
"latestVersion": row.LatestVersion,
})
}
return records
}
func sortedPartsStatusSubRecords(user store.UserState) []map[string]any { func sortedPartsStatusSubRecords(user store.UserState) []map[string]any {
keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs)) keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs))
for k := range user.PartsStatusSubs { for k := range user.PartsStatusSubs {
@@ -0,0 +1,72 @@
package userdata
import (
"sync"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
var labyrinthCatalog = sync.OnceValue(masterdata.LoadLabyrinthCatalog)
func init() {
register("IUserEventQuestLabyrinthSeason", func(user store.UserState) string {
chapters := labyrinthCatalog().ChaptersByOrder
records := make([]map[string]any, 0, len(chapters))
for _, ch := range chapters {
if st, ok := user.LabyrinthSeasons[ch.EventQuestChapterId]; ok {
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": st.EventQuestChapterId,
"lastJoinSeasonNumber": st.LastJoinSeasonNumber,
"lastSeasonRewardReceivedSeasonNumber": st.LastSeasonRewardReceivedSeasonNumber,
"latestVersion": st.LatestVersion,
})
continue
}
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": ch.EventQuestChapterId,
"lastJoinSeasonNumber": ch.LatestSeasonNumber,
"lastSeasonRewardReceivedSeasonNumber": 0,
"latestVersion": user.GameStartDatetime,
})
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
register("IUserEventQuestLabyrinthStage", func(user store.UserState) string {
records := make([]map[string]any, 0)
for _, ch := range labyrinthCatalog().ChaptersByOrder {
for _, stageOrder := range ch.StageOrders {
key := store.LabyrinthStageKey{
EventQuestChapterId: ch.EventQuestChapterId,
StageOrder: stageOrder,
}
if st, ok := user.LabyrinthStages[key]; ok {
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": st.EventQuestChapterId,
"stageOrder": st.StageOrder,
"isReceivedStageClearReward": st.IsReceivedStageClearReward,
"accumulationRewardReceivedQuestMissionCount": st.AccumulationRewardReceivedQuestMissionCount,
"latestVersion": st.LatestVersion,
})
continue
}
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": ch.EventQuestChapterId,
"stageOrder": stageOrder,
"isReceivedStageClearReward": false,
"accumulationRewardReceivedQuestMissionCount": 0,
"latestVersion": user.GameStartDatetime,
})
}
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
}
+79 -33
View File
@@ -3,6 +3,7 @@ package userdata
import ( import (
"sort" "sort"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
@@ -13,13 +14,30 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
ids = append(ids, int(id)) ids = append(ids, int(id))
} }
sort.Ints(ids) sort.Ints(ids)
var replayQuestId int32
if user.MainQuest.SavedContext.Active && questHandler != nil {
if scene, ok := questHandler.SceneById[user.MainQuest.ProgressQuestSceneId]; ok {
replayQuestId = scene.QuestId
}
}
records := make([]map[string]any, 0, len(ids)) records := make([]map[string]any, 0, len(ids))
for _, id := range ids { for _, id := range ids {
row := user.Quests[int32(id)] row := user.Quests[int32(id)]
stateType := row.QuestStateType
if replayQuestId != 0 {
switch {
case int32(id) == replayQuestId:
stateType = model.UserQuestStateTypeActive
case stateType == model.UserQuestStateTypeActive:
stateType = model.UserQuestStateTypeCleared
}
}
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"questId": row.QuestId, "questId": row.QuestId,
"questStateType": row.QuestStateType, "questStateType": stateType,
"isBattleOnly": row.IsBattleOnly, "isBattleOnly": row.IsBattleOnly,
"latestStartDatetime": row.LatestStartDatetime, "latestStartDatetime": row.LatestStartDatetime,
"clearCount": row.ClearCount, "clearCount": row.ClearCount,
@@ -33,8 +51,26 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
} }
func sortedQuestMissionRecords(user store.UserState) []map[string]any { func sortedQuestMissionRecords(user store.UserState) []map[string]any {
keys := make([]store.QuestMissionKey, 0, len(user.QuestMissions)) questMissions := make(map[store.QuestMissionKey]store.UserQuestMissionState, len(user.QuestMissions))
for key := range user.QuestMissions { for key, qm := range user.QuestMissions {
questMissions[key] = qm
}
// Force-clear hidden-story quest-missions so their report gimmicks unlock.
for _, key := range hiddenStoryRequirements().QuestMissions {
if existing, ok := questMissions[key]; ok && existing.IsClear {
continue
}
questMissions[key] = store.UserQuestMissionState{
QuestId: key.QuestId,
QuestMissionId: key.QuestMissionId,
IsClear: true,
LatestClearDatetime: user.GameStartDatetime,
LatestVersion: user.GameStartDatetime,
}
}
keys := make([]store.QuestMissionKey, 0, len(questMissions))
for key := range questMissions {
keys = append(keys, key) keys = append(keys, key)
} }
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
@@ -45,7 +81,7 @@ func sortedQuestMissionRecords(user store.UserState) []map[string]any {
}) })
records := make([]map[string]any, 0, len(keys)) records := make([]map[string]any, 0, len(keys))
for _, key := range keys { for _, key := range keys {
row := user.QuestMissions[key] row := questMissions[key]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"questId": row.QuestId, "questId": row.QuestId,
@@ -98,38 +134,29 @@ func init() {
return s return s
}) })
register("IUserMainQuestSeasonRoute", func(user store.UserState) string { register("IUserMainQuestSeasonRoute", func(user store.UserState) string {
if len(user.MainQuestSeasonRoutes) == 0 { if questHandler == nil {
// Fallback to current (season, route) for legacy saves with no history. return "[]"
s, _ := utils.EncodeJSONMaps(map[string]any{
"userId": user.UserId,
"mainQuestSeasonId": user.MainQuest.MainQuestSeasonId,
"mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId,
"latestVersion": user.MainQuest.LatestVersion,
})
return s
} }
keys := make([]store.SeasonRouteKey, 0, len(user.MainQuestSeasonRoutes)) pairs := questHandler.SeasonRoutesFor(&user)
for k := range user.MainQuestSeasonRoutes { if len(pairs) == 0 {
keys = append(keys, k) return "[]"
} }
sort.Slice(keys, func(i, j int) bool { seasons := make([]int32, 0, len(pairs))
if keys[i].MainQuestSeasonId != keys[j].MainQuestSeasonId { for s := range pairs {
return keys[i].MainQuestSeasonId < keys[j].MainQuestSeasonId seasons = append(seasons, s)
} }
return keys[i].MainQuestRouteId < keys[j].MainQuestRouteId sort.Slice(seasons, func(i, j int) bool { return seasons[i] < seasons[j] })
}) records := make([]map[string]any, 0, len(seasons))
records := make([]map[string]any, 0, len(keys)) for _, s := range seasons {
for _, k := range keys {
e := user.MainQuestSeasonRoutes[k]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"mainQuestSeasonId": e.MainQuestSeasonId, "mainQuestSeasonId": s,
"mainQuestRouteId": e.MainQuestRouteId, "mainQuestRouteId": pairs[s],
"latestVersion": e.LatestVersion, "latestVersion": user.MainQuest.LatestVersion,
}) })
} }
s, _ := utils.EncodeJSONMaps(records...) out, _ := utils.EncodeJSONMaps(records...)
return s return out
}) })
register("IUserEventQuestProgressStatus", func(user store.UserState) string { register("IUserEventQuestProgressStatus", func(user store.UserState) string {
s, _ := utils.EncodeJSONMaps(map[string]any{ s, _ := utils.EncodeJSONMaps(map[string]any{
@@ -219,11 +246,30 @@ func init() {
s, _ := utils.EncodeJSONMaps(records...) s, _ := utils.EncodeJSONMaps(records...)
return s return s
}) })
register("IUserEventQuestTowerAccumulationReward", func(user store.UserState) string {
if len(user.TowerAccumulationRewards) == 0 {
return "[]"
}
ids := make([]int, 0, len(user.TowerAccumulationRewards))
for id := range user.TowerAccumulationRewards {
ids = append(ids, int(id))
}
sort.Ints(ids)
records := make([]map[string]any, 0, len(ids))
for _, id := range ids {
st := user.TowerAccumulationRewards[int32(id)]
records = append(records, map[string]any{
"userId": user.UserId,
"eventQuestChapterId": st.EventQuestChapterId,
"latestRewardReceiveQuestMissionClearCount": st.LatestRewardReceiveQuestMissionClearCount,
"latestVersion": st.LatestVersion,
})
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
registerStatic( registerStatic(
"IUserEventQuestDailyGroupCompleteReward", "IUserEventQuestDailyGroupCompleteReward",
"IUserEventQuestLabyrinthSeason",
"IUserEventQuestLabyrinthStage",
"IUserEventQuestTowerAccumulationReward",
"IUserQuestReplayFlowRewardGroup", "IUserQuestReplayFlowRewardGroup",
"IUserQuestAutoOrbit", "IUserQuestAutoOrbit",
"IUserQuestSceneChoice", "IUserQuestSceneChoice",
+25 -3
View File
@@ -2,8 +2,11 @@ package userdata
import ( import (
"sort" "sort"
"sync"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils" "lunar-tear/server/internal/utils"
) )
@@ -192,16 +195,35 @@ func sortedTutorialRecords(user store.UserState) []map[string]any {
return records return records
} }
var hiddenStoryRequirements = sync.OnceValue(masterdata.LoadHiddenStoryRequirements)
func sortedMissionRecords(user store.UserState) []map[string]any { func sortedMissionRecords(user store.UserState) []map[string]any {
ids := make([]int, 0, len(user.Missions)) missions := make(map[int32]store.UserMissionState, len(user.Missions))
for id := range user.Missions { for id, m := range user.Missions {
missions[id] = m
}
for _, missionId := range hiddenStoryRequirements().MissionIds {
if existing, ok := missions[missionId]; ok && existing.MissionProgressStatusType >= int32(model.MissionProgressStatusTypeClear) {
continue
}
missions[missionId] = store.UserMissionState{
MissionId: missionId,
StartDatetime: user.GameStartDatetime,
MissionProgressStatusType: int32(model.MissionProgressStatusTypeClear),
ClearDatetime: user.GameStartDatetime,
LatestVersion: user.GameStartDatetime,
}
}
ids := make([]int, 0, len(missions))
for id := range missions {
ids = append(ids, int(id)) ids = append(ids, int(id))
} }
sort.Ints(ids) sort.Ints(ids)
records := make([]map[string]any, 0, len(ids)) records := make([]map[string]any, 0, len(ids))
for _, id := range ids { for _, id := range ids {
row := user.Missions[int32(id)] row := missions[int32(id)]
records = append(records, map[string]any{ records = append(records, map[string]any{
"userId": user.UserId, "userId": user.UserId,
"missionId": row.MissionId, "missionId": row.MissionId,
+28
View File
@@ -0,0 +1,28 @@
package userdata
import (
"sync"
"lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/store"
"lunar-tear/server/internal/utils"
)
var webviewPanelMissionCatalog = sync.OnceValue(masterdata.LoadWebviewPanelMissionCatalog)
func init() {
register("IUserWebviewPanelMission", func(user store.UserState) string {
pageIds := webviewPanelMissionCatalog().PageIds
records := make([]map[string]any, 0, len(pageIds))
for _, pageId := range pageIds {
records = append(records, map[string]any{
"userId": user.UserId,
"webviewPanelMissionPageId": pageId,
"rewardReceiveDatetime": user.GameStartDatetime,
"latestVersion": user.GameStartDatetime,
})
}
s, _ := utils.EncodeJSONMaps(records...)
return s
})
}
+7
View File
@@ -3,6 +3,7 @@ package userdata
import ( import (
"sort" "sort"
"lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -10,6 +11,12 @@ type Projector func(user store.UserState) string
var projectors = make(map[string]Projector) var projectors = make(map[string]Projector)
var questHandler *questflow.QuestHandler
func SetQuestHandler(h *questflow.QuestHandler) {
questHandler = h
}
func register(tableName string, fn Projector) { func register(tableName string, fn Projector) {
projectors[tableName] = fn projectors[tableName] = fn
} }
@@ -102,6 +102,7 @@ func FullClientTableMap(user store.UserState) map[string]string {
"IUserBigHuntWeeklyStatus": projectTable("IUserBigHuntWeeklyStatus", user), "IUserBigHuntWeeklyStatus": projectTable("IUserBigHuntWeeklyStatus", user),
"IUserFacebook": projectTable("IUserFacebook", user), "IUserFacebook": projectTable("IUserFacebook", user),
"IUserApple": projectTable("IUserApple", user), "IUserApple": projectTable("IUserApple", user),
"IUserWebviewPanelMission": projectTable("IUserWebviewPanelMission", user),
} }
} }
@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE user_parts_preset_tags (
user_id INTEGER NOT NULL REFERENCES users(user_id),
user_parts_preset_tag_number INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, user_parts_preset_tag_number)
);
-- +goose Down
DROP TABLE IF EXISTS user_parts_preset_tags;
@@ -0,0 +1,11 @@
-- +goose Up
CREATE TABLE user_event_quest_tower_accumulation_rewards (
user_id INTEGER NOT NULL REFERENCES users(user_id),
event_quest_chapter_id INTEGER NOT NULL,
latest_reward_receive_quest_mission_clear_count INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, event_quest_chapter_id)
);
-- +goose Down
DROP TABLE IF EXISTS user_event_quest_tower_accumulation_rewards;
@@ -0,0 +1,23 @@
-- +goose Up
CREATE TABLE user_event_quest_labyrinth_seasons (
user_id INTEGER NOT NULL REFERENCES users(user_id),
event_quest_chapter_id INTEGER NOT NULL,
last_join_season_number INTEGER NOT NULL DEFAULT 0,
last_season_reward_received_season_number INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, event_quest_chapter_id)
);
CREATE TABLE user_event_quest_labyrinth_stages (
user_id INTEGER NOT NULL REFERENCES users(user_id),
event_quest_chapter_id INTEGER NOT NULL,
stage_order INTEGER NOT NULL,
is_received_stage_clear_reward INTEGER NOT NULL DEFAULT 0,
accumulation_reward_received_quest_mission_count INTEGER NOT NULL DEFAULT 0,
latest_version INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, event_quest_chapter_id, stage_order)
);
-- +goose Down
DROP TABLE IF EXISTS user_event_quest_labyrinth_stages;
DROP TABLE IF EXISTS user_event_quest_labyrinth_seasons;
+21
View File
@@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"embed"
"github.com/pressly/goose/v3"
)
//go:embed *.sql
var FS embed.FS
func Up(ctx context.Context, db *sql.DB) error {
goose.SetBaseFS(FS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.UpContext(ctx, db, ".", goose.WithAllowMissing())
}