3 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
38 changed files with 1294 additions and 295 deletions
+117
View File
@@ -0,0 +1,117 @@
name: Build and Publish Release Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { goos: linux, goarch: amd64, archive: tar.gz }
- { goos: linux, goarch: arm64, archive: tar.gz }
- { goos: darwin, goarch: amd64, archive: tar.gz }
- { goos: darwin, goarch: arm64, archive: tar.gz }
- { goos: windows, goarch: amd64, archive: zip }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Install protoc
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: Install protoc-gen-go plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: Generate proto stubs
working-directory: server
run: make proto
- name: Cross-compile binaries
working-directory: server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
set -euo pipefail
stage="staging"
mkdir -p "$stage/bin"
ext=""
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
build() {
local name="$1" dest="$2"
go build -trimpath -ldflags="-s -w" -o "$dest/${name}${ext}" "./cmd/${name}"
}
# Wizard sits at the root so end-users see it immediately.
build wizard "$stage"
# Sub-services and admin tools go in bin/ to match the runtime layout.
for name in dev lunar-tear octo-cdn auth-server import-snapshot claim-account register-account wizard-restore; do
build "$name" "$stage/bin"
done
- name: Stage docs
working-directory: server
run: |
cp ../README.md staging/README.md
cp ../LICENSE staging/LICENSE
- name: Archive
working-directory: server
run: |
set -euo pipefail
name="lunar-tear-server-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}"
mv staging "$name"
if [ "${{ matrix.archive }}" = "zip" ]; then
zip -r "${name}.zip" "$name"
else
tar -czf "${name}.tar.gz" "$name"
fi
ls -lh "${name}".*
- uses: actions/upload-artifact@v4
with:
name: lunar-tear-server-${{ matrix.goos }}-${{ matrix.goarch }}
path: server/lunar-tear-server-*.${{ matrix.archive }}
if-no-files-found: error
release:
name: Attach archives to GitHub Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: List downloaded artifacts
run: ls -lh artifacts/
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
draft: true
+94 -87
View File
@@ -5,7 +5,14 @@ Discord server: https://discord.gg/MZAf5aVkJG
## How To Launch The Server ## How To Launch The Server
### Prerequisites ### Download & Run (no setup)
Prebuilt binaries are published for Linux, macOS, and Windows on the [Releases page](https://github.com/Walter-Sparrow/lunar-tear/releases).
1. Download the archive for your OS/arch (`lunar-tear-server-<version>-<os>-<arch>.{tar.gz,zip}`).
2. Run `./wizard` (macOS/Linux) or double-click `wizard.exe` (Windows).
### Prerequisites (build from source)
- Go 1.25+ - Go 1.25+
- [goose](https://github.com/pressly/goose) migration tool - [goose](https://github.com/pressly/goose) migration tool
@@ -40,12 +47,12 @@ By default the wizard uses ports 8003 (gRPC), 8080 (CDN), and 3000 (auth). Overr
go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080 go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ------- | ---------------------------------- | | ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `--prefer-saved` | `false` | Reuse saved config without prompting | | `--prefer-saved` | `false` | Reuse saved config without prompting |
| `--grpc-port` | `8003` | gRPC server port | | `--grpc-port` | `8003` | gRPC server port |
| `--cdn-port` | `8080` | CDN server port | | `--cdn-port` | `8080` | CDN server port |
| `--auth-port` | `3000` | Auth server port | | `--auth-port` | `3000` | Auth server port |
| `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin-port` | `0` | Admin webhook port (`0` = disabled). Bound on `127.0.0.1`; only takes effect when `LUNAR_ADMIN_TOKEN` is set in the env. |
Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing. Custom ports are saved to `.wizard.json` alongside your other settings. On the next run the saved ports are reused automatically — no need to pass the flags again. If you later pass different port flags, the wizard warns you that the ports changed and asks for confirmation before continuing.
@@ -105,9 +112,9 @@ go run ./cmd/import-snapshot \
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | --------------------------------------------- | | ------------ | ------------ | --------------------------------------------- |
| `--snapshot` | *(required)* | Path to JSON snapshot file | | `--snapshot` | _(required)_ | Path to JSON snapshot file |
| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) | | `--uuid` | _(required)_ | UUID to assign (must match the client's UUID) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
### Run ### Run
@@ -174,40 +181,40 @@ Or via `make`:
make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000" make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| --------------------- | ------------------ | ---------------------------------------- | | -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address | | `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
| `--auth.db` | `db/auth.db` | auth-server SQLite database path | | `--auth.db` | `db/auth.db` | auth-server SQLite database path |
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address | | `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
| `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr | | `--cdn.public-addr` | `10.0.2.2:8080` | octo-cdn externally-reachable addr |
| `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address | | `--grpc.listen` | `0.0.0.0:8003` | lunar-tear gRPC listen address |
| `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr | | `--grpc.public-addr` | `10.0.2.2:8003` | lunar-tear externally-reachable addr |
| `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear | | `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear |
| `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear | | `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear |
| `--no-register` | `false` | disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | disable new user registrations (only already registered users can connect). |
| `--admin.listen` | *(empty)* | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. | | `--admin.listen` | _(empty)_ | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
| `--no-color` | `false` | disable colored output | | `--no-color` | `false` | disable colored output |
### Ports ### Ports
| Protocol | Port | Binary | Notes | | Protocol | Port | Binary | Notes |
| -------- | ---- | ------------- | ----------------------------------------------------------- | | -------- | ---- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) | | gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages | | HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
| HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set | | HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set |
| HTTP | 3000 | `auth-server` | account registration and login | | HTTP | 3000 | `auth-server` | account registration and login |
### Game Server Flags (`lunar-tear`) ### Game Server Flags (`lunar-tear`)
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | ----------------- | ---------------------------------------------------- | | ---------------- | ---------------- | --------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) | | `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients | | `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) | | `--octo-url` | _(required)_ | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) | | `--auth-url` | _(empty)_ | Auth server base URL (e.g. `http://localhost:3000`) |
| `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. | | `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. |
| `--no-register` | `false` | Disable new user registrations (only already registered users can connect). | | `--no-register` | `false` | Disable new user registrations (only already registered users can connect). |
### Live Master Data Reload ### Live Master Data Reload
@@ -231,11 +238,11 @@ Security defaults are fail-closed:
### CDN Flags (`octo-cdn`) ### CDN Flags (`octo-cdn`)
| Flag | Default | Description | | Flag | Default | Description |
| --------------- | ----------------- | -------------------------------------------------------- | | --------------- | ---------------- | --------------------------------------------------------- |
| `--listen` | `0.0.0.0:8080` | local bind address | | `--listen` | `0.0.0.0:8080` | local bind address |
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) | | `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
| `--assets-dir` | `.` | root directory containing the `assets/` tree | | `--assets-dir` | `.` | root directory containing the `assets/` tree |
### Docker ### Docker
@@ -250,22 +257,22 @@ The `db/` directory is mounted as a volume so both `game.db` and `auth.db` persi
Each service has its own image and can be deployed independently: Each service has its own image and can be deployed independently:
| Service | Image | Default Port | Notes | | Service | Image | Default Port | Notes |
| -------- | --------------------------- | ------------ | ------------------------------ | | -------- | --------------------------- | ------------ | -------------------------------- |
| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook | | `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN | | `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login | | `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
The game server is configured via environment variables in the compose file: The game server is configured via environment variables in the compose file:
| Env var | Description | | Env var | Description |
| --------------------- | -------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------- |
| `LUNAR_LISTEN` | gRPC bind address | | `LUNAR_LISTEN` | gRPC bind address |
| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game | | `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets | | `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
| `LUNAR_AUTH_URL` | Auth server base URL (optional) | | `LUNAR_AUTH_URL` | Auth server base URL (optional) |
| `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) | | `LUNAR_ADMIN_LISTEN` | Admin webhook bind address inside the container (compose default: `0.0.0.0:8082`) |
| `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** | | `LUNAR_ADMIN_TOKEN` | Bearer token for the admin webhook. **The webhook does not bind unless this is set.** |
Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up. Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without it. The admin webhook is published to `127.0.0.1:8082` on the host so the master-data reload endpoint stays loopback-only by default; set `LUNAR_ADMIN_TOKEN` (e.g. via a `.env` file) before bringing the stack up.
@@ -273,22 +280,22 @@ Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without
All targets run from the `server/` directory. All targets run from the `server/` directory.
| Target | Description | | Target | Description |
| -------------- | ------------------------------------------------------- | | ----------------------------- | ------------------------------------------------------ |
| `make proto` | Regenerate protobuf stubs | | `make proto` | Regenerate protobuf stubs |
| `make build` | Build the game server binary | | `make build` | Build the game server binary |
| `make build-cdn` | Build the CDN binary | | `make build-cdn` | Build the CDN binary |
| `make build-auth` | Build the auth server binary | | `make build-auth` | Build the auth server binary |
| `make build-dev` | Build the dev runner binary to `bin/` | | `make build-dev` | Build the dev runner binary to `bin/` |
| `make build-all` | Build all service binaries to `bin/` | | `make build-all` | Build all service binaries to `bin/` |
| `make build-import` | Build the import-snapshot tool | | `make build-import` | Build the import-snapshot tool |
| `make build-claim-account` | Build the claim-account tool | | `make build-claim-account` | Build the claim-account tool |
| `make build-register-account` | Build the register-account tool | | `make build-register-account` | Build the register-account tool |
| `make clean` | Remove the `bin/` directory | | `make clean` | Remove the `bin/` directory |
| `make dev` | Run all three services with one command | | `make dev` | Run all three services with one command |
| `make migrate` | Run goose migrations on `db/game.db` | | `make migrate` | Run goose migrations on `db/game.db` |
| `make restore` | Interactive restore of `db/game.db` from `db/backups/` | | `make restore` | Interactive restore of `db/game.db` from `db/backups/` |
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) | | `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
## Claim Account ## Claim Account
@@ -301,10 +308,10 @@ cd server
go run ./cmd/claim-account --name "PlayerName" --db db/game.db go run ./cmd/claim-account --name "PlayerName" --db db/game.db
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| -------- | ------------ | ---------------------------------------------------- | | -------- | ------------ | ---------------------------- |
| `--name` | *(required)* | In-game player name to claim | | `--name` | _(required)_ | In-game player name to claim |
| `--db` | `db/game.db` | SQLite database path | | `--db` | `db/game.db` | SQLite database path |
## Auth Server ## Auth Server
@@ -323,12 +330,12 @@ The `--secret` flag accepts a hex-encoded HMAC key. If omitted, a random key is
### Flags ### Flags
| Flag | Default | Description | | Flag | Default | Description |
| ---------------- | --------------- | -------------------------------------------- | | --------------- | -------------- | -------------------------------------------------------------------------- |
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) | | `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
| `--db` | `db/auth.db` | SQLite database path for auth users | | `--db` | `db/auth.db` | SQLite database path for auth users |
| `--secret` | *(generated)* | Hex-encoded HMAC secret for token signing | | `--secret` | _(generated)_ | Hex-encoded HMAC secret for token signing |
| `--no-register` | `false` | Disable new user registrations (only already registered users can log in). | | `--no-register` | `false` | Disable new user registrations (only already registered users can log in). |
## Create account ## Create account
@@ -339,13 +346,13 @@ A primary mean of registering new accounts when `--no-register` flag is passed t
go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android" go run ./cmd/register-account --name "AccountName" --password "AccountPassword" --platform "android"
``` ```
| Flag | Default | Description | | Flag | Default | Description |
| ------------ | ------------ | ------------------------------------------------------------ | | ------------ | ------------ | ------------------------------------------------- |
| `--name` | *(required)* | Auth Server account nickname to be registered | | `--name` | _(required)_ | Auth Server account nickname to be registered |
| `--password` | *(required)* | Auth Server account password to be registered | | `--password` | _(required)_ | Auth Server account password to be registered |
| `--platform` | `android` | Platform of new user account (`android` or `ios`) | | `--platform` | `android` | Platform of new user account (`android` or `ios`) |
| `--db` | `db/game.db` | SQLite main database path | | `--db` | `db/game.db` | SQLite main database path |
| `--auth-db` | `db/auth.db` | SQLite Auth Server database path | | `--auth-db` | `db/auth.db` | SQLite Auth Server database path |
This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login! This only sets the nickname of Auth Server account, a player can choose their in-game nickname upon first login!
+1
View File
@@ -40,6 +40,7 @@ var childTables = []string{
"user_big_hunt_max_scores", "user_big_hunt_max_scores",
"user_quest_limit_content_status", "user_quest_limit_content_status",
"user_side_story_quests", "user_side_story_quests",
"user_main_quest_season_routes",
"user_missions", "user_missions",
"user_quest_missions", "user_quest_missions",
"user_quests", "user_quests",
+6 -2
View File
@@ -120,8 +120,12 @@ func main() {
colorCyan = "" colorCyan = ""
} }
log.Println("building services...") if _, err := os.Stat("go.mod"); err == nil {
buildAll() log.Println("building services...")
buildAll()
} else {
log.Println("prebuilt mode: skipping build, using bin/ from archive")
}
ext := binExt() ext := binExt()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+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
}
+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),
+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
}
+7 -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,14 +43,16 @@ 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) 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)
} }
+7 -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,13 +41,15 @@ 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)
} }
+37 -1
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"
@@ -28,9 +31,10 @@ type QuestHandler struct {
Config *masterdata.GameConfig Config *masterdata.GameConfig
Granter *store.PossessionGranter Granter *store.PossessionGranter
SideStoryChapterByEventQuestId map[int32]int32 SideStoryChapterByEventQuestId map[int32]int32
Campaigns *campaign.Catalog
} }
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog) *QuestHandler { func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog) *QuestHandler {
granter := BuildGranter(catalog) granter := BuildGranter(catalog)
var sideStoryChapters map[int32]int32 var sideStoryChapters map[int32]int32
if sideStory != nil { if sideStory != nil {
@@ -41,6 +45,7 @@ func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameCo
Config: config, Config: config,
Granter: granter, Granter: granter,
SideStoryChapterByEventQuestId: sideStoryChapters, SideStoryChapterByEventQuestId: sideStoryChapters,
Campaigns: campaigns,
} }
} }
@@ -70,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,
@@ -84,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,
} }
} }
+10 -7
View File
@@ -61,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]
@@ -259,7 +260,7 @@ 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
@@ -277,8 +278,9 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
} }
} }
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)
} }
@@ -322,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) {
+21 -24
View File
@@ -46,7 +46,6 @@ 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())
} }
} }
} }
@@ -59,22 +58,27 @@ func (h *QuestHandler) advanceReplayFlowScene(user *store.UserState, sceneId int
user.MainQuest.ReplayFlowHeadQuestSceneId = sceneId user.MainQuest.ReplayFlowHeadQuestSceneId = sceneId
} }
func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) { func (h *QuestHandler) SeasonRoutesFor(user *store.UserState) map[int32]int32 {
if seasonId <= 0 || routeId <= 0 { out := make(map[int32]int32)
return for seasonId, routes := range h.RoutesBySeason {
if seasonId <= 1 {
continue
}
for _, routeId := range routes {
finalQuestId, ok := h.RouteCompletionQuestId[routeId]
if !ok {
continue
}
if q, ok := user.Quests[finalQuestId]; ok && q.ClearCount > 0 {
out[seasonId] = routeId
break
}
}
} }
if user.MainQuestSeasonRoutes == nil { if cur := user.MainQuest.MainQuestSeasonId; cur >= 2 && user.MainQuest.CurrentMainQuestRouteId > 0 {
user.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry) out[cur] = user.MainQuest.CurrentMainQuestRouteId
}
key := store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId}
if _, exists := user.MainQuestSeasonRoutes[key]; exists {
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) {
@@ -177,15 +181,8 @@ 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
}
}
if len(user.MainQuestSeasonRoutes) == 0 &&
user.MainQuest.MainQuestSeasonId == seasonId &&
user.MainQuest.CurrentMainQuestRouteId != 0 &&
user.MainQuest.CurrentMainQuestRouteId != routeId {
return model.QuestFlowTypeAnotherRouteReplayFlow return model.QuestFlowTypeAnotherRouteReplayFlow
} }
return model.QuestFlowTypeReplayFlow return model.QuestFlowTypeReplayFlow
+10 -1
View File
@@ -4,10 +4,12 @@ import (
"fmt" "fmt"
"log" "log"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gacha"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/masterdata/memorydb" "lunar-tear/server/internal/masterdata/memorydb"
"lunar-tear/server/internal/questflow" "lunar-tear/server/internal/questflow"
"lunar-tear/server/internal/userdata"
) )
// buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever // buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever
@@ -34,7 +36,13 @@ func buildCatalogs() (*Catalogs, error) {
return nil, fmt.Errorf("load quest catalog: %w", err) return nil, fmt.Errorf("load quest catalog: %w", err)
} }
sideStoryCatalog := masterdata.LoadSideStoryCatalog() sideStoryCatalog := masterdata.LoadSideStoryCatalog()
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog) campaignCatalog, err := campaign.Load()
if err != nil {
return nil, fmt.Errorf("load campaign catalog: %w", err)
}
log.Printf("campaign catalog loaded: %d enhance, %d quest", campaignCatalog.EnhanceCount(), campaignCatalog.QuestCount())
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 {
@@ -170,6 +178,7 @@ func buildCatalogs() (*Catalogs, error) {
BigHunt: bigHuntCatalog, BigHunt: bigHuntCatalog,
Tower: towerCatalog, Tower: towerCatalog,
Labyrinth: labyrinthCatalog, Labyrinth: labyrinthCatalog,
Campaign: campaignCatalog,
QuestHandler: questHandler, QuestHandler: questHandler,
GachaHandler: gachaHandler, GachaHandler: gachaHandler,
}, nil }, nil
+2 -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"
@@ -52,23 +53,17 @@ type Catalogs struct {
BigHunt *masterdata.BigHuntCatalog BigHunt *masterdata.BigHuntCatalog
Tower *masterdata.TowerCatalog Tower *masterdata.TowerCatalog
Labyrinth *masterdata.LabyrinthCatalog 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 {
@@ -77,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)
@@ -91,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()
} }
+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 {
+12 -4
View File
@@ -7,8 +7,10 @@ import (
"math/rand" "math/rand"
pb "lunar-tear/server/gen/proto" pb "lunar-tear/server/gen/proto"
"lunar-tear/server/internal/campaign"
"lunar-tear/server/internal/gametime" "lunar-tear/server/internal/gametime"
"lunar-tear/server/internal/masterdata" "lunar-tear/server/internal/masterdata"
"lunar-tear/server/internal/model"
"lunar-tear/server/internal/runtime" "lunar-tear/server/internal/runtime"
"lunar-tear/server/internal/store" "lunar-tear/server/internal/store"
) )
@@ -180,17 +182,23 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
successRate = r successRate = r
} }
} }
baseRate := successRate
successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{
PartsId: part.PartsId,
PartsGroupId: partDef.PartsGroupId,
Rarity: model.RarityType(partDef.RarityType),
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate)
if rand.Intn(1000) < int(successRate) { if rand.Intn(1000) < int(successRate) {
part.Level++ part.Level++
isSuccess = true isSuccess = true
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level-1, part.Level, successRate, goldCost) part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost)
grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis) grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis)
} else { } else {
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)", log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)",
part.PartsId, part.Level, successRate, goldCost) part.PartsId, part.Level, successRate, baseRate, goldCost)
} }
part.LatestVersion = nowMillis part.LatestVersion = nowMillis
-1
View File
@@ -198,7 +198,6 @@ func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteReque
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok { if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
user.MainQuest.MainQuestSeasonId = seasonId user.MainQuest.MainQuestSeasonId = seasonId
questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis())
} }
now := gametime.NowMillis() now := gametime.NowMillis()
user.PortalCageStatus.IsCurrentProgress = false user.PortalCageStatus.IsCurrentProgress = false
+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 {
+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) {
-11
View File
@@ -86,7 +86,6 @@ func initMaps(u *store.UserState) {
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)
@@ -378,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
-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 {
@@ -798,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}
-15
View File
@@ -41,7 +41,6 @@ type UserState struct {
LoginBonus UserLoginBonusState LoginBonus UserLoginBonusState
Tutorials map[int32]TutorialProgressState Tutorials map[int32]TutorialProgressState
MainQuest MainQuestState MainQuest MainQuestState
MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry
EventQuest EventQuestState EventQuest EventQuestState
ExtraQuest ExtraQuestState ExtraQuest ExtraQuestState
SideStoryQuests map[int32]SideStoryQuestProgress SideStoryQuests map[int32]SideStoryQuestProgress
@@ -162,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)
} }
@@ -590,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
+4 -2
View File
@@ -101,8 +101,9 @@ func ChangedTables(before, after *store.UserState) []string {
add("IUserMainQuestMainFlowStatus") add("IUserMainQuestMainFlowStatus")
add("IUserMainQuestProgressStatus") add("IUserMainQuestProgressStatus")
add("IUserMainQuestReplayFlowStatus") add("IUserMainQuestReplayFlowStatus")
} // IUserMainQuestSeasonRoute is derived from MainQuest + Quests at projection
if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) { // time (see proj_quest.go / questflow.QuestHandler.SeasonRoutesFor) — flag it
// whenever either of those upstream inputs changes.
add("IUserMainQuestSeasonRoute") add("IUserMainQuestSeasonRoute")
} }
if before.EventQuest != after.EventQuest { if before.EventQuest != after.EventQuest {
@@ -202,6 +203,7 @@ func ChangedTables(before, after *store.UserState) []string {
} }
if !mapsEqualStruct(before.Quests, after.Quests) { if !mapsEqualStruct(before.Quests, after.Quests) {
add("IUserQuest") add("IUserQuest")
add("IUserMainQuestSeasonRoute")
} }
if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) { if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) {
add("IUserQuestMission") add("IUserQuestMission")
+36 -27
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,
@@ -116,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{
+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
} }
+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())
}