Compare commits

...

141 Commits

Author SHA1 Message Date
Ilia Denisov 5271f2b1ec feat(ui): lobby site-style sidebar + profile screen (#47)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m30s
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
2026-05-26 13:42:10 +02:00
developer 793b709d8f feat(ui): readable order card — status as background tint, corner ✕ (#58)
Deploy · Dev / deploy (push) Successful in 49s
Tests · UI / test (push) Successful in 2m40s
Closes #46

F8-03 — повышаем читаемость карточки приказа: перенос длинного текста, статус в фоне карточки через --color-{success,danger,warning}-subtle, маленький угловой ✕ всегда видимый.
2026-05-26 06:39:09 +00:00
Ilia Denisov 2294d8b3d9 fix(ui): tighter order card — calculator-scale font, corner-flush ✕;
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m47s
stabilise report-sections e2e

Owner review on PR #58:

- shrink the order-card body to 0.8rem (matching the calculator's body
  text scale) so the order list reads as part of the sidebar's
  density, not its own larger surface;
- shrink the delete ✕ to 0.95rem and glue it flush to the card's
  top-right corner (no offset, sized to fit the corner padding-space);
- tighten the card padding to match the smaller text.

Independently — the same review asked to fix `report-sections › every
TOC anchor lands its section in view`, which had been a long-standing
e2e flake (run #366 on `development` already failed it twice before
passing on retry; my PR's run #367 simply exhausted all five retries).
The root cause is the smooth `scrollIntoView` settling slower than
Playwright's 5 s viewport wait under heavy CI load. The production
TOC already honours `prefers-reduced-motion: reduce` and swaps to an
instant scroll there; switching the Playwright config to that media
mode makes every spec deterministic without touching production code.
2026-05-26 08:28:58 +02:00
Ilia Denisov 5ca30df334 feat(ui): readable order card — status as background tint, corner ✕
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m48s
The order-tab row now wraps long labels (`overflow-wrap: anywhere`),
encodes status into the card background via the design-token subtle
palette (applied → success-subtle, invalid/rejected/conflict →
danger-subtle, draft/valid/submitting → warning-subtle), and exposes
a small framed `✕` delete button absolutely positioned in the
card's top-right corner — always visible, labelled by
`game.sidebar.order.command_delete` for assistive tech. The textual
status name remains in the DOM as an `.sr-only` node so screen
readers and the existing testids still observe it.

Refs #46
2026-05-26 07:23:44 +02:00
developer 1f6791549a Merge pull request 'fix(ui): turn navigator no-op when re-selecting the on-screen turn' (#57) from feature/issue-45-turn-navigator-noop into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · UI / test (push) Successful in 2m41s
2026-05-26 05:13:23 +00:00
Ilia Denisov e82c9f8bbd fix(ui): no-op when re-selecting the turn already on screen
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 3m35s
Clicking the current-turn row in the header turn navigator while
already viewing it routed through returnToCurrent() →
viewTurn(currentTurn), which re-fetches the live report and flips the
view through `loading`. At turn 0 the only row is the live turn, so
the dropdown always fired a pointless backend round-trip and redraw.

Guard goToTurn() against re-selecting the on-screen turn
(turn === viewedTurn): just close the popover and stop. Leaving
history is unaffected — there the viewed turn differs from the target.

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 00:18:30 +02:00
developer b957d17022 Merge pull request 'feat(ui): autofocus login fields; keep verification code out of form history' (#56) from feature/issue-44-login-autofocus-otp into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · UI / test (push) Successful in 2m59s
2026-05-25 22:11:35 +00:00
Ilia Denisov 3d5b331bd9 feat(ui): autofocus login fields; keep verification code out of form history
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m51s
The two-step e-mail login now drops the cursor on each step's primary
field as it mounts — the e-mail field on load, the code field once the
e-mail step advances — via a small `use:` action. Focusing fires each
input's onfocus, which clears the readonly autofill guard, so the field
is editable straight away.

The code input now requests `autocomplete="one-time-code"` instead of
`new-password`. The latter is a password-manager hint and does not stop
Firefox saving the typed code to form history (it was offering the
previous code back in a dropdown). `one-time-code` is the semantic token
for a verification code; Firefox honours it specifically to keep the
value out of form history (Mozilla bug 1547294). The e-mail field keeps
`new-password` to fend off saved-login autofill.

Tests: new Vitest cases assert autofocus on both steps and the code
field's `one-time-code` token; a new Playwright case covers the same in
Chromium and WebKit (Safari engine). Firefox form history is owner
manual-QA — there is no Firefox project in the e2e matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:53:20 +02:00
developer 6f2967024a Merge pull request 'feat(ui): map canvas follows light/dark theme; fix invisible gear control' (#42) from feature/issue-40-map-light-theme into development
Deploy · Dev / deploy (push) Successful in 1m58s
Tests · UI / test (push) Successful in 2m39s
2026-05-24 07:15:08 +00:00
Ilia Denisov f6e4a4f6bd feat(ui): map canvas follows light/dark theme; fix invisible gear control
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.

This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.

The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.

Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 08:49:37 +02:00
owner d44ad9b6eb Merge pull request 'fix(gateway): verify client signature before payload_hash' (#41) from feature/issue-39-verification-order into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m1s
Tests · Integration / integration (push) Successful in 1m40s
Reviewed-on: #41
2026-05-24 05:59:44 +00:00
Ilia Denisov 91e34a0929 fix(gateway): verify client signature before payload_hash
Tests · Go / test (push) Successful in 2m1s
Tests · Go / test (pull_request) Successful in 2m58s
Tests · Integration / integration (pull_request) Successful in 1m39s
ARCHITECTURE.md §15 "Verification order" specifies signature verification
(step 4) before payload_hash (step 5), but the authenticated-edge
decorator chain wrapped the payload-hash gate outside the signature gate,
so the hash was checked first. gateway/README.md and gateway/docs/flows.md
had drifted to match the code (hash-first), leaving ARCHITECTURE.md as the
lone source describing the intended order.

Swap the two decorators in server.go so the signature gate runs first, and
align README + flows.md to ARCHITECTURE.md. Signature-first is the
cryptographically sound order: the signature covers the payload_hash field,
so the request is authenticated before any of its content is processed.

Observable side effect: a request carrying a tampered payload_hash whose
signature was computed over the original hash is now rejected at the
signature gate (UNAUTHENTICATED "invalid request signature") instead of the
hash gate (INVALID_ARGUMENT). Security is unchanged — both refusals happen
before the payload is handled. The four payload-hash unit tests re-sign
over the tampered hash so they keep exercising the hash gate; the
cross-service integration test signs over the overridden hash and already
accepts both codes.

Refs #39

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 02:42:09 +02:00
developer f0857243e2 Merge pull request 'feat(ui): single-URL game app-shell (in-memory screens/views)' (#35) from feature/ui-app-shell into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · UI / test (push) Successful in 2m59s
2026-05-23 20:18:09 +00:00
Ilia Denisov 1dadf08672 docs(ui): clarify lobby re-enter starts at the map, only refresh restores
Tests · UI / test (pull_request) Successful in 2m32s
The single-URL restore replays the saved screen/view on an in-place
refresh only. Re-entering a game from the lobby resets activeView to the
map (lobby calls activeView.reset() before appScreen.go("game")), and
browser Back / the return-to-lobby control exit to the lobby. Spell this
out so the refresh-restore is not mistaken for a per-re-enter restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:14:15 +02:00
Ilia Denisov c1672224a6 fix(ui): pin the mobile game shell to the viewport
Tests · UI / test (push) Successful in 2m49s
The app-shell migration surfaced a mobile-only e2e failure: taps on the
bottom-tab bar, the map-toggles menu, and the planet sheet were
intercepted by sibling elements despite the targets being on top.

Root cause: `.game-shell` used `min-height: 100vh`, so sub-pixel content
overflowed the viewport and made the document scrollable. On mobile that
scroll toggles the browser's dynamic toolbar, which resizes the viewport
and every `position: fixed` overlay (their sizes derive from `100vh`)
mid-gesture — defeating Playwright's actionability hit-test, and making
the real controls jittery to tap.

Pin the shell with `position: fixed; inset: 0` on the mobile breakpoint
so it leaves document flow: the document can no longer scroll, the
toolbar stays put, the viewport and overlays stay stable, and the
active-view area remains the single internal scroll region. Desktop is
unchanged (the rule is scoped to max-width: 767.98px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:40:06 +02:00
Ilia Denisov e31fb2c17a docs(ui): sync docs to the app-shell; fix stale nav comments
Tests · UI / test (push) Failing after 9m28s
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy,
game-state + secondary topic docs) and ui/README for the single-URL
app-shell (in-memory screens/views, Back→lobby via shallow routing,
sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a
Phase-10 supersede note (implemented; standalone-compatible). Fix stale
code comments (session-store auth gate, report-sections spec contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:04:11 +02:00
Ilia Denisov 4e0058d46c test(ui): migrate suite to the app-shell (state-driven navigation)
- Unit: repoint moved screen imports (lib/screens, lib/game), mock
  $lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the
  removed gameId props, assert screen/view selection.
- e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via
  enterGame(...) instead of a /games/:id URL; URL assertions become content
  assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow
  routing) and mocks /rpc on game entry.
- Remove the obsolete report scroll-restore test (it relied on a SvelteKit
  route Snapshot that no longer exists); update the missing-membership test
  to the new lobby-redirect+toast behaviour. Fix a stale report.svelte
  docstring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:49:35 +02:00
Ilia Denisov 80545e9f9d feat(ui): app-shell behaviour — restore validation, return-to-lobby, push
- A restored game that no longer exists (cancelled/removed/revoked) drops to
  the lobby with a toast instead of the in-game error state: game-state
  exposes a `notFound` flag and the shell redirects via appScreen.go("lobby").
- Add a visible "return to lobby" control to the in-game header.
- Push/toast deep-links use activeView.select(...) (no URL); fix a latent
  visibility-listener double-install on in-place game switches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:11:54 +02:00
Ilia Denisov be7f06e163 feat(ui): screen-level history for the app-shell (Back → lobby)
Mirror the screen into browser history via SvelteKit shallow routing
(pushState/replaceState with page.state) so Back/Forward move between
screens while the URL stays at /game/. Overlays (game, lobby-create) push;
lobby/login replace. A popstate→page.state effect syncs the store back
without re-pushing (no loop); the boot stamp puts a restored overlay above
the load entry so Back falls through to lobby. In-game view switches never
touch history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:07:03 +02:00
Ilia Denisov b6770d394c feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:04:04 +02:00
Ilia Denisov 182beebcd6 feat(ui): app-nav state stores (app-shell foundation)
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage
snapshot — the in-memory source of truth that replaces URL-based screen/view
routing for the single-URL app-shell. Not wired in yet (additive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:45:27 +02:00
developer ae91037bc3 Merge pull request 'feat(deploy): single-origin path-based deployment + project site' (#34) from feature/deploy-single-origin into development
Deploy · Dev / deploy (push) Successful in 43s
Tests · Go / test (push) Successful in 2m3s
Build · Site / build (push) Successful in 9s
Tests · Integration / integration (push) Successful in 1m49s
Tests · UI / test (push) Successful in 2m36s
2026-05-23 17:24:25 +00:00
Ilia Denisov ec98639d49 fix(site): link to the game with target=_self to avoid VitePress SPA 404
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m38s
Build · Site / build (pull_request) Successful in 10s
Tests · Go / test (pull_request) Failing after 3m0s
Tests · UI / test (pull_request) Successful in 2m35s
VitePress is a Vue SPA; a same-origin link to /game/ (a separate app, not
a VitePress page) was intercepted by its client router and rendered
VitePress's own 404 until a manual reload. Mark the game links (both
home pages and the nav item) target="_self" so the click is a real
browser navigation that the edge Caddy serves from the game bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 19:13:15 +02:00
Ilia Denisov 9cb5097f54 fix(ui): redirect app root to lobby/login; evict stale root service worker
Tests · UI / test (push) Has been cancelled
Build · Site / build (push) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 1m42s
Build · Site / build (pull_request) Successful in 6s
Tests · UI / test (pull_request) Successful in 2m23s
Tests · Go / test (pull_request) Successful in 1m56s
- The app root ("/", i.e. /game/) rendered a dev "workspace skeleton"
  stub, and the layout guard only redirected anonymous users off it, so
  an authenticated visitor stayed on the stub. Redirect "/" to /lobby
  (authenticated) and /login (anonymous), and replace the stub with a
  minimal loading placeholder. Drop the obsolete landing-stub unit test
  (root redirect is covered by the auth-flow e2e).
- Ship a tombstone /service-worker.js on the project site so any old
  root-scoped PWA worker (from when the game lived at the origin root)
  unregisters itself instead of serving a stale cached page at the
  site origin. The game now registers its worker only under /game/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:53:16 +02:00
Ilia Denisov a453b74b04 test(ui): assert relative manifest start_url in the PWA spec
Tests · UI / test (push) Successful in 2m28s
Tests · Integration / integration (pull_request) Successful in 1m46s
Build · Site / build (pull_request) Successful in 10s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m46s
The single-origin manifest now uses relative URLs (`start_url: "./"`) so
it stays base-agnostic under `/` and `/game/`. Update the PWA spec to
assert the relative value instead of the old absolute `/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:24:55 +02:00
Ilia Denisov 8565942392 feat(deploy): single-origin path-based deployment + project site
Build · Site / build (push) Successful in 8s
Tests · Go / test (push) Successful in 2m22s
Tests · UI / test (push) Failing after 2m42s
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.

- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
  edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
  manifest, service-worker scope, WASM loader, and all navigation
  base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
  allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
  math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
  site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
  local-dev READMEs, CLAUDE.md, ui/PLAN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:19:07 +02:00
developer fa0df5183a Merge pull request 'chore: remove deprecated client' (#33) from chore/cleanup-code-and-repo into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m37s
Tests · Go / test (push) Successful in 3m11s
Tests · UI / test (push) Successful in 2m34s
Reviewed-on: https://gitea.lan/developer/galaxy-game/pulls/33
2026-05-23 14:57:28 +00:00
Ilia Denisov 8e0a1c39c0 chore: remove deprecated client
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (push) Successful in 2m25s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m2s
2026-05-23 16:55:02 +02:00
developer 780769b3c4 Merge pull request 'docs(ui): documentation finalization (F7)' (#32) from feature/ui-finalize-f7-docs into development 2026-05-22 14:07:02 +00:00
Ilia Denisov 53fb4f5f76 docs(ui): finalize docs sync — README finalized-web summary + topic links (F7)
The de-archaeology and the ui/docs index landed in the earlier reorg
(0 "Phase N" refs in ui/docs/; 24 topic docs linked). This finishes the
sync: ui/README.md gains a finalized-web-target summary and links the new
topic docs (design-system, a11y, error-state-ux, pwa-strategy).
docs/ARCHITECTURE.md and docs/FUNCTIONAL.md need no change — they cover
the backend/gateway/cross-service contracts with no stale UI statements;
the finalized UI is client-local UX documented under ui/docs/. Marks F7
done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:06:27 +02:00
developer 1dd8df9f6e Merge pull request 'feat(ui): installable offline PWA (F5)' (#31) from feature/ui-finalize-f5-pwa into development
Deploy · Dev / deploy (push) Successful in 46s
Tests · UI / test (push) Successful in 2m55s
2026-05-22 14:02:40 +00:00
Ilia Denisov 11f51944df fix(ui): register the service worker in production only
Tests · UI / test (push) Successful in 2m19s
Tests · UI / test (pull_request) Successful in 2m25s
SvelteKit's automatic SW registration also runs under `vite dev`, where
the worker intercepted/cached the dev-server e2e suite (42 failures).
Disable auto-registration (kit.serviceWorker.register: false) and
register the worker manually from the root layout guarded by `!dev`, so
`vite dev` and the e2e suite run worker-free while the production build —
and the PWA preview test — still install it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:56:32 +02:00
Ilia Denisov 04c7f6e68a feat(ui): installable offline PWA — service worker, manifest, icons (F5)
Tests · UI / test (push) Failing after 7m31s
Native SvelteKit service worker (src/service-worker.ts): a version-keyed
cache precaches the app shell + build artefacts (incl. core.wasm) +
static files; activate purges old caches; the gateway is never
intercepted; navigations fall back to the cached shell offline. Adds
static/manifest.webmanifest, a generated placeholder icon set
(scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and
manifest / theme-color / apple-touch tags in app.html.

Gated by Playwright against a production preview (playwright.pwa.config.ts
+ tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test):
manifest + installable icons, SW registration + a single version-keyed
cache, and offline shell load. Lighthouse is not used — its PWA category
was removed in v12.

Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:46:42 +02:00
developer c066a8958e Merge pull request 'build(ui): build core.wasm in CI, stop committing the binary (F6)' (#30) from feature/ui-finalize-f6-wasm-ci into development
Deploy · Dev / deploy (push) Successful in 44s
Tests · UI / test (push) Successful in 2m18s
2026-05-22 12:37:24 +00:00
Ilia Denisov b729036778 build(ui): build core.wasm in CI, stop committing the binary (F6)
Tests · UI / test (push) Successful in 3m48s
Tests · UI / test (pull_request) Successful in 2m35s
core.wasm and wasm_exec.js are no longer tracked (untracked + gitignored).
A reusable composite action .gitea/actions/build-wasm installs TinyGo
(actions/cache'd) and runs `make -C ui wasm`; it runs in all three
frontend-building workflows — ui-test (before Playwright; Vitest uses the
fake Core and needs no build), dev-deploy, and prod-build. ui-test gains a
Go setup (TinyGo shells out to Go); the deploy workflows already had one.

Docs: ui/docs/wasm-toolchain.md, ui/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:29:33 +02:00
developer 9d3a652b6b Merge pull request 'feat(ui): error & state UX (F4)' (#29) from feature/ui-finalize-f4-error-ux into development
Deploy · Dev / deploy (push) Successful in 35s
Tests · UI / test (push) Successful in 2m19s
2026-05-22 11:53:16 +00:00
Ilia Denisov b07b8fb1c8 test(ui): cargo-routes counts the selection ring in the primitive total
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m4s
The F4 selection ring is a real map primitive. The cargo-route flow has
the source planet selected, so the total primitive count is 8 (7 + the
ring circle), not 7; the line count (3) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:50:42 +02:00
Ilia Denisov 35e27c5aec fix(ui): bottom-sheet tap-outside only fires while the sheet is shown
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m53s
The planet/ship-group sheets stay mounted on desktop but are hidden by a
media query (`display: none`); the document-level tap-outside listener
fired regardless, so the first click after selecting a planet cleared the
selection — breaking every desktop inspector/select flow in CI. Guard the
handler on the sheet's computed display (`offsetParent` is unreliable for
`position: fixed`). The swipe handle is naturally inert when hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:45:41 +02:00
Ilia Denisov 8dcaf1c6c6 feat(ui): error & state UX — error surface, view states, map selection, sheet gestures (F4)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 7m13s
- lib/error/: classify any caught error into a stable ErrorKind from the
  transport signal (HTTP status / Connect Code / fetch TypeError /
  navigator.onLine); map to translated error.* messages via reportError
  (sticky Retry toast for retryable kinds) or errorMessageKey (inline).
  Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
  the right live-region role + optional action; entity tables
  (races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
  the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
  for the planet/ship-group bottom-sheets (hand-rolled pointer events).

Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:29:11 +02:00
developer 87d524fb89 Merge pull request 'feat(ui): localisation completeness — persistence + parity guards (F3)' (#28) from feature/ui-finalize-f3-i18n into development
Deploy · Dev / deploy (push) Successful in 38s
Tests · UI / test (push) Successful in 2m10s
2026-05-22 06:57:23 +00:00
Ilia Denisov 1e62837c68 feat(ui): locale persistence + i18n completeness guards (F3)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m11s
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.

- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
  and the store boots from stored > browser detection > default, so a
  language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
  values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:48:13 +02:00
developer c56050f5dd Merge pull request 'feat(ui): accessibility pass — WCAG 2.2 AA (F2)' (#27) from feature/ui-finalize-f2-a11y into development
Deploy · Dev / deploy (push) Successful in 35s
Tests · UI / test (push) Successful in 2m38s
2026-05-22 06:43:40 +00:00
Ilia Denisov 70f2973396 fix(ui): darken light-theme danger to meet AA contrast
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m33s
With the default theme now following the OS, Playwright renders the light
theme, where the previous light `--color-danger` (#c84d4d, ~3.9:1 on a
near-white surface) failed WCAG 1.4.3 on error text — caught by the axe
scan of the science designer's empty-name error. Darken light
`--color-danger` to #c0392b (~5.5:1 on white; white-on-danger fills stay
≥5:1). Dark theme unchanged.
2026-05-22 08:40:38 +02:00
Ilia Denisov e193f3ca88 feat(ui): default theme to system (follow OS light/dark)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Failing after 2m7s
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:36:17 +02:00
Ilia Denisov 642c5b7322 feat(ui): accessibility pass — WCAG 2.2 AA for login/lobby/shell (F2)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m9s
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:

- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
  trap + restore) and restoreFocus (menu focus restore) actions, the
  --color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
  tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
  focus restore (account / view / turn-navigator / map-toggles /
  bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
  role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
  reachable by keyboard via the sidebar inspector and tables (accessible
  alternative; in-canvas keyboard nav deferred).

Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:25:14 +02:00
developer dcc655c7c4 Merge pull request 'feat(ui): visual design system — tokens + light/dark theming (F1)' (#26) from feature/ui-finalize-f1-tokens into development
Deploy · Dev / deploy (push) Successful in 37s
Tests · UI / test (push) Successful in 2m16s
2026-05-22 05:37:11 +00:00
Ilia Denisov 4ad96b0ef7 feat(ui): migrate all view bodies to design tokens (F1b)
Tests · UI / test (push) Successful in 2m11s
Tests · UI / test (pull_request) Successful in 2m7s
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.

Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.

Updates ui/docs/design-system.md (migration status + exceptions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:24:02 +02:00
Ilia Denisov 973480d812 feat(ui): design tokens + light/dark theming, migrate in-game chrome (F1a)
Tests · UI / test (push) Successful in 2m4s
Introduce the shared design-token system under
ui/frontend/src/lib/theme/: tokens.css (dark default + light palette,
plus spacing/radii/typography scales), base.css global baseline
(document background, text, token focus ring, selection), and
theme.svelte.ts (system/light/dark choice, persisted to localStorage,
applied via data-theme on <html>). A pre-paint guard in app.html
resolves the theme before the app boots to avoid a flash, and the theme
picker is wired into the previously-disabled account-menu stub.

Migrate the always-visible in-game chrome to the tokens (header, account
menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as
before, light comes for free. The default stays dark during the
incremental migration; the remaining view bodies migrate in F1b.

Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:02:13 +02:00
developer 44ed0a90eb Merge pull request 'docs(ui): finalize MVP plan structure + de-archaeologize topic docs' (#25) from feature/ui-finalization-plan into development 2026-05-21 21:25:06 +00:00
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00
developer 51865b8cf4 Merge pull request 'Phase 30 — Ship Class Calculator (goal-seek, reach circles, planet build)' (#24) from feature/ui-calculator into development
Deploy · Dev / deploy (push) Successful in 38s
Tests · Go / test (push) Successful in 2m6s
Tests · Integration / integration (push) Successful in 1m43s
Tests · UI / test (push) Successful in 2m1s
2026-05-21 19:47:16 +00:00
Ilia Denisov 4d3cfd11a3 docs(ui): mark Phase 30 (ship-class calculator) done in PLAN.md
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · UI / test (pull_request) Successful in 1m59s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:40:45 +02:00
Ilia Denisov b1b87c8521 feat(ui-calculator): input validation, load caps, ceil display, modernization layout
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle

- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero

- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*

- modernization total upgrade cost spans two columns (single line)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:24:40 +02:00
Ilia Denisov 3ea29cf8b5 fix(ui-calculator): keep calculator state long-lived; don't eject on planet click
Tests · UI / test (push) Successful in 1m59s
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.

While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:29:08 +02:00
Ilia Denisov 9ae7b88b89 feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:04:07 +02:00
developer 00159ddf7c Merge pull request 'ci: per-job pnpm install dir to fix the host-runner setup-pnpm race' (#23) from feature/ci-pnpm-setup-isolation into development
Deploy · Dev / deploy (push) Successful in 40s
Tests · UI / test (push) Successful in 2m4s
2026-05-20 15:31:12 +00:00
Ilia Denisov b24d53b82f ci: install pnpm into a per-job dir to fix the host-runner setup race
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 1m56s
`pnpm/action-setup@v4` defaults to installing pnpm in the shared
`~/setup-pnpm`. On the single host-mode runner $HOME is shared across
concurrent jobs, so when two pnpm jobs overlap (e.g. a post-merge
`dev-deploy` and `ui-test`, which sit in different concurrency groups)
their self-installers race and one fails with
`ENOTEMPTY ... rmdir '~/setup-pnpm/node_modules/.bin/store/v11/files'`
before the tests even run.

Point each step's `dest` at `${{ runner.temp }}/setup-pnpm` (a per-job
isolated directory) so concurrent jobs never share the install location.
The action still adds `dest` to PATH, so setup-node's pnpm cache and
later `pnpm` calls are unaffected; the pnpm package store stays shared
(safe — pnpm locks it). Applied to the three workflows that set up pnpm:
ui-test, dev-deploy, prod-build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:26:49 +02:00
developer 6572f5b59d Merge pull request 'fix(ui-map): inverse stencil mask for the visibility fog (Safari pan perf, stage 2)' (#22) from feature/ui-map-fog-inverse-mask into development
Deploy · Dev / deploy (push) Successful in 31s
Tests · UI / test (push) Successful in 1m58s
2026-05-20 15:02:03 +00:00
Ilia Denisov a08f4f55b0 fix(ui-map): cut the visibility fog with an inverse stencil mask (Safari pan perf, stage 2)
Tests · UI / test (push) Successful in 1m57s
Tests · UI / test (pull_request) Successful in 1m56s
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.

Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.

fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.

Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:53:54 +02:00
developer 44c18c3ef4 Merge pull request 'fix(ui-map): render-on-demand + drop pan inertia (Safari fog freeze, stage 1)' (#21) from feature/ui-map-render-on-demand into development
Deploy · Dev / deploy (push) Successful in 30s
Tests · UI / test (push) Successful in 1m57s
2026-05-20 14:43:57 +00:00
Ilia Denisov 51902b995f fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.

Stage 1 (vector-preserving, no rasterisation):

- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
  single Ticker.shared flush gated on viewport.dirty (camera) plus an
  internal requestRender() from every content mutation (fog / hide-set /
  extras / wrap mode / resize / pick overlay). An idle map now does zero
  GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
  instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
  deterministic e2e assertions.

Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.

If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:28:18 +02:00
developer 0da2f4b6fb Merge pull request #20 from feature/ui-map-toggles
Deploy · Dev / deploy (push) Successful in 30s
Tests · Integration / integration (push) Successful in 1m43s
Tests · Go / test (push) Successful in 2m5s
Tests · UI / test (push) Successful in 2m56s
Phase 29 — Map Toggles

Reviewed and verified visually on dev-deploy.
2026-05-19 22:37:29 +00:00
Ilia Denisov 53b892ae00 fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:26:06 +02:00
Ilia Denisov 00e84579ca fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:

1. With three LOCAL planets close together, only the last planet
   glyph stayed visible inside the bg holes — the other two were
   obscured. The previous implementation stacked the fog rectangle
   plus every bg circle onto a single `Graphics` via repeated
   `g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
   multi-shape Graphics is supported in theory, but in practice
   only the last shape's fill seems to land, dropping the earlier
   bg holes (and the planet glyphs on top look like they vanished
   along with their hole). Splitting each op onto its own
   `Graphics` inside a per-copy `Container` removes the ambiguity
   — one shape, one fill, one render pass.

2. A planet near the right world edge produced a "sector" — the
   bg circle painted into the area past the seam, but the
   neighbouring tile's fog rectangle then overpainted that bleed,
   leaving a quarter-circle hole. In torus mode each visibility
   circle is now drawn at the nine wrapped positions
   (`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
   neighbour-tile-aligned positions keep the hole continuous
   across the seam. No-wrap mode keeps a single emission per
   circle, because wrapped circles would leak into the visible
   world rectangle as unwanted holes.

The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:04:35 +02:00
Ilia Denisov 7ade838df8 test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.

`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:42:39 +02:00
Ilia Denisov 37580b7699 fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.

Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.

Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:39:39 +02:00
Ilia Denisov 2f4dc01d54 fix(ui-map): apply wrap-mode flips in place instead of remounting
Tests · UI / test (push) Successful in 2m51s
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").

The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:33:38 +02:00
Ilia Denisov 7c46aa4bec fix(ui-e2e): tighten Phase 29 effect tracking + radio wiring
Tests · UI / test (push) Failing after 7m19s
Run #217 surfaced three independent bugs that survived the first
fixup pass:

1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
   but JS bitwise AND always returns a signed int32 — the mask had
   to be re-converted with `>>> 0` AFTER the AND, not before. Result
   was always 0 on the previous run, masking the next two bugs by
   making the persistence test's high-bit-count assertions a
   tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
   `toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
   never landed in the effect's dependency set — toggling fog or any
   marker / group / kind flag did not re-run the effect, so the
   renderer never received the new hide / fog input. Explicit
   `void toggles.X` reads now live at the top of the effect so every
   key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
   suppresses on a re-activation of an already-checked input — the
   Playwright `.click()` flake on the second wrap test reflected the
   missed event. Switched to `onclick` and short-circuited when the
   target mode is already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:23:15 +02:00
Ilia Denisov 2528d63b51 fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:

1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
   in signed space — high-bit-prefix primitives (cargo route 0x80…,
   battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
   leaked into the planet list. Filter switched to a `0 < id < 1e7`
   window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
   before masking so the bitmask comparison works regardless of
   whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
   after the click — the Svelte effect re-runs asynchronously, so the
   tests saw stale state. Both now `waitForFunction` on the canonical
   "settled" signal: empty fog circles for the fog flip, and a new
   `getMapMode()` debug accessor for the wrap-mode remount.

Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:15 +02:00
Ilia Denisov 2bd1b54936 feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:33:53 +02:00
developer 65c0fbb87d Merge pull request 'fix(generator): drop incorrect distinctness assert in TestPlanetRandomName' (#19) from feature/fix-flaky-planet-random-name into development
Deploy · Dev / deploy (push) Successful in 31s
Tests · Integration / integration (push) Successful in 1m35s
Tests · Go / test (push) Successful in 2m58s
2026-05-19 08:15:45 +00:00
Ilia Denisov 3d06f49f3c fix(generator): drop incorrect distinctness assert in TestPlanetRandomName
Tests · Go / test (push) Successful in 1m54s
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m2s
`RandomName` builds the suffix as two independent `rand.Intn(1000)`
calls, so the two 4-digit halves collide on ~0.1% of runs. The
sub-test asserted `g[2] != g[3]`, which flakes whenever the same
value lands twice — once per ~1000 sub-runs per class, so across
the seven `PlanetClass` rows the integration suite hit it on
`#199 go-unit.yaml` against `feature/subscribe-events-heartbeat`
(`"0074"` collision).

Distinctness is not a property `RandomName` promises and is not
load-bearing for callers: `game/internal/controller/generate_game.go`
uses these names for planet labels and already tolerates duplicate
names across planets, so collisions inside one name are no worse
than collisions between names. Drop the assert; keep the format and
class-prefix checks, which are the actual contract.

Stress-tested with `-count=200`: 200 consecutive iterations × 7
classes = 1400 sub-runs without a single failure where the prior
version's flake probability would have surfaced ~once on average.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:13:25 +02:00
developer 08345606a5 Merge pull request 'fix(dev-deploy): explicit Cache-Control on the UI surface' (#18) from feature/caddy-cache-headers into development
Deploy · Dev / deploy (push) Successful in 27s
2026-05-19 08:11:40 +00:00
Ilia Denisov b85a9e1b9b fix(dev-deploy): explicit Cache-Control on the UI surface
Caddy's `file_server` did not set Cache-Control on the SvelteKit
build, so browsers fell back to heuristic caching keyed off
Last-Modified. On the long-lived dev environment the heuristic
window leaves the previous deploy's `index.html` cached for
minutes-to-hours, and Safari combined that with stale conditional
requests into a visible multi-second freeze on every reload (the
reproduction was "private window reloads instantly, normal window
hangs; clearing Safari caches restores normal speed"). Push
delivery itself works — heartbeat keeps the SubscribeEvents stream
alive — but the bundle path stalls behind the browser revalidating
a chain of stale chunks.

Mirror the standard SvelteKit cache split inside both Caddyfiles:

- `_app/immutable/*` — hash-named JS/CSS chunks Vite emits with
  content-addressed file names — `Cache-Control:
  public, max-age=31536000, immutable`. Safe to cache forever
  because the name changes whenever the content does, so the next
  deploy serves new files under new URLs.
- Everything else (`index.html` fallback via `try_files`,
  `env.js`, `version.json`, `core.wasm`, `wasm_exec.js`,
  `favicon.svg`) — `Cache-Control: no-cache, must-revalidate`.
  The browser still uses the cached body when the ETag matches,
  but it always asks first; a fresh deploy reaches the user on
  the next reload without a manual cache clear.

Smoke-tested locally: a docker-run Caddy with this config returns
the immutable header only for `/_app/immutable/*` and the
no-cache header for `/index.html`, `/env.js`, and the SPA-fallback
path `/some/route`. The Caddyfile passes `caddy validate` in
both `Caddyfile.dev` and `Caddyfile.prod`; the pre-existing
formatting warning on line 7 is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:11:09 +02:00
developer 8170abd5fa Merge pull request 'feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive' (#17) from feature/subscribe-events-heartbeat into development
Deploy · Dev / deploy (push) Successful in 33s
Tests · Integration / integration (push) Successful in 1m36s
Tests · Go / test (push) Successful in 3m18s
Tests · UI / test (push) Successful in 2m28s
2026-05-19 07:35:35 +00:00
Ilia Denisov 14b65389ef feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive
Tests · UI / test (push) Successful in 2m35s
Tests · Go / test (push) Successful in 1m56s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m0s
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.

Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.

The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).

Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.

Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.

Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
  interval returns nil, heartbeat fires after silence, real Send
  resets the timer, Stop / context-cancel halt the goroutine,
  Send errors propagate.
- `server_test.go`: integration tests through the full gateway
  pipeline — heartbeat fires after the configured silence window,
  zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
  negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
  dispatched to handlers; leading heartbeat still flips
  `connectionStatus` to `connected`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:29:29 +02:00
developer 8f84075c4b Merge pull request 'fix(battle-viewer): unblock synthetic-game battle load' (#16) from feature/synthetic-battle-loading-fix into development
Deploy · Dev / deploy (push) Successful in 34s
Tests · UI / test (push) Successful in 2m20s
2026-05-19 05:58:02 +00:00
Ilia Denisov bde01b1ce2 fix(battle-viewer): unblock synthetic-game battle load
Tests · UI / test (push) Successful in 2m18s
Tests · UI / test (pull_request) Waiting to run
The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.

Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.

Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
  game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
  `battle-not-found`; registered fixture → `BattleViewer` mounts.

Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
  raw REST path. Updated to the signed ConnectRPC command and
  noted the synthetic short-circuit. Russian mirror updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:52:26 +02:00
developer 82bdb6777a Merge pull request 'fix(dev-deploy): seed geoip onto a named volume' (#15) from feature/dev-deploy-geoip-volume into development
Deploy · Dev / deploy (push) Successful in 28s
2026-05-19 00:02:12 +00:00
Ilia Denisov f70258849f fix(dev-deploy): seed geoip onto a named volume
`docker restart galaxy-dev-backend` failed with "not a directory"
after every dev-deploy workflow run. Root cause: the compose file
bind-mounted the geoip database via a relative path
(`../../pkg/geoip/test-data/test-data/GeoIP2-Country-Test.mmdb`).
When the Gitea runner invoked `docker compose up`, the path
resolved against the runner's ephemeral workspace under
`/home/runner/.cache/act/<hash>/hostexecutor/...`. The bind source
baked into the running container therefore pointed at that
ephemeral path; the runner deleted the workspace once the workflow
finished, and any later `docker restart` could not remount.

Replace the bind with a named volume `galaxy-dev-geoip-data`,
seeded at deploy time:

- `tools/dev-deploy/docker-compose.yml`: mount
  `galaxy-dev-geoip-data:/var/lib/galaxy:ro` instead of a relative
  bind. Declare the volume in the top-level `volumes:` block.

- `.gitea/workflows/dev-deploy.yaml`: new `Seed geoip volume` step
  (placed right after the existing UI-volume seed) copies the
  fixture from `pkg/geoip/test-data/test-data/` into the named
  volume via an ephemeral alpine container, the same pattern UI
  seeding already uses.

- `tools/dev-deploy/Makefile`: new `seed-geoip` target performs
  the same copy from the persistent checkout. `up` and `rebuild`
  now depend on it, so a hand-run `make -C tools/dev-deploy up`
  populates the volume without operator action.

- `tools/dev-deploy/README.md`: updated the make-targets table to
  list `seed-geoip`.

- `tools/dev-deploy/KNOWN-ISSUES.md`: the entry for the restart
  failure is downgraded to a "fixed" postmortem; the symptom,
  cause, and where the fix lives are kept for future reference.

Verification on the dev host (this branch checked out):

  $ make -C tools/dev-deploy up                # populates the volume, brings stack healthy
  $ docker restart galaxy-dev-backend          # used to error "not a directory"
  $ until [ "$(docker inspect -f '{{.State.Health.Status}}' galaxy-dev-backend)" = "healthy" ]; do sleep 2; done
  $ echo "ok"                                   # backend up 6s, healthy

The pre-existing sandbox engine `galaxy-game-80f3ce86-...` survived
both `make up` and `docker restart` untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:59:38 +02:00
developer d19aa3aac5 Merge pull request 'fix(integration): scope preclean to galaxy.stack=integration' (#14) from feature/preclean-stack-scope into development
Tests · Integration / integration (push) Successful in 1m36s
2026-05-18 23:48:47 +00:00
Ilia Denisov a338ebf058 fix(integration): scope preclean to galaxy.stack=integration
Tests · Integration / integration (pull_request) Successful in 1m37s
Root cause for the long-standing "Dev Sandbox flips to cancelled
after dev-deploy" symptom in push-triggered cycles: when
`integration.yaml` runs in parallel with `dev-deploy.yaml`, its
`integration/scripts/preclean.sh` issues a `docker rm -f` over every
container labelled `galaxy.backend=1`. That label is stamped by the
backend's runtime adapter on every engine it spawns — including the
engines living in the long-lived dev-deploy environment on the same
Docker daemon. Each post-merge auto-deploy therefore had the
integration preclean wipe the dev-sandbox engine, and the new
backend's reconciler tick observed `container disappeared` and
cascaded the sandbox into `cancelled`.

Fix:

- `integration/testenv/backend.go` now sets
  `BACKEND_STACK_LABEL=integration` on every backend-under-test, so
  the engines spawned by integration carry
  `galaxy.stack=integration` in addition to `galaxy.backend=1`. The
  backend support for this env was added in the previous CI tidy-up
  PR (#13).

- `integration/scripts/preclean.sh` gains a multi-label AND filter
  helper and uses it to scope engine cleanup to the combination
  `galaxy.backend=1 AND galaxy.stack=integration`. dev-deploy and
  local-dev engines carry different `galaxy.stack` values, so the
  AND match leaves them alone.

- `docs/ARCHITECTURE.md` "Container labels" — refreshed to call out
  the AND-scoping rule and the new integration backend stamp.

- `tools/dev-deploy/KNOWN-ISSUES.md` — the sandbox-cancel entry
  gets an "Update" section recording the root cause and the fix; the
  status is downgraded to "partially fixed" because the solo
  `workflow_dispatch` reproduction (which does NOT trigger
  integration) remains unexplained.

- `tools/dev-deploy/KNOWN-ISSUES.md` — separately, document the
  `docker restart galaxy-dev-backend` failure caused by the
  runner-workspace bind-mount that surfaced while diagnosing this
  issue. Workaround: `make -C tools/dev-deploy up` from the
  persistent checkout. Real fix is a follow-up (bake fixture into
  image or copy to named volume).

Verification:

- `go build ./backend/... ./integration/...` — clean.
- `bash -n integration/scripts/preclean.sh` — syntax OK.
- Live AND-filter check on the dev host:
  `docker ps -aq --filter label=galaxy.backend=1 --filter label=galaxy.stack=integration`
  returns nothing while the dev-deploy engine
  `galaxy-game-80f3ce86-...` keeps running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:37:55 +02:00
developer f91cf6eb41 Merge pull request 'chore(ci): tidy CI/dev infra — drop local-ci, lift migration rule' (#13) from feature/ci-tidy-up into development
Deploy · Dev / deploy (push) Successful in 28s
Tests · Go / test (push) Successful in 2m0s
Tests · Integration / integration (push) Successful in 1m40s
2026-05-18 23:10:21 +00:00
Ilia Denisov daed2690c1 fix(compose): keep galaxy.stack label on containers only
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 2m0s
The previous commit stamped `galaxy.stack=<value>` on services,
volumes, and networks. Putting it on volumes/networks changes their
compose config-hash on every label revision, so `docker compose up`
tries to recreate them — which on the long-lived dev environment
either destroys the postgres data volume or deadlocks while trying
to remove `galaxy-dev-internal` with containers still bound to it.
Observed live: run #184 hung in compose recreate after the three
stateful services were stopped, with no recovery.

Containers alone are sufficient for the cleanup contract (we filter
containers, not volumes or networks). Roll back the label on volumes
and networks in both compose files and capture the rule in
docs/ARCHITECTURE.md so the next contributor does not reintroduce it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:00:21 +02:00
Ilia Denisov a9087691a3 chore(ci): tidy CI/dev infra — drop local-ci, lift migration rule, scope by galaxy.stack label
Tests · Go / test (push) Successful in 2m6s
Tests · Go / test (pull_request) Successful in 3m1s
Tests · Integration / integration (pull_request) Successful in 1m42s
Five connected cleanups across the dev/CI infrastructure:

1. Drop tools/local-ci/. The standalone Gitea + act_runner stack was
   the legacy "offline workflow validator"; the per-stage CI gate now
   runs on gitea.lan and the directory was only retained as a
   fallback. Removing it leaves no operational dependency: backend,
   gateway, and game code have no references; documentation that
   pointed at it (CLAUDE.md, docs/ARCHITECTURE.md, ui/docs/testing.md,
   tools/dev-deploy/README.md, tools/local-dev/README.md) is updated
   in this same change. Historical "Verified on local-ci run N"
   markers in ui/PLAN.md are preserved unchanged.

2. Lift the pre-production single-migration rule. The rule forced
   every schema delta into 00001_init.sql and required a manual
   make clean-data wipe on every backward-incompatible change in
   tools/dev-deploy/. Future schema deltas now land as additive
   sequence-numbered files (00002_*.sql, …) that goose applies
   automatically on backend startup; 00001_init.sql becomes an
   immutable baseline. Authoring conventions live in
   backend/internal/postgres/migrations/README.md. The chain may be
   squashed back into a fresh 00001 as a deliberate one-time
   operation before the first production deployment.

3. Document the deployment cadence. The dev environment is
   single-tenant: pushes to feature/* run the test workflows
   (go-unit, ui-test, integration) only; dev-deploy.yaml fires on
   push to development. A workflow_dispatch override on
   dev-deploy.yaml lets a developer preview a feature branch on the
   shared dev environment before merge; the next merge into
   development overwrites the manual deploy idempotently.

4. Scope compose-managed resources by an explicit
   galaxy.stack=<local-dev|dev-deploy> label. Both compose files
   stamp the label on every service, network, and named volume.
   Makefiles in tools/local-dev/ and tools/dev-deploy/ filter their
   engine-cleanup operations by (stack-label AND engine OCI title)
   so they never touch unrelated workloads on the same daemon.
   dev-deploy.yaml gains a pre-`compose up` step that reaps stale
   exited/dead containers under the dev-deploy stack label.

5. Backend now stamps the same galaxy.stack=<value> label on every
   engine container it spawns, sourced from a new BACKEND_STACK_LABEL
   env var (empty → label not applied; legacy-safe). Both compose
   files set it to their stack name (local-dev / dev-deploy). The
   contract is recorded in docs/ARCHITECTURE.md under
   "Container labels". A package-level test in
   backend/internal/runtime exercises both the label-present and
   label-absent paths.

No tests intentionally regressed: go test ./backend/internal/{config,
runtime,dockerclient} is green, both compose files validate cleanly,
and the backend, gateway, and game modules all build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:32:42 +02:00
developer 5eec7013ba Merge pull request 'chore(dev-deploy): KNOWN-ISSUES entry for sandbox-cancel after redispatch' (#12) from chore/dev-sandbox-cancel-todo into development 2026-05-16 21:17:37 +00:00
Ilia Denisov 49f614926a KNOWN-ISSUES: park sandbox-cancel; owner rejected host-side hypotheses
After the live investigation, the project owner confirms that none
of the host-side cleanup paths apply: no docker prune cron, no
manual `docker rm`, no `dockerd` restart in the window, and the
engine binary does not crash while idling on API calls.

Replace the host-side hypothesis list with a one-line note that
they were considered and rejected, narrow the open suspicion to
the `dev-deploy.yaml` job sequence (`docker build` + `docker
compose build` + the alpine `docker run --rm` for UI seeding +
`docker compose up -d --wait --remove-orphans`), and park the
entry. Reopen if the symptom recurs with a fresh
`docker events --since 0` capture armed before the deploy
starts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:16:51 +02:00
Ilia Denisov cadb72b412 KNOWN-ISSUES: rule out compose orphan reap; narrow to host-side reap
Tests · UI / test (push) Successful in 2m36s
Tests · Go / test (push) Successful in 2m38s
A live `docker inspect` of an engine container and two redispatch
runs with `docker events` captured confirm:

- Engine has no `com.docker.compose.*` labels and `AutoRemove=false`,
  so `--remove-orphans` cannot reap it.
- Two consecutive `dev-deploy.yaml` redispatches with an engine
  already running emitted `die` / `destroy` events only for
  `galaxy-dev-{backend,api,caddy}` — never for the engine.
- The reconciler tick that fires 60s after backend recreate
  correctly matched the surviving engine in both cases
  (`status=running` in both `games` and `runtime_records`).
- `runtime.Service` has no `Shutdown` that proactively removes
  engine containers, so a graceful backend exit also leaves them
  alone.

The repro window therefore needs a separate trigger that removed
the engine container outside of compose. The new hypotheses point
at host-side `docker prune` jobs, a `dockerd` restart that lost the
container, or an early `Engine.Init` failure that exited the engine
before `status=running` reached the runtime row. The investigation
list now leads with `journalctl -u docker` and the host crontab —
those are the cheapest checks to confirm or rule out next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:10:13 +02:00
Ilia Denisov 5177fef2ef tools/dev-deploy: log the sandbox-cancellation TODO
Capture the diagnostic notes for the issue we hit after every
`dev-deploy.yaml` redispatch: the freshly-bootstrapped "Dev Sandbox"
game ends up `cancelled` ~15 minutes later, with the runtime
reconciler reporting "container disappeared". The engine never
shows up in `docker ps -a --filter label=galaxy-game-engine`, so
either it never spawned or it was removed before any host-side
snapshot.

`KNOWN-ISSUES.md` records the symptom, the log excerpt, three
working hypotheses (runtime spawn race, `--remove-orphans`
interaction, engine `--rm` lifecycle), and the investigation
checklist before opening an issue. The README gets a one-line
pointer so future redeploys land on the doc immediately.

No code change — this is the placeholder so the next person
investigating the cancellation pattern does not have to
rediscover the diagnostic from scratch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:56:25 +02:00
developer 823be9d980 Phase 28: diplomatic mail UI
Deploy · Dev / deploy (push) Successful in 29s
Tests · Go / test (push) Successful in 2m5s
Tests · Integration / integration (push) Successful in 1m45s
Tests · UI / test (push) Successful in 2m31s
Merges feat/ui-stage-28 into development.
2026-05-16 20:56:16 +00:00
Ilia Denisov 2119f825d6 mail UI: dedupe broadcast fan-out and drop in-game admin compose
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m24s
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:

1. `/sent` returns one row per recipient for broadcast and admin
   fan-outs (so the admin tooling can render the materialised
   audience). The list pane fed all rows into the stand-alone
   bucket, so the `{#each entries as e (entryKey(e))}` key in
   `thread-list.svelte` collapsed to the same `standalone:${id}`
   for every recipient and Svelte 5 aborted the render with
   `each_key_duplicate`. Dedupe stand-alones by `message_id` in
   `buildEntries`.

2. The compose dialog exposed an `admin` kind toggle gated on
   "owner of game". That was a Phase 28 plan decision, but admin
   compose is an operator tool (server admin), not an in-game
   action — every game owner should not be able to broadcast
   admin notifications. Drop the admin option, the audience
   sub-toggles, and the admin path through `submit`. The
   `MailStore.composeAdmin` wrapper and the backend RPC stay so
   the future admin UI can call them.

Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:38:59 +02:00
Ilia Denisov 57e6c1d253 gateway: CORS allow-list for the authenticated Connect-Web surface
Tests · Go / test (push) Successful in 2m9s
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Successful in 2m52s
The public REST listener already exposes
`GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS`; the authenticated
Connect-Web listener on the separate gRPC port had no equivalent.
That worked in `tools/local-dev` (Vite proxy makes everything
same-origin) and would work in production once UI and gateway share
a single hostname, but the long-lived dev environment serves the
UI from `https://www.galaxy.lan` and the gateway from
`https://api.galaxy.lan` — every `/galaxy.gateway.v1.EdgeGateway/*`
fetch failed in the browser with the WebKit "Load failed" generic
message because the response carried no `Access-Control-Allow-Origin`
header. Lobby rendered as "[unknown] Load failed" with no game.

Mirror the public-REST CORS surface for the authenticated handler:

- new env `GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS`;
- new `AuthenticatedGRPCConfig.CORSAllowedOrigins` field;
- new `grpcapi.withCORS` middleware wrapping the Connect mux;
- dev-deploy stack sets the env to `https://www.galaxy.lan`.

The middleware speaks plain net/http (the Connect handler is mounted
on a ServeMux, not gin), handles preflight 204 immediately, and
exposes the Connect-Web header set the browser needs to read the
response (`Grpc-Status`, `Grpc-Message`, `Connect-Protocol-Version`).
Empty allow-list disables the middleware — production stays at
"single hostname" by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:15:11 +02:00
Ilia Denisov 4b2a949f12 dev-deploy Caddy: route Connect-Web traffic to gateway :9090
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m27s
`api.galaxy.lan` was proxying every path to `galaxy-api:8080` (the
public REST listener), so authenticated Connect-Web calls
(`/galaxy.gateway.v1.EdgeGateway/ExecuteCommand`,
`/galaxy.gateway.v1.EdgeGateway/SubscribeEvents`) collapsed to a 404
from the public route table — the lobby loaded the static bundle
but every authenticated query failed silently.

Split routing by path: `/galaxy.gateway.v1.EdgeGateway/*` goes to
the authenticated listener on `:9090`, everything else stays on
`:8080`. Mirrors the Vite dev-server proxy in
`ui/frontend/vite.config.ts`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:03:55 +02:00
Ilia Denisov 81917acc3e dev-deploy: enable Dev Sandbox bootstrap and synthetic-report loader
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m23s
Two long-standing dev-environment ergonomics had not survived the
move from the bespoke local-dev stack to the CI-driven dev-deploy:

1. `BACKEND_DEV_SANDBOX_EMAIL` defaulted to an empty string in the
   dev-deploy compose, so the auto-provisioned "Dev Sandbox" game
   never appeared on `https://www.galaxy.lan`. Bake `dev@galaxy.lan`
   as the default — matches `.env.example` and lets a developer who
   logs in with that email find a ready-to-play game in the lobby.

2. The lobby's synthetic-report loader was gated on
   `import.meta.env.DEV`, which is true only for `vite dev` (the
   tools/local-dev path). The long-lived dev environment builds
   with `vite build` (production mode), so the section was always
   stripped from its bundle. Gate it on an explicit
   `VITE_GALAXY_DEV_AFFORDANCES` flag instead and set it both in
   `.env.development` (preserves `pnpm dev` behaviour) and in the
   `dev-deploy.yaml` build step. The `prod-build.yaml` build path
   leaves the flag unset, so production stays clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:46:24 +02:00
Ilia Denisov 859b157a59 auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Go / test (push) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · UI / test (pull_request) Successful in 2m51s
Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:

1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
   *before* the dev-fixed-code override. A developer who burned past
   `ChallengeMaxAttempts` on an existing un-consumed challenge (easy
   to trigger when the throttle reuses one challenge_id) hit
   `ErrTooManyAttempts` and the UI rendered "code expired or already
   used" even though the fixed code was correct. Reorder so the
   dev-fixed-code branch runs first and bypasses both the bcrypt
   verify and the attempts gate. Production stays unaffected
   because production loaders refuse to set `DevFixedCode`.

2. `dev-deploy.yaml` only fires on push to `development`, so the
   matching docker-compose default change for
   `BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
   before this PR merged. Add `workflow_dispatch: {}` so a developer
   can deploy any branch — typically a feature branch under review —
   from the Gitea Actions UI without waiting for the merge.

Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:28:30 +02:00
Ilia Denisov 166baf4be0 battle-viewer e2e: mock user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · UI / test (pull_request) Successful in 2m23s
Phase 28 moved the battle fetch off the REST passthrough onto the
signed envelope, so the Playwright spec's `page.route(...)` against
the old REST path no longer intercepts anything and the viewer
times out waiting for data. Update the spec to:

- Build a FlatBuffers `BattleReport` payload in
  `fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern).
- Add a `user.games.battle` case to the ExecuteCommand mock that
  decodes the FBS `GameBattleRequest`, returns the encoded report
  when the battle_id matches the seeded one, and surfaces a
  canonical `not_found` resultCode otherwise.
- Drop the obsolete REST route stubs.
- Drive the negative-path test with a real UUID that does not match
  the seeded one, so the gateway-side switch is the source of the
  404 (the old `missing-uuid` literal was no longer a valid wire
  shape for the UUID decoder).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:55:15 +02:00
Ilia Denisov ebd156ece2 battle-fetch: migrate to user.games.battle ConnectRPC command
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m6s
Tests · Go / test (push) Successful in 2m7s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Failing after 3m42s
The Phase 27 BattleViewer was the last UI surface still issuing raw
fetch() against the backend REST contract (`/api/v1/user/games/...
/battles/...`). The dev-deploy gateway never proxied that path, so
the viewer worked only in tools/local-dev/. Move it onto the signed
ConnectRPC channel every other authenticated surface already uses.

Wire pieces:
- FBS GameBattleRequest in pkg/schema/fbs/battle.fbs, regenerated
  Go + TS bindings.
- MessageTypeUserGamesBattle constant + GameBattleRequest struct in
  pkg/model/report/messages.go.
- pkg/transcoder/battle.go gains GameBattleRequestToPayload and
  PayloadToGameBattleRequest helpers.
- gateway games_commands.go switches on the new message type and
  GETs /api/v1/user/games/{id}/battles/{turn}/{battle_id}; the JSON
  response is re-encoded as a FlatBuffers BattleReport before being
  returned. 404 from backend surfaces as the canonical `not_found`
  gateway error.
- ui/frontend/src/api/battle-fetch.ts now builds the FBS request,
  calls GalaxyClient.executeCommand, and decodes the FBS response
  into the existing UI shape (Record<string,string> race/ship maps,
  string-form UUID). BattleFetchError carries an HTTP-style status
  derived from the result code so the active-view's not_found branch
  keeps working.
- battle.svelte pulls the GalaxyClient from the in-game shell
  context. While the layout's boot Promise.all is in flight the
  effect stays in `loading` until the client handle becomes
  non-null.
- ui/Makefile FBS_INPUTS gains battle.fbs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:41:54 +02:00
Ilia Denisov 8bc75fd71b dev-deploy: default BACKEND_AUTH_DEV_FIXED_CODE to 123456
The long-lived dev environment now opts into the bcrypt-bypass on a
fresh `up`/`rebuild` so a returning developer can sign in with `123456`
even after the matching browser session was cleared (the real emailed
code is single-use). Set the variable to an empty string in `.env` to
force real Mailpit codes (mail-flow QA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:41:32 +02:00
Ilia Denisov 1556d36511 Phase 28: mark stage done after CI gate green
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · UI / test (pull_request) Successful in 2m20s
Gitea runs at commit 6d0272b:
- go-unit #134 → success
- ui-test #136 → success
- integration #135 → success

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:56:29 +02:00
Ilia Denisov 6d0272b078 Phase 28 (Step 11): Vitest coverage for MailStore threading
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m43s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m24s
`tests/mail-store.test.ts` exercises the `entries` derived rune
with handcrafted inbox + sent fixtures:

- personal messages exchanged with one race collapse into a
  per-race thread with messages sorted oldest → newest;
- system mail (`sender_kind=system`) and admin notifications
  (`sender_kind=admin`) surface as stand-alone items even when a
  race-name snapshot is present;
- the caller's own paid-tier broadcasts (`broadcast_scope=
  game_broadcast`) render as stand-alone outgoing items;
- `unreadCount` counts inbox rows with `readAt === null`.

The store fields are mutated directly to avoid wiring a fake
`GalaxyClient`; the underlying `$derived` rune fires whenever
those fields change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:50:01 +02:00
Ilia Denisov c48bc83890 Phase 28 (Step 10): docs — diplomail UI topic + FUNCTIONAL mirror
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
  surface, recipient-by-race-name decision, threading model,
  translation toggle, push events, badge, layout, and
  accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
  UI's per-race threading rule, the absent read-receipt UX, and
  the recipient-by-race-name compose path. Mirrored verbatim into
  `docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
  stage" block matching the implementation plan, and the artifact
  list updated to the actual file set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:48:16 +02:00
Ilia Denisov db81bd8e08 Phase 28 (Steps 7+8): header unread badge + push/init wiring
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 3m25s
Step 7 — header view-menu badge.

`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.

Step 8 — push event handler + MailStore init in the in-game layout.

`routes/games/[id]/+layout.svelte`:

- registers a `diplomail.message.received` handler alongside the
  existing `game.turn.ready` / `game.paused` ones, parses the
  signed payload, calls `mailStore.applyPushEvent` to refresh the
  inbox for the matching game, and raises a toast with a "view"
  deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
  `Promise.all` so the inbox + sent lists are warm by the time the
  view mounts, and the badge counter is populated before any user
  interaction;
- disposes the new subscription in the `onDestroy` block so a game
  switch does not leak handlers across navigations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:46:00 +02:00
Ilia Denisov f7300f25a3 Phase 28 (Steps 6+9): mail active view + i18n keys
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m36s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
Step 6 — mail active view + subcomponents.

- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
  list / detail layout: two-pane on desktop, one-pane stack on
  mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
  threads collapsed to their last message plus stand-alone
  system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
  transcript for one race; bodies render through `textContent`,
  per-message Show original / translation toggles flip the
  rendering when a translated body is present, and a persistent
  reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
  stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
  recipient race picker fed from `report.races[]`, kind toggle
  (personal / broadcast / admin), admin sub-toggle for target
  user / all and recipient-scope picker. Server-side enforces
  paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
  i18n-title mapping for lifecycle-hook system mail so both the
  list and the detail pane pick the same canonical title.

Step 9 — i18n strings (en + ru).

`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:43:09 +02:00
Ilia Denisov fdd5fd193d Phase 28 (Step 5): MailStore reactive state
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m38s
Tests · Go / test (pull_request) Successful in 3m19s
Tests · UI / test (pull_request) Waiting to run
Adds `src/lib/mail-store.svelte.ts` — the reactive store that
coordinates the in-game mail view. Responsibilities:

- holds the inbox and sent listings for the current game and fires
  the initial parallel fetch (`fetchInbox` + `fetchSent`) on
  `setGame`;
- exposes a `entries` derived rune that builds the unified list
  pane: per-race threads merged from incoming + outgoing personal
  messages, plus stand-alone items for system / admin / own
  paid-tier broadcasts. Thread messages are sorted oldest → newest
  for chat-style rendering; the list itself sorts newest-first by
  the most-recent entry timestamp;
- derives `unreadCount` from `readAt === null` rows for the header
  view-menu badge;
- imperative `markRead` / `softDelete` actions with optimistic
  state flips and roll-back on RPC failure;
- compose actions for personal / paid-tier broadcast / owner-admin
  sends;
- `applyPushEvent(gameId)` hook called by the layout when a
  `diplomail.message.received` push frame arrives; refetches the
  inbox without trusting the preview payload;
- persists the most recent message id under
  `cache.diplomail/${gameId}/last-seen` so a returning session can
  pre-paint the badge without a network round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:37:32 +02:00
Ilia Denisov 7378d4c8ed Phase 28 (Step 4): UI api/diplomail.ts wrappers
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m20s
Tests · Integration / integration (pull_request) Successful in 1m43s
Adds typed wrappers around `GalaxyClient.executeCommand` for the
eight Phase 28 mail RPCs. Each wrapper builds the matching
FlatBuffers request, decodes the response, and surfaces backend
errors through a dedicated `MailError` (mirroring `LobbyError`).
The compose helpers accept the recipient race name directly so the
UI can feed it straight from `report.races[].name` without a
membership lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:21 +02:00
Ilia Denisov 4cb03736de Phase 28 (Step 3): gateway translators for user.games.mail.*
Tests · Go / test (push) Successful in 2m10s
Tests · Go / test (pull_request) Successful in 2m11s
Tests · Integration / integration (pull_request) Successful in 1m55s
Tests · UI / test (pull_request) Waiting to run
Adds the gateway-side translation layer that maps the eight new
ConnectRPC mail commands onto backend's
`/api/v1/user/games/{game_id}/mail/*` REST endpoints.

- `gateway/internal/backendclient/mail_commands.go` defines
  `ExecuteMailCommand` and one helper per command (inbox, sent,
  message.get, send, broadcast, admin, read, delete). Each helper
  decodes the FlatBuffers request envelope, issues the REST call
  via the existing `*RESTClient.do`, decodes the JSON body, and
  re-encodes a typed FlatBuffers response. Recipient identifiers
  travel through unchanged so the new `recipient_race_name`
  shortcut introduced in Step 1 reaches backend untouched.
- `routes.go` exposes a `MailRoutes` constructor and a matching
  `mailCommandClient` implementing `downstream.Client`.
- `cmd/gateway/main.go` registers the new routes alongside the
  existing user / lobby / game-engine routes.
- `mail_commands_test.go` covers the inbox, send-by-race-name, and
  read-state paths end-to-end against an `httptest.Server`,
  asserting request shapes (path, body, X-User-ID) and the
  decoded FlatBuffers response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:32:50 +02:00
Ilia Denisov 57d2286f5e Phase 28 (Step 3a): /sent returns full message detail per recipient
Tests · Go / test (push) Successful in 2m5s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m53s
Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.

- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
  to `[]InboxEntry`. SQL grows an INNER JOIN with
  `diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
  `userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
  `UserMailMessageDetail`; the standalone `UserMailSentSummary`
  schema is dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:27:39 +02:00
Ilia Denisov fed282f2d2 Phase 28 (Step 2): FBS schemas + message-type constants for mail
Tests · UI / test (push) Has been cancelled
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Go / test (push) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m50s
Adds the wire schema for the eight `user.games.mail.*` ConnectRPC
commands together with the shared payload types (`MailMessage`,
`MailRecipientState`, `MailBroadcastReceipt`). Send-request tables
carry the optional `recipient_race_name` introduced in Step 1.

Drops:

- `pkg/schema/fbs/diplomail.fbs` — schema sources;
- `pkg/schema/fbs/diplomail/*.go` — generated Go bindings (flatc
  `--go --go-module-name galaxy/schema/fbs`);
- `pkg/model/diplomail/diplomail.go` — message-type catalog used by
  the gateway router;
- `ui/frontend/src/proto/galaxy/fbs/diplomail/*.ts` — generated TS
  bindings consumed by the upcoming UI client wrapper;
- `ui/Makefile` `FBS_INPUTS` extended to pick the new schema up on
  the next `make -C ui fbs-ts` run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:21:23 +02:00
Ilia Denisov 7b43ce5844 Phase 28 (Step 1): backend support for race-name mail send
Tests · Go / test (push) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m2s
Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:

- accepts `recipient_race_name` on `POST /messages` and
  `POST /admin` (target=user) as an alternative to
  `recipient_user_id`; the service resolves it via the existing
  `Memberships.ListMembers(gameID, "active")` and rejects with
  `forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
  every player sender (admin / system rows stay NULL). The UI keys
  per-race threading on this column.

Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:07:48 +02:00
developer 74c1e7ab24 Merge pull request 'diplomail (Stage A→D): backend in-game diplomatic mail' (#10) from feature/diplomail-backend into development
Deploy · Dev / deploy (push) Successful in 39s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (push) Successful in 1m42s
2026-05-15 18:43:27 +00:00
Ilia Denisov 2d36b54b8d diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 2m4s
Tests · Integration / integration (pull_request) Successful in 1m37s
Closes the documentation gaps from the freshly-audited diplomail
implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with
the full user-facing story across all five stages, mirrored into
FUNCTIONAL_ru.md as the project conventions require. A new
backend/docs/diplomail-translator-setup.md captures the
LibreTranslate operational recipe (Docker image, env wiring,
manual smoke test, troubleshooting). The package README gains a
"Multi-instance posture" note documenting the deliberate absence
of FOR UPDATE in the worker pickup query — single-instance is
safe today; multi-instance scaling will revisit the claim
mechanism.

Two small edge-case tests round things out: malformed
LibreTranslate response bodies (single string, short array,
empty array, missing field) must surface as errors so the worker
falls back instead of crashing; and an empty translation queue
must produce zero events on three consecutive Worker.Tick calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:35:36 +02:00
Ilia Denisov 9f7c9099bc diplomail (Stage E): LibreTranslate client + async translation worker
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:15:28 +02:00
Ilia Denisov e22f4b7800 diplomail (Stage D): language detection + lazy translation cache
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · Integration / integration (pull_request) Successful in 1m35s
Replaces the LangUndetermined placeholder with whatlanggo-backed
body detection on every send path, then adds a translation cache
keyed on (message_id, target_lang) populated lazily on the
per-message read endpoint. The noop translator that ships with
Stage D returns engine="noop", which the service treats as
"translation unavailable" — wiring a real backend (LibreTranslate
HTTP client is the documented next step) is a one-file swap.

GetMessage and ListInbox now accept a targetLang argument; the HTTP
layer resolves the caller's accounts.preferred_language and
forwards it. Inbox uses the cache only (never calls the
translator) so bulk reads stay fast under future SaaS backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:16:12 +02:00
Ilia Denisov 362f92e520 diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:02:46 +02:00
Ilia Denisov b3f24cc440 diplomail (Stage B): admin/owner sends + lifecycle hooks
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).

Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:54 +02:00
Ilia Denisov 535e27008f diplomail (Stage A): add in-game personal mail subsystem
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m45s
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail
channel; the existing `mail` package is a transactional email
outbox and the `notification` catalog is one-way platform events.
Stage A lands the schema (diplomail_messages / _recipients /
_translations), a single-recipient personal send/read/delete
service path, a `diplomail.message.received` push kind plumbed
through the notification pipeline, and an unread-counts endpoint
that drives the lobby badge. Admin / system mail, lifecycle hooks,
paid-tier broadcast, multi-game broadcast, bulk purge and language
detection / translation cache come in stages B–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:28:55 +02:00
developer 77cb7c78b6 Merge pull request #9: ui-test singleton queue
Tests · UI / test (push) Successful in 2m14s
Replaces per-sha cancel-in-progress (which fired spurious self-cancels) with a singleton queueing group. ui-test #74 (push) and #75 (pull_request) both green at ~2m, queue-not-cancel verified.
2026-05-15 06:57:09 +00:00
Ilia Denisov 1a0e3e992f ci/ui-test: queue runs in one bucket instead of cancelling
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m20s
`cancel-in-progress: true` killed run #73 even though it was the
only ui-test in its concurrency group — Gitea appears to cancel the
in-progress job on its own under that setting in some edge cases.

Switch to a singleton group with `cancel-in-progress: false`. The
new behaviour is simple queueing: only one ui-test workflow runs at
a time across the repository, the rest wait. Vite-on-:5173 cannot
collide because there is never a second ui-test alive. The wall-time
hit is bounded — ui-test is ~2 minutes — and bursts are rare enough
that queueing is cheap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:51:54 +02:00
developer faf598b2cd Merge pull request #8: Playwright tuning + concurrency for ui-test
Tests · UI / test (push) Failing after 6s
Deploy · Dev / deploy (push) Successful in 32s
Caps Playwright at 4 workers + 4 retries to absorb the host-mode flake budget, and serialises ui-test runs by head sha so push and pull_request events for the same commit cannot collide on Vite :5173.
2026-05-15 06:49:06 +00:00
Ilia Denisov 6e6186a571 ci/ui-test: key concurrency by head sha, not gitea.ref
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m17s
`gitea.ref` differs between push (`refs/heads/<branch>`) and
pull_request (`refs/pull/N/head`) events even for the same commit,
so the two parallel runs land in different concurrency groups and
the Vite-on-:5173 collision is not suppressed. Switching the key to
the head sha (`gitea.event.pull_request.head.sha || gitea.sha`)
collapses both events into one bucket, leaving exactly one ui-test
alive per commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:46:00 +02:00
Ilia Denisov e3bb30201d ci/ui-test: serialise per-ref + clear stale Vite before Playwright
Tests · UI / test (pull_request) Failing after 6s
Tests · UI / test (push) Successful in 2m21s
Two ui-test jobs cannot coexist on the same host: Playwright's
`webServer` spec spawns `pnpm dev` on :5173, and on a host-mode
runner the port lives in the host namespace shared by every job.
ui-test #67 hit "Error: http://localhost:5173 is already used"
because a parallel job's Vite still held the port.

Two changes:

1. `concurrency: ui-test-${{ gitea.ref }}` with `cancel-in-progress:
   true`. New push/PR runs against the same ref kill any earlier
   ui-test before starting, so we never have two `pnpm dev`s alive
   at once.
2. `pkill -f 'vite dev' || true` plus `fuser -k 5173/tcp` right
   before Playwright. Defence in depth in case the concurrency
   cancellation does not reap the spawned shell promptly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:42:08 +02:00
Ilia Denisov 7ff81de2b6 ui/frontend: cap Playwright at 4 workers, retry 4 times
Tests · UI / test (pull_request) Failing after 26s
Tests · UI / test (push) Successful in 2m21s
Under host-mode runner the default 6 workers + 1 retry consistently
land on ~7 flakies and an occasional hard fail per ui-test run
(ui-test #59 most recently). Workers share CPU and the host Docker
daemon with gitea, the long-lived dev stack, and the user's host
Caddy; the extra wall time from contention pushes individual
expectations past their timeouts.

Lower the worker cap to 4 to keep parallelism but give each worker
real CPU headroom, and raise retries to 4 so the rare slow page is
absorbed without surfacing as failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:39:22 +02:00
developer 9d65bf5157 Merge pull request #7: flaky RandomSuffix + CORS allow-list
Deploy · Dev / deploy (push) Successful in 34s
Tests · Go / test (push) Successful in 1m40s
Tests · Integration / integration (push) Successful in 1m40s
Fixes the birthday-collision flake in TestRandomSuffixGenerator and adds an env-driven CORS allow-list on the public gateway so the dev UI on https://www.galaxy.lan can reach https://api.galaxy.lan.
2026-05-15 06:35:58 +00:00
Ilia Denisov 1855e43699 gateway: add CORS allow-list for the public REST surface
Tests · Go / test (push) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 1m45s
Tests · Integration / integration (pull_request) Successful in 1m36s
Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list
on the public REST server so the dev UI on https://www.galaxy.lan can
call https://api.galaxy.lan without the browser blocking the
cross-origin response. Defaults to empty (no CORS) so the production
posture stays closed.

The middleware mounts before route classification and anti-abuse, so
OPTIONS preflights never charge against per-class rate-limit buckets.

`tools/dev-deploy/docker-compose.yml` opts the dev gateway into a
single allowed origin (`https://www.galaxy.lan`); local-dev keeps the
defaults because Vite proxies through the same origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:58:14 +02:00
Ilia Denisov 7bce67462c pkg/util: harden TestRandomSuffixGenerator against birthday collisions
The previous test asserted that no two adjacent samples from a
~10 000-element space were equal across 100 iterations. The birthday
math gives that adjacency check a ~1 % flake rate per run; with the
new gitea.lan CI volume that turned into observable random failures
(go-unit #51 on feature/enable-actions-cache hit "Should not be:
'6635'").

Replace adjacency with a distinctness floor over a wider 200-sample
draw. A stuck generator (single value) lands at 1 unique; a
256-element range lands at ~196; the natural full-range generator
lands at ~198. A floor of 150 catches the failure modes the test was
actually written to guard against and never trips on legitimate
randomness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:58:02 +02:00
developer 2be7e5c110 Merge pull request #6: re-enable actions cache
Deploy · Dev / deploy (push) Successful in 27s
Tests · Go / test (push) Successful in 1m43s
Tests · Integration / integration (push) Successful in 1m42s
Tests · UI / test (push) Failing after 2m10s
Cache service answers on 10.200.0.1:43513 after the nftables fix. setup-go/setup-node opt back into cache: true / cache: pnpm. Cache hit verified in run #55 (ui-test on PR head).
2026-05-15 05:46:57 +00:00
Ilia Denisov 2a95bf4a50 ci: re-enable actions cache now that the runner serves it
Tests · UI / test (push) Successful in 2m20s
Tests · Go / test (push) Failing after 2m21s
Tests · Go / test (pull_request) Successful in 1m40s
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · UI / test (pull_request) Successful in 2m2s
The Gitea Actions cache service now answers on 10.200.0.1:43513
(post nftables fix on the runner side). Turn `cache: true` and
`cache: pnpm` back on so setup-go/setup-node can use it for
cross-job tarball caching on top of the host-persistent caches we
already rely on.

The setup-* actions still tolerate the cache being unavailable, so
this is reversible to `cache: false` if the service goes away again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:39 +02:00
developer fd071260ec Merge pull request #5: drop cache: setting in setup-go / setup-node
Deploy · Dev / deploy (push) Successful in 31s
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (push) Successful in 1m42s
Tests · UI / test (push) Successful in 2m11s
Avoids the zombie reserveCache retries against the unreachable Gitea Actions cache service on :43513. Host-mode runner keeps caches warm in $HOME without needing actions/cache plumbing.
2026-05-14 04:47:56 +00:00
Ilia Denisov 8058f26397 ci: drop cache: setting in setup-go/setup-node
Tests · Go / test (push) Successful in 2m21s
Tests · UI / test (push) Successful in 2m22s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · Integration / integration (pull_request) Successful in 1m37s
Tests · UI / test (pull_request) Successful in 2m7s
`cache: true` (setup-go) and `cache: pnpm` (setup-node) make the
actions push and pull tarballs through the Gitea Actions cache
service at 192.168.0.222:43513. That endpoint currently does not
answer, so every workflow burns minutes per run on reserveCache
retries before the action gives up.

In host-mode the real caches live under the runner user's $HOME
(~/go/pkg/mod, ~/.cache/go-build, ~/.local/share/pnpm,
~/.cache/ms-playwright) and persist between jobs without any
actions/cache plumbing. Switching cache: off avoids the zombie
retries and uses the local disk caches the runner already has warm.

Reviving the cache service is a separate TODO. Until then this is
the simpler and faster baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:39:22 +02:00
developer 660044559c Merge pull request #4: cleanup after host-mode runner
Deploy · Dev / deploy (push) Successful in 28s
Tests · Go / test (push) Successful in 1m41s
Tests · Integration / integration (push) Successful in 1m45s
Tests · UI / test (push) Successful in 2m14s
Drops the docker-in-docker workarounds (GIT_SSL_NO_VERIFY env, GeoIP image bake, playwright --with-deps) now that act_runner executes jobs natively on the host.
2026-05-14 04:31:27 +00:00
Ilia Denisov 9135991887 ci/ui-test: drop --with-deps now that runner is host-mode
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (push) Failing after 2m32s
Tests · Integration / integration (pull_request) Successful in 1m52s
Tests · UI / test (pull_request) Successful in 2m3s
`playwright install --with-deps` shells out to `sudo apt-get install`
for the system libraries that headless browsers need. In a job
container that runs as root this is silent; on a host-mode runner the
non-interactive sudo prompts for a password, fails three times, and
the step exits 1.

Drop --with-deps. The system .so libraries are installed once on the
host via `pnpm exec playwright install-deps` (or the equivalent
apt-get incantation); workflow runs only need to fetch the browser
binaries themselves, which lives under the runner user's home and
needs no privilege.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:59:45 +02:00
Ilia Denisov bb74e3336e dev-deploy: restore GeoIP bind-mount, drop image bake
Tests · Integration / integration (pull_request) Successful in 2m14s
Tests · Go / test (pull_request) Successful in 2m19s
Tests · UI / test (pull_request) Failing after 51m17s
With the runner in host-mode, compose bind-mount paths resolve to
real host paths the Docker daemon can see, so the GeoIP file no
longer needs to be baked into the backend image to survive CI. Bring
back the bind-mount of `pkg/geoip/test-data/.../mmdb`, matching how
local-dev sources it. Image now only carries the backend binary,
symmetric with the production `backend/Dockerfile`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:04:11 +02:00
Ilia Denisov 4a88b24f4b ci: drop GIT_SSL_NO_VERIFY now that runner is host-mode
The act_runner now executes jobs natively on the host (no per-job
container), so actions/checkout uses the host's system CA store,
which already trusts the host-Caddy root CA. The workaround that
disabled TLS verification for `git fetch` is no longer needed and
just hides legitimate cert issues if they ever appear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:04:11 +02:00
developer fe8ad6a02a Merge pull request 'dev-deploy: fix backend startup in CI' (#3) from feature/dev-deploy-followups into development
Tests · Integration / integration (push) Successful in 2m16s
Tests · Go / test (push) Successful in 2m39s
Tests · UI / test (push) Successful in 12m29s
Deploy · Dev / deploy (push) Successful in 44s
Reviewed-on: https://gitea.dev/developer/galaxy-game/pulls/3
2026-05-13 22:42:03 +00:00
Ilia Denisov 9ebb2e7f0f ci: rename workflows for Gitea UI readability
Tests · Go / test (push) Successful in 2m31s
Tests · Integration / integration (pull_request) Successful in 2m23s
Tests · Go / test (pull_request) Successful in 2m50s
Tests · UI / test (push) Successful in 13m2s
Tests · UI / test (pull_request) Successful in 13m22s
Switches the `name:` field on every workflow to the bulleted style:

  Tests · Go            (go-unit.yaml)
  Tests · UI            (ui-test.yaml)
  Tests · Integration   (integration.yaml)
  Deploy · Dev          (dev-deploy.yaml)
  Build · Prod          (prod-build.yaml)
  Deploy · Prod         (deploy-prod.yaml)

File names stay the same so existing path filters and any URL
references continue to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:22:53 +02:00
Ilia Denisov 0da360a644 dev-deploy: fix backend startup in CI
Two bugs surfaced on the first real merge into development:

1. `${{ env.HOME }}` evaluates to empty string at the workflow stage,
   so GALAXY_DEV_GAME_STATE_DIR became `/.galaxy-dev/game-state`.
   Resolve in the shell instead of YAML.

2. The compose bind-mount of GeoIP2-Country-Test.mmdb referenced a
   path inside the runner's workspace volume, which the host Docker
   daemon cannot see — it created an empty directory and the backend
   crashed with "geoip database: is a directory" in a restart loop.
   Bake the file into the backend image so dev-deploy no longer needs
   a bind-mount; local-dev compose still mounts it on top for swap-in
   during development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:22:16 +02:00
developer 6686059535 Merge pull request 'tools/dev-deploy: long-lived dev environment behind host Caddy' (#2) from feature/ci-reorg-and-dev-deploy into development
go-unit / test (push) Successful in 2m32s
integration / integration (push) Successful in 2m3s
dev-deploy / deploy (push) Failing after 5m7s
ui-test / test (push) Has been cancelled
Reviewed-on: https://gitea.dev/developer/galaxy-game/pulls/2
2026-05-13 22:10:24 +00:00
Ilia Denisov c6c5f3c8dd ci: skip TLS verify for actions/checkout on LAN Gitea
go-unit / test (push) Successful in 2m28s
go-unit / test (pull_request) Successful in 2m30s
integration / integration (pull_request) Successful in 2m20s
ui-test / test (push) Successful in 13m5s
ui-test / test (pull_request) Successful in 14m31s
The Gitea host serves https://gitea.iliadenisov.ru with a cert signed
by host-Caddy's internal CA, which the runner-image's CA bundle does
not trust. actions/checkout@v4 fails on `git fetch` as a result, so
every workflow on gitea.lan has been failing — visible only now that
we made gitea.lan the primary CI target.

Sets GIT_SSL_NO_VERIFY=true on every workflow as a quick fix. Safe in
practice because both endpoints sit on the same LAN. The long-term
fix is to bake the Caddy root CA into the runner image and drop this
env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:43:51 +02:00
Ilia Denisov f00c8efd18 docs: sync project guides to the new CI flow
go-unit / test (pull_request) Failing after 30s
integration / integration (pull_request) Failing after 34s
ui-test / test (pull_request) Failing after 37s
Aligns the project guides with the branching/CI/environment changes
landed in the previous commits:

- CLAUDE.md: per-stage CI gate now closes against gitea.lan; describes
  the main/development/feature/* flow and the workflow surface
- docs/ARCHITECTURE.md: new section 18 "CI and Environments" covering
  branches, workflows, and the local-dev / dev-deploy / local-ci
  triad; section numbering shifted accordingly
- tools/local-ci/README.md: marked as fallback (offline / runner
  isolation only)
- tools/local-dev/README.md and ui/README.md: cross-link to
  tools/dev-deploy/ for production-shaped testing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:26:57 +02:00
Ilia Denisov f316952c12 ci: split workflows for linear development flow
Reshapes .gitea/workflows/ around the new main ← development ←
feature/* branching model:

- go-unit.yaml — Go unit tests, runs on push/PR matching Go paths
- ui-test.yaml — narrowed to Vitest + Playwright only (Go tests now
  live in go-unit.yaml)
- integration.yaml — testcontainers suite, fires on PR to
  development/main and on push to development
- dev-deploy.yaml — builds the stack and (re)deploys tools/dev-deploy/
  on every merge into development
- prod-build.yaml — builds prod images on push to main and uploads
  docker save bundles as artifacts (30-day retention)
- deploy-prod.yaml — workflow_dispatch placeholder for the future
  SSH-based rollout

ui-release.yaml is removed; its v* tag trigger is superseded by
prod-build.yaml plus the manual deploy-prod entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:26:46 +02:00
Ilia Denisov 00c79064fc tools/dev-deploy: long-lived dev environment behind host Caddy
A docker-compose stack that hosts postgres, redis, mailpit, backend,
gateway, and an app-routing Caddy. Reachable through the host Caddy at
https://www.galaxy.lan (static SPA) and https://api.galaxy.lan (REST +
gRPC). Coexists with tools/local-dev/ and tools/local-ci/ by giving
every name (compose project, container, network, volume) a distinct
galaxy-dev-* prefix.

State is persisted in named volumes; game-state lives under
${GALAXY_DEV_GAME_STATE_DIR:-$HOME/.galaxy-dev/game-state} so the
default works for a non-root runner without sudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:26:35 +02:00
521 changed files with 38888 additions and 24570 deletions
+33
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
name: Deploy · Prod
# Placeholder for the production rollout workflow. Today it only proves
# the manual entry point works; the actual `docker save | ssh prod
# docker load` + remote `docker compose up -d` pipeline is wired in
# once the production host, SSH credentials, and DNS are decided.
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to deploy (commit-<sha12>, produced by prod-build.yaml)"
required: true
type: string
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Announce target
run: |
echo "Would deploy image tag: ${{ inputs.image_tag }}"
echo "TODO:"
echo " 1. Download galaxy-images-${{ inputs.image_tag }} from prod-build artifacts."
echo " 2. scp the .tar.gz bundles to the production host."
echo " 3. ssh prod 'docker load -i ...' for backend / gateway / engine."
echo " 4. ssh prod 'docker compose -f /opt/galaxy/docker-compose.yml up -d'."
echo " 5. Probe https://<public host>/healthz and roll back on failure."
+194
View File
@@ -0,0 +1,194 @@
name: Deploy · Dev
# Builds the Galaxy stack and (re)deploys it into the long-lived dev
# environment on the host running this Gitea Actions runner. Triggered
# on every merge into `development`. Branch protections on `development`
# guarantee the commit already passed `go-unit`, `ui-test`, and
# `integration` as part of the PR that produced this push, so this
# workflow does not re-run those tests — it focuses on packaging and
# rollout.
#
# `workflow_dispatch` is also accepted so a developer can deploy any
# branch (typically a feature branch under active review) into the
# shared dev environment from the Gitea Actions UI without waiting for
# the PR to merge first. The deploy job picks up whatever the chosen
# ref is — same packaging + healthcheck steps as the merge path.
on:
push:
branches:
- development
paths:
- 'backend/**'
- 'gateway/**'
- 'game/**'
- 'pkg/**'
- 'ui/**'
- 'site/**'
- 'go.work'
- 'go.work.sum'
- 'tools/dev-deploy/**'
- '.gitea/workflows/dev-deploy.yaml'
- '!**/*.md'
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.7
# Install pnpm into a per-job directory so concurrent jobs on
# the shared host runner do not race on the default
# `~/setup-pnpm` (the self-installer otherwise fails with
# `ENOTEMPTY` while cleaning a sibling job's install).
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install UI dependencies
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build core.wasm
uses: ./.gitea/actions/build-wasm
- name: Build UI frontend
working-directory: ui/frontend
env:
# Single-origin deployment: an empty base URL means the
# gateway shares the document origin (REST at /api, Connect at
# /rpc). The game UI is served under the /game/ base path.
VITE_GATEWAY_BASE_URL: ""
BASE_PATH: /game
# Surface the synthetic-report loader and similar dev-only
# affordances in the long-lived dev bundle. The prod build
# path (`prod-build.yaml`) leaves this flag unset so the
# production bundle keeps the same affordances stripped.
VITE_GALAXY_DEV_AFFORDANCES: "true"
run: |
# The response-signing public key is committed in
# `.env.development` alongside its private counterpart in
# `tools/local-dev/keys/`. Pull it from there at build time so
# the production-mode bundle ships the same key the dev
# gateway uses to sign.
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
pnpm build
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
- name: Build galaxy-engine image
working-directory: ${{ gitea.workspace }}
run: |
docker build \
-t galaxy-engine:dev \
-f game/Dockerfile \
.
- name: Build backend + gateway images
working-directory: tools/dev-deploy
run: |
docker compose build galaxy-backend galaxy-api
- name: Seed UI volume
run: |
docker volume create galaxy-dev-ui-dist >/dev/null
docker run --rm \
-v galaxy-dev-ui-dist:/dst \
-v "${{ gitea.workspace }}/ui/frontend/build:/src:ro" \
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
- name: Seed site volume
run: |
docker volume create galaxy-dev-site-dist >/dev/null
docker run --rm \
-v galaxy-dev-site-dist:/dst \
-v "${{ gitea.workspace }}/site/.vitepress/dist:/src:ro" \
alpine sh -c 'rm -rf /dst/* /dst/.??* 2>/dev/null; cp -a /src/. /dst/'
- name: Seed geoip volume
run: |
# Copy the GeoIP test fixture into a named volume so the
# backend can mount it as /var/lib/galaxy. A bind-mount with
# a relative path would resolve against this runner's
# ephemeral workspace under /home/runner/.cache/act/<hash>/,
# which the runner deletes once the workflow ends — the next
# `docker restart galaxy-dev-backend` would then fail with
# "not a directory" because the mount source vanished.
docker volume create galaxy-dev-geoip-data >/dev/null
docker run --rm \
-v galaxy-dev-geoip-data:/dst \
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
- name: Reap stray dev-deploy containers
run: |
# Remove any non-running compose-managed containers from
# earlier deploys before `compose up`. Filter by the stack
# label so we never touch unrelated workloads on the same
# daemon. Running containers (incl. engine instances backend
# spawned itself with the same label) are left intact —
# those are reattached by the backend reconciler on boot.
ids=$(docker ps -aq \
--filter "label=galaxy.stack=dev-deploy" \
--filter "status=exited" \
--filter "status=created" \
--filter "status=dead")
if [ -n "$ids" ]; then
echo "reaping: $ids"
docker rm -f $ids
fi
- name: Bring up the stack
working-directory: tools/dev-deploy
run: |
# Resolve in the shell, not in YAML expressions — `env.HOME`
# is empty at the workflow-evaluation stage.
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
docker compose up -d --wait --remove-orphans
- name: Probe the stack
run: |
set -e
# Use --resolve so the probe goes through the same routing as
# a browser on the host: the host Caddy on :443 (which has
# `tls internal`) terminates and forwards into the edge
# network. We accept the host's internal CA via -k because
# the runner image has no reason to trust it.
curl -sk --max-time 10 https://galaxy.lan/healthz \
| tee /tmp/healthz
test -s /tmp/healthz
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://galaxy.lan/ | tee /tmp/site_status
grep -qE '^(200|304)$' /tmp/site_status
curl -sk --max-time 10 -o /dev/null -w '%{http_code}\n' \
https://galaxy.lan/game/ | tee /tmp/game_status
grep -qE '^(200|304)$' /tmp/game_status
+78
View File
@@ -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/...
+65
View File
@@ -0,0 +1,65 @@
name: Tests · Integration
# Full integration suite (testcontainers-driven, ~510 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
+139
View File
@@ -0,0 +1,139 @@
name: Build · Prod
# Builds the production-grade Docker images and the UI bundle on every
# merge into `main`, then saves the artifacts so a future
# `deploy-prod.yaml` run can ship them to the production host. This
# workflow does not deploy anything by itself — production rollout is
# strictly manual (workflow_dispatch on `deploy-prod.yaml`).
on:
push:
branches:
- main
paths:
- 'backend/**'
- 'gateway/**'
- 'game/**'
- 'pkg/**'
- 'ui/**'
- 'site/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/prod-build.yaml'
- '!**/*.md'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.7
# Install pnpm into a per-job directory so concurrent jobs on
# the shared host runner do not race on the default
# `~/setup-pnpm` (the self-installer otherwise fails with
# `ENOTEMPTY` while cleaning a sibling job's install).
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- name: Resolve image tag
id: tag
run: |
short_sha=$(git rev-parse --short=12 HEAD)
echo "tag=commit-${short_sha}" >>"$GITHUB_OUTPUT"
- name: Build backend image
run: |
docker build \
-t "galaxy/backend:${{ steps.tag.outputs.tag }}" \
-f backend/Dockerfile \
.
- name: Build gateway image
run: |
docker build \
-t "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
-f gateway/Dockerfile \
.
- name: Build engine image
run: |
docker build \
-t "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
-f game/Dockerfile \
.
- name: Install UI dependencies
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build core.wasm
uses: ./.gitea/actions/build-wasm
- name: Build UI bundle
working-directory: ui/frontend
env:
# Single-origin deployment: an empty base URL means the
# gateway shares the document origin (REST at /api, Connect at
# /rpc). The game UI is served under the /game/ base path.
VITE_GATEWAY_BASE_URL: ""
BASE_PATH: /game
run: |
# Production response-signing public key is not in the repo
# yet (the dev key in `tools/local-dev/keys/` is for dev
# only). When real prod keys exist, source them from a Gitea
# Actions secret and set VITE_GATEWAY_RESPONSE_PUBLIC_KEY
# here. Until then the prod bundle compiles with the dev
# key as a placeholder so the artifact exists.
export VITE_GATEWAY_RESPONSE_PUBLIC_KEY="$(grep -E '^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=' .env.development | cut -d= -f2)"
pnpm build
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
- name: Save images as artifact bundles
run: |
mkdir -p artifacts
docker save "galaxy/backend:${{ steps.tag.outputs.tag }}" \
| gzip >"artifacts/backend-${{ steps.tag.outputs.tag }}.tar.gz"
docker save "galaxy/gateway:${{ steps.tag.outputs.tag }}" \
| gzip >"artifacts/gateway-${{ steps.tag.outputs.tag }}.tar.gz"
docker save "galaxy/game-engine:${{ steps.tag.outputs.tag }}" \
| gzip >"artifacts/game-engine-${{ steps.tag.outputs.tag }}.tar.gz"
tar -C ui/frontend -czf \
"artifacts/ui-dist-${{ steps.tag.outputs.tag }}.tar.gz" build
tar -C site/.vitepress -czf \
"artifacts/site-dist-${{ steps.tag.outputs.tag }}.tar.gz" dist
- name: Upload images
uses: actions/upload-artifact@v4
with:
name: galaxy-images-${{ steps.tag.outputs.tag }}
path: artifacts/*.tar.gz
retention-days: 30
+47
View File
@@ -0,0 +1,47 @@
name: Build · Site
# Builds the VitePress project site so a broken site change fails its PR.
# The dev-deploy / prod-build workflows build and ship the site
# separately; this is the fast PR gate. No `!**/*.md` exclusion — the
# site is Markdown, so content changes must be exercised too.
on:
push:
paths:
- 'site/**'
- '.gitea/workflows/site-build.yaml'
pull_request:
paths:
- 'site/**'
- '.gitea/workflows/site-build.yaml'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.7
dest: ${{ runner.temp }}/setup-pnpm
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml
- name: Install site dependencies
working-directory: site
run: pnpm install --frozen-lockfile
- name: Build project site
working-directory: site
run: pnpm build
-148
View File
@@ -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
+53 -60
View File
@@ -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
+61 -31
View File
@@ -34,32 +34,58 @@ This repository hosts the Galaxy Game project.
deeper than what fits in `README.md` (per-feature design notes,
protocol specs, runbooks). Not stage-by-stage history.
## Branching and CI flow
Branches:
- `main` — production-track. Direct pushes are disallowed; the only
way in is a PR merge from `development`. A merge fires
`prod-build.yaml` which packages the artifacts; production rollout
is manual through `deploy-prod.yaml`.
- `development` — long-lived dev integration branch. Every merge into
it auto-deploys to the dev environment via `dev-deploy.yaml`
(single origin `https://galaxy.lan`: site at `/`, game at `/game/`,
gateway REST at `/api`).
- `feature/*` — short-lived branches off `development`. Merged back
via PR; only then do they reach the dev environment automatically.
Workflows in `.gitea/workflows/`:
| File | Trigger | What it does |
|------|---------|--------------|
| `go-unit.yaml` | push + PR matching Go paths | Fast Go unit tests. |
| `ui-test.yaml` | push + PR matching `ui/**` | Vitest + Playwright. |
| `integration.yaml` | PR to `development`/`main`; push to `development` | testcontainers integration suite. |
| `dev-deploy.yaml` | push to `development`; `workflow_dispatch` on any ref | Build images + (re)deploy to `tools/dev-deploy/`. |
| `prod-build.yaml` | push to `main` | Build prod images and `docker save` into artifacts. |
| `deploy-prod.yaml` | `workflow_dispatch` | Manual rollout (placeholder until prod host exists). |
### Deployment cadence
The long-lived dev environment (`tools/dev-deploy/`) is single-tenant:
one live deployment, redeployed on every merge into `development`.
While a PR is open the dev environment stays on whatever was last
merged — pushes to `feature/*` only fire the test workflows
(`go-unit`, `ui-test`, `integration`), not `dev-deploy.yaml`.
To preview an unmerged feature branch on the shared dev environment,
trigger `dev-deploy.yaml` manually from the Gitea UI
(**Actions → Deploy · Dev → Run workflow**) and pick the feature ref.
The deploy is idempotent: the next merge into `development` simply
overwrites whatever the manual dispatch left behind.
## Per-stage CI gate
Every completed stage from any `PLAN.md` (per-service or `ui/PLAN.md`)
must be exercised on the local Gitea Actions runner before being
declared done. The runbook lives in `tools/local-ci/README.md`; the
short version is:
must be exercised on `gitea.lan` before being declared done. The
short version:
1. Commit the stage changes.
2. `make -C tools/local-ci push` pushes `HEAD` to the local Gitea
instance and triggers every workflow that matches the changed
paths.
3. Poll the latest run via the API snippet in `ui/docs/testing.md`
(or the Gitea UI on `http://localhost:3000`) until it leaves
1. Commit the stage changes on the feature branch.
2. `git push gitea …` to publish the branch.
3. Poll the latest run in the Gitea UI (or the API) until it leaves
`running`. Inspect the log on failure.
4. Only after the run is `success` may the stage be marked done in
the corresponding `PLAN.md`.
This applies even when the local unit-test suite is green —
workflow-only failures (path filters, action-version mismatches,
missing secrets, runner-only environment differences) are cheap to
catch here and expensive to catch on a remote PR. The push step is
implicitly authorised: do not ask for confirmation on every stage.
If `tools/local-ci` is not running, bring it up first
(`make -C tools/local-ci up`); do not skip this gate. The single
exception is when the user explicitly waives it for a stage.
4. Only after every workflow that fired is `success` may the stage be
marked done in the corresponding `PLAN.md`.
## Decisions during stage implementation
@@ -87,18 +113,22 @@ The existing codebase of `galaxy/<service>` may be modified or extended when a
plan stage requires it. All such changes must be covered by new or updated tests
and reflected in documentation when they affect documented behavior.
## Pre-production migration rule
## Migrations
The platform is not yet in production. Schema changes for `backend` go
into the existing `backend/internal/postgres/migrations/00001_init.sql`
file rather than into new `00002_*`-prefixed files. Local databases and
integration test harnesses are recreated from scratch on every pull.
Schema changes for `backend` go into a new `0000N_*.sql` file under
`backend/internal/postgres/migrations/` with a monotonically increasing
prefix. `00001_init.sql` is the historical baseline and stays
immutable; every subsequent change is its own additive migration with
matching Up/Down sides. `pressly/goose/v3` (embedded into the backend
binary) applies pending migrations on startup, so the long-lived dev
environment picks up schema deltas without a manual reset.
**This rule is removed before the first production deployment.** From
that point on every schema change becomes a new migration file with a
monotonically increasing prefix, and `00001_init.sql` becomes immutable
history. See `backend/internal/postgres/migrations/README.md` for
details.
Before the first production deployment the migration chain may be
squashed back into a single fresh `00001_init.sql` for a clean slate;
plan that work as an explicit task when it lands. See
`backend/internal/postgres/migrations/README.md` for the local
authoring conventions (file naming, transactional vs. non-transactional
sections, backward-compatible deletes, rollback expectations).
## Documentation discipline
+12 -4
View File
@@ -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
View File
@@ -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
}
+164
View File
@@ -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.
+5 -4
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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=
+46
View File
@@ -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)
+19 -10
View File
@@ -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
+100
View File
@@ -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)
+217
View File
@@ -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`.
+634
View File
@@ -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)
}
+181
View File
@@ -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)
}
}
}
+135
View File
@@ -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
+32
View File
@@ -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")
)
+441
View File
@@ -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
}
+822
View File
@@ -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
}
+267
View File
@@ -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
}
+209
View File
@@ -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
}
+18
View File
@@ -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) {
+54
View File
@@ -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
}
+70
View File
@@ -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.).
+4
View File
@@ -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 {
+36
View File
@@ -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.
+5
View File
@@ -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)
+19 -2
View File
@@ -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
-- =====================================================================
+40 -20
View File
@@ -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",
}
+26 -8
View File
@@ -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)
}
}
})
}
}
+30
View File
@@ -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"`
}
+25
View File
@@ -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())
+867
View File
@@ -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.
-10
View File
@@ -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.
-10
View File
@@ -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"
)
-116
View File
@@ -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)
}
}
-39
View File
@@ -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)
})
}
}
-101
View File
@@ -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()
}
-289
View File
@@ -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)
}
}
-227
View File
@@ -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)
}
-53
View File
@@ -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)
}
-51
View File
@@ -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()
}
-133
View File
@@ -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
}
-6
View File
@@ -1,6 +0,0 @@
package client
import "embed"
//go:embed resource/lang
var Translations embed.FS
-46
View File
@@ -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
)
-82
View File
@@ -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=
-61
View File
@@ -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, &params, 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()
}
-214
View File
@@ -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()
}
-211
View File
@@ -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
}
-181
View File
@@ -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)
}
-34
View File
@@ -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
}
-14
View File
@@ -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
}
-56
View File
@@ -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))
}
-48
View File
@@ -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)
}
}
-76
View File
@@ -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 &registry{
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) {
}
-119
View File
@@ -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))
}
}
-30
View File
@@ -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
View File
@@ -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)
}
-110
View File
@@ -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()
}
-201
View File
@@ -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)
}
-367
View File
@@ -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
}
-60
View File
@@ -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))
}
-42
View File
@@ -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() {
}
-629
View File
@@ -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
}
-16
View File
@@ -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()
}
-217
View File
@@ -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)
}
-123
View File
@@ -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
}
}
-165
View File
@@ -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.
-642
View File
@@ -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)
}
-661
View File
@@ -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)
}
-225
View File
@@ -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")
}
}
-336
View File
@@ -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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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
-411
View File
@@ -1,411 +0,0 @@
package world
import (
"github.com/fogleman/gg"
"github.com/stretchr/testify/require"
"image"
"image/color"
"testing"
)
type benchBgTheme struct {
img image.Image
anchor BackgroundAnchorMode
tileMode BackgroundTileMode
scaleMode BackgroundScaleMode
}
func (t benchBgTheme) ID() string { return "benchbg" }
func (t benchBgTheme) Name() string { return "benchbg" }
func (t benchBgTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} }
func (t benchBgTheme) BackgroundImage() image.Image { return t.img }
func (t benchBgTheme) BackgroundTileMode() BackgroundTileMode { return t.tileMode }
func (t benchBgTheme) BackgroundScaleMode() BackgroundScaleMode { return t.scaleMode }
func (t benchBgTheme) BackgroundAnchorMode() BackgroundAnchorMode { return t.anchor }
func (t benchBgTheme) PointStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, PointRadiusPx: 2}
}
func (t benchBgTheme) LineStyle() Style {
return Style{StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) CircleStyle() Style {
return Style{FillColor: color.RGBA{A: 255}, StrokeColor: color.RGBA{A: 255}, StrokeWidthPx: 1}
}
func (t benchBgTheme) PointClassOverride(PointClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) LineClassOverride(LineClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
func (t benchBgTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) {
return StyleOverride{}, false
}
// BenchmarkRender_IncrementalPan_NoBackground benchmarks render Incremental Pan No Background.
func BenchmarkRender_IncrementalPan_NoBackground(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
// Some primitives to keep it realistic but not dominant.
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
// Initial render (commit state).
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat World Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleNone)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit benchmarks render Incremental Pan Background Repeat World Anchor Scale Fit.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_WorldAnchor_ScaleFit(b *testing.B) {
benchRenderBg(b, BackgroundAnchorWorld, BackgroundTileRepeat, BackgroundScaleFit)
}
// BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone benchmarks render Incremental Pan Background Repeat Viewport Anchor Scale None.
func BenchmarkRender_IncrementalPan_BackgroundRepeat_ViewportAnchor_ScaleNone(b *testing.B) {
benchRenderBg(b, BackgroundAnchorViewport, BackgroundTileRepeat, BackgroundScaleNone)
}
func benchRenderBg(b *testing.B, anchor BackgroundAnchorMode, tile BackgroundTileMode, scale BackgroundScaleMode) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1200, 800, 1.0)
for i := 0; i < 200; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
w.Reindex()
// Background tile (RGBA) — typical texture size.
bg := image.NewRGBA(image.Rect(0, 0, 96, 96))
// Make it semi-transparent so draw.Over has real work.
for y := 0; y < 96; y++ {
for x := 0; x < 96; x++ {
bg.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 18})
}
}
w.SetTheme(benchBgTheme{img: bg, anchor: anchor, tileMode: tile, scaleMode: scale})
dc := gg.NewContext(1200, 800)
drawer := &GGDrawer{DC: dc}
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{
AllowShiftOnly: false,
CoalesceUpdates: false,
MaxCatchUpAreaPx: 0,
RenderBudgetMs: 0,
},
},
}
_ = w.Render(drawer, params)
b.ResetTimer()
for i := 0; i < b.N; i++ {
params.CameraXWorldFp += 1 * SCALE
_ = w.Render(drawer, params)
}
}
// BenchmarkDrawPlanSinglePass_Lines_GG benchmarks draw Plan Single Pass Lines GG.
func BenchmarkDrawPlanSinglePass_Lines_GG(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
// Make a lot of lines, including ones that likely wrap.
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600) // shift to create various deltas
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkDrawPlanSinglePass_Lines_Fake benchmarks draw Plan Single Pass Lines Fake.
func BenchmarkDrawPlanSinglePass_Lines_Fake(b *testing.B) {
w := NewWorld(600, 600)
w.IndexOnViewportChange(1000, 700, 1.0)
for i := 0; i < 4000; i++ {
x1 := float64(i % 600)
y1 := float64((i * 7) % 600)
x2 := float64((i*13 + 500) % 600)
y2 := float64((i*17 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
drawer := &fakePrimitiveDrawer{}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Reset command log so it doesn't grow forever and dominate allocations.
drawer.Reset()
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips verifies render Incremental Shift Uses Outer Clip Not Per Tile Clips.
func TestRender_IncrementalShift_UsesOuterClip_NotPerTileClips(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
w.resetGrid(2 * SCALE)
_, _ = w.AddPoint(5, 5)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
Incremental: &IncrementalPolicy{AllowShiftOnly: false},
},
}
// First render initializes state.
d1 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d1, params))
// Small pan.
params2 := params
params2.CameraXWorldFp += 1 * SCALE
d2 := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d2, params2))
// Expect very few ClipRect calls (dirty strips count), not per tile.
clipCmds := d2.CommandsByName("ClipRect")
require.NotEmpty(t, clipCmds)
require.LessOrEqual(t, len(clipCmds), 4)
}
// TestRender_BatchesConsecutiveLinesByStyleID verifies render Batches Consecutive Lines By Style ID.
func TestRender_BatchesConsecutiveLinesByStyleID(t *testing.T) {
t.Parallel()
w := NewWorld(10, 10)
w.IndexOnViewportChange(100, 80, 1.0)
// Two lines with default style, same priority.
_, _ = w.AddLine(1, 1, 8, 1)
_, _ = w.AddLine(1, 2, 8, 2)
w.Reindex()
params := RenderParams{
ViewportWidthPx: 100,
ViewportHeightPx: 80,
MarginXPx: 25,
MarginYPx: 20,
CameraXWorldFp: 5 * SCALE,
CameraYWorldFp: 5 * SCALE,
CameraZoom: 1.0,
}
d := &fakePrimitiveDrawer{}
require.NoError(t, w.Render(d, params))
// We expect at least two AddLine, but only 1 Stroke for that run in a tile.
adds := d.CommandsByName("AddLine")
strokes := d.CommandsByName("Stroke")
require.GreaterOrEqual(t, len(adds), 2)
require.GreaterOrEqual(t, len(strokes), 1)
// Stronger: within any consecutive group of AddLine commands, count strokes <= 1.
// (Keep it loose to avoid depending on tile partitioning.)
}
// BenchmarkDrawPlanSinglePass_DrawItemsReuse benchmarks draw Plan Single Pass Draw Items Reuse.
func BenchmarkDrawPlanSinglePass_DrawItemsReuse(b *testing.B) {
w := NewWorld(600, 600)
// Make grid + index available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Add enough objects so tiles have candidates.
for i := range 2000 {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := range 500 {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 5.0)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
plan, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
dc := gg.NewContext(params.CanvasWidthPx(), params.CanvasHeightPx())
drawer := &GGDrawer{DC: dc}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// We don't clear here; we only measure the draw loop overhead.
w.drawPlanSinglePass(drawer, plan, true, drawPlanSinglePassClipEnabled, false)
}
}
// BenchmarkBuildRenderPlanStageA_Candidates benchmarks build Render Plan Stage A Candidates.
func BenchmarkBuildRenderPlanStageA_Candidates(b *testing.B) {
w := NewWorld(600, 600)
// Make the index/grid available.
w.IndexOnViewportChange(1000, 700, 1.0)
// Populate with enough objects to create duplicates across cells.
// Circles and lines create bbox indexing (more duplicates).
for i := 0; i < 2000; i++ {
_, _ = w.AddPoint(float64(i%600), float64((i*7)%600))
}
for i := 0; i < 1200; i++ {
_, _ = w.AddCircle(float64((i*11)%600), float64((i*13)%600), 8.0)
}
for i := 0; i < 1200; i++ {
x1 := float64((i*3 + 10) % 600)
y1 := float64((i*5 + 20) % 600)
x2 := float64((i*7 + 400) % 600)
y2 := float64((i*11 + 300) % 600)
_, _ = w.AddLine(x1, y1, x2, y2)
}
w.Reindex()
params := RenderParams{
ViewportWidthPx: 1000,
ViewportHeightPx: 700,
MarginXPx: 250,
MarginYPx: 175,
CameraXWorldFp: 300 * SCALE,
CameraYWorldFp: 300 * SCALE,
CameraZoom: 1.0,
Options: &RenderOptions{
BackgroundColor: color.RGBA{A: 255},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := w.buildRenderPlan(params)
if err != nil {
b.Fatalf("build plan: %v", err)
}
}
}

Some files were not shown because too many files have changed in this diff Show More