Compare commits
115 Commits
main
...
fa0df5183a
| Author | SHA1 | Date | |
|---|---|---|---|
| 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://api.galaxy.com/healthz and roll back on failure."
|
||||
@@ -0,0 +1,170 @@
|
||||
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/**'
|
||||
- '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:
|
||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.lan
|
||||
# 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: 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 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://api.galaxy.lan/healthz \
|
||||
| tee /tmp/healthz
|
||||
test -s /tmp/healthz
|
||||
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
|
||||
https://www.galaxy.lan/ | tee /tmp/www_status
|
||||
grep -qE '^(200|304)$' /tmp/www_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,124 @@
|
||||
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/**'
|
||||
- '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:
|
||||
VITE_GATEWAY_BASE_URL: https://api.galaxy.com
|
||||
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: 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
|
||||
|
||||
- name: Upload images
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: galaxy-images-${{ steps.tag.outputs.tag }}
|
||||
path: artifacts/*.tar.gz
|
||||
retention-days: 30
|
||||
@@ -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,57 @@ 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`
|
||||
(reachable at `https://www.galaxy.lan` / `https://api.galaxy.lan`).
|
||||
- `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 +112,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user