mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Compare commits
26 Commits
6c9e3c45f0
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7c1df4fd | |||
| 2d0c0d8ef0 | |||
| 810adcf990 | |||
| ef69c54949 | |||
| b65c1c5fce | |||
| ab5a999ffe | |||
| 8520b67a8b | |||
| 42ff8ec88f | |||
| 2cf0c153e1 | |||
| 956dbfaefd | |||
| 0d46ee4557 | |||
| fa2a124d47 | |||
| 25cbe8635f | |||
| 1dc5b8fd7c | |||
| c9a1929279 | |||
| fb111cf1ec | |||
| 26c10ac429 | |||
| 15beefb5b8 | |||
| dd00cadc18 | |||
| ae884b4060 | |||
| fa5d023f58 | |||
| 00817684ef | |||
| 44a03d222b | |||
| 23f0d26fcd | |||
| cc9dc4f1c5 | |||
| 479ace5c8e |
@@ -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
|
||||
@@ -11,6 +11,7 @@ server/claim-account
|
||||
server/octo-cdn
|
||||
server/dev
|
||||
server/wizard
|
||||
server/wizard-restore
|
||||
server/.wizard.json
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -5,7 +5,14 @@ Discord server: https://discord.gg/MZAf5aVkJG
|
||||
|
||||
## How To Launch The Server
|
||||
|
||||
### Prerequisites
|
||||
### Download & Run (no setup)
|
||||
|
||||
Prebuilt binaries are published for Linux, macOS, and Windows on the [Releases page](https://github.com/Walter-Sparrow/lunar-tear/releases).
|
||||
|
||||
1. Download the archive for your OS/arch (`lunar-tear-server-<version>-<os>-<arch>.{tar.gz,zip}`).
|
||||
2. Run `./wizard` (macOS/Linux) or double-click `wizard.exe` (Windows).
|
||||
|
||||
### Prerequisites (build from source)
|
||||
|
||||
- Go 1.25+
|
||||
- [goose](https://github.com/pressly/goose) migration tool
|
||||
@@ -41,7 +48,7 @@ go run ./cmd/wizard --grpc-port 9003 --cdn-port 9080
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---------------- | ------- | ---------------------------------- |
|
||||
| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `--prefer-saved` | `false` | Reuse saved config without prompting |
|
||||
| `--grpc-port` | `8003` | gRPC server port |
|
||||
| `--cdn-port` | `8080` | CDN server port |
|
||||
@@ -74,6 +81,17 @@ mkdir -p db
|
||||
goose -dir migrations -allow-missing sqlite3 db/game.db up
|
||||
```
|
||||
|
||||
### Backups & Restore
|
||||
|
||||
The wizard backs up your save every time you launch it. To roll back to an earlier save:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
make restore
|
||||
```
|
||||
|
||||
Pick a backup from the list and confirm.
|
||||
|
||||
### Importing a Snapshot
|
||||
|
||||
To import a JSON snapshot into the database, use the import tool. The `--uuid` flag must match the UUID your game client sends during authentication:
|
||||
@@ -94,8 +112,8 @@ go run ./cmd/import-snapshot \
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ------------ | ------------ | --------------------------------------------- |
|
||||
| `--snapshot` | *(required)* | Path to JSON snapshot file |
|
||||
| `--uuid` | *(required)* | UUID to assign (must match the client's UUID) |
|
||||
| `--snapshot` | _(required)_ | Path to JSON snapshot file |
|
||||
| `--uuid` | _(required)_ | UUID to assign (must match the client's UUID) |
|
||||
| `--db` | `db/game.db` | SQLite database path |
|
||||
|
||||
### Run
|
||||
@@ -164,7 +182,7 @@ make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| --------------------- | ------------------ | ---------------------------------------- |
|
||||
| -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--auth.listen` | `0.0.0.0:3000` | auth-server listen address |
|
||||
| `--auth.db` | `db/auth.db` | auth-server SQLite database path |
|
||||
| `--cdn.listen` | `0.0.0.0:8080` | octo-cdn local bind address |
|
||||
@@ -174,13 +192,13 @@ make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
|
||||
| `--grpc.octo-url` | `http://10.0.2.2:8080` | Octo CDN base URL passed to lunar-tear |
|
||||
| `--grpc.auth-url` | `http://localhost:3000` | auth server base URL passed to lunar-tear |
|
||||
| `--no-register` | `false` | disable new user registrations (only already registered users can connect). |
|
||||
| `--admin.listen` | *(empty)* | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
|
||||
| `--admin.listen` | _(empty)_ | lunar-tear admin webhook bind. Empty = leave default; webhook only binds when `LUNAR_ADMIN_TOKEN` is set in the env. |
|
||||
| `--no-color` | `false` | disable colored output |
|
||||
|
||||
### Ports
|
||||
|
||||
| Protocol | Port | Binary | Notes |
|
||||
| -------- | ---- | ------------- | ----------------------------------------------------------- |
|
||||
| -------- | ---- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| gRPC | 443 | `lunar-tear` | default; configurable with `--listen` (requires patched client) |
|
||||
| HTTP | 8080 | `octo-cdn` | Octo asset API + game web pages |
|
||||
| HTTP | 8082 | `lunar-tear` | admin webhook (`/api/admin/master-data/reload`); loopback by default, only binds when `LUNAR_ADMIN_TOKEN` is set |
|
||||
@@ -189,12 +207,12 @@ make dev ARGS="--grpc.listen 0.0.0.0:9000 --grpc.public-addr 10.0.2.2:9000"
|
||||
### Game Server Flags (`lunar-tear`)
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---------------- | ----------------- | ---------------------------------------------------- |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------- |
|
||||
| `--listen` | `0.0.0.0:443` | gRPC listen address (host:port) |
|
||||
| `--public-addr` | `127.0.0.1:443` | externally-reachable host:port advertised to clients |
|
||||
| `--octo-url` | *(required)* | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
|
||||
| `--octo-url` | _(required)_ | CDN base URL the client uses for assets (e.g. `http://10.0.2.2:8080`) |
|
||||
| `--db` | `db/game.db` | SQLite database path |
|
||||
| `--auth-url` | *(empty)* | Auth server base URL (e.g. `http://localhost:3000`) |
|
||||
| `--auth-url` | _(empty)_ | Auth server base URL (e.g. `http://localhost:3000`) |
|
||||
| `--admin-listen` | `127.0.0.1:8082` | Admin webhook listen address. Only binds when `LUNAR_ADMIN_TOKEN` is set. |
|
||||
| `--no-register` | `false` | Disable new user registrations (only already registered users can connect). |
|
||||
|
||||
@@ -221,7 +239,7 @@ Security defaults are fail-closed:
|
||||
### CDN Flags (`octo-cdn`)
|
||||
|
||||
| Flag | Default | Description |
|
||||
| --------------- | ----------------- | -------------------------------------------------------- |
|
||||
| --------------- | ---------------- | --------------------------------------------------------- |
|
||||
| `--listen` | `0.0.0.0:8080` | local bind address |
|
||||
| `--public-addr` | `127.0.0.1:8080` | externally-reachable address (used in list.bin rewriting) |
|
||||
| `--assets-dir` | `.` | root directory containing the `assets/` tree |
|
||||
@@ -240,7 +258,7 @@ The `db/` directory is mounted as a volume so both `game.db` and `auth.db` persi
|
||||
Each service has its own image and can be deployed independently:
|
||||
|
||||
| Service | Image | Default Port | Notes |
|
||||
| -------- | --------------------------- | ------------ | ------------------------------ |
|
||||
| -------- | --------------------------- | ------------ | -------------------------------- |
|
||||
| `server` | `kretts/lunar-tear:latest` | 8003, 8082 | gRPC game server + admin webhook |
|
||||
| `cdn` | `kretts/octo-cdn:latest` | 8080 | HTTP asset CDN |
|
||||
| `auth` | `kretts/auth-server:latest` | 3000 | Account registration and login |
|
||||
@@ -248,7 +266,7 @@ Each service has its own image and can be deployed independently:
|
||||
The game server is configured via environment variables in the compose file:
|
||||
|
||||
| Env var | Description |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| -------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `LUNAR_LISTEN` | gRPC bind address |
|
||||
| `LUNAR_PUBLIC_ADDR` | Client-facing address advertised to the game |
|
||||
| `LUNAR_OCTO_URL` | CDN base URL the client uses for assets |
|
||||
@@ -263,7 +281,7 @@ Auth is optional — if `LUNAR_AUTH_URL` is unset the game server starts without
|
||||
All targets run from the `server/` directory.
|
||||
|
||||
| Target | Description |
|
||||
| -------------- | ------------------------------------------------------- |
|
||||
| ----------------------------- | ------------------------------------------------------ |
|
||||
| `make proto` | Regenerate protobuf stubs |
|
||||
| `make build` | Build the game server binary |
|
||||
| `make build-cdn` | Build the CDN binary |
|
||||
@@ -276,6 +294,7 @@ All targets run from the `server/` directory.
|
||||
| `make clean` | Remove the `bin/` directory |
|
||||
| `make dev` | Run all three services with one command |
|
||||
| `make migrate` | Run goose migrations on `db/game.db` |
|
||||
| `make restore` | Interactive restore of `db/game.db` from `db/backups/` |
|
||||
| `make import` | Import a snapshot (`SNAPSHOT=... UUID=...` required) |
|
||||
|
||||
## Claim Account
|
||||
@@ -290,8 +309,8 @@ go run ./cmd/claim-account --name "PlayerName" --db db/game.db
|
||||
```
|
||||
|
||||
| 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 |
|
||||
|
||||
## Auth Server
|
||||
@@ -312,10 +331,10 @@ The `--secret` flag accepts a hex-encoded HMAC key. If omitted, a random key is
|
||||
### Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---------------- | --------------- | -------------------------------------------- |
|
||||
| --------------- | -------------- | -------------------------------------------------------------------------- |
|
||||
| `--listen` | `0.0.0.0:3000` | HTTP listen address (host:port) |
|
||||
| `--db` | `db/auth.db` | SQLite database path for auth users |
|
||||
| `--secret` | *(generated)* | Hex-encoded HMAC secret for token signing |
|
||||
| `--secret` | _(generated)_ | Hex-encoded HMAC secret for token signing |
|
||||
| `--no-register` | `false` | Disable new user registrations (only already registered users can log in). |
|
||||
|
||||
## Create account
|
||||
@@ -328,9 +347,9 @@ go run ./cmd/register-account --name "AccountName" --password "AccountPassword"
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ------------ | ------------ | ------------------------------------------------------------ |
|
||||
| `--name` | *(required)* | Auth Server account nickname to be registered |
|
||||
| `--password` | *(required)* | Auth Server account password to be registered |
|
||||
| ------------ | ------------ | ------------------------------------------------- |
|
||||
| `--name` | _(required)_ | Auth Server account nickname to be registered |
|
||||
| `--password` | _(required)_ | Auth Server account password to be registered |
|
||||
| `--platform` | `android` | Platform of new user account (`android` or `ios`) |
|
||||
| `--db` | `db/game.db` | SQLite main database path |
|
||||
| `--auth-db` | `db/auth.db` | SQLite Auth Server database path |
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-16
|
||||
|
||||
### Working
|
||||
|
||||
- `--no-register` flag and `register-account` CLI tool
|
||||
- Database backup support in the wizard
|
||||
- Subjugation Quests — rewards, battle report, and triple-deck preset persistence
|
||||
- `MaterialSaleObtainPossession` item grants when selling materials
|
||||
- Memoir protect / unprotect
|
||||
- Effect item usage (`UseEffectItem`)
|
||||
- Recollections of Dusk
|
||||
|
||||
### Fixed
|
||||
|
||||
- Login bonus
|
||||
- Main quest replay (Map replay)
|
||||
- Weapon awaken level cap
|
||||
- Gacha pool overhaul
|
||||
- Quest mission rewards
|
||||
- Menu-pick quest start — wrong state from replay flow, black screen on quests with no difficulty, and normal-difficulty handling
|
||||
|
||||
## 2026-05-02
|
||||
|
||||
### Working
|
||||
|
||||
+4
-1
@@ -51,6 +51,9 @@ dev:
|
||||
migrate:
|
||||
$(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up
|
||||
|
||||
restore:
|
||||
go run ./cmd/wizard-restore
|
||||
|
||||
import:
|
||||
ifndef SNAPSHOT
|
||||
$(error SNAPSHOT is required, e.g. make import SNAPSHOT=snapshots/scene_1.json UUID=...)
|
||||
@@ -60,4 +63,4 @@ ifndef UUID
|
||||
endif
|
||||
go run ./cmd/import-snapshot --snapshot $(SNAPSHOT) --uuid $(UUID)
|
||||
|
||||
.PHONY: proto build build-cdn build-auth build-import build-claim-account build-register-account build-dev build-all clean dev migrate import
|
||||
.PHONY: proto build build-cdn build-auth build-import build-claim-account build-register-account build-dev build-all clean dev migrate restore import
|
||||
|
||||
@@ -40,6 +40,7 @@ var childTables = []string{
|
||||
"user_big_hunt_max_scores",
|
||||
"user_quest_limit_content_status",
|
||||
"user_side_story_quests",
|
||||
"user_main_quest_season_routes",
|
||||
"user_missions",
|
||||
"user_quest_missions",
|
||||
"user_quests",
|
||||
|
||||
@@ -120,8 +120,12 @@ func main() {
|
||||
colorCyan = ""
|
||||
}
|
||||
|
||||
if _, err := os.Stat("go.mod"); err == nil {
|
||||
log.Println("building services...")
|
||||
buildAll()
|
||||
} else {
|
||||
log.Println("prebuilt mode: skipping build, using bin/ from archive")
|
||||
}
|
||||
|
||||
ext := binExt()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
@@ -124,4 +124,5 @@ func registerServices(
|
||||
pb.RegisterSideStoryQuestServiceServer(srv, service.NewSideStoryQuestServiceServer(userStore, userStore, holder))
|
||||
pb.RegisterBigHuntServiceServer(srv, service.NewBigHuntServiceServer(userStore, userStore, holder))
|
||||
pb.RegisterRewardServiceServer(srv, service.NewRewardServiceServer(userStore, userStore, holder))
|
||||
pb.RegisterLabyrinthServiceServer(srv, service.NewLabyrinthServiceServer(userStore, userStore, holder))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
gameDBPath = "db/game.db"
|
||||
backupDir = "db/backups"
|
||||
backupSuffix = ".bak"
|
||||
)
|
||||
|
||||
const banner = `
|
||||
_ _____
|
||||
| | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _
|
||||
| |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_|
|
||||
|____\_,_|_||_\__,_|_| |_|\___|\__,_|_|
|
||||
|
||||
╭──────────────────────────────╮
|
||||
│ RESTORE │
|
||||
╰──────────────────────────────╯
|
||||
`
|
||||
|
||||
func main() {
|
||||
lipgloss.EnableLegacyWindowsANSI(os.Stdout)
|
||||
lipgloss.EnableLegacyWindowsANSI(os.Stderr)
|
||||
fmt.Print(banner)
|
||||
|
||||
chosen, ok := pickBackup()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !confirmOverwrite(chosen) {
|
||||
fmt.Println(" cancelled — nothing changed")
|
||||
return
|
||||
}
|
||||
if err := doRestore(chosen); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " restore failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" restored %s from %s\n", gameDBPath, chosen)
|
||||
}
|
||||
|
||||
func pickBackup() (string, bool) {
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, " no backups found in", backupDir)
|
||||
return "", false
|
||||
}
|
||||
var backups []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) {
|
||||
backups = append(backups, e.Name())
|
||||
}
|
||||
}
|
||||
if len(backups) == 0 {
|
||||
fmt.Fprintln(os.Stderr, " no backups found in", backupDir)
|
||||
return "", false
|
||||
}
|
||||
sort.Slice(backups, func(i, j int) bool { return backups[i] > backups[j] })
|
||||
|
||||
options := make([]huh.Option[string], 0, len(backups)+1)
|
||||
for _, name := range backups {
|
||||
options = append(options, huh.NewOption(name, name))
|
||||
}
|
||||
options = append(options, huh.NewOption("Cancel", ""))
|
||||
|
||||
var chosen string
|
||||
if err := huh.NewSelect[string]().
|
||||
Title("Pick a backup to restore").
|
||||
Description("db/game.db will be replaced by the chosen file.").
|
||||
Options(options...).
|
||||
Value(&chosen).
|
||||
Run(); err != nil || chosen == "" {
|
||||
return "", false
|
||||
}
|
||||
return chosen, true
|
||||
}
|
||||
|
||||
func confirmOverwrite(chosen string) bool {
|
||||
confirm := false
|
||||
if err := huh.NewConfirm().
|
||||
Title("Overwrite db/game.db?").
|
||||
Description(fmt.Sprintf(
|
||||
"This will REPLACE db/game.db with %s.\n"+
|
||||
"Any progress since that backup will be lost.\n"+
|
||||
"(A fresh backup will be taken on the next ./wizard launch.)",
|
||||
chosen)).
|
||||
Affirmative("Yes, restore").
|
||||
Negative("Cancel").
|
||||
Value(&confirm).
|
||||
Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return confirm
|
||||
}
|
||||
|
||||
func doRestore(chosen string) error {
|
||||
src := filepath.Join(backupDir, chosen)
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%s no longer exists", src)
|
||||
}
|
||||
if err := copyFile(src, gameDBPath); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(gameDBPath + "-wal")
|
||||
_ = os.Remove(gameDBPath + "-shm")
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2/spinner"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
gameDBPath = "db/game.db"
|
||||
backupDir = "db/backups"
|
||||
backupSuffix = ".bak"
|
||||
backupRetainN = 10
|
||||
)
|
||||
|
||||
func backupGameDB() {
|
||||
if _, err := os.Stat(gameDBPath); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
|
||||
if !sourceMode {
|
||||
fmt.Println(" Backing up db/game.db...")
|
||||
doBackupGameDB()
|
||||
return
|
||||
}
|
||||
|
||||
_ = spinner.New().Title(" Backing up db/game.db...").Action(doBackupGameDB).Run()
|
||||
}
|
||||
|
||||
func doBackupGameDB() {
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " failed to create %s: %v\n", backupDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||
dest := filepath.Join(backupDir, fmt.Sprintf("game.db.%s%s", ts, backupSuffix))
|
||||
|
||||
db, err := sql.Open("sqlite", gameDBPath+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " failed to open %s: %v\n", gameDBPath, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
escaped := strings.ReplaceAll(dest, "'", "''")
|
||||
if _, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", escaped)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " VACUUM INTO failed: %v\n", err)
|
||||
_ = os.Remove(dest)
|
||||
return
|
||||
}
|
||||
|
||||
pruneOldBackups()
|
||||
}
|
||||
|
||||
func pruneOldBackups() {
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var backups []os.DirEntry
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), "game.db.") && strings.HasSuffix(e.Name(), backupSuffix) {
|
||||
backups = append(backups, e)
|
||||
}
|
||||
}
|
||||
if len(backups) <= backupRetainN {
|
||||
return
|
||||
}
|
||||
sort.Slice(backups, func(i, j int) bool { return backups[i].Name() < backups[j].Name() })
|
||||
for _, old := range backups[:len(backups)-backupRetainN] {
|
||||
_ = os.Remove(filepath.Join(backupDir, old.Name()))
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,21 @@ func main() {
|
||||
|
||||
fmt.Print(banner)
|
||||
|
||||
sourceMode = isSourceCheckout()
|
||||
|
||||
if !*setupOnly {
|
||||
validateAssets()
|
||||
if sourceMode {
|
||||
validateTools()
|
||||
validateProtocIncludes()
|
||||
runProtoc()
|
||||
backupGameDB()
|
||||
runMigrate()
|
||||
downloadDeps()
|
||||
} else {
|
||||
backupGameDB()
|
||||
runMigrateEmbedded()
|
||||
}
|
||||
}
|
||||
|
||||
ip, cfg, firstRun := resolveIP(*preferSaved)
|
||||
@@ -900,6 +908,7 @@ func launchDev(ip string, p ports) {
|
||||
}
|
||||
devBin := filepath.Join("bin", "dev"+ext)
|
||||
|
||||
if sourceMode {
|
||||
_ = spinner.New().Title(" Building services...").Action(func() {
|
||||
if err := os.MkdirAll("bin", 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
|
||||
@@ -907,6 +916,7 @@ func launchDev(ip string, p ports) {
|
||||
}
|
||||
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
|
||||
}).Run()
|
||||
}
|
||||
|
||||
devArgs := []string{
|
||||
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
|
||||
|
||||
@@ -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
@@ -7,12 +7,12 @@ require (
|
||||
github.com/pierrec/lz4/v4 v4.1.26
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.49.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -34,21 +34,25 @@ require (
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pressly/goose/v3 v3.27.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
||||
modernc.org/libc v1.72.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -54,8 +54,12 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -66,10 +70,14 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
@@ -82,20 +90,28 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -109,18 +125,25 @@ golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -131,16 +154,21 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -49,11 +49,6 @@ type RewardItem struct {
|
||||
Count int32
|
||||
}
|
||||
|
||||
type BigHuntWeeklyRewardKey struct {
|
||||
ScheduleId int32
|
||||
AttributeType int32
|
||||
}
|
||||
|
||||
type BigHuntCatalog struct {
|
||||
BossQuestById map[int32]BigHuntBossQuestRow
|
||||
QuestById map[int32]BigHuntQuestRow
|
||||
@@ -64,7 +59,7 @@ type BigHuntCatalog struct {
|
||||
ScoreRewardSchedules map[int32][]ScoreRewardScheduleEntry
|
||||
ScoreRewardThresholds map[int32][]ScoreRewardThreshold
|
||||
RewardItems map[int32][]RewardItem
|
||||
WeeklyRewardSchedules map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry
|
||||
WeeklyRewardSchedulesByAttr map[int32][]ScoreRewardScheduleEntry
|
||||
}
|
||||
|
||||
func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMillis int64) int32 {
|
||||
@@ -80,8 +75,8 @@ func (c *BigHuntCatalog) ResolveActiveScoreRewardGroupId(scheduleId int32, nowMi
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupId(key BigHuntWeeklyRewardKey, nowMillis int64) int32 {
|
||||
entries := c.WeeklyRewardSchedules[key]
|
||||
func (c *BigHuntCatalog) ResolveActiveWeeklyRewardGroupIdByAttr(attributeType int32, nowMillis int64) int32 {
|
||||
entries := c.WeeklyRewardSchedulesByAttr[attributeType]
|
||||
for _, e := range entries {
|
||||
if nowMillis >= e.StartDatetime {
|
||||
return e.BigHuntScoreRewardGroupId
|
||||
@@ -264,20 +259,16 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
|
||||
if err != nil {
|
||||
log.Fatalf("load big hunt weekly attribute score reward group schedule table: %v", err)
|
||||
}
|
||||
weeklyRewardSchedules := make(map[BigHuntWeeklyRewardKey][]ScoreRewardScheduleEntry)
|
||||
weeklyRewardSchedulesByAttr := make(map[int32][]ScoreRewardScheduleEntry)
|
||||
for _, r := range weeklySchedRows {
|
||||
key := BigHuntWeeklyRewardKey{
|
||||
ScheduleId: r.BigHuntWeeklyAttributeScoreRewardGroupScheduleId,
|
||||
AttributeType: r.AttributeType,
|
||||
}
|
||||
weeklyRewardSchedules[key] = append(weeklyRewardSchedules[key], ScoreRewardScheduleEntry{
|
||||
weeklyRewardSchedulesByAttr[r.AttributeType] = append(weeklyRewardSchedulesByAttr[r.AttributeType], ScoreRewardScheduleEntry{
|
||||
BigHuntScoreRewardGroupId: r.BigHuntScoreRewardGroupId,
|
||||
StartDatetime: r.StartDatetime,
|
||||
})
|
||||
}
|
||||
for k := range weeklyRewardSchedules {
|
||||
sort.Slice(weeklyRewardSchedules[k], func(i, j int) bool {
|
||||
return weeklyRewardSchedules[k][i].StartDatetime > weeklyRewardSchedules[k][j].StartDatetime
|
||||
for k := range weeklyRewardSchedulesByAttr {
|
||||
sort.Slice(weeklyRewardSchedulesByAttr[k], func(i, j int) bool {
|
||||
return weeklyRewardSchedulesByAttr[k][i].StartDatetime > weeklyRewardSchedulesByAttr[k][j].StartDatetime
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +285,6 @@ func LoadBigHuntCatalog() *BigHuntCatalog {
|
||||
ScoreRewardSchedules: scoreRewardSchedules,
|
||||
ScoreRewardThresholds: scoreRewardThresholds,
|
||||
RewardItems: rewardItems,
|
||||
WeeklyRewardSchedules: weeklyRewardSchedules,
|
||||
WeeklyRewardSchedulesByAttr: weeklyRewardSchedulesByAttr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
type ConsumableItemCatalog struct {
|
||||
All map[int32]EntityMConsumableItem
|
||||
Effects map[int32][]EntityMConsumableItemEffect
|
||||
}
|
||||
|
||||
func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
|
||||
@@ -15,12 +16,20 @@ func LoadConsumableItemCatalog() (*ConsumableItemCatalog, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load consumable item table: %w", err)
|
||||
}
|
||||
effects, err := utils.ReadTable[EntityMConsumableItemEffect]("m_consumable_item_effect")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load consumable item effect table: %w", err)
|
||||
}
|
||||
|
||||
catalog := &ConsumableItemCatalog{
|
||||
All: make(map[int32]EntityMConsumableItem, len(rows)),
|
||||
Effects: make(map[int32][]EntityMConsumableItemEffect, len(effects)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
catalog.All[row.ConsumableItemId] = row
|
||||
}
|
||||
for _, e := range effects {
|
||||
catalog.Effects[e.ConsumableItemId] = append(catalog.Effects[e.ConsumableItemId], e)
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package masterdata
|
||||
|
||||
// Source: based on public_costumes_link_export_2026-05-21_142314.csv by @Keziah,
|
||||
// with each weapon resolved to its m_weapon_evolution_group EvolutionOrder=1 root
|
||||
var costumeWeaponPairings = map[int32]int32{
|
||||
10100: 101001,
|
||||
10101: 101011,
|
||||
10102: 101021,
|
||||
10103: 101031,
|
||||
10104: 101041,
|
||||
10105: 101051,
|
||||
10106: 101061,
|
||||
10107: 101071,
|
||||
10108: 101081,
|
||||
10109: 101091,
|
||||
10110: 101101,
|
||||
10111: 101121,
|
||||
10112: 101111,
|
||||
10113: 101131,
|
||||
10114: 101141,
|
||||
10115: 101151,
|
||||
10116: 101161,
|
||||
10117: 101171,
|
||||
10118: 101181,
|
||||
10119: 101191,
|
||||
10120: 101201,
|
||||
10121: 101211,
|
||||
21000: 210161,
|
||||
21001: 210031,
|
||||
21002: 210171,
|
||||
21003: 210181,
|
||||
21004: 210191,
|
||||
21005: 210271,
|
||||
22000: 220081,
|
||||
22001: 220021,
|
||||
22002: 220051,
|
||||
22003: 220061,
|
||||
22004: 220141,
|
||||
22005: 220161,
|
||||
22006: 220181,
|
||||
22007: 220191,
|
||||
22008: 220211,
|
||||
22009: 220231,
|
||||
22010: 220241,
|
||||
23000: 230001,
|
||||
23001: 230021,
|
||||
23004: 230051,
|
||||
23005: 230151,
|
||||
23006: 230261,
|
||||
23007: 230271,
|
||||
23008: 230281,
|
||||
24000: 240091,
|
||||
24001: 240121,
|
||||
24002: 240131,
|
||||
24003: 240011,
|
||||
24004: 240081,
|
||||
24005: 240201,
|
||||
24006: 240221,
|
||||
24007: 240241,
|
||||
24008: 240271,
|
||||
24009: 240311,
|
||||
25000: 250121,
|
||||
25001: 250071,
|
||||
25002: 250011,
|
||||
25003: 250151,
|
||||
25005: 250021,
|
||||
25006: 250171,
|
||||
25007: 250221,
|
||||
25008: 250231,
|
||||
25009: 250261,
|
||||
31000: 310081,
|
||||
31001: 310061,
|
||||
31002: 310021,
|
||||
31004: 310191,
|
||||
31005: 310211,
|
||||
31008: 310221,
|
||||
31009: 310241,
|
||||
31010: 310261,
|
||||
31011: 310291,
|
||||
31013: 310321,
|
||||
31014: 310331,
|
||||
31015: 310371,
|
||||
31016: 310401,
|
||||
31017: 310411,
|
||||
31018: 310421,
|
||||
31019: 310431,
|
||||
31020: 310461,
|
||||
31021: 310471,
|
||||
31022: 310481,
|
||||
31023: 310511,
|
||||
31024: 310531,
|
||||
31025: 310541,
|
||||
31026: 310551,
|
||||
31027: 310571,
|
||||
31028: 310591,
|
||||
31029: 310621,
|
||||
31030: 310641,
|
||||
31031: 310661,
|
||||
31032: 310691,
|
||||
31033: 310701,
|
||||
31034: 310711,
|
||||
32000: 320081,
|
||||
32001: 320041,
|
||||
32002: 320011,
|
||||
32003: 320111,
|
||||
32004: 320051,
|
||||
32005: 320141,
|
||||
32006: 320151,
|
||||
32007: 320171,
|
||||
32008: 320181,
|
||||
32009: 320201,
|
||||
32011: 320231,
|
||||
32012: 320241,
|
||||
32013: 320271,
|
||||
32014: 320281,
|
||||
32015: 320301,
|
||||
32016: 320331,
|
||||
32017: 320351,
|
||||
32018: 320371,
|
||||
32019: 320381,
|
||||
32020: 320391,
|
||||
32021: 320421,
|
||||
32022: 320431,
|
||||
32023: 320441,
|
||||
32024: 320451,
|
||||
32025: 320461,
|
||||
32026: 320471,
|
||||
32027: 320501,
|
||||
32028: 320531,
|
||||
32029: 320541,
|
||||
32030: 320551,
|
||||
32031: 320561,
|
||||
32032: 320581,
|
||||
32033: 320601,
|
||||
32034: 320611,
|
||||
32035: 320621,
|
||||
32036: 320641,
|
||||
33000: 330001,
|
||||
33001: 330121,
|
||||
33002: 330011,
|
||||
33003: 330021,
|
||||
33005: 330161,
|
||||
33006: 330171,
|
||||
33007: 330191,
|
||||
33009: 330211,
|
||||
33010: 330231,
|
||||
33011: 330261,
|
||||
33012: 330281,
|
||||
33013: 330321,
|
||||
33014: 330341,
|
||||
33015: 330381,
|
||||
33016: 330401,
|
||||
33017: 330421,
|
||||
33018: 330451,
|
||||
33019: 330471,
|
||||
33020: 330501,
|
||||
33021: 330521,
|
||||
33022: 330541,
|
||||
33023: 330551,
|
||||
33024: 330561,
|
||||
33025: 330571,
|
||||
33026: 330581,
|
||||
33027: 330591,
|
||||
33028: 330601,
|
||||
33029: 330631,
|
||||
33030: 330641,
|
||||
33031: 330671,
|
||||
33032: 330691,
|
||||
33033: 330701,
|
||||
34000: 340011,
|
||||
34001: 340121,
|
||||
34002: 340151,
|
||||
34003: 340161,
|
||||
34004: 340131,
|
||||
34005: 340071,
|
||||
34009: 340231,
|
||||
34010: 340241,
|
||||
34011: 340251,
|
||||
34012: 340261,
|
||||
34013: 340281,
|
||||
34014: 340291,
|
||||
34015: 340301,
|
||||
34016: 340321,
|
||||
34017: 340341,
|
||||
34018: 340351,
|
||||
34019: 340361,
|
||||
34020: 340381,
|
||||
34021: 340391,
|
||||
34022: 340411,
|
||||
34023: 340421,
|
||||
34024: 340441,
|
||||
34025: 340451,
|
||||
34026: 340461,
|
||||
34027: 340491,
|
||||
34028: 340501,
|
||||
34029: 340521,
|
||||
34030: 340531,
|
||||
34031: 340541,
|
||||
34032: 340571,
|
||||
34033: 340601,
|
||||
34034: 340611,
|
||||
34035: 340621,
|
||||
34036: 340631,
|
||||
34037: 340651,
|
||||
34038: 340681,
|
||||
34039: 340701,
|
||||
34040: 340721,
|
||||
34041: 340731,
|
||||
34042: 340751,
|
||||
34043: 340761,
|
||||
34044: 340781,
|
||||
34045: 340801,
|
||||
34046: 340831,
|
||||
34047: 340861,
|
||||
34048: 340871,
|
||||
35000: 350011,
|
||||
35001: 350161,
|
||||
35002: 350141,
|
||||
35003: 350061,
|
||||
35005: 350081,
|
||||
35006: 350121,
|
||||
35008: 350181,
|
||||
35009: 350191,
|
||||
35010: 350221,
|
||||
35011: 350231,
|
||||
35012: 350261,
|
||||
35013: 350271,
|
||||
35014: 350301,
|
||||
35015: 350321,
|
||||
35016: 350341,
|
||||
35017: 350361,
|
||||
35018: 350391,
|
||||
35019: 350401,
|
||||
35020: 350411,
|
||||
35021: 350431,
|
||||
35022: 350441,
|
||||
35023: 350451,
|
||||
35024: 350461,
|
||||
35025: 350491,
|
||||
35026: 350501,
|
||||
35027: 350511,
|
||||
35028: 350531,
|
||||
35029: 350551,
|
||||
35030: 350601,
|
||||
35031: 350621,
|
||||
35032: 350631,
|
||||
35033: 350641,
|
||||
35034: 350661,
|
||||
35035: 350681,
|
||||
35036: 350691,
|
||||
35037: 350701,
|
||||
35038: 350711,
|
||||
41000: 410031,
|
||||
41001: 410071,
|
||||
41002: 410111,
|
||||
41003: 410151,
|
||||
42000: 420031,
|
||||
42001: 420071,
|
||||
42002: 420111,
|
||||
42003: 420151,
|
||||
43000: 430031,
|
||||
43001: 430071,
|
||||
43002: 430111,
|
||||
43003: 430151,
|
||||
44000: 440031,
|
||||
44001: 440071,
|
||||
44002: 440111,
|
||||
44003: 440151,
|
||||
44004: 440191,
|
||||
45000: 450031,
|
||||
45001: 450071,
|
||||
45002: 450111,
|
||||
45003: 450151,
|
||||
51001: 510011,
|
||||
51002: 510021,
|
||||
51003: 510031,
|
||||
52001: 520011,
|
||||
52002: 520021,
|
||||
53001: 530011,
|
||||
53002: 530021,
|
||||
54001: 540011,
|
||||
54002: 540021,
|
||||
55001: 550011,
|
||||
55002: 550021,
|
||||
55003: 550031,
|
||||
}
|
||||
@@ -124,9 +124,9 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
|
||||
})
|
||||
}
|
||||
|
||||
for groupId, steps := range stepupSteps {
|
||||
for _, steps := range stepupSteps {
|
||||
first := steps[0]
|
||||
gachaId := groupId
|
||||
gachaId := first.DestinationDomainId
|
||||
|
||||
medal := gachaToMedal[first.DestinationDomainId]
|
||||
medalId := medal.GachaMedalId
|
||||
@@ -154,7 +154,7 @@ func LoadGachaCatalog() ([]store.GachaCatalogEntry, map[int32]GachaMedalInfo, er
|
||||
GachaDecorationType: model.GachaDecorationFestival,
|
||||
SortOrder: first.SortOrderDesc,
|
||||
BannerAssetName: first.BannerAssetName,
|
||||
GroupId: groupId,
|
||||
GroupId: gachaId,
|
||||
CeilingCount: model.PityCeilingCount,
|
||||
PricePhases: pricePhases,
|
||||
MaxStepNumber: maxStep,
|
||||
|
||||
@@ -3,7 +3,6 @@ package masterdata
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
@@ -34,9 +33,22 @@ type ShopFeaturedEntry struct {
|
||||
WeaponId int32
|
||||
}
|
||||
|
||||
type CatalogTerm struct {
|
||||
TermId int32
|
||||
StartDatetime int64
|
||||
Costumes []GachaPoolItem
|
||||
Weapons []GachaPoolItem
|
||||
}
|
||||
|
||||
// StandardPoolTermId is the catalog term whose items form the cross-banner
|
||||
// standard pool (term 1 holds the launch starter set).
|
||||
const StandardPoolTermId int32 = 1
|
||||
|
||||
type GachaCatalog struct {
|
||||
CostumesByRarity map[int32][]GachaPoolItem
|
||||
WeaponsByRarity map[int32][]GachaPoolItem
|
||||
StandardCostumesByRarity map[int32][]GachaPoolItem
|
||||
StandardWeaponsByRarity map[int32][]GachaPoolItem
|
||||
Materials []GachaPoolItem
|
||||
CostumeById map[int32]GachaPoolItem
|
||||
WeaponById map[int32]GachaPoolItem
|
||||
@@ -44,6 +56,8 @@ type GachaCatalog struct {
|
||||
FeaturedByGacha map[int32]FeaturedSet
|
||||
BannerPools map[int32]*BannerPool
|
||||
ShopFeaturedByMedal map[int32][]ShopFeaturedEntry // consumableId -> paired entries
|
||||
TermById map[int32]*CatalogTerm
|
||||
TermsByStartDatetime map[int64][]*CatalogTerm
|
||||
}
|
||||
|
||||
func LoadGachaPool() (*GachaCatalog, error) {
|
||||
@@ -73,28 +87,54 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
}
|
||||
evolvedWeapons := buildEvolvedWeaponSet(evoGroupRows)
|
||||
|
||||
terms, err := utils.ReadTable[EntityMCatalogTerm]("m_catalog_term")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load catalog term table: %w", err)
|
||||
}
|
||||
firstClearRewards, err := utils.ReadTable[EntityMQuestFirstClearRewardGroup]("m_quest_first_clear_reward_group")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load quest first clear reward group table: %w", err)
|
||||
}
|
||||
sceneGrants, err := utils.ReadTable[EntityMUserQuestSceneGrantPossession]("m_user_quest_scene_grant_possession")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load user quest scene grant possession table: %w", err)
|
||||
}
|
||||
missionRewardRows, err := utils.ReadTable[EntityMMissionReward]("m_mission_reward")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mission reward table: %w", err)
|
||||
}
|
||||
|
||||
questGrantedCostumes := make(map[int32]bool)
|
||||
questGrantedWeapons := make(map[int32]bool)
|
||||
collectGrant := func(possType, possId int32) {
|
||||
switch possType {
|
||||
case int32(model.PossessionTypeCostume):
|
||||
questGrantedCostumes[possId] = true
|
||||
case int32(model.PossessionTypeWeapon):
|
||||
questGrantedWeapons[possId] = true
|
||||
}
|
||||
}
|
||||
for _, r := range firstClearRewards {
|
||||
collectGrant(r.PossessionType, r.PossessionId)
|
||||
}
|
||||
for _, r := range sceneGrants {
|
||||
collectGrant(r.PossessionType, r.PossessionId)
|
||||
}
|
||||
for _, r := range missionRewardRows {
|
||||
collectGrant(r.PossessionType, r.PossessionId)
|
||||
}
|
||||
|
||||
catalogCostumeSet := make(map[int32]bool, len(catalogCostumes))
|
||||
costumeTermId := make(map[int32]int32, len(catalogCostumes))
|
||||
for _, c := range catalogCostumes {
|
||||
catalogCostumeSet[c.CostumeId] = true
|
||||
costumeTermId[c.CostumeId] = c.CatalogTermId
|
||||
}
|
||||
catalogWeaponSet := make(map[int32]bool, len(catalogWeapons))
|
||||
for _, w := range catalogWeapons {
|
||||
catalogWeaponSet[w.WeaponId] = true
|
||||
}
|
||||
|
||||
costumeWeaponType := make(map[int32]int32, len(costumes))
|
||||
for _, c := range costumes {
|
||||
costumeWeaponType[c.CostumeId] = c.SkillfulWeaponType
|
||||
}
|
||||
|
||||
weaponTypeById := make(map[int32]int32, len(weapons))
|
||||
weaponRarityById := make(map[int32]int32, len(weapons))
|
||||
restrictedWeapons := make(map[int32]bool)
|
||||
for _, w := range weapons {
|
||||
weaponTypeById[w.WeaponId] = w.WeaponType
|
||||
weaponRarityById[w.WeaponId] = w.RarityType
|
||||
if w.IsRestrictDiscard {
|
||||
restrictedWeapons[w.WeaponId] = true
|
||||
}
|
||||
@@ -107,8 +147,16 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
WeaponById: make(map[int32]GachaPoolItem),
|
||||
CostumeWeaponMap: make(map[int32]int32),
|
||||
FeaturedByGacha: make(map[int32]FeaturedSet),
|
||||
TermById: make(map[int32]*CatalogTerm),
|
||||
TermsByStartDatetime: make(map[int64][]*CatalogTerm),
|
||||
}
|
||||
for _, t := range terms {
|
||||
ct := &CatalogTerm{TermId: t.CatalogTermId, StartDatetime: t.StartDatetime}
|
||||
pool.TermById[t.CatalogTermId] = ct
|
||||
pool.TermsByStartDatetime[t.StartDatetime] = append(pool.TermsByStartDatetime[t.StartDatetime], ct)
|
||||
}
|
||||
|
||||
questGrantedCostumeCount := 0
|
||||
for _, c := range costumes {
|
||||
if !catalogCostumeSet[c.CostumeId] {
|
||||
continue
|
||||
@@ -116,6 +164,10 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
if c.RarityType < model.RaritySRare {
|
||||
continue
|
||||
}
|
||||
if questGrantedCostumes[c.CostumeId] {
|
||||
questGrantedCostumeCount++
|
||||
continue
|
||||
}
|
||||
item := GachaPoolItem{
|
||||
PossessionType: int32(model.PossessionTypeCostume),
|
||||
PossessionId: c.CostumeId,
|
||||
@@ -127,11 +179,18 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
}
|
||||
|
||||
restrictedCount := 0
|
||||
questGrantedWeaponCount := 0
|
||||
evolvedFilteredCount := 0
|
||||
for _, w := range weapons {
|
||||
if !catalogWeaponSet[w.WeaponId] {
|
||||
continue
|
||||
}
|
||||
if evolvedWeapons[w.WeaponId] {
|
||||
evolvedFilteredCount++
|
||||
continue
|
||||
}
|
||||
if questGrantedWeapons[w.WeaponId] {
|
||||
questGrantedWeaponCount++
|
||||
continue
|
||||
}
|
||||
item := GachaPoolItem{
|
||||
@@ -147,62 +206,56 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
pool.WeaponsByRarity[w.RarityType] = append(pool.WeaponsByRarity[w.RarityType], item)
|
||||
}
|
||||
|
||||
log.Printf("[GachaPool] excluded %d evolved weapons, %d restricted weapons from pool", len(evolvedWeapons), restrictedCount)
|
||||
|
||||
type weaponKey struct {
|
||||
TermId int32
|
||||
WeaponType int32
|
||||
Rarity int32
|
||||
// Bucket catalog items into their terms (uses the post-filter CostumeById/WeaponById).
|
||||
for _, cc := range catalogCostumes {
|
||||
ct := pool.TermById[cc.CatalogTermId]
|
||||
if ct == nil {
|
||||
continue
|
||||
}
|
||||
if item, ok := pool.CostumeById[cc.CostumeId]; ok {
|
||||
ct.Costumes = append(ct.Costumes, item)
|
||||
}
|
||||
}
|
||||
weaponsByKey := make(map[weaponKey][]int32)
|
||||
for _, cw := range catalogWeapons {
|
||||
if evolvedWeapons[cw.WeaponId] || restrictedWeapons[cw.WeaponId] {
|
||||
ct := pool.TermById[cw.CatalogTermId]
|
||||
if ct == nil || restrictedWeapons[cw.WeaponId] {
|
||||
continue
|
||||
}
|
||||
wt := weaponTypeById[cw.WeaponId]
|
||||
r := weaponRarityById[cw.WeaponId]
|
||||
if wt == 0 || r < model.RaritySRare {
|
||||
continue
|
||||
if item, ok := pool.WeaponById[cw.WeaponId]; ok {
|
||||
ct.Weapons = append(ct.Weapons, item)
|
||||
}
|
||||
k := weaponKey{TermId: cw.CatalogTermId, WeaponType: wt, Rarity: r}
|
||||
weaponsByKey[k] = append(weaponsByKey[k], cw.WeaponId)
|
||||
}
|
||||
for k, ids := range weaponsByKey {
|
||||
slices.Sort(ids)
|
||||
weaponsByKey[k] = ids
|
||||
}
|
||||
|
||||
exact, pattern, bestGuess := 0, 0, 0
|
||||
for costumeId, item := range pool.CostumeById {
|
||||
tid := costumeTermId[costumeId]
|
||||
wt := costumeWeaponType[costumeId]
|
||||
k := weaponKey{TermId: tid, WeaponType: wt, Rarity: item.RarityType}
|
||||
candidates := weaponsByKey[k]
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
// Standard pool: items in term 1 (the launch starter set, same on every banner).
|
||||
pool.StandardCostumesByRarity = make(map[int32][]GachaPoolItem)
|
||||
pool.StandardWeaponsByRarity = make(map[int32][]GachaPoolItem)
|
||||
if std := pool.TermById[StandardPoolTermId]; std != nil {
|
||||
for _, c := range std.Costumes {
|
||||
pool.StandardCostumesByRarity[c.RarityType] = append(pool.StandardCostumesByRarity[c.RarityType], c)
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
pool.CostumeWeaponMap[costumeId] = candidates[0]
|
||||
exact++
|
||||
continue
|
||||
for _, w := range std.Weapons {
|
||||
pool.StandardWeaponsByRarity[w.RarityType] = append(pool.StandardWeaponsByRarity[w.RarityType], w)
|
||||
}
|
||||
idPattern := costumeId*10 + 1
|
||||
found := false
|
||||
for _, wid := range candidates {
|
||||
if wid == idPattern {
|
||||
}
|
||||
stdCos, stdWea := 0, 0
|
||||
for _, items := range pool.StandardCostumesByRarity {
|
||||
stdCos += len(items)
|
||||
}
|
||||
for _, items := range pool.StandardWeaponsByRarity {
|
||||
stdWea += len(items)
|
||||
}
|
||||
|
||||
log.Printf("[GachaPool] catalog terms: %d, standard pool: %d costumes + %d weapons (term %d)",
|
||||
len(pool.TermById), stdCos, stdWea, StandardPoolTermId)
|
||||
log.Printf("[GachaPool] pool excludes %d evolved, %d quest-granted costumes, %d quest-granted weapons, %d restricted weapons",
|
||||
evolvedFilteredCount, questGrantedCostumeCount, questGrantedWeaponCount, restrictedCount)
|
||||
|
||||
for costumeId := range pool.CostumeById {
|
||||
if wid, ok := costumeWeaponPairings[costumeId]; ok {
|
||||
pool.CostumeWeaponMap[costumeId] = wid
|
||||
pattern++
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
pool.CostumeWeaponMap[costumeId] = candidates[0]
|
||||
bestGuess++
|
||||
}
|
||||
}
|
||||
log.Printf("[GachaPool] costume-weapon pairing: %d exact, %d id-pattern, %d best-guess, %d total",
|
||||
exact, pattern, bestGuess, len(pool.CostumeWeaponMap))
|
||||
log.Printf("[GachaPool] costume-weapon pairing: %d entries from lookup table", len(pool.CostumeWeaponMap))
|
||||
|
||||
for _, m := range materials {
|
||||
pool.Materials = append(pool.Materials, GachaPoolItem{
|
||||
@@ -217,7 +270,6 @@ func LoadGachaPool() (*GachaCatalog, error) {
|
||||
|
||||
func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
|
||||
pool.ShopFeaturedByMedal = make(map[int32][]ShopFeaturedEntry)
|
||||
shopPairs := 0
|
||||
for _, cells := range shop.ExchangeShopCells {
|
||||
consumableId := shop.Items[cells[0].ShopItemId].PriceId
|
||||
|
||||
@@ -237,16 +289,12 @@ func (pool *GachaCatalog) BuildShopFeatured(shop *ShopCatalog) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, ShopFeaturedEntry{CostumeId: costumeId, WeaponId: weaponId})
|
||||
if costumeId != 0 && weaponId != 0 {
|
||||
pool.CostumeWeaponMap[costumeId] = weaponId
|
||||
shopPairs++
|
||||
}
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
pool.ShopFeaturedByMedal[consumableId] = entries
|
||||
}
|
||||
}
|
||||
log.Printf("[GachaPool] shop featured: %d consumables, %d costume-weapon pairs overridden", len(pool.ShopFeaturedByMedal), shopPairs)
|
||||
log.Printf("[GachaPool] shop featured: %d consumables", len(pool.ShopFeaturedByMedal))
|
||||
}
|
||||
|
||||
func (pool *GachaCatalog) PruneUnpairedCostumes() {
|
||||
@@ -269,119 +317,138 @@ func (pool *GachaCatalog) PruneUnpairedCostumes() {
|
||||
log.Printf("[GachaPool] pruned %d unpaired costumes", pruned)
|
||||
}
|
||||
|
||||
func (pool *GachaCatalog) BuildFeaturedMapping(entries []store.GachaCatalogEntry) {
|
||||
// BuildFeaturedFromTerms derives a featured set for each non-chapter banner by
|
||||
// unioning items from catalog terms that started on the banner's StartDatetime
|
||||
// (excluding term 1 — the standard pool). Falls back to medal-exchange shop
|
||||
// contents for banners whose StartDatetime doesn't line up with a term.
|
||||
func (pool *GachaCatalog) BuildFeaturedFromTerms(entries []store.GachaCatalogEntry) {
|
||||
matched := 0
|
||||
fromShop := 0
|
||||
gachaEligible := 0
|
||||
for _, entry := range entries {
|
||||
if entry.MedalConsumableItemId == 0 {
|
||||
if entry.GachaLabelType == model.GachaLabelChapter {
|
||||
continue
|
||||
}
|
||||
shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]
|
||||
if !ok || len(shopEntries) == 0 {
|
||||
gachaEligible++
|
||||
|
||||
costumes, weapons := pool.unionTermFeatured(entry.StartDatetime)
|
||||
|
||||
if len(costumes) == 0 && len(weapons) == 0 && entry.MedalConsumableItemId != 0 {
|
||||
if shopEntries, ok := pool.ShopFeaturedByMedal[entry.MedalConsumableItemId]; ok {
|
||||
costumes, weapons = pool.featuredFromShop(shopEntries)
|
||||
if len(costumes) > 0 || len(weapons) > 0 {
|
||||
fromShop++
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(costumes) == 0 && len(weapons) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seenCostume := make(map[int32]bool)
|
||||
linkedWeapons := make(map[int32]bool)
|
||||
var costumes []GachaPoolItem
|
||||
for _, se := range shopEntries {
|
||||
if se.CostumeId != 0 && !seenCostume[se.CostumeId] {
|
||||
costumes = append(costumes, pool.CostumeById[se.CostumeId])
|
||||
seenCostume[se.CostumeId] = true
|
||||
linkedWeapons[se.WeaponId] = true
|
||||
}
|
||||
}
|
||||
|
||||
seenWeapon := make(map[int32]bool)
|
||||
var weapons []GachaPoolItem
|
||||
for _, se := range shopEntries {
|
||||
if se.WeaponId != 0 && !linkedWeapons[se.WeaponId] && !seenWeapon[se.WeaponId] {
|
||||
if item, ok := pool.WeaponById[se.WeaponId]; ok {
|
||||
weapons = append(weapons, item)
|
||||
seenWeapon[se.WeaponId] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(costumes, func(i, j int) bool { return costumes[i].PossessionId < costumes[j].PossessionId })
|
||||
sort.Slice(weapons, func(i, j int) bool { return weapons[i].PossessionId < weapons[j].PossessionId })
|
||||
|
||||
pool.FeaturedByGacha[entry.GachaId] = FeaturedSet{Costumes: costumes, Weapons: weapons}
|
||||
matched++
|
||||
}
|
||||
log.Printf("[GachaPool] featured mapping: %d/%d banners matched via shop", matched, len(entries))
|
||||
log.Printf("[GachaPool] featured per banner: %d/%d (term-match + %d from shop-fallback)",
|
||||
matched, gachaEligible, fromShop)
|
||||
}
|
||||
|
||||
func (pool *GachaCatalog) unionTermFeatured(startDatetime int64) (costumes, weapons []GachaPoolItem) {
|
||||
coTerms := pool.TermsByStartDatetime[startDatetime]
|
||||
if len(coTerms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
seenCostume := make(map[int32]bool)
|
||||
seenWeapon := make(map[int32]bool)
|
||||
for _, t := range coTerms {
|
||||
if t.TermId == StandardPoolTermId {
|
||||
continue
|
||||
}
|
||||
for _, c := range t.Costumes {
|
||||
if c.RarityType < model.RaritySRare || seenCostume[c.PossessionId] {
|
||||
continue
|
||||
}
|
||||
costumes = append(costumes, c)
|
||||
seenCostume[c.PossessionId] = true
|
||||
}
|
||||
for _, w := range t.Weapons {
|
||||
if w.RarityType < model.RaritySRare || seenWeapon[w.PossessionId] {
|
||||
continue
|
||||
}
|
||||
weapons = append(weapons, w)
|
||||
seenWeapon[w.PossessionId] = true
|
||||
}
|
||||
}
|
||||
return costumes, weapons
|
||||
}
|
||||
|
||||
func (pool *GachaCatalog) featuredFromShop(shopEntries []ShopFeaturedEntry) (costumes, weapons []GachaPoolItem) {
|
||||
seenCostume := make(map[int32]bool)
|
||||
seenWeapon := make(map[int32]bool)
|
||||
linkedWeapons := make(map[int32]bool)
|
||||
for _, se := range shopEntries {
|
||||
if se.CostumeId == 0 || seenCostume[se.CostumeId] {
|
||||
continue
|
||||
}
|
||||
if item, ok := pool.CostumeById[se.CostumeId]; ok && item.RarityType >= model.RaritySRare {
|
||||
costumes = append(costumes, item)
|
||||
seenCostume[se.CostumeId] = true
|
||||
linkedWeapons[se.WeaponId] = true
|
||||
}
|
||||
}
|
||||
for _, se := range shopEntries {
|
||||
if se.WeaponId == 0 || linkedWeapons[se.WeaponId] || seenWeapon[se.WeaponId] {
|
||||
continue
|
||||
}
|
||||
if item, ok := pool.WeaponById[se.WeaponId]; ok && item.RarityType >= model.RaritySRare {
|
||||
weapons = append(weapons, item)
|
||||
seenWeapon[se.WeaponId] = true
|
||||
}
|
||||
}
|
||||
return costumes, weapons
|
||||
}
|
||||
|
||||
func (pool *GachaCatalog) BuildBannerPools(entries []store.GachaCatalogEntry) {
|
||||
allFeaturedCostumes := make(map[int32]bool)
|
||||
allFeaturedWeapons := make(map[int32]bool)
|
||||
for _, fs := range pool.FeaturedByGacha {
|
||||
for _, c := range fs.Costumes {
|
||||
allFeaturedCostumes[c.PossessionId] = true
|
||||
allFeaturedWeapons[pool.CostumeWeaponMap[c.PossessionId]] = true
|
||||
}
|
||||
for _, w := range fs.Weapons {
|
||||
allFeaturedWeapons[w.PossessionId] = true
|
||||
}
|
||||
}
|
||||
|
||||
commonCostumes := make(map[int32][]GachaPoolItem)
|
||||
for rarity, items := range pool.CostumesByRarity {
|
||||
for _, item := range items {
|
||||
if !allFeaturedCostumes[item.PossessionId] {
|
||||
commonCostumes[rarity] = append(commonCostumes[rarity], item)
|
||||
}
|
||||
}
|
||||
}
|
||||
commonWeapons := make(map[int32][]GachaPoolItem)
|
||||
for rarity, items := range pool.WeaponsByRarity {
|
||||
for _, item := range items {
|
||||
if !allFeaturedWeapons[item.PossessionId] {
|
||||
commonWeapons[rarity] = append(commonWeapons[rarity], item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commonPool := &BannerPool{
|
||||
CostumesByRarity: commonCostumes,
|
||||
WeaponsByRarity: commonWeapons,
|
||||
}
|
||||
|
||||
pool.BannerPools = make(map[int32]*BannerPool)
|
||||
for _, entry := range entries {
|
||||
fs, hasFeatured := pool.FeaturedByGacha[entry.GachaId]
|
||||
if !hasFeatured {
|
||||
pool.BannerPools[entry.GachaId] = commonPool
|
||||
continue
|
||||
}
|
||||
|
||||
bannerCostumes := cloneRarityMap(pool.StandardCostumesByRarity)
|
||||
bannerWeapons := cloneRarityMap(pool.StandardWeaponsByRarity)
|
||||
|
||||
var allFeatured []GachaPoolItem
|
||||
bannerCostumes := make(map[int32][]GachaPoolItem)
|
||||
for rarity, items := range commonCostumes {
|
||||
bannerCostumes[rarity] = append(bannerCostumes[rarity], items...)
|
||||
}
|
||||
bannerWeapons := make(map[int32][]GachaPoolItem)
|
||||
for rarity, items := range commonWeapons {
|
||||
bannerWeapons[rarity] = append(bannerWeapons[rarity], items...)
|
||||
}
|
||||
if hasFeatured {
|
||||
for _, c := range fs.Costumes {
|
||||
bannerCostumes[c.RarityType] = append(bannerCostumes[c.RarityType], c)
|
||||
allFeatured = append(allFeatured, c)
|
||||
wid := pool.CostumeWeaponMap[c.PossessionId]
|
||||
w := pool.WeaponById[wid]
|
||||
if wid, ok := pool.CostumeWeaponMap[c.PossessionId]; ok {
|
||||
if w, ok := pool.WeaponById[wid]; ok {
|
||||
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
||||
allFeatured = append(allFeatured, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, w := range fs.Weapons {
|
||||
bannerWeapons[w.RarityType] = append(bannerWeapons[w.RarityType], w)
|
||||
allFeatured = append(allFeatured, w)
|
||||
}
|
||||
|
||||
}
|
||||
pool.BannerPools[entry.GachaId] = &BannerPool{
|
||||
CostumesByRarity: bannerCostumes,
|
||||
WeaponsByRarity: bannerWeapons,
|
||||
Featured: allFeatured,
|
||||
}
|
||||
}
|
||||
log.Printf("[GachaPool] banner pools: %d banners built from standard pool + per-banner featured", len(pool.BannerPools))
|
||||
}
|
||||
|
||||
log.Printf("[GachaPool] banner pools: %d banners, %d featured costumes stripped, %d featured weapons stripped",
|
||||
len(pool.BannerPools), len(allFeaturedCostumes), len(allFeaturedWeapons))
|
||||
func cloneRarityMap(src map[int32][]GachaPoolItem) map[int32][]GachaPoolItem {
|
||||
dst := make(map[int32][]GachaPoolItem, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = append([]GachaPoolItem(nil), v...)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func buildEvolvedWeaponSet(rows []EntityMWeaponEvolutionGroup) map[int32]bool {
|
||||
|
||||
@@ -3,57 +3,463 @@ package masterdata
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
// MaxUserGimmickRows is the server's per-table cap for gimmick projections and the
|
||||
// in-memory user.Gimmick.Sequences map. The client hard-caps each of its
|
||||
// IUserGimmick* tables at 1024 rows; we project up to this many with a small
|
||||
// safety margin so we never overflow client buffers. Shared by InitSequenceSchedule
|
||||
// (limits user.Gimmick.Sequences map size) and the IUserGimmick / Ornament /
|
||||
// Sequence projections.
|
||||
const MaxUserGimmickRows = 1000
|
||||
|
||||
type gimmickScheduleEntry struct {
|
||||
ScheduleId int32
|
||||
StartDatetime int64
|
||||
EndDatetime int64
|
||||
FirstSequenceId int32
|
||||
RequiredQuestId int32 // 0 = always active
|
||||
IsHidden bool // hidden-story or cage-memory gimmick: bypasses the quest gate
|
||||
Rank int // trim priority — see gimmickTypeRank
|
||||
}
|
||||
|
||||
func readGimmickTable[T any](name, what string) ([]T, bool) {
|
||||
rows, err := utils.ReadTable[T](name)
|
||||
if err != nil {
|
||||
log.Printf("[gimmick] %s unavailable, %s empty: %v", name, what, err)
|
||||
return nil, false
|
||||
}
|
||||
return rows, true
|
||||
}
|
||||
|
||||
func gimmickTypeRank(t model.GimmickType) int {
|
||||
switch t {
|
||||
case model.GimmickTypeReport: // hidden missions / stories
|
||||
return 0
|
||||
case model.GimmickTypeCageMemory: // lost archives
|
||||
return 1
|
||||
case model.GimmickTypeCageTreasureHunt: // treasure
|
||||
return 2
|
||||
case model.GimmickTypeBrokenObelisk, model.GimmickTypeFirstBrokenObelisk:
|
||||
return 3
|
||||
case model.GimmickTypeIronGrill:
|
||||
return 4
|
||||
case model.GimmickTypeRadioMessage:
|
||||
return 5
|
||||
case model.GimmickTypeMapOnlyCageTreasureHunt, model.GimmickTypeMapOnlyHideObelisk:
|
||||
return 6
|
||||
case model.GimmickTypeCageIntervalDropItem, model.GimmickTypeMapOnlyCageIntervalDrop:
|
||||
return 7 // birds — bottom
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
type gimmickTypeTables struct {
|
||||
byGimmick map[int32]model.GimmickType
|
||||
bySequence map[int32]model.GimmickType
|
||||
}
|
||||
|
||||
var gimmickTypes = sync.OnceValue(loadGimmickTypes)
|
||||
|
||||
func loadGimmickTypes() gimmickTypeTables {
|
||||
empty := gimmickTypeTables{
|
||||
byGimmick: map[int32]model.GimmickType{},
|
||||
bySequence: map[int32]model.GimmickType{},
|
||||
}
|
||||
|
||||
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "type tables")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "type tables")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "type tables")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
byGimmick := make(map[int32]model.GimmickType, len(gimmicks))
|
||||
for _, g := range gimmicks {
|
||||
byGimmick[g.GimmickId] = model.GimmickType(g.GimmickType)
|
||||
}
|
||||
typeByGroup := make(map[int32]model.GimmickType, len(groups))
|
||||
for _, grp := range groups {
|
||||
if _, seen := typeByGroup[grp.GimmickGroupId]; seen {
|
||||
continue
|
||||
}
|
||||
if t, ok := byGimmick[grp.GimmickId]; ok {
|
||||
typeByGroup[grp.GimmickGroupId] = t
|
||||
}
|
||||
}
|
||||
bySequence := make(map[int32]model.GimmickType, len(sequences))
|
||||
for _, seq := range sequences {
|
||||
if t, ok := typeByGroup[seq.GimmickGroupId]; ok {
|
||||
bySequence[seq.GimmickSequenceId] = t
|
||||
}
|
||||
}
|
||||
return gimmickTypeTables{byGimmick: byGimmick, bySequence: bySequence}
|
||||
}
|
||||
|
||||
func gimmickSequenceTypes() map[int32]model.GimmickType {
|
||||
return gimmickTypes().bySequence
|
||||
}
|
||||
|
||||
func LoadGimmickSequenceRanks() map[int32]int {
|
||||
types := gimmickSequenceTypes()
|
||||
out := make(map[int32]int, len(types))
|
||||
for sid, t := range types {
|
||||
out[sid] = gimmickTypeRank(t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type SequenceReward struct {
|
||||
PossessionType int32
|
||||
PossessionId int32
|
||||
Count int32
|
||||
}
|
||||
|
||||
type GimmickCatalog struct {
|
||||
schedules []gimmickScheduleEntry
|
||||
hiddenSequences map[int32]bool // GimmickSequenceId -> report/cage-memory
|
||||
sequenceRewards map[int32][]SequenceReward // GimmickSequenceId -> clear rewards
|
||||
gimmickTypes map[int32]model.GimmickType
|
||||
cageMemoryItems map[int32]int32 // CageMemory GimmickId -> ImportantItemId (type 4)
|
||||
hiddenBirdRewards map[GimmickOrnamentRef]SequenceReward
|
||||
}
|
||||
|
||||
func LoadGimmickCatalog(resolver *ConditionResolver) (*GimmickCatalog, error) {
|
||||
func LoadGimmickCatalog(resolver *ConditionResolver, cageOrnaments *CageOrnamentCatalog) (*GimmickCatalog, error) {
|
||||
rows, err := utils.ReadTable[EntityMGimmickSequenceSchedule]("m_gimmick_sequence_schedule")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load gimmick sequence schedule table: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]gimmickScheduleEntry, 0, len(rows))
|
||||
seqTypes := gimmickSequenceTypes()
|
||||
hiddenSeq := make(map[int32]bool, len(seqTypes))
|
||||
for sid, t := range seqTypes {
|
||||
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory {
|
||||
hiddenSeq[sid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pick rule: prefer schedules with EndDatetime in the future (so the client's
|
||||
// in-cage filter doesn't drop them), then by lowest StartDatetime (longest
|
||||
// historical validity, tends to carry the simplest RequiredQuestId), tie-broken
|
||||
// by lowest ScheduleId for determinism. The future-end preference matters for
|
||||
// "Fickle Black Birds" (type 1) where real expiry dates vary; other types have
|
||||
// EndDatetime = 9999-03-31 so the preference is a no-op.
|
||||
now := gametime.NowMillis()
|
||||
bestBySeq := make(map[int32]gimmickScheduleEntry, len(rows))
|
||||
for _, r := range rows {
|
||||
entry := gimmickScheduleEntry{
|
||||
ScheduleId: r.GimmickSequenceScheduleId,
|
||||
StartDatetime: r.StartDatetime,
|
||||
EndDatetime: r.EndDatetime,
|
||||
FirstSequenceId: r.FirstGimmickSequenceId,
|
||||
IsHidden: hiddenSeq[r.FirstGimmickSequenceId],
|
||||
Rank: gimmickTypeRank(seqTypes[r.FirstGimmickSequenceId]),
|
||||
}
|
||||
if r.ReleaseEvaluateConditionId != 0 {
|
||||
if qid, ok := resolver.RequiredQuestId(r.ReleaseEvaluateConditionId); ok {
|
||||
entry.RequiredQuestId = qid
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if existing, ok := bestBySeq[entry.FirstSequenceId]; ok {
|
||||
existingFuture := existing.EndDatetime > now
|
||||
entryFuture := entry.EndDatetime > now
|
||||
if existingFuture != entryFuture {
|
||||
// Future-end schedule wins over expired one.
|
||||
if existingFuture {
|
||||
continue
|
||||
}
|
||||
} else if existing.StartDatetime < entry.StartDatetime ||
|
||||
(existing.StartDatetime == entry.StartDatetime && existing.ScheduleId <= entry.ScheduleId) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
bestBySeq[entry.FirstSequenceId] = entry
|
||||
}
|
||||
|
||||
log.Printf("gimmick catalog loaded: %d schedules", len(entries))
|
||||
return &GimmickCatalog{schedules: entries}, nil
|
||||
entries := make([]gimmickScheduleEntry, 0, len(bestBySeq))
|
||||
hiddenCount := 0
|
||||
for _, entry := range bestBySeq {
|
||||
if entry.IsHidden {
|
||||
hiddenCount++
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
dedupedCount := len(rows) - len(entries)
|
||||
|
||||
// Sort by (Rank, ScheduleId) so ActiveScheduleKeys returns the priority order
|
||||
// directly: treasure/lost-archives/hidden-missions first, birds last. This is
|
||||
// what lets InitSequenceSchedule's 1000-row cap trim from the bottom.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Rank != entries[j].Rank {
|
||||
return entries[i].Rank < entries[j].Rank
|
||||
}
|
||||
return entries[i].ScheduleId < entries[j].ScheduleId
|
||||
})
|
||||
|
||||
sequenceRewards := loadGimmickSequenceRewards()
|
||||
cageMemoryItems := loadCageMemoryImportantItems(gimmickTypes().byGimmick)
|
||||
hiddenBirdRewards := loadHiddenBirdRewards(cageOrnaments)
|
||||
|
||||
log.Printf("gimmick catalog loaded: %d schedules (%d hidden-content, %d duplicates dropped), %d reward sequences, %d cage-memory items, %d hidden-bird rewards",
|
||||
len(entries), hiddenCount, dedupedCount, len(sequenceRewards), len(cageMemoryItems), len(hiddenBirdRewards))
|
||||
return &GimmickCatalog{
|
||||
schedules: entries,
|
||||
hiddenSequences: hiddenSeq,
|
||||
sequenceRewards: sequenceRewards,
|
||||
gimmickTypes: gimmickTypes().byGimmick,
|
||||
cageMemoryItems: cageMemoryItems,
|
||||
hiddenBirdRewards: hiddenBirdRewards,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HiddenBirdReward returns the per-tap reward for a MAP_ONLY_CAGE_TREASURE_HUNT
|
||||
// ("Hidden Black Birds", type 7) ornament. Returns false if there's no mapping
|
||||
// (e.g. the ornament view has no corresponding cage-ornament-reward entry).
|
||||
func (c *GimmickCatalog) HiddenBirdReward(gimmickId, ornamentIndex int32) (SequenceReward, bool) {
|
||||
r, ok := c.hiddenBirdRewards[GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex}]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// loadHiddenBirdRewards resolves (GimmickId, OrnamentIndex) -> CageOrnamentReward for
|
||||
// every type-7 ("Hidden Black Birds") gimmick. The mapping is structural:
|
||||
//
|
||||
// m_gimmick (GimmickType == 7) -> GimmickOrnamentGroupId
|
||||
// m_gimmick_ornament (matching group) -> GimmickOrnamentViewId
|
||||
// m_cage_ornament (CageOrnamentId == ViewId) -> CageOrnamentRewardId
|
||||
// m_cage_ornament_reward (matching id) -> PossessionType / PossessionId / Count
|
||||
//
|
||||
// 110 of 114 type-7 ornaments have a matching m_cage_ornament row in the current
|
||||
// data; the rest log a warning and are silently skipped so the player just gets
|
||||
// no reward on those (no crash).
|
||||
func loadHiddenBirdRewards(cageOrnaments *CageOrnamentCatalog) map[GimmickOrnamentRef]SequenceReward {
|
||||
empty := map[GimmickOrnamentRef]SequenceReward{}
|
||||
if cageOrnaments == nil {
|
||||
return empty
|
||||
}
|
||||
|
||||
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "hidden-bird rewards")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "hidden-bird rewards")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
gimmicksByGroup := make(map[int32][]int32)
|
||||
for _, g := range gimmicks {
|
||||
if model.GimmickType(g.GimmickType) == model.GimmickTypeMapOnlyCageTreasureHunt {
|
||||
gimmicksByGroup[g.GimmickOrnamentGroupId] = append(gimmicksByGroup[g.GimmickOrnamentGroupId], g.GimmickId)
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[GimmickOrnamentRef]SequenceReward)
|
||||
missing := 0
|
||||
for _, o := range ornaments {
|
||||
gids, ok := gimmicksByGroup[o.GimmickOrnamentGroupId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
reward, ok := cageOrnaments.LookupReward(o.GimmickOrnamentViewId)
|
||||
if !ok {
|
||||
missing++
|
||||
continue
|
||||
}
|
||||
entry := SequenceReward{
|
||||
PossessionType: reward.PossessionType,
|
||||
PossessionId: reward.PossessionId,
|
||||
Count: reward.Count,
|
||||
}
|
||||
for _, gid := range gids {
|
||||
out[GimmickOrnamentRef{GimmickId: gid, OrnamentIndex: o.GimmickOrnamentIndex}] = entry
|
||||
}
|
||||
}
|
||||
if missing > 0 {
|
||||
log.Printf("[gimmick] %d hidden-bird ornaments had no m_cage_ornament_reward row", missing)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *GimmickCatalog) GimmickType(gimmickId int32) model.GimmickType {
|
||||
return c.gimmickTypes[gimmickId]
|
||||
}
|
||||
|
||||
// CageMemoryImportantItem returns the ImportantItemId (type 4) that the library uses
|
||||
// to mark a tapped cage memory as collected, given the world-gimmick id. The mapping
|
||||
// is derived from m_gimmick_additional_asset texture suffixes — see
|
||||
// loadCageMemoryImportantItems.
|
||||
func (c *GimmickCatalog) CageMemoryImportantItem(gimmickId int32) (int32, bool) {
|
||||
id, ok := c.cageMemoryItems[gimmickId]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// importantItemTypeCageMemory mirrors EntityMImportantItem.ImportantItemType==4 — the
|
||||
// CageMemory entry that the library's HasCageMemory check resolves to.
|
||||
const importantItemTypeCageMemory int32 = 4
|
||||
|
||||
func loadCageMemoryImportantItems(typeByGimmick map[int32]model.GimmickType) map[int32]int32 {
|
||||
empty := map[int32]int32{}
|
||||
|
||||
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "cage-memory items")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
chapters, ok := readGimmickTable[EntityMMainQuestChapter]("m_main_quest_chapter", "cage-memory items")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
routes, ok := readGimmickTable[EntityMMainQuestRoute]("m_main_quest_route", "cage-memory items")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
cageMemories, ok := readGimmickTable[EntityMCageMemory]("m_cage_memory", "cage-memory items")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
items, ok := readGimmickTable[EntityMImportantItem]("m_important_item", "cage-memory items")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
chapterByOrnamentGroup := make(map[int32]int32, len(ornaments))
|
||||
for _, o := range ornaments {
|
||||
if _, seen := chapterByOrnamentGroup[o.GimmickOrnamentGroupId]; seen {
|
||||
continue
|
||||
}
|
||||
chapterByOrnamentGroup[o.GimmickOrnamentGroupId] = o.ChapterId
|
||||
}
|
||||
routeByChapter := make(map[int32]int32, len(chapters))
|
||||
for _, c := range chapters {
|
||||
routeByChapter[c.MainQuestChapterId] = c.MainQuestRouteId
|
||||
}
|
||||
seasonByRoute := make(map[int32]int32, len(routes))
|
||||
for _, r := range routes {
|
||||
seasonByRoute[r.MainQuestRouteId] = r.MainQuestSeasonId
|
||||
}
|
||||
cmsBySeason := make(map[int32][]int32)
|
||||
for _, c := range cageMemories {
|
||||
cmsBySeason[c.MainQuestSeasonId] = append(cmsBySeason[c.MainQuestSeasonId], c.CageMemoryId)
|
||||
}
|
||||
for s := range cmsBySeason {
|
||||
sort.Slice(cmsBySeason[s], func(i, j int) bool { return cmsBySeason[s][i] < cmsBySeason[s][j] })
|
||||
}
|
||||
itemByCageMemory := make(map[int32]int32)
|
||||
for _, it := range items {
|
||||
if it.ImportantItemType == importantItemTypeCageMemory && it.CageMemoryId != 0 {
|
||||
itemByCageMemory[it.CageMemoryId] = it.ImportantItemId
|
||||
}
|
||||
}
|
||||
|
||||
gimmicksByRoute := make(map[int32][]int32)
|
||||
for gid, t := range typeByGimmick {
|
||||
if t != model.GimmickTypeCageMemory {
|
||||
continue
|
||||
}
|
||||
chapter, ok := chapterByOrnamentGroup[gid]
|
||||
if !ok {
|
||||
log.Printf("[gimmick] cage-memory %d has no ornament row, skipping mapping", gid)
|
||||
continue
|
||||
}
|
||||
route, ok := routeByChapter[chapter]
|
||||
if !ok {
|
||||
log.Printf("[gimmick] cage-memory %d chapter %d has no route, skipping mapping", gid, chapter)
|
||||
continue
|
||||
}
|
||||
gimmicksByRoute[route] = append(gimmicksByRoute[route], gid)
|
||||
}
|
||||
for r := range gimmicksByRoute {
|
||||
sort.Slice(gimmicksByRoute[r], func(i, j int) bool { return gimmicksByRoute[r][i] < gimmicksByRoute[r][j] })
|
||||
}
|
||||
|
||||
out := make(map[int32]int32)
|
||||
for route, gids := range gimmicksByRoute {
|
||||
season, ok := seasonByRoute[route]
|
||||
if !ok {
|
||||
log.Printf("[gimmick] route %d has no season, skipping %d cage-memory gimmicks", route, len(gids))
|
||||
continue
|
||||
}
|
||||
seasonCms := cmsBySeason[season]
|
||||
for i, gid := range gids {
|
||||
if i >= len(seasonCms) {
|
||||
log.Printf("[gimmick] route %d (season %d) has %d cage-memory gimmicks but only %d cage memories; gimmick %d skipped",
|
||||
route, season, len(gids), len(seasonCms), gid)
|
||||
continue
|
||||
}
|
||||
cageMemoryId := seasonCms[i]
|
||||
itemId, ok := itemByCageMemory[cageMemoryId]
|
||||
if !ok {
|
||||
log.Printf("[gimmick] cage memory %d (gimmick %d) has no m_important_item row (type 4), skipping",
|
||||
cageMemoryId, gid)
|
||||
continue
|
||||
}
|
||||
out[gid] = itemId
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadGimmickSequenceRewards() map[int32][]SequenceReward {
|
||||
empty := map[int32][]SequenceReward{}
|
||||
|
||||
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence rewards")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
rewardGroups, ok := readGimmickTable[EntityMGimmickSequenceRewardGroup]("m_gimmick_sequence_reward_group", "sequence rewards")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
rewardsByGroup := make(map[int32][]SequenceReward)
|
||||
for _, rg := range rewardGroups {
|
||||
if rg.PossessionType == 0 || rg.PossessionId == 0 {
|
||||
continue
|
||||
}
|
||||
rewardsByGroup[rg.GimmickSequenceRewardGroupId] = append(
|
||||
rewardsByGroup[rg.GimmickSequenceRewardGroupId], SequenceReward{
|
||||
PossessionType: rg.PossessionType,
|
||||
PossessionId: rg.PossessionId,
|
||||
Count: rg.Count,
|
||||
})
|
||||
}
|
||||
|
||||
rewardsBySequence := make(map[int32][]SequenceReward, len(sequences))
|
||||
for _, seq := range sequences {
|
||||
if rewards := rewardsByGroup[seq.GimmickSequenceRewardGroupId]; len(rewards) > 0 {
|
||||
rewardsBySequence[seq.GimmickSequenceId] = rewards
|
||||
}
|
||||
}
|
||||
return rewardsBySequence
|
||||
}
|
||||
|
||||
func (c *GimmickCatalog) IsHiddenSequence(sequenceId int32) bool {
|
||||
return c.hiddenSequences[sequenceId]
|
||||
}
|
||||
|
||||
func (c *GimmickCatalog) SequenceRewards(sequenceId int32) []SequenceReward {
|
||||
return c.sequenceRewards[sequenceId]
|
||||
}
|
||||
|
||||
func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int64) []store.GimmickSequenceKey {
|
||||
var keys []store.GimmickSequenceKey
|
||||
keys := make([]store.GimmickSequenceKey, 0, len(c.schedules))
|
||||
for _, s := range c.schedules {
|
||||
if nowMillis < s.StartDatetime || nowMillis > s.EndDatetime {
|
||||
continue
|
||||
if nowMillis < s.StartDatetime {
|
||||
continue // future schedules still skipped
|
||||
}
|
||||
if s.RequiredQuestId != 0 {
|
||||
if !s.IsHidden && s.RequiredQuestId != 0 {
|
||||
q, ok := user.Quests[s.RequiredQuestId]
|
||||
if !ok || q.QuestStateType != model.UserQuestStateTypeCleared {
|
||||
continue
|
||||
@@ -66,3 +472,126 @@ func (c *GimmickCatalog) ActiveScheduleKeys(user store.UserState, nowMillis int6
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
type GimmickOrnamentRef struct {
|
||||
GimmickId int32
|
||||
OrnamentIndex int32
|
||||
}
|
||||
|
||||
func LoadGimmickOrnamentRefs() map[int32][]GimmickOrnamentRef {
|
||||
empty := map[int32][]GimmickOrnamentRef{}
|
||||
|
||||
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "ornament refs")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
groups, ok := readGimmickTable[EntityMGimmickGroup]("m_gimmick_group", "ornament refs")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
gimmicks, ok := readGimmickTable[EntityMGimmick]("m_gimmick", "ornament refs")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
ornaments, ok := readGimmickTable[EntityMGimmickOrnament]("m_gimmick_ornament", "ornament refs")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
indicesByOrnamentGroup := make(map[int32][]int32)
|
||||
for _, o := range ornaments {
|
||||
indicesByOrnamentGroup[o.GimmickOrnamentGroupId] = append(
|
||||
indicesByOrnamentGroup[o.GimmickOrnamentGroupId], o.GimmickOrnamentIndex)
|
||||
}
|
||||
ornamentGroupByGimmick := make(map[int32]int32, len(gimmicks))
|
||||
for _, g := range gimmicks {
|
||||
ornamentGroupByGimmick[g.GimmickId] = g.GimmickOrnamentGroupId
|
||||
}
|
||||
gimmicksByGroup := make(map[int32][]int32)
|
||||
for _, grp := range groups {
|
||||
gimmicksByGroup[grp.GimmickGroupId] = append(gimmicksByGroup[grp.GimmickGroupId], grp.GimmickId)
|
||||
}
|
||||
|
||||
refsBySequence := make(map[int32][]GimmickOrnamentRef, len(sequences))
|
||||
for _, seq := range sequences {
|
||||
var refs []GimmickOrnamentRef
|
||||
for _, gimmickId := range gimmicksByGroup[seq.GimmickGroupId] {
|
||||
for _, ornamentIndex := range indicesByOrnamentGroup[ornamentGroupByGimmick[gimmickId]] {
|
||||
refs = append(refs, GimmickOrnamentRef{GimmickId: gimmickId, OrnamentIndex: ornamentIndex})
|
||||
}
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
refsBySequence[seq.GimmickSequenceId] = refs
|
||||
}
|
||||
}
|
||||
log.Printf("gimmick ornament refs loaded: %d sequences", len(refsBySequence))
|
||||
return refsBySequence
|
||||
}
|
||||
|
||||
func LoadHiddenGimmickSequenceIDs() map[int32]bool {
|
||||
types := gimmickSequenceTypes()
|
||||
out := make(map[int32]bool, len(types))
|
||||
for sid, t := range types {
|
||||
if t == model.GimmickTypeReport || t == model.GimmickTypeCageMemory || t == model.GimmickTypeMapOnlyCageTreasureHunt {
|
||||
out[sid] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func LoadBirdGimmickIDs() map[int32]bool {
|
||||
byGimmick := gimmickTypes().byGimmick
|
||||
out := make(map[int32]bool, len(byGimmick))
|
||||
for gid, t := range byGimmick {
|
||||
if t == model.GimmickTypeCageIntervalDropItem || t == model.GimmickTypeMapOnlyCageIntervalDrop {
|
||||
out[gid] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func LoadGimmickSequenceChains() map[int32][]int32 {
|
||||
empty := map[int32][]int32{}
|
||||
|
||||
sequences, ok := readGimmickTable[EntityMGimmickSequence]("m_gimmick_sequence", "sequence chains")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
groups, ok := readGimmickTable[EntityMGimmickSequenceGroup]("m_gimmick_sequence_group", "sequence chains")
|
||||
if !ok {
|
||||
return empty
|
||||
}
|
||||
|
||||
membersByGroup := make(map[int32][]int32)
|
||||
for _, g := range groups {
|
||||
membersByGroup[g.GimmickSequenceGroupId] = append(membersByGroup[g.GimmickSequenceGroupId], g.GimmickSequenceId)
|
||||
}
|
||||
nextGroupBySequence := make(map[int32]int32, len(sequences))
|
||||
for _, seq := range sequences {
|
||||
nextGroupBySequence[seq.GimmickSequenceId] = seq.NextGimmickSequenceGroupId
|
||||
}
|
||||
|
||||
chains := make(map[int32][]int32, len(sequences))
|
||||
for _, seq := range sequences {
|
||||
start := seq.GimmickSequenceId
|
||||
seen := map[int32]bool{start: true}
|
||||
chain := []int32{start}
|
||||
for queue := []int32{start}; len(queue) > 0; {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
nextGroup := nextGroupBySequence[cur]
|
||||
if nextGroup == 0 {
|
||||
continue
|
||||
}
|
||||
for _, member := range membersByGroup[nextGroup] {
|
||||
if !seen[member] {
|
||||
seen[member] = true
|
||||
chain = append(chain, member)
|
||||
queue = append(queue, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
chains[start] = chain
|
||||
}
|
||||
return chains
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package masterdata
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
type HiddenStoryRequirements struct {
|
||||
MissionIds []int32
|
||||
QuestMissions []store.QuestMissionKey
|
||||
}
|
||||
|
||||
func LoadHiddenStoryRequirements() HiddenStoryRequirements {
|
||||
var empty HiddenStoryRequirements
|
||||
|
||||
gimmicks, err := utils.ReadTable[EntityMGimmick]("m_gimmick")
|
||||
if err != nil {
|
||||
log.Printf("[hiddenstory] m_gimmick unavailable: %v", err)
|
||||
return empty
|
||||
}
|
||||
conditions, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
|
||||
if err != nil {
|
||||
log.Printf("[hiddenstory] m_evaluate_condition unavailable: %v", err)
|
||||
return empty
|
||||
}
|
||||
valueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
|
||||
if err != nil {
|
||||
log.Printf("[hiddenstory] m_evaluate_condition_value_group unavailable: %v", err)
|
||||
return empty
|
||||
}
|
||||
|
||||
condById := make(map[int32]EntityMEvaluateCondition, len(conditions))
|
||||
for _, c := range conditions {
|
||||
condById[c.EvaluateConditionId] = c
|
||||
}
|
||||
valuesByGroup := make(map[int32]map[int32]int64)
|
||||
for _, vg := range valueGroups {
|
||||
g := valuesByGroup[vg.EvaluateConditionValueGroupId]
|
||||
if g == nil {
|
||||
g = make(map[int32]int64)
|
||||
valuesByGroup[vg.EvaluateConditionValueGroupId] = g
|
||||
}
|
||||
g[vg.GroupIndex] = vg.Value
|
||||
}
|
||||
|
||||
missionSet := make(map[int32]struct{})
|
||||
questMissionSet := make(map[store.QuestMissionKey]struct{})
|
||||
seen := make(map[int32]bool)
|
||||
|
||||
var resolve func(conditionId int32, depth int)
|
||||
resolve = func(conditionId int32, depth int) {
|
||||
if conditionId == 0 || depth > 16 || seen[conditionId] {
|
||||
return
|
||||
}
|
||||
seen[conditionId] = true
|
||||
c, ok := condById[conditionId]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
group := valuesByGroup[c.EvaluateConditionValueGroupId]
|
||||
switch model.EvaluateConditionFunctionType(c.EvaluateConditionFunctionType) {
|
||||
case model.EvaluateConditionFunctionTypeRecursion:
|
||||
// Value-group entries are sub-condition ids; satisfying all leaves makes
|
||||
// both AND and OR recursion conditions evaluate true.
|
||||
for _, sub := range group {
|
||||
resolve(int32(sub), depth+1)
|
||||
}
|
||||
case model.EvaluateConditionFunctionTypeMissionClear:
|
||||
if v, ok := group[defaultGroupIndex]; ok {
|
||||
missionSet[int32(v)] = struct{}{}
|
||||
}
|
||||
case model.EvaluateConditionFunctionTypeQuestMissionClear:
|
||||
questId, ok1 := group[1]
|
||||
questMissionId, ok2 := group[2]
|
||||
if ok1 && ok2 {
|
||||
questMissionSet[store.QuestMissionKey{
|
||||
QuestId: int32(questId),
|
||||
QuestMissionId: int32(questMissionId),
|
||||
}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range gimmicks {
|
||||
switch model.GimmickType(g.GimmickType) {
|
||||
case model.GimmickTypeReport, model.GimmickTypeCageMemory:
|
||||
resolve(g.ClearEvaluateConditionId, 0)
|
||||
}
|
||||
}
|
||||
|
||||
req := HiddenStoryRequirements{}
|
||||
for id := range missionSet {
|
||||
req.MissionIds = append(req.MissionIds, id)
|
||||
}
|
||||
for key := range questMissionSet {
|
||||
req.QuestMissions = append(req.QuestMissions, key)
|
||||
}
|
||||
log.Printf("hidden-story requirements: %d missions, %d quest-missions", len(req.MissionIds), len(req.QuestMissions))
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package masterdata
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
type LabyrinthChapter struct {
|
||||
EventQuestChapterId int32
|
||||
LatestSeasonNumber int32
|
||||
StageOrders []int32
|
||||
}
|
||||
|
||||
type LabyrinthStageTier struct {
|
||||
QuestMissionClearCount int32
|
||||
Rewards []RewardItem
|
||||
}
|
||||
|
||||
type LabyrinthSeasonMilestone struct {
|
||||
HeadQuestId int32
|
||||
HeadStageOrder int32
|
||||
Rewards []RewardItem
|
||||
}
|
||||
|
||||
type labyrinthStageKey struct {
|
||||
ChapterId int32
|
||||
StageOrder int32
|
||||
}
|
||||
|
||||
type LabyrinthCatalog struct {
|
||||
ChaptersByOrder []LabyrinthChapter
|
||||
ClearRewardsByStage map[labyrinthStageKey][]RewardItem
|
||||
AccumTiersByStage map[labyrinthStageKey][]LabyrinthStageTier
|
||||
SeasonMilestonesByChapter map[int32][]LabyrinthSeasonMilestone
|
||||
}
|
||||
|
||||
func (c *LabyrinthCatalog) StageClearReward(chapterId, stageOrder int32) []RewardItem {
|
||||
return c.ClearRewardsByStage[labyrinthStageKey{chapterId, stageOrder}]
|
||||
}
|
||||
|
||||
func (c *LabyrinthCatalog) CollectAccumulationRewards(chapterId, stageOrder, oldCount, targetCount int32) ([]RewardItem, int32) {
|
||||
var items []RewardItem
|
||||
highest := int32(0)
|
||||
for _, t := range c.AccumTiersByStage[labyrinthStageKey{chapterId, stageOrder}] {
|
||||
if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount {
|
||||
items = append(items, t.Rewards...)
|
||||
if t.QuestMissionClearCount > highest {
|
||||
highest = t.QuestMissionClearCount
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, highest
|
||||
}
|
||||
|
||||
func (c *LabyrinthCatalog) SeasonMilestones(chapterId int32) []LabyrinthSeasonMilestone {
|
||||
return c.SeasonMilestonesByChapter[chapterId]
|
||||
}
|
||||
|
||||
func LoadLabyrinthCatalog() *LabyrinthCatalog {
|
||||
seasonRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeason]("m_event_quest_labyrinth_season")
|
||||
if err != nil {
|
||||
log.Printf("[labyrinth] m_event_quest_labyrinth_season unavailable, labyrinth disabled: %v", err)
|
||||
return &LabyrinthCatalog{}
|
||||
}
|
||||
stageRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStage]("m_event_quest_labyrinth_stage")
|
||||
if err != nil {
|
||||
log.Printf("[labyrinth] m_event_quest_labyrinth_stage unavailable, labyrinth disabled: %v", err)
|
||||
return &LabyrinthCatalog{}
|
||||
}
|
||||
|
||||
// chapterId -> highest SeasonNumber
|
||||
latestSeason := make(map[int32]int32)
|
||||
for _, r := range seasonRows {
|
||||
if r.SeasonNumber > latestSeason[r.EventQuestChapterId] {
|
||||
latestSeason[r.EventQuestChapterId] = r.SeasonNumber
|
||||
}
|
||||
}
|
||||
// chapterId -> stage orders
|
||||
stagesByChapter := make(map[int32][]int32)
|
||||
for _, r := range stageRows {
|
||||
stagesByChapter[r.EventQuestChapterId] = append(stagesByChapter[r.EventQuestChapterId], r.StageOrder)
|
||||
}
|
||||
|
||||
chapters := make([]LabyrinthChapter, 0, len(latestSeason))
|
||||
for chapterId, season := range latestSeason {
|
||||
stages := stagesByChapter[chapterId]
|
||||
sort.Slice(stages, func(i, j int) bool { return stages[i] < stages[j] })
|
||||
chapters = append(chapters, LabyrinthChapter{
|
||||
EventQuestChapterId: chapterId,
|
||||
LatestSeasonNumber: season,
|
||||
StageOrders: stages,
|
||||
})
|
||||
}
|
||||
sort.Slice(chapters, func(i, j int) bool {
|
||||
return chapters[i].EventQuestChapterId < chapters[j].EventQuestChapterId
|
||||
})
|
||||
|
||||
clearRewards, accumTiers, seasonMilestones := loadLabyrinthRewards(seasonRows, stageRows)
|
||||
|
||||
log.Printf("labyrinth catalog loaded: %d chapters, %d stages with clear rewards, %d with accumulation rewards, %d chapters with season rewards",
|
||||
len(chapters), len(clearRewards), len(accumTiers), len(seasonMilestones))
|
||||
return &LabyrinthCatalog{
|
||||
ChaptersByOrder: chapters,
|
||||
ClearRewardsByStage: clearRewards,
|
||||
AccumTiersByStage: accumTiers,
|
||||
SeasonMilestonesByChapter: seasonMilestones,
|
||||
}
|
||||
}
|
||||
|
||||
func loadLabyrinthRewards(seasonRows []EntityMEventQuestLabyrinthSeason, stageRows []EntityMEventQuestLabyrinthStage) (
|
||||
clearRewards map[labyrinthStageKey][]RewardItem,
|
||||
accumTiers map[labyrinthStageKey][]LabyrinthStageTier,
|
||||
seasonMilestones map[int32][]LabyrinthSeasonMilestone,
|
||||
) {
|
||||
rewardGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthRewardGroup]("m_event_quest_labyrinth_reward_group")
|
||||
if err != nil {
|
||||
log.Printf("[labyrinth] m_event_quest_labyrinth_reward_group unavailable, rewards disabled: %v", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// reward group id -> reward items
|
||||
itemsByRewardGroup := make(map[int32][]RewardItem)
|
||||
for _, r := range rewardGroupRows {
|
||||
itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = append(itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId], RewardItem{
|
||||
PossessionType: r.PossessionType,
|
||||
PossessionId: r.PossessionId,
|
||||
Count: r.Count,
|
||||
})
|
||||
}
|
||||
|
||||
// per-stage one-time clear reward
|
||||
clearRewards = make(map[labyrinthStageKey][]RewardItem)
|
||||
for _, r := range stageRows {
|
||||
if r.StageClearRewardGroupId == 0 {
|
||||
continue
|
||||
}
|
||||
if items := itemsByRewardGroup[r.StageClearRewardGroupId]; len(items) > 0 {
|
||||
clearRewards[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = items
|
||||
}
|
||||
}
|
||||
|
||||
if accumGroupRows, err := utils.ReadTable[EntityMEventQuestLabyrinthStageAccumulationRewardGroup]("m_event_quest_labyrinth_stage_accumulation_reward_group"); err != nil {
|
||||
log.Printf("[labyrinth] m_event_quest_labyrinth_stage_accumulation_reward_group unavailable, accumulation rewards disabled: %v", err)
|
||||
} else {
|
||||
// accumulation group id -> tiers (threshold + resolved reward items)
|
||||
tiersByGroup := make(map[int32][]LabyrinthStageTier)
|
||||
for _, r := range accumGroupRows {
|
||||
tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestLabyrinthStageAccumulationRewardGroupId], LabyrinthStageTier{
|
||||
QuestMissionClearCount: r.QuestMissionClearCount,
|
||||
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
|
||||
})
|
||||
}
|
||||
accumTiers = make(map[labyrinthStageKey][]LabyrinthStageTier)
|
||||
for _, r := range stageRows {
|
||||
if r.StageAccumulationRewardGroupId == 0 {
|
||||
continue
|
||||
}
|
||||
tiers := tiersByGroup[r.StageAccumulationRewardGroupId]
|
||||
sort.Slice(tiers, func(i, j int) bool {
|
||||
return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount
|
||||
})
|
||||
accumTiers[labyrinthStageKey{r.EventQuestChapterId, r.StageOrder}] = tiers
|
||||
}
|
||||
}
|
||||
|
||||
// per-chapter season-reward milestones
|
||||
if seasonRewardRows, err := utils.ReadTable[EntityMEventQuestLabyrinthSeasonRewardGroup]("m_event_quest_labyrinth_season_reward_group"); err != nil {
|
||||
log.Printf("[labyrinth] m_event_quest_labyrinth_season_reward_group unavailable, season rewards disabled: %v", err)
|
||||
} else {
|
||||
seasonMilestones = buildLabyrinthSeasonMilestones(seasonRows, seasonRewardRows, itemsByRewardGroup)
|
||||
}
|
||||
|
||||
return clearRewards, accumTiers, seasonMilestones
|
||||
}
|
||||
|
||||
func buildLabyrinthSeasonMilestones(
|
||||
seasonRows []EntityMEventQuestLabyrinthSeason,
|
||||
seasonRewardRows []EntityMEventQuestLabyrinthSeasonRewardGroup,
|
||||
itemsByRewardGroup map[int32][]RewardItem,
|
||||
) map[int32][]LabyrinthSeasonMilestone {
|
||||
// chapter -> SeasonRewardGroupId (all seasons of a chapter share one)
|
||||
groupByChapter := make(map[int32]int32)
|
||||
for _, r := range seasonRows {
|
||||
groupByChapter[r.EventQuestChapterId] = r.SeasonRewardGroupId
|
||||
}
|
||||
// SeasonRewardGroupId -> its rows, in table order
|
||||
rowsByGroup := make(map[int32][]EntityMEventQuestLabyrinthSeasonRewardGroup)
|
||||
for _, r := range seasonRewardRows {
|
||||
rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId] = append(rowsByGroup[r.EventQuestLabyrinthSeasonRewardGroupId], r)
|
||||
}
|
||||
|
||||
milestones := make(map[int32][]LabyrinthSeasonMilestone)
|
||||
for chapterId, seasonGroupId := range groupByChapter {
|
||||
rows := rowsByGroup[seasonGroupId]
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
// rank distinct reward-group ids ascending -> 1-based head stage order
|
||||
stageByRewardGroup := make(map[int32]int32)
|
||||
var distinct []int32
|
||||
for _, r := range rows {
|
||||
if _, seen := stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId]; !seen {
|
||||
stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId] = 0
|
||||
distinct = append(distinct, r.EventQuestLabyrinthRewardGroupId)
|
||||
}
|
||||
}
|
||||
sort.Slice(distinct, func(i, j int) bool { return distinct[i] < distinct[j] })
|
||||
for i, gid := range distinct {
|
||||
stageByRewardGroup[gid] = int32(i + 1)
|
||||
}
|
||||
|
||||
list := make([]LabyrinthSeasonMilestone, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, LabyrinthSeasonMilestone{
|
||||
HeadQuestId: r.HeadQuestId,
|
||||
HeadStageOrder: stageByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
|
||||
Rewards: itemsByRewardGroup[r.EventQuestLabyrinthRewardGroupId],
|
||||
})
|
||||
}
|
||||
milestones[chapterId] = list
|
||||
}
|
||||
return milestones
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package masterdata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/utils"
|
||||
@@ -34,6 +35,7 @@ func BuildExpThresholds(paramMapRows []EntityMNumericalParameterMap, mapId int32
|
||||
type MaterialCatalog struct {
|
||||
All map[int32]EntityMMaterial
|
||||
ByType map[model.MaterialType]map[int32]EntityMMaterial
|
||||
SaleObtain map[int32][]EntityMMaterialSaleObtainPossession
|
||||
}
|
||||
|
||||
func LoadMaterialCatalog() (*MaterialCatalog, error) {
|
||||
@@ -45,6 +47,7 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
|
||||
catalog := &MaterialCatalog{
|
||||
All: make(map[int32]EntityMMaterial, len(rows)),
|
||||
ByType: make(map[model.MaterialType]map[int32]EntityMMaterial),
|
||||
SaleObtain: make(map[int32][]EntityMMaterialSaleObtainPossession),
|
||||
}
|
||||
for _, row := range rows {
|
||||
catalog.All[row.MaterialId] = row
|
||||
@@ -54,5 +57,15 @@ func LoadMaterialCatalog() (*MaterialCatalog, error) {
|
||||
}
|
||||
catalog.ByType[mt][row.MaterialId] = row
|
||||
}
|
||||
|
||||
saleRows, err := utils.ReadTable[EntityMMaterialSaleObtainPossession]("m_material_sale_obtain_possession")
|
||||
if err != nil {
|
||||
log.Printf("material catalog: sale-obtain table unavailable, side rewards on sell will be skipped: %v", err)
|
||||
} else {
|
||||
for _, row := range saleRows {
|
||||
catalog.SaleObtain[row.MaterialSaleObtainPossessionId] = append(catalog.SaleObtain[row.MaterialSaleObtainPossessionId], row)
|
||||
}
|
||||
}
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
@@ -158,5 +158,9 @@ func buildPartsStatusMain() (map[int32]PartsStatusMainDef, map[int32][]int32) {
|
||||
id++
|
||||
}
|
||||
}
|
||||
// Newer parts groups (PartsGroupId 401-490) use PartsStatusSubLotteryGroupId
|
||||
// 11/12 for rarities 10/20 instead of 1/2. Same stat pools — alias them.
|
||||
pool[11] = pool[1]
|
||||
pool[12] = pool[2]
|
||||
return defs, pool
|
||||
}
|
||||
|
||||
@@ -34,8 +34,11 @@ type QuestCatalog struct {
|
||||
TutorialUnlockConditions []EntityMTutorialUnlockCondition
|
||||
ChapterLastSceneByQuestId map[int32]int32
|
||||
SeasonIdByRouteId map[int32]int32
|
||||
QuestsWithDifficulty map[int32]bool // any questId referenced in m_quest_relation_main_flow
|
||||
RoutesBySeason map[int32][]int32
|
||||
RouteCompletionQuestId map[int32]int32
|
||||
BattleOnlyTargetSceneByQuestId map[int32]int32
|
||||
MainQuestChapterIdByQuestId map[int32]int32
|
||||
EventQuestTypeByChapterId map[int32]int32
|
||||
|
||||
UserExpThresholds []int32
|
||||
CharacterExpThresholds []int32
|
||||
@@ -115,8 +118,53 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
|
||||
return nil, fmt.Errorf("load main quest route table: %w", err)
|
||||
}
|
||||
seasonIdByRouteId := make(map[int32]int32, len(routes))
|
||||
routesBySeason := make(map[int32][]int32, len(routes))
|
||||
sortOrderByRoute := make(map[int32]int32, len(routes))
|
||||
for _, r := range routes {
|
||||
seasonIdByRouteId[r.MainQuestRouteId] = r.MainQuestSeasonId
|
||||
routesBySeason[r.MainQuestSeasonId] = append(routesBySeason[r.MainQuestSeasonId], r.MainQuestRouteId)
|
||||
sortOrderByRoute[r.MainQuestRouteId] = r.SortOrder
|
||||
}
|
||||
for seasonId, ids := range routesBySeason {
|
||||
s := ids
|
||||
sort.Slice(s, func(i, j int) bool { return sortOrderByRoute[s[i]] > sortOrderByRoute[s[j]] })
|
||||
routesBySeason[seasonId] = s
|
||||
}
|
||||
|
||||
anotherReplayConds, err := utils.ReadTable[EntityMMainQuestRouteAnotherReplayFlowUnlockCondition]("m_main_quest_route_another_replay_flow_unlock_condition")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load main quest route another replay flow unlock condition table: %w", err)
|
||||
}
|
||||
evaluateConds, err := utils.ReadTable[EntityMEvaluateCondition]("m_evaluate_condition")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load evaluate condition table: %w", err)
|
||||
}
|
||||
valueGroupByConditionId := make(map[int32]int32, len(evaluateConds))
|
||||
for _, c := range evaluateConds {
|
||||
valueGroupByConditionId[c.EvaluateConditionId] = c.EvaluateConditionValueGroupId
|
||||
}
|
||||
evaluateValueGroups, err := utils.ReadTable[EntityMEvaluateConditionValueGroup]("m_evaluate_condition_value_group")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load evaluate condition value group table: %w", err)
|
||||
}
|
||||
valueByGroupId := make(map[int32]int32, len(evaluateValueGroups))
|
||||
for _, vg := range evaluateValueGroups {
|
||||
if _, exists := valueByGroupId[vg.EvaluateConditionValueGroupId]; exists {
|
||||
continue
|
||||
}
|
||||
valueByGroupId[vg.EvaluateConditionValueGroupId] = int32(vg.Value)
|
||||
}
|
||||
routeCompletionQuestId := make(map[int32]int32, len(anotherReplayConds))
|
||||
for _, c := range anotherReplayConds {
|
||||
valueGroupId, ok := valueGroupByConditionId[c.UnlockEvaluateConditionId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
questId, ok := valueByGroupId[valueGroupId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
routeCompletionQuestId[c.MainQuestRouteId] = questId
|
||||
}
|
||||
|
||||
firstClearSwitches, err := utils.ReadTable[EntityMQuestFirstClearRewardSwitch]("m_quest_first_clear_reward_switch")
|
||||
@@ -240,21 +288,6 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
|
||||
return nil, fmt.Errorf("load tutorial unlock condition table: %w", err)
|
||||
}
|
||||
|
||||
relations, err := utils.ReadTable[EntityMQuestRelationMainFlow]("m_quest_relation_main_flow")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load quest relation main flow table: %w", err)
|
||||
}
|
||||
questsWithDifficulty := make(map[int32]bool, len(relations)*3)
|
||||
for _, r := range relations {
|
||||
questsWithDifficulty[r.MainFlowQuestId] = true
|
||||
if r.ReplayFlowQuestId != 0 {
|
||||
questsWithDifficulty[r.ReplayFlowQuestId] = true
|
||||
}
|
||||
if r.SubFlowQuestId != 0 {
|
||||
questsWithDifficulty[r.SubFlowQuestId] = true
|
||||
}
|
||||
}
|
||||
|
||||
battleOnlyTargetSceneByQuestId := make(map[int32]int32)
|
||||
for _, scene := range scenes {
|
||||
if scene.IsBattleOnlyTarget {
|
||||
@@ -351,12 +384,23 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
|
||||
chapterBySequenceId[chapter.MainQuestSequenceGroupId] = chapter
|
||||
}
|
||||
routeIdByQuestId := make(map[int32]int32)
|
||||
mainQuestChapterIdByQuestId := make(map[int32]int32)
|
||||
for _, sequence := range sequences {
|
||||
if chapter, ok := chapterBySequenceId[sequence.MainQuestSequenceId]; ok {
|
||||
routeIdByQuestId[sequence.QuestId] = chapter.MainQuestRouteId
|
||||
mainQuestChapterIdByQuestId[sequence.QuestId] = chapter.MainQuestChapterId
|
||||
}
|
||||
}
|
||||
|
||||
eventChapters, err := utils.ReadTable[EntityMEventQuestChapter]("m_event_quest_chapter")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event quest chapter table: %w", err)
|
||||
}
|
||||
eventQuestTypeByChapterId := make(map[int32]int32, len(eventChapters))
|
||||
for _, ec := range eventChapters {
|
||||
eventQuestTypeByChapterId[ec.EventQuestChapterId] = ec.EventQuestType
|
||||
}
|
||||
|
||||
sortedChapters := make([]EntityMMainQuestChapter, len(chapters))
|
||||
copy(sortedChapters, chapters)
|
||||
sort.Slice(sortedChapters, func(i, j int) bool {
|
||||
@@ -555,8 +599,11 @@ func LoadQuestCatalog(partsCatalog *PartsCatalog) (*QuestCatalog, error) {
|
||||
TutorialUnlockConditions: tutorialUnlockConds,
|
||||
ChapterLastSceneByQuestId: chapterLastSceneByQuestId,
|
||||
SeasonIdByRouteId: seasonIdByRouteId,
|
||||
QuestsWithDifficulty: questsWithDifficulty,
|
||||
RoutesBySeason: routesBySeason,
|
||||
RouteCompletionQuestId: routeCompletionQuestId,
|
||||
BattleOnlyTargetSceneByQuestId: battleOnlyTargetSceneByQuestId,
|
||||
MainQuestChapterIdByQuestId: mainQuestChapterIdByQuestId,
|
||||
EventQuestTypeByChapterId: eventQuestTypeByChapterId,
|
||||
|
||||
UserExpThresholds: BuildExpThresholds(paramMapRows, 1),
|
||||
CharacterExpThresholds: BuildExpThresholds(paramMapRows, 31),
|
||||
@@ -578,7 +625,3 @@ func (q *QuestCatalog) BattleOnlyTargetSceneIdFor(questId int32) (int32, bool) {
|
||||
v, ok := q.BattleOnlyTargetSceneByQuestId[questId]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (q *QuestCatalog) QuestHasDifficulty(questId int32) bool {
|
||||
return q.QuestsWithDifficulty[questId]
|
||||
}
|
||||
|
||||
@@ -2,11 +2,35 @@ package masterdata
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
type SideStorySceneInfo struct {
|
||||
SceneId int32
|
||||
Type model.SideStorySceneIdType
|
||||
}
|
||||
|
||||
type SideStoryQuestInfo struct {
|
||||
SideStoryQuestId int32
|
||||
Scenes []SideStorySceneInfo // the 7 scenes, one per type
|
||||
Quests []int32 // ordered event quests (the chapter+difficulty sequence)
|
||||
}
|
||||
|
||||
type SideStoryCatalog struct {
|
||||
FirstSceneByQuestId map[int32]int32
|
||||
QuestById map[int32]*SideStoryQuestInfo
|
||||
ChapterByEventQuestId map[int32]int32 // event quest id -> side story chapter id
|
||||
}
|
||||
|
||||
func (q *SideStoryQuestInfo) SceneIdByType(t model.SideStorySceneIdType) (int32, bool) {
|
||||
for _, s := range q.Scenes {
|
||||
if s.Type == t {
|
||||
return s.SceneId, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func LoadSideStoryCatalog() *SideStoryCatalog {
|
||||
@@ -14,14 +38,90 @@ func LoadSideStoryCatalog() *SideStoryCatalog {
|
||||
if err != nil {
|
||||
log.Fatalf("load side story quest scene table: %v", err)
|
||||
}
|
||||
limitContents, err := utils.ReadTable[EntityMSideStoryQuestLimitContent]("m_side_story_quest_limit_content")
|
||||
if err != nil {
|
||||
log.Fatalf("load side story quest limit content table: %v", err)
|
||||
}
|
||||
seqGroups, err := utils.ReadTable[EntityMEventQuestSequenceGroup]("m_event_quest_sequence_group")
|
||||
if err != nil {
|
||||
log.Fatalf("load event quest sequence group table: %v", err)
|
||||
}
|
||||
sequences, err := utils.ReadTable[EntityMEventQuestSequence]("m_event_quest_sequence")
|
||||
if err != nil {
|
||||
log.Fatalf("load event quest sequence table: %v", err)
|
||||
}
|
||||
|
||||
firstScene := make(map[int32]int32, len(scenes)/7)
|
||||
for _, s := range scenes {
|
||||
if s.SortOrder == 1 {
|
||||
firstScene[s.SideStoryQuestId] = s.SideStoryQuestSceneId
|
||||
seqRows := make(map[int32][]EntityMEventQuestSequence)
|
||||
for _, s := range sequences {
|
||||
seqRows[s.EventQuestSequenceId] = append(seqRows[s.EventQuestSequenceId], s)
|
||||
}
|
||||
orderedQuestIds := make(map[int32][]int32, len(seqRows))
|
||||
for seqId, rows := range seqRows {
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].SortOrder < rows[j].SortOrder })
|
||||
ids := make([]int32, len(rows))
|
||||
for i, r := range rows {
|
||||
ids[i] = r.QuestId
|
||||
}
|
||||
orderedQuestIds[seqId] = ids
|
||||
}
|
||||
|
||||
// (chapterId, difficulty) -> sequenceId. Sequence group id == chapter id.
|
||||
type chapDiff struct{ chapter, difficulty int32 }
|
||||
sequenceByChapterDiff := make(map[chapDiff]int32, len(seqGroups))
|
||||
for _, g := range seqGroups {
|
||||
sequenceByChapterDiff[chapDiff{g.EventQuestSequenceGroupId, g.DifficultyType}] = g.EventQuestSequenceId
|
||||
}
|
||||
|
||||
// sideStoryQuestId -> limit content row. Limit content id == side story quest id.
|
||||
limitByQuest := make(map[int32]EntityMSideStoryQuestLimitContent, len(limitContents))
|
||||
for _, lc := range limitContents {
|
||||
limitByQuest[lc.SideStoryQuestLimitContentId] = lc
|
||||
}
|
||||
|
||||
// sideStoryQuestId -> scene rows
|
||||
scenesByQuest := make(map[int32][]EntityMSideStoryQuestScene)
|
||||
for _, sc := range scenes {
|
||||
scenesByQuest[sc.SideStoryQuestId] = append(scenesByQuest[sc.SideStoryQuestId], sc)
|
||||
}
|
||||
|
||||
questById := make(map[int32]*SideStoryQuestInfo, len(scenesByQuest))
|
||||
chapterByEventQuest := make(map[int32]int32)
|
||||
|
||||
for ssqId, rows := range scenesByQuest {
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].SortOrder < rows[j].SortOrder })
|
||||
|
||||
var orderedQuests []int32
|
||||
var chapterId, difficulty int32
|
||||
if lc, ok := limitByQuest[ssqId]; ok {
|
||||
chapterId = lc.EventQuestChapterId
|
||||
difficulty = lc.DifficultyType
|
||||
if seqId, ok := sequenceByChapterDiff[chapDiff{chapterId, difficulty}]; ok {
|
||||
orderedQuests = orderedQuestIds[seqId]
|
||||
}
|
||||
}
|
||||
if chapterId != 0 {
|
||||
for _, questId := range orderedQuests {
|
||||
chapterByEventQuest[questId] = chapterId
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("side story catalog loaded: %d quests", len(firstScene))
|
||||
return &SideStoryCatalog{FirstSceneByQuestId: firstScene}
|
||||
info := &SideStoryQuestInfo{
|
||||
SideStoryQuestId: ssqId,
|
||||
Scenes: make([]SideStorySceneInfo, 0, len(rows)),
|
||||
Quests: orderedQuests,
|
||||
}
|
||||
for _, sc := range rows {
|
||||
info.Scenes = append(info.Scenes, SideStorySceneInfo{
|
||||
SceneId: sc.SideStoryQuestSceneId,
|
||||
Type: model.SideStorySceneIdType(sc.SortOrder),
|
||||
})
|
||||
}
|
||||
questById[ssqId] = info
|
||||
}
|
||||
|
||||
log.Printf("side story catalog loaded: %d quests, %d scenes", len(questById), len(scenes))
|
||||
return &SideStoryCatalog{
|
||||
QuestById: questById,
|
||||
ChapterByEventQuestId: chapterByEventQuest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package masterdata
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
type TowerTier struct {
|
||||
QuestMissionClearCount int32
|
||||
Rewards []RewardItem
|
||||
}
|
||||
|
||||
type TowerCatalog struct {
|
||||
TiersByChapter map[int32][]TowerTier
|
||||
}
|
||||
|
||||
func (c *TowerCatalog) CollectRewards(chapterId, oldCount, targetCount int32) ([]RewardItem, int32) {
|
||||
var items []RewardItem
|
||||
highest := int32(0)
|
||||
for _, t := range c.TiersByChapter[chapterId] {
|
||||
if t.QuestMissionClearCount > oldCount && t.QuestMissionClearCount <= targetCount {
|
||||
items = append(items, t.Rewards...)
|
||||
if t.QuestMissionClearCount > highest {
|
||||
highest = t.QuestMissionClearCount
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, highest
|
||||
}
|
||||
|
||||
func LoadTowerCatalog() *TowerCatalog {
|
||||
// chapterId -> accumulation reward group id
|
||||
accumRewardRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationReward]("m_event_quest_tower_accumulation_reward")
|
||||
if err != nil {
|
||||
log.Fatalf("load event quest tower accumulation reward table: %v", err)
|
||||
}
|
||||
groupByChapter := make(map[int32]int32, len(accumRewardRows))
|
||||
for _, r := range accumRewardRows {
|
||||
groupByChapter[r.EventQuestChapterId] = r.EventQuestTowerAccumulationRewardGroupId
|
||||
}
|
||||
|
||||
// reward group id -> reward items
|
||||
rewardGroupRows, err := utils.ReadTable[EntityMEventQuestTowerRewardGroup]("m_event_quest_tower_reward_group")
|
||||
if err != nil {
|
||||
log.Fatalf("load event quest tower reward group table: %v", err)
|
||||
}
|
||||
itemsByRewardGroup := make(map[int32][]RewardItem)
|
||||
for _, r := range rewardGroupRows {
|
||||
itemsByRewardGroup[r.EventQuestTowerRewardGroupId] = append(itemsByRewardGroup[r.EventQuestTowerRewardGroupId], RewardItem{
|
||||
PossessionType: r.PossessionType,
|
||||
PossessionId: r.PossessionId,
|
||||
Count: r.Count,
|
||||
})
|
||||
}
|
||||
|
||||
// accumulation group id -> tiers (threshold + resolved reward items)
|
||||
accumGroupRows, err := utils.ReadTable[EntityMEventQuestTowerAccumulationRewardGroup]("m_event_quest_tower_accumulation_reward_group")
|
||||
if err != nil {
|
||||
log.Fatalf("load event quest tower accumulation reward group table: %v", err)
|
||||
}
|
||||
tiersByGroup := make(map[int32][]TowerTier)
|
||||
for _, r := range accumGroupRows {
|
||||
tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId] = append(tiersByGroup[r.EventQuestTowerAccumulationRewardGroupId], TowerTier{
|
||||
QuestMissionClearCount: r.QuestMissionClearCount,
|
||||
Rewards: itemsByRewardGroup[r.EventQuestTowerRewardGroupId],
|
||||
})
|
||||
}
|
||||
|
||||
// resolve per-chapter, sorted ascending by threshold
|
||||
tiersByChapter := make(map[int32][]TowerTier, len(groupByChapter))
|
||||
for chapterId, groupId := range groupByChapter {
|
||||
tiers := tiersByGroup[groupId]
|
||||
sort.Slice(tiers, func(i, j int) bool {
|
||||
return tiers[i].QuestMissionClearCount < tiers[j].QuestMissionClearCount
|
||||
})
|
||||
tiersByChapter[chapterId] = tiers
|
||||
}
|
||||
|
||||
log.Printf("tower catalog loaded: %d chapters", len(tiersByChapter))
|
||||
|
||||
return &TowerCatalog{TiersByChapter: tiersByChapter}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package masterdata
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
type WebviewPanelMissionCatalog struct {
|
||||
PageIds []int32 // every WebviewPanelMissionPageId, sorted ascending
|
||||
}
|
||||
|
||||
func LoadWebviewPanelMissionCatalog() *WebviewPanelMissionCatalog {
|
||||
rows, err := utils.ReadTable[EntityMWebviewPanelMissionPage]("m_webview_panel_mission_page")
|
||||
if err != nil {
|
||||
log.Printf("load webview panel mission page table: %v", err)
|
||||
return &WebviewPanelMissionCatalog{}
|
||||
}
|
||||
ids := make([]int32, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.WebviewPanelMissionPageId)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return &WebviewPanelMissionCatalog{PageIds: ids}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type GimmickType int32
|
||||
|
||||
const (
|
||||
GimmickTypeUnknown GimmickType = 0
|
||||
GimmickTypeCageTreasureHunt GimmickType = 1 // "Fickle Black Birds" — in-cage flick-and-tap birds
|
||||
GimmickTypeCageIntervalDropItem GimmickType = 2 // "Lost Items" — in-cage 3-dot drops that respawn on interval
|
||||
GimmickTypeBrokenObelisk GimmickType = 3 // "Broken Scarecrow" (per tip text); zero rows in m_gimmick, unused
|
||||
GimmickTypeIronGrill GimmickType = 4 // unused (zero rows in m_gimmick), in-game name unknown
|
||||
GimmickTypeRadioMessage GimmickType = 5 // unused (zero rows in m_gimmick); client has GimmickRadioMessage class but no data
|
||||
GimmickTypeFirstBrokenObelisk GimmickType = 6 // variant of Broken Scarecrow; zero rows in m_gimmick, unused
|
||||
GimmickTypeMapOnlyCageTreasureHunt GimmickType = 7 // "Hidden Black Birds" — world-map birds; per-tap reward from m_cage_ornament_reward
|
||||
GimmickTypeMapOnlyCageIntervalDrop GimmickType = 8 // map-side variant of Lost Items
|
||||
GimmickTypeReport GimmickType = 9 // "Hidden Stories" — hidden mission markers
|
||||
GimmickTypeCageMemory GimmickType = 10 // "Lost Archives" — collectible library entries (one-shot ImportantItem type-4)
|
||||
GimmickTypeMapOnlyHideObelisk GimmickType = 11 // "Stray Scarecrow" — world-map scarecrows (not yet implemented)
|
||||
)
|
||||
@@ -12,10 +12,6 @@ const (
|
||||
QuestFlowTypeAnotherRouteReplayFlow QuestFlowType = 4
|
||||
)
|
||||
|
||||
// IsReplayQuestFlowType reports whether the flow type indicates an active
|
||||
// replay session — either same-route REPLAY_FLOW or cross-route
|
||||
// ANOTHER_ROUTE_REPLAY_FLOW. Mirrors the client's Story.IsReplayQuestFlowType
|
||||
// predicate (dump.cs:768202).
|
||||
func IsReplayQuestFlowType(t int32) bool {
|
||||
return t == int32(QuestFlowTypeReplayFlow) ||
|
||||
t == int32(QuestFlowTypeAnotherRouteReplayFlow)
|
||||
@@ -47,6 +43,15 @@ const (
|
||||
QuestResultTypeFullResult QuestResultType = 3
|
||||
)
|
||||
|
||||
type MissionProgressStatusType int32
|
||||
|
||||
const (
|
||||
MissionProgressStatusTypeUnknown MissionProgressStatusType = 0
|
||||
MissionProgressStatusTypeInProgress MissionProgressStatusType = 1
|
||||
MissionProgressStatusTypeClear MissionProgressStatusType = 2
|
||||
MissionProgressStatusTypeRewardReceived MissionProgressStatusType = 9
|
||||
)
|
||||
|
||||
type QuestSceneType int32
|
||||
|
||||
const (
|
||||
@@ -164,6 +169,19 @@ type SideStoryQuestStateType int32
|
||||
|
||||
const (
|
||||
SideStoryQuestStateUnknown SideStoryQuestStateType = 0
|
||||
SideStoryQuestStateActive SideStoryQuestStateType = 1
|
||||
SideStoryQuestStateCleared SideStoryQuestStateType = 2
|
||||
SideStoryQuestStateActive SideStoryQuestStateType = 2
|
||||
SideStoryQuestStateCleared SideStoryQuestStateType = 3
|
||||
)
|
||||
|
||||
type SideStorySceneIdType int32
|
||||
|
||||
const (
|
||||
SideStorySceneInvalid SideStorySceneIdType = 0
|
||||
SideStorySceneIntroduction SideStorySceneIdType = 1
|
||||
SideStoryScenePlayGeneralQuest SideStorySceneIdType = 2
|
||||
SideStorySceneUnlockLastQuest SideStorySceneIdType = 3
|
||||
SideStoryScenePlayLastQuest SideStorySceneIdType = 4
|
||||
SideStorySceneOutroduction SideStorySceneIdType = 5
|
||||
SideStorySceneShowCostumeAcquisition SideStorySceneIdType = 6
|
||||
SideStoryScenePlayFreeQuest SideStorySceneIdType = 7
|
||||
)
|
||||
|
||||
@@ -17,7 +17,8 @@ func (h *QuestHandler) HandleBigHuntQuestStart(user *store.UserState, questId, u
|
||||
|
||||
if quest.Stamina > 0 {
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
|
||||
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForBigHunt(questId), nowMillis)
|
||||
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
questState := user.Quests[questId]
|
||||
@@ -33,13 +34,15 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i
|
||||
panic(fmt.Sprintf("unknown questId=%d for HandleBigHuntQuestFinish", questId))
|
||||
}
|
||||
|
||||
outcome := h.evaluateFinishOutcome(user, questId)
|
||||
target := h.targetForBigHunt(questId)
|
||||
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
|
||||
if !isRetired {
|
||||
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
|
||||
}
|
||||
|
||||
if isRetired && !isAnnihilated && quest.Stamina > 1 {
|
||||
refund := quest.Stamina - 1
|
||||
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
|
||||
if isRetired && !isAnnihilated && consumed > 1 {
|
||||
refund := consumed - 1
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleEventQuestStart(user *store.UserState, eventQuestCh
|
||||
|
||||
if quest.Stamina > 0 {
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
|
||||
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForEvent(eventQuestChapterId, questId), nowMillis)
|
||||
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
questState := user.Quests[questId]
|
||||
@@ -42,17 +43,22 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
|
||||
panic(fmt.Sprintf("unknown questId=%d for HandleEventQuestFinish", questId))
|
||||
}
|
||||
|
||||
outcome := h.evaluateFinishOutcome(user, questId)
|
||||
target := h.targetForEvent(eventQuestChapterId, questId)
|
||||
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
|
||||
if !isRetired {
|
||||
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
|
||||
h.recordSideStoryLimitContentStatus(user, questId, nowMillis)
|
||||
}
|
||||
|
||||
if isRetired && !isAnnihilated && quest.Stamina > 1 {
|
||||
refund := quest.Stamina - 1
|
||||
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
|
||||
if isRetired && !isAnnihilated && consumed > 1 {
|
||||
refund := consumed - 1
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
restoreClearedAfterRetire(user, questId, isRetired)
|
||||
|
||||
user.EventQuest.CurrentEventQuestChapterId = 0
|
||||
user.EventQuest.CurrentQuestId = 0
|
||||
user.EventQuest.CurrentQuestSceneId = 0
|
||||
@@ -64,6 +70,18 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (h *QuestHandler) recordSideStoryLimitContentStatus(user *store.UserState, questId int32, nowMillis int64) {
|
||||
chapterId, ok := h.SideStoryChapterByEventQuestId[questId]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st := user.QuestLimitContentStatus[questId]
|
||||
st.LimitContentQuestStatusType = 1
|
||||
st.EventQuestChapterId = chapterId
|
||||
st.LatestVersion = nowMillis
|
||||
user.QuestLimitContentStatus[questId] = st
|
||||
}
|
||||
|
||||
func (h *QuestHandler) HandleEventQuestRestart(user *store.UserState, eventQuestChapterId, questId int32, nowMillis int64) {
|
||||
h.HandleQuestRestart(user, questId, nowMillis)
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ func (h *QuestHandler) HandleExtraQuestStart(user *store.UserState, questId, use
|
||||
|
||||
if quest.Stamina > 0 {
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.ConsumeStamina(user, quest.Stamina, maxMillis, nowMillis)
|
||||
stamina := h.staminaWithCampaign(quest.Stamina, h.targetForExtra(questId), nowMillis)
|
||||
store.ConsumeStamina(user, stamina, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
questState := user.Quests[questId]
|
||||
@@ -40,17 +41,21 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int
|
||||
panic(fmt.Sprintf("unknown questId=%d for HandleExtraQuestFinish", questId))
|
||||
}
|
||||
|
||||
outcome := h.evaluateFinishOutcome(user, questId)
|
||||
target := h.targetForExtra(questId)
|
||||
outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis)
|
||||
if !isRetired {
|
||||
h.applyQuestVictory(user, questId, &outcome, nowMillis, false)
|
||||
}
|
||||
|
||||
if isRetired && !isAnnihilated && quest.Stamina > 1 {
|
||||
refund := quest.Stamina - 1
|
||||
consumed := h.staminaWithCampaign(quest.Stamina, target, nowMillis)
|
||||
if isRetired && !isAnnihilated && consumed > 1 {
|
||||
refund := consumed - 1
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
restoreClearedAfterRetire(user, questId, isRetired)
|
||||
|
||||
user.ExtraQuest.CurrentQuestId = 0
|
||||
user.ExtraQuest.CurrentQuestSceneId = 0
|
||||
user.ExtraQuest.HeadQuestSceneId = 0
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package questflow
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
@@ -27,11 +30,23 @@ type QuestHandler struct {
|
||||
*masterdata.QuestCatalog
|
||||
Config *masterdata.GameConfig
|
||||
Granter *store.PossessionGranter
|
||||
SideStoryChapterByEventQuestId map[int32]int32
|
||||
Campaigns *campaign.Catalog
|
||||
}
|
||||
|
||||
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *QuestHandler {
|
||||
func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog) *QuestHandler {
|
||||
granter := BuildGranter(catalog)
|
||||
return &QuestHandler{QuestCatalog: catalog, Config: config, Granter: granter}
|
||||
var sideStoryChapters map[int32]int32
|
||||
if sideStory != nil {
|
||||
sideStoryChapters = sideStory.ChapterByEventQuestId
|
||||
}
|
||||
return &QuestHandler{
|
||||
QuestCatalog: catalog,
|
||||
Config: config,
|
||||
Granter: granter,
|
||||
SideStoryChapterByEventQuestId: sideStoryChapters,
|
||||
Campaigns: campaigns,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
|
||||
@@ -60,12 +75,40 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
|
||||
releaseConditions[groupId] = conds
|
||||
}
|
||||
partsById := make(map[int32]store.PartsRef, len(catalog.PartsById))
|
||||
partsVariants := make(map[int32]map[int32][]int32)
|
||||
for id, p := range catalog.PartsById {
|
||||
partsById[id] = store.PartsRef{
|
||||
PartsGroupId: p.PartsGroupId,
|
||||
RarityType: p.RarityType,
|
||||
PartsInitialLotteryId: p.PartsInitialLotteryId,
|
||||
PartsStatusMainLotteryGroupId: p.PartsStatusMainLotteryGroupId,
|
||||
PartsStatusSubLotteryGroupId: p.PartsStatusSubLotteryGroupId,
|
||||
}
|
||||
if partsVariants[p.PartsGroupId] == nil {
|
||||
partsVariants[p.PartsGroupId] = map[int32][]int32{}
|
||||
}
|
||||
partsVariants[p.PartsGroupId][p.RarityType] = append(partsVariants[p.PartsGroupId][p.RarityType], p.PartsId)
|
||||
}
|
||||
for _, byRarity := range partsVariants {
|
||||
for _, ids := range byRarity {
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
}
|
||||
}
|
||||
|
||||
partsSubDefs := make(map[int32]store.PartsStatusSubDef, len(catalog.PartsStatusMainById))
|
||||
for id, d := range catalog.PartsStatusMainById {
|
||||
var fn func(int32) int32
|
||||
if f, ok := catalog.FuncResolver.Resolve(d.StatusNumericalFunctionId); ok {
|
||||
fn = f.Evaluate
|
||||
}
|
||||
partsSubDefs[id] = store.PartsStatusSubDef{
|
||||
StatusKindType: d.StatusKindType,
|
||||
StatusCalculationType: d.StatusCalculationType,
|
||||
StatusChangeInitialValue: d.StatusChangeInitialValue,
|
||||
StatusFunc: fn,
|
||||
}
|
||||
}
|
||||
|
||||
return &store.PossessionGranter{
|
||||
CostumeById: costumeById,
|
||||
WeaponById: weaponById,
|
||||
@@ -74,5 +117,8 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter {
|
||||
ReleaseConditions: releaseConditions,
|
||||
PartsById: partsById,
|
||||
DefaultPartsStatusMainByLotteryGroup: catalog.DefaultPartsStatusMainByLotteryGroup,
|
||||
PartsVariantsByGroupRarity: partsVariants,
|
||||
PartsSubStatusPool: catalog.SubStatusPool,
|
||||
PartsSubStatusDefs: partsSubDefs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ func (h *QuestHandler) initQuestState(user *store.UserState, questId int32) {
|
||||
}
|
||||
|
||||
func isMainQuestPlayable(quest masterdata.EntityMQuest) bool {
|
||||
return !quest.IsRunInTheBackground && quest.IsCountedAsQuest
|
||||
if quest.IsRunInTheBackground {
|
||||
// A background quest is still actively played — and must NOT be
|
||||
// auto-cleared on start — when it carries battle content (a non-zero
|
||||
// recommended deck power, e.g. quests 500/515/30515). Pure cutscene
|
||||
// background quests have RecommendedDeckPower == 0.
|
||||
return quest.RecommendedDeckPower > 0
|
||||
}
|
||||
return quest.IsCountedAsQuest
|
||||
}
|
||||
|
||||
func (h *QuestHandler) clearQuestMissions(user *store.UserState, questId int32, nowMillis int64) {
|
||||
@@ -54,7 +61,8 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
|
||||
|
||||
h.initQuestState(user, questId)
|
||||
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]
|
||||
@@ -62,7 +70,7 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
|
||||
questState.UserDeckNumber = userDeckNumber
|
||||
|
||||
isCleared := questState.QuestStateType == model.UserQuestStateTypeCleared
|
||||
isMenuPick := !isReplayFlow && !isMainFlow && (isCleared || h.QuestHasDifficulty(questId))
|
||||
isMenuPick := !isReplayFlow && !isMainFlow
|
||||
|
||||
switch {
|
||||
case isMenuPick:
|
||||
@@ -77,9 +85,14 @@ func (h *QuestHandler) handleQuestStartInternal(user *store.UserState, questId i
|
||||
user.MainQuest.LatestVersion = nowMillis
|
||||
log.Printf("[HandleQuestStart] QuestMenuPick quest=%d isBattleOnly=%v scene=%d cleared=%v",
|
||||
questId, isBattleOnly, sceneId, isCleared)
|
||||
if isCleared {
|
||||
questState.LatestStartDatetime = nowMillis
|
||||
user.Quests[questId] = questState
|
||||
return
|
||||
}
|
||||
|
||||
case isReplayFlow:
|
||||
h.applyReplayStart(user, questId, isBattleOnly, nowMillis)
|
||||
h.applyReplayStart(user, quest, questId, isBattleOnly, nowMillis)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,12 +135,11 @@ func snapshotMainQuestIfNeeded(user *store.UserState) {
|
||||
MainQuestSeasonId: user.MainQuest.MainQuestSeasonId,
|
||||
IsReachedLastQuestScene: user.MainQuest.IsReachedLastQuestScene,
|
||||
PortalCageInProgress: user.PortalCageStatus.IsCurrentProgress,
|
||||
CurrentQuestFlowType: user.MainQuest.CurrentQuestFlowType,
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve CurrentQuestFlowType when HandleReplayFlowSceneProgress already
|
||||
// set it: replay-variant ids (30000+) aren't in RouteIdByQuestId.
|
||||
func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, isBattleOnly bool, nowMillis int64) {
|
||||
func (h *QuestHandler) applyReplayStart(user *store.UserState, quest masterdata.EntityMQuest, questId int32, isBattleOnly bool, nowMillis int64) {
|
||||
flowType := h.replayFlowTypeFromQuestId(user, questId)
|
||||
if model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) {
|
||||
flowType = model.QuestFlowType(user.MainQuest.CurrentQuestFlowType)
|
||||
@@ -136,12 +148,26 @@ func (h *QuestHandler) applyReplayStart(user *store.UserState, questId int32, is
|
||||
user.MainQuest.LatestVersion = nowMillis
|
||||
|
||||
questState := user.Quests[questId]
|
||||
questState.QuestStateType = model.UserQuestStateTypeActive
|
||||
questState.LatestStartDatetime = nowMillis
|
||||
user.Quests[questId] = questState
|
||||
|
||||
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v current=%d head=%d",
|
||||
questId, flowType, isBattleOnly,
|
||||
if isMainQuestPlayable(quest) {
|
||||
questState.QuestStateType = model.UserQuestStateTypeActive
|
||||
user.Quests[questId] = questState
|
||||
} else {
|
||||
if questState.QuestStateType != model.UserQuestStateTypeCleared {
|
||||
questState.QuestStateType = model.UserQuestStateTypeCleared
|
||||
questState.ClearCount++
|
||||
questState.DailyClearCount++
|
||||
questState.LastClearDatetime = nowMillis
|
||||
}
|
||||
user.Quests[questId] = questState
|
||||
if sceneIds := h.SceneIdsByQuestId[questId]; len(sceneIds) > 0 {
|
||||
h.advanceReplayFlowScene(user, sceneIds[0])
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[HandleQuestStart] replay quest=%d flowType=%s isBattleOnly=%v playable=%v current=%d head=%d",
|
||||
questId, flowType, isBattleOnly, isMainQuestPlayable(quest),
|
||||
user.MainQuest.ReplayFlowCurrentQuestSceneId,
|
||||
user.MainQuest.ReplayFlowHeadQuestSceneId)
|
||||
}
|
||||
@@ -160,8 +186,8 @@ func (h *QuestHandler) menuPickSceneId(questId int32, isBattleOnly bool) int32 {
|
||||
|
||||
func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, outcome *FinishOutcome, nowMillis int64, wasReplay bool) {
|
||||
questState := user.Quests[questId]
|
||||
if !questState.IsRewardGranted {
|
||||
h.applyExpAndGoldRewards(user, questId, nowMillis)
|
||||
if !questState.IsRewardGranted {
|
||||
if !wasReplay {
|
||||
h.applyFirstClearItemRewards(user, questId, nowMillis)
|
||||
outcome.ChangedWeaponStoryIds = append(outcome.ChangedWeaponStoryIds,
|
||||
@@ -215,6 +241,17 @@ func (h *QuestHandler) finalizeChainPreviousQuest(user *store.UserState, questId
|
||||
log.Printf("[HandleMainQuestSceneProgress] finalized chain-previous quest %d (cleared)", questId)
|
||||
}
|
||||
|
||||
func restoreClearedAfterRetire(user *store.UserState, questId int32, isRetired bool) {
|
||||
if !isRetired {
|
||||
return
|
||||
}
|
||||
qs := user.Quests[questId]
|
||||
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
|
||||
qs.QuestStateType = model.UserQuestStateTypeCleared
|
||||
user.Quests[questId] = qs
|
||||
}
|
||||
}
|
||||
|
||||
func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, isRetired, isAnnihilated bool, nowMillis int64) FinishOutcome {
|
||||
quest, ok := h.QuestById[questId]
|
||||
if !ok {
|
||||
@@ -223,36 +260,32 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
|
||||
|
||||
h.initQuestState(user, questId)
|
||||
|
||||
outcome := h.evaluateFinishOutcome(user, questId)
|
||||
outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis)
|
||||
wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType)
|
||||
wasMenuReplay := user.MainQuest.SavedContext.Active
|
||||
|
||||
if !isRetired {
|
||||
h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay)
|
||||
|
||||
if isMainQuestPlayable(quest) && !wasMenuReplay {
|
||||
// A replay-flow finish must NOT move the MainFlow scene pointer: the
|
||||
// finished quest is a replay-variant (30000+) with no chapter, so a
|
||||
// replay scene left in CurrentQuestSceneId makes the client world map's
|
||||
// CalculatorWorldMap.GetCurrentSeasonId resolve chapter 0 and NRE. The
|
||||
// replay's own position is tracked in ReplayFlowCurrentQuestSceneId.
|
||||
if isMainQuestPlayable(quest) && !wasMenuReplay && !wasReplay {
|
||||
lastSceneId := h.getLastMainFlowSceneId(questId)
|
||||
h.advanceMainFlowScene(user, questId, lastSceneId)
|
||||
}
|
||||
}
|
||||
|
||||
if isRetired && !isAnnihilated && quest.Stamina > 1 {
|
||||
refund := quest.Stamina - 1
|
||||
consumed := h.staminaWithCampaign(quest.Stamina, h.targetForMain(questId), nowMillis)
|
||||
if isRetired && !isAnnihilated && consumed > 1 {
|
||||
refund := consumed - 1
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.RecoverStamina(user, refund*1000, maxMillis, nowMillis)
|
||||
}
|
||||
|
||||
// On retire of a previously-cleared quest (cage Menu Pick replay or
|
||||
// Map Play replay), HandleQuestStart marked QuestStateType=Active for
|
||||
// the run. With applyQuestVictory skipped on retire, that Active sticks
|
||||
// and the cage UI shows the quest as locked. Restore Cleared.
|
||||
if isRetired {
|
||||
qs := user.Quests[questId]
|
||||
if qs.ClearCount > 0 && qs.QuestStateType == model.UserQuestStateTypeActive {
|
||||
qs.QuestStateType = model.UserQuestStateTypeCleared
|
||||
user.Quests[questId] = qs
|
||||
}
|
||||
}
|
||||
restoreClearedAfterRetire(user, questId, isRetired)
|
||||
|
||||
user.MainQuest.ProgressQuestSceneId = 0
|
||||
user.MainQuest.ProgressHeadQuestSceneId = 0
|
||||
@@ -270,13 +303,14 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i
|
||||
user.MainQuest.CurrentMainQuestRouteId = ctx.CurrentMainQuestRouteId
|
||||
user.MainQuest.MainQuestSeasonId = ctx.MainQuestSeasonId
|
||||
user.MainQuest.IsReachedLastQuestScene = ctx.IsReachedLastQuestScene
|
||||
user.MainQuest.CurrentQuestFlowType = ctx.CurrentQuestFlowType
|
||||
user.PortalCageStatus.IsCurrentProgress = ctx.PortalCageInProgress
|
||||
user.PortalCageStatus.LatestVersion = nowMillis
|
||||
user.MainQuest.SavedContext = store.SavedQuestContext{}
|
||||
user.MainQuest.LatestVersion = nowMillis
|
||||
log.Printf("[HandleQuestFinish] restored snapshot for quest %d (route=%d season=%d scene=%d head=%d cage=%v)",
|
||||
log.Printf("[HandleQuestFinish] restored snapshot for quest %d (route=%d season=%d scene=%d head=%d cage=%v flow=%d)",
|
||||
questId, ctx.CurrentMainQuestRouteId, ctx.MainQuestSeasonId,
|
||||
ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress)
|
||||
ctx.CurrentQuestSceneId, ctx.HeadQuestSceneId, ctx.PortalCageInProgress, ctx.CurrentQuestFlowType)
|
||||
}
|
||||
|
||||
h.clearQuestMissions(user, questId, nowMillis)
|
||||
@@ -290,18 +324,19 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount
|
||||
panic(fmt.Sprintf("unknown questId=%d for HandleQuestSkip", questId))
|
||||
}
|
||||
|
||||
target := h.targetForMain(questId)
|
||||
maxMillis := h.MaxStaminaByLevel[user.Status.Level] * 1000
|
||||
store.ConsumeStamina(user, skipCount, maxMillis, nowMillis)
|
||||
perSkipStamina := h.staminaWithCampaign(questDef.Stamina, target, nowMillis)
|
||||
store.ConsumeStamina(user, perSkipStamina*skipCount, maxMillis, nowMillis)
|
||||
|
||||
skipTicketId := h.Config.ConsumableItemIdForQuestSkipTicket
|
||||
user.ConsumableItems[skipTicketId] -= skipCount
|
||||
if user.ConsumableItems[skipTicketId] < 0 {
|
||||
user.ConsumableItems[skipTicketId] = 0
|
||||
}
|
||||
|
||||
var allDrops []RewardGrant
|
||||
for range skipCount {
|
||||
drops := h.computeDropRewards(questDef)
|
||||
drops := h.computeDropRewards(questDef, target, nowMillis)
|
||||
for _, drop := range drops {
|
||||
h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gameutil"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
func (h *QuestHandler) isQuestCleared(user *store.UserState, questId int32) bool {
|
||||
quest, ok := user.Quests[questId]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("unknown questId=%d for isQuestCleared", questId))
|
||||
return false
|
||||
}
|
||||
return quest.QuestStateType == model.UserQuestStateTypeCleared
|
||||
}
|
||||
@@ -40,7 +41,7 @@ func (h *QuestHandler) firstClearRewardGroupId(user *store.UserState, questDef m
|
||||
return rewardGroupId
|
||||
}
|
||||
|
||||
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32) FinishOutcome {
|
||||
func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int32, target campaign.QuestTarget, nowMillis int64) FinishOutcome {
|
||||
outcome := FinishOutcome{}
|
||||
questState, ok := user.Quests[questId]
|
||||
if !ok {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest) []RewardGrant {
|
||||
if questDef.QuestPickupRewardGroupId == 0 {
|
||||
return nil
|
||||
}
|
||||
func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant {
|
||||
var drops []RewardGrant
|
||||
var dropRate campaign.DropRateMul
|
||||
if h.Campaigns != nil {
|
||||
dropRate = h.Campaigns.QuestDropRate(target, h.campaignFilter(nowMillis))
|
||||
}
|
||||
if questDef.QuestPickupRewardGroupId != 0 {
|
||||
for _, dropId := range h.PickupRewardIdsByGroupId[questDef.QuestPickupRewardGroupId] {
|
||||
if bdr, ok := h.BattleDropRewardById[dropId]; ok {
|
||||
drops = append(drops, RewardGrant{
|
||||
PossessionType: model.PossessionType(bdr.PossessionType),
|
||||
PossessionId: bdr.PossessionId,
|
||||
Count: bdr.Count,
|
||||
Count: dropRate.Apply(bdr.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
return drops
|
||||
}
|
||||
return h.appendBonusDrops(drops, target, nowMillis)
|
||||
}
|
||||
|
||||
func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, nowMillis int64) {
|
||||
@@ -166,6 +170,10 @@ func (h *QuestHandler) applyExpRewards(user *store.UserState, questId int32, now
|
||||
return
|
||||
}
|
||||
|
||||
if questDef.CharacterExp == 0 && questDef.CostumeExp == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deckCostumeUuids, deckCharacterIds := h.resolveDeckUnits(user, questId)
|
||||
if deckCostumeUuids == nil {
|
||||
log.Printf("[applyExpRewards] questId=%d skipping character/costume exp (deck not resolved)", questId)
|
||||
|
||||
@@ -46,29 +46,39 @@ func (h *QuestHandler) advanceMainFlowScene(user *store.UserState, questId, scen
|
||||
user.MainQuest.CurrentMainQuestRouteId = routeId
|
||||
if seasonId, ok := h.SeasonIdByRouteId[routeId]; ok {
|
||||
user.MainQuest.MainQuestSeasonId = seasonId
|
||||
RecordSeasonRoute(user, seasonId, routeId, gametime.NowMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backs IUserMainQuestSeasonRoute: the client needs the history to load
|
||||
// scene metadata when cage menu-replay jumps to older chapters.
|
||||
func RecordSeasonRoute(user *store.UserState, seasonId, routeId int32, nowMillis int64) {
|
||||
if seasonId <= 0 || routeId <= 0 {
|
||||
func (h *QuestHandler) advanceReplayFlowScene(user *store.UserState, sceneId int32) {
|
||||
if !h.isSceneAhead(sceneId, user.MainQuest.ReplayFlowHeadQuestSceneId) {
|
||||
return
|
||||
}
|
||||
if user.MainQuestSeasonRoutes == nil {
|
||||
user.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry)
|
||||
user.MainQuest.ReplayFlowCurrentQuestSceneId = sceneId
|
||||
user.MainQuest.ReplayFlowHeadQuestSceneId = sceneId
|
||||
}
|
||||
|
||||
func (h *QuestHandler) SeasonRoutesFor(user *store.UserState) map[int32]int32 {
|
||||
out := make(map[int32]int32)
|
||||
for seasonId, routes := range h.RoutesBySeason {
|
||||
if seasonId <= 1 {
|
||||
continue
|
||||
}
|
||||
key := store.SeasonRouteKey{MainQuestSeasonId: seasonId, MainQuestRouteId: routeId}
|
||||
if _, exists := user.MainQuestSeasonRoutes[key]; exists {
|
||||
return
|
||||
for _, routeId := range routes {
|
||||
finalQuestId, ok := h.RouteCompletionQuestId[routeId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user.MainQuestSeasonRoutes[key] = store.SeasonRouteEntry{
|
||||
MainQuestSeasonId: seasonId,
|
||||
MainQuestRouteId: routeId,
|
||||
LatestVersion: nowMillis,
|
||||
if q, ok := user.Quests[finalQuestId]; ok && q.ClearCount > 0 {
|
||||
out[seasonId] = routeId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur := user.MainQuest.MainQuestSeasonId; cur >= 2 && user.MainQuest.CurrentMainQuestRouteId > 0 {
|
||||
out[cur] = user.MainQuest.CurrentMainQuestRouteId
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *QuestHandler) HandleMainFlowSceneProgress(user *store.UserState, questSceneId int32, nowMillis int64) {
|
||||
@@ -142,6 +152,12 @@ func (h *QuestHandler) HandleReplayFlowSceneProgress(user *store.UserState, ques
|
||||
user.PortalCageStatus.IsCurrentProgress = false
|
||||
user.PortalCageStatus.LatestVersion = nowMillis
|
||||
|
||||
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
|
||||
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
|
||||
flowType := h.replayFlowType(user, questSceneId)
|
||||
user.MainQuest.CurrentQuestFlowType = int32(flowType)
|
||||
user.MainQuest.LatestVersion = nowMillis
|
||||
@@ -165,11 +181,10 @@ func (h *QuestHandler) replayFlowTypeForRoute(user *store.UserState, routeId int
|
||||
if !ok {
|
||||
return model.QuestFlowTypeReplayFlow
|
||||
}
|
||||
for key, entry := range user.MainQuestSeasonRoutes {
|
||||
if key.MainQuestSeasonId == seasonId && entry.MainQuestRouteId != routeId {
|
||||
pairs := h.SeasonRoutesFor(user)
|
||||
if recorded, ok := pairs[seasonId]; ok && recorded != routeId {
|
||||
return model.QuestFlowTypeAnotherRouteReplayFlow
|
||||
}
|
||||
}
|
||||
return model.QuestFlowTypeReplayFlow
|
||||
}
|
||||
|
||||
@@ -215,7 +230,10 @@ func (h *QuestHandler) HandleMainQuestSceneProgress(user *store.UserState, quest
|
||||
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeSubFlow)
|
||||
user.MainQuest.ProgressQuestFlowType = int32(model.QuestFlowTypeSubFlow)
|
||||
}
|
||||
} else {
|
||||
} else if !isReplay {
|
||||
// Background/non-playable quest: advance the MainFlow pointer — but not
|
||||
// during a replay, where the isReplay block below tracks the ReplayFlow
|
||||
// scene and the MainFlow pointer must stay on real main-story progress.
|
||||
user.MainQuest.CurrentQuestSceneId = questSceneId
|
||||
if h.isSceneAhead(questSceneId, user.MainQuest.HeadQuestSceneId) {
|
||||
user.MainQuest.HeadQuestSceneId = questSceneId
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gacha"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/masterdata/memorydb"
|
||||
"lunar-tear/server/internal/questflow"
|
||||
"lunar-tear/server/internal/userdata"
|
||||
)
|
||||
|
||||
// buildCatalogs runs the full Load*/Build*/Enrich* sequence against whatever
|
||||
@@ -33,7 +35,14 @@ func buildCatalogs() (*Catalogs, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load quest catalog: %w", err)
|
||||
}
|
||||
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig)
|
||||
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
|
||||
campaignCatalog, err := campaign.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load campaign catalog: %w", err)
|
||||
}
|
||||
log.Printf("campaign catalog loaded: %d enhance, %d quest", campaignCatalog.EnhanceCount(), campaignCatalog.QuestCount())
|
||||
questHandler := questflow.NewQuestHandler(questCatalog, gameConfig, sideStoryCatalog, campaignCatalog)
|
||||
userdata.SetQuestHandler(questHandler)
|
||||
|
||||
gachaEntries, medalInfo, err := masterdata.LoadGachaCatalog()
|
||||
if err != nil {
|
||||
@@ -57,7 +66,7 @@ func buildCatalogs() (*Catalogs, error) {
|
||||
|
||||
gachaPool.BuildShopFeatured(shopCatalog)
|
||||
gachaPool.PruneUnpairedCostumes()
|
||||
gachaPool.BuildFeaturedMapping(gachaEntries)
|
||||
gachaPool.BuildFeaturedFromTerms(gachaEntries)
|
||||
gachaPool.BuildBannerPools(gachaEntries)
|
||||
masterdata.EnrichCatalogPromotions(gachaEntries, gachaPool)
|
||||
|
||||
@@ -113,7 +122,7 @@ func buildCatalogs() (*Catalogs, error) {
|
||||
}
|
||||
log.Printf("explore catalog loaded: %d explores, %d grade assets", len(exploreCatalog.Explores), len(exploreCatalog.GradeAssets))
|
||||
|
||||
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver)
|
||||
gimmickCatalog, err := masterdata.LoadGimmickCatalog(conditionResolver, cageOrnamentCatalog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load gimmick catalog: %w", err)
|
||||
}
|
||||
@@ -136,9 +145,12 @@ func buildCatalogs() (*Catalogs, error) {
|
||||
}
|
||||
log.Printf("companion catalog loaded: %d companions, %d categories", len(companionCatalog.CompanionById), len(companionCatalog.GoldCostByCategory))
|
||||
|
||||
sideStoryCatalog := masterdata.LoadSideStoryCatalog()
|
||||
bigHuntCatalog := masterdata.LoadBigHuntCatalog()
|
||||
|
||||
towerCatalog := masterdata.LoadTowerCatalog()
|
||||
|
||||
labyrinthCatalog := masterdata.LoadLabyrinthCatalog()
|
||||
|
||||
return &Catalogs{
|
||||
GameConfig: gameConfig,
|
||||
Parts: partsCatalog,
|
||||
@@ -164,6 +176,9 @@ func buildCatalogs() (*Catalogs, error) {
|
||||
Companion: companionCatalog,
|
||||
SideStory: sideStoryCatalog,
|
||||
BigHunt: bigHuntCatalog,
|
||||
Tower: towerCatalog,
|
||||
Labyrinth: labyrinthCatalog,
|
||||
Campaign: campaignCatalog,
|
||||
QuestHandler: questHandler,
|
||||
GachaHandler: gachaHandler,
|
||||
}, nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gacha"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/masterdata/memorydb"
|
||||
@@ -50,23 +51,19 @@ type Catalogs struct {
|
||||
Companion *masterdata.CompanionCatalog
|
||||
SideStory *masterdata.SideStoryCatalog
|
||||
BigHunt *masterdata.BigHuntCatalog
|
||||
Tower *masterdata.TowerCatalog
|
||||
Labyrinth *masterdata.LabyrinthCatalog
|
||||
Campaign *campaign.Catalog
|
||||
|
||||
// Catalog-derived handlers must rebuild on every reload because they
|
||||
// embed/cache pointers to specific catalog instances.
|
||||
QuestHandler *questflow.QuestHandler
|
||||
GachaHandler *gacha.GachaHandler
|
||||
}
|
||||
|
||||
// Holder owns the current *Catalogs and the bin.e path. Concurrent readers
|
||||
// call Get(); the single-writer Reload() rebuilds and atomically publishes.
|
||||
type Holder struct {
|
||||
binPath string
|
||||
cur atomic.Pointer[Catalogs]
|
||||
}
|
||||
|
||||
// NewHolder reads the binary at binPath, builds the initial catalogs, and
|
||||
// returns a ready-to-use Holder. Subsequent calls to Reload() re-read the
|
||||
// same path.
|
||||
func NewHolder(binPath string) (*Holder, error) {
|
||||
h := &Holder{binPath: binPath}
|
||||
if err := h.Reload(); err != nil {
|
||||
@@ -75,9 +72,6 @@ func NewHolder(binPath string) (*Holder, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Reload re-reads the bin.e from disk, rebuilds every catalog and handler,
|
||||
// atomically publishes the new snapshot, and bumps the bin.e mtime so client
|
||||
// caches invalidate (see service/data.go GetLatestMasterDataVersion).
|
||||
func (h *Holder) Reload() error {
|
||||
if err := memorydb.Init(h.binPath); err != nil {
|
||||
return fmt.Errorf("memorydb.Init: %w", err)
|
||||
@@ -89,16 +83,11 @@ func (h *Holder) Reload() error {
|
||||
h.cur.Store(c)
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(h.binPath, now, now); err != nil {
|
||||
// Non-fatal: the catalogs swapped fine in-memory; clients may take
|
||||
// longer to invalidate their cached download but server-side state is
|
||||
// already coherent.
|
||||
log.Printf("[runtime] os.Chtimes(%s) failed (clients may not invalidate cache): %v", h.binPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the current snapshot. Safe for concurrent callers; the returned
|
||||
// pointer is stable for the duration of the caller's use.
|
||||
func (h *Holder) Get() *Catalogs {
|
||||
return h.cur.Load()
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
|
||||
|
||||
cat := s.holder.Get()
|
||||
reward, ok := cat.CageOrnament.LookupReward(req.CageOrnamentId)
|
||||
if !ok {
|
||||
log.Fatalf("[CageOrnamentService] ReceiveReward: no reward for cageOrnamentId=%d", req.CageOrnamentId)
|
||||
}
|
||||
granter := cat.QuestHandler.Granter
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
@@ -40,9 +36,21 @@ func (s *CageOrnamentServiceServer) ReceiveReward(ctx context.Context, req *pb.R
|
||||
AcquisitionDatetime: nowMillis,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
|
||||
if ok {
|
||||
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(reward.PossessionType), reward.PossessionId, reward.Count, nowMillis)
|
||||
}
|
||||
})
|
||||
|
||||
if !ok {
|
||||
// "Fickle Black Birds" (type-1 gimmicks) tap into this RPC with CageOrnamentIds
|
||||
// not present in m_cage_ornament_reward (their GimmickOrnamentViewIds are 101/103,
|
||||
// not the 1002xxx-style ids the table uses). Record the access and return an empty
|
||||
// reward so the client doesn't hang and the server doesn't crash.
|
||||
log.Printf("[CageOrnamentService] ReceiveReward: no reward mapping for cageOrnamentId=%d, returning empty",
|
||||
req.CageOrnamentId)
|
||||
return &pb.ReceiveRewardResponse{}, nil
|
||||
}
|
||||
|
||||
return &pb.ReceiveRewardResponse{
|
||||
CageOrnamentReward: []*pb.CageOrnamentReward{
|
||||
{
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"log"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
@@ -21,6 +23,49 @@ func NewConsumableItemServiceServer(users store.UserRepository, sessions store.S
|
||||
return &ConsumableItemServiceServer{users: users, sessions: sessions, holder: holder}
|
||||
}
|
||||
|
||||
func (s *ConsumableItemServiceServer) UseEffectItem(ctx context.Context, req *pb.ConsumableItemUseEffectItemRequest) (*pb.ConsumableItemUseEffectItemResponse, error) {
|
||||
log.Printf("[ConsumableItemService] UseEffectItem: consumableItemId=%d count=%d", req.ConsumableItemId, req.Count)
|
||||
|
||||
cat := s.holder.Get()
|
||||
catalog := cat.ConsumableItem
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
if _, ok := catalog.All[req.ConsumableItemId]; !ok {
|
||||
log.Printf("[ConsumableItemService] UseEffectItem: unknown consumableItemId=%d", req.ConsumableItemId)
|
||||
return
|
||||
}
|
||||
cur := user.ConsumableItems[req.ConsumableItemId]
|
||||
if cur < req.Count {
|
||||
log.Printf("[ConsumableItemService] UseEffectItem: insufficient consumableItemId=%d have=%d need=%d", req.ConsumableItemId, cur, req.Count)
|
||||
return
|
||||
}
|
||||
|
||||
user.ConsumableItems[req.ConsumableItemId] -= req.Count
|
||||
if user.ConsumableItems[req.ConsumableItemId] <= 0 {
|
||||
delete(user.ConsumableItems, req.ConsumableItemId)
|
||||
}
|
||||
|
||||
maxStaminaMillis := cat.Shop.MaxStaminaMillis[user.Status.Level]
|
||||
for _, effect := range catalog.Effects[req.ConsumableItemId] {
|
||||
switch effect.EffectTargetType {
|
||||
case model.EffectTargetStaminaRecovery:
|
||||
millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxStaminaMillis)
|
||||
store.RecoverStamina(user, millis*req.Count, maxStaminaMillis, nowMillis)
|
||||
default:
|
||||
log.Printf("[ConsumableItemService] UseEffectItem: unhandled effect targetType=%d valueType=%d value=%d itemId=%d",
|
||||
effect.EffectTargetType, effect.EffectValueType, effect.EffectValue, req.ConsumableItemId)
|
||||
}
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consumable item use effect item: %w", err)
|
||||
}
|
||||
|
||||
return &pb.ConsumableItemUseEffectItemResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *ConsumableItemServiceServer) Sell(ctx context.Context, req *pb.ConsumableItemSellRequest) (*pb.ConsumableItemSellResponse, error) {
|
||||
log.Printf("[ConsumableItemService] Sell: %d item(s)", len(req.ConsumableItemPossession))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/gameutil"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
@@ -50,6 +51,12 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
|
||||
return
|
||||
}
|
||||
|
||||
expBonus := cat.Campaign.CostumeExpBonus(campaign.CostumeTarget{
|
||||
CostumeId: costume.CostumeId,
|
||||
CharacterId: cm.CharacterId,
|
||||
SkillfulWeaponType: cm.SkillfulWeaponType,
|
||||
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
|
||||
|
||||
totalExp := int32(0)
|
||||
totalMaterialCount := int32(0)
|
||||
for materialId, count := range req.Materials {
|
||||
@@ -71,7 +78,7 @@ func (s *CostumeServiceServer) Enhance(ctx context.Context, req *pb.EnhanceReque
|
||||
if mat.WeaponType != 0 && mat.WeaponType == cm.SkillfulWeaponType {
|
||||
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||
}
|
||||
totalExp += expPerUnit * count
|
||||
totalExp += expBonus.Apply(expPerUnit * count)
|
||||
}
|
||||
|
||||
if costFunc, ok := catalog.EnhanceCostByRarity[cm.RarityType]; ok && totalMaterialCount > 0 {
|
||||
|
||||
@@ -191,11 +191,45 @@ func (s *DeckServiceServer) ReplaceTripleDeck(ctx context.Context, req *pb.Repla
|
||||
}
|
||||
store.ApplyDeckReplacement(user, model.DeckType(detail.DeckType), detail.UserDeckNumber, deckSlotsFromProto(detail.Deck), nowMillis)
|
||||
}
|
||||
|
||||
key := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
|
||||
td := user.TripleDecks[key]
|
||||
td.DeckType = model.DeckType(req.DeckType)
|
||||
td.UserDeckNumber = req.UserDeckNumber
|
||||
td.DeckNumber01 = innerDeckNumber(req.DeckDetail01)
|
||||
td.DeckNumber02 = innerDeckNumber(req.DeckDetail02)
|
||||
td.DeckNumber03 = innerDeckNumber(req.DeckDetail03)
|
||||
td.LatestVersion = nowMillis
|
||||
user.TripleDecks[key] = td
|
||||
})
|
||||
|
||||
return &pb.ReplaceTripleDeckResponse{}, nil
|
||||
}
|
||||
|
||||
func innerDeckNumber(d *pb.DeckDetail) int32 {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.UserDeckNumber
|
||||
}
|
||||
|
||||
func (s *DeckServiceServer) UpdateTripleDeckName(ctx context.Context, req *pb.UpdateTripleDeckNameRequest) (*pb.UpdateTripleDeckNameResponse, error) {
|
||||
log.Printf("[DeckService] UpdateTripleDeckName: deckType=%d deckNumber=%d name=%q", req.DeckType, req.UserDeckNumber, req.Name)
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
key := store.DeckKey{DeckType: model.DeckType(req.DeckType), UserDeckNumber: req.UserDeckNumber}
|
||||
td := user.TripleDecks[key]
|
||||
td.DeckType = model.DeckType(req.DeckType)
|
||||
td.UserDeckNumber = req.UserDeckNumber
|
||||
td.Name = req.Name
|
||||
td.LatestVersion = gametime.NowMillis()
|
||||
user.TripleDecks[key] = td
|
||||
})
|
||||
|
||||
return &pb.UpdateTripleDeckNameResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *DeckServiceServer) ReplaceMultiDeck(ctx context.Context, req *pb.ReplaceMultiDeckRequest) (*pb.ReplaceMultiDeckResponse, error) {
|
||||
log.Printf("[DeckService] ReplaceMultiDeck: %d entries", len(req.DeckDetail))
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
|
||||
@@ -166,7 +166,15 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
|
||||
}
|
||||
|
||||
var drawResult *gacha.DrawResult
|
||||
ownedCostumes := map[int32]bool{}
|
||||
ownedWeapons := map[int32]bool{}
|
||||
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
for _, c := range user.Costumes {
|
||||
ownedCostumes[c.CostumeId] = true
|
||||
}
|
||||
for _, w := range user.Weapons {
|
||||
ownedWeapons[w.WeaponId] = true
|
||||
}
|
||||
var drawErr error
|
||||
drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount)
|
||||
if drawErr != nil {
|
||||
@@ -203,15 +211,6 @@ func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb
|
||||
weaponPT := int32(model.PossessionTypeWeapon)
|
||||
isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType)
|
||||
|
||||
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
|
||||
for _, c := range updatedUser.Costumes {
|
||||
ownedCostumes[c.CostumeId] = true
|
||||
}
|
||||
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
|
||||
for _, w := range updatedUser.Weapons {
|
||||
ownedWeapons[w.WeaponId] = true
|
||||
}
|
||||
|
||||
for i, item := range drawResult.Items {
|
||||
isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser)
|
||||
|
||||
@@ -352,7 +351,15 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR
|
||||
handler := s.holder.Get().GachaHandler
|
||||
|
||||
var items []gacha.DrawnItem
|
||||
ownedCostumes := map[int32]bool{}
|
||||
ownedWeapons := map[int32]bool{}
|
||||
updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
for _, c := range user.Costumes {
|
||||
ownedCostumes[c.CostumeId] = true
|
||||
}
|
||||
for _, w := range user.Weapons {
|
||||
ownedWeapons[w.WeaponId] = true
|
||||
}
|
||||
var drawErr error
|
||||
items, drawErr = handler.HandleRewardDraw(user, 1)
|
||||
if drawErr != nil {
|
||||
@@ -363,15 +370,6 @@ func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawR
|
||||
return nil, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
|
||||
ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes))
|
||||
for _, c := range updatedUser.Costumes {
|
||||
ownedCostumes[c.CostumeId] = true
|
||||
}
|
||||
ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons))
|
||||
for _, w := range updatedUser.Weapons {
|
||||
ownedWeapons[w.WeaponId] = true
|
||||
}
|
||||
|
||||
results := make([]*pb.RewardGachaItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, &pb.RewardGachaItem{
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
|
||||
@@ -43,6 +45,10 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
|
||||
log.Printf("[GimmickService] UpdateGimmickProgress: scheduleId=%d sequenceId=%d gimmickId=%d ornamentIndex=%d progressValueBit=%d flowType=%d",
|
||||
req.GimmickSequenceScheduleId, req.GimmickSequenceId, req.GimmickId, req.GimmickOrnamentIndex, req.ProgressValueBit, req.FlowType)
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
cat := s.holder.Get()
|
||||
|
||||
var ornamentRewards []*pb.GimmickReward
|
||||
var sequenceCleared bool
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
nowMillis := gametime.NowMillis()
|
||||
progressKey := store.GimmickKey{
|
||||
@@ -53,7 +59,6 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
|
||||
progress := user.Gimmick.Progress[progressKey]
|
||||
progress.Key = progressKey
|
||||
progress.StartDatetime = nowMillis
|
||||
user.Gimmick.Progress[progressKey] = progress
|
||||
|
||||
ornamentKey := store.GimmickOrnamentKey{
|
||||
GimmickSequenceScheduleId: req.GimmickSequenceScheduleId,
|
||||
@@ -66,28 +71,151 @@ func (s *GimmickServiceServer) UpdateGimmickProgress(ctx context.Context, req *p
|
||||
ornament.ProgressValueBit = req.ProgressValueBit
|
||||
ornament.BaseDatetime = nowMillis
|
||||
user.Gimmick.OrnamentProgress[ornamentKey] = ornament
|
||||
|
||||
// Per-type branches:
|
||||
// * Report (type 9, "Hidden Stories") — mark gimmick + sequence
|
||||
// cleared, grant SequenceRewards (ImportantItem type 3, library reads it).
|
||||
// * MapOnlyCageTreasureHunt (type 7, "Hidden Black Birds") — same as Report
|
||||
// but the per-tap reward also comes back from m_cage_ornament_reward via
|
||||
// GimmickOrnamentViewId.
|
||||
// * CageMemory (type 10, "Lost Archives") — resolve an ImportantItem
|
||||
// (type 4) from the gimmick's monitor texture and grant it. IsGimmickCleared
|
||||
// stays false (matches original userdata; only ornament progress flips).
|
||||
// * CageTreasureHunt / CageIntervalDropItem* — stub per-tap material so
|
||||
// the client's reward popup fires; real reward source still unmapped.
|
||||
switch cat.Gimmick.GimmickType(req.GimmickId) {
|
||||
case model.GimmickTypeReport:
|
||||
progress.IsGimmickCleared = true
|
||||
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
|
||||
|
||||
case model.GimmickTypeMapOnlyCageTreasureHunt:
|
||||
r, ok := cat.Gimmick.HiddenBirdReward(req.GimmickId, req.GimmickOrnamentIndex)
|
||||
if !ok {
|
||||
log.Printf("[GimmickService] UpdateGimmickProgress: hidden-bird %d ornament %d has no reward mapping, skipping",
|
||||
req.GimmickId, req.GimmickOrnamentIndex)
|
||||
break
|
||||
}
|
||||
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
|
||||
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
|
||||
PossessionType: r.PossessionType,
|
||||
PossessionId: r.PossessionId,
|
||||
Count: r.Count,
|
||||
})
|
||||
progress.IsGimmickCleared = true
|
||||
sequenceCleared = markSequenceClearedOnce(user, cat, req.GimmickSequenceScheduleId, req.GimmickSequenceId, nowMillis)
|
||||
|
||||
case model.GimmickTypeCageMemory:
|
||||
itemId, ok := cat.Gimmick.CageMemoryImportantItem(req.GimmickId)
|
||||
if !ok {
|
||||
log.Printf("[GimmickService] UpdateGimmickProgress: cage memory %d has no important-item mapping, skipping grant",
|
||||
req.GimmickId)
|
||||
break
|
||||
}
|
||||
if _, owned := user.ImportantItems[itemId]; owned {
|
||||
break
|
||||
}
|
||||
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeImportantItem, itemId, 1, nowMillis)
|
||||
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
|
||||
PossessionType: int32(model.PossessionTypeImportantItem),
|
||||
PossessionId: itemId,
|
||||
Count: 1,
|
||||
})
|
||||
|
||||
case model.GimmickTypeCageTreasureHunt,
|
||||
model.GimmickTypeCageIntervalDropItem,
|
||||
model.GimmickTypeMapOnlyCageIntervalDrop:
|
||||
// Per-tap drops with no per-gimmick reward in master data:
|
||||
// * type 1 — "Fickle Black Birds" in the cage
|
||||
// * type 2 — "Lost Items" in the cage
|
||||
// * type 8 — Lost Items (map variant)
|
||||
// Stub: grant 1 of Material 100004 (the most-common reward across
|
||||
// m_cage_ornament_reward — 15 occurrences — likely a low-tier shard) per
|
||||
// tap so the client's reward-popup path fires and the player accumulates
|
||||
// something. Replace once a real per-gimmick mapping surfaces.
|
||||
const stubMaterialId = int32(100004)
|
||||
const stubMaterialCount = int32(1)
|
||||
cat.QuestHandler.Granter.GrantFull(user, model.PossessionTypeMaterial, stubMaterialId, stubMaterialCount, nowMillis)
|
||||
ornamentRewards = append(ornamentRewards, &pb.GimmickReward{
|
||||
PossessionType: int32(model.PossessionTypeMaterial),
|
||||
PossessionId: stubMaterialId,
|
||||
Count: stubMaterialCount,
|
||||
})
|
||||
}
|
||||
user.Gimmick.Progress[progressKey] = progress
|
||||
})
|
||||
|
||||
var clearReward []*pb.GimmickReward
|
||||
if sequenceCleared {
|
||||
for _, r := range cat.Gimmick.SequenceRewards(req.GimmickSequenceId) {
|
||||
clearReward = append(clearReward, &pb.GimmickReward{
|
||||
PossessionType: r.PossessionType,
|
||||
PossessionId: r.PossessionId,
|
||||
Count: r.Count,
|
||||
})
|
||||
}
|
||||
}
|
||||
return &pb.UpdateGimmickProgressResponse{
|
||||
GimmickOrnamentReward: []*pb.GimmickReward{},
|
||||
IsSequenceCleared: false,
|
||||
GimmickSequenceClearReward: []*pb.GimmickReward{},
|
||||
GimmickOrnamentReward: ornamentRewards,
|
||||
IsSequenceCleared: sequenceCleared,
|
||||
GimmickSequenceClearReward: clearReward,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func markSequenceClearedOnce(user *store.UserState, cat *runtime.Catalogs, scheduleId, sequenceId int32, nowMillis int64) bool {
|
||||
seqKey := store.GimmickSequenceKey{
|
||||
GimmickSequenceScheduleId: scheduleId,
|
||||
GimmickSequenceId: sequenceId,
|
||||
}
|
||||
sequence := user.Gimmick.Sequences[seqKey]
|
||||
sequence.Key = seqKey
|
||||
defer func() { user.Gimmick.Sequences[seqKey] = sequence }()
|
||||
|
||||
if sequence.IsGimmickSequenceCleared {
|
||||
return false
|
||||
}
|
||||
sequence.IsGimmickSequenceCleared = true
|
||||
sequence.ClearDatetime = nowMillis
|
||||
for _, r := range cat.Gimmick.SequenceRewards(sequenceId) {
|
||||
cat.QuestHandler.Granter.GrantFull(user, model.PossessionType(r.PossessionType), r.PossessionId, r.Count, nowMillis)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *GimmickServiceServer) InitSequenceSchedule(ctx context.Context, _ *emptypb.Empty) (*pb.InitSequenceScheduleResponse, error) {
|
||||
log.Printf("[GimmickService] InitSequenceSchedule")
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
now := gametime.NowMillis()
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
eligible := s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now)
|
||||
eligibleSet := make(map[store.GimmickSequenceKey]struct{}, len(eligible))
|
||||
for _, key := range eligible {
|
||||
eligibleSet[key] = struct{}{}
|
||||
}
|
||||
pruned := 0
|
||||
for key, entry := range user.Gimmick.Sequences {
|
||||
if _, ok := eligibleSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
if entry.IsGimmickSequenceCleared {
|
||||
continue
|
||||
}
|
||||
delete(user.Gimmick.Sequences, key)
|
||||
pruned++
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, key := range s.holder.Get().Gimmick.ActiveScheduleKeys(*user, now) {
|
||||
for _, key := range eligible {
|
||||
if len(user.Gimmick.Sequences) >= masterdata.MaxUserGimmickRows {
|
||||
break
|
||||
}
|
||||
if _, exists := user.Gimmick.Sequences[key]; !exists {
|
||||
user.Gimmick.Sequences[key] = store.GimmickSequenceState{Key: key}
|
||||
added++
|
||||
}
|
||||
}
|
||||
if added > 0 {
|
||||
log.Printf("[GimmickService] InitSequenceSchedule: added %d sequences (total %d)", added, len(user.Gimmick.Sequences))
|
||||
if pruned > 0 || added > 0 {
|
||||
log.Printf("[GimmickService] InitSequenceSchedule: pruned %d stale, added %d sequences (total %d, eligible %d, cap %d)",
|
||||
pruned, added, len(user.Gimmick.Sequences), len(eligible), masterdata.MaxUserGimmickRows)
|
||||
}
|
||||
})
|
||||
return &pb.InitSequenceScheduleResponse{}, nil
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
|
||||
type LabyrinthServiceServer struct {
|
||||
pb.UnimplementedLabyrinthServiceServer
|
||||
users store.UserRepository
|
||||
sessions store.SessionRepository
|
||||
holder *runtime.Holder
|
||||
}
|
||||
|
||||
func NewLabyrinthServiceServer(users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder) *LabyrinthServiceServer {
|
||||
if holder == nil {
|
||||
panic("runtime holder is required")
|
||||
}
|
||||
return &LabyrinthServiceServer{users: users, sessions: sessions, holder: holder}
|
||||
}
|
||||
|
||||
func (s *LabyrinthServiceServer) ReceiveStageAccumulationReward(ctx context.Context, req *pb.ReceiveStageAccumulationRewardRequest) (*pb.ReceiveStageAccumulationRewardResponse, error) {
|
||||
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d questMissionClearCount=%d",
|
||||
req.EventQuestChapterId, req.StageOrder, req.QuestMissionClearCount)
|
||||
|
||||
cat := s.holder.Get()
|
||||
laby := cat.Labyrinth
|
||||
granter := cat.QuestHandler.Granter
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
key := store.LabyrinthStageKey{
|
||||
EventQuestChapterId: req.EventQuestChapterId,
|
||||
StageOrder: req.StageOrder,
|
||||
}
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
rec := user.LabyrinthStages[key]
|
||||
old := rec.AccumulationRewardReceivedQuestMissionCount
|
||||
|
||||
items, highest := laby.CollectAccumulationRewards(req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
|
||||
if highest <= old {
|
||||
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: nothing to grant for chapter=%d stage=%d (claimed=%d, target=%d)",
|
||||
req.EventQuestChapterId, req.StageOrder, old, req.QuestMissionClearCount)
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
|
||||
}
|
||||
|
||||
rec.EventQuestChapterId = req.EventQuestChapterId
|
||||
rec.StageOrder = req.StageOrder
|
||||
rec.AccumulationRewardReceivedQuestMissionCount = highest
|
||||
rec.LatestVersion = nowMillis
|
||||
user.LabyrinthStages[key] = rec
|
||||
|
||||
log.Printf("[LabyrinthService] ReceiveStageAccumulationReward: chapter=%d stage=%d granted %d item(s), claimed %d -> %d",
|
||||
req.EventQuestChapterId, req.StageOrder, len(items), old, highest)
|
||||
})
|
||||
|
||||
return &pb.ReceiveStageAccumulationRewardResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *LabyrinthServiceServer) ReceiveStageClearReward(ctx context.Context, req *pb.ReceiveStageClearRewardRequest) (*pb.ReceiveStageClearRewardResponse, error) {
|
||||
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d",
|
||||
req.EventQuestChapterId, req.StageOrder)
|
||||
|
||||
cat := s.holder.Get()
|
||||
laby := cat.Labyrinth
|
||||
granter := cat.QuestHandler.Granter
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
key := store.LabyrinthStageKey{
|
||||
EventQuestChapterId: req.EventQuestChapterId,
|
||||
StageOrder: req.StageOrder,
|
||||
}
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
rec := user.LabyrinthStages[key]
|
||||
if rec.IsReceivedStageClearReward {
|
||||
log.Printf("[LabyrinthService] ReceiveStageClearReward: already claimed chapter=%d stage=%d",
|
||||
req.EventQuestChapterId, req.StageOrder)
|
||||
return
|
||||
}
|
||||
|
||||
items := laby.StageClearReward(req.EventQuestChapterId, req.StageOrder)
|
||||
for _, it := range items {
|
||||
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
|
||||
}
|
||||
|
||||
rec.EventQuestChapterId = req.EventQuestChapterId
|
||||
rec.StageOrder = req.StageOrder
|
||||
rec.IsReceivedStageClearReward = true
|
||||
rec.LatestVersion = nowMillis
|
||||
user.LabyrinthStages[key] = rec
|
||||
|
||||
log.Printf("[LabyrinthService] ReceiveStageClearReward: chapter=%d stage=%d granted %d item(s)",
|
||||
req.EventQuestChapterId, req.StageOrder, len(items))
|
||||
})
|
||||
|
||||
return &pb.ReceiveStageClearRewardResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *LabyrinthServiceServer) UpdateSeasonData(ctx context.Context, req *pb.UpdateSeasonDataRequest) (*pb.UpdateSeasonDataResponse, error) {
|
||||
laby := s.holder.Get().Labyrinth
|
||||
|
||||
var seasonResult []*pb.LabyrinthSeasonResult
|
||||
for _, m := range laby.SeasonMilestones(req.EventQuestChapterId) {
|
||||
rewards := make([]*pb.LabyrinthReward, 0, len(m.Rewards))
|
||||
for _, it := range m.Rewards {
|
||||
rewards = append(rewards, &pb.LabyrinthReward{
|
||||
PossessionType: it.PossessionType,
|
||||
PossessionId: it.PossessionId,
|
||||
Count: it.Count,
|
||||
})
|
||||
}
|
||||
seasonResult = append(seasonResult, &pb.LabyrinthSeasonResult{
|
||||
EventQuestChapterId: req.EventQuestChapterId,
|
||||
HeadQuestId: m.HeadQuestId,
|
||||
SeasonReward: rewards,
|
||||
HeadStageOrder: m.HeadStageOrder,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("[LabyrinthService] UpdateSeasonData: chapter=%d -> %d milestone(s)",
|
||||
req.EventQuestChapterId, len(seasonResult))
|
||||
return &pb.UpdateSeasonDataResponse{SeasonResult: seasonResult}, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
@@ -52,6 +53,14 @@ func (s *MaterialServiceServer) Sell(ctx context.Context, req *pb.MaterialSellRe
|
||||
gold := mat.SellPrice * item.Count
|
||||
totalGold += gold
|
||||
log.Printf("[MaterialService] Sell: materialId=%d x%d -> %d gold", item.MaterialId, item.Count, gold)
|
||||
|
||||
if mat.MaterialSaleObtainPossessionId != 0 {
|
||||
for _, row := range catalog.SaleObtain[mat.MaterialSaleObtainPossessionId] {
|
||||
grantCount := row.Count * item.Count
|
||||
store.GrantPossession(user, model.PossessionType(row.PossessionType), row.PossessionId, grantCount)
|
||||
log.Printf("[MaterialService] Sell: materialId=%d x%d -> SaleObtain type=%d id=%d +%d", item.MaterialId, item.Count, row.PossessionType, row.PossessionId, grantCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalGold > 0 {
|
||||
|
||||
@@ -44,6 +44,30 @@ const informationPage = `<!DOCTYPE html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const panelMissionPage = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Panel Missions</title>
|
||||
<style>
|
||||
body { margin:0; padding:48px 20px; font-family:"Noto Sans",sans-serif;
|
||||
background:#0a0a0f; color:#d4cfc6; text-align:center; }
|
||||
h1 { font-size:1.3em; letter-spacing:.15em; color:#e8e0d0; margin-bottom:6px; }
|
||||
.sub { font-size:.75em; color:#888; margin-bottom:28px; }
|
||||
.sep { width:60px; border:none; border-top:1px solid #333; margin:24px auto; }
|
||||
p { font-size:.85em; line-height:1.6; color:#999; max-width:340px; margin:0 auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PANEL MISSIONS</h1>
|
||||
<div class="sub">Card Stories</div>
|
||||
<hr class="sep">
|
||||
<p>All panel missions are cleared.</p>
|
||||
<p>Their Card Stories are available in Library › Extra Stories.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// resourcesURLOriginal is the base URL embedded in list.bin; must be replaced with same-length (43 bytes) when rewriting.
|
||||
const resourcesURLOriginal = "https://resources.app.nierreincarnation.com"
|
||||
|
||||
@@ -456,6 +480,13 @@ func (s *OctoHTTPServer) handleWebAPI(w http.ResponseWriter, r *http.Request, pa
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(path, "panelmission") {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(panelMissionPage))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`<!DOCTYPE html><html><body></body></html>`))
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"math/rand"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
@@ -26,6 +28,50 @@ func NewPartsServiceServer(users store.UserRepository, sessions store.SessionRep
|
||||
return &PartsServiceServer{users: users, sessions: sessions, holder: holder}
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) Protect(ctx context.Context, req *pb.PartsProtectRequest) (*pb.PartsProtectResponse, error) {
|
||||
log.Printf("[PartsService] Protect: uuids=%v", req.UserPartsUuid)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
for _, uuid := range req.UserPartsUuid {
|
||||
part, ok := user.Parts[uuid]
|
||||
if !ok {
|
||||
log.Printf("[PartsService] Protect: part uuid=%s not found", uuid)
|
||||
continue
|
||||
}
|
||||
part.IsProtected = true
|
||||
part.LatestVersion = nowMillis
|
||||
user.Parts[uuid] = part
|
||||
}
|
||||
})
|
||||
|
||||
return &pb.PartsProtectResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) Unprotect(ctx context.Context, req *pb.PartsUnprotectRequest) (*pb.PartsUnprotectResponse, error) {
|
||||
log.Printf("[PartsService] Unprotect: uuids=%v", req.UserPartsUuid)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
for _, uuid := range req.UserPartsUuid {
|
||||
part, ok := user.Parts[uuid]
|
||||
if !ok {
|
||||
log.Printf("[PartsService] Unprotect: part uuid=%s not found", uuid)
|
||||
continue
|
||||
}
|
||||
part.IsProtected = false
|
||||
part.LatestVersion = nowMillis
|
||||
user.Parts[uuid] = part
|
||||
}
|
||||
})
|
||||
|
||||
return &pb.PartsUnprotectResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) Sell(ctx context.Context, req *pb.PartsSellRequest) (*pb.PartsSellResponse, error) {
|
||||
log.Printf("[PartsService] Sell: %d part(s)", len(req.UserPartsUuid))
|
||||
|
||||
@@ -136,17 +182,23 @@ func (s *PartsServiceServer) Enhance(ctx context.Context, req *pb.PartsEnhanceRe
|
||||
successRate = r
|
||||
}
|
||||
}
|
||||
baseRate := successRate
|
||||
successRate = cat.Campaign.PartsRateBonus(campaign.PartsTarget{
|
||||
PartsId: part.PartsId,
|
||||
PartsGroupId: partDef.PartsGroupId,
|
||||
Rarity: model.RarityType(partDef.RarityType),
|
||||
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll}).Apply(baseRate)
|
||||
|
||||
if rand.Intn(1000) < int(successRate) {
|
||||
part.Level++
|
||||
isSuccess = true
|
||||
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰, cost=%d gold)",
|
||||
part.PartsId, part.Level-1, part.Level, successRate, goldCost)
|
||||
log.Printf("[PartsService] Enhance: SUCCESS partsId=%d level %d -> %d (rate=%d‰ base=%d‰, cost=%d gold)",
|
||||
part.PartsId, part.Level-1, part.Level, successRate, baseRate, goldCost)
|
||||
|
||||
grantPartsSubStatuses(catalog, user, req.UserPartsUuid, part, partDef, nowMillis)
|
||||
} else {
|
||||
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰, cost=%d gold)",
|
||||
part.PartsId, part.Level, successRate, goldCost)
|
||||
log.Printf("[PartsService] Enhance: FAIL partsId=%d stays level %d (rate=%d‰ base=%d‰, cost=%d gold)",
|
||||
part.PartsId, part.Level, successRate, baseRate, goldCost)
|
||||
}
|
||||
|
||||
part.LatestVersion = nowMillis
|
||||
@@ -226,3 +278,102 @@ func (s *PartsServiceServer) ReplacePreset(ctx context.Context, req *pb.PartsRep
|
||||
|
||||
return &pb.PartsReplacePresetResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) UpdatePresetName(ctx context.Context, req *pb.PartsUpdatePresetNameRequest) (*pb.PartsUpdatePresetNameResponse, error) {
|
||||
log.Printf("[PartsService] UpdatePresetName: preset=%d name=%q", req.UserPartsPresetNumber, req.Name)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
preset := user.PartsPresets[req.UserPartsPresetNumber]
|
||||
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
|
||||
preset.Name = req.Name
|
||||
preset.LatestVersion = nowMillis
|
||||
user.PartsPresets[req.UserPartsPresetNumber] = preset
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parts update preset name: %w", err)
|
||||
}
|
||||
|
||||
return &pb.PartsUpdatePresetNameResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) UpdatePresetTagNumber(ctx context.Context, req *pb.PartsUpdatePresetTagNumberRequest) (*pb.PartsUpdatePresetTagNumberResponse, error) {
|
||||
log.Printf("[PartsService] UpdatePresetTagNumber: preset=%d tag=%d", req.UserPartsPresetNumber, req.UserPartsPresetTagNumber)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
preset := user.PartsPresets[req.UserPartsPresetNumber]
|
||||
preset.UserPartsPresetNumber = req.UserPartsPresetNumber
|
||||
preset.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
|
||||
preset.LatestVersion = nowMillis
|
||||
user.PartsPresets[req.UserPartsPresetNumber] = preset
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parts update preset tag number: %w", err)
|
||||
}
|
||||
|
||||
return &pb.PartsUpdatePresetTagNumberResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) UpdatePresetTagName(ctx context.Context, req *pb.PartsUpdatePresetTagNameRequest) (*pb.PartsUpdatePresetTagNameResponse, error) {
|
||||
log.Printf("[PartsService] UpdatePresetTagName: tag=%d name=%q", req.UserPartsPresetTagNumber, req.Name)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
tag := user.PartsPresetTags[req.UserPartsPresetTagNumber]
|
||||
tag.UserPartsPresetTagNumber = req.UserPartsPresetTagNumber
|
||||
tag.Name = req.Name
|
||||
tag.LatestVersion = nowMillis
|
||||
user.PartsPresetTags[req.UserPartsPresetTagNumber] = tag
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parts update preset tag name: %w", err)
|
||||
}
|
||||
|
||||
return &pb.PartsUpdatePresetTagNameResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) CopyPreset(ctx context.Context, req *pb.PartsCopyPresetRequest) (*pb.PartsCopyPresetResponse, error) {
|
||||
log.Printf("[PartsService] CopyPreset: from=%d to=%d", req.FromUserPartsPresetNumber, req.ToUserPartsPresetNumber)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
from, ok := user.PartsPresets[req.FromUserPartsPresetNumber]
|
||||
if !ok {
|
||||
log.Printf("[PartsService] CopyPreset: source preset=%d not found, skipping", req.FromUserPartsPresetNumber)
|
||||
return
|
||||
}
|
||||
to := from
|
||||
to.UserPartsPresetNumber = req.ToUserPartsPresetNumber
|
||||
to.LatestVersion = nowMillis
|
||||
user.PartsPresets[req.ToUserPartsPresetNumber] = to
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parts copy preset: %w", err)
|
||||
}
|
||||
|
||||
return &pb.PartsCopyPresetResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *PartsServiceServer) RemovePreset(ctx context.Context, req *pb.PartsRemovePresetRequest) (*pb.PartsRemovePresetResponse, error) {
|
||||
log.Printf("[PartsService] RemovePreset: preset=%d", req.UserPartsPresetNumber)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
|
||||
_, err := s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
delete(user.PartsPresets, req.UserPartsPresetNumber)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parts remove preset: %w", err)
|
||||
}
|
||||
|
||||
return &pb.PartsRemovePresetResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ func (s *PortalCageServiceServer) UpdatePortalCageSceneProgress(ctx context.Cont
|
||||
user.MainQuest.CurrentQuestFlowType = int32(model.QuestFlowTypeMainFlow)
|
||||
user.MainQuest.LatestVersion = now
|
||||
}
|
||||
// Returning to Mama's Room also ends any active side story.
|
||||
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
|
||||
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
|
||||
LatestVersion: now,
|
||||
}
|
||||
}
|
||||
})
|
||||
return &pb.UpdatePortalCageSceneProgressResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.St
|
||||
log.Printf("[BigHuntService] StartBigHuntQuest: unknown bigHuntQuestId=%d", req.BigHuntQuestId)
|
||||
}
|
||||
|
||||
today := gametime.StartOfDayMillis()
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
if ok {
|
||||
engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, req.UserDeckNumber, nowMillis)
|
||||
@@ -60,6 +62,9 @@ func (s *BigHuntServiceServer) StartBigHuntQuest(ctx context.Context, req *pb.St
|
||||
user.BigHuntDeckNumber = req.UserDeckNumber
|
||||
|
||||
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||
if st.LatestChallengeDatetime < today {
|
||||
st.DailyChallengeCount = 0
|
||||
}
|
||||
st.DailyChallengeCount++
|
||||
st.LatestChallengeDatetime = nowMillis
|
||||
st.LatestVersion = nowMillis
|
||||
@@ -98,12 +103,15 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
|
||||
var scoreInfo *pb.BigHuntScoreInfo
|
||||
var scoreRewards []*pb.BigHuntReward
|
||||
var battleReportWaves []*pb.BigHuntBattleReportWave
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
engine.HandleBigHuntQuestFinish(user, bhQuest.QuestId, req.IsRetired, false, nowMillis)
|
||||
|
||||
if req.IsRetired || user.BigHuntProgress.IsDryRun {
|
||||
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
|
||||
user.BigHuntBattleBinary = nil
|
||||
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,11 +137,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
|
||||
userScore := baseScore * int64(1000+difficultyBonusPermil+aliveBonusPermil+maxComboBonusPermil) / 1000
|
||||
|
||||
isHighScore := false
|
||||
oldMaxBoss := user.BigHuntMaxScores[bossQuest.BigHuntBossId]
|
||||
oldMax := oldMaxBoss.MaxScore
|
||||
if userScore > oldMax {
|
||||
isHighScore = true
|
||||
if userScore > user.BigHuntMaxScores[bossQuest.BigHuntBossId].MaxScore {
|
||||
user.BigHuntMaxScores[bossQuest.BigHuntBossId] = store.BigHuntMaxScore{
|
||||
MaxScore: userScore,
|
||||
MaxScoreUpdateDatetime: nowMillis,
|
||||
@@ -146,7 +150,8 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
BigHuntBossId: bossQuest.BigHuntBossId,
|
||||
}
|
||||
oldSchedMax := user.BigHuntScheduleMaxScores[schedKey].MaxScore
|
||||
if userScore > oldSchedMax {
|
||||
isHighScore := userScore > oldSchedMax
|
||||
if isHighScore {
|
||||
user.BigHuntScheduleMaxScores[schedKey] = store.BigHuntScheduleMaxScore{
|
||||
MaxScore: userScore,
|
||||
MaxScoreUpdateDatetime: nowMillis,
|
||||
@@ -184,7 +189,7 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
rewardGroupId := catalog.ResolveActiveScoreRewardGroupId(
|
||||
bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
|
||||
if rewardGroupId > 0 {
|
||||
newItems := catalog.CollectNewRewards(rewardGroupId, oldMax, userScore)
|
||||
newItems := catalog.CollectNewRewards(rewardGroupId, oldSchedMax, userScore)
|
||||
for _, item := range newItems {
|
||||
engine.Granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
|
||||
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
|
||||
@@ -196,6 +201,31 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
}
|
||||
}
|
||||
|
||||
if len(detail.CostumeBattleInfo) > 0 {
|
||||
wavesByIndex := map[int32]*pb.BigHuntBattleReportWave{}
|
||||
var waveOrder []int32
|
||||
for _, ci := range detail.CostumeBattleInfo {
|
||||
wave, ok := wavesByIndex[ci.WaveIndex]
|
||||
if !ok {
|
||||
wave = &pb.BigHuntBattleReportWave{}
|
||||
wavesByIndex[ci.WaveIndex] = wave
|
||||
waveOrder = append(waveOrder, ci.WaveIndex)
|
||||
}
|
||||
wave.BattleReportCostume = append(wave.BattleReportCostume, &pb.BigHuntBattleReportCostume{
|
||||
CostumeId: ci.CostumeId,
|
||||
TotalDamage: ci.TotalDamage,
|
||||
HitCount: ci.HitCount,
|
||||
BattleReportRandomDisplay: &pb.BattleReportRandomDisplay{
|
||||
RandomDisplayValueType: ci.RandomDisplayValueType,
|
||||
RandomDisplayValue: ci.RandomDisplayValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, idx := range waveOrder {
|
||||
battleReportWaves = append(battleReportWaves, wavesByIndex[idx])
|
||||
}
|
||||
}
|
||||
|
||||
user.BigHuntProgress = store.BigHuntProgress{LatestVersion: nowMillis}
|
||||
user.BigHuntBattleBinary = nil
|
||||
user.BigHuntBattleDetail = store.BigHuntBattleDetail{}
|
||||
@@ -208,12 +238,17 @@ func (s *BigHuntServiceServer) FinishBigHuntQuest(ctx context.Context, req *pb.F
|
||||
scoreRewards = []*pb.BigHuntReward{}
|
||||
}
|
||||
|
||||
if battleReportWaves == nil {
|
||||
battleReportWaves = []*pb.BigHuntBattleReportWave{}
|
||||
}
|
||||
battleReport := &pb.BigHuntBattleReport{
|
||||
BattleReportWave: battleReportWaves,
|
||||
}
|
||||
|
||||
return &pb.FinishBigHuntQuestResponse{
|
||||
ScoreInfo: scoreInfo,
|
||||
ScoreReward: scoreRewards,
|
||||
BattleReport: &pb.BigHuntBattleReport{
|
||||
BattleReportWave: []*pb.BigHuntBattleReportWave{},
|
||||
},
|
||||
BattleReport: battleReport,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -231,6 +266,8 @@ func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.
|
||||
var battleBinary []byte
|
||||
var deckNumber int32
|
||||
|
||||
today := gametime.StartOfDayMillis()
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
engine.HandleBigHuntQuestStart(user, bhQuest.QuestId, user.BigHuntDeckNumber, nowMillis)
|
||||
|
||||
@@ -238,6 +275,9 @@ func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.
|
||||
user.BigHuntProgress.LatestVersion = nowMillis
|
||||
|
||||
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||
if st.LatestChallengeDatetime < today {
|
||||
st.DailyChallengeCount = 0
|
||||
}
|
||||
st.DailyChallengeCount++
|
||||
st.LatestChallengeDatetime = nowMillis
|
||||
st.LatestVersion = nowMillis
|
||||
@@ -256,19 +296,58 @@ func (s *BigHuntServiceServer) RestartBigHuntQuest(ctx context.Context, req *pb.
|
||||
func (s *BigHuntServiceServer) SkipBigHuntQuest(ctx context.Context, req *pb.SkipBigHuntQuestRequest) (*pb.SkipBigHuntQuestResponse, error) {
|
||||
log.Printf("[BigHuntService] SkipBigHuntQuest: bossQuestId=%d skipCount=%d", req.BigHuntBossQuestId, req.SkipCount)
|
||||
|
||||
cat := s.holder.Get()
|
||||
catalog := cat.BigHunt
|
||||
granter := cat.QuestHandler.Granter
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
today := gametime.StartOfDayMillis()
|
||||
|
||||
bossQuest, hasBossQuest := catalog.BossQuestById[req.BigHuntBossQuestId]
|
||||
var scoreRewards []*pb.BigHuntReward
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
st := user.BigHuntStatuses[req.BigHuntBossQuestId]
|
||||
if st.LatestChallengeDatetime < today {
|
||||
st.DailyChallengeCount = 0
|
||||
}
|
||||
st.DailyChallengeCount += req.SkipCount
|
||||
st.LatestChallengeDatetime = nowMillis
|
||||
st.LatestVersion = nowMillis
|
||||
user.BigHuntStatuses[req.BigHuntBossQuestId] = st
|
||||
|
||||
if !hasBossQuest || req.SkipCount <= 0 {
|
||||
return
|
||||
}
|
||||
rewardGroupId := catalog.ResolveActiveScoreRewardGroupId(bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
|
||||
if rewardGroupId == 0 {
|
||||
return
|
||||
}
|
||||
maxScore := user.BigHuntScheduleMaxScores[store.BigHuntScheduleScoreKey{
|
||||
BigHuntScheduleId: catalog.ActiveScheduleId,
|
||||
BigHuntBossId: bossQuest.BigHuntBossId,
|
||||
}].MaxScore
|
||||
if maxScore <= 0 {
|
||||
return
|
||||
}
|
||||
items := catalog.CollectNewRewards(rewardGroupId, 0, maxScore)
|
||||
for n := int32(0); n < req.SkipCount; n++ {
|
||||
for _, item := range items {
|
||||
granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
|
||||
scoreRewards = append(scoreRewards, &pb.BigHuntReward{
|
||||
PossessionType: item.PossessionType,
|
||||
PossessionId: item.PossessionId,
|
||||
Count: item.Count,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if scoreRewards == nil {
|
||||
scoreRewards = []*pb.BigHuntReward{}
|
||||
}
|
||||
return &pb.SkipBigHuntQuestResponse{
|
||||
ScoreReward: []*pb.BigHuntReward{},
|
||||
ScoreReward: scoreRewards,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -291,12 +370,35 @@ func (s *BigHuntServiceServer) SaveBigHuntBattleInfo(ctx context.Context, req *p
|
||||
user.BigHuntBattleBinary = req.BattleBinary
|
||||
|
||||
if req.BigHuntBattleDetail != nil {
|
||||
existingCostumes := user.BigHuntBattleDetail.CostumeBattleInfo
|
||||
nextWaveIndex := int32(bigHuntWaveCount(existingCostumes))
|
||||
newCostumes := make([]store.BigHuntCostumeBattleInfo, 0, len(req.BigHuntBattleDetail.CostumeBattleInfo))
|
||||
for _, ci := range req.BigHuntBattleDetail.CostumeBattleInfo {
|
||||
if ci == nil {
|
||||
continue
|
||||
}
|
||||
var rdType int32
|
||||
var rdValue int64
|
||||
if rd := ci.BattleReportRandomDisplay; rd != nil {
|
||||
rdType = rd.RandomDisplayValueType
|
||||
rdValue = rd.RandomDisplayValue
|
||||
}
|
||||
newCostumes = append(newCostumes, store.BigHuntCostumeBattleInfo{
|
||||
WaveIndex: nextWaveIndex,
|
||||
CostumeId: resolveBigHuntCostumeId(user, ci.UserDeckNumber, ci.DeckCharacterNumber),
|
||||
TotalDamage: ci.TotalDamage,
|
||||
HitCount: ci.HitCount,
|
||||
RandomDisplayValueType: rdType,
|
||||
RandomDisplayValue: rdValue,
|
||||
})
|
||||
}
|
||||
user.BigHuntBattleDetail = store.BigHuntBattleDetail{
|
||||
DeckType: req.BigHuntBattleDetail.DeckType,
|
||||
UserTripleDeckNumber: req.BigHuntBattleDetail.UserTripleDeckNumber,
|
||||
BossKnockDownCount: req.BigHuntBattleDetail.BossKnockDownCount,
|
||||
MaxComboCount: req.BigHuntBattleDetail.MaxComboCount,
|
||||
TotalDamage: totalDamage,
|
||||
CostumeBattleInfo: append(existingCostumes, newCostumes...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,14 +453,49 @@ func (s *BigHuntServiceServer) GetBigHuntTopData(ctx context.Context, _ *emptypb
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bigHuntWaveCount(infos []store.BigHuntCostumeBattleInfo) int {
|
||||
if len(infos) == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(infos[len(infos)-1].WaveIndex) + 1
|
||||
}
|
||||
|
||||
func resolveBigHuntCostumeId(user *store.UserState, userDeckNumber, deckCharacterNumber int32) int32 {
|
||||
if userDeckNumber == 0 {
|
||||
userDeckNumber = user.BigHuntDeckNumber
|
||||
}
|
||||
for _, dt := range []model.DeckType{model.DeckTypeBigHunt, model.DeckTypeQuest} {
|
||||
deck, ok := user.Decks[store.DeckKey{DeckType: dt, UserDeckNumber: userDeckNumber}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var dcUuid string
|
||||
switch deckCharacterNumber {
|
||||
case 1:
|
||||
dcUuid = deck.UserDeckCharacterUuid01
|
||||
case 2:
|
||||
dcUuid = deck.UserDeckCharacterUuid02
|
||||
case 3:
|
||||
dcUuid = deck.UserDeckCharacterUuid03
|
||||
}
|
||||
if dcUuid == "" {
|
||||
continue
|
||||
}
|
||||
dc, ok := user.DeckCharacters[dcUuid]
|
||||
if !ok || dc.UserCostumeUuid == "" {
|
||||
continue
|
||||
}
|
||||
if costume, ok := user.Costumes[dc.UserCostumeUuid]; ok {
|
||||
return costume.CostumeId
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveBigHuntWeeklyRewards(catalog *masterdata.BigHuntCatalog, user store.UserState, weeklyVersion, nowMillis int64) []*pb.BigHuntReward {
|
||||
var rewards []*pb.BigHuntReward
|
||||
for _, boss := range catalog.BossByBossId {
|
||||
rewardKey := masterdata.BigHuntWeeklyRewardKey{
|
||||
ScheduleId: 1,
|
||||
AttributeType: boss.AttributeType,
|
||||
}
|
||||
rewardGroupId := catalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
|
||||
rewardGroupId := catalog.ResolveActiveWeeklyRewardGroupIdByAttr(boss.AttributeType, nowMillis)
|
||||
if rewardGroupId == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -198,11 +198,15 @@ func (s *QuestServiceServer) SetRoute(ctx context.Context, req *pb.SetRouteReque
|
||||
user.MainQuest.CurrentMainQuestRouteId = req.MainQuestRouteId
|
||||
if seasonId, ok := engine.SeasonIdByRouteId[req.MainQuestRouteId]; ok {
|
||||
user.MainQuest.MainQuestSeasonId = seasonId
|
||||
questflow.RecordSeasonRoute(user, seasonId, req.MainQuestRouteId, gametime.NowMillis())
|
||||
}
|
||||
now := gametime.NowMillis()
|
||||
user.PortalCageStatus.IsCurrentProgress = false
|
||||
user.PortalCageStatus.LatestVersion = now
|
||||
if user.SideStoryActiveProgress.CurrentSideStoryQuestId != 0 {
|
||||
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
|
||||
LatestVersion: now,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return &pb.SetRouteResponse{}, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
@@ -22,34 +23,92 @@ func NewSideStoryQuestServiceServer(users store.UserRepository, sessions store.S
|
||||
return &SideStoryQuestServiceServer{users: users, sessions: sessions, holder: holder}
|
||||
}
|
||||
|
||||
func sideStoryClearedCount(info *masterdata.SideStoryQuestInfo, user *store.UserState) int {
|
||||
cleared := 0
|
||||
for _, questId := range info.Quests {
|
||||
if user.QuestLimitContentStatus[questId].LimitContentQuestStatusType == 1 {
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
func sideStoryQuestCleared(info *masterdata.SideStoryQuestInfo, user *store.UserState) bool {
|
||||
return info != nil && len(info.Quests) > 0 && sideStoryClearedCount(info, user) == len(info.Quests)
|
||||
}
|
||||
|
||||
func sideStoryNextSceneAfterBattle(info *masterdata.SideStoryQuestInfo, user *store.UserState) (int32, bool) {
|
||||
cleared := sideStoryClearedCount(info, user)
|
||||
if cleared == 0 {
|
||||
return 0, false
|
||||
}
|
||||
total := len(info.Quests)
|
||||
var sceneType model.SideStorySceneIdType
|
||||
switch {
|
||||
case cleared >= total:
|
||||
sceneType = model.SideStorySceneOutroduction
|
||||
case cleared == total-1:
|
||||
sceneType = model.SideStorySceneUnlockLastQuest
|
||||
default:
|
||||
sceneType = model.SideStoryScenePlayLastQuest
|
||||
}
|
||||
return info.SceneIdByType(sceneType)
|
||||
}
|
||||
|
||||
func applySideStoryProgressState(progress *store.SideStoryQuestProgress, info *masterdata.SideStoryQuestInfo, user *store.UserState) {
|
||||
if sideStoryQuestCleared(info, user) {
|
||||
progress.SideStoryQuestStateType = model.SideStoryQuestStateCleared
|
||||
} else if progress.SideStoryQuestStateType == model.SideStoryQuestStateUnknown {
|
||||
progress.SideStoryQuestStateType = model.SideStoryQuestStateActive
|
||||
}
|
||||
}
|
||||
|
||||
func setSideStoryActive(user *store.UserState, questId, sceneId int32, nowMillis int64) {
|
||||
user.SideStoryActiveProgress = store.SideStoryActiveProgress{
|
||||
CurrentSideStoryQuestId: questId,
|
||||
CurrentSideStoryQuestSceneId: sceneId,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
|
||||
func setSideStoryScene(user *store.UserState, info *masterdata.SideStoryQuestInfo, questId, sceneId int32, nowMillis int64) {
|
||||
progress := user.SideStoryQuests[questId]
|
||||
progress.HeadSideStoryQuestSceneId = sceneId
|
||||
applySideStoryProgressState(&progress, info, user)
|
||||
progress.LatestVersion = nowMillis
|
||||
user.SideStoryQuests[questId] = progress
|
||||
setSideStoryActive(user, questId, sceneId, nowMillis)
|
||||
}
|
||||
|
||||
func (s *SideStoryQuestServiceServer) MoveSideStoryQuestProgress(ctx context.Context, req *pb.MoveSideStoryQuestRequest) (*pb.MoveSideStoryQuestResponse, error) {
|
||||
log.Printf("[SideStoryQuestService] MoveSideStoryQuestProgress: sideStoryQuestId=%d", req.SideStoryQuestId)
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
firstSceneId := s.holder.Get().SideStory.FirstSceneByQuestId[req.SideStoryQuestId]
|
||||
info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
if info == nil || len(info.Quests) == 0 {
|
||||
log.Printf("[SideStoryQuestService] unknown sideStoryQuestId=%d, skipping", req.SideStoryQuestId)
|
||||
return
|
||||
}
|
||||
|
||||
existing, exists := user.SideStoryQuests[req.SideStoryQuestId]
|
||||
|
||||
var sceneId int32
|
||||
if exists && existing.HeadSideStoryQuestSceneId > 0 {
|
||||
sceneId = existing.HeadSideStoryQuestSceneId
|
||||
var scene int32
|
||||
var ok bool
|
||||
if !exists || existing.HeadSideStoryQuestSceneId == 0 {
|
||||
scene, ok = info.SceneIdByType(model.SideStorySceneIntroduction)
|
||||
} else {
|
||||
sceneId = firstSceneId
|
||||
}
|
||||
|
||||
user.SideStoryActiveProgress.CurrentSideStoryQuestId = req.SideStoryQuestId
|
||||
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = sceneId
|
||||
user.SideStoryActiveProgress.LatestVersion = nowMillis
|
||||
|
||||
if !exists {
|
||||
user.SideStoryQuests[req.SideStoryQuestId] = store.SideStoryQuestProgress{
|
||||
HeadSideStoryQuestSceneId: firstSceneId,
|
||||
SideStoryQuestStateType: model.SideStoryQuestStateActive,
|
||||
LatestVersion: nowMillis,
|
||||
scene, ok = sideStoryNextSceneAfterBattle(info, user)
|
||||
if !ok {
|
||||
scene, ok = existing.HeadSideStoryQuestSceneId, true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setSideStoryScene(user, info, req.SideStoryQuestId, scene, nowMillis)
|
||||
})
|
||||
|
||||
return &pb.MoveSideStoryQuestResponse{}, nil
|
||||
@@ -61,16 +120,10 @@ func (s *SideStoryQuestServiceServer) UpdateSideStoryQuestSceneProgress(ctx cont
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
user.SideStoryActiveProgress.CurrentSideStoryQuestSceneId = req.SideStoryQuestSceneId
|
||||
user.SideStoryActiveProgress.LatestVersion = nowMillis
|
||||
info := s.holder.Get().SideStory.QuestById[req.SideStoryQuestId]
|
||||
|
||||
progress := user.SideStoryQuests[req.SideStoryQuestId]
|
||||
if req.SideStoryQuestSceneId > progress.HeadSideStoryQuestSceneId {
|
||||
progress.HeadSideStoryQuestSceneId = req.SideStoryQuestSceneId
|
||||
}
|
||||
progress.LatestVersion = nowMillis
|
||||
user.SideStoryQuests[req.SideStoryQuestId] = progress
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
setSideStoryScene(user, info, req.SideStoryQuestId, req.SideStoryQuestSceneId, nowMillis)
|
||||
})
|
||||
|
||||
return &pb.UpdateSideStoryQuestSceneProgressResponse{}, nil
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
|
||||
func (s *QuestServiceServer) ReceiveTowerAccumulationReward(ctx context.Context, req *pb.ReceiveTowerAccumulationRewardRequest) (*pb.ReceiveTowerAccumulationRewardResponse, error) {
|
||||
log.Printf("[QuestService] ReceiveTowerAccumulationReward: eventQuestChapterId=%d targetMissionClearCount=%d",
|
||||
req.EventQuestChapterId, req.TargetMissionClearCount)
|
||||
|
||||
cat := s.holder.Get()
|
||||
tower := cat.Tower
|
||||
granter := cat.QuestHandler.Granter
|
||||
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
rec := user.TowerAccumulationRewards[req.EventQuestChapterId]
|
||||
old := rec.LatestRewardReceiveQuestMissionClearCount
|
||||
|
||||
items, highest := tower.CollectRewards(req.EventQuestChapterId, old, req.TargetMissionClearCount)
|
||||
if highest <= old {
|
||||
log.Printf("[QuestService] ReceiveTowerAccumulationReward: nothing to grant for chapter=%d (claimed=%d, target=%d)",
|
||||
req.EventQuestChapterId, old, req.TargetMissionClearCount)
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
granter.GrantFull(user, model.PossessionType(it.PossessionType), it.PossessionId, it.Count, nowMillis)
|
||||
}
|
||||
|
||||
rec.EventQuestChapterId = req.EventQuestChapterId
|
||||
rec.LatestRewardReceiveQuestMissionClearCount = highest
|
||||
rec.LatestVersion = nowMillis
|
||||
user.TowerAccumulationRewards[req.EventQuestChapterId] = rec
|
||||
|
||||
log.Printf("[QuestService] ReceiveTowerAccumulationReward: chapter=%d granted %d item(s), claimed %d -> %d",
|
||||
req.EventQuestChapterId, len(items), old, highest)
|
||||
})
|
||||
|
||||
return &pb.ReceiveTowerAccumulationRewardResponse{}, nil
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/runtime"
|
||||
"lunar-tear/server/internal/store"
|
||||
@@ -38,12 +37,42 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
|
||||
userId := CurrentUserId(ctx, s.users, s.sessions)
|
||||
nowMillis := gametime.NowMillis()
|
||||
weeklyVersion := gametime.WeeklyVersion(nowMillis)
|
||||
today := gametime.StartOfDayMillis()
|
||||
|
||||
var weeklyScoreResults []*pb.WeeklyScoreResult
|
||||
var weeklyRewards []*pb.BigHuntReward
|
||||
isReceived := false
|
||||
|
||||
s.users.UpdateUser(userId, func(user *store.UserState) {
|
||||
for bossQuestId, bossQuest := range bhCatalog.BossQuestById {
|
||||
st := user.BigHuntStatuses[bossQuestId]
|
||||
if st.LastDailyRewardReceivedDayVersion >= today {
|
||||
continue
|
||||
}
|
||||
rewardGroupId := bhCatalog.ResolveActiveScoreRewardGroupId(bossQuest.BigHuntScoreRewardGroupScheduleId, nowMillis)
|
||||
if rewardGroupId == 0 {
|
||||
continue
|
||||
}
|
||||
maxScore := user.BigHuntScheduleMaxScores[store.BigHuntScheduleScoreKey{
|
||||
BigHuntScheduleId: bhCatalog.ActiveScheduleId,
|
||||
BigHuntBossId: bossQuest.BigHuntBossId,
|
||||
}].MaxScore
|
||||
if maxScore <= 0 {
|
||||
continue
|
||||
}
|
||||
items := bhCatalog.CollectNewRewards(rewardGroupId, 0, maxScore)
|
||||
for _, item := range items {
|
||||
granter.GrantFull(user, model.PossessionType(item.PossessionType), item.PossessionId, item.Count, nowMillis)
|
||||
}
|
||||
if len(items) > 0 {
|
||||
log.Printf("[RewardService] ReceiveBigHuntReward: bossQuestId=%d granted %d daily rewards (maxScore=%d, group=%d)",
|
||||
bossQuestId, len(items), maxScore, rewardGroupId)
|
||||
}
|
||||
st.LastDailyRewardReceivedDayVersion = today
|
||||
st.LatestVersion = nowMillis
|
||||
user.BigHuntStatuses[bossQuestId] = st
|
||||
}
|
||||
|
||||
ws := user.BigHuntWeeklyStatuses[weeklyVersion]
|
||||
isReceived = ws.IsReceivedWeeklyReward
|
||||
|
||||
@@ -67,11 +96,7 @@ func (s *RewardServiceServer) ReceiveBigHuntReward(ctx context.Context, _ *empty
|
||||
|
||||
if !isReceived {
|
||||
for _, boss := range bhCatalog.BossByBossId {
|
||||
rewardKey := masterdata.BigHuntWeeklyRewardKey{
|
||||
ScheduleId: 1,
|
||||
AttributeType: boss.AttributeType,
|
||||
}
|
||||
rewardGroupId := bhCatalog.ResolveActiveWeeklyRewardGroupId(rewardKey, nowMillis)
|
||||
rewardGroupId := bhCatalog.ResolveActiveWeeklyRewardGroupIdByAttr(boss.AttributeType, nowMillis)
|
||||
if rewardGroupId == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -194,22 +194,10 @@ func applyShopContentEffects(catalog *masterdata.ShopCatalog, user *store.UserSt
|
||||
switch effect.EffectTargetType {
|
||||
case model.EffectTargetStaminaRecovery:
|
||||
maxMillis := catalog.MaxStaminaMillis[user.Status.Level]
|
||||
millis := resolveShopEffectMillis(catalog, effect.EffectValueType, effect.EffectValue, user.Status.Level)
|
||||
millis := store.ResolveStaminaEffectMillis(effect.EffectValueType, effect.EffectValue, maxMillis)
|
||||
store.RecoverStamina(user, millis*qty, maxMillis, nowMillis)
|
||||
default:
|
||||
log.Printf("[ShopService] unhandled effect: shopItemId=%d targetType=%d", shopItemId, effect.EffectTargetType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveShopEffectMillis(catalog *masterdata.ShopCatalog, effectValueType, effectValue, userLevel int32) int32 {
|
||||
switch effectValueType {
|
||||
case model.EffectValueFixed:
|
||||
return effectValue
|
||||
case model.EffectValuePermil:
|
||||
maxMillis := catalog.MaxStaminaMillis[userLevel]
|
||||
return effectValue * maxMillis / 1000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
|
||||
pb "lunar-tear/server/gen/proto"
|
||||
"lunar-tear/server/internal/campaign"
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/gameutil"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
@@ -91,6 +92,12 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
|
||||
return
|
||||
}
|
||||
|
||||
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
|
||||
WeaponId: weapon.WeaponId,
|
||||
WeaponType: wm.WeaponType,
|
||||
AttributeType: wm.AttributeType,
|
||||
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
|
||||
|
||||
totalExp := int32(0)
|
||||
totalMaterialCount := int32(0)
|
||||
for materialId, count := range req.Materials {
|
||||
@@ -112,7 +119,7 @@ func (s *WeaponServiceServer) EnhanceByMaterial(ctx context.Context, req *pb.Enh
|
||||
if mat.WeaponType != 0 && mat.WeaponType == wm.WeaponType {
|
||||
expPerUnit = expPerUnit * config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||
}
|
||||
totalExp += expPerUnit * count
|
||||
totalExp += expBonus.Apply(expPerUnit * count)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
expBonus := cat.Campaign.WeaponExpBonus(campaign.WeaponTarget{
|
||||
WeaponId: weapon.WeaponId,
|
||||
WeaponType: wm.WeaponType,
|
||||
AttributeType: wm.AttributeType,
|
||||
}, campaign.Filter{NowMillis: nowMillis, UserStatus: campaign.TargetUserStatusAll})
|
||||
|
||||
totalExp := int32(0)
|
||||
consumedCount := int32(0)
|
||||
for _, uuid := range req.MaterialUserWeaponUuids {
|
||||
@@ -722,7 +735,7 @@ func (s *WeaponServiceServer) EnhanceByWeapon(ctx context.Context, req *pb.Enhan
|
||||
if matMaster.WeaponType != 0 && matMaster.WeaponType == wm.WeaponType {
|
||||
baseExp = baseExp * config.MaterialSameWeaponExpCoefficientPermil / 1000
|
||||
}
|
||||
totalExp += baseExp
|
||||
totalExp += expBonus.Apply(baseExp)
|
||||
|
||||
if medals, ok := catalog.MedalsByWeaponId[matWeapon.WeaponId]; ok {
|
||||
for itemId, count := range medals {
|
||||
|
||||
@@ -14,6 +14,7 @@ func CloneUserState(u UserState) UserState {
|
||||
out.DeckSubWeapons = cloneSliceMap(u.DeckSubWeapons)
|
||||
out.DeckParts = cloneSliceMap(u.DeckParts)
|
||||
out.Decks = maps.Clone(u.Decks)
|
||||
out.TripleDecks = maps.Clone(u.TripleDecks)
|
||||
out.Quests = maps.Clone(u.Quests)
|
||||
out.QuestMissions = maps.Clone(u.QuestMissions)
|
||||
out.WeaponStories = maps.Clone(u.WeaponStories)
|
||||
@@ -25,11 +26,15 @@ func CloneUserState(u UserState) UserState {
|
||||
Unlocks: maps.Clone(u.Gimmick.Unlocks),
|
||||
}
|
||||
out.CageOrnamentRewards = maps.Clone(u.CageOrnamentRewards)
|
||||
out.TowerAccumulationRewards = maps.Clone(u.TowerAccumulationRewards)
|
||||
out.LabyrinthSeasons = maps.Clone(u.LabyrinthSeasons)
|
||||
out.LabyrinthStages = maps.Clone(u.LabyrinthStages)
|
||||
out.ConsumableItems = maps.Clone(u.ConsumableItems)
|
||||
out.Materials = maps.Clone(u.Materials)
|
||||
out.Parts = maps.Clone(u.Parts)
|
||||
out.PartsGroupNotes = maps.Clone(u.PartsGroupNotes)
|
||||
out.PartsPresets = maps.Clone(u.PartsPresets)
|
||||
out.PartsPresetTags = maps.Clone(u.PartsPresetTags)
|
||||
out.PartsStatusSubs = maps.Clone(u.PartsStatusSubs)
|
||||
out.ImportantItems = maps.Clone(u.ImportantItems)
|
||||
out.CostumeActiveSkills = maps.Clone(u.CostumeActiveSkills)
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -102,7 +103,19 @@ type WeaponStoryReleaseCond struct {
|
||||
|
||||
type PartsRef struct {
|
||||
PartsGroupId int32
|
||||
RarityType int32
|
||||
PartsInitialLotteryId int32
|
||||
PartsStatusMainLotteryGroupId int32
|
||||
PartsStatusSubLotteryGroupId int32
|
||||
}
|
||||
|
||||
// PartsStatusSubDef carries the per-lottery-id sub-status shape needed at
|
||||
// grant time. Held here so the store package does not import masterdata.
|
||||
type PartsStatusSubDef struct {
|
||||
StatusKindType int32
|
||||
StatusCalculationType int32
|
||||
StatusChangeInitialValue int32
|
||||
StatusFunc func(level int32) int32
|
||||
}
|
||||
|
||||
type PossessionGranter struct {
|
||||
@@ -114,6 +127,9 @@ type PossessionGranter struct {
|
||||
|
||||
PartsById map[int32]PartsRef
|
||||
DefaultPartsStatusMainByLotteryGroup map[int32]int32
|
||||
PartsVariantsByGroupRarity map[int32]map[int32][]int32
|
||||
PartsSubStatusPool map[int32][]int32
|
||||
PartsSubStatusDefs map[int32]PartsStatusSubDef
|
||||
|
||||
LastChangedStoryWeaponIds []int32
|
||||
}
|
||||
@@ -184,26 +200,73 @@ func (g *PossessionGranter) GrantCompanion(user *UserState, companionId int32, n
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) GrantParts(user *UserState, partsId int32, nowMillis int64) {
|
||||
var mainStatId int32
|
||||
if ref, ok := g.PartsById[partsId]; ok {
|
||||
mainStatId = g.DefaultPartsStatusMainByLotteryGroup[ref.PartsStatusMainLotteryGroupId]
|
||||
if _, exists := user.PartsGroupNotes[ref.PartsGroupId]; !exists {
|
||||
user.PartsGroupNotes[ref.PartsGroupId] = PartsGroupNoteState{
|
||||
PartsGroupId: ref.PartsGroupId,
|
||||
func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) {
|
||||
ref, refOk := g.PartsById[requestedPartsId]
|
||||
if !refOk {
|
||||
key := uuid.New().String()
|
||||
user.Parts[key] = PartsState{
|
||||
UserPartsUuid: key,
|
||||
PartsId: requestedPartsId,
|
||||
Level: 1,
|
||||
AcquisitionDatetime: nowMillis,
|
||||
}
|
||||
log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", requestedPartsId)
|
||||
return
|
||||
}
|
||||
|
||||
chosenPartsId := requestedPartsId
|
||||
chosenRef := ref
|
||||
if variants := g.PartsVariantsByGroupRarity[ref.PartsGroupId][ref.RarityType]; len(variants) == 5 {
|
||||
chosenPartsId = variants[rand.Intn(len(variants))]
|
||||
chosenRef = g.PartsById[chosenPartsId]
|
||||
} else {
|
||||
log.Printf("[GrantParts] no 5-variant set for group=%d rarity=%d (have %d), granting requested=%d", ref.PartsGroupId, ref.RarityType, len(variants), requestedPartsId)
|
||||
}
|
||||
|
||||
mainStatId := g.DefaultPartsStatusMainByLotteryGroup[chosenRef.PartsStatusMainLotteryGroupId]
|
||||
if _, exists := user.PartsGroupNotes[chosenRef.PartsGroupId]; !exists {
|
||||
user.PartsGroupNotes[chosenRef.PartsGroupId] = PartsGroupNoteState{
|
||||
PartsGroupId: chosenRef.PartsGroupId,
|
||||
FirstAcquisitionDatetime: nowMillis,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
}
|
||||
key := uuid.New().String()
|
||||
user.Parts[key] = PartsState{
|
||||
UserPartsUuid: key,
|
||||
PartsId: partsId,
|
||||
PartsId: chosenPartsId,
|
||||
Level: 1,
|
||||
PartsStatusMainId: mainStatId,
|
||||
AcquisitionDatetime: nowMillis,
|
||||
}
|
||||
|
||||
initialCount := chosenRef.PartsInitialLotteryId
|
||||
pool := g.PartsSubStatusPool[chosenRef.PartsStatusSubLotteryGroupId]
|
||||
if initialCount > 1 && len(pool) > 0 {
|
||||
for i := int32(0); i < initialCount-1; i++ {
|
||||
pickId := pool[rand.Intn(len(pool))]
|
||||
def, ok := g.PartsSubStatusDefs[pickId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val := def.StatusChangeInitialValue
|
||||
if def.StatusFunc != nil {
|
||||
val = def.StatusFunc(1)
|
||||
}
|
||||
user.PartsStatusSubs[PartsStatusSubKey{UserPartsUuid: key, StatusIndex: i + 1}] = PartsStatusSubState{
|
||||
UserPartsUuid: key,
|
||||
StatusIndex: i + 1,
|
||||
PartsStatusSubLotteryId: pickId,
|
||||
Level: 1,
|
||||
StatusKindType: def.StatusKindType,
|
||||
StatusCalculationType: def.StatusCalculationType,
|
||||
StatusChangeValue: val,
|
||||
LatestVersion: nowMillis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[GrantParts] requested=%d chosen=%d variant=%d group=%d rarity=%d preUnlockedSubs=%d", requestedPartsId, chosenPartsId, initialCount, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1)
|
||||
}
|
||||
|
||||
func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) {
|
||||
|
||||
@@ -8,7 +8,6 @@ const (
|
||||
starterMissionId = int32(1)
|
||||
starterMainQuestRouteId = int32(1)
|
||||
starterMainQuestSeasonId = int32(1)
|
||||
missionInProgress = int32(1)
|
||||
|
||||
defaultBirthYear = int32(2000)
|
||||
defaultBirthMonth = int32(1)
|
||||
@@ -97,6 +96,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
|
||||
Companions: make(map[string]CompanionState),
|
||||
DeckCharacters: make(map[string]DeckCharacterState),
|
||||
Decks: make(map[DeckKey]DeckState),
|
||||
TripleDecks: make(map[DeckKey]TripleDeckState),
|
||||
DeckSubWeapons: make(map[string][]string),
|
||||
DeckParts: make(map[string][]string),
|
||||
Quests: make(map[int32]UserQuestState),
|
||||
@@ -113,7 +113,7 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
|
||||
starterMissionId: {
|
||||
MissionId: starterMissionId,
|
||||
StartDatetime: nowMillis,
|
||||
MissionProgressStatusType: missionInProgress,
|
||||
MissionProgressStatusType: int32(model.MissionProgressStatusTypeInProgress),
|
||||
},
|
||||
},
|
||||
Gimmick: GimmickState{
|
||||
@@ -123,12 +123,16 @@ func SeedUserState(userId int64, uuid string, nowMillis int64, platform model.Cl
|
||||
Unlocks: make(map[GimmickKey]GimmickUnlockState),
|
||||
},
|
||||
CageOrnamentRewards: make(map[int32]CageOrnamentRewardState),
|
||||
TowerAccumulationRewards: make(map[int32]TowerAccumulationRewardState),
|
||||
LabyrinthSeasons: make(map[int32]LabyrinthSeasonState),
|
||||
LabyrinthStages: make(map[LabyrinthStageKey]LabyrinthStageState),
|
||||
ConsumableItems: make(map[int32]int32),
|
||||
Materials: make(map[int32]int32),
|
||||
Thoughts: make(map[string]ThoughtState),
|
||||
Parts: make(map[string]PartsState),
|
||||
PartsGroupNotes: make(map[int32]PartsGroupNoteState),
|
||||
PartsPresets: make(map[int32]PartsPresetState),
|
||||
PartsPresetTags: make(map[int32]PartsPresetTagState),
|
||||
PartsStatusSubs: make(map[PartsStatusSubKey]PartsStatusSubState),
|
||||
ImportantItems: make(map[int32]int32),
|
||||
CostumeActiveSkills: make(map[string]CostumeActiveSkillState),
|
||||
|
||||
@@ -43,6 +43,7 @@ func initMaps(u *store.UserState) {
|
||||
u.Thoughts = make(map[string]store.ThoughtState)
|
||||
u.DeckCharacters = make(map[string]store.DeckCharacterState)
|
||||
u.Decks = make(map[store.DeckKey]store.DeckState)
|
||||
u.TripleDecks = make(map[store.DeckKey]store.TripleDeckState)
|
||||
u.DeckSubWeapons = make(map[string][]string)
|
||||
u.DeckParts = make(map[string][]string)
|
||||
u.Quests = make(map[int32]store.UserQuestState)
|
||||
@@ -60,6 +61,7 @@ func initMaps(u *store.UserState) {
|
||||
u.Parts = make(map[string]store.PartsState)
|
||||
u.PartsGroupNotes = make(map[int32]store.PartsGroupNoteState)
|
||||
u.PartsPresets = make(map[int32]store.PartsPresetState)
|
||||
u.PartsPresetTags = make(map[int32]store.PartsPresetTagState)
|
||||
u.PartsStatusSubs = make(map[store.PartsStatusSubKey]store.PartsStatusSubState)
|
||||
u.DeckTypeNotes = make(map[model.DeckType]store.DeckTypeNoteState)
|
||||
u.ConsumableItems = make(map[int32]int32)
|
||||
@@ -75,13 +77,15 @@ func initMaps(u *store.UserState) {
|
||||
u.ShopReplaceableLineup = make(map[int32]store.UserShopReplaceableLineupState)
|
||||
u.ExploreScores = make(map[int32]store.ExploreScoreState)
|
||||
u.CageOrnamentRewards = make(map[int32]store.CageOrnamentRewardState)
|
||||
u.TowerAccumulationRewards = make(map[int32]store.TowerAccumulationRewardState)
|
||||
u.LabyrinthSeasons = make(map[int32]store.LabyrinthSeasonState)
|
||||
u.LabyrinthStages = make(map[store.LabyrinthStageKey]store.LabyrinthStageState)
|
||||
u.CharacterBoards = make(map[int32]store.CharacterBoardState)
|
||||
u.CharacterBoardAbilities = make(map[store.CharacterBoardAbilityKey]store.CharacterBoardAbilityState)
|
||||
u.CharacterBoardStatusUps = make(map[store.CharacterBoardStatusUpKey]store.CharacterBoardStatusUpState)
|
||||
u.CharacterRebirths = make(map[int32]store.CharacterRebirthState)
|
||||
u.AutoSaleSettings = make(map[int32]store.AutoSaleSettingState)
|
||||
u.SideStoryQuests = make(map[int32]store.SideStoryQuestProgress)
|
||||
u.MainQuestSeasonRoutes = make(map[store.SeasonRouteKey]store.SeasonRouteEntry)
|
||||
u.QuestLimitContentStatus = make(map[int32]store.QuestLimitContentStatus)
|
||||
u.BigHuntMaxScores = make(map[int32]store.BigHuntMaxScore)
|
||||
u.BigHuntStatuses = make(map[int32]store.BigHuntStatus)
|
||||
@@ -131,7 +135,7 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
|
||||
progress_quest_flow_type, main_quest_season_id, latest_version,
|
||||
saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id,
|
||||
saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id,
|
||||
saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress,
|
||||
saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, saved_ctx_current_quest_flow_type,
|
||||
replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id
|
||||
FROM user_main_quest WHERE user_id=?`, uid).
|
||||
Scan(&u.MainQuest.CurrentQuestFlowType, &u.MainQuest.CurrentMainQuestRouteId, &u.MainQuest.CurrentQuestSceneId,
|
||||
@@ -139,7 +143,7 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
|
||||
&u.MainQuest.ProgressQuestFlowType, &u.MainQuest.MainQuestSeasonId, &u.MainQuest.LatestVersion,
|
||||
&ctxActive, &u.MainQuest.SavedContext.CurrentQuestSceneId, &u.MainQuest.SavedContext.HeadQuestSceneId,
|
||||
&u.MainQuest.SavedContext.CurrentMainQuestRouteId, &u.MainQuest.SavedContext.MainQuestSeasonId,
|
||||
&ctxIsLast, &ctxCage,
|
||||
&ctxIsLast, &ctxCage, &u.MainQuest.SavedContext.CurrentQuestFlowType,
|
||||
&u.MainQuest.ReplayFlowCurrentQuestSceneId, &u.MainQuest.ReplayFlowHeadQuestSceneId)
|
||||
u.MainQuest.IsReachedLastQuestScene = b != 0
|
||||
u.MainQuest.SavedContext.Active = ctxActive != 0
|
||||
@@ -173,6 +177,13 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) {
|
||||
&u.BigHuntBattleDetail.TotalDamage, &u.BigHuntDeckNumber, &u.BigHuntBattleBinary)
|
||||
u.BigHuntProgress.IsDryRun = isDryRun != 0
|
||||
|
||||
queryRows(db, `SELECT wave_index, costume_id, total_damage, hit_count, random_display_value_type, random_display_value
|
||||
FROM user_big_hunt_costume_battle_infos WHERE user_id=? ORDER BY wave_index, sort_order`, uid, func(rows *sql.Rows) {
|
||||
var ci store.BigHuntCostumeBattleInfo
|
||||
rows.Scan(&ci.WaveIndex, &ci.CostumeId, &ci.TotalDamage, &ci.HitCount, &ci.RandomDisplayValueType, &ci.RandomDisplayValue)
|
||||
u.BigHuntBattleDetail.CostumeBattleInfo = append(u.BigHuntBattleDetail.CostumeBattleInfo, ci)
|
||||
})
|
||||
|
||||
var isActive, isUnread int
|
||||
_ = db.QueryRow(`SELECT is_active, start_count, finish_count, last_started_at, last_finished_at,
|
||||
last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count
|
||||
@@ -292,6 +303,16 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
|
||||
u.Decks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version
|
||||
FROM user_triple_decks WHERE user_id=?`, uid,
|
||||
func(rows *sql.Rows) {
|
||||
var v store.TripleDeckState
|
||||
var dt int32
|
||||
rows.Scan(&dt, &v.UserDeckNumber, &v.Name, &v.DeckNumber01, &v.DeckNumber02, &v.DeckNumber03, &v.LatestVersion)
|
||||
v.DeckType = model.DeckType(dt)
|
||||
u.TripleDecks[store.DeckKey{DeckType: v.DeckType, UserDeckNumber: v.UserDeckNumber}] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT user_deck_character_uuid, ordinal, user_weapon_uuid
|
||||
FROM user_deck_sub_weapons WHERE user_id=? ORDER BY user_deck_character_uuid, ordinal`, uid,
|
||||
func(rows *sql.Rows) {
|
||||
@@ -356,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
|
||||
FROM user_quest_limit_content_status WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var id int32
|
||||
@@ -474,6 +485,14 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
|
||||
u.PartsPresets[v.UserPartsPresetNumber] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT user_parts_preset_tag_number, name, latest_version
|
||||
FROM user_parts_preset_tags WHERE user_id=?`, uid,
|
||||
func(rows *sql.Rows) {
|
||||
var v store.PartsPresetTagState
|
||||
rows.Scan(&v.UserPartsPresetTagNumber, &v.Name, &v.LatestVersion)
|
||||
u.PartsPresetTags[v.UserPartsPresetTagNumber] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT user_parts_uuid, status_index, parts_status_sub_lottery_id, level,
|
||||
status_kind_type, status_calculation_type, status_change_value, latest_version
|
||||
FROM user_parts_status_subs WHERE user_id=?`, uid,
|
||||
@@ -624,6 +643,29 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
|
||||
u.CageOrnamentRewards[v.CageOrnamentId] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version
|
||||
FROM user_event_quest_tower_accumulation_rewards WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var v store.TowerAccumulationRewardState
|
||||
rows.Scan(&v.EventQuestChapterId, &v.LatestRewardReceiveQuestMissionClearCount, &v.LatestVersion)
|
||||
u.TowerAccumulationRewards[v.EventQuestChapterId] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version
|
||||
FROM user_event_quest_labyrinth_seasons WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var v store.LabyrinthSeasonState
|
||||
rows.Scan(&v.EventQuestChapterId, &v.LastJoinSeasonNumber, &v.LastSeasonRewardReceivedSeasonNumber, &v.LatestVersion)
|
||||
u.LabyrinthSeasons[v.EventQuestChapterId] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version
|
||||
FROM user_event_quest_labyrinth_stages WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var v store.LabyrinthStageState
|
||||
var rcvd int
|
||||
rows.Scan(&v.EventQuestChapterId, &v.StageOrder, &rcvd, &v.AccumulationRewardReceivedQuestMissionCount, &v.LatestVersion)
|
||||
v.IsReceivedStageClearReward = rcvd != 0
|
||||
u.LabyrinthStages[store.LabyrinthStageKey{EventQuestChapterId: v.EventQuestChapterId, StageOrder: v.StageOrder}] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version
|
||||
FROM user_shop_items WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var v store.UserShopItemState
|
||||
@@ -688,11 +730,11 @@ func loadMapTables(db *sql.DB, uid int64, u *store.UserState) {
|
||||
u.BigHuntMaxScores[id] = v
|
||||
})
|
||||
|
||||
queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version
|
||||
queryRows(db, `SELECT big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version
|
||||
FROM user_big_hunt_statuses WHERE user_id=?`, uid, func(rows *sql.Rows) {
|
||||
var id int32
|
||||
var v store.BigHuntStatus
|
||||
rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LatestVersion)
|
||||
rows.Scan(&id, &v.DailyChallengeCount, &v.LatestChallengeDatetime, &v.LastDailyRewardReceivedDayVersion, &v.LatestVersion)
|
||||
u.BigHuntStatuses[id] = v
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
u.LoginBonus.LatestRewardReceiveDatetime, u.LoginBonus.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id, saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id, saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
if err := exec(`INSERT INTO user_main_quest (user_id, current_quest_flow_type, current_main_quest_route_id, current_quest_scene_id, head_quest_scene_id, is_reached_last_quest_scene, progress_quest_scene_id, progress_head_quest_scene_id, progress_quest_flow_type, main_quest_season_id, latest_version, saved_ctx_active, saved_ctx_current_quest_scene_id, saved_ctx_head_quest_scene_id, saved_ctx_current_main_quest_route_id, saved_ctx_main_quest_season_id, saved_ctx_is_reached_last_quest_scene, saved_ctx_portal_cage_in_progress, saved_ctx_current_quest_flow_type, replay_flow_current_quest_scene_id, replay_flow_head_quest_scene_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
uid, u.MainQuest.CurrentQuestFlowType, u.MainQuest.CurrentMainQuestRouteId, u.MainQuest.CurrentQuestSceneId,
|
||||
u.MainQuest.HeadQuestSceneId, boolToInt(u.MainQuest.IsReachedLastQuestScene), u.MainQuest.ProgressQuestSceneId,
|
||||
u.MainQuest.ProgressHeadQuestSceneId, u.MainQuest.ProgressQuestFlowType, u.MainQuest.MainQuestSeasonId,
|
||||
@@ -59,6 +59,7 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
u.MainQuest.SavedContext.CurrentMainQuestRouteId, u.MainQuest.SavedContext.MainQuestSeasonId,
|
||||
boolToInt(u.MainQuest.SavedContext.IsReachedLastQuestScene),
|
||||
boolToInt(u.MainQuest.SavedContext.PortalCageInProgress),
|
||||
u.MainQuest.SavedContext.CurrentQuestFlowType,
|
||||
u.MainQuest.ReplayFlowCurrentQuestSceneId, u.MainQuest.ReplayFlowHeadQuestSceneId); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -82,6 +83,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
u.BigHuntBattleDetail.MaxComboCount, u.BigHuntBattleDetail.TotalDamage, u.BigHuntDeckNumber, u.BigHuntBattleBinary); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, ci := range u.BigHuntBattleDetail.CostumeBattleInfo {
|
||||
if err := exec(`INSERT INTO user_big_hunt_costume_battle_infos (user_id, wave_index, sort_order, costume_id, total_damage, hit_count, random_display_value_type, random_display_value) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
uid, ci.WaveIndex, i, ci.CostumeId, ci.TotalDamage, ci.HitCount, ci.RandomDisplayValueType, ci.RandomDisplayValue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := exec(`INSERT INTO user_battle (user_id, is_active, start_count, finish_count, last_started_at, last_finished_at, last_user_party_count, last_npc_party_count, last_battle_binary_size, last_elapsed_frame_count) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
uid, boolToInt(u.Battle.IsActive), u.Battle.StartCount, u.Battle.FinishCount, u.Battle.LastStartedAt,
|
||||
u.Battle.LastFinishedAt, u.Battle.LastUserPartyCount, u.Battle.LastNpcPartyCount,
|
||||
@@ -164,6 +171,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for k, v := range u.TripleDecks {
|
||||
if err := exec(`INSERT INTO user_triple_decks (user_id, deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
uid, int32(k.DeckType), k.UserDeckNumber, v.Name, v.DeckNumber01, v.DeckNumber02, v.DeckNumber03, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for key, uuids := range u.DeckSubWeapons {
|
||||
for i, uuid := range uuids {
|
||||
if err := exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`,
|
||||
@@ -211,12 +224,6 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
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 {
|
||||
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 {
|
||||
@@ -299,6 +306,12 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, v := range u.PartsPresetTags {
|
||||
if err := exec(`INSERT INTO user_parts_preset_tags (user_id, user_parts_preset_tag_number, name, latest_version) VALUES (?,?,?,?)`,
|
||||
uid, v.UserPartsPresetTagNumber, v.Name, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, v := range u.PartsStatusSubs {
|
||||
if err := exec(`INSERT INTO user_parts_status_subs (user_id, user_parts_uuid, status_index, parts_status_sub_lottery_id, level, status_kind_type, status_calculation_type, status_change_value, latest_version) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
uid, v.UserPartsUuid, v.StatusIndex, v.PartsStatusSubLotteryId, v.Level, v.StatusKindType, v.StatusCalculationType, v.StatusChangeValue, v.LatestVersion); err != nil {
|
||||
@@ -435,6 +448,24 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, v := range u.TowerAccumulationRewards {
|
||||
if err := exec(`INSERT INTO user_event_quest_tower_accumulation_rewards (user_id, event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version) VALUES (?,?,?,?)`,
|
||||
uid, v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, v := range u.LabyrinthSeasons {
|
||||
if err := exec(`INSERT INTO user_event_quest_labyrinth_seasons (user_id, event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version) VALUES (?,?,?,?,?)`,
|
||||
uid, v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for k, v := range u.LabyrinthStages {
|
||||
if err := exec(`INSERT INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
|
||||
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, v := range u.ShopItems {
|
||||
if err := exec(`INSERT INTO user_shop_items (user_id, shop_item_id, bought_count, latest_bought_count_changed_datetime, latest_version) VALUES (?,?,?,?,?)`,
|
||||
uid, v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion); err != nil {
|
||||
@@ -478,8 +509,8 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error {
|
||||
}
|
||||
}
|
||||
for id, v := range u.BigHuntStatuses {
|
||||
if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version) VALUES (?,?,?,?,?)`,
|
||||
uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion); err != nil {
|
||||
if err := exec(`INSERT INTO user_big_hunt_statuses (user_id, big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version) VALUES (?,?,?,?,?,?)`,
|
||||
uid, id, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LastDailyRewardReceivedDayVersion, v.LatestVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -566,7 +597,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
}
|
||||
}
|
||||
if before.MainQuest != after.MainQuest {
|
||||
if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_ctx_active=?, saved_ctx_current_quest_scene_id=?, saved_ctx_head_quest_scene_id=?, saved_ctx_current_main_quest_route_id=?, saved_ctx_main_quest_season_id=?, saved_ctx_is_reached_last_quest_scene=?, saved_ctx_portal_cage_in_progress=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`,
|
||||
if err := exec(`UPDATE user_main_quest SET current_quest_flow_type=?, current_main_quest_route_id=?, current_quest_scene_id=?, head_quest_scene_id=?, is_reached_last_quest_scene=?, progress_quest_scene_id=?, progress_head_quest_scene_id=?, progress_quest_flow_type=?, main_quest_season_id=?, latest_version=?, saved_ctx_active=?, saved_ctx_current_quest_scene_id=?, saved_ctx_head_quest_scene_id=?, saved_ctx_current_main_quest_route_id=?, saved_ctx_main_quest_season_id=?, saved_ctx_is_reached_last_quest_scene=?, saved_ctx_portal_cage_in_progress=?, saved_ctx_current_quest_flow_type=?, replay_flow_current_quest_scene_id=?, replay_flow_head_quest_scene_id=? WHERE user_id=?`,
|
||||
after.MainQuest.CurrentQuestFlowType, after.MainQuest.CurrentMainQuestRouteId, after.MainQuest.CurrentQuestSceneId,
|
||||
after.MainQuest.HeadQuestSceneId, boolToInt(after.MainQuest.IsReachedLastQuestScene), after.MainQuest.ProgressQuestSceneId,
|
||||
after.MainQuest.ProgressHeadQuestSceneId, after.MainQuest.ProgressQuestFlowType, after.MainQuest.MainQuestSeasonId,
|
||||
@@ -576,6 +607,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
after.MainQuest.SavedContext.CurrentMainQuestRouteId, after.MainQuest.SavedContext.MainQuestSeasonId,
|
||||
boolToInt(after.MainQuest.SavedContext.IsReachedLastQuestScene),
|
||||
boolToInt(after.MainQuest.SavedContext.PortalCageInProgress),
|
||||
after.MainQuest.SavedContext.CurrentQuestFlowType,
|
||||
after.MainQuest.ReplayFlowCurrentQuestSceneId, after.MainQuest.ReplayFlowHeadQuestSceneId, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -598,7 +630,7 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if before.BigHuntProgress != after.BigHuntProgress || before.BigHuntBattleDetail != after.BigHuntBattleDetail || before.BigHuntDeckNumber != after.BigHuntDeckNumber {
|
||||
if before.BigHuntProgress != after.BigHuntProgress || !bigHuntBattleDetailEqual(before.BigHuntBattleDetail, after.BigHuntBattleDetail) || before.BigHuntDeckNumber != after.BigHuntDeckNumber {
|
||||
if err := exec(`UPDATE user_big_hunt_state SET current_big_hunt_boss_quest_id=?, current_big_hunt_quest_id=?, current_quest_scene_id=?, is_dry_run=?, latest_version=?, deck_type=?, user_triple_deck_number=?, boss_knock_down_count=?, max_combo_count=?, total_damage=?, deck_number=?, battle_binary=? WHERE user_id=?`,
|
||||
after.BigHuntProgress.CurrentBigHuntBossQuestId, after.BigHuntProgress.CurrentBigHuntQuestId,
|
||||
after.BigHuntProgress.CurrentQuestSceneId, boolToInt(after.BigHuntProgress.IsDryRun), after.BigHuntProgress.LatestVersion,
|
||||
@@ -606,6 +638,15 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
after.BigHuntBattleDetail.MaxComboCount, after.BigHuntBattleDetail.TotalDamage, after.BigHuntDeckNumber, after.BigHuntBattleBinary, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec(`DELETE FROM user_big_hunt_costume_battle_infos WHERE user_id=?`, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, ci := range after.BigHuntBattleDetail.CostumeBattleInfo {
|
||||
if err := exec(`INSERT INTO user_big_hunt_costume_battle_infos (user_id, wave_index, sort_order, costume_id, total_damage, hit_count, random_display_value_type, random_display_value) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
uid, ci.WaveIndex, i, ci.CostumeId, ci.TotalDamage, ci.HitCount, ci.RandomDisplayValueType, ci.RandomDisplayValue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if before.Battle != after.Battle {
|
||||
if err := exec(`UPDATE user_battle SET is_active=?, start_count=?, finish_count=?, last_started_at=?, last_finished_at=?, last_user_party_count=?, last_npc_party_count=?, last_battle_binary_size=?, last_elapsed_frame_count=? WHERE user_id=?`,
|
||||
@@ -696,6 +737,18 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range after.TripleDecks {
|
||||
if old, ok := before.TripleDecks[k]; !ok || old != v {
|
||||
exec(`INSERT OR REPLACE INTO user_triple_decks (user_id, deck_type, user_deck_number, name, deck_number01, deck_number02, deck_number03, latest_version) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
uid, int32(k.DeckType), k.UserDeckNumber, v.Name, v.DeckNumber01, v.DeckNumber02, v.DeckNumber03, v.LatestVersion)
|
||||
}
|
||||
}
|
||||
for k := range before.TripleDecks {
|
||||
if _, ok := after.TripleDecks[k]; !ok {
|
||||
exec(`DELETE FROM user_triple_decks WHERE user_id=? AND deck_type=? AND user_deck_number=?`, uid, int32(k.DeckType), k.UserDeckNumber)
|
||||
}
|
||||
}
|
||||
|
||||
replaceSliceTable(tx, uid, "user_deck_sub_weapons", after.DeckSubWeapons, func(key string, uuids []string) {
|
||||
for i, uuid := range uuids {
|
||||
exec(`INSERT INTO user_deck_sub_weapons (user_id, user_deck_character_uuid, ordinal, user_weapon_uuid) VALUES (?,?,?,?)`, uid, key, i, uuid)
|
||||
@@ -739,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}
|
||||
}, "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",
|
||||
func(v store.QuestLimitContentStatus) []any {
|
||||
return []any{0, v.LimitContentQuestStatusType, v.EventQuestChapterId, v.LatestVersion}
|
||||
@@ -827,6 +869,10 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
func(v store.PartsPresetState) []any {
|
||||
return []any{v.UserPartsPresetNumber, v.UserPartsUuid01, v.UserPartsUuid02, v.UserPartsUuid03, v.Name, v.UserPartsPresetTagNumber, v.LatestVersion}
|
||||
}, "user_parts_preset_number, user_parts_uuid01, user_parts_uuid02, user_parts_uuid03, name, user_parts_preset_tag_number, latest_version")
|
||||
diffMapInt32(tx, uid, before.PartsPresetTags, after.PartsPresetTags, "user_parts_preset_tags", "user_parts_preset_tag_number",
|
||||
func(v store.PartsPresetTagState) []any {
|
||||
return []any{v.UserPartsPresetTagNumber, v.Name, v.LatestVersion}
|
||||
}, "user_parts_preset_tag_number, name, latest_version")
|
||||
|
||||
for k, v := range after.PartsStatusSubs {
|
||||
if old, ok := before.PartsStatusSubs[k]; !ok || old != v {
|
||||
@@ -949,6 +995,27 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
return []any{v.CageOrnamentId, v.AcquisitionDatetime, v.LatestVersion}
|
||||
},
|
||||
"cage_ornament_id, acquisition_datetime, latest_version")
|
||||
diffMapInt32(tx, uid, before.TowerAccumulationRewards, after.TowerAccumulationRewards, "user_event_quest_tower_accumulation_rewards", "event_quest_chapter_id",
|
||||
func(v store.TowerAccumulationRewardState) []any {
|
||||
return []any{v.EventQuestChapterId, v.LatestRewardReceiveQuestMissionClearCount, v.LatestVersion}
|
||||
},
|
||||
"event_quest_chapter_id, latest_reward_receive_quest_mission_clear_count, latest_version")
|
||||
diffMapInt32(tx, uid, before.LabyrinthSeasons, after.LabyrinthSeasons, "user_event_quest_labyrinth_seasons", "event_quest_chapter_id",
|
||||
func(v store.LabyrinthSeasonState) []any {
|
||||
return []any{v.EventQuestChapterId, v.LastJoinSeasonNumber, v.LastSeasonRewardReceivedSeasonNumber, v.LatestVersion}
|
||||
},
|
||||
"event_quest_chapter_id, last_join_season_number, last_season_reward_received_season_number, latest_version")
|
||||
for k, v := range after.LabyrinthStages {
|
||||
if old, ok := before.LabyrinthStages[k]; !ok || old != v {
|
||||
exec(`INSERT OR REPLACE INTO user_event_quest_labyrinth_stages (user_id, event_quest_chapter_id, stage_order, is_received_stage_clear_reward, accumulation_reward_received_quest_mission_count, latest_version) VALUES (?,?,?,?,?,?)`,
|
||||
uid, k.EventQuestChapterId, k.StageOrder, boolToInt(v.IsReceivedStageClearReward), v.AccumulationRewardReceivedQuestMissionCount, v.LatestVersion)
|
||||
}
|
||||
}
|
||||
for k := range before.LabyrinthStages {
|
||||
if _, ok := after.LabyrinthStages[k]; !ok {
|
||||
exec(`DELETE FROM user_event_quest_labyrinth_stages WHERE user_id=? AND event_quest_chapter_id=? AND stage_order=?`, uid, k.EventQuestChapterId, k.StageOrder)
|
||||
}
|
||||
}
|
||||
diffMapInt32(tx, uid, before.ShopItems, after.ShopItems, "user_shop_items", "shop_item_id",
|
||||
func(v store.UserShopItemState) []any {
|
||||
return []any{v.ShopItemId, v.BoughtCount, v.LatestBoughtCountChangedDatetime, v.LatestVersion}
|
||||
@@ -1015,9 +1082,9 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
"big_hunt_boss_id, max_score, max_score_update_datetime, latest_version")
|
||||
diffMapInt32(tx, uid, before.BigHuntStatuses, after.BigHuntStatuses, "user_big_hunt_statuses", "big_hunt_boss_id",
|
||||
func(v store.BigHuntStatus) []any {
|
||||
return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LatestVersion}
|
||||
return []any{0, v.DailyChallengeCount, v.LatestChallengeDatetime, v.LastDailyRewardReceivedDayVersion, v.LatestVersion}
|
||||
},
|
||||
"big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, latest_version")
|
||||
"big_hunt_boss_id, daily_challenge_count, latest_challenge_datetime, last_daily_reward_received_day_version, latest_version")
|
||||
|
||||
for k, v := range after.BigHuntScheduleMaxScores {
|
||||
if old, ok := before.BigHuntScheduleMaxScores[k]; !ok || old != v {
|
||||
@@ -1056,6 +1123,23 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func bigHuntBattleDetailEqual(a, b store.BigHuntBattleDetail) bool {
|
||||
if a.DeckType != b.DeckType || a.UserTripleDeckNumber != b.UserTripleDeckNumber ||
|
||||
a.BossKnockDownCount != b.BossKnockDownCount || a.MaxComboCount != b.MaxComboCount ||
|
||||
a.TotalDamage != b.TotalDamage {
|
||||
return false
|
||||
}
|
||||
if len(a.CostumeBattleInfo) != len(b.CostumeBattleInfo) {
|
||||
return false
|
||||
}
|
||||
for i := range a.CostumeBattleInfo {
|
||||
if a.CostumeBattleInfo[i] != b.CostumeBattleInfo[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func diffMapInt32[V comparable](tx *sql.Tx, uid int64, before, after map[int32]V, table, keyCol string, vals func(V) []any, cols string) {
|
||||
for k, v := range after {
|
||||
if old, ok := before[k]; !ok || old != v {
|
||||
|
||||
@@ -79,6 +79,9 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
|
||||
|
||||
// Child tables in reverse-dependency order (matches schema's goose Down).
|
||||
childTables := []string{
|
||||
"user_event_quest_labyrinth_stages",
|
||||
"user_event_quest_labyrinth_seasons",
|
||||
"user_event_quest_tower_accumulation_rewards",
|
||||
"user_cage_ornament_rewards",
|
||||
"user_shop_replaceable_lineup",
|
||||
"user_shop_items",
|
||||
@@ -119,6 +122,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error {
|
||||
"user_decks",
|
||||
"user_deck_characters",
|
||||
"user_parts_status_subs",
|
||||
"user_parts_preset_tags",
|
||||
"user_parts_presets",
|
||||
"user_parts_group_notes",
|
||||
"user_parts",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package store
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"log"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
)
|
||||
|
||||
const StaminaRecoveryDivisor int64 = 180
|
||||
|
||||
@@ -39,3 +43,14 @@ func ReplenishStamina(user *UserState, maxStaminaMillis int32, nowMillis int64)
|
||||
user.Status.StaminaUpdateDatetime = nowMillis
|
||||
log.Printf("[ReplenishStamina] set to %d", maxStaminaMillis)
|
||||
}
|
||||
|
||||
func ResolveStaminaEffectMillis(effectValueType, effectValue, maxStaminaMillis int32) int32 {
|
||||
switch effectValueType {
|
||||
case model.EffectValueFixed:
|
||||
return effectValue * 1000
|
||||
case model.EffectValuePermil:
|
||||
return effectValue * maxStaminaMillis / 1000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ type UserState struct {
|
||||
LoginBonus UserLoginBonusState
|
||||
Tutorials map[int32]TutorialProgressState
|
||||
MainQuest MainQuestState
|
||||
MainQuestSeasonRoutes map[SeasonRouteKey]SeasonRouteEntry
|
||||
EventQuest EventQuestState
|
||||
ExtraQuest ExtraQuestState
|
||||
SideStoryQuests map[int32]SideStoryQuestProgress
|
||||
@@ -70,17 +69,22 @@ type UserState struct {
|
||||
Thoughts map[string]ThoughtState
|
||||
DeckCharacters map[string]DeckCharacterState
|
||||
Decks map[DeckKey]DeckState
|
||||
TripleDecks map[DeckKey]TripleDeckState
|
||||
Quests map[int32]UserQuestState
|
||||
QuestMissions map[QuestMissionKey]UserQuestMissionState
|
||||
Missions map[int32]UserMissionState
|
||||
WeaponStories map[int32]WeaponStoryState
|
||||
Gimmick GimmickState
|
||||
CageOrnamentRewards map[int32]CageOrnamentRewardState
|
||||
TowerAccumulationRewards map[int32]TowerAccumulationRewardState
|
||||
LabyrinthSeasons map[int32]LabyrinthSeasonState
|
||||
LabyrinthStages map[LabyrinthStageKey]LabyrinthStageState
|
||||
ConsumableItems map[int32]int32
|
||||
Materials map[int32]int32
|
||||
Parts map[string]PartsState
|
||||
PartsGroupNotes map[int32]PartsGroupNoteState
|
||||
PartsPresets map[int32]PartsPresetState
|
||||
PartsPresetTags map[int32]PartsPresetTagState
|
||||
PartsStatusSubs map[PartsStatusSubKey]PartsStatusSubState
|
||||
ImportantItems map[int32]int32
|
||||
CostumeActiveSkills map[string]CostumeActiveSkillState
|
||||
@@ -142,6 +146,9 @@ func (u *UserState) EnsureMaps() {
|
||||
if u.Decks == nil {
|
||||
u.Decks = make(map[DeckKey]DeckState)
|
||||
}
|
||||
if u.TripleDecks == nil {
|
||||
u.TripleDecks = make(map[DeckKey]TripleDeckState)
|
||||
}
|
||||
if u.DeckSubWeapons == nil {
|
||||
u.DeckSubWeapons = make(map[string][]string)
|
||||
}
|
||||
@@ -154,9 +161,6 @@ func (u *UserState) EnsureMaps() {
|
||||
if u.SideStoryQuests == nil {
|
||||
u.SideStoryQuests = make(map[int32]SideStoryQuestProgress)
|
||||
}
|
||||
if u.MainQuestSeasonRoutes == nil {
|
||||
u.MainQuestSeasonRoutes = make(map[SeasonRouteKey]SeasonRouteEntry)
|
||||
}
|
||||
if u.QuestLimitContentStatus == nil {
|
||||
u.QuestLimitContentStatus = make(map[int32]QuestLimitContentStatus)
|
||||
}
|
||||
@@ -187,6 +191,15 @@ func (u *UserState) EnsureMaps() {
|
||||
if u.CageOrnamentRewards == nil {
|
||||
u.CageOrnamentRewards = make(map[int32]CageOrnamentRewardState)
|
||||
}
|
||||
if u.TowerAccumulationRewards == nil {
|
||||
u.TowerAccumulationRewards = make(map[int32]TowerAccumulationRewardState)
|
||||
}
|
||||
if u.LabyrinthSeasons == nil {
|
||||
u.LabyrinthSeasons = make(map[int32]LabyrinthSeasonState)
|
||||
}
|
||||
if u.LabyrinthStages == nil {
|
||||
u.LabyrinthStages = make(map[LabyrinthStageKey]LabyrinthStageState)
|
||||
}
|
||||
if u.ConsumableItems == nil {
|
||||
u.ConsumableItems = make(map[int32]int32)
|
||||
}
|
||||
@@ -202,6 +215,9 @@ func (u *UserState) EnsureMaps() {
|
||||
if u.PartsPresets == nil {
|
||||
u.PartsPresets = make(map[int32]PartsPresetState)
|
||||
}
|
||||
if u.PartsPresetTags == nil {
|
||||
u.PartsPresetTags = make(map[int32]PartsPresetTagState)
|
||||
}
|
||||
if u.PartsStatusSubs == nil {
|
||||
u.PartsStatusSubs = make(map[PartsStatusSubKey]PartsStatusSubState)
|
||||
}
|
||||
@@ -455,6 +471,16 @@ type DeckState struct {
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type TripleDeckState struct {
|
||||
DeckType model.DeckType
|
||||
UserDeckNumber int32
|
||||
Name string
|
||||
DeckNumber01 int32
|
||||
DeckNumber02 int32
|
||||
DeckNumber03 int32
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type DeckCharacterInput struct {
|
||||
UserCostumeUuid string
|
||||
MainUserWeaponUuid string
|
||||
@@ -530,6 +556,7 @@ type SavedQuestContext struct {
|
||||
MainQuestSeasonId int32
|
||||
IsReachedLastQuestScene bool
|
||||
PortalCageInProgress bool
|
||||
CurrentQuestFlowType int32
|
||||
}
|
||||
|
||||
type EventQuestState struct {
|
||||
@@ -559,17 +586,6 @@ type SideStoryActiveProgress struct {
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type SeasonRouteKey struct {
|
||||
MainQuestSeasonId int32
|
||||
MainQuestRouteId int32
|
||||
}
|
||||
|
||||
type SeasonRouteEntry struct {
|
||||
MainQuestSeasonId int32
|
||||
MainQuestRouteId int32
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type QuestLimitContentStatus struct {
|
||||
LimitContentQuestStatusType int32
|
||||
EventQuestChapterId int32
|
||||
@@ -593,6 +609,7 @@ type BigHuntMaxScore struct {
|
||||
type BigHuntStatus struct {
|
||||
DailyChallengeCount int32
|
||||
LatestChallengeDatetime int64
|
||||
LastDailyRewardReceivedDayVersion int64
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
@@ -656,6 +673,16 @@ type BigHuntBattleDetail struct {
|
||||
BossKnockDownCount int32
|
||||
MaxComboCount int32
|
||||
TotalDamage int64
|
||||
CostumeBattleInfo []BigHuntCostumeBattleInfo
|
||||
}
|
||||
|
||||
type BigHuntCostumeBattleInfo struct {
|
||||
WaveIndex int32
|
||||
CostumeId int32
|
||||
TotalDamage int64
|
||||
HitCount int32
|
||||
RandomDisplayValueType int32
|
||||
RandomDisplayValue int64
|
||||
}
|
||||
|
||||
type BattleState struct {
|
||||
@@ -838,6 +865,33 @@ type CageOrnamentRewardState struct {
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type TowerAccumulationRewardState struct {
|
||||
EventQuestChapterId int32
|
||||
LatestRewardReceiveQuestMissionClearCount int32
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type LabyrinthSeasonState struct {
|
||||
EventQuestChapterId int32
|
||||
LastJoinSeasonNumber int32
|
||||
LastSeasonRewardReceivedSeasonNumber int32
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
// LabyrinthStageKey is the composite key for UserState.LabyrinthStages.
|
||||
type LabyrinthStageKey struct {
|
||||
EventQuestChapterId int32
|
||||
StageOrder int32
|
||||
}
|
||||
|
||||
type LabyrinthStageState struct {
|
||||
EventQuestChapterId int32
|
||||
StageOrder int32
|
||||
IsReceivedStageClearReward bool
|
||||
AccumulationRewardReceivedQuestMissionCount int32
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type PartsState struct {
|
||||
UserPartsUuid string
|
||||
PartsId int32
|
||||
@@ -864,6 +918,12 @@ type PartsPresetState struct {
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type PartsPresetTagState struct {
|
||||
UserPartsPresetTagNumber int32
|
||||
Name string
|
||||
LatestVersion int64
|
||||
}
|
||||
|
||||
type PartsStatusSubKey struct {
|
||||
UserPartsUuid string
|
||||
StatusIndex int32
|
||||
|
||||
@@ -101,8 +101,9 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
add("IUserMainQuestMainFlowStatus")
|
||||
add("IUserMainQuestProgressStatus")
|
||||
add("IUserMainQuestReplayFlowStatus")
|
||||
}
|
||||
if !mapsEqualStruct(before.MainQuestSeasonRoutes, after.MainQuestSeasonRoutes) {
|
||||
// IUserMainQuestSeasonRoute is derived from MainQuest + Quests at projection
|
||||
// time (see proj_quest.go / questflow.QuestHandler.SeasonRoutesFor) — flag it
|
||||
// whenever either of those upstream inputs changes.
|
||||
add("IUserMainQuestSeasonRoute")
|
||||
}
|
||||
if before.EventQuest != after.EventQuest {
|
||||
@@ -163,6 +164,9 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
if !mapsEqualStruct(before.PartsPresets, after.PartsPresets) {
|
||||
add("IUserPartsPreset")
|
||||
}
|
||||
if !mapsEqualStruct(before.PartsPresetTags, after.PartsPresetTags) {
|
||||
add("IUserPartsPresetTag")
|
||||
}
|
||||
if !mapsEqualStruct(before.PartsStatusSubs, after.PartsStatusSubs) {
|
||||
add("IUserPartsStatusSub")
|
||||
}
|
||||
@@ -188,6 +192,9 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
if !mapsEqualStruct(before.Decks, after.Decks) {
|
||||
add("IUserDeck")
|
||||
}
|
||||
if !mapsEqualStruct(before.TripleDecks, after.TripleDecks) {
|
||||
add("IUserTripleDeck")
|
||||
}
|
||||
if !mapsEqualSliceValues(before.DeckSubWeapons, after.DeckSubWeapons) {
|
||||
add("IUserDeckSubWeaponGroup")
|
||||
}
|
||||
@@ -196,6 +203,7 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
}
|
||||
if !mapsEqualStruct(before.Quests, after.Quests) {
|
||||
add("IUserQuest")
|
||||
add("IUserMainQuestSeasonRoute")
|
||||
}
|
||||
if !mapsEqualStruct(before.QuestMissions, after.QuestMissions) {
|
||||
add("IUserQuestMission")
|
||||
@@ -257,6 +265,12 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
if !mapsEqualStruct(before.CageOrnamentRewards, after.CageOrnamentRewards) {
|
||||
add("IUserCageOrnamentReward")
|
||||
}
|
||||
if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) {
|
||||
add("IUserEventQuestTowerAccumulationReward")
|
||||
}
|
||||
if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) {
|
||||
add("IUserEventQuestLabyrinthStage")
|
||||
}
|
||||
|
||||
if !mapsEqualStruct(before.BigHuntMaxScores, after.BigHuntMaxScores) {
|
||||
add("IUserBigHuntMaxScore")
|
||||
@@ -275,10 +289,12 @@ func ChangedTables(before, after *store.UserState) []string {
|
||||
}
|
||||
|
||||
if !gimmickStateEqual(before.Gimmick, after.Gimmick) {
|
||||
if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) {
|
||||
if !mapsEqualStruct(before.Gimmick.Progress, after.Gimmick.Progress) ||
|
||||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
|
||||
add("IUserGimmick")
|
||||
}
|
||||
if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) {
|
||||
if !mapsEqualStruct(before.Gimmick.OrnamentProgress, after.Gimmick.OrnamentProgress) ||
|
||||
!mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
|
||||
add("IUserGimmickOrnamentProgress")
|
||||
}
|
||||
if !mapsEqualStruct(before.Gimmick.Sequences, after.Gimmick.Sequences) {
|
||||
@@ -358,6 +374,8 @@ func keyFieldsForTable(table string) []string {
|
||||
return []string{"userId", "userDeckCharacterUuid"}
|
||||
case "IUserDeck":
|
||||
return []string{"userId", "deckType", "userDeckNumber"}
|
||||
case "IUserTripleDeck":
|
||||
return []string{"userId", "deckType", "userDeckNumber"}
|
||||
case "IUserDeckSubWeaponGroup":
|
||||
return []string{"userId", "userDeckCharacterUuid", "sortOrder"}
|
||||
case "IUserDeckPartsGroup":
|
||||
@@ -414,8 +432,14 @@ func keyFieldsForTable(table string) []string {
|
||||
return []string{"userId", "partsGroupId"}
|
||||
case "IUserPartsPreset":
|
||||
return []string{"userId", "userPartsPresetNumber"}
|
||||
case "IUserPartsPresetTag":
|
||||
return []string{"userId", "userPartsPresetTagNumber"}
|
||||
case "IUserCageOrnamentReward":
|
||||
return []string{"userId", "cageOrnamentId"}
|
||||
case "IUserEventQuestTowerAccumulationReward":
|
||||
return []string{"userId", "eventQuestChapterId"}
|
||||
case "IUserEventQuestLabyrinthStage":
|
||||
return []string{"userId", "eventQuestChapterId", "stageOrder"}
|
||||
case "IUserAutoSaleSettingDetail":
|
||||
return []string{"userId", "possessionAutoSaleItemType"}
|
||||
case "IUserCharacterRebirth":
|
||||
|
||||
@@ -61,6 +61,7 @@ func init() {
|
||||
"bigHuntBossQuestId": int32(id),
|
||||
"dailyChallengeCount": st.DailyChallengeCount,
|
||||
"latestChallengeDatetime": st.LatestChallengeDatetime,
|
||||
"lastDailyRewardReceivedDayVersion": st.LastDailyRewardReceivedDayVersion,
|
||||
"latestVersion": st.LatestVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ func init() {
|
||||
s, _ := utils.EncodeJSONMaps(sortedDeckRecords(user)...)
|
||||
return s
|
||||
})
|
||||
register("IUserTripleDeck", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(sortedTripleDeckRecords(user)...)
|
||||
return s
|
||||
})
|
||||
register("IUserDeckCharacter", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(sortedDeckCharacterRecords(user)...)
|
||||
return s
|
||||
@@ -68,6 +72,35 @@ func sortedDeckRecords(user store.UserState) []map[string]any {
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedTripleDeckRecords(user store.UserState) []map[string]any {
|
||||
keys := make([]store.DeckKey, 0, len(user.TripleDecks))
|
||||
for key := range user.TripleDecks {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].DeckType != keys[j].DeckType {
|
||||
return keys[i].DeckType < keys[j].DeckType
|
||||
}
|
||||
return keys[i].UserDeckNumber < keys[j].UserDeckNumber
|
||||
})
|
||||
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
row := user.TripleDecks[key]
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"deckType": int32(row.DeckType),
|
||||
"userDeckNumber": row.UserDeckNumber,
|
||||
"name": row.Name,
|
||||
"deckNumber01": row.DeckNumber01,
|
||||
"deckNumber02": row.DeckNumber02,
|
||||
"deckNumber03": row.DeckNumber03,
|
||||
"latestVersion": row.LatestVersion,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedDeckCharacterRecords(user store.UserState) []map[string]any {
|
||||
keys := sortedStringKeys(user.DeckCharacters)
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
|
||||
@@ -2,11 +2,21 @@ package userdata
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
var gimmickOrnamentRefs = sync.OnceValue(masterdata.LoadGimmickOrnamentRefs)
|
||||
var gimmickSequenceChains = sync.OnceValue(masterdata.LoadGimmickSequenceChains)
|
||||
var hiddenSequenceSet = sync.OnceValue(masterdata.LoadHiddenGimmickSequenceIDs)
|
||||
var gimmickSequenceRanks = sync.OnceValue(masterdata.LoadGimmickSequenceRanks)
|
||||
var birdGimmicks = sync.OnceValue(masterdata.LoadBirdGimmickIDs)
|
||||
|
||||
const birdDefaultBaseDatetime int64 = 1577836800000 // 2020-01-01 00:00:00 UTC in ms
|
||||
|
||||
func init() {
|
||||
register("IUserGimmick", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(sortedGimmickRecords(user)...)
|
||||
@@ -26,9 +36,65 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func projectActiveChainOrnaments(
|
||||
user store.UserState,
|
||||
addKey func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef),
|
||||
sizeFn func() int,
|
||||
cap int,
|
||||
) {
|
||||
refs := gimmickOrnamentRefs()
|
||||
chains := gimmickSequenceChains()
|
||||
hiddenSeq := hiddenSequenceSet()
|
||||
|
||||
walkChain := func(seqKey store.GimmickSequenceKey) {
|
||||
chain := chains[seqKey.GimmickSequenceId]
|
||||
if len(chain) == 0 {
|
||||
chain = []int32{seqKey.GimmickSequenceId}
|
||||
}
|
||||
for _, seqId := range chain {
|
||||
for _, ref := range refs[seqId] {
|
||||
addKey(seqKey, seqId, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nonHidden []store.GimmickSequenceKey
|
||||
for seqKey := range user.Gimmick.Sequences {
|
||||
if hiddenSeq[seqKey.GimmickSequenceId] {
|
||||
walkChain(seqKey)
|
||||
} else {
|
||||
nonHidden = append(nonHidden, seqKey)
|
||||
}
|
||||
}
|
||||
for _, seqKey := range nonHidden {
|
||||
if sizeFn() >= cap {
|
||||
break
|
||||
}
|
||||
walkChain(seqKey)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedGimmickRecords(user store.UserState) []map[string]any {
|
||||
keys := make([]store.GimmickKey, 0, len(user.Gimmick.Progress))
|
||||
|
||||
keySet := make(map[store.GimmickKey]struct{})
|
||||
// Real progress rows (genuine user data) — always kept.
|
||||
for key := range user.Gimmick.Progress {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
projectActiveChainOrnaments(user,
|
||||
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
|
||||
keySet[store.GimmickKey{
|
||||
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
|
||||
GimmickSequenceId: seqId,
|
||||
GimmickId: ref.GimmickId,
|
||||
}] = struct{}{}
|
||||
},
|
||||
func() int { return len(keySet) },
|
||||
masterdata.MaxUserGimmickRows,
|
||||
)
|
||||
|
||||
keys := make([]store.GimmickKey, 0, len(keySet))
|
||||
for key := range keySet {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
@@ -37,57 +103,103 @@ func sortedGimmickRecords(user store.UserState) []map[string]any {
|
||||
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
row := user.Gimmick.Progress[key]
|
||||
isGimmickCleared := false
|
||||
startDatetime := user.GameStartDatetime
|
||||
latestVersion := user.GameStartDatetime
|
||||
if row, ok := user.Gimmick.Progress[key]; ok {
|
||||
isGimmickCleared = row.IsGimmickCleared
|
||||
startDatetime = row.StartDatetime
|
||||
latestVersion = row.LatestVersion
|
||||
}
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId,
|
||||
"gimmickSequenceId": row.Key.GimmickSequenceId,
|
||||
"gimmickId": row.Key.GimmickId,
|
||||
"isGimmickCleared": row.IsGimmickCleared,
|
||||
"startDatetime": row.StartDatetime,
|
||||
"latestVersion": row.LatestVersion,
|
||||
"gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
|
||||
"gimmickSequenceId": key.GimmickSequenceId,
|
||||
"gimmickId": key.GimmickId,
|
||||
"isGimmickCleared": isGimmickCleared,
|
||||
"startDatetime": startDatetime,
|
||||
"latestVersion": latestVersion,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedGimmickOrnamentProgressRecords(user store.UserState) []map[string]any {
|
||||
keys := make([]store.GimmickOrnamentKey, 0, len(user.Gimmick.OrnamentProgress))
|
||||
|
||||
keySet := make(map[store.GimmickOrnamentKey]struct{})
|
||||
// Real progress rows (genuine user data) — always kept.
|
||||
for key := range user.Gimmick.OrnamentProgress {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
projectActiveChainOrnaments(user,
|
||||
func(seqKey store.GimmickSequenceKey, seqId int32, ref masterdata.GimmickOrnamentRef) {
|
||||
keySet[store.GimmickOrnamentKey{
|
||||
GimmickSequenceScheduleId: seqKey.GimmickSequenceScheduleId,
|
||||
GimmickSequenceId: seqId,
|
||||
GimmickId: ref.GimmickId,
|
||||
GimmickOrnamentIndex: ref.OrnamentIndex,
|
||||
}] = struct{}{}
|
||||
},
|
||||
func() int { return len(keySet) },
|
||||
masterdata.MaxUserGimmickRows,
|
||||
)
|
||||
|
||||
keys := make([]store.GimmickOrnamentKey, 0, len(keySet))
|
||||
for key := range keySet {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return compareGimmickOrnamentKey(keys[i], keys[j]) < 0
|
||||
})
|
||||
|
||||
birdG := birdGimmicks()
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
row := user.Gimmick.OrnamentProgress[key]
|
||||
progressValueBit := int32(0)
|
||||
baseDatetime := user.GameStartDatetime
|
||||
latestVersion := user.GameStartDatetime
|
||||
if row, ok := user.Gimmick.OrnamentProgress[key]; ok {
|
||||
progressValueBit = row.ProgressValueBit
|
||||
baseDatetime = row.BaseDatetime
|
||||
latestVersion = row.LatestVersion
|
||||
} else if birdG[key.GimmickId] {
|
||||
baseDatetime = birdDefaultBaseDatetime
|
||||
}
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"gimmickSequenceScheduleId": row.Key.GimmickSequenceScheduleId,
|
||||
"gimmickSequenceId": row.Key.GimmickSequenceId,
|
||||
"gimmickId": row.Key.GimmickId,
|
||||
"gimmickOrnamentIndex": row.Key.GimmickOrnamentIndex,
|
||||
"progressValueBit": row.ProgressValueBit,
|
||||
"baseDatetime": row.BaseDatetime,
|
||||
"latestVersion": row.LatestVersion,
|
||||
"gimmickSequenceScheduleId": key.GimmickSequenceScheduleId,
|
||||
"gimmickSequenceId": key.GimmickSequenceId,
|
||||
"gimmickId": key.GimmickId,
|
||||
"gimmickOrnamentIndex": key.GimmickOrnamentIndex,
|
||||
"progressValueBit": progressValueBit,
|
||||
"baseDatetime": baseDatetime,
|
||||
"latestVersion": latestVersion,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedGimmickSequenceRecords(user store.UserState) []map[string]any {
|
||||
|
||||
ranks := gimmickSequenceRanks()
|
||||
|
||||
keys := make([]store.GimmickSequenceKey, 0, len(user.Gimmick.Sequences))
|
||||
for key := range user.Gimmick.Sequences {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
ri, rj := ranks[keys[i].GimmickSequenceId], ranks[keys[j].GimmickSequenceId]
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
if keys[i].GimmickSequenceScheduleId != keys[j].GimmickSequenceScheduleId {
|
||||
return keys[i].GimmickSequenceScheduleId < keys[j].GimmickSequenceScheduleId
|
||||
}
|
||||
return keys[i].GimmickSequenceId < keys[j].GimmickSequenceId
|
||||
})
|
||||
if len(keys) > masterdata.MaxUserGimmickRows {
|
||||
keys = keys[:masterdata.MaxUserGimmickRows]
|
||||
}
|
||||
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
|
||||
@@ -86,6 +86,10 @@ func init() {
|
||||
s, _ := utils.EncodeJSONMaps(sortedPartsPresetRecords(user)...)
|
||||
return s
|
||||
})
|
||||
register("IUserPartsPresetTag", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(sortedPartsPresetTagRecords(user)...)
|
||||
return s
|
||||
})
|
||||
register("IUserCostumeAwakenStatusUp", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(sortedCostumeAwakenStatusUpRecords(user)...)
|
||||
return s
|
||||
@@ -122,7 +126,6 @@ func init() {
|
||||
"IUserCostumeLevelBonusReleaseStatus",
|
||||
"IUserCostumeLotteryEffectAbility",
|
||||
"IUserCostumeLotteryEffectStatusUp",
|
||||
"IUserPartsPresetTag",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -496,6 +499,25 @@ func sortedPartsPresetRecords(user store.UserState) []map[string]any {
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedPartsPresetTagRecords(user store.UserState) []map[string]any {
|
||||
ids := make([]int, 0, len(user.PartsPresetTags))
|
||||
for id := range user.PartsPresetTags {
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
sort.Ints(ids)
|
||||
records := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
row := user.PartsPresetTags[int32(id)]
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"userPartsPresetTagNumber": row.UserPartsPresetTagNumber,
|
||||
"name": row.Name,
|
||||
"latestVersion": row.LatestVersion,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func sortedPartsStatusSubRecords(user store.UserState) []map[string]any {
|
||||
keys := make([]store.PartsStatusSubKey, 0, len(user.PartsStatusSubs))
|
||||
for k := range user.PartsStatusSubs {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package userdata
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
var labyrinthCatalog = sync.OnceValue(masterdata.LoadLabyrinthCatalog)
|
||||
|
||||
func init() {
|
||||
register("IUserEventQuestLabyrinthSeason", func(user store.UserState) string {
|
||||
chapters := labyrinthCatalog().ChaptersByOrder
|
||||
records := make([]map[string]any, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
if st, ok := user.LabyrinthSeasons[ch.EventQuestChapterId]; ok {
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"eventQuestChapterId": st.EventQuestChapterId,
|
||||
"lastJoinSeasonNumber": st.LastJoinSeasonNumber,
|
||||
"lastSeasonRewardReceivedSeasonNumber": st.LastSeasonRewardReceivedSeasonNumber,
|
||||
"latestVersion": st.LatestVersion,
|
||||
})
|
||||
continue
|
||||
}
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"eventQuestChapterId": ch.EventQuestChapterId,
|
||||
"lastJoinSeasonNumber": ch.LatestSeasonNumber,
|
||||
"lastSeasonRewardReceivedSeasonNumber": 0,
|
||||
"latestVersion": user.GameStartDatetime,
|
||||
})
|
||||
}
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
})
|
||||
|
||||
register("IUserEventQuestLabyrinthStage", func(user store.UserState) string {
|
||||
records := make([]map[string]any, 0)
|
||||
for _, ch := range labyrinthCatalog().ChaptersByOrder {
|
||||
for _, stageOrder := range ch.StageOrders {
|
||||
key := store.LabyrinthStageKey{
|
||||
EventQuestChapterId: ch.EventQuestChapterId,
|
||||
StageOrder: stageOrder,
|
||||
}
|
||||
if st, ok := user.LabyrinthStages[key]; ok {
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"eventQuestChapterId": st.EventQuestChapterId,
|
||||
"stageOrder": st.StageOrder,
|
||||
"isReceivedStageClearReward": st.IsReceivedStageClearReward,
|
||||
"accumulationRewardReceivedQuestMissionCount": st.AccumulationRewardReceivedQuestMissionCount,
|
||||
"latestVersion": st.LatestVersion,
|
||||
})
|
||||
continue
|
||||
}
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"eventQuestChapterId": ch.EventQuestChapterId,
|
||||
"stageOrder": stageOrder,
|
||||
"isReceivedStageClearReward": false,
|
||||
"accumulationRewardReceivedQuestMissionCount": 0,
|
||||
"latestVersion": user.GameStartDatetime,
|
||||
})
|
||||
}
|
||||
}
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package userdata
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
@@ -13,13 +14,30 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
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))
|
||||
for _, id := range ids {
|
||||
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{
|
||||
"userId": user.UserId,
|
||||
"questId": row.QuestId,
|
||||
"questStateType": row.QuestStateType,
|
||||
"questStateType": stateType,
|
||||
"isBattleOnly": row.IsBattleOnly,
|
||||
"latestStartDatetime": row.LatestStartDatetime,
|
||||
"clearCount": row.ClearCount,
|
||||
@@ -33,8 +51,26 @@ func sortedQuestRecords(user store.UserState) []map[string]any {
|
||||
}
|
||||
|
||||
func sortedQuestMissionRecords(user store.UserState) []map[string]any {
|
||||
keys := make([]store.QuestMissionKey, 0, len(user.QuestMissions))
|
||||
for key := range user.QuestMissions {
|
||||
questMissions := make(map[store.QuestMissionKey]store.UserQuestMissionState, len(user.QuestMissions))
|
||||
for key, qm := range user.QuestMissions {
|
||||
questMissions[key] = qm
|
||||
}
|
||||
// Force-clear hidden-story quest-missions so their report gimmicks unlock.
|
||||
for _, key := range hiddenStoryRequirements().QuestMissions {
|
||||
if existing, ok := questMissions[key]; ok && existing.IsClear {
|
||||
continue
|
||||
}
|
||||
questMissions[key] = store.UserQuestMissionState{
|
||||
QuestId: key.QuestId,
|
||||
QuestMissionId: key.QuestMissionId,
|
||||
IsClear: true,
|
||||
LatestClearDatetime: user.GameStartDatetime,
|
||||
LatestVersion: user.GameStartDatetime,
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]store.QuestMissionKey, 0, len(questMissions))
|
||||
for key := range questMissions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
@@ -45,7 +81,7 @@ func sortedQuestMissionRecords(user store.UserState) []map[string]any {
|
||||
})
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
row := user.QuestMissions[key]
|
||||
row := questMissions[key]
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"questId": row.QuestId,
|
||||
@@ -98,38 +134,29 @@ func init() {
|
||||
return s
|
||||
})
|
||||
register("IUserMainQuestSeasonRoute", func(user store.UserState) string {
|
||||
if len(user.MainQuestSeasonRoutes) == 0 {
|
||||
// Fallback to current (season, route) for legacy saves with no history.
|
||||
s, _ := utils.EncodeJSONMaps(map[string]any{
|
||||
"userId": user.UserId,
|
||||
"mainQuestSeasonId": user.MainQuest.MainQuestSeasonId,
|
||||
"mainQuestRouteId": user.MainQuest.CurrentMainQuestRouteId,
|
||||
"latestVersion": user.MainQuest.LatestVersion,
|
||||
})
|
||||
return s
|
||||
if questHandler == nil {
|
||||
return "[]"
|
||||
}
|
||||
keys := make([]store.SeasonRouteKey, 0, len(user.MainQuestSeasonRoutes))
|
||||
for k := range user.MainQuestSeasonRoutes {
|
||||
keys = append(keys, k)
|
||||
pairs := questHandler.SeasonRoutesFor(&user)
|
||||
if len(pairs) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].MainQuestSeasonId != keys[j].MainQuestSeasonId {
|
||||
return keys[i].MainQuestSeasonId < keys[j].MainQuestSeasonId
|
||||
seasons := make([]int32, 0, len(pairs))
|
||||
for s := range pairs {
|
||||
seasons = append(seasons, s)
|
||||
}
|
||||
return keys[i].MainQuestRouteId < keys[j].MainQuestRouteId
|
||||
})
|
||||
records := make([]map[string]any, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
e := user.MainQuestSeasonRoutes[k]
|
||||
sort.Slice(seasons, func(i, j int) bool { return seasons[i] < seasons[j] })
|
||||
records := make([]map[string]any, 0, len(seasons))
|
||||
for _, s := range seasons {
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"mainQuestSeasonId": e.MainQuestSeasonId,
|
||||
"mainQuestRouteId": e.MainQuestRouteId,
|
||||
"latestVersion": e.LatestVersion,
|
||||
"mainQuestSeasonId": s,
|
||||
"mainQuestRouteId": pairs[s],
|
||||
"latestVersion": user.MainQuest.LatestVersion,
|
||||
})
|
||||
}
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
out, _ := utils.EncodeJSONMaps(records...)
|
||||
return out
|
||||
})
|
||||
register("IUserEventQuestProgressStatus", func(user store.UserState) string {
|
||||
s, _ := utils.EncodeJSONMaps(map[string]any{
|
||||
@@ -219,11 +246,30 @@ func init() {
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
})
|
||||
register("IUserEventQuestTowerAccumulationReward", func(user store.UserState) string {
|
||||
if len(user.TowerAccumulationRewards) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
ids := make([]int, 0, len(user.TowerAccumulationRewards))
|
||||
for id := range user.TowerAccumulationRewards {
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
sort.Ints(ids)
|
||||
records := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
st := user.TowerAccumulationRewards[int32(id)]
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"eventQuestChapterId": st.EventQuestChapterId,
|
||||
"latestRewardReceiveQuestMissionClearCount": st.LatestRewardReceiveQuestMissionClearCount,
|
||||
"latestVersion": st.LatestVersion,
|
||||
})
|
||||
}
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
})
|
||||
registerStatic(
|
||||
"IUserEventQuestDailyGroupCompleteReward",
|
||||
"IUserEventQuestLabyrinthSeason",
|
||||
"IUserEventQuestLabyrinthStage",
|
||||
"IUserEventQuestTowerAccumulationReward",
|
||||
"IUserQuestReplayFlowRewardGroup",
|
||||
"IUserQuestAutoOrbit",
|
||||
"IUserQuestSceneChoice",
|
||||
|
||||
@@ -2,8 +2,11 @@ package userdata
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"lunar-tear/server/internal/gametime"
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/model"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
@@ -192,16 +195,35 @@ func sortedTutorialRecords(user store.UserState) []map[string]any {
|
||||
return records
|
||||
}
|
||||
|
||||
var hiddenStoryRequirements = sync.OnceValue(masterdata.LoadHiddenStoryRequirements)
|
||||
|
||||
func sortedMissionRecords(user store.UserState) []map[string]any {
|
||||
ids := make([]int, 0, len(user.Missions))
|
||||
for id := range user.Missions {
|
||||
missions := make(map[int32]store.UserMissionState, len(user.Missions))
|
||||
for id, m := range user.Missions {
|
||||
missions[id] = m
|
||||
}
|
||||
for _, missionId := range hiddenStoryRequirements().MissionIds {
|
||||
if existing, ok := missions[missionId]; ok && existing.MissionProgressStatusType >= int32(model.MissionProgressStatusTypeClear) {
|
||||
continue
|
||||
}
|
||||
missions[missionId] = store.UserMissionState{
|
||||
MissionId: missionId,
|
||||
StartDatetime: user.GameStartDatetime,
|
||||
MissionProgressStatusType: int32(model.MissionProgressStatusTypeClear),
|
||||
ClearDatetime: user.GameStartDatetime,
|
||||
LatestVersion: user.GameStartDatetime,
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(missions))
|
||||
for id := range missions {
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
records := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
row := user.Missions[int32(id)]
|
||||
row := missions[int32(id)]
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"missionId": row.MissionId,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package userdata
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"lunar-tear/server/internal/masterdata"
|
||||
"lunar-tear/server/internal/store"
|
||||
"lunar-tear/server/internal/utils"
|
||||
)
|
||||
|
||||
var webviewPanelMissionCatalog = sync.OnceValue(masterdata.LoadWebviewPanelMissionCatalog)
|
||||
|
||||
func init() {
|
||||
register("IUserWebviewPanelMission", func(user store.UserState) string {
|
||||
pageIds := webviewPanelMissionCatalog().PageIds
|
||||
records := make([]map[string]any, 0, len(pageIds))
|
||||
for _, pageId := range pageIds {
|
||||
records = append(records, map[string]any{
|
||||
"userId": user.UserId,
|
||||
"webviewPanelMissionPageId": pageId,
|
||||
"rewardReceiveDatetime": user.GameStartDatetime,
|
||||
"latestVersion": user.GameStartDatetime,
|
||||
})
|
||||
}
|
||||
s, _ := utils.EncodeJSONMaps(records...)
|
||||
return s
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package userdata
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"lunar-tear/server/internal/questflow"
|
||||
"lunar-tear/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,12 @@ type Projector func(user store.UserState) string
|
||||
|
||||
var projectors = make(map[string]Projector)
|
||||
|
||||
var questHandler *questflow.QuestHandler
|
||||
|
||||
func SetQuestHandler(h *questflow.QuestHandler) {
|
||||
questHandler = h
|
||||
}
|
||||
|
||||
func register(tableName string, fn Projector) {
|
||||
projectors[tableName] = fn
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func FullClientTableMap(user store.UserState) map[string]string {
|
||||
"IUserThought": projectTable("IUserThought", user),
|
||||
"IUserDeckCharacter": projectTable("IUserDeckCharacter", user),
|
||||
"IUserDeck": projectTable("IUserDeck", user),
|
||||
"IUserTripleDeck": projectTable("IUserTripleDeck", user),
|
||||
"IUserLogin": projectTable("IUserLogin", user),
|
||||
"IUserLoginBonus": projectTable("IUserLoginBonus", user),
|
||||
"IUserMission": projectTable("IUserMission", user),
|
||||
@@ -101,6 +102,7 @@ func FullClientTableMap(user store.UserState) map[string]string {
|
||||
"IUserBigHuntWeeklyStatus": projectTable("IUserBigHuntWeeklyStatus", user),
|
||||
"IUserFacebook": projectTable("IUserFacebook", user),
|
||||
"IUserApple": projectTable("IUserApple", user),
|
||||
"IUserWebviewPanelMission": projectTable("IUserWebviewPanelMission", user),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE user_main_quest ADD COLUMN saved_ctx_current_quest_flow_type INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE user_main_quest DROP COLUMN saved_ctx_current_quest_flow_type;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE user_big_hunt_statuses ADD COLUMN last_daily_reward_received_day_version INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE user_big_hunt_costume_battle_infos (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
wave_index INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL,
|
||||
costume_id INTEGER NOT NULL DEFAULT 0,
|
||||
total_damage INTEGER NOT NULL DEFAULT 0,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
random_display_value_type INTEGER NOT NULL DEFAULT 0,
|
||||
random_display_value INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, wave_index, sort_order)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_big_hunt_costume_battle_infos;
|
||||
ALTER TABLE user_big_hunt_statuses DROP COLUMN last_daily_reward_received_day_version;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE user_triple_decks (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
deck_type INTEGER NOT NULL,
|
||||
user_deck_number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
deck_number01 INTEGER NOT NULL DEFAULT 0,
|
||||
deck_number02 INTEGER NOT NULL DEFAULT 0,
|
||||
deck_number03 INTEGER NOT NULL DEFAULT 0,
|
||||
latest_version INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, deck_type, user_deck_number)
|
||||
);
|
||||
|
||||
-- Legacy BigHunt wave-decks have no preset wrapper rows and aren't reachable
|
||||
-- via the new IUserTripleDeck projection. Drop them so users start clean and
|
||||
-- repopulate via ReplaceTripleDeck from the client.
|
||||
DELETE FROM user_decks WHERE deck_type = 5;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_triple_decks;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE user_parts_preset_tags (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
user_parts_preset_tag_number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
latest_version INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, user_parts_preset_tag_number)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_parts_preset_tags;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE user_event_quest_tower_accumulation_rewards (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
event_quest_chapter_id INTEGER NOT NULL,
|
||||
latest_reward_receive_quest_mission_clear_count INTEGER NOT NULL DEFAULT 0,
|
||||
latest_version INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, event_quest_chapter_id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_event_quest_tower_accumulation_rewards;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE user_event_quest_labyrinth_seasons (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
event_quest_chapter_id INTEGER NOT NULL,
|
||||
last_join_season_number INTEGER NOT NULL DEFAULT 0,
|
||||
last_season_reward_received_season_number INTEGER NOT NULL DEFAULT 0,
|
||||
latest_version INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, event_quest_chapter_id)
|
||||
);
|
||||
|
||||
CREATE TABLE user_event_quest_labyrinth_stages (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id),
|
||||
event_quest_chapter_id INTEGER NOT NULL,
|
||||
stage_order INTEGER NOT NULL,
|
||||
is_received_stage_clear_reward INTEGER NOT NULL DEFAULT 0,
|
||||
accumulation_reward_received_quest_mission_count INTEGER NOT NULL DEFAULT 0,
|
||||
latest_version INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, event_quest_chapter_id, stage_order)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_event_quest_labyrinth_stages;
|
||||
DROP TABLE IF EXISTS user_event_quest_labyrinth_seasons;
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user