Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeeaa16f40 | ||
|
|
d3057a2fc5 | ||
|
|
78bd14318d | ||
|
|
a378f9904c | ||
|
|
23ab34ab72 | ||
|
|
3632d4b67d | ||
|
|
571a754e4e | ||
|
|
bdb13b890d | ||
|
|
7809d47d39 | ||
|
|
d72d7c2e80 | ||
|
|
60edc99780 | ||
|
|
ce49447b6e | ||
|
|
deabea60ca | ||
|
|
7a1f3431ae | ||
|
|
1954448219 |
No files matched your search
@@ -0,0 +1,103 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Rust, web and container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
panel/web/package-lock.json
|
||||
launcher/package-lock.json
|
||||
- name: Install Linux build dependencies
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check Rust
|
||||
run: |
|
||||
mkdir -p launcher/dist
|
||||
echo '<!doctype html>' > launcher/dist/index.html
|
||||
cargo fmt --all --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
- name: Check panel and launcher UI
|
||||
run: |
|
||||
for app in panel/web launcher; do
|
||||
(cd "$app" && npm ci --no-audit --no-fund && npm run check && npm run build)
|
||||
done
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITEA_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build panel container
|
||||
run: |
|
||||
bash scripts/ci/install-linux.sh image
|
||||
docker build -t scopenet-panel:ci .
|
||||
|
||||
launcher-windows:
|
||||
name: Windows launcher
|
||||
runs-on: scopenet
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Windows build dependencies
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
- run: npm run build
|
||||
working-directory: launcher
|
||||
- name: Build NSIS installer
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
run: |
|
||||
npx tauri build --bundles nsis
|
||||
if ($LASTEXITCODE -ne 0) { throw "NSIS build failed with exit code $LASTEXITCODE" }
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer-ci
|
||||
path: target/release/bundle/nsis/*-setup.exe
|
||||
if-no-files-found: error
|
||||
- run: cargo clippy -p scopenet-launcher --locked -- -D warnings
|
||||
- run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked
|
||||
|
||||
engine-online:
|
||||
name: Real Minecraft installs (manual)
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Linux build dependencies
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Server integrations
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths: ['integrations/**', '.gitea/workflows/integrations.yml']
|
||||
pull_request:
|
||||
paths: ['integrations/**', '.gitea/workflows/integrations.yml']
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
default: '0.5.0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
paper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Check Linux and Java dependencies
|
||||
run: bash scripts/ci/install-linux.sh java 21
|
||||
- name: Build Paper with Gradle 8.10.2
|
||||
run: |
|
||||
sed -i 's/gradle-9.6.0-bin.zip/gradle-8.10.2-bin.zip/' integrations/gradle/wrapper/gradle-wrapper.properties
|
||||
integrations/gradlew -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.5.0' }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-paper
|
||||
path: integrations/paper/build/libs/scopenet-paper-*.jar
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: integration-test-reports
|
||||
path: integrations/common/build/reports/tests/
|
||||
|
||||
mods:
|
||||
name: Fabric and Forge / ${{ matrix.mc.version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mc:
|
||||
- { version: '1.20.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '1.21.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '26.1.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.3', java: '25', gradle: '9.6.0' }
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.mc.java }}
|
||||
- name: Check Linux and Java dependencies
|
||||
run: bash scripts/ci/install-linux.sh java '${{ matrix.mc.java }}'
|
||||
- name: Build both loaders
|
||||
run: |
|
||||
sed -i 's/gradle-9.6.0-bin.zip/gradle-${{ matrix.mc.gradle }}-bin.zip/' integrations/gradle/wrapper/gradle-wrapper.properties
|
||||
for loader in fabric forge; do
|
||||
integrations/gradlew -p integrations -Ploader="$loader" -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.5.0' }}" :"$loader":build
|
||||
done
|
||||
- name: Audit Fabric startup hooks
|
||||
run: integrations/gradlew -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.5.0' }}" -PverifyMixins=true :fabric:runServer
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-mods-${{ matrix.mc.version }}
|
||||
path: |
|
||||
integrations/fabric/build/libs/scopenet-fabric-${{ matrix.mc.version }}-*.jar
|
||||
integrations/forge/build/libs/scopenet-forge-${{ matrix.mc.version }}-*.jar
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,208 @@
|
||||
name: Release
|
||||
|
||||
# Publish by pushing a tag to the gitea remote (`git push gitea v0.5.0`)
|
||||
# or from the Actions tab → Release → Run workflow (enter a version).
|
||||
#
|
||||
# Optional repository variables (Settings → Secrets and variables → Actions → Variables):
|
||||
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
|
||||
# LOCK_PANEL "1" to stop players from changing the server
|
||||
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g. 0.2.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
tag: ${{ steps.v.outputs.tag }}
|
||||
steps:
|
||||
- name: Verify Linux runner
|
||||
run: |
|
||||
test "$(uname -s)" = Linux
|
||||
command -v bash
|
||||
- id: v
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then v="$INPUT_VERSION"; else v="$GITHUB_REF_NAME"; fi
|
||||
v="${v#v}"
|
||||
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi
|
||||
echo "version=$v" >> "$GITEA_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITEA_OUTPUT"
|
||||
|
||||
launcher:
|
||||
name: Windows installer
|
||||
needs: version
|
||||
runs-on: scopenet
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Windows build dependencies
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release
|
||||
|
||||
- name: Stamp version
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
$cargo = Get-Content Cargo.toml -Raw
|
||||
$cargo = [regex]::new('(?m)^version = ".*"').Replace($cargo, 'version = "' + $env:VERSION + '"', 1)
|
||||
[IO.File]::WriteAllText((Join-Path (Get-Location) 'Cargo.toml'), $cargo)
|
||||
node -e "for(const f of ['launcher/src-tauri/tauri.conf.json','launcher/package.json']){const fs=require('fs');const c=JSON.parse(fs.readFileSync(f));c.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(c,null,2))}"
|
||||
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
|
||||
- name: Build installer
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
|
||||
run: |
|
||||
if ($env:LAUNCHER_NAME) {
|
||||
$config = node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))"
|
||||
npx tauri build --bundles nsis --config $config
|
||||
} else {
|
||||
npx tauri build --bundles nsis
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Installer build failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Collect installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force out | Out-Null
|
||||
Copy-Item target/release/bundle/nsis/*-setup.exe out/
|
||||
Get-ChildItem out
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: out/*-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
panel-image:
|
||||
name: Panel image (Gitea registry)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Image name
|
||||
run: echo "IMAGE=${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITEA_ENV"
|
||||
- name: Stamp version
|
||||
run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITEA_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITEA_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Check Linux container dependencies
|
||||
run: bash scripts/ci/install-linux.sh image
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.jadonsandbox.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
|
||||
server-integrations:
|
||||
needs: version
|
||||
uses: ./.gitea/workflows/integrations.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
release:
|
||||
name: Gitea release
|
||||
needs: [version, launcher, panel-image, server-integrations]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Linux release dependencies
|
||||
run: bash scripts/ci/install-linux.sh release
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: server-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release notes
|
||||
run: |
|
||||
IMAGE="${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||
cat > notes.md <<NOTES
|
||||
## Downloads
|
||||
|
||||
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||
|
||||
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
|
||||
|
||||
**Admin panel (Docker):**
|
||||
\`\`\`bash
|
||||
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||
\`\`\`
|
||||
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
|
||||
NOTES
|
||||
- name: Publish Gitea release and assets
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TAG: ${{ needs.version.outputs.tag }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v curl && command -v jq
|
||||
test -n "$TOKEN" || { echo 'GITEA_TOKEN is unavailable'; exit 1; }
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
body=$(jq -n --arg tag "$TAG" --arg notes "$(cat notes.md)" --arg sha "$GITHUB_SHA" --argjson pre "$(case "$VERSION" in *-*) echo true;; *) echo false;; esac)" '{tag_name:$tag,name:$tag,body:$notes,target_commitish:$sha,prerelease:$pre}')
|
||||
release=$(curl -fsS -H "Authorization: token $TOKEN" "$api/tags/$TAG") || \
|
||||
release=$(curl --fail-with-body -sS -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d "$body" "$api")
|
||||
id=$(printf '%s' "$release" | jq -er '.id')
|
||||
for file in dist/*-setup.exe dist/*.jar; do
|
||||
test -f "$file" || continue
|
||||
if printf '%s' "$release" | jq -e --arg name "$(basename "$file")" '.assets[]? | select(.name == $name)' >/dev/null; then continue; fi
|
||||
curl --fail-with-body -sS -H "Authorization: token $TOKEN" -F "attachment=@$file" "$api/$id/assets?name=$(basename "$file")" >/dev/null
|
||||
done
|
||||
@@ -1,100 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (fmt, clippy, tests)
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install Tauri system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
- name: Placeholder launcher frontend (tauri::generate_context needs it)
|
||||
run: mkdir -p launcher/dist && echo '<!doctype html>' > launcher/dist/index.html
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
- run: cargo test --workspace --locked
|
||||
|
||||
web:
|
||||
name: Web UIs (type-check + build)
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
app: [panel/web, launcher]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ matrix.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ matrix.app }}/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run check
|
||||
- run: npm run build
|
||||
|
||||
launcher-windows:
|
||||
name: Launcher (Windows build check)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: launcher/package-lock.json
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
- run: npm run build
|
||||
working-directory: launcher
|
||||
- run: cargo clippy -p scopenet-launcher --locked -- -D warnings
|
||||
- run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked
|
||||
|
||||
engine-online:
|
||||
name: Real installs (Mojang, Fabric, Quilt, Forge, NeoForge)
|
||||
runs-on: ubuntu-24.04
|
||||
# Talks to third-party servers; a hiccup there shouldn't block merges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install games (vanilla, legacy, Fabric, Quilt, Forge, NeoForge)
|
||||
run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture
|
||||
|
||||
docker:
|
||||
name: Docker image builds
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Server integrations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths: ['integrations/**', '.github/workflows/integrations.yml']
|
||||
pull_request:
|
||||
paths: ['integrations/**', '.github/workflows/integrations.yml']
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
default: '0.4.0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
paper:
|
||||
name: Paper and protocol tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.10.2'
|
||||
- run: gradle -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.4.0' }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-paper
|
||||
path: integrations/paper/build/libs/scopenet-paper-*.jar
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: integration-test-reports
|
||||
path: integrations/common/build/reports/tests/
|
||||
|
||||
mods:
|
||||
name: ${{ matrix.loader }} / ${{ matrix.mc.version }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
loader: [fabric, forge]
|
||||
mc:
|
||||
- { version: '1.20.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '1.21.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '26.1.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.3', java: '25', gradle: '9.6.0' }
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.mc.java }}
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: ${{ matrix.mc.gradle }}
|
||||
- run: gradle -p integrations -Ploader=${{ matrix.loader }} -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.4.0' }}" :${{ matrix.loader }}:build
|
||||
- name: Audit Fabric hooks during server bootstrap (no world startup)
|
||||
if: matrix.loader == 'fabric'
|
||||
run: gradle -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.4.0' }}" -PverifyMixins=true :fabric:runServer
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.loader }}-${{ matrix.mc.version }}
|
||||
path: integrations/${{ matrix.loader }}/build/libs/scopenet-${{ matrix.loader }}-${{ matrix.mc.version }}-*.jar
|
||||
if-no-files-found: error
|
||||
@@ -1,180 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`)
|
||||
# or from the Actions tab → Release → Run workflow (enter a version).
|
||||
#
|
||||
# Optional repository variables (Settings → Secrets and variables → Actions → Variables):
|
||||
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
|
||||
# LOCK_PANEL "1" to stop players from changing the server
|
||||
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g. 0.2.0)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
tag: ${{ steps.v.outputs.tag }}
|
||||
steps:
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}"; else v="${GITHUB_REF_NAME}"; fi
|
||||
v="${v#v}"
|
||||
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||
|
||||
launcher:
|
||||
name: Windows installer
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: launcher/package-lock.json
|
||||
|
||||
- name: Stamp version
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml
|
||||
node -e "const f='launcher/src-tauri/tauri.conf.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||
node -e "const f='launcher/package.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||
|
||||
- run: npm ci --no-audit --no-fund
|
||||
working-directory: launcher
|
||||
|
||||
- name: Build installer
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
env:
|
||||
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||
SCOPENET_REPO: ${{ github.repository }}
|
||||
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
|
||||
run: |
|
||||
if [ -n "$LAUNCHER_NAME" ]; then
|
||||
CONFIG=$(node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))")
|
||||
npx tauri build --bundles nsis --config "$CONFIG"
|
||||
else
|
||||
npx tauri build --bundles nsis
|
||||
fi
|
||||
|
||||
- name: Collect installer
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p out
|
||||
cp target/release/bundle/nsis/*-setup.exe out/
|
||||
ls -la out
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: out/*-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
panel-image:
|
||||
name: Panel image (GHCR)
|
||||
needs: version
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Image name (GHCR needs lowercase)
|
||||
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV"
|
||||
- name: Stamp version
|
||||
run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
server-integrations:
|
||||
needs: version
|
||||
uses: ./.github/workflows/integrations.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
release:
|
||||
name: GitHub release
|
||||
needs: [version, launcher, panel-image, server-integrations]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: server-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release notes
|
||||
run: |
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||
cat > notes.md <<NOTES
|
||||
## Downloads
|
||||
|
||||
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||
|
||||
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
|
||||
|
||||
**Admin panel (Docker):**
|
||||
\`\`\`bash
|
||||
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||
\`\`\`
|
||||
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
|
||||
NOTES
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: ${{ needs.version.outputs.tag }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
body_path: notes.md
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
|
||||
files: |
|
||||
dist/*-setup.exe
|
||||
dist/*.jar
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Runner setup
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux image and JAR dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Rust and system packages
|
||||
run: bash scripts/ci/install-linux.sh rust
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- run: bash scripts/ci/install-linux.sh java 21
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- run: bash scripts/ci/install-linux.sh java 25
|
||||
- id: docker
|
||||
run: |
|
||||
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: docker/setup-docker-action@v5
|
||||
if: steps.docker.outputs.ready != 'true'
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- run: bash scripts/ci/install-linux.sh image
|
||||
|
||||
windows:
|
||||
name: Windows installer dependencies
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install PowerShell, Rust and MSVC
|
||||
shell: powershell
|
||||
run: .\scripts\ci\install-windows.ps1
|
||||
@@ -30,7 +30,7 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
|
||||
- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
|
||||
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
|
||||
- **Settings** — memory with a recommendation for your PC, resolution, fullscreen, what happens on launch, Java path, GC presets (G1 / ZGC), JVM args, **Minecraft keybind editor** (applied to every instance), custom accent colour, glass/animation toggles, UI scale, storage manager, per-instance overrides.
|
||||
- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from GitHub Releases, single-instance window, `Ctrl+Enter` to play.
|
||||
- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from Gitea Releases, single-instance window, `Ctrl+Enter` to play.
|
||||
- **Branding from the panel** — name, logo, colours, font, corner radius, glass, image/video background, news feed, social links, custom CSS. No rebuild needed.
|
||||
|
||||
### Admin panel features
|
||||
@@ -52,8 +52,8 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
|
||||
### 1. Run the panel
|
||||
|
||||
```bash
|
||||
git clone https://github.com/scopeddlol/SCOPENET-MC.git
|
||||
cd SCOPENET-MC
|
||||
git clone https://gitea.jadonsandbox.com/blade/scopenet-mc.git
|
||||
cd scopenet-mc
|
||||
cp .env.example .env
|
||||
# edit .env → set ADMIN_PASSWORD and PUBLIC_URL for your domain
|
||||
docker compose config --quiet
|
||||
@@ -70,15 +70,21 @@ Open `http://your-server:8080`, sign in as `admin`, then:
|
||||
|
||||
### 2. Build your branded launcher
|
||||
|
||||
1. In GitHub → **Settings → Secrets and variables → Actions → Variables**, add:
|
||||
1. In Gitea → **Repository Settings → Actions → Variables**, add:
|
||||
- `PANEL_URL` = `https://panel.example.com` (players connect automatically)
|
||||
- `LOCK_PANEL` = `1` (optional: hide the "change server" option)
|
||||
- `LAUNCHER_NAME` = `MyServer Launcher` (optional)
|
||||
2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`.
|
||||
|
||||
The workflow builds the **Windows installer**, pushes the **panel image to GHCR** (`ghcr.io/<owner>/scopenet-mc-panel`, amd64 + arm64) and publishes a **GitHub release**. Installed launchers pick up new releases automatically.
|
||||
The workflow builds the **Windows installer**, pushes the **panel image to Gitea registry** (`gitea.jadonsandbox.com/blade/scopenet-mc-panel`, amd64 + arm64) and publishes a **Gitea release**. Installed launchers pick up new releases automatically.
|
||||
|
||||
> The GHCR package is private by default the first time — make it public under your GitHub profile → Packages if you want `docker pull` without logging in.
|
||||
> The Gitea registry package may be private at first — make it public under the repository owner’s Packages settings if you want `docker pull` without logging in.
|
||||
|
||||
### Gitea runner setup
|
||||
|
||||
Register a Linux host runner with the `ubuntu-latest` label and a Windows host runner with `scopenet:host` (selected by `runs-on: scopenet`). Linux builds the panel Docker image and server JARs; Windows builds the NSIS installer. CI also builds and uploads a Windows NSIS artifact so the installer can be checked without publishing a release. The workflows use [the Linux setup tool](scripts/ci/install-linux.sh) and [the Windows setup tool](scripts/ci/install-windows.ps1) to install or check job dependencies. Linux Node 22 and Java 21/25 are downloaded by `setup-node` and `setup-java`; the Linux tool installs Rust, Tauri system packages, and release utilities. The Windows tool downloads PowerShell 7.6.6 and Node 22 if unavailable, then installs Rust and Microsoft C++ Build Tools. Docker and Buildx are set up by Docker's Actions. The Linux runner must allow a Docker daemon to start or provide one, and system package installation needs root or passwordless sudo. Enable Actions for the repository and allow its job token to write releases and packages. See [Gitea's runner labels](https://docs.gitea.com/runner/labels/) and [token permissions](https://docs.gitea.com/usage/actions/token-permissions/).
|
||||
|
||||
The setup tools also run directly on matching hosts: `bash scripts/ci/install-linux.sh rust` prepares Rust and Tauri builds, `bash scripts/ci/install-linux.sh image` checks Docker, `bash scripts/ci/install-linux.sh java 21` checks a Java integration runner, and `powershell -File scripts/ci/install-windows.ps1` prepares the Windows installer runner. Gitea uses them in CI and release jobs; GitHub provides a manual **Runner setup** workflow for both platforms. Run the Linux `rust` mode as root or with passwordless sudo; the Windows script may need administrator rights to install Microsoft C++ Build Tools.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -92,7 +98,7 @@ launcher/ Desktop launcher UI (Svelte 5)
|
||||
launcher/src-tauri Desktop launcher shell (Tauri 2)
|
||||
Dockerfile Panel image (static binary on scratch)
|
||||
docker-compose.yml Panel deployment
|
||||
.github/workflows CI + release (installer, GitHub release, GHCR image)
|
||||
.gitea/workflows CI + release (installer, Gitea release, Gitea registry image)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+73
-17
@@ -7,18 +7,67 @@ use std::path::Path;
|
||||
/// Player-owned vanilla preferences. Pack-managed options.txt may be replaced
|
||||
/// during sync, so these are captured separately and merged back afterward.
|
||||
const VANILLA_KEYS: &[&str] = &[
|
||||
"fov", "gamma", "graphicsMode", "fancyGraphics", "renderDistance", "simulationDistance",
|
||||
"maxFps", "enableVsync", "fullscreen", "fullscreenResolution", "guiScale", "particles",
|
||||
"clouds", "renderClouds", "entityShadows", "entityDistanceScaling", "biomeBlendRadius",
|
||||
"mipmapLevels", "ao", "ambientOcclusion", "bobView", "attackIndicator", "autoJump",
|
||||
"mouseSensitivity", "invertYMouse", "discrete_mouse_scroll", "mouseWheelSensitivity",
|
||||
"rawMouseInput", "toggleCrouch", "toggleSprint", "sneakToggled", "sprintToggled",
|
||||
"screenEffectScale", "fovEffectScale", "darknessEffectScale", "narrator", "subtitles",
|
||||
"chatVisibility", "chatColors", "chatLinks", "chatLinksPrompt", "chatOpacity", "textBackgroundOpacity",
|
||||
"backgroundForChatOnly", "chatScale", "chatWidth", "chatHeightFocused", "chatHeightUnfocused",
|
||||
"mainHand", "lang", "forceUnicodeFont", "tutorialStep", "hideBundleTutorial",
|
||||
"soundDevice", "directionalAudio", "musicFrequency", "notificationDisplayTime",
|
||||
"joinedFirstServer", "pauseOnLostFocus", "reducedDebugInfo", "showSubtitles",
|
||||
"fov",
|
||||
"gamma",
|
||||
"graphicsMode",
|
||||
"fancyGraphics",
|
||||
"renderDistance",
|
||||
"simulationDistance",
|
||||
"maxFps",
|
||||
"enableVsync",
|
||||
"fullscreen",
|
||||
"fullscreenResolution",
|
||||
"guiScale",
|
||||
"particles",
|
||||
"clouds",
|
||||
"renderClouds",
|
||||
"entityShadows",
|
||||
"entityDistanceScaling",
|
||||
"biomeBlendRadius",
|
||||
"mipmapLevels",
|
||||
"ao",
|
||||
"ambientOcclusion",
|
||||
"bobView",
|
||||
"attackIndicator",
|
||||
"autoJump",
|
||||
"mouseSensitivity",
|
||||
"invertYMouse",
|
||||
"discrete_mouse_scroll",
|
||||
"mouseWheelSensitivity",
|
||||
"rawMouseInput",
|
||||
"toggleCrouch",
|
||||
"toggleSprint",
|
||||
"sneakToggled",
|
||||
"sprintToggled",
|
||||
"screenEffectScale",
|
||||
"fovEffectScale",
|
||||
"darknessEffectScale",
|
||||
"narrator",
|
||||
"subtitles",
|
||||
"chatVisibility",
|
||||
"chatColors",
|
||||
"chatLinks",
|
||||
"chatLinksPrompt",
|
||||
"chatOpacity",
|
||||
"textBackgroundOpacity",
|
||||
"backgroundForChatOnly",
|
||||
"chatScale",
|
||||
"chatWidth",
|
||||
"chatHeightFocused",
|
||||
"chatHeightUnfocused",
|
||||
"mainHand",
|
||||
"lang",
|
||||
"forceUnicodeFont",
|
||||
"tutorialStep",
|
||||
"hideBundleTutorial",
|
||||
"soundDevice",
|
||||
"directionalAudio",
|
||||
"musicFrequency",
|
||||
"notificationDisplayTime",
|
||||
"joinedFirstServer",
|
||||
"pauseOnLostFocus",
|
||||
"reducedDebugInfo",
|
||||
"showSubtitles",
|
||||
];
|
||||
|
||||
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
|
||||
@@ -28,10 +77,13 @@ pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, Stri
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(contents.lines().filter_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
|
||||
}).collect())
|
||||
Ok(contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
|
||||
@@ -85,7 +137,11 @@ mod tests {
|
||||
#[test]
|
||||
fn captures_only_vanilla_preferences() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("options.txt"), "fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("options.txt"),
|
||||
"fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n",
|
||||
)
|
||||
.unwrap();
|
||||
let prefs = read_vanilla_preferences(dir.path()).unwrap();
|
||||
assert_eq!(prefs.get("fov").map(String::as_str), Some("0.8"));
|
||||
assert_eq!(prefs.get("autoJump").map(String::as_str), Some("false"));
|
||||
|
||||
@@ -475,7 +475,7 @@ pub struct Quest {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub period: String, // "daily", "weekly"
|
||||
pub period: String, // "daily", "weekly"
|
||||
pub category: String, // "mining", "combat", "building", "farming", "social", "exploration"
|
||||
pub target_stat: String,
|
||||
pub target_count: i64,
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ Everything on this page is pushed to launchers when they start or refresh — no
|
||||
|
||||
## Releasing the launcher
|
||||
|
||||
See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer.
|
||||
See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks Gitea Releases on start and offers the new installer.
|
||||
|
||||
**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`.
|
||||
|
||||
|
||||
+4
-4
@@ -17,15 +17,15 @@ It is on by default for every server. To change it:
|
||||
1. In the admin panel open **Servers → your server → Settings** and use the **SCOPENET Map** switch.
|
||||
2. In the plugin/mod config: `map.enabled` (Paper `config.yml`) or `map.enabled=true` (Fabric `scopenet.properties`). The old `livemap.enabled` key is still read.
|
||||
|
||||
The first tiles appear a minute or two after the server starts. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority.
|
||||
The first tiles should appear after the server starts; the time depends on region size and storage speed. The server draws the regions closest to players first, then works through the rest quietly in the background at low priority.
|
||||
|
||||
## In the launcher
|
||||
|
||||
Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players, **Online** to jump to a player, the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles.
|
||||
Players with a linked server get a **Live Map** button on the Home screen. Pan by dragging, zoom with the wheel or the + and − buttons, double click to zoom in. Use the toggles to hide claims, places or players. Open **Online** to choose individual players and whether their skin heads appear, or to jump to a player; use the crosshair to find yourself and the star to go to spawn. The **Guilds → Territory** claim painter uses the same tiles.
|
||||
|
||||
## In the admin panel
|
||||
|
||||
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
|
||||
**Servers → your server** shows tile counts, the game server’s current rendering stage, region files found, queued and uploaded tiles, recent errors, and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
|
||||
|
||||
## On your public landing page
|
||||
|
||||
@@ -43,6 +43,6 @@ Disk use is small: a world of 500 explored regions is roughly 50–150 MB on the
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Drawing the world…" never ends.** The server has to be connected to the panel with a valid token, have `map.enabled` on, and have its world folder readable by the Minecraft process. The server log says `SCOPENET Map is on` when it starts drawing.
|
||||
- **"Drawing the world…" never ends.** Run `/map` on Paper or `/scopenet map` on Fabric to see the server-side stage. Check **Servers → your server** for the same progress and the last error. If no region files are found, check the world folder; if uploads fail, check the panel URL and token. The server log says `SCOPENET Map is on` when it starts drawing.
|
||||
- **A flat grey or missing area.** The chunk was never generated, or is saved in an older world format (before Minecraft 1.18).
|
||||
- **Colours look off for a modded block.** Unknown blocks get a colour from their name; vanilla blocks use their texture colour.
|
||||
@@ -175,6 +175,11 @@ public final class Integration implements AutoCloseable {
|
||||
if (current != null) current.requestScan();
|
||||
}
|
||||
|
||||
public String mapStatus() {
|
||||
net.scopenet.worldmap.WorldMapSync current = map;
|
||||
return current == null ? "Map is not started. Check map.enabled in the server config and restart." : current.status();
|
||||
}
|
||||
|
||||
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
|
||||
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ public final class TileStore {
|
||||
|
||||
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
|
||||
|
||||
public boolean exists(Key k) { return Files.isRegularFile(file(k)); }
|
||||
|
||||
/** The tile's pixels, or null if it was never rendered. */
|
||||
public synchronized int[] load(Key k) {
|
||||
int[] cached = memory.get(k);
|
||||
|
||||
@@ -68,6 +68,14 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
private long overlaySentAt;
|
||||
private String lastPlayersSent = "";
|
||||
private long playersSentAt;
|
||||
private volatile String stage = "starting";
|
||||
private volatile String lastError = "";
|
||||
private volatile String lastRegion = "";
|
||||
private volatile long regionsRendered;
|
||||
private volatile long tilesUploaded;
|
||||
private volatile long lastUploadAt;
|
||||
private volatile int regionFiles;
|
||||
private volatile int queuedTiles;
|
||||
|
||||
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
|
||||
this.client = client;
|
||||
@@ -90,6 +98,15 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
players = List.copyOf(next);
|
||||
}
|
||||
|
||||
/** Safe to read on the game thread for /map and /scopenet map. */
|
||||
public String status() {
|
||||
return "Map " + (enabled ? stage : "waiting for panel/map setting") + "; world=" + worldRoot
|
||||
+ "; region files=" + regionFiles + "; rendered=" + regionsRendered
|
||||
+ "; queued tiles=" + queuedTiles + "; uploaded=" + tilesUploaded
|
||||
+ (lastRegion.isEmpty() ? "" : "; last region=" + lastRegion)
|
||||
+ (lastError.isEmpty() ? "" : "; error=" + lastError);
|
||||
}
|
||||
|
||||
/** Where the claims and pins come from ({@code {"claims":[...],"pins":[...]}}); asked every few seconds, sent when it changes. */
|
||||
public void setOverlay(java.util.function.Supplier<String> source) {
|
||||
overlay = source;
|
||||
@@ -126,12 +143,17 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403 || e.status == 404) enabled = false;
|
||||
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
|
||||
lastError = "panel HTTP " + e.status + ": " + e.getMessage();
|
||||
stage = "upload failed";
|
||||
pause = 30_000;
|
||||
} catch (Exception e) {
|
||||
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
stage = "failed";
|
||||
pause = 60_000;
|
||||
}
|
||||
if (stateDirty) saveState();
|
||||
queuedTiles = state.dirty.size();
|
||||
try {
|
||||
synchronized (wake) {
|
||||
if (!wakeRequested) wake.wait(pause);
|
||||
@@ -163,8 +185,12 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
}
|
||||
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
|
||||
enabled = on;
|
||||
stage = on ? "scanning regions" : "disabled on panel";
|
||||
if (on) lastError = "";
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
enabled = false; // an older panel without the map
|
||||
stage = "panel configuration failed";
|
||||
lastError = "panel HTTP " + e.status + ": " + e.getMessage();
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
@@ -172,6 +198,7 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
|
||||
private boolean cycle() throws IOException, InterruptedException {
|
||||
if (!Files.isDirectory(worldRoot)) {
|
||||
stage = "world folder missing";
|
||||
if (!warnedMissing) {
|
||||
warnedMissing = true;
|
||||
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
|
||||
@@ -181,12 +208,15 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
long now = System.currentTimeMillis();
|
||||
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
|
||||
List<Stale> stale = new ArrayList<>();
|
||||
int filesFound = 0;
|
||||
int completeFiles = 0;
|
||||
List<Player> online = players;
|
||||
for (Dim dim : DIMS) {
|
||||
Path dir = regionDir(dim);
|
||||
if (dir == null) continue;
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
|
||||
for (Path file : stream) {
|
||||
filesFound++;
|
||||
String[] parts = file.getFileName().toString().split("\\.");
|
||||
int rx, rz;
|
||||
try {
|
||||
@@ -195,13 +225,14 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
} catch (RuntimeException e) {
|
||||
continue;
|
||||
}
|
||||
if (Files.size(file) <= 8192) continue;
|
||||
completeFiles++;
|
||||
long mtime = Files.getLastModifiedTime(file).toMillis();
|
||||
String key = dim.dir() + "/" + rx + "." + rz;
|
||||
Long done = state.regions.get(key);
|
||||
if (done != null && mtime <= done) continue;
|
||||
Long last = renderedAt.get(key);
|
||||
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
|
||||
if (Files.size(file) < 8192) continue;
|
||||
if (last != null && now - last < RERENDER_GAP_MS) continue;
|
||||
double distance = Double.MAX_VALUE;
|
||||
for (Player p : online) {
|
||||
if (!dim.id().equals(p.dimension())) continue;
|
||||
@@ -212,27 +243,41 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
}
|
||||
regionFiles = filesFound;
|
||||
if (filesFound == 0) stage = "no region files found";
|
||||
else if (completeFiles == 0) stage = "region files have no saved chunks yet";
|
||||
boolean worked = false;
|
||||
if (!stale.isEmpty()) {
|
||||
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
|
||||
Stale next = stale.get(0);
|
||||
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
|
||||
worked = true;
|
||||
worked = renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
|
||||
}
|
||||
if (flushUploads(stale.size() <= 1)) worked = true;
|
||||
if (filesFound > 0 && stale.isEmpty() && state.dirty.isEmpty() && state.regions.size() >= filesFound) stage = "caught up";
|
||||
return worked;
|
||||
}
|
||||
|
||||
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
|
||||
private boolean renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
|
||||
stage = "rendering " + key;
|
||||
lastRegion = key;
|
||||
int[][] tiles;
|
||||
try {
|
||||
tiles = RegionRenderer.render(file, dim.kind());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// A region being written right now; try again after the gap.
|
||||
renderedAt.put(key, System.currentTimeMillis());
|
||||
return;
|
||||
lastError = "render " + key + ": " + e.getMessage();
|
||||
return false;
|
||||
}
|
||||
renderedAt.put(key, System.currentTimeMillis());
|
||||
if (tiles == null) {
|
||||
// A region may still be generating or use a format we cannot read. Never mark it complete.
|
||||
lastError = "No readable full chunks in " + key + "; will retry";
|
||||
stage = "waiting for readable chunks";
|
||||
return false;
|
||||
}
|
||||
lastError = "";
|
||||
regionsRendered++;
|
||||
List<TileStore.Key> changed = new ArrayList<>();
|
||||
if (tiles != null) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -261,6 +306,7 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
}
|
||||
state.regions.put(key, mtime);
|
||||
stateDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
|
||||
@@ -297,6 +343,9 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
}
|
||||
if (inBatch.isEmpty()) continue;
|
||||
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
|
||||
tilesUploaded += inBatch.size();
|
||||
lastUploadAt = System.currentTimeMillis();
|
||||
stage = "uploading tiles";
|
||||
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
|
||||
stateDirty = true;
|
||||
sentAny = true;
|
||||
@@ -344,12 +393,34 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
lastOverlaySent = ov;
|
||||
overlaySentAt = now;
|
||||
}
|
||||
if (now - diagnosticsSentAt >= 10_000) {
|
||||
JsonObject diagnostic = new JsonObject();
|
||||
diagnostic.addProperty("stage", stage);
|
||||
diagnostic.addProperty("error", lastError);
|
||||
diagnostic.addProperty("world_root", worldRoot.toString());
|
||||
diagnostic.addProperty("region_files", regionFiles);
|
||||
diagnostic.addProperty("regions_rendered", regionsRendered);
|
||||
diagnostic.addProperty("queued_tiles", queuedTiles);
|
||||
diagnostic.addProperty("tiles_uploaded", tilesUploaded);
|
||||
diagnostic.addProperty("last_region", lastRegion);
|
||||
diagnostic.addProperty("last_upload_ms", lastUploadAt);
|
||||
try {
|
||||
client.post("map/diagnostics", diagnostic);
|
||||
diagnosticsSentAt = now;
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
// Diagnostics are optional while an older panel is being upgraded.
|
||||
diagnosticsSentAt = now;
|
||||
if (e.status != 404) lastError = "diagnostics HTTP " + e.status + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
} catch (PanelClient.HttpFailure e) {
|
||||
if (e.status == 403 || e.status == 404) enabled = false;
|
||||
} catch (Exception ignored) {
|
||||
// the next tick retries
|
||||
lastError = "live upload HTTP " + e.status + ": " + e.getMessage();
|
||||
} catch (Exception e) {
|
||||
lastError = "live upload: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
private long diagnosticsSentAt;
|
||||
|
||||
// ---- world layout ----
|
||||
|
||||
@@ -378,7 +449,23 @@ public final class WorldMapSync implements AutoCloseable {
|
||||
Path f = cacheDir.resolve("state.json");
|
||||
if (Files.isRegularFile(f)) {
|
||||
State s = gson.fromJson(Files.readString(f), State.class);
|
||||
if (s != null && s.regions != null && s.dirty != null) state = s;
|
||||
if (s != null && s.regions != null && s.dirty != null) {
|
||||
state = s;
|
||||
// Older builds could mark an unreadable region complete without producing a tile.
|
||||
// Clear those entries on upgrade so a live server can recover without an admin reset.
|
||||
state.regions.keySet().removeIf(key -> {
|
||||
String[] parts = key.split("[/ .]");
|
||||
if (parts.length != 3) return true;
|
||||
try {
|
||||
int rx = Integer.parseInt(parts[1]), rz = Integer.parseInt(parts[2]);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (store.exists(new TileStore.Key(parts[0], 0, rx * 2 + (i & 1), rz * 2 + (i >> 1)))) return false;
|
||||
}
|
||||
} catch (NumberFormatException e) { return true; }
|
||||
return true;
|
||||
});
|
||||
stateDirty = true;
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
state = new State();
|
||||
|
||||
+7
-1
@@ -19,7 +19,7 @@ final class ScopenetCommand implements CoreCommand {
|
||||
@Override public String permission() { return "scopenet.command.scopenet"; }
|
||||
|
||||
@Override public List<String> complete(CorePlayer player, String[] args) {
|
||||
return args.length <= 1 ? List.of("help", "status", "reload") : List.of();
|
||||
return args.length <= 1 ? List.of("help", "status", "map", "reload") : List.of();
|
||||
}
|
||||
|
||||
@Override public void run(CorePlayer p, String[] args) {
|
||||
@@ -33,6 +33,11 @@ final class ScopenetCommand implements CoreCommand {
|
||||
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
|
||||
features.sendStatus(p);
|
||||
}
|
||||
case "map" -> {
|
||||
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to view map status."); return; }
|
||||
net.scopenet.integration.Integration integration = net.scopenet.minecraft.Bridge.integration();
|
||||
p.send(Format.GOLD + "SCOPENET Map: " + Format.WHITE + (integration == null ? "Integration unavailable" : integration.mapStatus()));
|
||||
}
|
||||
case "reload" -> {
|
||||
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
|
||||
features.reload(p);
|
||||
@@ -51,5 +56,6 @@ final class ScopenetCommand implements CoreCommand {
|
||||
p.send(Format.YELLOW + " /" + c.name() + aliases);
|
||||
}
|
||||
p.send(Format.GRAY + "Open the launcher's Command guide for what each one does.");
|
||||
p.send(Format.YELLOW + " /scopenet map" + Format.GRAY + " - Map rendering and upload progress");
|
||||
}
|
||||
}
|
||||
File mode changed.
@@ -87,6 +87,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
// Register /scopenet admin & help command
|
||||
ScopenetCommandHandler scopenetCmd = new ScopenetCommandHandler(this, integration, () -> compat);
|
||||
bindCommand("scopenet", scopenetCmd);
|
||||
bindCommand("map", scopenetCmd);
|
||||
|
||||
// Register Essentials commands & GUI
|
||||
{
|
||||
|
||||
+14
@@ -22,6 +22,10 @@ public final class ScopenetCommandHandler implements CommandExecutor {
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (command.getName().equalsIgnoreCase("map")) {
|
||||
sendMap(sender);
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0 || args[0].equalsIgnoreCase("help")) {
|
||||
if (!sender.hasPermission("scopenet.command.scopenet.help")) {
|
||||
sender.sendMessage(ChatColor.RED + "You do not have permission to view SCOPENET help.");
|
||||
@@ -34,6 +38,7 @@ public final class ScopenetCommandHandler implements CommandExecutor {
|
||||
String sub = args[0].toLowerCase();
|
||||
switch (sub) {
|
||||
case "status" -> sendStatus(sender);
|
||||
case "map" -> sendMap(sender);
|
||||
case "reload" -> handleReload(sender);
|
||||
default -> {
|
||||
sender.sendMessage(ChatColor.RED + "Unknown command. Use " + ChatColor.YELLOW + "/scopenet help");
|
||||
@@ -92,6 +97,7 @@ public final class ScopenetCommandHandler implements CommandExecutor {
|
||||
if (sender.hasPermission("scopenet.admin")) {
|
||||
sender.sendMessage(ChatColor.LIGHT_PURPLE + "[Admin Commands]");
|
||||
sender.sendMessage(ChatColor.YELLOW + " /scopenet status" + ChatColor.GRAY + " - Integration health and module statuses");
|
||||
sender.sendMessage(ChatColor.YELLOW + " /map" + ChatColor.GRAY + " - Map rendering and upload progress");
|
||||
sender.sendMessage(ChatColor.YELLOW + " /scopenet reload" + ChatColor.GRAY + " - Reload config and feature flags");
|
||||
}
|
||||
}
|
||||
@@ -137,6 +143,14 @@ public final class ScopenetCommandHandler implements CommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMap(CommandSender sender) {
|
||||
if (!sender.hasPermission("scopenet.command.scopenet.status")) {
|
||||
sender.sendMessage(ChatColor.RED + "You do not have permission to view map status.");
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(ChatColor.GOLD + "SCOPENET Map: " + ChatColor.WHITE + integration.mapStatus());
|
||||
}
|
||||
|
||||
private String formatFlag(boolean enabled) {
|
||||
return enabled ? ChatColor.GREEN + "ENABLED" : ChatColor.RED + "DISABLED";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ commands:
|
||||
permission: scopenet.command.scopenet
|
||||
description: SCOPENET server commands, status, and help
|
||||
aliases: [sn]
|
||||
map:
|
||||
permission: scopenet.command.scopenet.status
|
||||
description: Show live map rendering and upload progress
|
||||
spawn:
|
||||
permission: scopenet.command.spawn
|
||||
description: Teleport to the server spawn point
|
||||
|
||||
@@ -155,8 +155,7 @@ pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f6
|
||||
if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() {
|
||||
return Err("FOV must be between 30 and 110 degrees".into());
|
||||
}
|
||||
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default()
|
||||
.insert("fov".into(), format!("{fov:.3}"));
|
||||
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default().insert("fov".into(), format!("{fov:.3}"));
|
||||
state.save_settings().map_err(aerr)
|
||||
}
|
||||
|
||||
@@ -763,14 +762,18 @@ async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String
|
||||
pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?;
|
||||
let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not request to join guild").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not load guild requests").await);
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
@@ -778,44 +781,73 @@ pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: Strin
|
||||
pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?;
|
||||
let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not review guild request").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
|
||||
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); }
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not load guild roles").await);
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); }
|
||||
let resp =
|
||||
account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not create guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")).await?.json(&serde_json::json!({"role":role})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); }
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role"))
|
||||
.await?
|
||||
.json(&serde_json::json!({"role":role}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not assign guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); }
|
||||
let resp =
|
||||
account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not delete guild role").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_guild(state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")).await?
|
||||
pub async fn update_guild(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
description: String,
|
||||
motd: String,
|
||||
icon_url: String,
|
||||
banner_url: String,
|
||||
) -> Res<()> {
|
||||
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}"))
|
||||
.await?
|
||||
.json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url}))
|
||||
.send().await.map_err(err)?;
|
||||
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); }
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(guild_response_error(resp, "could not update guild").await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -949,7 +981,13 @@ pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, serv
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
|
||||
pub async fn transfer_guild_wallet(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
server_id: i64,
|
||||
amount: f64,
|
||||
withdraw: bool,
|
||||
) -> Res<serde_json::Value> {
|
||||
let action = if withdraw { "withdraw" } else { "deposit" };
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
|
||||
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
|
||||
@@ -1203,7 +1241,6 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
|
||||
Ok(body["liked"].as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map actions and guild invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -228,7 +228,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
state.game.lock().unwrap().push(RunningGame { run_id: run_id.clone(), instance_id: instance_id.clone(), kill: Some(kill_tx) });
|
||||
app.emit("launch://state", StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None }).ok();
|
||||
app.emit(
|
||||
"launch://state",
|
||||
StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None },
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match settings.after_launch.as_str() {
|
||||
@@ -337,8 +341,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
}
|
||||
}
|
||||
}
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report })
|
||||
.ok();
|
||||
app2.emit(
|
||||
"game://exit",
|
||||
ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report },
|
||||
)
|
||||
.ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@@ -6,17 +6,17 @@ use scopenet_shared::LauncherManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||
/// Values baked in at build time by CI (see `.gitea/workflows/release.yml`).
|
||||
pub mod build {
|
||||
/// Default panel URL for this build.
|
||||
pub const PANEL_URL: Option<&str> = match option_env!("SCOPENET_PANEL_URL") {
|
||||
Some(u) if !u.is_empty() => Some(u),
|
||||
_ => Some("https://scopenetmcpanel.scopedd.lol"),
|
||||
};
|
||||
/// `owner/repo` used to check GitHub releases for updates.
|
||||
/// Full Gitea repository URL used to check releases for updates.
|
||||
pub const REPO: Option<&str> = match option_env!("SCOPENET_REPO") {
|
||||
Some(r) if !r.is_empty() => Some(r),
|
||||
_ => Some("scopeddlol/SCOPENET-MC"),
|
||||
_ => Some("https://gitea.jadonsandbox.com/blade/scopenet-mc"),
|
||||
};
|
||||
/// When set to "1", players can't change the panel URL.
|
||||
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
|
||||
|
||||
@@ -23,31 +23,19 @@ pub fn capture(state: &AppState) -> Option<Recorder> {
|
||||
let accounts = state.accounts.read().unwrap();
|
||||
let active = accounts.active()?;
|
||||
|
||||
Some(Recorder {
|
||||
http: state.http.clone(),
|
||||
panel,
|
||||
token,
|
||||
username: Some(active.username.clone()),
|
||||
})
|
||||
Some(Recorder { http: state.http.clone(), panel, token, username: Some(active.username.clone()) })
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
let mut req = self
|
||||
.http
|
||||
.post(format!("{}/api/v1/launcher/events", self.panel))
|
||||
.timeout(Duration::from_secs(5));
|
||||
let mut req = self.http.post(format!("{}/api/v1/launcher/events", self.panel)).timeout(Duration::from_secs(5));
|
||||
if let Some(token) = &self.token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
req.json(&LaunchEvent {
|
||||
kind: kind.into(),
|
||||
instance_id: instance.into(),
|
||||
username: self.username.clone(),
|
||||
})
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
req.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: self.username.clone() })
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
|
||||
//! Self-update via Gitea Releases: find a newer `*-setup.exe`, download
|
||||
//! it and hand over to the installer.
|
||||
|
||||
use crate::state::build;
|
||||
@@ -42,14 +42,12 @@ pub fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
|
||||
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
|
||||
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
|
||||
let release: Release = http
|
||||
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let repo = reqwest::Url::parse(repo).context("invalid release repository URL")?;
|
||||
if repo.scheme() != "https" {
|
||||
anyhow::bail!("release repository must use HTTPS");
|
||||
}
|
||||
let api = format!("{}/api/v1/repos{}/releases/latest", repo.origin().ascii_serialization(), repo.path());
|
||||
let release: Release = http.get(api).header("Accept", "application/json").send().await?.error_for_status()?.json().await?;
|
||||
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -68,7 +66,9 @@ pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
|
||||
|
||||
/// Download the installer and start it. The caller exits the app afterwards.
|
||||
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
|
||||
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
|
||||
let expected = reqwest::Url::parse(build::REPO.unwrap_or_default()).context("invalid release repository URL")?;
|
||||
let actual = reqwest::Url::parse(url).context("invalid update URL")?;
|
||||
if actual.scheme() != "https" || actual.origin() != expected.origin() {
|
||||
anyhow::bail!("refusing to download an update from an unexpected host");
|
||||
}
|
||||
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
|
||||
|
||||
@@ -41,13 +41,14 @@ html, body {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Long names and unbroken strings wrap inside their box instead of spilling out of it. */
|
||||
overflow-wrap: anywhere;
|
||||
overflow-wrap: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#app { height: 100%; }
|
||||
|
||||
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
|
||||
*, *::before, *::after { min-width: 0; }
|
||||
.row > *, .col > *, .glass > * { min-width: 0; }
|
||||
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
|
||||
|
||||
|
||||
@@ -91,12 +92,13 @@ button {
|
||||
gap: 0.5rem;
|
||||
font-weight: 520;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
|
||||
white-space: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
button.primary, button.ghost, button.danger, button.icon, button.tool { white-space: nowrap; }
|
||||
button.icon, button.tool { white-space: nowrap; }
|
||||
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
|
||||
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
@@ -170,7 +170,7 @@ async function mockInvokeInner(cmd: string, args: Record<string, any>): Promise<
|
||||
return [{ id: 1, server_id: 1, server_name: 'Survival SMP', from_uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', from_name: 'Notch', to_uuid: 'friend-1', to_name: 'Alex', amount: 150, description: 'Payment to Alex', created_at: '2026-09-29T15:30:00Z' }];
|
||||
case 'bootstrap': {
|
||||
const b: Bootstrap = {
|
||||
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
|
||||
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'https://gitea.jadonsandbox.com/blade/scopenet-mc', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
|
||||
panel_url: settings.panel_url,
|
||||
settings,
|
||||
accounts,
|
||||
|
||||
+273
-64
@@ -49,9 +49,22 @@ pub struct EmbedStyle {
|
||||
impl Default for EmbedStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true, username: String::new(), avatar_url: String::new(), content: String::new(), title: String::new(), url: String::new(),
|
||||
description: String::new(), color: "#8b6cff".into(), thumbnail: String::new(), image: String::new(), author_name: String::new(),
|
||||
author_icon: String::new(), footer: String::new(), footer_icon: String::new(), timestamp: true, fields: vec![],
|
||||
enabled: true,
|
||||
username: String::new(),
|
||||
avatar_url: String::new(),
|
||||
content: String::new(),
|
||||
title: String::new(),
|
||||
url: String::new(),
|
||||
description: String::new(),
|
||||
color: "#8b6cff".into(),
|
||||
thumbnail: String::new(),
|
||||
image: String::new(),
|
||||
author_name: String::new(),
|
||||
author_icon: String::new(),
|
||||
footer: String::new(),
|
||||
footer_icon: String::new(),
|
||||
timestamp: true,
|
||||
fields: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +159,11 @@ pub fn fill(text: &str, vars: &Vars) -> String {
|
||||
}
|
||||
|
||||
fn cut(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max { s.to_string() } else { format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>()) }
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
fn color(s: &str) -> u32 {
|
||||
@@ -215,7 +232,10 @@ pub fn render(style: &EmbedStyle, vars: &Vars, extra_description: Option<&str>)
|
||||
e.insert("fields".into(), json!(fields));
|
||||
}
|
||||
let mut body = Map::new();
|
||||
body.insert("username".into(), json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80)));
|
||||
body.insert(
|
||||
"username".into(),
|
||||
json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80)),
|
||||
);
|
||||
if let Some(u) = https(fill(&style.avatar_url, vars)) {
|
||||
body.insert("avatar_url".into(), json!(u));
|
||||
}
|
||||
@@ -240,7 +260,11 @@ fn base_url(state: &AppState) -> String {
|
||||
|
||||
pub fn avatar(state: &AppState, uuid: &str) -> String {
|
||||
let base = base_url(state);
|
||||
if base.is_empty() || uuid.is_empty() { String::new() } else { format!("{base}/api/v1/avatar/{uuid}?size=128") }
|
||||
if base.is_empty() || uuid.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{base}/api/v1/avatar/{uuid}?size=128")
|
||||
}
|
||||
}
|
||||
|
||||
async fn server_name(state: &AppState) -> String {
|
||||
@@ -258,9 +282,43 @@ async fn common(state: &AppState) -> Vars {
|
||||
}
|
||||
|
||||
pub const ANNOUNCEMENT_PLACEHOLDERS: [(&str, &[&str]); 3] = [
|
||||
("achievement", &["player", "uuid", "avatar", "achievement", "achievement_description", "xp", "category", "level", "rank_title", "guild", "guild_tag", "guild_line", "server_name", "panel", "time"]),
|
||||
(
|
||||
"achievement",
|
||||
&[
|
||||
"player",
|
||||
"uuid",
|
||||
"avatar",
|
||||
"achievement",
|
||||
"achievement_description",
|
||||
"xp",
|
||||
"category",
|
||||
"level",
|
||||
"rank_title",
|
||||
"guild",
|
||||
"guild_tag",
|
||||
"guild_line",
|
||||
"server_name",
|
||||
"panel",
|
||||
"time",
|
||||
],
|
||||
),
|
||||
("member", &["player", "uuid", "avatar", "members_total", "server_name", "panel", "time"]),
|
||||
("guild", &["guild", "tag", "leader", "leader_avatar", "guild_description", "members", "claims", "guilds_total", "server_name", "panel", "time"]),
|
||||
(
|
||||
"guild",
|
||||
&[
|
||||
"guild",
|
||||
"tag",
|
||||
"leader",
|
||||
"leader_avatar",
|
||||
"guild_description",
|
||||
"members",
|
||||
"claims",
|
||||
"guilds_total",
|
||||
"server_name",
|
||||
"panel",
|
||||
"time",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars {
|
||||
@@ -268,11 +326,22 @@ pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars {
|
||||
v.insert("player".into(), plain(name));
|
||||
v.insert("uuid".into(), uuid.to_string());
|
||||
v.insert("avatar".into(), avatar(state, uuid));
|
||||
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
|
||||
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let (l, t) = level.unwrap_or((1, None));
|
||||
v.insert("level".into(), l.to_string());
|
||||
v.insert("rank_title".into(), plain(&t.unwrap_or_default()));
|
||||
let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
|
||||
let guild: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let (gn, gt) = guild.unwrap_or_default();
|
||||
v.insert("guild".into(), plain(&gn));
|
||||
v.insert("guild_tag".into(), plain(>));
|
||||
@@ -291,7 +360,13 @@ pub async fn guild_vars(state: &AppState, guild_id: &str) -> Vars {
|
||||
.ok()
|
||||
.flatten();
|
||||
let (name, tag, desc, leader_uuid, members, claims) = row.unwrap_or_default();
|
||||
let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&leader_uuid).fetch_optional(&state.db).await.ok().flatten().unwrap_or_default();
|
||||
let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?")
|
||||
.bind(&leader_uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guilds").fetch_one(&state.db).await.unwrap_or(0);
|
||||
v.insert("guild".into(), plain(&name));
|
||||
v.insert("tag".into(), plain(&tag));
|
||||
@@ -309,12 +384,31 @@ pub async fn sample_vars(state: &AppState, kind: &str) -> Vars {
|
||||
let mut v = common(state).await;
|
||||
let me = state.cfg.admin_username.clone();
|
||||
let pairs: Vec<(&str, String)> = match kind {
|
||||
"achievement" => vec![("player", me), ("uuid", "00000000-0000-0000-0000-000000000000".into()), ("avatar", String::new()), ("achievement", "Deep Diver".into()),
|
||||
("achievement_description", "Mine 5,000 blocks below Y 0".into()), ("xp", "250".into()), ("category", "Mining".into()), ("level", "14".into()),
|
||||
("rank_title", "Veteran".into()), ("guild", "Iron Wolves".into()), ("guild_tag", "IRON".into()), ("guild_line", "[IRON] Iron Wolves".into())],
|
||||
"achievement" => vec![
|
||||
("player", me),
|
||||
("uuid", "00000000-0000-0000-0000-000000000000".into()),
|
||||
("avatar", String::new()),
|
||||
("achievement", "Deep Diver".into()),
|
||||
("achievement_description", "Mine 5,000 blocks below Y 0".into()),
|
||||
("xp", "250".into()),
|
||||
("category", "Mining".into()),
|
||||
("level", "14".into()),
|
||||
("rank_title", "Veteran".into()),
|
||||
("guild", "Iron Wolves".into()),
|
||||
("guild_tag", "IRON".into()),
|
||||
("guild_line", "[IRON] Iron Wolves".into()),
|
||||
],
|
||||
"member" => vec![("player", me), ("uuid", String::new()), ("avatar", String::new()), ("members_total", "128".into())],
|
||||
_ => vec![("guild", "Iron Wolves".into()), ("tag", "IRON".into()), ("leader", me), ("leader_avatar", String::new()), ("guild_description", "Defenders of the realm.".into()),
|
||||
("members", "1".into()), ("claims", "0".into()), ("guilds_total", "12".into())],
|
||||
_ => vec![
|
||||
("guild", "Iron Wolves".into()),
|
||||
("tag", "IRON".into()),
|
||||
("leader", me),
|
||||
("leader_avatar", String::new()),
|
||||
("guild_description", "Defenders of the realm.".into()),
|
||||
("members", "1".into()),
|
||||
("claims", "0".into()),
|
||||
("guilds_total", "12".into()),
|
||||
],
|
||||
};
|
||||
for (k, val) in pairs {
|
||||
v.insert(k.into(), val);
|
||||
@@ -327,7 +421,14 @@ pub async fn sample_vars(state: &AppState, kind: &str) -> Vars {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()> {
|
||||
let resp = state.http.post(webhook).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
let resp = state
|
||||
.http
|
||||
.post(webhook)
|
||||
.json(body)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
|
||||
}
|
||||
@@ -336,7 +437,14 @@ pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()
|
||||
|
||||
/// Post a new message and return its id (so it can be edited later).
|
||||
async fn post_returning_id(state: &AppState, webhook: &str, body: &Value) -> AppResult<String> {
|
||||
let resp = state.http.post(format!("{webhook}?wait=true")).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
let resp = state
|
||||
.http
|
||||
.post(format!("{webhook}?wait=true"))
|
||||
.json(body)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
|
||||
}
|
||||
@@ -385,46 +493,81 @@ pub struct LiveConfig {
|
||||
|
||||
impl Default for LiveConfig {
|
||||
fn default() -> Self {
|
||||
Self { style: EmbedStyle::default(), webhook_url: String::new(), interval_secs: 120, limit: 10, sort: "playtime".into(), server_id: 0, row: String::new(), message_id: String::new() }
|
||||
Self {
|
||||
style: EmbedStyle::default(),
|
||||
webhook_url: String::new(),
|
||||
interval_secs: 120,
|
||||
limit: 10,
|
||||
sort: "playtime".into(),
|
||||
server_id: 0,
|
||||
row: String::new(),
|
||||
message_id: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_live() -> BTreeMap<String, LiveConfig> {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("status".to_string(), LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false,
|
||||
title: "🟢 Server status".into(),
|
||||
description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(),
|
||||
color: "#34d399".into(), footer: "{server_name} · updated".into(), ..Default::default()
|
||||
m.insert(
|
||||
"status".to_string(),
|
||||
LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false,
|
||||
title: "🟢 Server status".into(),
|
||||
description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(),
|
||||
color: "#34d399".into(),
|
||||
footer: "{server_name} · updated".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(),
|
||||
..Default::default()
|
||||
});
|
||||
m.insert("guilds".to_string(), LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false, title: "🛡️ Active guilds".into(), description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(),
|
||||
color: "#22d3ee".into(), footer: "{server_name}".into(), ..Default::default()
|
||||
);
|
||||
m.insert(
|
||||
"guilds".to_string(),
|
||||
LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false,
|
||||
title: "🛡️ Active guilds".into(),
|
||||
description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(),
|
||||
color: "#22d3ee".into(),
|
||||
footer: "{server_name}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(),
|
||||
..Default::default()
|
||||
});
|
||||
m.insert("leaderboard".to_string(), LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false, title: "🏆 Leaderboard — {sort_label}".into(), description: "{rows}".into(),
|
||||
color: "#fcd34d".into(), footer: "{server_name}".into(), ..Default::default()
|
||||
);
|
||||
m.insert(
|
||||
"leaderboard".to_string(),
|
||||
LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false,
|
||||
title: "🏆 Leaderboard — {sort_label}".into(),
|
||||
description: "{rows}".into(),
|
||||
color: "#fcd34d".into(),
|
||||
footer: "{server_name}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "`{rank}.` **{player}** — {value}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "`{rank}.` **{player}** — {value}".into(),
|
||||
..Default::default()
|
||||
});
|
||||
m.insert("baltop".to_string(), LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false, title: "💰 Richest players".into(), description: "{rows}\n\nTotal in circulation: **{total}**".into(),
|
||||
color: "#f59e0b".into(), footer: "{server_name}".into(), ..Default::default()
|
||||
);
|
||||
m.insert(
|
||||
"baltop".to_string(),
|
||||
LiveConfig {
|
||||
style: EmbedStyle {
|
||||
enabled: false,
|
||||
title: "💰 Richest players".into(),
|
||||
description: "{rows}\n\nTotal in circulation: **{total}**".into(),
|
||||
color: "#f59e0b".into(),
|
||||
footer: "{server_name}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "`{rank}.` **{player}** — {balance}".into(),
|
||||
..Default::default()
|
||||
},
|
||||
row: "`{rank}.` **{player}** — {balance}".into(),
|
||||
..Default::default()
|
||||
});
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
@@ -461,7 +604,11 @@ fn money(v: f64) -> String {
|
||||
}
|
||||
|
||||
fn hours(secs: i64) -> String {
|
||||
if secs >= 3600 { format!("{}h {}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) }
|
||||
if secs >= 3600 {
|
||||
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
||||
} else {
|
||||
format!("{}m", secs / 60)
|
||||
}
|
||||
}
|
||||
|
||||
/// The embed body for one live kind, built from current data.
|
||||
@@ -486,8 +633,18 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
|
||||
r.insert("players".into(), s.players.to_string());
|
||||
r.insert("max".into(), if s.max_players > 0 { s.max_players.to_string() } else { "–".into() });
|
||||
r.insert("tps".into(), s.tps.map(|t| format!("{t:.1}")).unwrap_or_else(|| "–".into()));
|
||||
r.insert("version".into(), plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) }));
|
||||
r.insert("last_seen".into(), s.last_seen.as_deref().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| format!("<t:{}:R>", t.timestamp())).unwrap_or_else(|| "never".into()));
|
||||
r.insert(
|
||||
"version".into(),
|
||||
plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) }),
|
||||
);
|
||||
r.insert(
|
||||
"last_seen".into(),
|
||||
s.last_seen
|
||||
.as_deref()
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|t| format!("<t:{}:R>", t.timestamp()))
|
||||
.unwrap_or_else(|| "never".into()),
|
||||
);
|
||||
rows.push(fill(&row_tpl, &r));
|
||||
}
|
||||
}
|
||||
@@ -499,13 +656,22 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
|
||||
.bind(limit)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let totals: (i64, i64) = sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?;
|
||||
let totals: (i64, i64) =
|
||||
sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?;
|
||||
vars.insert("guilds_total".into(), totals.0.to_string());
|
||||
vars.insert("claims_total".into(), totals.1.to_string());
|
||||
for (i, (name, tag, level, members, claims, leader, leader_uuid)) in list.into_iter().enumerate() {
|
||||
let mut r = vars.clone();
|
||||
for (k, v) in [("rank", (i + 1).to_string()), ("guild", plain(&name)), ("tag", plain(&tag)), ("level", level.to_string()), ("members", members.to_string()),
|
||||
("claims", claims.to_string()), ("leader", plain(&leader)), ("leader_avatar", avatar(state, &leader_uuid))] {
|
||||
for (k, v) in [
|
||||
("rank", (i + 1).to_string()),
|
||||
("guild", plain(&name)),
|
||||
("tag", plain(&tag)),
|
||||
("level", level.to_string()),
|
||||
("members", members.to_string()),
|
||||
("claims", claims.to_string()),
|
||||
("leader", plain(&leader)),
|
||||
("leader_avatar", avatar(state, &leader_uuid)),
|
||||
] {
|
||||
r.insert(k.into(), v);
|
||||
}
|
||||
rows.push(fill(&row_tpl, &r));
|
||||
@@ -525,24 +691,53 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
|
||||
);
|
||||
let list: Vec<(String, String, i64, i64, i64, i64, i64, i64)> = sqlx::query_as(&sql).bind(limit).fetch_all(&state.db).await?;
|
||||
for (i, (uuid, name, playtime, kills, blocks, level, deaths, mobs)) in list.into_iter().enumerate() {
|
||||
let value = match unit { "level" => format!("level {level}"), "kills" => format!("{kills} kills"), "blocks" => format!("{blocks} blocks"), _ => hours(playtime) };
|
||||
let value = match unit {
|
||||
"level" => format!("level {level}"),
|
||||
"kills" => format!("{kills} kills"),
|
||||
"blocks" => format!("{blocks} blocks"),
|
||||
_ => hours(playtime),
|
||||
};
|
||||
let mut r = vars.clone();
|
||||
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("value", value), ("playtime", hours(playtime)),
|
||||
("kills", kills.to_string()), ("blocks", blocks.to_string()), ("level", level.to_string()), ("deaths", deaths.to_string()), ("mob_kills", mobs.to_string())] {
|
||||
for (k, v) in [
|
||||
("rank", (i + 1).to_string()),
|
||||
("player", plain(&name)),
|
||||
("avatar", avatar(state, &uuid)),
|
||||
("value", value),
|
||||
("playtime", hours(playtime)),
|
||||
("kills", kills.to_string()),
|
||||
("blocks", blocks.to_string()),
|
||||
("level", level.to_string()),
|
||||
("deaths", deaths.to_string()),
|
||||
("mob_kills", mobs.to_string()),
|
||||
] {
|
||||
r.insert(k.into(), v);
|
||||
}
|
||||
rows.push(fill(&row_tpl, &r));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let sid: i64 = if cfg.server_id > 0 { cfg.server_id } else { sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await? };
|
||||
let sid: i64 = if cfg.server_id > 0 {
|
||||
cfg.server_id
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await?
|
||||
};
|
||||
let economy = crate::routes::servers::economy_scope(&state.db, sid).await?;
|
||||
let list: Vec<(String, String, f64)> = sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?").bind(economy).bind(limit).fetch_all(&state.db).await?;
|
||||
let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?").bind(economy).fetch_one(&state.db).await?;
|
||||
let list: Vec<(String, String, f64)> =
|
||||
sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?")
|
||||
.bind(economy)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?")
|
||||
.bind(economy)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
vars.insert("total".into(), money(total));
|
||||
for (i, (uuid, name, balance)) in list.into_iter().enumerate() {
|
||||
let mut r = vars.clone();
|
||||
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))] {
|
||||
for (k, v) in
|
||||
[("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))]
|
||||
{
|
||||
r.insert(k.into(), v);
|
||||
}
|
||||
rows.push(fill(&row_tpl, &r));
|
||||
@@ -565,7 +760,14 @@ pub async fn refresh_live(state: &AppState, kind: &str, force_new: bool) -> AppR
|
||||
let body = build_live(state, kind, &cfg).await?;
|
||||
let mut id = if force_new { String::new() } else { cfg.message_id.clone() };
|
||||
if !id.is_empty() {
|
||||
let resp = state.http.patch(format!("{hook}/messages/{id}")).json(&body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
let resp = state
|
||||
.http
|
||||
.patch(format!("{hook}/messages/{id}"))
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
id.clear(); // the message was deleted in Discord: post a fresh one
|
||||
} else if !resp.status().is_success() {
|
||||
@@ -616,7 +818,14 @@ mod tests {
|
||||
let mut v = Vars::new();
|
||||
v.insert("long".into(), "x".repeat(5000));
|
||||
v.insert("img".into(), "javascript:alert(1)".into());
|
||||
let style = EmbedStyle { title: "{long}".into(), description: "{long}".into(), thumbnail: "{img}".into(), color: "nonsense".into(), fields: vec![field("", "empty name", false), field("ok", "{long}", true)], ..Default::default() };
|
||||
let style = EmbedStyle {
|
||||
title: "{long}".into(),
|
||||
description: "{long}".into(),
|
||||
thumbnail: "{img}".into(),
|
||||
color: "nonsense".into(),
|
||||
fields: vec![field("", "empty name", false), field("ok", "{long}", true)],
|
||||
..Default::default()
|
||||
};
|
||||
let body = render(&style, &v, None);
|
||||
let e = &body["embeds"][0];
|
||||
assert!(e["title"].as_str().unwrap().chars().count() <= 256);
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod config;
|
||||
pub mod db;
|
||||
pub mod embeds;
|
||||
pub mod error;
|
||||
pub mod worldmap;
|
||||
pub mod net;
|
||||
pub mod packs;
|
||||
pub mod progression;
|
||||
@@ -19,6 +18,7 @@ pub mod seed;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod textures;
|
||||
pub mod worldmap;
|
||||
pub mod yggdrasil;
|
||||
|
||||
use axum::http::{header, HeaderValue};
|
||||
|
||||
@@ -72,7 +72,14 @@ pub struct Rules {
|
||||
|
||||
impl Default for Rules {
|
||||
fn default() -> Self {
|
||||
Self { starting_balance: 1000.0, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 }
|
||||
Self {
|
||||
starting_balance: 1000.0,
|
||||
guild_base_claims: 16,
|
||||
guild_claims_per_member: 0,
|
||||
guild_claims_per_level: 0,
|
||||
guild_max_members: 0,
|
||||
market_max_listings: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +251,11 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
|
||||
("rule_claims_per_level", p.rules.guild_claims_per_level),
|
||||
("rule_guild_max_members", p.rules.guild_max_members),
|
||||
] {
|
||||
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(key).bind(value.to_string()).execute(pool).await?;
|
||||
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.bind(key)
|
||||
.bind(value.to_string())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -66,7 +66,10 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
|
||||
report.add("ranks", run(c, "DELETE FROM player_ranks WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("notifications", run(c, "DELETE FROM server_notifications WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM server_events WHERE uuid = ?", &[uuid]).await?);
|
||||
report.add("activity", run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?);
|
||||
report.add(
|
||||
"activity",
|
||||
run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?,
|
||||
);
|
||||
|
||||
// ---- social ----
|
||||
report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?);
|
||||
@@ -119,7 +122,12 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
|
||||
// ---- economy ----
|
||||
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
|
||||
// Items a guild had listed stay for the guild; only the lister is anonymised.
|
||||
run(c, "UPDATE server_market SET seller_uuid = ?, seller_name = ? WHERE seller_uuid = ? AND seller_guild_id IS NOT NULL", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
run(
|
||||
c,
|
||||
"UPDATE server_market SET seller_uuid = ?, seller_name = ? WHERE seller_uuid = ? AND seller_guild_id IS NOT NULL",
|
||||
&[DELETED_UUID, DELETED_NAME, uuid],
|
||||
)
|
||||
.await?;
|
||||
report.add("economy", run(c, "DELETE FROM server_market WHERE seller_uuid = ?", &[uuid]).await?);
|
||||
run(c, "UPDATE economy_transactions SET from_uuid = ?, from_name = ? WHERE from_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
|
||||
+95
-18
@@ -14,7 +14,11 @@ pub const MAX_ACTIONS: usize = 20;
|
||||
const SOURCES: [&str; 3] = ["quest", "achievement", "level_reward"];
|
||||
|
||||
pub fn check_source(kind: &str) -> AppResult<()> {
|
||||
if SOURCES.contains(&kind) { Ok(()) } else { Err(AppError::bad_request("unknown reward source")) }
|
||||
if SOURCES.contains(&kind) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::bad_request("unknown reward source"))
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &Map<String, Value>, key: &str, max: usize) -> String {
|
||||
@@ -126,7 +130,13 @@ pub fn validate(actions: &Value) -> AppResult<Vec<Value>> {
|
||||
|
||||
fn pretty_item(id: &str) -> String {
|
||||
let name = id.rsplit(':').next().unwrap_or(id).replace(['_', '/'], " ");
|
||||
name.split_whitespace().map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() }).collect::<Vec<_>>().join(" ")
|
||||
name.split_whitespace()
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// A short, player-facing line for one action (commands stay vague on purpose).
|
||||
@@ -137,7 +147,15 @@ pub fn describe(a: &Value) -> String {
|
||||
"item" => format!("{} x{}", pretty_item(a["item"].as_str().unwrap_or("")), n("amount")),
|
||||
"permission" => {
|
||||
let m = n("minutes");
|
||||
let span = if m == 0 { String::new() } else if m % 1440 == 0 { format!(" for {} day{}", m / 1440, if m / 1440 == 1 { "" } else { "s" }) } else if m % 60 == 0 { format!(" for {}h", m / 60) } else { format!(" for {m}m") };
|
||||
let span = if m == 0 {
|
||||
String::new()
|
||||
} else if m % 1440 == 0 {
|
||||
format!(" for {} day{}", m / 1440, if m / 1440 == 1 { "" } else { "s" })
|
||||
} else if m % 60 == 0 {
|
||||
format!(" for {}h", m / 60)
|
||||
} else {
|
||||
format!(" for {m}m")
|
||||
};
|
||||
format!("Permission: {}{}", a["node"].as_str().unwrap_or(""), span)
|
||||
}
|
||||
"group" => format!("Group: {}", a["group"].as_str().unwrap_or("")),
|
||||
@@ -149,20 +167,42 @@ pub fn describe(a: &Value) -> String {
|
||||
}
|
||||
|
||||
pub async fn bundle(conn: &mut SqliteConnection, kind: &str, id: &str) -> AppResult<Vec<Value>> {
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT actions FROM reward_bundles WHERE source_type = ? AND source_id = ?").bind(kind).bind(id).fetch_optional(&mut *conn).await?;
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT actions FROM reward_bundles WHERE source_type = ? AND source_id = ?")
|
||||
.bind(kind)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
Ok(raw.and_then(|r| serde_json::from_str::<Vec<Value>>(&r).ok()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Queue a bundle for a player who just earned its source. Does nothing when the source has no bundle.
|
||||
pub async fn enqueue(conn: &mut SqliteConnection, uuid: &str, kind: &str, id: &str) -> AppResult<()> {
|
||||
let has: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reward_bundles WHERE source_type = ? AND source_id = ? AND actions <> '[]')").bind(kind).bind(id).fetch_one(&mut *conn).await?;
|
||||
let has: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reward_bundles WHERE source_type = ? AND source_id = ? AND actions <> '[]')")
|
||||
.bind(kind)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if has {
|
||||
sqlx::query("INSERT INTO reward_queue (uuid, source_type, source_id, created_at) VALUES (?, ?, ?, ?)").bind(uuid).bind(kind).bind(id).bind(crate::db::now()).execute(&mut *conn).await?;
|
||||
sqlx::query("INSERT INTO reward_queue (uuid, source_type, source_id, created_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(uuid)
|
||||
.bind(kind)
|
||||
.bind(id)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn credit(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, name: &str, amount: f64, server: Option<i64>, note: &str) -> AppResult<()> {
|
||||
async fn credit(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
uuid: &str,
|
||||
name: &str,
|
||||
amount: f64,
|
||||
server: Option<i64>,
|
||||
note: &str,
|
||||
) -> AppResult<()> {
|
||||
// One credit per economy: servers sharing a group share balances, so they must not be paid twice.
|
||||
let servers: Vec<i64> = match server {
|
||||
Some(id) => vec![id],
|
||||
@@ -184,7 +224,13 @@ async fn credit(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, name:
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for economy in economies {
|
||||
crate::routes::economy::ensure_balance(tx, economy, uuid, name).await?;
|
||||
sqlx::query("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ?").bind(amount).bind(&now).bind(economy).bind(uuid).execute(&mut **tx).await?;
|
||||
sqlx::query("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(amount)
|
||||
.bind(&now)
|
||||
.bind(economy)
|
||||
.bind(uuid)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, 'server', 'Rewards', ?, ?, ?, ?, ?)")
|
||||
.bind(economy).bind(uuid).bind(name).bind(amount).bind(note).bind(&now).execute(&mut **tx).await?;
|
||||
}
|
||||
@@ -202,15 +248,18 @@ async fn source_label(conn: &mut SqliteConnection, kind: &str, id: &str) -> Stri
|
||||
|
||||
/// Apply every queued bundle. Safe to call often; each queue row is handled exactly once.
|
||||
pub async fn process_queue(state: &AppState) -> AppResult<usize> {
|
||||
let rows: Vec<(i64, String, String, String)> = sqlx::query_as("SELECT id, uuid, source_type, source_id FROM reward_queue ORDER BY id LIMIT 200").fetch_all(&state.db).await?;
|
||||
let rows: Vec<(i64, String, String, String)> =
|
||||
sqlx::query_as("SELECT id, uuid, source_type, source_id FROM reward_queue ORDER BY id LIMIT 200").fetch_all(&state.db).await?;
|
||||
let mut done = 0;
|
||||
for (queue_id, uuid, kind, id) in rows {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let gone: Option<i64> = sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?;
|
||||
let gone: Option<i64> =
|
||||
sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?;
|
||||
if gone.is_none() {
|
||||
continue; // another request got there first
|
||||
}
|
||||
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_default();
|
||||
let name: String =
|
||||
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
tx.commit().await?;
|
||||
continue;
|
||||
@@ -221,13 +270,19 @@ pub async fn process_queue(state: &AppState) -> AppResult<usize> {
|
||||
for action in bundle(&mut tx, &kind, &id).await? {
|
||||
let server = action["server_id"].as_i64();
|
||||
match action["type"].as_str().unwrap_or("") {
|
||||
"money" => credit(&mut tx, &uuid, &name, action["amount"].as_f64().unwrap_or(0.0), server, &format!("Reward: {label}")).await?,
|
||||
"money" => {
|
||||
credit(&mut tx, &uuid, &name, action["amount"].as_f64().unwrap_or(0.0), server, &format!("Reward: {label}")).await?
|
||||
}
|
||||
"claim_chunks" => {
|
||||
sqlx::query("INSERT INTO player_bonuses (uuid, claim_chunks) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET claim_chunks = claim_chunks + excluded.claim_chunks")
|
||||
.bind(&uuid).bind(action["amount"].as_i64().unwrap_or(0)).execute(&mut *tx).await?;
|
||||
}
|
||||
"badge" => {
|
||||
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_else(|| "[]".into());
|
||||
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or_else(|| "[]".into());
|
||||
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
|
||||
let badge = action["badge"].as_str().unwrap_or("").to_string();
|
||||
if !badge.is_empty() && !badges.contains(&badge) {
|
||||
@@ -239,8 +294,17 @@ pub async fn process_queue(state: &AppState) -> AppResult<usize> {
|
||||
other => {
|
||||
let mut payload = action.clone();
|
||||
payload["reason"] = json!(label);
|
||||
sqlx::query("INSERT INTO reward_deliveries (uuid, server_id, kind, payload, source, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(&uuid).bind(server).bind(other).bind(payload.to_string()).bind(&source).bind(&now).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO reward_deliveries (uuid, server_id, kind, payload, source, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.bind(server)
|
||||
.bind(other)
|
||||
.bind(payload.to_string())
|
||||
.bind(&source)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +334,11 @@ pub async fn poll(state: &AppState, server_id: i64) -> AppResult<Vec<Value>> {
|
||||
.await?;
|
||||
let now = crate::db::now();
|
||||
for (id, ..) in &rows {
|
||||
sqlx::query("UPDATE reward_deliveries SET sent_at = ?, attempts = attempts + 1 WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE reward_deliveries SET sent_at = ?, attempts = attempts + 1 WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(rows.into_iter().map(|(id, uuid, name, kind, payload)| json!({ "id": id, "uuid": uuid, "name": name, "kind": kind, "payload": serde_json::from_str::<Value>(&payload).unwrap_or(json!({})) })).collect())
|
||||
@@ -281,12 +349,21 @@ pub async fn ack(state: &AppState, done: &[i64], failed: &[(i64, String)]) -> Ap
|
||||
let now = crate::db::now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
for id in done {
|
||||
sqlx::query("UPDATE reward_deliveries SET delivered_at = ?, error = NULL WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE reward_deliveries SET delivered_at = ?, error = NULL WHERE id = ?")
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for (id, error) in failed {
|
||||
let error: String = error.chars().filter(|c| !c.is_control()).take(200).collect();
|
||||
// A refused delivery (e.g. commands switched off on that server) is retried a few times, then left for the admin to see.
|
||||
sqlx::query("UPDATE reward_deliveries SET error = ?, sent_at = ? WHERE id = ? AND delivered_at IS NULL").bind(error).bind(&now).bind(id).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE reward_deliveries SET error = ?, sent_at = ? WHERE id = ? AND delivered_at IS NULL")
|
||||
.bind(error)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -10,39 +10,17 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Get all achievements for authenticated user with their unlock status.
|
||||
pub async fn get_my_achievements(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Achievement>>> {
|
||||
pub async fn get_my_achievements(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<Achievement>>> {
|
||||
get_achievements_for_user(&state, &auth.uuid, false).await.map(Json)
|
||||
}
|
||||
|
||||
/// Get public unlocked achievements for a specific player profile.
|
||||
pub async fn get_player_achievements(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Achievement>>> {
|
||||
pub async fn get_player_achievements(Path(uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<Achievement>>> {
|
||||
get_achievements_for_user(&state, &uuid, true).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn get_achievements_for_user(
|
||||
state: &AppState,
|
||||
uuid: &str,
|
||||
unlocked_only: bool,
|
||||
) -> AppResult<Vec<Achievement>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
bool,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
pub async fn get_achievements_for_user(state: &AppState, uuid: &str, unlocked_only: bool) -> AppResult<Vec<Achievement>> {
|
||||
let rows: Vec<(String, String, String, String, String, String, String, String, i64, bool, Option<String>)> = sqlx::query_as(
|
||||
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
|
||||
a.icon_bg, a.icon_border, a.xp_reward, a.secret, ua.unlocked_at
|
||||
FROM achievements a
|
||||
@@ -55,43 +33,29 @@ pub async fn get_achievements_for_user(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.filter_map(
|
||||
|(
|
||||
.filter_map(|(id, title, desc, cat, frame, item, bg, border, xp, secret, unlocked_at)| {
|
||||
let unlocked = unlocked_at.is_some();
|
||||
if unlocked_only && !unlocked {
|
||||
return None;
|
||||
}
|
||||
Some(Achievement {
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
cat,
|
||||
frame,
|
||||
item,
|
||||
bg,
|
||||
border,
|
||||
xp,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: None,
|
||||
target_count: None,
|
||||
unlocked,
|
||||
unlocked_at,
|
||||
)| {
|
||||
let unlocked = unlocked_at.is_some();
|
||||
if unlocked_only && !unlocked {
|
||||
return None;
|
||||
}
|
||||
Some(Achievement {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: None,
|
||||
target_count: None,
|
||||
unlocked,
|
||||
unlocked_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(list)
|
||||
@@ -114,26 +78,8 @@ pub struct AdminAchievementRow {
|
||||
}
|
||||
|
||||
/// Admin list all achievements.
|
||||
pub async fn admin_list_achievements(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<AdminAchievementRow>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
bool,
|
||||
i64,
|
||||
)> = sqlx::query_as(
|
||||
pub async fn admin_list_achievements(_admin: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminAchievementRow>>> {
|
||||
let rows: Vec<(String, String, String, String, String, String, String, String, String, String, i64, i64, bool, i64)> = sqlx::query_as(
|
||||
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
|
||||
a.icon_bg, a.icon_border, a.requirement_type, a.requirement_key,
|
||||
a.requirement_value, a.xp_reward, a.secret,
|
||||
@@ -148,50 +94,33 @@ pub async fn admin_list_achievements(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
cat,
|
||||
frame,
|
||||
item,
|
||||
bg,
|
||||
border,
|
||||
req_type,
|
||||
req_key,
|
||||
req_val,
|
||||
xp,
|
||||
secret,
|
||||
.map(|(id, title, desc, cat, frame, item, bg, border, req_type, req_key, req_val, xp, secret, total_unlocked)| {
|
||||
AdminAchievementRow {
|
||||
achievement: Achievement {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: Some(req_key.clone()),
|
||||
target_count: Some(req_val),
|
||||
unlocked: false,
|
||||
unlocked_at: None,
|
||||
},
|
||||
requirement_type: req_type,
|
||||
requirement_key: req_key.clone(),
|
||||
requirement_value: req_val,
|
||||
stat_type: req_key,
|
||||
target_count: req_val,
|
||||
total_unlocked,
|
||||
)| {
|
||||
AdminAchievementRow {
|
||||
achievement: Achievement {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
category: cat,
|
||||
icon_frame: frame.clone(),
|
||||
frame_type: Some(frame),
|
||||
icon_item: item,
|
||||
icon_bg: bg,
|
||||
icon_border: border,
|
||||
xp_reward: xp,
|
||||
secret,
|
||||
stat_type: Some(req_key.clone()),
|
||||
target_count: Some(req_val),
|
||||
unlocked: false,
|
||||
unlocked_at: None,
|
||||
},
|
||||
requirement_type: req_type,
|
||||
requirement_key: req_key.clone(),
|
||||
requirement_value: req_val,
|
||||
stat_type: req_key,
|
||||
target_count: req_val,
|
||||
total_unlocked,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
@@ -224,10 +153,7 @@ pub async fn admin_create_achievement(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AchievementPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let id = payload
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
||||
let id = payload.id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
|
||||
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
|
||||
@@ -267,7 +193,7 @@ pub async fn admin_update_achievement(
|
||||
// FIX #19: Only overwrite requirement_type / key / value when the caller
|
||||
// explicitly supplies them. Use COALESCE so NULL leaves the existing value.
|
||||
let req_type = payload.requirement_type; // None if not in the request
|
||||
let req_key = payload.requirement_key; // None if not in the request
|
||||
let req_key = payload.requirement_key; // None if not in the request
|
||||
let req_val = payload.requirement_value; // None if not in the request
|
||||
|
||||
sqlx::query(
|
||||
@@ -306,15 +232,8 @@ pub async fn admin_update_achievement(
|
||||
}
|
||||
|
||||
/// Admin delete achievement.
|
||||
pub async fn admin_delete_achievement(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn admin_delete_achievement(_admin: AdminUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'achievement' AND source_id = ?").bind(&id).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM achievements WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM achievements WHERE id = ?").bind(&id).execute(&state.db).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -234,7 +234,14 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State<AppState>
|
||||
.ok_or_else(|| AppError::not_found("player not found"))?;
|
||||
// Removes the account and everything tied to its UUID, not just the login.
|
||||
let report = crate::purge::purge_user(&state, &user).await?;
|
||||
crate::routes::activity::record(&state, &me, "panel", "account_deleted", Some(&format!("{} ({} records removed)", user.username, report.total()))).await?;
|
||||
crate::routes::activity::record(
|
||||
&state,
|
||||
&me,
|
||||
"panel",
|
||||
"account_deleted",
|
||||
Some(&format!("{} ({} records removed)", user.username, report.total())),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true, "report": report })))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ fn dashed_or_err(uuid: &str) -> AppResult<String> {
|
||||
/// Everything a placeholder or `getPlayer()` needs in one round trip.
|
||||
pub async fn player_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
let user: Option<(String, String, String)> = sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let user: Option<(String, String, String)> =
|
||||
sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let Some((name, role, joined)) = user else {
|
||||
return Ok(Json(json!({ "exists": false, "uuid": uuid })));
|
||||
};
|
||||
@@ -38,10 +39,22 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
.await?;
|
||||
let balance = crate::routes::economy::balance_for(&state, server.id, &uuid).await?;
|
||||
let (playtime, kills, deaths): (Option<i64>, Option<i64>, Option<i64>) =
|
||||
sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
|
||||
let (server_playtime,): (Option<i64>,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?").bind(&uuid).bind(server.id).fetch_one(&state.db).await?;
|
||||
let friends: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
let achievements: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
|
||||
sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?")
|
||||
.bind(&uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let (server_playtime,): (Option<i64>,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?")
|
||||
.bind(&uuid)
|
||||
.bind(server.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let friends: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)")
|
||||
.bind(&uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let achievements: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
// Quest progress for this period: how many of the player's quests are done.
|
||||
let cfg = crate::progression::load_pool(&state.db).await?;
|
||||
@@ -65,8 +78,14 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
|
||||
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
|
||||
}
|
||||
drop(conn); // give the connection back before the pool is used again
|
||||
let rank: Option<(String, String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
let rank: Option<(String, String, String, String)> =
|
||||
sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server.id)
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let online: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"exists": true,
|
||||
@@ -122,7 +141,8 @@ pub async fn add_xp(GameServer(server): GameServer, State(state): State<AppState
|
||||
return Err(AppError::not_found("player not found"));
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let (mode, amount) = if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) };
|
||||
let (mode, amount) =
|
||||
if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) };
|
||||
let change = match p.scope.as_str() {
|
||||
"global" => crate::progression::adjust_global(&mut tx, &uuid, mode, amount, &now).await?,
|
||||
"server" => crate::progression::adjust_server(&mut tx, server.id, &uuid, mode, amount, &now).await?,
|
||||
@@ -131,9 +151,30 @@ pub async fn add_xp(GameServer(server): GameServer, State(state): State<AppState
|
||||
if change.new_xp > change.old_xp {
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
|
||||
}
|
||||
let detail = format!("{} XP {} → {} (level {} → {}) via {}: {}", p.scope, change.old_xp, change.new_xp, change.old_level, change.new_level, server.name, p.reason.chars().filter(|c| !c.is_control()).take(100).collect::<String>());
|
||||
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)").bind(server.id).bind(&uuid).bind(detail).bind(crate::db::now()).execute(&mut *tx).await?;
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level })).await
|
||||
let detail = format!(
|
||||
"{} XP {} → {} (level {} → {}) via {}: {}",
|
||||
p.scope,
|
||||
change.old_xp,
|
||||
change.new_xp,
|
||||
change.old_level,
|
||||
change.new_level,
|
||||
server.name,
|
||||
p.reason.chars().filter(|c| !c.is_control()).take(100).collect::<String>()
|
||||
);
|
||||
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)")
|
||||
.bind(server.id)
|
||||
.bind(&uuid)
|
||||
.bind(detail)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
finish_operation(
|
||||
tx,
|
||||
server.id,
|
||||
&p.operation_id,
|
||||
json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -148,7 +189,11 @@ pub struct ObjectivePayload {
|
||||
}
|
||||
|
||||
/// `completeQuestObjective()`: move a player's quest forward from another plugin.
|
||||
pub async fn quest_objective(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ObjectivePayload>) -> AppResult<Json<Value>> {
|
||||
pub async fn quest_objective(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<ObjectivePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let uuid = dashed_or_err(&p.uuid)?;
|
||||
if !(0..=1_000_000).contains(&p.amount) || p.objective.is_empty() || p.objective.len() > 120 {
|
||||
return Err(AppError::bad_request("invalid objective"));
|
||||
@@ -158,7 +203,10 @@ pub async fn quest_objective(GameServer(server): GameServer, State(state): State
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let now = crate::db::now();
|
||||
let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1").bind(&p.objective).fetch_optional(&mut *tx).await?;
|
||||
let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1")
|
||||
.bind(&p.objective)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let advanced = if let Some((period, target)) = quest {
|
||||
let cfg = crate::progression::load(&mut tx).await?;
|
||||
let key = if period == "weekly" { crate::routes::quests::current_weekly_key() } else { crate::routes::quests::current_daily_key() };
|
||||
@@ -177,9 +225,15 @@ pub async fn quest_objective(GameServer(server): GameServer, State(state): State
|
||||
true
|
||||
} else {
|
||||
// Not a quest id: treat it as an action ("block_broken:STONE") like the plugin's own events.
|
||||
let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
|
||||
let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?")
|
||||
.bind(&uuid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
crate::routes::quests::advance_action_quests(&mut tx, &uuid, &p.objective, p.amount.max(1), &now).await?;
|
||||
let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
|
||||
let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?")
|
||||
.bind(&uuid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
after > before
|
||||
};
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "advanced": advanced })).await
|
||||
@@ -197,7 +251,9 @@ pub async fn friends(GameServer(_): GameServer, State(state): State<AppState>, J
|
||||
.bind(&uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::<Vec<_>>() })))
|
||||
Ok(Json(
|
||||
json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::<Vec<_>>() }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -213,16 +269,41 @@ pub struct GuildQuery {
|
||||
pub async fn guild(GameServer(server): GameServer, State(state): State<AppState>, Json(q): Json<GuildQuery>) -> AppResult<Json<Value>> {
|
||||
let id: Option<String> = if !q.member.is_empty() {
|
||||
let uuid = dashed_or_err(&q.member)?;
|
||||
sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?").bind(uuid).bind(&server.instance_id).fetch_optional(&state.db).await?
|
||||
sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?")
|
||||
.bind(uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)").bind(q.guild.trim()).bind(&server.instance_id).fetch_optional(&state.db).await?
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)",
|
||||
)
|
||||
.bind(q.guild.trim())
|
||||
.bind(&server.instance_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
};
|
||||
let Some(id) = id else { return Ok(Json(json!({ "exists": false }))) };
|
||||
let (name, tag, desc, leader, created): (String, String, String, String, String) =
|
||||
sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
|
||||
let members: Vec<(String, String, String)> = sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at").bind(&id).fetch_all(&state.db).await?;
|
||||
let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?").bind(&id).bind(server.id).fetch_one(&state.db).await?;
|
||||
let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0.0)").bind(&id).bind(server.id).fetch_one(&state.db).await?;
|
||||
sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let members: Vec<(String, String, String)> =
|
||||
sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at")
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?")
|
||||
.bind(&id)
|
||||
.bind(server.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0.0)")
|
||||
.bind(&id)
|
||||
.bind(server.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"exists": true, "id": id, "name": name, "tag": tag, "description": desc, "leader": leader, "created": created,
|
||||
"claims": claims, "balance": balance,
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
//! managed level-title roles are replaced when a player's rank changes.
|
||||
|
||||
use crate::auth::{AdminUser, AuthUser};
|
||||
use crate::embeds;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::connections::ConnectionsSettings;
|
||||
use crate::state::AppState;
|
||||
use crate::store;
|
||||
use axum::extract::{Path, State};
|
||||
use crate::embeds;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -37,7 +37,15 @@ pub struct DiscordSettings {
|
||||
|
||||
impl Default for DiscordSettings {
|
||||
fn default() -> Self {
|
||||
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
|
||||
Self {
|
||||
role_sync: "off".into(),
|
||||
base_role_id: String::new(),
|
||||
invite_url: String::new(),
|
||||
webhook_url: String::new(),
|
||||
notify_achievements: true,
|
||||
notify_guilds: true,
|
||||
notify_members: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +55,19 @@ pub async fn load(state: &AppState) -> AppResult<DiscordSettings> {
|
||||
|
||||
pub fn valid_webhook(url: &str) -> bool {
|
||||
url.is_empty()
|
||||
|| ["https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/", "https://ptb.discord.com/api/webhooks/", "https://canary.discord.com/api/webhooks/"]
|
||||
.iter()
|
||||
.any(|p| url.starts_with(p))
|
||||
|| [
|
||||
"https://discord.com/api/webhooks/",
|
||||
"https://discordapp.com/api/webhooks/",
|
||||
"https://ptb.discord.com/api/webhooks/",
|
||||
"https://canary.discord.com/api/webhooks/",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| url.starts_with(p))
|
||||
}
|
||||
|
||||
pub fn valid_invite(url: &str) -> bool {
|
||||
url.is_empty() || ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p))
|
||||
url.is_empty()
|
||||
|| ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p))
|
||||
}
|
||||
|
||||
fn view(s: &DiscordSettings) -> Value {
|
||||
@@ -66,7 +80,8 @@ fn view(s: &DiscordSettings) -> Value {
|
||||
|
||||
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let conn: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?;
|
||||
let linked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?;
|
||||
let linked: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?;
|
||||
let mut v = view(&load(&state).await?);
|
||||
v["bot_ready"] = json!(!conn.discord_bot_token.is_empty() && !conn.discord_guild_id.is_empty());
|
||||
v["linked_accounts"] = json!(linked);
|
||||
@@ -124,7 +139,10 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
|
||||
pub async fn public_info(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
|
||||
let s = load(&state).await?;
|
||||
let linked: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'").bind(user.id).fetch_optional(&state.db).await?;
|
||||
sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'")
|
||||
.bind(user.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "invite_url": s.invite_url, "linked": linked.is_some(), "display_name": linked.map(|l| l.1) })))
|
||||
}
|
||||
|
||||
@@ -137,7 +155,12 @@ pub struct RoleMapping {
|
||||
pub discord_role: String,
|
||||
}
|
||||
|
||||
pub async fn set_role_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<RoleMapping>) -> AppResult<Json<Value>> {
|
||||
pub async fn set_role_mapping(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(m): Json<RoleMapping>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let role = m.discord_role.trim();
|
||||
if !role.bytes().all(|b| b.is_ascii_digit()) || role.len() > 24 {
|
||||
return Err(AppError::bad_request("Discord role IDs are numbers"));
|
||||
@@ -222,16 +245,28 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() {
|
||||
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
|
||||
}
|
||||
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
|
||||
let mapping: Vec<(i64, String)> =
|
||||
sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
|
||||
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&state.db).await?;
|
||||
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
|
||||
return Ok(report);
|
||||
}
|
||||
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
|
||||
let linked: Vec<(i64, String, String)> = sqlx::query_as(
|
||||
"SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (user_id, discord_id, uuid) in linked {
|
||||
report.checked += 1;
|
||||
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
let resp = bot_request(
|
||||
state,
|
||||
reqwest::Method::GET,
|
||||
format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id),
|
||||
&c.discord_bot_token,
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
@@ -248,29 +283,55 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
report.failed += 1;
|
||||
break;
|
||||
}
|
||||
if !resp.status().is_success() { report.failed += 1; continue; }
|
||||
if !resp.status().is_success() {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
}
|
||||
let Ok(member) = resp.json::<Value>().await else {
|
||||
report.failed += 1;
|
||||
continue;
|
||||
};
|
||||
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
|
||||
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
|
||||
let roles: Vec<String> =
|
||||
member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
|
||||
let groups: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
|
||||
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
|
||||
let xp: i64 =
|
||||
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
|
||||
let chosen = rank_roles
|
||||
.iter()
|
||||
.find(|(required, _)| *required <= level)
|
||||
.map(|(_, role)| role.clone())
|
||||
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
|
||||
for g in p.add_groups {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)")
|
||||
.bind(user_id)
|
||||
.bind(g)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
report.groups_added += 1;
|
||||
}
|
||||
// A level title takes precedence over mapped panel groups on Discord.
|
||||
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
|
||||
let wanted_roles = if let Some(role) = &chosen {
|
||||
if roles.contains(role) {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![role.clone()]
|
||||
}
|
||||
} else {
|
||||
p.add_roles
|
||||
};
|
||||
for role in wanted_roles {
|
||||
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await;
|
||||
let done = bot_request(
|
||||
state,
|
||||
reqwest::Method::PUT,
|
||||
format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id),
|
||||
&c.discord_bot_token,
|
||||
)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await;
|
||||
match done {
|
||||
Ok(r) if r.status().is_success() => report.roles_added += 1,
|
||||
_ => report.failed += 1,
|
||||
@@ -279,11 +340,24 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
|
||||
}
|
||||
if let Some(chosen) = chosen {
|
||||
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
|
||||
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
|
||||
managed.sort(); managed.dedup();
|
||||
if !settings.base_role_id.is_empty() {
|
||||
managed.push(settings.base_role_id.clone());
|
||||
}
|
||||
managed.sort();
|
||||
managed.dedup();
|
||||
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
|
||||
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
|
||||
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
|
||||
let done = bot_request(
|
||||
state,
|
||||
reqwest::Method::DELETE,
|
||||
format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id),
|
||||
&c.discord_bot_token,
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
match done {
|
||||
Ok(r) if r.status().is_success() => report.roles_removed += 1,
|
||||
_ => report.failed += 1,
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
@@ -347,7 +421,12 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
|
||||
.await?;
|
||||
for (uuid, who, title, desc, xp, category, at) in rows {
|
||||
let mut vars = embeds::player_vars(state, &uuid, &who).await;
|
||||
for (k, v) in [("achievement", embeds::plain(&title)), ("achievement_description", embeds::plain(&desc)), ("xp", xp.to_string()), ("category", embeds::plain(&category))] {
|
||||
for (k, v) in [
|
||||
("achievement", embeds::plain(&title)),
|
||||
("achievement_description", embeds::plain(&desc)),
|
||||
("xp", xp.to_string()),
|
||||
("category", embeds::plain(&category)),
|
||||
] {
|
||||
vars.insert(k.into(), v);
|
||||
}
|
||||
if embeds::announce_event(state, "achievement", &vars).await.unwrap_or(false) {
|
||||
@@ -357,7 +436,11 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
|
||||
}
|
||||
}
|
||||
if s.notify_guilds {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
|
||||
let rows: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10")
|
||||
.bind(&cursor.guilds)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (id, at) in rows {
|
||||
let vars = embeds::guild_vars(state, &id).await;
|
||||
if embeds::announce_event(state, "guild", &vars).await.unwrap_or(false) {
|
||||
@@ -367,11 +450,16 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
|
||||
}
|
||||
}
|
||||
if s.notify_members {
|
||||
let rows: Vec<(String, String, String)> =
|
||||
sqlx::query_as("SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?;
|
||||
let rows: Vec<(String, String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10",
|
||||
)
|
||||
.bind(&cursor.members)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (uuid, name, at) in rows {
|
||||
let mut vars = embeds::player_vars(state, &uuid, &name).await;
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
|
||||
let total: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
|
||||
vars.insert("members_total".into(), total.to_string());
|
||||
if embeds::announce_event(state, "member", &vars).await.unwrap_or(false) {
|
||||
sent += 1;
|
||||
|
||||
@@ -14,7 +14,12 @@ use std::collections::BTreeMap;
|
||||
|
||||
fn check_style(s: &EmbedStyle) -> AppResult<()> {
|
||||
let too_long = |t: &str, n: usize| t.chars().count() > n;
|
||||
if too_long(&s.title, 600) || too_long(&s.description, 4000) || too_long(&s.footer, 600) || too_long(&s.content, 2000) || s.fields.len() > 25 {
|
||||
if too_long(&s.title, 600)
|
||||
|| too_long(&s.description, 4000)
|
||||
|| too_long(&s.footer, 600)
|
||||
|| too_long(&s.content, 2000)
|
||||
|| s.fields.len() > 25
|
||||
{
|
||||
return Err(AppError::bad_request("a Discord message part is too long (title 256, text 4096, footer 2048, 25 fields)"));
|
||||
}
|
||||
for url in [&s.url, &s.thumbnail, &s.image, &s.avatar_url, &s.author_icon, &s.footer_icon] {
|
||||
|
||||
+178
-107
@@ -10,23 +10,47 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
|
||||
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
|
||||
pub(crate) async fn begin_operation(
|
||||
state: &AppState,
|
||||
server_id: i64,
|
||||
operation_id: &str,
|
||||
) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
|
||||
if operation_id.is_empty() || operation_id.len() > 100 {
|
||||
return Err(AppError::bad_request("Invalid operation ID"));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
// The first statement obtains the write lock before reading any balances.
|
||||
let inserted = sqlx::query("INSERT OR IGNORE INTO economy_operations(server_id, operation_id, response) VALUES (?, ?, '')")
|
||||
.bind(server_id).bind(operation_id).execute(&mut *tx).await?.rows_affected();
|
||||
.bind(server_id)
|
||||
.bind(operation_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
let previous = if inserted == 0 {
|
||||
let raw: String = sqlx::query_scalar("SELECT response FROM economy_operations WHERE server_id = ? AND operation_id = ?")
|
||||
.bind(server_id).bind(operation_id).fetch_one(&mut *tx).await?;
|
||||
.bind(server_id)
|
||||
.bind(operation_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
Some(serde_json::from_str(&raw)?)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((tx, previous))
|
||||
}
|
||||
|
||||
pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
|
||||
pub(crate) async fn finish_operation(
|
||||
mut tx: sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
server_id: i64,
|
||||
operation_id: &str,
|
||||
response: Value,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
|
||||
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
|
||||
.bind(response.to_string())
|
||||
.bind(server_id)
|
||||
.bind(operation_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
@@ -47,15 +71,29 @@ pub struct AdjustBalancePayload {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn server_adjust_balance(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<AdjustBalancePayload>) -> AppResult<Json<Value>> {
|
||||
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 { return Err(AppError::bad_request("Invalid amount")); }
|
||||
pub async fn server_adjust_balance(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<AdjustBalancePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 {
|
||||
return Err(AppError::bad_request("Invalid amount"));
|
||||
}
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
ensure_balance(&mut tx, server.economy_id, &p.uuid, &p.username).await?;
|
||||
let balance: Option<f64> = sqlx::query_scalar("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance")
|
||||
.bind(p.delta).bind(chrono::Utc::now().to_rfc3339()).bind(server.economy_id).bind(&p.uuid).bind(p.delta).fetch_optional(&mut *tx).await?;
|
||||
let Some(balance) = balance else { return Err(AppError::bad_request("Insufficient funds")); };
|
||||
let (from, from_name, to, to_name) = if p.delta > 0.0 { ("server", "Server Shop", p.uuid.as_str(), p.username.as_str()) } else { (p.uuid.as_str(), p.username.as_str(), "server", "Server Shop") };
|
||||
let Some(balance) = balance else {
|
||||
return Err(AppError::bad_request("Insufficient funds"));
|
||||
};
|
||||
let (from, from_name, to, to_name) = if p.delta > 0.0 {
|
||||
("server", "Server Shop", p.uuid.as_str(), p.username.as_str())
|
||||
} else {
|
||||
(p.uuid.as_str(), p.username.as_str(), "server", "Server Shop")
|
||||
};
|
||||
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(server.id).bind(from).bind(from_name).bind(to).bind(to_name).bind(p.delta.abs()).bind(&p.description).bind(chrono::Utc::now().to_rfc3339()).execute(&mut *tx).await?;
|
||||
finish_operation(tx, server.id, &p.operation_id, serde_json::json!({"ok": true, "balance": balance})).await
|
||||
@@ -64,8 +102,11 @@ pub async fn server_adjust_balance(GameServer(server): GameServer, State(state):
|
||||
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.id, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.item_data, g.tag
|
||||
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45")
|
||||
.bind(server.id).fetch_all(&state.db).await?;
|
||||
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45",
|
||||
)
|
||||
.bind(server.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data, guild)| serde_json::json!({"id":id,"seller_name":seller,"seller_guild":guild,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
|
||||
}
|
||||
|
||||
@@ -74,10 +115,7 @@ pub async fn server_market_read(GameServer(server): GameServer, State(state): St
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Get current user's economy balances across all connected game servers.
|
||||
pub async fn get_my_balances(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
|
||||
pub async fn get_my_balances(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
|
||||
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
|
||||
"SELECT se.server_id, gs.name, se.balance
|
||||
FROM server_economy se
|
||||
@@ -91,22 +129,14 @@ pub async fn get_my_balances(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(sid, sname, bal)| ServerEconomyBalance {
|
||||
server_id: sid,
|
||||
server_name: sname,
|
||||
balance: bal,
|
||||
currency_symbol: "$".into(),
|
||||
})
|
||||
.map(|(sid, sname, bal)| ServerEconomyBalance { server_id: sid, server_name: sname, balance: bal, currency_symbol: "$".into() })
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get current user's recent transactions across all servers.
|
||||
pub async fn get_my_transactions(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<EconomyTransaction>>> {
|
||||
pub async fn get_my_transactions(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<EconomyTransaction>>> {
|
||||
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
|
||||
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
|
||||
FROM economy_transactions et
|
||||
@@ -140,10 +170,7 @@ pub async fn get_my_transactions(
|
||||
}
|
||||
|
||||
/// Get top richest players for a specific server.
|
||||
pub async fn get_server_baltop(
|
||||
Path(server_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
pub async fn get_server_baltop(Path(server_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||
"SELECT uuid, username, balance
|
||||
FROM server_economy
|
||||
@@ -155,25 +182,14 @@ pub async fn get_server_baltop(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||
rank: i + 1,
|
||||
uuid,
|
||||
username,
|
||||
balance,
|
||||
})
|
||||
.collect();
|
||||
let list =
|
||||
rows.into_iter().enumerate().map(|(i, (uuid, username, balance))| BaltopEntry { rank: i + 1, uuid, username, balance }).collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Get active marketplace listings for a server.
|
||||
pub async fn get_server_market(
|
||||
Path(server_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<MarketListing>>> {
|
||||
pub async fn get_server_market(Path(server_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Vec<MarketListing>>> {
|
||||
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.id, m.server_id, m.seller_uuid, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.created_at, g.tag
|
||||
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id
|
||||
@@ -216,7 +232,12 @@ pub struct ServerBalanceQuery {
|
||||
|
||||
/// A player's balance on a server (0 until they have an account there).
|
||||
pub async fn balance_for(state: &AppState, server_id: i64, uuid: &str) -> AppResult<f64> {
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&state.db).await?.unwrap_or(0.0))
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub async fn server_get_balance(
|
||||
@@ -227,13 +248,11 @@ pub async fn server_get_balance(
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let username = payload.username.unwrap_or_else(|| "Player".into());
|
||||
|
||||
let bal_opt: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let bal_opt: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let balance = match bal_opt {
|
||||
Some(b) => b,
|
||||
@@ -286,13 +305,11 @@ pub async fn server_transfer(
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Check sender balance
|
||||
let from_bal: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
||||
)
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.from_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let from_bal: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.from_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let current_from_bal = from_bal.unwrap_or(0.0);
|
||||
if current_from_bal < payload.amount {
|
||||
@@ -381,10 +398,7 @@ pub async fn server_sync_balance(
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn server_baltop(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
pub async fn server_baltop(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<BaltopEntry>>> {
|
||||
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
||||
"SELECT uuid, username, balance
|
||||
FROM server_economy
|
||||
@@ -396,16 +410,8 @@ pub async fn server_baltop(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
||||
rank: i + 1,
|
||||
uuid,
|
||||
username,
|
||||
balance,
|
||||
})
|
||||
.collect();
|
||||
let list =
|
||||
rows.into_iter().enumerate().map(|(i, (uuid, username, balance))| BaltopEntry { rank: i + 1, uuid, username, balance }).collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
@@ -430,20 +436,36 @@ pub async fn server_market_list(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<MarketListPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 { return Err(AppError::bad_request("Invalid listing")); }
|
||||
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 {
|
||||
return Err(AppError::bad_request("Invalid listing"));
|
||||
}
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let limit = crate::progression::load(&mut tx).await?.rules.market_max_listings;
|
||||
if limit > 0 {
|
||||
let mine: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market WHERE server_id = ? AND seller_uuid = ?").bind(server.id).bind(&payload.seller_uuid).fetch_one(&mut *tx).await?;
|
||||
if mine >= limit { return Err(AppError::bad_request(format!("You can have at most {limit} market listings at once"))); }
|
||||
let mine: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market WHERE server_id = ? AND seller_uuid = ?")
|
||||
.bind(server.id)
|
||||
.bind(&payload.seller_uuid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if mine >= limit {
|
||||
return Err(AppError::bad_request(format!("You can have at most {limit} market listings at once")));
|
||||
}
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let guild_id = if payload.as_guild {
|
||||
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can sell for the guild")); }
|
||||
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||
if !m.can_spend() {
|
||||
return Err(AppError::forbidden("Only guild leaders and officers can sell for the guild"));
|
||||
}
|
||||
Some(m.guild_id)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data, seller_guild_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -482,7 +504,9 @@ pub async fn server_market_buy(
|
||||
Json(payload): Json<MarketBuyPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let listing: Option<(String, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"DELETE FROM server_market
|
||||
WHERE id = ? AND server_id = ?
|
||||
@@ -503,10 +527,16 @@ pub async fn server_market_buy(
|
||||
None => None,
|
||||
};
|
||||
let buyer_guild = if payload.as_guild {
|
||||
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.buyer_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can spend guild money")); }
|
||||
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.buyer_uuid)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||
if !m.can_spend() {
|
||||
return Err(AppError::forbidden("Only guild leaders and officers can spend guild money"));
|
||||
}
|
||||
Some(m)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ensure_balance(&mut tx, server.economy_id, &payload.buyer_uuid, &payload.buyer_name).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -514,38 +544,67 @@ pub async fn server_market_buy(
|
||||
// Buyer pays: their own balance, or their guild's bank.
|
||||
let new_balance = if let Some(m) = &buyer_guild {
|
||||
let left = crate::routes::guild_bank::adjust_wallet(&mut tx, server.economy_id, &m.guild_id, -price).await?;
|
||||
crate::routes::guild_bank::log(&mut tx, server.economy_id, &m.guild_id, &payload.buyer_uuid, "purchase", price, &format!("Market: {amount}x {item_name}")).await?;
|
||||
crate::routes::guild_bank::log(
|
||||
&mut tx,
|
||||
server.economy_id,
|
||||
&m.guild_id,
|
||||
&payload.buyer_uuid,
|
||||
"purchase",
|
||||
price,
|
||||
&format!("Market: {amount}x {item_name}"),
|
||||
)
|
||||
.await?;
|
||||
left
|
||||
} else {
|
||||
let buyer_bal: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server.economy_id).bind(&payload.buyer_uuid).fetch_optional(&mut *tx).await?.unwrap_or(0.0);
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
if buyer_bal < price {
|
||||
return Err(AppError::bad_request("Insufficient funds"));
|
||||
}
|
||||
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(price).bind(&now).bind(server.economy_id).bind(&payload.buyer_uuid).execute(&mut *tx).await?;
|
||||
.bind(price)
|
||||
.bind(&now)
|
||||
.bind(server.economy_id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
buyer_bal - price
|
||||
};
|
||||
|
||||
// Seller is paid: the guild bank for guild listings, otherwise the player.
|
||||
let (to_uuid, to_name) = if let Some((guild_id, tag)) = &seller_guild {
|
||||
crate::routes::guild_bank::adjust_wallet(&mut tx, server.economy_id, guild_id, price).await?;
|
||||
crate::routes::guild_bank::log(&mut tx, server.economy_id, guild_id, &seller_uuid, "sale", price, &format!("Market: {amount}x {item_name}")).await?;
|
||||
crate::routes::guild_bank::log(
|
||||
&mut tx,
|
||||
server.economy_id,
|
||||
guild_id,
|
||||
&seller_uuid,
|
||||
"sale",
|
||||
price,
|
||||
&format!("Market: {amount}x {item_name}"),
|
||||
)
|
||||
.await?;
|
||||
(format!("guild:{guild_id}"), format!("[{tag}] guild bank"))
|
||||
} else {
|
||||
// A seller without an account first gets the usual starting balance, then the sale on top.
|
||||
ensure_balance(&mut tx, server.economy_id, &seller_uuid, &seller_name).await?;
|
||||
sqlx::query("UPDATE server_economy SET balance = balance + ?, username = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(price).bind(&seller_name).bind(&now).bind(server.economy_id).bind(&seller_uuid)
|
||||
.execute(&mut *tx).await?;
|
||||
.bind(price)
|
||||
.bind(&seller_name)
|
||||
.bind(&now)
|
||||
.bind(server.economy_id)
|
||||
.bind(&seller_uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
(seller_uuid.clone(), seller_name.clone())
|
||||
};
|
||||
|
||||
// Delete listing
|
||||
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
||||
.bind(payload.listing_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM server_market WHERE id = ?").bind(payload.listing_id).execute(&mut *tx).await?;
|
||||
|
||||
// Log transaction
|
||||
let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name);
|
||||
@@ -554,8 +613,14 @@ pub async fn server_market_buy(
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(match &buyer_guild { Some(m) => format!("guild:{}", m.guild_id), None => payload.buyer_uuid.clone() })
|
||||
.bind(match &buyer_guild { Some(m) => format!("[{}] guild bank", m.tag), None => payload.buyer_name.clone() })
|
||||
.bind(match &buyer_guild {
|
||||
Some(m) => format!("guild:{}", m.guild_id),
|
||||
None => payload.buyer_uuid.clone(),
|
||||
})
|
||||
.bind(match &buyer_guild {
|
||||
Some(m) => format!("[{}] guild bank", m.tag),
|
||||
None => payload.buyer_name.clone(),
|
||||
})
|
||||
.bind(&to_uuid)
|
||||
.bind(&to_name)
|
||||
.bind(price)
|
||||
@@ -564,13 +629,19 @@ pub async fn server_market_buy(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({
|
||||
"ok": true,
|
||||
"item_id": item_id,
|
||||
"item_data": item_data,
|
||||
"item_name": item_name,
|
||||
"amount": amount,
|
||||
"price": price,
|
||||
"new_balance": new_balance
|
||||
})).await
|
||||
finish_operation(
|
||||
tx,
|
||||
server.id,
|
||||
&payload.operation_id,
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"item_id": item_id,
|
||||
"item_data": item_data,
|
||||
"item_name": item_name,
|
||||
"amount": amount,
|
||||
"price": price,
|
||||
"new_balance": new_balance
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -54,7 +54,8 @@ pub async fn templates(state: &AppState) -> AppResult<Vec<EmailTemplate>> {
|
||||
Ok(if stored.is_empty() { defaults() } else { stored })
|
||||
}
|
||||
|
||||
pub const PLACEHOLDERS: [&str; 11] = ["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"];
|
||||
pub const PLACEHOLDERS: [&str; 11] =
|
||||
["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
@@ -112,7 +113,8 @@ fn emphasis(s: &str) -> String {
|
||||
|
||||
/// Plain text to the branded HTML layout. Everything the admin or a player typed is escaped first.
|
||||
pub fn html(body: &str, brand: &str, accent: &str, footer: &str) -> String {
|
||||
let accent = if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" };
|
||||
let accent =
|
||||
if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" };
|
||||
let mut content = String::new();
|
||||
let mut list = false;
|
||||
for raw in body.lines() {
|
||||
@@ -185,15 +187,27 @@ async fn vars_for(state: &AppState, p: &Person) -> AppResult<Vars> {
|
||||
let brand = store::branding(state).await?.name;
|
||||
let panel = state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string();
|
||||
let mut v = Vars::new();
|
||||
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
let level: Option<(i64, Option<String>)> =
|
||||
sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
let (lvl, title) = level.unwrap_or((1, None));
|
||||
let title = plain(&title.unwrap_or_default());
|
||||
let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
let guild: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?")
|
||||
.bind(&p.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let (gn, gt) = guild.unwrap_or_default();
|
||||
for (k, val) in [
|
||||
("player", plain(&p.username)), ("uuid", p.uuid.clone()), ("email", p.email.clone()), ("level", lvl.to_string()),
|
||||
("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }), ("rank_title", title),
|
||||
("guild", plain(&gn)), ("guild_tag", plain(>)), ("brand", brand), ("panel_url", panel.clone()),
|
||||
("player", plain(&p.username)),
|
||||
("uuid", p.uuid.clone()),
|
||||
("email", p.email.clone()),
|
||||
("level", lvl.to_string()),
|
||||
("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }),
|
||||
("rank_title", title),
|
||||
("guild", plain(&gn)),
|
||||
("guild_tag", plain(>)),
|
||||
("brand", brand),
|
||||
("panel_url", panel.clone()),
|
||||
("unsubscribe_url", format!("{panel}/api/v1/email/unsubscribe?u={}&t={}", p.uuid, unsub_token(state, &p.uuid).await?)),
|
||||
] {
|
||||
v.insert(k.into(), val);
|
||||
@@ -216,7 +230,10 @@ async fn build(state: &AppState, subject: &str, body: &str, important: bool, p:
|
||||
let footer_text = if important {
|
||||
format!("This message is about your {brand} account.")
|
||||
} else {
|
||||
format!("You are receiving this because you have an account on {brand}. <a href=\"{}\" style=\"color:#8d97b8\">Unsubscribe</a>", esc(&vars["unsubscribe_url"]))
|
||||
format!(
|
||||
"You are receiving this because you have an account on {brand}. <a href=\"{}\" style=\"color:#8d97b8\">Unsubscribe</a>",
|
||||
esc(&vars["unsubscribe_url"])
|
||||
)
|
||||
};
|
||||
let mut plain_text = text.clone();
|
||||
if !important {
|
||||
@@ -241,14 +258,22 @@ pub struct Audience {
|
||||
|
||||
async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResult<Vec<Person>> {
|
||||
let optout = if important { "" } else { " AND u.email_optout = 0" };
|
||||
let base = format!("SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}");
|
||||
let base = format!(
|
||||
"SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}"
|
||||
);
|
||||
let rows: Vec<(String, String, String)> = match a.kind.as_str() {
|
||||
"all" => sqlx::query_as(&format!("{base} ORDER BY u.id")).fetch_all(&state.db).await?,
|
||||
"group" => sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id")).bind(a.group_id).fetch_all(&state.db).await?,
|
||||
"group" => {
|
||||
sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id"))
|
||||
.bind(a.group_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
"players" => {
|
||||
let mut out = Vec::new();
|
||||
for n in a.names.iter().take(500) {
|
||||
let r: Option<(String, String, String)> = sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?;
|
||||
let r: Option<(String, String, String)> =
|
||||
sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?;
|
||||
out.extend(r);
|
||||
}
|
||||
out
|
||||
@@ -264,8 +289,11 @@ async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResul
|
||||
|
||||
pub async fn get_all(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let conn = connections::settings(&state).await?;
|
||||
let log: Vec<(i64, String, String, i64, i64, i64, Option<String>, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20").fetch_all(&state.db).await?;
|
||||
let log: Vec<(i64, String, String, i64, i64, i64, Option<String>, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let groups: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM groups ORDER BY name").fetch_all(&state.db).await?;
|
||||
Ok(Json(json!({
|
||||
"configured": !conn.resend_api_key.is_empty() && !conn.sender_email.is_empty(),
|
||||
@@ -330,7 +358,9 @@ pub struct AudienceBody {
|
||||
|
||||
pub async fn audience(_: AdminUser, State(state): State<AppState>, Json(b): Json<AudienceBody>) -> AppResult<Json<Value>> {
|
||||
let list = recipients(&state, &b.audience, b.important).await?;
|
||||
Ok(Json(json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::<Vec<_>>(), "limit": MAX_RECIPIENTS })))
|
||||
Ok(Json(
|
||||
json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::<Vec<_>>(), "limit": MAX_RECIPIENTS }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -362,11 +392,21 @@ pub async fn send(admin: AdminUser, State(state): State<AppState>, Json(b): Json
|
||||
}
|
||||
let label = match b.audience.kind.as_str() {
|
||||
"all" => "Everyone".to_string(),
|
||||
"group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?").bind(b.audience.group_id).fetch_optional(&state.db).await?.map(|n| format!("Group: {n}")).unwrap_or_else(|| "A group".into()),
|
||||
"group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?")
|
||||
.bind(b.audience.group_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|n| format!("Group: {n}"))
|
||||
.unwrap_or_else(|| "A group".into()),
|
||||
_ => format!("{} chosen players", people.len()),
|
||||
};
|
||||
let log_id: i64 = sqlx::query_scalar("INSERT INTO email_log (subject, audience, total, started_at) VALUES (?, ?, ?, ?) RETURNING id")
|
||||
.bind(b.subject.chars().take(200).collect::<String>()).bind(label).bind(people.len() as i64).bind(crate::db::now()).fetch_one(&state.db).await?;
|
||||
.bind(b.subject.chars().take(200).collect::<String>())
|
||||
.bind(label)
|
||||
.bind(people.len() as i64)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let total = people.len();
|
||||
let bg = state.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -376,8 +416,16 @@ pub async fn send(admin: AdminUser, State(state): State<AppState>, Json(b): Json
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match outcome {
|
||||
Ok(()) => { let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await; }
|
||||
Err(e) => { let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?").bind(e.message.chars().take(200).collect::<String>()).bind(log_id).execute(&bg.db).await; }
|
||||
Ok(()) => {
|
||||
let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?")
|
||||
.bind(e.message.chars().take(200).collect::<String>())
|
||||
.bind(log_id)
|
||||
.execute(&bg.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
@@ -397,7 +445,9 @@ pub struct UnsubQuery {
|
||||
}
|
||||
|
||||
pub async fn unsubscribe(State(state): State<AppState>, Query(q): Query<UnsubQuery>) -> Html<String> {
|
||||
let page = |msg: &str| Html(format!("<!doctype html><meta name=viewport content='width=device-width'><body style=\"font-family:system-ui;background:#0f1220;color:#e9ecff;display:grid;place-items:center;min-height:100vh;margin:0\"><div style=\"max-width:420px;padding:32px;text-align:center\"><h2>{}</h2></div></body>", esc(msg)));
|
||||
let page = |msg: &str| {
|
||||
Html(format!("<!doctype html><meta name=viewport content='width=device-width'><body style=\"font-family:system-ui;background:#0f1220;color:#e9ecff;display:grid;place-items:center;min-height:100vh;margin:0\"><div style=\"max-width:420px;padding:32px;text-align:center\"><h2>{}</h2></div></body>", esc(msg)))
|
||||
};
|
||||
let ok = matches!(unsub_token(&state, &q.u).await, Ok(t) if t == q.t);
|
||||
if !ok {
|
||||
return page("That link isn't valid.");
|
||||
|
||||
@@ -71,7 +71,15 @@ pub async fn adjust_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id
|
||||
balance.ok_or_else(|| AppError::bad_request("The guild bank doesn't have enough funds"))
|
||||
}
|
||||
|
||||
pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, actor: &str, kind: &str, amount: f64, note: &str) -> AppResult<()> {
|
||||
pub async fn log(
|
||||
conn: &mut SqliteConnection,
|
||||
server_id: i64,
|
||||
guild_id: &str,
|
||||
actor: &str,
|
||||
kind: &str,
|
||||
amount: f64,
|
||||
note: &str,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query("INSERT INTO guild_wallet_transactions(server_id, guild_id, actor_uuid, kind, amount, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(server_id)
|
||||
.bind(guild_id)
|
||||
@@ -86,7 +94,12 @@ pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, ac
|
||||
}
|
||||
|
||||
async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<f64> {
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0.0))
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,7 +147,11 @@ pub struct Transfer {
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Transfer>) -> AppResult<Json<Value>> {
|
||||
pub async fn server_bank_transfer(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<Transfer>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
valid_amount(p.amount)?;
|
||||
let withdraw = match p.action.as_str() {
|
||||
"deposit" => false,
|
||||
@@ -164,8 +181,18 @@ pub async fn server_bank_transfer(GameServer(server): GameServer, State(state):
|
||||
let my_balance = moved.ok_or_else(|| AppError::bad_request("Insufficient funds"))?;
|
||||
let balance = adjust_wallet(&mut tx, server.economy_id, &m.guild_id, guild_delta).await?;
|
||||
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, &p.action, p.amount, "").await?;
|
||||
let message = if withdraw { format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag) } else { format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag) };
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message })).await
|
||||
let message = if withdraw {
|
||||
format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag)
|
||||
} else {
|
||||
format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag)
|
||||
};
|
||||
finish_operation(
|
||||
tx,
|
||||
server.id,
|
||||
&p.operation_id,
|
||||
json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -179,7 +206,11 @@ pub struct Credit {
|
||||
}
|
||||
|
||||
/// Shop sales made with `/guild sell`: the proceeds go to the guild bank instead of the seller.
|
||||
pub async fn server_bank_credit(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Credit>) -> AppResult<Json<Value>> {
|
||||
pub async fn server_bank_credit(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<Credit>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
valid_amount(p.amount)?;
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
@@ -226,7 +257,8 @@ pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State
|
||||
let balance = adjust_wallet(&mut tx, server.economy_id, &m.guild_id, -p.amount).await?;
|
||||
adjust_wallet(&mut tx, server.economy_id, &target_id, p.amount).await?;
|
||||
let note = p.note.chars().take(60).collect::<String>();
|
||||
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}")).await?;
|
||||
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}"))
|
||||
.await?;
|
||||
log(&mut tx, server.economy_id, &target_id, &p.uuid, "transfer_in", p.amount, &format!("from [{}] {} {note}", m.tag, m.name)).await?;
|
||||
let message = format!("Paid ${:.2} from [{}] to [{}] {}.", p.amount, m.tag, target_tag, target_name);
|
||||
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
|
||||
|
||||
@@ -147,74 +147,152 @@ pub async fn guild_wallet(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?)")
|
||||
.bind(&guild_id).bind(&auth.uuid).bind(query.server_id).fetch_one(&state.db).await?;
|
||||
if !member { return Err(AppError::forbidden("Guild membership is required")); }
|
||||
if !member {
|
||||
return Err(AppError::forbidden("Guild membership is required"));
|
||||
}
|
||||
let sid = crate::routes::servers::economy_scope(&state.db, query.server_id).await?;
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id=? AND server_id=?")
|
||||
.bind(&guild_id).bind(sid).fetch_optional(&state.db).await?.unwrap_or(0.0);
|
||||
.bind(&guild_id)
|
||||
.bind(sid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
let rows: Vec<(i64, String, String, f64, String, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at,note FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30")
|
||||
.bind(&guild_id).bind(sid).fetch_all(&state.db).await?;
|
||||
let role: String = sqlx::query_scalar("SELECT gm.role FROM guild_members gm WHERE gm.guild_id=? AND gm.uuid=?")
|
||||
.bind(&guild_id).bind(&auth.uuid).fetch_one(&state.db).await?;
|
||||
.bind(&guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
// A player's first transfer creates their balance at 1000, so show that until then.
|
||||
let my_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id=? AND uuid=?")
|
||||
.bind(sid).bind(&auth.uuid).fetch_optional(&state.db).await?.unwrap_or(1000.0);
|
||||
Ok(Json(serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()})))
|
||||
.bind(sid)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.unwrap_or(1000.0);
|
||||
Ok(Json(
|
||||
serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
|
||||
pub async fn guild_wallet_deposit(
|
||||
auth: AuthUser,
|
||||
Path(guild_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<GuildWalletTransfer>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
wallet_transfer(&state, &auth, &guild_id, p, false).await
|
||||
}
|
||||
|
||||
pub async fn guild_wallet_withdraw(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
|
||||
pub async fn guild_wallet_withdraw(
|
||||
auth: AuthUser,
|
||||
Path(guild_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<GuildWalletTransfer>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
wallet_transfer(&state, &auth, &guild_id, p, true).await
|
||||
}
|
||||
|
||||
async fn wallet_transfer(state: &AppState, auth: &AuthUser, guild_id: &str, p: GuildWalletTransfer, withdraw: bool) -> AppResult<Json<Value>> {
|
||||
async fn wallet_transfer(
|
||||
state: &AppState,
|
||||
auth: &AuthUser,
|
||||
guild_id: &str,
|
||||
p: GuildWalletTransfer,
|
||||
withdraw: bool,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if !p.amount.is_finite() || p.amount <= 0.0 || p.amount > 1e9 || (p.amount * 100.0).fract().abs() > 0.00001 {
|
||||
return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals"));
|
||||
}
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT gm.role FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?")
|
||||
.bind(guild_id).bind(&auth.uuid).bind(p.server_id).fetch_optional(&state.db).await?;
|
||||
let Some(role) = role else { return Err(AppError::forbidden("Guild membership is required")); };
|
||||
if withdraw && role != "leader" && role != "officer" { return Err(AppError::forbidden("Only guild leaders and officers can withdraw")); }
|
||||
let Some(role) = role else {
|
||||
return Err(AppError::forbidden("Guild membership is required"));
|
||||
};
|
||||
if withdraw && role != "leader" && role != "officer" {
|
||||
return Err(AppError::forbidden("Only guild leaders and officers can withdraw"));
|
||||
}
|
||||
// Servers in one economy group share balances and guild banks.
|
||||
let mut p = p;
|
||||
p.server_id = crate::routes::servers::economy_scope(&state.db, p.server_id).await?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut tx = state.db.begin().await?;
|
||||
// The first write serializes transfers across concurrent requests.
|
||||
sqlx::query("INSERT INTO guild_wallets(server_id,guild_id,balance,updated_at) VALUES(?,?,0,?) ON CONFLICT(server_id,guild_id) DO NOTHING")
|
||||
.bind(p.server_id).bind(guild_id).bind(&now).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO guild_wallets(server_id,guild_id,balance,updated_at) VALUES(?,?,0,?) ON CONFLICT(server_id,guild_id) DO NOTHING",
|
||||
)
|
||||
.bind(p.server_id)
|
||||
.bind(guild_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO server_economy(server_id,uuid,username,balance,updated_at) VALUES(?,?,?,1000,?) ON CONFLICT(server_id,uuid) DO NOTHING")
|
||||
.bind(p.server_id).bind(&auth.uuid).bind(&auth.username).bind(&now).execute(&mut *tx).await?;
|
||||
let source = if withdraw {
|
||||
sqlx::query("UPDATE guild_wallets SET balance=balance-?,updated_at=? WHERE server_id=? AND guild_id=? AND balance>=?")
|
||||
.bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).bind(p.amount).execute(&mut *tx).await?
|
||||
.bind(p.amount)
|
||||
.bind(&now)
|
||||
.bind(p.server_id)
|
||||
.bind(guild_id)
|
||||
.bind(p.amount)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query("UPDATE server_economy SET balance=balance-?,updated_at=? WHERE server_id=? AND uuid=? AND balance>=?")
|
||||
.bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).bind(p.amount).execute(&mut *tx).await?
|
||||
.bind(p.amount)
|
||||
.bind(&now)
|
||||
.bind(p.server_id)
|
||||
.bind(&auth.uuid)
|
||||
.bind(p.amount)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
if source.rows_affected() == 0 { return Err(AppError::bad_request("Insufficient funds")); }
|
||||
if source.rows_affected() == 0 {
|
||||
return Err(AppError::bad_request("Insufficient funds"));
|
||||
}
|
||||
if withdraw {
|
||||
sqlx::query("UPDATE server_economy SET balance=balance+?,updated_at=? WHERE server_id=? AND uuid=?")
|
||||
.bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).execute(&mut *tx).await?;
|
||||
.bind(p.amount)
|
||||
.bind(&now)
|
||||
.bind(p.server_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query("UPDATE guild_wallets SET balance=balance+?,updated_at=? WHERE server_id=? AND guild_id=?")
|
||||
.bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).execute(&mut *tx).await?;
|
||||
.bind(p.amount)
|
||||
.bind(&now)
|
||||
.bind(p.server_id)
|
||||
.bind(guild_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query("INSERT INTO guild_wallet_transactions(server_id,guild_id,actor_uuid,kind,amount,created_at) VALUES(?,?,?,?,?,?)")
|
||||
.bind(p.server_id).bind(guild_id).bind(&auth.uuid).bind(if withdraw { "withdraw" } else { "deposit" }).bind(p.amount).bind(&now).execute(&mut *tx).await?;
|
||||
.bind(p.server_id)
|
||||
.bind(guild_id)
|
||||
.bind(&auth.uuid)
|
||||
.bind(if withdraw { "withdraw" } else { "deposit" })
|
||||
.bind(p.amount)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id=? AND guild_id=?")
|
||||
.bind(p.server_id).bind(guild_id).fetch_one(&mut *tx).await?;
|
||||
.bind(p.server_id)
|
||||
.bind(guild_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(serde_json::json!({"balance":balance})))
|
||||
}
|
||||
|
||||
/// Refuse a new member when the guild is at the admin-set member limit (0 = no limit).
|
||||
pub async fn check_room(conn: &mut sqlx::SqliteConnection, guild_id: &str) -> AppResult<()> {
|
||||
let limit: i64 = sqlx::query_scalar("SELECT COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_guild_max_members'), 0)").fetch_one(&mut *conn).await?;
|
||||
let limit: i64 = sqlx::query_scalar("SELECT COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_guild_max_members'), 0)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if limit > 0 {
|
||||
let members: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
|
||||
let members: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
|
||||
if members >= limit {
|
||||
return Err(AppError::bad_request(format!("That guild is full ({limit} members)")));
|
||||
}
|
||||
@@ -481,14 +559,30 @@ pub struct AddMemberPayload {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinRequestPayload { #[serde(default)] pub message: String }
|
||||
pub struct JoinRequestPayload {
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(payload): Json<JoinRequestPayload>) -> AppResult<Json<Value>> {
|
||||
let instance: Option<String> = sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
|
||||
pub async fn request_join(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<JoinRequestPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let instance: Option<String> =
|
||||
sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
|
||||
let instance = instance.ok_or_else(|| AppError::not_found("guild not found"))?;
|
||||
let in_guild: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)")
|
||||
.bind(&auth.uuid).bind(&instance).fetch_one(&state.db).await?;
|
||||
if in_guild { return Err(AppError::bad_request("Leave your current guild before requesting to join another")); }
|
||||
let in_guild: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?)",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&instance)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if in_guild {
|
||||
return Err(AppError::bad_request("Leave your current guild before requesting to join another"));
|
||||
}
|
||||
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?").bind(auth.id).fetch_one(&state.db).await?;
|
||||
sqlx::query("INSERT INTO guild_join_requests(guild_id,uuid,name,message,created_at) VALUES(?,?,?,?,?) ON CONFLICT(guild_id,uuid) DO UPDATE SET message=excluded.message,created_at=excluded.created_at")
|
||||
.bind(&id).bind(&auth.uuid).bind(&name).bind(payload.message.trim().chars().take(300).collect::<String>()).bind(crate::db::now()).execute(&state.db).await?;
|
||||
@@ -496,25 +590,51 @@ pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state):
|
||||
}
|
||||
|
||||
pub async fn list_join_requests(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
let rows: Vec<(String,String,String,String)> = sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(rows.into_iter().map(|(uuid,name,message,created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at})).collect::<Vec<_>>())))
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot review requests"));
|
||||
}
|
||||
let rows: Vec<(String, String, String, String)> =
|
||||
sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!(rows
|
||||
.into_iter()
|
||||
.map(|(uuid, name, message, created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at}))
|
||||
.collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JoinDecision { pub accept: bool }
|
||||
pub struct JoinDecision {
|
||||
pub accept: bool,
|
||||
}
|
||||
|
||||
pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(decision): Json<JoinDecision>) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
|
||||
pub async fn respond_join_request(
|
||||
auth: AuthUser,
|
||||
Path((id, uuid)): Path<(String, String)>,
|
||||
State(state): State<AppState>,
|
||||
Json(decision): Json<JoinDecision>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot review requests"));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_join_requests WHERE guild_id=? AND uuid=?")
|
||||
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
|
||||
.bind(&id)
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
|
||||
if decision.accept {
|
||||
check_room(&mut tx, &id).await?;
|
||||
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
|
||||
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
|
||||
.bind(&id)
|
||||
.bind(&uuid)
|
||||
.bind(&name)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
|
||||
} else {
|
||||
sqlx::query("DELETE FROM guild_join_requests WHERE guild_id=? AND uuid=?").bind(&id).bind(&uuid).execute(&mut *tx).await?;
|
||||
@@ -526,79 +646,166 @@ pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String
|
||||
#[derive(Deserialize)]
|
||||
pub struct GuildRoleInput {
|
||||
pub name: String,
|
||||
#[serde(default)] pub priority: i64,
|
||||
#[serde(default)] pub can_invite: bool,
|
||||
#[serde(default)] pub can_kick: bool,
|
||||
#[serde(default)] pub can_claim: bool,
|
||||
#[serde(default)] pub can_post: bool,
|
||||
#[serde(default)] pub can_manage: bool,
|
||||
#[serde(default)]
|
||||
pub priority: i64,
|
||||
#[serde(default)]
|
||||
pub can_invite: bool,
|
||||
#[serde(default)]
|
||||
pub can_kick: bool,
|
||||
#[serde(default)]
|
||||
pub can_claim: bool,
|
||||
#[serde(default)]
|
||||
pub can_post: bool,
|
||||
#[serde(default)]
|
||||
pub can_manage: bool,
|
||||
}
|
||||
|
||||
async fn guild_can(state: &AppState, guild_id: &str, uuid: &str, action: &str) -> AppResult<bool> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
let Some(role) = role else { return Ok(false); };
|
||||
if role == "leader" || role == "officer" { return Ok(true); }
|
||||
if role == "member" { return Ok(matches!(action, "claim" | "post")); }
|
||||
let column = match action { "invite" => "can_invite", "kick" => "can_kick", "claim" => "can_claim", "post" => "can_post", "manage" => "can_manage", _ => return Ok(false) };
|
||||
.bind(guild_id)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let Some(role) = role else {
|
||||
return Ok(false);
|
||||
};
|
||||
if role == "leader" || role == "officer" {
|
||||
return Ok(true);
|
||||
}
|
||||
if role == "member" {
|
||||
return Ok(matches!(action, "claim" | "post"));
|
||||
}
|
||||
let column = match action {
|
||||
"invite" => "can_invite",
|
||||
"kick" => "can_kick",
|
||||
"claim" => "can_claim",
|
||||
"post" => "can_post",
|
||||
"manage" => "can_manage",
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let allowed: Option<i64> = sqlx::query_scalar(&format!("SELECT {column} FROM guild_roles WHERE guild_id=? AND name=?"))
|
||||
.bind(guild_id).bind(&role).fetch_optional(&state.db).await?;
|
||||
.bind(guild_id)
|
||||
.bind(&role)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(allowed.unwrap_or(0) != 0)
|
||||
}
|
||||
|
||||
async fn require_guild_leader(state: &AppState, guild_id: &str, uuid: &str) -> AppResult<()> {
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
|
||||
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
|
||||
if role.as_deref() != Some("leader") { return Err(AppError::forbidden("Only the guild leader can manage roles")); }
|
||||
.bind(guild_id)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if role.as_deref() != Some("leader") {
|
||||
return Err(AppError::forbidden("Only the guild leader can manage roles"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_guild_roles(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id=? AND uuid=?)")
|
||||
.bind(&id).bind(&auth.uuid).fetch_one(&state.db).await?;
|
||||
if !member { return Err(AppError::forbidden("Guild membership is required")); }
|
||||
.bind(&id)
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !member {
|
||||
return Err(AppError::forbidden("Guild membership is required"));
|
||||
}
|
||||
let roles: Vec<(i64,String,i64,i64,i64,i64,i64,i64)> = sqlx::query_as("SELECT id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage FROM guild_roles WHERE guild_id=? ORDER BY priority DESC,name")
|
||||
.bind(&id).fetch_all(&state.db).await?;
|
||||
Ok(Json(json!(roles.into_iter().map(|(id,name,priority,invite,kick,claim,post,manage)| json!({"id":id,"name":name,"priority":priority,"can_invite":invite!=0,"can_kick":kick!=0,"can_claim":claim!=0,"can_post":post!=0,"can_manage":manage!=0})).collect::<Vec<_>>())))
|
||||
}
|
||||
|
||||
pub async fn create_guild_role(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(role): Json<GuildRoleInput>) -> AppResult<Json<Value>> {
|
||||
pub async fn create_guild_role(
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(role): Json<GuildRoleInput>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name = role.name.trim();
|
||||
if name.len() < 2 || name.len() > 24 || !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|
||||
|| ["leader","officer","member"].iter().any(|r| r.eq_ignore_ascii_case(name)) {
|
||||
return Err(AppError::bad_request("Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role"));
|
||||
if name.len() < 2
|
||||
|| name.len() > 24
|
||||
|| !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|
||||
|| ["leader", "officer", "member"].iter().any(|r| r.eq_ignore_ascii_case(name))
|
||||
{
|
||||
return Err(AppError::bad_request(
|
||||
"Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role",
|
||||
));
|
||||
}
|
||||
let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=? COLLATE NOCASE)")
|
||||
.bind(&id).bind(name).fetch_one(&state.db).await?;
|
||||
if duplicate { return Err(AppError::conflict("A role with this name already exists")); }
|
||||
let inserted = sqlx::query("INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)")
|
||||
.bind(&id).bind(name).bind(role.priority.clamp(0,100)).bind(role.can_invite).bind(role.can_kick).bind(role.can_claim).bind(role.can_post).bind(role.can_manage)
|
||||
.execute(&state.db).await?;
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if duplicate {
|
||||
return Err(AppError::conflict("A role with this name already exists"));
|
||||
}
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.bind(role.priority.clamp(0, 100))
|
||||
.bind(role.can_invite)
|
||||
.bind(role.can_kick)
|
||||
.bind(role.can_claim)
|
||||
.bind(role.can_post)
|
||||
.bind(role.can_manage)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({"ok":true,"id":inserted.last_insert_rowid()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AssignRole { pub role: String }
|
||||
pub struct AssignRole {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
pub async fn assign_guild_role(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(input): Json<AssignRole>) -> AppResult<Json<Value>> {
|
||||
pub async fn assign_guild_role(
|
||||
auth: AuthUser,
|
||||
Path((id, uuid)): Path<(String, String)>,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<AssignRole>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
if input.role == "leader" { return Err(AppError::bad_request("Use leadership transfer to assign the leader role")); }
|
||||
if input.role == "leader" {
|
||||
return Err(AppError::bad_request("Use leadership transfer to assign the leader role"));
|
||||
}
|
||||
if input.role != "member" && input.role != "officer" {
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=?)")
|
||||
.bind(&id).bind(&input.role).fetch_one(&state.db).await?;
|
||||
if !exists { return Err(AppError::bad_request("Role does not belong to this guild")); }
|
||||
.bind(&id)
|
||||
.bind(&input.role)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(AppError::bad_request("Role does not belong to this guild"));
|
||||
}
|
||||
}
|
||||
let done = sqlx::query("UPDATE guild_members SET role=? WHERE guild_id=? AND uuid=? AND role<>'leader'")
|
||||
.bind(&input.role).bind(&id).bind(&uuid).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 { return Err(AppError::not_found("member not found or is guild leader")); }
|
||||
.bind(&input.role)
|
||||
.bind(&id)
|
||||
.bind(&uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() == 0 {
|
||||
return Err(AppError::not_found("member not found or is guild leader"));
|
||||
}
|
||||
Ok(Json(json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn delete_guild_role(auth: AuthUser, Path((id, role_id)): Path<(String,i64)>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
pub async fn delete_guild_role(
|
||||
auth: AuthUser,
|
||||
Path((id, role_id)): Path<(String, i64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_guild_leader(&state, &id, &auth.uuid).await?;
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_roles WHERE guild_id=? AND id=?")
|
||||
.bind(&id).bind(role_id).fetch_optional(&state.db).await?;
|
||||
.bind(&id)
|
||||
.bind(role_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("role not found"))?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE guild_members SET role='member' WHERE guild_id=? AND role=?").bind(&id).bind(&name).execute(&mut *tx).await?;
|
||||
@@ -1061,7 +1268,8 @@ pub async fn server_claim_index(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ClaimIndexPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let revision: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into());
|
||||
let revision: String =
|
||||
sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into());
|
||||
if payload.revision.as_deref() == Some(revision.as_str()) {
|
||||
return Ok(Json(json!({ "revision": revision, "unchanged": true })));
|
||||
}
|
||||
@@ -1166,7 +1374,9 @@ pub async fn server_claim_chunk(
|
||||
let Some((guild_id, _role)) = member else {
|
||||
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
|
||||
};
|
||||
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
|
||||
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? {
|
||||
return Err(AppError::forbidden("Your guild role cannot claim land"));
|
||||
}
|
||||
|
||||
let max_claims: i64 =
|
||||
sqlx::query_scalar("SELECT (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
|
||||
|
||||
@@ -61,12 +61,18 @@ pub struct GroupMapping {
|
||||
pub luckperms_group: String,
|
||||
}
|
||||
|
||||
pub async fn set_group_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<GroupMapping>) -> AppResult<Json<Value>> {
|
||||
pub async fn set_group_mapping(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(m): Json<GroupMapping>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let lp = m.luckperms_group.trim();
|
||||
if lp.len() > 64 || !lp.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) {
|
||||
return Err(AppError::bad_request("LuckPerms group names are letters, digits, _ - and ."));
|
||||
}
|
||||
let done = sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?;
|
||||
let done =
|
||||
sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?;
|
||||
if done.rows_affected() == 0 {
|
||||
return Err(AppError::not_found("group not found"));
|
||||
}
|
||||
@@ -129,7 +135,8 @@ fn clean(s: &str, max: usize) -> String {
|
||||
async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Value) -> AppResult<Value> {
|
||||
let settings = load_settings(conn).await?;
|
||||
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
|
||||
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
|
||||
let mapped: Vec<(i64, String)> =
|
||||
sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
|
||||
let level_groups: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.luckperms_group') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.luckperms_group') IS NOT NULL AND json_extract(reward_data, '$.luckperms_group') <> '' ORDER BY level_req DESC")
|
||||
.fetch_all(&mut *conn).await?;
|
||||
let now = crate::db::now();
|
||||
@@ -159,7 +166,11 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if !level_groups.is_empty() {
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?")
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
let level = crate::progression::level_from_xp(xp).0;
|
||||
let target = level_groups.iter().find(|(required, _)| *required <= level).map(|(_, group)| group.to_lowercase());
|
||||
let mut add = Vec::new();
|
||||
@@ -167,21 +178,37 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
|
||||
for (_, group) in &level_groups {
|
||||
let key = group.to_lowercase();
|
||||
if target.as_deref() == Some(key.as_str()) {
|
||||
if !groups.contains(&key) { add.push(key); }
|
||||
} else if groups.contains(&key) { remove.push(key); }
|
||||
if !groups.contains(&key) {
|
||||
add.push(key);
|
||||
}
|
||||
} else if groups.contains(&key) {
|
||||
remove.push(key);
|
||||
}
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() {
|
||||
level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove}));
|
||||
}
|
||||
if !add.is_empty() || !remove.is_empty() { level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove})); }
|
||||
}
|
||||
if settings.luckperms_sync == "off" || mapped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let user_id: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?;
|
||||
let user_id: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?;
|
||||
let Some(user_id) = user_id else { continue };
|
||||
let member_of: HashSet<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&mut *conn).await?.into_iter().collect();
|
||||
let member_of: HashSet<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let lp_set: HashSet<String> = groups.iter().cloned().collect();
|
||||
let plan = plan(&settings.luckperms_sync, &lp_set, &mapped, &member_of);
|
||||
for gid in &plan.panel_add {
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(gid).execute(&mut *conn).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)")
|
||||
.bind(user_id)
|
||||
.bind(gid)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
for gid in &plan.panel_remove {
|
||||
sqlx::query("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?").bind(user_id).bind(gid).execute(&mut *conn).await?;
|
||||
@@ -245,7 +272,10 @@ pub async fn server_report(GameServer(server): GameServer, State(state): State<A
|
||||
pub async fn admin_server(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
get_server(&state, id).await?;
|
||||
let rows: Vec<(String, String, String, String)> =
|
||||
sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name").bind(id).fetch_all(&state.db).await?;
|
||||
sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name")
|
||||
.bind(id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let list: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(name, version, data, updated_at)| json!({ "name": name, "version": version, "data": serde_json::from_str::<Value>(&data).unwrap_or(Value::Null), "updated_at": updated_at }))
|
||||
@@ -297,12 +327,22 @@ pub async fn notify(conn: &mut SqliteConnection, server_id: Option<i64>, kind: &
|
||||
/// Take (and delete) what is waiting for this server. Oldest first, bounded.
|
||||
pub async fn take_notifications(conn: &mut SqliteConnection, server_id: i64) -> AppResult<Vec<Value>> {
|
||||
let rows: Vec<(i64, String, String, String, String)> =
|
||||
sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100").bind(server_id).fetch_all(&mut *conn).await?;
|
||||
sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100")
|
||||
.bind(server_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
if let Some(last) = rows.last().map(|r| r.0) {
|
||||
sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?").bind(server_id).bind(last).execute(&mut *conn).await?;
|
||||
sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?")
|
||||
.bind(server_id)
|
||||
.bind(last)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
// Stale entries (a server that was offline for days) aren't worth replaying.
|
||||
sqlx::query("DELETE FROM server_notifications WHERE created_at < ?").bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)).execute(&mut *conn).await?;
|
||||
sqlx::query("DELETE FROM server_notifications WHERE created_at < ?")
|
||||
.bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(_, kind, uuid, payload, at)| json!({ "kind": kind, "uuid": uuid, "data": serde_json::from_str::<Value>(&payload).unwrap_or(Value::Null), "at": at })).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,7 @@ pub struct FaqItem {
|
||||
|
||||
impl Default for FaqItem {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "".into(),
|
||||
question: "".into(),
|
||||
answer: "".into(),
|
||||
}
|
||||
Self { id: "".into(), question: "".into(), answer: "".into() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,23 +79,22 @@ pub struct LandingTheme {
|
||||
impl Default for LandingTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: "#090a0f".into(), surface: "#151620".into(), accent: "#6d6af5".into(),
|
||||
text: "#f1f2f6".into(), muted: "#8b8f9a".into(), max_width: 1140, radius: 14,
|
||||
font: "Inter".into(), footer_text: "Your worlds, one click away.".into(),
|
||||
background: "#090a0f".into(),
|
||||
surface: "#151620".into(),
|
||||
accent: "#6d6af5".into(),
|
||||
text: "#f1f2f6".into(),
|
||||
muted: "#8b8f9a".into(),
|
||||
max_width: 1140,
|
||||
radius: 14,
|
||||
font: "Inter".into(),
|
||||
footer_text: "Your worlds, one click away.".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LandingBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "".into(),
|
||||
block_type: "hero".into(),
|
||||
enabled: true,
|
||||
title: None,
|
||||
subtitle: None,
|
||||
options: serde_json::json!({}),
|
||||
}
|
||||
Self { id: "".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +187,9 @@ pub async fn public_landing(State(state): State<AppState>) -> AppResult<Json<Lan
|
||||
}
|
||||
// If server ip is empty, try to populate from first server
|
||||
if cfg.server_ip.is_empty() {
|
||||
if let Ok(Some(first_server)) = sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await {
|
||||
if let Ok(Some(first_server)) =
|
||||
sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await
|
||||
{
|
||||
cfg.server_ip = first_server;
|
||||
}
|
||||
}
|
||||
@@ -203,7 +200,11 @@ pub async fn admin_get_landing(_: AdminUser, State(state): State<AppState>) -> A
|
||||
Ok(Json(get_config(&state).await?))
|
||||
}
|
||||
|
||||
pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json(cfg): Json<LandingConfig>) -> AppResult<Json<LandingConfig>> {
|
||||
pub async fn admin_put_landing(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Json(cfg): Json<LandingConfig>,
|
||||
) -> AppResult<Json<LandingConfig>> {
|
||||
if cfg.blocks.len() > 100 || cfg.custom_css.len() > 100_000 {
|
||||
return Err(AppError::bad_request("landing page exceeds the editor limits"));
|
||||
}
|
||||
@@ -217,11 +218,7 @@ pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json
|
||||
Ok(Json(cfg))
|
||||
}
|
||||
|
||||
pub async fn upload_launcher(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
mut form: Multipart,
|
||||
) -> AppResult<Json<LandingConfig>> {
|
||||
pub async fn upload_launcher(_: AdminUser, State(state): State<AppState>, mut form: Multipart) -> AppResult<Json<LandingConfig>> {
|
||||
let mut platform = String::from("windows");
|
||||
let mut label = String::from("Windows Installer");
|
||||
let mut filename = String::new();
|
||||
@@ -249,10 +246,8 @@ pub async fn upload_launcher(
|
||||
}
|
||||
|
||||
// Sanitize filename to prevent directory traversal
|
||||
let safe_name = PathBuf::from(&filename)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format!("launcher-{platform}"));
|
||||
let safe_name =
|
||||
PathBuf::from(&filename).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| format!("launcher-{platform}"));
|
||||
|
||||
let dir = state.cfg.downloads_dir();
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
@@ -265,14 +260,7 @@ pub async fn upload_launcher(
|
||||
let mut cfg = get_config(&state).await?;
|
||||
let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
|
||||
let download_entry = HostedDownload {
|
||||
platform: platform.clone(),
|
||||
label,
|
||||
filename: safe_name,
|
||||
file_url,
|
||||
size,
|
||||
uploaded_at: ts,
|
||||
};
|
||||
let download_entry = HostedDownload { platform: platform.clone(), label, filename: safe_name, file_url, size, uploaded_at: ts };
|
||||
|
||||
if let Some(idx) = cfg.hosted_downloads.iter().position(|d| d.platform == platform) {
|
||||
cfg.hosted_downloads[idx] = download_entry;
|
||||
@@ -284,10 +272,7 @@ pub async fn upload_launcher(
|
||||
Ok(Json(cfg))
|
||||
}
|
||||
|
||||
pub async fn download_launcher(
|
||||
State(state): State<AppState>,
|
||||
Path(platform): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
pub async fn download_launcher(State(state): State<AppState>, Path(platform): Path<String>) -> AppResult<Response> {
|
||||
let cfg = get_config(&state).await?;
|
||||
let entry = cfg.hosted_downloads.iter().find(|d| d.platform == platform || d.platform == "all");
|
||||
|
||||
@@ -307,13 +292,7 @@ pub async fn download_launcher(
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
return Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, mime),
|
||||
(header::CONTENT_DISPOSITION, cd.as_str()),
|
||||
],
|
||||
Body::from(bytes),
|
||||
).into_response());
|
||||
return Ok(([(header::CONTENT_TYPE, mime), (header::CONTENT_DISPOSITION, cd.as_str())], Body::from(bytes)).into_response());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,10 @@ pub use crate::progression::{level_from_xp, xp_for_level};
|
||||
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
|
||||
/// this function only processes level-up rewards (titles, badges, item entitlements).
|
||||
pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, now: &str) -> AppResult<()> {
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
|
||||
let xp: i64 =
|
||||
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
|
||||
let level = level_from_xp(xp).0;
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
|
||||
.bind(level).bind(uuid).execute(&mut **tx).await?;
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(level).bind(uuid).execute(&mut **tx).await?;
|
||||
let rewards: Vec<(i64, String, Option<i64>, String, String, String)> = sqlx::query_as(
|
||||
"SELECT r.id, r.level_type, r.server_id, r.reward_type, r.reward_name, r.reward_data FROM level_rewards r
|
||||
WHERE (r.level_type = 'global' AND r.level_req <= ?)
|
||||
@@ -28,8 +27,15 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
|
||||
.bind(level).bind(uuid).fetch_all(&mut **tx).await?;
|
||||
for (id, scope, server_id, kind, name, data) in rewards {
|
||||
let inserted = sqlx::query("INSERT OR IGNORE INTO granted_rewards(uuid, reward_id, granted_at) VALUES (?, ?, ?)")
|
||||
.bind(uuid).bind(id).bind(now).execute(&mut **tx).await?.rows_affected();
|
||||
if inserted == 0 { continue; }
|
||||
.bind(uuid)
|
||||
.bind(id)
|
||||
.bind(now)
|
||||
.execute(&mut **tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if inserted == 0 {
|
||||
continue;
|
||||
}
|
||||
crate::rewards::enqueue(&mut **tx, uuid, "level_reward", &id.to_string()).await?;
|
||||
match kind.as_str() {
|
||||
"title" if scope == "global" => {
|
||||
@@ -37,15 +43,24 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
|
||||
}
|
||||
"title" => {
|
||||
sqlx::query("UPDATE server_levels SET rank_name = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(&name).bind(server_id).bind(uuid).execute(&mut **tx).await?;
|
||||
.bind(&name)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"badge" | "profile_badge" => {
|
||||
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or_else(|| "[]".into());
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.unwrap_or_else(|| "[]".into());
|
||||
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
|
||||
let data: Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
let badge = data["badge"].as_str().unwrap_or(&name).to_string();
|
||||
if !badges.contains(&badge) { badges.push(badge); }
|
||||
if !badges.contains(&badge) {
|
||||
badges.push(badge);
|
||||
}
|
||||
sqlx::query("INSERT INTO user_levels(uuid, badges, updated_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET badges = excluded.badges")
|
||||
.bind(uuid).bind(serde_json::to_string(&badges)?).bind(now).execute(&mut **tx).await?;
|
||||
}
|
||||
@@ -71,28 +86,21 @@ pub struct RewardRow {
|
||||
}
|
||||
|
||||
/// Get current user's global and server levels.
|
||||
pub async fn get_my_levels(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserLevelInfo>> {
|
||||
pub async fn get_my_levels(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<UserLevelInfo>> {
|
||||
get_user_levels_by_uuid(&state, &auth.uuid).await.map(Json)
|
||||
}
|
||||
|
||||
/// Public / Profile lookup for player levels.
|
||||
pub async fn get_player_levels(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserLevelInfo>> {
|
||||
pub async fn get_player_levels(Path(uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<UserLevelInfo>> {
|
||||
get_user_levels_by_uuid(&state, &uuid).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<UserLevelInfo> {
|
||||
let row: Option<(i64, i64, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let row: Option<(i64, i64, Option<String>, String)> =
|
||||
sqlx::query_as("SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (global_xp, _db_lvl, title, badges_json) = row.unwrap_or((0, 1, None, "[]".into()));
|
||||
let (global_level, current_level_xp, next_level_xp, progress_pct) = level_from_xp(global_xp);
|
||||
@@ -155,13 +163,11 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rank_opt: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?",
|
||||
)
|
||||
.bind(global_xp)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(1));
|
||||
let rank_opt: Option<i64> = sqlx::query_scalar("SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?")
|
||||
.bind(global_xp)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(1));
|
||||
|
||||
Ok(UserLevelInfo {
|
||||
uuid: uuid.to_string(),
|
||||
@@ -181,9 +187,7 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
|
||||
}
|
||||
|
||||
/// Global leveling leaderboard.
|
||||
pub async fn get_level_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Value>>> {
|
||||
pub async fn get_level_leaderboard(State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(String, String, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, ul.global_xp, ul.global_level, ul.title
|
||||
FROM user_levels ul
|
||||
@@ -217,9 +221,7 @@ pub async fn get_level_leaderboard(
|
||||
}
|
||||
|
||||
/// Get available rewards.
|
||||
pub async fn list_rewards(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<Value>>> {
|
||||
pub async fn list_rewards(State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(i64, String, Option<i64>, i64, String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT id, level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at
|
||||
FROM level_rewards
|
||||
@@ -232,11 +234,9 @@ pub async fn list_rewards(
|
||||
.into_iter()
|
||||
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
|
||||
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
|
||||
let desc = parsed_data.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let icon = parsed_data.get("icon")
|
||||
let desc = parsed_data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let icon = parsed_data
|
||||
.get("icon")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(match rtype.as_str() {
|
||||
"title" => "👑",
|
||||
@@ -284,7 +284,8 @@ pub struct RewardPayload {
|
||||
|
||||
fn validate_rank_mapping(data: &serde_json::Map<String, Value>) -> AppResult<()> {
|
||||
if data.get("discord_role_id").is_some_and(|v| !v.is_string() && !v.is_null())
|
||||
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null()) {
|
||||
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null())
|
||||
{
|
||||
return Err(AppError::bad_request("Rank role mappings must be text"));
|
||||
}
|
||||
let discord = data.get("discord_role_id").and_then(Value::as_str).unwrap_or("");
|
||||
@@ -309,12 +310,18 @@ async fn refresh_global_titles(state: &AppState) -> AppResult<()> {
|
||||
.bind(level).fetch_optional(&mut *tx).await?;
|
||||
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&mut *tx).await?;
|
||||
}
|
||||
let members: Vec<(i64, String, i64)> = sqlx::query_as("SELECT server_id, uuid, server_xp FROM server_levels").fetch_all(&mut *tx).await?;
|
||||
let members: Vec<(i64, String, i64)> =
|
||||
sqlx::query_as("SELECT server_id, uuid, server_xp FROM server_levels").fetch_all(&mut *tx).await?;
|
||||
for (server_id, uuid, xp) in members {
|
||||
let level = level_from_xp(xp).0;
|
||||
let rank: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='server' AND server_id=? AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
|
||||
.bind(server_id).bind(level).fetch_optional(&mut *tx).await?;
|
||||
sqlx::query("UPDATE server_levels SET rank_name=? WHERE server_id=? AND uuid=?").bind(rank).bind(server_id).bind(uuid).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE server_levels SET rank_name=? WHERE server_id=? AND uuid=?")
|
||||
.bind(rank)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
@@ -416,16 +423,12 @@ pub async fn admin_update_reward(
|
||||
}
|
||||
|
||||
/// Admin delete reward.
|
||||
pub async fn admin_delete_reward(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'level_reward' AND source_id = ?").bind(id.to_string()).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM level_rewards WHERE id = ?")
|
||||
.bind(id)
|
||||
pub async fn admin_delete_reward(_admin: AdminUser, Path(id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'level_reward' AND source_id = ?")
|
||||
.bind(id.to_string())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM level_rewards WHERE id = ?").bind(id).execute(&state.db).await?;
|
||||
refresh_global_titles(&state).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
+139
-45
@@ -22,12 +22,25 @@ fn ago(secs: i64) -> String {
|
||||
}
|
||||
|
||||
async fn online_on(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<bool> {
|
||||
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)").bind(server_id).bind(uuid).fetch_one(&mut *conn).await?)
|
||||
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Keep only printable characters, collapse whitespace, and cut to `max`.
|
||||
fn clean(text: &str, max: usize) -> String {
|
||||
text.chars().filter(|c| c.is_whitespace() || !c.is_control()).map(|c| if c.is_whitespace() { ' ' } else { c }).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ").chars().take(max).collect()
|
||||
text.chars()
|
||||
.filter(|c| c.is_whitespace() || !c.is_control())
|
||||
.map(|c| if c.is_whitespace() { ' ' } else { c })
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(max)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -40,13 +53,21 @@ pub struct MapAction {
|
||||
}
|
||||
|
||||
/// `POST /servers/{id}/map/actions`: a signed-in player acts on another player they clicked on the map.
|
||||
pub async fn map_action(auth: AuthUser, Path(server_id): Path<i64>, State(state): State<AppState>, Json(a): Json<MapAction>) -> AppResult<Json<Value>> {
|
||||
pub async fn map_action(
|
||||
auth: AuthUser,
|
||||
Path(server_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
Json(a): Json<MapAction>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
get_server(&state, server_id).await?;
|
||||
if a.target_uuid == auth.uuid {
|
||||
return Err(AppError::bad_request("That's you"));
|
||||
}
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')").bind(&a.target_uuid).fetch_one(&mut *conn).await?;
|
||||
let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')")
|
||||
.bind(&a.target_uuid)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if !active {
|
||||
return Err(AppError::not_found("Player not found"));
|
||||
}
|
||||
@@ -77,16 +98,18 @@ pub async fn map_action(auth: AuthUser, Path(server_id): Path<i64>, State(state)
|
||||
}
|
||||
_ => return Err(AppError::bad_request("unknown action")),
|
||||
};
|
||||
sqlx::query("INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(server_id)
|
||||
.bind(&a.action)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(&a.target_uuid)
|
||||
.bind(&text)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(&a.action)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&auth.username)
|
||||
.bind(&a.target_uuid)
|
||||
.bind(&text)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -122,7 +145,8 @@ pub async fn poll_actions(GameServer(server): GameServer, State(state): State<Ap
|
||||
|
||||
/// Queue a line of chat for a player on every server where they are online.
|
||||
pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str) -> AppResult<()> {
|
||||
let servers: Vec<i64> = sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?;
|
||||
let servers: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?;
|
||||
for id in servers {
|
||||
sqlx::query("INSERT INTO server_actions (server_id, kind, to_uuid, text, created_at) VALUES (?, 'notify', ?, ?, ?)")
|
||||
.bind(id)
|
||||
@@ -140,22 +164,42 @@ pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Invite `target_uuid` to `guild_id` on behalf of `inviter_uuid` (who must lead or officiate it).
|
||||
pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &str, guild_id: &str, target_uuid: &str) -> AppResult<Value> {
|
||||
pub async fn create_invite(
|
||||
state: &AppState,
|
||||
inviter_uuid: &str,
|
||||
inviter_name: &str,
|
||||
guild_id: &str,
|
||||
target_uuid: &str,
|
||||
) -> AppResult<Value> {
|
||||
if inviter_uuid == target_uuid {
|
||||
return Err(AppError::bad_request("You can't invite yourself"));
|
||||
}
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?;
|
||||
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
|
||||
.bind(guild_id)
|
||||
.bind(inviter_uuid)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let allowed = match role.as_deref() {
|
||||
Some("leader" | "officer") => true,
|
||||
Some(custom) => sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
|
||||
.bind(guild_id).bind(custom).fetch_optional(&mut *conn).await?.unwrap_or(0) != 0,
|
||||
Some(custom) => {
|
||||
sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
|
||||
.bind(guild_id)
|
||||
.bind(custom)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.unwrap_or(0)
|
||||
!= 0
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Err(AppError::forbidden("Your guild role cannot invite players"));
|
||||
}
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?;
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'")
|
||||
.bind(target_uuid)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
|
||||
let (name, tag, instance): (String, String, String) =
|
||||
sqlx::query_as("SELECT name, tag, instance_id FROM guilds WHERE id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
|
||||
@@ -178,15 +222,21 @@ pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &
|
||||
return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Too many invitations; wait a minute"));
|
||||
}
|
||||
// One open invitation per guild and player.
|
||||
sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'").bind(guild_id).bind(target_uuid).execute(&mut *conn).await?;
|
||||
let id: i64 = sqlx::query_scalar("INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id")
|
||||
sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'")
|
||||
.bind(guild_id)
|
||||
.bind(inviter_uuid)
|
||||
.bind(inviter_name)
|
||||
.bind(target_uuid)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&mut *conn)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(guild_id)
|
||||
.bind(inviter_uuid)
|
||||
.bind(inviter_name)
|
||||
.bind(target_uuid)
|
||||
.bind(crate::db::now())
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
tell_player(&mut conn, target_uuid, &format!("\u{a7}e{inviter_name} \u{a7}ainvited you to join \u{a7}6[{tag}] {name}\u{a7}a. \u{a7}7Type \u{a7}e/guild accept \u{a7}7or \u{a7}c/guild decline\u{a7}7, or answer in the launcher.")).await?;
|
||||
Ok(json!({ "ok": true, "id": id, "player": target_name }))
|
||||
}
|
||||
@@ -196,7 +246,12 @@ pub struct InvitePayload {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
pub async fn send_invite(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<InvitePayload>) -> AppResult<Json<Value>> {
|
||||
pub async fn send_invite(
|
||||
auth: AuthUser,
|
||||
Path(guild_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<InvitePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
Ok(Json(create_invite(&state, &auth.uuid, &auth.username, &guild_id, &p.uuid).await?))
|
||||
}
|
||||
|
||||
@@ -224,20 +279,38 @@ pub async fn my_invites(auth: AuthUser, State(state): State<AppState>) -> AppRes
|
||||
pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i64>, tag: Option<&str>, accept: bool) -> AppResult<Value> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let row: Option<(i64, String)> = match (invite, tag) {
|
||||
(Some(id), _) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'").bind(id).bind(uuid).fetch_optional(&mut *tx).await?,
|
||||
(None, Some(tag)) => sqlx::query_as(
|
||||
"SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id
|
||||
(Some(id), _) => {
|
||||
sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'")
|
||||
.bind(id)
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
(None, Some(tag)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id
|
||||
WHERE i.target_uuid = ? AND i.status = 'pending' AND g.tag = ? COLLATE NOCASE ORDER BY i.id DESC LIMIT 1",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(tag)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?,
|
||||
(None, None) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1").bind(uuid).fetch_optional(&mut *tx).await?,
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(tag)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
(None, None) => {
|
||||
sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let Some((id, guild_id)) = row else { return Err(AppError::not_found("You have no open guild invitation")) };
|
||||
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?").bind(if accept { "accepted" } else { "declined" }).bind(id).execute(&mut *tx).await?;
|
||||
let (gname, gtag): (String, String) = sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
|
||||
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?")
|
||||
.bind(if accept { "accepted" } else { "declined" })
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let (gname, gtag): (String, String) =
|
||||
sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
|
||||
if accept {
|
||||
crate::routes::guilds::check_room(&mut tx, &guild_id).await?;
|
||||
let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)")
|
||||
@@ -251,7 +324,11 @@ pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i6
|
||||
// The database refuses a second guild on the same instance.
|
||||
return Err(AppError::bad_request("You're already in a guild here. Leave it first."));
|
||||
}
|
||||
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)").bind(uuid).bind(crate::db::now()).execute(&mut *tx).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)")
|
||||
.bind(uuid)
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(json!({ "ok": true, "accepted": accept, "guild": gname, "tag": gtag }))
|
||||
@@ -279,10 +356,18 @@ pub struct ServerInvite {
|
||||
pub accept: bool,
|
||||
}
|
||||
|
||||
pub async fn server_invite_send(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
|
||||
let inviter: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
pub async fn server_invite_send(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<ServerInvite>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let inviter: Option<String> =
|
||||
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
let inviter = inviter.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE").bind(p.target.trim()).fetch_optional(&state.db).await?;
|
||||
let target: Option<String> = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE")
|
||||
.bind(p.target.trim())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let target = target.ok_or_else(|| AppError::not_found("No player with that name"))?;
|
||||
let guild: Option<String> = sqlx::query_scalar(
|
||||
"SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ? AND gm.role IN ('leader', 'officer')",
|
||||
@@ -295,12 +380,21 @@ pub async fn server_invite_send(GameServer(server): GameServer, State(state): St
|
||||
Ok(Json(create_invite(&state, &p.uuid, &inviter, &guild, &target).await?))
|
||||
}
|
||||
|
||||
pub async fn server_invites(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
|
||||
pub async fn server_invites(
|
||||
GameServer(_): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<ServerInvite>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? })))
|
||||
}
|
||||
|
||||
pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
|
||||
let name: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
pub async fn server_invite_respond(
|
||||
GameServer(_): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<ServerInvite>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let name: Option<String> =
|
||||
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
|
||||
let name = name.ok_or_else(|| AppError::not_found("Account not found"))?;
|
||||
let tag = if p.tag.trim().is_empty() { None } else { Some(p.tag.trim().to_string()) };
|
||||
Ok(Json(respond(&state, &p.uuid, &name, None, tag.as_deref(), p.accept).await?))
|
||||
|
||||
@@ -4,24 +4,24 @@ pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod connections;
|
||||
pub mod dev_api;
|
||||
pub mod economy;
|
||||
pub mod guild_bank;
|
||||
pub mod guilds;
|
||||
pub mod discord;
|
||||
pub mod discord_studio;
|
||||
pub mod economy;
|
||||
pub mod emails;
|
||||
pub mod guild_bank;
|
||||
pub mod guilds;
|
||||
pub mod integrations;
|
||||
pub mod landing;
|
||||
pub mod worldmap;
|
||||
pub mod map;
|
||||
pub mod leveling;
|
||||
pub mod rewards;
|
||||
pub mod map;
|
||||
pub mod meta;
|
||||
pub mod progression;
|
||||
pub mod public;
|
||||
pub mod quests;
|
||||
pub mod rewards;
|
||||
pub mod servers;
|
||||
pub mod social;
|
||||
pub mod worldmap;
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
@@ -259,6 +259,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/map/tiles", post(worldmap::game_tiles))
|
||||
.route("/map/players", post(worldmap::game_players))
|
||||
.route("/map/overlay", post(worldmap::game_overlay))
|
||||
.route("/map/diagnostics", post(worldmap::game_diagnostics))
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024));
|
||||
|
||||
Router::new()
|
||||
|
||||
+113
-160
@@ -30,15 +30,24 @@ fn action_matches(target: &str, action: &str) -> bool {
|
||||
let Some(target) = target.strip_prefix("action:") else { return false };
|
||||
let (target_action, target_dimension) = target.split_once('@').map_or((target, None), |(a, d)| (a, Some(d)));
|
||||
let (actual_action, actual_dimension) = action.split_once('@').map_or((action, None), |(a, d)| (a, Some(d)));
|
||||
if target_dimension.is_some_and(|d| Some(d) != actual_dimension) { return false; }
|
||||
if target_dimension.is_some_and(|d| Some(d) != actual_dimension) {
|
||||
return false;
|
||||
}
|
||||
let Some((target_kind, materials)) = target_action.split_once(':') else { return false };
|
||||
let Some((actual_kind, material)) = actual_action.split_once(':') else { return false };
|
||||
if target_kind != actual_kind { return false; }
|
||||
if target_kind != actual_kind {
|
||||
return false;
|
||||
}
|
||||
materials.split('|').any(|pattern| {
|
||||
if pattern == "*" { true }
|
||||
else if let Some(suffix) = pattern.strip_prefix('*') { material.ends_with(suffix) }
|
||||
else if let Some(prefix) = pattern.strip_suffix('*') { material.starts_with(prefix) }
|
||||
else { material == pattern }
|
||||
if pattern == "*" {
|
||||
true
|
||||
} else if let Some(suffix) = pattern.strip_prefix('*') {
|
||||
material.ends_with(suffix)
|
||||
} else if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
material.starts_with(prefix)
|
||||
} else {
|
||||
material == pattern
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,18 +68,27 @@ mod action_tests {
|
||||
/// Advance quests that require a particular block, mob, or dimension rather
|
||||
/// than a generic stat counter.
|
||||
pub async fn advance_action_quests(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, action: &str, count: i64, now: &str,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
uuid: &str,
|
||||
action: &str,
|
||||
count: i64,
|
||||
now: &str,
|
||||
) -> AppResult<()> {
|
||||
if count <= 0 { return Ok(()); }
|
||||
let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'",
|
||||
).fetch_all(&mut **tx).await?;
|
||||
if count <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<(String, String, String, i64)> =
|
||||
sqlx::query_as("SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'")
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let daily = current_daily_key();
|
||||
let weekly = current_weekly_key();
|
||||
let cfg = crate::progression::load(&mut **tx).await?;
|
||||
let mut assigned: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
|
||||
for (id, period, target, required) in rows {
|
||||
if !action_matches(&target, action) { continue; }
|
||||
if !action_matches(&target, action) {
|
||||
continue;
|
||||
}
|
||||
let key = if period == "weekly" { &weekly } else { &daily };
|
||||
// Only quests this player was given for the period make progress.
|
||||
let period_name = if period == "weekly" { "weekly" } else { "daily" };
|
||||
@@ -78,7 +96,9 @@ pub async fn advance_action_quests(
|
||||
let ids = crate::progression::assigned_quest_ids(&mut **tx, &cfg, uuid, period_name, key).await?;
|
||||
assigned.insert(period_name, ids);
|
||||
}
|
||||
if !assigned[period_name].contains(&id) { continue; }
|
||||
if !assigned[period_name].contains(&id) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
@@ -87,17 +107,21 @@ pub async fn advance_action_quests(
|
||||
completed = CASE WHEN progress + excluded.progress >= ? THEN 1 ELSE completed END,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid).bind(&id).bind(key).bind(count).bind(count >= required).bind(now).bind(required)
|
||||
.execute(&mut **tx).await?;
|
||||
.bind(uuid)
|
||||
.bind(&id)
|
||||
.bind(key)
|
||||
.bind(count)
|
||||
.bind(count >= required)
|
||||
.bind(now)
|
||||
.bind(required)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all active daily and weekly quests for the authenticated user with live progress.
|
||||
pub async fn get_my_quests(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<UserQuest>>> {
|
||||
pub async fn get_my_quests(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<UserQuest>>> {
|
||||
let daily_key = current_daily_key();
|
||||
let weekly_key = current_weekly_key();
|
||||
|
||||
@@ -143,102 +167,68 @@ pub async fn get_my_quests(
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.filter(|r| if r.3 == "weekly" { weekly_ids.contains(&r.0) } else { daily_ids.contains(&r.0) })
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
period,
|
||||
cat,
|
||||
stat,
|
||||
target,
|
||||
xp,
|
||||
icon,
|
||||
enabled,
|
||||
progress_opt,
|
||||
completed_opt,
|
||||
claimed_opt,
|
||||
period_key_opt,
|
||||
)| {
|
||||
let quest_period_is_weekly = period == "weekly";
|
||||
let progress = progress_opt.unwrap_or(0);
|
||||
let completed = completed_opt.unwrap_or(progress >= target);
|
||||
let claimed = claimed_opt.unwrap_or(false);
|
||||
let pkey = period_key_opt.unwrap_or_else(|| {
|
||||
if period == "weekly" {
|
||||
weekly_key.clone()
|
||||
} else {
|
||||
daily_key.clone()
|
||||
}
|
||||
});
|
||||
.map(|(id, title, desc, period, cat, stat, target, xp, icon, enabled, progress_opt, completed_opt, claimed_opt, period_key_opt)| {
|
||||
let quest_period_is_weekly = period == "weekly";
|
||||
let progress = progress_opt.unwrap_or(0);
|
||||
let completed = completed_opt.unwrap_or(progress >= target);
|
||||
let claimed = claimed_opt.unwrap_or(false);
|
||||
let pkey = period_key_opt.unwrap_or_else(|| if period == "weekly" { weekly_key.clone() } else { daily_key.clone() });
|
||||
|
||||
UserQuest {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
progress,
|
||||
current_count: progress,
|
||||
completed,
|
||||
claimed,
|
||||
period_key: pkey,
|
||||
expires_at: Some(if quest_period_is_weekly { weekly_ends.clone() } else { daily_ends.clone() }),
|
||||
}
|
||||
},
|
||||
)
|
||||
UserQuest {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
progress,
|
||||
current_count: progress,
|
||||
completed,
|
||||
claimed,
|
||||
period_key: pkey,
|
||||
expires_at: Some(if quest_period_is_weekly { weekly_ends.clone() } else { daily_ends.clone() }),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
/// Claim a completed quest reward.
|
||||
pub async fn claim_quest(
|
||||
auth: AuthUser,
|
||||
Path(quest_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn claim_quest(auth: AuthUser, Path(quest_id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let quest: Option<(String, i64, i64)> = sqlx::query_as(
|
||||
"SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1",
|
||||
)
|
||||
.bind(&quest_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let quest: Option<(String, i64, i64)> =
|
||||
sqlx::query_as("SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1")
|
||||
.bind(&quest_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (period, target_count, xp_reward) =
|
||||
quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
|
||||
let (period, target_count, xp_reward) = quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
|
||||
|
||||
let period_key = if period == "weekly" {
|
||||
current_weekly_key()
|
||||
} else {
|
||||
current_daily_key()
|
||||
};
|
||||
let period_key = if period == "weekly" { current_weekly_key() } else { current_daily_key() };
|
||||
|
||||
let cfg = crate::progression::load(&mut tx).await?;
|
||||
if !crate::progression::is_assigned(&mut tx, &cfg, &auth.uuid, &period, &period_key, &quest_id).await? {
|
||||
return Err(AppError::bad_request("That quest isn't one of yours this period"));
|
||||
}
|
||||
|
||||
let user_quest: Option<(i64, bool, bool)> = sqlx::query_as(
|
||||
"SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&quest_id)
|
||||
.bind(&period_key)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_quest: Option<(i64, bool, bool)> =
|
||||
sqlx::query_as("SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?")
|
||||
.bind(&auth.uuid)
|
||||
.bind(&quest_id)
|
||||
.bind(&period_key)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (progress, completed, claimed) = user_quest.unwrap_or((0, false, false));
|
||||
|
||||
@@ -283,17 +273,10 @@ pub async fn claim_quest(
|
||||
.await?;
|
||||
|
||||
// Read new total XP & compute new level
|
||||
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(&auth.uuid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(&auth.uuid).fetch_one(&mut *tx).await?;
|
||||
|
||||
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
|
||||
.bind(new_lvl)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(new_lvl).bind(&auth.uuid).execute(&mut *tx).await?;
|
||||
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &auth.uuid, &now).await?;
|
||||
crate::rewards::enqueue(&mut tx, &auth.uuid, "quest", &quest_id).await?;
|
||||
@@ -322,24 +305,8 @@ pub struct AdminQuestRow {
|
||||
}
|
||||
|
||||
/// Admin list all quests.
|
||||
pub async fn admin_list_quests(
|
||||
_admin: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<AdminQuestRow>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
bool,
|
||||
i64,
|
||||
bool,
|
||||
)> = sqlx::query_as(
|
||||
pub async fn admin_list_quests(_admin: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminQuestRow>>> {
|
||||
let rows: Vec<(String, String, String, String, String, String, i64, i64, String, bool, i64, bool)> = sqlx::query_as(
|
||||
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
|
||||
q.target_count, q.xp_reward, q.icon, q.enabled,
|
||||
COUNT(uq.user_uuid) as total_completions, q.pinned
|
||||
@@ -353,29 +320,25 @@ pub async fn admin_list_quests(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions, pinned)| {
|
||||
AdminQuestRow {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
total_completions: completions,
|
||||
pinned,
|
||||
}
|
||||
.map(|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions, pinned)| AdminQuestRow {
|
||||
quest: Quest {
|
||||
id,
|
||||
title,
|
||||
description: desc,
|
||||
period: period.clone(),
|
||||
category: cat,
|
||||
target_stat: stat.clone(),
|
||||
target_count: target,
|
||||
xp_reward: xp,
|
||||
icon,
|
||||
enabled,
|
||||
quest_type: Some(period),
|
||||
stat_type: Some(stat),
|
||||
active: Some(enabled),
|
||||
},
|
||||
)
|
||||
total_completions: completions,
|
||||
pinned,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
@@ -407,10 +370,7 @@ pub async fn admin_create_quest(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let period = payload.period.unwrap_or_else(|| "daily".into());
|
||||
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
|
||||
let id = payload
|
||||
.id
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
|
||||
let id = payload.id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
@@ -477,15 +437,8 @@ pub async fn admin_update_quest(
|
||||
}
|
||||
|
||||
/// Admin delete quest.
|
||||
pub async fn admin_delete_quest(
|
||||
_admin: AdminUser,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn admin_delete_quest(_admin: AdminUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'quest' AND source_id = ?").bind(&id).execute(&state.db).await?;
|
||||
sqlx::query("DELETE FROM quests WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM quests WHERE id = ?").bind(&id).execute(&state.db).await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -22,7 +22,12 @@ pub struct BundleBody {
|
||||
actions: Value,
|
||||
}
|
||||
|
||||
pub async fn admin_put(_: AdminUser, State(state): State<AppState>, Path((kind, id)): Path<(String, String)>, Json(body): Json<BundleBody>) -> AppResult<Json<Value>> {
|
||||
pub async fn admin_put(
|
||||
_: AdminUser,
|
||||
State(state): State<AppState>,
|
||||
Path((kind, id)): Path<(String, String)>,
|
||||
Json(body): Json<BundleBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
rewards::check_source(&kind)?;
|
||||
let actions = rewards::validate(&body.actions)?;
|
||||
if actions.is_empty() {
|
||||
@@ -58,7 +63,8 @@ pub async fn admin_deliveries(_: AdminUser, State(state): State<AppState>, Query
|
||||
|
||||
/// `{"quest:daily_miner": ["$200", "Diamond x3"], ...}` for the launcher to show next to each reward.
|
||||
pub async fn public_summaries(_: AuthUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let rows: Vec<(String, String, String)> = sqlx::query_as("SELECT source_type, source_id, actions FROM reward_bundles").fetch_all(&state.db).await?;
|
||||
let rows: Vec<(String, String, String)> =
|
||||
sqlx::query_as("SELECT source_type, source_id, actions FROM reward_bundles").fetch_all(&state.db).await?;
|
||||
let mut out = BTreeMap::new();
|
||||
for (kind, id, actions) in rows {
|
||||
let list: Vec<Value> = serde_json::from_str(&actions).unwrap_or_default();
|
||||
|
||||
+144
-142
@@ -266,12 +266,7 @@ pub fn ips_match(sess_str: &str, req_str: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_launcher_ip(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
req_ip: Option<&str>,
|
||||
since: &str,
|
||||
) -> AppResult<bool> {
|
||||
async fn verify_launcher_ip(state: &AppState, user_id: Option<i64>, req_ip: Option<&str>, since: &str) -> AppResult<bool> {
|
||||
let req_ip = match req_ip {
|
||||
Some(ip) if !ip.trim().is_empty() => ip.trim(),
|
||||
_ => return Ok(false),
|
||||
@@ -422,11 +417,16 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
let leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true);
|
||||
let mut xp_before = std::collections::HashMap::new();
|
||||
if !leveling_on {
|
||||
for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid))
|
||||
.chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed))) {
|
||||
if xp_before.contains_key(&uuid) { continue; }
|
||||
for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid)).chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed)))
|
||||
{
|
||||
if xp_before.contains_key(&uuid) {
|
||||
continue;
|
||||
}
|
||||
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or(0);
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
xp_before.insert(uuid, xp);
|
||||
}
|
||||
}
|
||||
@@ -533,8 +533,8 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.bind(&kind)
|
||||
.bind(e.detail.as_deref().map(|d| clip(d, 256)))
|
||||
.bind(at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if quests_on && kind == "action" {
|
||||
if let (Some(uuid), Some(detail)) = (e.uuid.as_deref().and_then(dashed), e.detail.as_deref()) {
|
||||
@@ -549,38 +549,49 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
// Check if event unlocks an achievement (if achievements enabled)
|
||||
if achievements_on {
|
||||
if let Some(ref euuid) = e.uuid.as_deref().and_then(dashed) {
|
||||
let event_ach: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?",
|
||||
)
|
||||
.bind(&kind)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let event_ach: Vec<String> =
|
||||
sqlx::query_scalar("SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?")
|
||||
.bind(&kind)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for ach_id in event_ach {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(euuid)
|
||||
.bind(ach_id)
|
||||
.bind(&at_now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
|
||||
.bind(euuid)
|
||||
.bind(ach_id)
|
||||
.bind(&at_now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rewarded = std::collections::HashSet::new();
|
||||
for d in &s.stats { if let Some(uuid) = dashed(&d.uuid) { rewarded.insert(uuid); } }
|
||||
for e in &s.events { if let Some(uuid) = e.uuid.as_deref().and_then(dashed) { rewarded.insert(uuid); } }
|
||||
for d in &s.stats {
|
||||
if let Some(uuid) = dashed(&d.uuid) {
|
||||
rewarded.insert(uuid);
|
||||
}
|
||||
}
|
||||
for e in &s.events {
|
||||
if let Some(uuid) = e.uuid.as_deref().and_then(dashed) {
|
||||
rewarded.insert(uuid);
|
||||
}
|
||||
}
|
||||
if leveling_on {
|
||||
for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; }
|
||||
for uuid in rewarded {
|
||||
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?;
|
||||
}
|
||||
} else {
|
||||
// Achievement unlocks remain enabled, but their trigger must not grant
|
||||
// progression on a server whose leveling module is disabled.
|
||||
for (uuid, xp) in xp_before {
|
||||
let level = crate::routes::leveling::level_from_xp(xp).0;
|
||||
sqlx::query("UPDATE user_levels SET global_xp = ?, global_level = ? WHERE uuid = ?")
|
||||
.bind(xp).bind(level).bind(&uuid).execute(&mut *tx).await?;
|
||||
.bind(xp)
|
||||
.bind(level)
|
||||
.bind(&uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
@@ -633,61 +644,55 @@ async fn update_progression_for_delta(
|
||||
let delta_xp = (base_xp * global_mult).round() as i64;
|
||||
let server_delta_xp = (base_xp * server_mult).round() as i64;
|
||||
|
||||
if delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
|
||||
if delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
global_xp = global_xp + excluded.global_xp,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Recompute global level
|
||||
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
|
||||
.bind(new_lvl)
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if server_delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
|
||||
// Recompute global level
|
||||
let new_xp: i64 =
|
||||
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_one(&mut **tx).await?;
|
||||
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
|
||||
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(new_lvl).bind(uuid).execute(&mut **tx).await?;
|
||||
}
|
||||
|
||||
if server_delta_xp > 0 {
|
||||
sqlx::query(
|
||||
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
||||
server_xp = server_xp + excluded.server_xp,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.bind(server_delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Recompute server level
|
||||
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
|
||||
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(new_s_lvl)
|
||||
)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.bind(server_delta_xp)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// Recompute server level
|
||||
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
|
||||
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
|
||||
.bind(new_s_lvl)
|
||||
.bind(server_id)
|
||||
.bind(uuid)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,12 +720,11 @@ async fn update_progression_for_delta(
|
||||
}
|
||||
|
||||
// Daily quests matching this stat
|
||||
let d_quests: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?",
|
||||
)
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let d_quests: Vec<(String, i64)> =
|
||||
sqlx::query_as("SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?")
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for (qid, target) in d_quests.into_iter().filter(|(id, _)| daily_ids.contains(id)) {
|
||||
sqlx::query(
|
||||
@@ -743,12 +747,11 @@ async fn update_progression_for_delta(
|
||||
}
|
||||
|
||||
// Weekly quests matching this stat
|
||||
let w_quests: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?",
|
||||
)
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let w_quests: Vec<(String, i64)> =
|
||||
sqlx::query_as("SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?")
|
||||
.bind(stat_name)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for (qid, target) in w_quests.into_iter().filter(|(id, _)| weekly_ids.contains(id)) {
|
||||
sqlx::query(
|
||||
@@ -802,42 +805,35 @@ async fn update_progression_for_delta(
|
||||
.await?;
|
||||
|
||||
for ach_id in eligible {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level milestone achievements
|
||||
let global_level: Option<i64> = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?")
|
||||
.bind(uuid)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let global_level: Option<i64> =
|
||||
sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut **tx).await?;
|
||||
|
||||
if let Some(lvl) = global_level.filter(|_| achievements_on) {
|
||||
let lvl_ach: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?",
|
||||
)
|
||||
.bind(lvl)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
let lvl_ach: Vec<String> =
|
||||
sqlx::query_scalar("SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?")
|
||||
.bind(lvl)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for ach_id in lvl_ach {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
|
||||
.bind(uuid)
|
||||
.bind(ach_id)
|
||||
.bind(at_now)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,12 +960,24 @@ pub async fn regenerate_token(_: AdminUser, State(state): State<AppState>, Path(
|
||||
|
||||
pub async fn remove(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
// If this server holds its group's shared balances, hand them to the next server first.
|
||||
let group: String = sqlx::query_scalar("SELECT economy_group FROM game_servers WHERE id = ?").bind(id).fetch_optional(&state.db).await?.unwrap_or_default();
|
||||
let group: String = sqlx::query_scalar("SELECT economy_group FROM game_servers WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
if !group.is_empty() && economy_scope(&state.db, id).await? == id {
|
||||
let next: Option<i64> = sqlx::query_scalar("SELECT MIN(id) FROM game_servers WHERE id <> ? AND economy_group = ? COLLATE NOCASE").bind(id).bind(&group).fetch_one(&state.db).await?;
|
||||
let next: Option<i64> = sqlx::query_scalar("SELECT MIN(id) FROM game_servers WHERE id <> ? AND economy_group = ? COLLATE NOCASE")
|
||||
.bind(id)
|
||||
.bind(&group)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if let Some(next) = next {
|
||||
for table in ["server_economy", "guild_wallets", "guild_wallet_transactions"] {
|
||||
let sql = if table == "guild_wallet_transactions" { format!("UPDATE {table} SET server_id = ? WHERE server_id = ?") } else { format!("UPDATE OR IGNORE {table} SET server_id = ? WHERE server_id = ?") };
|
||||
let sql = if table == "guild_wallet_transactions" {
|
||||
format!("UPDATE {table} SET server_id = ? WHERE server_id = ?")
|
||||
} else {
|
||||
format!("UPDATE OR IGNORE {table} SET server_id = ? WHERE server_id = ?")
|
||||
};
|
||||
sqlx::query(&sql).bind(next).bind(id).execute(&state.db).await?;
|
||||
}
|
||||
}
|
||||
@@ -1133,22 +1141,25 @@ pub struct AggregatedStatRow {
|
||||
/// Public list of servers with status and player counts.
|
||||
pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let servers: Vec<ServerRow> = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
|
||||
let list: Vec<Value> = servers.into_iter().map(|s| {
|
||||
let is_online = s.is_online();
|
||||
json!({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"instance_id": s.instance_id,
|
||||
"map": s.map_enabled,
|
||||
"online": is_online,
|
||||
"players_online": if is_online { s.online_count } else { 0 },
|
||||
"players_max": s.max_players,
|
||||
"software": s.software,
|
||||
"mc_version": s.mc_version,
|
||||
"tps": s.tps,
|
||||
"last_seen": s.last_seen,
|
||||
let list: Vec<Value> = servers
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let is_online = s.is_online();
|
||||
json!({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"instance_id": s.instance_id,
|
||||
"map": s.map_enabled,
|
||||
"online": is_online,
|
||||
"players_online": if is_online { s.online_count } else { 0 },
|
||||
"players_max": s.max_players,
|
||||
"software": s.software,
|
||||
"mc_version": s.mc_version,
|
||||
"tps": s.tps,
|
||||
"last_seen": s.last_seen,
|
||||
})
|
||||
})
|
||||
}).collect();
|
||||
.collect();
|
||||
Ok(Json(json!({ "servers": list })))
|
||||
}
|
||||
|
||||
@@ -1186,10 +1197,7 @@ pub async fn public_server_leaderboard(
|
||||
}
|
||||
|
||||
/// Public global leaderboard aggregated across all servers.
|
||||
pub async fn global_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<LeaderboardQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn global_leaderboard(State(state): State<AppState>, Query(q): Query<LeaderboardQuery>) -> AppResult<Json<Value>> {
|
||||
let sort = match q.sort.as_str() {
|
||||
"joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(),
|
||||
_ => "playtime_secs",
|
||||
@@ -1216,9 +1224,7 @@ pub async fn global_leaderboard(
|
||||
.await?;
|
||||
|
||||
let (players, playtime): (i64, Option<i64>) =
|
||||
sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats").fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"leaderboard": leaderboard,
|
||||
@@ -1228,10 +1234,7 @@ pub async fn global_leaderboard(
|
||||
}
|
||||
|
||||
/// Player's personal stats across servers (authenticated endpoint for launcher).
|
||||
pub async fn account_player_stats(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn account_player_stats(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
|
||||
let rows: Vec<StatRowNamed> = sqlx::query_as(
|
||||
"SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC",
|
||||
)
|
||||
@@ -1253,17 +1256,16 @@ pub async fn account_player_stats(
|
||||
MAX(last_seen) as last_seen
|
||||
FROM player_stats
|
||||
WHERE uuid = ?
|
||||
GROUP BY uuid"
|
||||
GROUP BY uuid",
|
||||
)
|
||||
.bind(&user.uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let events: Vec<EventRow> =
|
||||
sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20")
|
||||
.bind(&user.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let events: Vec<EventRow> = sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20")
|
||||
.bind(&user.uuid)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"username": user.username,
|
||||
|
||||
+108
-141
@@ -13,10 +13,7 @@ use serde_json::Value;
|
||||
// Friends System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_friends(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<FriendInfo>>> {
|
||||
pub async fn list_friends(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<FriendInfo>>> {
|
||||
let rows: Vec<(String, String, String, String, bool, Option<String>)> = sqlx::query_as(
|
||||
"SELECT
|
||||
(CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid,
|
||||
@@ -48,14 +45,7 @@ pub async fn list_friends(
|
||||
"pending_incoming".to_string()
|
||||
};
|
||||
|
||||
FriendInfo {
|
||||
uuid,
|
||||
username,
|
||||
status: final_status,
|
||||
online,
|
||||
playing_on,
|
||||
last_seen: None,
|
||||
}
|
||||
FriendInfo { uuid, username, status: final_status, online, playing_on, last_seen: None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -80,12 +70,8 @@ pub async fn send_friend_request(
|
||||
.ok_or_else(|| AppError::bad_request("Username is required"))?
|
||||
.trim();
|
||||
|
||||
let target: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let target: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE").bind(name).fetch_optional(&state.db).await?;
|
||||
|
||||
let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?;
|
||||
|
||||
@@ -171,11 +157,7 @@ pub async fn accept_friend_request(
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn remove_friend(
|
||||
auth: AuthUser,
|
||||
Path(target_uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn remove_friend(auth: AuthUser, Path(target_uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query(
|
||||
"DELETE FROM friendships
|
||||
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
|
||||
@@ -202,10 +184,7 @@ pub async fn respond_friend_request(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RespondFriendPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let target = payload
|
||||
.target_uuid
|
||||
.or(payload.friend_uuid)
|
||||
.ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
|
||||
let target = payload.target_uuid.or(payload.friend_uuid).ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
|
||||
|
||||
if payload.accept {
|
||||
accept_friend_request(auth, Path(target), State(state)).await
|
||||
@@ -348,23 +327,8 @@ pub async fn send_direct_message(
|
||||
// Game Invites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_my_game_invites(
|
||||
auth: AuthUser,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Vec<GameInvite>>> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
)> = sqlx::query_as(
|
||||
pub async fn list_my_game_invites(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<GameInvite>>> {
|
||||
let rows: Vec<(String, String, String, String, String, String, Option<i64>, Option<String>, String, String, String)> = sqlx::query_as(
|
||||
"SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid,
|
||||
gi.instance_id, i.name as instance_name,
|
||||
gi.server_id, gs.name as server_name,
|
||||
@@ -382,21 +346,19 @@ pub async fn list_my_game_invites(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
|
||||
id,
|
||||
sender_uuid: suuid,
|
||||
sender_name: sname,
|
||||
recipient_uuid: ruuid,
|
||||
instance_id: iid,
|
||||
instance_name: iname,
|
||||
server_id: sid,
|
||||
server_name: sname_opt,
|
||||
status,
|
||||
created_at: cat,
|
||||
expires_at: eat,
|
||||
},
|
||||
)
|
||||
.map(|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
|
||||
id,
|
||||
sender_uuid: suuid,
|
||||
sender_name: sname,
|
||||
recipient_uuid: ruuid,
|
||||
instance_id: iid,
|
||||
instance_name: iname,
|
||||
server_id: sid,
|
||||
server_name: sname_opt,
|
||||
status,
|
||||
created_at: cat,
|
||||
expires_at: eat,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(list))
|
||||
@@ -425,10 +387,8 @@ pub async fn send_game_invite(
|
||||
if !friends {
|
||||
return Err(AppError::forbidden("You can only invite friends"));
|
||||
}
|
||||
let instance: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)")
|
||||
.bind(&payload.instance_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let instance: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)").bind(&payload.instance_id).fetch_one(&state.db).await?;
|
||||
if !instance {
|
||||
return Err(AppError::not_found("Instance not found"));
|
||||
}
|
||||
@@ -465,20 +425,14 @@ pub async fn respond_game_invite(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RespondInvitePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let status = if payload.action == "accept" {
|
||||
"accepted"
|
||||
} else {
|
||||
"declined"
|
||||
};
|
||||
let status = if payload.action == "accept" { "accepted" } else { "declined" };
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(&invite_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?")
|
||||
.bind(status)
|
||||
.bind(&invite_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": status })))
|
||||
}
|
||||
@@ -492,21 +446,16 @@ pub async fn get_player_profile(
|
||||
Path(uuid): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<UserProfileView>> {
|
||||
let user_row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT uuid, username FROM users WHERE uuid = ?",
|
||||
)
|
||||
.bind(&uuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let user_row: Option<(String, String)> =
|
||||
sqlx::query_as("SELECT uuid, username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
|
||||
|
||||
let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?;
|
||||
|
||||
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> =
|
||||
sqlx::query_as("SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?")
|
||||
.bind(&puuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None));
|
||||
|
||||
@@ -522,12 +471,8 @@ pub async fn get_player_profile(
|
||||
};
|
||||
|
||||
// Unlocked achievements count
|
||||
let achievements_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let achievements_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&puuid).fetch_one(&state.db).await?;
|
||||
|
||||
// Recent posts
|
||||
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, bool, String)> = sqlx::query_as(
|
||||
@@ -556,16 +501,22 @@ pub async fn get_player_profile(
|
||||
.collect();
|
||||
|
||||
let achievements = crate::routes::achievements::get_achievements_for_user(&state, &puuid, true).await?;
|
||||
let friends_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ? OR friend_uuid = ?)")
|
||||
.bind(&puuid).bind(&puuid).fetch_one(&state.db).await?;
|
||||
let created_at: String = sqlx::query_scalar("SELECT created_at FROM users WHERE uuid = ?")
|
||||
.bind(&puuid).fetch_one(&state.db).await?;
|
||||
let friends_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ? OR friend_uuid = ?)")
|
||||
.bind(&puuid)
|
||||
.bind(&puuid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let created_at: String = sqlx::query_scalar("SELECT created_at FROM users WHERE uuid = ?").bind(&puuid).fetch_one(&state.db).await?;
|
||||
let stats: Option<crate::routes::servers::AggregatedStatRow> = sqlx::query_as(
|
||||
"SELECT uuid, name, SUM(playtime_secs) playtime_secs, SUM(joins) joins, SUM(deaths) deaths,
|
||||
SUM(player_kills) player_kills, SUM(mob_kills) mob_kills, SUM(blocks_broken) blocks_broken,
|
||||
SUM(blocks_placed) blocks_placed, SUM(messages) messages, MIN(first_seen) first_seen, MAX(last_seen) last_seen
|
||||
FROM player_stats WHERE uuid = ? GROUP BY uuid")
|
||||
.bind(&puuid).fetch_optional(&state.db).await?;
|
||||
FROM player_stats WHERE uuid = ? GROUP BY uuid",
|
||||
)
|
||||
.bind(&puuid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let extras = profile_extras(&state, &puuid, viewer.as_ref().map(|v| v.uuid.as_str())).await?;
|
||||
Ok(Json(UserProfileView {
|
||||
online: extras.online,
|
||||
@@ -620,7 +571,8 @@ struct ProfileExtras {
|
||||
async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> AppResult<ProfileExtras> {
|
||||
let db = &state.db;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(uuid).fetch_one(db).await?;
|
||||
let last_seen: Option<String> = sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
|
||||
let last_seen: Option<String> =
|
||||
sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
|
||||
|
||||
let guild: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? ORDER BY gm.joined_at LIMIT 1",
|
||||
@@ -675,12 +627,21 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
|
||||
let text = match kind.as_str() {
|
||||
"join" => format!("Joined {server}"),
|
||||
"death" => format!("Died on {server}"),
|
||||
"kill" => format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() }),
|
||||
_ => format!("Earned {}", if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }),
|
||||
"kill" => {
|
||||
format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() })
|
||||
}
|
||||
_ => format!(
|
||||
"Earned {}",
|
||||
if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }
|
||||
),
|
||||
};
|
||||
scopenet_shared::ProfileActivity { kind, text, at }
|
||||
})
|
||||
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity { kind: "achievement".into(), text: format!("Unlocked {title}"), at }))
|
||||
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity {
|
||||
kind: "achievement".into(),
|
||||
text: format!("Unlocked {title}"),
|
||||
at,
|
||||
}))
|
||||
.collect();
|
||||
recent_activity.sort_by(|a, b| b.at.cmp(&a.at));
|
||||
recent_activity.truncate(8);
|
||||
@@ -715,7 +676,8 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
|
||||
None => ("none".to_string(), vec![]),
|
||||
};
|
||||
|
||||
let accent_color: Option<String> = sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
|
||||
let accent_color: Option<String> =
|
||||
sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
|
||||
let discord_linked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM account_connections ac JOIN users u ON u.id = ac.user_id WHERE u.uuid = ? AND ac.provider = 'discord')")
|
||||
.bind(uuid)
|
||||
.fetch_one(db)
|
||||
@@ -725,7 +687,11 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
|
||||
online,
|
||||
last_seen,
|
||||
guild: guild.map(|(id, name, tag, role)| scopenet_shared::ProfileGuild { id, name, tag, role }),
|
||||
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank { display: if display.is_empty() { primary } else { display }, prefix, server_name }),
|
||||
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank {
|
||||
display: if display.is_empty() { primary } else { display },
|
||||
prefix,
|
||||
server_name,
|
||||
}),
|
||||
favorite_server,
|
||||
wealth,
|
||||
recent_activity,
|
||||
@@ -782,7 +748,11 @@ pub async fn update_my_profile(
|
||||
if !valid {
|
||||
return Err(AppError::bad_request("accent colour must look like #8b6cff"));
|
||||
}
|
||||
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?").bind(if color.is_empty() { None } else { Some(color) }).bind(&auth.uuid).execute(&state.db).await?;
|
||||
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?")
|
||||
.bind(if color.is_empty() { None } else { Some(color) })
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
@@ -835,42 +805,27 @@ pub async fn create_user_post(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_user_post(
|
||||
auth: AuthUser,
|
||||
Path(post_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?")
|
||||
.bind(post_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
pub async fn delete_user_post(auth: AuthUser, Path(post_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?").bind(post_id).bind(&auth.uuid).execute(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn like_user_post(
|
||||
auth: AuthUser,
|
||||
Path(post_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
pub async fn like_user_post(auth: AuthUser, Path(post_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)")
|
||||
.bind(post_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists { return Err(AppError::not_found("Post not found")); }
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)").bind(post_id).fetch_one(&mut *tx).await?;
|
||||
if !exists {
|
||||
return Err(AppError::not_found("Post not found"));
|
||||
}
|
||||
let added = sqlx::query("INSERT OR IGNORE INTO user_post_likes (post_id, user_uuid) VALUES (?, ?)")
|
||||
.bind(post_id)
|
||||
.bind(&auth.uuid)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected() > 0;
|
||||
.rows_affected()
|
||||
> 0;
|
||||
if added {
|
||||
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
|
||||
.bind(post_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?").bind(post_id).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -895,9 +850,21 @@ pub async fn search_members(
|
||||
let q = query.q.unwrap_or_default().trim().to_lowercase().replace(['%', '_', '\\'], "");
|
||||
let pattern = format!("%{q}%");
|
||||
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, bool, Option<String>, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<String>,
|
||||
bool,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
|
||||
ul.global_level, ul.title,
|
||||
COALESCE((SELECT SUM(ps.playtime_secs) FROM player_stats ps WHERE ps.uuid = u.uuid), 0),
|
||||
(SELECT MAX(ps.last_seen) FROM player_stats ps WHERE ps.uuid = u.uuid),
|
||||
@@ -909,12 +876,12 @@ pub async fn search_members(
|
||||
WHERE u.status = 'active' AND u.uuid <> ?1 AND (?2 = '' OR LOWER(u.username) LIKE ?3)
|
||||
ORDER BY (CASE WHEN LOWER(u.username) = ?2 THEN 0 ELSE 1 END), 10 DESC, u.username COLLATE NOCASE
|
||||
LIMIT 60",
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&q)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
)
|
||||
.bind(&auth.uuid)
|
||||
.bind(&q)
|
||||
.bind(&pattern)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
|
||||
@@ -42,13 +42,20 @@ pub struct DimQuery {
|
||||
dim: String,
|
||||
}
|
||||
|
||||
pub async fn game_tiles(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>, body: Bytes) -> AppResult<Json<Value>> {
|
||||
pub async fn game_tiles(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<DimQuery>,
|
||||
body: Bytes,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.map_enabled)?;
|
||||
let slug = dim_slug(&q.dim).ok_or_else(|| AppError::bad_request("unknown dimension"))?;
|
||||
let tiles = parse_tiles(&body).map_err(AppError::bad_request)?;
|
||||
let map = state.worldmap.clone();
|
||||
let id = server.id;
|
||||
let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles))
|
||||
.await
|
||||
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
Ok(Json(json!({ "stored": stored })))
|
||||
}
|
||||
|
||||
@@ -57,14 +64,22 @@ pub struct PlayersBody {
|
||||
players: Vec<LivePlayer>,
|
||||
}
|
||||
|
||||
pub async fn game_players(GameServer(server): GameServer, State(state): State<AppState>, Json(b): Json<PlayersBody>) -> AppResult<Json<Value>> {
|
||||
pub async fn game_players(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(b): Json<PlayersBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.map_enabled)?;
|
||||
state.worldmap.set_players(server.id, b.players);
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Claims and pins, already drawn into shapes by the game server. Stored as is, size-limited.
|
||||
pub async fn game_overlay(GameServer(server): GameServer, State(state): State<AppState>, Json(overlay): Json<Value>) -> AppResult<Json<Value>> {
|
||||
pub async fn game_overlay(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(overlay): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.map_enabled)?;
|
||||
let ok = overlay.is_object() && overlay.get("claims").is_none_or(|c| c.is_array()) && overlay.get("pins").is_none_or(|p| p.is_array());
|
||||
if !ok || overlay.to_string().len() > 4 * 1024 * 1024 {
|
||||
@@ -74,6 +89,19 @@ pub async fn game_overlay(GameServer(server): GameServer, State(state): State<Ap
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn game_diagnostics(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(value): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.map_enabled)?;
|
||||
if !value.is_object() || value.to_string().len() > 4096 {
|
||||
return Err(AppError::bad_request("invalid map diagnostics"));
|
||||
}
|
||||
state.worldmap.set_diagnostics(server.id, value);
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launcher and admin viewers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,13 +156,16 @@ pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id):
|
||||
v["stats"] = serde_json::to_value(state.worldmap.stats(id))?;
|
||||
v["epoch"] = json!(state.worldmap.epoch(id));
|
||||
v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?;
|
||||
v["diagnostics"] = state.worldmap.diagnostics(id);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
get_server(&state, id).await?;
|
||||
let map = state.worldmap.clone();
|
||||
tokio::task::spawn_blocking(move || map.reset(id)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
tokio::task::spawn_blocking(move || map.reset(id))
|
||||
.await
|
||||
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -146,7 +177,12 @@ pub struct TokenQuery {
|
||||
|
||||
/// `GET /api/map/{server}/{dimension}/{zoom}/{x}/{y}.png?t=<token>`. The token comes from [`viewer_info`], so only signed-in
|
||||
/// players can load tiles, yet plain `<img>` tags work.
|
||||
pub async fn tile(State(state): State<AppState>, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query<TokenQuery>, headers: axum::http::HeaderMap) -> Response {
|
||||
pub async fn tile(
|
||||
State(state): State<AppState>,
|
||||
Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>,
|
||||
Query(q): Query<TokenQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let not_found = || (StatusCode::NOT_FOUND, "no tile").into_response();
|
||||
if !state.worldmap.token_ok(id, &q.t) {
|
||||
return (StatusCode::UNAUTHORIZED, "map link expired").into_response();
|
||||
@@ -193,7 +229,9 @@ async fn landing_map(state: &AppState, requested: i64) -> AppResult<(i64, Value)
|
||||
if !server.map_enabled {
|
||||
return Err(AppError::not_found("no public map"));
|
||||
}
|
||||
let flag = |key: &str, default: bool| block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default);
|
||||
let flag = |key: &str, default: bool| {
|
||||
block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default)
|
||||
};
|
||||
Ok((id, json!({ "claims": flag("show_claims", true), "pins": flag("show_pins", true), "players": flag("show_players", false) })))
|
||||
}
|
||||
|
||||
|
||||
+940
-88
File diff suppressed because it is too large.
Load diff
@@ -56,6 +56,7 @@ pub struct WorldMap {
|
||||
tokens: Mutex<HashMap<String, (i64, Instant)>>,
|
||||
overlays: Mutex<HashMap<i64, Value>>,
|
||||
stats: Mutex<HashMap<i64, Stats>>,
|
||||
diagnostics: Mutex<HashMap<i64, (Instant, Value)>>,
|
||||
}
|
||||
|
||||
/// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space").
|
||||
@@ -67,7 +68,10 @@ pub fn dim_slug(id: &str) -> Option<String> {
|
||||
"end" => "the_end".to_string(),
|
||||
other => other.replace(':', "__"),
|
||||
};
|
||||
let ok = !slug.is_empty() && slug.len() <= 48 && slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.')) && !slug.contains("..");
|
||||
let ok = !slug.is_empty()
|
||||
&& slug.len() <= 48
|
||||
&& slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.'))
|
||||
&& !slug.contains("..");
|
||||
ok.then_some(slug)
|
||||
}
|
||||
|
||||
@@ -130,6 +134,7 @@ impl WorldMap {
|
||||
tokens: Default::default(),
|
||||
overlays: Default::default(),
|
||||
stats: Default::default(),
|
||||
diagnostics: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +180,8 @@ impl WorldMap {
|
||||
pub fn summary(&self, id: i64) -> Value {
|
||||
let mut dims = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(self.server_dir(id)) else { return json!({ "dimensions": dims }) };
|
||||
let mut names: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect();
|
||||
let mut names: Vec<String> =
|
||||
rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect();
|
||||
names.sort_by_key(|n| (n != "overworld", n != "the_nether", n != "the_end", n.clone()));
|
||||
for slug in names {
|
||||
let (mut count, mut bytes) = (0u64, 0u64);
|
||||
@@ -213,6 +219,7 @@ impl WorldMap {
|
||||
std::fs::write(dir.join("epoch"), epoch.to_string())?;
|
||||
self.overlays.lock().unwrap().remove(&id);
|
||||
*self.stats.lock().unwrap().entry(id).or_default() = Stats::default();
|
||||
self.diagnostics.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -227,12 +234,30 @@ impl WorldMap {
|
||||
self.players.lock().unwrap().remove(&id);
|
||||
self.overlays.lock().unwrap().remove(&id);
|
||||
self.stats.lock().unwrap().remove(&id);
|
||||
self.diagnostics.lock().unwrap().remove(&id);
|
||||
}
|
||||
|
||||
pub fn stats(&self, id: i64) -> Stats {
|
||||
self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&self, id: i64, value: Value) {
|
||||
self.diagnostics.lock().unwrap().insert(id, (Instant::now(), value));
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self, id: i64) -> Value {
|
||||
self.diagnostics
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.map(|(at, value)| {
|
||||
let mut value = value.clone();
|
||||
value["age_seconds"] = json!(at.elapsed().as_secs());
|
||||
value
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
// ---- players -------------------------------------------------------------------------
|
||||
|
||||
pub fn set_players(&self, id: i64, mut players: Vec<LivePlayer>) {
|
||||
@@ -274,7 +299,10 @@ impl WorldMap {
|
||||
if let Some(v) = self.overlays.lock().unwrap().get(&id) {
|
||||
return v.clone();
|
||||
}
|
||||
let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json")).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or_else(|| json!({ "claims": [], "pins": [] }));
|
||||
let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.unwrap_or_else(|| json!({ "claims": [], "pins": [] }));
|
||||
self.overlays.lock().unwrap().insert(id, loaded.clone());
|
||||
loaded
|
||||
}
|
||||
@@ -379,7 +407,15 @@ mod tests {
|
||||
assert!(!map.token_ok(4, &t), "a token only opens its own server");
|
||||
assert!(!map.token_ok(3, "nope"));
|
||||
|
||||
let p = |name: &str, x: f64| LivePlayer { uuid: "ab-12\u{7}zz".into(), name: format!("{name}\n"), dimension: "minecraft:overworld".into(), x, y: 64.0, z: 0.0, yaw: 0.0 };
|
||||
let p = |name: &str, x: f64| LivePlayer {
|
||||
uuid: "ab-12\u{7}zz".into(),
|
||||
name: format!("{name}\n"),
|
||||
dimension: "minecraft:overworld".into(),
|
||||
x,
|
||||
y: 64.0,
|
||||
z: 0.0,
|
||||
yaw: 0.0,
|
||||
};
|
||||
map.set_players(3, vec![p("Alex", 1.0), p("Bad", f64::NAN), p("Far", 1e9)]);
|
||||
let live = map.live_players(3);
|
||||
assert_eq!(live.len(), 1);
|
||||
|
||||
@@ -32,13 +32,22 @@ async fn claim_index_follows_claims_and_membership() {
|
||||
assert!(same.get("claims").is_none());
|
||||
|
||||
// A guild, then a claim: the revision moves and the index lists the chunk.
|
||||
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let (_, g) =
|
||||
t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let gid = g["id"].as_str().unwrap().to_string();
|
||||
let (_, r1) = index(Some(rev0.clone())).await;
|
||||
assert_eq!(r1["unchanged"], false, "guild creation bumps the revision");
|
||||
let claim = |x: i32| {
|
||||
let (t, tok, gid) = (&t, tokens[0].clone(), gid.clone());
|
||||
async move { t.call("POST", &format!("/api/v1/guilds/{gid}/claim"), Some(&tok), Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4}))).await }
|
||||
async move {
|
||||
t.call(
|
||||
"POST",
|
||||
&format!("/api/v1/guilds/{gid}/claim"),
|
||||
Some(&tok),
|
||||
Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let (s, c1) = claim(3).await;
|
||||
assert_eq!(s, StatusCode::OK, "{c1}");
|
||||
@@ -53,7 +62,12 @@ async fn claim_index_follows_claims_and_membership() {
|
||||
assert_eq!(index(Some(rev2.clone())).await.1["unchanged"], true);
|
||||
|
||||
// Membership changes bump it too.
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')")
|
||||
.bind(&gid)
|
||||
.bind(&steve_uuid)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, idx) = index(Some(rev2.clone())).await;
|
||||
assert_eq!(idx["unchanged"], false);
|
||||
assert_eq!(idx["members"][&gid].as_array().unwrap().len(), 2);
|
||||
|
||||
@@ -29,12 +29,21 @@ async fn plugins_read_players_and_change_progress() {
|
||||
assert_eq!(call("player/info", json!({"uuid": "nope"})).await.0, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Info: levels, balance, guild, quests.
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 321.5, 'now')").bind(sid).bind(&steve).execute(&t.db).await.unwrap();
|
||||
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 321.5, 'now')")
|
||||
.bind(sid)
|
||||
.bind(&steve)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, g) =
|
||||
t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
|
||||
let (s, info) = call("player/info", json!({"uuid": steve})).await;
|
||||
assert_eq!(s, StatusCode::OK, "{info}");
|
||||
assert_eq!((info["name"].as_str(), info["balance"].as_f64(), info["guild"]["tag"].as_str(), info["guild"]["role"].as_str()), (Some("Steve"), Some(321.5), Some("IRON"), Some("leader")));
|
||||
assert_eq!(
|
||||
(info["name"].as_str(), info["balance"].as_f64(), info["guild"]["tag"].as_str(), info["guild"]["role"].as_str()),
|
||||
(Some("Steve"), Some(321.5), Some("IRON"), Some("leader"))
|
||||
);
|
||||
// Founding a guild unlocks an achievement worth XP, so the player starts above zero.
|
||||
let base = info["global"]["xp"].as_i64().unwrap();
|
||||
assert!(base > 0);
|
||||
@@ -53,21 +62,33 @@ async fn plugins_read_players_and_change_progress() {
|
||||
let (_, r) = call("player/xp", xp("x3", "global", -200)).await;
|
||||
assert_eq!(r["xp"].as_i64(), Some(base + 1000));
|
||||
assert_eq!(call("player/xp", xp("x4", "global", 0)).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(call("player/xp", json!({"operation_id": "x5", "uuid": "00000000-0000-0000-0000-00000000dead", "amount": 5})).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
call("player/xp", json!({"operation_id": "x5", "uuid": "00000000-0000-0000-0000-00000000dead", "amount": 5})).await.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
let (_, info) = call("player/info", json!({"uuid": steve})).await;
|
||||
assert_eq!((info["global"]["xp"].as_i64(), info["server"]["xp"].as_i64(), info["server"]["level"].as_i64()), (Some(base + 1000), Some(400), Some(2)));
|
||||
assert_eq!(
|
||||
(info["global"]["xp"].as_i64(), info["server"]["xp"].as_i64(), info["server"]["level"].as_i64()),
|
||||
(Some(base + 1000), Some(400), Some(2))
|
||||
);
|
||||
|
||||
// Quest objectives by quest id (complete it), and by action.
|
||||
let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
|
||||
let quest = quests.as_array().unwrap().iter().find(|q| q["quest"]["period"] == "daily").unwrap()["quest"]["id"].as_str().unwrap().to_string();
|
||||
let quest =
|
||||
quests.as_array().unwrap().iter().find(|q| q["quest"]["period"] == "daily").unwrap()["quest"]["id"].as_str().unwrap().to_string();
|
||||
let (s, r) = call("player/quest-objective", json!({"operation_id": "q1", "uuid": steve, "objective": quest, "amount": 0})).await;
|
||||
assert_eq!((s, r["advanced"].clone()), (StatusCode::OK, json!(true)), "{r}");
|
||||
let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
|
||||
let done = quests.as_array().unwrap().iter().find(|q| q["quest"]["id"] == quest.as_str()).unwrap();
|
||||
assert_eq!(done["completed"], true);
|
||||
let (_, r) = call("player/quest-objective", json!({"operation_id": "q2", "uuid": steve, "objective": "block_broken:DIAMOND_ORE@minecraft:overworld", "amount": 3})).await;
|
||||
let (_, r) = call(
|
||||
"player/quest-objective",
|
||||
json!({"operation_id": "q2", "uuid": steve, "objective": "block_broken:DIAMOND_ORE@minecraft:overworld", "amount": 3}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r["advanced"], true);
|
||||
let (_, r) = call("player/quest-objective", json!({"operation_id": "q3", "uuid": steve, "objective": "nothing:at_all", "amount": 3})).await;
|
||||
let (_, r) =
|
||||
call("player/quest-objective", json!({"operation_id": "q3", "uuid": steve, "objective": "nothing:at_all", "amount": 3})).await;
|
||||
assert_eq!(r["advanced"], false);
|
||||
let (_, info) = call("player/info", json!({"uuid": steve})).await;
|
||||
assert_eq!(info["quests"]["daily"]["completed"], 1);
|
||||
|
||||
@@ -20,7 +20,9 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
|
||||
tpl["achievement"]["image"] = json!("https://cdn.example.com/banner.png");
|
||||
let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl.clone()}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]}))).await;
|
||||
let (s, p) = t
|
||||
.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]})))
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{p}");
|
||||
let e = &p["embeds"][0];
|
||||
assert_eq!(e["title"], "🎉 admin did it!");
|
||||
@@ -34,7 +36,12 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
|
||||
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival SMP", "instance_id": "smp"}))).await;
|
||||
let sid = srv["server"]["id"].as_i64().unwrap();
|
||||
t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron Wolves", "tag": "IRON"}))).await;
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')").bind(sid).bind(t.uuid("Steve").await).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')")
|
||||
.bind(sid)
|
||||
.bind(t.uuid("Steve").await)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
for (kind, needle) in [("status", "Survival SMP"), ("guilds", "Iron Wolves"), ("baltop", "$123,456"), ("leaderboard", "Leaderboard")] {
|
||||
let cfg = all["live"][kind].clone();
|
||||
let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": kind, "live": cfg}))).await;
|
||||
@@ -47,7 +54,10 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
|
||||
live["status"]["interval_secs"] = json!(5);
|
||||
live["status"]["limit"] = json!(500);
|
||||
live["status"]["webhook_url"] = json!("https://evil.example.com/hook");
|
||||
assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
live["status"]["webhook_url"] = json!("https://discord.com/api/webhooks/1/abc");
|
||||
assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live}))).await.0, StatusCode::OK);
|
||||
let (_, again) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await;
|
||||
|
||||
@@ -20,21 +20,32 @@ async fn emails_use_templates_audiences_and_opt_outs() {
|
||||
assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(ok)).await.0, StatusCode::OK);
|
||||
|
||||
// Placeholders fill per player, and the HTML is branded and has an unsubscribe link.
|
||||
let (s, p) = t.call("POST", "/api/admin/email/preview", Some(&admin), Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"}))).await;
|
||||
let (s, p) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/admin/email/preview",
|
||||
Some(&admin),
|
||||
Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{p}");
|
||||
assert_eq!(p["subject"], "Hi admin");
|
||||
assert!(p["html"].as_str().unwrap().contains("<strong>admin</strong>") && p["html"].as_str().unwrap().contains("Unsubscribe"));
|
||||
assert!(p["text"].as_str().unwrap().contains("/api/v1/email/unsubscribe?u="));
|
||||
|
||||
// Only active players with an email count.
|
||||
let count = |a: Value| { let (t, admin) = (&t, admin.clone()); async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 } };
|
||||
let count = |a: Value| {
|
||||
let (t, admin) = (&t, admin.clone());
|
||||
async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 }
|
||||
};
|
||||
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "Steve and Alex have an address; Notch and admin don't");
|
||||
assert_eq!(count(json!({"kind": "players", "names": ["steve", "nobody"]})).await["count"], 1);
|
||||
|
||||
// Opting out through the link removes a player from marketing mail, but not from account notices.
|
||||
let steve = t.uuid("Steve").await;
|
||||
let token = {
|
||||
let secret: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap();
|
||||
let secret: String =
|
||||
sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap();
|
||||
let secret = secret.trim_matches('"').to_string();
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(format!("{secret}:{steve}").as_bytes()).iter().take(16).map(|b| format!("{b:02x}")).collect::<String>()
|
||||
@@ -44,15 +55,21 @@ async fn emails_use_templates_audiences_and_opt_outs() {
|
||||
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "a wrong token changes nothing");
|
||||
t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t={token}")).await;
|
||||
assert_eq!(count(json!({"kind": "all"})).await["count"], 1);
|
||||
let (_, important) = t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await;
|
||||
let (_, important) =
|
||||
t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await;
|
||||
assert_eq!(important["count"], 2);
|
||||
|
||||
// Without SMTP configured, a send is queued but each message fails with a clear reason in the log.
|
||||
let (s, sent) = t.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}}))).await;
|
||||
let (s, sent) = t
|
||||
.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}})))
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{sent}");
|
||||
for _ in 0..40 {
|
||||
let (_, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await;
|
||||
if all["log"][0]["failed"] == 1 { assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend")); return; }
|
||||
if all["log"][0]["failed"] == 1 {
|
||||
assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend"));
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
panic!("the send was never logged");
|
||||
|
||||
@@ -38,7 +38,8 @@ async fn leveling_and_quests_progression() {
|
||||
let (alex_token, _, alex_uuid, _) = setup_users(&t).await;
|
||||
let (_server_id, server_token) = create_test_server(&t, &admin).await;
|
||||
// Hand every quest to every player so progress can be checked per quest.
|
||||
let (s, v) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await;
|
||||
let (s, v) =
|
||||
t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
|
||||
// 1. Initial level check
|
||||
@@ -97,8 +98,8 @@ async fn leveling_and_quests_progression() {
|
||||
assert!(current_xp > 0, "Alex should have earned XP");
|
||||
let (s, quests_after) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let progress = |id: &str| quests_after.as_array().unwrap().iter()
|
||||
.find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap();
|
||||
let progress =
|
||||
|id: &str| quests_after.as_array().unwrap().iter().find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap();
|
||||
assert_eq!(progress("q_d_mine_diamond"), 4);
|
||||
assert_eq!(progress("q_d_mine_coal"), 0);
|
||||
|
||||
@@ -146,8 +147,14 @@ async fn guilds_and_land_claims() {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await;
|
||||
let (server_id, server_token) = create_test_server(&t, &admin).await;
|
||||
let (status, updated) = t.call("PUT", &format!("/api/admin/servers/{server_id}"), Some(&admin),
|
||||
Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"}))).await;
|
||||
let (status, updated) = t
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/servers/{server_id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{updated}");
|
||||
|
||||
// 1. Alex founds a guild
|
||||
@@ -261,11 +268,23 @@ async fn guilds_and_land_claims() {
|
||||
assert_eq!(check_unclaimed["allowed"], true);
|
||||
|
||||
// The in-game command uses the server unclaim route rather than DELETE.
|
||||
let (s, _) = t.call("POST", "/api/server/v1/guilds/claim", Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/claim",
|
||||
Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, response) = t.call("POST", "/api/server/v1/guilds/unclaim", Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
|
||||
let (s, response) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/guilds/unclaim",
|
||||
Some(&server_token),
|
||||
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{response}");
|
||||
}
|
||||
|
||||
@@ -275,24 +294,12 @@ async fn social_friends_dms_and_profiles() {
|
||||
let (alex_token, steve_token, alex_uuid, steve_uuid) = setup_users(&t).await;
|
||||
|
||||
// 1. Alex sends friend request to Steve
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/social/friends/request",
|
||||
Some(&alex_token),
|
||||
Some(json!({"friend_username": "Steve"})),
|
||||
)
|
||||
.await;
|
||||
let (s, _) = t.call("POST", "/api/v1/social/friends/request", Some(&alex_token), Some(json!({"friend_username": "Steve"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// 2. Steve accepts friend request
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/v1/social/friends/respond",
|
||||
Some(&steve_token),
|
||||
Some(json!({"friend_uuid": &alex_uuid, "accept": true})),
|
||||
)
|
||||
.call("POST", "/api/v1/social/friends/respond", Some(&steve_token), Some(json!({"friend_uuid": &alex_uuid, "accept": true})))
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
|
||||
@@ -21,14 +21,20 @@ async fn fixture() -> Fixture {
|
||||
for n in ["Alex", "Steve", "Mia"] {
|
||||
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
|
||||
}
|
||||
let (alex, steve, mia) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await, t.login("Mia", "password123").await);
|
||||
let (alex, steve, mia) =
|
||||
(t.login("Alex", "password123").await, t.login("Steve", "password123").await, t.login("Mia", "password123").await);
|
||||
let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await);
|
||||
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
|
||||
let sid = srv["server"]["id"].as_i64().unwrap();
|
||||
let server = srv["token"].as_str().unwrap().to_string();
|
||||
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&alex), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let gid = g["id"].as_str().unwrap().to_string();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', '2026-01-01')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', '2026-01-01')")
|
||||
.bind(&gid)
|
||||
.bind(&steve_uuid)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
// Mia leads a second guild.
|
||||
t.call("POST", "/api/v1/guilds", Some(&mia), Some(json!({"instance_id": "smp", "name": "Void", "tag": "VOID"}))).await;
|
||||
Fixture { t, server, sid, alex, steve, alex_uuid, steve_uuid, gid }
|
||||
@@ -39,7 +45,12 @@ impl Fixture {
|
||||
self.t.call("POST", &format!("/api/server/v1/{path}"), Some(&self.server), Some(body)).await
|
||||
}
|
||||
async fn guild_balance(&self) -> f64 {
|
||||
sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)").bind(&self.gid).bind(self.sid).fetch_one(&self.t.db).await.unwrap()
|
||||
sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)")
|
||||
.bind(&self.gid)
|
||||
.bind(self.sid)
|
||||
.fetch_one(&self.t.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +98,18 @@ async fn bank_deposits_withdrawals_and_roles() {
|
||||
#[tokio::test]
|
||||
async fn shop_sales_and_guild_payments() {
|
||||
let f = fixture().await;
|
||||
let credit = json!({"operation_id": "sale-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 64.0, "description": "64x Cobblestone"});
|
||||
let credit =
|
||||
json!({"operation_id": "sale-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 64.0, "description": "64x Cobblestone"});
|
||||
let (s, r) = f.post("guilds/bank/credit", credit.clone()).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(f.post("guilds/bank/credit", credit).await.1, r);
|
||||
assert_eq!(f.guild_balance().await, 64.0);
|
||||
let (_, nobody) = f.post("guilds/bank/credit", json!({"operation_id": "sale-2", "uuid": "00000000-0000-0000-0000-000000000009", "username": "X", "amount": 5.0})).await;
|
||||
let (_, nobody) = f
|
||||
.post(
|
||||
"guilds/bank/credit",
|
||||
json!({"operation_id": "sale-2", "uuid": "00000000-0000-0000-0000-000000000009", "username": "X", "amount": 5.0}),
|
||||
)
|
||||
.await;
|
||||
assert!(nobody["error"].as_str().unwrap().contains("not in a guild"));
|
||||
|
||||
let pay = |op: &str, who: &str, tag: &str, amount: f64| json!({"operation_id": op, "uuid": who, "to_tag": tag, "amount": amount});
|
||||
@@ -103,7 +120,8 @@ async fn shop_sales_and_guild_payments() {
|
||||
let (s, r) = f.post("guilds/bank/pay", pay("p5", &f.alex_uuid, "void", 24.0)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["balance"], 40.0);
|
||||
let void_balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id <> ?").bind(&f.gid).fetch_one(&f.t.db).await.unwrap();
|
||||
let void_balance: f64 =
|
||||
sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id <> ?").bind(&f.gid).fetch_one(&f.t.db).await.unwrap();
|
||||
assert_eq!(void_balance, 24.0);
|
||||
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||
assert_eq!(kinds, ["sale", "transfer_out", "transfer_in"]);
|
||||
@@ -126,7 +144,13 @@ async fn guild_market_listings_and_purchases() {
|
||||
// Mia's guild buys it with guild money: she's an officer but the bank is empty first.
|
||||
let mia_uuid = f.t.uuid("Mia").await;
|
||||
let buy = |op: &str| json!({"operation_id": op, "listing_id": listing, "buyer_uuid": mia_uuid, "buyer_name": "Mia", "as_guild": true});
|
||||
sqlx::query("INSERT INTO guild_wallets (server_id, guild_id, balance, updated_at) SELECT ?, id, 50, 'now' FROM guilds WHERE tag = 'VOID'").bind(f.sid).execute(&f.t.db).await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO guild_wallets (server_id, guild_id, balance, updated_at) SELECT ?, id, 50, 'now' FROM guilds WHERE tag = 'VOID'",
|
||||
)
|
||||
.bind(f.sid)
|
||||
.execute(&f.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (s, e) = f.post("economy/market/buy", buy("b1")).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "{e}");
|
||||
let still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market").fetch_one(&f.t.db).await.unwrap();
|
||||
@@ -136,7 +160,8 @@ async fn guild_market_listings_and_purchases() {
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["new_balance"], 380.0);
|
||||
assert_eq!(f.guild_balance().await, 120.0, "the sale is paid into the listing guild's bank");
|
||||
let alex_balance: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
|
||||
let alex_balance: Option<f64> =
|
||||
sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
|
||||
assert!(alex_balance.is_none(), "the lister is not paid personally");
|
||||
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||
assert_eq!(kinds, ["purchase", "sale"]);
|
||||
@@ -150,6 +175,7 @@ async fn guild_market_listings_and_purchases() {
|
||||
let (s, r) = f.post("economy/market/buy", pay).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["new_balance"], 880.0);
|
||||
let steve_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.steve_uuid).fetch_one(&f.t.db).await.unwrap();
|
||||
let steve_balance: f64 =
|
||||
sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.steve_uuid).fetch_one(&f.t.db).await.unwrap();
|
||||
assert_eq!(steve_balance, 1120.0);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ async fn leader_approves_join_request_and_role_controls_claims() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
for name in ["Leader", "Applicant"] {
|
||||
let (status, body) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
|
||||
let (status, body) =
|
||||
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
}
|
||||
let leader = t.login("Leader", "password123").await;
|
||||
@@ -14,7 +15,8 @@ async fn leader_approves_join_request_and_role_controls_claims() {
|
||||
let applicant_uuid = t.uuid("Applicant").await;
|
||||
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await;
|
||||
let server_id = server["server"]["id"].as_i64().unwrap();
|
||||
let (status, guild) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
|
||||
let (status, guild) =
|
||||
t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{guild}");
|
||||
let id = guild["id"].as_str().unwrap();
|
||||
let path = format!("/api/v1/guilds/{id}");
|
||||
@@ -23,11 +25,18 @@ async fn leader_approves_join_request_and_role_controls_claims() {
|
||||
assert_eq!(t.call("GET", &format!("{path}/requests"), Some(&applicant), None).await.0, StatusCode::FORBIDDEN);
|
||||
let (_, requests) = t.call("GET", &format!("{path}/requests"), Some(&leader), None).await;
|
||||
assert_eq!(requests[0]["message"], "Let me in");
|
||||
assert_eq!(t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0, StatusCode::OK);
|
||||
assert_eq!(
|
||||
t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let (status, role) = t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
|
||||
let (status, role) =
|
||||
t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
|
||||
assert_eq!(status, StatusCode::OK, "{role}");
|
||||
assert_eq!(t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0, StatusCode::OK);
|
||||
assert_eq!(
|
||||
t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
let claim = json!({"server_id":server_id,"dimension":"minecraft:overworld","chunk_x":1,"chunk_z":1});
|
||||
assert_eq!(t.call("POST", &format!("{path}/claim"), Some(&applicant), Some(claim)).await.0, StatusCode::FORBIDDEN);
|
||||
let role_id = role["id"].as_i64().unwrap();
|
||||
|
||||
@@ -24,7 +24,14 @@ impl Env {
|
||||
(self.t.login(name, "password123").await, self.t.uuid(name).await)
|
||||
}
|
||||
async fn report(&self, name: &str, data: Value) -> (StatusCode, Value) {
|
||||
self.t.call("POST", "/api/server/v1/integrations/report", Some(&self.server), Some(json!({"name": name, "version": "5.4.1", "data": data}))).await
|
||||
self.t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/integrations/report",
|
||||
Some(&self.server),
|
||||
Some(json!({"name": name, "version": "5.4.1", "data": data})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn sync(&self, online: &[(&str, &str)]) -> Value {
|
||||
let online: Vec<Value> = online.iter().map(|(u, n)| json!({"uuid": u, "name": n})).collect();
|
||||
@@ -48,19 +55,31 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
|
||||
}
|
||||
let (_, groups) = e.t.call("GET", "/api/admin/groups", Some(&e.admin), None).await;
|
||||
let id = |n: &str| groups.as_array().unwrap().iter().find(|g| g["name"] == n).unwrap()["id"].as_i64().unwrap();
|
||||
let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("VIP")), Some(&e.admin), Some(json!({"luckperms_group": "vip"}))).await;
|
||||
let (s, _) =
|
||||
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("VIP")), Some(&e.admin), Some(json!({"luckperms_group": "vip"})))
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "bad group!"}))).await;
|
||||
let (s, _) =
|
||||
e.t.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/groups/{}/luckperms", id("Staff")),
|
||||
Some(&e.admin),
|
||||
Some(json!({"luckperms_group": "bad group!"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "Moderator"}))).await;
|
||||
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "Moderator"})))
|
||||
.await;
|
||||
|
||||
let lp = |steve_groups: Value| json!({
|
||||
let lp = |steve_groups: Value| {
|
||||
json!({
|
||||
"groups": [{"name": "vip", "weight": 10}, {"name": "moderator", "weight": 50}],
|
||||
"players": [
|
||||
{"uuid": steve, "primary": "VIP", "display": "VIP", "prefix": "§6[VIP] ", "weight": 10, "groups": steve_groups, "permissions": ["scopenet.command.*", "essentials.fly"]},
|
||||
{"uuid": alex, "primary": "default", "groups": ["default"]},
|
||||
{"uuid": "not-a-uuid", "primary": "x"}
|
||||
]});
|
||||
]})
|
||||
};
|
||||
|
||||
// Default mode is display-only: ranks are stored, no group changes.
|
||||
let (s, r) = e.report("luckperms", lp(json!(["default", "vip"]))).await;
|
||||
@@ -77,7 +96,8 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
|
||||
assert_eq!(view["integrations"][0]["data"]["groups"][1]["name"], "moderator");
|
||||
|
||||
// game_to_panel: mapped groups follow LuckPerms, including removal.
|
||||
let (s, _) = e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "game_to_panel"}))).await;
|
||||
let (s, _) =
|
||||
e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "game_to_panel"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
e.report("luckperms", lp(json!(["default", "vip", "moderator"]))).await;
|
||||
assert_eq!(e.groups_of("Steve").await, ["Staff", "VIP"]);
|
||||
@@ -86,7 +106,11 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
|
||||
assert!(r["config"]["assign"].as_array().unwrap().is_empty());
|
||||
|
||||
// panel_to_game: the panel's groups are pushed to the game as assignments.
|
||||
sqlx::query("INSERT INTO user_groups (user_id, group_id) SELECT u.id, ? FROM users u WHERE u.username = 'Alex'").bind(id("VIP")).execute(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO user_groups (user_id, group_id) SELECT u.id, ? FROM users u WHERE u.username = 'Alex'")
|
||||
.bind(id("VIP"))
|
||||
.execute(&e.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "panel_to_game"}))).await;
|
||||
let (_, r) = e.report("luckperms", lp(json!(["default", "moderator"]))).await;
|
||||
let assign = r["config"]["assign"].as_array().unwrap();
|
||||
@@ -107,7 +131,11 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
|
||||
async fn other_integrations_are_stored_per_server() {
|
||||
let e = env().await;
|
||||
e.report("spark", json!({"tps": {"m1": 19.98}, "mspt": {"mean": 12.5, "p95": 31.0}})).await;
|
||||
e.report("worldguard", json!({"regions": [{"world": "world", "id": "spawn", "min": [-50, 0, -50], "max": [50, 255, 50], "owners": ["admin"]}]})).await;
|
||||
e.report(
|
||||
"worldguard",
|
||||
json!({"regions": [{"world": "world", "id": "spawn", "min": [-50, 0, -50], "max": [50, 255, 50], "owners": ["admin"]}]}),
|
||||
)
|
||||
.await;
|
||||
e.report("spark", json!({"tps": {"m1": 20.0}})).await;
|
||||
let (_, view) = e.t.call("GET", &format!("/api/admin/servers/{}/integrations", e.sid), Some(&e.admin), None).await;
|
||||
let names: Vec<&str> = view["integrations"].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap()).collect();
|
||||
@@ -127,7 +155,11 @@ async fn game_servers_receive_events_with_their_sync() {
|
||||
// Level-ups: global and per-server.
|
||||
let adjust = |scope: &str, mode: &str, amount: i64| {
|
||||
let (t, admin, uuid, sid) = (&e.t, e.admin.clone(), steve.clone(), e.sid);
|
||||
let body = if scope == "global" { json!({"scope": "global", "mode": mode, "amount": amount}) } else { json!({"scope": "server", "server_id": sid, "mode": mode, "amount": amount}) };
|
||||
let body = if scope == "global" {
|
||||
json!({"scope": "global", "mode": mode, "amount": amount})
|
||||
} else {
|
||||
json!({"scope": "server", "server_id": sid, "mode": mode, "amount": amount})
|
||||
};
|
||||
async move { t.call("POST", &format!("/api/admin/progression/players/{uuid}/adjust"), Some(&admin), Some(body)).await }
|
||||
};
|
||||
adjust("global", "set_xp", 10).await;
|
||||
@@ -135,7 +167,8 @@ async fn game_servers_receive_events_with_their_sync() {
|
||||
adjust("server", "set_xp", 10).await;
|
||||
adjust("server", "set_xp", 300).await; // level 1 -> 2
|
||||
let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone();
|
||||
let kinds: Vec<(&str, &str)> = n.as_array().unwrap().iter().map(|x| (x["kind"].as_str().unwrap(), x["data"]["scope"].as_str().unwrap_or(""))).collect();
|
||||
let kinds: Vec<(&str, &str)> =
|
||||
n.as_array().unwrap().iter().map(|x| (x["kind"].as_str().unwrap(), x["data"]["scope"].as_str().unwrap_or(""))).collect();
|
||||
assert_eq!(kinds, [("level_up", "global"), ("level_up", "server")], "{n}");
|
||||
assert_eq!((n[0]["data"]["previous"].as_i64(), n[0]["data"]["level"].as_i64()), (Some(1), Some(5)));
|
||||
assert_eq!(n[0]["uuid"], steve);
|
||||
@@ -147,10 +180,27 @@ async fn game_servers_receive_events_with_their_sync() {
|
||||
|
||||
// Achievements and guild changes.
|
||||
let ach: String = sqlx::query_scalar("SELECT id FROM achievements LIMIT 1").fetch_one(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, 'now')").bind(&steve).bind(&ach).execute(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guilds (id, instance_id, name, tag, leader_uuid, created_at) VALUES ('g1', 'smp', 'Iron', 'IRON', ?, 'now')").bind(&alex).execute(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Steve', 'member', 'now')").bind(&steve).execute(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Alex', 'leader', 'now')").bind(&alex).execute(&e.t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, 'now')")
|
||||
.bind(&steve)
|
||||
.bind(&ach)
|
||||
.execute(&e.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO guilds (id, instance_id, name, tag, leader_uuid, created_at) VALUES ('g1', 'smp', 'Iron', 'IRON', ?, 'now')")
|
||||
.bind(&alex)
|
||||
.execute(&e.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Steve', 'member', 'now')")
|
||||
.bind(&steve)
|
||||
.execute(&e.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Alex', 'leader', 'now')")
|
||||
.bind(&alex)
|
||||
.execute(&e.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("DELETE FROM guild_members WHERE uuid = ?").bind(&steve).execute(&e.t.db).await.unwrap();
|
||||
let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone();
|
||||
let kinds: Vec<&str> = n.as_array().unwrap().iter().map(|x| x["kind"].as_str().unwrap()).collect();
|
||||
|
||||
@@ -34,7 +34,13 @@ async fn world() -> World {
|
||||
|
||||
impl World {
|
||||
async fn online(&self, uuid: &str, name: &str) {
|
||||
sqlx::query("INSERT OR REPLACE INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z')").bind(self.sid).bind(uuid).bind(name).execute(&self.t.db).await.unwrap();
|
||||
sqlx::query("INSERT OR REPLACE INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z')")
|
||||
.bind(self.sid)
|
||||
.bind(uuid)
|
||||
.bind(name)
|
||||
.execute(&self.t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
async fn poll(&self) -> Value {
|
||||
self.t.call("POST", "/api/server/v1/actions/poll", Some(&self.server), Some(json!({}))).await.1
|
||||
@@ -55,7 +61,14 @@ async fn tpa_and_messages_are_queued_once_for_the_game_server() {
|
||||
w.online(&w.alex_uuid, "Alex").await;
|
||||
assert_eq!(w.t.call("POST", &path, Some(&w.alex), Some(tpa)).await.0, StatusCode::OK);
|
||||
|
||||
let (s, r) = w.t.call("POST", &path, Some(&w.alex), Some(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " hi\n\tthere\u{7} friend "}))).await;
|
||||
let (s, r) =
|
||||
w.t.call(
|
||||
"POST",
|
||||
&path,
|
||||
Some(&w.alex),
|
||||
Some(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " hi\n\tthere\u{7} friend "})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
|
||||
let polled = w.poll().await;
|
||||
@@ -70,7 +83,10 @@ async fn tpa_and_messages_are_queued_once_for_the_game_server() {
|
||||
assert_eq!(bad(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " "})).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(bad(json!({"action": "kill", "target_uuid": w.steve_uuid})).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(bad(json!({"action": "tpa", "target_uuid": w.alex_uuid})).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(w.t.call("POST", &path, None, Some(json!({"action": "tpa", "target_uuid": w.steve_uuid}))).await.0, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
w.t.call("POST", &path, None, Some(json!({"action": "tpa", "target_uuid": w.steve_uuid}))).await.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
// A burst is slowed down.
|
||||
let mut last = StatusCode::OK;
|
||||
@@ -91,7 +107,14 @@ async fn guild_invites_need_consent() {
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
let in_guild = |uuid: &str| {
|
||||
let (db, gid, uuid) = (w.t.db.clone(), w.gid.clone(), uuid.to_string());
|
||||
async move { sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)").bind(gid).bind(uuid).fetch_one(&db).await.unwrap() }
|
||||
async move {
|
||||
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)")
|
||||
.bind(gid)
|
||||
.bind(uuid)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
assert!(!in_guild(&w.steve_uuid).await, "nobody is added without saying yes");
|
||||
|
||||
@@ -125,14 +148,22 @@ async fn invites_work_in_game_too() {
|
||||
};
|
||||
let (s, r) = server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "steve"})).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(server("guilds/invite/send", json!({"uuid": w.steve_uuid, "target": "alex"})).await.0, StatusCode::FORBIDDEN, "not a leader");
|
||||
assert_eq!(
|
||||
server("guilds/invite/send", json!({"uuid": w.steve_uuid, "target": "alex"})).await.0,
|
||||
StatusCode::FORBIDDEN,
|
||||
"not a leader"
|
||||
);
|
||||
assert_eq!(server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "nobody"})).await.0, StatusCode::NOT_FOUND);
|
||||
|
||||
let (_, list) = server("guilds/invite/list", json!({"uuid": w.steve_uuid})).await;
|
||||
assert_eq!(list["invites"][0]["guild_tag"], "IRON");
|
||||
let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "tag": "iron", "accept": false})).await;
|
||||
assert_eq!((s, r["accepted"].clone()), (StatusCode::OK, json!(false)));
|
||||
assert_eq!(server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await.0, StatusCode::NOT_FOUND, "a declined invitation is gone");
|
||||
assert_eq!(
|
||||
server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await.0,
|
||||
StatusCode::NOT_FOUND,
|
||||
"a declined invitation is gone"
|
||||
);
|
||||
|
||||
server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "Steve"})).await;
|
||||
let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await;
|
||||
|
||||
@@ -13,14 +13,29 @@ async fn servers_in_an_economy_group_share_balances() {
|
||||
let mut tokens = vec![];
|
||||
let mut ids = vec![];
|
||||
for (name, group) in [("SMP", "network"), ("Survival", "Network"), ("Modded", "")] {
|
||||
let (s, r) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": name, "instance_id": name.to_lowercase(), "economy_group": group}))).await;
|
||||
let (s, r) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/admin/servers",
|
||||
Some(&admin),
|
||||
Some(json!({"name": name, "instance_id": name.to_lowercase(), "economy_group": group})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
tokens.push(r["token"].as_str().unwrap().to_string());
|
||||
ids.push(r["server"]["id"].as_i64().unwrap());
|
||||
}
|
||||
let adjust = |token: String, op: &'static str, delta: f64| {
|
||||
let (t, uuid) = (&t, uuid.clone());
|
||||
async move { t.call("POST", "/api/server/v1/economy/adjust", Some(&token), Some(json!({"uuid": uuid, "username": "Alex", "delta": delta, "operation_id": op, "description": "test"}))).await }
|
||||
async move {
|
||||
t.call(
|
||||
"POST",
|
||||
"/api/server/v1/economy/adjust",
|
||||
Some(&token),
|
||||
Some(json!({"uuid": uuid, "username": "Alex", "delta": delta, "operation_id": op, "description": "test"})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let balance = |token: String| {
|
||||
let (t, uuid) = (&t, uuid.clone());
|
||||
@@ -38,7 +53,14 @@ async fn servers_in_an_economy_group_share_balances() {
|
||||
assert_eq!(balance(tokens[2].clone()).await.1["balance"], 1000.0);
|
||||
|
||||
// Bad group names are refused.
|
||||
let (s, _) = t.call("PUT", &format!("/api/admin/servers/{}", ids[2]), Some(&admin), Some(json!({"name": "Modded", "instance_id": "modded", "economy_group": "a<b>"}))).await;
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/servers/{}", ids[2]),
|
||||
Some(&admin),
|
||||
Some(json!({"name": "Modded", "instance_id": "modded", "economy_group": "a<b>"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Removing the server that holds the shared balances keeps them for the rest of the group.
|
||||
|
||||
@@ -20,7 +20,12 @@ async fn profile_shows_the_whole_player() {
|
||||
sqlx::query("INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) VALUES (?, ?, 'accepted', ?, 'x', 'x')").bind(a).bind(b).bind(a).execute(&t.db).await.unwrap();
|
||||
}
|
||||
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')")
|
||||
.bind(sid)
|
||||
.bind(&su)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, groups, permissions, weight, updated_at) VALUES (?, ?, 'vip', 'VIP', '[VIP]', '[\"vip\"]', '[]', 10, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO player_stats (server_id, uuid, name, playtime_secs, first_seen, last_seen) VALUES (?, ?, 'Steve', 7200, 'x', '2026-01-01T00:00:00Z')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, 'Steve', 'join', NULL, '2026-01-01T00:00:00Z'), (?, ?, 'Steve', 'chat', 'secret', '2026-01-01T00:00:01Z')").bind(sid).bind(&su).bind(sid).bind(&su).execute(&t.db).await.unwrap();
|
||||
@@ -43,6 +48,7 @@ async fn profile_shows_the_whole_player() {
|
||||
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "#22D3EE"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await.1["accent_color"], "#22d3ee");
|
||||
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
|
||||
let (s, _) =
|
||||
t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -47,6 +47,12 @@ async fn the_landing_page_decides_what_the_public_sees_of_the_map() {
|
||||
|
||||
// Another server's map isn't exposed, and turning the section off closes it.
|
||||
assert_eq!(t.call("GET", "/api/v1/public/map/999", None, None).await.0, StatusCode::NOT_FOUND);
|
||||
t.call("PUT", "/api/admin/landing", Some(&admin), Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]}))).await;
|
||||
t.call(
|
||||
"PUT",
|
||||
"/api/admin/landing",
|
||||
Some(&admin),
|
||||
Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -15,14 +15,28 @@ async fn renaming_a_rank_title_reaches_players() {
|
||||
.bind(sid).bind(&uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap();
|
||||
|
||||
let (s, made) = t.call("POST", "/api/admin/rewards", Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Squire"}))).await;
|
||||
let (s, made) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/admin/rewards",
|
||||
Some(&admin),
|
||||
Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Squire"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{made}");
|
||||
let id = made["id"].as_i64().unwrap();
|
||||
let rank = |v: &Value| v["server_levels"][0]["rank_name"].clone();
|
||||
let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await;
|
||||
assert_eq!(rank(&levels), "Squire", "{levels}");
|
||||
|
||||
let (s, r) = t.call("PUT", &format!("/api/admin/rewards/{id}"), Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Knight"}))).await;
|
||||
let (s, r) = t
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/rewards/{id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Knight"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await;
|
||||
assert_eq!(rank(&levels), "Knight", "a renamed rank shows up straight away");
|
||||
@@ -40,11 +54,24 @@ async fn renaming_a_global_title_reaches_the_profile_and_reward_list() {
|
||||
let steve = t.login("Steve", "password123").await;
|
||||
let uuid = t.uuid("Steve").await;
|
||||
sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap();
|
||||
let (_, made) = t.call("POST", "/api/admin/levels/rewards", Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Explorer"}))).await;
|
||||
let (_, made) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/admin/levels/rewards",
|
||||
Some(&admin),
|
||||
Some(json!({"level": 100, "reward_type": "title", "reward_value": "Explorer"})),
|
||||
)
|
||||
.await;
|
||||
let id = made["id"].as_i64().unwrap();
|
||||
let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await;
|
||||
assert_eq!(me["title"], "Explorer", "{me}");
|
||||
t.call("PUT", &format!("/api/admin/levels/rewards/{id}"), Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Pathfinder"}))).await;
|
||||
t.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/levels/rewards/{id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"level": 100, "reward_type": "title", "reward_value": "Pathfinder"})),
|
||||
)
|
||||
.await;
|
||||
let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await;
|
||||
assert_eq!(me["title"], "Pathfinder", "{me}");
|
||||
let rewards = me["rewards"].to_string();
|
||||
|
||||
@@ -24,16 +24,29 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
|
||||
|
||||
// Bad bundles are refused; good ones are normalised.
|
||||
for a in [json!({"type": "money", "amount": -5}), json!({"type": "item", "item": "bad item!", "amount": 1}), json!({"type": "nope"})] {
|
||||
assert_eq!(t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [a]}))).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [a]}))).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
assert_eq!(t.call("PUT", "/api/admin/reward-bundles/banana/x", Some(&admin), Some(json!({"actions": []}))).await.0, StatusCode::BAD_REQUEST);
|
||||
let (s, saved) = t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [
|
||||
assert_eq!(
|
||||
t.call("PUT", "/api/admin/reward-bundles/banana/x", Some(&admin), Some(json!({"actions": []}))).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let (s, saved) = t
|
||||
.call(
|
||||
"PUT",
|
||||
"/api/admin/reward-bundles/achievement/ach_test",
|
||||
Some(&admin),
|
||||
Some(json!({"actions": [
|
||||
{"type": "money", "amount": 250},
|
||||
{"type": "claim_chunks", "amount": 8},
|
||||
{"type": "item", "item": "diamond", "amount": 3},
|
||||
{"type": "permission", "node": "essentials.fly", "minutes": 10080},
|
||||
{"type": "group", "group": "vip"},
|
||||
{"type": "message", "text": "Well done!"}]}))).await;
|
||||
{"type": "message", "text": "Well done!"}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{saved}");
|
||||
assert_eq!(saved["actions"][2]["item"], "minecraft:diamond");
|
||||
|
||||
@@ -44,17 +57,32 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
|
||||
assert_eq!(sums["achievement:ach_test"][3], "Permission: essentials.fly for 7 days");
|
||||
|
||||
// Earning it applies the panel-side rewards straight away…
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Steve', 'now')").bind(sid).bind(&steve).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_test', 'now')").bind(&steve).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Steve', 'now')")
|
||||
.bind(sid)
|
||||
.bind(&steve)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_test', 'now')")
|
||||
.bind(&steve)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (s, polled) = game("rewards/poll", json!({})).await;
|
||||
assert_eq!(s, StatusCode::OK, "{polled}");
|
||||
let kinds: Vec<String> = polled["deliveries"].as_array().unwrap().iter().map(|d| d["kind"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(kinds, ["item", "permission", "group", "message"], "{polled}");
|
||||
assert_eq!(polled["deliveries"][0]["payload"]["item"], "minecraft:diamond");
|
||||
assert_eq!(polled["deliveries"][0]["name"], "Steve");
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(sid).bind(&steve).fetch_one(&t.db).await.unwrap();
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(sid)
|
||||
.bind(&steve)
|
||||
.fetch_one(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(balance, 1250.0, "the starting 1000 plus 250");
|
||||
let chunks: i64 = sqlx::query_scalar("SELECT claim_chunks FROM player_bonuses WHERE uuid = ?").bind(&steve).fetch_one(&t.db).await.unwrap();
|
||||
let chunks: i64 =
|
||||
sqlx::query_scalar("SELECT claim_chunks FROM player_bonuses WHERE uuid = ?").bind(&steve).fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(chunks, 8);
|
||||
|
||||
// …and a delivery is handed out once until acknowledged.
|
||||
@@ -69,7 +97,8 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
|
||||
assert_eq!(log.iter().filter(|d| !d["delivered_at"].is_null()).count(), 2);
|
||||
|
||||
// The extra chunks lift the player's guild's claim limit.
|
||||
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let (_, g) =
|
||||
t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||
let (_, detail) = t.call("GET", &format!("/api/v1/guilds/{}", g["id"].as_str().unwrap()), Some(&steve_token), None).await;
|
||||
assert_eq!(detail["max_claims"].as_i64(), Some(16 + 8), "{detail}");
|
||||
}
|
||||
@@ -86,7 +115,12 @@ async fn deliveries_wait_for_the_player_to_be_online() {
|
||||
sqlx::query("INSERT INTO reward_deliveries (uuid, kind, payload, source, created_at) VALUES (?, 'item', '{\"type\":\"item\",\"item\":\"minecraft:apple\",\"amount\":1}', 'x', 'now')").bind(&alex).execute(&t.db).await.unwrap();
|
||||
let poll = || async { t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1 };
|
||||
assert!(poll().await["deliveries"].as_array().unwrap().is_empty(), "offline players get nothing yet");
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')").bind(sid).bind(&alex).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')")
|
||||
.bind(sid)
|
||||
.bind(&alex)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(poll().await["deliveries"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
@@ -99,22 +133,62 @@ async fn a_rank_milestone_can_grant_more_than_its_title() {
|
||||
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
|
||||
let sid = srv["server"]["id"].as_i64().unwrap();
|
||||
let key = srv["token"].as_str().unwrap().to_string();
|
||||
let (_, made) = t.call("POST", "/api/admin/rewards", Some(&admin), Some(json!({"level_type": "global", "level_req": 3, "reward_type": "title", "reward_name": "Veteran"}))).await;
|
||||
let (_, made) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/admin/rewards",
|
||||
Some(&admin),
|
||||
Some(json!({"level_type": "global", "level_req": 3, "reward_type": "title", "reward_name": "Veteran"})),
|
||||
)
|
||||
.await;
|
||||
let id = made["id"].as_i64().unwrap();
|
||||
let (s, _) = t.call("PUT", &format!("/api/admin/reward-bundles/level_reward/{id}"), Some(&admin), Some(json!({"actions": [
|
||||
{"type": "money", "amount": 500}, {"type": "command", "command": "/give {player} cake 1"}]}))).await;
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/reward-bundles/level_reward/{id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"actions": [
|
||||
{"type": "money", "amount": 500}, {"type": "command", "command": "/give {player} cake 1"}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')").bind(sid).bind(&alex).execute(&t.db).await.unwrap();
|
||||
let (s, r) = t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv", "uuid": alex, "scope": "global", "amount": 100000}))).await;
|
||||
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')")
|
||||
.bind(sid)
|
||||
.bind(&alex)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (s, r) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/player/xp",
|
||||
Some(&key),
|
||||
Some(json!({"operation_id": "lv", "uuid": alex, "scope": "global", "amount": 100000})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
let (_, polled) = t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await;
|
||||
assert_eq!(polled["deliveries"][0]["kind"], "command", "{polled}");
|
||||
assert_eq!(polled["deliveries"][0]["payload"]["command"], "give {player} cake 1");
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(sid).bind(&alex).fetch_one(&t.db).await.unwrap();
|
||||
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||
.bind(sid)
|
||||
.bind(&alex)
|
||||
.fetch_one(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(balance, 1500.0);
|
||||
// Earning the same milestone twice pays once.
|
||||
t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100}))).await;
|
||||
assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"].as_array().unwrap().is_empty());
|
||||
t.call(
|
||||
"POST",
|
||||
"/api/server/v1/player/xp",
|
||||
Some(&key),
|
||||
Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100})),
|
||||
)
|
||||
.await;
|
||||
assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -68,12 +68,20 @@ async fn search_befriend_message_invite() {
|
||||
|
||||
/// Every table and column is scanned, so a table added later is covered too.
|
||||
async fn mentions(t: &TestApp, needle: &str) -> Vec<String> {
|
||||
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetch_all(&t.db).await.unwrap();
|
||||
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.fetch_all(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut hits = Vec::new();
|
||||
for table in tables {
|
||||
let cols: Vec<String> = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
|
||||
let cols: Vec<String> =
|
||||
sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
|
||||
for col in cols {
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?")).bind(needle).fetch_one(&t.db).await.unwrap();
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?"))
|
||||
.bind(needle)
|
||||
.fetch_one(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
if n > 0 {
|
||||
hits.push(format!("{table}.{col}"));
|
||||
}
|
||||
@@ -130,10 +138,16 @@ async fn deleting_an_account_removes_all_its_data() {
|
||||
.bind(sid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
|
||||
// Guilds: Steve leads one with Alex in it, and is sole member of another.
|
||||
let (s, g) = t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
|
||||
let (s, g) =
|
||||
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{g}");
|
||||
let gid = g["id"].as_str().unwrap().to_string();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')").bind(&gid).bind(&alex_uuid).execute(&t.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')")
|
||||
.bind(&gid)
|
||||
.bind(&alex_uuid)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO guild_wallet_transactions (server_id, guild_id, actor_uuid, kind, amount, created_at) VALUES (?, ?, ?, 'deposit', 5, 'now')").bind(sid).bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||
let (s, g2) = t.call("POST", "/api/v1/guilds", Some(&admin), Some(json!({"instance_id": "smp", "name": "Solo", "tag": "SOLO"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{g2}");
|
||||
@@ -147,11 +161,26 @@ async fn deleting_an_account_removes_all_its_data() {
|
||||
|
||||
// Nothing refers to them any more (their name appears only as plain text in free-form rows we don't keep).
|
||||
assert_eq!(mentions(&t, &steve_uuid).await, vec!["reserved_usernames.uuid".to_string()]);
|
||||
for table in ["users", "user_levels", "player_stats", "friendships", "direct_messages", "user_profiles", "user_posts", "server_market", "server_economy"] {
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?")).bind(&steve_uuid).fetch_one(&t.db).await.unwrap_or(0);
|
||||
for table in [
|
||||
"users",
|
||||
"user_levels",
|
||||
"player_stats",
|
||||
"friendships",
|
||||
"direct_messages",
|
||||
"user_profiles",
|
||||
"user_posts",
|
||||
"server_market",
|
||||
"server_economy",
|
||||
] {
|
||||
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?"))
|
||||
.bind(&steve_uuid)
|
||||
.fetch_one(&t.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
assert_eq!(n, 0, "{table}");
|
||||
}
|
||||
let name_hits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
|
||||
let name_hits: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(name_hits, 0);
|
||||
|
||||
// Other people keep what is theirs: Alex is now the guild's leader; the
|
||||
@@ -160,7 +189,11 @@ async fn deleting_an_account_removes_all_its_data() {
|
||||
assert_eq!(leader, alex_uuid);
|
||||
let from: String = sqlx::query_scalar("SELECT from_name FROM economy_transactions").fetch_one(&t.db).await.unwrap();
|
||||
assert_eq!(from, "Deleted player");
|
||||
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?").bind(alex_post["id"].as_i64().unwrap()).fetch_one(&t.db).await.unwrap();
|
||||
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?")
|
||||
.bind(alex_post["id"].as_i64().unwrap())
|
||||
.fetch_one(&t.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(likes, 0);
|
||||
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
|
||||
assert!(friends.as_array().unwrap().is_empty());
|
||||
|
||||
@@ -102,7 +102,10 @@ async fn players_and_overlay_are_served_to_viewers() {
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let overlay = json!({"claims": [{"guild_id": "g1", "name": "Iron", "tag": "IRON", "color": 123}], "pins": [{"id": "spawn-0", "kind": "spawn", "label": "Spawn"}]});
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(overlay)).await.0, StatusCode::OK);
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
let (s, o) = w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
@@ -114,6 +117,20 @@ async fn players_and_overlay_are_served_to_viewers() {
|
||||
assert_eq!(info["players"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn map_diagnostics_are_admin_only_and_reset_with_tiles() {
|
||||
let w = world().await;
|
||||
let payload = json!({"stage":"waiting for readable chunks","error":"No readable full chunks","region_files":1});
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", Some(&w.server), Some(payload)).await.0, StatusCode::OK);
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", None, Some(json!({}))).await.0, StatusCode::UNAUTHORIZED);
|
||||
let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
assert_eq!(status["diagnostics"]["stage"], "waiting for readable chunks");
|
||||
assert!(status["diagnostics"]["age_seconds"].is_number());
|
||||
w.t.call("DELETE", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
assert!(status["diagnostics"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_map_can_be_switched_off_and_reset() {
|
||||
let w = world().await;
|
||||
@@ -124,9 +141,20 @@ async fn the_map_can_be_switched_off_and_reset() {
|
||||
assert_eq!(cfg["epoch"], 1, "game servers see a new epoch and redraw");
|
||||
let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await;
|
||||
assert_eq!(info["ready"], false);
|
||||
assert_eq!(w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN, "admin only");
|
||||
assert_eq!(
|
||||
w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0,
|
||||
StatusCode::FORBIDDEN,
|
||||
"admin only"
|
||||
);
|
||||
|
||||
let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false}))).await;
|
||||
let (s, _) =
|
||||
w.t.call(
|
||||
"PUT",
|
||||
&format!("/api/admin/servers/{}", w.sid),
|
||||
Some(&w.admin),
|
||||
Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await.0, StatusCode::FORBIDDEN);
|
||||
let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await;
|
||||
|
||||
@@ -38,10 +38,10 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body { background: var(--bg); overflow-wrap: anywhere; }
|
||||
body { background: var(--bg); line-height: 1.5; overflow-wrap: normal; }
|
||||
|
||||
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
|
||||
*, *::before, *::after { min-width: 0; }
|
||||
.row > *, .grid > *, .card > *, .page-head > *, .section-title > * { min-width: 0; }
|
||||
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
|
||||
|
||||
|
||||
@@ -147,12 +147,14 @@ button, .btn {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 520;
|
||||
line-height: 1.35;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
|
||||
white-space: normal;
|
||||
overflow-wrap: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
button.primary, button.ghost, button.danger, button.icon, .btn { white-space: nowrap; }
|
||||
button.icon { white-space: nowrap; }
|
||||
button:hover { background: var(--surface-3); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
@@ -204,6 +206,7 @@ label.field {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; }
|
||||
|
||||
@@ -278,6 +281,7 @@ table.table {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr { transition: background 0.1s; }
|
||||
|
||||
@@ -72,6 +72,21 @@
|
||||
<div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div>
|
||||
<div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div>
|
||||
</div>
|
||||
<div class="diagnostics" role="status">
|
||||
{#if status?.diagnostics}
|
||||
<div class="diagnostic-head"><strong>Game server: {status.diagnostics.stage}</strong><span class:stale={status.diagnostics.age_seconds > 30}>{status.diagnostics.age_seconds > 30 ? 'No recent report' : `Reported ${status.diagnostics.age_seconds}s ago`}</span></div>
|
||||
<div class="diagnostic-grid">
|
||||
<span>Region files <strong>{status.diagnostics.region_files}</strong></span>
|
||||
<span>Rendered <strong>{status.diagnostics.regions_rendered}</strong></span>
|
||||
<span>Queued tiles <strong>{status.diagnostics.queued_tiles}</strong></span>
|
||||
<span>Uploaded <strong>{status.diagnostics.tiles_uploaded}</strong></span>
|
||||
</div>
|
||||
{#if status.diagnostics.error}<p class="diagnostic-error">{status.diagnostics.error}</p>{/if}
|
||||
<details><summary>Paths and upload details</summary><p>World: <code>{status.diagnostics.world_root}</code></p><p>Last region: {status.diagnostics.last_region || '—'}</p><p>Last overlay: {status.stats.last_overlay_at ? timeAgo(status.stats.last_overlay_at) : 'never'}</p></details>
|
||||
{:else}
|
||||
<p>No map report from the game server yet. Check <code>/map</code> in game, the server's <code>map.enabled</code> setting, and its panel token.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if status?.dimensions.length}
|
||||
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
|
||||
{/if}
|
||||
@@ -108,6 +123,15 @@
|
||||
.stats > div { display: flex; flex-direction: column; gap: 2px; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.78rem; color: var(--muted); }
|
||||
.stats strong { color: var(--text); font-size: 1.05rem; }
|
||||
.dims { margin: 0; }
|
||||
.diagnostics { padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); line-height: 1.5; }
|
||||
.diagnostic-head { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.diagnostic-head span { color: var(--good); font-size: 0.82rem; }
|
||||
.diagnostic-head span.stale, .diagnostic-error { color: var(--bad); }
|
||||
.diagnostic-grid { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0.5rem 0; color: var(--muted); font-size: 0.85rem; }
|
||||
.diagnostic-grid strong { color: var(--text); margin-left: 0.25rem; }
|
||||
.diagnostics details { margin-top: 0.4rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.diagnostics summary { cursor: pointer; }
|
||||
.diagnostics code { overflow-wrap: anywhere; }
|
||||
.stage { height: min(68vh, 640px); min-height: 380px; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); --map-accent: var(--accent); }
|
||||
.pcard { height: 100%; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; border-radius: 18px; background: rgba(14, 19, 34, 0.94); border: 1px solid rgba(255,255,255,0.12); backdrop-filter: blur(14px); color: #f4f6ff; position: relative; align-self: flex-start; height: auto; width: 100%; }
|
||||
.pcard .who { display: flex; gap: 0.7rem; align-items: center; }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<Globe size={14} /> View landing page
|
||||
</a>
|
||||
|
||||
<a class="docs" href="https://github.com/scopeddlol/SCOPENET-MC#readme" target="_blank" rel="noreferrer">
|
||||
<a class="docs" href="https://gitea.jadonsandbox.com/blade/scopenet-mc#readme" target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={14} /> Documentation
|
||||
</a>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<li>
|
||||
<strong>Install the SCOPENET integration</strong>
|
||||
<span class="muted small">Use scopenet-paper for Paper, Purpur or Spigot. Fabric and Forge need the JAR for the exact Minecraft version: 1.20.1, 1.21.1, 26.1.2, 26.2 or 26.3.</span>
|
||||
<a class="dl" href="https://github.com/scopeddlol/SCOPENET-MC/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
<a class="dl" href="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Configure it</strong>
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface MapStatus {
|
||||
players: number;
|
||||
epoch: number;
|
||||
stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null };
|
||||
diagnostics: { stage: string; error: string; world_root: string; region_files: number; regions_rendered: number; queued_tiles: number; tiles_uploaded: number; last_region: string; last_upload_ms: number; age_seconds: number } | null;
|
||||
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
data = await get<Data>('/api/admin/email');
|
||||
templates = structuredClone(data.templates);
|
||||
if (!groupId && data.groups[0]) groupId = data.groups[0].id;
|
||||
const d = await get<Data>('/api/admin/email');
|
||||
data = d;
|
||||
templates = structuredClone(d.templates);
|
||||
if (!groupId && d.groups[0]) groupId = d.groups[0].id;
|
||||
} catch (e) { toastError(e); }
|
||||
}
|
||||
onMount(() => { load(); const i = setInterval(() => get<Data>('/api/admin/email').then((d) => data && (data.log = d.log)).catch(() => {}), 5000); return () => clearInterval(i); });
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
</div>
|
||||
{:else if tab === 'downloads'}
|
||||
<div class="settings-grid card"><h2>Launcher downloads</h2><p>Upload installers or point players to an external release page.</p>
|
||||
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://github.com/.../releases" /></label>
|
||||
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases" /></label>
|
||||
{#each ['windows', 'mac', 'linux'] as platform}
|
||||
{@const download = config.hosted_downloads.find((d) => d.platform === platform)}
|
||||
<div class="download-row"><div><strong>{platform[0].toUpperCase() + platform.slice(1)}</strong><p>{download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}</p></div><label class="btn secondary sm"><Upload size={15} /> {uploading ? 'Uploading…' : 'Upload installer'}<input type="file" hidden disabled={uploading} onchange={(e) => upload(platform, e.currentTarget.files?.[0])} /></label></div>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
{/if}
|
||||
</label>
|
||||
<label class="field">Launcher download link
|
||||
<input bind:value={s.launcher_download_url} placeholder="https://github.com/you/repo/releases/latest" />
|
||||
<input bind:value={s.launcher_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" />
|
||||
<span class="help">Shown on the dashboard so you can share it easily.</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
5e931abbb8b037c8de349afdd0b4165c23059e58672f7e0a11a27c4f70db6bed scopenet-client-fabric-1.20.1-0.5.0.jar
|
||||
cb8690f842f0efb7a336c568a12e11c5bc9da2986ab68f337d4ca211c99cecb5 scopenet-fabric-1.20.1-0.5.0.jar
|
||||
e1e6650e05f5a66655f88be8e892095e79ddf8cd5502affea9bd68033e33cc21 scopenet-fabric-1.21.1-0.5.0.jar
|
||||
a54a1478c0740eb4aef20797037738355e3482ac1f4eaaa9b408d16e2303b7ed scopenet-fabric-26.3-0.5.0.jar
|
||||
d9a22725d0b2554f31e010a6f596a73c023b339490ef982b5d53e8f866a8c447 scopenet-forge-1.20.1-0.5.0.jar
|
||||
49098a90919fdea02f4eec26e332cd6a51fc7bb398ba1d55f87528f52610e250 scopenet-forge-1.21.1-0.5.0.jar
|
||||
31e5076947f33ad1ec0037d92736b28eb8a47e4be84a6bacbf1dfd11308f579a scopenet-forge-26.3-0.5.0.jar
|
||||
1f4428de07b3061862c5dac3c7214ae87f9525385533a8c244ab67ec31ee76da scopenet-paper-0.5.0.jar
|
||||
3ade714b0869b9fbb0e6175280b69d5bb6531543ce8ce48c7f231f170081dd5f SCOPENET Launcher_0.5.0_x64-setup.exe
|
||||
305118fa405699992c608cd68275eeae59b9304727a59f445d04bf2a3c639131 scopenet-client-fabric-1.20.1-0.5.0.jar
|
||||
185a0e755240dd1f2ea281ca887ccb6b519063ac17f4e81b7133af06af268928 scopenet-fabric-1.20.1-0.5.0.jar
|
||||
1a1db2db43bf89dbd5e5d20ca747e5c8550e0eb6fe4ac2b921bef22523990efa scopenet-fabric-1.21.1-0.5.0.jar
|
||||
297ec651cfb432ae0e112076c3e95c32832b6e8fa9848fb93f81ff855d595264 scopenet-fabric-26.3-0.5.0.jar
|
||||
dae2b854d84b6e76b1389f27534c0724a72cf24244bb3f990c84d2d07d54a210 scopenet-forge-1.20.1-0.5.0.jar
|
||||
13b1588fe514d2f0b3f3c8fdd658b9a58fb3b31a94eb6fc214ace491b500d411 scopenet-forge-1.21.1-0.5.0.jar
|
||||
8d8b5b533f623b8c96acd2207f0a95f60e8d20963e49240d1dd3df60eae0689b scopenet-forge-26.3-0.5.0.jar
|
||||
2d9a3f8e997e76e7312870d9a515056de668cc563c809f7d61025d734193858e scopenet-paper-0.5.0.jar
|
||||
beaa1e906a3dc05292bf04e3fb592b2b53038fb1ab0e0214182e9829edced21e SCOPENET Launcher_0.5.0_x64-setup.exe
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install job-local dependencies on Linux Actions runners.
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
rust|image|java|release) ;;
|
||||
*) echo "Usage: $0 {rust|image|java|release}" >&2; exit 2 ;;
|
||||
esac
|
||||
[[ "$(uname -s)" == Linux ]] || { echo 'This setup tool requires Linux.' >&2; exit 1; }
|
||||
|
||||
add_path() {
|
||||
export PATH="$1:$PATH"
|
||||
for path_file in "${GITHUB_PATH:-}" "${GITEA_PATH:-}"; do
|
||||
[[ -z "$path_file" ]] || printf '%s\n' "$1" >> "$path_file"
|
||||
done
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
local apt=()
|
||||
local missing=()
|
||||
command -v apt-get >/dev/null || { echo 'Automatic system package installation requires apt-get (Ubuntu/Debian runner).' >&2; exit 1; }
|
||||
for package in "$@"; do
|
||||
dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed' || missing+=("$package")
|
||||
done
|
||||
[[ ${#missing[@]} -gt 0 ]] || return 0
|
||||
if [[ "$(id -u)" != 0 ]]; then
|
||||
command -v sudo >/dev/null || { echo 'System packages require root or passwordless sudo.' >&2; exit 1; }
|
||||
sudo -n true || { echo 'Passwordless sudo is required for system packages.' >&2; exit 1; }
|
||||
apt=(sudo -n)
|
||||
fi
|
||||
"${apt[@]}" apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive "${apt[@]}" apt-get install -y --no-install-recommends "${missing[@]}"
|
||||
}
|
||||
|
||||
require() {
|
||||
command -v "$1" >/dev/null || { echo "Missing $1 after dependency setup." >&2; exit 1; }
|
||||
}
|
||||
|
||||
case "$mode" in
|
||||
rust)
|
||||
install_packages build-essential curl file libayatana-appindicator3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev pkg-config wget
|
||||
if ! command -v rustup >/dev/null; then
|
||||
installer="$(mktemp)"
|
||||
trap 'rm -f "$installer"' EXIT
|
||||
curl -fsSL https://sh.rustup.rs -o "$installer"
|
||||
sh "$installer" -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
add_path "${CARGO_HOME:-$HOME/.cargo}/bin"
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup default stable
|
||||
require cargo
|
||||
require rustc
|
||||
require node
|
||||
require npm
|
||||
[[ "$(node -p 'process.versions.node.split(".")[0]')" -ge 22 ]] || { echo 'Node 22+ is required.' >&2; exit 1; }
|
||||
pkg-config --exists webkit2gtk-4.1 ayatana-appindicator3-0.1 librsvg-2.0
|
||||
;;
|
||||
image)
|
||||
require docker
|
||||
docker info >/dev/null || { echo 'Docker daemon is unavailable. Mount the host socket or run a Docker-capable host runner.' >&2; exit 1; }
|
||||
docker buildx version >/dev/null || { echo 'Docker Buildx is missing. Install the Docker buildx plugin on the host runner.' >&2; exit 1; }
|
||||
;;
|
||||
java)
|
||||
require java
|
||||
[[ -n "${2:-}" ]] || { echo 'Java major version is required.' >&2; exit 2; }
|
||||
java -version 2>&1 | grep -q "${2}\." || { echo "Java $2 is required for this integration build." >&2; exit 1; }
|
||||
require bash
|
||||
;;
|
||||
release)
|
||||
if ! command -v curl >/dev/null || ! command -v jq >/dev/null; then install_packages curl jq; fi
|
||||
require curl
|
||||
require jq
|
||||
;;
|
||||
esac
|
||||
echo "Linux $mode dependencies are ready."
|
||||
@@ -0,0 +1,85 @@
|
||||
# Installs job-local PowerShell and Rust dependencies for the Windows launcher.
|
||||
param([ValidateSet('Launcher')][string]$Mode = 'Launcher')
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not [Environment]::OSVersion.Platform.ToString().StartsWith('Win')) {
|
||||
throw 'This setup tool requires Windows.'
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
if (-not $env:RUNNER_TEMP) { $env:RUNNER_TEMP = $env:TEMP }
|
||||
|
||||
function Add-RunnerPath([string]$Directory) {
|
||||
$env:PATH = "$Directory;$env:PATH"
|
||||
foreach ($pathFile in @($env:GITHUB_PATH, $env:GITEA_PATH)) {
|
||||
if ($pathFile) { Add-Content -Path $pathFile -Value $Directory }
|
||||
}
|
||||
}
|
||||
|
||||
$pwsh = Get-Command pwsh.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if (-not $pwsh) {
|
||||
$machinePwsh = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'
|
||||
if (Test-Path $machinePwsh) { $pwsh = $machinePwsh }
|
||||
}
|
||||
if (-not $pwsh) {
|
||||
$version = '7.6.6'
|
||||
$installDir = Join-Path $env:RUNNER_TEMP "scopenet-pwsh-$version"
|
||||
$pwsh = Join-Path $installDir 'pwsh.exe'
|
||||
if (-not (Test-Path $pwsh)) {
|
||||
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
|
||||
$archive = Join-Path $env:RUNNER_TEMP "PowerShell-$version-win-x64.zip"
|
||||
Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/PowerShell-$version-win-x64.zip" -OutFile $archive
|
||||
Expand-Archive -Path $archive -DestinationPath $installDir -Force
|
||||
Remove-Item $archive
|
||||
}
|
||||
}
|
||||
Add-RunnerPath (Split-Path $pwsh)
|
||||
Write-Host "PowerShell $(& $pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()') is ready."
|
||||
|
||||
$node = Get-Command node.exe -ErrorAction SilentlyContinue
|
||||
$nodeMajor = if ($node) { [int]((& node --version).TrimStart('v').Split('.')[0]) } else { 0 }
|
||||
if ($nodeMajor -lt 22) {
|
||||
$nodeVersion = '22.23.3'
|
||||
$nodeArchive = Join-Path $env:RUNNER_TEMP "node-v$nodeVersion-win-x64.zip"
|
||||
$nodeRoot = Join-Path $env:RUNNER_TEMP 'scopenet-node'
|
||||
$nodeBin = Join-Path $nodeRoot "node-v$nodeVersion-win-x64"
|
||||
if (-not (Test-Path (Join-Path $nodeBin 'node.exe'))) {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri "https://nodejs.org/dist/v$nodeVersion/node-v$nodeVersion-win-x64.zip" -OutFile $nodeArchive
|
||||
Expand-Archive -Path $nodeArchive -DestinationPath $nodeRoot -Force
|
||||
Remove-Item $nodeArchive
|
||||
}
|
||||
Add-RunnerPath $nodeBin
|
||||
}
|
||||
Write-Host "Node $(& node --version) is ready."
|
||||
|
||||
$cargoBin = Join-Path $env:USERPROFILE '.cargo\bin'
|
||||
Add-RunnerPath $cargoBin
|
||||
if (-not (Get-Command rustup.exe -ErrorAction SilentlyContinue)) {
|
||||
$installer = Join-Path $env:RUNNER_TEMP 'scopenet-rustup-init.exe'
|
||||
Invoke-WebRequest -UseBasicParsing -Uri 'https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe' -OutFile $installer
|
||||
& $installer -y --profile minimal --default-toolchain stable
|
||||
if ($LASTEXITCODE -ne 0) { throw "rustup installer failed with exit code $LASTEXITCODE" }
|
||||
Remove-Item $installer
|
||||
}
|
||||
& rustup toolchain install stable-x86_64-pc-windows-msvc --profile minimal --component clippy --component rustfmt
|
||||
if ($LASTEXITCODE -ne 0) { throw "Rust toolchain install failed with exit code $LASTEXITCODE" }
|
||||
& rustup default stable-x86_64-pc-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) { throw "Rust toolchain selection failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
$vsPath = if (Test-Path $vswhere) { & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath }
|
||||
if (-not $vsPath) {
|
||||
$installer = Join-Path $env:RUNNER_TEMP 'scopenet-vs-buildtools.exe'
|
||||
Invoke-WebRequest -UseBasicParsing -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $installer
|
||||
$arguments = @('--quiet', '--wait', '--norestart', '--add', 'Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended')
|
||||
$process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru
|
||||
if ($process.ExitCode -eq 3010) { throw 'Microsoft C++ Build Tools installed, but Windows must be restarted before this runner can build.' }
|
||||
if ($process.ExitCode -ne 0) { throw "Microsoft C++ Build Tools installation failed with exit code $($process.ExitCode). Run the runner with administrator rights." }
|
||||
Remove-Item $installer
|
||||
$vsPath = if (Test-Path $vswhere) { & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath }
|
||||
if (-not $vsPath) { throw 'Microsoft C++ Build Tools installation did not provide the x64 MSVC compiler.' }
|
||||
}
|
||||
Write-Host "Microsoft C++ Build Tools are ready at $vsPath."
|
||||
foreach ($tool in @('cargo.exe', 'rustc.exe', 'node.exe', 'npm.cmd')) {
|
||||
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Missing $tool after dependency setup." }
|
||||
}
|
||||
if ([int]((& node --version).TrimStart('v').Split('.')[0]) -lt 22) { throw 'Node 22+ is required.' }
|
||||
Write-Host "Windows $Mode dependencies are ready."
|
||||
@@ -37,9 +37,11 @@
|
||||
let zoomText = $state('');
|
||||
let listOpen = $state(false);
|
||||
let following = $state<string | null>(null);
|
||||
let hiddenPlayers = $state<string[]>([]);
|
||||
let showSkins = $state(true);
|
||||
|
||||
const dims = $derived((info?.dimensions ?? []).filter((d) => d.available));
|
||||
const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim));
|
||||
const here = $derived((overlay?.players ?? []).filter((p) => dimSlug(p.dimension) === dim && !hiddenPlayers.includes(p.uuid)));
|
||||
const everyone = $derived(overlay?.players ?? []);
|
||||
const me = $derived(selfUuid ? everyone.find((p) => p.uuid.toLowerCase() === selfUuid.toLowerCase()) : undefined);
|
||||
const waiting = $derived(!info || !info.ready);
|
||||
@@ -59,13 +61,15 @@
|
||||
});
|
||||
|
||||
$effect(() => { if (view && info) { view.setInfo(info); dim = view.currentDimension?.slug ?? ''; } });
|
||||
$effect(() => { if (view && overlay) view.setOverlay(overlay); });
|
||||
$effect(() => { if (view && overlay) view.setOverlay({ ...overlay, players: overlay.players.filter((p) => !hiddenPlayers.includes(p.uuid)) }); });
|
||||
$effect(() => { view?.setLayers(layers); });
|
||||
$effect(() => { view?.setSkins(showSkins); });
|
||||
$effect(() => { view?.setSelf(selfUuid); });
|
||||
$effect(() => { if (view && info && initialDimension) view.setDimension(initialDimension); });
|
||||
|
||||
function chooseDimension(slug: string) { view?.setDimension(slug); dim = view?.currentDimension?.slug ?? slug; following = null; }
|
||||
function goTo(p: LivePlayer) {
|
||||
if (hiddenPlayers.includes(p.uuid)) hiddenPlayers = hiddenPlayers.filter((id) => id !== p.uuid);
|
||||
if (dimSlug(p.dimension) !== dim) chooseDimension(p.dimension);
|
||||
view?.flyTo(p.x, p.z, Math.max(view.getView().scale, 1.2));
|
||||
view?.select({ type: 'player', player: p });
|
||||
@@ -76,6 +80,10 @@
|
||||
view?.follow(following);
|
||||
}
|
||||
function findMe() { if (me) { goTo(me); } }
|
||||
function togglePlayer(uuid: string) {
|
||||
hiddenPlayers = hiddenPlayers.includes(uuid) ? hiddenPlayers.filter((id) => id !== uuid) : [...hiddenPlayers, uuid];
|
||||
if (hiddenPlayers.includes(uuid) && selection?.type === 'player' && selection.player.uuid === uuid) closeCard();
|
||||
}
|
||||
function toSpawn() {
|
||||
const spawn = view?.pinsOfKind('spawn')[0];
|
||||
if (spawn) view?.flyTo(spawn.x, spawn.z, 1);
|
||||
@@ -118,8 +126,10 @@
|
||||
</button>{/if}
|
||||
{#if listOpen && everyone.length}
|
||||
<ul class="plist" aria-label="Players online">
|
||||
<li class="options">{#if avatarUrl}<label><input type="checkbox" bind:checked={showSkins} /> Show skins</label>{/if}<button onclick={() => (hiddenPlayers = hiddenPlayers.length ? [] : everyone.map((p) => p.uuid))}>{hiddenPlayers.length ? 'Show all' : 'Hide all'}</button></li>
|
||||
{#each everyone as p (p.uuid)}
|
||||
<li>
|
||||
<input type="checkbox" aria-label={`Show ${p.name} on map`} checked={!hiddenPlayers.includes(p.uuid)} onchange={() => togglePlayer(p.uuid)} />
|
||||
<button onclick={() => goTo(p)}>
|
||||
{#if avatarUrl}<img src={avatarUrl(p.uuid)} alt="" width="22" height="22" />{/if}
|
||||
<span class="nm">{p.name}</span>
|
||||
@@ -204,6 +214,11 @@
|
||||
.right { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.plist { list-style: none; margin: 0; padding: 6px; width: 230px; max-height: 320px; overflow: auto; border-radius: 16px; background: var(--map-glass, rgba(14, 19, 34, 0.92)); border: 1px solid var(--map-line, rgba(255,255,255,0.12)); backdrop-filter: blur(14px); box-shadow: 0 14px 40px rgba(0,0,0,0.45); }
|
||||
.plist button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: 600 13px/1 inherit; cursor: pointer; text-align: left; }
|
||||
.plist li { display: flex; align-items: center; gap: 3px; }
|
||||
.plist input { accent-color: var(--map-accent, #8b6cff); flex: 0 0 auto; }
|
||||
.plist .options { justify-content: space-between; padding: 6px 9px; border-bottom: 1px solid var(--map-line, rgba(255,255,255,0.12)); margin-bottom: 3px; font-size: 12px; }
|
||||
.plist .options label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.plist .options button { width: auto; padding: 4px 6px; font-size: 12px; }
|
||||
.plist button:hover { background: rgba(255,255,255,0.08); }
|
||||
.plist img { border-radius: 6px; image-rendering: pixelated; }
|
||||
.nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -42,6 +42,7 @@ export class MapView {
|
||||
private dimension: MapDimension | null = null;
|
||||
private overlay: MapOverlay = { players: [], claims: [], pins: [] };
|
||||
private layers: MapLayers = { claims: true, pins: true, players: true };
|
||||
private showSkins = true;
|
||||
private tiles = new Map<string, Tile>();
|
||||
private queue: string[] = [];
|
||||
private loading = 0;
|
||||
@@ -117,6 +118,7 @@ export class MapView {
|
||||
}
|
||||
|
||||
setLayers(layers: MapLayers) { this.layers = { ...layers }; this.invalidate(); }
|
||||
setSkins(show: boolean) { this.showSkins = show; this.invalidate(); }
|
||||
|
||||
setSelf(uuid: string | null | undefined) { this.opts.selfUuid = uuid ?? null; this.invalidate(); }
|
||||
|
||||
@@ -611,7 +613,7 @@ export class MapView {
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r + 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ring; ctx.fill();
|
||||
ctx.restore();
|
||||
const img = this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
|
||||
const img = this.showSkins && this.opts.avatarUrl ? this.icon(this.opts.avatarUrl(p.uuid)) : null;
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, Math.PI * 2); ctx.clip();
|
||||
if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, s.x - r, s.y - r, r * 2, r * 2); }
|
||||
|
||||
Reference in new issue
Block a user