Compare commits
141 Commits
main
..
5271f2b1ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 5271f2b1ec | |||
| 793b709d8f | |||
| 2294d8b3d9 | |||
| 5ca30df334 | |||
| 1f6791549a | |||
| e82c9f8bbd | |||
| b957d17022 | |||
| 3d5b331bd9 | |||
| 6f2967024a | |||
| f6e4a4f6bd | |||
| d44ad9b6eb | |||
| 91e34a0929 | |||
| f0857243e2 | |||
| 1dadf08672 | |||
| c1672224a6 | |||
| e31fb2c17a | |||
| 4e0058d46c | |||
| 80545e9f9d | |||
| be7f06e163 | |||
| b6770d394c | |||
| 182beebcd6 | |||
| ae91037bc3 | |||
| ec98639d49 | |||
| 9cb5097f54 | |||
| a453b74b04 | |||
| 8565942392 | |||
| fa0df5183a | |||
| 8e0a1c39c0 | |||
| 780769b3c4 | |||
| 53fb4f5f76 | |||
| 1dd8df9f6e | |||
| 11f51944df | |||
| 04c7f6e68a | |||
| c066a8958e | |||
| b729036778 | |||
| 9d3a652b6b | |||
| b07b8fb1c8 | |||
| 35e27c5aec | |||
| 8dcaf1c6c6 | |||
| 87d524fb89 | |||
| 1e62837c68 | |||
| c56050f5dd | |||
| 70f2973396 | |||
| e193f3ca88 | |||
| 642c5b7322 | |||
| dcc655c7c4 | |||
| 4ad96b0ef7 | |||
| 973480d812 | |||
| 44ed0a90eb | |||
| a89048f6c5 | |||
| 51865b8cf4 | |||
| 4d3cfd11a3 | |||
| b1b87c8521 | |||
| 3ea29cf8b5 | |||
| 9ae7b88b89 | |||
| 00159ddf7c | |||
| b24d53b82f | |||
| 6572f5b59d | |||
| a08f4f55b0 | |||
| 44c18c3ef4 | |||
| 51902b995f | |||
| 0da2f4b6fb | |||
| 53b892ae00 | |||
| 00e84579ca | |||
| 7ade838df8 | |||
| 37580b7699 | |||
| 2f4dc01d54 | |||
| 7c46aa4bec | |||
| 2528d63b51 | |||
| 2bd1b54936 | |||
| 65c0fbb87d | |||
| 3d06f49f3c | |||
| 08345606a5 | |||
| b85a9e1b9b | |||
| 8170abd5fa | |||
| 14b65389ef | |||
| 8f84075c4b | |||
| bde01b1ce2 | |||
| 82bdb6777a | |||
| f70258849f | |||
| d19aa3aac5 | |||
| a338ebf058 | |||
| f91cf6eb41 | |||
| daed2690c1 | |||
| a9087691a3 | |||
| 5eec7013ba | |||
| 49f614926a | |||
| cadb72b412 | |||
| 5177fef2ef | |||
| 823be9d980 | |||
| 2119f825d6 | |||
| 57e6c1d253 | |||
| 4b2a949f12 | |||
| 81917acc3e | |||
| 859b157a59 | |||
| 166baf4be0 | |||
| ebd156ece2 | |||
| 8bc75fd71b | |||
| 1556d36511 | |||
| 6d0272b078 | |||
| c48bc83890 | |||
| db81bd8e08 | |||
| f7300f25a3 | |||
| fdd5fd193d | |||
| 7378d4c8ed | |||
| 4cb03736de | |||
| 57d2286f5e | |||
| fed282f2d2 | |||
| 7b43ce5844 | |||
| 74c1e7ab24 | |||
| 2d36b54b8d | |||
| 9f7c9099bc | |||
| e22f4b7800 | |||
| 362f92e520 | |||
| b3f24cc440 | |||
| 535e27008f | |||
| 77cb7c78b6 | |||
| 1a0e3e992f | |||
| faf598b2cd | |||
| 6e6186a571 | |||
| e3bb30201d | |||
| 7ff81de2b6 | |||
| 9d65bf5157 | |||
| 1855e43699 | |||
| 7bce67462c | |||
| 2be7e5c110 | |||
| 2a95bf4a50 | |||
| fd071260ec | |||
| 8058f26397 | |||
| 660044559c | |||
| 9135991887 | |||
| bb74e3336e | |||
| 4a88b24f4b | |||
| fe8ad6a02a | |||
| 9ebb2e7f0f | |||
| 0da360a644 | |||
| 6686059535 | |||
| c6c5f3c8dd | |||
| f00c8efd18 | |||
| f316952c12 | |||
| 00c79064fc |
@@ -0,0 +1,33 @@
|
||||
name: Build core.wasm
|
||||
description: >-
|
||||
Install TinyGo (cached) and build ui/core to frontend/static/core.wasm
|
||||
and wasm_exec.js via `make -C ui wasm`. The binaries are no longer
|
||||
committed, so every workflow that builds or serves the frontend bundle
|
||||
(ui-test, dev-deploy, prod-build) runs this first. Requires Go to be
|
||||
set up by the caller — TinyGo shells out to the Go toolchain.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore TinyGo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/galaxy-tinygo
|
||||
key: tinygo-0.41.1-linux-amd64
|
||||
|
||||
- name: Install TinyGo
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="0.41.1"
|
||||
root="$HOME/.cache/galaxy-tinygo/tinygo"
|
||||
if [ ! -x "$root/bin/tinygo" ]; then
|
||||
mkdir -p "$HOME/.cache/galaxy-tinygo"
|
||||
curl -fsSL "https://github.com/tinygo-org/tinygo/releases/download/v${version}/tinygo${version}.linux-amd64.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cache/galaxy-tinygo"
|
||||
fi
|
||||
echo "$root/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build core.wasm
|
||||
shell: bash
|
||||
run: make -C ui wasm
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Deploy · Prod
|
||||
|
||||
# Placeholder for the production rollout workflow. Today it only proves
|
||||
# the manual entry point works; the actual `docker save | ssh prod
|
||||
# docker load` + remote `docker compose up -d` pipeline is wired in
|
||||
# once the production host, SSH credentials, and DNS are decided.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to deploy (commit-<sha12>, produced by prod-build.yaml)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Announce target
|
||||
run: |
|
||||
echo "Would deploy image tag: ${{ inputs.image_tag }}"
|
||||
echo "TODO:"
|
||||
echo " 1. Download galaxy-images-${{ inputs.image_tag }} from prod-build artifacts."
|
||||
echo " 2. scp the .tar.gz bundles to the production host."
|
||||
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
|
||||
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
|
||||
echo " 5. Probe https://<public host>/healthz and roll back on failure."
|
||||
@@ -0,0 +1,194 @@
|
||||
name: Deploy · Dev
|
||||
|
||||
# Builds the Galaxy stack and (re)deploys it into the long-lived dev
|
||||
# environment on the host running this Gitea Actions runner. Triggered
|
||||
# on every merge into `development`. Branch protections on `development`
|
||||
# guarantee the commit already passed `go-unit`, `ui-test`, and
|
||||
# `integration` as part of the PR that produced this push, so this
|
||||
# workflow does not re-run those tests — it focuses on packaging and
|
||||
# rollout.
|
||||
#
|
||||
# `workflow_dispatch` is also accepted so a developer can deploy any
|
||||
# branch (typically a feature branch under active review) into the
|
||||
# shared dev environment from the Gitea Actions UI without waiting for
|
||||
# the PR to merge first. The deploy job picks up whatever the chosen
|
||||
# ref is — same packaging + healthcheck steps as the merge path.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/**'
|
||||
- 'site/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- 'tools/dev-deploy/**'
|
||||
- '.gitea/workflows/dev-deploy.yaml'
|
||||
- '!**/*.md'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
# Install pnpm into a per-job directory so concurrent jobs on
|
||||
# the shared host runner do not race on the default
|
||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build core.wasm
|
||||
uses: ./.gitea/actions/build-wasm
|
||||
|
||||
- name: Build UI frontend
|
||||
working-directory: ui/frontend
|
||||
env:
|
||||
# Single-origin deployment: an empty base URL means the
|
||||
# gateway shares the document origin (REST at /api, Connect at
|
||||
# /rpc). The game UI is served under the /game/ base path.
|
||||
VITE_GATEWAY_BASE_URL: ""
|
||||
BASE_PATH: /game
|
||||
# Surface the synthetic-report loader and similar dev-only
|
||||
# affordances in the long-lived dev bundle. The prod build
|
||||
# path (`prod-build.yaml`) leaves this flag unset so the
|
||||
# production bundle keeps the same affordances stripped.
|
||||
VITE_GALAXY_DEV_AFFORDANCES: "true"
|
||||
run: |
|
||||
# The response-signing public key is committed in
|
||||
# `.env.development` alongside its private counterpart in
|
||||
# `tools/local-dev/keys/`. Pull it from there at build time so
|
||||
# the production-mode bundle ships the same key the dev
|
||||
# gateway uses to sign.
|
||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||
pnpm build
|
||||
|
||||
- name: Install site dependencies
|
||||
working-directory: site
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project site
|
||||
working-directory: site
|
||||
run: pnpm build
|
||||
|
||||
- name: Build galaxy-engine image
|
||||
working-directory: ${{ gitea.workspace }}
|
||||
run: |
|
||||
docker build \
|
||||
-t galaxy-engine:dev \
|
||||
-f game/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build backend + gateway images
|
||||
working-directory: tools/dev-deploy
|
||||
run: |
|
||||
docker compose build galaxy-backend galaxy-api
|
||||
|
||||
- name: Seed UI volume
|
||||
run: |
|
||||
docker volume create galaxy-dev-ui-dist >/dev/null
|
||||
docker run --rm \
|
||||
-v galaxy-dev-ui-dist:/dst \
|
||||
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
|
||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||
|
||||
- name: Seed site volume
|
||||
run: |
|
||||
docker volume create galaxy-dev-site-dist >/dev/null
|
||||
docker run --rm \
|
||||
-v galaxy-dev-site-dist:/dst \
|
||||
-v "${{ gitea.workspace }}/site/.vitepress/dist:/src:ro" \
|
||||
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
|
||||
|
||||
- name: Seed geoip volume
|
||||
run: |
|
||||
# Copy the GeoIP test fixture into a named volume so the
|
||||
# backend can mount it as /var/lib/galaxy. A bind-mount with
|
||||
# a relative path would resolve against this runner's
|
||||
# ephemeral workspace under /home/runner/.cache/act/<hash>/,
|
||||
# which the runner deletes once the workflow ends — the next
|
||||
# `docker restart galaxy-dev-backend` would then fail with
|
||||
# "not a directory" because the mount source vanished.
|
||||
docker volume create galaxy-dev-geoip-data >/dev/null
|
||||
docker run --rm \
|
||||
-v galaxy-dev-geoip-data:/dst \
|
||||
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
||||
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
||||
|
||||
- name: Reap stray dev-deploy containers
|
||||
run: |
|
||||
# Remove any non-running compose-managed containers from
|
||||
# earlier deploys before `compose up`. Filter by the stack
|
||||
# label so we never touch unrelated workloads on the same
|
||||
# daemon. Running containers (incl. engine instances backend
|
||||
# spawned itself with the same label) are left intact —
|
||||
# those are reattached by the backend reconciler on boot.
|
||||
ids=$(docker ps -aq \
|
||||
--filter "label=galaxy.stack=dev-deploy" \
|
||||
--filter "status=exited" \
|
||||
--filter "status=created" \
|
||||
--filter "status=dead")
|
||||
if [ -n "$ids" ]; then
|
||||
echo "reaping: $ids"
|
||||
docker rm -f $ids
|
||||
fi
|
||||
|
||||
- name: Bring up the stack
|
||||
working-directory: tools/dev-deploy
|
||||
run: |
|
||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||
# is empty at the workflow-evaluation stage.
|
||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||
docker compose up -d --wait --remove-orphans
|
||||
|
||||
- name: Probe the stack
|
||||
run: |
|
||||
set -e
|
||||
# Use --resolve so the probe goes through the same routing as
|
||||
# a browser on the host: the host Caddy on :443 (which has
|
||||
# `tls internal`) terminates and forwards into the edge
|
||||
# network. We accept the host's internal CA via -k because
|
||||
# the runner image has no reason to trust it.
|
||||
curl -sk --max-time 10 https://galaxy.lan/healthz \
|
||||
| tee /tmp/healthz
|
||||
test -s /tmp/healthz
|
||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||
https://galaxy.lan/ | tee /tmp/site_status
|
||||
grep -qE '^(200|304)$' /tmp/site_status
|
||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||
https://galaxy.lan/game/ | tee /tmp/game_status
|
||||
grep -qE '^(200|304)$' /tmp/game_status
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Tests · Go
|
||||
|
||||
# Fast unit tests for the Go side of the monorepo. Runs on every push
|
||||
# and pull request whose path filter matches a Go source directory.
|
||||
# The integration suite (testcontainers-driven, slow) lives in
|
||||
# `integration.yaml` and only fires for PRs into `development`/`main`
|
||||
# and pushes to `development`.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Tests · Integration
|
||||
|
||||
# Full integration suite (testcontainers-driven, ~5–10 minutes). Heavy
|
||||
# enough that we do not run it on every push to a feature branch — only
|
||||
# when there is an open PR aimed at `development`/`main`, or after a
|
||||
# merge into `development`. The unit jobs (`go-unit.yaml`,
|
||||
# `ui-test.yaml`) keep guarding fast feedback on every push.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- development
|
||||
- main
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'integration/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/integration.yaml'
|
||||
- '!**/*.md'
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/core/**'
|
||||
- 'integration/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/integration.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run integration suite
|
||||
# `make integration` precleans leftover docker-compose state and
|
||||
# then runs every test under integration/ serially (-p=1
|
||||
# -parallel=1, 15-minute per-test timeout). Testcontainers
|
||||
# reaches the host's docker daemon via the socket Gitea exposes
|
||||
# to the runner; the workflow inherits the same access the
|
||||
# runner has.
|
||||
run: make -C integration integration
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Build · Prod
|
||||
|
||||
# Builds the production-grade Docker images and the UI bundle on every
|
||||
# merge into `main`, then saves the artifacts so a future
|
||||
# `deploy-prod.yaml` run can ship them to the production host. This
|
||||
# workflow does not deploy anything by itself — production rollout is
|
||||
# strictly manual (workflow_dispatch on `deploy-prod.yaml`).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'ui/**'
|
||||
- 'site/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/prod-build.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
# Install pnpm into a per-job directory so concurrent jobs on
|
||||
# the shared host runner do not race on the default
|
||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
short_sha=$(git rev-parse --short=12 HEAD)
|
||||
echo "tag=commit-${short_sha}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build backend image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||
-f backend/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build gateway image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||
-f gateway/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Build engine image
|
||||
run: |
|
||||
docker build \
|
||||
-t "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||
-f game/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Install UI dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build core.wasm
|
||||
uses: ./.gitea/actions/build-wasm
|
||||
|
||||
- name: Build UI bundle
|
||||
working-directory: ui/frontend
|
||||
env:
|
||||
# Single-origin deployment: an empty base URL means the
|
||||
# gateway shares the document origin (REST at /api, Connect at
|
||||
# /rpc). The game UI is served under the /game/ base path.
|
||||
VITE_GATEWAY_BASE_URL: ""
|
||||
BASE_PATH: /game
|
||||
run: |
|
||||
# Production response-signing public key is not in the repo
|
||||
# yet (the dev key in `tools/local-dev/keys/` is for dev
|
||||
# only). When real prod keys exist, source them from a Gitea
|
||||
# Actions secret and set VITE_GATEWAY_RESPONSE_PUBLIC_KEY
|
||||
# here. Until then the prod bundle compiles with the dev
|
||||
# key as a placeholder so the artifact exists.
|
||||
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
|
||||
pnpm build
|
||||
|
||||
- name: Install site dependencies
|
||||
working-directory: site
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project site
|
||||
working-directory: site
|
||||
run: pnpm build
|
||||
|
||||
- name: Save images as artifact bundles
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
docker save "galaxy/backend:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/backend-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
docker save "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/gateway-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
docker save "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
|
||||
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
|
||||
tar -C ui/frontend -czf \
|
||||
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
|
||||
tar -C site/.vitepress -czf \
|
||||
"artifacts/site-dist-${{ steps.tag.outputs.tag }}.tar.gz" dist
|
||||
|
||||
- name: Upload images
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: galaxy-images-${{ steps.tag.outputs.tag }}
|
||||
path: artifacts/*.tar.gz
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build · Site
|
||||
|
||||
# Builds the VitePress project site so a broken site change fails its PR.
|
||||
# The dev-deploy / prod-build workflows build and ship the site
|
||||
# separately; this is the fast PR gate. No `!**/*.md` exclusion — the
|
||||
# site is Markdown, so content changes must be exercised too.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.gitea/workflows/site-build.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.gitea/workflows/site-build.yaml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: site/pnpm-lock.yaml
|
||||
|
||||
- name: Install site dependencies
|
||||
working-directory: site
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project site
|
||||
working-directory: site
|
||||
run: pnpm build
|
||||
@@ -1,148 +0,0 @@
|
||||
name: ui-release
|
||||
|
||||
# Tier 2 (release) workflow. Runs on tag push.
|
||||
#
|
||||
# Currently mirrors the Tier 1 step set. Visual regression baseline
|
||||
# checks and the macOS-runner iOS smoke job are landed in later phases
|
||||
# of ui/PLAN.md and live as commented sections at the end of this file
|
||||
# until those phases ship.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ui/pnpm-lock.yaml
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Vitest
|
||||
working-directory: ui/frontend
|
||||
run: pnpm test
|
||||
|
||||
- name: Run Playwright
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ui/frontend/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload Playwright traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: ui/frontend/test-results/
|
||||
retention-days: 14
|
||||
|
||||
# visual-regression: enabled in Phase 33 of ui/PLAN.md, once the PWA
|
||||
# shell and service worker land and a snapshot baseline is committed
|
||||
# under ui/frontend/tests/__snapshots__/.
|
||||
#
|
||||
# visual-regression:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: test
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: pnpm/action-setup@v4
|
||||
# with: { version: 11.0.7 }
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 22
|
||||
# cache: pnpm
|
||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
||||
# - working-directory: ui
|
||||
# run: pnpm install --frozen-lockfile
|
||||
# - working-directory: ui/frontend
|
||||
# run: pnpm exec playwright install --with-deps
|
||||
# - working-directory: ui/frontend
|
||||
# run: pnpm exec playwright test --grep @visual
|
||||
|
||||
# ios-smoke: enabled in Phase 32 of ui/PLAN.md, once the Capacitor
|
||||
# wrapper lands. Runs a Capacitor + Appium smoke against an iOS
|
||||
# simulator on a macOS runner.
|
||||
#
|
||||
# ios-smoke:
|
||||
# runs-on: macos-13
|
||||
# needs: test
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: pnpm/action-setup@v4
|
||||
# with: { version: 11.0.7 }
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: 22
|
||||
# cache: pnpm
|
||||
# cache-dependency-path: ui/pnpm-lock.yaml
|
||||
# - working-directory: ui
|
||||
# run: pnpm install --frozen-lockfile
|
||||
# - working-directory: ui/mobile
|
||||
# run: pnpm exec cap sync ios && pnpm exec appium-smoke ios
|
||||
@@ -1,41 +1,33 @@
|
||||
name: ui-test
|
||||
name: Tests · UI
|
||||
|
||||
# Tier 1 (per-PR) workflow. Runs Vitest + Playwright for the UI client and
|
||||
# the monorepo Go service tests (everything except the integration suite,
|
||||
# which lives behind `make -C integration integration` and needs a Docker
|
||||
# daemon set up for testcontainers).
|
||||
#
|
||||
# The path filter is intentionally broad until a dedicated go-test
|
||||
# workflow is introduced; this is the only CI gate today.
|
||||
# UI-side unit and end-to-end tests (Vitest + Playwright). The Go side
|
||||
# of the workspace is tested in `go-unit.yaml`. Both workflows can run
|
||||
# in parallel for a push that touches Go and UI together.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/ui-test.yaml'
|
||||
# Skip docs-only commits. Negation removes pure markdown changes;
|
||||
# mixed commits (code + .md) still match a positive pattern above
|
||||
# and trigger the workflow. Image and other binary asset paths
|
||||
# are already outside the positive list.
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'ui/**'
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'game/**'
|
||||
- 'pkg/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/ui-test.yaml'
|
||||
- '!**/*.md'
|
||||
|
||||
# Playwright launches its own `pnpm dev` on :5173, and in host-mode
|
||||
# the runner shares the host's port namespace with every other job,
|
||||
# so two parallel ui-test runs collide on EADDRINUSE. Serialise via a
|
||||
# singleton concurrency group with queueing — new runs wait their
|
||||
# turn instead of cancelling the in-progress one. cancel-in-progress
|
||||
# is explicitly false because Gitea has shown spurious self-cancel
|
||||
# behaviour under cancel-in-progress: true even when no other run
|
||||
# shares the group.
|
||||
concurrency:
|
||||
group: ui-test-singleton
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -48,45 +40,15 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Run Go tests
|
||||
# client/ is the deprecated Fyne client; excluded from CI per
|
||||
# ui/PLAN.md §74. -count=1 disables Go's test cache so a green
|
||||
# run never depends on a previous runner's cached state. The
|
||||
# backend suite is run with -p 1 because most backend packages
|
||||
# spawn their own Postgres testcontainer, and parallel
|
||||
# Postgres bootstraps starve each other on a constrained
|
||||
# runner. pkg modules are listed one by one because ./pkg/...
|
||||
# does not recurse across the independent go.work modules
|
||||
# under pkg/.
|
||||
run: |
|
||||
go test -count=1 -p 1 ./backend/...
|
||||
go test -count=1 \
|
||||
./gateway/... \
|
||||
./game/... \
|
||||
./ui/core/... \
|
||||
./pkg/calc/... \
|
||||
./pkg/connector/... \
|
||||
./pkg/cronutil/... \
|
||||
./pkg/error/... \
|
||||
./pkg/geoip/... \
|
||||
./pkg/model/... \
|
||||
./pkg/postgres/... \
|
||||
./pkg/redisconn/... \
|
||||
./pkg/schema/... \
|
||||
./pkg/storage/... \
|
||||
./pkg/transcoder/... \
|
||||
./pkg/util/...
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.7
|
||||
# Install pnpm into a per-job directory so concurrent jobs on
|
||||
# the shared host runner do not race on the default
|
||||
# `~/setup-pnpm` (the self-installer otherwise fails with
|
||||
# `ENOTEMPTY` while cleaning a sibling job's install).
|
||||
dest: ${{ runner.temp }}/setup-pnpm
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -99,18 +61,49 @@ jobs:
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.work
|
||||
cache: true
|
||||
|
||||
- name: Build core.wasm
|
||||
uses: ./.gitea/actions/build-wasm
|
||||
|
||||
- name: Install Playwright browsers
|
||||
# `--with-deps` would shell out to `sudo apt-get install` for
|
||||
# the system .so libraries, which the host-mode runner cannot
|
||||
# run non-interactively. The host has the deps installed once,
|
||||
# globally; we only need to fetch the browser binaries here.
|
||||
# If a future run fails with missing libraries, install them
|
||||
# on the host via `pnpm exec playwright install-deps` (one
|
||||
# shot, requires sudo).
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright install --with-deps
|
||||
run: pnpm exec playwright install
|
||||
|
||||
- name: Run Vitest
|
||||
working-directory: ui/frontend
|
||||
run: pnpm test
|
||||
|
||||
- name: Clear stale Vite from :5173
|
||||
# Defence in depth in case a previous job's webServer survived
|
||||
# the concurrency-cancel — `pkill` does not fail when there is
|
||||
# nothing to kill, and `fuser -k` cleans up anything else
|
||||
# holding the port.
|
||||
run: |
|
||||
pkill -f 'vite dev' || true
|
||||
fuser -k 5173/tcp 2>/dev/null || true
|
||||
|
||||
- name: Run Playwright
|
||||
working-directory: ui/frontend
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Run PWA tests
|
||||
# Builds + previews the production bundle (the service worker only
|
||||
# precaches a real build) and checks manifest / SW / offline.
|
||||
working-directory: ui/frontend
|
||||
run: pnpm test:pwa
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -34,32 +34,58 @@ This repository hosts the Galaxy Game project.
|
||||
deeper than what fits in `README.md` (per-feature design notes,
|
||||
protocol specs, runbooks). Not stage-by-stage history.
|
||||
|
||||
## Branching and CI flow
|
||||
|
||||
Branches:
|
||||
|
||||
- `main` — production-track. Direct pushes are disallowed; the only
|
||||
way in is a PR merge from `development`. A merge fires
|
||||
`prod-build.yaml` which packages the artifacts; production rollout
|
||||
is manual through `deploy-prod.yaml`.
|
||||
- `development` — long-lived dev integration branch. Every merge into
|
||||
it auto-deploys to the dev environment via `dev-deploy.yaml`
|
||||
(single origin `https://galaxy.lan`: site at `/`, game at `/game/`,
|
||||
gateway REST at `/api`).
|
||||
- `feature/*` — short-lived branches off `development`. Merged back
|
||||
via PR; only then do they reach the dev environment automatically.
|
||||
|
||||
Workflows in `.gitea/workflows/`:
|
||||
|
||||
| File | Trigger | What it does |
|
||||
|------|---------|--------------|
|
||||
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
|
||||
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
|
||||
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
|
||||
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images + (re)deploy to `tools/dev-deploy/`. |
|
||||
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
|
||||
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
|
||||
|
||||
### Deployment cadence
|
||||
|
||||
The long-lived dev environment (`tools/dev-deploy/`) is single-tenant:
|
||||
one live deployment, redeployed on every merge into `development`.
|
||||
While a PR is open the dev environment stays on whatever was last
|
||||
merged — pushes to `feature/*` only fire the test workflows
|
||||
(`go-unit`, `ui-test`, `integration`), not `dev-deploy.yaml`.
|
||||
|
||||
To preview an unmerged feature branch on the shared dev environment,
|
||||
trigger `dev-deploy.yaml` manually from the Gitea UI
|
||||
(**Actions → Deploy · Dev → Run workflow**) and pick the feature ref.
|
||||
The deploy is idempotent: the next merge into `development` simply
|
||||
overwrites whatever the manual dispatch left behind.
|
||||
|
||||
## Per-stage CI gate
|
||||
|
||||
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
|
||||
must be exercised on the local Gitea Actions runner before being
|
||||
declared done. The runbook lives in `tools/local-ci/README.md`; the
|
||||
short version is:
|
||||
must be exercised on `gitea.lan` before being declared done. The
|
||||
short version:
|
||||
|
||||
1. Commit the stage changes.
|
||||
2. `make -C tools/local-ci push` — pushes `HEAD` to the local Gitea
|
||||
instance and triggers every workflow that matches the changed
|
||||
paths.
|
||||
3. Poll the latest run via the API snippet in `ui/docs/testing.md`
|
||||
(or the Gitea UI on `http://localhost:3000`) until it leaves
|
||||
1. Commit the stage changes on the feature branch.
|
||||
2. `git push gitea …` to publish the branch.
|
||||
3. Poll the latest run in the Gitea UI (or the API) until it leaves
|
||||
`running`. Inspect the log on failure.
|
||||
4. Only after the run is `success` may the stage be marked done in
|
||||
the corresponding `PLAN.md`.
|
||||
|
||||
This applies even when the local unit-test suite is green —
|
||||
workflow-only failures (path filters, action-version mismatches,
|
||||
missing secrets, runner-only environment differences) are cheap to
|
||||
catch here and expensive to catch on a remote PR. The push step is
|
||||
implicitly authorised: do not ask for confirmation on every stage.
|
||||
|
||||
If `tools/local-ci` is not running, bring it up first
|
||||
(`make -C tools/local-ci up`); do not skip this gate. The single
|
||||
exception is when the user explicitly waives it for a stage.
|
||||
4. Only after every workflow that fired is `success` may the stage be
|
||||
marked done in the corresponding `PLAN.md`.
|
||||
|
||||
## Decisions during stage implementation
|
||||
|
||||
@@ -87,18 +113,22 @@ The existing codebase of `galaxy/<service>` may be modified or extended when a
|
||||
plan stage requires it. All such changes must be covered by new or updated tests
|
||||
and reflected in documentation when they affect documented behavior.
|
||||
|
||||
## Pre-production migration rule
|
||||
## Migrations
|
||||
|
||||
The platform is not yet in production. Schema changes for `backend` go
|
||||
into the existing `backend/internal/postgres/migrations/00001_init.sql`
|
||||
file rather than into new `00002_*`-prefixed files. Local databases and
|
||||
integration test harnesses are recreated from scratch on every pull.
|
||||
Schema changes for `backend` go into a new `0000N_*.sql` file under
|
||||
`backend/internal/postgres/migrations/` with a monotonically increasing
|
||||
prefix. `00001_init.sql` is the historical baseline and stays
|
||||
immutable; every subsequent change is its own additive migration with
|
||||
matching Up/Down sides. `pressly/goose/v3` (embedded into the backend
|
||||
binary) applies pending migrations on startup, so the long-lived dev
|
||||
environment picks up schema deltas without a manual reset.
|
||||
|
||||
**This rule is removed before the first production deployment.** From
|
||||
that point on every schema change becomes a new migration file with a
|
||||
monotonically increasing prefix, and `00001_init.sql` becomes immutable
|
||||
history. See `backend/internal/postgres/migrations/README.md` for
|
||||
details.
|
||||
Before the first production deployment the migration chain may be
|
||||
squashed back into a single fresh `00001_init.sql` for a clean slate;
|
||||
plan that work as an explicit task when it lands. See
|
||||
`backend/internal/postgres/migrations/README.md` for the local
|
||||
authoring conventions (file naming, transactional vs. non-transactional
|
||||
sections, backward-compatible deletes, rollback expectations).
|
||||
|
||||
## Documentation discipline
|
||||
|
||||
|
||||
+12
-4
@@ -45,6 +45,7 @@ backend/
|
||||
│ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations
|
||||
│ ├── auth/ # email-code challenges, device sessions, Ed25519 keys
|
||||
│ ├── config/ # env-var loader, Validate
|
||||
│ ├── diplomail/ # diplomatic-mail messages, recipients, translations
|
||||
│ ├── dockerclient/ # docker/docker wrapper for container ops
|
||||
│ ├── engineclient/ # net/http client to galaxy-game containers
|
||||
│ ├── geo/ # geoip lookup, declared_country, per-user counters
|
||||
@@ -128,9 +129,16 @@ fast.
|
||||
| `BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT` | no | `256` | Engine container `--pids-limit`. |
|
||||
| `BACKEND_RUNTIME_CONTAINER_STATE_MOUNT` | no | `/var/lib/galaxy-game` | Absolute in-container path for the per-game state bind mount. |
|
||||
| `BACKEND_RUNTIME_STOP_GRACE_PERIOD` | no | `10s` | SIGTERM-to-SIGKILL grace period for engine container stop. |
|
||||
| `BACKEND_STACK_LABEL` | no | — | Optional value stamped as `galaxy.stack=<value>` on every engine container backend spawns. Lets host-side tooling (Makefile / CI) scope cleanup to one dev stack. Empty → label is not applied. |
|
||||
| `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. |
|
||||
| `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. |
|
||||
| `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. |
|
||||
| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. |
|
||||
| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. |
|
||||
| `BACKEND_DIPLOMAIL_TRANSLATOR_URL` | no | — | Base URL of a LibreTranslate-compatible instance (`http://libretranslate:5000`). Empty → translator falls through to no-op (recipients are delivered with the original body). |
|
||||
| `BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT` | no | `10s` | Per-request HTTP timeout for the translation worker. |
|
||||
| `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` | no | `5` | Number of failed HTTP attempts before the worker delivers the message with the original body (fallback). |
|
||||
| `BACKEND_DIPLOMAIL_WORKER_INTERVAL` | no | `2s` | How often the async translation worker scans for pending pairs. The worker processes one pair per tick. |
|
||||
|
||||
If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without
|
||||
`BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is
|
||||
@@ -146,10 +154,10 @@ seeded `admin_accounts` ahead of time.
|
||||
before the HTTP listener opens. The startup path also issues a
|
||||
`CREATE SCHEMA IF NOT EXISTS backend` so a fresh database does not
|
||||
trip goose's bookkeeping table on the first migration.
|
||||
- Pre-production uses one migration file (`00001_init.sql`) covering
|
||||
every backend domain (auth, user, admin, lobby, runtime, mail,
|
||||
notification, geo). Future migrations are sequence-numbered and
|
||||
additive.
|
||||
- Migrations are sequence-numbered (`0000N_*.sql`) and applied
|
||||
additively. `00001_init.sql` is the historical baseline; every
|
||||
schema change after it is a new file with a higher prefix. See
|
||||
`internal/postgres/migrations/README.md` for the authoring rules.
|
||||
- Queries are written through `go-jet/jet/v2`. The generated code is in
|
||||
`internal/postgres/jet/backend/` and is committed; `internal/postgres/jet/jet.go`
|
||||
carries package metadata that survives regeneration.
|
||||
|
||||
+315
-1
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
// time/tzdata embeds the IANA timezone database so time.LoadLocation
|
||||
// works in container images without /usr/share/zoneinfo (distroless
|
||||
@@ -25,6 +26,9 @@ import (
|
||||
"galaxy/backend/internal/auth"
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/devsandbox"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
"galaxy/backend/internal/dockerclient"
|
||||
"galaxy/backend/internal/engineclient"
|
||||
"galaxy/backend/internal/geo"
|
||||
@@ -131,6 +135,7 @@ func run(ctx context.Context) (err error) {
|
||||
lobbyCascade := &lobbyCascadeAdapter{}
|
||||
userNotifyCascade := &userNotificationCascadeAdapter{}
|
||||
lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{}
|
||||
lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{}
|
||||
runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{}
|
||||
|
||||
userSvc := user.NewService(user.Deps{
|
||||
@@ -197,6 +202,7 @@ func run(ctx context.Context) (err error) {
|
||||
Cache: lobbyCache,
|
||||
Runtime: runtimeGateway,
|
||||
Notification: lobbyNotifyPublisher,
|
||||
Diplomail: lobbyDiplomailPublisher,
|
||||
Entitlement: &userEntitlementAdapter{svc: userSvc},
|
||||
Config: cfg.Lobby,
|
||||
Logger: logger,
|
||||
@@ -301,6 +307,25 @@ func run(ctx context.Context) (err error) {
|
||||
userNotifyCascade.svc = notifSvc
|
||||
lobbyNotifyPublisher.svc = notifSvc
|
||||
runtimeNotifyPublisher.svc = notifSvc
|
||||
|
||||
diplomailStore := diplomail.NewStore(db)
|
||||
diplomailTranslator, err := buildDiplomailTranslator(cfg.Diplomail, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build diplomail translator: %w", err)
|
||||
}
|
||||
diplomailSvc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomailStore,
|
||||
Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc},
|
||||
Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc},
|
||||
Entitlements: &diplomailEntitlementAdapter{users: userSvc},
|
||||
Games: &diplomailGameAdapter{lobby: lobbySvc},
|
||||
Detector: detector.New(),
|
||||
Translator: diplomailTranslator,
|
||||
Config: cfg.Diplomail,
|
||||
Logger: logger,
|
||||
})
|
||||
lobbyDiplomailPublisher.svc = diplomailSvc
|
||||
diplomailWorker := diplomail.NewWorker(diplomailSvc)
|
||||
if email := cfg.Notification.AdminEmail; email == "" {
|
||||
logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped")
|
||||
} else {
|
||||
@@ -325,9 +350,11 @@ func run(ctx context.Context) (err error) {
|
||||
adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger)
|
||||
adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger)
|
||||
adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger)
|
||||
adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger)
|
||||
adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger)
|
||||
adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger)
|
||||
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
|
||||
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
|
||||
|
||||
ready := func() bool {
|
||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||
@@ -356,9 +383,11 @@ func run(ctx context.Context) (err error) {
|
||||
AdminRuntimes: adminRuntimesHandlers,
|
||||
AdminEngineVersions: adminEngineVersionsHandlers,
|
||||
AdminMail: adminMailHandlers,
|
||||
AdminDiplomail: adminDiplomailHandlers,
|
||||
AdminNotifications: adminNotificationsHandlers,
|
||||
AdminGeo: adminGeoHandlers,
|
||||
UserGames: userGamesHandlers,
|
||||
UserMail: userMailHandlers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build backend router: %w", err)
|
||||
@@ -374,7 +403,7 @@ func run(ctx context.Context) (err error) {
|
||||
runtimeScheduler := runtimeSvc.SchedulerComponent()
|
||||
runtimeReconciler := runtimeSvc.Reconciler()
|
||||
|
||||
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
||||
components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, diplomailWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler}
|
||||
if metricsServer.Enabled() {
|
||||
components = append(components, metricsServer)
|
||||
}
|
||||
@@ -579,3 +608,288 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co
|
||||
}
|
||||
return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload)
|
||||
}
|
||||
|
||||
// diplomailMembershipAdapter implements `diplomail.MembershipLookup`
|
||||
// by walking the lobby cache (for active rows) and the lobby service
|
||||
// (for any-status rows) and stitching each membership row to the
|
||||
// immutable `accounts.user_name` resolved through `*user.Service`.
|
||||
type diplomailMembershipAdapter struct {
|
||||
lobby *lobby.Service
|
||||
users *user.Service
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
cache := a.lobby.Cache()
|
||||
if cache == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := cache.GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
var found *lobby.Membership
|
||||
for _, m := range cache.MembershipsForGame(gameID) {
|
||||
if m.UserID == userID {
|
||||
mm := m
|
||||
found = &mm
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return diplomail.ActiveMembership{}, err
|
||||
}
|
||||
return diplomail.ActiveMembership{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return diplomail.MemberSnapshot{}, err
|
||||
}
|
||||
var found *lobby.Membership
|
||||
for _, m := range members {
|
||||
if m.UserID == userID {
|
||||
mm := m
|
||||
found = &mm
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return diplomail.MemberSnapshot{}, err
|
||||
}
|
||||
return diplomail.MemberSnapshot{
|
||||
UserID: userID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: found.RaceName,
|
||||
Status: found.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.users == nil {
|
||||
return nil, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return nil, diplomail.ErrNotFound
|
||||
}
|
||||
members, err := a.lobby.ListMembershipsForGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches := func(status string) bool {
|
||||
switch scope {
|
||||
case diplomail.RecipientScopeActive:
|
||||
return status == lobby.MembershipStatusActive
|
||||
case diplomail.RecipientScopeActiveAndRemoved:
|
||||
return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved
|
||||
case diplomail.RecipientScopeAllMembers:
|
||||
return true
|
||||
default:
|
||||
return status == lobby.MembershipStatusActive
|
||||
}
|
||||
}
|
||||
out := make([]diplomail.MemberSnapshot, 0, len(members))
|
||||
for _, m := range members {
|
||||
if !matches(m.Status) {
|
||||
continue
|
||||
}
|
||||
account, err := a.users.GetAccount(ctx, m.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err)
|
||||
}
|
||||
out = append(out, diplomail.MemberSnapshot{
|
||||
UserID: m.UserID,
|
||||
GameID: gameID,
|
||||
GameName: game.GameName,
|
||||
UserName: account.UserName,
|
||||
RaceName: m.RaceName,
|
||||
Status: m.Status,
|
||||
PreferredLanguage: account.PreferredLanguage,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher`
|
||||
// by translating each lobby.LifecycleEvent into the diplomail
|
||||
// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`.
|
||||
// The svc pointer is patched once diplomailSvc exists — diplomail
|
||||
// depends on lobby through MembershipLookup, so the lobby service
|
||||
// is constructed first and patched up.
|
||||
type lobbyDiplomailPublisherAdapter struct {
|
||||
svc *diplomail.Service
|
||||
}
|
||||
|
||||
func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
|
||||
GameID: ev.GameID,
|
||||
Kind: ev.Kind,
|
||||
Actor: ev.Actor,
|
||||
Reason: ev.Reason,
|
||||
TargetUser: ev.TargetUser,
|
||||
})
|
||||
}
|
||||
|
||||
// buildDiplomailTranslator selects the diplomail translator backend
|
||||
// from configuration: a non-empty `TranslatorURL` constructs the
|
||||
// LibreTranslate HTTP client; an empty URL falls through to the
|
||||
// noop translator so deployments without a translation service still
|
||||
// boot and deliver mail with the fallback path.
|
||||
func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) {
|
||||
if cfg.TranslatorURL == "" {
|
||||
logger.Info("diplomail translator URL not configured, using noop translator")
|
||||
return translator.NewNoop(), nil
|
||||
}
|
||||
return translator.NewLibreTranslate(translator.LibreTranslateConfig{
|
||||
URL: cfg.TranslatorURL,
|
||||
Timeout: cfg.TranslatorTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
// diplomailEntitlementAdapter implements
|
||||
// `diplomail.EntitlementReader` by reading the user-service
|
||||
// entitlement snapshot. The IsPaid flag mirrors the per-tier policy
|
||||
// defined in `internal/user`, so updates to the tier set (monthly,
|
||||
// yearly, permanent, …) flow through without changes here.
|
||||
type diplomailEntitlementAdapter struct {
|
||||
users *user.Service
|
||||
}
|
||||
|
||||
func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if a == nil || a.users == nil {
|
||||
return false, nil
|
||||
}
|
||||
snap, err := a.users.GetEntitlementSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return snap.IsPaid, nil
|
||||
}
|
||||
|
||||
// diplomailGameAdapter implements `diplomail.GameLookup`. The
|
||||
// running-games and finished-games queries walk the lobby cache so
|
||||
// the admin multi-game broadcast and bulk-purge endpoints do not
|
||||
// fan out a per-game DB query each time. GetGame falls back to the
|
||||
// cache; an unknown id is surfaced as ErrNotFound (the diplomail
|
||||
// sentinel).
|
||||
type diplomailGameAdapter struct {
|
||||
lobby *lobby.Service
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []diplomail.GameSnapshot
|
||||
for _, game := range a.lobby.Cache().ListGames() {
|
||||
if !isRunningStatus(game.Status) {
|
||||
continue
|
||||
}
|
||||
out = append(out, gameSnapshot(game))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil {
|
||||
return nil, nil
|
||||
}
|
||||
games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]diplomail.GameSnapshot, 0, len(games))
|
||||
for _, g := range games {
|
||||
out = append(out, gameSnapshot(g))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) {
|
||||
if a == nil || a.lobby == nil || a.lobby.Cache() == nil {
|
||||
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
game, ok := a.lobby.Cache().GetGame(gameID)
|
||||
if !ok {
|
||||
return diplomail.GameSnapshot{}, diplomail.ErrNotFound
|
||||
}
|
||||
return gameSnapshot(game), nil
|
||||
}
|
||||
|
||||
func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot {
|
||||
out := diplomail.GameSnapshot{
|
||||
GameID: g.GameID,
|
||||
GameName: g.GameName,
|
||||
Status: g.Status,
|
||||
}
|
||||
if g.FinishedAt != nil {
|
||||
f := *g.FinishedAt
|
||||
out.FinishedAt = &f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isRunningStatus(status string) bool {
|
||||
switch status {
|
||||
case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// diplomailNotificationPublisherAdapter implements
|
||||
// `diplomail.NotificationPublisher` by translating each
|
||||
// DiplomailNotification into a notification.Intent and routing it
|
||||
// through `*notification.Service.Submit`. The publisher leaves the
|
||||
// `diplomail.message.received` catalog entry to handle channel
|
||||
// fan-out (push only in Stage A).
|
||||
type diplomailNotificationPublisherAdapter struct {
|
||||
svc *notification.Service
|
||||
}
|
||||
|
||||
func (a *diplomailNotificationPublisherAdapter) PublishDiplomailEvent(ctx context.Context, ev diplomail.DiplomailNotification) error {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil
|
||||
}
|
||||
intent := notification.Intent{
|
||||
Kind: ev.Kind,
|
||||
IdempotencyKey: ev.IdempotencyKey,
|
||||
Recipients: []uuid.UUID{ev.Recipient},
|
||||
Payload: ev.Payload,
|
||||
}
|
||||
_, err := a.svc.Submit(ctx, intent)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# LibreTranslate setup for diplomatic mail
|
||||
|
||||
This document describes how to run the LibreTranslate backend that the
|
||||
diplomatic-mail subsystem uses for body translation. The instructions
|
||||
target three audiences: developers spinning up LibreTranslate
|
||||
alongside `tools/local-dev`, operators preparing a real deployment,
|
||||
and reviewers verifying the end-to-end translation flow by hand.
|
||||
|
||||
## When you need LibreTranslate
|
||||
|
||||
The diplomatic-mail worker runs unconditionally — `make up` and `make
|
||||
test` both work without any translator. With
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
|
||||
short-circuits the pipeline: messages are delivered in the original
|
||||
language, and the inbox handler returns the original body to every
|
||||
reader.
|
||||
|
||||
You only need LibreTranslate when you want to exercise the cross-
|
||||
language path: sender writes in language X, recipient's
|
||||
`accounts.preferred_language` is Y, the worker is expected to fetch
|
||||
a Y rendering. The pipeline is otherwise identical and unaware of
|
||||
which engine is producing translations.
|
||||
|
||||
## Running a local instance
|
||||
|
||||
LibreTranslate ships a public Docker image at
|
||||
`libretranslate/libretranslate`. The image is ~3 GB on first pull
|
||||
because it bundles every supported language model; subsequent runs
|
||||
reuse the layer cache.
|
||||
|
||||
The simplest setup is a one-shot container:
|
||||
|
||||
```bash
|
||||
docker run --rm -d --name libretranslate \
|
||||
-p 5000:5000 \
|
||||
-e LT_LOAD_ONLY=en,ru \
|
||||
libretranslate/libretranslate:latest
|
||||
```
|
||||
|
||||
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
|
||||
container fits in ~600 MB of RAM. Drop the variable to load every
|
||||
language pair LibreTranslate ships.
|
||||
|
||||
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
|
||||
model cache). Wait until `curl -s http://localhost:5000/languages`
|
||||
returns a JSON array before pointing backend at it.
|
||||
|
||||
## Wiring backend at it
|
||||
|
||||
Add three env vars to the backend process:
|
||||
|
||||
```
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
|
||||
```
|
||||
|
||||
When backend lives inside the `tools/local-dev` Docker network and
|
||||
LibreTranslate runs on the host, replace `localhost` with the host's
|
||||
docker-bridge address (`http://host.docker.internal:5000` on
|
||||
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
|
||||
default).
|
||||
|
||||
For a stack-internal deployment, drop LibreTranslate into the same
|
||||
Docker compose file alongside backend and reach it by its service
|
||||
name:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
environment:
|
||||
LT_LOAD_ONLY: "en,ru"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 12
|
||||
|
||||
backend:
|
||||
environment:
|
||||
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
|
||||
depends_on:
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
## Manual smoke test
|
||||
|
||||
Once both services are up:
|
||||
|
||||
1. Register two accounts via the public auth flow. Set the second
|
||||
account's `preferred_language` to a value that differs from the
|
||||
sender's writing language (e.g. sender writes in English, second
|
||||
account is `ru`).
|
||||
2. Create a private game with the first account, invite the second,
|
||||
land both as active members.
|
||||
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
|
||||
with the body in English.
|
||||
4. Watch backend logs for the diplomail worker. After ~2 seconds you
|
||||
should see `translator attempt succeeded` (or equivalent INFO
|
||||
line) and the recipient flipped to `available_at`.
|
||||
5. As the second account, fetch
|
||||
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
|
||||
response should carry both `body` (English original) and
|
||||
`translated_body` (Russian) along with the `translation_lang`
|
||||
and `translator` fields.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
|
||||
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
|
||||
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
|
||||
word body takes ~200 ms on a modern x86 core; the diplomail worker
|
||||
is single-threaded by design, so steady-state throughput is
|
||||
`1 / avg_latency` per backend instance.
|
||||
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
|
||||
pending pairs by at most ~31 seconds per pair (the worker's
|
||||
exponential backoff schedule), then falls back to the original
|
||||
body. Inbox listings never depend on the translator's
|
||||
availability.
|
||||
- **API key.** Backend does not send an API key. Self-hosted
|
||||
deployments without `LT_API_KEYS` configured accept anonymous
|
||||
POSTs by default, which matches our deployment posture
|
||||
(LibreTranslate sits on the internal docker network, not
|
||||
reachable from outside).
|
||||
- **Models.** Adding a new target language is an operator-side
|
||||
task: install the corresponding Argos model into the
|
||||
LibreTranslate container (`argospm install …`) and either restart
|
||||
the container or send a SIGHUP. The diplomail pipeline notices
|
||||
the new language pair automatically — there is no allow-list
|
||||
inside backend.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`translator: do request: dial tcp ...: connect: connection refused`.**
|
||||
LibreTranslate is not listening on the configured address. Verify
|
||||
with `curl http://${URL}/languages`. On Docker setups, double-
|
||||
check the bridge address discussion above.
|
||||
- **`translator: libretranslate http 400`** in worker logs but the
|
||||
language pair clearly exists.
|
||||
Make sure the request used the two-letter codes (`en`, not
|
||||
`en-US`). Backend normalises before sending; if you see a region
|
||||
subtag in the log, file an issue against `internal/diplomail` —
|
||||
the normalisation should be unconditional.
|
||||
- **`translator: libretranslate http 503`.**
|
||||
Container is still loading models. Wait for `/languages` to
|
||||
respond `200`. The worker retries with backoff, so steady-state
|
||||
recovers automatically.
|
||||
- **Worker logs only "noop translator returned, delivering
|
||||
fallback".**
|
||||
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
|
||||
process. Confirm with `docker compose exec backend env | grep
|
||||
DIPLOMAIL`.
|
||||
|
||||
## Future work
|
||||
|
||||
- Adding an OpenTelemetry counter and histogram for translator
|
||||
outcomes is tracked in the diplomail package README; the metrics
|
||||
will surface in Grafana once LibreTranslate is deployed.
|
||||
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
|
||||
in M minutes) is planned through a new
|
||||
`diplomail.translator.unhealthy` notification kind. Not wired
|
||||
yet — current monitoring lives in zap logs.
|
||||
@@ -28,10 +28,11 @@ test stack. The list mirrors the steady-state behaviour documented in
|
||||
## Migrations
|
||||
|
||||
`pressly/goose/v3` applies embedded migrations from
|
||||
`internal/postgres/migrations/`. The pre-production set ships as
|
||||
`00001_init.sql` plus additive numbered files. Backend always runs
|
||||
`CREATE SCHEMA IF NOT EXISTS backend` before goose so a fresh database
|
||||
does not trip the bookkeeping table on the first migration.
|
||||
`internal/postgres/migrations/`. Migrations are additive,
|
||||
sequence-numbered files (`00001_init.sql` is the baseline). Backend
|
||||
always runs `CREATE SCHEMA IF NOT EXISTS backend` before goose so a
|
||||
fresh database does not trip the bookkeeping table on the first
|
||||
migration.
|
||||
|
||||
`internal/postgres/migrations_test.go` asserts that the migration
|
||||
produces the expected table set; adding a table without updating the
|
||||
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
galaxy/model v0.0.0
|
||||
galaxy/postgres v0.0.0
|
||||
galaxy/util v0.0.0-00010101000000-000000000000
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/disciplinedware/go-confusables v0.1.1
|
||||
github.com/getkin/kin-openapi v0.135.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
|
||||
@@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
|
||||
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
|
||||
@@ -513,6 +513,52 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the
|
||||
// dev-mode override is a true escape hatch: a developer who already
|
||||
// burned past ChallengeMaxAttempts on a long-lived dev challenge
|
||||
// (typically because the throttle merged repeated send-email-code
|
||||
// calls onto one challenge_id) can still recover by submitting the
|
||||
// fixed code without first waiting out the challenge TTL.
|
||||
func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
cfg := authConfig()
|
||||
cfg.DevFixedCode = "999999"
|
||||
svc := buildServiceWithConfig(t, db, cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
// Burn through the attempts ceiling with deliberately wrong codes.
|
||||
for i := range cfg.ChallengeMaxAttempts + 1 {
|
||||
_, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||
ChallengeID: id,
|
||||
Code: "111111",
|
||||
ClientPublicKey: randomKey(t),
|
||||
TimeZone: "UTC",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("attempt %d unexpectedly succeeded", i)
|
||||
}
|
||||
}
|
||||
|
||||
// The dev-fixed code still goes through.
|
||||
session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{
|
||||
ChallengeID: id,
|
||||
Code: "999999",
|
||||
ClientPublicKey: randomKey(t),
|
||||
TimeZone: "UTC",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dev-fixed-code after attempts exhausted: %v", err)
|
||||
}
|
||||
if session.DeviceSessionID == uuid.Nil {
|
||||
t.Fatalf("dev-fixed-code did not produce a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
svc, mailer, _, _ := buildService(t, db)
|
||||
|
||||
@@ -163,15 +163,28 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
||||
return Session{}, err
|
||||
}
|
||||
|
||||
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
||||
s.deps.Logger.Info("auth challenge attempts exhausted",
|
||||
// The dev-mode fixed-code override is checked first so it bypasses
|
||||
// both the bcrypt verify and the per-challenge attempts ceiling.
|
||||
// Without this, a developer who already burned through
|
||||
// `ChallengeMaxAttempts` on an existing un-consumed challenge —
|
||||
// for example after the throttle merged repeated send-email-code
|
||||
// calls onto one challenge_id — could not recover with the fixed
|
||||
// code either, defeating the purpose of the override. Production
|
||||
// deployments leave `DevFixedCode` empty, so this branch is
|
||||
// inert and the regular attempts gate still applies.
|
||||
if s.devFixedCodeMatches(in.Code) {
|
||||
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
||||
zap.String("challenge_id", in.ChallengeID.String()),
|
||||
zap.Int32("attempts", loaded.Attempts),
|
||||
)
|
||||
return Session{}, ErrTooManyAttempts
|
||||
}
|
||||
|
||||
if !s.devFixedCodeMatches(in.Code) {
|
||||
} else {
|
||||
if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
|
||||
s.deps.Logger.Info("auth challenge attempts exhausted",
|
||||
zap.String("challenge_id", in.ChallengeID.String()),
|
||||
zap.Int32("attempts", loaded.Attempts),
|
||||
)
|
||||
return Session{}, ErrTooManyAttempts
|
||||
}
|
||||
if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
|
||||
if errors.Is(err, ErrCodeMismatch) {
|
||||
s.deps.Logger.Info("auth challenge code mismatch",
|
||||
@@ -182,10 +195,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi
|
||||
}
|
||||
return Session{}, err
|
||||
}
|
||||
} else {
|
||||
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
|
||||
zap.String("challenge_id", in.ChallengeID.String()),
|
||||
)
|
||||
}
|
||||
|
||||
// Re-check permanent_block after verifying the code. SendEmailCode
|
||||
|
||||
@@ -91,11 +91,19 @@ const (
|
||||
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
||||
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
||||
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
||||
envRuntimeStackLabel = "BACKEND_STACK_LABEL"
|
||||
|
||||
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
||||
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
||||
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
||||
|
||||
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
|
||||
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
|
||||
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
|
||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||
|
||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||
@@ -163,6 +171,12 @@ const (
|
||||
defaultNotificationWorkerInterval = 5 * time.Second
|
||||
defaultNotificationMaxAttempts = 8
|
||||
|
||||
defaultDiplomailMaxBodyBytes = 4096
|
||||
defaultDiplomailMaxSubjectBytes = 256
|
||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||
defaultDiplomailTranslatorMaxAttempts = 5
|
||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||
|
||||
defaultDevSandboxEngineVersion = "0.1.0"
|
||||
defaultDevSandboxPlayerCount = 20
|
||||
)
|
||||
@@ -201,6 +215,7 @@ type Config struct {
|
||||
Engine EngineConfig
|
||||
Runtime RuntimeConfig
|
||||
Notification NotificationConfig
|
||||
Diplomail DiplomailConfig
|
||||
DevSandbox DevSandboxConfig
|
||||
|
||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||
@@ -395,6 +410,50 @@ type RuntimeConfig struct {
|
||||
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
||||
// applied during stop / cancel / restart / patch.
|
||||
StopGracePeriod time.Duration
|
||||
|
||||
// StackLabel is the optional value backend stamps as
|
||||
// `galaxy.stack=<value>` on every engine container it spawns. It
|
||||
// lets host-side tooling (Makefile, CI workflows) scope cleanup
|
||||
// operations to a single dev stack without touching unrelated
|
||||
// workloads on the same Docker daemon. When empty, the label is
|
||||
// not applied.
|
||||
StackLabel string
|
||||
}
|
||||
|
||||
// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits
|
||||
// are enforced in the service layer, so they can be tuned at runtime
|
||||
// without a schema migration. Body and subject are stored as plain
|
||||
// UTF-8 text; HTML is neither parsed nor sanitised on the server.
|
||||
type DiplomailConfig struct {
|
||||
// MaxBodyBytes caps the length of `diplomail_messages.body` in
|
||||
// bytes (not runes). A send whose body exceeds the limit is
|
||||
// rejected with ErrInvalidInput.
|
||||
MaxBodyBytes int
|
||||
|
||||
// MaxSubjectBytes caps the length of `diplomail_messages.subject`
|
||||
// in bytes. Subjects are optional; the empty-string default
|
||||
// passes the limit trivially.
|
||||
MaxSubjectBytes int
|
||||
|
||||
// TranslatorURL is the base URL of the LibreTranslate-compatible
|
||||
// instance the async translation worker calls. When empty, the
|
||||
// worker still runs but falls through to "deliver original"
|
||||
// (the noop translator returns engine=noop).
|
||||
TranslatorURL string
|
||||
|
||||
// TranslatorTimeout bounds a single HTTP request to the
|
||||
// translator. Worker retries (exponential backoff up to
|
||||
// TranslatorMaxAttempts) layer on top.
|
||||
TranslatorTimeout time.Duration
|
||||
|
||||
// TranslatorMaxAttempts is the number of times the worker tries
|
||||
// to translate one (message, target_lang) pair before falling
|
||||
// back to delivering the original body.
|
||||
TranslatorMaxAttempts int
|
||||
|
||||
// WorkerInterval bounds how often the async translation worker
|
||||
// scans for pending pairs. The worker handles one pair per tick.
|
||||
WorkerInterval time.Duration
|
||||
}
|
||||
|
||||
// NotificationConfig configures the notification fan-out module
|
||||
@@ -494,6 +553,13 @@ func DefaultConfig() Config {
|
||||
WorkerInterval: defaultNotificationWorkerInterval,
|
||||
MaxAttempts: defaultNotificationMaxAttempts,
|
||||
},
|
||||
Diplomail: DiplomailConfig{
|
||||
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
|
||||
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
|
||||
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
|
||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||
},
|
||||
DevSandbox: DevSandboxConfig{
|
||||
EngineVersion: defaultDevSandboxEngineVersion,
|
||||
PlayerCount: defaultDevSandboxPlayerCount,
|
||||
@@ -648,6 +714,7 @@ func LoadFromEnv() (Config, error) {
|
||||
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Runtime.StackLabel = strings.TrimSpace(loadString(envRuntimeStackLabel, cfg.Runtime.StackLabel))
|
||||
|
||||
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
||||
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
||||
@@ -657,6 +724,23 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
|
||||
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||
@@ -853,6 +937,22 @@ func (c Config) Validate() error {
|
||||
if c.Notification.MaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
|
||||
}
|
||||
|
||||
if c.Diplomail.MaxBodyBytes <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes)
|
||||
}
|
||||
if c.Diplomail.MaxSubjectBytes < 0 {
|
||||
return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
|
||||
}
|
||||
if c.Diplomail.TranslatorTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
|
||||
}
|
||||
if c.Diplomail.TranslatorMaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
|
||||
}
|
||||
if c.Diplomail.WorkerInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
|
||||
}
|
||||
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
||||
if _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# diplomail
|
||||
|
||||
`diplomail` owns the diplomatic-mail subsystem of the Galaxy backend
|
||||
service. Messages live in the lobby-side domain (their storage and
|
||||
lifecycle are tied to a game), but they are surfaced inside the game UI
|
||||
— the lobby exposes only an unread-count badge per game.
|
||||
|
||||
## Stages
|
||||
|
||||
The package ships in four staged increments. Stage A is the surface
|
||||
described below; the remaining stages add admin / system mail,
|
||||
lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk
|
||||
purge, and the language-detection / translation cache.
|
||||
|
||||
| Stage | Scope | Status |
|
||||
|-------|-------|--------|
|
||||
| A | Schema, personal single-recipient send / read / delete, unread badge, push event with body-language `und` | shipped |
|
||||
| B | Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players | shipped |
|
||||
| C | Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability | shipped |
|
||||
| D | Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch | shipped |
|
||||
| E | LibreTranslate HTTP client + async translation worker with exponential backoff + delivery gating on translation completion | shipped |
|
||||
|
||||
## Tables
|
||||
|
||||
Three Postgres tables in the `backend` schema:
|
||||
|
||||
- `diplomail_messages` — one row per send (personal, admin, or
|
||||
system). Captures `game_name` and IP at insert time so audit
|
||||
rendering survives renames and purges. The `sender_race_name`
|
||||
column snapshots the sender's race in the game at send time when
|
||||
the sender is a player with an active membership; the in-game UI
|
||||
keys per-race thread grouping on this column.
|
||||
- `diplomail_recipients` — one row per (message, recipient). Holds
|
||||
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
|
||||
state. Snapshot fields (`recipient_user_name`,
|
||||
`recipient_race_name`) are captured at insert time and survive
|
||||
membership revocation.
|
||||
- `diplomail_translations` — cached per (message, target_lang)
|
||||
rendering. One translation is reused across every recipient that
|
||||
asks for that language.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Caller | Pre-conditions |
|
||||
|--------|--------|----------------|
|
||||
| Send personal | user | active membership in game; recipient is active member |
|
||||
| Paid-tier broadcast | paid-tier user | active membership; recipients = every other active member |
|
||||
| Send admin (single user) | game owner OR site admin | recipient is any-status member of the game |
|
||||
| Send admin (broadcast) | game owner OR site admin | recipient scope ∈ `active` / `active_and_removed` / `all_members`; sender excluded |
|
||||
| Multi-game admin broadcast | site admin | scope `selected` (with `game_ids`) or `all_running` |
|
||||
| Bulk purge | site admin | `older_than_years >= 1`; targets games with terminal status finished more than N years ago |
|
||||
| Read message | the recipient | row exists in `diplomail_recipients(message_id, user_id)`; non-active members see admin-kind only |
|
||||
| Mark read | the recipient | row exists; idempotent if already marked |
|
||||
| Soft delete | the recipient | `read_at IS NOT NULL` (open-then-delete, item 10) |
|
||||
|
||||
Stage D will add body-language detection (whatlanggo) and the
|
||||
translation cache + async worker.
|
||||
|
||||
System mail is produced internally by lobby lifecycle hooks:
|
||||
`Service.transition()` emits `game.paused` / `game.cancelled` system
|
||||
mail to every active member; `Service.changeMembershipStatus` /
|
||||
`Service.AdminBanMember` emit `membership.removed` /
|
||||
`membership.blocked` system mail addressed to the affected user.
|
||||
|
||||
## Content rules
|
||||
|
||||
- Body is plain UTF-8 text. The server does **not** parse, sanitise,
|
||||
or escape HTML — the UI renders messages via `textContent`.
|
||||
- Body length is capped by `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default
|
||||
4096). Subject length is capped by
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). Both limits
|
||||
live in the service layer so they can be tuned without a schema
|
||||
migration.
|
||||
- `body_lang` is filled at send time by the configured
|
||||
`detector.LanguageDetector` (default: `whatlanggo`, body-only,
|
||||
≥ 25 runes; shorter bodies stay `und`).
|
||||
|
||||
## Recipient selection
|
||||
|
||||
`POST /messages` and `POST /admin` (when `target="user"`) accept the
|
||||
recipient identifier in one of two shapes:
|
||||
|
||||
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
|
||||
may be any active member of the game.
|
||||
- `recipient_race_name` (string) — resolves to the active member
|
||||
with this race name in the game. Race names are unique by lobby
|
||||
invariant; lobby-removed and blocked members cannot be reached
|
||||
through the race-name shortcut (they no longer appear in the
|
||||
active scope). Exactly one of the two fields must be supplied;
|
||||
supplying both, or neither, returns `invalid_request`.
|
||||
|
||||
The race-name path lets the in-game UI compose mail directly off
|
||||
the engine's `report.races[]` view without an extra membership
|
||||
round-trip.
|
||||
|
||||
## Translation
|
||||
|
||||
Stage D adds a lazy translation cache. When a recipient reads a
|
||||
message through `GET /api/v1/user/games/{game_id}/mail/messages/{id}`,
|
||||
the handler resolves the caller's `accounts.preferred_language` and
|
||||
asks `Service.GetMessage(…, targetLang)` to attach a translation:
|
||||
|
||||
- on cache hit (row in `diplomail_translations`), the rendering is
|
||||
returned directly under `translated_subject` / `translated_body`;
|
||||
- on cache miss, the configured `translator.Translator` is invoked.
|
||||
A non-noop result is persisted and returned to the caller; the
|
||||
noop translator that ships with Stage D returns `engine == "noop"`,
|
||||
which is treated as "translation unavailable" and the caller falls
|
||||
back to the original body.
|
||||
|
||||
The inbox listing (`/inbox`) reuses cached translations but never
|
||||
calls the translator on miss — bulk listings stay fast even when a
|
||||
real translator (LibreTranslate, SaaS engine) introduces I/O cost.
|
||||
|
||||
Future work plugs a real `translator.Translator` (LibreTranslate
|
||||
HTTP client is the documented next step) without touching the rest
|
||||
of the system.
|
||||
|
||||
## Async translation (Stage E)
|
||||
|
||||
Stage E switches the translation pipeline from "lazy at read" to
|
||||
"async at send". The send path stays synchronous from the
|
||||
caller's perspective: the message and recipient rows are inserted
|
||||
in one transaction. What changes is delivery semantics:
|
||||
|
||||
- Recipients whose `preferred_language` matches the detected
|
||||
`body_lang` (or whose body language is `und`) get
|
||||
`available_at = now()` straight away and the push event fires
|
||||
during the request.
|
||||
- Recipients whose `preferred_language` differs are inserted with
|
||||
`available_at IS NULL`. They are **not** visible in inbox, unread
|
||||
count, or push events until the worker translates the message.
|
||||
|
||||
The worker (`internal/diplomail.Worker`, started as an
|
||||
`app.Component` in `cmd/backend/main`) ticks once every
|
||||
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`). Each tick:
|
||||
|
||||
1. Picks one distinct `(message_id, recipient_preferred_language)`
|
||||
pair from `diplomail_recipients` where `available_at IS NULL`
|
||||
and `next_translation_attempt_at` is unset or due.
|
||||
2. Loads the source message, checks the translation cache.
|
||||
3. On cache hit → marks every pending recipient of the pair
|
||||
delivered and emits push.
|
||||
4. On cache miss → asks the configured `Translator`:
|
||||
- success → caches the translation, marks delivered, push;
|
||||
- HTTP 400 (unsupported pair) → marks delivered without a
|
||||
translation (fallback to original);
|
||||
- other failure → bumps `translation_attempts`, schedules the
|
||||
retry via `next_translation_attempt_at`, leaves pending.
|
||||
5. After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||
the worker falls back to delivering the original body so a
|
||||
prolonged LibreTranslate outage does not strand messages.
|
||||
|
||||
Retry backoff is exponential `1s → 2s → 4s → 8s → 16s` (capped at
|
||||
60s) per pair. Operators monitor the LibreTranslate dependency
|
||||
through standard OpenTelemetry export — translation outcomes
|
||||
surface in `diplomail.worker` logs at Info / Warn levels;
|
||||
Grafana / Prometheus dashboards live outside this package.
|
||||
|
||||
### Multi-instance posture (known limitation)
|
||||
|
||||
`PickPendingTranslationPair` intentionally drops `FOR UPDATE`: the
|
||||
worker is single-threaded per process, and we did not want a slow
|
||||
LibreTranslate HTTP call to keep a row-lock open. The cost is a
|
||||
small window where two backend instances pulling at the same
|
||||
moment can both claim the same pair: the cache-write side stays
|
||||
clean (`INSERT … ON CONFLICT DO NOTHING`), but each instance will
|
||||
publish its own push event to every recipient of the pair, so the
|
||||
duplicate push is the visible failure mode.
|
||||
|
||||
The current deployment runs a single backend instance and the
|
||||
window does not exist. When the platform scales to multiple
|
||||
instances, we will revisit the pickup query — either by holding
|
||||
the lock through the HTTP call (with a short timeout to bound the
|
||||
worst case) or by introducing a `claimed_at` column and a
|
||||
short-lived advisory lease. The change is local to this package
|
||||
and does not affect callers.
|
||||
|
||||
For the LibreTranslate operational recipe — installing, wiring,
|
||||
manual smoke test — see
|
||||
[`backend/docs/diplomail-translator-setup.md`](../../docs/diplomail-translator-setup.md).
|
||||
|
||||
## Push integration
|
||||
|
||||
Every successful send emits a `diplomail.message.received` push
|
||||
intent through the existing notification pipeline. The catalog entry
|
||||
limits delivery to the push channel — email is intentionally absent;
|
||||
the inbox endpoint is the durable fallback for offline users. The
|
||||
payload includes the recipient's freshly recomputed unread count for
|
||||
the lobby badge and for the in-game header.
|
||||
|
||||
## Lifecycle hooks (Stage B)
|
||||
|
||||
The lobby module is the producer of system mail. Stage B will add a
|
||||
`DiplomailPublisher` collaborator on `lobby.Service` and call it on
|
||||
`paused` / `cancelled` transitions and on `BlockMembership` /
|
||||
`AdminBanMember`. The publisher constructs a
|
||||
`kind='admin', sender_kind='system'` message with a templated body;
|
||||
the recipient receives the durable copy in their inbox even after the
|
||||
membership is revoked.
|
||||
|
||||
If a future stage adds inactivity-based player removal at the lobby
|
||||
sweeper, that path **must** call the same publisher so the kicked
|
||||
player has the explanation in their inbox.
|
||||
|
||||
## Wiring
|
||||
|
||||
`cmd/backend/main.go` constructs `*diplomail.Service` with three
|
||||
collaborators:
|
||||
|
||||
- `*Store` over the shared Postgres pool;
|
||||
- `MembershipLookup` adapter that walks the lobby cache for the
|
||||
active `(game_id, user_id)` row and stitches in the immutable
|
||||
`accounts.user_name`;
|
||||
- `NotificationPublisher` adapter that translates each
|
||||
`DiplomailNotification` into a `notification.Intent` and routes it
|
||||
through `*notification.Service.Submit`.
|
||||
@@ -0,0 +1,634 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SendAdminPersonal persists an admin-kind message addressed to a
|
||||
// single recipient and fan-outs the push event. The HTTP layer is
|
||||
// responsible for the owner-vs-admin authorisation decision; this
|
||||
// function trusts the caller designation it receives.
|
||||
//
|
||||
// The recipient may be in any membership status, so the lookup goes
|
||||
// through MembershipLookup.GetMembershipAnyStatus. This lets the
|
||||
// owner / admin reach a kicked player to explain the kick or follow
|
||||
// up after a removal.
|
||||
func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
|
||||
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
|
||||
}
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC())
|
||||
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err)
|
||||
}
|
||||
if len(recipients) != 1 {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients))
|
||||
}
|
||||
|
||||
if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) }
|
||||
return msg, recipients[0], nil
|
||||
}
|
||||
|
||||
// SendAdminBroadcast persists an admin-kind broadcast addressed to
|
||||
// every member matching `RecipientScope`, then emits one push event
|
||||
// per recipient. The caller's own membership row, when present, is
|
||||
// excluded from the recipient list — broadcasters do not get a copy
|
||||
// of their own message.
|
||||
func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
scope, err := normaliseScope(in.RecipientScope)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err)
|
||||
}
|
||||
members = filterOutCaller(members, in.CallerUserID)
|
||||
if len(members) == 0 {
|
||||
return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput)
|
||||
}
|
||||
|
||||
gameName := members[0].GameName
|
||||
msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
|
||||
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||
}
|
||||
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||
}
|
||||
return msg, recipients, nil
|
||||
}
|
||||
|
||||
// SendPlayerBroadcast persists a paid-tier player broadcast and
|
||||
// fans out the push event to every other active member of the game.
|
||||
// The send is `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"` — recipients reply to it as if
|
||||
// it were a single-recipient personal send, and the reply targets
|
||||
// only the broadcaster. The caller's entitlement tier is checked
|
||||
// against `EntitlementReader`; free-tier callers are rejected with
|
||||
// ErrForbidden.
|
||||
func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return Message{}, nil, err
|
||||
}
|
||||
if s.deps.Entitlements == nil {
|
||||
return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden)
|
||||
}
|
||||
paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err)
|
||||
}
|
||||
if !paid {
|
||||
return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden)
|
||||
}
|
||||
|
||||
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||
}
|
||||
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err)
|
||||
}
|
||||
callerID := in.SenderUserID
|
||||
members = filterOutCaller(members, &callerID)
|
||||
if len(members) == 0 {
|
||||
return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput)
|
||||
}
|
||||
|
||||
username := sender.UserName
|
||||
senderRace := sender.RaceName
|
||||
msgInsert := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
GameName: sender.GameName,
|
||||
Kind: KindPersonal,
|
||||
SenderKind: SenderKindPlayer,
|
||||
SenderUserID: &callerID,
|
||||
SenderUsername: &username,
|
||||
SenderRaceName: &senderRace,
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: BroadcastScopeGameBroadcast,
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||
}
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||
}
|
||||
return msg, recipients, nil
|
||||
}
|
||||
|
||||
// SendAdminMultiGameBroadcast emits one admin-kind message per game
|
||||
// resolved from the input scope and fans out the push events. A
|
||||
// recipient who plays in multiple addressed games receives one
|
||||
// independently-deletable inbox entry per game; this avoids cross-
|
||||
// game leakage of admin context and keeps the per-game unread badge
|
||||
// honest.
|
||||
func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) {
|
||||
subject, body, err := s.prepareContent(in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
scope, err := normaliseScope(in.RecipientScope)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if s.deps.Games == nil {
|
||||
return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
||||
}
|
||||
games, err := s.resolveMultiGameTargets(ctx, in)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(games) == 0 {
|
||||
return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput)
|
||||
}
|
||||
|
||||
totalRecipients := 0
|
||||
out := make([]Message, 0, len(games))
|
||||
for _, game := range games {
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
s.deps.Logger.Debug("multi-game broadcast skips empty game",
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
zap.String("scope", scope))
|
||||
continue
|
||||
}
|
||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
|
||||
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||
}
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||
}
|
||||
out = append(out, msg)
|
||||
totalRecipients += len(recipients)
|
||||
}
|
||||
return out, totalRecipients, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) {
|
||||
switch in.Scope {
|
||||
case MultiGameScopeAllRunning:
|
||||
games, err := s.deps.Games.ListRunningGames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diplomail: list running games: %w", err)
|
||||
}
|
||||
return games, nil
|
||||
case MultiGameScopeSelected, "":
|
||||
if len(in.GameIDs) == 0 {
|
||||
return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput)
|
||||
}
|
||||
out := make([]GameSnapshot, 0, len(in.GameIDs))
|
||||
for _, id := range in.GameIDs {
|
||||
game, err := s.deps.Games.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id)
|
||||
}
|
||||
return nil, fmt.Errorf("diplomail: load game %s: %w", id, err)
|
||||
}
|
||||
out = append(out, game)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
// BulkCleanup deletes every diplomail_messages row tied to games that
|
||||
// finished more than `OlderThanYears` years ago. Returns the affected
|
||||
// game ids and the count of removed messages. The minimum allowed
|
||||
// value is 1 year — finer-grained pruning would risk wiping live
|
||||
// arbitration evidence.
|
||||
func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) {
|
||||
if in.OlderThanYears < 1 {
|
||||
return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput)
|
||||
}
|
||||
if s.deps.Games == nil {
|
||||
return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput)
|
||||
}
|
||||
cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0)
|
||||
games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err)
|
||||
}
|
||||
if len(games) == 0 {
|
||||
return CleanupResult{}, nil
|
||||
}
|
||||
gameIDs := make([]uuid.UUID, 0, len(games))
|
||||
for _, g := range games {
|
||||
gameIDs = append(gameIDs, g.GameID)
|
||||
}
|
||||
deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs)
|
||||
if err != nil {
|
||||
return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err)
|
||||
}
|
||||
return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil
|
||||
}
|
||||
|
||||
// ListMessagesForAdmin returns a paginated, optionally-filtered view
|
||||
// of every persisted message. Used by the admin observability
|
||||
// endpoint to inspect what has been sent and trace abuse reports.
|
||||
func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) {
|
||||
rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter)
|
||||
if err != nil {
|
||||
return AdminMessagePage{}, err
|
||||
}
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
return AdminMessagePage{
|
||||
Items: rows,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublishLifecycle persists a system-kind message in response to a
|
||||
// lobby lifecycle transition and fan-outs push events to the
|
||||
// affected recipients. Game-scoped transitions (`game.paused`,
|
||||
// `game.cancelled`) reach every active member; membership-scoped
|
||||
// transitions (`membership.removed`, `membership.blocked`) reach the
|
||||
// kicked player only. Failures inside the function are logged at
|
||||
// Warn level — lifecycle hooks must not block the lobby state
|
||||
// machine on a downstream mail failure.
|
||||
func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
switch ev.Kind {
|
||||
case LifecycleKindGamePaused, LifecycleKindGameCancelled:
|
||||
return s.publishGameLifecycle(ctx, ev)
|
||||
case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked:
|
||||
return s.publishMembershipLifecycle(ctx, ev)
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
s.deps.Logger.Debug("lifecycle skip: no active members",
|
||||
zap.String("game_id", ev.GameID.String()),
|
||||
zap.String("kind", ev.Kind))
|
||||
return nil
|
||||
}
|
||||
gameName := members[0].GameName
|
||||
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcptInserts := make([]RecipientInsert, 0, len(members))
|
||||
for _, m := range members {
|
||||
rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC()))
|
||||
}
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error {
|
||||
if ev.TargetUser == nil {
|
||||
return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput)
|
||||
}
|
||||
target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: load target membership: %w", err)
|
||||
}
|
||||
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
|
||||
|
||||
msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
|
||||
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC())
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||
if err != nil {
|
||||
return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err)
|
||||
}
|
||||
if len(recipients) == 1 && recipients[0].AvailableAt != nil {
|
||||
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareContent normalises subject and body the same way SendPersonal
|
||||
// does. Factored out so admin and lifecycle paths share the
|
||||
// length-and-utf8 validation rules.
|
||||
func (s *Service) prepareContent(subject, body string) (string, string, error) {
|
||||
subj := strings.TrimRight(subject, " \t")
|
||||
bod := strings.TrimRight(body, " \t\n")
|
||||
if err := s.validateContent(subj, bod); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return subj, bod, nil
|
||||
}
|
||||
|
||||
// buildAdminMessageInsert encapsulates the message-row construction
|
||||
// for every admin-kind send. The CHECK constraint maps sender
|
||||
// shapes:
|
||||
//
|
||||
// sender_kind='player' → CallerKind owner; sender_user_id set,
|
||||
// sender_race_name resolved from
|
||||
// Memberships.GetActiveMembership
|
||||
// sender_kind='admin' → CallerKind admin; sender_user_id nil
|
||||
// sender_kind='system' → CallerKind system; sender_username nil
|
||||
func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
|
||||
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
|
||||
out := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: gameID,
|
||||
GameName: gameName,
|
||||
Kind: KindAdmin,
|
||||
SenderIP: senderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: scope,
|
||||
}
|
||||
switch callerKind {
|
||||
case CallerKindOwner:
|
||||
if callerUserID == nil {
|
||||
return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput)
|
||||
}
|
||||
uid := *callerUserID
|
||||
uname := callerUsername
|
||||
out.SenderKind = SenderKindPlayer
|
||||
out.SenderUserID = &uid
|
||||
out.SenderUsername = &uname
|
||||
// Owner race snapshot is best-effort: a private-game owner who
|
||||
// has an active membership in their own game contributes a
|
||||
// race name; an owner who is not a current member (or whose
|
||||
// membership is removed/blocked) leaves the field nil. The
|
||||
// CHECK constraint accepts both shapes for sender_kind='player'.
|
||||
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
|
||||
race := ownerMember.RaceName
|
||||
out.SenderRaceName = &race
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
|
||||
}
|
||||
case CallerKindAdmin:
|
||||
uname := callerUsername
|
||||
out.SenderKind = SenderKindAdmin
|
||||
out.SenderUsername = &uname
|
||||
case CallerKindSystem:
|
||||
out.SenderKind = SenderKindSystem
|
||||
default:
|
||||
return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert.
|
||||
// The race-name snapshot is nullable so a kicked player with no race
|
||||
// name on file is still addressable.
|
||||
//
|
||||
// `bodyLang` is the detected language of the message body. When the
|
||||
// recipient's preferred_language matches body_lang (or body_lang is
|
||||
// undetermined), the function fills AvailableAt with `now` so the
|
||||
// recipient row is materialised already-delivered; otherwise
|
||||
// AvailableAt stays nil and the translation worker takes over.
|
||||
func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert {
|
||||
in := RecipientInsert{
|
||||
RecipientID: uuid.New(),
|
||||
MessageID: messageID,
|
||||
GameID: m.GameID,
|
||||
UserID: m.UserID,
|
||||
RecipientUserName: m.UserName,
|
||||
RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage),
|
||||
}
|
||||
if m.RaceName != "" {
|
||||
race := m.RaceName
|
||||
in.RecipientRaceName = &race
|
||||
}
|
||||
if needsTranslation(bodyLang, in.RecipientPreferredLanguage) {
|
||||
// AvailableAt left nil → worker will deliver after the
|
||||
// translation cache is materialised (or after fallback).
|
||||
} else {
|
||||
t := now.UTC()
|
||||
in.AvailableAt = &t
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// needsTranslation reports whether a recipient with preferredLang
|
||||
// needs to wait for a translated rendering before the message is
|
||||
// considered delivered. Undetermined body language and empty
|
||||
// recipient preferences are short-circuited to "no translation
|
||||
// needed" so we never block delivery on something the detector
|
||||
// could not label.
|
||||
func needsTranslation(bodyLang, preferredLang string) bool {
|
||||
bodyLang = normaliseLang(bodyLang)
|
||||
preferredLang = normaliseLang(preferredLang)
|
||||
if bodyLang == "" || bodyLang == LangUndetermined {
|
||||
return false
|
||||
}
|
||||
if preferredLang == "" || preferredLang == LangUndetermined {
|
||||
return false
|
||||
}
|
||||
return bodyLang != preferredLang
|
||||
}
|
||||
|
||||
// normaliseLang strips any region subtag and lowercases the result so
|
||||
// `en-US` and `EN` both collapse to `en`. The diplomail layer uses
|
||||
// ISO 639-1 codes; whatlanggo and LibreTranslate share that
|
||||
// vocabulary.
|
||||
func normaliseLang(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(tag, "-_"); i > 0 {
|
||||
tag = tag[:i]
|
||||
}
|
||||
return strings.ToLower(tag)
|
||||
}
|
||||
|
||||
func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error {
|
||||
switch callerKind {
|
||||
case CallerKindOwner:
|
||||
if callerUserID == nil {
|
||||
return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput)
|
||||
}
|
||||
if callerUsername == "" {
|
||||
return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput)
|
||||
}
|
||||
case CallerKindAdmin:
|
||||
if callerUsername == "" {
|
||||
return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput)
|
||||
}
|
||||
case CallerKindSystem:
|
||||
// no extra checks
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normaliseScope(scope string) (string, error) {
|
||||
switch scope {
|
||||
case "", RecipientScopeActive:
|
||||
return RecipientScopeActive, nil
|
||||
case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers:
|
||||
return scope, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope)
|
||||
}
|
||||
}
|
||||
|
||||
func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot {
|
||||
if callerUserID == nil {
|
||||
return members
|
||||
}
|
||||
out := make([]MemberSnapshot, 0, len(members))
|
||||
for _, m := range members {
|
||||
if m.UserID == *callerUserID {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderGameLifecycle returns the (subject, body) pair persisted for
|
||||
// the `game.paused` / `game.cancelled` system message. Bodies are in
|
||||
// English; Stage D will translate them on demand into each
|
||||
// recipient's preferred_language and cache the result.
|
||||
func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
actor = "the system"
|
||||
}
|
||||
reasonTail := ""
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
reasonTail = " Reason: " + r + "."
|
||||
}
|
||||
switch kind {
|
||||
case LifecycleKindGamePaused:
|
||||
return "Game paused",
|
||||
fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail)
|
||||
case LifecycleKindGameCancelled:
|
||||
return "Game cancelled",
|
||||
fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail)
|
||||
}
|
||||
return "Game lifecycle update",
|
||||
fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail)
|
||||
}
|
||||
|
||||
// renderMembershipLifecycle returns the (subject, body) pair persisted
|
||||
// for the `membership.removed` / `membership.blocked` system message.
|
||||
func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
actor = "the system"
|
||||
}
|
||||
reasonTail := ""
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
reasonTail = " Reason: " + r + "."
|
||||
}
|
||||
switch kind {
|
||||
case LifecycleKindMembershipRemoved:
|
||||
return "Membership removed",
|
||||
fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail)
|
||||
case LifecycleKindMembershipBlocked:
|
||||
return "Membership blocked",
|
||||
fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail)
|
||||
}
|
||||
return "Membership update",
|
||||
fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Deps aggregates every collaborator the diplomail Service depends on.
|
||||
//
|
||||
// Store and Memberships are required. Logger and Now default to
|
||||
// zap.NewNop / time.Now when nil. Notification falls back to a no-op
|
||||
// publisher so unit tests can construct a Service with only the
|
||||
// required collaborators populated. Entitlements and Games are
|
||||
// optional — they are used by Stage C surfaces (paid-tier player
|
||||
// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may
|
||||
// pass nil for tests that do not exercise those paths.
|
||||
type Deps struct {
|
||||
Store *Store
|
||||
Memberships MembershipLookup
|
||||
Notification NotificationPublisher
|
||||
Entitlements EntitlementReader
|
||||
Games GameLookup
|
||||
Detector detector.LanguageDetector
|
||||
Translator translator.Translator
|
||||
Config config.DiplomailConfig
|
||||
Logger *zap.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// EntitlementReader is the read-only surface diplomail uses to gate
|
||||
// the paid-tier player broadcast. The canonical implementation in
|
||||
// `cmd/backend/main` reads
|
||||
// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`.
|
||||
type EntitlementReader interface {
|
||||
IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// GameLookup exposes the slim view of `games` the multi-game admin
|
||||
// broadcast and bulk-cleanup paths consume. The canonical
|
||||
// implementation walks the lobby cache plus an explicit store call
|
||||
// for finished-game pruning.
|
||||
type GameLookup interface {
|
||||
// ListRunningGames returns every game whose `status` is one of
|
||||
// the still-active values (running, paused, starting, …). The
|
||||
// admin `all_running` broadcast scope iterates over the result.
|
||||
ListRunningGames(ctx context.Context) ([]GameSnapshot, error)
|
||||
|
||||
// ListFinishedGamesBefore returns every game whose `finished_at`
|
||||
// is older than `cutoff`. The bulk-purge admin endpoint reads
|
||||
// this to compose the cascade-delete IN list.
|
||||
ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error)
|
||||
|
||||
// GetGame returns one game snapshot identified by id, or
|
||||
// ErrNotFound. Used by the multi-game broadcast to verify the
|
||||
// caller-supplied id list before enqueuing fan-out work.
|
||||
GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error)
|
||||
}
|
||||
|
||||
// GameSnapshot is the trim view of `games` consumed by the multi-game
|
||||
// admin broadcast and the cleanup paths. The struct intentionally
|
||||
// avoids the full `lobby.GameRecord` so the diplomail package stays
|
||||
// decoupled from the lobby domain.
|
||||
type GameSnapshot struct {
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Status string
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// ActiveMembership is the slim view of a single (user, game) roster
|
||||
// row the diplomail package needs at send time: it confirms the
|
||||
// participant is active in the game and captures the snapshot fields
|
||||
// (`game_name`, `user_name`, `race_name`, `preferred_language`) that
|
||||
// we persist on each new message / recipient row.
|
||||
type ActiveMembership struct {
|
||||
UserID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
UserName string
|
||||
RaceName string
|
||||
PreferredLanguage string
|
||||
}
|
||||
|
||||
// MembershipLookup is the read-only surface diplomail uses to verify
|
||||
// "is this user an active member of this game" and to snapshot the
|
||||
// roster metadata. The canonical implementation in `cmd/backend/main`
|
||||
// adapts the `*lobby.Service` membership cache to this interface.
|
||||
//
|
||||
// GetActiveMembership returns ErrNotFound (the diplomail sentinel)
|
||||
// when the user is not an active member of the game; the service
|
||||
// boundary maps that to 403 forbidden.
|
||||
//
|
||||
// GetMembershipAnyStatus returns the same shape regardless of
|
||||
// membership status (`active`, `removed`, `blocked`). Used by the
|
||||
// inbox read path to check whether a kicked recipient still belongs
|
||||
// to the game's roster; ErrNotFound is surfaced when the user has
|
||||
// never been a member.
|
||||
//
|
||||
// ListMembers returns every roster row matching scope, in stable
|
||||
// order. Scope values are `active`, `active_and_removed`, and
|
||||
// `all_members` (the spec calls these out by name). Used by the
|
||||
// broadcast composition step in admin / owner sends.
|
||||
type MembershipLookup interface {
|
||||
GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error)
|
||||
GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error)
|
||||
ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error)
|
||||
}
|
||||
|
||||
// Recipient scope values accepted by ListMembers and by the
|
||||
// `recipients` request field on admin / owner broadcasts.
|
||||
const (
|
||||
RecipientScopeActive = "active"
|
||||
RecipientScopeActiveAndRemoved = "active_and_removed"
|
||||
RecipientScopeAllMembers = "all_members"
|
||||
)
|
||||
|
||||
// MemberSnapshot is the slim view of a membership row that survives
|
||||
// all three status values. RaceName is the immutable string captured
|
||||
// at registration time; an empty value is legal for rare cases where
|
||||
// the row was inserted without one. PreferredLanguage is included so
|
||||
// the broadcast and lifecycle paths can decide whether the recipient
|
||||
// needs to wait for a translation before delivery.
|
||||
type MemberSnapshot struct {
|
||||
UserID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
UserName string
|
||||
RaceName string
|
||||
PreferredLanguage string
|
||||
Status string
|
||||
}
|
||||
|
||||
// NotificationPublisher is the outbound surface diplomail uses to
|
||||
// emit the `diplomail.message.received` push event. The canonical
|
||||
// implementation in `cmd/backend/main` adapts the notification.Service
|
||||
// the same way it adapts `lobby.NotificationPublisher`; tests pass
|
||||
// the no-op publisher below to avoid wiring the dispatcher.
|
||||
type NotificationPublisher interface {
|
||||
PublishDiplomailEvent(ctx context.Context, ev DiplomailNotification) error
|
||||
}
|
||||
|
||||
// DiplomailNotification is the open shape carried by a per-recipient
|
||||
// push intent. The struct lives in the diplomail package so the
|
||||
// producer vocabulary stays here; the publisher adapter translates it
|
||||
// into a `notification.Intent` at the wiring boundary.
|
||||
type DiplomailNotification struct {
|
||||
Kind string
|
||||
IdempotencyKey string
|
||||
Recipient uuid.UUID
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
// NewNoopNotificationPublisher returns a publisher that logs every
|
||||
// call at debug level and returns nil. Used by unit tests and as the
|
||||
// fallback inside NewService when callers leave Deps.Notification nil.
|
||||
func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &noopNotificationPublisher{logger: logger.Named("diplomail.notify.noop")}
|
||||
}
|
||||
|
||||
type noopNotificationPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (p *noopNotificationPublisher) PublishDiplomailEvent(_ context.Context, ev DiplomailNotification) error {
|
||||
p.logger.Debug("noop notification",
|
||||
zap.String("kind", ev.Kind),
|
||||
zap.String("idempotency_key", ev.IdempotencyKey),
|
||||
zap.String("recipient", ev.Recipient.String()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package detector wraps the body-language detection used by the
|
||||
// diplomail subsystem. The package exposes a narrow `LanguageDetector`
|
||||
// interface so the implementation can be swapped without touching the
|
||||
// callers; the default backed-by-whatlanggo detector handles 84
|
||||
// natural languages and ships with the embedded statistical profiles.
|
||||
//
|
||||
// Detection happens only on the body. Subjects are short and
|
||||
// frequently template-like ("Re: ..."), so detecting on them adds
|
||||
// noise. The diplomail Service feeds the body, captures the BCP 47
|
||||
// tag returned here, and stores it in `diplomail_messages.body_lang`.
|
||||
package detector
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/abadojack/whatlanggo"
|
||||
)
|
||||
|
||||
// Undetermined is the BCP 47 placeholder stored when detection cannot
|
||||
// confidently identify a language (empty body, too-short body, mixed
|
||||
// scripts the detector refuses to bet on).
|
||||
const Undetermined = "und"
|
||||
|
||||
// LanguageDetector is the read-only surface diplomail consumes when
|
||||
// it needs to label a message body. Detect must never panic and
|
||||
// must never return an error: detection failure simply yields
|
||||
// `Undetermined`.
|
||||
type LanguageDetector interface {
|
||||
Detect(body string) string
|
||||
}
|
||||
|
||||
// New returns the package-default detector backed by `whatlanggo`.
|
||||
// The instance is safe for concurrent use; whatlanggo's `Detect`
|
||||
// reads the embedded profiles without state mutation. Callers that
|
||||
// want a fixed allow-list can build their own implementation around
|
||||
// the same interface.
|
||||
func New() LanguageDetector {
|
||||
return &whatlangDetector{}
|
||||
}
|
||||
|
||||
type whatlangDetector struct{}
|
||||
|
||||
// minRunes is the lower bound on body length below which whatlanggo
|
||||
// can flip between near-synonyms; for shorter bodies we return
|
||||
// `Undetermined` and let the noop translator skip the slot. The
|
||||
// value matches whatlanggo's documented "stable above ~25 runes"
|
||||
// guidance.
|
||||
const minRunes = 25
|
||||
|
||||
// Detect returns the BCP 47 tag for body, or `Undetermined` when the
|
||||
// body is empty / too short / whatlanggo refuses to label it. The
|
||||
// trim is applied so leading whitespace does not bias the script
|
||||
// detector toward Latin. We deliberately do not gate on
|
||||
// `info.IsReliable()` because the gate is too conservative for the
|
||||
// short sentences typical of in-game mail; a misclassification only
|
||||
// hurts the translation cache key, never correctness.
|
||||
func (d *whatlangDetector) Detect(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return Undetermined
|
||||
}
|
||||
if utf8.RuneCountInString(body) < minRunes {
|
||||
return Undetermined
|
||||
}
|
||||
info := whatlanggo.Detect(body)
|
||||
tag := info.Lang.Iso6391()
|
||||
if tag == "" {
|
||||
return Undetermined
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
// NoopDetector returns the placeholder unconditionally. Used by
|
||||
// tests and by Stage A code paths that predate the real detector.
|
||||
type NoopDetector struct{}
|
||||
|
||||
// Detect always returns `Undetermined` regardless of input.
|
||||
func (NoopDetector) Detect(string) string { return Undetermined }
|
||||
@@ -0,0 +1,49 @@
|
||||
package detector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDetectKnownLanguages(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := New()
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "english paragraph",
|
||||
text: "The trade agreement should be signed before the next turn. " +
|
||||
"I expect a written response by the time the engine generates the next report.",
|
||||
want: "en",
|
||||
},
|
||||
{
|
||||
name: "russian paragraph",
|
||||
text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " +
|
||||
"совместную операцию по освоению гиперпространственных маршрутов. " +
|
||||
"Жду твоего письменного ответа до конца следующего хода игры, " +
|
||||
"чтобы мы успели согласовать детали и подписать договор вовремя.",
|
||||
want: "ru",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := d.Detect(tc.text)
|
||||
if got != tc.want {
|
||||
t.Fatalf("Detect = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShortOrEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := New()
|
||||
short := []string{"", "hi", " "}
|
||||
for _, s := range short {
|
||||
if got := d.Detect(s); got != Undetermined {
|
||||
t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package diplomail owns the diplomatic-mail subsystem of the Galaxy
|
||||
// backend service. Messages live in the lobby-side domain (their
|
||||
// storage and lifecycle are tied to a game), but they are surfaced
|
||||
// in-game: lobby exposes only an unread-count badge per game while the
|
||||
// in-game mail view reads and writes through this package.
|
||||
//
|
||||
// Stage A implements the personal single-recipient subset:
|
||||
//
|
||||
// - send/read/mark-read/soft-delete handlers for a player addressing
|
||||
// one other active member of the game;
|
||||
// - a push event (`diplomail.message.received`) materialised through
|
||||
// the existing notification pipeline so the recipient gets a live
|
||||
// toast when online;
|
||||
// - an unread-counts endpoint that drives the lobby badge.
|
||||
//
|
||||
// Later stages add admin/owner/system mail, lifecycle hooks, paid-tier
|
||||
// player broadcasts, multi-game broadcasts, bulk purge, and the
|
||||
// language-detection / translation cache.
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail/detector"
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Kind values stored verbatim in `diplomail_messages.kind`. The schema
|
||||
// CHECK constraint pins this to the closed set declared below.
|
||||
const (
|
||||
// KindPersonal is a replyable player-to-player message. The
|
||||
// sender is always a `sender_kind='player'`.
|
||||
KindPersonal = "personal"
|
||||
|
||||
// KindAdmin is a non-replyable administrative notification.
|
||||
// The sender is either a human admin (`sender_kind='admin'`)
|
||||
// or the system itself (`sender_kind='system'`).
|
||||
KindAdmin = "admin"
|
||||
)
|
||||
|
||||
// Sender kind values stored verbatim in `diplomail_messages.sender_kind`.
|
||||
const (
|
||||
// SenderKindPlayer marks the sender as an end-user account.
|
||||
// `sender_user_id` and `sender_username` carry the player's id
|
||||
// and immutable `accounts.user_name`.
|
||||
SenderKindPlayer = "player"
|
||||
|
||||
// SenderKindAdmin marks the sender as a site administrator.
|
||||
// `sender_username` carries `admin_accounts.username`.
|
||||
SenderKindAdmin = "admin"
|
||||
|
||||
// SenderKindSystem marks the sender as the service itself
|
||||
// (lifecycle hooks). Both id and username are NULL.
|
||||
SenderKindSystem = "system"
|
||||
)
|
||||
|
||||
// Broadcast scope values stored verbatim in
|
||||
// `diplomail_messages.broadcast_scope`. Stage A only emits `single`;
|
||||
// Stage B / C add `game_broadcast` and `multi_game_broadcast`.
|
||||
const (
|
||||
BroadcastScopeSingle = "single"
|
||||
BroadcastScopeGameBroadcast = "game_broadcast"
|
||||
BroadcastScopeMultiGameBroadcast = "multi_game_broadcast"
|
||||
)
|
||||
|
||||
// LangUndetermined is the BCP 47 placeholder stored in
|
||||
// `diplomail_messages.body_lang` when language detection has not yet
|
||||
// been performed or could not produce a result. Stage A writes this
|
||||
// value unconditionally; Stage D replaces it with the detected tag.
|
||||
const LangUndetermined = "und"
|
||||
|
||||
// Service is the diplomatic-mail entry point. Every public method is
|
||||
// goroutine-safe; concurrency safety is delegated to Postgres for
|
||||
// persisted state.
|
||||
type Service struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
// NewService constructs a Service from deps. Logger and Now are
|
||||
// defaulted; Store must be non-nil and Memberships must be non-nil
|
||||
// because every send path queries the active membership roster.
|
||||
func NewService(deps Deps) *Service {
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = zap.NewNop()
|
||||
}
|
||||
deps.Logger = deps.Logger.Named("diplomail")
|
||||
if deps.Now == nil {
|
||||
deps.Now = time.Now
|
||||
}
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Detector == nil {
|
||||
deps.Detector = detector.NoopDetector{}
|
||||
}
|
||||
if deps.Translator == nil {
|
||||
deps.Translator = translator.NewNoop()
|
||||
}
|
||||
if deps.Config.MaxBodyBytes <= 0 {
|
||||
deps.Config.MaxBodyBytes = 4096
|
||||
}
|
||||
if deps.Config.MaxSubjectBytes < 0 {
|
||||
deps.Config.MaxSubjectBytes = 256
|
||||
}
|
||||
return &Service{deps: deps}
|
||||
}
|
||||
|
||||
// Config returns the service's runtime configuration. Tests and the
|
||||
// HTTP layer occasionally surface the limits to clients (the OpenAPI
|
||||
// schema documents them too).
|
||||
func (s *Service) Config() config.DiplomailConfig {
|
||||
if s == nil {
|
||||
return config.DiplomailConfig{}
|
||||
}
|
||||
return s.deps.Config
|
||||
}
|
||||
|
||||
// Logger returns the package-named logger. Used by the optional async
|
||||
// worker and by tests asserting on log output.
|
||||
func (s *Service) Logger() *zap.Logger {
|
||||
if s == nil {
|
||||
return zap.NewNop()
|
||||
}
|
||||
return s.deps.Logger
|
||||
}
|
||||
|
||||
// nowUTC returns the configured clock normalised to UTC. Matches the
|
||||
// convention used everywhere else in `backend` so persisted
|
||||
// timestamps compare cleanly regardless of host timezone.
|
||||
func (s *Service) nowUTC() time.Time {
|
||||
return s.deps.Now().UTC()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
package diplomail
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors surface common rejection reasons across the
|
||||
// diplomail package. Handlers map them to HTTP envelopes through
|
||||
// `respondDiplomailError` in `internal/server/handlers_user_mail.go`.
|
||||
//
|
||||
// Adding a new sentinel here is a deliberate API change: it appears in
|
||||
// the handler error map and may surface as a new wire `code` value.
|
||||
// Reuse the existing set when the behaviour overlaps.
|
||||
var (
|
||||
// ErrInvalidInput reports request-level validation failures
|
||||
// (empty body, body or subject over the configured byte limit,
|
||||
// invalid UUID, non-UTF-8 bytes). Maps to 400 invalid_request.
|
||||
ErrInvalidInput = errors.New("diplomail: invalid input")
|
||||
|
||||
// ErrNotFound reports that the requested message does not exist
|
||||
// or is not visible to the caller. Maps to 404 not_found.
|
||||
ErrNotFound = errors.New("diplomail: not found")
|
||||
|
||||
// ErrForbidden reports that the caller is authenticated but not
|
||||
// authorised for the requested action (not an active member of
|
||||
// the game; not a recipient of the message). Maps to 403
|
||||
// forbidden.
|
||||
ErrForbidden = errors.New("diplomail: forbidden")
|
||||
|
||||
// ErrConflict reports that the requested action conflicts with
|
||||
// the current persisted state (e.g. soft-deleting a message
|
||||
// that has not been marked read yet). Maps to 409 conflict.
|
||||
ErrConflict = errors.New("diplomail: conflict")
|
||||
)
|
||||
@@ -0,0 +1,441 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// previewMaxRunes bounds the body excerpt embedded in the push event
|
||||
// so the gRPC payload stays small. The value matches the UI's
|
||||
// "two lines" tease and is intentionally not configurable — clients
|
||||
// drive their own truncation off the canonical fetch.
|
||||
const previewMaxRunes = 120
|
||||
|
||||
// SendPersonal persists a single-recipient personal message and
|
||||
// fan-outs a `diplomail.message.received` push event to the
|
||||
// recipient. Validation rules:
|
||||
//
|
||||
// - both sender and recipient must be active members of GameID;
|
||||
// - the recipient must differ from the sender;
|
||||
// - the body must be non-empty, valid UTF-8, and within the
|
||||
// configured byte limit;
|
||||
// - the subject must be valid UTF-8 and within the configured
|
||||
// byte limit (zero is allowed).
|
||||
//
|
||||
// On any rule violation the function returns ErrInvalidInput or
|
||||
// ErrForbidden; the inserted Message is never persisted in those
|
||||
// cases.
|
||||
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
|
||||
subject := strings.TrimRight(in.Subject, " \t")
|
||||
body := strings.TrimRight(in.Body, " \t\n")
|
||||
if err := s.validateContent(subject, body); err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
|
||||
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, err
|
||||
}
|
||||
if in.SenderUserID == recipientID {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
|
||||
}
|
||||
|
||||
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
|
||||
}
|
||||
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
|
||||
}
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err)
|
||||
}
|
||||
|
||||
username := sender.UserName
|
||||
senderRace := sender.RaceName
|
||||
senderUserID := in.SenderUserID
|
||||
msgInsert := MessageInsert{
|
||||
MessageID: uuid.New(),
|
||||
GameID: in.GameID,
|
||||
GameName: sender.GameName,
|
||||
Kind: KindPersonal,
|
||||
SenderKind: SenderKindPlayer,
|
||||
SenderUserID: &senderUserID,
|
||||
SenderUsername: &username,
|
||||
SenderRaceName: &senderRace,
|
||||
SenderIP: in.SenderIP,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
BodyLang: s.deps.Detector.Detect(body),
|
||||
BroadcastScope: BroadcastScopeSingle,
|
||||
}
|
||||
raceName := recipient.RaceName
|
||||
rcptInsert := buildRecipientInsert(
|
||||
msgInsert.MessageID,
|
||||
MemberSnapshot{
|
||||
UserID: recipientID,
|
||||
GameID: in.GameID,
|
||||
GameName: recipient.GameName,
|
||||
UserName: recipient.UserName,
|
||||
RaceName: raceName,
|
||||
PreferredLanguage: recipient.PreferredLanguage,
|
||||
Status: "active",
|
||||
},
|
||||
msgInsert.BodyLang,
|
||||
s.nowUTC(),
|
||||
)
|
||||
|
||||
msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert})
|
||||
if err != nil {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err)
|
||||
}
|
||||
if len(recipients) != 1 {
|
||||
return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients))
|
||||
}
|
||||
|
||||
if recipients[0].AvailableAt != nil {
|
||||
s.publishMessageReceived(ctx, msg, recipients[0])
|
||||
}
|
||||
return msg, recipients[0], nil
|
||||
}
|
||||
|
||||
// resolveActiveRecipient turns a (user_id, race_name) pair into the
|
||||
// canonical user id of an active member of gameID. Exactly one of the
|
||||
// two inputs must be set; both-set or both-empty returns
|
||||
// ErrInvalidInput. Race-name resolution is restricted to the active
|
||||
// scope so lobby-removed and blocked members cannot be reached
|
||||
// through the race-name shortcut. ErrInvalidInput is also returned
|
||||
// when the race name matches zero members; ErrForbidden when the
|
||||
// race name matches more than one active row (defence in depth — race
|
||||
// names are unique within a game by lobby invariant).
|
||||
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
|
||||
byRaceName = strings.TrimSpace(byRaceName)
|
||||
hasUser := byUserID != uuid.Nil
|
||||
hasRace := byRaceName != ""
|
||||
switch {
|
||||
case hasUser && hasRace:
|
||||
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
|
||||
case !hasUser && !hasRace:
|
||||
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
|
||||
case hasUser:
|
||||
return byUserID, nil
|
||||
}
|
||||
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
|
||||
}
|
||||
var found []MemberSnapshot
|
||||
for _, m := range members {
|
||||
if m.RaceName == byRaceName {
|
||||
found = append(found, m)
|
||||
}
|
||||
}
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
|
||||
case 1:
|
||||
return found[0].UserID, nil
|
||||
default:
|
||||
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
|
||||
}
|
||||
}
|
||||
|
||||
// GetMessage returns the InboxEntry for messageID addressed to
|
||||
// userID. ErrNotFound is returned when the caller is not a recipient
|
||||
// of the message — handlers translate that to 404 so the existence
|
||||
// of the message is not leaked. The same sentinel is returned when
|
||||
// the caller is no longer an active member of the game and the
|
||||
// message is personal-kind: post-kick visibility is restricted to
|
||||
// admin/system mail (item 8 of the spec).
|
||||
//
|
||||
// When `targetLang` is non-empty and differs from the message's
|
||||
// `body_lang`, the function consults the translation cache; on a
|
||||
// miss it asks the configured Translator to produce a rendering and
|
||||
// persists the result. The noop translator returns the input
|
||||
// unchanged with `engine == "noop"`, which is treated as
|
||||
// "translation unavailable" — the entry comes back with `Translation
|
||||
// == nil` and the caller renders the original body.
|
||||
func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) {
|
||||
entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID)
|
||||
if err != nil {
|
||||
return InboxEntry{}, err
|
||||
}
|
||||
allowed, err := s.allowedKinds(ctx, entry.GameID, userID)
|
||||
if err != nil {
|
||||
return InboxEntry{}, err
|
||||
}
|
||||
if !allowed[entry.Kind] {
|
||||
return InboxEntry{}, ErrNotFound
|
||||
}
|
||||
if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil {
|
||||
entry.Translation = tr
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// resolveTranslation returns the cached translation for
|
||||
// (message, targetLang), lazily computing and persisting one on
|
||||
// cache miss. Returns nil when no translation is needed (target is
|
||||
// empty, matches `body_lang`, or the message body is itself
|
||||
// undetermined) or when the configured translator declares the
|
||||
// rendering unavailable.
|
||||
func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||
return nil
|
||||
}
|
||||
if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil {
|
||||
t := existing
|
||||
return &t
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
s.deps.Logger.Warn("load translation failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if s.deps.Translator == nil {
|
||||
return nil
|
||||
}
|
||||
result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("translator call failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if result.Engine == "" || result.Engine == "noop" {
|
||||
return nil
|
||||
}
|
||||
tr := Translation{
|
||||
TranslationID: uuid.New(),
|
||||
MessageID: msg.MessageID,
|
||||
TargetLang: targetLang,
|
||||
TranslatedSubject: result.Subject,
|
||||
TranslatedBody: result.Body,
|
||||
Translator: result.Engine,
|
||||
}
|
||||
stored, err := s.deps.Store.InsertTranslation(ctx, tr)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("insert translation failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("target_lang", targetLang),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return &stored
|
||||
}
|
||||
|
||||
// ListInbox returns every non-deleted message addressed to userID in
|
||||
// gameID, newest first. Read state is preserved per entry; the HTTP
|
||||
// layer renders both the message and the recipient row. Personal
|
||||
// messages are filtered out when the caller is no longer an active
|
||||
// member of the game so a kicked player keeps read access to the
|
||||
// admin/system explanation of the kick but not to historical
|
||||
// player-to-player threads.
|
||||
//
|
||||
// When `targetLang` is non-empty and differs from a row's body
|
||||
// language, the function consults the translation cache (without
|
||||
// re-translating on miss; the per-message read endpoint owns that
|
||||
// path so the bulk listing never blocks on translator I/O).
|
||||
func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) {
|
||||
entries, err := s.deps.Store.ListInbox(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed, err := s.allowedKinds(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := entries
|
||||
if !(allowed[KindPersonal] && allowed[KindAdmin]) {
|
||||
out = make([]InboxEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if allowed[e.Kind] {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetLang == "" {
|
||||
return out, nil
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookupCachedTranslation reads an existing translation row without
|
||||
// asking the Translator to compute one. The bulk inbox listing uses
|
||||
// this to avoid per-row translator I/O; GetMessage uses the full
|
||||
// `resolveTranslation` helper which falls through to the translator
|
||||
// on cache miss.
|
||||
func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation {
|
||||
if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
s.deps.Logger.Debug("inbox translation lookup failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := existing
|
||||
return &out
|
||||
}
|
||||
|
||||
// allowedKinds resolves the set of message kinds the caller may read
|
||||
// in gameID. An active member can read everything; a former member
|
||||
// (status removed or blocked) can read admin-kind only. A user who
|
||||
// has never been a member of the game but is still listed as a
|
||||
// recipient (legacy / system message) is granted the same admin-only
|
||||
// view. The function never returns an empty set: even non-members
|
||||
// keep their read access to admin mail.
|
||||
func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) {
|
||||
if s.deps.Memberships == nil {
|
||||
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||
}
|
||||
if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil {
|
||||
return map[string]bool{KindPersonal: true, KindAdmin: true}, nil
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]bool{KindAdmin: true}, nil
|
||||
}
|
||||
|
||||
// ListSent returns the sender-side view of personal messages
|
||||
// authored by senderUserID in gameID, newest first. Each entry pairs
|
||||
// the message with one of its recipient rows; single sends contribute
|
||||
// one entry per message, broadcasts contribute one entry per
|
||||
// addressee. Admin and system rows have no `sender_user_id` and are
|
||||
// therefore excluded; the user surface does not need them.
|
||||
func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
||||
return s.deps.Store.ListSent(ctx, gameID, senderUserID)
|
||||
}
|
||||
|
||||
// MarkRead transitions a recipient row to `read`. Idempotent: a
|
||||
// second call on an already-read row is a no-op. Returns the
|
||||
// resulting Recipient. ErrNotFound is surfaced when the caller is
|
||||
// not a recipient of the message.
|
||||
func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
||||
return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC())
|
||||
}
|
||||
|
||||
// DeleteMessage soft-deletes the recipient row identified by
|
||||
// (messageID, userID). The row must already have `read_at` set, or
|
||||
// the call returns ErrConflict (item 10 of the spec: open-then-delete).
|
||||
// Returns ErrNotFound when the caller is not a recipient.
|
||||
func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) {
|
||||
return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC())
|
||||
}
|
||||
|
||||
// UnreadCountsForUser returns the lobby badge breakdown.
|
||||
func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
|
||||
return s.deps.Store.UnreadCountsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// validateContent enforces the body/subject byte limits and rejects
|
||||
// non-UTF-8 input. Stage A applies the rules to plain text only; HTML
|
||||
// is treated as plain text by the server (the UI renders via
|
||||
// textContent) and gets no special handling.
|
||||
func (s *Service) validateContent(subject, body string) error {
|
||||
if body == "" {
|
||||
return fmt.Errorf("%w: body must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if !utf8.ValidString(body) {
|
||||
return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput)
|
||||
}
|
||||
if len(body) > s.deps.Config.MaxBodyBytes {
|
||||
return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes)
|
||||
}
|
||||
if subject != "" {
|
||||
if !utf8.ValidString(subject) {
|
||||
return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput)
|
||||
}
|
||||
if len(subject) > s.deps.Config.MaxSubjectBytes {
|
||||
return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishMessageReceived emits the per-recipient push notification.
|
||||
// Failures are logged at debug level: notifications are best-effort
|
||||
// over the gRPC stream, and clients always have the unread-counts
|
||||
// endpoint as the durable fallback.
|
||||
func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) {
|
||||
unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("compute unread count for push payload failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
unreadGame = 0
|
||||
}
|
||||
unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID)
|
||||
if err != nil {
|
||||
s.deps.Logger.Warn("compute unread totals for push payload failed",
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
unreadTotals = nil
|
||||
}
|
||||
unreadTotal := 0
|
||||
for _, u := range unreadTotals {
|
||||
unreadTotal += u.Unread
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message_id": msg.MessageID.String(),
|
||||
"game_id": msg.GameID.String(),
|
||||
"kind": msg.Kind,
|
||||
"sender_kind": msg.SenderKind,
|
||||
"subject": msg.Subject,
|
||||
"preview": preview(msg.Body, previewMaxRunes),
|
||||
"preview_lang": msg.BodyLang,
|
||||
"unread_total": unreadTotal,
|
||||
"unread_game": unreadGame,
|
||||
}
|
||||
ev := DiplomailNotification{
|
||||
Kind: "diplomail.message.received",
|
||||
IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(),
|
||||
Recipient: recipient.UserID,
|
||||
Payload: payload,
|
||||
}
|
||||
if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish diplomail event failed",
|
||||
zap.String("message_id", msg.MessageID.String()),
|
||||
zap.String("recipient", recipient.UserID.String()),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// preview truncates s to at most max runes and appends a horizontal
|
||||
// ellipsis when truncation actually happened. The function operates
|
||||
// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic,
|
||||
// emoji) survive without corruption.
|
||||
func preview(s string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == max {
|
||||
return s[:i] + "…"
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for the diplomail
|
||||
// package. All queries are built through go-jet against the generated
|
||||
// table bindings under `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// messageColumns is the canonical projection for diplomail_messages
|
||||
// reads.
|
||||
func messageColumns() postgres.ColumnList {
|
||||
m := table.DiplomailMessages
|
||||
return postgres.ColumnList{
|
||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// recipientColumns is the canonical projection for
|
||||
// diplomail_recipients reads.
|
||||
func recipientColumns() postgres.ColumnList {
|
||||
r := table.DiplomailRecipients
|
||||
return postgres.ColumnList{
|
||||
r.RecipientID, r.MessageID, r.GameID, r.UserID,
|
||||
r.RecipientUserName, r.RecipientRaceName, r.RecipientPreferredLanguage,
|
||||
r.AvailableAt, r.TranslationAttempts, r.NextTranslationAttemptAt,
|
||||
r.DeliveredAt, r.ReadAt, r.DeletedAt, r.NotifiedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// MessageInsert carries the immutable per-message fields. The store
|
||||
// fills MessageID, sets CreatedAt to `now()` via the column default,
|
||||
// and leaves recipient-side state to InsertRecipient.
|
||||
type MessageInsert struct {
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Kind string
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderRaceName *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
BodyLang string
|
||||
BroadcastScope string
|
||||
}
|
||||
|
||||
// RecipientInsert carries the per-recipient snapshot. AvailableAt
|
||||
// captures the async-delivery contract: when non-nil, the recipient
|
||||
// row is materialised already-delivered (no translation needed or
|
||||
// the language matches); when nil, the recipient is queued for the
|
||||
// translation worker.
|
||||
type RecipientInsert struct {
|
||||
RecipientID uuid.UUID
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
RecipientUserName string
|
||||
RecipientRaceName *string
|
||||
RecipientPreferredLanguage string
|
||||
AvailableAt *time.Time
|
||||
}
|
||||
|
||||
// InsertMessageWithRecipients persists a Message together with one or
|
||||
// more Recipient rows inside a single transaction. The function is
|
||||
// the canonical write path for every send variant: Stage A passes a
|
||||
// single-element slice; later stages reuse the same path for
|
||||
// broadcasts.
|
||||
func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInsert, recipients []RecipientInsert) (Message, []Recipient, error) {
|
||||
if len(recipients) == 0 {
|
||||
return Message{}, nil, errors.New("diplomail store: at least one recipient required")
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail store: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
m := table.DiplomailMessages
|
||||
msgStmt := m.INSERT(
|
||||
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
|
||||
m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
|
||||
m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
|
||||
).VALUES(
|
||||
msg.MessageID,
|
||||
msg.GameID,
|
||||
msg.GameName,
|
||||
msg.Kind,
|
||||
msg.SenderKind,
|
||||
uuidPtrArg(msg.SenderUserID),
|
||||
stringPtrArg(msg.SenderUsername),
|
||||
stringPtrArg(msg.SenderRaceName),
|
||||
msg.SenderIP,
|
||||
msg.Subject,
|
||||
msg.Body,
|
||||
msg.BodyLang,
|
||||
msg.BroadcastScope,
|
||||
).RETURNING(messageColumns())
|
||||
|
||||
var msgRow model.DiplomailMessages
|
||||
if err := msgStmt.QueryContext(ctx, tx, &msgRow); err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail store: insert message: %w", err)
|
||||
}
|
||||
|
||||
r := table.DiplomailRecipients
|
||||
rcptStmt := r.INSERT(
|
||||
r.RecipientID, r.MessageID, r.GameID, r.UserID,
|
||||
r.RecipientUserName, r.RecipientRaceName,
|
||||
r.RecipientPreferredLanguage, r.AvailableAt,
|
||||
)
|
||||
for _, in := range recipients {
|
||||
rcptStmt = rcptStmt.VALUES(
|
||||
in.RecipientID,
|
||||
in.MessageID,
|
||||
in.GameID,
|
||||
in.UserID,
|
||||
in.RecipientUserName,
|
||||
stringPtrArg(in.RecipientRaceName),
|
||||
in.RecipientPreferredLanguage,
|
||||
timePtrArg(in.AvailableAt),
|
||||
)
|
||||
}
|
||||
rcptStmt = rcptStmt.RETURNING(recipientColumns())
|
||||
|
||||
var rcptRows []model.DiplomailRecipients
|
||||
if err := rcptStmt.QueryContext(ctx, tx, &rcptRows); err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail store: insert recipients: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Message{}, nil, fmt.Errorf("diplomail store: commit: %w", err)
|
||||
}
|
||||
|
||||
return messageFromModel(msgRow), recipientsFromModel(rcptRows), nil
|
||||
}
|
||||
|
||||
// LoadMessage returns the Message row identified by messageID. The
|
||||
// function is used by readers that already verified recipient
|
||||
// authorisation; callers that need both the message and the
|
||||
// recipient's per-user state should use LoadInboxEntry.
|
||||
func (s *Store) LoadMessage(ctx context.Context, messageID uuid.UUID) (Message, error) {
|
||||
m := table.DiplomailMessages
|
||||
stmt := postgres.SELECT(messageColumns()).
|
||||
FROM(m).
|
||||
WHERE(m.MessageID.EQ(postgres.UUID(messageID))).
|
||||
LIMIT(1)
|
||||
var row model.DiplomailMessages
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Message{}, ErrNotFound
|
||||
}
|
||||
return Message{}, fmt.Errorf("diplomail store: load message %s: %w", messageID, err)
|
||||
}
|
||||
return messageFromModel(row), nil
|
||||
}
|
||||
|
||||
// LoadInboxEntry returns a Message together with the caller's
|
||||
// Recipient row, both for messageID. Returns ErrNotFound when the
|
||||
// caller is not a recipient of the message — this is also how the
|
||||
// service layer enforces "only recipients may read".
|
||||
func (s *Store) LoadInboxEntry(ctx context.Context, messageID, userID uuid.UUID) (InboxEntry, error) {
|
||||
m := table.DiplomailMessages
|
||||
r := table.DiplomailRecipients
|
||||
cols := append(messageColumns(), recipientColumns()...)
|
||||
stmt := postgres.SELECT(cols).
|
||||
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))),
|
||||
).
|
||||
LIMIT(1)
|
||||
var dest struct {
|
||||
model.DiplomailMessages
|
||||
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return InboxEntry{}, ErrNotFound
|
||||
}
|
||||
return InboxEntry{}, fmt.Errorf("diplomail store: load inbox entry %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
return InboxEntry{
|
||||
Message: messageFromModel(dest.DiplomailMessages),
|
||||
Recipient: recipientFromModel(dest.Recipient),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListInbox returns the recipient view of messages addressed to
|
||||
// userID in gameID, newest first. Soft-deleted rows
|
||||
// (`deleted_at IS NOT NULL`) are excluded. Rows still waiting for
|
||||
// the async translation worker (`available_at IS NULL`) are also
|
||||
// excluded — they will appear once delivery is complete.
|
||||
func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) {
|
||||
m := table.DiplomailMessages
|
||||
r := table.DiplomailRecipients
|
||||
cols := append(messageColumns(), recipientColumns()...)
|
||||
stmt := postgres.SELECT(cols).
|
||||
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
|
||||
WHERE(
|
||||
r.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(r.GameID.EQ(postgres.UUID(gameID))).
|
||||
AND(r.DeletedAt.IS_NULL()).
|
||||
AND(r.AvailableAt.IS_NOT_NULL()),
|
||||
).
|
||||
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC())
|
||||
var dest []struct {
|
||||
model.DiplomailMessages
|
||||
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: list inbox %s/%s: %w", gameID, userID, err)
|
||||
}
|
||||
out := make([]InboxEntry, 0, len(dest))
|
||||
for _, row := range dest {
|
||||
out = append(out, InboxEntry{
|
||||
Message: messageFromModel(row.DiplomailMessages),
|
||||
Recipient: recipientFromModel(row.Recipient),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListSent returns the sender-side view of personal messages
|
||||
// authored by senderUserID in gameID, newest first. Each
|
||||
// `InboxEntry` carries the message together with one of its
|
||||
// recipient rows — single sends produce one entry per message;
|
||||
// game broadcasts produce one entry per addressee (the in-game
|
||||
// mail UI collapses broadcast entries into a single stand-alone
|
||||
// item by `message_id`). Admin / system rows have
|
||||
// `sender_user_id IS NULL` and are excluded by the WHERE clause.
|
||||
func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) {
|
||||
m := table.DiplomailMessages
|
||||
r := table.DiplomailRecipients
|
||||
cols := append(messageColumns(), recipientColumns()...)
|
||||
stmt := postgres.SELECT(cols).
|
||||
FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))).
|
||||
WHERE(
|
||||
m.GameID.EQ(postgres.UUID(gameID)).
|
||||
AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))),
|
||||
).
|
||||
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC())
|
||||
var dest []struct {
|
||||
model.DiplomailMessages
|
||||
Recipient model.DiplomailRecipients `alias:"diplomail_recipients"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err)
|
||||
}
|
||||
out := make([]InboxEntry, 0, len(dest))
|
||||
for _, row := range dest {
|
||||
out = append(out, InboxEntry{
|
||||
Message: messageFromModel(row.DiplomailMessages),
|
||||
Recipient: recipientFromModel(row.Recipient),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkRead sets `read_at = at` on the recipient row identified by
|
||||
// (messageID, userID). Idempotent: a row that is already marked read
|
||||
// is left untouched but the existing Recipient is returned.
|
||||
// Returns ErrNotFound when the user is not a recipient of the message.
|
||||
func (s *Store) MarkRead(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := r.UPDATE(r.ReadAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))).
|
||||
AND(r.ReadAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(recipientColumns())
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, fmt.Errorf("diplomail store: mark read %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
// The row exists but read_at was already set, or the row
|
||||
// does not exist at all. Fetch to disambiguate.
|
||||
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
|
||||
if loadErr != nil {
|
||||
return Recipient{}, loadErr
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// SoftDelete sets `deleted_at = at` on the recipient row identified by
|
||||
// (messageID, userID). The row must already have `read_at` set;
|
||||
// otherwise the call returns ErrConflict so a hostile client cannot
|
||||
// erase a message before opening it (item 10 of the spec).
|
||||
// Returns ErrNotFound when the user is not a recipient.
|
||||
func (s *Store) SoftDelete(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := r.UPDATE(r.DeletedAt).
|
||||
SET(postgres.TimestampzT(at.UTC())).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))).
|
||||
AND(r.ReadAt.IS_NOT_NULL()).
|
||||
AND(r.DeletedAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(recipientColumns())
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if !errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, fmt.Errorf("diplomail store: soft delete %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
existing, loadErr := s.LoadRecipient(ctx, messageID, userID)
|
||||
if loadErr != nil {
|
||||
return Recipient{}, loadErr
|
||||
}
|
||||
if existing.ReadAt == nil {
|
||||
return Recipient{}, fmt.Errorf("%w: message must be read before delete", ErrConflict)
|
||||
}
|
||||
// Already deleted: return the existing row idempotently.
|
||||
return existing, nil
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// LoadRecipient fetches the Recipient row keyed on (messageID, userID).
|
||||
// Returns ErrNotFound when no such recipient exists.
|
||||
func (s *Store) LoadRecipient(ctx context.Context, messageID, userID uuid.UUID) (Recipient, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := postgres.SELECT(recipientColumns()).
|
||||
FROM(r).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.UserID.EQ(postgres.UUID(userID))),
|
||||
).
|
||||
LIMIT(1)
|
||||
var row model.DiplomailRecipients
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Recipient{}, ErrNotFound
|
||||
}
|
||||
return Recipient{}, fmt.Errorf("diplomail store: load recipient %s/%s: %w", messageID, userID, err)
|
||||
}
|
||||
return recipientFromModel(row), nil
|
||||
}
|
||||
|
||||
// UnreadCountForUserGame returns the count of unread, non-deleted,
|
||||
// delivered messages addressed to userID in gameID. Recipients
|
||||
// still waiting for translation (`available_at IS NULL`) are
|
||||
// excluded so the badge does not flicker.
|
||||
func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.UUID) (int, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
|
||||
FROM(r).
|
||||
WHERE(
|
||||
r.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(r.GameID.EQ(postgres.UUID(gameID))).
|
||||
AND(r.ReadAt.IS_NULL()).
|
||||
AND(r.DeletedAt.IS_NULL()).
|
||||
AND(r.AvailableAt.IS_NOT_NULL()),
|
||||
)
|
||||
var dest struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("diplomail store: unread count %s/%s: %w", gameID, userID, err)
|
||||
}
|
||||
return int(dest.Count), nil
|
||||
}
|
||||
|
||||
// PendingTranslationPair carries one unit of work picked by the
|
||||
// translation worker. Multiple recipients of the same message that
|
||||
// share a preferred_language collapse into one pair, because the
|
||||
// translation is shared via the diplomail_translations cache.
|
||||
// CurrentAttempts is the highest `translation_attempts` value across
|
||||
// the matching recipient rows, so the worker can decide whether the
|
||||
// next attempt is the last one before falling back.
|
||||
type PendingTranslationPair struct {
|
||||
MessageID uuid.UUID
|
||||
TargetLang string
|
||||
CurrentAttempts int32
|
||||
}
|
||||
|
||||
// PickPendingTranslationPair returns one pair eligible for the
|
||||
// translation worker, or `ok == false` when the queue is empty. The
|
||||
// pair is the (message, target_lang) of any recipient where
|
||||
// `available_at IS NULL` and `next_translation_attempt_at` is either
|
||||
// unset or already due. The query intentionally drops the
|
||||
// `FOR UPDATE` clause — the worker is single-threaded per process,
|
||||
// and the optimistic UPDATE in `MarkPairDelivered` /
|
||||
// `MarkPairFallback` filters by `available_at IS NULL`, so a stale
|
||||
// pickup never delivers twice.
|
||||
func (s *Store) PickPendingTranslationPair(ctx context.Context, now time.Time) (PendingTranslationPair, bool, error) {
|
||||
r := table.DiplomailRecipients
|
||||
stmt := postgres.SELECT(
|
||||
r.MessageID.AS("message_id"),
|
||||
r.RecipientPreferredLanguage.AS("target_lang"),
|
||||
postgres.MAX(r.TranslationAttempts).AS("attempts"),
|
||||
).
|
||||
FROM(r).
|
||||
WHERE(
|
||||
r.AvailableAt.IS_NULL().
|
||||
AND(r.RecipientPreferredLanguage.NOT_EQ(postgres.String(""))).
|
||||
AND(r.NextTranslationAttemptAt.IS_NULL().
|
||||
OR(r.NextTranslationAttemptAt.LT_EQ(postgres.TimestampzT(now.UTC())))),
|
||||
).
|
||||
GROUP_BY(r.MessageID, r.RecipientPreferredLanguage).
|
||||
ORDER_BY(r.MessageID.ASC(), r.RecipientPreferredLanguage.ASC()).
|
||||
LIMIT(1)
|
||||
var dest struct {
|
||||
MessageID uuid.UUID `alias:"message_id"`
|
||||
TargetLang string `alias:"target_lang"`
|
||||
Attempts int32 `alias:"attempts"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return PendingTranslationPair{}, false, nil
|
||||
}
|
||||
return PendingTranslationPair{}, false, fmt.Errorf("diplomail store: pick pending pair: %w", err)
|
||||
}
|
||||
if dest.MessageID == (uuid.UUID{}) {
|
||||
return PendingTranslationPair{}, false, nil
|
||||
}
|
||||
return PendingTranslationPair{
|
||||
MessageID: dest.MessageID,
|
||||
TargetLang: dest.TargetLang,
|
||||
CurrentAttempts: dest.Attempts,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// MarkPairDelivered flips every still-pending recipient of (messageID,
|
||||
// targetLang) to `available_at = at`, optionally persisting the
|
||||
// translation row alongside in the same transaction. Returns the
|
||||
// recipients that were just delivered (used by the worker to fan out
|
||||
// push events).
|
||||
func (s *Store) MarkPairDelivered(ctx context.Context, messageID uuid.UUID, targetLang string, translation *Translation, at time.Time) ([]Recipient, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: begin deliver tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if translation != nil {
|
||||
t := table.DiplomailTranslations
|
||||
ins := t.INSERT(
|
||||
t.TranslationID, t.MessageID, t.TargetLang,
|
||||
t.TranslatedSubject, t.TranslatedBody, t.Translator,
|
||||
).VALUES(
|
||||
translation.TranslationID, translation.MessageID, translation.TargetLang,
|
||||
translation.TranslatedSubject, translation.TranslatedBody, translation.Translator,
|
||||
).ON_CONFLICT(t.MessageID, t.TargetLang).DO_NOTHING()
|
||||
if _, err := ins.ExecContext(ctx, tx); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: upsert translation: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
r := table.DiplomailRecipients
|
||||
upd := r.UPDATE(r.AvailableAt, r.NextTranslationAttemptAt).
|
||||
SET(postgres.TimestampzT(at.UTC()), postgres.NULL).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
|
||||
AND(r.AvailableAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(recipientColumns())
|
||||
|
||||
var rows []model.DiplomailRecipients
|
||||
if err := upd.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: mark pair delivered: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: commit deliver: %w", err)
|
||||
}
|
||||
out := make([]Recipient, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, recipientFromModel(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SchedulePairRetry bumps the attempt counter and schedules the next
|
||||
// translation attempt for `next`. The recipient rows stay in the
|
||||
// pending queue (`available_at IS NULL`). Returns the new attempt
|
||||
// counter so the worker can decide whether to fall back to the
|
||||
// original on the next pickup.
|
||||
func (s *Store) SchedulePairRetry(ctx context.Context, messageID uuid.UUID, targetLang string, next time.Time) (int32, error) {
|
||||
r := table.DiplomailRecipients
|
||||
upd := r.UPDATE(r.TranslationAttempts, r.NextTranslationAttemptAt).
|
||||
SET(r.TranslationAttempts.ADD(postgres.Int(1)), postgres.TimestampzT(next.UTC())).
|
||||
WHERE(
|
||||
r.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))).
|
||||
AND(r.AvailableAt.IS_NULL()),
|
||||
).
|
||||
RETURNING(r.TranslationAttempts)
|
||||
var dest []struct {
|
||||
TranslationAttempts int32 `alias:"diplomail_recipients.translation_attempts"`
|
||||
}
|
||||
if err := upd.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return 0, fmt.Errorf("diplomail store: schedule pair retry: %w", err)
|
||||
}
|
||||
if len(dest) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
max := dest[0].TranslationAttempts
|
||||
for _, d := range dest[1:] {
|
||||
if d.TranslationAttempts > max {
|
||||
max = d.TranslationAttempts
|
||||
}
|
||||
}
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// translationColumns is the canonical projection for
|
||||
// diplomail_translations reads.
|
||||
func translationColumns() postgres.ColumnList {
|
||||
t := table.DiplomailTranslations
|
||||
return postgres.ColumnList{
|
||||
t.TranslationID, t.MessageID, t.TargetLang,
|
||||
t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadTranslation returns the cached translation row for
|
||||
// (messageID, targetLang). Returns ErrNotFound when no cache row
|
||||
// exists yet — the caller decides whether to compute and persist
|
||||
// one.
|
||||
func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) {
|
||||
t := table.DiplomailTranslations
|
||||
stmt := postgres.SELECT(translationColumns()).
|
||||
FROM(t).
|
||||
WHERE(t.MessageID.EQ(postgres.UUID(messageID)).
|
||||
AND(t.TargetLang.EQ(postgres.String(targetLang)))).
|
||||
LIMIT(1)
|
||||
var row model.DiplomailTranslations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Translation{}, ErrNotFound
|
||||
}
|
||||
return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err)
|
||||
}
|
||||
return translationFromModel(row), nil
|
||||
}
|
||||
|
||||
// InsertTranslation persists a new translation cache row. The unique
|
||||
// constraint on (message_id, target_lang) prevents duplicate
|
||||
// renderings. Callers that race on the same (message, lang) pair
|
||||
// should be prepared for a UNIQUE violation; the second writer can
|
||||
// fall back to LoadTranslation.
|
||||
func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) {
|
||||
t := table.DiplomailTranslations
|
||||
stmt := t.INSERT(
|
||||
t.TranslationID, t.MessageID, t.TargetLang,
|
||||
t.TranslatedSubject, t.TranslatedBody, t.Translator,
|
||||
).VALUES(
|
||||
in.TranslationID, in.MessageID, in.TargetLang,
|
||||
in.TranslatedSubject, in.TranslatedBody, in.Translator,
|
||||
).RETURNING(translationColumns())
|
||||
|
||||
var row model.DiplomailTranslations
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err)
|
||||
}
|
||||
return translationFromModel(row), nil
|
||||
}
|
||||
|
||||
func translationFromModel(row model.DiplomailTranslations) Translation {
|
||||
return Translation{
|
||||
TranslationID: row.TranslationID,
|
||||
MessageID: row.MessageID,
|
||||
TargetLang: row.TargetLang,
|
||||
TranslatedSubject: row.TranslatedSubject,
|
||||
TranslatedBody: row.TranslatedBody,
|
||||
Translator: row.Translator,
|
||||
TranslatedAt: row.TranslatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteMessagesForGames removes every diplomail_messages row whose
|
||||
// game_id falls in the supplied set. The cascade defined on the
|
||||
// `diplomail_recipients` and `diplomail_translations` foreign keys
|
||||
// removes the per-recipient state and the cached translations in
|
||||
// the same transaction. Returns the count of messages removed.
|
||||
//
|
||||
// Used by the admin bulk-purge endpoint; callers are expected to
|
||||
// have already filtered the input set to terminal-state games.
|
||||
func (s *Store) DeleteMessagesForGames(ctx context.Context, gameIDs []uuid.UUID) (int, error) {
|
||||
if len(gameIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
args := make([]postgres.Expression, 0, len(gameIDs))
|
||||
for _, id := range gameIDs {
|
||||
args = append(args, postgres.UUID(id))
|
||||
}
|
||||
m := table.DiplomailMessages
|
||||
stmt := m.DELETE().WHERE(m.GameID.IN(args...))
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("diplomail store: bulk delete messages: %w", err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("diplomail store: rows affected: %w", err)
|
||||
}
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
// ListMessagesForAdmin returns a paginated slice of messages
|
||||
// matching filter. The result is ordered by created_at DESC,
|
||||
// message_id DESC. Total is the count without pagination so the
|
||||
// caller can render a "page X of N" envelope.
|
||||
func (s *Store) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) ([]Message, int, error) {
|
||||
m := table.DiplomailMessages
|
||||
page := filter.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := filter.PageSize
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
conditions := postgres.BoolExpression(nil)
|
||||
addCondition := func(cond postgres.BoolExpression) {
|
||||
if conditions == nil {
|
||||
conditions = cond
|
||||
return
|
||||
}
|
||||
conditions = conditions.AND(cond)
|
||||
}
|
||||
if filter.GameID != nil {
|
||||
addCondition(m.GameID.EQ(postgres.UUID(*filter.GameID)))
|
||||
}
|
||||
if filter.Kind != "" {
|
||||
addCondition(m.Kind.EQ(postgres.String(filter.Kind)))
|
||||
}
|
||||
if filter.SenderKind != "" {
|
||||
addCondition(m.SenderKind.EQ(postgres.String(filter.SenderKind)))
|
||||
}
|
||||
|
||||
countStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(m)
|
||||
if conditions != nil {
|
||||
countStmt = countStmt.WHERE(conditions)
|
||||
}
|
||||
var countDest struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := countStmt.QueryContext(ctx, s.db, &countDest); err != nil {
|
||||
return nil, 0, fmt.Errorf("diplomail store: count admin messages: %w", err)
|
||||
}
|
||||
|
||||
listStmt := postgres.SELECT(messageColumns()).FROM(m)
|
||||
if conditions != nil {
|
||||
listStmt = listStmt.WHERE(conditions)
|
||||
}
|
||||
listStmt = listStmt.
|
||||
ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()).
|
||||
LIMIT(int64(pageSize)).
|
||||
OFFSET(int64((page - 1) * pageSize))
|
||||
|
||||
var rows []model.DiplomailMessages
|
||||
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, 0, fmt.Errorf("diplomail store: list admin messages: %w", err)
|
||||
}
|
||||
out := make([]Message, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, messageFromModel(row))
|
||||
}
|
||||
return out, int(countDest.Count), nil
|
||||
}
|
||||
|
||||
// UnreadCountsForUser returns a per-game breakdown of unread messages
|
||||
// addressed to userID, plus the matching game names so the lobby
|
||||
// badge UI can render entries even after the recipient's membership
|
||||
// has been revoked. The slice is ordered by game name.
|
||||
func (s *Store) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) {
|
||||
r := table.DiplomailRecipients
|
||||
m := table.DiplomailMessages
|
||||
stmt := postgres.SELECT(
|
||||
r.GameID.AS("game_id"),
|
||||
postgres.MAX(m.GameName).AS("game_name"),
|
||||
postgres.COUNT(postgres.STAR).AS("count"),
|
||||
).
|
||||
FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))).
|
||||
WHERE(
|
||||
r.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(r.ReadAt.IS_NULL()).
|
||||
AND(r.DeletedAt.IS_NULL()).
|
||||
AND(r.AvailableAt.IS_NOT_NULL()),
|
||||
).
|
||||
GROUP_BY(r.GameID).
|
||||
ORDER_BY(postgres.MAX(m.GameName).ASC())
|
||||
var dest []struct {
|
||||
GameID uuid.UUID `alias:"game_id"`
|
||||
GameName string `alias:"game_name"`
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
|
||||
return nil, fmt.Errorf("diplomail store: unread counts %s: %w", userID, err)
|
||||
}
|
||||
out := make([]UnreadCount, 0, len(dest))
|
||||
for _, row := range dest {
|
||||
out = append(out, UnreadCount{
|
||||
GameID: row.GameID,
|
||||
GameName: row.GameName,
|
||||
Unread: int(row.Count),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// messageFromModel converts a jet-generated row to the domain type.
|
||||
func messageFromModel(row model.DiplomailMessages) Message {
|
||||
out := Message{
|
||||
MessageID: row.MessageID,
|
||||
GameID: row.GameID,
|
||||
GameName: row.GameName,
|
||||
Kind: row.Kind,
|
||||
SenderKind: row.SenderKind,
|
||||
SenderIP: row.SenderIP,
|
||||
Subject: row.Subject,
|
||||
Body: row.Body,
|
||||
BodyLang: row.BodyLang,
|
||||
BroadcastScope: row.BroadcastScope,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
if row.SenderUserID != nil {
|
||||
id := *row.SenderUserID
|
||||
out.SenderUserID = &id
|
||||
}
|
||||
if row.SenderUsername != nil {
|
||||
name := *row.SenderUsername
|
||||
out.SenderUsername = &name
|
||||
}
|
||||
if row.SenderRaceName != nil {
|
||||
name := *row.SenderRaceName
|
||||
out.SenderRaceName = &name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recipientFromModel converts a jet-generated row to the domain type.
|
||||
func recipientFromModel(row model.DiplomailRecipients) Recipient {
|
||||
out := Recipient{
|
||||
RecipientID: row.RecipientID,
|
||||
MessageID: row.MessageID,
|
||||
GameID: row.GameID,
|
||||
UserID: row.UserID,
|
||||
RecipientUserName: row.RecipientUserName,
|
||||
RecipientPreferredLanguage: row.RecipientPreferredLanguage,
|
||||
AvailableAt: row.AvailableAt,
|
||||
TranslationAttempts: row.TranslationAttempts,
|
||||
NextTranslationAttemptAt: row.NextTranslationAttemptAt,
|
||||
DeliveredAt: row.DeliveredAt,
|
||||
ReadAt: row.ReadAt,
|
||||
DeletedAt: row.DeletedAt,
|
||||
NotifiedAt: row.NotifiedAt,
|
||||
}
|
||||
if row.RecipientRaceName != nil {
|
||||
name := *row.RecipientRaceName
|
||||
out.RecipientRaceName = &name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recipientsFromModel converts a slice in place. Used by
|
||||
// InsertMessageWithRecipients.
|
||||
func recipientsFromModel(rows []model.DiplomailRecipients) []Recipient {
|
||||
out := make([]Recipient, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, recipientFromModel(row))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uuidPtrArg returns the jet argument expression for a nullable UUID.
|
||||
// Pre-NULL handling here avoids a custom NULL literal at every call
|
||||
// site.
|
||||
func uuidPtrArg(v *uuid.UUID) postgres.Expression {
|
||||
if v == nil {
|
||||
return postgres.NULL
|
||||
}
|
||||
return postgres.UUID(*v)
|
||||
}
|
||||
|
||||
// stringPtrArg returns the jet argument expression for a nullable
|
||||
// text column.
|
||||
func stringPtrArg(v *string) postgres.Expression {
|
||||
if v == nil {
|
||||
return postgres.NULL
|
||||
}
|
||||
return postgres.String(*v)
|
||||
}
|
||||
|
||||
// timePtrArg returns the jet argument expression for a nullable
|
||||
// timestamptz column.
|
||||
func timePtrArg(v *time.Time) postgres.Expression {
|
||||
if v == nil {
|
||||
return postgres.NULL
|
||||
}
|
||||
return postgres.TimestampzT(v.UTC())
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package translator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibreTranslateEngine is the engine identifier persisted in
|
||||
// `diplomail_translations.translator` for cache rows produced by the
|
||||
// LibreTranslate client.
|
||||
const LibreTranslateEngine = "libretranslate"
|
||||
|
||||
// LibreTranslateConfig configures the HTTP client. URL is the base
|
||||
// of the deployed instance (without `/translate`). Timeout bounds a
|
||||
// single HTTP request; the worker layers retry / backoff on top.
|
||||
type LibreTranslateConfig struct {
|
||||
URL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ErrUnsupportedLanguagePair classifies a LibreTranslate 400 response
|
||||
// that indicates the engine cannot translate between the requested
|
||||
// source / target codes. The worker treats this as terminal: no
|
||||
// further retries, deliver the original.
|
||||
var ErrUnsupportedLanguagePair = errors.New("translator: language pair not supported by libretranslate")
|
||||
|
||||
// NewLibreTranslate constructs a Translator that posts to
|
||||
// `<URL>/translate`. Returns an error when URL is empty so wiring
|
||||
// catches "translator misconfigured" at startup rather than at
|
||||
// first-translation-attempt.
|
||||
func NewLibreTranslate(cfg LibreTranslateConfig) (Translator, error) {
|
||||
url := strings.TrimRight(strings.TrimSpace(cfg.URL), "/")
|
||||
if url == "" {
|
||||
return nil, errors.New("translator: libretranslate URL must be set")
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &libreTranslate{
|
||||
endpoint: url + "/translate",
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type libreTranslate struct {
|
||||
endpoint string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// requestBody is the LibreTranslate POST /translate input shape.
|
||||
// `q` is sent as a two-element array so the engine returns one
|
||||
// translation per element in the same call (subject + body).
|
||||
type requestBody struct {
|
||||
Q []string `json:"q"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// responseBody is the LibreTranslate output shape when `q` is an
|
||||
// array. The single-string-q variant is a different shape; we never
|
||||
// emit a single-q request so the client always sees the array form.
|
||||
type responseBody struct {
|
||||
TranslatedText []string `json:"translatedText"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Translate posts subject + body to LibreTranslate, normalising the
|
||||
// language codes and classifying the response. The 400 / unsupported-
|
||||
// pair path is signalled by `ErrUnsupportedLanguagePair`. All other
|
||||
// HTTP errors (timeout, 5xx, network failure) come back as wrapped
|
||||
// errors so the worker can backoff and retry.
|
||||
func (l *libreTranslate) Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) {
|
||||
src := normaliseLanguageCode(srcLang)
|
||||
dst := normaliseLanguageCode(dstLang)
|
||||
if src == "" || dst == "" {
|
||||
return Result{}, fmt.Errorf("translator: missing source or target language (src=%q dst=%q)", srcLang, dstLang)
|
||||
}
|
||||
if src == dst {
|
||||
return Result{Subject: subject, Body: body, Engine: NoopEngine}, nil
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(requestBody{
|
||||
Q: []string{subject, body},
|
||||
Source: src,
|
||||
Target: dst,
|
||||
Format: "text",
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("translator: marshal request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("translator: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := l.client.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("translator: do request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("translator: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
return Result{}, fmt.Errorf("%w: %s", ErrUnsupportedLanguagePair, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return Result{}, fmt.Errorf("translator: libretranslate http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var out responseBody
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Result{}, fmt.Errorf("translator: unmarshal response: %w", err)
|
||||
}
|
||||
if out.Error != "" {
|
||||
return Result{}, fmt.Errorf("translator: libretranslate error: %s", out.Error)
|
||||
}
|
||||
if len(out.TranslatedText) != 2 {
|
||||
return Result{}, fmt.Errorf("translator: libretranslate returned %d strings, want 2", len(out.TranslatedText))
|
||||
}
|
||||
return Result{
|
||||
Subject: out.TranslatedText[0],
|
||||
Body: out.TranslatedText[1],
|
||||
Engine: LibreTranslateEngine,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normaliseLanguageCode collapses a BCP 47 tag to the ISO 639-1 base
|
||||
// that LibreTranslate expects (`en-US` → `en`, `EN` → `en`). The
|
||||
// helper is mirrored on the diplomail service side; both sides need
|
||||
// to use the same normalisation so cache keys line up.
|
||||
func normaliseLanguageCode(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(tag, "-_"); i > 0 {
|
||||
tag = tag[:i]
|
||||
}
|
||||
return strings.ToLower(tag)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package translator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLibreTranslateHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
requestSource string
|
||||
requestTarget string
|
||||
requestQ []string
|
||||
requestFormat string
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var in requestBody
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
t.Errorf("unmarshal: %v", err)
|
||||
}
|
||||
requestSource = in.Source
|
||||
requestTarget = in.Target
|
||||
requestQ = in.Q
|
||||
requestFormat = in.Format
|
||||
_ = json.NewEncoder(w).Encode(responseBody{
|
||||
TranslatedText: []string{"[ru] " + in.Q[0], "[ru] " + in.Q[1]},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tr, err := NewLibreTranslate(LibreTranslateConfig{URL: server.URL, Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
res, err := tr.Translate(context.Background(), "en", "ru", "Hello", "World")
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
if res.Engine != LibreTranslateEngine {
|
||||
t.Fatalf("engine = %q, want %q", res.Engine, LibreTranslateEngine)
|
||||
}
|
||||
if res.Subject != "[ru] Hello" || res.Body != "[ru] World" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if requestSource != "en" || requestTarget != "ru" || requestFormat != "text" {
|
||||
t.Fatalf("request fields: src=%q dst=%q fmt=%q", requestSource, requestTarget, requestFormat)
|
||||
}
|
||||
if len(requestQ) != 2 || requestQ[0] != "Hello" || requestQ[1] != "World" {
|
||||
t.Fatalf("request q = %v", requestQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreTranslateNormalisesLanguageCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
var src, dst string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var in requestBody
|
||||
_ = json.Unmarshal(body, &in)
|
||||
src, dst = in.Source, in.Target
|
||||
_ = json.NewEncoder(w).Encode(responseBody{TranslatedText: []string{"a", "b"}})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
if _, err := tr.Translate(context.Background(), "EN-US", "ru-RU", "x", "y"); err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
if src != "en" || dst != "ru" {
|
||||
t.Fatalf("normalised codes src=%q dst=%q, want en/ru", src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreTranslateUnsupportedPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"language not supported"}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
_, err := tr.Translate(context.Background(), "en", "xx", "subject", "body")
|
||||
if !errors.Is(err, ErrUnsupportedLanguagePair) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedLanguagePair", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreTranslateServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("kaboom"))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
_, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrUnsupportedLanguagePair) {
|
||||
t.Fatalf("err mis-classified as unsupported pair: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Fatalf("err = %v, want mention of 500", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreTranslateSameSourceAndTargetIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("translator should not call the server for identical src/dst: %s", r.URL.Path)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
res, err := tr.Translate(context.Background(), "en", "EN", "x", "y")
|
||||
if err != nil {
|
||||
t.Fatalf("translate: %v", err)
|
||||
}
|
||||
if res.Engine != NoopEngine {
|
||||
t.Fatalf("engine = %q, want %q", res.Engine, NoopEngine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreTranslateRequiresURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := NewLibreTranslate(LibreTranslateConfig{URL: ""})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for empty URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibreTranslateRejectsMalformedArray defends against a server
|
||||
// that returns a partial / unexpected `translatedText` payload. The
|
||||
// client must surface an error (not panic, not return a half-empty
|
||||
// Result) so the worker can decide between retry and fallback.
|
||||
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"single string", `{"translatedText": "only one"}`},
|
||||
{"array of one", `{"translatedText": ["only one"]}`},
|
||||
{"empty array", `{"translatedText": []}`},
|
||||
{"missing field", `{"foo":"bar"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
body := tc.body
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package translator wraps the per-language rendering for the
|
||||
// diplomail subsystem. The package exposes a narrow `Translator`
|
||||
// interface so the actual translation backend (LibreTranslate, an
|
||||
// in-process model, a SaaS engine, …) can be swapped without
|
||||
// touching the rest of the codebase.
|
||||
//
|
||||
// Stage D ships a `NoopTranslator` that returns the input unchanged.
|
||||
// The diplomail Service treats a `Name == NoopEngine` result as
|
||||
// "translation unavailable" and refrains from writing a cache row;
|
||||
// the inbox handler then returns the original body with a
|
||||
// `translated == false` payload. The contract lets the rest of the
|
||||
// system ship without a translation backend; future stages can wire
|
||||
// a real `Translator` without code changes elsewhere.
|
||||
package translator
|
||||
|
||||
import "context"
|
||||
|
||||
// NoopEngine is the engine identifier returned by `NoopTranslator`.
|
||||
// The diplomail Service checks for this value to decide whether to
|
||||
// persist a `diplomail_translations` row.
|
||||
const NoopEngine = "noop"
|
||||
|
||||
// Result carries one translated rendering plus the engine identifier
|
||||
// that produced it. The engine name is persisted as
|
||||
// `diplomail_translations.translator` so an operator can see which
|
||||
// backend produced each row.
|
||||
type Result struct {
|
||||
Subject string
|
||||
Body string
|
||||
Engine string
|
||||
}
|
||||
|
||||
// Translator is the read-only surface diplomail consumes when it
|
||||
// needs to render a message for a recipient whose
|
||||
// `preferred_language` differs from `body_lang`. Implementations
|
||||
// must be safe for concurrent use; `Translate` may be invoked from
|
||||
// the async worker on many messages at once.
|
||||
type Translator interface {
|
||||
// Translate renders `subject` and `body` from `srcLang` into
|
||||
// `dstLang`. A nil error with `Result.Engine == NoopEngine`
|
||||
// signals that no real rendering happened.
|
||||
Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error)
|
||||
}
|
||||
|
||||
// NewNoop returns a Translator that always returns the input
|
||||
// unchanged with engine name `NoopEngine`.
|
||||
func NewNoop() Translator {
|
||||
return noop{}
|
||||
}
|
||||
|
||||
type noop struct{}
|
||||
|
||||
func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) {
|
||||
return Result{
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Engine: NoopEngine,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Message mirrors a row in `backend.diplomail_messages` enriched with
|
||||
// the per-message metadata captured at insert time.
|
||||
//
|
||||
// SenderUserID and SenderUsername are nullable in the DB so that the
|
||||
// CHECK constraint can cover the three legal sender shapes:
|
||||
//
|
||||
// - player: SenderUserID set, SenderUsername set
|
||||
// - admin: SenderUserID nil, SenderUsername set
|
||||
// - system: SenderUserID nil, SenderUsername nil
|
||||
type Message struct {
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Kind string
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
// SenderRaceName carries the snapshot of the sender's race in the
|
||||
// game at send time. Non-nil for sender_kind='player' rows, nil
|
||||
// for admin and system. The in-game mail UI groups personal
|
||||
// threads by this name (Phase 28).
|
||||
SenderRaceName *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
BodyLang string
|
||||
BroadcastScope string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recipient mirrors a row in `backend.diplomail_recipients`. The
|
||||
// per-recipient state (read/deleted/delivered/notified) lives here.
|
||||
// RecipientUserName, RecipientRaceName, and
|
||||
// RecipientPreferredLanguage are snapshots taken at insert time so
|
||||
// the inbox listing, admin search, and translation worker render
|
||||
// correctly even after the source rows are renamed or revoked.
|
||||
//
|
||||
// AvailableAt encodes the async-translation contract introduced in
|
||||
// Stage E:
|
||||
//
|
||||
// - non-nil → message is visible to the recipient (in inbox /
|
||||
// unread counts / push events) starting from this timestamp;
|
||||
// - nil → recipient is waiting for the translation worker to fan
|
||||
// out the translated rendering. The translation_attempts counter
|
||||
// tracks the number of failed LibreTranslate calls; the worker
|
||||
// gives up after `MaxTranslationAttempts` and falls back to the
|
||||
// original body, flipping AvailableAt to now().
|
||||
type Recipient struct {
|
||||
RecipientID uuid.UUID
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
RecipientUserName string
|
||||
RecipientRaceName *string
|
||||
RecipientPreferredLanguage string
|
||||
AvailableAt *time.Time
|
||||
TranslationAttempts int32
|
||||
NextTranslationAttemptAt *time.Time
|
||||
DeliveredAt *time.Time
|
||||
ReadAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
NotifiedAt *time.Time
|
||||
}
|
||||
|
||||
// InboxEntry is the read-side projection composed of a Message and the
|
||||
// caller's own Recipient row. The HTTP layer renders one of these per
|
||||
// item in the inbox listing. Translation, when non-nil, carries the
|
||||
// per-recipient rendering returned from
|
||||
// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the
|
||||
// `body_translated` payload field; Stage D ships a noop translator,
|
||||
// so this field stays nil until a real backend is wired.
|
||||
type InboxEntry struct {
|
||||
Message
|
||||
Recipient Recipient
|
||||
Translation *Translation
|
||||
}
|
||||
|
||||
// Translation mirrors a row in `backend.diplomail_translations`. The
|
||||
// engine identifier is preserved so an operator can see which
|
||||
// backend produced the cached rendering.
|
||||
type Translation struct {
|
||||
TranslationID uuid.UUID
|
||||
MessageID uuid.UUID
|
||||
TargetLang string
|
||||
TranslatedSubject string
|
||||
TranslatedBody string
|
||||
Translator string
|
||||
TranslatedAt time.Time
|
||||
}
|
||||
|
||||
// SendPersonalInput is the request payload for SendPersonal: the
|
||||
// caller sending a single-recipient personal message. Exactly one of
|
||||
// RecipientUserID and RecipientRaceName must be non-zero; the
|
||||
// service resolves a non-empty RecipientRaceName to the active
|
||||
// member with that race in the game. Other validation (active
|
||||
// membership, body length, etc.) is performed inside the service.
|
||||
type SendPersonalInput struct {
|
||||
GameID uuid.UUID
|
||||
SenderUserID uuid.UUID
|
||||
RecipientUserID uuid.UUID
|
||||
RecipientRaceName string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// CallerKind enumerates the privileged sender roles for admin-kind
|
||||
// messages. Owners (`CallerKindOwner`) are players who own a private
|
||||
// game; admins (`CallerKindAdmin`) hit the dedicated admin route;
|
||||
// `CallerKindSystem` is reserved for internal lifecycle hooks.
|
||||
const (
|
||||
CallerKindOwner = "owner"
|
||||
CallerKindAdmin = "admin"
|
||||
CallerKindSystem = "system"
|
||||
)
|
||||
|
||||
// SendAdminPersonalInput is the request payload for an owner /
|
||||
// admin / system sending an admin-kind message to a single
|
||||
// recipient. Exactly one of RecipientUserID and RecipientRaceName
|
||||
// must be non-zero; the service resolves a non-empty
|
||||
// RecipientRaceName to the active member with that race in the
|
||||
// game. Authorization (owner-vs-admin distinction) is enforced by
|
||||
// the HTTP layer; the service trusts the caller designation.
|
||||
type SendAdminPersonalInput struct {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientUserID uuid.UUID
|
||||
RecipientRaceName string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// SendAdminBroadcastInput is the request payload for an owner /
|
||||
// admin / system broadcasting an admin-kind message inside a single
|
||||
// game. RecipientScope selects the address book; the sender's own
|
||||
// recipient row is never created (a broadcast author does not get a
|
||||
// copy of their own message).
|
||||
type SendAdminBroadcastInput struct {
|
||||
GameID uuid.UUID
|
||||
CallerKind string
|
||||
CallerUserID *uuid.UUID
|
||||
CallerUsername string
|
||||
RecipientScope string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// LifecycleEventKind enumerates the producer-side intents the lobby
|
||||
// emits when a game-state or membership-state transition lands.
|
||||
const (
|
||||
LifecycleKindGamePaused = "game.paused"
|
||||
LifecycleKindGameCancelled = "game.cancelled"
|
||||
LifecycleKindMembershipRemoved = "membership.removed"
|
||||
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||
)
|
||||
|
||||
// SendPlayerBroadcastInput is the request payload for the paid-tier
|
||||
// player broadcast. The sender is a player; recipients are the
|
||||
// active members of the game minus the sender. The resulting message
|
||||
// is `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"` — recipients may reply as if it
|
||||
// were a personal send, but the reply goes back to the broadcaster
|
||||
// only.
|
||||
type SendPlayerBroadcastInput struct {
|
||||
GameID uuid.UUID
|
||||
SenderUserID uuid.UUID
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// MultiGameBroadcastScope enumerates the admin multi-game broadcast
|
||||
// modes. `selected` requires `GameIDs`; `all_running` enumerates
|
||||
// every game whose status is non-terminal through GameLookup.
|
||||
const (
|
||||
MultiGameScopeSelected = "selected"
|
||||
MultiGameScopeAllRunning = "all_running"
|
||||
)
|
||||
|
||||
// SendMultiGameBroadcastInput is the request payload for the admin
|
||||
// multi-game broadcast. The service materialises one message row per
|
||||
// addressed game (so a recipient who plays in two games receives two
|
||||
// independently-deletable inbox entries), then fan-outs the push
|
||||
// events.
|
||||
type SendMultiGameBroadcastInput struct {
|
||||
CallerUsername string
|
||||
Scope string
|
||||
GameIDs []uuid.UUID
|
||||
RecipientScope string
|
||||
Subject string
|
||||
Body string
|
||||
SenderIP string
|
||||
}
|
||||
|
||||
// BulkCleanupInput selects messages eligible for purge. OlderThanYears
|
||||
// must be >= 1; the service translates the value into a cutoff
|
||||
// expressed in years and walks `GameLookup.ListFinishedGamesBefore`.
|
||||
type BulkCleanupInput struct {
|
||||
OlderThanYears int
|
||||
}
|
||||
|
||||
// CleanupResult summarises a bulk-cleanup run for the admin response
|
||||
// envelope.
|
||||
type CleanupResult struct {
|
||||
GameIDs []uuid.UUID
|
||||
MessagesDeleted int
|
||||
}
|
||||
|
||||
// AdminMessageListing is the filter passed to ListMessagesForAdmin.
|
||||
// Pagination uses (Page, PageSize) consistent with the rest of the
|
||||
// admin surface. Filters are AND-combined; the empty filter returns
|
||||
// every persisted row.
|
||||
type AdminMessageListing struct {
|
||||
Page int
|
||||
PageSize int
|
||||
GameID *uuid.UUID
|
||||
Kind string
|
||||
SenderKind string
|
||||
}
|
||||
|
||||
// AdminMessagePage is the canonical pagination envelope.
|
||||
type AdminMessagePage struct {
|
||||
Items []Message
|
||||
Total int
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// LifecycleEvent is the payload lobby hands to PublishLifecycle when
|
||||
// a transition needs to be reflected as durable system mail. The
|
||||
// recipient set is derived by the service:
|
||||
//
|
||||
// - For game.* events the message fans out to every active member
|
||||
// of the game except the actor (the actor sees the action in
|
||||
// their own UI through other channels).
|
||||
// - For membership.* events the message addresses exactly
|
||||
// `TargetUser` (the kicked player), regardless of their current
|
||||
// membership status — this is how a kicked player retains read
|
||||
// access to the explanation of the kick.
|
||||
type LifecycleEvent struct {
|
||||
GameID uuid.UUID
|
||||
Kind string
|
||||
Actor string
|
||||
Reason string
|
||||
TargetUser *uuid.UUID
|
||||
}
|
||||
|
||||
// UnreadCount carries a per-game unread-count row returned by
|
||||
// UnreadCountsForUser. The lobby badge UI consumes the slice plus the
|
||||
// derived total.
|
||||
type UnreadCount struct {
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Unread int
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package diplomail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/diplomail/translator"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// translationBackoff returns the sleep applied before retry attempt
|
||||
// `attempt`. attempt is 1-indexed (the value the row carries AFTER
|
||||
// the failure is recorded). The schedule mirrors the spec —
|
||||
// 1s → 2s → 4s → 8s → 16s — so 5 failed attempts span ~31 seconds
|
||||
// before the worker falls back to delivering the original.
|
||||
func translationBackoff(attempt int32) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return 0
|
||||
}
|
||||
out := time.Second
|
||||
for i := int32(1); i < attempt; i++ {
|
||||
out *= 2
|
||||
}
|
||||
const cap = 60 * time.Second
|
||||
if out > cap {
|
||||
return cap
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Worker drives the async translation pipeline. Each tick picks a
|
||||
// single (message_id, target_lang) pair from
|
||||
// `diplomail_recipients` where `available_at IS NULL`, asks the
|
||||
// configured Translator to render the body, and either delivers the
|
||||
// pending recipients (success) or schedules a retry (transient
|
||||
// failure) or delivers them with a fallback to the original body
|
||||
// (terminal failure / max attempts).
|
||||
//
|
||||
// The worker is single-threaded by design: one HTTP call to
|
||||
// LibreTranslate at a time. This protects the upstream from spikes
|
||||
// and keeps the implementation reviewable.
|
||||
//
|
||||
// Implements `internal/app.Component` so it plugs into the same
|
||||
// lifecycle as the mail and notification workers.
|
||||
type Worker struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewWorker constructs a Worker bound to svc. Returning a non-nil
|
||||
// Worker even when the translator is the noop fallback is
|
||||
// intentional — the pickup query still works and falls through to
|
||||
// fallback delivery, which is the desired behaviour for setups
|
||||
// without LibreTranslate.
|
||||
func NewWorker(svc *Service) *Worker { return &Worker{svc: svc} }
|
||||
|
||||
// Run drives the worker loop until ctx is cancelled.
|
||||
func (w *Worker) Run(ctx context.Context) error {
|
||||
if w == nil || w.svc == nil {
|
||||
return nil
|
||||
}
|
||||
logger := w.svc.deps.Logger.Named("worker")
|
||||
interval := w.svc.deps.Config.WorkerInterval
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Warn("diplomail worker initial tick failed", zap.Error(err))
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Warn("diplomail worker tick failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown is a no-op: every translation outcome is committed inside
|
||||
// tick before returning, so cancelling the parent ctx is enough.
|
||||
func (w *Worker) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// Tick exposes the per-tick work for tests so they can drive the
|
||||
// worker without depending on the ticker.
|
||||
func (w *Worker) Tick(ctx context.Context) error { return w.tick(ctx) }
|
||||
|
||||
// tick picks one pair from the queue and applies the result. The
|
||||
// per-tick budget is one pair on purpose: the worker is single
|
||||
// threaded and we do not want a fast LibreTranslate instance to
|
||||
// starve the rest of the backend's I/O behind a long-running batch.
|
||||
func (w *Worker) tick(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
pair, ok, err := w.svc.deps.Store.PickPendingTranslationPair(ctx, w.svc.nowUTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return w.processPair(ctx, pair)
|
||||
}
|
||||
|
||||
// processPair runs the full pipeline for one (message, target_lang).
|
||||
// Steps:
|
||||
//
|
||||
// 1. Load the source message.
|
||||
// 2. Check the translation cache. If a row already exists (another
|
||||
// worker pre-populated it, or two pairs converged on the same
|
||||
// target), reuse it and deliver.
|
||||
// 3. Otherwise call the configured Translator.
|
||||
// 4. Apply the outcome: success → cache + deliver; unsupported
|
||||
// pair → deliver fallback (no cache row); other failure →
|
||||
// schedule retry or deliver fallback after MaxAttempts.
|
||||
// 5. Fan out push events for every recipient whose `available_at`
|
||||
// just transitioned.
|
||||
func (w *Worker) processPair(ctx context.Context, pair PendingTranslationPair) error {
|
||||
logger := w.svc.deps.Logger.Named("worker").With(
|
||||
zap.String("message_id", pair.MessageID.String()),
|
||||
zap.String("target_lang", pair.TargetLang),
|
||||
)
|
||||
msg, err := w.svc.deps.Store.LoadMessage(ctx, pair.MessageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cached, err := w.svc.deps.Store.LoadTranslation(ctx, pair.MessageID, pair.TargetLang); err == nil {
|
||||
t := cached
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, &t, logger)
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
result, callErr := w.svc.deps.Translator.Translate(ctx, msg.BodyLang, pair.TargetLang, msg.Subject, msg.Body)
|
||||
if callErr == nil && result.Engine != "" && result.Engine != translator.NoopEngine {
|
||||
tr := Translation{
|
||||
TranslationID: uuid.New(),
|
||||
MessageID: msg.MessageID,
|
||||
TargetLang: pair.TargetLang,
|
||||
TranslatedSubject: result.Subject,
|
||||
TranslatedBody: result.Body,
|
||||
Translator: result.Engine,
|
||||
}
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, &tr, logger)
|
||||
}
|
||||
if callErr == nil {
|
||||
// Noop translator (or engine returned empty). Treat as
|
||||
// "translation unavailable" — deliver fallback so users
|
||||
// see the original.
|
||||
logger.Debug("translator returned noop, delivering fallback")
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
if errors.Is(callErr, translator.ErrUnsupportedLanguagePair) {
|
||||
logger.Info("language pair unsupported, delivering fallback", zap.Error(callErr))
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
|
||||
// Transient failure — bump the attempts counter and schedule a
|
||||
// retry. The next attempt timestamp is computed from the
|
||||
// post-increment counter so the spec's 1s→2s→4s→8s→16s schedule
|
||||
// applies between retries of the same pair.
|
||||
maxAttempts := w.svc.deps.Config.TranslatorMaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
nextAttempt := pair.CurrentAttempts + 1
|
||||
if int(nextAttempt) >= maxAttempts {
|
||||
logger.Warn("translator max attempts reached, delivering fallback",
|
||||
zap.Int32("attempts", nextAttempt), zap.Error(callErr))
|
||||
return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger)
|
||||
}
|
||||
next := w.svc.nowUTC().Add(translationBackoff(nextAttempt + 1))
|
||||
if _, err := w.svc.deps.Store.SchedulePairRetry(ctx, pair.MessageID, pair.TargetLang, next); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("translator attempt failed, scheduled retry",
|
||||
zap.Int32("attempts", nextAttempt),
|
||||
zap.Time("next_attempt_at", next),
|
||||
zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverPair flips every still-pending recipient of (messageID,
|
||||
// targetLang) to delivered, optionally inserting the translation row
|
||||
// in the same transaction, and emits push events to the recipients
|
||||
// who were just unblocked.
|
||||
func (w *Worker) deliverPair(ctx context.Context, msg Message, targetLang string, translation *Translation, logger *zap.Logger) error {
|
||||
recipients, err := w.svc.deps.Store.MarkPairDelivered(ctx, msg.MessageID, targetLang, translation, w.svc.nowUTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
logger.Debug("deliver yielded no recipients (already delivered)")
|
||||
return nil
|
||||
}
|
||||
for _, r := range recipients {
|
||||
w.svc.publishMessageReceived(ctx, msg, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) {
|
||||
return g, ok
|
||||
}
|
||||
|
||||
// ListGames returns a snapshot copy of every cached game. Terminal-
|
||||
// state games (finished, cancelled) are evicted from the cache on
|
||||
// `PutGame`, so the result reflects the live roster of running /
|
||||
// paused / draft / starting / etc. games. The slice is freshly
|
||||
// allocated and safe for the caller to mutate.
|
||||
func (c *Cache) ListGames() []GameRecord {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]GameRecord, 0, len(c.games))
|
||||
for _, g := range c.games {
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PutGame stores game in the cache when its status is cacheable;
|
||||
// terminal statuses (finished, cancelled) cause the entry to be evicted.
|
||||
func (c *Cache) PutGame(game GameRecord) {
|
||||
|
||||
@@ -51,6 +51,37 @@ type NotificationPublisher interface {
|
||||
PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error
|
||||
}
|
||||
|
||||
// DiplomailPublisher is the outbound surface the lobby uses to drop a
|
||||
// durable system mail entry whenever a game-state or
|
||||
// membership-state transition needs to land in the affected players'
|
||||
// inboxes. The real implementation in `cmd/backend/main` adapts the
|
||||
// `*diplomail.Service.PublishLifecycle` call; tests and partial
|
||||
// wiring fall back to `NewNoopDiplomailPublisher`.
|
||||
type DiplomailPublisher interface {
|
||||
PublishLifecycle(ctx context.Context, event LifecycleEvent) error
|
||||
}
|
||||
|
||||
// LifecycleEvent is the open shape carried by a system-mail intent.
|
||||
// `Kind` is one of the lobby-internal constants
|
||||
// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only
|
||||
// for membership-scoped events; the publisher derives the game-scoped
|
||||
// recipient set itself.
|
||||
type LifecycleEvent struct {
|
||||
GameID uuid.UUID
|
||||
Kind string
|
||||
Actor string
|
||||
Reason string
|
||||
TargetUser *uuid.UUID
|
||||
}
|
||||
|
||||
// Lifecycle-event kinds the lobby emits.
|
||||
const (
|
||||
LifecycleKindGamePaused = "game.paused"
|
||||
LifecycleKindGameCancelled = "game.cancelled"
|
||||
LifecycleKindMembershipRemoved = "membership.removed"
|
||||
LifecycleKindMembershipBlocked = "membership.blocked"
|
||||
)
|
||||
|
||||
// LobbyNotification is the open shape carried by a notification intent.
|
||||
// The implementation emits a small set of `Kind` values matching the catalog in
|
||||
// `backend/README.md` §10. The `Payload` map is the kind-specific data
|
||||
@@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs
|
||||
// every call at debug level and returns nil. Used by tests and by
|
||||
// the lobby Service factory when the Deps.Diplomail field is left
|
||||
// nil.
|
||||
func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")}
|
||||
}
|
||||
|
||||
type noopDiplomailPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error {
|
||||
p.logger.Debug("noop diplomail lifecycle",
|
||||
zap.String("kind", event.Kind),
|
||||
zap.String("game_id", event.GameID.String()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"galaxy/cronutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||
@@ -233,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco
|
||||
return s.deps.Store.ListMyGames(ctx, userID)
|
||||
}
|
||||
|
||||
// ListFinishedGamesBefore returns every game whose status is
|
||||
// `finished` or `cancelled` and whose `finished_at` is strictly older
|
||||
// than cutoff. The result walks the store through the admin-paged
|
||||
// query with a 200-row batch size; the caller is expected to invoke
|
||||
// this from rare admin workflows (diplomail bulk cleanup) rather
|
||||
// than hot-path reads.
|
||||
func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) {
|
||||
const pageSize = 200
|
||||
page := 1
|
||||
var out []GameRecord
|
||||
for {
|
||||
batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
for _, g := range batch {
|
||||
if g.Status != GameStatusFinished && g.Status != GameStatusCancelled {
|
||||
continue
|
||||
}
|
||||
if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
if len(batch) < pageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteGame removes the game and every referencing row (memberships,
|
||||
// applications, invites, runtime_records, player_mappings) via the
|
||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||
@@ -441,9 +477,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle
|
||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||
}
|
||||
}
|
||||
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// emitGameLifecycleMail asks the diplomail publisher to drop a
|
||||
// system-mail entry whenever a state change is user-visible. Only
|
||||
// the `paused` and `cancelled` transitions emit mail today (the spec
|
||||
// names them explicitly); `running`/`finished`/etc. are signalled by
|
||||
// other channels and do not need a durable inbox entry.
|
||||
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
|
||||
var kind string
|
||||
switch rule.To {
|
||||
case GameStatusPaused:
|
||||
kind = LifecycleKindGamePaused
|
||||
case GameStatusCancelled:
|
||||
kind = LifecycleKindGameCancelled
|
||||
default:
|
||||
return
|
||||
}
|
||||
actor := "the game owner"
|
||||
if callerIsAdmin {
|
||||
actor = "an administrator"
|
||||
}
|
||||
ev := LifecycleEvent{
|
||||
GameID: game.GameID,
|
||||
Kind: kind,
|
||||
Actor: actor,
|
||||
Reason: rule.Reason,
|
||||
}
|
||||
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish lifecycle mail failed",
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// checkOwner enforces ownership semantics:
|
||||
//
|
||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||
|
||||
@@ -124,6 +124,7 @@ type Deps struct {
|
||||
Cache *Cache
|
||||
Runtime RuntimeGateway
|
||||
Notification NotificationPublisher
|
||||
Diplomail DiplomailPublisher
|
||||
Entitlement EntitlementProvider
|
||||
Policy *Policy
|
||||
Config config.LobbyConfig
|
||||
@@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) {
|
||||
if deps.Notification == nil {
|
||||
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Diplomail == nil {
|
||||
deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger)
|
||||
}
|
||||
if deps.Policy == nil {
|
||||
policy, err := NewPolicy()
|
||||
if err != nil {
|
||||
|
||||
@@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID,
|
||||
zap.String("membership_id", updated.MembershipID.String()),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason)
|
||||
_ = game
|
||||
return updated, nil
|
||||
}
|
||||
@@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus(
|
||||
zap.String("kind", notificationKind),
|
||||
zap.Error(pubErr))
|
||||
}
|
||||
s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "")
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// emitMembershipLifecycleMail asks the diplomail publisher to drop a
|
||||
// durable explanation into the kicked player's inbox. The mail
|
||||
// survives the membership row going to `removed` / `blocked` so the
|
||||
// player keeps read access to it (soft-access rule, item 8).
|
||||
func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) {
|
||||
var kind string
|
||||
switch newStatus {
|
||||
case MembershipStatusRemoved:
|
||||
kind = LifecycleKindMembershipRemoved
|
||||
case MembershipStatusBlocked:
|
||||
kind = LifecycleKindMembershipBlocked
|
||||
default:
|
||||
return
|
||||
}
|
||||
actor := "the game owner"
|
||||
if callerIsAdmin {
|
||||
actor = "an administrator"
|
||||
}
|
||||
target := membership.UserID
|
||||
ev := LifecycleEvent{
|
||||
GameID: membership.GameID,
|
||||
Kind: kind,
|
||||
Actor: actor,
|
||||
Reason: reason,
|
||||
TargetUser: &target,
|
||||
}
|
||||
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
|
||||
s.deps.Logger.Warn("publish membership lifecycle mail failed",
|
||||
zap.String("membership_id", membership.MembershipID.String()),
|
||||
zap.String("kind", kind),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool {
|
||||
if game.Visibility == VisibilityPublic {
|
||||
// Public-game membership management is admin-only.
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||
KindGameTurnReady = "game.turn.ready"
|
||||
KindGamePaused = "game.paused"
|
||||
KindDiplomailReceived = "diplomail.message.received"
|
||||
)
|
||||
|
||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||
@@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{
|
||||
KindGamePaused: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
KindDiplomailReceived: {
|
||||
Channels: []string{ChannelPush},
|
||||
},
|
||||
}
|
||||
|
||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||
@@ -133,5 +137,6 @@ func SupportedKinds() []string {
|
||||
KindRuntimeStartConfigInvalid,
|
||||
KindGameTurnReady,
|
||||
KindGamePaused,
|
||||
KindDiplomailReceived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) {
|
||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||
KindGameTurnReady: {ChannelPush},
|
||||
KindGamePaused: {ChannelPush},
|
||||
KindDiplomailReceived: {ChannelPush},
|
||||
}
|
||||
for kind, want := range expect {
|
||||
entry, ok := LookupCatalog(kind)
|
||||
|
||||
@@ -25,9 +25,15 @@ import (
|
||||
// payload is `{game_id, turn, reason}` consumed by the same in-game
|
||||
// shell layout, so there is no value in dragging a FB schema in for
|
||||
// one consumer.
|
||||
//
|
||||
// `diplomail.message.received` (Stage A) carries the message metadata
|
||||
// plus an unread-count snapshot. Stage A intentionally ships the
|
||||
// payload as JSON so the diplomail UI can iterate on the contract
|
||||
// without a FB schema dance; a later stage can promote it.
|
||||
var jsonFriendlyKinds = map[string]bool{
|
||||
KindGameTurnReady: true,
|
||||
KindGamePaused: true,
|
||||
KindGameTurnReady: true,
|
||||
KindGamePaused: true,
|
||||
KindDiplomailReceived: true,
|
||||
}
|
||||
|
||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||
@@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||
"turn": int32(7),
|
||||
"reason": "generation_failed",
|
||||
}},
|
||||
{"diplomail message received", KindDiplomailReceived, map[string]any{
|
||||
"message_id": gameID.String(),
|
||||
"game_id": gameID.String(),
|
||||
"kind": "personal",
|
||||
"sender_kind": "player",
|
||||
"subject": "Trade deal",
|
||||
"preview": "Care to talk gas mining?",
|
||||
"preview_lang": "en",
|
||||
"unread_total": 3,
|
||||
"unread_game": 1,
|
||||
}},
|
||||
}
|
||||
|
||||
seenKinds := map[string]bool{}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiplomailMessages struct {
|
||||
MessageID uuid.UUID `sql:"primary_key"`
|
||||
GameID uuid.UUID
|
||||
GameName string
|
||||
Kind string
|
||||
SenderKind string
|
||||
SenderUserID *uuid.UUID
|
||||
SenderUsername *string
|
||||
SenderRaceName *string
|
||||
SenderIP string
|
||||
Subject string
|
||||
Body string
|
||||
BodyLang string
|
||||
BroadcastScope string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiplomailRecipients struct {
|
||||
RecipientID uuid.UUID `sql:"primary_key"`
|
||||
MessageID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
RecipientUserName string
|
||||
RecipientRaceName *string
|
||||
RecipientPreferredLanguage string
|
||||
AvailableAt *time.Time
|
||||
TranslationAttempts int32
|
||||
NextTranslationAttemptAt *time.Time
|
||||
DeliveredAt *time.Time
|
||||
ReadAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
NotifiedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiplomailTranslations struct {
|
||||
TranslationID uuid.UUID `sql:"primary_key"`
|
||||
MessageID uuid.UUID
|
||||
TargetLang string
|
||||
TranslatedSubject string
|
||||
TranslatedBody string
|
||||
Translator string
|
||||
TranslatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var DiplomailMessages = newDiplomailMessagesTable("backend", "diplomail_messages", "")
|
||||
|
||||
type diplomailMessagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MessageID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
GameName postgres.ColumnString
|
||||
Kind postgres.ColumnString
|
||||
SenderKind postgres.ColumnString
|
||||
SenderUserID postgres.ColumnString
|
||||
SenderUsername postgres.ColumnString
|
||||
SenderRaceName postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
Subject postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
BodyLang postgres.ColumnString
|
||||
BroadcastScope postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type DiplomailMessagesTable struct {
|
||||
diplomailMessagesTable
|
||||
|
||||
EXCLUDED diplomailMessagesTable
|
||||
}
|
||||
|
||||
// AS creates new DiplomailMessagesTable with assigned alias
|
||||
func (a DiplomailMessagesTable) AS(alias string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new DiplomailMessagesTable with assigned schema name
|
||||
func (a DiplomailMessagesTable) FromSchema(schemaName string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new DiplomailMessagesTable with assigned table prefix
|
||||
func (a DiplomailMessagesTable) WithPrefix(prefix string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new DiplomailMessagesTable with assigned table suffix
|
||||
func (a DiplomailMessagesTable) WithSuffix(suffix string) *DiplomailMessagesTable {
|
||||
return newDiplomailMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newDiplomailMessagesTable(schemaName, tableName, alias string) *DiplomailMessagesTable {
|
||||
return &DiplomailMessagesTable{
|
||||
diplomailMessagesTable: newDiplomailMessagesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newDiplomailMessagesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomailMessagesTable {
|
||||
var (
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
GameNameColumn = postgres.StringColumn("game_name")
|
||||
KindColumn = postgres.StringColumn("kind")
|
||||
SenderKindColumn = postgres.StringColumn("sender_kind")
|
||||
SenderUserIDColumn = postgres.StringColumn("sender_user_id")
|
||||
SenderUsernameColumn = postgres.StringColumn("sender_username")
|
||||
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
SubjectColumn = postgres.StringColumn("subject")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
BodyLangColumn = postgres.StringColumn("body_lang")
|
||||
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return diplomailMessagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MessageID: MessageIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
GameName: GameNameColumn,
|
||||
Kind: KindColumn,
|
||||
SenderKind: SenderKindColumn,
|
||||
SenderUserID: SenderUserIDColumn,
|
||||
SenderUsername: SenderUsernameColumn,
|
||||
SenderRaceName: SenderRaceNameColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
Subject: SubjectColumn,
|
||||
Body: BodyColumn,
|
||||
BodyLang: BodyLangColumn,
|
||||
BroadcastScope: BroadcastScopeColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var DiplomailRecipients = newDiplomailRecipientsTable("backend", "diplomail_recipients", "")
|
||||
|
||||
type diplomailRecipientsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
RecipientID postgres.ColumnString
|
||||
MessageID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
RecipientUserName postgres.ColumnString
|
||||
RecipientRaceName postgres.ColumnString
|
||||
RecipientPreferredLanguage postgres.ColumnString
|
||||
AvailableAt postgres.ColumnTimestampz
|
||||
TranslationAttempts postgres.ColumnInteger
|
||||
NextTranslationAttemptAt postgres.ColumnTimestampz
|
||||
DeliveredAt postgres.ColumnTimestampz
|
||||
ReadAt postgres.ColumnTimestampz
|
||||
DeletedAt postgres.ColumnTimestampz
|
||||
NotifiedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type DiplomailRecipientsTable struct {
|
||||
diplomailRecipientsTable
|
||||
|
||||
EXCLUDED diplomailRecipientsTable
|
||||
}
|
||||
|
||||
// AS creates new DiplomailRecipientsTable with assigned alias
|
||||
func (a DiplomailRecipientsTable) AS(alias string) *DiplomailRecipientsTable {
|
||||
return newDiplomailRecipientsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new DiplomailRecipientsTable with assigned schema name
|
||||
func (a DiplomailRecipientsTable) FromSchema(schemaName string) *DiplomailRecipientsTable {
|
||||
return newDiplomailRecipientsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new DiplomailRecipientsTable with assigned table prefix
|
||||
func (a DiplomailRecipientsTable) WithPrefix(prefix string) *DiplomailRecipientsTable {
|
||||
return newDiplomailRecipientsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new DiplomailRecipientsTable with assigned table suffix
|
||||
func (a DiplomailRecipientsTable) WithSuffix(suffix string) *DiplomailRecipientsTable {
|
||||
return newDiplomailRecipientsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newDiplomailRecipientsTable(schemaName, tableName, alias string) *DiplomailRecipientsTable {
|
||||
return &DiplomailRecipientsTable{
|
||||
diplomailRecipientsTable: newDiplomailRecipientsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newDiplomailRecipientsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDiplomailRecipientsTableImpl(schemaName, tableName, alias string) diplomailRecipientsTable {
|
||||
var (
|
||||
RecipientIDColumn = postgres.StringColumn("recipient_id")
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
RecipientUserNameColumn = postgres.StringColumn("recipient_user_name")
|
||||
RecipientRaceNameColumn = postgres.StringColumn("recipient_race_name")
|
||||
RecipientPreferredLanguageColumn = postgres.StringColumn("recipient_preferred_language")
|
||||
AvailableAtColumn = postgres.TimestampzColumn("available_at")
|
||||
TranslationAttemptsColumn = postgres.IntegerColumn("translation_attempts")
|
||||
NextTranslationAttemptAtColumn = postgres.TimestampzColumn("next_translation_attempt_at")
|
||||
DeliveredAtColumn = postgres.TimestampzColumn("delivered_at")
|
||||
ReadAtColumn = postgres.TimestampzColumn("read_at")
|
||||
DeletedAtColumn = postgres.TimestampzColumn("deleted_at")
|
||||
NotifiedAtColumn = postgres.TimestampzColumn("notified_at")
|
||||
allColumns = postgres.ColumnList{RecipientIDColumn, MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{RecipientPreferredLanguageColumn, TranslationAttemptsColumn}
|
||||
)
|
||||
|
||||
return diplomailRecipientsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
RecipientID: RecipientIDColumn,
|
||||
MessageID: MessageIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
RecipientUserName: RecipientUserNameColumn,
|
||||
RecipientRaceName: RecipientRaceNameColumn,
|
||||
RecipientPreferredLanguage: RecipientPreferredLanguageColumn,
|
||||
AvailableAt: AvailableAtColumn,
|
||||
TranslationAttempts: TranslationAttemptsColumn,
|
||||
NextTranslationAttemptAt: NextTranslationAttemptAtColumn,
|
||||
DeliveredAt: DeliveredAtColumn,
|
||||
ReadAt: ReadAtColumn,
|
||||
DeletedAt: DeletedAtColumn,
|
||||
NotifiedAt: NotifiedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var DiplomailTranslations = newDiplomailTranslationsTable("backend", "diplomail_translations", "")
|
||||
|
||||
type diplomailTranslationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
TranslationID postgres.ColumnString
|
||||
MessageID postgres.ColumnString
|
||||
TargetLang postgres.ColumnString
|
||||
TranslatedSubject postgres.ColumnString
|
||||
TranslatedBody postgres.ColumnString
|
||||
Translator postgres.ColumnString
|
||||
TranslatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type DiplomailTranslationsTable struct {
|
||||
diplomailTranslationsTable
|
||||
|
||||
EXCLUDED diplomailTranslationsTable
|
||||
}
|
||||
|
||||
// AS creates new DiplomailTranslationsTable with assigned alias
|
||||
func (a DiplomailTranslationsTable) AS(alias string) *DiplomailTranslationsTable {
|
||||
return newDiplomailTranslationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new DiplomailTranslationsTable with assigned schema name
|
||||
func (a DiplomailTranslationsTable) FromSchema(schemaName string) *DiplomailTranslationsTable {
|
||||
return newDiplomailTranslationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new DiplomailTranslationsTable with assigned table prefix
|
||||
func (a DiplomailTranslationsTable) WithPrefix(prefix string) *DiplomailTranslationsTable {
|
||||
return newDiplomailTranslationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new DiplomailTranslationsTable with assigned table suffix
|
||||
func (a DiplomailTranslationsTable) WithSuffix(suffix string) *DiplomailTranslationsTable {
|
||||
return newDiplomailTranslationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newDiplomailTranslationsTable(schemaName, tableName, alias string) *DiplomailTranslationsTable {
|
||||
return &DiplomailTranslationsTable{
|
||||
diplomailTranslationsTable: newDiplomailTranslationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newDiplomailTranslationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newDiplomailTranslationsTableImpl(schemaName, tableName, alias string) diplomailTranslationsTable {
|
||||
var (
|
||||
TranslationIDColumn = postgres.StringColumn("translation_id")
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
TargetLangColumn = postgres.StringColumn("target_lang")
|
||||
TranslatedSubjectColumn = postgres.StringColumn("translated_subject")
|
||||
TranslatedBodyColumn = postgres.StringColumn("translated_body")
|
||||
TranslatorColumn = postgres.StringColumn("translator")
|
||||
TranslatedAtColumn = postgres.TimestampzColumn("translated_at")
|
||||
allColumns = postgres.ColumnList{TranslationIDColumn, MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{TranslatedSubjectColumn, TranslatedAtColumn}
|
||||
)
|
||||
|
||||
return diplomailTranslationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
TranslationID: TranslationIDColumn,
|
||||
MessageID: MessageIDColumn,
|
||||
TargetLang: TargetLangColumn,
|
||||
TranslatedSubject: TranslatedSubjectColumn,
|
||||
TranslatedBody: TranslatedBodyColumn,
|
||||
Translator: TranslatorColumn,
|
||||
TranslatedAt: TranslatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ func UseSchema(schema string) {
|
||||
AuthChallenges = AuthChallenges.FromSchema(schema)
|
||||
BlockedEmails = BlockedEmails.FromSchema(schema)
|
||||
DeviceSessions = DeviceSessions.FromSchema(schema)
|
||||
DiplomailMessages = DiplomailMessages.FromSchema(schema)
|
||||
DiplomailRecipients = DiplomailRecipients.FromSchema(schema)
|
||||
DiplomailTranslations = DiplomailTranslations.FromSchema(schema)
|
||||
EngineVersions = EngineVersions.FromSchema(schema)
|
||||
EntitlementRecords = EntitlementRecords.FromSchema(schema)
|
||||
EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema)
|
||||
|
||||
@@ -606,7 +606,8 @@ CREATE TABLE notifications (
|
||||
'lobby.race_name.expired',
|
||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||
'runtime.start_config_invalid',
|
||||
'game.turn.ready', 'game.paused'
|
||||
'game.turn.ready', 'game.paused',
|
||||
'diplomail.message.received'
|
||||
))
|
||||
);
|
||||
|
||||
@@ -662,6 +663,126 @@ CREATE TABLE notification_malformed_intents (
|
||||
CREATE INDEX notification_malformed_intents_listing_idx
|
||||
ON notification_malformed_intents (received_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- Diplomail domain
|
||||
-- =====================================================================
|
||||
|
||||
-- diplomail_messages is the canonical record of every diplomatic-mail
|
||||
-- send: one row per personal message, owner/admin send, broadcast, or
|
||||
-- system notification. game_name is captured at insert time so the
|
||||
-- bulk-purge / rename paths still render correctly. sender_username
|
||||
-- carries either accounts.user_name (sender_kind='player') or
|
||||
-- admin_accounts.username (sender_kind='admin'); system senders leave
|
||||
-- it NULL. body and subject are plain UTF-8; length limits are enforced
|
||||
-- in the service layer and may be tuned without a migration.
|
||||
CREATE TABLE diplomail_messages (
|
||||
message_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
game_name text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
sender_kind text NOT NULL,
|
||||
sender_user_id uuid,
|
||||
sender_username text,
|
||||
-- sender_race_name is the immutable snapshot of the sender's race
|
||||
-- in this game, captured at insert time when sender_kind='player'.
|
||||
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
|
||||
-- per-race threading on this column.
|
||||
sender_race_name text,
|
||||
sender_ip text NOT NULL DEFAULT '',
|
||||
subject text NOT NULL DEFAULT '',
|
||||
body text NOT NULL,
|
||||
body_lang text NOT NULL DEFAULT 'und',
|
||||
broadcast_scope text NOT NULL DEFAULT 'single',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT diplomail_messages_kind_chk
|
||||
CHECK (kind IN ('personal', 'admin')),
|
||||
CONSTRAINT diplomail_messages_sender_kind_chk
|
||||
CHECK (sender_kind IN ('player', 'admin', 'system')),
|
||||
CONSTRAINT diplomail_messages_sender_identity_chk CHECK (
|
||||
(sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR
|
||||
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
|
||||
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
|
||||
),
|
||||
-- sender_race_name is only meaningful for player senders. Admin
|
||||
-- and system rows never carry a race; player rows carry one when
|
||||
-- the sender has an active membership at send time (a non-playing
|
||||
-- private-game owner may legitimately have none).
|
||||
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
|
||||
sender_kind = 'player' OR sender_race_name IS NULL
|
||||
),
|
||||
CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
|
||||
(kind = 'personal' AND sender_kind = 'player') OR
|
||||
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
|
||||
),
|
||||
CONSTRAINT diplomail_messages_broadcast_scope_chk
|
||||
CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast'))
|
||||
);
|
||||
|
||||
CREATE INDEX diplomail_messages_game_idx
|
||||
ON diplomail_messages (game_id, created_at DESC);
|
||||
|
||||
CREATE INDEX diplomail_messages_sender_user_idx
|
||||
ON diplomail_messages (sender_user_id, created_at DESC)
|
||||
WHERE sender_user_id IS NOT NULL;
|
||||
|
||||
-- diplomail_recipients carries one row per (message, recipient). The
|
||||
-- per-user read/delete/deliver/notified state lives here. recipient
|
||||
-- snapshots (user_name, race_name) are captured at insert time so the
|
||||
-- inbox listing and admin search render without joining accounts /
|
||||
-- memberships and survive race-name renames, membership revocation,
|
||||
-- and account soft-delete. recipient_race_name is nullable for the
|
||||
-- rare admin notifications addressed to a player who no longer has an
|
||||
-- active membership in the game.
|
||||
CREATE TABLE diplomail_recipients (
|
||||
recipient_id uuid PRIMARY KEY,
|
||||
message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
|
||||
game_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
recipient_user_name text NOT NULL,
|
||||
recipient_race_name text,
|
||||
recipient_preferred_language text NOT NULL DEFAULT '',
|
||||
available_at timestamptz,
|
||||
translation_attempts integer NOT NULL DEFAULT 0,
|
||||
next_translation_attempt_at timestamptz,
|
||||
delivered_at timestamptz,
|
||||
read_at timestamptz,
|
||||
deleted_at timestamptz,
|
||||
notified_at timestamptz,
|
||||
CONSTRAINT diplomail_recipients_unique UNIQUE (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX diplomail_recipients_inbox_idx
|
||||
ON diplomail_recipients (user_id, game_id, deleted_at, read_at);
|
||||
|
||||
CREATE INDEX diplomail_recipients_unread_idx
|
||||
ON diplomail_recipients (user_id, game_id)
|
||||
WHERE read_at IS NULL AND deleted_at IS NULL AND available_at IS NOT NULL;
|
||||
|
||||
-- Index drives the translation worker's pending-pair pickup. The
|
||||
-- partial filter keeps the scan tight: terminal-state recipients
|
||||
-- (with a non-NULL available_at) never appear in this btree. The
|
||||
-- composite ordering puts the next-attempt clock first so the
|
||||
-- backoff filter (`next_translation_attempt_at <= now()`) seeks
|
||||
-- before the secondary cluster on (message_id, lang).
|
||||
CREATE INDEX diplomail_recipients_pending_translation_idx
|
||||
ON diplomail_recipients (next_translation_attempt_at, message_id, recipient_preferred_language)
|
||||
WHERE available_at IS NULL;
|
||||
|
||||
-- diplomail_translations caches one rendered translation per
|
||||
-- (message, target_lang) so a broadcast addressed to many recipients
|
||||
-- with the same preferred_language is translated once. translator
|
||||
-- identifies the backend that produced the row.
|
||||
CREATE TABLE diplomail_translations (
|
||||
translation_id uuid PRIMARY KEY,
|
||||
message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE,
|
||||
target_lang text NOT NULL,
|
||||
translated_subject text NOT NULL DEFAULT '',
|
||||
translated_body text NOT NULL,
|
||||
translator text NOT NULL,
|
||||
translated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT diplomail_translations_unique UNIQUE (message_id, target_lang)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Geo domain
|
||||
-- =====================================================================
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
# Backend migrations
|
||||
|
||||
Goose migrations embedded into the backend binary by `embed.go`. Applied
|
||||
at startup before any listener opens (see `internal/postgres`).
|
||||
Goose (`pressly/goose/v3`) migrations embedded into the backend binary
|
||||
by `embed.go`. Applied at startup before any listener opens — see
|
||||
`internal/postgres`.
|
||||
|
||||
## Pre-production single-file rule
|
||||
## Authoring conventions
|
||||
|
||||
**While the platform is not yet in production, every schema change goes
|
||||
into the existing `00001_init.sql` file** rather than a new
|
||||
`00002_*`-prefixed file. The intent is to keep the schema in one
|
||||
canonical place so reviewers and developers do not have to reconstruct
|
||||
the latest shape from a chain of incremental migrations.
|
||||
- Each schema change is a new file with a monotonically increasing
|
||||
numeric prefix and a snake-case slug:
|
||||
`0000N_short_description.sql`. Reuse of a prefix is forbidden once
|
||||
the file is merged.
|
||||
- `00001_init.sql` is the historical baseline. Treat it as immutable
|
||||
history; do not edit it to land new schema. Squashing the chain back
|
||||
into a fresh `00001` is reserved for the explicit pre-production
|
||||
cut-over.
|
||||
- Every file MUST contain both an `-- +goose Up` and `-- +goose Down`
|
||||
section, even if Down is a single `DROP …` for the same artefacts.
|
||||
Down migrations are exercised by the schema test and serve as the
|
||||
documented rollback path.
|
||||
- Destructive changes (dropping columns/tables, renaming with data
|
||||
loss) MUST be split into at least two migrations so the chain stays
|
||||
rollable forward and backward without coordinated code+schema
|
||||
windows:
|
||||
1. add the new shape, dual-write the data, leave the old shape in
|
||||
place;
|
||||
2. once all readers have switched, drop the old shape in a follow-up
|
||||
migration.
|
||||
- Migrations are applied automatically on backend startup, so a fresh
|
||||
push to `development` plus the `dev-deploy.yaml` workflow brings the
|
||||
long-lived dev database up to head without manual intervention.
|
||||
`make -C tools/dev-deploy clean-data` is only needed when a developer
|
||||
deliberately wants a fresh database.
|
||||
- The integration harness (`backend/internal/postgres/migrations_test.go`)
|
||||
spins up a disposable Postgres per run and asserts the final table
|
||||
set. When a migration adds or removes tables, update the expected
|
||||
list in the same patch.
|
||||
|
||||
Operationally this means that pulling a branch with schema changes
|
||||
requires a fresh database — the only consumer today is local development
|
||||
and integration tests, both of which spin up disposable Postgres
|
||||
instances.
|
||||
## Pre-production squash
|
||||
|
||||
> **Remove this rule before the first production deployment.** From
|
||||
> that point on every schema change must be a new migration file with a
|
||||
> monotonically increasing prefix, and `00001_init.sql` becomes
|
||||
> immutable history.
|
||||
|
||||
If you need to make a change, edit `00001_init.sql` directly. Down
|
||||
migrations should still be kept in sync (they live at the bottom of the
|
||||
file — currently a single `DROP SCHEMA backend CASCADE`).
|
||||
The chain may be squashed back into one clean `00001_init.sql` before
|
||||
the first production deployment. That is a deliberate, one-time
|
||||
operation; until then, additive numbered files are the rule. After the
|
||||
squash this file gets a short note that `00001_init.sql` represents
|
||||
the production baseline and the policy above continues to apply for
|
||||
every later migration.
|
||||
|
||||
@@ -68,6 +68,10 @@ var expectedBackendTables = []string{
|
||||
"notification_malformed_intents",
|
||||
"notification_routes",
|
||||
"notifications",
|
||||
// Diplomail domain.
|
||||
"diplomail_messages",
|
||||
"diplomail_recipients",
|
||||
"diplomail_translations",
|
||||
// Geo domain.
|
||||
"user_country_counters",
|
||||
}
|
||||
|
||||
@@ -537,10 +537,7 @@ func (s *Service) runStart(ctx context.Context, op OperationLog) error {
|
||||
Env: map[string]string{
|
||||
"GAME_STATE_PATH": statePath,
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"galaxy.game_id": gameID.String(),
|
||||
"galaxy.engine_version": version.Version,
|
||||
},
|
||||
Labels: s.engineLabels(gameID.String(), version.Version),
|
||||
BindMounts: []dockerclient.BindMount{
|
||||
{
|
||||
HostPath: hostStatePath,
|
||||
@@ -735,10 +732,7 @@ func (s *Service) runPatch(ctx context.Context, op OperationLog, target EngineVe
|
||||
Env: map[string]string{
|
||||
"GAME_STATE_PATH": statePath,
|
||||
},
|
||||
Labels: map[string]string{
|
||||
"galaxy.game_id": op.GameID.String(),
|
||||
"galaxy.engine_version": target.Version,
|
||||
},
|
||||
Labels: s.engineLabels(op.GameID.String(), target.Version),
|
||||
BindMounts: []dockerclient.BindMount{
|
||||
{HostPath: hostStatePath, MountPath: s.deps.Config.ContainerStateMount},
|
||||
},
|
||||
@@ -938,6 +932,30 @@ func (s *Service) upsertRuntimeRecord(ctx context.Context, in runtimeRecordInser
|
||||
// containers attach to. Wired from cfg.Docker.Network through Deps.
|
||||
func (s *Service) dockerNetwork() string { return s.deps.DockerNetwork }
|
||||
|
||||
// engineLabels returns the label set stamped on every engine container
|
||||
// spawned for gameID running engineVersion. The runtime adapter merges
|
||||
// `dockerclient.ManagedLabel` separately; this helper covers the
|
||||
// game-scoped labels plus an optional `galaxy.stack=<value>` from the
|
||||
// runtime config so host-side tooling can scope cleanup by dev stack
|
||||
// without touching unrelated workloads.
|
||||
func (s *Service) engineLabels(gameID, engineVersion string) map[string]string {
|
||||
return engineLabels(gameID, engineVersion, s.deps.Config.StackLabel)
|
||||
}
|
||||
|
||||
// engineLabels is the side-effect-free part of `(*Service).engineLabels`,
|
||||
// exposed at package scope so unit tests can exercise the labelling
|
||||
// rules without building a full Service.
|
||||
func engineLabels(gameID, engineVersion, stackLabel string) map[string]string {
|
||||
labels := map[string]string{
|
||||
"galaxy.game_id": gameID,
|
||||
"galaxy.engine_version": engineVersion,
|
||||
}
|
||||
if stackLabel != "" {
|
||||
labels["galaxy.stack"] = stackLabel
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// waitForEngineHealthz polls the engine `/healthz` endpoint until it
|
||||
// responds 2xx or until the timeout elapses. The Docker daemon
|
||||
// reports a container as `running` as soon as the entrypoint starts,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package runtime
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEngineLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
gameID string
|
||||
version string
|
||||
stackLabel string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "stack label omitted when empty",
|
||||
gameID: "11111111-1111-1111-1111-111111111111",
|
||||
version: "0.1.0",
|
||||
stackLabel: "",
|
||||
want: map[string]string{
|
||||
"galaxy.game_id": "11111111-1111-1111-1111-111111111111",
|
||||
"galaxy.engine_version": "0.1.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stack label included when set",
|
||||
gameID: "22222222-2222-2222-2222-222222222222",
|
||||
version: "0.2.3",
|
||||
stackLabel: "dev-deploy",
|
||||
want: map[string]string{
|
||||
"galaxy.game_id": "22222222-2222-2222-2222-222222222222",
|
||||
"galaxy.engine_version": "0.2.3",
|
||||
"galaxy.stack": "dev-deploy",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := engineLabels(tc.gameID, tc.version, tc.stackLabel)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len(labels) = %d, want %d (got %v)", len(got), len(tc.want), got)
|
||||
}
|
||||
for k, v := range tc.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("labels[%q] = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{
|
||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||
"message_id": "00000000-0000-0000-0000-00000000000a",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"turn": "42",
|
||||
@@ -149,6 +150,35 @@ var requestBodyStubs = map[string]map[string]any{
|
||||
"user_id": pathParamStubs["user_id"],
|
||||
"reason": "ToS violation",
|
||||
},
|
||||
"userMailSendPersonal": {
|
||||
"recipient_user_id": pathParamStubs["user_id"],
|
||||
"subject": "Contract test subject",
|
||||
"body": "Contract test body",
|
||||
},
|
||||
"userMailSendAdmin": {
|
||||
"target": "user",
|
||||
"recipient_user_id": pathParamStubs["user_id"],
|
||||
"subject": "Contract test admin subject",
|
||||
"body": "Contract test admin body",
|
||||
},
|
||||
"adminDiplomailSend": {
|
||||
"target": "user",
|
||||
"recipient_user_id": pathParamStubs["user_id"],
|
||||
"subject": "Contract test admin subject",
|
||||
"body": "Contract test admin body",
|
||||
},
|
||||
"userMailSendBroadcast": {
|
||||
"subject": "Contract test paid broadcast",
|
||||
"body": "Contract test paid broadcast body",
|
||||
},
|
||||
"adminDiplomailBroadcast": {
|
||||
"scope": "all_running",
|
||||
"subject": "Contract test multi-game broadcast",
|
||||
"body": "Contract test multi-game broadcast body",
|
||||
},
|
||||
"adminDiplomailCleanup": {
|
||||
"older_than_years": 1,
|
||||
},
|
||||
}
|
||||
|
||||
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed
|
||||
// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send /
|
||||
// broadcast). The handler is intentionally separate from
|
||||
// `AdminMailHandlers`, which owns the unrelated email outbox surface
|
||||
// under `/api/v1/admin/mail/*`.
|
||||
type AdminDiplomailHandlers struct {
|
||||
svc *diplomail.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminDiplomailHandlers constructs the handler set. svc may be
|
||||
// nil — in that case every handler returns 501 not_implemented.
|
||||
func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")}
|
||||
}
|
||||
|
||||
// Send handles POST /api/v1/admin/games/{game_id}/mail. The body
|
||||
// shape mirrors the owner route: `target="user"` requires
|
||||
// `recipient_user_id`; `target="all"` accepts an optional
|
||||
// `recipients` scope. The authenticated admin username is captured
|
||||
// from the basicauth context and persisted as `sender_username`.
|
||||
func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailSend")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendAdminRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
var recipientID uuid.UUID
|
||||
if req.RecipientUserID != "" {
|
||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: username,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
case "all":
|
||||
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindAdmin,
|
||||
CallerUsername: username,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
default:
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast handles POST /api/v1/admin/mail/broadcast. Body:
|
||||
//
|
||||
// {
|
||||
// "scope": "selected" | "all_running",
|
||||
// "game_ids": ["..."],
|
||||
// "recipients": "active" | "active_and_removed" | "all_members",
|
||||
// "subject": "...",
|
||||
// "body": "..."
|
||||
// }
|
||||
//
|
||||
// The handler routes through SendAdminMultiGameBroadcast and returns
|
||||
// a fan-out receipt describing the message ids created and the
|
||||
// total recipient count.
|
||||
func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailBroadcast")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
var req adminDiplomailBroadcastRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
gameIDs := make([]uuid.UUID, 0, len(req.GameIDs))
|
||||
for _, raw := range req.GameIDs {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs")
|
||||
return
|
||||
}
|
||||
gameIDs = append(gameIDs, parsed)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{
|
||||
CallerUsername: username,
|
||||
Scope: req.Scope,
|
||||
GameIDs: gameIDs,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailBroadcastResponseWire{
|
||||
RecipientCount: total,
|
||||
Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)),
|
||||
}
|
||||
for _, m := range msgs {
|
||||
out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup handles POST /api/v1/admin/mail/cleanup. Body:
|
||||
//
|
||||
// { "older_than_years": 1 }
|
||||
//
|
||||
// The endpoint removes every diplomail_messages row whose game
|
||||
// finished more than the supplied number of years ago. The cascade
|
||||
// on the recipient and translation tables prunes the per-user state
|
||||
// in the same transaction. Returns a CleanupResult envelope.
|
||||
func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailCleanup")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
_ = username
|
||||
var req adminDiplomailCleanupRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailCleanupResponseWire{
|
||||
MessagesDeleted: result.MessagesDeleted,
|
||||
GameIDs: make([]string, 0, len(result.GameIDs)),
|
||||
}
|
||||
for _, id := range result.GameIDs {
|
||||
out.GameIDs = append(out.GameIDs, id.String())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/mail/messages. Supports pagination
|
||||
// via `page` and `page_size`, plus optional `game_id`, `kind`, and
|
||||
// `sender_kind` filters.
|
||||
func (h *AdminDiplomailHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminDiplomailList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
username, ok := basicauth.UsernameFromContext(c.Request.Context())
|
||||
if !ok || username == "" {
|
||||
httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required")
|
||||
return
|
||||
}
|
||||
filter := diplomail.AdminMessageListing{
|
||||
Page: parsePositiveQueryInt(c.Query("page"), 1),
|
||||
PageSize: parsePositiveQueryInt(c.Query("page_size"), 50),
|
||||
Kind: c.Query("kind"),
|
||||
SenderKind: c.Query("sender_kind"),
|
||||
}
|
||||
if raw := c.Query("game_id"); raw != "" {
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
filter.GameID = &parsed
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
page, err := h.svc.ListMessagesForAdmin(ctx, filter)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "admin mail list", ctx, err)
|
||||
return
|
||||
}
|
||||
out := adminDiplomailListResponseWire{
|
||||
Total: page.Total,
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Items: make([]adminDiplomailMessageWire, 0, len(page.Items)),
|
||||
}
|
||||
for _, m := range page.Items {
|
||||
entry := adminDiplomailMessageWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
SenderKind: m.SenderKind,
|
||||
SenderIP: m.SenderIP,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
}
|
||||
if m.SenderUserID != nil {
|
||||
s := m.SenderUserID.String()
|
||||
entry.SenderUserID = &s
|
||||
}
|
||||
if m.SenderUsername != nil {
|
||||
s := *m.SenderUsername
|
||||
entry.SenderUsername = &s
|
||||
}
|
||||
out.Items = append(out.Items, entry)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastRequestWire struct {
|
||||
Scope string `json:"scope"`
|
||||
GameIDs []string `json:"game_ids,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastMessageWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
}
|
||||
|
||||
type adminDiplomailBroadcastResponseWire struct {
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
Messages []adminDiplomailBroadcastMessageWire `json:"messages"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupRequestWire struct {
|
||||
OlderThanYears int `json:"older_than_years"`
|
||||
}
|
||||
|
||||
type adminDiplomailCleanupResponseWire struct {
|
||||
MessagesDeleted int `json:"messages_deleted"`
|
||||
GameIDs []string `json:"game_ids"`
|
||||
}
|
||||
|
||||
type adminDiplomailMessageWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
SenderIP string `json:"sender_ip,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type adminDiplomailListResponseWire struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Items []adminDiplomailMessageWire `json:"items"`
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/backend/internal/diplomail"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/server/clientip"
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/userid"
|
||||
"galaxy/backend/internal/telemetry"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserMailHandlers groups the diplomatic-mail handlers under
|
||||
// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side
|
||||
// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the
|
||||
// personal subset; Stage B adds the owner-only admin send path,
|
||||
// which needs `*lobby.Service` to confirm ownership and `*user.Service`
|
||||
// to resolve the owner's `user_name` for the `sender_username` column.
|
||||
type UserMailHandlers struct {
|
||||
svc *diplomail.Service
|
||||
lobby *lobby.Service
|
||||
users *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserMailHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented. lobby and
|
||||
// users are optional: when either is nil the admin-send handler
|
||||
// degrades to 501 (the personal-send and read paths stay functional).
|
||||
func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &UserMailHandlers{
|
||||
svc: svc,
|
||||
lobby: lobbySvc,
|
||||
users: users,
|
||||
logger: logger.Named("http.user.mail"),
|
||||
}
|
||||
}
|
||||
|
||||
// preferredLanguage looks up the caller's `accounts.preferred_language`
|
||||
// so the per-message read can attach the cached translation when
|
||||
// available. Failures are logged at debug level and the function
|
||||
// returns an empty string — translation is best-effort and the
|
||||
// caller still receives the original body.
|
||||
func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string {
|
||||
if h.users == nil {
|
||||
return ""
|
||||
}
|
||||
account, err := h.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
h.logger.Debug("resolve preferred_language failed",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
return account.PreferredLanguage
|
||||
}
|
||||
|
||||
// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages.
|
||||
func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSendPersonal")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
var recipientID uuid.UUID
|
||||
if req.RecipientUserID != "" {
|
||||
parsed, perr := uuid.Parse(req.RecipientUserID)
|
||||
if perr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
||||
func (h *UserMailHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false))
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox.
|
||||
func (h *UserMailHandlers) Inbox() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailInbox")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
targetLang := h.preferredLanguage(ctx, userID)
|
||||
items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail inbox", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
|
||||
for _, e := range items {
|
||||
out.Items = append(out.Items, mailMessageDetailToWire(e, false))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Sent handles GET /api/v1/user/games/{game_id}/mail/sent.
|
||||
func (h *UserMailHandlers) Sent() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSent")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.ListSent(ctx, gameID, userID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail sent", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailSentListWire{Items: make([]userMailMessageDetailWire, 0, len(items))}
|
||||
for _, entry := range items {
|
||||
out.Items = append(out.Items, mailMessageDetailToWire(entry, false))
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read.
|
||||
func (h *UserMailHandlers) MarkRead() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailMarkRead")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rcpt, err := h.svc.MarkRead(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail mark read", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}.
|
||||
func (h *UserMailHandlers) Delete() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailDelete")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
if _, ok := parseGameIDParam(c); !ok {
|
||||
return
|
||||
}
|
||||
messageID, ok := parseMessageIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail delete", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt))
|
||||
}
|
||||
}
|
||||
|
||||
// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast.
|
||||
//
|
||||
// The endpoint is the paid-tier player broadcast: any player on a
|
||||
// non-`free` entitlement tier may send one personal message that
|
||||
// fans out to every other active member of the game. The result
|
||||
// rows carry `kind="personal"`, `sender_kind="player"`,
|
||||
// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403.
|
||||
func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailSendBroadcast")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendBroadcastRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: userID,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
}
|
||||
}
|
||||
|
||||
// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin.
|
||||
//
|
||||
// Owner-only: the caller must be the owner of the private game. The
|
||||
// handler resolves the owner's `user_name` so the
|
||||
// `sender_username` column carries a useful identity, then routes to
|
||||
// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for
|
||||
// `target="all"`). Site administrators use the separate admin route
|
||||
// in `handlers_admin_mail_send.go`.
|
||||
func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
|
||||
if h.svc == nil || h.lobby == nil || h.users == nil {
|
||||
return handlers.NotImplemented("userMailSendAdmin")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req userMailSendAdminRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
game, err := h.lobby.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err)
|
||||
return
|
||||
}
|
||||
if game.OwnerUserID == nil || *game.OwnerUserID != userID {
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game")
|
||||
return
|
||||
}
|
||||
account, err := h.users.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Target {
|
||||
case "", "user":
|
||||
var recipientID uuid.UUID
|
||||
if req.RecipientUserID != "" {
|
||||
parsed, parseErr := uuid.Parse(req.RecipientUserID)
|
||||
if parseErr != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
recipientID = parsed
|
||||
}
|
||||
callerUserID := userID
|
||||
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindOwner,
|
||||
CallerUserID: &callerUserID,
|
||||
CallerUsername: account.UserName,
|
||||
RecipientUserID: recipientID,
|
||||
RecipientRaceName: req.RecipientRaceName,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true))
|
||||
case "all":
|
||||
callerUserID := userID
|
||||
msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
|
||||
GameID: gameID,
|
||||
CallerKind: diplomail.CallerKindOwner,
|
||||
CallerUserID: &callerUserID,
|
||||
CallerUsername: account.UserName,
|
||||
RecipientScope: req.Recipients,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
SenderIP: clientip.ExtractSourceIP(c),
|
||||
})
|
||||
if sendErr != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients))
|
||||
default:
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts.
|
||||
func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("userMailUnreadCounts")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
items, err := h.svc.UnreadCountsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err)
|
||||
return
|
||||
}
|
||||
out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))}
|
||||
total := 0
|
||||
for _, u := range items {
|
||||
out.Items = append(out.Items, userMailUnreadCountWire{
|
||||
GameID: u.GameID.String(),
|
||||
GameName: u.GameName,
|
||||
Unread: u.Unread,
|
||||
})
|
||||
total += u.Unread
|
||||
}
|
||||
out.Total = total
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// respondDiplomailError maps diplomail-package sentinels to the
|
||||
// standard JSON error envelope. Unknown errors land on a 500.
|
||||
func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, diplomail.ErrInvalidInput):
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
|
||||
case errors.Is(err, diplomail.ErrNotFound):
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
|
||||
case errors.Is(err, diplomail.ErrForbidden):
|
||||
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
|
||||
case errors.Is(err, diplomail.ErrConflict):
|
||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||
default:
|
||||
logger.Error(op+" failed",
|
||||
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
|
||||
)
|
||||
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
|
||||
}
|
||||
}
|
||||
|
||||
// parseMessageIDParam reads `message_id` from the path. Writes a 400
|
||||
// envelope on invalid input and returns false in that case.
|
||||
func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("message_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// userMailSendRequestWire mirrors the request body for SendPersonal.
|
||||
// Exactly one of `recipient_user_id` and `recipient_race_name` must
|
||||
// be supplied; the service rejects ambiguous and empty inputs.
|
||||
type userMailSendRequestWire struct {
|
||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailSendBroadcastRequestWire mirrors the request body for the
|
||||
// paid-tier player broadcast. There is no `target` discriminator —
|
||||
// the recipient set is always "every other active member".
|
||||
type userMailSendBroadcastRequestWire struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailSendAdminRequestWire mirrors the request body for the
|
||||
// owner-only admin send. `target="user"` requires exactly one of
|
||||
// `recipient_user_id` and `recipient_race_name`; `target="all"`
|
||||
// accepts the optional `recipients` scope (default `active`).
|
||||
type userMailSendAdminRequestWire struct {
|
||||
Target string `json:"target"`
|
||||
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||
Recipients string `json:"recipients,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// userMailBroadcastReceiptWire is the response shape returned after a
|
||||
// successful broadcast. It carries the canonical message metadata
|
||||
// together with the count of materialised recipient rows so the
|
||||
// caller (UI, admin tool) can confirm the fan-out happened.
|
||||
type userMailBroadcastReceiptWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RecipientCount int `json:"recipient_count"`
|
||||
}
|
||||
|
||||
func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire {
|
||||
return userMailBroadcastReceiptWire{
|
||||
MessageID: m.MessageID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
GameName: m.GameName,
|
||||
Kind: m.Kind,
|
||||
SenderKind: m.SenderKind,
|
||||
Subject: m.Subject,
|
||||
Body: m.Body,
|
||||
BodyLang: m.BodyLang,
|
||||
BroadcastScope: m.BroadcastScope,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(timestampLayout),
|
||||
RecipientCount: len(recipients),
|
||||
}
|
||||
}
|
||||
|
||||
// userMailMessageDetailWire mirrors the unified response shape for
|
||||
// inbox listings and per-message reads. Sender identifiers are
|
||||
// optional: system messages carry neither user id nor username.
|
||||
// Translation fields are populated when a cached rendering exists
|
||||
// for the caller's `preferred_language`; the UI renders
|
||||
// `body_translated` and surfaces the original through a
|
||||
// "show original" toggle.
|
||||
type userMailMessageDetailWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
SenderKind string `json:"sender_kind"`
|
||||
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||
SenderUsername *string `json:"sender_username,omitempty"`
|
||||
SenderRaceName *string `json:"sender_race_name,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
BodyLang string `json:"body_lang"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
RecipientUserID string `json:"recipient_user_id"`
|
||||
RecipientUserName string `json:"recipient_user_name,omitempty"`
|
||||
RecipientRaceName *string `json:"recipient_race_name,omitempty"`
|
||||
ReadAt *string `json:"read_at,omitempty"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||
Translator *string `json:"translator,omitempty"`
|
||||
}
|
||||
|
||||
type userMailInboxListWire struct {
|
||||
Items []userMailMessageDetailWire `json:"items"`
|
||||
}
|
||||
|
||||
// userMailSentListWire mirrors the response shape for the
|
||||
// sender-side listing. Phase 28's in-game UI threads sent messages
|
||||
// by the recipient's race name, so the wire carries the full
|
||||
// message detail (including the recipient snapshot) — single sends
|
||||
// contribute one row per message, broadcasts contribute one row per
|
||||
// addressee and the UI collapses them by `message_id`.
|
||||
type userMailSentListWire struct {
|
||||
Items []userMailMessageDetailWire `json:"items"`
|
||||
}
|
||||
|
||||
type userMailUnreadCountWire struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name,omitempty"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
type userMailUnreadCountsResponseWire struct {
|
||||
Total int `json:"total"`
|
||||
Items []userMailUnreadCountWire `json:"items"`
|
||||
}
|
||||
|
||||
func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire {
|
||||
out := userMailMessageDetailWire{
|
||||
MessageID: entry.MessageID.String(),
|
||||
GameID: entry.GameID.String(),
|
||||
GameName: entry.GameName,
|
||||
Kind: entry.Kind,
|
||||
SenderKind: entry.SenderKind,
|
||||
Subject: entry.Subject,
|
||||
Body: entry.Body,
|
||||
BodyLang: entry.BodyLang,
|
||||
BroadcastScope: entry.BroadcastScope,
|
||||
CreatedAt: entry.CreatedAt.UTC().Format(timestampLayout),
|
||||
RecipientUserID: entry.Recipient.UserID.String(),
|
||||
RecipientUserName: entry.Recipient.RecipientUserName,
|
||||
}
|
||||
if entry.SenderUserID != nil {
|
||||
s := entry.SenderUserID.String()
|
||||
out.SenderUserID = &s
|
||||
}
|
||||
if entry.SenderUsername != nil {
|
||||
s := *entry.SenderUsername
|
||||
out.SenderUsername = &s
|
||||
}
|
||||
if entry.SenderRaceName != nil {
|
||||
s := *entry.SenderRaceName
|
||||
out.SenderRaceName = &s
|
||||
}
|
||||
if entry.Recipient.RecipientRaceName != nil {
|
||||
s := *entry.Recipient.RecipientRaceName
|
||||
out.RecipientRaceName = &s
|
||||
}
|
||||
if entry.Recipient.ReadAt != nil {
|
||||
s := entry.Recipient.ReadAt.UTC().Format(timestampLayout)
|
||||
out.ReadAt = &s
|
||||
}
|
||||
if entry.Recipient.DeletedAt != nil {
|
||||
s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout)
|
||||
out.DeletedAt = &s
|
||||
}
|
||||
if entry.Translation != nil {
|
||||
tr := entry.Translation
|
||||
subj := tr.TranslatedSubject
|
||||
body := tr.TranslatedBody
|
||||
lang := tr.TargetLang
|
||||
engine := tr.Translator
|
||||
out.TranslatedSubject = &subj
|
||||
out.TranslatedBody = &body
|
||||
out.TranslationLang = &lang
|
||||
out.Translator = &engine
|
||||
}
|
||||
_ = justCreated
|
||||
return out
|
||||
}
|
||||
|
||||
// mailRecipientStateToWire renders the recipient row after a
|
||||
// mark-read or soft-delete call. The caller only needs the per-user
|
||||
// state, not the full message body again.
|
||||
func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire {
|
||||
out := userMailRecipientStateWire{
|
||||
MessageID: r.MessageID.String(),
|
||||
}
|
||||
if r.ReadAt != nil {
|
||||
s := r.ReadAt.UTC().Format(timestampLayout)
|
||||
out.ReadAt = &s
|
||||
}
|
||||
if r.DeletedAt != nil {
|
||||
s := r.DeletedAt.UTC().Format(timestampLayout)
|
||||
out.DeletedAt = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type userMailRecipientStateWire struct {
|
||||
MessageID string `json:"message_id"`
|
||||
ReadAt *string `json:"read_at,omitempty"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type RouterDependencies struct {
|
||||
UserLobbyMy *UserLobbyMyHandlers
|
||||
UserLobbyRaceNames *UserLobbyRaceNamesHandlers
|
||||
UserGames *UserGamesHandlers
|
||||
UserMail *UserMailHandlers
|
||||
UserSessions *UserSessionsHandlers
|
||||
AdminAdminAccounts *AdminAdminAccountsHandlers
|
||||
AdminUsers *AdminUsersHandlers
|
||||
@@ -75,6 +76,7 @@ type RouterDependencies struct {
|
||||
AdminRuntimes *AdminRuntimesHandlers
|
||||
AdminEngineVersions *AdminEngineVersionsHandlers
|
||||
AdminMail *AdminMailHandlers
|
||||
AdminDiplomail *AdminDiplomailHandlers
|
||||
AdminNotifications *AdminNotificationsHandlers
|
||||
AdminGeo *AdminGeoHandlers
|
||||
InternalSessions *InternalSessionsHandlers
|
||||
@@ -163,6 +165,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.UserGames == nil {
|
||||
deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserMail == nil {
|
||||
deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger)
|
||||
}
|
||||
if deps.UserSessions == nil {
|
||||
deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -184,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies {
|
||||
if deps.AdminMail == nil {
|
||||
deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminDiplomail == nil {
|
||||
deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger)
|
||||
}
|
||||
if deps.AdminNotifications == nil {
|
||||
deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger)
|
||||
}
|
||||
@@ -255,6 +263,9 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
my.GET("/invites", deps.UserLobbyMy.Invites())
|
||||
my.GET("/race-names", deps.UserLobbyMy.RaceNames())
|
||||
|
||||
lobbyMail := lobbyGroup.Group("/mail")
|
||||
lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts())
|
||||
|
||||
raceNames := lobbyGroup.Group("/race-names")
|
||||
raceNames.POST("/register", deps.UserLobbyRaceNames.Register())
|
||||
|
||||
@@ -265,6 +276,16 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||
|
||||
userMail := userGames.Group("/:game_id/mail")
|
||||
userMail.POST("/messages", deps.UserMail.SendPersonal())
|
||||
userMail.POST("/broadcast", deps.UserMail.SendBroadcast())
|
||||
userMail.POST("/admin", deps.UserMail.SendAdmin())
|
||||
userMail.GET("/messages/:message_id", deps.UserMail.Get())
|
||||
userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead())
|
||||
userMail.DELETE("/messages/:message_id", deps.UserMail.Delete())
|
||||
userMail.GET("/inbox", deps.UserMail.Inbox())
|
||||
userMail.GET("/sent", deps.UserMail.Sent())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll())
|
||||
@@ -299,6 +320,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
||||
games.POST("/:game_id/force-start", deps.AdminGames.ForceStart())
|
||||
games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop())
|
||||
games.POST("/:game_id/ban-member", deps.AdminGames.BanMember())
|
||||
games.POST("/:game_id/mail", deps.AdminDiplomail.Send())
|
||||
|
||||
runtimes := group.Group("/runtimes")
|
||||
runtimes.GET("/:game_id", deps.AdminRuntimes.Get())
|
||||
@@ -318,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d
|
||||
mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts())
|
||||
mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery())
|
||||
mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters())
|
||||
mail.GET("/messages", deps.AdminDiplomail.List())
|
||||
mail.POST("/broadcast", deps.AdminDiplomail.Broadcast())
|
||||
mail.POST("/cleanup", deps.AdminDiplomail.Cleanup())
|
||||
|
||||
notifications := group.Group("/notifications")
|
||||
notifications.GET("", deps.AdminNotifications.List())
|
||||
|
||||
@@ -1144,6 +1144,295 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendPersonal
|
||||
summary: Send a personal diplomatic mail message
|
||||
description: |
|
||||
Sends a replyable personal message from the authenticated user
|
||||
to another active member of the same game. Both sender and
|
||||
recipient must be active members. Body is plain UTF-8 text
|
||||
(no HTML processing on the server); `subject` is optional.
|
||||
Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||
(default 4096) and subject length at
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Personal message accepted and persisted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/broadcast:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendBroadcast
|
||||
summary: Send a paid-tier personal broadcast to a game's active members
|
||||
description: |
|
||||
Paid-tier players (`entitlement.is_paid == true`) may send one
|
||||
personal message that fans out to every other active member of
|
||||
the game. Free-tier callers receive 403. The resulting rows
|
||||
carry `kind="personal"`, `sender_kind="player"`,
|
||||
`broadcast_scope="game_broadcast"`. Recipients reply through
|
||||
the regular personal-send endpoint; the reply targets the
|
||||
broadcaster only.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendBroadcastRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Personal broadcast accepted; receipt carries the recipient count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/admin:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendAdmin
|
||||
summary: Send a non-replyable admin notification (owner only)
|
||||
description: |
|
||||
Owner-only: the caller must be the owner of the private game.
|
||||
`target="user"` requires `recipient_user_id`; `target="all"`
|
||||
accepts an optional `recipients` scope (`active` by default,
|
||||
plus `active_and_removed` and `all_members`). The message
|
||||
carries `kind="admin"` and is therefore non-replyable.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailGet
|
||||
summary: Read one diplomatic mail message
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
delete:
|
||||
tags: [User]
|
||||
operationId: userMailDelete
|
||||
summary: Soft-delete a previously-read message
|
||||
description: |
|
||||
Marks the caller's recipient row for the message as deleted.
|
||||
The underlying message stays persisted (admin / system mail is
|
||||
retained for the lifetime of the game). The recipient row must
|
||||
have `read_at` set first; otherwise the call returns 409.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message soft-deleted for the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"409":
|
||||
$ref: "#/components/responses/ConflictError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}/read:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailMarkRead
|
||||
summary: Mark a diplomatic mail message as read
|
||||
description: |
|
||||
Idempotent. Sets `read_at` on the caller's recipient row when
|
||||
it is still unread; a second call on an already-read row is a
|
||||
no-op and the existing state is returned.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Recipient state after the mark-read.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/inbox:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailInbox
|
||||
summary: List the caller's inbox for a game
|
||||
description: |
|
||||
Returns every non-soft-deleted mail row addressed to the
|
||||
caller in the given game, newest first. Includes the
|
||||
per-recipient read state. Soft access: the caller may not be
|
||||
an active member if every visible row carries
|
||||
`kind="admin"`.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Inbox entries for the caller in the given game.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailInboxList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/sent:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailSent
|
||||
summary: List the caller's sent personal messages in a game
|
||||
description: |
|
||||
Returns personal messages authored by the caller in the given
|
||||
game, newest first. Admin / system messages are not listed
|
||||
(they have no `sender_user_id`).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Sent personal messages by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSentList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/lobby/mail/unread-counts:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailUnreadCounts
|
||||
summary: Per-game and total unread mail counts for the caller
|
||||
description: |
|
||||
Drives the lobby badge: returns one entry per game the caller
|
||||
has any unread mail in, plus the global total. The response
|
||||
is empty (and `total == 0`) when there is nothing unread.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
responses:
|
||||
"200":
|
||||
description: Per-game unread counts addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailUnreadCountsResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/sessions:
|
||||
get:
|
||||
tags: [User]
|
||||
@@ -1704,6 +1993,176 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/broadcast:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailBroadcast
|
||||
summary: Multi-game admin broadcast
|
||||
description: |
|
||||
Fans out one admin-kind broadcast across the games selected
|
||||
by `scope`. `scope="selected"` requires `game_ids`;
|
||||
`scope="all_running"` enumerates every game whose status is
|
||||
non-terminal. Recipients are resolved per-game via the same
|
||||
scope vocabulary as the per-game admin send. A recipient
|
||||
appearing in multiple addressed games receives one
|
||||
independently-deletable inbox entry per game.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailBroadcastRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Broadcast accepted; per-game message ids and total recipient count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailBroadcastResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/cleanup:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailCleanup
|
||||
summary: Bulk-purge diplomail messages from old finished games
|
||||
description: |
|
||||
Removes every `diplomail_messages` row whose game finished
|
||||
more than `older_than_years` years ago. Cascading FKs prune
|
||||
the recipient and translation tables in the same transaction.
|
||||
`older_than_years` must be >= 1.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailCleanupRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Cleanup result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailCleanupResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/mail/messages:
|
||||
get:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailList
|
||||
summary: Paginated admin view of diplomail messages
|
||||
description: |
|
||||
Returns the canonical message rows for admin observability.
|
||||
Optional filters: `game_id`, `kind` (personal / admin),
|
||||
`sender_kind` (player / admin / system). Pagination via
|
||||
`page` and `page_size`.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: game_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: kind
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
- name: sender_kind
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated diplomail messages.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminDiplomailListResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/games/{game_id}/mail:
|
||||
post:
|
||||
tags: [Admin]
|
||||
operationId: adminDiplomailSend
|
||||
summary: Send a diplomatic-mail admin notification to one game
|
||||
description: |
|
||||
Site-admin send for the diplomatic-mail subsystem. Body shape
|
||||
mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin`
|
||||
endpoint. `target="user"` requires `recipient_user_id`;
|
||||
`target="all"` accepts an optional `recipients` scope
|
||||
(`active` / `active_and_removed` / `all_members`). The
|
||||
authenticated admin username is persisted as `sender_username`.
|
||||
security:
|
||||
- AdminBasicAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendAdminRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin message persisted; broadcasts return a fan-out receipt.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/UserMailMessageDetail"
|
||||
- $ref: "#/components/schemas/UserMailBroadcastReceipt"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"401":
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/admin/runtimes/{game_id}:
|
||||
get:
|
||||
tags: [Admin]
|
||||
@@ -2247,6 +2706,13 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
MessageID:
|
||||
name: message_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
NotificationID:
|
||||
name: notification_id
|
||||
in: path
|
||||
@@ -3599,6 +4065,407 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeviceSession"
|
||||
UserMailSendRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [body]
|
||||
properties:
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Either `recipient_user_id` or `recipient_race_name` must
|
||||
be supplied; supplying both is rejected as
|
||||
`invalid_request`.
|
||||
recipient_race_name:
|
||||
type: string
|
||||
description: |
|
||||
Resolves to the active member with this race name in the
|
||||
game. Mutually exclusive with `recipient_user_id`. The
|
||||
server returns `forbidden` when the matching member is no
|
||||
longer active (lobby-removed / blocked).
|
||||
subject:
|
||||
type: string
|
||||
description: |
|
||||
Optional subject. Empty string and missing field are
|
||||
treated the same.
|
||||
body:
|
||||
type: string
|
||||
description: Plain UTF-8 body. HTML is not parsed on the server.
|
||||
UserMailSendAdminRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [target, body]
|
||||
properties:
|
||||
target:
|
||||
type: string
|
||||
enum: [user, all]
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
One of `recipient_user_id` and `recipient_race_name` is
|
||||
required when `target="user"`. Identifies the recipient
|
||||
of the personal admin message by uuid; the recipient may
|
||||
be in any membership status (admin notifications can
|
||||
reach kicked players when addressed by user_id).
|
||||
recipient_race_name:
|
||||
type: string
|
||||
description: |
|
||||
Optional alternative to `recipient_user_id` when
|
||||
`target="user"`. Resolves to the active member with this
|
||||
race name in the game; lobby-removed and blocked members
|
||||
cannot be reached through the race-name shortcut.
|
||||
recipients:
|
||||
type: string
|
||||
enum: [active, active_and_removed, all_members]
|
||||
description: |
|
||||
Optional scope when `target="all"`. Defaults to `active`.
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
UserMailBroadcastReceipt:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
- recipient_count
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recipient_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
UserMailSendBroadcastRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [body]
|
||||
properties:
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
AdminDiplomailBroadcastRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [scope, body]
|
||||
properties:
|
||||
scope:
|
||||
type: string
|
||||
enum: [selected, all_running]
|
||||
game_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
recipients:
|
||||
type: string
|
||||
enum: [active, active_and_removed, all_members]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
AdminDiplomailBroadcastResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [recipient_count, messages]
|
||||
properties:
|
||||
recipient_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, game_id]
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
AdminDiplomailCleanupRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [older_than_years]
|
||||
properties:
|
||||
older_than_years:
|
||||
type: integer
|
||||
minimum: 1
|
||||
AdminDiplomailCleanupResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [messages_deleted, game_ids]
|
||||
properties:
|
||||
messages_deleted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
game_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
AdminDiplomailMessage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
sender_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sender_username:
|
||||
type: string
|
||||
nullable: true
|
||||
sender_ip:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
AdminDiplomailListResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [total, page, page_size, items]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
page:
|
||||
type: integer
|
||||
minimum: 1
|
||||
page_size:
|
||||
type: integer
|
||||
minimum: 1
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AdminDiplomailMessage"
|
||||
UserMailMessageDetail:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
- recipient_user_id
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
sender_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sender_username:
|
||||
type: string
|
||||
nullable: true
|
||||
sender_race_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Snapshot of the sender's race name in this game at send
|
||||
time. Populated when `sender_kind="player"` and the
|
||||
sender had an active membership at send time; nil for
|
||||
admin and system messages, and for player messages sent
|
||||
by a private-game owner who was not an active member at
|
||||
send time. The in-game UI keys per-race threading on this
|
||||
field.
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
description: BCP 47 tag. `und` until Stage D adds detection.
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
recipient_user_name:
|
||||
type: string
|
||||
recipient_race_name:
|
||||
type: string
|
||||
nullable: true
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
translated_subject:
|
||||
type: string
|
||||
description: |
|
||||
Subject rendered into the caller's preferred_language by
|
||||
the translation cache. Absent when the caller's language
|
||||
matches `body_lang` or the translator could not produce
|
||||
a rendering.
|
||||
translated_body:
|
||||
type: string
|
||||
description: |
|
||||
Body rendered into the caller's preferred_language. Same
|
||||
absence semantics as `translated_subject`.
|
||||
translation_lang:
|
||||
type: string
|
||||
description: BCP 47 tag of the rendered translation.
|
||||
translator:
|
||||
type: string
|
||||
description: Identifier of the translation engine that produced the cached row.
|
||||
UserMailInboxList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
UserMailSentList:
|
||||
description: |
|
||||
Sender-side listing of personal messages authored by the
|
||||
caller. Each item carries the same shape as inbox entries
|
||||
(including the recipient snapshot); single sends contribute
|
||||
one row per message, broadcasts contribute one row per
|
||||
addressee so the in-game UI can collapse them by
|
||||
`message_id` into a single stand-alone item.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
UserMailUnreadCount:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [game_id, unread]
|
||||
properties:
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
unread:
|
||||
type: integer
|
||||
minimum: 0
|
||||
UserMailUnreadCountsResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [total, items]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailUnreadCount"
|
||||
UserMailRecipientState:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id]
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
NotImplementedError:
|
||||
description: Endpoint is documented but not implemented yet.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Client for Galaxy Plus
|
||||
|
||||
UI Client is capable of:
|
||||
|
||||
- Register a new player and login for an existing player using only e-mail and one-time codes,
|
||||
- Enlist to a new Game from available onboard Games list,
|
||||
- Request list of Games in which Player participating,
|
||||
- Request, store and display particular Game data,
|
||||
- Use push-like mechanism for receiving asynchronous updates from Server,
|
||||
- Offline mode when no internet connection is available or user desired to work offline.
|
||||
@@ -1,10 +0,0 @@
|
||||
// Package appmeta provides shared application metadata used by both the
|
||||
// bootstrap loader process and the standalone UI client process.
|
||||
package appmeta
|
||||
|
||||
const (
|
||||
// AppID is the shared Fyne application identifier used for a common storage root.
|
||||
AppID = "GalaxyPlus"
|
||||
// DefaultBackendURL is the default backend HTTP endpoint used by local runs.
|
||||
DefaultBackendURL = "http://127.0.0.1:8080"
|
||||
)
|
||||
@@ -1,116 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gerr "galaxy/error"
|
||||
)
|
||||
|
||||
var (
|
||||
checkConnectionInterval = 5 * time.Second
|
||||
checkVersionInterval = time.Hour
|
||||
statePersistInterval = time.Second
|
||||
)
|
||||
|
||||
func (e *client) startBackground() {
|
||||
if e.conn == nil || e.updater == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go e.backgroundLoop()
|
||||
}
|
||||
|
||||
func (e *client) stopBackground() {
|
||||
e.backgroundOnce.Do(func() {
|
||||
close(e.backgroundStop)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *client) backgroundLoop() {
|
||||
checkConnTimer := time.NewTimer(checkConnectionInterval)
|
||||
checkVersionTimer := time.NewTimer(checkVersionInterval)
|
||||
persistStateTimer := time.NewTimer(statePersistInterval)
|
||||
defer func() {
|
||||
checkConnTimer.Stop()
|
||||
checkVersionTimer.Stop()
|
||||
persistStateTimer.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.backgroundStop:
|
||||
return
|
||||
case <-checkConnTimer.C:
|
||||
if e.conn != nil {
|
||||
e.OnConnection(e.conn.CheckConnection())
|
||||
}
|
||||
checkConnTimer.Reset(checkConnectionInterval)
|
||||
case <-checkVersionTimer.C:
|
||||
if e.updater != nil {
|
||||
if err := e.updater.CheckAndPrepareLatest(); err != nil {
|
||||
e.handlerError(err)
|
||||
}
|
||||
}
|
||||
checkVersionTimer.Reset(checkVersionInterval)
|
||||
case <-persistStateTimer.C:
|
||||
e.ensureStatePersist()
|
||||
persistStateTimer.Reset(statePersistInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) ensureStatePersist() {
|
||||
param := e.GetParams()
|
||||
needSaving := false
|
||||
e.stateMu.Lock()
|
||||
if e.world != nil {
|
||||
if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom {
|
||||
e.state.CameraZoom = param.CameraZoom
|
||||
needSaving = true
|
||||
}
|
||||
if param.CameraXWorldFp != e.state.CameraXFp {
|
||||
e.state.CameraXFp = param.CameraXWorldFp
|
||||
needSaving = true
|
||||
}
|
||||
if param.CameraYWorldFp != e.state.CameraYFp {
|
||||
e.state.CameraYFp = param.CameraYWorldFp
|
||||
needSaving = true
|
||||
}
|
||||
}
|
||||
if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset {
|
||||
e.state.MapSplitterOffset = e.mapSplitter.Offset
|
||||
needSaving = true
|
||||
}
|
||||
if e.accInfo.Open != e.state.AccordionInfoOpen {
|
||||
e.state.AccordionInfoOpen = e.accInfo.Open
|
||||
needSaving = true
|
||||
}
|
||||
if e.accCalc.Open != e.state.AccordionCalcOpen {
|
||||
e.state.AccordionCalcOpen = e.accCalc.Open
|
||||
needSaving = true
|
||||
}
|
||||
if needSaving {
|
||||
if err := e.s.SaveState(*e.state); err != nil {
|
||||
e.handlerError(err)
|
||||
}
|
||||
}
|
||||
e.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *client) handlerError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("ERROR: %s\n", err)
|
||||
|
||||
switch {
|
||||
case gerr.IsConnection(err):
|
||||
e.OnConnectionError(err)
|
||||
case gerr.IsStorage(err):
|
||||
e.OnStorageError(err)
|
||||
default:
|
||||
e.OnServiceError(err)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
gerr "galaxy/error"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandlerErrorDispatchesByClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantEvent string
|
||||
}{
|
||||
{name: "connection", err: gerr.WrapConnection(errors.New("dial")), wantEvent: "connection"},
|
||||
{name: "storage", err: gerr.WrapStorage(errors.New("write file")), wantEvent: "storage"},
|
||||
{name: "service", err: gerr.WrapService(errors.New("bad response")), wantEvent: "service"},
|
||||
{name: "unclassified defaults to service", err: errors.New("plain"), wantEvent: "service"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got string
|
||||
c := &client{
|
||||
onConnectionErrFn: func(error) { got = "connection" },
|
||||
onStorageErrFn: func(error) { got = "storage" },
|
||||
onServiceErrFn: func(error) { got = "service" },
|
||||
}
|
||||
|
||||
c.handlerError(tt.err)
|
||||
|
||||
require.Equal(t, tt.wantEvent, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type interactiveRaster struct {
|
||||
widget.BaseWidget
|
||||
|
||||
min fyne.Size
|
||||
raster *canvas.Raster
|
||||
onLayout func(fyne.Size)
|
||||
onScrolled func(*fyne.ScrollEvent)
|
||||
onDragged func(*fyne.DragEvent)
|
||||
onDragEnd func()
|
||||
onTapped func(*fyne.PointEvent)
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) SetMinSize(size fyne.Size) {
|
||||
r.min = size
|
||||
r.Resize(size)
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) MinSize() fyne.Size {
|
||||
return r.min
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) CreateRenderer() fyne.WidgetRenderer {
|
||||
return &rasterWidgetRender{
|
||||
canvas: r,
|
||||
bg: canvas.NewRasterWithPixels(bgPattern),
|
||||
onLayout: r.onLayout,
|
||||
}
|
||||
}
|
||||
|
||||
// Tapped is a left-click event
|
||||
func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
||||
if r.onTapped == nil {
|
||||
return
|
||||
}
|
||||
r.onTapped(ev)
|
||||
}
|
||||
|
||||
// TappedSecondary is a right-click event
|
||||
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
|
||||
|
||||
func newInteractiveRaster(
|
||||
raster *canvas.Raster,
|
||||
onLayout func(fyne.Size),
|
||||
onScrolled func(*fyne.ScrollEvent),
|
||||
onDragged func(*fyne.DragEvent),
|
||||
onDragEnd func(),
|
||||
onTapped func(*fyne.PointEvent),
|
||||
) *interactiveRaster {
|
||||
r := &interactiveRaster{
|
||||
raster: raster,
|
||||
onLayout: onLayout,
|
||||
onScrolled: onScrolled,
|
||||
onDragged: onDragged,
|
||||
onDragEnd: onDragEnd,
|
||||
onTapped: onTapped,
|
||||
}
|
||||
r.ExtendBaseWidget(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func bgPattern(x, y, _, _ int) color.Color {
|
||||
const boxSize = 25
|
||||
|
||||
if (x/boxSize)%2 == (y/boxSize)%2 {
|
||||
return color.Gray{Y: 58}
|
||||
}
|
||||
|
||||
return color.Gray{Y: 84}
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) Scrolled(e *fyne.ScrollEvent) {
|
||||
if r.onScrolled == nil {
|
||||
return
|
||||
}
|
||||
r.onScrolled(e)
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) Dragged(e *fyne.DragEvent) {
|
||||
if r.onDragged == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.onDragged(e)
|
||||
}
|
||||
|
||||
func (r *interactiveRaster) DragEnd() {
|
||||
if r.onDragEnd == nil {
|
||||
return
|
||||
}
|
||||
r.onDragEnd()
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
|
||||
"galaxy/client/updater"
|
||||
"galaxy/client/widget/calculator"
|
||||
"galaxy/client/world"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/storage"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
const version = "1.0.0"
|
||||
|
||||
type client struct {
|
||||
s storage.Storage
|
||||
conn connector.Connector
|
||||
app fyne.App
|
||||
window fyne.Window
|
||||
|
||||
state *mc.State
|
||||
stateMu sync.RWMutex
|
||||
|
||||
reg *registry
|
||||
|
||||
calculator *calculator.Calculator
|
||||
mapSplitter *container.Split
|
||||
accInfo *widget.AccordionItem
|
||||
accCalc *widget.AccordionItem
|
||||
|
||||
// loadReportFunc func(uint)
|
||||
|
||||
world *world.World
|
||||
drawer *world.GGDrawer
|
||||
raster *canvas.Raster
|
||||
co *RasterCoalescer[world.RenderParams]
|
||||
pan *PanController
|
||||
|
||||
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
|
||||
// Viewport/margins are NOT stored here; they come from raster draw callback.
|
||||
mu sync.RWMutex
|
||||
wp *world.RenderParams
|
||||
canvasScale float32
|
||||
|
||||
// Latest raster geometry metadata for correct event->pixel conversion:
|
||||
// - logical size: raster.Size() (Fyne units)
|
||||
// - pixel size: last (wPx,hPx) passed to draw callback
|
||||
metaMu sync.RWMutex
|
||||
lastRasterLogicW float32
|
||||
lastRasterLogicH float32
|
||||
lastRasterPxW int
|
||||
lastRasterPxH int
|
||||
lastCanvasScale float32 // optional, useful for debugging
|
||||
|
||||
// Snapshot of params actually used for the last render (includes viewport/margins).
|
||||
// Used for HitTest and to keep UI interactions consistent with what the user sees.
|
||||
lastRenderedMu sync.RWMutex
|
||||
lastRenderedParams world.RenderParams
|
||||
|
||||
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
|
||||
lastIndexedViewportW int
|
||||
lastIndexedViewportH int
|
||||
lastIndexedZoomFp int
|
||||
|
||||
lastCanvasW int
|
||||
lastCanvasH int
|
||||
|
||||
viewportImg *image.RGBA
|
||||
viewportW int
|
||||
viewportH int
|
||||
|
||||
hits []world.Hit
|
||||
|
||||
updater *updater.Manager
|
||||
backgroundStop chan struct{}
|
||||
backgroundOnce sync.Once
|
||||
|
||||
onConnectionFn func(bool)
|
||||
onConnectionErrFn func(error)
|
||||
onStorageErrFn func(error)
|
||||
onServiceErrFn func(error)
|
||||
}
|
||||
|
||||
func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) {
|
||||
e := &client{
|
||||
s: s,
|
||||
conn: conn,
|
||||
app: app,
|
||||
window: app.NewWindow("Galaxy Plus"),
|
||||
reg: newRegistry(),
|
||||
lastCanvasScale: 1.0,
|
||||
world: nil,
|
||||
hits: make([]world.Hit, 5),
|
||||
backgroundStop: make(chan struct{}),
|
||||
}
|
||||
e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass))
|
||||
e.updater = updater.NewManager(e.s, e.conn)
|
||||
|
||||
stateExists, err := e.s.StateExists()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stateExists {
|
||||
state, err := e.s.LoadState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.state = &state
|
||||
} else {
|
||||
e.state = &mc.State{
|
||||
ClientCurrentVersion: e.Version(),
|
||||
CameraZoom: 1.0,
|
||||
MapSplitterOffset: 0.5,
|
||||
AccordionInfoOpen: false,
|
||||
AccordionCalcOpen: false,
|
||||
}
|
||||
if err := e.s.SaveState(*e.state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if e.state.CameraZoom <= 0 {
|
||||
e.state.CameraZoom = 1.0
|
||||
}
|
||||
if e.state.MapSplitterOffset <= 0 {
|
||||
e.state.MapSplitterOffset = 0.5
|
||||
}
|
||||
e.wp = &world.RenderParams{
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
CameraZoom: e.state.CameraZoom,
|
||||
CameraXWorldFp: e.state.CameraXFp,
|
||||
CameraYWorldFp: e.state.CameraYFp,
|
||||
}
|
||||
|
||||
e.drawer = &world.GGDrawer{DC: nil}
|
||||
|
||||
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||
return e.draw(wPx, hPx)
|
||||
})
|
||||
|
||||
e.pan = NewPanController(e)
|
||||
|
||||
e.co = NewRasterCoalescer(
|
||||
FyneExecutor{},
|
||||
e.raster,
|
||||
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||
return e.renderRasterImage(wPx, hPx, p)
|
||||
},
|
||||
)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *client) BuildUI(w fyne.Window) {
|
||||
mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
|
||||
toolbar := widget.NewToolbar(
|
||||
widget.NewToolbarAction(
|
||||
theme.FolderIcon(),
|
||||
func() { e.initReportAsync("GAME_ID", 0) }),
|
||||
widget.NewToolbarSeparator(),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateBackIcon(),
|
||||
func() {}),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateNextIcon(),
|
||||
func() {}),
|
||||
)
|
||||
|
||||
e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack())
|
||||
e.accInfo.Open = e.state.AccordionInfoOpen
|
||||
e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject)
|
||||
e.accCalc.Open = e.state.AccordionCalcOpen
|
||||
|
||||
accordion := widget.NewAccordion()
|
||||
accordion.MultiOpen = true
|
||||
accordion.Append(e.accCalc)
|
||||
accordion.Append(e.accInfo)
|
||||
|
||||
e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion))
|
||||
e.mapSplitter.SetOffset(e.state.MapSplitterOffset)
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon(
|
||||
lang.L("title.map"),
|
||||
theme.GridIcon(),
|
||||
e.mapSplitter),
|
||||
container.NewTabItemWithIcon(
|
||||
"Calculator",
|
||||
theme.ComputerIcon(),
|
||||
container.NewStack(widget.NewButton("Calc", func() {})),
|
||||
),
|
||||
)
|
||||
|
||||
th := tabs.Theme()
|
||||
icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo))
|
||||
|
||||
statusLeft := widget.NewTextGridFromString("Status")
|
||||
statusAd := widget.NewTextGridFromString("")
|
||||
|
||||
statusBar := container.NewBorder(
|
||||
nil, // top
|
||||
nil, // bottom
|
||||
container.NewHBox(statusLeft, widget.NewSeparator()), // left
|
||||
container.NewHBox(widget.NewSeparator(), icon), // right
|
||||
statusAd, // center
|
||||
)
|
||||
|
||||
content := container.NewBorder(
|
||||
toolbar, // top
|
||||
statusBar, // bottom
|
||||
nil, // left
|
||||
nil, // right
|
||||
tabs, // center
|
||||
)
|
||||
|
||||
w.CenterOnScreen()
|
||||
w.SetContent(content)
|
||||
s := statusBar.Size()
|
||||
icon.SetMinSize(fyne.NewSize(s.Height, s.Height))
|
||||
e.initLatestReport()
|
||||
}
|
||||
|
||||
func (e *client) loadWorld(w *world.World) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.SetCircleRadiusScaleFp(world.SCALE / 1000)
|
||||
e.world = w
|
||||
// TODO: store camera position in user settings
|
||||
e.wp.CameraXWorldFp = w.W / 2
|
||||
e.wp.CameraYWorldFp = w.H / 2
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) Run() error {
|
||||
e.BuildUI(e.window)
|
||||
e.startBackground()
|
||||
e.RequestRefresh()
|
||||
e.window.SetMaster()
|
||||
e.window.Resize(fyne.NewSize(800, 600))
|
||||
e.window.CenterOnScreen()
|
||||
e.window.SetOnClosed(e.Shutdown)
|
||||
e.window.ShowAndRun()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *client) Shutdown() {
|
||||
e.stopBackground()
|
||||
e.ensureStatePersist()
|
||||
e.window.Close()
|
||||
}
|
||||
|
||||
// TODO: remove func?
|
||||
func (e *client) Version() string { return version }
|
||||
|
||||
func (e *client) OnConnection(isGood bool) {
|
||||
if e.onConnectionFn != nil {
|
||||
e.onConnectionFn(isGood)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) OnConnectionError(err error) {
|
||||
if e.onConnectionErrFn != nil {
|
||||
e.onConnectionErrFn(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) OnStorageError(err error) {
|
||||
if e.onStorageErrFn != nil {
|
||||
e.onStorageErrFn(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) OnServiceError(err error) {
|
||||
if e.onServiceErrFn != nil {
|
||||
e.onServiceErrFn(err)
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testExecutor struct {
|
||||
mu sync.Mutex
|
||||
queue []func()
|
||||
}
|
||||
|
||||
func (e *testExecutor) Post(fn func()) {
|
||||
e.mu.Lock()
|
||||
e.queue = append(e.queue, fn)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *testExecutor) FlushAll() {
|
||||
for {
|
||||
var fn func()
|
||||
e.mu.Lock()
|
||||
if len(e.queue) > 0 {
|
||||
fn = e.queue[0]
|
||||
e.queue = e.queue[1:]
|
||||
}
|
||||
e.mu.Unlock()
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
type testRefresher struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
}
|
||||
|
||||
func (r *testRefresher) Refresh() {
|
||||
r.mu.Lock()
|
||||
r.count++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *testRefresher) Count() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.count
|
||||
}
|
||||
|
||||
func TestRasterCoalescer_RequestBeforeDraw_CoalescesToLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &testExecutor{}
|
||||
ref := &testRefresher{}
|
||||
|
||||
var got []int
|
||||
|
||||
co := NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||
got = append(got, p)
|
||||
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
})
|
||||
|
||||
co.Request(1)
|
||||
co.Request(2)
|
||||
co.Request(3)
|
||||
|
||||
// Only a single refresh should be scheduled before the next Draw().
|
||||
exec.FlushAll()
|
||||
require.Equal(t, 1, ref.Count())
|
||||
|
||||
_ = co.Draw(10, 10)
|
||||
require.Equal(t, []int{3}, got)
|
||||
}
|
||||
|
||||
func TestRasterCoalescer_RequestDuringDraw_SchedulesOneFollowUpRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &testExecutor{}
|
||||
ref := &testRefresher{}
|
||||
var got []int
|
||||
|
||||
var co *RasterCoalescer[int]
|
||||
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||
got = append(got, p)
|
||||
if p == 1 {
|
||||
co.Request(2)
|
||||
co.Request(3)
|
||||
}
|
||||
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
})
|
||||
|
||||
co.Request(1)
|
||||
|
||||
exec.FlushAll()
|
||||
require.Equal(t, 1, ref.Count())
|
||||
|
||||
// First draw renders 1 and schedules exactly one additional refresh.
|
||||
_ = co.Draw(10, 10)
|
||||
exec.FlushAll()
|
||||
require.Equal(t, 2, ref.Count())
|
||||
|
||||
// Second draw renders latest (3).
|
||||
_ = co.Draw(10, 10)
|
||||
require.Equal(t, []int{1, 3}, got)
|
||||
}
|
||||
|
||||
func TestRasterCoalescer_ManyRequestsWhileDrawing_StillOnlyOneExtraRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &testExecutor{}
|
||||
ref := &testRefresher{}
|
||||
var got []int
|
||||
|
||||
var co *RasterCoalescer[int]
|
||||
co = NewRasterCoalescer(exec, ref, func(w, h int, p int) image.Image {
|
||||
got = append(got, p)
|
||||
if p == 1 {
|
||||
for i := 2; i <= 50; i++ {
|
||||
co.Request(i)
|
||||
}
|
||||
}
|
||||
return image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
})
|
||||
|
||||
co.Request(1)
|
||||
exec.FlushAll()
|
||||
require.Equal(t, 1, ref.Count())
|
||||
|
||||
_ = co.Draw(10, 10)
|
||||
exec.FlushAll()
|
||||
require.Equal(t, 2, ref.Count())
|
||||
|
||||
_ = co.Draw(10, 10)
|
||||
require.Equal(t, []int{1, 50}, got)
|
||||
}
|
||||
|
||||
func TestCopyViewportRGBA_CopiesROIAndIsIndependentFromSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, 5, 6))
|
||||
|
||||
// Fill src with a pattern: pixel (x,y) has RGBA = (x, y, 0, 255).
|
||||
for y := 0; y < 20; y++ {
|
||||
for x := 0; x < 20; x++ {
|
||||
off := y*src.Stride + x*4
|
||||
src.Pix[off+0] = byte(x)
|
||||
src.Pix[off+1] = byte(y)
|
||||
src.Pix[off+2] = 0
|
||||
src.Pix[off+3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
marginX, marginY := 7, 9
|
||||
copyViewportRGBA(dst, src, marginX, marginY, 5, 6)
|
||||
|
||||
// Verify a few pixels in dst match the expected source ROI.
|
||||
// dst(0,0) == src(marginX, marginY)
|
||||
{
|
||||
off := 0*dst.Stride + 0*4
|
||||
require.Equal(t, byte(marginX), dst.Pix[off+0])
|
||||
require.Equal(t, byte(marginY), dst.Pix[off+1])
|
||||
require.Equal(t, byte(255), dst.Pix[off+3])
|
||||
}
|
||||
// dst(4,5) == src(marginX+4, marginY+5)
|
||||
{
|
||||
off := 5*dst.Stride + 4*4
|
||||
require.Equal(t, byte(marginX+4), dst.Pix[off+0])
|
||||
require.Equal(t, byte(marginY+5), dst.Pix[off+1])
|
||||
require.Equal(t, byte(255), dst.Pix[off+3])
|
||||
}
|
||||
|
||||
// Mutate src ROI after copy and ensure dst is unchanged (no aliasing).
|
||||
{
|
||||
off := (marginY+0)*src.Stride + (marginX+0)*4
|
||||
src.Pix[off+0] = 200
|
||||
src.Pix[off+1] = 201
|
||||
src.Pix[off+3] = 123
|
||||
}
|
||||
|
||||
offDst := 0*dst.Stride + 0*4
|
||||
require.Equal(t, byte(marginX), dst.Pix[offDst+0])
|
||||
require.Equal(t, byte(marginY), dst.Pix[offDst+1])
|
||||
require.Equal(t, byte(255), dst.Pix[offDst+3])
|
||||
}
|
||||
|
||||
func TestEventPosToPixel_FloorMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := &client{}
|
||||
|
||||
// Pretend raster logical is 100x50, pixel is 1000x500.
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterLogicW = 100
|
||||
e.lastRasterLogicH = 50
|
||||
e.lastRasterPxW = 1000
|
||||
e.lastRasterPxH = 500
|
||||
e.metaMu.Unlock()
|
||||
|
||||
x, y, ok := e.eventPosToPixel(0, 0)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 0, x)
|
||||
require.Equal(t, 0, y)
|
||||
|
||||
// Middle
|
||||
x, y, ok = e.eventPosToPixel(50, 25)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 500, x)
|
||||
require.Equal(t, 250, y)
|
||||
|
||||
// Near max logical should map near max pixel with floor.
|
||||
x, y, ok = e.eventPosToPixel(99.9, 49.9)
|
||||
require.True(t, ok)
|
||||
require.GreaterOrEqual(t, x, 998)
|
||||
require.GreaterOrEqual(t, y, 498)
|
||||
|
||||
// Clamp
|
||||
x, y, ok = e.eventPosToPixel(-10, 999)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 0, x)
|
||||
require.Equal(t, 500, y)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/client"
|
||||
"galaxy/client/appmeta"
|
||||
"galaxy/client/loader"
|
||||
"galaxy/connector/http"
|
||||
"galaxy/storage/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
defer func() {
|
||||
if err == nil {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.Join(err, fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
app := app.NewWithID(appmeta.AppID)
|
||||
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||
return
|
||||
}
|
||||
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
l, err := loader.NewLoader(s, c, app)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = l.Run(ctx)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/client"
|
||||
"galaxy/client/appmeta"
|
||||
"galaxy/connector/http"
|
||||
"galaxy/storage/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
defer func() {
|
||||
if err == nil {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.Join(err, fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
app := app.NewWithID(appmeta.AppID)
|
||||
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||
return
|
||||
}
|
||||
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c, err := client.NewClient(s, conn, app)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = c.Run()
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package client
|
||||
|
||||
/*
|
||||
Fyne-friendly latest-wins coalescing for canvas.NewRaster(draw func(w,h int) image.Image).
|
||||
|
||||
Key property:
|
||||
- draw() renders at most once per invocation (never loops).
|
||||
- if new requests arrived while drawing, we schedule exactly one extra Refresh.
|
||||
*/
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// UIExecutor posts a function to run on the UI/main thread.
|
||||
type UIExecutor interface {
|
||||
Post(fn func())
|
||||
}
|
||||
|
||||
// Refresher is the minimal interface we need from fyne.CanvasObject / Raster.
|
||||
type Refresher interface {
|
||||
Refresh()
|
||||
}
|
||||
|
||||
// RasterRenderer renders the latest params and returns an image.
|
||||
// Must be called on the UI thread (inside draw callback).
|
||||
type RasterRenderer[P any] func(wPx, hPx int, params P) image.Image
|
||||
|
||||
// RasterCoalescer implements latest-wins coalescing for raster rendering.
|
||||
// It is designed specifically for toolkits like fyne where the system calls draw(w,h)
|
||||
// and expects a returned image.
|
||||
type RasterCoalescer[P any] struct {
|
||||
exec UIExecutor
|
||||
refresher Refresher
|
||||
renderer RasterRenderer[P]
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// inDraw == true while Draw() is running on UI thread.
|
||||
inDraw bool
|
||||
|
||||
// refreshQueued == true when we have already posted a Refresh() that has not yet
|
||||
// resulted in a Draw() call (or is expected to call Draw soon).
|
||||
refreshQueued bool
|
||||
|
||||
// pending == true when new params arrived while inDraw==true.
|
||||
// Draw() will schedule exactly one follow-up Refresh after it returns.
|
||||
pending bool
|
||||
|
||||
latest P
|
||||
have bool
|
||||
}
|
||||
|
||||
// NewRasterCoalescer creates a new coalescer.
|
||||
// - exec.Post must run fn on UI thread.
|
||||
// - refresher.Refresh will trigger the framework to call draw(w,h).
|
||||
func NewRasterCoalescer[P any](exec UIExecutor, refresher Refresher, renderer RasterRenderer[P]) *RasterCoalescer[P] {
|
||||
if exec == nil {
|
||||
panic("RasterCoalescer: nil executor")
|
||||
}
|
||||
if refresher == nil {
|
||||
panic("RasterCoalescer: nil refresher")
|
||||
}
|
||||
if renderer == nil {
|
||||
panic("RasterCoalescer: nil renderer")
|
||||
}
|
||||
return &RasterCoalescer[P]{exec: exec, refresher: refresher, renderer: renderer}
|
||||
}
|
||||
|
||||
// Request stores the latest params and schedules exactly one refresh (latest-wins).
|
||||
// Can be called from any goroutine.
|
||||
func (c *RasterCoalescer[P]) Request(params P) {
|
||||
c.mu.Lock()
|
||||
c.latest = params
|
||||
c.have = true
|
||||
|
||||
// If we are currently inside Draw(), don't schedule refresh immediately.
|
||||
// Just mark pending; Draw() will schedule one follow-up refresh after it returns.
|
||||
if c.inDraw {
|
||||
c.pending = true
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Not drawing. Schedule at most one refresh until the next Draw() happens.
|
||||
if c.refreshQueued {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
c.refreshQueued = true
|
||||
c.mu.Unlock()
|
||||
|
||||
c.exec.Post(c.refresher.Refresh)
|
||||
}
|
||||
|
||||
// Draw must be called from the raster draw callback on the UI thread.
|
||||
// It renders exactly once with the latest snapshot.
|
||||
// If more requests arrived while drawing, it schedules exactly one extra refresh.
|
||||
func (c *RasterCoalescer[P]) Draw(wPx, hPx int) image.Image {
|
||||
c.mu.Lock()
|
||||
// A Draw call corresponds to a previously scheduled refresh being serviced.
|
||||
c.refreshQueued = false
|
||||
|
||||
if !c.have {
|
||||
c.mu.Unlock()
|
||||
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
|
||||
}
|
||||
|
||||
c.inDraw = true
|
||||
c.pending = false
|
||||
params := c.latest
|
||||
c.mu.Unlock()
|
||||
|
||||
img := c.renderer(wPx, hPx, params)
|
||||
|
||||
c.mu.Lock()
|
||||
needAnother := c.pending
|
||||
c.pending = false
|
||||
c.inDraw = false
|
||||
|
||||
// If we need another frame, schedule exactly one refresh (if not already queued).
|
||||
if needAnother && !c.refreshQueued {
|
||||
c.refreshQueued = true
|
||||
c.mu.Unlock()
|
||||
c.exec.Post(c.refresher.Refresh)
|
||||
return img
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
return img
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package client
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed resource/lang
|
||||
var Translations embed.FS
|
||||
@@ -1,46 +0,0 @@
|
||||
module galaxy/client
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.3
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.3.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.1 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
|
||||
fyne.io/fyne/v2 v2.7.3/go.mod h1:gu+dlIcZWSzKZmnrY8Fbnj2Hirabv2ek+AKsfQ2bBlw=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||
github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc=
|
||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
|
||||
github.com/go-text/typesetting v0.3.3/go.mod h1:vIRUT25mLQaSh4C8H/lIsKppQz/Gdb8Pu/tNwpi52ts=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
|
||||
github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,61 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"galaxy/client/world"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
var m = func(v int) float64 { return float64(v) / float64(world.SCALE) }
|
||||
|
||||
func (e *client) onTapped(ev *fyne.PointEvent) {
|
||||
if e.world == nil || ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
params := e.getLastRenderedParams()
|
||||
hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx)
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(hits) == 0 {
|
||||
e.calculator.UnloadPlanet()
|
||||
return
|
||||
}
|
||||
|
||||
for i := range hits {
|
||||
e.onHit(hits[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) onHit(hit world.Hit) {
|
||||
// var coord string
|
||||
// if hit.Kind == world.KindLine {
|
||||
// coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
|
||||
// } else {
|
||||
// coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
|
||||
// }
|
||||
// fmt.Println("hit:", hit.ID, "Coord:", coord)
|
||||
switch hit.Kind {
|
||||
case world.KindPoint:
|
||||
case world.KindCircle:
|
||||
e.onHitCircle(hit.ID)
|
||||
case world.KindLine:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) onHitCircle(id world.PrimitiveID) {
|
||||
p, ok := e.reg.localPlanet(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
e.calculator.LoadPlanet(p.Name, p.Number, p.FreeIndustry.F(), p.Material.F(), p.Resources.F())
|
||||
e.calculator.Refresh()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"galaxy/client/updater"
|
||||
"galaxy/connector"
|
||||
"galaxy/storage"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
const (
|
||||
loaderLogViewportColumns = 80
|
||||
loaderLogViewportRows = 12
|
||||
)
|
||||
|
||||
type loader struct {
|
||||
app fyne.App
|
||||
storage storage.Storage
|
||||
connector connector.Connector
|
||||
updater *updater.Manager
|
||||
runner uiRunner
|
||||
debugWindow fyne.Window
|
||||
textGrid *widget.TextGrid
|
||||
btn *widget.Button
|
||||
|
||||
ctx context.Context
|
||||
|
||||
resultMu sync.Mutex
|
||||
result error
|
||||
|
||||
closeMu sync.Mutex
|
||||
closeQuits bool
|
||||
}
|
||||
|
||||
// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size
|
||||
// from the active Fyne text metrics.
|
||||
func loaderLogViewportMinSize(app fyne.App) fyne.Size {
|
||||
if app == nil || app.Driver() == nil {
|
||||
return fyne.NewSize(0, 0)
|
||||
}
|
||||
|
||||
cellSize, _ := app.Driver().RenderedTextSize(
|
||||
"M",
|
||||
theme.TextSize(),
|
||||
fyne.TextStyle{Monospace: true},
|
||||
nil,
|
||||
)
|
||||
|
||||
return fyne.NewSize(
|
||||
cellSize.Width*loaderLogViewportColumns,
|
||||
cellSize.Height*loaderLogViewportRows,
|
||||
)
|
||||
}
|
||||
|
||||
func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) {
|
||||
l := &loader{
|
||||
app: app,
|
||||
connector: conn,
|
||||
storage: s,
|
||||
updater: updater.NewManager(s, conn),
|
||||
runner: execRunner{},
|
||||
textGrid: widget.NewTextGrid(),
|
||||
debugWindow: app.NewWindow("Loader"),
|
||||
}
|
||||
l.btn = widget.NewButton("Retry", l.onButtonAction)
|
||||
l.btn.Disable()
|
||||
l.textGrid.Scroll = fyne.ScrollNone
|
||||
l.debugWindow.SetCloseIntercept(l.onWindowClose)
|
||||
|
||||
logScroll := container.NewScroll(l.textGrid)
|
||||
logScroll.Direction = container.ScrollBoth
|
||||
logScroll.SetMinSize(loaderLogViewportMinSize(app))
|
||||
|
||||
actionBar := container.NewCenter(container.NewHBox(l.btn))
|
||||
|
||||
content := container.NewBorder(nil, actionBar, nil, nil, logScroll)
|
||||
l.debugWindow.SetContent(content)
|
||||
l.debugWindow.Resize(content.MinSize())
|
||||
l.debugWindow.SetFixedSize(true)
|
||||
l.debugWindow.CenterOnScreen()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *loader) runOnce(ctx context.Context) error {
|
||||
target, err := l.updater.EnsureLaunchTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.logText(fmt.Sprintf("Starting UI client v%s", target.Version))
|
||||
l.logText(fmt.Sprintf("Executable: %s", target.Path))
|
||||
|
||||
exitCode, runErr := l.runner.Run(ctx, target.Path)
|
||||
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
|
||||
|
||||
switch {
|
||||
case runErr != nil:
|
||||
return errors.Join(fmt.Errorf("launch UI client v%s: %w", target.Version, runErr), markErr)
|
||||
case exitCode != 0:
|
||||
return errors.Join(fmt.Errorf("UI client v%s exited with code %d", target.Version, exitCode), markErr)
|
||||
default:
|
||||
return markErr
|
||||
}
|
||||
}
|
||||
|
||||
// init prepares and launches the standalone UI client, or shows a retry button on failure.
|
||||
func (l *loader) init(ctx context.Context) {
|
||||
l.setCloseQuits(false)
|
||||
fyne.Do(func() {
|
||||
l.textGrid.SetText("")
|
||||
l.btn.Hide()
|
||||
l.btn.Disable()
|
||||
// show debugWindow can be done with future debug mode, e.g. with -debug flag
|
||||
l.debugWindow.Hide()
|
||||
})
|
||||
|
||||
err := l.runOnce(ctx)
|
||||
if err == nil || errors.Is(err, context.Canceled) {
|
||||
l.setResult(nil)
|
||||
fyne.Do(func() {
|
||||
l.debugWindow.Hide()
|
||||
l.app.Quit()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
l.setCloseQuits(true)
|
||||
l.setResult(err)
|
||||
l.logError(err)
|
||||
fyne.Do(func() {
|
||||
l.btn.SetText("Retry")
|
||||
l.btn.Enable()
|
||||
l.btn.Show()
|
||||
l.debugWindow.Show()
|
||||
})
|
||||
}
|
||||
|
||||
func (l *loader) onButtonAction() {
|
||||
if l.ctx == nil {
|
||||
return
|
||||
}
|
||||
go l.init(l.ctx)
|
||||
}
|
||||
|
||||
func (l *loader) onWindowClose() {
|
||||
if l.getCloseQuits() {
|
||||
l.app.Quit()
|
||||
return
|
||||
}
|
||||
|
||||
l.debugWindow.Hide()
|
||||
}
|
||||
|
||||
func (l *loader) logText(v string) {
|
||||
if l.textGrid == nil {
|
||||
return
|
||||
}
|
||||
fyne.Do(func() { l.textGrid.Append(v) })
|
||||
}
|
||||
|
||||
func (l *loader) logError(err error) {
|
||||
l.logText(fmt.Sprintf("ERROR: %s", err))
|
||||
}
|
||||
|
||||
func (l *loader) setResult(err error) {
|
||||
l.resultMu.Lock()
|
||||
defer l.resultMu.Unlock()
|
||||
l.result = err
|
||||
}
|
||||
|
||||
func (l *loader) getResult() error {
|
||||
l.resultMu.Lock()
|
||||
defer l.resultMu.Unlock()
|
||||
return l.result
|
||||
}
|
||||
|
||||
func (l *loader) setCloseQuits(v bool) {
|
||||
l.closeMu.Lock()
|
||||
defer l.closeMu.Unlock()
|
||||
l.closeQuits = v
|
||||
}
|
||||
|
||||
func (l *loader) getCloseQuits() bool {
|
||||
l.closeMu.Lock()
|
||||
defer l.closeMu.Unlock()
|
||||
return l.closeQuits
|
||||
}
|
||||
|
||||
// Run starts the loader window, launches the standalone UI process, and returns
|
||||
// the final launch result once the loader application exits.
|
||||
func (l *loader) Run(ctx context.Context) error {
|
||||
l.ctx = ctx
|
||||
|
||||
go l.init(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
fyne.Do(l.app.Quit)
|
||||
}()
|
||||
|
||||
l.app.Run()
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return l.getResult()
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"galaxy/client/updater"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
"galaxy/storage"
|
||||
"galaxy/storage/fs"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubConnector struct {
|
||||
versions []connector.VersionInfo
|
||||
versionErr error
|
||||
downloads map[string][]byte
|
||||
downloadErr error
|
||||
}
|
||||
|
||||
func (c *stubConnector) CheckConnection() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *stubConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
||||
if c.versionErr != nil {
|
||||
return nil, c.versionErr
|
||||
}
|
||||
return c.versions, nil
|
||||
}
|
||||
|
||||
func (c *stubConnector) DownloadVersion(url string) ([]byte, error) {
|
||||
if c.downloadErr != nil {
|
||||
return nil, c.downloadErr
|
||||
}
|
||||
data, ok := c.downloads[url]
|
||||
if !ok {
|
||||
return nil, errors.New("missing download payload")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *stubConnector) FetchReport(mc.GameID, uint, func(report.Report, error)) {}
|
||||
|
||||
type stubRunner struct {
|
||||
paths []string
|
||||
exitCode int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(_ context.Context, path string) (int, error) {
|
||||
r.paths = append(r.paths, path)
|
||||
return r.exitCode, r.err
|
||||
}
|
||||
|
||||
func TestRunOnceFirstLaunchDownloadsAndPromotesVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
payload := []byte("ui-binary-1.2.3")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.2.3",
|
||||
URL: "https://example.com/ui-1.2.3.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
runner := &stubRunner{}
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")),
|
||||
runner: runner,
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.2.3", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
expectedPath := filepath.Join(s.StorageRoot(), updater.ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.Equal(t, []string{expectedPath}, runner.paths)
|
||||
}
|
||||
|
||||
func TestRunOnceSpawnFailureClearsPendingAndKeepsCurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
|
||||
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
|
||||
|
||||
payload := []byte("ui-binary-1.1.0")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.1.0",
|
||||
URL: "https://example.com/ui-1.1.0.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
|
||||
require.NoError(t, manager.CheckAndPrepareLatest())
|
||||
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: manager,
|
||||
runner: &stubRunner{
|
||||
err: errors.New("spawn failed"),
|
||||
},
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.Error(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
currentExists, _, err := s.FileExists(currentPath)
|
||||
require.NoError(t, err)
|
||||
require.True(t, currentExists)
|
||||
|
||||
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.NoError(t, err)
|
||||
require.False(t, nextExists)
|
||||
}
|
||||
|
||||
func TestRunOnceNonZeroExitClearsPendingAndKeepsCurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
|
||||
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
|
||||
|
||||
payload := []byte("ui-binary-1.1.0")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.1.0",
|
||||
URL: "https://example.com/ui-1.1.0.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
|
||||
require.NoError(t, manager.CheckAndPrepareLatest())
|
||||
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: manager,
|
||||
runner: &stubRunner{
|
||||
exitCode: 23,
|
||||
},
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.Error(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.NoError(t, err)
|
||||
require.False(t, nextExists)
|
||||
}
|
||||
|
||||
func newTestStorage(t *testing.T) *testStorage {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
s, err := fs.NewFS(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testStorage{Storage: s, root: root}
|
||||
}
|
||||
|
||||
type testStorage struct {
|
||||
storage.Storage
|
||||
root string
|
||||
}
|
||||
|
||||
func (s *testStorage) StorageRoot() string {
|
||||
return s.root
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
fynetest "fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLoaderConfiguresWindowGeometry(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, spy.window)
|
||||
require.Same(t, spy.window, l.debugWindow)
|
||||
require.True(t, spy.window.setContentCalled)
|
||||
require.True(t, spy.window.resizeCalled)
|
||||
require.Equal(t, spy.window.content.MinSize(), spy.window.resizeSize)
|
||||
require.True(t, spy.window.fixedSizeCalled)
|
||||
require.True(t, spy.window.fixedSize)
|
||||
require.True(t, spy.window.centerOnScreenCalled)
|
||||
}
|
||||
|
||||
func TestNewLoaderBuildsScrollableBorderLayout(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, app)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, ok := l.debugWindow.Content().(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "*layout.borderLayout", fmt.Sprintf("%T", content.Layout))
|
||||
require.Len(t, content.Objects, 2)
|
||||
|
||||
logScroll, ok := content.Objects[0].(*container.Scroll)
|
||||
require.True(t, ok)
|
||||
require.Same(t, l.textGrid, logScroll.Content)
|
||||
require.Equal(t, container.ScrollBoth, logScroll.Direction)
|
||||
require.Equal(t, loaderLogViewportMinSize(app), logScroll.MinSize())
|
||||
require.Equal(t, fyne.ScrollNone, l.textGrid.Scroll)
|
||||
|
||||
actionBar, ok := content.Objects[1].(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Len(t, actionBar.Objects, 1)
|
||||
|
||||
actionRow, ok := actionBar.Objects[0].(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Len(t, actionRow.Objects, 1)
|
||||
require.Same(t, l.btn, actionRow.Objects[0])
|
||||
|
||||
content.Resize(content.MinSize())
|
||||
|
||||
require.Equal(t, fyne.NewPos(0, 0), logScroll.Position())
|
||||
require.Equal(t, content.Size().Width, logScroll.Size().Width)
|
||||
require.Equal(
|
||||
t,
|
||||
content.Size().Height-actionBar.MinSize().Height-theme.Padding(),
|
||||
logScroll.Size().Height,
|
||||
)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
fyne.NewPos(0, content.Size().Height-actionBar.MinSize().Height),
|
||||
actionBar.Position(),
|
||||
)
|
||||
require.Equal(t, content.Size().Width, actionBar.Size().Width)
|
||||
require.Equal(t, actionRow.MinSize().Width, actionRow.Size().Width)
|
||||
require.Equal(t, l.btn.MinSize().Width, l.btn.Size().Width)
|
||||
require.Equal(t, l.btn.MinSize().Height, l.btn.Size().Height)
|
||||
require.Equal(t, (content.Size().Width-actionRow.Size().Width)/2, actionRow.Position().X)
|
||||
}
|
||||
|
||||
func TestNewLoaderInterceptsWindowCloseByHidingWindow(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, spy.window)
|
||||
require.Same(t, spy.window, l.debugWindow)
|
||||
require.NotNil(t, spy.window.closeIntercept)
|
||||
|
||||
spy.window.closeIntercept()
|
||||
|
||||
require.Equal(t, 1, spy.window.hideCalls)
|
||||
require.Zero(t, spy.window.closeCalls)
|
||||
require.Zero(t, spy.quitCalls)
|
||||
}
|
||||
|
||||
func TestLoaderWindowCloseQuitsApplicationAfterLaunchFailure(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
l.setCloseQuits(true)
|
||||
spy.window.closeIntercept()
|
||||
|
||||
require.Zero(t, spy.window.hideCalls)
|
||||
require.Zero(t, spy.window.closeCalls)
|
||||
require.Equal(t, 1, spy.quitCalls)
|
||||
}
|
||||
|
||||
type spyApp struct {
|
||||
fyne.App
|
||||
window *spyWindow
|
||||
quitCalls int
|
||||
}
|
||||
|
||||
func (a *spyApp) NewWindow(title string) fyne.Window {
|
||||
a.window = &spyWindow{Window: a.App.NewWindow(title)}
|
||||
return a.window
|
||||
}
|
||||
|
||||
func (a *spyApp) Quit() {
|
||||
a.quitCalls++
|
||||
a.App.Quit()
|
||||
}
|
||||
|
||||
type spyWindow struct {
|
||||
fyne.Window
|
||||
|
||||
content fyne.CanvasObject
|
||||
closeIntercept func()
|
||||
resizeSize fyne.Size
|
||||
hideCalls int
|
||||
closeCalls int
|
||||
setContentCalled bool
|
||||
resizeCalled bool
|
||||
fixedSize bool
|
||||
fixedSizeCalled bool
|
||||
centerOnScreenCalled bool
|
||||
}
|
||||
|
||||
func (w *spyWindow) CenterOnScreen() {
|
||||
w.centerOnScreenCalled = true
|
||||
w.Window.CenterOnScreen()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Close() {
|
||||
w.closeCalls++
|
||||
w.Window.Close()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Hide() {
|
||||
w.hideCalls++
|
||||
w.Window.Hide()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Resize(size fyne.Size) {
|
||||
w.resizeCalled = true
|
||||
w.resizeSize = size
|
||||
w.Window.Resize(size)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetContent(content fyne.CanvasObject) {
|
||||
w.setContentCalled = true
|
||||
w.content = content
|
||||
w.Window.SetContent(content)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetCloseIntercept(callback func()) {
|
||||
w.closeIntercept = callback
|
||||
w.Window.SetCloseIntercept(callback)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetFixedSize(fixed bool) {
|
||||
w.fixedSizeCalled = true
|
||||
w.fixedSize = fixed
|
||||
w.Window.SetFixedSize(fixed)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// uiRunner executes the standalone UI artifact and returns its exit code.
|
||||
type uiRunner interface {
|
||||
Run(context.Context, string) (int, error)
|
||||
}
|
||||
|
||||
type execRunner struct{}
|
||||
|
||||
func (execRunner) Run(ctx context.Context, path string) (int, error) {
|
||||
cmd := exec.CommandContext(ctx, path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
|
||||
return -1, err
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package loader
|
||||
|
||||
import "crypto/sha256"
|
||||
|
||||
// SumSHA256 calculates SHA-256 for the provided byte slice and returns
|
||||
// the raw 32-byte digest as a fixed-size array.
|
||||
func SumSHA256(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// EqualSHA256 returns true when both SHA-256 digests are identical.
|
||||
func EqualSHA256(a, b [32]byte) bool {
|
||||
return a == b
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSumSHA256 verifies that SumSHA256 returns the same digest
|
||||
// as the standard library implementation for a non-empty payload.
|
||||
func TestSumSHA256(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte("hello world")
|
||||
expected := sha256.Sum256(data)
|
||||
|
||||
actual := SumSHA256(data)
|
||||
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
// TestSumSHA256Empty verifies that SumSHA256 correctly handles
|
||||
// an empty byte slice.
|
||||
func TestSumSHA256Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte{}
|
||||
expected := sha256.Sum256(data)
|
||||
|
||||
actual := SumSHA256(data)
|
||||
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
// TestEqualSHA256Same verifies that two identical digests
|
||||
// are considered equal.
|
||||
func TestEqualSHA256Same(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := []byte("hello")
|
||||
digest := sha256.Sum256(data)
|
||||
|
||||
require.True(t, EqualSHA256(digest, digest))
|
||||
}
|
||||
|
||||
// TestEqualSHA256Different verifies that different digests
|
||||
// are considered not equal.
|
||||
func TestEqualSHA256Different(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
digestA := sha256.Sum256([]byte("hello"))
|
||||
digestB := sha256.Sum256([]byte("world"))
|
||||
|
||||
require.False(t, EqualSHA256(digestA, digestB))
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"galaxy/client/world"
|
||||
)
|
||||
|
||||
func mockWorld() *world.World {
|
||||
w := world.NewWorld(300, 300)
|
||||
mockWorldInit(w)
|
||||
return w
|
||||
}
|
||||
|
||||
func mockWorldInit(w *world.World) {
|
||||
lineStyle := w.AddStyleLine(world.StyleOverride{
|
||||
StrokeColor: color.RGBA{R: 0, G: 255, B: 0, A: 255},
|
||||
StrokeWidthPx: new(3.0),
|
||||
StrokeDashes: new([]float64{10.}),
|
||||
})
|
||||
|
||||
if _, err := w.AddCircle(150, 150, 50); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddCircle(150, 299, 30); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := w.AddCircle(299, 150, 30); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddLine(100, 20, 200, 30, world.LineWithStyleID(lineStyle), world.LineWithPriority(500)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddLine(50, 50, 250, 100); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddPoint(10, 10); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := w.AddPoint(25, 255); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"galaxy/client/world"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
const (
|
||||
entityClassUnknown int = iota - 1
|
||||
entityClassLocalPlanet
|
||||
entityClassOthersPlanet
|
||||
entityClassFreePlanet
|
||||
entityClassUnidentifiedPlanet
|
||||
)
|
||||
|
||||
type registry struct {
|
||||
report *report.Report
|
||||
localPlanetIndex map[world.PrimitiveID]int
|
||||
unidentifiedPlanetIndex map[world.PrimitiveID]int
|
||||
}
|
||||
|
||||
func newRegistry() *registry {
|
||||
return ®istry{
|
||||
localPlanetIndex: make(map[world.PrimitiveID]int),
|
||||
unidentifiedPlanetIndex: make(map[world.PrimitiveID]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *registry) clear(report *report.Report) {
|
||||
r.report = report
|
||||
clear(r.localPlanetIndex)
|
||||
clear(r.unidentifiedPlanetIndex)
|
||||
}
|
||||
|
||||
func (r *registry) entityClass(id world.PrimitiveID) int {
|
||||
if r.isLocalPlanet(id) {
|
||||
return entityClassLocalPlanet
|
||||
}
|
||||
if r.isUnidentifiedPlanet(id) {
|
||||
return entityClassUnidentifiedPlanet
|
||||
}
|
||||
return entityClassUnknown
|
||||
}
|
||||
|
||||
func (r *registry) registerLocalPlanet(id world.PrimitiveID, index int) {
|
||||
r.localPlanetIndex[id] = index
|
||||
}
|
||||
|
||||
func (r *registry) isLocalPlanet(id world.PrimitiveID) bool {
|
||||
_, ok := r.localPlanetIndex[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *registry) localPlanet(id world.PrimitiveID) (*report.LocalPlanet, bool) {
|
||||
i, ok := r.localPlanetIndex[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if i > len(r.report.LocalPlanet)-1 {
|
||||
return nil, false
|
||||
}
|
||||
return &r.report.LocalPlanet[i], true
|
||||
}
|
||||
|
||||
func (r *registry) registerUnidentifiedPlanet(id world.PrimitiveID, index int) {
|
||||
r.unidentifiedPlanetIndex[id] = index
|
||||
}
|
||||
|
||||
func (r *registry) isUnidentifiedPlanet(id world.PrimitiveID) bool {
|
||||
_, ok := r.unidentifiedPlanetIndex[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *client) createShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
|
||||
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"galaxy/client/widget/calculator"
|
||||
"galaxy/client/world"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
"slices"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
func (e *client) initLatestReport() {
|
||||
e.stateMu.Lock()
|
||||
if e.state.ActiveGameID != nil {
|
||||
if stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == *e.state.ActiveGameID }); stateIdx >= 0 {
|
||||
e.initReportAsync(*e.state.ActiveGameID, e.state.GameState[stateIdx].ActiveTurn)
|
||||
}
|
||||
}
|
||||
e.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *client) initReportAsync(gid mc.GameID, t uint) {
|
||||
e.s.ReportExistsAsync(gid, t, func(b bool, err error) { e.reportAtStorageExists(gid, t, b, err) })
|
||||
}
|
||||
|
||||
func (e *client) reportAtStorageExists(gid mc.GameID, t uint, exists bool, err error) {
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
e.s.LoadReportAsync(gid, t, func(r report.Report, err error) { e.loadReportHandler(gid, r, err) })
|
||||
return
|
||||
}
|
||||
e.conn.FetchReport(gid, t, func(r report.Report, err error) { e.fetchReportHandler(gid, r, err) })
|
||||
}
|
||||
|
||||
func (e *client) fetchReportHandler(gid mc.GameID, r report.Report, err error) {
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
e.s.SaveReportAsync(gid, r.Turn, r, func(err error) { e.loadReportHandler(gid, r, err) })
|
||||
}
|
||||
|
||||
func (e *client) loadReportHandler(gid mc.GameID, r report.Report, err error) {
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
e.stateMu.Lock()
|
||||
needSaveState := false
|
||||
stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == gid })
|
||||
if stateIdx < 0 {
|
||||
e.state.GameState = append(e.state.GameState, mc.GameState{ID: gid, LastTurn: r.Turn, ActiveTurn: r.Turn})
|
||||
stateIdx = len(e.state.GameState) - 1
|
||||
needSaveState = true
|
||||
}
|
||||
if e.state.ActiveGameID == nil {
|
||||
e.state.ActiveGameID = new(gid)
|
||||
needSaveState = true
|
||||
}
|
||||
if e.state.GameState[stateIdx].LastTurn < r.Turn {
|
||||
e.state.GameState[stateIdx].LastTurn = r.Turn
|
||||
e.state.GameState[stateIdx].ActiveTurn = r.Turn
|
||||
needSaveState = true
|
||||
}
|
||||
if needSaveState {
|
||||
if err := e.s.SaveState(*e.state); err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
e.stateMu.Unlock()
|
||||
|
||||
e.setReport(r)
|
||||
}
|
||||
|
||||
func (e *client) setReport(r report.Report) {
|
||||
w := world.NewWorld(int(r.Width), int(r.Height))
|
||||
e.reg.clear(&r)
|
||||
for i := range r.LocalPlanet {
|
||||
p := r.LocalPlanet[i]
|
||||
id, err := w.AddCircle(p.X.F(), p.Y.F(), p.Size.F(), world.CircleWithClass(world.CircleClassLocalPlanet))
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
e.reg.registerLocalPlanet(id, i)
|
||||
}
|
||||
for i := range r.UnidentifiedPlanet {
|
||||
p := r.UnidentifiedPlanet[i]
|
||||
id, err := w.AddPoint(p.X.F(), p.Y.F(), world.PointWithClass(world.PointClassTrackIncoming))
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
return
|
||||
}
|
||||
e.reg.registerUnidentifiedPlanet(id, i)
|
||||
}
|
||||
e.loadWorld(w)
|
||||
|
||||
selfIdx := slices.IndexFunc(r.Player, func(p report.Player) bool { return p.Name == r.Race })
|
||||
if selfIdx >= 0 {
|
||||
fyne.Do(func() {
|
||||
e.calculator.Init(
|
||||
calculator.WithPlayerDrives(r.Player[selfIdx].Drive.F()),
|
||||
calculator.WithPlayerWeapons(r.Player[selfIdx].Weapons.F()),
|
||||
calculator.WithPlayerShields(r.Player[selfIdx].Shields.F()),
|
||||
calculator.WithPlayerCargo(r.Player[selfIdx].Cargo.F()),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
e.OnServiceError(fmt.Errorf("race %q not found at report players list", r.Race))
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"title": {
|
||||
"map": "Map",
|
||||
"calculator": "Ship Calculator",
|
||||
"info": "Info"
|
||||
},
|
||||
"planet": {
|
||||
"title": "Planet #{{.Number}} '{{.Name}}' production fot this ship:",
|
||||
"mat": "Materials",
|
||||
"prod.mass": "Prod. Mass",
|
||||
"prod.ships": "Ships"
|
||||
},
|
||||
"tech": {
|
||||
"d": "Drive",
|
||||
"w": "Weapons",
|
||||
"s": "Shields",
|
||||
"c": "Cargo"
|
||||
},
|
||||
"ship": {
|
||||
"action.create": "Create",
|
||||
"mass": "Mass",
|
||||
"speed": "Speed",
|
||||
"attack": "Attack",
|
||||
"defense": "Defense",
|
||||
"load": "Load"
|
||||
},
|
||||
"label": {
|
||||
"max": "Max."
|
||||
}
|
||||
}
|
||||
-334
@@ -1,334 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"galaxy/client/world"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
/*
|
||||
Fyne integration notes:
|
||||
|
||||
- canvas.NewRaster calls draw(w,h) on the UI thread.
|
||||
- We MUST keep draw() cheap and never loop re-rendering inside it.
|
||||
- Coalescing must therefore schedule refreshes and render at most once per draw call.
|
||||
- The world renderer expects:
|
||||
- RenderParams.ViewportWidthPx/HeightPx: the size of the visible viewport.
|
||||
- RenderParams.MarginXPx/MarginYPx: margins around viewport.
|
||||
- RenderParams.CameraXWorldFp/YWorldFp: camera center in world-fixed units.
|
||||
- RenderParams.CameraZoom: float zoom (converted inside world).
|
||||
- world.Render draws on the full expanded canvas (viewport + 2*margins on each axis).
|
||||
|
||||
This adapter enforces:
|
||||
- viewport sizes come from draw(w,h)
|
||||
- margins are computed from viewport sizes (w/4 and h/4)
|
||||
- gg context backing image is resized to the expanded canvas size
|
||||
- IndexOnViewportChange is called when viewport sizes changed (you can also include zoom if desired)
|
||||
*/
|
||||
|
||||
var (
|
||||
blankImage image.Image = image.NewRGBA(image.Rect(0, 0, 0, 0))
|
||||
)
|
||||
|
||||
// FyneExecutor posts functions onto the Fyne UI thread.
|
||||
type FyneExecutor struct{}
|
||||
|
||||
func (FyneExecutor) Post(fn func()) {
|
||||
fyne.Do(fn)
|
||||
}
|
||||
|
||||
// GetParams returns a copy of current render params for external reads.
|
||||
func (e *client) GetParams() world.RenderParams {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return *e.wp
|
||||
}
|
||||
|
||||
// UpdateParams applies a modification function to render params and schedules a refresh.
|
||||
// This is a safe way to mutate camera/zoom from event handlers.
|
||||
func (e *client) UpdateParams(fn func(p *world.RenderParams)) {
|
||||
e.mu.Lock()
|
||||
fn(e.wp)
|
||||
p := *e.wp
|
||||
e.mu.Unlock()
|
||||
|
||||
e.co.Request(p)
|
||||
}
|
||||
|
||||
// RequestRefresh schedules a refresh with the current params snapshot.
|
||||
// Useful if you changed world objects and want to redraw.
|
||||
func (e *client) RequestRefresh() {
|
||||
e.mu.RLock()
|
||||
p := *e.wp
|
||||
e.mu.RUnlock()
|
||||
e.co.Request(p)
|
||||
}
|
||||
|
||||
// draw is the raster callback. It must be cheap and must not block on multiple re-renders.
|
||||
// It delegates coalescing + rendering decision to RasterCoalescer.
|
||||
func (e *client) draw(wPx, hPx int) image.Image {
|
||||
return e.co.Draw(wPx, hPx)
|
||||
}
|
||||
|
||||
// renderRasterImage renders the expanded canvas into the GGDrawer backing image,
|
||||
// then copies only the viewport ROI into a reusable viewport buffer and returns it.
|
||||
func (e *client) renderRasterImage(viewportW, viewportH int, p world.RenderParams) image.Image {
|
||||
if e.world == nil {
|
||||
return image.NewRGBA(image.Rect(0, 0, 0, 0))
|
||||
}
|
||||
|
||||
// Keep the incoming zoom snapshot so we can safely sync corrected zoom back
|
||||
// to base params only when no newer zoom was written concurrently.
|
||||
inputZoom := p.CameraZoom
|
||||
|
||||
// Record current raster pixel size (used for event coordinate conversion).
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterPxW = viewportW
|
||||
e.lastRasterPxH = viewportH
|
||||
e.metaMu.Unlock()
|
||||
|
||||
// Fill viewport/margins derived from draw callback.
|
||||
p.ViewportWidthPx = viewportW
|
||||
p.ViewportHeightPx = viewportH
|
||||
p.MarginXPx = viewportW / 4
|
||||
p.MarginYPx = viewportH / 4
|
||||
|
||||
// Correct zoom for viewport/world constraints, and clamp camera for no-wrap.
|
||||
correctedZoom := e.world.CorrectCameraZoom(inputZoom, viewportW, viewportH)
|
||||
p.CameraZoom = correctedZoom
|
||||
|
||||
// Sync corrected zoom to the canonical UI-facing params snapshot.
|
||||
// Guard prevents stale render snapshots from overwriting a newer zoom value
|
||||
// that may have been set by another UI event.
|
||||
e.mu.Lock()
|
||||
if e.wp.CameraZoom == inputZoom {
|
||||
e.wp.CameraZoom = correctedZoom
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
// Ensure indexing is up-to-date when viewport size or zoom changes.
|
||||
zoomFp, err := p.CameraZoomFp()
|
||||
if err == nil {
|
||||
if viewportW != e.lastIndexedViewportW || viewportH != e.lastIndexedViewportH || zoomFp != e.lastIndexedZoomFp {
|
||||
e.world.IndexOnViewportChange(viewportW, viewportH, p.CameraZoom)
|
||||
e.lastIndexedViewportW = viewportW
|
||||
e.lastIndexedViewportH = viewportH
|
||||
e.lastIndexedZoomFp = zoomFp
|
||||
}
|
||||
}
|
||||
|
||||
e.world.ClampRenderParamsNoWrap(&p)
|
||||
|
||||
// Ensure backing expanded canvas (gg context) is sized properly.
|
||||
canvasW := p.CanvasWidthPx()
|
||||
canvasH := p.CanvasHeightPx()
|
||||
e.ensureDrawerCanvas(canvasW, canvasH)
|
||||
|
||||
// Render into expanded canvas backing.
|
||||
_ = e.world.Render(e.drawer, p) // TODO: handle error
|
||||
|
||||
// Save snapshot of params actually used for this render (for HitTest consistency).
|
||||
e.lastRenderedMu.Lock()
|
||||
e.lastRenderedParams = p
|
||||
e.lastRenderedMu.Unlock()
|
||||
|
||||
// Copy viewport ROI into reusable viewport buffer and return it.
|
||||
e.ensureViewportBuffer(viewportW, viewportH)
|
||||
|
||||
src, ok := e.drawer.DC.Image().(*image.RGBA)
|
||||
if !ok || src == nil {
|
||||
return image.NewRGBA(image.Rect(0, 0, viewportW, viewportH))
|
||||
}
|
||||
|
||||
copyViewportRGBA(e.viewportImg, src, p.MarginXPx, p.MarginYPx, viewportW, viewportH)
|
||||
return e.viewportImg
|
||||
}
|
||||
|
||||
// ensureDrawerCanvas ensures drawer has a gg.Context sized to canvasW x canvasH.
|
||||
func (e *client) ensureDrawerCanvas(canvasW, canvasH int) {
|
||||
if e.drawer.DC != nil && e.lastCanvasW == canvasW && e.lastCanvasH == canvasH {
|
||||
return
|
||||
}
|
||||
// world.NewGGContextRGBA should return *gg.Context backed by *image.RGBA (gg.NewContext does).
|
||||
e.drawer.DC = NewGGContextRGBA(canvasW, canvasH)
|
||||
e.lastCanvasW = canvasW
|
||||
e.lastCanvasH = canvasH
|
||||
}
|
||||
|
||||
func (e *client) ensureViewportBuffer(w, h int) {
|
||||
if e.viewportImg != nil && e.viewportW == w && e.viewportH == h {
|
||||
return
|
||||
}
|
||||
e.viewportImg = image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
e.viewportW = w
|
||||
e.viewportH = h
|
||||
}
|
||||
|
||||
func (e *client) getLastRenderedParams() world.RenderParams {
|
||||
e.lastRenderedMu.RLock()
|
||||
defer e.lastRenderedMu.RUnlock()
|
||||
return e.lastRenderedParams
|
||||
}
|
||||
|
||||
// eventPosToPixel converts event logical coordinates (Fyne units) into pixel coordinates,
|
||||
// using the last known raster logical size and the last draw callback pixel size.
|
||||
//
|
||||
// pixelX = floor(eventX * rasterPixelWidth / rasterLogicalWidth)
|
||||
func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool) {
|
||||
e.metaMu.RLock()
|
||||
logW := e.lastRasterLogicW
|
||||
logH := e.lastRasterLogicH
|
||||
pxW := e.lastRasterPxW
|
||||
pxH := e.lastRasterPxH
|
||||
e.metaMu.RUnlock()
|
||||
|
||||
if logW <= 0 || logH <= 0 || pxW <= 0 || pxH <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
x := int(math.Floor(float64(eventX) * float64(pxW) / float64(logW)))
|
||||
y := int(math.Floor(float64(eventY) * float64(pxH) / float64(logH)))
|
||||
|
||||
// Clamp to viewport bounds.
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > pxW {
|
||||
x = pxW
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
} else if y > pxH {
|
||||
y = pxH
|
||||
}
|
||||
return x, y, true
|
||||
}
|
||||
|
||||
func (e *client) CanvasScale() float32 {
|
||||
e.metaMu.RLock()
|
||||
defer e.metaMu.RUnlock()
|
||||
if e.lastCanvasScale <= 0 {
|
||||
return 1
|
||||
}
|
||||
return e.lastCanvasScale
|
||||
}
|
||||
|
||||
func (e *client) ForceFullRedraw() {
|
||||
if e.world == nil {
|
||||
return
|
||||
}
|
||||
e.world.ForceFullRedrawNext()
|
||||
}
|
||||
|
||||
func (e *client) onRasterWidgetLayout(fyne.Size) {
|
||||
e.updateSizes()
|
||||
}
|
||||
|
||||
// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw.
|
||||
// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback).
|
||||
func (e *client) updateSizes() {
|
||||
canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
||||
if canvasObj == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sz := e.raster.Size() // logical (Fyne units)
|
||||
scale := canvasObj.Scale()
|
||||
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterLogicW = sz.Width
|
||||
e.lastRasterLogicH = sz.Height
|
||||
e.lastCanvasScale = scale
|
||||
e.metaMu.Unlock()
|
||||
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) onDragged(ev *fyne.DragEvent) {
|
||||
e.pan.Dragged(ev)
|
||||
}
|
||||
|
||||
func (e *client) onDradEnd() {
|
||||
e.pan.DragEnd()
|
||||
}
|
||||
|
||||
func (e *client) onScrolled(s *fyne.ScrollEvent) {
|
||||
if e.world == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use last rendered viewport sizes (pixel) for zoom logic.
|
||||
e.metaMu.RLock()
|
||||
vw := e.lastRasterPxW
|
||||
vh := e.lastRasterPxH
|
||||
e.metaMu.RUnlock()
|
||||
if vw <= 0 || vh <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
oldZoom := e.wp.CameraZoom
|
||||
|
||||
// Exponential zoom factor; tune later.
|
||||
const base = 1.005
|
||||
delta := float64(s.Scrolled.DY)
|
||||
newZoom := oldZoom * math.Pow(base, delta)
|
||||
|
||||
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
|
||||
if newZoom == oldZoom {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Pivot zoom for no-wrap behavior.
|
||||
newCamX, newCamY := world.PivotZoomCameraNoWrap(
|
||||
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
|
||||
vw, vh,
|
||||
cxPx, cyPx,
|
||||
oldZoomFp, newZoomFp,
|
||||
)
|
||||
|
||||
e.wp.CameraZoom = newZoom
|
||||
e.wp.CameraXWorldFp = newCamX
|
||||
e.wp.CameraYWorldFp = newCamY
|
||||
e.mu.Unlock()
|
||||
|
||||
// Any zoom change should rebuild index and force full redraw.
|
||||
e.world.ForceFullRedrawNext()
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
|
||||
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
|
||||
// It avoids SubImage aliasing issues: dst becomes independent from src backing memory.
|
||||
func copyViewportRGBA(dst, src *image.RGBA, marginX, marginY, vw, vh int) {
|
||||
for y := 0; y < vh; y++ {
|
||||
srcOff := (marginY+y)*src.Stride + marginX*4
|
||||
dstOff := y * dst.Stride
|
||||
n := vw * 4
|
||||
copy(dst.Pix[dstOff:dstOff+n], src.Pix[srcOff:srcOff+n])
|
||||
}
|
||||
}
|
||||
|
||||
func NewGGContextRGBA(w, h int) *gg.Context {
|
||||
return gg.NewContext(w, h)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
|
||||
"galaxy/client/world"
|
||||
)
|
||||
|
||||
/*
|
||||
Client pan integration for Fyne Draggable:
|
||||
|
||||
- DragEvent.Dragged provides per-event delta in Fyne logical units.
|
||||
- Client knows canvasScale (pixels per Fyne unit) and converts to pixels.
|
||||
- We update camera center in world-fixed (CameraXWorldFp/YWorldFp).
|
||||
|
||||
Sign convention (map follows pointer):
|
||||
- Drag right (dxPx > 0): move world content right => move camera left => CameraXWorldFp -= dxWorldFp
|
||||
- Drag down (dyPx > 0): move world content down => move camera up => CameraYWorldFp -= dyWorldFp
|
||||
*/
|
||||
|
||||
// draggableClient is the minimal interface we need from your client implementation.
|
||||
// If your Client already has these methods/fields, you can fold the code directly into it.
|
||||
type draggableClient interface {
|
||||
// CanvasScale returns pixels per Fyne logical unit.
|
||||
CanvasScale() float32
|
||||
|
||||
// UpdateParams applies a mutation and schedules refresh through your coalescer.
|
||||
UpdateParams(fn func(p *world.RenderParams))
|
||||
|
||||
// RequestRefresh schedules a refresh with current params (no mutation).
|
||||
RequestRefresh()
|
||||
|
||||
// ForceFullRedraw forces a full redraw on next Render (used on DragEnd).
|
||||
ForceFullRedraw()
|
||||
}
|
||||
|
||||
// PanController holds per-drag transient state.
|
||||
type PanController struct {
|
||||
ed draggableClient
|
||||
|
||||
dragging bool
|
||||
lastFx float32 // last absolute position in Fyne units
|
||||
lastFy float32
|
||||
|
||||
// Remainders to keep subpixel fyne->px conversion stable across many events.
|
||||
remPxX float32
|
||||
remPxY float32
|
||||
}
|
||||
|
||||
func NewPanController(ed draggableClient) *PanController {
|
||||
return &PanController{ed: ed}
|
||||
}
|
||||
|
||||
// Dragged processes one drag event, updates camera center by delta, and schedules redraw.
|
||||
func (p *PanController) Dragged(ev *fyne.DragEvent) {
|
||||
if ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
scale := p.ed.CanvasScale()
|
||||
if scale <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// DragEvent.Dragged is delta in Fyne logical units (device independent).
|
||||
// Convert to pixels by multiplying by canvas scale.
|
||||
dxPxF := ev.Dragged.DX * scale
|
||||
dyPxF := ev.Dragged.DY * scale
|
||||
|
||||
// accumulate subpixel remainder in pixels
|
||||
dxPxF += p.remPxX
|
||||
dyPxF += p.remPxY
|
||||
|
||||
dxPx := int(math.Round(float64(dxPxF)))
|
||||
dyPx := int(math.Round(float64(dyPxF)))
|
||||
|
||||
p.remPxX = dxPxF - float32(dxPx)
|
||||
p.remPxY = dyPxF - float32(dyPx)
|
||||
|
||||
if dxPx == 0 && dyPx == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p.ed.UpdateParams(func(rp *world.RenderParams) {
|
||||
zoomFp, err := rp.CameraZoomFp()
|
||||
if err != nil || zoomFp <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
dxWorldFp := world.PixelSpanToWorldFixed(dxPx, zoomFp)
|
||||
dyWorldFp := world.PixelSpanToWorldFixed(dyPx, zoomFp)
|
||||
|
||||
// Map follows pointer
|
||||
rp.CameraXWorldFp -= dxWorldFp
|
||||
rp.CameraYWorldFp -= dyWorldFp
|
||||
})
|
||||
}
|
||||
|
||||
// DragEnd ends the drag gesture. We force a full redraw next to eliminate any
|
||||
// possible artifacts from incremental shifting and to "settle" the final state.
|
||||
func (p *PanController) DragEnd() {
|
||||
p.dragging = false
|
||||
p.remPxX = 0
|
||||
p.remPxY = 0
|
||||
|
||||
p.ed.ForceFullRedraw()
|
||||
p.ed.RequestRefresh()
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/test"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"galaxy/client/world"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
scale float32
|
||||
p world.RenderParams
|
||||
|
||||
forced bool
|
||||
updates int
|
||||
refresh int
|
||||
}
|
||||
|
||||
func (e *fakeClient) CanvasScale() float32 { return e.scale }
|
||||
|
||||
func (e *fakeClient) UpdateParams(fn func(p *world.RenderParams)) {
|
||||
fn(&e.p)
|
||||
e.updates++
|
||||
}
|
||||
|
||||
func (e *fakeClient) RequestRefresh() { e.refresh++ }
|
||||
|
||||
func (e *fakeClient) ForceFullRedraw() { e.forced = true }
|
||||
|
||||
func TestPanController_DraggedUpdatesCameraByDeltaPx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fe := &fakeClient{
|
||||
scale: 1.0, // 1 fyne unit == 1 px for the test
|
||||
p: world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
CameraXWorldFp: 5 * world.SCALE,
|
||||
CameraYWorldFp: 5 * world.SCALE,
|
||||
},
|
||||
}
|
||||
|
||||
pc := NewPanController(fe)
|
||||
|
||||
// Drag right by +3 px and down by +2 px.
|
||||
pc.Dragged(&fyne.DragEvent{
|
||||
Dragged: fyne.Delta{DX: 3, DY: 2},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, fe.updates)
|
||||
|
||||
// Map follows pointer => camera moves opposite to pointer delta.
|
||||
require.Equal(t, 5*world.SCALE-3*world.SCALE, fe.p.CameraXWorldFp)
|
||||
require.Equal(t, 5*world.SCALE-2*world.SCALE, fe.p.CameraYWorldFp)
|
||||
}
|
||||
|
||||
func TestPanController_DraggedUsesCanvasScaleByMultiplying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fe := &fakeClient{
|
||||
scale: 2.0, // 2 px per fyne unit
|
||||
p: world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
CameraXWorldFp: 0,
|
||||
CameraYWorldFp: 0,
|
||||
},
|
||||
}
|
||||
|
||||
pc := NewPanController(fe)
|
||||
|
||||
// Dragged.DX=1 fyne unit => 2 px after scaling.
|
||||
pc.Dragged(&fyne.DragEvent{
|
||||
Dragged: fyne.Delta{DX: 1, DY: 0},
|
||||
})
|
||||
|
||||
require.Equal(t, -2*world.SCALE, fe.p.CameraXWorldFp)
|
||||
}
|
||||
|
||||
func TestPanController_DragEndForcesFullRedrawAndRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fe := &fakeClient{
|
||||
scale: 1.0,
|
||||
p: world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
CameraXWorldFp: 0,
|
||||
CameraYWorldFp: 0,
|
||||
},
|
||||
}
|
||||
|
||||
pc := NewPanController(fe)
|
||||
|
||||
// Simulate a drag start.
|
||||
pc.Dragged(&fyne.DragEvent{PointEvent: fyne.PointEvent{Position: fyne.Position{X: 1, Y: 1}}})
|
||||
|
||||
pc.DragEnd()
|
||||
require.True(t, fe.forced)
|
||||
require.Equal(t, 1, fe.refresh)
|
||||
}
|
||||
|
||||
// Optional: demonstrate use of fyne/test package to ensure types are available.
|
||||
// (Not strictly needed, but keeps fyne dependency "active" in tests.)
|
||||
func TestFyneTestDriverIsUsable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_ = test.NewApp()
|
||||
}
|
||||
|
||||
type immediateExecutor struct{}
|
||||
|
||||
func (immediateExecutor) Post(fn func()) {
|
||||
if fn != nil {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
type noopRefresher struct{}
|
||||
|
||||
func (noopRefresher) Refresh() {}
|
||||
|
||||
func newZoomSyncTestClient(t *testing.T, worldW, worldH int, cameraZoom float64) *client {
|
||||
t.Helper()
|
||||
|
||||
w := world.NewWorld(worldW, worldH)
|
||||
e := &client{
|
||||
world: w,
|
||||
drawer: &world.GGDrawer{},
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: cameraZoom,
|
||||
CameraXWorldFp: w.W / 2,
|
||||
CameraYWorldFp: w.H / 2,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
hits: make([]world.Hit, 5),
|
||||
}
|
||||
|
||||
e.co = NewRasterCoalescer(
|
||||
immediateExecutor{},
|
||||
noopRefresher{},
|
||||
func(wPx, hPx int, _ world.RenderParams) image.Image {
|
||||
return image.NewRGBA(image.Rect(0, 0, wPx, hPx))
|
||||
},
|
||||
)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
func TestRenderRasterImage_SyncsCorrectedZoomToBaseParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newZoomSyncTestClient(t, 10, 10, 1.0)
|
||||
p := *e.wp
|
||||
|
||||
correctedZoom := e.world.CorrectCameraZoom(p.CameraZoom, 100, 100)
|
||||
require.NotEqual(t, p.CameraZoom, correctedZoom)
|
||||
|
||||
_ = e.renderRasterImage(100, 100, p)
|
||||
|
||||
require.Equal(t, correctedZoom, e.wp.CameraZoom)
|
||||
}
|
||||
|
||||
func TestRenderRasterImage_DoesNotOverrideNewerBaseZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newZoomSyncTestClient(t, 10, 10, 1.0)
|
||||
p := *e.wp
|
||||
|
||||
// Simulate a newer UI update that happened after this render snapshot was taken.
|
||||
e.wp.CameraZoom = 3.0
|
||||
|
||||
_ = e.renderRasterImage(100, 100, p)
|
||||
|
||||
require.Equal(t, 3.0, e.wp.CameraZoom)
|
||||
}
|
||||
|
||||
func TestPanController_Dragged_AfterRenderZoomCorrection_UsesSyncedZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := newZoomSyncTestClient(t, 10, 10, 1.0)
|
||||
|
||||
// Initial render corrects zoom and syncs it into base params.
|
||||
_ = e.renderRasterImage(100, 100, *e.wp)
|
||||
|
||||
syncedZoom := e.wp.CameraZoom
|
||||
require.NotEqual(t, 1.0, syncedZoom)
|
||||
|
||||
zoomFp, err := world.CameraZoomToWorldFixed(syncedZoom)
|
||||
require.NoError(t, err)
|
||||
|
||||
startX := e.wp.CameraXWorldFp
|
||||
pan := NewPanController(e)
|
||||
pan.Dragged(&fyne.DragEvent{
|
||||
Dragged: fyne.Delta{DX: 1, DY: 0},
|
||||
})
|
||||
|
||||
expectedShift := world.PixelSpanToWorldFixed(1, zoomFp)
|
||||
require.Equal(t, startX-expectedShift, e.wp.CameraXWorldFp)
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
// Package updater manages standalone UI client artifacts, version selection,
|
||||
// and persisted update state shared by the loader and the UI process.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"galaxy/connector"
|
||||
gerr "galaxy/error"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/storage"
|
||||
"galaxy/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// ArtifactDir keeps versioned UI executables isolated from user data files.
|
||||
ArtifactDir = "ui"
|
||||
// ArtifactPrefix is the file name prefix used for all managed UI artifacts.
|
||||
ArtifactPrefix = "client-ui"
|
||||
)
|
||||
|
||||
// LaunchTarget describes the executable artifact selected for the next UI run.
|
||||
type LaunchTarget struct {
|
||||
Version string
|
||||
Path string
|
||||
Pending bool
|
||||
}
|
||||
|
||||
// Manager coordinates client update state, artifact downloads, and cleanup.
|
||||
type Manager struct {
|
||||
storage storage.Storage
|
||||
connector connector.Connector
|
||||
goos string
|
||||
goarch string
|
||||
kind string
|
||||
}
|
||||
|
||||
// Option customizes Manager construction.
|
||||
type Option func(*Manager)
|
||||
|
||||
// WithPlatform overrides the runtime platform used for version matching.
|
||||
func WithPlatform(goos, goarch string) Option {
|
||||
return func(m *Manager) {
|
||||
if goos != "" {
|
||||
m.goos = goos
|
||||
}
|
||||
if goarch != "" {
|
||||
m.goarch = goarch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithArtifactKind overrides the artifact kind accepted by the manager.
|
||||
func WithArtifactKind(kind string) Option {
|
||||
return func(m *Manager) {
|
||||
if kind != "" {
|
||||
m.kind = kind
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewManager constructs an update manager for standalone executable artifacts.
|
||||
func NewManager(s storage.Storage, c connector.Connector, opts ...Option) *Manager {
|
||||
m := &Manager{
|
||||
storage: s,
|
||||
connector: c,
|
||||
goos: runtime.GOOS,
|
||||
goarch: runtime.GOARCH,
|
||||
kind: connector.ArtifactKindExecutable,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ArtifactPath returns the deterministic local storage path for the given versioned artifact.
|
||||
func ArtifactPath(version, goos, goarch, kind string) string {
|
||||
name := fmt.Sprintf("%s-%s-%s-%s-%s", ArtifactPrefix, version, goos, goarch, kind)
|
||||
if goos == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return filepath.Join(ArtifactDir, name)
|
||||
}
|
||||
|
||||
// LatestCompatibleVersion returns the latest supported version for the selected platform and kind.
|
||||
func LatestCompatibleVersion(versions []connector.VersionInfo, goos, goarch, kind string) (connector.VersionInfo, bool, error) {
|
||||
platformMatches := make([]connector.VersionInfo, 0, len(versions))
|
||||
for _, version := range versions {
|
||||
if version.OS == goos && version.Arch == goarch {
|
||||
platformMatches = append(platformMatches, version)
|
||||
}
|
||||
}
|
||||
if len(platformMatches) == 0 {
|
||||
return connector.VersionInfo{}, false, nil
|
||||
}
|
||||
|
||||
candidates := make([]connector.VersionInfo, 0, len(platformMatches))
|
||||
unsupportedKinds := make(map[string]struct{})
|
||||
seenVersion := make(map[string]struct{})
|
||||
for _, version := range platformMatches {
|
||||
if version.Kind != kind {
|
||||
unsupportedKinds[version.Kind] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, ok := seenVersion[version.Version]; ok {
|
||||
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||
fmt.Errorf("ambiguous client artifact version %q for %s/%s", version.Version, goos, goarch),
|
||||
)
|
||||
}
|
||||
seenVersion[version.Version] = struct{}{}
|
||||
candidates = append(candidates, version)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
kinds := make([]string, 0, len(unsupportedKinds))
|
||||
for kind := range unsupportedKinds {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
slices.Sort(kinds)
|
||||
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||
fmt.Errorf("unsupported client artifact kind(s) for %s/%s: %s", goos, goarch, strings.Join(kinds, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
type semVersion struct {
|
||||
info connector.VersionInfo
|
||||
sem util.SemVer
|
||||
}
|
||||
semvers := make([]semVersion, len(candidates))
|
||||
for i, candidate := range candidates {
|
||||
semver, err := util.ParseSemver(candidate.Version)
|
||||
if err != nil {
|
||||
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||
fmt.Errorf("parse client version %q: %w", candidate.Version, err),
|
||||
)
|
||||
}
|
||||
semvers[i] = semVersion{info: candidate, sem: semver}
|
||||
}
|
||||
|
||||
slices.SortFunc(semvers, func(a, b semVersion) int {
|
||||
return util.CompareSemver(a.sem, b.sem)
|
||||
})
|
||||
return semvers[0].info, true, nil
|
||||
}
|
||||
|
||||
// EnsureLaunchTarget returns the versioned executable that should be launched next.
|
||||
// On the very first run, when no current or pending version exists yet, it downloads
|
||||
// the latest compatible artifact and marks it as pending.
|
||||
func (m *Manager) EnsureLaunchTarget() (LaunchTarget, error) {
|
||||
state, err := m.ensureState()
|
||||
if err != nil {
|
||||
return LaunchTarget{}, err
|
||||
}
|
||||
|
||||
if state.ClientNextVersion != nil {
|
||||
return m.launchTargetForVersion(*state.ClientNextVersion, true)
|
||||
}
|
||||
if state.ClientCurrentVersion != "" {
|
||||
return m.launchTargetForVersion(state.ClientCurrentVersion, false)
|
||||
}
|
||||
if err := m.CheckAndPrepareLatest(); err != nil {
|
||||
return LaunchTarget{}, err
|
||||
}
|
||||
|
||||
state, err = m.ensureState()
|
||||
if err != nil {
|
||||
return LaunchTarget{}, err
|
||||
}
|
||||
if state.ClientNextVersion == nil {
|
||||
return LaunchTarget{}, gerr.WrapService(errors.New("latest client version was not prepared for launch"))
|
||||
}
|
||||
|
||||
return m.launchTargetForVersion(*state.ClientNextVersion, true)
|
||||
}
|
||||
|
||||
// CheckAndPrepareLatest checks the backend manifest and downloads a newer compatible
|
||||
// artifact when one exists.
|
||||
func (m *Manager) CheckAndPrepareLatest() error {
|
||||
if m.connector == nil {
|
||||
return gerr.WrapService(errors.New("client updater connector is not configured"))
|
||||
}
|
||||
|
||||
versions, err := m.connector.CheckVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, ok, err := LatestCompatibleVersion(versions, m.goos, m.goarch, m.kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return gerr.WrapService(
|
||||
fmt.Errorf("server did not provide a compatible %s client for %s/%s", m.kind, m.goos, m.goarch),
|
||||
)
|
||||
}
|
||||
|
||||
state, err := m.ensureState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
latestSemver, err := util.ParseSemver(latest.Version)
|
||||
if err != nil {
|
||||
return gerr.WrapService(fmt.Errorf("parse latest client version %q: %w", latest.Version, err))
|
||||
}
|
||||
|
||||
if state.ClientCurrentVersion != "" {
|
||||
currentSemver, err := util.ParseSemver(state.ClientCurrentVersion)
|
||||
if err != nil {
|
||||
return gerr.WrapService(fmt.Errorf("parse current client version %q: %w", state.ClientCurrentVersion, err))
|
||||
}
|
||||
if util.CompareSemver(currentSemver, latestSemver) >= 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if state.ClientNextVersion != nil {
|
||||
nextSemver, err := util.ParseSemver(*state.ClientNextVersion)
|
||||
if err != nil {
|
||||
return gerr.WrapService(fmt.Errorf("parse pending client version %q: %w", *state.ClientNextVersion, err))
|
||||
}
|
||||
if util.CompareSemver(nextSemver, latestSemver) >= 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.downloadArtifact(latest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.ClientNextVersion = &latest.Version
|
||||
return m.saveState(state)
|
||||
}
|
||||
|
||||
// MarkLaunchResult records the outcome of a launched artifact and promotes
|
||||
// pending versions to current only after a successful run.
|
||||
func (m *Manager) MarkLaunchResult(version string, exitCode int, runErr error) error {
|
||||
state, err := m.ensureState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if state.ClientNextVersion != nil && *state.ClientNextVersion == version {
|
||||
if runErr == nil && exitCode == 0 {
|
||||
state.ClientCurrentVersion = version
|
||||
}
|
||||
state.ClientNextVersion = nil
|
||||
if err := m.saveState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.cleanupArtifacts(state)
|
||||
}
|
||||
|
||||
if runErr == nil && exitCode == 0 {
|
||||
return m.cleanupArtifacts(state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) launchTargetForVersion(version string, pending bool) (LaunchTarget, error) {
|
||||
path := ArtifactPath(version, m.goos, m.goarch, m.kind)
|
||||
exists, absPath, err := m.storage.FileExists(path)
|
||||
if err != nil {
|
||||
return LaunchTarget{}, err
|
||||
}
|
||||
if !exists {
|
||||
return LaunchTarget{}, gerr.WrapStorage(
|
||||
fmt.Errorf("client artifact for version %q not found at %q", version, path),
|
||||
)
|
||||
}
|
||||
return LaunchTarget{
|
||||
Version: version,
|
||||
Path: absPath,
|
||||
Pending: pending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ensureState() (mc.State, error) {
|
||||
if m.storage == nil {
|
||||
return mc.State{}, gerr.WrapStorage(errors.New("client updater storage is not configured"))
|
||||
}
|
||||
|
||||
exists, err := m.storage.StateExists()
|
||||
if err != nil {
|
||||
return mc.State{}, err
|
||||
}
|
||||
if !exists {
|
||||
state := mc.State{}
|
||||
if err := m.storage.SaveState(state); err != nil {
|
||||
return mc.State{}, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
return m.storage.LoadState()
|
||||
}
|
||||
|
||||
func (m *Manager) saveState(state mc.State) error {
|
||||
return m.storage.SaveState(state)
|
||||
}
|
||||
|
||||
func (m *Manager) downloadArtifact(version connector.VersionInfo) error {
|
||||
data, err := m.connector.DownloadVersion(version.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := connector.NewSHA256Digest(data)
|
||||
if !digest.Equal(version.Checksum) {
|
||||
return gerr.WrapService(fmt.Errorf("downloaded client artifact checksum mismatch for version %s", version.Version))
|
||||
}
|
||||
|
||||
path := ArtifactPath(version.Version, version.OS, version.Arch, version.Kind)
|
||||
exists, _, err := m.storage.FileExists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
storedData, err := m.storage.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if connector.NewSHA256Digest(storedData).Equal(version.Checksum) {
|
||||
return nil
|
||||
}
|
||||
if err := m.storage.DeleteFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.storage.WriteFile(path, data)
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupArtifacts(state mc.State) error {
|
||||
files, err := m.storage.ListFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
retain := make(map[string]struct{}, 2)
|
||||
if state.ClientCurrentVersion != "" {
|
||||
retain[ArtifactPath(state.ClientCurrentVersion, m.goos, m.goarch, m.kind)] = struct{}{}
|
||||
}
|
||||
if state.ClientNextVersion != nil {
|
||||
retain[ArtifactPath(*state.ClientNextVersion, m.goos, m.goarch, m.kind)] = struct{}{}
|
||||
}
|
||||
|
||||
prefix := filepath.ToSlash(ArtifactDir) + "/"
|
||||
for _, file := range files {
|
||||
slashed := filepath.ToSlash(file)
|
||||
if !strings.HasPrefix(slashed, prefix) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(file), ArtifactPrefix+"-") {
|
||||
continue
|
||||
}
|
||||
if _, ok := retain[file]; ok {
|
||||
continue
|
||||
}
|
||||
if err := m.storage.DeleteFile(file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/connector"
|
||||
gerr "galaxy/error"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestArtifactPathWindowsAddsExe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.Equal(t, `ui\client-ui-1.2.3-windows-amd64-executable.exe`, got)
|
||||
}
|
||||
|
||||
func TestLatestCompatibleVersionSelectsPlatformExecutable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
versions := []connector.VersionInfo{
|
||||
{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.0"},
|
||||
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.3.0"},
|
||||
{OS: "windows", Arch: "arm64", Kind: connector.ArtifactKindExecutable, Version: "9.9.9"},
|
||||
}
|
||||
|
||||
got, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "1.3.0", got.Version)
|
||||
}
|
||||
|
||||
func TestLatestCompatibleVersionRejectsUnsupportedKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
versions := []connector.VersionInfo{
|
||||
{OS: "windows", Arch: "amd64", Kind: "shared-library", Version: "1.0.0"},
|
||||
}
|
||||
|
||||
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
require.True(t, gerr.IsService(err))
|
||||
}
|
||||
|
||||
func TestLatestCompatibleVersionRejectsAmbiguousVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
versions := []connector.VersionInfo{
|
||||
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||
}
|
||||
|
||||
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.False(t, ok)
|
||||
require.Error(t, err)
|
||||
require.True(t, gerr.IsService(err))
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
type rasterWidgetRender struct {
|
||||
canvas *interactiveRaster
|
||||
bg *canvas.Raster
|
||||
onLayout func(fyne.Size)
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) Layout(size fyne.Size) {
|
||||
r.bg.Resize(size)
|
||||
r.canvas.raster.Resize(size)
|
||||
if r.onLayout != nil {
|
||||
r.onLayout(size)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) MinSize() fyne.Size {
|
||||
return r.MinSize()
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) Refresh() {
|
||||
canvas.Refresh(r.canvas)
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) BackgroundColor() color.Color {
|
||||
return theme.Color(theme.ColorNameBackground)
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) Objects() []fyne.CanvasObject {
|
||||
return []fyne.CanvasObject{r.bg, r.canvas.raster}
|
||||
}
|
||||
|
||||
func (r *rasterWidgetRender) Destroy() {
|
||||
}
|
||||
@@ -1,629 +0,0 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"galaxy/calc"
|
||||
"galaxy/client/widget/numeric"
|
||||
"galaxy/util"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type CalculatorOpt func(*Calculator)
|
||||
type ShipClass struct {
|
||||
Name string
|
||||
Drive float64
|
||||
Armament uint
|
||||
Weapons float64
|
||||
Shields float64
|
||||
Cargo float64
|
||||
}
|
||||
type ShipClassFn func(string, float64, uint, float64, float64, float64)
|
||||
|
||||
type Calculator struct {
|
||||
CanvasObject fyne.CanvasObject
|
||||
|
||||
playerDrivesTech float64
|
||||
playerWeaponsTech float64
|
||||
playerShieldsTech float64
|
||||
playerCargoTech float64
|
||||
|
||||
shipDriveEntry *numeric.FloatEntry
|
||||
shipWeaponsEntry *numeric.FloatEntry
|
||||
shipArmamentEntry *numeric.IntEntry
|
||||
shipShieldsEntry *numeric.FloatEntry
|
||||
shipCargoEntry *numeric.FloatEntry
|
||||
|
||||
playerDrivesTechEntry *numeric.FloatEntry
|
||||
playerWeaponsTechEntry *numeric.FloatEntry
|
||||
playerShieldsTechEntry *numeric.FloatEntry
|
||||
playerCargoTechEntry *numeric.FloatEntry
|
||||
|
||||
drivesTechOverride *widget.Check
|
||||
weaponsTechOverride *widget.Check
|
||||
shieldsTechOverride *widget.Check
|
||||
cargoTechOverride *widget.Check
|
||||
|
||||
massEntry *numeric.FloatEntry
|
||||
speedEntry *numeric.FloatEntry
|
||||
attackEntry *numeric.FloatEntry
|
||||
defenseEntry *numeric.FloatEntry
|
||||
cargoLoadEntry *numeric.FloatEntry
|
||||
planetMatEntry *numeric.FloatEntry
|
||||
|
||||
massOverride *widget.Check
|
||||
speedOverride *widget.Check
|
||||
attackOverride *widget.Check
|
||||
defenseOverride *widget.Check
|
||||
cargoLoadMaximize *widget.Check
|
||||
planetMatOverride *widget.Check
|
||||
|
||||
planetLabel *widget.Label
|
||||
planetMassProdLabel *widget.Label
|
||||
planetShipsProdLabel *widget.Label
|
||||
planetContainer fyne.CanvasObject
|
||||
planetProdContainer fyne.CanvasObject
|
||||
|
||||
shipSelector *widget.SelectEntry
|
||||
shipCreateButton *widget.Button
|
||||
|
||||
onCreateHandler ShipClassFn
|
||||
loader ShipClassFn
|
||||
knownClasses []ShipClass
|
||||
|
||||
validateMu sync.RWMutex
|
||||
|
||||
l, mat, res float64
|
||||
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func WithPlayerDrives(v float64) CalculatorOpt {
|
||||
return func(c *Calculator) { c.playerDrivesTech = v }
|
||||
}
|
||||
|
||||
func WithPlayerWeapons(v float64) CalculatorOpt {
|
||||
return func(c *Calculator) { c.playerWeaponsTech = v }
|
||||
}
|
||||
|
||||
func WithPlayerShields(v float64) CalculatorOpt {
|
||||
return func(c *Calculator) { c.playerShieldsTech = v }
|
||||
}
|
||||
|
||||
func WithPlayerCargo(v float64) CalculatorOpt {
|
||||
return func(c *Calculator) { c.playerCargoTech = v }
|
||||
}
|
||||
|
||||
func WithCreateHandler(f ShipClassFn) CalculatorOpt {
|
||||
return func(c *Calculator) { c.onCreateHandler = f }
|
||||
}
|
||||
|
||||
func NewCaclulator(opts ...CalculatorOpt) *Calculator {
|
||||
c := &Calculator{}
|
||||
|
||||
c.shipCreateButton = widget.NewButton(lang.L("ship.action.create"), c.onCreateShipClassButton)
|
||||
c.shipCreateButton.Disable()
|
||||
c.loader = c.LoadShipClass
|
||||
|
||||
c.planetMatEntry = numeric.NewFloatEntry(10, c.onPlanetMatChange)
|
||||
c.planetMatOverride = widget.NewCheck("", c.overridePlanetMat)
|
||||
c.planetMatOverride.Disable()
|
||||
c.planetLabel = widget.NewLabel("")
|
||||
c.planetMassProdLabel = bareLabel("")
|
||||
c.planetShipsProdLabel = bareLabel("")
|
||||
c.planetProdContainer = container.NewHBox(
|
||||
label(lang.L("planet.prod.mass")+":"),
|
||||
fixedLabel(c.planetMassProdLabel, 80),
|
||||
label(lang.L("planet.prod.ships")+":"),
|
||||
fixedLabel(c.planetShipsProdLabel, 80),
|
||||
)
|
||||
c.planetProdContainer.Hide()
|
||||
|
||||
c.planetContainer = container.NewVBox(
|
||||
widget.NewSeparator(),
|
||||
container.NewHBox(c.planetLabel),
|
||||
rowForItem(lang.L("planet.mat")+":", floatEntry(c.planetMatEntry, 100), c.planetMatOverride),
|
||||
c.planetProdContainer,
|
||||
)
|
||||
c.planetContainer.Hide()
|
||||
|
||||
c.shipSelector = widget.NewSelectEntry(nil)
|
||||
c.shipSelector.OnChanged = c.onShipSelectorChange
|
||||
|
||||
c.shipDriveEntry = numeric.NewFloatEntry(7, c.onShipDriveChange)
|
||||
c.shipWeaponsEntry = numeric.NewFloatEntry(7, c.onShipWeaponsChange)
|
||||
c.shipArmamentEntry = numeric.NewIntEntry(7, c.onShipArmamentChange)
|
||||
c.shipShieldsEntry = numeric.NewFloatEntry(7, c.onShipShieldsChange)
|
||||
c.shipCargoEntry = numeric.NewFloatEntry(7, c.onShipCargoChange)
|
||||
|
||||
c.playerDrivesTechEntry = numeric.NewFloatEntry(7, c.onDrivesTechChange)
|
||||
c.playerWeaponsTechEntry = numeric.NewFloatEntry(7, c.onWeaponsTechChange)
|
||||
c.playerShieldsTechEntry = numeric.NewFloatEntry(7, c.onShieldsTechChange)
|
||||
c.playerCargoTechEntry = numeric.NewFloatEntry(7, c.onCargoTechChange)
|
||||
|
||||
c.massEntry = numeric.NewFloatEntry(7, c.onMassChange)
|
||||
c.speedEntry = numeric.NewFloatEntry(7, c.onSpeedChange)
|
||||
c.attackEntry = numeric.NewFloatEntry(7, c.onAttackChange)
|
||||
c.defenseEntry = numeric.NewFloatEntry(7, c.onDefenseChange)
|
||||
c.cargoLoadEntry = numeric.NewFloatEntry(7, c.onCargoLoadChange)
|
||||
|
||||
c.drivesTechOverride = widget.NewCheck("", c.overrideDrivesTech)
|
||||
c.drivesTechOverride.Disable()
|
||||
c.weaponsTechOverride = widget.NewCheck("", c.overrideWeaponsTech)
|
||||
c.weaponsTechOverride.Disable()
|
||||
c.shieldsTechOverride = widget.NewCheck("", c.overrideShieldsTech)
|
||||
c.shieldsTechOverride.Disable()
|
||||
c.cargoTechOverride = widget.NewCheck("", c.overrideCargoTech)
|
||||
c.cargoTechOverride.Disable()
|
||||
|
||||
c.massOverride = widget.NewCheck("", c.overrideMass)
|
||||
c.massOverride.Disable()
|
||||
c.speedOverride = widget.NewCheck("", c.overrideSpeed)
|
||||
c.speedOverride.Disable()
|
||||
c.attackOverride = widget.NewCheck("", c.overrideAttack)
|
||||
c.attackOverride.Disable()
|
||||
c.defenseOverride = widget.NewCheck("", c.overrideDefense)
|
||||
c.defenseOverride.Disable()
|
||||
c.cargoLoadMaximize = widget.NewCheck(lang.L("label.max"), c.maximizeCargoLoad)
|
||||
c.cargoLoadMaximize.SetChecked(true)
|
||||
|
||||
createShip := container.NewBorder(
|
||||
nil, // top
|
||||
nil, // bottom
|
||||
nil, // left
|
||||
c.shipCreateButton, // right
|
||||
c.shipSelector, // center
|
||||
)
|
||||
|
||||
c.CanvasObject = container.NewVBox(
|
||||
container.NewPadded(createShip),
|
||||
widget.NewSeparator(),
|
||||
rowForTech(lang.L("tech.d")+":",
|
||||
c.shipDriveEntry, floatEntry(c.playerDrivesTechEntry, 80), c.drivesTechOverride),
|
||||
rowForWeapons(lang.L("tech.w")+":",
|
||||
c.shipArmamentEntry, c.shipWeaponsEntry, floatEntry(c.playerWeaponsTechEntry, 80), c.weaponsTechOverride),
|
||||
rowForTech(lang.L("tech.s")+":",
|
||||
c.shipShieldsEntry, floatEntry(c.playerShieldsTechEntry, 80), c.shieldsTechOverride),
|
||||
rowForTech(lang.L("tech.c")+":",
|
||||
c.shipCargoEntry, floatEntry(c.playerCargoTechEntry, 80), c.cargoTechOverride),
|
||||
widget.NewSeparator(),
|
||||
rowForItem(lang.L("ship.load")+":",
|
||||
floatEntry(c.cargoLoadEntry, 80), c.cargoLoadMaximize),
|
||||
rowForItem(lang.L("ship.mass")+":",
|
||||
floatEntry(c.massEntry, 80), c.massOverride),
|
||||
rowForItem(lang.L("ship.speed")+":",
|
||||
floatEntry(c.speedEntry, 80), c.speedOverride),
|
||||
rowForItem(lang.L("ship.attack")+":",
|
||||
floatEntry(c.attackEntry, 80), c.attackOverride),
|
||||
rowForItem(lang.L("ship.defense")+":",
|
||||
floatEntry(c.defenseEntry, 80), c.defenseOverride),
|
||||
c.planetContainer,
|
||||
)
|
||||
|
||||
c.Init(opts...)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Calculator) Init(opts ...CalculatorOpt) {
|
||||
for i := range opts {
|
||||
opts[i](c)
|
||||
}
|
||||
|
||||
c.playerDrivesTechEntry.SetOrigin(c.playerDrivesTech)
|
||||
c.playerWeaponsTechEntry.SetOrigin(c.playerWeaponsTech)
|
||||
c.playerShieldsTechEntry.SetOrigin(c.playerShieldsTech)
|
||||
c.playerCargoTechEntry.SetOrigin(c.playerCargoTech)
|
||||
|
||||
c.CanvasObject.Show()
|
||||
}
|
||||
|
||||
func (c *Calculator) Refresh() {
|
||||
c.validate()
|
||||
c.CanvasObject.Refresh()
|
||||
}
|
||||
|
||||
func (c *Calculator) RegisterClasses(shipClass ...ShipClass) {
|
||||
c.knownClasses = shipClass
|
||||
names := make([]string, len(c.knownClasses))
|
||||
for i := range c.knownClasses {
|
||||
names[i] = c.knownClasses[i].Name
|
||||
}
|
||||
slices.Sort(names)
|
||||
c.shipSelector = widget.NewSelectEntry(names)
|
||||
c.shipSelector.OnChanged = c.onShipSelectorChange
|
||||
}
|
||||
|
||||
func (c *Calculator) onCreateShipClassButton() {
|
||||
if c.onCreateHandler == nil || !c.Valid {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Calculator) validate() {
|
||||
fyne.Do(func() {
|
||||
c.validateMu.Lock()
|
||||
err := c.validateEntries()
|
||||
c.Valid = err == nil
|
||||
if err != nil {
|
||||
} else {
|
||||
}
|
||||
c.shipClassNameValidate()
|
||||
c.validateMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Calculator) validateEntries() (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
c.cargoLoadEntry.Clear()
|
||||
if !c.massOverride.Checked {
|
||||
c.massEntry.Clear()
|
||||
}
|
||||
if !c.speedOverride.Checked {
|
||||
c.speedEntry.Clear()
|
||||
}
|
||||
if !c.attackOverride.Checked {
|
||||
c.attackEntry.Clear()
|
||||
}
|
||||
if !c.defenseOverride.Checked {
|
||||
c.defenseEntry.Clear()
|
||||
}
|
||||
// c.planetProdContainer.Hide()
|
||||
}
|
||||
}()
|
||||
drive, ok := c.shipDriveEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Parameter Drive is not valid")
|
||||
return
|
||||
}
|
||||
driveTech, ok := c.playerDrivesTechEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Drive tech level is not valid")
|
||||
return
|
||||
}
|
||||
armament, ok := c.shipArmamentEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Parameter Armament is not valid")
|
||||
return
|
||||
}
|
||||
weapons, ok := c.shipWeaponsEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Parameter Weapons is not valid")
|
||||
return
|
||||
}
|
||||
weaponsTech, ok := c.playerWeaponsTechEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Weapons tech level is not valid")
|
||||
return
|
||||
}
|
||||
shields, ok := c.shipShieldsEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Parameter Shields is not valid")
|
||||
return
|
||||
}
|
||||
shieldsTech, ok := c.playerShieldsTechEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Shields tech level is not valid")
|
||||
return
|
||||
}
|
||||
cargo, ok := c.shipCargoEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Parameter Cargo is not valid")
|
||||
return
|
||||
}
|
||||
cargoTech, ok := c.playerCargoTechEntry.Value()
|
||||
if !ok {
|
||||
err = errors.New("Cargo tech level is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
err = calc.ValidateShipTypeValues(drive, armament, weapons, shields, cargo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var cargoLoad float64
|
||||
if c.cargoLoadMaximize.Checked {
|
||||
cargoLoad = calc.CargoCapacity(cargo, cargoTech)
|
||||
c.cargoLoadEntry.SetOrigin(cargoLoad)
|
||||
} else if cargoLoad, ok = c.cargoLoadEntry.Value(); !ok {
|
||||
err = errors.New("Cargo load value is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
emptyMass, ok := calc.EmptyMass(drive, weapons, uint(armament), shields, cargo)
|
||||
if !ok {
|
||||
err = errors.New("Unable to calculate empty mass (check armament and weapons)")
|
||||
return
|
||||
}
|
||||
fullMass := calc.FullMass(emptyMass, cargoLoad)
|
||||
speed := calc.Speed(calc.DriveEffective(drive, driveTech), fullMass)
|
||||
effectiveAttack := calc.EffectiveAttack(weapons, weaponsTech)
|
||||
effectiveDefense := calc.EffectiveDefence(shields, shieldsTech, fullMass)
|
||||
|
||||
c.massEntry.SetOrigin(emptyMass)
|
||||
c.speedEntry.SetOrigin(speed)
|
||||
c.attackEntry.SetOrigin(effectiveAttack)
|
||||
c.defenseEntry.SetOrigin(effectiveDefense)
|
||||
|
||||
planetMat, ok := c.planetMatEntry.Value()
|
||||
if !ok {
|
||||
// c.planetProdContainer.Hide()
|
||||
} else {
|
||||
massProd := calc.PlanetProduceShipMass(c.l, planetMat, c.res)
|
||||
c.planetMassProdLabel.SetText(strconv.FormatFloat(util.Fixed3(massProd), 'f', -1, 64))
|
||||
ships := 0.
|
||||
if emptyMass > 0 {
|
||||
ships = massProd / emptyMass
|
||||
}
|
||||
c.planetShipsProdLabel.SetText(strconv.FormatFloat(util.Fixed3(ships), 'f', -1, 64))
|
||||
c.planetProdContainer.Show()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Calculator) onOriginInputChange(cb *widget.Check, e *numeric.FloatEntry) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
if cb != nil {
|
||||
cb.Checked = e.Overriden()
|
||||
if !cb.Checked {
|
||||
cb.Disable()
|
||||
} else {
|
||||
cb.Enable()
|
||||
}
|
||||
}
|
||||
c.onFloatEntryChange(e)
|
||||
}
|
||||
|
||||
func (c *Calculator) onFloatEntryChange(e *numeric.FloatEntry) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.Validate()
|
||||
c.validate()
|
||||
}
|
||||
|
||||
func (c *Calculator) onIntEntryChange(e *numeric.IntEntry) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.Validate()
|
||||
c.validate()
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideChecked(cb *widget.Check, e *numeric.FloatEntry) {
|
||||
if cb == nil || e == nil {
|
||||
return
|
||||
}
|
||||
if !cb.Checked {
|
||||
e.Reset()
|
||||
cb.Disable()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipDriveChange(string) {
|
||||
c.onFloatEntryChange(c.shipDriveEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipArmamentChange(string) {
|
||||
defer c.onIntEntryChange(c.shipArmamentEntry)
|
||||
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
|
||||
return
|
||||
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
|
||||
return
|
||||
} else if armament > 0 && weapons == 0 {
|
||||
c.shipWeaponsEntry.SetOrigin(1.0)
|
||||
} else if armament == 0 && weapons > 0 {
|
||||
c.shipWeaponsEntry.SetOrigin(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipWeaponsChange(string) {
|
||||
defer c.onFloatEntryChange(c.shipWeaponsEntry)
|
||||
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
|
||||
return
|
||||
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
|
||||
return
|
||||
} else if weapons > 0 && armament == 0 {
|
||||
c.shipArmamentEntry.SetOrigin(1)
|
||||
} else if weapons == 0 && armament > 0 {
|
||||
c.shipArmamentEntry.SetOrigin(0)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipShieldsChange(string) {
|
||||
c.onFloatEntryChange(c.shipShieldsEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipCargoChange(string) {
|
||||
c.onFloatEntryChange(c.shipCargoEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onDrivesTechChange(string) {
|
||||
c.onOriginInputChange(c.drivesTechOverride, c.playerDrivesTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideDrivesTech(bool) {
|
||||
c.overrideChecked(c.drivesTechOverride, c.playerDrivesTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onWeaponsTechChange(string) {
|
||||
c.onOriginInputChange(c.weaponsTechOverride, c.playerWeaponsTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideWeaponsTech(bool) {
|
||||
c.overrideChecked(c.weaponsTechOverride, c.playerWeaponsTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onShieldsTechChange(string) {
|
||||
c.onOriginInputChange(c.shieldsTechOverride, c.playerShieldsTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideShieldsTech(bool) {
|
||||
c.overrideChecked(c.shieldsTechOverride, c.playerShieldsTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onCargoTechChange(string) {
|
||||
c.onOriginInputChange(c.cargoTechOverride, c.playerCargoTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideCargoTech(bool) {
|
||||
c.overrideChecked(c.cargoTechOverride, c.playerCargoTechEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onCargoLoadChange(string) {
|
||||
c.onFloatEntryChange(c.cargoLoadEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onMassChange(string) {
|
||||
c.onOriginInputChange(c.massOverride, c.massEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideMass(bool) {
|
||||
c.overrideChecked(c.massOverride, c.massEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onSpeedChange(string) {
|
||||
c.onOriginInputChange(c.speedOverride, c.speedEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideSpeed(bool) {
|
||||
c.overrideChecked(c.speedOverride, c.speedEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onAttackChange(string) {
|
||||
c.onOriginInputChange(c.attackOverride, c.attackEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideAttack(bool) {
|
||||
c.overrideChecked(c.attackOverride, c.attackEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onDefenseChange(string) {
|
||||
c.onOriginInputChange(c.defenseOverride, c.defenseEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overrideDefense(bool) {
|
||||
c.overrideChecked(c.defenseOverride, c.defenseEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) maximizeCargoLoad(bool) {
|
||||
c.validate()
|
||||
}
|
||||
|
||||
func (c *Calculator) onPlanetMatChange(string) {
|
||||
c.onOriginInputChange(c.planetMatOverride, c.planetMatEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) overridePlanetMat(bool) {
|
||||
c.overrideChecked(c.planetMatOverride, c.planetMatEntry)
|
||||
}
|
||||
|
||||
func (c *Calculator) onShipSelectorChange(v string) {
|
||||
i, ok := c.shipClassNameValidate()
|
||||
if i < 0 || !ok || c.loader == nil {
|
||||
return
|
||||
}
|
||||
c.loader(
|
||||
c.knownClasses[i].Name,
|
||||
c.knownClasses[i].Drive,
|
||||
c.knownClasses[i].Armament,
|
||||
c.knownClasses[i].Weapons,
|
||||
c.knownClasses[i].Shields,
|
||||
c.knownClasses[i].Cargo,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Calculator) shipClassNameValidate() (int, bool) {
|
||||
var canCreateShip bool
|
||||
defer func() {
|
||||
if canCreateShip && c.Valid {
|
||||
c.shipCreateButton.Enable()
|
||||
} else {
|
||||
c.shipCreateButton.Disable()
|
||||
}
|
||||
}()
|
||||
name, canCreateShip := util.ValidateTypeName(c.shipSelector.Text)
|
||||
if canCreateShip {
|
||||
c.shipSelector.Text = name
|
||||
}
|
||||
i := slices.IndexFunc(c.knownClasses, func(v ShipClass) bool { return v.Name == name })
|
||||
canCreateShip = canCreateShip && i < 0
|
||||
return i, canCreateShip
|
||||
}
|
||||
|
||||
func (c *Calculator) LoadShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
|
||||
c.shipDriveEntry.SetOrigin(D)
|
||||
c.shipArmamentEntry.SetOrigin(int(A))
|
||||
c.shipWeaponsEntry.SetOrigin(W)
|
||||
c.shipShieldsEntry.SetOrigin(S)
|
||||
c.shipCargoEntry.SetOrigin(C)
|
||||
}
|
||||
|
||||
func rowForItem(l string, entry, override fyne.CanvasObject) fyne.CanvasObject {
|
||||
i := []fyne.CanvasObject{label(l), entry}
|
||||
if override != nil {
|
||||
i = append(i, override)
|
||||
}
|
||||
return container.NewHBox(i...)
|
||||
}
|
||||
|
||||
func rowForTech(l string, shipEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewHBox(
|
||||
label(l),
|
||||
floatEntry(shipEntry, 115),
|
||||
widget.NewLabel("@"),
|
||||
techEntry,
|
||||
btn,
|
||||
)
|
||||
}
|
||||
|
||||
func rowForWeapons(l string, armamentEntry, weaponsEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.NewHBox(
|
||||
label(l),
|
||||
intEntry(armamentEntry, 35),
|
||||
floatEntry(weaponsEntry, 75),
|
||||
widget.NewLabel("@"),
|
||||
techEntry,
|
||||
btn,
|
||||
)
|
||||
}
|
||||
|
||||
func label(l string) fyne.CanvasObject {
|
||||
return fixedLabel(bareLabel(l), 110)
|
||||
}
|
||||
|
||||
func fixedLabel(w *widget.Label, width float32) fyne.CanvasObject {
|
||||
s := container.NewHScroll(w)
|
||||
s.SetMinSize(fyne.NewSize(width, 1))
|
||||
return s
|
||||
}
|
||||
|
||||
func bareLabel(l string) *widget.Label {
|
||||
w := widget.NewLabelWithStyle(l, fyne.TextAlignTrailing, fyne.TextStyle{Monospace: true, Symbol: false})
|
||||
w.Selectable = false
|
||||
w.Truncation = fyne.TextTruncateOff
|
||||
return w
|
||||
}
|
||||
|
||||
func intEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
|
||||
s := container.NewHScroll(content)
|
||||
s.SetMinSize(fyne.NewSize(width, 1))
|
||||
return s
|
||||
}
|
||||
|
||||
func floatEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
|
||||
s := container.NewHScroll(content)
|
||||
s.SetMinSize(fyne.NewSize(width, 1))
|
||||
return s
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func (c *Calculator) UnloadPlanet() {
|
||||
c.planetContainer.Hide()
|
||||
}
|
||||
|
||||
func (c *Calculator) LoadPlanet(name string, number uint, L, Mat, Res float64) {
|
||||
c.l, c.mat, c.res = L, Mat, Res
|
||||
c.planetLabel.SetText(lang.L("planet.title", map[string]any{"Number": number, "Name": name}))
|
||||
c.planetMatEntry.SetOrigin(Mat)
|
||||
c.planetContainer.Show()
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package numeric
|
||||
|
||||
import (
|
||||
"galaxy/client/widget/validator"
|
||||
"galaxy/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/driver/mobile"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type FloatEntry struct {
|
||||
widget.Entry
|
||||
origin float64
|
||||
MaxValue float64
|
||||
maxSize uint
|
||||
validator fyne.StringValidator
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type IntEntry struct {
|
||||
widget.Entry
|
||||
origin uint
|
||||
MaxValue uint
|
||||
maxSize uint
|
||||
validator fyne.StringValidator
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry {
|
||||
e := &FloatEntry{maxSize: maxSize, validator: validator.FloatEntryValidator}
|
||||
e.ExtendBaseWidget(e)
|
||||
e.Entry.Scroll = fyne.ScrollNone
|
||||
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
// e.Validator = validator.FloatEntryValidator
|
||||
// e.AlwaysShowValidationError = true
|
||||
e.Entry.ActionItem = nil
|
||||
e.SetOrigin(0)
|
||||
e.Validate()
|
||||
e.Entry.OnChanged = onChanged
|
||||
return e
|
||||
}
|
||||
|
||||
func NewIntEntry(maxSize uint, onChanged func(string)) *IntEntry {
|
||||
e := &IntEntry{maxSize: maxSize, validator: validator.IntEntryValidator}
|
||||
e.ExtendBaseWidget(e)
|
||||
e.Entry.Scroll = fyne.ScrollNone
|
||||
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
|
||||
// e.Validator = validator.IntEntryValidator
|
||||
// e.AlwaysShowValidationError = true
|
||||
e.Entry.ActionItem = nil
|
||||
e.SetOrigin(0)
|
||||
e.Validate()
|
||||
e.Entry.OnChanged = onChanged
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *FloatEntry) CreateRenderer() fyne.WidgetRenderer {
|
||||
r := e.Entry.CreateRenderer()
|
||||
return r
|
||||
}
|
||||
|
||||
func (e *FloatEntry) TypedRune(r rune) {
|
||||
if !((r >= '0' && r <= '9') || r == '.') {
|
||||
return
|
||||
}
|
||||
if !lengthBelowLimit(e.Entry.Text, e.maxSize) && e.Entry.SelectedText() == "" {
|
||||
return
|
||||
}
|
||||
if r == '.' && strings.Contains(e.Entry.Text, ".") {
|
||||
return
|
||||
}
|
||||
e.Entry.TypedRune(r)
|
||||
}
|
||||
|
||||
func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) {
|
||||
paste, ok := shortcut.(*fyne.ShortcutPaste)
|
||||
if !ok {
|
||||
e.Entry.TypedShortcut(shortcut)
|
||||
return
|
||||
}
|
||||
|
||||
content := paste.Clipboard.Content()
|
||||
if _, err := strconv.ParseFloat(content, 64); err == nil {
|
||||
e.Entry.TypedShortcut(shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Keyboard() mobile.KeyboardType {
|
||||
return mobile.NumberKeyboard
|
||||
}
|
||||
|
||||
func (e *FloatEntry) SetOrigin(v float64) {
|
||||
if v < 0 {
|
||||
return
|
||||
}
|
||||
e.origin = v
|
||||
e.Reset()
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Reset() {
|
||||
e.SetValue(e.origin)
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Clear() {
|
||||
onChanged := e.Entry.OnChanged
|
||||
e.Entry.OnChanged = nil
|
||||
e.Entry.SetText("")
|
||||
e.Entry.OnChanged = onChanged
|
||||
}
|
||||
|
||||
func (e *FloatEntry) SetValue(v float64) {
|
||||
if v < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
e.Entry.SetText(strconv.FormatFloat(util.Fixed3(v), 'f', -1, 64))
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Value() (float64, bool) {
|
||||
if v, err := validator.ParseFloat(e.Entry.Text); err != nil {
|
||||
return 0, false
|
||||
} else {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Overriden() bool {
|
||||
if v, ok := e.Value(); !ok {
|
||||
return false
|
||||
} else {
|
||||
return util.Fixed3(v) != util.Fixed3(e.origin)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *FloatEntry) Validate() {
|
||||
if e.validator == nil {
|
||||
return
|
||||
}
|
||||
err := e.validator(e.Entry.Text)
|
||||
e.Valid = err == nil
|
||||
}
|
||||
|
||||
func (e *IntEntry) TypedRune(r rune) {
|
||||
if r >= '0' && r <= '9' {
|
||||
if lengthBelowLimit(e.Entry.Text, e.maxSize) || e.Entry.SelectedText() != "" {
|
||||
e.Entry.TypedRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *IntEntry) TypedShortcut(shortcut fyne.Shortcut) {
|
||||
paste, ok := shortcut.(*fyne.ShortcutPaste)
|
||||
if !ok {
|
||||
e.Entry.TypedShortcut(shortcut)
|
||||
return
|
||||
}
|
||||
|
||||
content := paste.Clipboard.Content()
|
||||
if _, err := strconv.ParseInt(content, 10, 64); err == nil {
|
||||
e.Entry.TypedShortcut(shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *IntEntry) Keyboard() mobile.KeyboardType {
|
||||
return mobile.NumberKeyboard
|
||||
}
|
||||
|
||||
func (e *IntEntry) SetOrigin(v int) {
|
||||
if v < 0 {
|
||||
return
|
||||
}
|
||||
e.origin = uint(v)
|
||||
e.Reset()
|
||||
}
|
||||
|
||||
func (e *IntEntry) Reset() {
|
||||
e.SetValue(int(e.origin))
|
||||
}
|
||||
|
||||
func (e *IntEntry) SetValue(v int) {
|
||||
if v < 0 {
|
||||
return
|
||||
}
|
||||
e.Entry.SetText(strconv.Itoa(v))
|
||||
}
|
||||
|
||||
func (e *IntEntry) Value() (int, bool) {
|
||||
if v, err := validator.ParseInt(e.Entry.Text); err != nil {
|
||||
return 0, false
|
||||
} else {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *IntEntry) Overriden() bool {
|
||||
if v, ok := e.Value(); !ok {
|
||||
return false
|
||||
} else {
|
||||
return v != int(e.origin)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *IntEntry) Validate() {
|
||||
if e.validator == nil {
|
||||
return
|
||||
}
|
||||
err := e.validator(e.Entry.Text)
|
||||
e.Valid = err == nil
|
||||
}
|
||||
|
||||
func lengthBelowLimit(s string, max uint) bool {
|
||||
return utf8.RuneCountInString(s) < int(max)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
type floatValidator func(float64) error
|
||||
|
||||
var (
|
||||
FloatEntryValidator = numericEntryValidator(
|
||||
nonNegativeValidator,
|
||||
minOrZeroValueValidator(1.),
|
||||
)
|
||||
IntEntryValidator = numericEntryValidator(
|
||||
intValidator,
|
||||
nonNegativeValidator,
|
||||
minOrZeroValueValidator(1.),
|
||||
)
|
||||
)
|
||||
|
||||
func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator {
|
||||
if first == nil {
|
||||
panic("first validator cannot be nil")
|
||||
}
|
||||
return func(s string) error {
|
||||
if err := first(s); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rest {
|
||||
if err := rest[i](s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewMutualValidator(other func() float64, valid func(float64) bool) fyne.StringValidator {
|
||||
if other == nil {
|
||||
panic("other value getter cannot be nil")
|
||||
}
|
||||
return func(s string) error {
|
||||
myValue, err := ParseFloat(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid(myValue) {
|
||||
return errors.New("invalid value")
|
||||
}
|
||||
if !valid(other()) {
|
||||
return errors.New("invalid other value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func numericEntryValidator(other ...floatValidator) fyne.StringValidator {
|
||||
return func(s string) error {
|
||||
v, err := ParseFloat(s)
|
||||
if err != nil {
|
||||
return errors.New("not a float value")
|
||||
}
|
||||
for i := range other {
|
||||
if err := other[i](v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func nonNegativeValidator(v float64) error {
|
||||
if v < 0 {
|
||||
return errors.New("value must be greater of equal to zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intValidator(v float64) error {
|
||||
if float64(int(v)) != v {
|
||||
return errors.New("value must be an integer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func minOrZeroValueValidator(min float64) floatValidator {
|
||||
return func(f float64) error {
|
||||
if f > 0 && f < min {
|
||||
return fmt.Errorf("value must be zero or >= %f", min)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func FloatValueValidator(s string) error {
|
||||
if _, err := ParseFloat(s); err != nil {
|
||||
return errors.New("not a float value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IntValueValidator(s string) error {
|
||||
if _, err := ParseInt(s); err != nil {
|
||||
return errors.New("not an integer value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseFloat(s string) (float64, error) {
|
||||
return strconv.ParseFloat(s, 64)
|
||||
}
|
||||
|
||||
func ParseInt(s string) (int, error) {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err != nil {
|
||||
return 0, err
|
||||
} else {
|
||||
return int(v), nil
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
# World rendering package
|
||||
|
||||
> **Deprecated.** This package belongs to the deprecated
|
||||
> `galaxy/client` Fyne client. New code must not import it. The
|
||||
> active map renderer lives in `ui/frontend/src/map/` (TypeScript
|
||||
> + PixiJS), with its specification in `ui/docs/renderer.md`. The
|
||||
> sources here remain for historical context only and are not the
|
||||
> reference algorithm for the new renderer.
|
||||
|
||||
## Purpose
|
||||
|
||||
`world` is the client-side map model and renderer for a 2D world that normally
|
||||
behaves like a torus. It owns:
|
||||
|
||||
- primitive storage (`Point`, `Line`, `Circle`)
|
||||
- world-space indexing for render and hit-test queries
|
||||
- theme and style resolution
|
||||
- full-frame and incremental rendering onto an expanded canvas
|
||||
- no-wrap helpers used by the UI when torus scrolling is disabled
|
||||
|
||||
The package does not own UI widgets, event loops, or camera policy beyond the
|
||||
helpers exposed for zoom/clamp calculations.
|
||||
|
||||
## Symbol Map
|
||||
|
||||
- World creation and mutation: `NewWorld`, `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `Reindex`
|
||||
- Viewport/index lifecycle: `IndexOnViewportChange`, `SetCircleRadiusScaleFp`
|
||||
- Rendering: `Render`, `RenderParams`, `RenderOptions`, `PrimitiveDrawer`, `GGDrawer`
|
||||
- No-wrap camera helpers: `CorrectCameraZoom`, `ClampCameraNoWrapViewport`, `ClampRenderParamsNoWrap`, `PivotZoomCameraNoWrap`
|
||||
- Hit testing: `HitTest`, `Hit`, `PrimitiveKind`
|
||||
- Styling and themes: `Style`, `StyleOverride`, `StyleTable`, `StyleTheme`, `DefaultTheme`, `ThemeLight`, `ThemeDark`
|
||||
|
||||
## Coordinate Model
|
||||
|
||||
- World geometry is stored in fixed-point integers.
|
||||
- `SCALE == 1000`, so `1.0` world units are represented as `1000`.
|
||||
- Primitive coordinates, radii, world dimensions, and camera positions use `world-fixed` units.
|
||||
- Viewport and canvas sizes use integer `canvas px`.
|
||||
- Rectangles in world space and canvas space are treated as half-open intervals:
|
||||
`[minX, maxX) x [minY, maxY)`.
|
||||
- `RenderParams` describes the visible viewport, but rendering happens on the
|
||||
expanded canvas:
|
||||
- `canvasWidthPx = viewportWidthPx + 2*marginXPx`
|
||||
- `canvasHeightPx = viewportHeightPx + 2*marginYPx`
|
||||
- The camera always points to the center of the visible viewport, not the center
|
||||
of the expanded canvas.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `World` stores torus dimensions `W` and `H` in fixed-point units.
|
||||
- `MapItem` is implemented by `Point`, `Line`, and `Circle`.
|
||||
- `PrimitiveID` is allocated by `World` and may be reused after removal.
|
||||
- Each primitive carries:
|
||||
- geometry in fixed-point world coordinates
|
||||
- `Priority` for deterministic draw order inside a tile
|
||||
- resolved `StyleID`
|
||||
- theme binding metadata (`Base`, `Override`, `Class`)
|
||||
- optional per-primitive hit slop in pixels
|
||||
- Themes resolve base styles per primitive kind, then optional class overrides,
|
||||
then optional user `StyleOverride`.
|
||||
- Explicit `StyleID` bypasses theme-relative recomputation across theme changes.
|
||||
|
||||
## Spatial Index Lifecycle
|
||||
|
||||
- Rendering and hit testing depend on the grid index stored in `World.grid`.
|
||||
- `IndexOnViewportChange` must be called after viewport size or zoom changes.
|
||||
- The grid cell size is derived from the current visible world span:
|
||||
- start from roughly `visibleMin / 8`
|
||||
- clamp into `[16*SCALE, 512*SCALE]`
|
||||
- `AddPoint`, `AddLine`, `AddCircle`, `Remove`, `SetCircleRadiusScaleFp`, and
|
||||
`Reindex` mark the index dirty and rebuild it automatically when the last
|
||||
viewport/zoom state is known.
|
||||
- Circle indexing uses the effective radius after `circleRadiusScaleFp` is applied.
|
||||
- Line indexing uses the torus-shortest representation and indexes its wrapped
|
||||
bounding boxes rather than exact rasterized coverage.
|
||||
|
||||
## Render Pipeline
|
||||
|
||||
`Render` follows this sequence:
|
||||
|
||||
1. Validate `RenderParams` and resolve background color/theme state.
|
||||
2. Convert zoom to fixed-point and compute the expanded unwrapped world rect.
|
||||
3. Split that rect into `WorldTile` segments:
|
||||
- torus mode uses wrapped tiling
|
||||
- no-wrap mode intersects against the bounded world once
|
||||
4. Query the spatial grid per tile and deduplicate candidates per tile by `PrimitiveID`.
|
||||
5. Build a `RenderPlan` containing:
|
||||
- tile-to-canvas clip rectangles
|
||||
- per-tile candidate lists
|
||||
6. Draw background before primitives.
|
||||
7. Draw primitives tile-by-tile in deterministic order:
|
||||
- `Priority` ascending
|
||||
- primitive kind as stable tie-breaker
|
||||
- `PrimitiveID` ascending
|
||||
8. For wrapped rendering:
|
||||
- points and circles emit only the torus copies that intersect the current tile
|
||||
- lines are split into torus-shortest canonical segments before projection
|
||||
|
||||
## Incremental Pan Rendering
|
||||
|
||||
- `Render` first tries incremental pan reuse through `ComputePanShiftPx` and
|
||||
`PlanIncrementalPan`.
|
||||
- If only camera pan changed and the shift stays inside the configured margins:
|
||||
- existing pixels are moved with `PrimitiveDrawer.CopyShift`
|
||||
- newly exposed strips become dirty rects
|
||||
- dirty rects are cleared, background-redrawn, and clipped primitive redraw is applied
|
||||
- If geometry changed in a way that breaks reuse, rendering falls back to full redraw.
|
||||
- Theme changes, circle radius scale changes, and explicit `ForceFullRedrawNext`
|
||||
reset incremental state.
|
||||
|
||||
## No-Wrap Behavior
|
||||
|
||||
When `RenderOptions.DisableWrapScroll == true`, the world is treated as a bounded
|
||||
plane instead of a torus.
|
||||
|
||||
- `CorrectCameraZoom` prevents the visible viewport from becoming larger than the world.
|
||||
- `ClampCameraNoWrapViewport` clamps the camera so the viewport remains inside the world.
|
||||
- `ClampRenderParamsNoWrap` applies the same rule directly to `RenderParams`.
|
||||
- `PivotZoomCameraNoWrap` keeps the world point under the cursor stable while zoom changes.
|
||||
|
||||
Margins are ignored by viewport clamp on purpose so panning remains usable even
|
||||
when the expanded canvas extends beyond the world bounds.
|
||||
|
||||
## Hit Testing
|
||||
|
||||
- `HitTest` expects the grid to be built already.
|
||||
- Cursor coordinates are passed in viewport pixels relative to the viewport top-left.
|
||||
- The query path is:
|
||||
1. convert cursor position into world-fixed coordinates
|
||||
2. clamp or wrap based on no-wrap mode
|
||||
3. query a conservative grid search box using default hit slop
|
||||
4. run exact per-primitive hit checks
|
||||
- Point hits use disc distance.
|
||||
- Circle hits distinguish between filled circles and stroke-only rings.
|
||||
- Line hits use the same torus-shortest segment decomposition as rendering.
|
||||
- Final ranking is:
|
||||
- `Priority` descending
|
||||
- squared distance ascending
|
||||
- primitive kind ascending
|
||||
- `PrimitiveID` ascending
|
||||
|
||||
## UI Integration Checklist
|
||||
|
||||
Typical UI flow:
|
||||
|
||||
1. Create the world with `NewWorld`.
|
||||
2. Add primitives and optional styles/themes.
|
||||
3. Before each render, compute the current viewport size in pixels.
|
||||
4. Call `CorrectCameraZoom` when UI zoom changes.
|
||||
5. Call `IndexOnViewportChange` when viewport size or zoom changes.
|
||||
6. If no-wrap mode is enabled, call `ClampRenderParamsNoWrap`.
|
||||
7. Render into a `PrimitiveDrawer` with `Render`.
|
||||
8. Reuse the same `RenderParams` snapshot for `HitTest`.
|
||||
|
||||
The `client` package in this repository follows exactly that pattern.
|
||||
|
||||
## Important Invariants and Limits
|
||||
|
||||
- `Render` and `HitTest` require the grid to be initialized; otherwise they return `errGridNotBuilt`.
|
||||
- The package assumes single-goroutine access to hot render scratch buffers stored in `World`.
|
||||
- `RenderScheduler` is only a coalescing example. It is not a license to call
|
||||
`Render` on arbitrary background goroutines in real UI code.
|
||||
- `PrimitiveDrawer` receives final canvas coordinates only; all torus math stays inside `world`.
|
||||
- Background anchoring can be viewport-relative or world-relative, but dirty redraws
|
||||
always use the same anchoring logic as full redraws.
|
||||
@@ -1,642 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"github.com/fogleman/gg"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
|
||||
//
|
||||
// The renderer is responsible for all torus logic, viewport/margin logic,
|
||||
// coordinate projection, and primitive duplication. This interface only accepts
|
||||
// final canvas pixel coordinates and exposes the minimum drawing operations
|
||||
// needed to build and render paths.
|
||||
//
|
||||
// AddPoint, AddLine, and AddCircle append geometry to the current path.
|
||||
// They do not render by themselves. The caller must finalize the path by
|
||||
// calling Stroke or Fill.
|
||||
//
|
||||
// Save and Restore are intended for temporary local state changes such as
|
||||
// clipping, colors, line width, or dash settings. After Restore, the outer
|
||||
// drawing state must be visible again.
|
||||
type PrimitiveDrawer interface {
|
||||
// Save stores the current drawing state.
|
||||
Save()
|
||||
|
||||
// Restore restores the most recently saved drawing state.
|
||||
Restore()
|
||||
|
||||
// ResetClip clears the current clipping region completely.
|
||||
ResetClip()
|
||||
|
||||
// ClipRect intersects the current clipping region with the given rectangle
|
||||
// in canvas pixel coordinates.
|
||||
ClipRect(x, y, w, h float64)
|
||||
|
||||
// SetStrokeColor sets the color used by Stroke.
|
||||
SetStrokeColor(c color.Color)
|
||||
|
||||
// SetFillColor sets the color used by Fill.
|
||||
SetFillColor(c color.Color)
|
||||
|
||||
// SetLineWidth sets the line width used by Stroke.
|
||||
SetLineWidth(width float64)
|
||||
|
||||
// SetDash sets the dash pattern used by Stroke.
|
||||
// Passing no values clears the current dash pattern.
|
||||
SetDash(dashes ...float64)
|
||||
|
||||
// SetDashOffset sets the dash phase used by Stroke.
|
||||
SetDashOffset(offset float64)
|
||||
|
||||
// AddPoint appends a point marker centered at (x, y) with radius r
|
||||
// to the current path in canvas pixel coordinates.
|
||||
AddPoint(x, y, r float64)
|
||||
|
||||
// AddLine appends a line segment to the current path in canvas pixel coordinates.
|
||||
AddLine(x1, y1, x2, y2 float64)
|
||||
|
||||
// AddCircle appends a circle to the current path in canvas pixel coordinates.
|
||||
AddCircle(cx, cy, r float64)
|
||||
|
||||
// Stroke renders the current path using the current stroke state.
|
||||
Stroke()
|
||||
|
||||
// Fill renders the current path using the current fill state.
|
||||
Fill()
|
||||
|
||||
// CopyShift shifts backing pixels by (dx,dy). Newly exposed areas become transparent/undefined;
|
||||
// caller is expected to ClearRectTo() the dirty areas before drawing.
|
||||
CopyShift(dx, dy int)
|
||||
|
||||
// Clear operations must NOT change clip state.
|
||||
ClearAllTo(bg color.Color)
|
||||
ClearRectTo(x, y, w, h int, bg color.Color)
|
||||
|
||||
DrawImage(img image.Image, x, y int)
|
||||
|
||||
DrawImageScaled(img image.Image, x, y, w, h int)
|
||||
}
|
||||
|
||||
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
|
||||
// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop
|
||||
// do not restore clip masks the way this package expects.
|
||||
type ggClipRect struct {
|
||||
x, y float64
|
||||
w, h float64
|
||||
}
|
||||
|
||||
// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context.
|
||||
//
|
||||
// It intentionally does not perform any world logic. It only forwards already
|
||||
// projected canvas coordinates to gg while additionally maintaining a clip stack
|
||||
// compatible with this package's Save/Restore contract.
|
||||
type GGDrawer struct {
|
||||
DC *gg.Context
|
||||
|
||||
clips []ggClipRect
|
||||
clipStack [][]ggClipRect
|
||||
|
||||
// scratch is a reusable buffer for CopyShift to avoid allocations.
|
||||
scratch *image.RGBA
|
||||
|
||||
bgCache bgTileCache
|
||||
}
|
||||
|
||||
// Save stores the current gg state and the current logical clip stack.
|
||||
func (d *GGDrawer) Save() {
|
||||
d.DC.Push()
|
||||
|
||||
snapshot := append([]ggClipRect(nil), d.clips...)
|
||||
d.clipStack = append(d.clipStack, snapshot)
|
||||
}
|
||||
|
||||
// Restore restores the previous gg state and rebuilds the outer clip state.
|
||||
//
|
||||
// gg.Context.Pop restores most state from the stack, but its clip mask handling
|
||||
// does not match this package's expected Save/Restore semantics. To preserve the
|
||||
// contract, GGDrawer explicitly resets the clip and replays the previously saved
|
||||
// clip rectangles after Pop.
|
||||
func (d *GGDrawer) Restore() {
|
||||
if len(d.clipStack) == 0 {
|
||||
panic("GGDrawer: Restore without matching Save")
|
||||
}
|
||||
|
||||
snapshot := d.clipStack[len(d.clipStack)-1]
|
||||
d.clipStack = d.clipStack[:len(d.clipStack)-1]
|
||||
|
||||
d.DC.Pop()
|
||||
|
||||
d.clips = append([]ggClipRect(nil), snapshot...)
|
||||
d.DC.ResetClip()
|
||||
for _, clip := range d.clips {
|
||||
d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h)
|
||||
d.DC.Clip()
|
||||
}
|
||||
}
|
||||
|
||||
// ResetClip clears the current clipping region and the logical clip stack
|
||||
// for the active state frame.
|
||||
func (d *GGDrawer) ResetClip() {
|
||||
d.DC.ResetClip()
|
||||
d.clips = nil
|
||||
}
|
||||
|
||||
// ClipRect intersects the current clipping region with the given rectangle
|
||||
// and records it so the clip can be reconstructed after Restore.
|
||||
func (d *GGDrawer) ClipRect(x, y, w, h float64) {
|
||||
d.DC.DrawRectangle(x, y, w, h)
|
||||
d.DC.Clip()
|
||||
|
||||
d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h})
|
||||
}
|
||||
|
||||
// SetStrokeColor sets the stroke color by installing a solid stroke pattern.
|
||||
func (d *GGDrawer) SetStrokeColor(c color.Color) {
|
||||
d.DC.SetStrokeStyle(gg.NewSolidPattern(c))
|
||||
}
|
||||
|
||||
// SetFillColor sets the fill color by installing a solid fill pattern.
|
||||
func (d *GGDrawer) SetFillColor(c color.Color) {
|
||||
d.DC.SetFillStyle(gg.NewSolidPattern(c))
|
||||
}
|
||||
|
||||
// SetLineWidth sets the line width used for stroking.
|
||||
func (d *GGDrawer) SetLineWidth(width float64) {
|
||||
d.DC.SetLineWidth(width)
|
||||
}
|
||||
|
||||
// SetDash sets the dash pattern used for stroking.
|
||||
func (d *GGDrawer) SetDash(dashes ...float64) {
|
||||
d.DC.SetDash(dashes...)
|
||||
}
|
||||
|
||||
// SetDashOffset sets the dash phase used for stroking.
|
||||
func (d *GGDrawer) SetDashOffset(offset float64) {
|
||||
d.DC.SetDashOffset(offset)
|
||||
}
|
||||
|
||||
// AddPoint appends a point marker to the current path.
|
||||
func (d *GGDrawer) AddPoint(x, y, r float64) {
|
||||
d.DC.DrawPoint(x, y, r)
|
||||
}
|
||||
|
||||
// AddLine appends a line segment to the current path.
|
||||
func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) {
|
||||
d.DC.DrawLine(x1, y1, x2, y2)
|
||||
}
|
||||
|
||||
// AddCircle appends a circle to the current path.
|
||||
func (d *GGDrawer) AddCircle(cx, cy, r float64) {
|
||||
d.DC.DrawCircle(cx, cy, r)
|
||||
}
|
||||
|
||||
// Stroke renders the current path using the current stroke state.
|
||||
func (d *GGDrawer) Stroke() {
|
||||
d.DC.Stroke()
|
||||
}
|
||||
|
||||
// Fill renders the current path using the current fill state.
|
||||
func (d *GGDrawer) Fill() {
|
||||
d.DC.Fill()
|
||||
}
|
||||
|
||||
// CopyShift shifts the backing RGBA image by (dx, dy) pixels.
|
||||
// It clears newly exposed areas to transparent.
|
||||
func (d *GGDrawer) CopyShift(dx, dy int) {
|
||||
if dx == 0 && dy == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
img, ok := d.DC.Image().(*image.RGBA)
|
||||
if !ok || img == nil {
|
||||
panic("GGDrawer.CopyShift: backing image is not *image.RGBA")
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w := b.Dx()
|
||||
h := b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
adx := abs(dx)
|
||||
ady := abs(dy)
|
||||
if adx >= w || ady >= h {
|
||||
// Everything shifts out of bounds => just clear.
|
||||
for i := range img.Pix {
|
||||
img.Pix[i] = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare scratch with the same bounds.
|
||||
if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h {
|
||||
d.scratch = image.NewRGBA(b)
|
||||
} else {
|
||||
// Clear scratch to transparent.
|
||||
for i := range d.scratch.Pix {
|
||||
d.scratch.Pix[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Compute source/destination rectangles.
|
||||
dstX0 := 0
|
||||
dstY0 := 0
|
||||
srcX0 := 0
|
||||
srcY0 := 0
|
||||
if dx > 0 {
|
||||
dstX0 = dx
|
||||
} else {
|
||||
srcX0 = -dx
|
||||
}
|
||||
if dy > 0 {
|
||||
dstY0 = dy
|
||||
} else {
|
||||
srcY0 = -dy
|
||||
}
|
||||
|
||||
copyW := w - max(dstX0, srcX0)
|
||||
copyH := h - max(dstY0, srcY0)
|
||||
if copyW <= 0 || copyH <= 0 {
|
||||
for i := range img.Pix {
|
||||
img.Pix[i] = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Copy row-by-row (RGBA, 4 bytes per pixel).
|
||||
for row := 0; row < copyH; row++ {
|
||||
srcY := srcY0 + row
|
||||
dstY := dstY0 + row
|
||||
|
||||
srcOff := srcY*img.Stride + srcX0*4
|
||||
dstOff := dstY*d.scratch.Stride + dstX0*4
|
||||
n := copyW * 4
|
||||
|
||||
copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n])
|
||||
}
|
||||
|
||||
// Swap buffers by copying scratch into img.
|
||||
// (We keep img pointer stable for gg.Context.)
|
||||
copy(img.Pix, d.scratch.Pix)
|
||||
}
|
||||
|
||||
func (d *GGDrawer) ClearAllTo(bg color.Color) {
|
||||
img, ok := d.DC.Image().(*image.RGBA)
|
||||
if !ok || img == nil {
|
||||
panic("GGDrawer.ClearAllTo: backing image is not *image.RGBA")
|
||||
}
|
||||
|
||||
R, G, B, A := rgba8(bg)
|
||||
|
||||
// Prepare one full scanline once.
|
||||
w := img.Bounds().Dx()
|
||||
if w <= 0 {
|
||||
return
|
||||
}
|
||||
line := make([]byte, w*4)
|
||||
for i := 0; i < len(line); i += 4 {
|
||||
line[i+0] = R
|
||||
line[i+1] = G
|
||||
line[i+2] = B
|
||||
line[i+3] = A
|
||||
}
|
||||
|
||||
// Copy scanline into each row (fast memmove).
|
||||
h := img.Bounds().Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
off := y * img.Stride
|
||||
copy(img.Pix[off:off+w*4], line)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GGDrawer) ClearRectTo(x, y, w, h int, bg color.Color) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
img, ok := d.DC.Image().(*image.RGBA)
|
||||
if !ok || img == nil {
|
||||
panic("GGDrawer.ClearRectTo: backing image is not *image.RGBA")
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
x0 := max(x, b.Min.X)
|
||||
y0 := max(y, b.Min.Y)
|
||||
x1 := min(x+w, b.Max.X)
|
||||
y1 := min(y+h, b.Max.Y)
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return
|
||||
}
|
||||
|
||||
R, G, B, A := rgba8(bg)
|
||||
|
||||
rowPx := x1 - x0
|
||||
rowBytes := rowPx * 4
|
||||
|
||||
// Build one row once for this rect width.
|
||||
line := make([]byte, rowBytes)
|
||||
for i := 0; i < rowBytes; i += 4 {
|
||||
line[i+0] = R
|
||||
line[i+1] = G
|
||||
line[i+2] = B
|
||||
line[i+3] = A
|
||||
}
|
||||
|
||||
for yy := y0; yy < y1; yy++ {
|
||||
off := yy*img.Stride + x0*4
|
||||
copy(img.Pix[off:off+rowBytes], line)
|
||||
}
|
||||
}
|
||||
|
||||
// rgba8 converts any color.Color into 8-bit RGBA components.
|
||||
func rgba8(c color.Color) (R, G, B, A byte) {
|
||||
r, g, b, a := c.RGBA()
|
||||
return byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)
|
||||
}
|
||||
|
||||
func (g *GGDrawer) DrawImage(img image.Image, x, y int) {
|
||||
g.DC.DrawImage(img, x, y)
|
||||
}
|
||||
|
||||
func (g *GGDrawer) DrawImageScaled(img image.Image, x, y, w, h int) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
b := img.Bounds()
|
||||
srcW := b.Dx()
|
||||
srcH := b.Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
g.DC.Push()
|
||||
// Translate to destination top-left.
|
||||
g.DC.Translate(float64(x), float64(y))
|
||||
// Scale so that the source bounds map to (w,h).
|
||||
g.DC.Scale(float64(w)/float64(srcW), float64(h)/float64(srcH))
|
||||
// Draw at origin in the scaled coordinate system.
|
||||
g.DC.DrawImage(img, 0, 0)
|
||||
g.DC.Pop()
|
||||
}
|
||||
|
||||
// bgTileCacheKey identifies one scaled background-tile variant cached by GGDrawer.
|
||||
type bgTileCacheKey struct {
|
||||
imgPtr uintptr
|
||||
scaleMode BackgroundScaleMode
|
||||
canvasW int
|
||||
canvasH int
|
||||
srcW int
|
||||
srcH int
|
||||
}
|
||||
|
||||
// bgTileCache stores the most recently used scaled background tile.
|
||||
type bgTileCache struct {
|
||||
key bgTileCacheKey
|
||||
valid bool
|
||||
scaledTile *image.RGBA
|
||||
tileW int
|
||||
tileH int
|
||||
}
|
||||
|
||||
// drawBackgroundFast renders the background directly into the RGBA backing
|
||||
// image, bypassing gg path construction when the drawer supports it.
|
||||
func (g *GGDrawer) drawBackgroundFast(w *World, params RenderParams, rect RectPx) bool {
|
||||
th := w.Theme()
|
||||
bgImg := th.BackgroundImage()
|
||||
if bgImg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
dst, ok := g.DC.Image().(*image.RGBA)
|
||||
if !ok || dst == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
canvasW := params.CanvasWidthPx()
|
||||
canvasH := params.CanvasHeightPx()
|
||||
|
||||
// Clamp rect to canvas.
|
||||
if rect.W <= 0 || rect.H <= 0 {
|
||||
return true
|
||||
}
|
||||
if rect.X < 0 {
|
||||
rect.W += rect.X
|
||||
rect.X = 0
|
||||
}
|
||||
if rect.Y < 0 {
|
||||
rect.H += rect.Y
|
||||
rect.Y = 0
|
||||
}
|
||||
if rect.X+rect.W > canvasW {
|
||||
rect.W = canvasW - rect.X
|
||||
}
|
||||
if rect.Y+rect.H > canvasH {
|
||||
rect.H = canvasH - rect.Y
|
||||
}
|
||||
if rect.W <= 0 || rect.H <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
imgB := bgImg.Bounds()
|
||||
srcW := imgB.Dx()
|
||||
srcH := imgB.Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
tileMode := th.BackgroundTileMode()
|
||||
anchor := th.BackgroundAnchorMode()
|
||||
scaleMode := th.BackgroundScaleMode()
|
||||
|
||||
// Compute scaled tile size in pixels (scale depends on canvas size).
|
||||
tileW, tileH := backgroundScaledSize(srcW, srcH, canvasW, canvasH, scaleMode)
|
||||
if tileW <= 0 || tileH <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Prepare the tile image (possibly scaled) from cache.
|
||||
tile := bgImg
|
||||
if scaleMode != BackgroundScaleNone || tileW != srcW || tileH != srcH {
|
||||
rgbaTile := g.getOrBuildScaledTile(bgImg, srcW, srcH, tileW, tileH, scaleMode, canvasW, canvasH)
|
||||
if rgbaTile == nil {
|
||||
// Fallback to slow path if we cannot scale (non-RGBA weirdness).
|
||||
return false
|
||||
}
|
||||
tile = rgbaTile
|
||||
}
|
||||
|
||||
offX, offY := w.backgroundAnchorOffsetPx(params, tileW, tileH, anchor)
|
||||
|
||||
switch tileMode {
|
||||
case BackgroundTileNone:
|
||||
// Draw single image centered in full canvas, then clipped by rect.
|
||||
x := (canvasW-tileW)/2 + offX
|
||||
y := (canvasH-tileH)/2 + offY
|
||||
w.drawOneTileRGBA(dst, tile, rect, x, y)
|
||||
|
||||
case BackgroundTileRepeat:
|
||||
originX := offX
|
||||
originY := offY
|
||||
|
||||
startX := floorDiv(rect.X-originX, tileW)*tileW + originX
|
||||
startY := floorDiv(rect.Y-originY, tileH)*tileH + originY
|
||||
|
||||
for yy := startY; yy < rect.Y+rect.H; yy += tileH {
|
||||
for xx := startX; xx < rect.X+rect.W; xx += tileW {
|
||||
w.drawOneTileRGBA(dst, tile, rect, xx, yy)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Treat unknown as none.
|
||||
x := (canvasW-tileW)/2 + offX
|
||||
y := (canvasH-tileH)/2 + offY
|
||||
w.drawOneTileRGBA(dst, tile, rect, x, y)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getOrBuildScaledTile returns the cached scaled tile image for the current
|
||||
// background configuration, rebuilding it when the cache key changes.
|
||||
func (g *GGDrawer) getOrBuildScaledTile(img image.Image, srcW, srcH, dstW, dstH int, mode BackgroundScaleMode, canvasW, canvasH int) *image.RGBA {
|
||||
// Identify image pointer (themes typically provide *image.RGBA).
|
||||
ptr := imagePointer(img)
|
||||
|
||||
key := bgTileCacheKey{
|
||||
imgPtr: ptr,
|
||||
scaleMode: mode,
|
||||
canvasW: canvasW,
|
||||
canvasH: canvasH,
|
||||
srcW: srcW,
|
||||
srcH: srcH,
|
||||
}
|
||||
if g.bgCache.valid && g.bgCache.key == key && g.bgCache.scaledTile != nil &&
|
||||
g.bgCache.tileW == dstW && g.bgCache.tileH == dstH {
|
||||
return g.bgCache.scaledTile
|
||||
}
|
||||
|
||||
// Scale only from *image.RGBA fast; otherwise, try a generic slow path.
|
||||
var scaled *image.RGBA
|
||||
if srcRGBA, ok := img.(*image.RGBA); ok {
|
||||
scaled = scaleNearestRGBA(srcRGBA, dstW, dstH)
|
||||
} else {
|
||||
scaled = scaleNearestGeneric(img, dstW, dstH)
|
||||
}
|
||||
|
||||
g.bgCache.key = key
|
||||
g.bgCache.valid = true
|
||||
g.bgCache.scaledTile = scaled
|
||||
g.bgCache.tileW = dstW
|
||||
g.bgCache.tileH = dstH
|
||||
|
||||
return scaled
|
||||
}
|
||||
|
||||
// imagePointer returns a stable pointer identity for pointer-backed images.
|
||||
// Non-pointer image values return 0, which disables cache reuse but remains correct.
|
||||
func imagePointer(img image.Image) uintptr {
|
||||
// Works well when img is a pointer type (e.g. *image.RGBA).
|
||||
// If not pointer, Pointer() returns 0; cache will be less effective but still correct.
|
||||
v := reflect.ValueOf(img)
|
||||
if v.Kind() == reflect.Pointer || v.Kind() == reflect.UnsafePointer {
|
||||
return v.Pointer()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// scaleNearestRGBA scales src -> dst with nearest-neighbor sampling.
|
||||
// This is intended for background textures; performance > quality.
|
||||
func scaleNearestRGBA(src *image.RGBA, dstW, dstH int) *image.RGBA {
|
||||
if dstW <= 0 || dstH <= 0 {
|
||||
return nil
|
||||
}
|
||||
sb := src.Bounds()
|
||||
sw := sb.Dx()
|
||||
sh := sb.Dy()
|
||||
if sw <= 0 || sh <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
|
||||
for y := 0; y < dstH; y++ {
|
||||
sy := (y * sh) / dstH
|
||||
srcOff := (sy+sb.Min.Y)*src.Stride + sb.Min.X*4
|
||||
dstOff := y * dst.Stride
|
||||
for x := 0; x < dstW; x++ {
|
||||
sx := (x * sw) / dstW
|
||||
si := srcOff + sx*4
|
||||
di := dstOff + x*4
|
||||
dst.Pix[di+0] = src.Pix[si+0]
|
||||
dst.Pix[di+1] = src.Pix[si+1]
|
||||
dst.Pix[di+2] = src.Pix[si+2]
|
||||
dst.Pix[di+3] = src.Pix[si+3]
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// scaleNearestGeneric scales an arbitrary image.Image with nearest-neighbor sampling.
|
||||
func scaleNearestGeneric(src image.Image, dstW, dstH int) *image.RGBA {
|
||||
if dstW <= 0 || dstH <= 0 {
|
||||
return nil
|
||||
}
|
||||
sb := src.Bounds()
|
||||
sw := sb.Dx()
|
||||
sh := sb.Dy()
|
||||
if sw <= 0 || sh <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
for y := 0; y < dstH; y++ {
|
||||
sy := sb.Min.Y + (y*sh)/dstH
|
||||
for x := 0; x < dstW; x++ {
|
||||
sx := sb.Min.X + (x*sw)/dstW
|
||||
dst.Set(x, y, src.At(sx, sy))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// drawOneTileRGBA draws tile at (x,y) into dst, but only the portion that intersects rect.
|
||||
// Uses draw.Over (alpha compositing), assuming caller already cleared rect to background color.
|
||||
func (w *World) drawOneTileRGBA(dst *image.RGBA, tile image.Image, rect RectPx, x, y int) {
|
||||
tileB := tile.Bounds()
|
||||
tw := tileB.Dx()
|
||||
th := tileB.Dy()
|
||||
if tw <= 0 || th <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Intersection of tile rect and target rect.
|
||||
tx0 := x
|
||||
ty0 := y
|
||||
tx1 := x + tw
|
||||
ty1 := y + th
|
||||
|
||||
rx0 := rect.X
|
||||
ry0 := rect.Y
|
||||
rx1 := rect.X + rect.W
|
||||
ry1 := rect.Y + rect.H
|
||||
|
||||
ix0 := max(tx0, rx0)
|
||||
iy0 := max(ty0, ry0)
|
||||
ix1 := min(tx1, rx1)
|
||||
iy1 := min(ty1, ry1)
|
||||
if ix0 >= ix1 || iy0 >= iy1 {
|
||||
return
|
||||
}
|
||||
|
||||
dstR := image.Rect(ix0, iy0, ix1, iy1)
|
||||
srcPt := image.Point{X: tileB.Min.X + (ix0 - tx0), Y: tileB.Min.Y + (iy0 - ty0)}
|
||||
|
||||
draw.Draw(dst, dstR, tile, srcPt, draw.Over)
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fogleman/gg"
|
||||
"github.com/stretchr/testify/require"
|
||||
"image"
|
||||
"image/color"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hasAnyNonTransparentPixel(img image.Image) bool {
|
||||
b := img.Bounds()
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
if a != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pixelHasAlpha(img image.Image, x, y int) bool {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
return a != 0
|
||||
}
|
||||
|
||||
// TestGGDrawerStrokeSequenceProducesPixels verifies gG Drawer Stroke Sequence Produces Pixels.
|
||||
func TestGGDrawerStrokeSequenceProducesPixels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.SetStrokeColor(color.RGBA{R: 255, A: 255})
|
||||
drawer.SetLineWidth(2)
|
||||
drawer.SetDash(4, 2)
|
||||
drawer.SetDashOffset(1)
|
||||
drawer.AddLine(4, 16, 28, 16)
|
||||
drawer.Stroke()
|
||||
|
||||
require.True(t, hasAnyNonTransparentPixel(dc.Image()))
|
||||
}
|
||||
|
||||
// TestGGDrawerFillSequenceProducesPixels verifies gG Drawer Fill Sequence Produces Pixels.
|
||||
func TestGGDrawerFillSequenceProducesPixels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.SetFillColor(color.RGBA{G: 255, A: 255})
|
||||
drawer.AddCircle(16, 16, 6)
|
||||
drawer.Fill()
|
||||
|
||||
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
||||
}
|
||||
|
||||
// TestGGDrawerPointSequenceProducesPixels verifies gG Drawer Point Sequence Produces Pixels.
|
||||
func TestGGDrawerPointSequenceProducesPixels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
|
||||
drawer.AddPoint(16, 16, 3)
|
||||
drawer.Fill()
|
||||
|
||||
require.True(t, pixelHasAlpha(dc.Image(), 16, 16))
|
||||
}
|
||||
|
||||
// TestGGDrawerClipRectLimitsDrawing verifies gG Drawer Clip Rect Limits Drawing.
|
||||
func TestGGDrawerClipRectLimitsDrawing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.Save()
|
||||
drawer.ClipRect(0, 0, 10, 32)
|
||||
drawer.SetFillColor(color.RGBA{B: 255, A: 255})
|
||||
drawer.AddCircle(15, 16, 10)
|
||||
drawer.Fill()
|
||||
drawer.Restore()
|
||||
|
||||
img := dc.Image()
|
||||
|
||||
require.True(t, pixelHasAlpha(img, 5, 16))
|
||||
require.False(t, pixelHasAlpha(img, 15, 16))
|
||||
}
|
||||
|
||||
// TestGGDrawerResetClipClearsClip verifies gG Drawer Reset Clip Clears Clip.
|
||||
func TestGGDrawerResetClipClearsClip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.ClipRect(0, 0, 10, 32)
|
||||
drawer.ResetClip()
|
||||
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
|
||||
drawer.AddCircle(15, 16, 10)
|
||||
drawer.Fill()
|
||||
|
||||
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
||||
}
|
||||
|
||||
// TestGGDrawerClearRectTo_FillsBackground verifies gG Drawer Clear Rect To Fills Background.
|
||||
func TestGGDrawerClearRectTo_FillsBackground(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(10, 10)
|
||||
dr := &GGDrawer{DC: dc}
|
||||
|
||||
// Draw something to ensure we overwrite non-background.
|
||||
dr.SetFillColor(color.RGBA{R: 255, A: 255})
|
||||
dr.AddCircle(5, 5, 5)
|
||||
dr.Fill()
|
||||
|
||||
bg := color.RGBA{A: 255} // black
|
||||
dr.ClearRectTo(1, 1, 2, 2, bg)
|
||||
|
||||
img := dc.Image()
|
||||
r, g, b, a := img.At(1, 1).RGBA()
|
||||
|
||||
require.Equal(t, uint32(0), r)
|
||||
require.Equal(t, uint32(0), g)
|
||||
require.Equal(t, uint32(0), b)
|
||||
require.Equal(t, uint32(0xffff), a)
|
||||
|
||||
// Pixel outside cleared rect should still have non-zero alpha.
|
||||
_, _, _, a2 := img.At(5, 5).RGBA()
|
||||
require.NotEqual(t, uint32(0), a2)
|
||||
}
|
||||
|
||||
// TestGGDrawerSaveRestoreRestoresClipState verifies gG Drawer Save Restore Restores Clip State.
|
||||
func TestGGDrawerSaveRestoreRestoresClipState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.Save()
|
||||
drawer.ClipRect(0, 0, 10, 32)
|
||||
drawer.Restore()
|
||||
|
||||
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
|
||||
drawer.AddCircle(15, 16, 10)
|
||||
drawer.Fill()
|
||||
|
||||
require.True(t, pixelHasAlpha(dc.Image(), 15, 16))
|
||||
}
|
||||
|
||||
// TestGGDrawerNestedSaveRestoreRestoresOuterClip verifies gG Drawer Nested Save Restore Restores Outer Clip.
|
||||
func TestGGDrawerNestedSaveRestoreRestoresOuterClip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(32, 32)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
drawer.ClipRect(0, 0, 20, 32)
|
||||
|
||||
drawer.Save()
|
||||
drawer.ClipRect(0, 0, 10, 32)
|
||||
drawer.Restore()
|
||||
|
||||
drawer.SetFillColor(color.RGBA{R: 255, G: 255, A: 255})
|
||||
drawer.AddCircle(15, 16, 10)
|
||||
drawer.Fill()
|
||||
|
||||
img := dc.Image()
|
||||
|
||||
require.True(t, pixelHasAlpha(img, 15, 16))
|
||||
require.False(t, pixelHasAlpha(img, 25, 16))
|
||||
}
|
||||
|
||||
// TestFakePrimitiveDrawerRecordsCommandsAndState verifies fake Primitive Drawer Records Commands And State.
|
||||
func TestFakePrimitiveDrawerRecordsCommandsAndState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
|
||||
d.Save()
|
||||
d.ClipRect(1, 2, 30, 40)
|
||||
d.SetStrokeColor(color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
d.SetFillColor(color.RGBA{R: 40, G: 50, B: 60, A: 255})
|
||||
d.SetLineWidth(3)
|
||||
d.SetDash(5, 6)
|
||||
d.SetDashOffset(7)
|
||||
d.AddLine(10, 11, 12, 13)
|
||||
d.Stroke()
|
||||
d.Restore()
|
||||
|
||||
requireDrawerCommandNames(t, d,
|
||||
"Save",
|
||||
"ClipRect",
|
||||
"SetStrokeColor",
|
||||
"SetFillColor",
|
||||
"SetLineWidth",
|
||||
"SetDash",
|
||||
"SetDashOffset",
|
||||
"AddLine",
|
||||
"Stroke",
|
||||
"Restore",
|
||||
)
|
||||
|
||||
cmd := requireDrawerSingleCommand(t, d, "AddLine")
|
||||
requireCommandArgs(t, cmd, 10, 11, 12, 13)
|
||||
requireCommandLineWidth(t, cmd, 3)
|
||||
requireCommandDashes(t, cmd, 5, 6)
|
||||
requireCommandDashOffset(t, cmd, 7)
|
||||
requireCommandClipRects(t, cmd, fakeClipRect{X: 1, Y: 2, W: 30, H: 40})
|
||||
require.Equal(t, color.RGBA{R: 10, G: 20, B: 30, A: 255}, cmd.StrokeColor)
|
||||
require.Equal(t, color.RGBA{R: 40, G: 50, B: 60, A: 255}, cmd.FillColor)
|
||||
}
|
||||
|
||||
// TestFakePrimitiveDrawerRestoreWithoutSavePanics verifies fake Primitive Drawer Restore Without Save Panics.
|
||||
func TestFakePrimitiveDrawerRestoreWithoutSavePanics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
|
||||
require.Panics(t, func() {
|
||||
d.Restore()
|
||||
})
|
||||
}
|
||||
|
||||
// TestFakePrimitiveDrawerSaveRestoreRestoresState verifies fake Primitive Drawer Save Restore Restores State.
|
||||
func TestFakePrimitiveDrawerSaveRestoreRestoresState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
|
||||
d.SetLineWidth(1)
|
||||
d.Save()
|
||||
d.SetLineWidth(9)
|
||||
d.ClipRect(1, 2, 3, 4)
|
||||
d.Restore()
|
||||
|
||||
state := d.CurrentState()
|
||||
|
||||
require.Equal(t, 1.0, state.LineWidth)
|
||||
require.Empty(t, state.Clips)
|
||||
require.Equal(t, 0, d.SaveDepth())
|
||||
}
|
||||
|
||||
// TestFakePrimitiveDrawerResetClipClearsOnlyClipState verifies fake Primitive Drawer Reset Clip Clears Only Clip State.
|
||||
func TestFakePrimitiveDrawerResetClipClearsOnlyClipState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
|
||||
d.SetLineWidth(4)
|
||||
d.ClipRect(1, 2, 3, 4)
|
||||
d.ResetClip()
|
||||
|
||||
state := d.CurrentState()
|
||||
|
||||
require.Equal(t, 4.0, state.LineWidth)
|
||||
require.Empty(t, state.Clips)
|
||||
}
|
||||
|
||||
// TestGGDrawerCopyShift_ShiftsPixels verifies gG Drawer Copy Shift Shifts Pixels.
|
||||
func TestGGDrawerCopyShift_ShiftsPixels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(10, 10)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
// Draw a single filled point at (1,1).
|
||||
drawer.SetFillColor(color.RGBA{R: 255, A: 255})
|
||||
drawer.AddPoint(1, 1, 1)
|
||||
drawer.Fill()
|
||||
|
||||
// Shift image right by 2 and down by 3.
|
||||
drawer.CopyShift(2, 3)
|
||||
|
||||
img := dc.Image()
|
||||
|
||||
// The old pixel near (1,1) should now be present near (3,4).
|
||||
// We check alpha only to avoid depending on exact blending.
|
||||
_, _, _, a := img.At(3, 4).RGBA()
|
||||
require.NotEqual(t, uint32(0), a)
|
||||
|
||||
// A pixel in the newly exposed top-left area should be transparent.
|
||||
_, _, _, a2 := img.At(0, 0).RGBA()
|
||||
require.Equal(t, uint32(0), a2)
|
||||
}
|
||||
|
||||
// TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState verifies gG Drawer Clear Rect To Does Not Affect Stroke State.
|
||||
func TestGGDrawer_ClearRectTo_DoesNotAffectStrokeState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dc := gg.NewContext(40, 20)
|
||||
d := &GGDrawer{DC: dc}
|
||||
|
||||
// Fill background to white.
|
||||
d.ClearAllTo(color.RGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
|
||||
// Configure stroke to red and draw first line.
|
||||
d.SetStrokeColor(color.RGBA{R: 255, A: 255})
|
||||
d.SetLineWidth(2)
|
||||
d.AddLine(2, 5, 38, 5)
|
||||
d.Stroke()
|
||||
|
||||
// Clear a rect in the middle with gray (must not affect stroke state).
|
||||
d.ClearRectTo(10, 0, 20, 20, color.RGBA{R: 200, G: 200, B: 200, A: 255})
|
||||
|
||||
// Draw second line WITHOUT reapplying stroke style; it must still be red.
|
||||
d.AddLine(2, 15, 38, 15)
|
||||
d.Stroke()
|
||||
|
||||
img := dc.Image()
|
||||
|
||||
// Sample a pixel from the second line (y ~15). We expect red channel dominates.
|
||||
r, g, b, a := img.At(20, 15).RGBA()
|
||||
require.Greater(t, a, uint32(0), "pixel must not be fully transparent")
|
||||
require.Greater(t, r, g, "expected red-ish pixel after ClearRectTo")
|
||||
require.Greater(t, r, b, "expected red-ish pixel after ClearRectTo")
|
||||
}
|
||||
|
||||
// fakeClipRect describes one clip rectangle in canvas pixel coordinates.
|
||||
type fakeClipRect struct {
|
||||
X, Y float64
|
||||
W, H float64
|
||||
}
|
||||
|
||||
// fakeDrawerState stores the active fake drawing state.
|
||||
// The state is copied on Save and restored on Restore.
|
||||
type fakeDrawerState struct {
|
||||
StrokeColor color.RGBA
|
||||
FillColor color.RGBA
|
||||
LineWidth float64
|
||||
Dashes []float64
|
||||
DashOffset float64
|
||||
Clips []fakeClipRect
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the state.
|
||||
func (s fakeDrawerState) clone() fakeDrawerState {
|
||||
out := s
|
||||
out.Dashes = append([]float64(nil), s.Dashes...)
|
||||
out.Clips = append([]fakeClipRect(nil), s.Clips...)
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeDrawerCommand is one recorded drawer call together with a snapshot
|
||||
// of the active fake drawing state at the moment of the call.
|
||||
type fakeDrawerCommand struct {
|
||||
Name string
|
||||
Args []float64
|
||||
StrokeColor color.RGBA
|
||||
FillColor color.RGBA
|
||||
LineWidth float64
|
||||
Dashes []float64
|
||||
DashOffset float64
|
||||
Clips []fakeClipRect
|
||||
}
|
||||
|
||||
// String returns a compact debug representation useful in assertion failures.
|
||||
func (c fakeDrawerCommand) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s args=%v stroke=%v fill=%v lineWidth=%v dashes=%v dashOffset=%v clips=%v",
|
||||
c.Name,
|
||||
c.Args,
|
||||
c.StrokeColor,
|
||||
c.FillColor,
|
||||
c.LineWidth,
|
||||
c.Dashes,
|
||||
c.DashOffset,
|
||||
c.Clips,
|
||||
)
|
||||
}
|
||||
|
||||
// fakePrimitiveDrawer is a reusable PrimitiveDrawer test double.
|
||||
// It records all calls and emulates stateful behavior, including nested
|
||||
// Save/Restore and clip reset semantics.
|
||||
type fakePrimitiveDrawer struct {
|
||||
commands []fakeDrawerCommand
|
||||
state fakeDrawerState
|
||||
stack []fakeDrawerState
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Ensure fakePrimitiveDrawer implements PrimitiveDrawer.
|
||||
var _ PrimitiveDrawer = (*fakePrimitiveDrawer)(nil)
|
||||
|
||||
// rgbaColor converts any color.Color into a comparable RGBA value.
|
||||
func rgbaColor(c color.Color) color.RGBA {
|
||||
if c == nil {
|
||||
return color.RGBA{}
|
||||
}
|
||||
return color.RGBAModel.Convert(c).(color.RGBA)
|
||||
}
|
||||
|
||||
// snapshotCommand records one command together with the current state snapshot.
|
||||
func (d *fakePrimitiveDrawer) snapshotCommand(name string, args ...float64) {
|
||||
cmd := fakeDrawerCommand{
|
||||
Name: name,
|
||||
Args: append([]float64(nil), args...),
|
||||
StrokeColor: d.state.StrokeColor,
|
||||
FillColor: d.state.FillColor,
|
||||
LineWidth: d.state.LineWidth,
|
||||
Dashes: append([]float64(nil), d.state.Dashes...),
|
||||
DashOffset: d.state.DashOffset,
|
||||
Clips: append([]fakeClipRect(nil), d.state.Clips...),
|
||||
}
|
||||
d.commands = append(d.commands, cmd)
|
||||
}
|
||||
|
||||
// Save stores the current fake state.
|
||||
func (d *fakePrimitiveDrawer) Save() {
|
||||
d.stack = append(d.stack, d.state.clone())
|
||||
d.snapshotCommand("Save")
|
||||
}
|
||||
|
||||
// Restore restores the most recently saved fake state.
|
||||
func (d *fakePrimitiveDrawer) Restore() {
|
||||
if len(d.stack) == 0 {
|
||||
panic("fakePrimitiveDrawer: Restore without matching Save")
|
||||
}
|
||||
|
||||
d.state = d.stack[len(d.stack)-1]
|
||||
d.stack = d.stack[:len(d.stack)-1]
|
||||
d.snapshotCommand("Restore")
|
||||
}
|
||||
|
||||
// ResetClip clears the current fake clip stack.
|
||||
func (d *fakePrimitiveDrawer) ResetClip() {
|
||||
d.state.Clips = nil
|
||||
d.snapshotCommand("ResetClip")
|
||||
}
|
||||
|
||||
// ClipRect appends one clip rectangle to the current fake state.
|
||||
func (d *fakePrimitiveDrawer) ClipRect(x, y, w, h float64) {
|
||||
d.state.Clips = append(d.state.Clips, fakeClipRect{X: x, Y: y, W: w, H: h})
|
||||
d.snapshotCommand("ClipRect", x, y, w, h)
|
||||
}
|
||||
|
||||
// SetStrokeColor sets the current fake stroke color.
|
||||
func (d *fakePrimitiveDrawer) SetStrokeColor(c color.Color) {
|
||||
d.state.StrokeColor = rgbaColor(c)
|
||||
d.snapshotCommand("SetStrokeColor")
|
||||
}
|
||||
|
||||
// SetFillColor sets the current fake fill color.
|
||||
func (d *fakePrimitiveDrawer) SetFillColor(c color.Color) {
|
||||
d.state.FillColor = rgbaColor(c)
|
||||
d.snapshotCommand("SetFillColor")
|
||||
}
|
||||
|
||||
// SetLineWidth sets the current fake line width.
|
||||
func (d *fakePrimitiveDrawer) SetLineWidth(width float64) {
|
||||
d.state.LineWidth = width
|
||||
d.snapshotCommand("SetLineWidth", width)
|
||||
}
|
||||
|
||||
// SetDash sets the current fake dash pattern.
|
||||
func (d *fakePrimitiveDrawer) SetDash(dashes ...float64) {
|
||||
d.state.Dashes = append([]float64(nil), dashes...)
|
||||
d.snapshotCommand("SetDash", dashes...)
|
||||
}
|
||||
|
||||
// SetDashOffset sets the current fake dash offset.
|
||||
func (d *fakePrimitiveDrawer) SetDashOffset(offset float64) {
|
||||
d.state.DashOffset = offset
|
||||
d.snapshotCommand("SetDashOffset", offset)
|
||||
}
|
||||
|
||||
// AddPoint records a point path append command.
|
||||
func (d *fakePrimitiveDrawer) AddPoint(x, y, r float64) {
|
||||
d.snapshotCommand("AddPoint", x, y, r)
|
||||
}
|
||||
|
||||
// AddLine records a line path append command.
|
||||
func (d *fakePrimitiveDrawer) AddLine(x1, y1, x2, y2 float64) {
|
||||
d.snapshotCommand("AddLine", x1, y1, x2, y2)
|
||||
}
|
||||
|
||||
// AddCircle records a circle path append command.
|
||||
func (d *fakePrimitiveDrawer) AddCircle(cx, cy, r float64) {
|
||||
d.snapshotCommand("AddCircle", cx, cy, r)
|
||||
}
|
||||
|
||||
// Stroke records a stroke finalization command.
|
||||
func (d *fakePrimitiveDrawer) Stroke() {
|
||||
d.snapshotCommand("Stroke")
|
||||
}
|
||||
|
||||
// Fill records a fill finalization command.
|
||||
func (d *fakePrimitiveDrawer) Fill() {
|
||||
d.snapshotCommand("Fill")
|
||||
}
|
||||
|
||||
// Commands returns a defensive copy of the recorded command log.
|
||||
func (d *fakePrimitiveDrawer) Commands() []fakeDrawerCommand {
|
||||
out := make([]fakeDrawerCommand, len(d.commands))
|
||||
copy(out, d.commands)
|
||||
return out
|
||||
}
|
||||
|
||||
// CommandNames returns only command names in call order.
|
||||
func (d *fakePrimitiveDrawer) CommandNames() []string {
|
||||
out := make([]string, 0, len(d.commands))
|
||||
for _, cmd := range d.commands {
|
||||
out = append(out, cmd.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CommandsByName returns all commands with the given name.
|
||||
func (d *fakePrimitiveDrawer) CommandsByName(name string) []fakeDrawerCommand {
|
||||
var out []fakeDrawerCommand
|
||||
for _, cmd := range d.commands {
|
||||
if cmd.Name == name {
|
||||
out = append(out, cmd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LastCommand returns the last recorded command and whether it exists.
|
||||
func (d *fakePrimitiveDrawer) LastCommand() (fakeDrawerCommand, bool) {
|
||||
if len(d.commands) == 0 {
|
||||
return fakeDrawerCommand{}, false
|
||||
}
|
||||
return d.commands[len(d.commands)-1], true
|
||||
}
|
||||
|
||||
// CurrentState returns a defensive copy of the current fake state.
|
||||
func (d *fakePrimitiveDrawer) CurrentState() fakeDrawerState {
|
||||
return d.state.clone()
|
||||
}
|
||||
|
||||
// SaveDepth returns the current Save/Restore nesting depth.
|
||||
func (d *fakePrimitiveDrawer) SaveDepth() int {
|
||||
return len(d.stack)
|
||||
}
|
||||
|
||||
// ResetLog clears only the command log and keeps the current state intact.
|
||||
func (d *fakePrimitiveDrawer) ResetLog() {
|
||||
d.commands = nil
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) CopyShift(dx, dy int) {
|
||||
d.snapshotCommand("CopyShift", float64(dx), float64(dy))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) ClearAllTo(_ color.Color) {
|
||||
// Store as a command; tests usually only care that it was called.
|
||||
d.snapshotCommand("ClearAllTo")
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) ClearRectTo(x, y, w, h int, _ color.Color) {
|
||||
d.snapshotCommand("ClearRectTo", float64(x), float64(y), float64(w), float64(h))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) DrawImage(_ image.Image, x, y int) {
|
||||
d.snapshotCommand("DrawImage", float64(x), float64(y))
|
||||
}
|
||||
|
||||
func (d *fakePrimitiveDrawer) DrawImageScaled(_ image.Image, x, y, w, h int) {
|
||||
d.snapshotCommand("DrawImageScaled", float64(x), float64(y), float64(w), float64(h))
|
||||
}
|
||||
func (d *fakePrimitiveDrawer) Reset() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.commands = d.commands[:0]
|
||||
}
|
||||
|
||||
// requireDrawerCommandNames asserts the exact command sequence recorded
|
||||
// by fakePrimitiveDrawer.
|
||||
func requireDrawerCommandNames(t *testing.T, d *fakePrimitiveDrawer, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, d.CommandNames())
|
||||
}
|
||||
|
||||
// requireDrawerCommandCount asserts the number of recorded commands.
|
||||
func requireDrawerCommandCount(t *testing.T, d *fakePrimitiveDrawer, want int) {
|
||||
t.Helper()
|
||||
|
||||
require.Len(t, d.Commands(), want)
|
||||
}
|
||||
|
||||
// requireDrawerCommandAt returns the command at the specified index.
|
||||
func requireDrawerCommandAt(t *testing.T, d *fakePrimitiveDrawer, index int) fakeDrawerCommand {
|
||||
t.Helper()
|
||||
|
||||
cmds := d.Commands()
|
||||
require.GreaterOrEqual(t, index, 0)
|
||||
require.Less(t, index, len(cmds))
|
||||
|
||||
return cmds[index]
|
||||
}
|
||||
|
||||
// requireDrawerSingleCommand returns the only command with the given name.
|
||||
func requireDrawerSingleCommand(t *testing.T, d *fakePrimitiveDrawer, name string) fakeDrawerCommand {
|
||||
t.Helper()
|
||||
|
||||
cmds := d.CommandsByName(name)
|
||||
require.Len(t, cmds, 1)
|
||||
|
||||
return cmds[0]
|
||||
}
|
||||
|
||||
// requireCommandName asserts the command name.
|
||||
func requireCommandName(t *testing.T, cmd fakeDrawerCommand, want string) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.Name)
|
||||
}
|
||||
|
||||
// requireCommandArgs asserts the exact float arguments.
|
||||
func requireCommandArgs(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.Args)
|
||||
}
|
||||
|
||||
// requireCommandArgsInDelta asserts the float arguments with tolerance.
|
||||
func requireCommandArgsInDelta(t *testing.T, cmd fakeDrawerCommand, delta float64, want ...float64) {
|
||||
t.Helper()
|
||||
|
||||
require.Len(t, cmd.Args, len(want))
|
||||
for i := range want {
|
||||
require.InDelta(t, want[i], cmd.Args[i], delta, "arg index %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// requireCommandClipRects asserts the clip stack snapshot attached to the command.
|
||||
func requireCommandClipRects(t *testing.T, cmd fakeDrawerCommand, want ...fakeClipRect) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.Clips)
|
||||
}
|
||||
|
||||
// requireCommandLineWidth asserts the line width snapshot attached to the command.
|
||||
func requireCommandLineWidth(t *testing.T, cmd fakeDrawerCommand, want float64) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.LineWidth)
|
||||
}
|
||||
|
||||
// requireCommandDashes asserts the dash snapshot attached to the command.
|
||||
func requireCommandDashes(t *testing.T, cmd fakeDrawerCommand, want ...float64) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.Dashes)
|
||||
}
|
||||
|
||||
// requireCommandDashOffset asserts the dash offset snapshot attached to the command.
|
||||
func requireCommandDashOffset(t *testing.T, cmd fakeDrawerCommand, want float64) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, want, cmd.DashOffset)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// PrimitiveKind identifies primitive types in hit-test results.
|
||||
type PrimitiveKind uint8
|
||||
|
||||
const (
|
||||
KindLine PrimitiveKind = iota
|
||||
KindCircle
|
||||
KindPoint
|
||||
)
|
||||
|
||||
// Hit describes one primitive that matches a hit-test query.
|
||||
type Hit struct {
|
||||
ID PrimitiveID
|
||||
Kind PrimitiveKind
|
||||
Priority int
|
||||
StyleID StyleID
|
||||
|
||||
// DistanceSq is squared distance in world-fixed units to the primitive geometry (best-effort).
|
||||
// Used for tie-breaking (smaller is better).
|
||||
DistanceSq u128
|
||||
|
||||
// Primitive world coordinates:
|
||||
// - Point: X,Y set
|
||||
// - Circle: X,Y,Radius set
|
||||
// - Line: X1,Y1,X2,Y2 set
|
||||
X, Y int
|
||||
Radius int
|
||||
X1, Y1 int
|
||||
X2, Y2 int
|
||||
}
|
||||
|
||||
// Default hit slop (in pixels) per primitive type.
|
||||
const (
|
||||
DefaultHitSlopLinePx = 6
|
||||
DefaultHitSlopCirclePx = 6
|
||||
DefaultHitSlopPointPx = 8
|
||||
|
||||
// If a circle's screen radius is below this threshold, treat it as point-like for hit testing.
|
||||
CirclePointLikeMinRadiusPx = 3
|
||||
)
|
||||
|
||||
// HitTest finds primitives under cursor (in viewport pixel coordinates) with hit slop.
|
||||
// The caller provides a buffer `out`. The returned slice aliases `out` (no allocations).
|
||||
//
|
||||
// If cap(out) is too small, it returns only the best hits by ranking:
|
||||
//
|
||||
// Priority desc, Distance asc, Kind asc, ID asc.
|
||||
//
|
||||
// Notes:
|
||||
// - cursorXPx/cursorYPx are relative to viewport top-left.
|
||||
// - Works for wrap and no-wrap modes (based on params.Options.DisableWrapScroll).
|
||||
func (w *World) HitTest(out []Hit, params *RenderParams, cursorXPx, cursorYPx int) ([]Hit, error) {
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.grid == nil || w.rows == 0 || w.cols == 0 {
|
||||
return nil, errGridNotBuilt
|
||||
}
|
||||
|
||||
zoomFp, err := params.CameraZoomFp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowWrap := true
|
||||
if params.Options != nil && params.Options.DisableWrapScroll {
|
||||
allowWrap = false
|
||||
}
|
||||
|
||||
// Use clamped camera in no-wrap mode for consistency.
|
||||
camX := params.CameraXWorldFp
|
||||
camY := params.CameraYWorldFp
|
||||
if !allowWrap {
|
||||
camX, camY = ClampCameraNoWrapViewport(
|
||||
camX, camY,
|
||||
params.ViewportWidthPx, params.ViewportHeightPx,
|
||||
zoomFp,
|
||||
w.W, w.H,
|
||||
)
|
||||
}
|
||||
|
||||
// Convert cursor viewport px to world-fixed coordinate (unwrapped relative to camera).
|
||||
worldPerPx := PixelSpanToWorldFixed(1, zoomFp)
|
||||
offXPx := cursorXPx - params.ViewportWidthPx/2
|
||||
offYPx := cursorYPx - params.ViewportHeightPx/2
|
||||
|
||||
cursorX := camX + offXPx*worldPerPx
|
||||
cursorY := camY + offYPx*worldPerPx
|
||||
|
||||
if allowWrap {
|
||||
cursorX = wrap(cursorX, w.W)
|
||||
cursorY = wrap(cursorY, w.H)
|
||||
} else {
|
||||
// Clamp cursor into world bounds to avoid weird negative coords in margins.
|
||||
cursorX = clamp(cursorX, 0, w.W-1)
|
||||
cursorY = clamp(cursorY, 0, w.H-1)
|
||||
}
|
||||
|
||||
// Compute a conservative search bbox around cursor using max possible slop (px->world).
|
||||
// We use the maximum of default slops; per-object overrides are handled later.
|
||||
maxSlopPx := max(DefaultHitSlopLinePx, max(DefaultHitSlopCirclePx, DefaultHitSlopPointPx))
|
||||
maxSlopWorld := PixelSpanToWorldFixed(maxSlopPx, zoomFp)
|
||||
|
||||
minX := cursorX - maxSlopWorld
|
||||
maxX := cursorX + maxSlopWorld + 1
|
||||
minY := cursorY - maxSlopWorld
|
||||
maxY := cursorY + maxSlopWorld + 1
|
||||
|
||||
var rects []Rect
|
||||
if allowWrap {
|
||||
rects = splitByWrap(w.W, w.H, minX, maxX, minY, maxY)
|
||||
} else {
|
||||
// Clamp to world.
|
||||
minX = clamp(minX, 0, w.W)
|
||||
maxX = clamp(maxX, 0, w.W)
|
||||
minY = clamp(minY, 0, w.H)
|
||||
maxY = clamp(maxY, 0, w.H)
|
||||
if maxX <= minX || maxY <= minY {
|
||||
return out[:0], nil
|
||||
}
|
||||
rects = []Rect{{minX: minX, maxX: maxX, minY: minY, maxY: maxY}}
|
||||
}
|
||||
|
||||
// Gather candidates from grid cells, dedupe by ID.
|
||||
cand := make(map[PrimitiveID]struct{}, 32)
|
||||
for _, r := range rects {
|
||||
colStart := w.worldToCellX(r.minX)
|
||||
colEnd := w.worldToCellX(r.maxX - 1)
|
||||
rowStart := w.worldToCellY(r.minY)
|
||||
rowEnd := w.worldToCellY(r.maxY - 1)
|
||||
|
||||
for row := rowStart; row <= rowEnd; row++ {
|
||||
for col := colStart; col <= colEnd; col++ {
|
||||
cell := w.grid[row][col]
|
||||
for _, it := range cell {
|
||||
cand[it.ID()] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use caller buffer as backing store; keep only best cap(out) hits.
|
||||
out = out[:0]
|
||||
limit := cap(out)
|
||||
|
||||
for id := range cand {
|
||||
cur, ok := w.objects[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h, ok := w.hitOne(cur, cursorX, cursorY, zoomFp, allowWrap)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
// Caller provided zero-cap buffer; cannot store anything.
|
||||
continue
|
||||
}
|
||||
|
||||
if len(out) < limit {
|
||||
out = append(out, h)
|
||||
continue
|
||||
}
|
||||
|
||||
// Replace the worst hit if the new one is better.
|
||||
worstIdx := 0
|
||||
for i := 1; i < len(out); i++ {
|
||||
if hitLess(out[worstIdx], out[i]) {
|
||||
worstIdx = i // out[i] is worse than out[worstIdx]
|
||||
}
|
||||
}
|
||||
if hitLess(h, out[worstIdx]) {
|
||||
out[worstIdx] = h
|
||||
}
|
||||
}
|
||||
|
||||
// Sort final hits by best-first order.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return hitLess(out[i], out[j])
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hitLess orders hits by:
|
||||
// Priority desc, DistanceSq asc, Kind asc, ID asc.
|
||||
func hitLess(a, b Hit) bool {
|
||||
if a.Priority != b.Priority {
|
||||
return a.Priority > b.Priority
|
||||
}
|
||||
if c := u128Cmp(a.DistanceSq, b.DistanceSq); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
|
||||
func (w *World) hitOne(it MapItem, cx, cy int, zoomFp int, allowWrap bool) (Hit, bool) {
|
||||
switch v := it.(type) {
|
||||
case Point:
|
||||
return hitPoint(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
||||
|
||||
case Circle:
|
||||
style, ok := w.styles.Get(v.StyleID)
|
||||
if !ok {
|
||||
// Unknown style should not happen; treat as no-hit rather than panic.
|
||||
return Hit{}, false
|
||||
}
|
||||
return hitCircle(v, circleRadiusEffFp(v.Radius, w.circleRadiusScaleFp), style, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
||||
|
||||
case Line:
|
||||
return hitLine(v, cx, cy, zoomFp, allowWrap, w.W, w.H)
|
||||
|
||||
default:
|
||||
panic("HitTest: unknown map item type")
|
||||
}
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHitTest_ReturnsBestByPriorityAndAllHits verifies hit Test Returns Best By Priority And All Hits.
|
||||
func TestHitTest_ReturnsBestByPriorityAndAllHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
// Build index once renderer state is initialized.
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
||||
|
||||
// Add overlapping objects near center.
|
||||
idLine, err := w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
|
||||
idCircle, err := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
|
||||
require.NoError(t, err)
|
||||
|
||||
idPoint, err := w.AddPoint(5.0, 5.0, PointWithPriority(200))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Force index rebuild from last state (Add already does it, but keep explicit).
|
||||
w.Reindex()
|
||||
|
||||
buf := make([]Hit, 0, 8)
|
||||
hits, err := w.HitTest(buf, ¶ms, 50, 50) // center of viewport
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should find all three, best first (priority desc).
|
||||
require.Len(t, hits, 3)
|
||||
require.Equal(t, idCircle, hits[0].ID)
|
||||
require.Equal(t, idPoint, hits[1].ID)
|
||||
require.Equal(t, idLine, hits[2].ID)
|
||||
}
|
||||
|
||||
// TestHitTest_BufferTooSmall_KeepsBestHits verifies hit Test Buffer Too Small Keeps Best Hits.
|
||||
func TestHitTest_BufferTooSmall_KeepsBestHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
||||
|
||||
_, _ = w.AddLine(4.5, 5.0, 5.5, 5.0, LineWithPriority(100))
|
||||
idCircle, _ := w.AddCircle(5.0, 5.0, 1.0, CircleWithPriority(300))
|
||||
_, _ = w.AddPoint(5.0, 5.0, PointWithPriority(200))
|
||||
w.Reindex()
|
||||
|
||||
// Only room for 1 hit => must keep the best (highest priority).
|
||||
buf := make([]Hit, 0, 1)
|
||||
hits, err := w.HitTest(buf, ¶ms, 50, 50)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hits, 1)
|
||||
require.Equal(t, idCircle, hits[0].ID)
|
||||
}
|
||||
|
||||
// TestHitTest_NoWrap_ClampsCameraAndStillHits verifies hit Test No Wrap Clamps Camera And Still Hits.
|
||||
func TestHitTest_NoWrap_ClampsCameraAndStillHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 25,
|
||||
CameraXWorldFp: -100000, // invalid camera, should be clamped
|
||||
CameraYWorldFp: -100000,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{DisableWrapScroll: true},
|
||||
}
|
||||
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
||||
|
||||
_, err := w.AddPoint(0.0, 0.0, PointWithPriority(100))
|
||||
require.NoError(t, err)
|
||||
w.Reindex()
|
||||
|
||||
// Tap near top-left of viewport should still map to world and find the point.
|
||||
buf := make([]Hit, 0, 8)
|
||||
hits, err := w.HitTest(buf, ¶ms, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hits)
|
||||
}
|
||||
|
||||
// TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter verifies hit Test Circle Stroke Only Hits Near Ring Not Center.
|
||||
func TestHitTest_CircleStrokeOnly_HitsNearRingNotCenter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
||||
|
||||
// Stroke-only circle: FillColor alpha=0 => ring mode.
|
||||
ov := StyleOverride{
|
||||
FillColor: color.RGBA{A: 0},
|
||||
StrokeColor: color.RGBA{A: 255},
|
||||
}
|
||||
strokeStyle := w.AddStyleCircle(ov)
|
||||
|
||||
_, err := w.AddCircle(5.0, 5.0, 2.0,
|
||||
CircleWithStyleID(strokeStyle),
|
||||
CircleWithPriority(100),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Reindex()
|
||||
|
||||
buf := make([]Hit, 0, 8)
|
||||
|
||||
// Center must NOT hit.
|
||||
hits, err := w.HitTest(buf, ¶ms, 50, 50)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, hits)
|
||||
|
||||
// Near ring should hit. For small circles we use a minimum visible ring radius (3px).
|
||||
// So tapping at +3px from center should be within ring+slop.
|
||||
hits, err = w.HitTest(buf, ¶ms, 50+3, 50)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hits)
|
||||
require.Equal(t, KindCircle, hits[0].Kind)
|
||||
}
|
||||
|
||||
// TestHitTest_CircleRadiusScale_AffectsHitArea verifies hit Test Circle Radius Scale Affects Hit Area.
|
||||
func TestHitTest_CircleRadiusScale_AffectsHitArea(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.SetTheme(DefaultTheme{}) // filled circles by default in our defaults
|
||||
w.IndexOnViewportChange(100, 100, 1.0)
|
||||
|
||||
// raw radius=2 units, centered at (5,5)
|
||||
_, err := w.AddCircle(5, 5, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// scale=2 => eff radius=4
|
||||
require.NoError(t, w.SetCircleRadiusScaleFp(2*SCALE))
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 100,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
// Tap at +4 px from center should hit (eff radius 4).
|
||||
buf := make([]Hit, 0, 8)
|
||||
hits, err := w.HitTest(buf, ¶ms, 50+4, 50)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hits)
|
||||
require.Equal(t, KindCircle, hits[0].Kind)
|
||||
|
||||
// Tap at +5 should typically miss (depending on slop); enforce by setting small slop via options.
|
||||
// We'll add a small-slope circle and test deterministically.
|
||||
}
|
||||
|
||||
// TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table verifies hit Test Circle Strict Thresholds With Radius Scale Table.
|
||||
func TestHitTest_Circle_StrictThresholds_WithRadiusScale_Table(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
fillVisible bool
|
||||
rawRadius int // world units (not fixed); zoom=1 => 1px per unit
|
||||
scaleFp int
|
||||
hitSlopPx int
|
||||
cursorDxPx int // offset from center in pixels along X axis
|
||||
wantHit bool
|
||||
wantKind PrimitiveKind
|
||||
}
|
||||
|
||||
// Common settings: world 20x20, viewport 200x200, camera at center (10,10).
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 200,
|
||||
ViewportHeightPx: 200,
|
||||
MarginXPx: 0,
|
||||
MarginYPx: 0,
|
||||
CameraXWorldFp: 10 * SCALE,
|
||||
CameraYWorldFp: 10 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
tests := []tc{
|
||||
{
|
||||
name: "filled: on boundary hits (R=4, S=1, dx=4)",
|
||||
fillVisible: true,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE, // eff radius = 4
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 4,
|
||||
wantHit: true,
|
||||
wantKind: KindCircle,
|
||||
},
|
||||
{
|
||||
name: "filled: outside beyond slop misses (R=4, S=1, dx=6)",
|
||||
fillVisible: true,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE,
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 6, // 6 > R+S = 5
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "filled: just inside slop hits (R=4, S=1, dx=5)",
|
||||
fillVisible: true,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE,
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 5, // == R+S
|
||||
wantHit: true,
|
||||
wantKind: KindCircle,
|
||||
},
|
||||
{
|
||||
name: "stroke-only: center must miss even if slop would cover",
|
||||
fillVisible: false,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE, // eff radius = 4
|
||||
hitSlopPx: 10, // huge, would normally include center without our rule
|
||||
cursorDxPx: 0,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "stroke-only: on ring hits (R=4, S=1, dx=4)",
|
||||
fillVisible: false,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE,
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 4,
|
||||
wantHit: true,
|
||||
wantKind: KindCircle,
|
||||
},
|
||||
{
|
||||
name: "stroke-only: inside ring beyond slop misses (R=4, S=1, dx=2)",
|
||||
fillVisible: false,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE,
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 2, // 2 < R-S = 3
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "stroke-only: outside ring beyond slop misses (R=4, S=1, dx=6)",
|
||||
fillVisible: false,
|
||||
rawRadius: 2,
|
||||
scaleFp: 2 * SCALE,
|
||||
hitSlopPx: 1,
|
||||
cursorDxPx: 6, // 6 > R+S = 5
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(20, 20)
|
||||
w.IndexOnViewportChange(params.ViewportWidthPx, params.ViewportHeightPx, params.CameraZoom)
|
||||
|
||||
require.NoError(t, w.SetCircleRadiusScaleFp(tt.scaleFp))
|
||||
|
||||
// Build a stroke-only circle style if needed.
|
||||
var opts []CircleOpt
|
||||
opts = append(opts, CircleWithHitSlopPx(tt.hitSlopPx))
|
||||
|
||||
if !tt.fillVisible {
|
||||
// Force fill alpha=0 => stroke-only for hit-test and rendering.
|
||||
sw := 1.0
|
||||
styleID := w.AddStyleCircle(StyleOverride{
|
||||
FillColor: color.RGBA{A: 0},
|
||||
StrokeColor: color.RGBA{A: 255},
|
||||
StrokeWidthPx: &sw,
|
||||
})
|
||||
opts = append(opts, CircleWithStyleID(styleID))
|
||||
}
|
||||
|
||||
_, err := w.AddCircle(10, 10, float64(tt.rawRadius), opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.Reindex()
|
||||
|
||||
// Cursor at viewport center +/- dx along X. At zoom=1, 1px == 1 world unit.
|
||||
cx := params.ViewportWidthPx/2 + tt.cursorDxPx
|
||||
cy := params.ViewportHeightPx / 2
|
||||
|
||||
buf := make([]Hit, 0, 8)
|
||||
hits, err := w.HitTest(buf, ¶ms, cx, cy)
|
||||
require.NoError(t, err)
|
||||
|
||||
if !tt.wantHit {
|
||||
require.Empty(t, hits)
|
||||
return
|
||||
}
|
||||
|
||||
require.NotEmpty(t, hits)
|
||||
require.Equal(t, tt.wantKind, hits[0].Kind)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,411 +0,0 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"github.com/fogleman/gg"
|
||||
"github.com/stretchr/testify/require"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type benchBgTheme struct {
|
||||
img image.Image
|
||||
anchor BackgroundAnchorMode
|
||||
tileMode BackgroundTileMode
|
||||
scaleMode BackgroundScaleMode
|
||||
}
|
||||
|
||||
func (t benchBgTheme) ID() string { return "benchbg" }
|
||||
func (t benchBgTheme) Name() string { return "benchbg" }
|
||||
|
||||
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
|
||||
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
|
||||
|
||||
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
|
||||
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
|
||||
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
|
||||
|
||||
func (t benchBgTheme) PointStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
|
||||
}
|
||||
func (t benchBgTheme) LineStyle() Style {
|
||||
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
func (t benchBgTheme) CircleStyle() Style {
|
||||
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
|
||||
}
|
||||
|
||||
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
|
||||
// BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background.
|
||||
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1200, 800, 1.0)
|
||||
|
||||
// Some primitives to keep it realistic but not dominant.
|
||||
for i := 0; i < 200; i++ {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
dc := gg.NewContext(1200, 800)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{
|
||||
AllowShiftOnly: false,
|
||||
CoalesceUpdates: false,
|
||||
MaxCatchUpAreaPx: 0,
|
||||
RenderBudgetMs: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Initial render (commit state).
|
||||
_ = w.Render(drawer, params)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
params.CameraXWorldFp += 1 * SCALE
|
||||
_ = w.Render(drawer, params)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None.
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
|
||||
}
|
||||
|
||||
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit.
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
|
||||
}
|
||||
|
||||
// BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None.
|
||||
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
|
||||
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
|
||||
}
|
||||
|
||||
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1200, 800, 1.0)
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
// Background tile (RGBA) — typical texture size.
|
||||
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
|
||||
// Make it semi-transparent so draw.Over has real work.
|
||||
for y := 0; y < 96; y++ {
|
||||
for x := 0; x < 96; x++ {
|
||||
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
|
||||
}
|
||||
}
|
||||
|
||||
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
|
||||
|
||||
dc := gg.NewContext(1200, 800)
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{
|
||||
AllowShiftOnly: false,
|
||||
CoalesceUpdates: false,
|
||||
MaxCatchUpAreaPx: 0,
|
||||
RenderBudgetMs: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_ = w.Render(drawer, params)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
params.CameraXWorldFp += 1 * SCALE
|
||||
_ = w.Render(drawer, params)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG.
|
||||
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
// Make a lot of lines, including ones that likely wrap.
|
||||
for i := 0; i < 4000; i++ {
|
||||
x1 := float64(i % 600)
|
||||
y1 := float64((i * 7) % 600)
|
||||
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
|
||||
y2 := float64((i*17 + 300) % 600)
|
||||
_, _ = w.AddLine(x1, y1, x2, y2)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlan(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake.
|
||||
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
for i := 0; i < 4000; i++ {
|
||||
x1 := float64(i % 600)
|
||||
y1 := float64((i * 7) % 600)
|
||||
x2 := float64((i*13 + 500) % 600)
|
||||
y2 := float64((i*17 + 300) % 600)
|
||||
_, _ = w.AddLine(x1, y1, x2, y2)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlan(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
drawer := &fakePrimitiveDrawer{}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Reset command log so it doesn't grow forever and dominate allocations.
|
||||
drawer.Reset()
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips.
|
||||
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.IndexOnViewportChange(100, 80, 1.0)
|
||||
w.resetGrid(2 * SCALE)
|
||||
|
||||
_, _ = w.AddPoint(5, 5)
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
|
||||
},
|
||||
}
|
||||
|
||||
// First render initializes state.
|
||||
d1 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d1, params))
|
||||
|
||||
// Small pan.
|
||||
params2 := params
|
||||
params2.CameraXWorldFp += 1 * SCALE
|
||||
|
||||
d2 := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d2, params2))
|
||||
|
||||
// Expect very few ClipRect calls (dirty strips count), not per tile.
|
||||
clipCmds := d2.CommandsByName("ClipRect")
|
||||
require.NotEmpty(t, clipCmds)
|
||||
require.LessOrEqual(t, len(clipCmds), 4)
|
||||
}
|
||||
|
||||
// TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID.
|
||||
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := NewWorld(10, 10)
|
||||
w.IndexOnViewportChange(100, 80, 1.0)
|
||||
|
||||
// Two lines with default style, same priority.
|
||||
_, _ = w.AddLine(1, 1, 8, 1)
|
||||
_, _ = w.AddLine(1, 2, 8, 2)
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 100,
|
||||
ViewportHeightPx: 80,
|
||||
MarginXPx: 25,
|
||||
MarginYPx: 20,
|
||||
CameraXWorldFp: 5 * SCALE,
|
||||
CameraYWorldFp: 5 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
}
|
||||
|
||||
d := &fakePrimitiveDrawer{}
|
||||
require.NoError(t, w.Render(d, params))
|
||||
|
||||
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
|
||||
adds := d.CommandsByName("AddLine")
|
||||
strokes := d.CommandsByName("Stroke")
|
||||
require.GreaterOrEqual(t, len(adds), 2)
|
||||
require.GreaterOrEqual(t, len(strokes), 1)
|
||||
|
||||
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
|
||||
// (Keep it loose to avoid depending on tile partitioning.)
|
||||
}
|
||||
|
||||
// BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse.
|
||||
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
|
||||
// Make grid + index available.
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
// Add enough objects so tiles have candidates.
|
||||
for i := range 2000 {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
for i := range 500 {
|
||||
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
plan, err := w.buildRenderPlan(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
|
||||
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
|
||||
drawer := &GGDrawer{DC: dc}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// We don't clear here; we only measure the draw loop overhead.
|
||||
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates.
|
||||
func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) {
|
||||
w := NewWorld(600, 600)
|
||||
|
||||
// Make the index/grid available.
|
||||
w.IndexOnViewportChange(1000, 700, 1.0)
|
||||
|
||||
// Populate with enough objects to create duplicates across cells.
|
||||
// Circles and lines create bbox indexing (more duplicates).
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
|
||||
}
|
||||
for i := 0; i < 1200; i++ {
|
||||
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0)
|
||||
}
|
||||
for i := 0; i < 1200; i++ {
|
||||
x1 := float64((i*3 + 10) % 600)
|
||||
y1 := float64((i*5 + 20) % 600)
|
||||
x2 := float64((i*7 + 400) % 600)
|
||||
y2 := float64((i*11 + 300) % 600)
|
||||
_, _ = w.AddLine(x1, y1, x2, y2)
|
||||
}
|
||||
w.Reindex()
|
||||
|
||||
params := RenderParams{
|
||||
ViewportWidthPx: 1000,
|
||||
ViewportHeightPx: 700,
|
||||
MarginXPx: 250,
|
||||
MarginYPx: 175,
|
||||
CameraXWorldFp: 300 * SCALE,
|
||||
CameraYWorldFp: 300 * SCALE,
|
||||
CameraZoom: 1.0,
|
||||
Options: &RenderOptions{
|
||||
BackgroundColor: color.RGBA{A: 255},
|
||||
},
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := w.buildRenderPlan(params)
|
||||
if err != nil {
|
||||
b.Fatalf("build plan: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user