diff --git a/.env.example b/.env.example index 3b7dc7040..1d98d04cb 100644 --- a/.env.example +++ b/.env.example @@ -224,6 +224,11 @@ # Directory containing OTP hook plugins. # GAMEND_CONTENT_PLUGINS_DIR=modules/plugins +# Directories of static files served ahead of the built-in ones (images/, +# game/, favicon.ico, robots.txt, theme.css), relative to the working +# directory and searched in order. Each is used when it exists. +# GAMEND_CONTENT_STATIC_DIRS=static,priv/static + # Path to the theme JSON. A single file serves every locale; its text is # translated via the gettext `theme` domain. # GAMEND_CONTENT_THEME_CONFIG= diff --git a/.github/workflows/build-and-check.yml b/.github/workflows/build-and-check.yml index 90219958f..744048998 100644 --- a/.github/workflows/build-and-check.yml +++ b/.github/workflows/build-and-check.yml @@ -18,6 +18,9 @@ env: IMAGE: ghcr.io/${{ github.repository }} ELIXIR_VERSION: "1.20.1" OTP_VERSION: "29.0.2" + # Linked statically into the server binaries (rel/scripts/static-deps.sh). + OPENSSL_VERSION: "3.5.8" + LIBSRTP_VERSION: "2.7.0" jobs: build: @@ -1275,3 +1278,246 @@ jobs: # ExDoc's output does not contain one — publishing without this would # delete it on the next push and take docs.gamend.org down. cname: docs.gamend.org + + # ── Server binaries ───────────────────────────────────────────────────── + # Downloadable releases of the server: `gamend` for macOS (Apple silicon) + # and Linux (x86_64, arm64), SQLite and Postgres builds, plus the gamend.org + # website as a project folder. Every push to main republishes them on the + # rolling `server-latest` release, which rel/install.sh and the + # actions/setup-gamend action download from. The scripts are rel/scripts/; + # guide: priv/docs/10-setup/15-standalone.md. + binaries-linux: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + # Built in Ubuntu 22.04 rather than on the runner's own image, so the + # binaries need glibc 2.35, not whatever the runner has. + container: ubuntu:22.04 + strategy: + fail-fast: false + matrix: + include: + - { name: gamend-linux-x86_64, runner: ubuntu-24.04, adapter: sqlite } + - { name: gamend-linux-x86_64-postgres, runner: ubuntu-24.04, adapter: postgres } + - { name: gamend-linux-arm64, runner: ubuntu-24.04-arm, adapter: sqlite } + - { name: gamend-linux-arm64-postgres, runner: ubuntu-24.04-arm, adapter: postgres } + services: + # For the Postgres builds' smoke test; the SQLite ones leave it idle. + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + GAMEND_DB_ADAPTER: ${{ matrix.adapter }} + BUILD_ROOT: /opt/gamend-build + steps: + - name: System packages + env: + DEBIAN_FRONTEND: noninteractive + run: | + apt-get update + apt-get install -y --no-install-recommends \ + build-essential autoconf pkg-config perl curl ca-certificates git \ + libncurses-dev jq file binutils unzip xz-utils brotli \ + imagemagick optipng pngquant + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Version + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + echo "GAMEND_CONTENT_APP_VERSION=1.0.$(git rev-list --count HEAD)" >> "$GITHUB_ENV" + + - name: Cache static OpenSSL, libsrtp and OTP + uses: actions/cache@v6 + with: + path: | + ${{ env.BUILD_ROOT }}/deps + ${{ env.BUILD_ROOT }}/otp + key: otp-${{ matrix.runner }}-ubuntu22-${{ env.OTP_VERSION }}-${{ env.OPENSSL_VERSION }}-${{ env.LIBSRTP_VERSION }}-${{ hashFiles('rel/scripts/static-deps.sh', 'rel/scripts/build-otp.sh') }} + + - name: Build the release + run: rel/scripts/build.sh "$BUILD_ROOT" + + - name: Package + run: rel/scripts/package.sh "${{ matrix.name }}" dist + + - name: Smoke test + run: | + # Only the Postgres build gets a database URL: an empty one still + # counts as set, and the SQLite build warns about it at boot. + if [ "$GAMEND_DB_ADAPTER" = postgres ]; then + export GAMEND_DB_URL=ecto://postgres:postgres@postgres:5432/gamend_smoke + fi + rel/scripts/smoke.sh "dist/${{ matrix.name }}.tar.gz" + + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.name }} + path: dist/${{ matrix.name }}.tar.gz + if-no-files-found: error + + binaries-macos: + name: ${{ matrix.name }} + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - { name: gamend-macos-arm64, adapter: sqlite } + - { name: gamend-macos-arm64-postgres, adapter: postgres } + env: + GAMEND_DB_ADAPTER: ${{ matrix.adapter }} + BUILD_ROOT: ${{ github.workspace }}/../gamend-build + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Version + run: echo "GAMEND_CONTENT_APP_VERSION=1.0.$(git rev-list --count HEAD)" >> "$GITHUB_ENV" + + # `mix assets.deploy` cuts the theme's responsive images with ImageMagick + # and optimizes PNGs, as the Docker build does. + - name: Image tools + run: | + for tool in imagemagick optipng pngquant brotli; do + brew list "$tool" > /dev/null 2>&1 || brew install "$tool" + done + + - name: Cache static OpenSSL, libsrtp and OTP + uses: actions/cache@v6 + with: + path: | + ${{ env.BUILD_ROOT }}/deps + ${{ env.BUILD_ROOT }}/otp + key: otp-macos-15-${{ env.OTP_VERSION }}-${{ env.OPENSSL_VERSION }}-${{ env.LIBSRTP_VERSION }}-${{ hashFiles('rel/scripts/static-deps.sh', 'rel/scripts/build-otp.sh') }} + + - name: Build the release + run: rel/scripts/build.sh "$BUILD_ROOT" + + - name: Import the signing certificate + if: github.event_name != 'pull_request' + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + if [ -z "$BUILD_CERTIFICATE_BASE64" ]; then + echo "No signing certificate configured; the macOS build ships unsigned." + exit 0 + fi + cert="$RUNNER_TEMP/certificate.p12" + keychain="$RUNNER_TEMP/signing.keychain-db" + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$cert" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain" + security import "$cert" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$keychain" > /dev/null + security list-keychain -d user -s "$keychain" + identity=$(security find-identity -v -p codesigning "$keychain" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" + + - name: Package (signed and notarized on main) + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: rel/scripts/package.sh "${{ matrix.name }}" dist + + # The Postgres build needs a database macOS runners do not have; its + # linkage is the SQLite build's, checked by package.sh. + - name: Smoke test + if: matrix.adapter == 'sqlite' + run: rel/scripts/smoke.sh "dist/${{ matrix.name }}.tar.gz" + + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.name }} + path: dist/${{ matrix.name }}.tar.gz + if-no-files-found: error + + binaries-website: + name: gamend-website + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - run: rel/scripts/website.sh dist + - uses: actions/upload-artifact@v7 + with: + name: gamend-website + path: dist/gamend-website.tar.gz + if-no-files-found: error + + # The Linux archive is built on Ubuntu 22.04; prove it runs, with nothing + # installed beyond curl, on the distributions people actually deploy to. + binaries-distros: + name: runs on ${{ matrix.image }} + needs: binaries-linux + runs-on: ubuntu-24.04 + container: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + image: ["debian:12-slim", "ubuntu:24.04", "fedora:42"] + steps: + - name: curl and tar + run: | + if command -v apt-get > /dev/null; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + else + # Fedora ships curl-minimal; --allowerasing lets dnf keep or swap it. + dnf install -y --allowerasing curl tar gzip findutils + fi + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: gamend-linux-x86_64 + path: dist + - run: rel/scripts/smoke.sh dist/gamend-linux-x86_64.tar.gz + + publish-binaries: + name: Publish server-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [binaries-linux, binaries-macos, binaries-website, binaries-distros] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Checksums and installer + run: | + cp rel/install.sh dist/install.sh + (cd dist && sha256sum *.tar.gz > SHA256SUMS) + cat dist/SHA256SUMS + + - name: Move server-latest to this commit and upload + env: + GH_TOKEN: ${{ github.token }} + run: | + version="1.0.$(git rev-list --count HEAD)" + notes="Gamend server $version, built from ${GITHUB_SHA::12}. + + Install: \`curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/main/rel/install.sh | sh\` + Guide: https://gamend.org/docs/standalone" + + git tag -f server-latest "$GITHUB_SHA" + git push -f origin refs/tags/server-latest + + if gh release view server-latest > /dev/null 2>&1; then + gh release edit server-latest --prerelease --title "Gamend server (latest)" --notes "$notes" + else + gh release create server-latest --prerelease --title "Gamend server (latest)" --notes "$notes" --verify-tag + fi + gh release upload server-latest dist/* --clobber diff --git a/AGENTS.md b/AGENTS.md index fd91fb9ab..bb5e2a2ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ runnable host app at the repository root. - `mix setup` once, then `mix dev.start` (creates the DB, migrates, builds assets, runs `phx.server`). - The endpoint module is `GamendWeb.Endpoint`; the host OTP app starts it. - `GamendHost.Application` starts `GamendWeb.HostSupervision.children/1`. A core process goes in that list; a host-only one goes in its `:extra` option. +- The downloadable release ([guide](priv/docs/10-setup/15-standalone.md)): `rel/overlays/bin/gamend` runs the server and hands every other command to `GamendWeb.CLI`, under the name of the mix task it mirrors (`db.migrate`, `demo.seed`, …). A new `db.*`/seed mix task gets its `GamendWeb.CLI` twin, with the logic in a module both call (`Gamend.DemoSeed`, `Gamend.Release`). A release reads everything relative to the working directory, never `RELEASE_ROOT`. Starter templates live in `priv/starter/`; packaging is `rel/scripts/*` and the `binaries-*` jobs of `.github/workflows/build-and-check.yml`. ### Routing ownership / extension point @@ -224,7 +225,7 @@ Web-side features with no context: the site search palette (`GamendWeb.SearchInd ### Hooks -- Plugins implement `Gamend.Hooks`. They load from `modules/plugins/*` (`GAMEND_CONTENT_PLUGINS_DIR`) as bundled `ebin/`; run `mix plugin.bundle` after changing one. Examples live in `modules/plugins_examples/`. +- Plugins implement `Gamend.Hooks`. They load from `modules/plugins/*` (`GAMEND_CONTENT_PLUGINS_DIR`) as bundled `ebin/`; run `mix plugin.bundle` after changing one. A release, which has no Mix, builds them in-process with `Gamend.Hooks.PluginBuilder` (`build/1`, `build_all/0`). Examples live in `modules/plugins_examples/`. - `before_*` hooks are pipelines: return `{:ok, value}` to allow (optionally modified) or `{:error, reason}` to block. `after_*` hooks run asynchronously via `Gamend.Async.run/1`. - **Never** dispatch a hook or broadcast inside a transaction or lock. Open transactions with `Gamend.AfterCommit.transaction/2` and broadcast with `Gamend.Broadcast.publish/2`, which wait for the commit; run a `before_*` hook before taking the lock. See [CONTRIBUTING.md](CONTRIBUTING.md#hooks-so-plugins-can-extend-the-feature). - Adding a callback touches six places: [CONTRIBUTING.md](CONTRIBUTING.md#hooks-so-plugins-can-extend-the-feature). The full hook list is in the [server scripting guide](priv/docs/40-gameplay/90-server-scripting.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index da4438b22..cdf0cf26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # September 2026 +- [added] **Gamend as a download, run with a `gamend` command.** Every push to main publishes the server on the `server-latest` release for macOS on Apple silicon and Linux x86_64 and arm64, in SQLite and `-postgres` builds (`gamend--[-postgres].tar.gz`), with OpenSSL and libsrtp linked in so nothing else needs installing; `rel/install.sh` downloads the right one and links `gamend` into `~/.local/bin`. A project is the folder `gamend` runs in: its `.env`, theme, markdown, `static/`, plugins and SQLite database. `bin/gamend` (a release overlay) runs the server with `start`, `daemon`, `stop`, `restart`, `remote` and `reload` (which re-reads the theme and markdown on the running server), and everything else through `GamendWeb.CLI` under the mix task's own name: `db.setup`, `db.migrate`, `db.rollback` (`--step`, `--to`, `--all`), `db.reset`, `db.seed` (new here as a mix alias of `host.seed` too), `demo.seed` and `plugin.bundle`. `gamend starter` copies a starter project in without overwriting anything: `default` (`priv/starter/default`), `website` (the gamend.org site, published as `gamend-website.tar.gz`), or any folder, `.tar.gz` or URL, and writes a `.env` with a fresh secret. Each project gets its own node name and a private cookie in `.gamend/`, and the node listens on loopback only, since the cookie inside a download is the same for everyone. The `actions/setup-gamend` action installs, starts and seeds a server in CI and sets `GAMEND_URL`, for a game client's end-to-end tests. `mix demo.seed` is now `Gamend.DemoSeed.run/1` behind a thin task, and `Gamend.Release` gained `rollback/1` and `dropdb/0`. Guide: [Download and run](/docs/standalone). + +- [changed] **A release runs from the directory it is started in.** `bin/gamend_host` anchored the SQLite database (`db/`) and the GeoIP file (`data/`) to the release's own root and read the theme from the build machine's checkout by absolute path, while the markdown content, the plugins dir and `priv/storage` already resolved against the working directory. All of them resolve against the working directory now, and a release reads that directory's `.env` as `mix phx.server` does in dev (real environment variables still win), so a release unpacked anywhere keeps a project's data and customisations in the folder it is started from. A release started from somewhere other than its own root finds its database there; set `GAMEND_DB_SQLITE_PATH` to keep the old one. The Docker `release` image starts from its own root, so nothing moves there, and it now carries the theme, `CHANGELOG.md`, `ROADMAP.md`, `blog/`, `priv/docs` and the plugins dir, which it had left out: it served no theme, no changelog, blog or guides, and loaded no plugins. The boot warning about ephemeral storage fires only for a path inside the release. + +- [added] **A project's own static files are served ahead of the engine's.** Someone running a release from a project folder could not add an image, a `game/` export or a favicon, because static files came only from the release's `priv/static`. The endpoint now serves the folders `GAMEND_CONTENT_STATIC_DIRS` lists first (default `static,priv/static`, relative to the working directory), for the top-level entries of `:host_static_paths` and never `assets/`, with the same cache headers as the built-in files; a folder that is an app's own `priv/static` is skipped. `GamendWeb.ProjectStatic.path_for/1` answers which file serves a URL, and everything that reads a static file by URL goes through it (image dimensions, srcset variants, `GamendWeb.SRI`, `theme.css`, the dark banner, `.well-known`, admin diagnostics), so a page links and hashes the project's file rather than the engine's at the same path. A width variant or a generated WebP only counts when it sits beside its original, so a replaced image never borrows the engine's smaller copies. `GamendWeb.SRI.integrity/1` is `nil` for a path the overlay can answer: its hash is cached until a reload while the overlay is read per request, and a file changed in between would otherwise be blocked by the browser. See the [theme guide](/docs/theme). + +- [added] **Missing `widths` variants are cut at runtime.** The cutting in `mix host.responsive_images` moved to `GamendWeb.ResponsiveImages`, which the task now calls, and a supervised process (in `GamendWeb.HostSupervision.children/1`) uses it to cut the variants a project's own images are missing, next to the original, at boot and after `Gamend.Theme.JSONConfig.reload/0` (which now emits `[:gamend, :theme, :reload]`). It needs ImageMagick (`magick`, or `convert` outside Windows); without it the server logs once at `:info` and pages serve the originals. It never writes inside the release, and cached presentation pages re-render once variants are cut (`GamendWeb.ProjectStatic.generation/0`). The same reload first runs `GamendWeb.ProjectStatic.reload/0`, which resolves the static folders again and rebuilds the `?v=` hashes and the `theme.css` link, so a folder created or a file replaced since boot shows without a restart; the telemetry handler only sends the process a message, so the caller of `reload/0` never waits on it or sees it fail. + +- [added] **A release builds plugins without Mix.** The downloadable engine and the `release` image ship the Elixir compiler but no `mix`, so the admin Config page's Build bundle was disabled there. `Gamend.Hooks.PluginBuilder` now falls back to building in-process (`Gamend.Hooks.PluginBuilder.InProcess`) when `mix` is not on the PATH: it reads the plugin's `mix.exs` without running it, transpiles `scripts/*.gd` into `gen/` for a GDScript plugin, checks that every runtime dependency ships with the engine or sits prebuilt in `deps//ebin` (naming the one that does not), compiles into a temporary directory and swaps it in as `ebin/` with the `.app` `mix plugin.bundle` writes, so a failed build keeps the old bundle. `hooks_module` is detected (`use Gamend.Hooks`, or the GDScript script named after the plugin) when `mix.exs` does not spell it literally, and a module the server already has is refused. A loaded plugin is stopped for the build and started again on the result (`PluginManager.suspend/1`, `resume/1`). `available?/0` is true in a release now, `mode/0` says which build runs, `build/2` takes `mode: :mix | :in_process`, and `build_all/0` builds every plugin. The engine gained `use Gamend.Hooks` (`Gamend.Hooks.Defaults`, the SDK's defaults verbatim, with a test that fails when they drift), since an in-process build compiles against the engine rather than the SDK. The host now depends on `gamend_plugin_tools` at runtime, so the GDScript transpiler ships in the release; `Gamend.GDScript.compile_all/2` takes `:root` and `Gamend.GDScript.source_path/1` is public. The bundled `webrtc_lobby_hook` dropped its unused `bunt` dependency, which the engine does not ship. Limits: no Hex dependencies beyond the engine's, no Erlang sources or Gleam, no `config/config.exs`, and protocols are consolidated. See [Building a plugin](/docs/server-scripting#building-a-plugin). + - [fixed] **The error page's links were English in every language.** `GamendWeb.ErrorHTML` looked the `:error_page_links` labels up in core's own catalogue, which holds none of a host's navigation, so from `/fr/…` a 404 offered "Dictionary" and "Play". It now looks them up in the host's backend (`:host_gettext_backend`) in the reader's locale, and keeps the literal if anything goes wrong. - [fixed] **The retention sweep no longer locks a SQLite database.** `Gamend.Retention` pruned old lobby snapshots and events in one `DELETE` each, and kept a flagged run's history with a correlated `NOT EXISTS` that re-scanned the lobby's snapshots for every row. SQLite has one writer, so on a large history (115k snapshots on a dev database) the statement held the write lock past the 15 s checkout timeout: every other write in the app queued behind it and page loads hung, then the sweep rolled back and did the same at the next run. It now deletes in batches of 500, each its own statement, and finds the flagged runs once (`lobby_id NOT IN` a subquery). A sweep cut short keeps what it deleted. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c614f5e9..bbd47945e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ Adding one callback touches six places — miss one and plugins break in confusi 2. Add the name to `internal_hooks()` — otherwise clients can invoke it over RPC. 3. `before_*` hooks: add to `lifecycle_pipeline_hook?/2`, plus a `normalize_pipeline_args/3` clause if the hook only vetoes (returns the value unchanged). 4. No-op implementation in `Gamend.Hooks.Default`. -5. Mirror in the SDK (`sdk/lib/gamend/hooks.ex`): `@callback`, `@optional_callbacks`, a default in `default_callbacks` or `more_default_callbacks` (the quoted code `__using__` injects), **and the `overridable_callbacks` list** — a default that isn't listed there cannot be overridden by plugins. +5. Mirror in the SDK (`sdk/lib/gamend/hooks.ex`): `@callback` and `@optional_callbacks`. The default goes in `Gamend.Hooks.Defaults` (`apps/gamend_core/lib/gamend/hooks/defaults.ex`): in `default_callbacks` or `more_default_callbacks` (the quoted code `use Gamend.Hooks` injects), **and in the `overridable_callbacks` list** — a default that isn't listed there cannot be overridden by plugins. `mix gen.sdk` (in `mix precommit`) copies that file into the SDK, so a plugin gets the same defaults built with Mix or in-process. 6. Document the hook in `priv/docs/40-gameplay/90-server-scripting.md`. **Hold the database only for database work.** On SQLite the repo has a single connection and every transaction takes the write lock, so anything slow inside a transaction or `Gamend.Lock.serialize/3` stalls every other request. Open transactions with `Gamend.AfterCommit.transaction/2` (or `serialize/3`) and broadcast with `Gamend.Broadcast.publish/2`: broadcasts, `Gamend.Async.run/1` tasks and anything passed to `Gamend.AfterCommit.defer/1` then wait for the commit, and a rollback drops them. A test in `after_commit_test.exs` fails on a bare `Repo.transaction` or `Phoenix.PubSub.broadcast` in core. Slow gates run *before* the lock: a plugin's `before_*` hook, a password check, an HTTP call. Inside it, re-check only what a concurrent writer could change (see `Lobbies.join_lobby/3`). When the hook needs the value the lock protects, go optimistic: read and ask the hook unlocked, then write under the lock only if the value is unchanged, and retry otherwise (`Lobbies.merge_metadata/2`). @@ -50,7 +50,7 @@ Adding one callback touches six places — miss one and plugins break in confusi - Hand-write struct stubs in `sdk/lib/gamend//` (the generator does not create them) — plugins can't compile against a struct that doesn't exist. - Add placeholder rules in `gen.sdk` for the new structs (`T | nil` and `{:ok, T}` return types). Without them a stub returns only `nil`/`{:ok, _}`, and every plugin that pattern-matches the other branch gets a bogus "clause cannot match" warning. - Verify with `cd modules/plugins_examples/example_hook && mix compile --force` — it must be warning-free. -- The server loads plugins from their bundled `ebin/`, not from `_build` — after changing a plugin, run `mix plugin.bundle` in its directory (or the admin Config build button) or the running server keeps the old code. +- The server loads plugins from their bundled `ebin/`, not from `_build` — after changing a plugin, run `mix plugin.bundle` in its directory (or the admin Config build button) or the running server keeps the old code. Without `mix` on the PATH (a release) the button builds in-process (`Gamend.Hooks.PluginBuilder.InProcess`), against the engine's modules rather than the SDK's stubs. ## Web diff --git a/Dockerfile b/Dockerfile index 88e18db4c..5e2300e83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,9 @@ COPY mix.exs mix.lock ./ # Umbrella apps: include their mix.exs files so deps can be resolved in a cached layer COPY apps/gamend_web/mix.exs apps/gamend_web/mix.exs COPY apps/gamend_core/mix.exs apps/gamend_core/mix.exs +# The GDScript transpiler (gamend_plugin_tools), a path dependency of the host. +# Without its mix.exs here, deps.get would resolve it from GitHub instead. +COPY sdk_tools/mix.exs sdk_tools/mix.exs # Install dependencies RUN mix deps.get @@ -144,6 +147,10 @@ FROM builder AS release-build RUN mix release --overwrite +# The plugin build above skips a missing plugins dir; the release stage copies +# it unconditionally, so make sure there is one to copy. +RUN mkdir -p "${GAMEND_CONTENT_PLUGINS_DIR}" + # ── Release runtime ─────────────────────────────────────────────────────── FROM ${RUNNER_IMAGE} AS release @@ -177,6 +184,16 @@ ENV GAMEND_DB_ADAPTER=${GAMEND_DB_ADAPTER} \ COPY --from=release-build /app/_build/prod/rel/gamend_host ./ COPY --from=release-build /app/VERSION ./VERSION +# The release reads its theme, markdown content and plugins relative to the +# directory it starts in, as `mix phx.server` does. None of them are part of +# the OTP release, so they come across on their own, to the paths the full +# image has them at. +COPY --from=release-build /app/theme ./theme +COPY --from=release-build /app/CHANGELOG.md /app/ROADMAP.md ./ +COPY --from=release-build /app/blog ./blog +COPY --from=release-build /app/priv/docs ./priv/docs +COPY --from=release-build /app/${GAMEND_CONTENT_PLUGINS_DIR} ./${GAMEND_CONTENT_PLUGINS_DIR} + EXPOSE 4000 443 # Mirrors the full target's CMD, with the release's `eval` standing in for the @@ -184,9 +201,10 @@ EXPOSE 4000 443 # and the default on most container hosts is 1024-10240; failure is tolerated # on purpose so a host that pins the hard limit lower still starts. # -# createdb is allowed to fail (a provisioned Postgres already has the database -# and the role may not be permitted to create one); migrate is not. -CMD ["sh", "-c", "ulimit -n 262144 2>/dev/null || true; bin/gamend_host eval 'Gamend.Release.createdb()' 2>/dev/null; bin/gamend_host eval 'Gamend.Release.migrate()' && bin/gamend_host start"] +# Gamend.Release.prepare/0 creates the database when it can (a provisioned +# Postgres already has it, and its role may not be permitted to create one) +# and then migrates, which is not allowed to fail. +CMD ["sh", "-c", "ulimit -n 262144 2>/dev/null || true; bin/gamend_host eval 'Gamend.Release.prepare()' && bin/gamend_host start"] # ── Full (default target) ───────────────────────────────────────────────── # Last on purpose: `docker build .` with no --target builds the final stage, diff --git a/README.md b/README.md index 3653b54ef..becbdb9ab 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ attached to the [`latest` release](https://github.com/appsinacup/gamend/releases - [JavaScript SDK](https://www.npmjs.com/package/@ughuuu/gamend) - [Elixir SDK](sdk/) — Stub modules for IDE autocomplete in custom hooks +## Download and run + +No Elixir needed: install the server, then start it from a project folder (macOS on Apple silicon, Linux x86_64 and arm64). + +```sh +curl -fsSL https://raw.githubusercontent.com/appsinacup/gamend/main/rel/install.sh | sh +mkdir my-game && cd my-game +gamend starter # an example site to edit: theme, pages, a post, a guide +gamend start # http://localhost:4000 +``` + +`gamend starter website` gives you the gamend.org site instead. For end-to-end tests in CI, `uses: appsinacup/gamend/actions/setup-gamend@main` starts a server and sets `GAMEND_URL`. Guide: [Download and run](https://gamend.org/docs/standalone). + ## Run Locally ### Prerequisites diff --git a/actions/setup-gamend/action.yml b/actions/setup-gamend/action.yml new file mode 100644 index 000000000..29539b631 --- /dev/null +++ b/actions/setup-gamend/action.yml @@ -0,0 +1,109 @@ +name: Set up Gamend +description: > + Installs the Gamend server, creates a project from a starter template and + starts it in the background, for end-to-end tests against a real server. + Linux (x86_64, arm64) and macOS (Apple silicon) runners. + +inputs: + version: + description: Release tag to install. + default: server-latest + adapter: + description: "Database: sqlite (no service needed) or postgres (set GAMEND_DB_URL in `env`)." + default: sqlite + project: + description: Directory of the project folder, created if missing (default $RUNNER_TEMP/gamend-project). + default: "" + starter: + description: Starter template to copy in (`default`, `website`, a path or a URL); empty for none. + default: default + port: + description: HTTP port. + default: "4000" + env: + description: Extra settings, one KEY=VALUE per line, appended to the project's .env. + default: "" + seed: + description: "Arguments for `gamend demo.seed` (e.g. `--count 50`), run once the server is up; empty to skip." + default: "" + start: + description: Start the server (`gamend daemon`) and wait until it answers. + default: "true" + timeout: + description: Seconds to wait for the server to answer. + default: "120" + +outputs: + url: + description: Base URL of the running server, e.g. http://127.0.0.1:4000 + value: ${{ steps.start.outputs.url }} + project: + description: The project folder; run `gamend` commands from it. + value: ${{ steps.project.outputs.dir }} + +runs: + using: composite + steps: + - name: Install gamend + shell: bash + env: + GAMEND_VERSION: ${{ inputs.version }} + GAMEND_ADAPTER: ${{ inputs.adapter }} + GAMEND_BIN_DIR: ${{ runner.temp }}/gamend-bin + run: | + sh "$GITHUB_ACTION_PATH/../../rel/install.sh" + echo "$GAMEND_BIN_DIR" >> "$GITHUB_PATH" + + - name: Create the project + id: project + shell: bash + env: + PROJECT: ${{ inputs.project }} + STARTER: ${{ inputs.starter }} + PORT: ${{ inputs.port }} + EXTRA_ENV: ${{ inputs.env }} + run: | + export PATH="$RUNNER_TEMP/gamend-bin:$PATH" + PROJECT="${PROJECT:-$RUNNER_TEMP/gamend-project}" + mkdir -p "$PROJECT" + cd "$PROJECT" + if [ -n "$STARTER" ]; then gamend starter "$STARTER"; fi + { + # `gamend starter` writes the secret; without a starter, make one. + if ! grep -qs '^GAMEND_AUTH_SECRET_KEY_BASE=' .env; then + echo "GAMEND_AUTH_SECRET_KEY_BASE=$(openssl rand -base64 48 | tr -d '\n')" + fi + echo "GAMEND_HTTP_PORT=$PORT" + if [ -n "$EXTRA_ENV" ]; then printf '%s\n' "$EXTRA_ENV"; fi + } >> .env + echo "dir=$(pwd -P)" >> "$GITHUB_OUTPUT" + + - name: Start the server + id: start + if: inputs.start == 'true' + shell: bash + working-directory: ${{ steps.project.outputs.dir }} + env: + PORT: ${{ inputs.port }} + TIMEOUT: ${{ inputs.timeout }} + SEED: ${{ inputs.seed }} + run: | + export PATH="$RUNNER_TEMP/gamend-bin:$PATH" + gamend daemon + url="http://127.0.0.1:$PORT" + for _ in $(seq 1 "$TIMEOUT"); do + curl -fsS "$url/api/v1/health" > /dev/null 2>&1 && break + sleep 1 + done + if ! curl -fsS "$url/api/v1/health" > /dev/null; then + echo "::error::Gamend did not answer on $url within ${TIMEOUT}s" + cat .gamend/tmp/log/erlang.log.* || true + exit 1 + fi + if [ -n "$SEED" ]; then + # shellcheck disable=SC2086 + gamend demo.seed $SEED + fi + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "GAMEND_URL=$url" >> "$GITHUB_ENV" + echo "Gamend is up on $url" diff --git a/apps/gamend_core/.gitignore b/apps/gamend_core/.gitignore index a2e6bd485..d75336bde 100644 --- a/apps/gamend_core/.gitignore +++ b/apps/gamend_core/.gitignore @@ -1 +1,3 @@ doc/ +# ExUnit @tag :tmp_dir scratch space +/tmp/ diff --git a/apps/gamend_core/lib/gamend/content_settings.ex b/apps/gamend_core/lib/gamend/content_settings.ex index 26985d5be..00bf648d2 100644 --- a/apps/gamend_core/lib/gamend/content_settings.ex +++ b/apps/gamend_core/lib/gamend/content_settings.ex @@ -1,7 +1,7 @@ defmodule Gamend.ContentSettings do @moduledoc """ Where the server finds host-supplied content: the theme config, hook plugins, - and the GeoIP database. + project static files, and the GeoIP database. """ use Gamend.Settings.Provider, @@ -19,6 +19,12 @@ defmodule Gamend.ContentSettings do doc: "Directory containing OTP hook plugins." ) + setting(:static_dirs, :list, + default: ["static", "priv/static"], + doc: + "Directories of static files served ahead of the built-in ones (images/, game/, favicon.ico, robots.txt, theme.css), relative to the working directory and searched in order. Each is used when it exists." + ) + setting(:geoip_db_path, :string, doc: "MaxMind mmdb file. Defaults to data/GeoLite2-Country.mmdb when present." ) diff --git a/apps/gamend_core/lib/gamend/demo_seed.ex b/apps/gamend_core/lib/gamend/demo_seed.ex new file mode 100644 index 000000000..46923b87f --- /dev/null +++ b/apps/gamend_core/lib/gamend/demo_seed.ex @@ -0,0 +1,1163 @@ +defmodule Gamend.DemoSeed do + @moduledoc """ + Fills the database with enough demo data to exercise pagination and the + list/detail pages at realistic sizes. + + Everything is namespaced with a `demo-seed` prefix so `--clean` can remove it + again without touching real data. + + ## Usage + + `mix demo.seed` in a checkout, `gamend demo.seed` from a release, with the + same arguments: + + mix demo.seed # all sets, 1000 rows each + mix demo.seed --count 250 # smaller run + mix demo.seed --only leaderboard # one set (comma-separated) + mix demo.seed --only group,tournament + mix demo.seed --clean # remove everything this task created + + ## Sets + + * `leaderboard` — a leaderboard with N scored records + * `group` — a public group with N members + * `tournament` — a tournament with N registered entries, still open + * `lobby_snapshot` — recorded runs for `/admin/lobby_snapshots`, capped at 12 + regardless of `--count` (this set is about having something to read, not + volume) + * `quest` — a daily, an auto-claim achievement, a chained follow-up and a + twelve-member group that lists as one card, with per-user progress in + every state (including claimable rows) + * `ready_check` — one check per seeded lobby in every outcome (open, + passed, timed out, declined), also capped at 12 + * `chat_moderation` — a blocklist across every severity and match mode, a + report queue deep enough to page through (every status, some filter-filed, + some resolved) and mutes in every scope, including expired ones + + The `lobby_snapshot` set goes through the real `capture_lobby/3` path rather + than inserting rows, so what you see is shaped exactly like production data — + including content-addressed section dedup. One of its runs reproduces the July + 2026 rubber-banding bug (a distance that reverts between snapshots), which is + the case the section diff exists to make obvious. + + Seeded runs keep their lobby row so `--clean` can find them again. Real + completed runs outlive theirs, since a lobby is deleted when its last member + leaves. + + All sets share one pool of N anonymous device accounts, so the same players + appear across them (as they would in a real deployment). + + Rows are inserted in bulk rather than through the contexts: this is about + volume, not about exercising business rules, and 1000 individual writes on + SQLite is slow. The cache is flushed afterwards so pages read the new rows. + """ + + import Ecto.Query + + alias Gamend.Accounts.User + alias Gamend.Chat.FilterWord + alias Gamend.Chat.Message + alias Gamend.Chat.Moderation + alias Gamend.Chat.Moderation.Normalizer + alias Gamend.Chat.Mute + alias Gamend.Chat.Report + alias Gamend.Groups.Group + alias Gamend.Groups.GroupMember + alias Gamend.Leaderboards.Leaderboard + alias Gamend.Leaderboards.Record + alias Gamend.Lobbies.Lobby + alias Gamend.LobbySnapshots + alias Gamend.LobbySnapshots.Event, as: SnapshotEvent + alias Gamend.LobbySnapshots.Snapshot + alias Gamend.LobbySnapshots.Writer + alias Gamend.Parties.Party + alias Gamend.Push.PushToken + alias Gamend.Quests.Quest + alias Gamend.Quests.QuestProgress + alias Gamend.ReadyChecks.Check, as: ReadyCheck + alias Gamend.ReadyChecks.Participant, as: ReadyCheckParticipant + alias Gamend.Repo + alias Gamend.Tournaments.Entry + alias Gamend.Tournaments.Tournament + alias Gamend.UUIDv7 + + @prefix "demo-seed" + @leaderboard_slug "demo_seed_scores" + @group_title "Demo Seed Group" + @tournament_slug "demo-seed-cup" + @quest_key_prefix "demo-seed-" + @default_count 1000 + @batch 500 + @all_sets ~w(leaderboard group tournament lobby_snapshot quest push ready_check chat_moderation) + @lobby_title_prefix "Demo Seed Run" + @max_runs 12 + @chat_lobby_title "#{@lobby_title_prefix} Chat" + @chat_room_types ~w(group lobby party) + @min_reports 48 + @max_mutes 24 + + # A spread over both match modes, all three severities and the provenance tag + # (nil is a hand-added word). Nothing here overlaps priv/chat_filter/en.txt, so + # importing the bundled list on top of a seeded database still works, and no + # word carries a doubled letter — the normalizer collapses those, and a word + # displayed as "bosting" reads like a typo in the admin list. + @filter_words [ + {"idiot", "block", "substring", nil}, + {"moron", "block", "substring", "en"}, + {"scumbag", "block", "exact", "en"}, + {"kys", "block", "exact", nil}, + {"arschloch", "block", "substring", "de"}, + {"salaud", "block", "substring", "fr"}, + {"imbecil", "block", "substring", "es"}, + {"damn", "mask", "substring", nil}, + {"crap", "mask", "substring", nil}, + {"numpty", "mask", "substring", "en"}, + {"plonker", "mask", "exact", "en"}, + {"mist", "mask", "exact", "de"}, + {"merde", "mask", "substring", "fr"}, + {"trash", "flag", "substring", nil}, + {"scam", "flag", "substring", nil}, + {"hacker", "flag", "substring", nil}, + {"cheater", "flag", "substring", "en"}, + {"smurf", "flag", "exact", nil}, + {"rmt", "flag", "exact", "en"}, + {"goldfarm", "flag", "substring", nil}, + {"gamekeys", "flag", "substring", nil}, + {"buy gold", "flag", "substring", "en"} + ] + + # Reported message and the reason it was reported for. The last two are + # benign: a queue with no bogus reports in it never shows why `dismissed` + # exists. + @chat_lines [ + {"you are the worst teammate I have had all week", "Harassment"}, + {"learn to play before you queue ranked", "Harassment"}, + {"stop stealing my objectives", "Griefing"}, + {"report him, he threw the game on purpose", "Griefing"}, + {"add me, I sell accounts cheap", "Real-money trading"}, + {"join my stream, the link is in my profile", "Spam or advertising"}, + {"nobody from your region should be allowed to queue", "Hate speech"}, + {"I am a moderator, send me your login to verify", "Impersonation"}, + {"gg wp, close one", "Harassment"}, + {"my ping is awful tonight", "Spam or advertising"} + ] + + # Each line is paired with the `flag` word it trips, which is what the filter + # puts in the reason of the report it files. + @flagged_lines [ + {"buy gold cheap, first ten buyers get a bonus", "buy gold"}, + {"this smurf ruins every lobby", "smurf"}, + {"you are trash, uninstall the game", "trash"}, + {"total scam, the drop rates are rigged", "scam"}, + {"goldfarm service, message me for prices", "goldfarm"} + ] + + @doc """ + Seeds (or with `--clean` removes) the demo data. Takes the same arguments as + `mix demo.seed`; the application must already be running. Raises + `ArgumentError` on an unknown set. + """ + @spec run([String.t()]) :: :ok + def run(args) do + {opts, _rest, _} = + OptionParser.parse(args, strict: [count: :integer, only: :string, clean: :boolean]) + + if opts[:clean] do + clean() + else + count = opts[:count] || @default_count + sets = parse_sets(opts[:only]) + + info("seeding #{count} rows per set: #{Enum.join(sets, ", ")}") + users = ensure_users(count) + + Enum.each(sets, fn + "leaderboard" -> seed_leaderboard(users) + "group" -> seed_group(users) + "tournament" -> seed_tournament(users) + "lobby_snapshot" -> seed_lobby_snapshots(users) + "quest" -> seed_quests(users) + "push" -> seed_push_tokens(users) + "ready_check" -> seed_ready_checks(users) + "chat_moderation" -> seed_chat_moderation(users) + end) + + Gamend.Cache.delete_all() + info("done — run `demo.seed --clean` to remove it again") + end + + :ok + end + + defp parse_sets(nil), do: @all_sets + + defp parse_sets(only) do + sets = only |> String.split(",", trim: true) |> Enum.map(&String.trim/1) + + case sets -- @all_sets do + [] -> + sets + + unknown -> + raise ArgumentError, + "unknown set(s): #{Enum.join(unknown, ", ")} (known: #{Enum.join(@all_sets, ", ")})" + end + end + + # ── Shared player pool ──────────────────────────────────────────────────── + + defp ensure_users(count) do + existing = + from(u in User, where: like(u.device_id, ^"#{@prefix}-%"), select: {u.device_id, u.id}) + |> Repo.all() + |> Map.new() + + missing = + for i <- 1..count, + device_id = device_id(i), + not Map.has_key?(existing, device_id), + do: {i, device_id} + + now = DateTime.utc_now(:second) + + missing + |> Enum.map(fn {i, device_id} -> + %{ + id: UUIDv7.generate(), + device_id: device_id, + username: username(i), + display_name: display_name(i), + is_admin: false, + is_activated: true, + metadata: %{}, + token_version: 0, + inserted_at: now, + updated_at: now + } + end) + |> insert_batches(User) + + info("players: #{count} (#{length(missing)} new)") + + from(u in User, + where: like(u.device_id, ^"#{@prefix}-%"), + order_by: u.device_id, + limit: ^count, + select: u.id + ) + |> Repo.all() + end + + defp device_id(i), do: "#{@prefix}-#{pad(i)}" + defp username(i), do: "#{@prefix}-#{pad(i)}" + defp display_name(i), do: "Demo Player #{pad(i)}" + defp pad(i), do: String.pad_leading(Integer.to_string(i), 5, "0") + + # ── Sets ────────────────────────────────────────────────────────────────── + + defp seed_leaderboard(user_ids) do + leaderboard = + upsert(Leaderboard, [slug: @leaderboard_slug], %{ + slug: @leaderboard_slug, + title: "Demo Seed Scores", + description: "Volume demo data.", + sort_order: :desc, + operator: :best, + metadata: %{} + }) + + Repo.delete_all(from(r in Record, where: r.leaderboard_id == ^leaderboard.id)) + now = DateTime.utc_now(:second) + + user_ids + |> Enum.map(fn user_id -> + %{ + id: UUIDv7.generate(), + leaderboard_id: leaderboard.id, + user_id: user_id, + score: :rand.uniform(1_000_000), + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + |> insert_batches(Record) + + info("leaderboard: #{length(user_ids)} records -> /leaderboards/#{@leaderboard_slug}") + end + + defp seed_group(user_ids) do + [creator | members] = user_ids + group = demo_group(user_ids) + + Repo.delete_all(from(m in GroupMember, where: m.group_id == ^group.id)) + now = DateTime.utc_now(:second) + + rows = + [%{user_id: creator, role: "admin"}] ++ Enum.map(members, &%{user_id: &1, role: "member"}) + + rows + |> Enum.map(fn row -> + %{ + id: UUIDv7.generate(), + group_id: group.id, + user_id: row.user_id, + role: row.role, + inserted_at: now, + updated_at: now + } + end) + |> insert_batches(GroupMember) + + info("group: #{length(rows)} members -> /groups/#{group.id}") + end + + defp demo_group(user_ids) do + upsert(Group, [title: @group_title], %{ + title: @group_title, + description: "Volume demo data.", + type: "public", + max_members: length(user_ids) + 10, + creator_id: hd(user_ids), + metadata: %{} + }) + end + + defp seed_tournament(user_ids) do + now = DateTime.utc_now(:second) + + tournament = + upsert(Tournament, [slug: @tournament_slug], %{ + slug: @tournament_slug, + title: "Demo Seed Cup", + description: "Volume demo data — registration is open.", + state: "registration", + registration_opens_at: DateTime.add(now, -3600), + starts_at: DateTime.add(now, 7 * 86_400), + round_window_sec: 3600, + bracket_size: 8, + team_size: 1, + deadline_policy: "forfeit_both", + metadata: %{} + }) + + Repo.delete_all(from(e in Entry, where: e.tournament_id == ^tournament.id)) + + user_ids + |> Enum.map(fn user_id -> + %{ + id: UUIDv7.generate(), + tournament_id: tournament.id, + leader_id: user_id, + wins: 0, + state: "registered", + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + |> insert_batches(Entry) + + info("tournament: #{length(user_ids)} entries -> /tournaments/#{tournament.id}") + end + + # ── Clean ───────────────────────────────────────────────────────────────── + + # A daily, an auto-claim achievement, a chain gated on it and a group that + # collapses to one card — with per-user progress in every state, including + # claimable completed rows. + defp seed_quests(user_ids) do + daily = + upsert_quest(%{ + key: @quest_key_prefix <> "daily-login", + title: "Demo Daily Login", + description: "Log in 3 times today.", + reset: "daily", + category: "Daily", + objectives: [%{event: "login", target: 3, params: %{}}], + rewards: [%{type: "currency", code: "gold", amount: 100}], + auto_claim: false, + active: true, + metadata: %{} + }) + + achievement = + upsert_quest(%{ + key: @quest_key_prefix <> "first-win", + title: "Demo First Win", + description: "Win your first demo match.", + reset: "never", + category: "Achievements", + objectives: [%{event: "demo_win", target: 1, params: %{}}], + rewards: [], + auto_claim: true, + active: true, + metadata: %{} + }) + + chain = + upsert_quest(%{ + key: @quest_key_prefix <> "veteran", + title: "Demo Veteran", + description: "Win 10 demo matches (after your first win).", + reset: "never", + category: "Chained", + objectives: [%{event: "demo_win", target: 10, params: %{}}], + rewards: [%{type: "item", code: "loot_crate", amount: 1}], + auto_claim: false, + prerequisite_quest_key: achievement.key, + active: true, + metadata: %{} + }) + + # A group is only worth looking at in bulk: twelve definitions, one card. + countries = ~w(Spain Romania Poland Portugal Greece Norway + Japan Chile Kenya Peru Iceland Vietnam) + + group_members = + for {country, i} <- Enum.with_index(countries) do + upsert_quest(%{ + key: @quest_key_prefix <> "visit-" <> String.downcase(country), + title: "Visit #{country}", + description: "Visit 5 cities in #{country}.", + reset: "never", + category: "Exploration", + group_key: @quest_key_prefix <> "world-tour", + group_title: "Sail the world", + sort_order: i, + objectives: [ + %{event: "demo_city_visited", target: 5, params: %{"country" => country}} + ], + rewards: [%{type: "currency", code: "gold", amount: 50}], + auto_claim: false, + active: true, + metadata: %{} + }) + end + + now = DateTime.utc_now(:second) + today = Gamend.Quests.period_key("daily", now) + + Repo.delete_all(from(p in QuestProgress, where: like(p.quest_key, ^"#{@quest_key_prefix}%"))) + + daily_rows = + user_ids + |> Enum.with_index() + |> Enum.map(fn {user_id, i} -> + completed? = rem(i, 3) == 0 + claimed? = rem(i, 6) == 0 + + status = + cond do + claimed? -> "claimed" + completed? -> "completed" + true -> "active" + end + + %{ + id: UUIDv7.generate(), + user_id: user_id, + quest_key: daily.key, + period_key: today, + objective_progress: %{"0" => if(completed?, do: 3, else: rem(i, 3))}, + status: status, + completed_at: if(completed?, do: now), + claimed_at: if(claimed?, do: now), + rewards_granted_at: if(claimed?, do: now), + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + + achievement_rows = + user_ids + |> Enum.with_index() + |> Enum.filter(fn {_id, i} -> rem(i, 2) == 0 end) + |> Enum.map(fn {user_id, _i} -> + %{ + id: UUIDv7.generate(), + user_id: user_id, + quest_key: achievement.key, + period_key: "static", + objective_progress: %{"0" => 1}, + status: "claimed", + completed_at: now, + claimed_at: now, + rewards_granted_at: now, + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + + chain_rows = + user_ids + |> Enum.with_index() + |> Enum.filter(fn {_id, i} -> rem(i, 4) == 0 end) + |> Enum.map(fn {user_id, i} -> + %{ + id: UUIDv7.generate(), + user_id: user_id, + quest_key: chain.key, + period_key: "static", + objective_progress: %{"0" => rem(i, 10)}, + status: "active", + completed_at: nil, + claimed_at: nil, + rewards_granted_at: nil, + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + + # Spread over the members so the collapsed card has a representative to + # pick: the one furthest along, not whatever sorted first. + group_rows = + for {user_id, i} <- Enum.with_index(user_ids), + rem(i, 3) == 0, + member = Enum.at(group_members, rem(i, length(group_members))) do + %{ + id: UUIDv7.generate(), + user_id: user_id, + quest_key: member.key, + period_key: "static", + objective_progress: %{"0" => rem(i, 5) + 1}, + status: "active", + completed_at: nil, + claimed_at: nil, + rewards_granted_at: nil, + metadata: %{}, + inserted_at: now, + updated_at: now + } + end + + rows = daily_rows ++ achievement_rows ++ chain_rows ++ group_rows + insert_batches(rows, QuestProgress) + + info( + "quests: #{3 + length(group_members)} definitions, #{length(rows)} progress rows -> /admin/quests" + ) + end + + # Definitions go through the context (embeds can't be bulk-inserted). + defp upsert_quest(attrs) do + case Repo.get_by(Quest, key: attrs.key) do + nil -> + {:ok, quest} = Gamend.Quests.create_quest(attrs) + quest + + quest -> + quest + end + end + + defp clean_quests do + Repo.delete_all(from(p in QuestProgress, where: like(p.quest_key, ^"#{@quest_key_prefix}%"))) + Repo.delete_all(from(q in Quest, where: like(q.key, ^"#{@quest_key_prefix}%"))) + end + + # One device per player (platform/provider cycling), every tenth disabled so + # the admin page shows the dead-token state. Log provider, so a test push + # against this data is observable in the server log. Rows cascade-delete + # with their demo user on clean. + defp seed_push_tokens(user_ids) do + Repo.delete_all(from(t in PushToken, where: like(t.token, ^"#{@prefix}-token-%"))) + now = DateTime.utc_now(:second) + + user_ids + |> Enum.with_index() + |> Enum.map(fn {user_id, i} -> + {platform, provider} = + case rem(i, 3) do + 0 -> {"android", "fcm"} + 1 -> {"ios", "apns"} + 2 -> {"web", "fcm"} + end + + %{ + id: UUIDv7.generate(), + user_id: user_id, + token: "#{@prefix}-token-#{pad(i)}", + platform: platform, + provider: provider, + device_id: device_id(i), + disabled_at: if(rem(i, 10) == 9, do: now), + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + |> insert_batches(PushToken) + + info("push: #{length(user_ids)} device tokens -> /admin/push") + end + + # Lobbies are hosted by seeded players and cascade on --clean, so the checks + # attached to them go too. Each run gets one open check plus a spread of + # resolved ones, which is what the admin page's 24h counters read. + defp seed_ready_checks(user_ids) do + hosts = Enum.take(user_ids, min(@max_runs, length(user_ids))) + now = DateTime.utc_now(:second) + + checks = + hosts + |> Enum.with_index() + |> Enum.map(fn {host_id, i} -> + {status, reason} = + case rem(i, 4) do + 0 -> {"pending", nil} + 1 -> {"passed", nil} + 2 -> {"failed", "timeout"} + 3 -> {"failed", "declined"} + end + + lobby = + upsert(Lobby, [title: "#{@lobby_title_prefix} Ready #{pad(i)}"], %{ + title: "#{@lobby_title_prefix} Ready #{pad(i)}", + host_id: host_id, + max_users: 4, + metadata: %{}, + state: "created", + state_changed_at: now + }) + + %{ + id: UUIDv7.generate(), + kind: if(rem(i, 3) == 0, do: "accept", else: "ready"), + status: status, + lobby_id: lobby.id, + deadline_at: DateTime.add(now, 15, :second), + opened_by: host_id, + reason: reason, + resolved_at: if(status != "pending", do: now), + metadata: %{}, + inserted_at: now, + updated_at: now + } + end) + + insert_batches(checks, ReadyCheck) + + participants = + checks + |> Enum.with_index() + |> Enum.flat_map(fn {check, i} -> + user_ids + |> Enum.slice(i, 3) + |> Enum.with_index() + |> Enum.map(fn {user_id, j} -> + %{ + id: UUIDv7.generate(), + ready_check_id: check.id, + user_id: user_id, + state: participant_state(check, j), + responded_at: now, + inserted_at: now, + updated_at: now + } + end) + end) + + insert_batches(participants, ReadyCheckParticipant) + + info("ready checks: #{length(checks)} (this set ignores --count) -> /admin/matchmaking") + end + + defp participant_state(%{status: "passed"}, _index), do: "ready" + defp participant_state(%{status: "failed", reason: "declined"}, 0), do: "declined" + defp participant_state(%{status: "failed", reason: "timeout"}, 0), do: "timed_out" + defp participant_state(%{status: "pending"}, 0), do: "pending" + defp participant_state(_check, _index), do: "ready" + + # ── Chat moderation ─────────────────────────────────────────────────────── + + # Reports point at real messages in real rooms, so the queue's content + # snapshots, "reported user" links and per-scope mute lists all resolve the + # way they do in production. + defp seed_chat_moderation(user_ids) do + rooms = chat_rooms(user_ids) + clean_chat_moderation(rooms) + + words = seed_filter_words() + messages = seed_chat_messages(user_ids, rooms) + reports = seed_chat_reports(messages, hd(user_ids)) + mutes = seed_chat_mutes(user_ids, rooms) + + info( + "chat moderation: #{words} filter words, #{reports} reports, #{mutes} mutes -> /admin/chat_reports" + ) + end + + defp chat_rooms(user_ids) do + now = DateTime.utc_now(:second) + [host | _rest] = user_ids + + lobby = + upsert(Lobby, [title: @chat_lobby_title], %{ + title: @chat_lobby_title, + host_id: host, + max_users: 4, + metadata: %{}, + state: "created", + state_changed_at: now + }) + + party = upsert(Party, [leader_id: host], %{leader_id: host, max_size: 4, metadata: %{}}) + + %{"group" => demo_group(user_ids).id, "lobby" => lobby.id, "party" => party.id} + end + + # Reports go before their messages: deleting a message only nils out the + # report pointing at it. + defp clean_chat_moderation(rooms) do + room_ids = Map.values(rooms) + message_ids = from(m in Message, where: m.chat_ref_id in ^room_ids, select: m.id) + + Repo.delete_all(from(r in Report, where: r.message_id in subquery(message_ids))) + Repo.delete_all(from(m in Message, where: m.chat_ref_id in ^room_ids)) + Repo.delete_all(from(m in Mute, where: m.user_id in subquery(demo_user_ids()))) + end + + # Words go in through the context: it stores them normalized (a raw insert + # would never match) and mirrors them into the ETS blocklist. + defp seed_filter_words do + clean_filter_words() + + Enum.count(@filter_words, fn {word, severity, match_mode, lang} -> + match?( + {:ok, _word}, + Moderation.create_filter_word(%{ + "word" => word, + "severity" => severity, + "match_mode" => match_mode, + "lang" => lang + }) + ) + end) + end + + defp clean_filter_words do + words = + Enum.map(@filter_words, fn {word, _severity, _mode, _lang} -> Normalizer.normalize(word) end) + + from(w in FilterWord, where: w.word in ^words) + |> Repo.all() + |> Enum.each(&Moderation.delete_filter_word/1) + end + + defp seed_chat_messages(user_ids, rooms) do + now = DateTime.utc_now(:second) + + rows = + user_ids + |> at_least(@min_reports) + |> Enum.with_index() + |> Enum.map(fn {sender_id, i} -> + chat_type = Enum.at(@chat_room_types, rem(i, length(@chat_room_types))) + {content, _reason, flagged?} = chat_line(i) + at = DateTime.add(now, -(i * 900 + 60), :second) + + %{ + id: UUIDv7.generate(), + sender_id: sender_id, + chat_type: chat_type, + chat_ref_id: Map.fetch!(rooms, chat_type), + content: content, + metadata: if(flagged?, do: %{"flagged" => true}, else: %{}), + inserted_at: at, + updated_at: at + } + end) + + insert_batches(rows, Message) + rows + end + + # Every fifth message is one the filter flagged itself, so the queue mixes + # system-filed reports (nil reporter) into the player-filed ones. Pure and + # index-keyed, so the report built for message `i` reads off the same line. + defp chat_line(i) when rem(i, 5) == 0 do + {content, word} = Enum.at(@flagged_lines, rem(div(i, 5), length(@flagged_lines))) + {content, "Filter: " <> word, true} + end + + defp chat_line(i) do + {content, reason} = Enum.at(@chat_lines, rem(i, length(@chat_lines))) + {content, reason, false} + end + + # The reporter is the next player along, since a player cannot report their + # own message (`Chat.report_message/3` rejects it). + defp seed_chat_reports(messages, moderator_id) do + now = DateTime.utc_now(:second) + + reporters = + messages + |> Stream.map(& &1.sender_id) + |> Stream.cycle() + |> Stream.drop(1) + |> Enum.take(length(messages)) + + rows = + messages + |> Enum.zip(reporters) + |> Enum.with_index() + |> Enum.map(fn {{message, reporter_id}, i} -> + {status, note} = report_status(i) + {_content, reason, flagged?} = chat_line(i) + at = DateTime.add(now, -i * 900, :second) + + %{ + id: UUIDv7.generate(), + reporter_id: if(flagged?, do: nil, else: reporter_id), + message_id: message.id, + reported_user_id: message.sender_id, + content_snapshot: message.content, + reason: reason, + status: status, + resolved_by: if(note, do: moderator_id), + resolution_note: note, + resolved_at: if(note, do: now), + inserted_at: at, + updated_at: at + } + end) + + insert_batches(rows, Report) + length(rows) + end + + defp report_status(i) do + case rem(i, 4) do + 0 -> {"open", nil} + 1 -> {"reviewing", nil} + 2 -> {"actioned", "24h mute — third report this week."} + 3 -> {"dismissed", "Heated, but inside the rules."} + end + end + + # Every scope, and a spread of permanent / expiring / already expired so the + # sweep has rows to reap. One mute per player keeps the unique index happy. + defp seed_chat_mutes(user_ids, rooms) do + now = DateTime.utc_now(:second) + [moderator | rest] = user_ids + + rows = + rest + |> Enum.take(@max_mutes) + |> Enum.with_index() + |> Enum.map(fn {user_id, i} -> + {scope, scope_ref_id} = mute_scope(rooms, i) + {expires_at, reason} = mute_expiry(now, i) + + %{ + id: UUIDv7.generate(), + user_id: user_id, + scope: scope, + scope_ref_id: scope_ref_id, + expires_at: expires_at, + reason: reason, + muted_by: moderator, + inserted_at: now, + updated_at: now + } + end) + + insert_batches(rows, Mute) + length(rows) + end + + defp mute_scope(rooms, i) do + case rem(i, 4) do + 0 -> {"global", nil} + 1 -> {"lobby", Map.fetch!(rooms, "lobby")} + 2 -> {"group", Map.fetch!(rooms, "group")} + 3 -> {"party", Map.fetch!(rooms, "party")} + end + end + + defp mute_expiry(now, i) do + case rem(div(i, 4), 3) do + 0 -> {nil, "Permanent — ban evasion."} + 1 -> {DateTime.add(now, 900), "Cooling off, back in 15 minutes."} + 2 -> {DateTime.add(now, -86_400), "Expired yesterday; waiting for the sweep."} + end + end + + # A small --count still has to fill more than one page of the report queue. + defp at_least(ids, minimum) when length(ids) >= minimum, do: ids + defp at_least(ids, minimum), do: ids |> Stream.cycle() |> Enum.take(minimum) + + defp demo_user_ids do + from(u in User, where: like(u.device_id, ^"#{@prefix}-%"), select: u.id) + end + + defp clean do + lb = Repo.get_by(Leaderboard, slug: @leaderboard_slug) + group = Repo.get_by(Group, title: @group_title) + tournament = Repo.get_by(Tournament, slug: @tournament_slug) + + if lb, do: Repo.delete_all(from(r in Record, where: r.leaderboard_id == ^lb.id)) + if group, do: Repo.delete_all(from(m in GroupMember, where: m.group_id == ^group.id)) + if tournament, do: Repo.delete_all(from(e in Entry, where: e.tournament_id == ^tournament.id)) + + if lb, do: Repo.delete(lb) + if group, do: Repo.delete(group) + if tournament, do: Repo.delete(tournament) + + # Before the players go: seeded lobbies reference them as host. + clean_lobby_snapshots() + clean_quests() + + # Reports, mutes and messages cascade with their players; the blocklist has + # no player to hang off. + clean_filter_words() + + {users, _} = Repo.delete_all(from(u in User, where: like(u.device_id, ^"#{@prefix}-%"))) + + Gamend.Cache.delete_all() + info("removed demo data (#{users} players)") + end + + # ── Helpers ─────────────────────────────────────────────────────────────── + + # insert_all rejects oversized statements, so rows go in batches. + defp insert_batches([], _schema), do: :ok + + defp insert_batches(rows, schema) do + rows + |> Enum.chunk_every(@batch) + |> Enum.each(&Repo.insert_all(schema, &1)) + end + + defp upsert(schema, lookup, attrs) do + case Repo.get_by(schema, lookup) do + nil -> + now = DateTime.utc_now(:second) + + attrs = + attrs + |> Map.put(:id, UUIDv7.generate()) + |> Map.put_new(:inserted_at, now) + |> Map.put_new(:updated_at, now) + + Repo.insert_all(schema, [attrs]) + Repo.get_by!(schema, lookup) + + found -> + found + end + end + + # ── Lobby snapshots ─────────────────────────────────────────────────────── + + defp seed_lobby_snapshots(user_ids) do + previous = Application.get_env(:gamend_core, Gamend.LobbySnapshots, []) + + # Capture is off by default, so force it on for the duration rather than + # making the operator set an env var to seed demo data. + Application.put_env( + :gamend_core, + Gamend.LobbySnapshots, + Keyword.merge(previous, enabled: true) + ) + + hosts = Enum.take(user_ids, @max_runs) + info("recording #{length(hosts)} runs (this set ignores --count)") + + hosts + |> Enum.with_index() + |> Enum.each(fn {host_id, index} -> record_run(host_id, index) end) + + Writer.flush() + backdate_runs() + + Application.put_env(:gamend_core, Gamend.LobbySnapshots, previous) + end + + # Run 0 is the interesting one: it replays the shape of the July 2026 + # rubber-banding bug, where the boat's distance and the slow's anchor both + # revert. Expanding snapshot 4 in the admin view shows it as a change *back*. + defp record_run(host_id, 0), do: play(host_id, "rubber-band", rubber_band_frames()) + defp record_run(host_id, 1), do: play(host_id, "hook error", error_frames()) + defp record_run(host_id, index), do: play(host_id, "run #{index}", normal_frames(index)) + + defp play(host_id, label, frames) do + {:ok, lobby} = + Gamend.Lobbies.create_lobby(%{ + title: "#{@lobby_title_prefix} — #{label}", + host_id: host_id, + max_users: 4 + }) + + Enum.each(frames, fn frame -> + {:ok, _} = + Gamend.Lobbies.update_lobby( + Repo.get!(Lobby, lobby.id), + %{metadata: frame.metadata} + ) + + LobbySnapshots.capture_lobby(lobby.id, frame.trigger, + sync: true, + flagged: Map.get(frame, :flagged, false), + user_id: host_id + ) + + Enum.each(Map.get(frame, :events, []), fn {kind, payload} -> + LobbySnapshots.record_event(lobby.id, kind, payload, user_id: host_id) + end) + end) + end + + defp boat(distance, speed, anchor) do + %{ + "boat_adventure" => %{ + "distance" => distance, + "speed" => speed, + "effects" => %{ + "speed_reduced" => %{"distance_at_start" => anchor, "duration_ms" => 4000} + }, + "actors" => [%{"type" => "starfish", "wave" => 1, "hp" => 2}] + }, + "word_match" => %{"score" => round(distance / 10), "current_word" => "harbour"}, + "game_state" => "running" + } + end + + defp rubber_band_frames do + [ + %{trigger: "hook:start_boat_game", metadata: boat(0.0, 100, 0.0)}, + %{ + trigger: "hook:guess_word", + metadata: boat(120.0, 100, 0.0), + events: [{"boat.speed", %{"from" => 100, "to" => 100, "gap" => 91.2}}] + }, + %{ + trigger: "timer:scheduled_collision", + metadata: boat(250.0, 50, 250.0), + events: [ + {"boat.collision", %{"actor" => "starfish", "wave" => 1, "damage" => 1}}, + {"boat.speed", %{"from" => 100, "to" => 50, "gap" => 78.39, "targets_ahead" => 8}} + ] + }, + %{trigger: "hook:guess_word", metadata: boat(370.0, 50, 250.0)}, + # The bug: a stale client echo re-anchors the slow, dragging distance back. + %{ + trigger: "hook:guess_word", + metadata: boat(250.0, 50, 250.0), + events: [{"boat.merge_divergence", %{"current" => 370.0, "incoming" => 250.0}}] + }, + %{trigger: "hook:guess_word", metadata: boat(480.0, 100, 250.0)}, + %{trigger: "lobby:deleted", metadata: boat(480.0, 100, 250.0) |> finished()} + ] + end + + defp error_frames do + [ + %{trigger: "hook:start_boat_game", metadata: boat(0.0, 100, 0.0)}, + %{trigger: "hook:guess_word", metadata: boat(90.0, 100, 0.0)}, + %{ + trigger: "hook:finish_boat_game", + metadata: boat(90.0, 100, 0.0), + flagged: true, + events: [{"hook.error", %{"reason" => "function_clause", "hook" => "finish_boat_game"}}] + } + ] + end + + defp normal_frames(index) do + steps = 3 + rem(index, 3) + + frames = + for step <- 0..steps do + distance = step * 140.0 + index * 10 + speed = if rem(step, 3) == 2, do: 50, else: 100 + + %{ + trigger: if(step == 0, do: "hook:start_boat_game", else: "hook:guess_word"), + metadata: boat(distance, speed, if(speed == 50, do: distance, else: 0.0)), + events: + if(speed == 50, + do: [{"boat.speed", %{"from" => 100, "to" => 50, "gap" => 62.5}}], + else: [] + ) + } + end + + # Every run ends the way a real one does: the last member leaves and the + # lobby is torn down. + teardown = %{trigger: "lobby:deleted", metadata: finished(List.last(frames).metadata)} + + Enum.reverse([teardown | Enum.reverse(frames)]) + end + + defp finished(metadata), do: put_in(metadata, ["game_state"], "finished") + + # Captures happen milliseconds apart, which makes every run look simultaneous + # in the list view. Spread them so durations and start times read like real + # sessions — and so the retention window has something meaningful to act on. + defp backdate_runs do + lobby_ids = + from(l in Lobby, + where: like(l.title, ^"#{@lobby_title_prefix}%"), + order_by: l.inserted_at, + select: l.id + ) + |> Repo.all() + + lobby_ids + |> Enum.with_index() + |> Enum.each(fn {lobby_id, run_index} -> + started = DateTime.add(DateTime.utc_now(), -(run_index * 5 + 1) * 3600, :second) + + shift_rows(Snapshot, lobby_id, started) + shift_rows(SnapshotEvent, lobby_id, started) + end) + end + + defp shift_rows(schema, lobby_id, started) do + ids = + from(r in schema, + where: r.lobby_id == ^lobby_id, + order_by: [asc: r.inserted_at, asc: r.id], + select: r.id + ) + |> Repo.all() + + ids + |> Enum.with_index() + |> Enum.each(fn {id, step} -> + at = DateTime.add(started, step * 6, :second) + Repo.update_all(from(r in schema, where: r.id == ^id), set: [inserted_at: at]) + end) + end + + defp clean_lobby_snapshots do + lobby_ids = + from(l in Lobby, where: like(l.title, ^"#{@lobby_title_prefix}%")) + |> Repo.all() + |> Enum.map(& &1.id) + + if lobby_ids != [] do + Repo.delete_all(from(s in Snapshot, where: s.lobby_id in ^lobby_ids)) + + Repo.delete_all(from(e in SnapshotEvent, where: e.lobby_id in ^lobby_ids)) + + Enum.each(lobby_ids, fn id -> + case Repo.get(Lobby, id) do + nil -> :ok + lobby -> Gamend.Lobbies.delete_lobby(lobby) + end + end) + end + + # Blobs are content-addressed and may be shared with real runs, so they are + # left for the retention sweep's reference-aware GC rather than deleted here. + info("removed #{length(lobby_ids)} seeded runs") + end + + defp info(message), do: IO.puts(" #{message}") +end diff --git a/apps/gamend_core/lib/gamend/hooks.ex b/apps/gamend_core/lib/gamend/hooks.ex index feb3536d1..f8e344cf1 100644 --- a/apps/gamend_core/lib/gamend/hooks.ex +++ b/apps/gamend_core/lib/gamend/hooks.ex @@ -18,6 +18,7 @@ defmodule Gamend.Hooks do alias Gamend.Chat.Report alias Gamend.Groups.Group alias Gamend.Hooks.Default, as: Default + alias Gamend.Hooks.Defaults alias Gamend.Hooks.PluginManager alias Gamend.Lobbies.Lobby alias Gamend.Parties.Party @@ -353,6 +354,28 @@ defmodule Gamend.Hooks do @callback after_lobby_host_change(Lobby.t(), String.t()) :: any() + @doc """ + Use this macro to get default implementations for all callbacks. + + This allows you to only implement the callbacks you need. It injects the + same defaults as the SDK's `use Gamend.Hooks`, so a plugin compiles the same + against the SDK (a Mix build) and against the engine (an in-process build, + `Gamend.Hooks.PluginBuilder`). + + ## Example + + defmodule MyGame.Hooks do + use Gamend.Hooks + + @impl true + def after_user_register(user) do + # Only implement what you need + :ok + end + end + """ + defmacro __using__(_opts), do: Defaults.quoted() + @doc "Return the configured module that implements the hooks behaviour." def module do # Primary config lives under :gamend_core. diff --git a/apps/gamend_core/lib/gamend/hooks/defaults.ex b/apps/gamend_core/lib/gamend/hooks/defaults.ex new file mode 100644 index 000000000..36a15c59a --- /dev/null +++ b/apps/gamend_core/lib/gamend/hooks/defaults.ex @@ -0,0 +1,371 @@ +defmodule Gamend.Hooks.Defaults do + @moduledoc false + + # What `use Gamend.Hooks` injects into a hooks module: `@behaviour + # Gamend.Hooks`, a default for every callback, all overridable. + # + # The one copy: `mix gen.sdk` writes this file into the SDK + # (sdk/lib/gamend/hooks/defaults.ex), whose `use Gamend.Hooks` calls it too, + # so a plugin behaves the same whether Mix builds it against the SDK or the + # engine builds it in-process (`Gamend.Hooks.PluginBuilder.InProcess`). + # + # Quoted code split across three attributes: one quote block this long is + # past credo's LongQuoteBlocks, and a function holding one counts every + # branch of the code it quotes. + + default_callbacks = + quote do + @impl true + def after_startup, do: :ok + + @impl true + def before_stop, do: :ok + + @impl true + def on_custom_hook(_hook, _args), do: {:error, :not_implemented} + + @impl true + def after_user_register(_user), do: :ok + + @impl true + def after_user_logged_in(_user), do: :ok + + @impl true + def after_user_updated(_user), do: :ok + + @impl true + def after_user_online(_user), do: :ok + + @impl true + def after_user_offline(_user), do: :ok + + @impl true + def after_user_deleted(_user), do: :ok + + @impl true + def after_wallet_changed(_change), do: :ok + + @impl true + def after_inventory_changed(_change), do: :ok + + @impl true + def before_user_register(_user, attrs), do: {:ok, attrs} + + @impl true + def before_user_update(_user, attrs), do: {:ok, attrs} + + @impl true + def validate_username(_username), do: :default + + @impl true + def before_lobby_create(attrs), do: {:ok, attrs} + + @impl true + def after_lobby_create(_lobby), do: :ok + + @impl true + def before_lobby_join(user, lobby, opts), do: {:ok, {user, lobby, opts}} + + @impl true + def before_group_create(_user, attrs), do: {:ok, attrs} + + @impl true + def before_group_join(user, group, opts), do: {:ok, {user, group, opts}} + + @impl true + def after_group_create(_group), do: :ok + + @impl true + def before_group_update(_group, attrs), do: {:ok, attrs} + + @impl true + def after_group_updated(_group), do: :ok + + @impl true + def after_group_join(_user_id, _group), do: :ok + + @impl true + def after_group_leave(_user_id, _group_id), do: :ok + + @impl true + def after_group_deleted(_group), do: :ok + + @impl true + def after_group_kick(_admin_id, _target_id, _group_id), do: :ok + + @impl true + def before_group_delete(group), do: {:ok, group} + + @impl true + def before_group_kick(admin_id, target_id, group_id), + do: {:ok, {admin_id, target_id, group_id}} + + @impl true + def before_party_join(user, party), do: {:ok, {user, party}} + + @impl true + def before_party_kick(admin, target, party), + do: {:ok, {admin, target, party}} + + @impl true + def before_purchase(_user, product), do: {:ok, product} + + @impl true + def after_purchase_fulfilled(_purchase), do: :ok + + @impl true + def after_purchase_revoked(_purchase), do: :ok + + @impl true + def after_entitlement_changed(_entitlement), do: :ok + + @impl true + def after_score_submitted(_record), do: :ok + + @impl true + def before_party_create(_user, attrs), do: {:ok, attrs} + + @impl true + def after_party_create(_party), do: :ok + + @impl true + def before_party_update(_party, attrs), do: {:ok, attrs} + + @impl true + def after_party_updated(_party), do: :ok + + @impl true + def after_party_join(_user, _party), do: :ok + + @impl true + def after_party_leave(_user, _party_id), do: :ok + end + + @default_callbacks default_callbacks + + more_default_callbacks = + quote do + @impl true + def after_party_kick(_target, _leader, _party), do: :ok + + @impl true + def after_party_disband(_party), do: :ok + + @impl true + def before_quest_claim(_user_id, _quest, _progress), do: :ok + + @impl true + def after_quest_completed(_progress), do: :ok + + @impl true + def after_quest_claimed(_progress), do: :ok + + @impl true + def after_lobby_join(_user, _lobby), do: :ok + + @impl true + def before_chat_message(_user, attrs), do: {:ok, attrs} + + @impl true + def after_chat_message(_message), do: :ok + + @impl true + def after_chat_message_reported(_report), do: :ok + + @impl true + def after_user_muted(_mute), do: :ok + + @impl true + def before_push_send(_user_id, message), do: {:ok, message} + + @impl true + def after_push_sent(_user_id, _message, _result), do: :ok + + @impl true + def before_lobby_leave(user, lobby), do: {:ok, {user, lobby}} + + @impl true + def after_lobby_leave(_user, _lobby), do: :ok + + @impl true + def before_lobby_update(_lobby, attrs), do: {:ok, attrs} + + @impl true + def after_lobby_updated(_lobby), do: :ok + + @impl true + def before_lobby_delete(lobby), do: {:ok, lobby} + + @impl true + def after_lobby_deleted(_lobby), do: :ok + + @impl true + def before_lobby_state_change(_lobby, _from, _to), do: :ok + + @impl true + def after_lobby_state_changed(_lobby, _from, _to), do: :ok + + @impl true + def before_lobby_kick(host, target, lobby), + do: {:ok, {host, target, lobby}} + + @impl true + def after_lobby_kick(_host, _target, _lobby), do: :ok + + @impl true + def after_lobby_host_change(_lobby, _new_host_id), do: :ok + + @impl true + def before_ready_check_open(_subject, _user_ids), do: :ok + + @impl true + def after_ready_check_passed(_check), do: :ok + + @impl true + def after_ready_check_failed(_check, _reason, _not_ready), do: :ok + + @impl true + def before_kv_get(_key, _opts), do: :public + + @impl true + def before_matchmaking_join(_user, attrs), do: {:ok, attrs} + + @impl true + def after_matchmaking_join(_user, _ticket), do: :ok + + @impl true + def after_matchmaking_cancel(_user_id, _count), do: :ok + + @impl true + def matchmaking_form_matches(_params, _tickets), do: :default + + @impl true + def after_matchmaking_matched(_tickets, _lobby_id), do: :ok + + @impl true + def before_tournament_register(_user, tournament), + do: {:ok, tournament} + + @impl true + def after_tournament_register(_user, _tournament), do: :ok + + @impl true + def before_tournament_leave(_user, tournament), do: {:ok, tournament} + + @impl true + def tournament_match_ready(_match), do: :ok + + @impl true + def tournament_match_expired(_match), do: :ok + + @impl true + def before_tournament_result(_match, winner), do: {:ok, winner} + + @impl true + def after_tournament_match_resolved(_match), do: :ok + + @impl true + def after_tournament_finished(_tournament, _standings), do: :ok + end + + @more_default_callbacks more_default_callbacks + + overridable_callbacks = + quote do + defoverridable after_startup: 0, + before_stop: 0, + before_group_delete: 1, + before_group_kick: 3, + before_party_join: 2, + before_party_kick: 3, + before_purchase: 2, + after_purchase_fulfilled: 1, + after_purchase_revoked: 1, + after_entitlement_changed: 1, + after_score_submitted: 1, + before_user_register: 2, + after_user_register: 1, + after_user_logged_in: 1, + after_user_updated: 1, + after_user_online: 1, + after_user_offline: 1, + after_user_deleted: 1, + after_wallet_changed: 1, + after_inventory_changed: 1, + before_user_update: 2, + validate_username: 1, + on_custom_hook: 2, + before_lobby_create: 1, + after_lobby_create: 1, + before_group_create: 2, + before_group_join: 3, + after_group_create: 1, + before_group_update: 2, + after_group_updated: 1, + after_group_join: 2, + after_group_leave: 2, + after_group_deleted: 1, + after_group_kick: 3, + before_party_create: 2, + after_party_create: 1, + before_party_update: 2, + after_party_updated: 1, + after_party_join: 2, + after_party_leave: 2, + after_party_kick: 3, + after_party_disband: 1, + before_quest_claim: 3, + after_quest_completed: 1, + after_quest_claimed: 1, + before_lobby_join: 3, + after_lobby_join: 2, + before_chat_message: 2, + after_chat_message: 1, + after_chat_message_reported: 1, + after_user_muted: 1, + before_push_send: 2, + after_push_sent: 3, + before_lobby_leave: 2, + after_lobby_leave: 2, + before_lobby_update: 2, + after_lobby_updated: 1, + before_lobby_delete: 1, + after_lobby_deleted: 1, + before_lobby_state_change: 3, + after_lobby_state_changed: 3, + before_lobby_kick: 3, + after_lobby_kick: 3, + after_lobby_host_change: 2, + before_ready_check_open: 2, + after_ready_check_passed: 1, + after_ready_check_failed: 3, + before_kv_get: 2, + before_matchmaking_join: 2, + after_matchmaking_join: 2, + after_matchmaking_cancel: 2, + matchmaking_form_matches: 2, + after_matchmaking_matched: 2, + before_tournament_register: 2, + after_tournament_register: 2, + before_tournament_leave: 2, + tournament_match_ready: 1, + tournament_match_expired: 1, + before_tournament_result: 2, + after_tournament_match_resolved: 1, + after_tournament_finished: 2 + end + + @overridable_callbacks overridable_callbacks + + @doc false + @spec quoted() :: Macro.t() + def quoted do + quote do + @behaviour Gamend.Hooks + + unquote(@default_callbacks) + unquote(@more_default_callbacks) + unquote(@overridable_callbacks) + end + end +end diff --git a/apps/gamend_core/lib/gamend/hooks/dynamic_rpcs.ex b/apps/gamend_core/lib/gamend/hooks/dynamic_rpcs.ex index 6e9650490..be6a308f5 100644 --- a/apps/gamend_core/lib/gamend/hooks/dynamic_rpcs.ex +++ b/apps/gamend_core/lib/gamend/hooks/dynamic_rpcs.ex @@ -74,6 +74,14 @@ defmodule Gamend.Hooks.DynamicRpcs do :ok end + @doc "Forget one plugin's exports, leaving every other plugin's in place." + @spec reset_plugin(plugin_name()) :: :ok + def reset_plugin(plugin_name) when is_binary(plugin_name) do + ensure_table!() + true = :ets.match_delete(@table, {{plugin_name, :_}, :_}) + :ok + end + @spec register_exports(plugin_name(), any()) :: {:ok, non_neg_integer()} | {:error, term()} def register_exports(plugin_name, raw) when is_binary(plugin_name) do ensure_table!() diff --git a/apps/gamend_core/lib/gamend/hooks/plugin_builder.ex b/apps/gamend_core/lib/gamend/hooks/plugin_builder.ex index 47bf009b0..e4defee6e 100644 --- a/apps/gamend_core/lib/gamend/hooks/plugin_builder.ex +++ b/apps/gamend_core/lib/gamend/hooks/plugin_builder.ex @@ -1,37 +1,79 @@ defmodule Gamend.Hooks.PluginBuilder do @moduledoc """ - Builds an OTP plugin bundle from plugin source code on disk. - - This is intended for admin-only workflows in development/self-hosted setups. - It runs `mix` commands on the server host/container. + Builds an OTP plugin bundle (`ebin/*.beam` + `ebin/.app`) from plugin + source code on disk, for the admin Config page and the command line. + + Two ways, picked per build: + + * **Mix**, when a `mix` executable is on the PATH (development, the + Dockerfile's `full` image): `mix deps.get`, `mix gamend.gdscript.compile` + for a GDScript plugin, `mix compile`, `mix plugin.bundle`, each in the + plugin's directory. + * **In-process**, when it is not (a release: the downloadable engine, the + `release` image): `Gamend.Hooks.PluginBuilder.InProcess` compiles the + plugin inside the running VM with the Elixir compiler the release ships. + It handles Elixir and GDScript plugins whose dependencies the engine + already ships or the plugin carries prebuilt under `deps//ebin`. + + Either way the result loads through `Gamend.Hooks.PluginManager` exactly as + a hand-built bundle does. Building runs the plugin's code (its `mix.exs` + under Mix, its module bodies in both), so this is for admins and the + operator's shell only. """ + alias Gamend.Hooks.PluginBuilder.InProcess + @type step_result :: %{ cmd: String.t(), status: non_neg_integer(), output: String.t() } + @type mode :: :mix | :in_process + @type build_result :: %{ ok?: boolean(), plugin: String.t(), source_dir: String.t(), + mode: mode(), started_at: DateTime.t(), finished_at: DateTime.t(), steps: [step_result()] } @doc """ - Whether this image can build plugin bundles at all. + Whether this image can build plugin bundles at all: with `mix`, or in-process + with the Elixir compiler. - The build shells out to `mix`, which a release image (the Dockerfile's - `release` target) does not ship: it carries compiled `.beam` files and no - build toolchain. Callers check this so an image without Mix presents a - disabled control with a reason, instead of three `System.cmd/3` calls that - fail on :enoent and surface as a build error. + The in-process build needs only the `elixir` and `compiler` applications, + which every release carries (`elixir` depends on `compiler`), so this is + true in a release too. Callers still check it so an image without either + presents a disabled control with a reason rather than a failed build. """ @spec available?() :: boolean() - def available?, do: System.find_executable("mix") != nil + def available?, do: mode() != nil + + @doc """ + How `build/1` would build here: `:mix` when this server itself runs under + Mix and a `mix` executable is on the PATH, else `:in_process` when the + compiler is loadable, else `nil`. + + A release builds in-process even with a `mix` on the PATH: that one belongs + to some other Elixir install, and started from a release it inherits the + release's ERTS environment (`ROOTDIR`, `BINDIR`) and fails to boot. + """ + @spec mode() :: mode() | nil + def mode do + cond do + mix_available?() -> :mix + InProcess.available?() -> :in_process + true -> nil + end + end + + defp mix_available? do + Code.ensure_loaded?(Mix.Project) and System.find_executable("mix") != nil + end @spec sources_dir() :: String.t() def sources_dir do @@ -60,8 +102,25 @@ defmodule Gamend.Hooks.PluginBuilder do end end - @spec build(String.t()) :: {:ok, build_result()} | {:error, term()} - def build(plugin_name) when is_binary(plugin_name) do + @doc """ + Builds one plugin from `sources_dir/0`. + + Returns `{:ok, result}` for every build that ran, successful or not + (`result.ok?` tells, and `result.steps` carries each step's output: compiler + errors, missing dependencies), and `{:error, reason}` when none could run: + `{:unknown_plugin, name}`, `:mix_unavailable`, `:build_unavailable`. + + Options: + + * `:mode` - `:auto` (default: Mix when on the PATH, else in-process), + `:mix` or `:in_process`. + + A plugin the manager has loaded is stopped for an in-process build and + started again when it ends (see `Gamend.Hooks.PluginBuilder.InProcess`). A + Mix build leaves it running; `PluginManager.reload/0` picks the new bundle up. + """ + @spec build(String.t(), keyword()) :: {:ok, build_result()} | {:error, term()} + def build(plugin_name, opts \\ []) when is_binary(plugin_name) do source_dir = sources_dir() # The name has to be one of the plugins we actually offer, checked here @@ -75,68 +134,103 @@ defmodule Gamend.Hooks.PluginBuilder do # sources directory with no basename and no allowlist, so # `../../../../tmp/x` reached `System.cmd`. Arguments are passed as a list, # so there was never a shell-injection hole; the working directory was the - # whole vulnerability. + # whole vulnerability. The in-process build compiles the directory's code + # in this VM, which makes the allowlist no less necessary. plugin_name = Path.basename(plugin_name) plugin_dir = Path.join(source_dir, plugin_name) - cond do - not available?() -> - return_error(:mix_unavailable) - - plugin_name not in list_buildable_plugins() -> - return_error({:unknown_plugin, plugin_name}) + with {:ok, mode} <- pick_mode(Keyword.get(opts, :mode, :auto)) do + cond do + plugin_name not in list_buildable_plugins() -> + {:error, {:unknown_plugin, plugin_name}} - File.exists?(Path.join(plugin_dir, "mix.exs")) -> - run_build_steps(plugin_name, source_dir, plugin_dir) + File.exists?(Path.join(plugin_dir, "mix.exs")) -> + run_build(mode, plugin_name, source_dir, plugin_dir) - true -> - return_error({:missing_mix_project, plugin_dir}) + true -> + {:error, {:missing_mix_project, plugin_dir}} + end end rescue e -> {:error, {:build_failed, Exception.message(e)}} end - defp run_build_steps(plugin_name, source_dir, plugin_dir) do - started_at = DateTime.utc_now() + @doc """ + Builds every plugin `list_buildable_plugins/0` offers, one after another, + and returns `[{name, build_result}]` in that order. Takes the options of + `build/2`. + """ + @spec build_all(keyword()) :: [{String.t(), {:ok, build_result()} | {:error, term()}}] + def build_all(opts \\ []) do + for name <- list_buildable_plugins(), do: {name, build(name, opts)} + end - env = - case System.get_env("MIX_ENV") do - nil -> [] - mix_env -> [{"MIX_ENV", mix_env}] - end + defp pick_mode(:auto) do + case mode() do + nil -> {:error, :build_unavailable} + mode -> {:ok, mode} + end + end - steps = - [ - {"mix deps.get", ["deps.get"]}, - {"mix compile", ["compile"]}, - {"mix plugin.bundle --verbose", ["plugin.bundle", "--verbose"]} - ] - |> Enum.map(fn {label, argv} -> - {output, status} = - System.cmd("mix", argv, - cd: plugin_dir, - env: env, - stderr_to_stdout: true - ) - - %{cmd: label, status: status, output: output} - end) + defp pick_mode(:mix) do + if mix_available?(), do: {:ok, :mix}, else: {:error, :mix_unavailable} + end - finished_at = DateTime.utc_now() + defp pick_mode(:in_process) do + if InProcess.available?(), do: {:ok, :in_process}, else: {:error, :build_unavailable} + end - ok? = Enum.all?(steps, &(&1.status == 0)) + defp run_build(mode, plugin_name, source_dir, plugin_dir) do + started_at = DateTime.utc_now() + + steps = + case mode do + :mix -> run_mix_steps(plugin_dir) + :in_process -> InProcess.run(plugin_name, plugin_dir) + end {:ok, %{ - ok?: ok?, + ok?: steps != [] and Enum.all?(steps, &(&1.status == 0)), plugin: plugin_name, source_dir: source_dir, + mode: mode, started_at: started_at, - finished_at: finished_at, + finished_at: DateTime.utc_now(), steps: steps }} end - defp return_error(reason), do: {:error, reason} + defp run_mix_steps(plugin_dir) do + env = + case System.get_env("MIX_ENV") do + nil -> [] + mix_env -> [{"MIX_ENV", mix_env}] + end + + # A GDScript plugin compiles `gen/`, which is generated from `scripts/`; + # without this step the build would bundle whatever `gen/` held. + gdscript = + if InProcess.gdscript_scripts(plugin_dir) != [], + do: [{"mix gamend.gdscript.compile", ["gamend.gdscript.compile"]}], + else: [] + + ([{"mix deps.get", ["deps.get"]}] ++ + gdscript ++ + [ + {"mix compile", ["compile"]}, + {"mix plugin.bundle --verbose", ["plugin.bundle", "--verbose"]} + ]) + |> Enum.map(fn {label, argv} -> + {output, status} = + System.cmd("mix", argv, + cd: plugin_dir, + env: env, + stderr_to_stdout: true + ) + + %{cmd: label, status: status, output: output} + end) + end end diff --git a/apps/gamend_core/lib/gamend/hooks/plugin_builder/in_process.ex b/apps/gamend_core/lib/gamend/hooks/plugin_builder/in_process.ex new file mode 100644 index 000000000..5994eb057 --- /dev/null +++ b/apps/gamend_core/lib/gamend/hooks/plugin_builder/in_process.ex @@ -0,0 +1,601 @@ +defmodule Gamend.Hooks.PluginBuilder.InProcess do + @moduledoc """ + Builds a plugin bundle inside the running VM, without Mix. + + A release (the downloadable engine, the Dockerfile's `release` image) ships + the Elixir compiler but no `mix` executable, so `Gamend.Hooks.PluginBuilder` + falls back to this. It produces what `mix plugin.bundle` does for the + plugin's own code: `ebin/*.beam` plus `ebin/.app`, loadable by + `Gamend.Hooks.PluginManager` unchanged. + + Steps, each reported as a `t:Gamend.Hooks.PluginBuilder.step_result/0`: + + 1. Read `mix.exs` without evaluating it (`Gamend.Hooks.PluginBuilder.Project`). + 2. GDScript plugins (`scripts/*.gd`): transpile into `gen/` with + `Gamend.GDScript`, as `mix gamend.gdscript.compile` does. + 3. Check dependencies. One the engine ships (phoenix, jason, req, ecto…) + or that sits prebuilt in `deps//ebin` is fine; `gamend_sdk` and + `gamend_plugin_tools` are compile-time only and ignored. Anything else + fails the build naming it: there is no Hex here to fetch it. + 4. Compile `elixirc_paths/**/*.ex` with `Kernel.ParallelCompiler` into a + temporary directory, then write the `.app` and swap it in as `ebin/`. + A failed build leaves the previous `ebin/` untouched. + + The compiler runs in this VM, so a plugin that is loaded is stopped for the + build (`PluginManager.suspend/1`) and started again afterwards + (`PluginManager.resume/1`), on the new bundle when the build succeeded and on + the old one when it failed. Unloading first matters: the compiler takes a + module that is already loaded as the one to compile against, so a sibling's + old macros and structs would end up in the new build. + + Limits: no Hex dependencies beyond what the engine ships or the plugin + carries prebuilt, no Erlang sources (`src/*.erl`), no Gleam, no + `config/config.exs`, and the engine's protocols are consolidated, so a + `defimpl` of an engine protocol (`Jason.Encoder`, `String.Chars`…) has no + effect; the compiler's warning saying so is in the compile step's output. + The compiler also prints its diagnostics to the server's stderr. + """ + + alias Gamend.Hooks.PluginBuilder.Project + alias Gamend.Hooks.PluginManager + + # Compile-time stubs and tooling: never needed, never loaded, at runtime. + @ignored_deps [:gamend_sdk, :gamend_plugin_tools] + @base_apps [:kernel, :stdlib, :elixir] + + @doc """ + Whether this VM can compile Elixir: the `elixir` and `compiler` applications + are loadable. True in any release, since `elixir` depends on `compiler`. + """ + @spec available?() :: boolean() + def available? do + Code.ensure_loaded?(Kernel.ParallelCompiler) and Code.ensure_loaded?(:compile) + end + + @doc """ + Builds the plugin in `plugin_dir` (named `plugin_name` by the loader). + Returns the steps it ran; the build succeeded when every status is `0`. + + Builds of one plugin directory run one at a time on this node. + """ + @spec run(String.t(), Path.t()) :: [Gamend.Hooks.PluginBuilder.step_result()] + def run(plugin_name, plugin_dir) do + plugin_dir = Path.expand(plugin_dir) + + case :global.trans( + {{__MODULE__, plugin_dir}, self()}, + fn -> do_run(plugin_name, plugin_dir) end, + [node()], + :infinity + ) do + :aborted -> [step("lock", 1, ["another build of #{plugin_name} did not let go"])] + steps -> steps + end + end + + defp do_run(plugin_name, dir) do + state = %{name: plugin_name, dir: dir, project: nil, dep_apps: [], optional_apps: []} + + [&read_step/1, &gdscript_step/1, &deps_step/1, &compile_steps/1] + |> Enum.reduce_while({state, []}, fn step, {state, steps} -> + case step.(state) do + {:ok, state, new_steps} -> {:cont, {state, steps ++ new_steps}} + {:error, new_steps} -> {:halt, {state, steps ++ new_steps}} + end + end) + |> elem(1) + end + + ## 1. mix.exs + + defp read_step(state) do + case Project.read(state.dir) do + {:ok, project} -> + lines = + [ + "app=#{inspect(project.app)} vsn=#{project.version} " <> + "elixirc_paths=#{inspect(project.elixirc_paths)}", + "deps: #{project.deps |> Enum.map(& &1.name) |> inspect()}" + ] ++ + name_mismatch(project.app, state.name) ++ + Enum.map(project.warnings, &("warning: " <> &1)) + + {:ok, %{state | project: project}, [step("read mix.exs", 0, lines)]} + + {:error, message} -> + {:error, [step("read mix.exs", 1, [message])]} + end + end + + # The loader finds `/ebin/.app`, so an app named otherwise builds + # but never loads — with Mix too. + defp name_mismatch(app, name) do + if Atom.to_string(app) == name, + do: [], + else: [ + "warning: app #{inspect(app)} is not the directory name #{name}; " <> + "the server loads ebin/#{name}.app" + ] + end + + ## 2. GDScript + + defp gdscript_step(state) do + case gdscript_scripts(state.dir) do + [] -> {:ok, state, []} + scripts -> transpile(state, scripts) + end + end + + @doc false + # Relative to the plugin, as `mix gamend.gdscript.compile` sees them from + # inside it: the path is what the generated header names. + @spec gdscript_scripts(Path.t()) :: [Path.t()] + def gdscript_scripts(dir) do + dir + |> Path.join("scripts/*.gd") + |> Path.wildcard() + |> Enum.map(&Path.relative_to(&1, dir)) + end + + @label_gdscript "gamend.gdscript.compile (in-process)" + + defp transpile(state, scripts) do + transpiler = gdscript_module() + + if Code.ensure_loaded?(transpiler) and function_exported?(transpiler, :source_path, 1) do + written = + scripts + |> transpiler.compile_all(root: state.dir) + |> Enum.map(fn {module, source} -> + target = Path.join("gen", transpiler.source_path(module)) + path = Path.join(state.dir, target) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, source) + "compiled -> #{target}" + end) + + unused = + if "gen" in state.project.elixirc_paths, + do: [], + else: ["warning: elixirc_paths does not include gen/, so none of it compiles"] + + {:ok, state, [step(@label_gdscript, 0, [written, unused])]} + else + {:error, + [ + step(@label_gdscript, 1, [ + "this engine does not ship the GDScript transpiler (the gamend_plugin_tools " <> + "package); build the plugin with `mix bundle` instead" + ]) + ]} + end + rescue + e -> {:error, [step(@label_gdscript, 1, [Exception.message(e)])]} + end + + # Resolved at runtime: the transpiler ships with the host app, not with + # gamend_core, so a literal call would not compile warning-free here. + defp gdscript_module, do: Module.concat(Gamend, GDScript) + + ## 3. Dependencies + + @label_deps "deps (in-process)" + + defp deps_step(state) do + required = + Enum.filter(state.project.deps, fn dep -> + dep.name not in @ignored_deps and dep.runtime? and dep.prod? + end) + + checked = Enum.map(required, &check_dep(&1, state.dir)) + + lines = + Enum.map(state.project.deps -- required, &"#{&1.name}: compile-time only, skipped") ++ + Enum.map(checked, &elem(&1, 1)) + + case for({:missing, _line, dep} <- checked, do: dep.name) do + [] -> + state = %{ + state + | dep_apps: for({status, _, dep} <- checked, status != :missing, do: dep.name), + optional_apps: for(dep <- required, dep.optional?, do: dep.name) + } + + {:ok, state, [step(@label_deps, 0, lines)]} + + missing -> + {:error, + [ + step( + @label_deps, + 1, + lines ++ + [ + "", + "missing: #{Enum.map_join(missing, ", ", &inspect/1)}. This engine does not ship " <> + "#{if length(missing) == 1, do: "it", else: "them"} and there is no Hex here to " <> + "fetch from. Bundle the plugin where Mix is available (`mix plugin.bundle` " <> + "copies runtime deps into deps//ebin), or drop the dependency." + ] + ) + ]} + end + end + + defp check_dep(dep, dir) do + prebuilt = Path.join([dir, "deps", Atom.to_string(dep.name), "ebin"]) + + cond do + engine_app?(dep.name, dir) -> {:engine, "#{dep.name}: provided by the engine", dep} + File.dir?(prebuilt) -> {:prebuilt, "#{dep.name}: prebuilt in deps/#{dep.name}/ebin", dep} + dep.optional? -> {:optional, "#{dep.name}: optional and not available", dep} + true -> {:missing, "#{dep.name}: MISSING", dep} + end + end + + # An app the engine's own code path provides. A plugin's bundled deps are on + # the path too while it is loaded, and do not count. + defp engine_app?(app, dir) do + case :code.lib_dir(app) do + lib when is_list(lib) -> not under?(Path.expand(List.to_string(lib)), dir) + _ -> false + end + end + + ## 4. Compile and bundle + + @label_compile "compile (in-process)" + @label_bundle "plugin.bundle (in-process)" + + defp compile_steps(state) do + files = source_files(state) + erlang = Path.wildcard(Path.join(state.dir, "src/**/*.erl")) + + cond do + erlang != [] -> + {:error, + [ + step(@label_compile, 1, [ + "Erlang sources under src/ need Mix (erlc); build this plugin with `mix plugin.bundle`" + ]) + ]} + + files == [] -> + {:error, + [ + step(@label_compile, 1, [ + "no .ex files under #{inspect(state.project.elixirc_paths)}" + ]) + ]} + + true -> + with_plugin_unloaded(state, fn -> compile_and_bundle(state, files) end) + end + end + + defp source_files(state) do + state.project.elixirc_paths + |> Enum.flat_map(&Path.wildcard(Path.join([state.dir, &1, "**", "*.ex"]))) + |> Enum.uniq() + |> Enum.sort() + end + + # Stop the plugin (and drop anything of it still loaded), take the plugin's + # own ebin off the code path and put its prebuilt deps on it, run `fun`, + # then undo all of that and start the plugin again if it was running. + defp with_plugin_unloaded(state, fun) do + suspended? = PluginManager.suspend(state.name) + own_ebin = Path.join(state.dir, "ebin") + had_own_ebin? = Code.delete_path(own_ebin) + + dep_paths = + state.dir |> Path.join("deps/*/ebin") |> Path.wildcard() |> Enum.filter(&File.dir?/1) + + on_path = MapSet.new(:code.get_path(), &List.to_string/1) + added = Enum.reject(dep_paths, &MapSet.member?(on_path, &1)) + + result = + try do + evict_loaded(state.dir) + Enum.each(added, &Code.append_path/1) + # A release may run the code server in embedded mode, where nothing + # loads on first use; the compiler needs the deps' modules loaded. + _ = PluginManager.load_beams(dep_paths, :code.get_mode()) + fun.() + catch + kind, reason -> {:caught, kind, reason, __STACKTRACE__} + after + Enum.each(added, &Code.delete_path/1) + if had_own_ebin?, do: Code.append_path(own_ebin) + evict_loaded(state.dir) + end + + resumed = if suspended?, do: PluginManager.resume(state.name) + + case result do + {:caught, kind, reason, stacktrace} -> :erlang.raise(kind, reason, stacktrace) + result when suspended? -> add_note(result, resume_note(result, resumed)) + result -> result + end + end + + defp resume_note(_result, %{status: {:error, reason}}), + do: "the plugin was loaded when the build began and did not start again: #{inspect(reason)}" + + defp resume_note(_result, nil), + do: "the plugin was loaded when the build began and could not be loaded again" + + defp resume_note({:ok, _state, _steps}, _plugin), + do: "restarted the plugin on the new bundle (it was loaded when the build began)" + + defp resume_note(_result, _plugin), + do: "restarted the plugin on its previous bundle (it was loaded when the build began)" + + defp add_note({:ok, state, steps}, line), do: {:ok, state, append_to_last(steps, line)} + defp add_note({:error, steps}, line), do: {:error, append_to_last(steps, line)} + + defp append_to_last(steps, line) do + List.update_at(steps, -1, fn step -> %{step | output: step.output <> "\n" <> line} end) + end + + # Unload every module loaded from inside the plugin directory, except its + # prebuilt deps: the previous bundle's (a plugin the manager failed to start + # keeps its beams loaded) and what the compiler loaded from the build + # directory while compiling (a module another one needed at compile time). + # After this nothing of the plugin's own code is loaded, so the loader reads + # the new bundle from disk like any other. + defp evict_loaded(dir) do + dir = Path.expand(dir) + deps = Path.join(dir, "deps") + + for {module, file} <- :code.all_loaded(), + is_list(file), + path = Path.expand(List.to_string(file)), + under?(path, dir), + not under?(path, deps) do + _ = :code.purge(module) + _ = :code.delete(module) + _ = :code.purge(module) + end + + :ok + end + + defp compile_and_bundle(state, files) do + tmp = Path.join(state.dir, ".ebin-build-#{System.unique_integer([:positive])}") + File.rm_rf!(tmp) + File.mkdir_p!(tmp) + + try do + case compile(files, tmp, state.dir) do + {:ok, modules, compile_step} -> + # compile_to_path/3 prepends the build directory to the code path, + # and loads a module another one needed at compile time. Neither may + # outlive the build: the loader reads the bundle from ebin/. + Code.delete_path(tmp) + evict_loaded(state.dir) + + case bundle(state, modules, tmp) do + {:ok, bundle_step} -> {:ok, state, [compile_step, bundle_step]} + {:error, bundle_step} -> {:error, [compile_step, bundle_step]} + end + + {:error, compile_step} -> + {:error, [compile_step]} + end + after + Code.delete_path(tmp) + File.rm_rf(tmp) + end + end + + defp compile(files, tmp, dir) do + case Kernel.ParallelCompiler.compile_to_path(files, tmp, return_diagnostics: true) do + {:ok, modules, info} -> + warnings = format_diagnostics(info.compile_warnings ++ info.runtime_warnings, dir) + summary = "Compiled #{length(files)} file(s), #{length(modules)} module(s)" + {:ok, Enum.sort(modules), step(@label_compile, 0, [warnings, summary])} + + {:error, errors, info} -> + diagnostics = + format_diagnostics(errors ++ info.compile_warnings ++ info.runtime_warnings, dir) + + {:error, step(@label_compile, 1, [diagnostics, "Compilation failed"])} + end + rescue + e -> {:error, step(@label_compile, 1, [Exception.message(e)])} + end + + defp format_diagnostics(diagnostics, dir) do + diagnostics + |> Enum.uniq_by(&{&1.severity, &1.message, &1.file, &1.position}) + |> Enum.map(fn diagnostic -> + location = + case {diagnostic.file, line(diagnostic.position)} do + {nil, _} -> "" + {file, nil} -> "\n " <> Path.relative_to(file, dir) + {file, line} -> "\n #{Path.relative_to(file, dir)}:#{line}" + end + + "#{diagnostic.severity}: #{String.trim(diagnostic.message)}#{location}" + end) + end + + defp line({line, _column}), do: line + defp line(line) when is_integer(line) and line > 0, do: line + defp line(_position), do: nil + + defp bundle(state, modules, tmp) do + project = state.project + attributes = Map.new(modules, &{&1, beam_attributes(tmp, &1)}) + + with :ok <- check_clashes(modules), + {:ok, hooks_module, how} <- hooks_module(state, modules, attributes) do + app_file = Path.join(tmp, "#{project.app}.app") + File.write!(app_file, app_contents(state, modules, hooks_module)) + :ok = swap_in(tmp, Path.join(state.dir, "ebin")) + + {:ok, + step(@label_bundle, 0, [ + "hooks_module=#{inspect(hooks_module)} (#{how})", + missing_hooks_module(hooks_module, modules), + "Bundled plugin #{project.app}: ebin/ (#{length(modules)} modules)" + ])} + else + {:error, message} -> {:error, step(@label_bundle, 1, [message, "ebin/ left as it was"])} + end + rescue + e -> {:error, step(@label_bundle, 1, [Exception.message(e), "ebin/ left as it was"])} + end + + # A module the engine (or another plugin) already defines would shadow it or + # be shadowed by it, depending on load order. + defp check_clashes(modules) do + clashes = + for module <- modules, (where = :code.which(module)) != :non_existing do + "#{inspect(module)} (already defined by #{format_where(where)})" + end + + case clashes do + [] -> + :ok + + _ -> + {:error, + "a plugin cannot redefine a module the server already has: " <> Enum.join(clashes, ", ")} + end + end + + defp format_where(where) when is_list(where), do: List.to_string(where) + defp format_where(where), do: inspect(where) + + defp hooks_module(%{project: %{hooks_module: module}}, _modules, _attributes) + when module != nil, + do: {:ok, module, "from mix.exs"} + + defp hooks_module(state, modules, attributes) do + behaviours = + Enum.filter(modules, fn module -> + Gamend.Hooks in Keyword.get(attributes[module], :behaviour, []) or + Gamend.Hooks in Keyword.get(attributes[module], :behavior, []) + end) + + case {behaviours, gdscript_hooks_module(state, modules)} do + {[module], _} -> + {:ok, module, "detected: @behaviour Gamend.Hooks"} + + {[], module} when module != nil -> + {:ok, module, "detected: the GDScript module named after the plugin"} + + {[], nil} -> + {:error, + "cannot tell which module is the hooks module: set `env: [hooks_module: MyModule]` " <> + "in application/0 of mix.exs, or `use Gamend.Hooks` in exactly one module"} + + {several, _} -> + {:error, + "several modules implement Gamend.Hooks (#{Enum.map_join(several, ", ", &inspect/1)}); " <> + "set `env: [hooks_module: MyModule]` in application/0 of mix.exs"} + end + end + + # GDScript output declares no behaviour. The plugin's main script is the one + # named after the plugin (what `mix gamend.gdscript.new` scaffolds), or the + # only one. + defp gdscript_hooks_module(state, modules) do + transpiler = gdscript_module() + + candidates = + case gdscript_scripts(state.dir) do + [] -> + [] + + [only] -> + [only] + + scripts -> + Enum.filter(scripts, &(Path.basename(&1, ".gd") == Atom.to_string(state.project.app))) + end + + with [script] <- candidates, + true <- Code.ensure_loaded?(transpiler), + module = Module.concat([transpiler.default_module(script)]), + true <- module in modules do + module + else + _ -> nil + end + end + + defp missing_hooks_module(module, modules) do + if module in modules, + do: [], + else: [ + "warning: hooks_module #{inspect(module)} is not one of the modules this build compiled" + ] + end + + defp beam_attributes(dir, module) do + beam = dir |> Path.join("#{module}.beam") |> String.to_charlist() + + case :beam_lib.chunks(beam, [:attributes]) do + {:ok, {_module, [attributes: attributes]}} -> attributes + _ -> [] + end + end + + # The shape `mix plugin.bundle` copies out of `_build`: what Mix's + # compile.app writes for the project. + defp app_contents(state, modules, hooks_module) do + project = state.project + + applications = + Enum.uniq( + @base_apps ++ project.extra_applications ++ (project.applications || state.dep_apps) + ) + + properties = + Enum.concat([ + [ + modules: modules, + optional_applications: state.optional_apps, + applications: applications, + description: String.to_charlist(project.description || Atom.to_string(project.app)), + registered: [], + vsn: String.to_charlist(project.version) + ], + if(project.mod, do: [mod: project.mod], else: []), + [env: Keyword.put(project.env, :hooks_module, hooks_module)] + ]) + + [:io_lib.format(~c"~p.~n", [{:application, project.app, properties}])] + |> IO.chardata_to_string() + end + + # Replace `ebin/` by the new build: the old one moves aside first and comes + # back if the new one cannot be put in place. + defp swap_in(new, ebin) do + old = ebin <> ".old-#{System.unique_integer([:positive])}" + had_old? = File.exists?(ebin) + + if had_old?, do: File.rename!(ebin, old) + + case File.rename(new, ebin) do + :ok -> + if had_old?, do: File.rm_rf(old) + :ok + + {:error, reason} -> + if had_old?, do: File.rename(old, ebin) + raise File.RenameError, source: new, destination: ebin, reason: reason + end + end + + # Both paths absolute and expanded. + defp under?(path, dir), do: path == dir or String.starts_with?(path, dir <> "/") + + # `lines` may nest lists of lines; they are flattened in order. + defp step(cmd, status, lines), + do: %{cmd: cmd, status: status, output: lines |> List.flatten() |> Enum.join("\n")} +end diff --git a/apps/gamend_core/lib/gamend/hooks/plugin_builder/project.ex b/apps/gamend_core/lib/gamend/hooks/plugin_builder/project.ex new file mode 100644 index 000000000..1cfc3659a --- /dev/null +++ b/apps/gamend_core/lib/gamend/hooks/plugin_builder/project.ex @@ -0,0 +1,408 @@ +defmodule Gamend.Hooks.PluginBuilder.Project do + @moduledoc """ + What a plugin's `mix.exs` declares, read without evaluating it. + + The in-process build (`Gamend.Hooks.PluginBuilder.InProcess`) runs inside + the server, so the project file is parsed with `Code.string_to_quoted/2` and + only literal values are taken from it: `app`, `version`, `description` and + `elixirc_paths` from `project/0`; `extra_applications`, `applications`, + `env` and `mod` from `application/0`; and the name and options of each + entry in `deps`. + + "Literal" includes what a mix.exs usually spells indirectly: a module + attribute (`version: @version`), a local function returning a literal + (`deps: deps()`, `elixirc_paths: elixirc_paths(Mix.env())`, resolved as + `:prod`), and `System.get_env("X") || @version`, which reads the right-hand + side. Anything else is left at its fallback and named in `warnings`: + app = the directory name, version `"0.0.0"`, elixirc_paths `["lib"]`. + """ + + @type dep :: %{ + name: atom(), + runtime?: boolean(), + optional?: boolean(), + prod?: boolean() + } + + @type t :: %__MODULE__{ + app: atom(), + version: String.t(), + description: String.t() | nil, + elixirc_paths: [String.t()], + extra_applications: [atom()], + applications: [atom()] | nil, + env: keyword(), + hooks_module: module() | nil, + mod: {module(), term()} | nil, + deps: [dep()], + warnings: [String.t()] + } + + defstruct app: nil, + version: "0.0.0", + description: nil, + elixirc_paths: ["lib"], + extra_applications: [], + applications: nil, + env: [], + hooks_module: nil, + mod: nil, + deps: [], + warnings: [] + + # How deep `deps()` -> `shared_dep(...)` -> ... may nest before giving up. + @max_depth 8 + + @doc """ + Reads `/mix.exs`. The app name falls back to the directory's + basename. + """ + @spec read(Path.t()) :: {:ok, t()} | {:error, String.t()} + def read(plugin_dir) do + path = Path.join(plugin_dir, "mix.exs") + fallback_app = plugin_dir |> Path.basename() |> String.to_atom() + + with {:ok, source} <- read_file(path), + {:ok, ast} <- parse(source, path), + {:ok, body} <- module_body(ast) do + {:ok, from_body(body, fallback_app)} + end + end + + defp read_file(path) do + case File.read(path) do + {:ok, source} -> {:ok, source} + {:error, reason} -> {:error, "cannot read #{path}: #{:file.format_error(reason)}"} + end + end + + defp parse(source, path) do + case Code.string_to_quoted(source, file: path, emit_warnings: false) do + {:ok, ast} -> + {:ok, ast} + + {:error, {meta, message, token}} -> + {:error, "#{path}:#{meta[:line]}: #{format_parse_message(message)}#{token}"} + end + end + + defp format_parse_message({prefix, suffix}), do: "#{prefix}#{suffix}" + defp format_parse_message(message), do: to_string(message) + + defp module_body({:defmodule, _, [_alias, [do: body]]}), do: {:ok, block(body)} + + defp module_body({:__block__, _, exprs}) do + Enum.find_value(exprs, {:error, "mix.exs defines no module"}, fn + {:defmodule, _, _} = mod -> module_body(mod) + _ -> nil + end) + end + + defp module_body(_ast), do: {:error, "mix.exs defines no module"} + + defp block({:__block__, _, exprs}), do: exprs + defp block(expr), do: [expr] + + defp from_body(exprs, fallback_app) do + ctx = %{ + attrs: collect_attributes(exprs), + defs: collect_defs(exprs), + depth: 0 + } + + project = keyword_ast(call_local(:project, [], ctx), ctx) + application = keyword_ast(call_local(:application, [], ctx), ctx) + + %__MODULE__{app: fallback_app} + |> read_project(project, ctx) + |> read_application(application, ctx) + # Collected newest first. + |> Map.update!(:warnings, &Enum.reverse/1) + |> Map.update!(:env, &Enum.reverse/1) + |> Map.update!(:deps, &Enum.reverse/1) + end + + defp read_project(acc, nil, _ctx), + do: warn(acc, "project/0 is not a literal keyword list; using defaults") + + defp read_project(acc, kw, ctx) do + acc + |> take(kw, :app, ctx, &app_name?/1, &%{&1 | app: &2}) + |> take(kw, :version, ctx, &is_binary/1, &%{&1 | version: &2}) + |> take(kw, :description, ctx, &is_binary/1, &%{&1 | description: &2}) + |> take(kw, :elixirc_paths, ctx, &string_list?/1, &%{&1 | elixirc_paths: &2}) + |> read_deps(Keyword.get(kw, :deps), ctx) + end + + defp read_application(acc, nil, _ctx), do: acc + + defp read_application(acc, kw, ctx) do + acc + |> take(kw, :extra_applications, ctx, &atom_list?/1, &%{&1 | extra_applications: &2}) + |> take(kw, :applications, ctx, &atom_list?/1, &%{&1 | applications: &2}) + |> take(kw, :mod, ctx, &mod?/1, &%{&1 | mod: &2}) + |> read_env(Keyword.get(kw, :env), ctx) + end + + # `hooks_module` is read on its own so a non-literal sibling does not hide + # it, and the rest of `env` is kept only when every value is literal. + defp read_env(acc, nil, _ctx), do: acc + + defp read_env(acc, env_ast, ctx) do + case keyword_ast(env_ast, ctx) do + nil -> + warn(acc, "application env is not a literal keyword list; left out of the .app") + + kw -> + Enum.reduce(kw, acc, fn {key, value_ast}, acc -> + case literal(value_ast, ctx) do + {:ok, value} when key == :hooks_module -> + put_hooks_module(acc, value) + + {:ok, value} -> + %{acc | env: [{key, value} | acc.env]} + + :error when key == :hooks_module -> + warn(acc, "env hooks_module is not a literal; it is detected after compiling") + + :error -> + warn(acc, "env #{inspect(key)} is not a literal; left out of the .app") + end + end) + end + end + + defp put_hooks_module(acc, value) when is_atom(value) and value != nil, + do: %{acc | hooks_module: value} + + defp put_hooks_module(acc, value) when is_binary(value) or is_list(value) do + with true <- is_binary(value) or List.ascii_printable?(value), + name when name != "" <- value |> to_string() |> String.trim_leading("Elixir.") do + %{acc | hooks_module: Module.concat([name])} + else + _ -> acc + end + end + + defp put_hooks_module(acc, _value), do: acc + + defp take(acc, kw, key, ctx, valid?, put) do + case Keyword.fetch(kw, key) do + :error -> + acc + + {:ok, value_ast} -> + with {:ok, value} <- literal(value_ast, ctx), + true <- valid?.(value) do + put.(acc, value) + else + _ -> warn(acc, "#{key} is not a literal; using the default") + end + end + end + + defp read_deps(acc, nil, _ctx), do: acc + + defp read_deps(acc, deps_ast, ctx) do + case resolve(deps_ast, ctx) do + entries when is_list(entries) -> + Enum.reduce(entries, acc, fn entry, acc -> + case dep(entry, ctx) do + {:ok, dep} -> %{acc | deps: [dep | acc.deps]} + :error -> warn(acc, "cannot read the dependency #{Macro.to_string(entry)}") + end + end) + + _other -> + warn(acc, "deps is not a literal list; no dependency is checked") + end + end + + # `{:name, requirement}`, `{:name, opts}`, `{:name, requirement, opts}`, or a + # local helper called with the name first (`shared_dep(:gamend_sdk, path)`), + # whose options cannot be known and are taken as the defaults. + defp dep({name, second}, ctx) when is_atom(name), do: dep_with(name, [second], ctx) + + defp dep({:{}, _, [name | rest]}, ctx) when is_atom(name) and rest != [], + do: dep_with(name, rest, ctx) + + defp dep({fun, _, [name | _]}, _ctx) when is_atom(fun) and is_atom(name) and name != nil, + do: {:ok, %{name: name, runtime?: true, optional?: false, prod?: true}} + + defp dep(_entry, _ctx), do: :error + + defp dep_with(name, rest, ctx) do + opts = + rest + |> List.last() + |> literal(ctx) + |> case do + {:ok, opts} when is_list(opts) -> if Keyword.keyword?(opts), do: opts, else: [] + _ -> [] + end + + only = opts |> Keyword.get(:only, :prod) |> List.wrap() + + {:ok, + %{ + name: name, + runtime?: Keyword.get(opts, :runtime, true) != false, + optional?: Keyword.get(opts, :optional, false) == true, + prod?: :prod in only + }} + end + + # A keyword list as a list of `{key, value_ast}`, without evaluating values. + defp keyword_ast(ast, ctx) do + case resolve(ast, ctx) do + list when is_list(list) -> + if Enum.all?(list, &match?({key, _} when is_atom(key), &1)), do: list + + _ -> + nil + end + end + + # Follow local calls and module attributes to the expression they return, + # without evaluating it. + defp resolve(_ast, %{depth: depth}) when depth > @max_depth, do: :error + + defp resolve({:@, _, [{name, _, context}]}, ctx) when is_atom(name) and is_atom(context), + do: Map.get(ctx.attrs, name, :error) + + defp resolve({:||, _, [left, right]}, ctx) do + case literal(left, ctx) do + {:ok, value} when value not in [nil, false] -> left + _ -> resolve(right, ctx) + end + end + + defp resolve({name, _, args} = ast, ctx) when is_atom(name) and is_list(args) do + if Map.has_key?(ctx.defs, {name, length(args)}), do: call_local(name, args, ctx), else: ast + end + + defp resolve(ast, _ctx), do: ast + + defp call_local(name, args, ctx) do + ctx = %{ctx | depth: ctx.depth + 1} + values = Enum.map(args, &literal(&1, ctx)) + + ctx.defs + |> Map.get({name, length(args)}, []) + |> Enum.find_value(:error, fn {patterns, body} -> + if clause_matches?(patterns, values), do: resolve(last(body), ctx) + end) + end + + defp clause_matches?(patterns, values) do + patterns + |> Enum.zip(values) + |> Enum.all?(fn + {{var, _, context}, _value} when is_atom(var) and is_atom(context) -> + true + + {pattern, {:ok, value}} -> + literal(pattern, %{attrs: %{}, defs: %{}, depth: 0}) == {:ok, value} + + {_pattern, :error} -> + false + end) + end + + defp last(body), do: body |> block() |> List.last() + + # Evaluate a literal term: atoms, numbers, strings, lists, tuples, maps, + # aliases, charlist/word sigils, `Mix.env()` (as `:prod`), and anything + # `resolve/2` can follow to one of those. + defp literal(ast, ctx) do + case resolve(ast, ctx) do + :error -> :error + resolved -> eval(resolved, ctx) + end + end + + defp eval(value, _ctx) when is_atom(value) or is_number(value) or is_binary(value), + do: {:ok, value} + + defp eval(list, ctx) when is_list(list), do: eval_all(list, ctx) + + defp eval({left, right}, ctx) do + with {:ok, [l, r]} <- eval_all([left, right], ctx), do: {:ok, {l, r}} + end + + defp eval({:{}, _, elems}, ctx) do + with {:ok, values} <- eval_all(elems, ctx), do: {:ok, List.to_tuple(values)} + end + + defp eval({:%{}, _, pairs}, ctx) do + with {:ok, values} <- eval_all(pairs, ctx), do: {:ok, Map.new(values)} + end + + defp eval({:__aliases__, _, parts}, _ctx) do + if Enum.all?(parts, &is_atom/1), do: {:ok, Module.concat(parts)}, else: :error + end + + defp eval({:-, _, [number]}, _ctx) when is_number(number), do: {:ok, -number} + + defp eval({:sigil_c, _, [{:<<>>, _, [string]}, []]}, _ctx) when is_binary(string), + do: {:ok, String.to_charlist(string)} + + defp eval({:sigil_w, _, [{:<<>>, _, [string]}, modifiers]}, _ctx) when is_binary(string) do + words = String.split(string) + + case modifiers do + ~c"a" -> {:ok, Enum.map(words, &String.to_atom/1)} + ~c"c" -> {:ok, Enum.map(words, &String.to_charlist/1)} + mods when mods in [[], ~c"s"] -> {:ok, words} + _ -> :error + end + end + + defp eval({{:., _, [{:__aliases__, _, [:Mix]}, :env]}, _, []}, _ctx), do: {:ok, :prod} + defp eval(_ast, _ctx), do: :error + + defp eval_all(asts, ctx) do + Enum.reduce_while(asts, {:ok, []}, fn ast, {:ok, acc} -> + case literal(ast, ctx) do + {:ok, value} -> {:cont, {:ok, [value | acc]}} + :error -> {:halt, :error} + end + end) + |> case do + {:ok, values} -> {:ok, Enum.reverse(values)} + :error -> :error + end + end + + defp collect_attributes(exprs) do + for {:@, _, [{name, _, [value]}]} <- exprs, is_atom(name), into: %{}, do: {name, value} + end + + defp collect_defs(exprs) do + exprs + |> Enum.flat_map(fn + {kind, _, [head, [do: body]]} when kind in [:def, :defp] -> def_clause(head, body) + _ -> [] + end) + |> Enum.group_by(fn {key, _clause} -> key end, fn {_key, clause} -> clause end) + end + + # Clauses with a guard are skipped: which one applies cannot be decided + # without evaluating it. + defp def_clause({:when, _, _}, _body), do: [] + + defp def_clause({name, _, args}, body) when is_atom(name) do + args = if is_list(args), do: args, else: [] + [{{name, length(args)}, {args, body}}] + end + + defp def_clause(_head, _body), do: [] + + defp app_name?(value), do: is_atom(value) and value not in [nil, true, false] + defp string_list?(value), do: is_list(value) and Enum.all?(value, &is_binary/1) + defp atom_list?(value), do: is_list(value) and Enum.all?(value, &is_atom/1) + defp mod?({module, _args}), do: is_atom(module) + defp mod?(_value), do: false + + defp warn(acc, message), do: %{acc | warnings: [message | acc.warnings]} +end diff --git a/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex b/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex index c89bd05de..cae5579fd 100644 --- a/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex +++ b/apps/gamend_core/lib/gamend/hooks/plugin_manager.ex @@ -143,6 +143,36 @@ defmodule Gamend.Hooks.PluginManager do GenServer.call(__MODULE__, :reload_and_after_startup, @timeout_ms) end + @doc """ + Stops and unloads one plugin, leaving the others running. Returns `true` + when the manager had it (loaded or failed), `false` when it did not or the + manager is not running. + + The in-process build (`Gamend.Hooks.PluginBuilder`) calls this before it + compiles the plugin in this VM. The compiler treats a module that is already + loaded as available, so a module compiled against a sibling that is still + loaded would take that sibling's *old* macros and structs; unloading the + plugin first makes the build see only its own new code. `resume/1` loads it + back. + """ + @spec suspend(plugin_name()) :: boolean() + def suspend(name) when is_binary(name) do + if GenServer.whereis(__MODULE__), + do: GenServer.call(__MODULE__, {:suspend, name}, @timeout_ms), + else: false + end + + @doc """ + Loads one plugin from disk again and runs its `after_startup/0`, the + counterpart of `suspend/1`. Returns the plugin (its `status` says whether it + started), or `nil` when the manager is not running or skips the name. + """ + @spec resume(plugin_name()) :: Plugin.t() | nil + def resume(name) when is_binary(name) do + if GenServer.whereis(__MODULE__), + do: GenServer.call(__MODULE__, {:resume, name}, @timeout_ms) + end + @spec call_rpc(plugin_name(), String.t(), list(), keyword()) :: {:ok, any()} | {:error, term()} def call_rpc(plugin, fn_name, args, opts \\ []) when is_binary(plugin) and is_binary(fn_name) and is_list(args) and is_list(opts) do @@ -291,6 +321,35 @@ defmodule Gamend.Hooks.PluginManager do {:reply, %{plugins: state_to_list(state), after_startup: results}, state} end + def handle_call({:suspend, name}, _from, state) do + case Map.pop(state, name) do + {nil, _state} -> + {:reply, false, state} + + {plugin, rest} -> + stop_unload_plugin(plugin) + _ = DynamicRpcs.reset_plugin(name) + {:reply, true, publish_snapshot(rest)} + end + end + + def handle_call({:resume, name}, _from, state) do + # A full reload may have loaded it again while it was suspended. + {previous, rest} = Map.pop(state, name) + if previous, do: stop_unload_plugin(previous) + _ = DynamicRpcs.reset_plugin(name) + + case load_plugin(plugins_dir(), name) do + %Plugin{} = plugin -> + state = publish_snapshot(Map.put(rest, name, plugin)) + _ = do_after_startup(%{name => plugin}) + {:reply, plugin, state} + + nil -> + {:reply, nil, publish_snapshot(rest)} + end + end + # Internals defp state_to_list(state) when is_map(state) do diff --git a/apps/gamend_core/lib/gamend/release.ex b/apps/gamend_core/lib/gamend/release.ex index 01705e813..c14d1f3e7 100644 --- a/apps/gamend_core/lib/gamend/release.ex +++ b/apps/gamend_core/lib/gamend/release.ex @@ -5,7 +5,8 @@ defmodule Gamend.Release do A release ships compiled `.beam` files and nothing else — no Mix, no project tree, no `mix` binary — so `mix db.migrate` cannot run inside an image built from `Dockerfile.release`. These functions are what the release's own - entrypoint calls instead: + entrypoint calls instead, usually through `bin/gamend db.migrate` and its + siblings (`GamendWeb.CLI`): bin/gamend_host eval "Gamend.Release.createdb()" bin/gamend_host eval "Gamend.Release.migrate()" @@ -28,28 +29,21 @@ defmodule Gamend.Release do Runs every pending migration — core's and the host's — on each repo. """ @spec migrate() :: :ok - def migrate do - paths = migration_paths() - - for repo <- repos() do - {:ok, _migrated, _apps} = - Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, paths, :up, all: true)) - end - - :ok - end + def migrate, do: run_migrations(repos(), :up, all: true) @doc """ Rolls `repo` back down to `version`. """ @spec rollback(module(), integer()) :: :ok - def rollback(repo, version) do - paths = migration_paths() - - {:ok, _rolled_back, _apps} = - Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, paths, :down, to: version)) + def rollback(repo, version), do: run_migrations([repo], :down, to: version) - :ok + @doc """ + Rolls every repo back, the way `mix db.rollback` does: `step: n` (the last + `n` migrations, 1 when no option is given), `to: version` or `all: true`. + """ + @spec rollback(keyword()) :: :ok + def rollback(opts) when is_list(opts) do + run_migrations(repos(), :down, if(opts == [], do: [step: 1], else: opts)) end @doc """ @@ -59,19 +53,67 @@ defmodule Gamend.Release do the server provisions the database already can skip this entirely. """ @spec createdb() :: :ok - def createdb do + def createdb, do: storage(:storage_up, :already_up, "create") + + @doc """ + Drops the database, mirroring `mix ecto.drop`. A database that does not + exist is not an error. + """ + @spec dropdb() :: :ok + def dropdb, do: storage(:storage_down, :already_down, "drop") + + @doc """ + What a release runs before it starts serving: create the database when it + can, then migrate. Creating may fail (a provisioned Postgres already has the + database, and its role may not be allowed to create one), which is reported + and passed over; a failed migration raises. `bin/gamend start` and the Docker + image's command both run this. + """ + @spec prepare() :: :ok + def prepare do + try do + createdb() + rescue + error -> IO.puts(:stderr, "warning: " <> Exception.message(error)) + end + + migrate() + end + + @doc """ + The project's seeds script, `priv/repo/seeds.exs` in the working directory, + or `nil` when there is none. `mix host.seed` and `gamend db.seed` both run it. + """ + @spec seeds_file() :: String.t() | nil + def seeds_file do + path = Path.expand("priv/repo/seeds.exs") + if File.regular?(path), do: path + end + + defp run_migrations(repos, direction, opts) do + paths = migration_paths() + + for repo <- repos do + {:ok, _versions, _apps} = + Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, paths, direction, opts)) + end + + :ok + end + + defp storage(fun, already, verb) do for repo <- repos() do start_driver(repo) - case repo.__adapter__().storage_up(repo.config()) do + case apply(repo.__adapter__(), fun, [repo.config()]) do :ok -> :ok - {:error, :already_up} -> + {:error, ^already} -> :ok {:error, reason} -> - raise "could not create storage for #{inspect(repo)}: #{inspect(reason)}" + raise "could not #{verb} storage for #{inspect(repo)}: #{inspect(reason)}" end end diff --git a/apps/gamend_core/lib/gamend/theme/json_config.ex b/apps/gamend_core/lib/gamend/theme/json_config.ex index 97289ec48..1ba53cb79 100644 --- a/apps/gamend_core/lib/gamend/theme/json_config.ex +++ b/apps/gamend_core/lib/gamend/theme/json_config.ex @@ -17,6 +17,11 @@ defmodule Gamend.Theme.JSONConfig do The decoded file is cached in `:persistent_term`; translation happens per read, against the caller's current locale. Call `reload/0` after editing the file at runtime. + + `reload/0` emits the telemetry event `[:gamend, :theme, :reload]` once the + cache is cleared, so work derived from the file can follow it without core + knowing who does it: the web app cuts the responsive image variants a new + config asks for (`GamendWeb.ResponsiveImages`). """ @behaviour Gamend.Theme @@ -165,6 +170,10 @@ defmodule Gamend.Theme.JSONConfig do mod == __MODULE__, do: :persistent_term.erase(key) + :telemetry.execute([:gamend, :theme, :reload], %{system_time: System.system_time()}, %{ + path: config_path() + }) + :ok end diff --git a/apps/gamend_core/lib/mix/tasks/demo.seed.ex b/apps/gamend_core/lib/mix/tasks/demo.seed.ex index 32dfae756..0a8866134 100644 --- a/apps/gamend_core/lib/mix/tasks/demo.seed.ex +++ b/apps/gamend_core/lib/mix/tasks/demo.seed.ex @@ -2,1159 +2,20 @@ defmodule Mix.Tasks.Demo.Seed do @shortdoc "Seeds large volumes of demo data (leaderboard, group, tournament)" @moduledoc """ - Fills the database with enough demo data to exercise pagination and the - list/detail pages at realistic sizes. + Fills the database with demo data. See `Gamend.DemoSeed` for the sets and + options; a release runs the same code as `gamend demo.seed`. - Everything is namespaced with a `demo-seed` prefix so `--clean` can remove it - again without touching real data. - - ## Usage - - mix demo.seed # all sets, 1000 rows each - mix demo.seed --count 250 # smaller run - mix demo.seed --only leaderboard # one set (comma-separated) - mix demo.seed --only group,tournament - mix demo.seed --clean # remove everything this task created - - ## Sets - - * `leaderboard` — a leaderboard with N scored records - * `group` — a public group with N members - * `tournament` — a tournament with N registered entries, still open - * `lobby_snapshot` — recorded runs for `/admin/lobby_snapshots`, capped at 12 - regardless of `--count` (this set is about having something to read, not - volume) - * `quest` — a daily, an auto-claim achievement, a chained follow-up and a - twelve-member group that lists as one card, with per-user progress in - every state (including claimable rows) - * `ready_check` — one check per seeded lobby in every outcome (open, - passed, timed out, declined), also capped at 12 - * `chat_moderation` — a blocklist across every severity and match mode, a - report queue deep enough to page through (every status, some filter-filed, - some resolved) and mutes in every scope, including expired ones - - The `lobby_snapshot` set goes through the real `capture_lobby/3` path rather - than inserting rows, so what you see is shaped exactly like production data — - including content-addressed section dedup. One of its runs reproduces the July - 2026 rubber-banding bug (a distance that reverts between snapshots), which is - the case the section diff exists to make obvious. - - Seeded runs keep their lobby row so `--clean` can find them again. Real - completed runs outlive theirs, since a lobby is deleted when its last member - leaves. - - All sets share one pool of N anonymous device accounts, so the same players - appear across them (as they would in a real deployment). - - Rows are inserted in bulk rather than through the contexts: this is about - volume, not about exercising business rules, and 1000 individual writes on - SQLite is slow. The cache is flushed afterwards so pages read the new rows. + mix demo.seed --count 250 --only leaderboard,group + mix demo.seed --clean """ use Mix.Task - import Ecto.Query - - alias Gamend.Accounts.User - alias Gamend.Chat.FilterWord - alias Gamend.Chat.Message - alias Gamend.Chat.Moderation - alias Gamend.Chat.Moderation.Normalizer - alias Gamend.Chat.Mute - alias Gamend.Chat.Report - alias Gamend.Groups.Group - alias Gamend.Groups.GroupMember - alias Gamend.Leaderboards.Leaderboard - alias Gamend.Leaderboards.Record - alias Gamend.Lobbies.Lobby - alias Gamend.LobbySnapshots - alias Gamend.LobbySnapshots.Event, as: SnapshotEvent - alias Gamend.LobbySnapshots.Snapshot - alias Gamend.LobbySnapshots.Writer - alias Gamend.Parties.Party - alias Gamend.Push.PushToken - alias Gamend.Quests.Quest - alias Gamend.Quests.QuestProgress - alias Gamend.ReadyChecks.Check, as: ReadyCheck - alias Gamend.ReadyChecks.Participant, as: ReadyCheckParticipant - alias Gamend.Repo - alias Gamend.Tournaments.Entry - alias Gamend.Tournaments.Tournament - alias Gamend.UUIDv7 - - @prefix "demo-seed" - @leaderboard_slug "demo_seed_scores" - @group_title "Demo Seed Group" - @tournament_slug "demo-seed-cup" - @quest_key_prefix "demo-seed-" - @default_count 1000 - @batch 500 - @all_sets ~w(leaderboard group tournament lobby_snapshot quest push ready_check chat_moderation) - @lobby_title_prefix "Demo Seed Run" - @max_runs 12 - @chat_lobby_title "#{@lobby_title_prefix} Chat" - @chat_room_types ~w(group lobby party) - @min_reports 48 - @max_mutes 24 - - # A spread over both match modes, all three severities and the provenance tag - # (nil is a hand-added word). Nothing here overlaps priv/chat_filter/en.txt, so - # importing the bundled list on top of a seeded database still works, and no - # word carries a doubled letter — the normalizer collapses those, and a word - # displayed as "bosting" reads like a typo in the admin list. - @filter_words [ - {"idiot", "block", "substring", nil}, - {"moron", "block", "substring", "en"}, - {"scumbag", "block", "exact", "en"}, - {"kys", "block", "exact", nil}, - {"arschloch", "block", "substring", "de"}, - {"salaud", "block", "substring", "fr"}, - {"imbecil", "block", "substring", "es"}, - {"damn", "mask", "substring", nil}, - {"crap", "mask", "substring", nil}, - {"numpty", "mask", "substring", "en"}, - {"plonker", "mask", "exact", "en"}, - {"mist", "mask", "exact", "de"}, - {"merde", "mask", "substring", "fr"}, - {"trash", "flag", "substring", nil}, - {"scam", "flag", "substring", nil}, - {"hacker", "flag", "substring", nil}, - {"cheater", "flag", "substring", "en"}, - {"smurf", "flag", "exact", nil}, - {"rmt", "flag", "exact", "en"}, - {"goldfarm", "flag", "substring", nil}, - {"gamekeys", "flag", "substring", nil}, - {"buy gold", "flag", "substring", "en"} - ] - - # Reported message and the reason it was reported for. The last two are - # benign: a queue with no bogus reports in it never shows why `dismissed` - # exists. - @chat_lines [ - {"you are the worst teammate I have had all week", "Harassment"}, - {"learn to play before you queue ranked", "Harassment"}, - {"stop stealing my objectives", "Griefing"}, - {"report him, he threw the game on purpose", "Griefing"}, - {"add me, I sell accounts cheap", "Real-money trading"}, - {"join my stream, the link is in my profile", "Spam or advertising"}, - {"nobody from your region should be allowed to queue", "Hate speech"}, - {"I am a moderator, send me your login to verify", "Impersonation"}, - {"gg wp, close one", "Harassment"}, - {"my ping is awful tonight", "Spam or advertising"} - ] - - # Each line is paired with the `flag` word it trips, which is what the filter - # puts in the reason of the report it files. - @flagged_lines [ - {"buy gold cheap, first ten buyers get a bonus", "buy gold"}, - {"this smurf ruins every lobby", "smurf"}, - {"you are trash, uninstall the game", "trash"}, - {"total scam, the drop rates are rigged", "scam"}, - {"goldfarm service, message me for prices", "goldfarm"} - ] - @impl Mix.Task def run(args) do Mix.Task.run("app.start") - - {opts, _rest, _} = - OptionParser.parse(args, strict: [count: :integer, only: :string, clean: :boolean]) - - if opts[:clean] do - clean() - else - count = opts[:count] || @default_count - sets = parse_sets(opts[:only]) - - info("seeding #{count} rows per set: #{Enum.join(sets, ", ")}") - users = ensure_users(count) - - Enum.each(sets, fn - "leaderboard" -> seed_leaderboard(users) - "group" -> seed_group(users) - "tournament" -> seed_tournament(users) - "lobby_snapshot" -> seed_lobby_snapshots(users) - "quest" -> seed_quests(users) - "push" -> seed_push_tokens(users) - "ready_check" -> seed_ready_checks(users) - "chat_moderation" -> seed_chat_moderation(users) - end) - - Gamend.Cache.delete_all() - info("done — run `mix demo.seed --clean` to remove it again") - end - end - - defp parse_sets(nil), do: @all_sets - - defp parse_sets(only) do - sets = only |> String.split(",", trim: true) |> Enum.map(&String.trim/1) - - case sets -- @all_sets do - [] -> - sets - - unknown -> - Mix.raise( - "unknown set(s): #{Enum.join(unknown, ", ")} (known: #{Enum.join(@all_sets, ", ")})" - ) - end - end - - # ── Shared player pool ──────────────────────────────────────────────────── - - defp ensure_users(count) do - existing = - from(u in User, where: like(u.device_id, ^"#{@prefix}-%"), select: {u.device_id, u.id}) - |> Repo.all() - |> Map.new() - - missing = - for i <- 1..count, - device_id = device_id(i), - not Map.has_key?(existing, device_id), - do: {i, device_id} - - now = DateTime.utc_now(:second) - - missing - |> Enum.map(fn {i, device_id} -> - %{ - id: UUIDv7.generate(), - device_id: device_id, - username: username(i), - display_name: display_name(i), - is_admin: false, - is_activated: true, - metadata: %{}, - token_version: 0, - inserted_at: now, - updated_at: now - } - end) - |> insert_batches(User) - - info("players: #{count} (#{length(missing)} new)") - - from(u in User, - where: like(u.device_id, ^"#{@prefix}-%"), - order_by: u.device_id, - limit: ^count, - select: u.id - ) - |> Repo.all() - end - - defp device_id(i), do: "#{@prefix}-#{pad(i)}" - defp username(i), do: "#{@prefix}-#{pad(i)}" - defp display_name(i), do: "Demo Player #{pad(i)}" - defp pad(i), do: String.pad_leading(Integer.to_string(i), 5, "0") - - # ── Sets ────────────────────────────────────────────────────────────────── - - defp seed_leaderboard(user_ids) do - leaderboard = - upsert(Leaderboard, [slug: @leaderboard_slug], %{ - slug: @leaderboard_slug, - title: "Demo Seed Scores", - description: "Volume demo data.", - sort_order: :desc, - operator: :best, - metadata: %{} - }) - - Repo.delete_all(from(r in Record, where: r.leaderboard_id == ^leaderboard.id)) - now = DateTime.utc_now(:second) - - user_ids - |> Enum.map(fn user_id -> - %{ - id: UUIDv7.generate(), - leaderboard_id: leaderboard.id, - user_id: user_id, - score: :rand.uniform(1_000_000), - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - |> insert_batches(Record) - - info("leaderboard: #{length(user_ids)} records -> /leaderboards/#{@leaderboard_slug}") - end - - defp seed_group(user_ids) do - [creator | members] = user_ids - group = demo_group(user_ids) - - Repo.delete_all(from(m in GroupMember, where: m.group_id == ^group.id)) - now = DateTime.utc_now(:second) - - rows = - [%{user_id: creator, role: "admin"}] ++ Enum.map(members, &%{user_id: &1, role: "member"}) - - rows - |> Enum.map(fn row -> - %{ - id: UUIDv7.generate(), - group_id: group.id, - user_id: row.user_id, - role: row.role, - inserted_at: now, - updated_at: now - } - end) - |> insert_batches(GroupMember) - - info("group: #{length(rows)} members -> /groups/#{group.id}") - end - - defp demo_group(user_ids) do - upsert(Group, [title: @group_title], %{ - title: @group_title, - description: "Volume demo data.", - type: "public", - max_members: length(user_ids) + 10, - creator_id: hd(user_ids), - metadata: %{} - }) - end - - defp seed_tournament(user_ids) do - now = DateTime.utc_now(:second) - - tournament = - upsert(Tournament, [slug: @tournament_slug], %{ - slug: @tournament_slug, - title: "Demo Seed Cup", - description: "Volume demo data — registration is open.", - state: "registration", - registration_opens_at: DateTime.add(now, -3600), - starts_at: DateTime.add(now, 7 * 86_400), - round_window_sec: 3600, - bracket_size: 8, - team_size: 1, - deadline_policy: "forfeit_both", - metadata: %{} - }) - - Repo.delete_all(from(e in Entry, where: e.tournament_id == ^tournament.id)) - - user_ids - |> Enum.map(fn user_id -> - %{ - id: UUIDv7.generate(), - tournament_id: tournament.id, - leader_id: user_id, - wins: 0, - state: "registered", - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - |> insert_batches(Entry) - - info("tournament: #{length(user_ids)} entries -> /tournaments/#{tournament.id}") - end - - # ── Clean ───────────────────────────────────────────────────────────────── - - # A daily, an auto-claim achievement, a chain gated on it and a group that - # collapses to one card — with per-user progress in every state, including - # claimable completed rows. - defp seed_quests(user_ids) do - daily = - upsert_quest(%{ - key: @quest_key_prefix <> "daily-login", - title: "Demo Daily Login", - description: "Log in 3 times today.", - reset: "daily", - category: "Daily", - objectives: [%{event: "login", target: 3, params: %{}}], - rewards: [%{type: "currency", code: "gold", amount: 100}], - auto_claim: false, - active: true, - metadata: %{} - }) - - achievement = - upsert_quest(%{ - key: @quest_key_prefix <> "first-win", - title: "Demo First Win", - description: "Win your first demo match.", - reset: "never", - category: "Achievements", - objectives: [%{event: "demo_win", target: 1, params: %{}}], - rewards: [], - auto_claim: true, - active: true, - metadata: %{} - }) - - chain = - upsert_quest(%{ - key: @quest_key_prefix <> "veteran", - title: "Demo Veteran", - description: "Win 10 demo matches (after your first win).", - reset: "never", - category: "Chained", - objectives: [%{event: "demo_win", target: 10, params: %{}}], - rewards: [%{type: "item", code: "loot_crate", amount: 1}], - auto_claim: false, - prerequisite_quest_key: achievement.key, - active: true, - metadata: %{} - }) - - # A group is only worth looking at in bulk: twelve definitions, one card. - countries = ~w(Spain Romania Poland Portugal Greece Norway - Japan Chile Kenya Peru Iceland Vietnam) - - group_members = - for {country, i} <- Enum.with_index(countries) do - upsert_quest(%{ - key: @quest_key_prefix <> "visit-" <> String.downcase(country), - title: "Visit #{country}", - description: "Visit 5 cities in #{country}.", - reset: "never", - category: "Exploration", - group_key: @quest_key_prefix <> "world-tour", - group_title: "Sail the world", - sort_order: i, - objectives: [ - %{event: "demo_city_visited", target: 5, params: %{"country" => country}} - ], - rewards: [%{type: "currency", code: "gold", amount: 50}], - auto_claim: false, - active: true, - metadata: %{} - }) - end - - now = DateTime.utc_now(:second) - today = Gamend.Quests.period_key("daily", now) - - Repo.delete_all(from(p in QuestProgress, where: like(p.quest_key, ^"#{@quest_key_prefix}%"))) - - daily_rows = - user_ids - |> Enum.with_index() - |> Enum.map(fn {user_id, i} -> - completed? = rem(i, 3) == 0 - claimed? = rem(i, 6) == 0 - - status = - cond do - claimed? -> "claimed" - completed? -> "completed" - true -> "active" - end - - %{ - id: UUIDv7.generate(), - user_id: user_id, - quest_key: daily.key, - period_key: today, - objective_progress: %{"0" => if(completed?, do: 3, else: rem(i, 3))}, - status: status, - completed_at: if(completed?, do: now), - claimed_at: if(claimed?, do: now), - rewards_granted_at: if(claimed?, do: now), - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - - achievement_rows = - user_ids - |> Enum.with_index() - |> Enum.filter(fn {_id, i} -> rem(i, 2) == 0 end) - |> Enum.map(fn {user_id, _i} -> - %{ - id: UUIDv7.generate(), - user_id: user_id, - quest_key: achievement.key, - period_key: "static", - objective_progress: %{"0" => 1}, - status: "claimed", - completed_at: now, - claimed_at: now, - rewards_granted_at: now, - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - - chain_rows = - user_ids - |> Enum.with_index() - |> Enum.filter(fn {_id, i} -> rem(i, 4) == 0 end) - |> Enum.map(fn {user_id, i} -> - %{ - id: UUIDv7.generate(), - user_id: user_id, - quest_key: chain.key, - period_key: "static", - objective_progress: %{"0" => rem(i, 10)}, - status: "active", - completed_at: nil, - claimed_at: nil, - rewards_granted_at: nil, - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - - # Spread over the members so the collapsed card has a representative to - # pick: the one furthest along, not whatever sorted first. - group_rows = - for {user_id, i} <- Enum.with_index(user_ids), - rem(i, 3) == 0, - member = Enum.at(group_members, rem(i, length(group_members))) do - %{ - id: UUIDv7.generate(), - user_id: user_id, - quest_key: member.key, - period_key: "static", - objective_progress: %{"0" => rem(i, 5) + 1}, - status: "active", - completed_at: nil, - claimed_at: nil, - rewards_granted_at: nil, - metadata: %{}, - inserted_at: now, - updated_at: now - } - end - - rows = daily_rows ++ achievement_rows ++ chain_rows ++ group_rows - insert_batches(rows, QuestProgress) - - info( - "quests: #{3 + length(group_members)} definitions, #{length(rows)} progress rows -> /admin/quests" - ) - end - - # Definitions go through the context (embeds can't be bulk-inserted). - defp upsert_quest(attrs) do - case Repo.get_by(Quest, key: attrs.key) do - nil -> - {:ok, quest} = Gamend.Quests.create_quest(attrs) - quest - - quest -> - quest - end - end - - defp clean_quests do - Repo.delete_all(from(p in QuestProgress, where: like(p.quest_key, ^"#{@quest_key_prefix}%"))) - Repo.delete_all(from(q in Quest, where: like(q.key, ^"#{@quest_key_prefix}%"))) - end - - # One device per player (platform/provider cycling), every tenth disabled so - # the admin page shows the dead-token state. Log provider, so a test push - # against this data is observable in the server log. Rows cascade-delete - # with their demo user on clean. - defp seed_push_tokens(user_ids) do - Repo.delete_all(from(t in PushToken, where: like(t.token, ^"#{@prefix}-token-%"))) - now = DateTime.utc_now(:second) - - user_ids - |> Enum.with_index() - |> Enum.map(fn {user_id, i} -> - {platform, provider} = - case rem(i, 3) do - 0 -> {"android", "fcm"} - 1 -> {"ios", "apns"} - 2 -> {"web", "fcm"} - end - - %{ - id: UUIDv7.generate(), - user_id: user_id, - token: "#{@prefix}-token-#{pad(i)}", - platform: platform, - provider: provider, - device_id: device_id(i), - disabled_at: if(rem(i, 10) == 9, do: now), - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - |> insert_batches(PushToken) - - info("push: #{length(user_ids)} device tokens -> /admin/push") - end - - # Lobbies are hosted by seeded players and cascade on --clean, so the checks - # attached to them go too. Each run gets one open check plus a spread of - # resolved ones, which is what the admin page's 24h counters read. - defp seed_ready_checks(user_ids) do - hosts = Enum.take(user_ids, min(@max_runs, length(user_ids))) - now = DateTime.utc_now(:second) - - checks = - hosts - |> Enum.with_index() - |> Enum.map(fn {host_id, i} -> - {status, reason} = - case rem(i, 4) do - 0 -> {"pending", nil} - 1 -> {"passed", nil} - 2 -> {"failed", "timeout"} - 3 -> {"failed", "declined"} - end - - lobby = - upsert(Lobby, [title: "#{@lobby_title_prefix} Ready #{pad(i)}"], %{ - title: "#{@lobby_title_prefix} Ready #{pad(i)}", - host_id: host_id, - max_users: 4, - metadata: %{}, - state: "created", - state_changed_at: now - }) - - %{ - id: UUIDv7.generate(), - kind: if(rem(i, 3) == 0, do: "accept", else: "ready"), - status: status, - lobby_id: lobby.id, - deadline_at: DateTime.add(now, 15, :second), - opened_by: host_id, - reason: reason, - resolved_at: if(status != "pending", do: now), - metadata: %{}, - inserted_at: now, - updated_at: now - } - end) - - insert_batches(checks, ReadyCheck) - - participants = - checks - |> Enum.with_index() - |> Enum.flat_map(fn {check, i} -> - user_ids - |> Enum.slice(i, 3) - |> Enum.with_index() - |> Enum.map(fn {user_id, j} -> - %{ - id: UUIDv7.generate(), - ready_check_id: check.id, - user_id: user_id, - state: participant_state(check, j), - responded_at: now, - inserted_at: now, - updated_at: now - } - end) - end) - - insert_batches(participants, ReadyCheckParticipant) - - info("ready checks: #{length(checks)} (this set ignores --count) -> /admin/matchmaking") - end - - defp participant_state(%{status: "passed"}, _index), do: "ready" - defp participant_state(%{status: "failed", reason: "declined"}, 0), do: "declined" - defp participant_state(%{status: "failed", reason: "timeout"}, 0), do: "timed_out" - defp participant_state(%{status: "pending"}, 0), do: "pending" - defp participant_state(_check, _index), do: "ready" - - # ── Chat moderation ─────────────────────────────────────────────────────── - - # Reports point at real messages in real rooms, so the queue's content - # snapshots, "reported user" links and per-scope mute lists all resolve the - # way they do in production. - defp seed_chat_moderation(user_ids) do - rooms = chat_rooms(user_ids) - clean_chat_moderation(rooms) - - words = seed_filter_words() - messages = seed_chat_messages(user_ids, rooms) - reports = seed_chat_reports(messages, hd(user_ids)) - mutes = seed_chat_mutes(user_ids, rooms) - - info( - "chat moderation: #{words} filter words, #{reports} reports, #{mutes} mutes -> /admin/chat_reports" - ) - end - - defp chat_rooms(user_ids) do - now = DateTime.utc_now(:second) - [host | _rest] = user_ids - - lobby = - upsert(Lobby, [title: @chat_lobby_title], %{ - title: @chat_lobby_title, - host_id: host, - max_users: 4, - metadata: %{}, - state: "created", - state_changed_at: now - }) - - party = upsert(Party, [leader_id: host], %{leader_id: host, max_size: 4, metadata: %{}}) - - %{"group" => demo_group(user_ids).id, "lobby" => lobby.id, "party" => party.id} - end - - # Reports go before their messages: deleting a message only nils out the - # report pointing at it. - defp clean_chat_moderation(rooms) do - room_ids = Map.values(rooms) - message_ids = from(m in Message, where: m.chat_ref_id in ^room_ids, select: m.id) - - Repo.delete_all(from(r in Report, where: r.message_id in subquery(message_ids))) - Repo.delete_all(from(m in Message, where: m.chat_ref_id in ^room_ids)) - Repo.delete_all(from(m in Mute, where: m.user_id in subquery(demo_user_ids()))) - end - - # Words go in through the context: it stores them normalized (a raw insert - # would never match) and mirrors them into the ETS blocklist. - defp seed_filter_words do - clean_filter_words() - - Enum.count(@filter_words, fn {word, severity, match_mode, lang} -> - match?( - {:ok, _word}, - Moderation.create_filter_word(%{ - "word" => word, - "severity" => severity, - "match_mode" => match_mode, - "lang" => lang - }) - ) - end) - end - - defp clean_filter_words do - words = - Enum.map(@filter_words, fn {word, _severity, _mode, _lang} -> Normalizer.normalize(word) end) - - from(w in FilterWord, where: w.word in ^words) - |> Repo.all() - |> Enum.each(&Moderation.delete_filter_word/1) - end - - defp seed_chat_messages(user_ids, rooms) do - now = DateTime.utc_now(:second) - - rows = - user_ids - |> at_least(@min_reports) - |> Enum.with_index() - |> Enum.map(fn {sender_id, i} -> - chat_type = Enum.at(@chat_room_types, rem(i, length(@chat_room_types))) - {content, _reason, flagged?} = chat_line(i) - at = DateTime.add(now, -(i * 900 + 60), :second) - - %{ - id: UUIDv7.generate(), - sender_id: sender_id, - chat_type: chat_type, - chat_ref_id: Map.fetch!(rooms, chat_type), - content: content, - metadata: if(flagged?, do: %{"flagged" => true}, else: %{}), - inserted_at: at, - updated_at: at - } - end) - - insert_batches(rows, Message) - rows - end - - # Every fifth message is one the filter flagged itself, so the queue mixes - # system-filed reports (nil reporter) into the player-filed ones. Pure and - # index-keyed, so the report built for message `i` reads off the same line. - defp chat_line(i) when rem(i, 5) == 0 do - {content, word} = Enum.at(@flagged_lines, rem(div(i, 5), length(@flagged_lines))) - {content, "Filter: " <> word, true} - end - - defp chat_line(i) do - {content, reason} = Enum.at(@chat_lines, rem(i, length(@chat_lines))) - {content, reason, false} - end - - # The reporter is the next player along, since a player cannot report their - # own message (`Chat.report_message/3` rejects it). - defp seed_chat_reports(messages, moderator_id) do - now = DateTime.utc_now(:second) - - reporters = - messages - |> Stream.map(& &1.sender_id) - |> Stream.cycle() - |> Stream.drop(1) - |> Enum.take(length(messages)) - - rows = - messages - |> Enum.zip(reporters) - |> Enum.with_index() - |> Enum.map(fn {{message, reporter_id}, i} -> - {status, note} = report_status(i) - {_content, reason, flagged?} = chat_line(i) - at = DateTime.add(now, -i * 900, :second) - - %{ - id: UUIDv7.generate(), - reporter_id: if(flagged?, do: nil, else: reporter_id), - message_id: message.id, - reported_user_id: message.sender_id, - content_snapshot: message.content, - reason: reason, - status: status, - resolved_by: if(note, do: moderator_id), - resolution_note: note, - resolved_at: if(note, do: now), - inserted_at: at, - updated_at: at - } - end) - - insert_batches(rows, Report) - length(rows) - end - - defp report_status(i) do - case rem(i, 4) do - 0 -> {"open", nil} - 1 -> {"reviewing", nil} - 2 -> {"actioned", "24h mute — third report this week."} - 3 -> {"dismissed", "Heated, but inside the rules."} - end - end - - # Every scope, and a spread of permanent / expiring / already expired so the - # sweep has rows to reap. One mute per player keeps the unique index happy. - defp seed_chat_mutes(user_ids, rooms) do - now = DateTime.utc_now(:second) - [moderator | rest] = user_ids - - rows = - rest - |> Enum.take(@max_mutes) - |> Enum.with_index() - |> Enum.map(fn {user_id, i} -> - {scope, scope_ref_id} = mute_scope(rooms, i) - {expires_at, reason} = mute_expiry(now, i) - - %{ - id: UUIDv7.generate(), - user_id: user_id, - scope: scope, - scope_ref_id: scope_ref_id, - expires_at: expires_at, - reason: reason, - muted_by: moderator, - inserted_at: now, - updated_at: now - } - end) - - insert_batches(rows, Mute) - length(rows) - end - - defp mute_scope(rooms, i) do - case rem(i, 4) do - 0 -> {"global", nil} - 1 -> {"lobby", Map.fetch!(rooms, "lobby")} - 2 -> {"group", Map.fetch!(rooms, "group")} - 3 -> {"party", Map.fetch!(rooms, "party")} - end - end - - defp mute_expiry(now, i) do - case rem(div(i, 4), 3) do - 0 -> {nil, "Permanent — ban evasion."} - 1 -> {DateTime.add(now, 900), "Cooling off, back in 15 minutes."} - 2 -> {DateTime.add(now, -86_400), "Expired yesterday; waiting for the sweep."} - end - end - - # A small --count still has to fill more than one page of the report queue. - defp at_least(ids, minimum) when length(ids) >= minimum, do: ids - defp at_least(ids, minimum), do: ids |> Stream.cycle() |> Enum.take(minimum) - - defp demo_user_ids do - from(u in User, where: like(u.device_id, ^"#{@prefix}-%"), select: u.id) - end - - defp clean do - lb = Repo.get_by(Leaderboard, slug: @leaderboard_slug) - group = Repo.get_by(Group, title: @group_title) - tournament = Repo.get_by(Tournament, slug: @tournament_slug) - - if lb, do: Repo.delete_all(from(r in Record, where: r.leaderboard_id == ^lb.id)) - if group, do: Repo.delete_all(from(m in GroupMember, where: m.group_id == ^group.id)) - if tournament, do: Repo.delete_all(from(e in Entry, where: e.tournament_id == ^tournament.id)) - - if lb, do: Repo.delete(lb) - if group, do: Repo.delete(group) - if tournament, do: Repo.delete(tournament) - - # Before the players go: seeded lobbies reference them as host. - clean_lobby_snapshots() - clean_quests() - - # Reports, mutes and messages cascade with their players; the blocklist has - # no player to hang off. - clean_filter_words() - - {users, _} = Repo.delete_all(from(u in User, where: like(u.device_id, ^"#{@prefix}-%"))) - - Gamend.Cache.delete_all() - info("removed demo data (#{users} players)") - end - - # ── Helpers ─────────────────────────────────────────────────────────────── - - # insert_all rejects oversized statements, so rows go in batches. - defp insert_batches([], _schema), do: :ok - - defp insert_batches(rows, schema) do - rows - |> Enum.chunk_every(@batch) - |> Enum.each(&Repo.insert_all(schema, &1)) + Gamend.DemoSeed.run(args) + rescue + error in ArgumentError -> Mix.raise(Exception.message(error)) end - - defp upsert(schema, lookup, attrs) do - case Repo.get_by(schema, lookup) do - nil -> - now = DateTime.utc_now(:second) - - attrs = - attrs - |> Map.put(:id, UUIDv7.generate()) - |> Map.put_new(:inserted_at, now) - |> Map.put_new(:updated_at, now) - - Repo.insert_all(schema, [attrs]) - Repo.get_by!(schema, lookup) - - found -> - found - end - end - - # ── Lobby snapshots ─────────────────────────────────────────────────────── - - defp seed_lobby_snapshots(user_ids) do - previous = Application.get_env(:gamend_core, Gamend.LobbySnapshots, []) - - # Capture is off by default, so force it on for the duration rather than - # making the operator set an env var to seed demo data. - Application.put_env( - :gamend_core, - Gamend.LobbySnapshots, - Keyword.merge(previous, enabled: true) - ) - - hosts = Enum.take(user_ids, @max_runs) - info("recording #{length(hosts)} runs (this set ignores --count)") - - hosts - |> Enum.with_index() - |> Enum.each(fn {host_id, index} -> record_run(host_id, index) end) - - Writer.flush() - backdate_runs() - - Application.put_env(:gamend_core, Gamend.LobbySnapshots, previous) - end - - # Run 0 is the interesting one: it replays the shape of the July 2026 - # rubber-banding bug, where the boat's distance and the slow's anchor both - # revert. Expanding snapshot 4 in the admin view shows it as a change *back*. - defp record_run(host_id, 0), do: play(host_id, "rubber-band", rubber_band_frames()) - defp record_run(host_id, 1), do: play(host_id, "hook error", error_frames()) - defp record_run(host_id, index), do: play(host_id, "run #{index}", normal_frames(index)) - - defp play(host_id, label, frames) do - {:ok, lobby} = - Gamend.Lobbies.create_lobby(%{ - title: "#{@lobby_title_prefix} — #{label}", - host_id: host_id, - max_users: 4 - }) - - Enum.each(frames, fn frame -> - {:ok, _} = - Gamend.Lobbies.update_lobby( - Repo.get!(Lobby, lobby.id), - %{metadata: frame.metadata} - ) - - LobbySnapshots.capture_lobby(lobby.id, frame.trigger, - sync: true, - flagged: Map.get(frame, :flagged, false), - user_id: host_id - ) - - Enum.each(Map.get(frame, :events, []), fn {kind, payload} -> - LobbySnapshots.record_event(lobby.id, kind, payload, user_id: host_id) - end) - end) - end - - defp boat(distance, speed, anchor) do - %{ - "boat_adventure" => %{ - "distance" => distance, - "speed" => speed, - "effects" => %{ - "speed_reduced" => %{"distance_at_start" => anchor, "duration_ms" => 4000} - }, - "actors" => [%{"type" => "starfish", "wave" => 1, "hp" => 2}] - }, - "word_match" => %{"score" => round(distance / 10), "current_word" => "harbour"}, - "game_state" => "running" - } - end - - defp rubber_band_frames do - [ - %{trigger: "hook:start_boat_game", metadata: boat(0.0, 100, 0.0)}, - %{ - trigger: "hook:guess_word", - metadata: boat(120.0, 100, 0.0), - events: [{"boat.speed", %{"from" => 100, "to" => 100, "gap" => 91.2}}] - }, - %{ - trigger: "timer:scheduled_collision", - metadata: boat(250.0, 50, 250.0), - events: [ - {"boat.collision", %{"actor" => "starfish", "wave" => 1, "damage" => 1}}, - {"boat.speed", %{"from" => 100, "to" => 50, "gap" => 78.39, "targets_ahead" => 8}} - ] - }, - %{trigger: "hook:guess_word", metadata: boat(370.0, 50, 250.0)}, - # The bug: a stale client echo re-anchors the slow, dragging distance back. - %{ - trigger: "hook:guess_word", - metadata: boat(250.0, 50, 250.0), - events: [{"boat.merge_divergence", %{"current" => 370.0, "incoming" => 250.0}}] - }, - %{trigger: "hook:guess_word", metadata: boat(480.0, 100, 250.0)}, - %{trigger: "lobby:deleted", metadata: boat(480.0, 100, 250.0) |> finished()} - ] - end - - defp error_frames do - [ - %{trigger: "hook:start_boat_game", metadata: boat(0.0, 100, 0.0)}, - %{trigger: "hook:guess_word", metadata: boat(90.0, 100, 0.0)}, - %{ - trigger: "hook:finish_boat_game", - metadata: boat(90.0, 100, 0.0), - flagged: true, - events: [{"hook.error", %{"reason" => "function_clause", "hook" => "finish_boat_game"}}] - } - ] - end - - defp normal_frames(index) do - steps = 3 + rem(index, 3) - - frames = - for step <- 0..steps do - distance = step * 140.0 + index * 10 - speed = if rem(step, 3) == 2, do: 50, else: 100 - - %{ - trigger: if(step == 0, do: "hook:start_boat_game", else: "hook:guess_word"), - metadata: boat(distance, speed, if(speed == 50, do: distance, else: 0.0)), - events: - if(speed == 50, - do: [{"boat.speed", %{"from" => 100, "to" => 50, "gap" => 62.5}}], - else: [] - ) - } - end - - # Every run ends the way a real one does: the last member leaves and the - # lobby is torn down. - teardown = %{trigger: "lobby:deleted", metadata: finished(List.last(frames).metadata)} - - Enum.reverse([teardown | Enum.reverse(frames)]) - end - - defp finished(metadata), do: put_in(metadata, ["game_state"], "finished") - - # Captures happen milliseconds apart, which makes every run look simultaneous - # in the list view. Spread them so durations and start times read like real - # sessions — and so the retention window has something meaningful to act on. - defp backdate_runs do - lobby_ids = - from(l in Lobby, - where: like(l.title, ^"#{@lobby_title_prefix}%"), - order_by: l.inserted_at, - select: l.id - ) - |> Repo.all() - - lobby_ids - |> Enum.with_index() - |> Enum.each(fn {lobby_id, run_index} -> - started = DateTime.add(DateTime.utc_now(), -(run_index * 5 + 1) * 3600, :second) - - shift_rows(Snapshot, lobby_id, started) - shift_rows(SnapshotEvent, lobby_id, started) - end) - end - - defp shift_rows(schema, lobby_id, started) do - ids = - from(r in schema, - where: r.lobby_id == ^lobby_id, - order_by: [asc: r.inserted_at, asc: r.id], - select: r.id - ) - |> Repo.all() - - ids - |> Enum.with_index() - |> Enum.each(fn {id, step} -> - at = DateTime.add(started, step * 6, :second) - Repo.update_all(from(r in schema, where: r.id == ^id), set: [inserted_at: at]) - end) - end - - defp clean_lobby_snapshots do - lobby_ids = - from(l in Lobby, where: like(l.title, ^"#{@lobby_title_prefix}%")) - |> Repo.all() - |> Enum.map(& &1.id) - - if lobby_ids != [] do - Repo.delete_all(from(s in Snapshot, where: s.lobby_id in ^lobby_ids)) - - Repo.delete_all(from(e in SnapshotEvent, where: e.lobby_id in ^lobby_ids)) - - Enum.each(lobby_ids, fn id -> - case Repo.get(Lobby, id) do - nil -> :ok - lobby -> Gamend.Lobbies.delete_lobby(lobby) - end - end) - end - - # Blobs are content-addressed and may be shared with real runs, so they are - # left for the retention sweep's reference-aware GC rather than deleted here. - info("removed #{length(lobby_ids)} seeded runs") - end - - defp info(message), do: Mix.shell().info(" #{message}") end diff --git a/apps/gamend_core/lib/mix/tasks/gen.sdk.ex b/apps/gamend_core/lib/mix/tasks/gen.sdk.ex index 115719fe4..e9dabad51 100644 --- a/apps/gamend_core/lib/mix/tasks/gen.sdk.ex +++ b/apps/gamend_core/lib/mix/tasks/gen.sdk.ex @@ -79,10 +79,26 @@ defmodule Mix.Tasks.Gen.Sdk do end) write_api_table(Map.merge(api_table, extra)) + write_hook_defaults(sdk_dir) Mix.shell().info("SDK stubs generated in #{sdk_dir}") end + # `use Gamend.Hooks` injects the same defaults in the SDK (a plugin built with + # Mix) as in the engine (a plugin built in-process), so the SDK gets the + # engine's module itself rather than a hand-kept copy that could drift. + @hook_defaults_source Path.expand("../../gamend/hooks/defaults.ex", __DIR__) + + defp write_hook_defaults(sdk_dir) do + path = Path.join([sdk_dir, "hooks", "defaults.ex"]) + File.mkdir_p!(Path.dirname(path)) + + File.write!(path, [ + "# Generated by `mix gen.sdk` from apps/gamend_core/lib/gamend/hooks/defaults.ex -- do not edit, run the task.\n", + File.read!(@hook_defaults_source) + ]) + end + # The GDScript front end resolves `Economy.grant(...)` against this table, so # it has to come from `@sdk_modules` rather than a hand-kept list -- otherwise # adding a context leaves it silently uncallable from a script. Arities ride diff --git a/apps/gamend_core/lib/mix/tasks/host.seed.ex b/apps/gamend_core/lib/mix/tasks/host.seed.ex index c38149755..856475d84 100644 --- a/apps/gamend_core/lib/mix/tasks/host.seed.ex +++ b/apps/gamend_core/lib/mix/tasks/host.seed.ex @@ -7,12 +7,9 @@ defmodule Mix.Tasks.Host.Seed do @impl Mix.Task def run(_args) do - seeds_path = Path.expand("priv/repo/seeds.exs") - - if File.exists?(seeds_path) do - Mix.Task.run("run", [seeds_path]) - else - Mix.shell().info("No seeds file at #{seeds_path}, skipping") + case Gamend.Release.seeds_file() do + nil -> Mix.shell().info("No seeds file at priv/repo/seeds.exs, skipping") + path -> Mix.Task.run("run", [path]) end end end diff --git a/apps/gamend_core/mix.exs b/apps/gamend_core/mix.exs index 9032fba04..f88d53c85 100644 --- a/apps/gamend_core/mix.exs +++ b/apps/gamend_core/mix.exs @@ -83,7 +83,11 @@ defmodule GamendCore.MixProject do # renders as undifferentiated text. {:lumis, "~> 0.1"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:ex_doc, "~> 0.40", only: :dev, runtime: false} + {:ex_doc, "~> 0.40", only: :dev, runtime: false}, + # The GDScript transpiler, for the in-process plugin build tests. At runtime + # the host ships it (a dependency of the root app); the core only calls + # it when it is loaded, so the core itself does not depend on it. + {:gamend_plugin_tools, path: "../../sdk_tools", only: :test} ] end diff --git a/apps/gamend_core/test/gamend/demo_seed_test.exs b/apps/gamend_core/test/gamend/demo_seed_test.exs new file mode 100644 index 000000000..4ebc5d61c --- /dev/null +++ b/apps/gamend_core/test/gamend/demo_seed_test.exs @@ -0,0 +1,40 @@ +defmodule Gamend.DemoSeedTest do + @moduledoc """ + `mix demo.seed` and a release's `gamend demo.seed` both run + `Gamend.DemoSeed.run/1`. A small count covers every set; `--clean` has to + take all of it away again. + """ + use Gamend.DataCase, async: false + + import ExUnit.CaptureIO + import Ecto.Query + + alias Gamend.Accounts.User + alias Gamend.DemoSeed + alias Gamend.Leaderboards.Leaderboard + alias Gamend.Repo + + test "seeds every set, and --clean removes what it seeded" do + output = capture_io(fn -> assert DemoSeed.run(["--count", "3"]) == :ok end) + + assert output =~ "done" + assert demo_users() > 0 + assert Repo.exists?(from(l in Leaderboard, where: like(l.slug, "demo_seed%"))) + + capture_io(fn -> assert DemoSeed.run(["--clean"]) == :ok end) + + assert demo_users() == 0 + refute Repo.exists?(from(l in Leaderboard, where: like(l.slug, "demo_seed%"))) + end + + test "an unknown set is an ArgumentError naming the known ones" do + error = assert_raise ArgumentError, fn -> DemoSeed.run(["--only", "nope"]) end + + assert Exception.message(error) =~ "unknown set(s): nope" + assert Exception.message(error) =~ "leaderboard" + end + + defp demo_users do + Repo.aggregate(from(u in User, where: like(u.device_id, "demo-seed-%")), :count) + end +end diff --git a/apps/gamend_core/test/gamend/hooks/defaults_test.exs b/apps/gamend_core/test/gamend/hooks/defaults_test.exs new file mode 100644 index 000000000..eb657d041 --- /dev/null +++ b/apps/gamend_core/test/gamend/hooks/defaults_test.exs @@ -0,0 +1,39 @@ +defmodule Gamend.Hooks.DefaultsTest do + @moduledoc """ + `use Gamend.Hooks` is the same in the SDK, which a Mix-built plugin compiles + against, and in the engine, which an in-process build compiles against: the + SDK's `Gamend.Hooks.Defaults` is this module's source, written by + `mix gen.sdk`. + """ + use ExUnit.Case, async: true + + @sdk_copy Path.expand("../../../../../sdk/lib/gamend/hooks/defaults.ex", __DIR__) + @source Path.expand("../../../lib/gamend/hooks/defaults.ex", __DIR__) + + @tag skip: not File.exists?(@sdk_copy) && "no sdk/ in this checkout" + test "the SDK carries the engine's defaults, as `mix gen.sdk` last wrote them" do + [_generated_header, copy] = @sdk_copy |> File.read!() |> String.split("\n", parts: 2) + + assert copy == File.read!(@source), "run `mix gen.sdk`" + end + + test "a module using it gets every overridable default" do + {:module, module, _, _} = + Module.create( + Module.concat(__MODULE__, "Plugin#{System.unique_integer([:positive])}"), + quote do + use Gamend.Hooks + + @impl true + def after_user_register(_user), do: :mine + end, + __ENV__ + ) + + assert Gamend.Hooks in Keyword.get(module.module_info(:attributes), :behaviour, []) + assert module.after_user_register(nil) == :mine + assert module.before_lobby_create(%{a: 1}) == {:ok, %{a: 1}} + assert module.before_kv_get("k", []) == :public + assert module.validate_username("x") == :default + end +end diff --git a/apps/gamend_core/test/gamend/hooks/plugin_builder/project_test.exs b/apps/gamend_core/test/gamend/hooks/plugin_builder/project_test.exs new file mode 100644 index 000000000..d10c302bd --- /dev/null +++ b/apps/gamend_core/test/gamend/hooks/plugin_builder/project_test.exs @@ -0,0 +1,115 @@ +defmodule Gamend.Hooks.PluginBuilder.ProjectTest do + use ExUnit.Case, async: true + + alias Gamend.Hooks.PluginBuilder.Project + + @moduletag :tmp_dir + + defp read(tmp_dir, source) do + dir = Path.join(tmp_dir, "my_plugin") + File.mkdir_p!(dir) + File.write!(Path.join(dir, "mix.exs"), source) + Project.read(dir) + end + + test "reads the literals a plugin's mix.exs declares, without running it", %{tmp_dir: tmp_dir} do + {:ok, project} = + read(tmp_dir, """ + defmodule MyPlugin.MixProject do + use Mix.Project + + @version "1.2.3" + # Evaluating this file would raise; reading it must not. + raise "evaluated" + + def project do + [ + app: :my_plugin, + version: System.get_env("APP_VERSION") || @version, + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps() + ] + end + + def application do + [ + mod: {MyPlugin.Application, []}, + extra_applications: [:logger, :crypto], + env: [hooks_module: Gamend.Modules.MyPlugin, greeting: "hi", limits: %{max: 3}] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib", "gen"] + + defp deps do + [ + shared_dep(:gamend_sdk, "../sdk"), + {:phoenix, "~> 1.8"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:bunt, "~> 1.0", optional: true} + ] + end + + defp shared_dep(app, path), do: {app, path: path, runtime: false} + end + """) + + assert project.app == :my_plugin + assert project.version == "1.2.3" + assert project.elixirc_paths == ["lib", "gen"] + assert project.extra_applications == [:logger, :crypto] + assert project.mod == {MyPlugin.Application, []} + assert project.hooks_module == Gamend.Modules.MyPlugin + assert project.env == [greeting: "hi", limits: %{max: 3}] + assert project.warnings == [] + + assert Enum.map(project.deps, &{&1.name, &1.runtime?, &1.prod?, &1.optional?}) == [ + {:gamend_sdk, true, true, false}, + {:phoenix, true, true, false}, + {:credo, false, false, false}, + {:bunt, true, true, true} + ] + end + + test "falls back, and says so, where a value is not a literal", %{tmp_dir: tmp_dir} do + {:ok, project} = + read(tmp_dir, """ + defmodule MyPlugin.MixProject do + use Mix.Project + + def project do + [app: String.to_atom("x"), version: version(), elixirc_paths: paths()] + end + + def application, do: [env: [hooks_module: Module.concat(["A"])]] + + defp version, do: File.read!("VERSION") + defp paths, do: Enum.map(["lib"], & &1) + end + """) + + assert project.app == :my_plugin + assert project.version == "0.0.0" + assert project.elixirc_paths == ["lib"] + assert project.hooks_module == nil + assert length(project.warnings) == 4 + end + + test "a string hooks_module becomes a module", %{tmp_dir: tmp_dir} do + {:ok, project} = + read(tmp_dir, """ + defmodule P.MixProject do + def project, do: [app: :my_plugin] + def application, do: [env: [hooks_module: "Elixir.Gamend.Modules.P"]] + end + """) + + assert project.hooks_module == Gamend.Modules.P + end + + test "a file that does not parse is an error", %{tmp_dir: tmp_dir} do + assert {:error, message} = read(tmp_dir, "defmodule P do\n def project, do: [\nend\n") + assert message =~ "mix.exs" + end +end diff --git a/apps/gamend_core/test/gamend/hooks/plugin_builder_in_process_test.exs b/apps/gamend_core/test/gamend/hooks/plugin_builder_in_process_test.exs new file mode 100644 index 000000000..95629131a --- /dev/null +++ b/apps/gamend_core/test/gamend/hooks/plugin_builder_in_process_test.exs @@ -0,0 +1,545 @@ +defmodule Gamend.Hooks.PluginBuilderInProcessTest do + @moduledoc """ + The build a release runs: no Mix, the compiler inside this VM. Forced with + `mode: :in_process`, since the test environment has `mix` on the PATH. + """ + # Points the plugin manager at a temporary plugins directory, and empties + # PATH in one test: both process-global. + use Gamend.DataCase, async: false + + import ExUnit.CaptureIO + + alias Gamend.Hooks.PluginBuilder + alias Gamend.Hooks.PluginManager + alias Gamend.SettingsHelpers + + @moduletag :tmp_dir + + setup %{tmp_dir: tmp_dir} do + root = Path.join(tmp_dir, "plugins") + File.mkdir_p!(root) + SettingsHelpers.put(:gamend_core, Gamend.ContentSettings, :plugins_dir, root) + + on_exit(fn -> + SettingsHelpers.delete(:gamend_core, Gamend.ContentSettings, :plugins_dir) + _ = PluginManager.reload() + end) + + n = System.unique_integer([:positive]) + %{root: root, name: "ipb_#{n}", module: Module.concat(Gamend.Modules, "Ipb#{n}"), n: n} + end + + describe "an Elixir plugin" do + test "builds into a bundle the manager loads", %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + assert result.mode == :in_process + + assert Enum.map(result.steps, & &1.cmd) == [ + "read mix.exs", + "deps (in-process)", + "compile (in-process)", + "plugin.bundle (in-process)" + ] + + helper = Module.concat(module, Helper) + + # Nothing of the build stays loaded, and no build directory is left. + refute :code.is_loaded(module) + refute :code.is_loaded(helper) + assert File.ls!(dir) |> Enum.sort() == ["ebin", "lib", "mix.exs"] + + assert ebin_files(dir) == + Enum.sort(["#{name}.app", "#{module}.beam", "#{helper}.beam"]) + + _ = PluginManager.reload() + + assert {:ok, %{status: :ok, hooks_module: ^module, vsn: "0.2.0"}} = + PluginManager.lookup(name) + + assert PluginManager.call_rpc(name, "hello", ["bob"]) == {:ok, "hi bob!"} + end + + test "writes the .app mix plugin.bundle does", %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + assert {:ok, %{ok?: true}} = build(name) + + assert {:ok, [{:application, app, props}]} = + :file.consult(Path.join([dir, "ebin", "#{name}.app"])) + + assert app == String.to_atom(name) + + # Same keys in the same order as Mix's compile.app output. + assert Keyword.keys(props) == + [ + :modules, + :optional_applications, + :applications, + :description, + :registered, + :vsn, + :env + ] + + assert props[:modules] == Enum.sort([module, Module.concat(module, Helper)]) + assert props[:optional_applications] == [] + # gamend_sdk is runtime: false and optional, so it is not an application. + assert props[:applications] == [:kernel, :stdlib, :elixir, :logger, :jason] + assert props[:description] == String.to_charlist(name) + assert props[:registered] == [] + assert props[:vsn] == ~c"0.2.0" + assert props[:env] == [hooks_module: module] + end + + test "rebuilding a loaded plugin compiles against its new code and restarts it", + %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + assert {:ok, %{ok?: true}} = build(name) + _ = PluginManager.reload() + assert PluginManager.call_rpc(name, "hello", ["bob"]) == {:ok, "hi bob!"} + + # The suffix is a macro in a sibling module: a build compiled against the + # loaded (old) sibling would still say "!". Loaded here as a release in + # embedded mode loads every beam of a plugin. + assert {:module, _} = Code.ensure_loaded(Module.concat(module, Helper)) + File.write!(Path.join([dir, "lib", "helper.ex"]), helper_source(module, "?")) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + assert List.last(result.steps).output =~ "restarted the plugin on the new bundle" + + assert {:ok, %{status: :ok}} = PluginManager.lookup(name) + assert PluginManager.call_rpc(name, "hello", ["bob"]) == {:ok, "hi bob?"} + + # A later full reload works on top of it. + _ = PluginManager.reload() + assert PluginManager.call_rpc(name, "hello", ["bob"]) == {:ok, "hi bob?"} + end + + test "a failed compile keeps the previous ebin", %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + assert {:ok, %{ok?: true}} = build(name) + before = ebin_contents(dir) + + File.write!(Path.join([dir, "lib", "hooks.ex"]), """ + defmodule #{inspect(module)} do + use Gamend.Hooks + def hello(name), do: not_a_function(name) + end + """) + + assert {:ok, result} = build(name) + refute result.ok? + + compile = Enum.find(result.steps, &(&1.cmd == "compile (in-process)")) + assert compile.status == 1 + assert compile.output =~ "undefined function not_a_function/1" + assert compile.output =~ "lib/hooks.ex:3" + + assert ebin_contents(dir) == before + assert File.ls!(dir) |> Enum.sort() == ["ebin", "lib", "mix.exs"] + end + + test "a failed build of a loaded plugin brings the old one back", + %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + assert {:ok, %{ok?: true}} = build(name) + _ = PluginManager.reload() + + File.write!( + Path.join([dir, "lib", "hooks.ex"]), + "defmodule Broken do\n def x(, do: 1\nend\n" + ) + + assert {:ok, %{ok?: false} = result} = build(name) + assert List.last(result.steps).output =~ "restarted the plugin on its previous bundle" + assert PluginManager.call_rpc(name, "hello", ["bob"]) == {:ok, "hi bob!"} + end + + test "a module the server already has is refused", + %{root: root, name: name, module: module, n: n} do + # Stands in for an engine module, without risking a real one. + existing = Module.concat(Gamend, "ExistingModule#{n}") + + {:module, ^existing, _, _} = + Module.create(existing, quote(do: def(hi, do: :engine)), __ENV__) + + dir = write_plugin(root, name, module) + + File.write!(Path.join([dir, "lib", "clash.ex"]), """ + defmodule #{inspect(existing)} do + def hi, do: :plugin + end + """) + + assert {:ok, %{ok?: false} = result} = build(name) + bundle = List.last(result.steps) + assert bundle.cmd == "plugin.bundle (in-process)" + + assert bundle.output =~ + "cannot redefine a module the server already has: #{inspect(existing)}" + + refute File.exists?(Path.join(dir, "ebin")) + # The loaded module is untouched. + assert existing.hi() == :engine + end + end + + describe "hooks_module" do + test "is detected from `use Gamend.Hooks` when mix.exs does not name it", + %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module, env: nil) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + assert List.last(result.steps).output =~ "detected: @behaviour Gamend.Hooks" + assert app_env(dir, name) == [hooks_module: module] + end + + test "is an error when several modules implement Gamend.Hooks", + %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module, env: nil) + + File.write!(Path.join([dir, "lib", "other.ex"]), """ + defmodule #{inspect(module)}.Other do + use Gamend.Hooks + end + """) + + assert {:ok, %{ok?: false} = result} = build(name) + assert List.last(result.steps).output =~ "several modules implement Gamend.Hooks" + refute File.exists?(Path.join(dir, "ebin")) + end + end + + describe "dependencies" do + test "one the engine does not ship fails the build, naming it", + %{root: root, name: name, module: module} do + dir = + write_plugin(root, name, module, + deps: ~s([{:jason, "~> 1.2"}, {:not_shipped_dep, "~> 1.0"}]) + ) + + assert {:ok, %{ok?: false} = result} = build(name) + deps = List.last(result.steps) + assert deps.cmd == "deps (in-process)" + assert deps.status == 1 + assert deps.output =~ "missing: :not_shipped_dep" + assert deps.output =~ "jason: provided by the engine" + refute File.exists?(Path.join(dir, "ebin")) + end + + test "one carried prebuilt in deps//ebin is on the code path while compiling", + %{root: root, name: name, module: module, n: n} do + dep_app = :"prebuilt_dep_#{n}" + dep_module = Module.concat(PrebuiltDep, "M#{n}") + dep_ebin = Path.join([root, name, "deps", Atom.to_string(dep_app), "ebin"]) + File.mkdir_p!(dep_ebin) + + {:module, ^dep_module, beam, _} = + Module.create(dep_module, quote(do: defmacro(word, do: "from the dep")), __ENV__) + + :code.purge(dep_module) + :code.delete(dep_module) + File.write!(Path.join(dep_ebin, "#{dep_module}.beam"), beam) + + File.write!( + Path.join(dep_ebin, "#{dep_app}.app"), + :io_lib.format(~c"~p.~n", [ + {:application, dep_app, + [vsn: ~c"1.0.0", modules: [dep_module], applications: [:kernel, :stdlib]]} + ]) + |> IO.chardata_to_string() + ) + + dir = write_plugin(root, name, module, deps: "[{#{inspect(dep_app)}, \"~> 1.0\"}]") + + File.write!(Path.join([dir, "lib", "dep_user.ex"]), """ + defmodule #{inspect(module)}.DepUser do + require #{inspect(dep_module)} + def word, do: #{inspect(dep_module)}.word() + end + """) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + assert Enum.at(result.steps, 1).output =~ "prebuilt in deps/#{dep_app}/ebin" + + {:ok, [{:application, _, props}]} = :file.consult(Path.join([dir, "ebin", "#{name}.app"])) + assert dep_app in props[:applications] + + # The dep's path was put on the code path for the compile only. + refute Enum.member?(:code.get_path(), String.to_charlist(dep_ebin)) + end + end + + describe "a GDScript plugin" do + test "is transpiled into gen/ and built like example_gdscript", + %{root: root, name: name, n: n} do + dir = write_gdscript_plugin(root, name, n, hooks_module?: true) + module = Module.concat(Gamend.Modules, Macro.camelize(name)) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + + assert Enum.map(result.steps, & &1.cmd) == [ + "read mix.exs", + "gamend.gdscript.compile (in-process)", + "deps (in-process)", + "compile (in-process)", + "plugin.bundle (in-process)" + ] + + # The same files `mix gamend.gdscript.compile` writes from inside the + # plugin, header included. + gen = Path.join([dir, "gen", "gamend", "modules"]) + + assert File.read!(Path.join(gen, "ipb#{n}.ex")) =~ + "# Generated from scripts/#{name}.gd by `mix gamend.gdscript.compile`" + + assert File.read!(Path.join(gen, "gd_rewards#{n}.ex")) =~ + "# Generated from scripts/gd_rewards_#{n}.gd" + + assert app_env(dir, name) == [hooks_module: module] + + _ = PluginManager.reload() + assert {:ok, %{status: :ok, hooks_module: ^module}} = PluginManager.lookup(name) + assert PluginManager.call_rpc(name, "greeting", ["bob"]) == {:ok, "Welcome, bob! 150 gold"} + end + + test "without hooks_module in mix.exs uses the script named after the plugin", + %{root: root, name: name, n: n} do + dir = write_gdscript_plugin(root, name, n, hooks_module?: false) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + + assert List.last(result.steps).output =~ + "detected: the GDScript module named after the plugin" + + assert app_env(dir, name) == [ + hooks_module: Module.concat(Gamend.Modules, Macro.camelize(name)) + ] + end + + test "a script error is reported and nothing is built", %{root: root, name: name, n: n} do + dir = write_gdscript_plugin(root, name, n, hooks_module?: true) + File.write!(Path.join([dir, "scripts", "#{name}.gd"]), "func broken(:\n\treturn 1\n") + + assert {:ok, %{ok?: false} = result} = build(name) + step = List.last(result.steps) + assert step.cmd == "gamend.gdscript.compile (in-process)" + assert step.output =~ "scripts/#{name}.gd" + refute File.exists?(Path.join(dir, "ebin")) + end + end + + test "a defimpl of a consolidated protocol builds, with the compiler's warning", + %{root: root, name: name, module: module} do + dir = write_plugin(root, name, module) + + File.write!(Path.join([dir, "lib", "impl.ex"]), """ + defmodule #{inspect(module)}.Thing do + defstruct [:x] + end + + defimpl String.Chars, for: #{inspect(module)}.Thing do + def to_string(_thing), do: "thing" + end + """) + + assert {:ok, result} = build(name) + assert result.ok?, output(result) + + # The release consolidates protocols; the test environment does too. + if Protocol.consolidated?(String.Chars) do + assert Enum.find(result.steps, &(&1.cmd == "compile (in-process)")).output =~ + "the String.Chars protocol has already been consolidated" + end + end + + test "without mix on the PATH, build/1 builds in-process", + %{root: root, name: name, module: module} do + write_plugin(root, name, module) + original = System.get_env("PATH") + + try do + System.put_env("PATH", "") + assert PluginBuilder.mode() == :in_process + assert {:ok, %{ok?: true, mode: :in_process}} = build(name, []) + after + System.put_env("PATH", original || "") + end + end + + test "build_all builds every plugin in the sources directory", + %{root: root, name: name, module: module} do + other = name <> "_b" + write_plugin(root, name, module) + write_plugin(root, other, Module.concat(module, B)) + + {results, _stderr} = with_io(:stderr, fn -> PluginBuilder.build_all(mode: :in_process) end) + assert [{^name, {:ok, %{ok?: true}}}, {^other, {:ok, %{ok?: true}}}] = results + end + + # The compiler also prints its diagnostics to stderr; keep the run readable. + defp build(name, opts \\ [mode: :in_process]) do + {result, _stderr} = with_io(:stderr, fn -> PluginBuilder.build(name, opts) end) + result + end + + ## Fixtures + + defp write_plugin(root, name, module, opts \\ []) do + dir = Path.join(root, name) + File.mkdir_p!(Path.join(dir, "lib")) + + deps = + Keyword.get( + opts, + :deps, + ~s([{:gamend_sdk, path: "../../../sdk", runtime: false, optional: true}, {:jason, "~> 1.2"}]) + ) + + env = + case Keyword.fetch(opts, :env) do + {:ok, nil} -> "" + _ -> ",\n env: [hooks_module: #{inspect(module)}]" + end + + File.write!(Path.join(dir, "mix.exs"), """ + defmodule #{Macro.camelize(name)}.MixProject do + use Mix.Project + + @version "0.2.0" + + def project do + [ + app: :#{name}, + version: System.get_env("SOME_VERSION") || @version, + elixir: "~> 1.20", + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger]#{env} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + #{deps} + end + end + """) + + File.write!(Path.join([dir, "lib", "hooks.ex"]), """ + defmodule #{inspect(module)} do + use Gamend.Hooks + require #{inspect(module)}.Helper + + def hello(name), do: "hi " <> name <> #{inspect(module)}.Helper.suffix() + end + """) + + File.write!(Path.join([dir, "lib", "helper.ex"]), helper_source(module, "!")) + dir + end + + defp helper_source(module, suffix) do + """ + defmodule #{inspect(module)}.Helper do + defmacro suffix, do: #{inspect(suffix)} + end + """ + end + + # Mirrors modules/plugins_examples/example_gdscript: two scripts, one + # reaching the other by its class_name, compiled from gen/. + defp write_gdscript_plugin(root, name, n, opts) do + dir = Path.join(root, name) + File.mkdir_p!(Path.join(dir, "scripts")) + module = "Gamend.Modules." <> Macro.camelize(name) + rewards = "GdRewards#{n}" + + env = + if Keyword.fetch!(opts, :hooks_module?), + do: ",\n env: [hooks_module: #{module}]", + else: "" + + File.write!(Path.join(dir, "mix.exs"), """ + defmodule #{Macro.camelize(name)}.MixProject do + use Mix.Project + + def project do + [ + app: :#{name}, + version: "0.1.0", + elixir: "~> 1.20", + elixirc_paths: ["gen"], + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases() + ] + end + + def application do + [ + extra_applications: [:logger]#{env} + ] + end + + defp deps do + [ + {:gamend_sdk, path: "../../../sdk", runtime: false, optional: true}, + {:gamend_plugin_tools, path: "../../../sdk_tools", runtime: false} + ] + end + + defp aliases do + [bundle: ["gamend.gdscript.compile", "plugin.bundle"]] + end + end + """) + + File.write!(Path.join([dir, "scripts", "#{name}.gd"]), """ + # The plugin's hooks, reaching the other script by its class_name. + + func greeting(name): + \treturn "Welcome, " + name + "! " + str(#{rewards}.starter_gold(true)) + " gold" + """) + + File.write!(Path.join([dir, "scripts", "gd_rewards_#{n}.gd"]), """ + class_name #{rewards} + + const BASE_GOLD = 100 + + func starter_gold(referred): + \treturn BASE_GOLD + 50 if referred else BASE_GOLD + """) + + dir + end + + defp ebin_files(dir), do: dir |> Path.join("ebin") |> File.ls!() |> Enum.sort() + + defp ebin_contents(dir) do + for file <- ebin_files(dir), into: %{} do + {file, File.read!(Path.join([dir, "ebin", file]))} + end + end + + defp app_env(dir, name) do + {:ok, [{:application, _, props}]} = :file.consult(Path.join([dir, "ebin", "#{name}.app"])) + props[:env] + end + + defp output(result), do: Enum.map_join(result.steps, "\n\n", &"$ #{&1.cmd}\n#{&1.output}") +end diff --git a/apps/gamend_core/test/gamend/hooks/plugin_builder_test.exs b/apps/gamend_core/test/gamend/hooks/plugin_builder_test.exs index 67ce06f47..3772a72d1 100644 --- a/apps/gamend_core/test/gamend/hooks/plugin_builder_test.exs +++ b/apps/gamend_core/test/gamend/hooks/plugin_builder_test.exs @@ -18,23 +18,29 @@ defmodule Gamend.Hooks.PluginBuilderTest do end end - describe "available?/0" do - test "true when a mix executable is reachable" do + describe "available?/0 and mode/0" do + test "builds with mix when a mix executable is reachable" do assert PluginBuilder.available?() + assert PluginBuilder.mode() == :mix end - test "false when it is not" do - without_mix_on_path(fn -> refute PluginBuilder.available?() end) + # A release ships the compiler but no mix: it builds in-process. + test "builds in-process when it is not" do + without_mix_on_path(fn -> + assert PluginBuilder.available?() + assert PluginBuilder.mode() == :in_process + end) end end describe "build/1" do - test "refuses up front when mix is unavailable" do + test "refuses a forced mix build up front when mix is unavailable" do # Without the guard this reaches System.cmd/3, which raises :enoent and # surfaces in the admin UI as an opaque build failure. The caller needs # to distinguish "this image cannot build" from "this build broke". without_mix_on_path(fn -> - assert PluginBuilder.build("anything") == {:error, :mix_unavailable} + assert PluginBuilder.build("anything", mode: :mix) == {:error, :mix_unavailable} + assert PluginBuilder.build("anything") == {:error, {:unknown_plugin, "anything"}} end) end @@ -49,6 +55,9 @@ defmodule Gamend.Hooks.PluginBuilderTest do test "cannot be steered out of the sources directory" do assert {:error, {:unknown_plugin, _}} = PluginBuilder.build("../../../../tmp/evil") assert {:error, {:unknown_plugin, _}} = PluginBuilder.build("/etc") + + assert {:error, {:unknown_plugin, _}} = + PluginBuilder.build("../../../../tmp/evil", mode: :in_process) end end diff --git a/apps/gamend_core/test/gamend/release_test.exs b/apps/gamend_core/test/gamend/release_test.exs new file mode 100644 index 000000000..017c233d1 --- /dev/null +++ b/apps/gamend_core/test/gamend/release_test.exs @@ -0,0 +1,22 @@ +defmodule Gamend.ReleaseTest do + # async: false — changes the working directory. + use ExUnit.Case, async: false + + alias Gamend.Release + + describe "seeds_file/0" do + @describetag :tmp_dir + + test "is the working directory's priv/repo/seeds.exs", %{tmp_dir: dir} do + path = Path.join(dir, "priv/repo/seeds.exs") + File.mkdir_p!(Path.dirname(path)) + File.write!(path, ":ok") + + assert File.cd!(dir, &Release.seeds_file/0) == path + end + + test "is nil when the project has none", %{tmp_dir: dir} do + assert File.cd!(dir, &Release.seeds_file/0) == nil + end + end +end diff --git a/apps/gamend_web/.gitignore b/apps/gamend_web/.gitignore index a2e6bd485..d75336bde 100644 --- a/apps/gamend_web/.gitignore +++ b/apps/gamend_web/.gitignore @@ -1 +1,3 @@ doc/ +# ExUnit @tag :tmp_dir scratch space +/tmp/ diff --git a/apps/gamend_web/lib/gamend_web.ex b/apps/gamend_web/lib/gamend_web.ex index 6b6d16df5..270912e89 100644 --- a/apps/gamend_web/lib/gamend_web.ex +++ b/apps/gamend_web/lib/gamend_web.ex @@ -46,6 +46,18 @@ defmodule GamendWeb do # are served directly by the host endpoint. def static_paths, do: ~w(assets fonts) + @doc """ + The host's OTP app: the `:host_static_app` a host sets to name itself, + `:gamend_web` when none does. Its `priv/static` is what the endpoint serves + and its `priv/starter` is what `gamend starter` copies. + """ + @spec host_app() :: atom() + def host_app, do: Application.get_env(:gamend_web, :host_static_app, :gamend_web) + + @doc "The app whose `priv/static/assets` holds the compiled CSS and JS (`:asset_static_app`, else the host app)." + @spec asset_app() :: atom() + def asset_app, do: Application.get_env(:gamend_web, :asset_static_app, host_app()) + def router do quote do use Phoenix.Router, helpers: false diff --git a/apps/gamend_web/lib/gamend_web/cli.ex b/apps/gamend_web/lib/gamend_web/cli.ex new file mode 100644 index 000000000..9c5f79c5e --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/cli.ex @@ -0,0 +1,223 @@ +defmodule GamendWeb.CLI do + @moduledoc """ + The commands behind a release's `bin/gamend`. + + A release has no Mix, so each command here is the release twin of the mix + task with the same name, and runs the same code: `gamend db.migrate` is + `mix db.migrate`, `gamend demo.seed --count 50` is `mix demo.seed --count 50`. + `bin/gamend` runs the server itself (`start`, `daemon`, `stop`, `remote`); + everything else reaches this module through the release's `eval`: + + bin/gamend_host eval "GamendWeb.CLI.main(System.argv())" -- db.migrate + + Every path is relative to the working directory, the project folder the + release serves: its `.env`, `priv/repo/seeds.exs` and `modules/plugins`. + `eval` starts no application, so the database commands run against a node + that serves nothing; the ones that need the application (`db.seed`, + `demo.seed`) start it with the HTTP listener and the job queues off, so they + can run beside a server that is already up. + """ + + alias Gamend.Hooks.PluginBuilder + alias Gamend.Release + alias GamendWeb.CLI.Starter + + @usage """ + Usage: gamend COMMAND [ARGS] + + Server: + start Create and migrate the database, then run the server + daemon The same, in the background + stop | restart Stop or restart a running server + reload Re-read theme/config.json and the markdown on a + running server + remote Open a shell on the running server + version Print the release version + + Database (the same names as the mix tasks): + db.setup Create the database, migrate, then run priv/repo/seeds.exs + db.migrate Run pending migrations + db.rollback Roll back: --step N (default 1), --to VERSION or --all + db.reset Drop the database and set it up again + db.seed Run priv/repo/seeds.exs + demo.seed Seed demo data: --count N, --only SETS, --clean + + Project: + starter [TEMPLATE] Copy a starter project into this folder; never + overwrites a file unless --force. TEMPLATE is a name + (default: "default", "website"), a .tar.gz path or URL + plugin.bundle [NAME...] + Build the plugins under modules/plugins (all when no + name is given) + + Every path is relative to the current directory. Settings come from the + environment and ./.env; the full list is in .env.example. + """ + + @doc """ + Runs `argv` and halts the VM with its exit status. + """ + @spec main([String.t()]) :: no_return() + def main(argv) do + # `eval` runs without a shell, whose stdio is latin1 until told otherwise. + :io.setopts(:standard_io, encoding: :unicode) + :io.setopts(:standard_error, encoding: :unicode) + + # `eval EXPR -- args` hands the separator through to System.argv/0. + argv = + case argv do + ["--" | rest] -> rest + argv -> argv + end + + status = + try do + run(argv) + rescue + error -> + IO.puts(:stderr, "error: " <> Exception.message(error)) + 1 + end + + System.halt(status) + end + + @doc """ + Runs `argv` and returns the exit status, without halting. + """ + @spec run([String.t()]) :: non_neg_integer() + def run(argv) + + def run(["db.setup" | _args]) do + Release.createdb() + Release.migrate() + seed_file() + end + + def run(["db.migrate" | _args]) do + Release.migrate() + 0 + end + + def run(["db.rollback" | args]) do + {opts, _rest, _invalid} = + OptionParser.parse(args, strict: [step: :integer, to: :integer, all: :boolean]) + + Release.rollback(opts) + 0 + end + + def run(["db.reset" | args]) do + Release.dropdb() + run(["db.setup" | args]) + end + + def run(["db.seed" | _args]), do: seed_file() + + def run(["demo.seed" | args]) do + with_app(fn -> Gamend.DemoSeed.run(args) end) + 0 + end + + def run(["plugin.bundle" | names]), do: bundle_plugins(names) + + def run(["starter" | args]), do: Starter.run(args) + + def run([help]) when help in ["help", "--help", "-h"] do + IO.write(@usage) + 0 + end + + def run([]) do + IO.write(@usage) + 0 + end + + def run([command | _args]) do + IO.puts(:stderr, "unknown command: #{command}\n") + IO.write(:stderr, @usage) + 1 + end + + @doc false + def usage, do: @usage + + # `mix host.seed` runs the same file with the application up; so does this. + defp seed_file do + case Release.seeds_file() do + nil -> + IO.puts("No seeds file at priv/repo/seeds.exs, skipping") + + path -> + with_app(fn -> Code.eval_file(path) end) + IO.puts("Seeded from #{Path.relative_to_cwd(path)}") + end + + 0 + end + + defp bundle_plugins(names) do + names = + case names do + [] -> PluginBuilder.list_buildable_plugins() + names -> names + end + + if names == [] do + IO.puts("No plugins to build under #{PluginBuilder.sources_dir()}") + 0 + else + names + |> Enum.map(&bundle_plugin/1) + |> Enum.max() + end + end + + defp bundle_plugin(name) do + case PluginBuilder.build(name) do + {:ok, %{ok?: true}} -> + IO.puts("Built #{name}") + 0 + + {:ok, result} -> + IO.puts(:stderr, "Failed to build #{name}") + Enum.each(Map.get(result, :steps, []), &IO.puts(:stderr, &1.output)) + 1 + + {:error, reason} -> + IO.puts(:stderr, "Failed to build #{name}: #{inspect(reason)}") + 1 + end + end + + # Starts the host application the way `mix run` does, minus the parts that + # would fight a server already running from the same folder: the HTTP + # listener and the job queues. + defp with_app(fun) do + app = GamendWeb.host_app() + + for loaded <- [:gamend_core, :gamend_web, app], do: Application.load(loaded) + + update_env(:gamend_web, GamendWeb.Endpoint, server: false) + update_env(:gamend_core, Oban, queues: false, plugins: false) + + # A one-off command prints its own result; the boot log (the resource + # banner, os_mon's alarms) is the server's business. + Logger.configure(level: :error) + + {:ok, _started} = Application.ensure_all_started(app) + + try do + fun.() + after + # The host starts :os_mon, whose port programs report the halt that + # follows as a crash unless it stops first. + Application.stop(:os_mon) + end + end + + defp update_env(app, key, overrides) do + current = Application.get_env(app, key, []) + Application.put_env(app, key, Keyword.merge(current, overrides)) + end +end diff --git a/apps/gamend_web/lib/gamend_web/cli/starter.ex b/apps/gamend_web/lib/gamend_web/cli/starter.ex new file mode 100644 index 000000000..c1fceb823 --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/cli/starter.ex @@ -0,0 +1,247 @@ +defmodule GamendWeb.CLI.Starter do + @moduledoc """ + `gamend starter`: copies a starter project into the working directory. + + A project folder is everything a release reads relative to where it runs: + `.env`, `theme/config.json`, the markdown content (`CHANGELOG.md`, + `ROADMAP.md`, `blog/`, `priv/docs/`), `static/` and `modules/plugins/`. A + starter is one of those, ready to edit. None of it is required: a release + runs from an empty folder, with no theme and no content. + + The template is a name under the host's `priv/starter/` (`default` when none + is given), or a `.tar.gz` of a project folder, by path or http(s) URL. The + name `website` is the Gamend website itself, published next to each release. + + An existing file is never overwritten unless `--force` is given, so running + it again on a project only fills in what is missing. A missing `.env` is + written with a fresh `GAMEND_AUTH_SECRET_KEY_BASE`, which is all a release + needs to start. + """ + + @website_base "https://github.com/appsinacup/gamend/releases/download" + + @spec run([String.t()]) :: non_neg_integer() + def run(args) do + {opts, rest, _invalid} = OptionParser.parse(args, strict: [force: :boolean]) + template = List.first(rest) || "default" + target = File.cwd!() + + with_template(template, fn source -> + {copied, skipped} = copy_tree(source, target, opts[:force] || false) + env = write_env(target) + + report(template, copied, skipped, env) + 0 + end) + end + + defp with_template("http" <> _ = url, fun), do: with_download(url, fun) + + defp with_template("website", fun), do: with_download(website_url(), fun) + + defp with_template(template, fun) do + cond do + String.ends_with?(template, [".tar.gz", ".tgz"]) and File.regular?(template) -> + with_archive(File.read!(template), fun) + + dir = template_dir(template) -> + fun.(dir) + + File.dir?(template) -> + fun.(Path.expand(template)) + + true -> + IO.puts(:stderr, "No starter template named #{inspect(template)}.") + IO.puts(:stderr, "Known templates: #{Enum.join(["website" | known_templates()], ", ")}") + 1 + end + end + + defp template_dir(name) do + with root when is_binary(root) <- templates_root(), + dir = Path.join(root, Path.basename(name)), + true <- File.dir?(dir) do + dir + else + _missing -> nil + end + end + + defp known_templates do + case templates_root() && File.ls(templates_root()) do + {:ok, names} -> Enum.sort(names) + _missing -> [] + end + end + + defp templates_root do + app = GamendWeb.host_app() + + case :code.priv_dir(app) do + dir when is_list(dir) -> Path.join(to_string(dir), "starter") + {:error, _not_loaded} -> nil + end + end + + # The website ships with each published release; a build without a version + # of its own (the mix.exs default) takes the rolling one. + defp website_url do + app = GamendWeb.host_app() + Application.load(app) + + tag = + case Application.spec(app, :vsn) do + vsn when vsn in [nil, ~c"1.0.0"] -> "server-latest" + vsn -> "server-v#{vsn}" + end + + "#{@website_base}/#{tag}/gamend-website.tar.gz" + end + + defp with_download(url, fun) do + IO.puts("Downloading #{url}") + {:ok, _started} = Application.ensure_all_started(:req) + + case Req.get(url, decode_body: false, retry: :transient) do + {:ok, %{status: 200, body: body}} -> + with_archive(body, fun) + + {:ok, %{status: status}} -> + IO.puts(:stderr, "Download failed: HTTP #{status}") + 1 + + {:error, reason} -> + IO.puts(:stderr, "Download failed: #{Exception.message(reason)}") + 1 + end + end + + # Unpacked into a scratch directory first, so what lands in the project goes + # through the same no-overwrite copy as a bundled template, and an archive + # entry can never write outside the project. The system `tar` reads what + # macOS's writes (extended attributes as pax records, which :erl_tar + # rejects); :erl_tar covers a machine without one. + defp with_archive(bytes, fun) do + tmp = Path.join(System.tmp_dir!(), "gamend-starter-#{System.unique_integer([:positive])}") + archive = tmp <> ".tar.gz" + File.mkdir_p!(tmp) + File.write!(archive, bytes) + + try do + case extract(archive, tmp) do + :ok -> + fun.(archive_root(tmp)) + + {:error, reason} -> + IO.puts(:stderr, "Not a .tar.gz project: #{reason}") + 1 + end + after + File.rm_rf(tmp) + File.rm(archive) + end + end + + defp extract(archive, dir) do + with tar when is_binary(tar) <- System.find_executable("tar"), + {_output, 0} <- System.cmd(tar, ["-xzf", archive, "-C", dir], stderr_to_stdout: true) do + :ok + else + nil -> + case :erl_tar.extract(String.to_charlist(archive), [ + :compressed, + {:cwd, String.to_charlist(dir)} + ]) do + :ok -> :ok + {:error, reason} -> {:error, inspect(reason)} + end + + {output, _status} -> + {:error, String.trim(output)} + end + end + + # An archive made with `tar czf x.tar.gz my-game/` holds one top directory; + # its contents are the project. + defp archive_root(dir) do + case File.ls!(dir) do + [only] -> if File.dir?(Path.join(dir, only)), do: Path.join(dir, only), else: dir + _many -> dir + end + end + + defp copy_tree(source, target, force?) do + source + |> files() + |> Enum.reduce({[], []}, fn relative, {copied, skipped} -> + from = Path.join(source, relative) + to = Path.join(target, relative) + + if File.exists?(to) and not force? do + {copied, [relative | skipped]} + else + File.mkdir_p!(Path.dirname(to)) + File.cp!(from, to) + {[relative | copied], skipped} + end + end) + |> then(fn {copied, skipped} -> {Enum.reverse(copied), Enum.reverse(skipped)} end) + end + + # Relative paths of every regular file under `dir`, dotfiles included, in a + # stable order. Symlinks are skipped: a template has no business pointing + # outside itself. + defp files(dir) do + dir + |> Path.join("**") + |> Path.wildcard(match_dot: true) + |> Enum.filter(®ular_file?/1) + |> Enum.map(&Path.relative_to(&1, dir)) + |> Enum.sort() + end + + defp regular_file?(path) do + match?({:ok, %File.Stat{type: :regular}}, File.lstat(path)) + end + + # A missing .env is written with a new secret; an existing one only gains a + # secret when it has none, and is otherwise left exactly as it is. + defp write_env(target) do + path = Path.join(target, ".env") + secret = 64 |> :crypto.strong_rand_bytes() |> Base.encode64(padding: false) + + cond do + not File.exists?(path) -> + File.write!(path, """ + # Written by `gamend starter`. Every setting, with its default, is in + # .env.example; real environment variables win over this file. + GAMEND_AUTH_SECRET_KEY_BASE=#{secret} + """) + + :created + + File.read!(path) =~ ~r/^\s*GAMEND_AUTH_SECRET_KEY_BASE=/m -> + :kept + + true -> + existing = File.read!(path) + separator = if existing == "" or String.ends_with?(existing, "\n"), do: "", else: "\n" + File.write!(path, separator <> "GAMEND_AUTH_SECRET_KEY_BASE=#{secret}\n", [:append]) + :appended + end + end + + defp report(template, copied, skipped, env) do + IO.puts("Starter #{inspect(template)} in #{File.cwd!()}") + Enum.each(copied, &IO.puts(" created #{&1}")) + Enum.each(skipped, &IO.puts(" kept #{&1} (exists; --force overwrites)")) + + case env do + :created -> IO.puts(" created .env (with a new secret key)") + :appended -> IO.puts(" updated .env (added a secret key)") + :kept -> :ok + end + + IO.puts("\nNext: gamend start") + end +end diff --git a/apps/gamend_web/lib/gamend_web/components/host_layouts.ex b/apps/gamend_web/lib/gamend_web/components/host_layouts.ex index f3aff23e8..adde3949c 100644 --- a/apps/gamend_web/lib/gamend_web/components/host_layouts.ex +++ b/apps/gamend_web/lib/gamend_web/components/host_layouts.ex @@ -587,29 +587,29 @@ defmodule GamendWeb.HostLayouts do # File IO also crosses onto a dirty scheduler, so the cost under load is # worse than the ~8us it measures on an idle box. # - # The consequence is that dropping a `theme.css` into a running release is - # not picked up until restart, which is the same rule the rest of the static - # pipeline already follows. + # The consequence is that dropping a `theme.css` into a running release, or + # into a project's static overlay, is not picked up until the static files + # are reloaded (`GamendWeb.ProjectStatic.reload/0`, on a theme reload) or the + # node restarts, which is the rule the rest of the static pipeline follows. defp host_theme_css_path do key = {__MODULE__, :host_theme_css_path} + generation = GamendWeb.ProjectStatic.generation() case :persistent_term.get(key, :miss) do - :miss -> - path = compute_host_theme_css_path() - :persistent_term.put(key, path) + {^generation, path} -> path - path -> + _ -> + path = compute_host_theme_css_path() + :persistent_term.put(key, {generation, path}) path end end + # Through the resolver, so a project's own `theme.css` in its static overlay + # counts as well as the host app's. defp compute_host_theme_css_path do - host_static_app = Application.get_env(:gamend_web, :host_static_app, :gamend_web) - host_static_dir = Application.app_dir(host_static_app, "priv/static") - theme_css_rel = String.trim_leading(@host_theme_css_path, "/") - - if File.exists?(Path.join(host_static_dir, theme_css_rel)) do + if GamendWeb.ProjectStatic.path_for(@host_theme_css_path) do @host_theme_css_path end end diff --git a/apps/gamend_web/lib/gamend_web/components/host_layouts/root.html.heex b/apps/gamend_web/lib/gamend_web/components/host_layouts/root.html.heex index ee8673e51..822110846 100644 --- a/apps/gamend_web/lib/gamend_web/components/host_layouts/root.html.heex +++ b/apps/gamend_web/lib/gamend_web/components/host_layouts/root.html.heex @@ -56,16 +56,11 @@ reload — the page painted at the top and glided down to where it had been. --% Map.get(theme, "tagline") || "" %> <% description = String.replace(description_raw, ~r/\*+/, "") %> <% banner_path = Map.get(theme, "banner", "") %> - <% host_static_app = Application.get_env(:gamend_web, :host_static_app, :gamend_web) %> - <% host_static_dir = Application.app_dir(host_static_app, "priv/static") %> - <% web_static_dir = Application.app_dir(:gamend_web, "priv/static") %> <% banner_dark_path = String.replace(banner_path, ~r/\.(\w+)$/, "_dark.\\1") %> - <% banner_dark_rel = String.trim_leading(banner_dark_path, "/") %> <% banner_dark_exists? = banner_dark_path != "" and banner_dark_path != banner_path and - (File.exists?(Path.join(host_static_dir, banner_dark_rel)) or - File.exists?(Path.join(web_static_dir, banner_dark_rel))) %> + GamendWeb.ProjectStatic.derived_from?(banner_dark_path, banner_path) %> <%!-- A page's own image first — the page-meta provider's `image/1`, a guide's social card or a post's cover — else the theme's banner. --%> <% image_path = diff --git a/apps/gamend_web/lib/gamend_web/components/presentation_page.ex b/apps/gamend_web/lib/gamend_web/components/presentation_page.ex index 132707389..6d6026b5f 100644 --- a/apps/gamend_web/lib/gamend_web/components/presentation_page.ex +++ b/apps/gamend_web/lib/gamend_web/components/presentation_page.ex @@ -5,6 +5,7 @@ defmodule GamendWeb.PresentationPage do use GamendWeb, :html + alias GamendWeb.ProjectStatic alias Phoenix.HTML.Safe @bold_pattern ~r/\*\*(.+?)\*\*/ @@ -81,7 +82,12 @@ defmodule GamendWeb.PresentationPage do """ @spec cached_body(map(), list(), String.t() | nil, String.t()) :: iodata() def cached_body(page_map, background_icons, locale, path) do - fingerprint = :erlang.phash2({page_map, background_icons}) + # The static generation is an input too: the srcsets list only the width + # variants on disk, and those can be cut after boot (see + # `GamendWeb.ResponsiveImages`) without the page map changing at all. + fingerprint = + :erlang.phash2({page_map, background_icons, ProjectStatic.generation()}) + key = {__MODULE__, :body, locale, path} case :persistent_term.get(key, :miss) do @@ -1084,14 +1090,13 @@ defmodule GamendWeb.PresentationPage do String.replace_suffix(path, ext, "-#{width}#{ext}") end + # Only a variant from the directory that serves the original: a project that + # replaces the engine's `banner.webp` with its own must not have the engine's + # `banner-480.webp`, a cut of a different picture, offered in its srcset. defp variant_exists?(path, width) do - variant = width_variant_path(path, width) - clean = URI.parse(variant).path || variant - - case static_file_path(clean) do - file when is_binary(file) -> File.regular?(file) - _ -> false - end + path + |> width_variant_path(width) + |> ProjectStatic.derived_from?(path) end # What share of the viewport the slot actually occupies, so the browser picks @@ -1183,22 +1188,21 @@ defmodule GamendWeb.PresentationPage do defp positive_int(_value), do: nil defp image_src(path) do - path = non_empty_string(path) + case non_empty_string(path) do + nil -> nil + path -> path |> optimized_image_path() |> versioned() + end + end - cond do - is_nil(path) -> - nil + defp versioned(path), do: GamendWeb.SRI.versioned_path(path) || path - generated = generated_image_path(path) -> - if GamendWeb.SRI.integrity(generated) do - GamendWeb.SRI.versioned_path(generated) || generated - else - GamendWeb.SRI.versioned_path(path) || path - end + # The WebP `mix host.optimize_images` made from a PNG or JPEG, when it was + # made from this one: a project's own `/images/logo.png` must not be swapped + # for the engine's WebP of the engine's logo. + defp optimized_image_path(path) do + generated = generated_image_path(path) - true -> - GamendWeb.SRI.versioned_path(path) || path - end + if generated && ProjectStatic.derived_from?(generated, path), do: generated, else: path end defp generated_image_path(path) do @@ -1219,41 +1223,15 @@ defmodule GamendWeb.PresentationPage do end end + # `path_for/1` takes the path as configured, query and all, and answers nil + # for an absolute URL rather than measuring a local file that shares its path. defp image_dimensions(path) do - path = non_empty_string(path) - clean_path = path && (URI.parse(path).path || path) - - with clean when is_binary(clean) <- clean_path, - file_path when is_binary(file_path) <- static_file_path(clean) do - read_image_dimensions(file_path) - else - _ -> {nil, nil} + case ProjectStatic.path_for(non_empty_string(path)) do + file_path when is_binary(file_path) -> read_image_dimensions(file_path) + nil -> {nil, nil} end end - defp static_file_path(clean_path) do - [ - Application.get_env(:gamend_web, :asset_static_app, :gamend_web), - Application.get_env(:gamend_web, :host_static_app, :gamend_web), - :gamend_web - ] - |> Enum.uniq() - |> Enum.map(&app_static_dir/1) - |> Enum.reject(&is_nil/1) - |> Enum.find_value(fn static_dir -> - file_path = Path.join(static_dir, String.trim_leading(clean_path, "/")) - if File.exists?(file_path), do: file_path - end) - end - - defp app_static_dir(app) when is_atom(app) do - if Application.spec(app, :vsn) do - Application.app_dir(app, "priv/static") - end - end - - defp app_static_dir(_app), do: nil - defp read_image_dimensions(file_path) do case File.read(file_path) do {:ok, diff --git a/apps/gamend_web/lib/gamend_web/endpoint.ex b/apps/gamend_web/lib/gamend_web/endpoint.ex index fa1c09bb7..f67c91351 100644 --- a/apps/gamend_web/lib/gamend_web/endpoint.ex +++ b/apps/gamend_web/lib/gamend_web/endpoint.ex @@ -1,6 +1,7 @@ defmodule GamendWeb.Endpoint do use Phoenix.Endpoint, otp_app: :gamend_web + alias GamendWeb.ProjectStatic alias Phoenix.Socket.Transport alias Phoenix.Transports.WebSocket @@ -50,6 +51,9 @@ defmodule GamendWeb.Endpoint do plug GamendWeb.Plugs.SecurityHeaders plug GamendWeb.Plugs.WellKnown plug GamendWeb.Plugs.GameHeaders + # First of the static plugs: a project's own files (`GamendWeb.ProjectStatic`) + # replace the engine's under the same name. + plug :serve_project_static plug :serve_game_static plug :serve_host_static plug :serve_asset_static @@ -208,6 +212,42 @@ defmodule GamendWeb.Endpoint do # the same way and describes the site's current shape, so it belongs here too. @revalidating_static ~w(robots.txt llms.txt .well-known) + # The project overlay (`GamendWeb.ProjectStatic`): GAMEND_CONTENT_STATIC_DIRS, + # `static/` then `priv/static/` in the working directory by default. A path is served with the options the built-in + # file at that path would get (the game build's headers and revalidation, the + # crawler files' revalidation, a year for the rest), so moving a file between + # the engine and the project changes nothing a browser sees. + # + # On every static request, so a miss has to cost next to nothing: no overlay + # directory is one persistent_term read, and a first path segment the overlay + # may not serve (`assets`, `live`, `api`, ...) is one list lookup more. + defp serve_project_static(%Plug.Conn{path_info: [first | _]} = conn, _opts) do + with [_ | _] = dirs <- ProjectStatic.dirs(), + true <- first in ProjectStatic.overlay_paths() do + serve_project_dirs(conn, dirs, first) + else + _ -> conn + end + end + + defp serve_project_static(conn, _opts), do: conn + + defp serve_project_dirs(conn, dirs, first) do + kind = + cond do + first == "game" -> :game_static_opts + first in @revalidating_static -> :revalidating_static_opts + true -> :host_static_opts + end + + Enum.reduce_while(dirs, conn, fn dir, conn -> + case Plug.Static.call(conn, configurable_static_opts(kind, dir, [first])) do + %{halted: true} = halted -> {:halt, halted} + passed -> {:cont, passed} + end + end) + end + defp serve_host_static(conn, _opts) do paths = host_static_paths() -- ~w(game) @@ -215,7 +255,7 @@ defmodule GamendWeb.Endpoint do conn, configurable_static_opts( :host_static_opts, - host_static_app(), + GamendWeb.host_app(), paths -- @revalidating_static ) ) @@ -228,7 +268,7 @@ defmodule GamendWeb.Endpoint do passed, configurable_static_opts( :revalidating_static_opts, - host_static_app(), + GamendWeb.host_app(), paths -- (paths -- @revalidating_static) ) ) @@ -238,14 +278,14 @@ defmodule GamendWeb.Endpoint do defp serve_game_static(conn, _opts) do Plug.Static.call( conn, - configurable_static_opts(:game_static_opts, host_static_app(), ~w(game)) + configurable_static_opts(:game_static_opts, GamendWeb.host_app(), ~w(game)) ) end defp serve_asset_static(conn, _opts) do Plug.Static.call( conn, - configurable_static_opts(:asset_static_opts, asset_static_app(), ~w(assets)) + configurable_static_opts(:asset_static_opts, GamendWeb.asset_app(), ~w(assets)) ) end @@ -373,21 +413,7 @@ defmodule GamendWeb.Endpoint do ) end - defp host_static_app do - Application.get_env(:gamend_web, :host_static_app, :gamend_web) - end - - defp asset_static_app do - Application.get_env(:gamend_web, :asset_static_app, host_static_app()) - end - - defp host_static_paths do - Application.get_env( - :gamend_web, - :host_static_paths, - ~w(images game favicon.ico robots.txt .well-known theme.css) - ) - end + defp host_static_paths, do: ProjectStatic.host_static_paths() defp gzip_static? do Application.get_env(:gamend_web, :gzip_static, false) diff --git a/apps/gamend_web/lib/gamend_web/host_supervision.ex b/apps/gamend_web/lib/gamend_web/host_supervision.ex index 9de963504..3c8308b83 100644 --- a/apps/gamend_web/lib/gamend_web/host_supervision.ex +++ b/apps/gamend_web/lib/gamend_web/host_supervision.ex @@ -89,7 +89,7 @@ defmodule GamendWeb.HostSupervision do defp register_host_app(opts) do host_app = Keyword.get_lazy(opts, :host_app, fn -> - Application.get_env(:gamend_web, :host_static_app, :gamend_web) + GamendWeb.host_app() end) if host_app not in Gamend.Settings.apps() do @@ -210,7 +210,11 @@ defmodule GamendWeb.HostSupervision do # only one node runs it and start_link returns :ignore on the others. Gamend.LobbySnapshots.Writer, # Signaling relay for WebRTC user-to-user and client-server topologies - {Gamend.Presence, pool_size: pool_size(:presence_pool_size)} + {Gamend.Presence, pool_size: pool_size(:presence_pool_size)}, + # Cuts the theme's srcset width variants for a project's own static + # files, at boot and after a theme reload. Its first pass runs after + # init returns, so ImageMagick never holds up the boot. + GamendWeb.ResponsiveImages ] ++ extra end diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/config.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/config.ex index 2560f8819..c843e2915 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/config.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/config.ex @@ -61,6 +61,7 @@ defmodule GamendWeb.AdminLive.Config do <.plugins_row plugin_build_available={@plugin_build_available?} + plugin_build_mode={@plugin_build_mode} plugin_build_form={@plugin_build_form} plugin_build_options={@plugin_build_options} plugin_build_result={@plugin_build_result} @@ -296,6 +297,7 @@ defmodule GamendWeb.AdminLive.Config do plugins_reload_result: nil, plugin_build_options: plugin_build_options(), plugin_build_available?: PluginBuilder.available?(), + plugin_build_mode: PluginBuilder.mode(), plugin_build_running?: false, plugin_build_result: nil, plugin_build_form: @@ -340,10 +342,16 @@ defmodule GamendWeb.AdminLive.Config do @impl true def handle_info({:plugin_build_finished, _name, {:ok, build_result}}, socket) do + # An in-process build restarts a plugin that was loaded, so the list may + # have changed under us. + plugins = PluginManager.list() + {:noreply, socket |> assign(:plugin_build_running?, false) |> assign(:plugin_build_result, build_result) + |> assign(:plugins, plugins) + |> assign(:plugins_counts, ConfigDiagnostics.plugin_counts(plugins)) |> put_flash(:info, "Plugin build finished")} end @@ -395,7 +403,7 @@ defmodule GamendWeb.AdminLive.Config do put_flash( socket, :error, - "This image has no mix executable, so it cannot build plugin bundles." + "This image cannot build plugin bundles: it has neither mix nor the Elixir compiler." )} socket.assigns.plugin_build_options == [] -> diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex index ecdf9ce12..1992de195 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/config_diagnostics.ex @@ -144,18 +144,10 @@ defmodule GamendWeb.AdminLive.ConfigDiagnostics do defp ecto_ipv6_recommended(false), do: "" # Compute dark-variant and fullscreen image existence for theme diagnostics. - # Convention: `file.ext` → `file_dark.ext`, detected via File.exists? on priv/static. + # Convention: `file.ext` → `file_dark.ext`, looked up the way the endpoint + # serves it (`GamendWeb.ProjectStatic`): a project's static overlay first, + # then the apps' priv/static. def theme_dark_variants(theme_map) do - static_dirs = - [ - Application.get_env(:gamend_web, :host_static_app, :gamend_web), - Application.get_env(:gamend_web, :asset_static_app, :gamend_web), - :gamend_web - ] - |> Enum.uniq() - |> Enum.map(&static_dir_for_app/1) - |> Enum.reject(&is_nil/1) - banner_path = (theme_map && Map.get(theme_map, "banner")) || "" banner_dark_path = derive_dark_path(banner_path) @@ -169,36 +161,21 @@ defmodule GamendWeb.AdminLive.ConfigDiagnostics do %{ banner_dark_path: banner_dark_path, - banner_dark_exists?: file_exists_in_static?(static_dirs, banner_dark_path), + banner_dark_exists?: file_exists_in_static?(banner_dark_path), logo_dark_path: logo_dark_path, - logo_dark_exists?: file_exists_in_static?(static_dirs, logo_dark_path), + logo_dark_exists?: file_exists_in_static?(logo_dark_path), favicon_dark_path: favicon_dark_path, - favicon_dark_exists?: file_exists_in_static?(static_dirs, favicon_dark_path), - fullscreen_exists?: file_exists_in_static?(static_dirs, "/images/fullscreen.png"), - fullscreen_dark_exists?: file_exists_in_static?(static_dirs, "/images/fullscreen_dark.png") + favicon_dark_exists?: file_exists_in_static?(favicon_dark_path), + fullscreen_exists?: file_exists_in_static?("/images/fullscreen.png"), + fullscreen_dark_exists?: file_exists_in_static?("/images/fullscreen_dark.png") } end defp derive_dark_path(""), do: "" defp derive_dark_path(path), do: String.replace(path, ~r/\.(\w+)$/, "_dark.\\1") - defp file_exists_in_static?(_static_dirs, ""), do: false - - defp file_exists_in_static?(static_dirs, path) do - relative_path = String.trim_leading(path, "/") - - Enum.any?(static_dirs, fn static_dir -> - File.exists?(Path.join(static_dir, relative_path)) - end) - end - - defp static_dir_for_app(app) when is_atom(app) do - if Application.spec(app, :vsn) do - Application.app_dir(app, "priv/static") - end - end - - defp static_dir_for_app(_app), do: nil + defp file_exists_in_static?(""), do: false + defp file_exists_in_static?(path), do: GamendWeb.ProjectStatic.path_for(path) != nil def exported_plugin_functions do plugins = PluginManager.hook_modules() diff --git a/apps/gamend_web/lib/gamend_web/live/admin_live/config_system_sections.ex b/apps/gamend_web/lib/gamend_web/live/admin_live/config_system_sections.ex index 3d57c58b6..d39133fd5 100644 --- a/apps/gamend_web/lib/gamend_web/live/admin_live/config_system_sections.ex +++ b/apps/gamend_web/lib/gamend_web/live/admin_live/config_system_sections.ex @@ -15,6 +15,7 @@ defmodule GamendWeb.AdminLive.ConfigSystemSections do @doc "The hook plugins: what loaded, and building a bundle." attr :plugin_build_available, :any, required: true + attr :plugin_build_mode, :atom, default: nil, doc: "`PluginBuilder.mode/0`" attr :plugin_build_form, :any, required: true attr :plugin_build_options, :any, required: true attr :plugin_build_result, :any, required: true @@ -79,18 +80,26 @@ defmodule GamendWeb.AdminLive.ConfigSystemSections do {if @plugin_build_running, do: "Building…", else: "Build bundle"} -
- SRC: {PluginBuilder.sources_dir()} — MIX_ENV: {System.get_env("MIX_ENV") || - ""} +
+ SRC: {PluginBuilder.sources_dir()} — BUILD: {plugin_build_mode_label(@plugin_build_mode)}
- <%= if not @plugin_build_available do %> -
- Bundling runs mix, which this image does not ship. Build the - bundle where the plugin sources live and mount the result, or run an - image built from the Dockerfile's full - target instead of release. -
+ <%= cond do %> + <% not @plugin_build_available -> %> +
+ This image ships neither mix + nor the Elixir compiler, so it cannot build bundles. Build them where the + plugin sources live and mount the result. +
+ <% @plugin_build_mode == :in_process -> %> +
+ No mix + here, so bundles compile inside the running server. Elixir and GDScript + plugins build; a dependency the server does not ship has to be prebuilt in + the plugin's deps/<dep>/ebin. A loaded plugin restarts + on the new bundle. +
+ <% true -> %> <% end %>
@@ -1035,6 +1044,12 @@ defmodule GamendWeb.AdminLive.ConfigSystemSections do end end + defp plugin_build_mode_label(:mix), + do: "mix (MIX_ENV=#{System.get_env("MIX_ENV") || ""})" + + defp plugin_build_mode_label(:in_process), do: "in-process" + defp plugin_build_mode_label(_mode), do: "unavailable" + defp plugin_build_output(%{steps: steps}) when is_list(steps) do steps |> Enum.map_join("\n\n", fn s -> diff --git a/apps/gamend_web/lib/gamend_web/plugs/well_known.ex b/apps/gamend_web/lib/gamend_web/plugs/well_known.ex index fb793918d..2cb6e5ec6 100644 --- a/apps/gamend_web/lib/gamend_web/plugs/well_known.ex +++ b/apps/gamend_web/lib/gamend_web/plugs/well_known.ex @@ -17,12 +17,18 @@ defmodule GamendWeb.Plugs.WellKnown do def call(conn, _opts), do: conn + # A project's own file in its static overlay first (`GamendWeb.ProjectStatic`), + # so an app built from the project answers for its own bundle id; else the + # configured app's. Served here rather than left to the overlay's + # `Plug.Static`, which would type an extensionless file as octet-stream. defp serve(conn, filename, opts \\ []) do static_app = Application.get_env(:gamend_web, :well_known_static_app) || - Application.get_env(:gamend_web, :host_static_app, :gamend_web) + GamendWeb.host_app() - path = Path.join(:code.priv_dir(static_app), "static/.well-known/#{filename}") + path = + GamendWeb.ProjectStatic.overlay_path_for("/.well-known/" <> filename) || + Path.join(:code.priv_dir(static_app), "static/.well-known/#{filename}") case File.read(path) do {:ok, body} when is_binary(body) -> diff --git a/apps/gamend_web/lib/gamend_web/project_static.ex b/apps/gamend_web/lib/gamend_web/project_static.ex new file mode 100644 index 000000000..bc0e2e1ca --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/project_static.ex @@ -0,0 +1,305 @@ +defmodule GamendWeb.ProjectStatic do + @moduledoc """ + Static files a project supplies next to the engine: the overlay. + + A release carries its own `priv/static` (the host app's and `gamend_web`'s), + which someone running the engine from a project folder cannot edit. The + overlay is a directory in that folder whose files are served *before* the + built-in ones, so a project adds its own images, a `game/` web export or a + favicon, and replaces one the engine ships by putting a file at the same path. + + ## Which directories + + `GAMEND_CONTENT_STATIC_DIRS` (`Gamend.ContentSettings`, `:static_dirs`), + `static,priv/static` by default: relative paths taken against the working + directory, searched in that order, each used when it exists the first time + the overlay is resolved. A directory created after that is picked up by + `reload/0`, which a theme reload runs, or a restart. + + A directory that *is* one of the apps' own `priv/static` is skipped: under + `mix phx.server` from the repository root, `priv/static` is the host app's + priv, which the endpoint already serves. + + ## What it may serve + + Only the top-level entries of `:host_static_paths` (`images`, `game`, + `favicon.ico`, `robots.txt`, `.well-known`, `theme.css`, ...), and never + `assets/`: the engine's digested CSS and JS always win. + + ## Lookups + + `path_for/1` answers which file a URL path is served from: the overlay first, + then the host app's `priv/static`, the asset app's, and `gamend_web`'s. + Everything that reads a static file by its URL (image dimensions, srcset + variants, the `?v=` content hash, the theme's `theme.css`) goes through it, so + the page links what the endpoint serves. + """ + + @dirs_key {__MODULE__, :dirs} + @roots_key {__MODULE__, :roots} + @generation_key {__MODULE__, :generation} + + @default_host_static_paths ~w(images game favicon.ico robots.txt .well-known theme.css) + + @doc """ + The overlay directories, expanded, in the order they are searched. + + Resolved on first use and cached until `reset/0` or `reload/0`: this sits in + front of every static request, and the answer only changes with the working + directory, the setting, or a directory appearing. + """ + @spec dirs() :: [String.t()] + def dirs do + case :persistent_term.get(@dirs_key, :miss) do + :miss -> + dirs = resolve_dirs() + :persistent_term.put(@dirs_key, dirs) + dirs + + dirs -> + dirs + end + end + + @doc "Forgets the resolved directories, so the next call resolves them again." + @spec reset() :: :ok + def reset do + :persistent_term.erase(@dirs_key) + :persistent_term.erase(@roots_key) + :ok + end + + @doc """ + Takes in whatever changed on disk: resolves the directories again and bumps + `generation/0`, so the `?v=` hashes, the `theme.css` link and the cached + presentation pages are rebuilt from the files as they are now. + + Run on a theme reload (`GamendWeb.ResponsiveImages` follows + `Gamend.Theme.JSONConfig.reload/0`), which is what makes a folder created or + a file replaced after boot show without a restart. + """ + @spec reload() :: :ok + def reload do + reset() + bump_generation() + end + + @doc """ + The top-level entries an overlay directory may answer: `:host_static_paths` + without `assets`. + """ + @spec overlay_paths() :: [String.t()] + def overlay_paths, do: host_static_paths() -- ["assets"] + + @doc "The configured `:host_static_paths`: the top-level entries served from the host app." + @spec host_static_paths() :: [String.t()] + def host_static_paths do + Application.get_env(:gamend_web, :host_static_paths, @default_host_static_paths) + end + + @doc """ + The file on disk a URL path is served from, or `nil`. + + Takes the path as written in a page or the theme config: a leading slash, a + query or a fragment are fine. An absolute URL, a `data:` URI or a path that + climbs with `..` is never a local file. + """ + @spec path_for(String.t() | nil) :: String.t() | nil + def path_for(url_path) do + case lookup(url_path) do + {_root, file} -> file + nil -> nil + end + end + + @doc """ + Like `path_for/1`, but answers `{static_dir, file}`: which directory the file + comes from as well as where it is. + """ + @spec lookup(String.t() | nil) :: {String.t(), String.t()} | nil + def lookup(url_path) do + case segments(url_path) do + nil -> nil + segments -> segments |> roots_for() |> find_file(segments) + end + end + + @doc "The file an overlay directory serves for a URL path, ignoring the built-in ones." + @spec overlay_path_for(String.t() | nil) :: String.t() | nil + def overlay_path_for(url_path) do + with [first | _] = segments <- segments(url_path), + true <- first in overlay_paths(), + {_root, file} <- find_file(dirs(), segments) do + file + else + _ -> nil + end + end + + @doc """ + Whether an overlay directory could answer this URL path: there is one, and + the path's top-level entry is one it may serve. + + True whether or not the file exists there right now, which is the point: the + endpoint checks the overlay on every request, so a file dropped in later is + served at once. + """ + @spec overlay_servable?(String.t() | nil) :: boolean() + def overlay_servable?(url_path) do + case segments(url_path) do + [first | _] -> first in overlay_paths() and dirs() != [] + _ -> false + end + end + + @doc """ + Whether `derived_url`, a file made from `original_url` (`main-480.webp` cut + from `main.webp`, `generated/logo.webp` from `logo.png`), is served from the + same directory as the original. + + A derived file only describes the original it was made from. When a project + replaces the engine's `/images/banner.webp`, the engine's `banner-480.webp` + is a smaller copy of a different picture, and linking it would show the + engine's art on the project's page. An original served from nowhere local + accepts a derived file from anywhere, as before the overlay existed. + """ + @spec derived_from?(String.t() | nil, String.t() | nil) :: boolean() + def derived_from?(derived_url, original_url) do + case lookup(derived_url) do + nil -> + false + + {root, _file} -> + case lookup(original_url) do + nil -> true + {^root, _file} -> true + _other -> false + end + end + end + + @doc """ + A counter bumped whenever the files pages link may have changed at runtime: + on `reload/0`, and when responsive image variants are cut after boot. + Everything cached from those files keys on it: the `?v=` hashes + (`GamendWeb.SRI`), whether a `theme.css` exists, and the cached presentation + page bodies. + """ + @spec generation() :: non_neg_integer() + def generation, do: :persistent_term.get(@generation_key, 0) + + @doc "Bumps `generation/0`." + @spec bump_generation() :: :ok + def bump_generation do + :persistent_term.put(@generation_key, generation() + 1) + end + + defp find_file(roots, segments) do + Enum.find_value(roots, fn root -> + file = Path.join([root | segments]) + if File.regular?(file), do: {root, file} + end) + end + + defp roots_for([first | _]) do + overlay = if first in overlay_paths(), do: dirs(), else: [] + overlay ++ app_roots(first) + end + + # The app dirs are cached too: `Application.app_dir/2` is a round trip + # through the code server, and this runs several times per page render. + # Keyed on the config it is built from, so a host (or a test) that points + # `:host_static_app` elsewhere is not served a stale list. + defp app_roots(first) do + config = {GamendWeb.host_app(), GamendWeb.asset_app()} + + roots = + case :persistent_term.get(@roots_key, :miss) do + {^config, roots} -> + roots + + _ -> + roots = build_app_roots(config) + :persistent_term.put(@roots_key, {config, roots}) + roots + end + + if first == "assets", do: roots.assets, else: roots.other + end + + defp build_app_roots({host_app, asset_app}) do + %{ + assets: app_dirs([asset_app, host_app, :gamend_web]), + other: app_dirs([host_app, asset_app, :gamend_web]) + } + end + + defp app_dirs(apps) do + apps + |> Enum.uniq() + |> Enum.map(&app_static_dir/1) + |> Enum.reject(&is_nil/1) + end + + defp app_static_dir(app) when is_atom(app) do + if Application.spec(app, :vsn), do: Application.app_dir(app, "priv/static") + end + + defp app_static_dir(_app), do: nil + + defp resolve_dirs do + own = [GamendWeb.host_app(), GamendWeb.asset_app(), :gamend_web] |> app_dirs() + + (Gamend.Settings.get(Gamend.ContentSettings, :static_dirs) || []) + |> Enum.map(&Path.expand/1) + |> Enum.filter(&File.dir?/1) + |> Enum.reject(fn dir -> Enum.any?(own, &same_dir?(dir, &1)) end) + |> Enum.uniq() + end + + # By path first, then by identity: `_build/dev/lib/gamend_host/priv` is a + # symlink to the repository's `priv`, so the two spellings of the host app's + # static dir only compare equal once the link is followed, which `File.stat` + # does. Windows reports every inode as 0, so identity is only trusted when + # there is one. + defp same_dir?(a, b) do + a = Path.expand(a) + b = Path.expand(b) + + a == b or + case {File.stat(a), File.stat(b)} do + {{:ok, %{inode: inode, major_device: dev}}, {:ok, %{inode: inode, major_device: dev}}} + when inode != 0 -> + true + + _ -> + false + end + end + + defp segments(url_path) when is_binary(url_path) and url_path != "" do + uri = URI.parse(url_path) + + with nil <- uri.scheme, + nil <- uri.host, + path when is_binary(path) <- uri.path, + [_ | _] = segments <- path |> String.split("/", trim: true) |> Enum.map(&decode/1), + true <- Enum.all?(segments, &safe_segment?/1) do + segments + else + _ -> nil + end + end + + defp segments(_url_path), do: nil + + defp decode(segment) do + URI.decode(segment) + rescue + ArgumentError -> segment + end + + defp safe_segment?(segment) do + segment not in [".", ".."] and not String.contains?(segment, ["/", "\\", ":", <<0>>]) + end +end diff --git a/apps/gamend_web/lib/gamend_web/responsive_images.ex b/apps/gamend_web/lib/gamend_web/responsive_images.ex new file mode 100644 index 000000000..1ea55589d --- /dev/null +++ b/apps/gamend_web/lib/gamend_web/responsive_images.ex @@ -0,0 +1,471 @@ +defmodule GamendWeb.ResponsiveImages do + @moduledoc """ + Cuts the width variants a theme image's `"widths"` asks for. + + `"widths": [480, 960]` on a `theme/config.json` image makes the renderer offer + `-480.` and `-960.` in a srcset, for the ones that exist + (`GamendWeb.PresentationPage` drops a width whose file is missing, so a + missing variant costs bytes, never a broken image). Two things write them: + + * `mix host.responsive_images`, at build time, into `priv/static`; + * this process, at runtime, into a project's static overlay + (`GamendWeb.ProjectStatic`): once at boot and again after every + `Gamend.Theme.JSONConfig.reload/0` (the `[:gamend, :theme, :reload]` + telemetry event), for each image whose original lives in a writable + overlay directory, next to the original. `refresh/0` asks for a pass by + hand, for a host whose theme module emits no event. + + A reload also runs `GamendWeb.ProjectStatic.reload/0` first, so a static + folder created, or a file replaced, since boot is served and linked with its + new hash. That happens in this process too: the telemetry handler only sends + a message, so whoever called `reload/0` never waits on it and never sees it + fail. + + The runtime pass only fills in what is missing. It never writes inside the + release, and it does its work in its own process, so a slow cut or a failure + delays and breaks nothing else. + + Both need ImageMagick: `magick`, or `convert` and `identify` outside Windows, + where `convert` is a system tool. Without it the runtime pass logs once at + `:info` that variants are skipped and does nothing more; the pages still + serve the full-size originals. + """ + + use GenServer + + require Logger + + alias GamendWeb.ProjectStatic + + @webp_quality "80" + @png_quality "82-96" + @reload_event [:gamend, :theme, :reload] + + @typedoc "`{source_url, variant_url, width}`: one variant a `widths` list implies." + @type planned :: {String.t(), String.t(), pos_integer()} + + @typedoc "An ImageMagick install: v7's `magick`, or v6's `convert` and `identify`." + @type tool :: {:magick, String.t()} | {:legacy, String.t(), String.t()} + + @typedoc "What happened to one variant, with its path relative to the static root." + @type result :: + {:generated + | :current + | :stale + | :missing_source + | :skipped_upscale + | :skipped_bigger + | {:failed, String.t()}, String.t()} + + ## Process + + @doc """ + Starts the runtime cutter. Options: `:name` (default `#{inspect(__MODULE__)}`, + `nil` for none) and `:tool` (default: found on `PATH`; `nil` means none). + """ + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + {name, opts} = Keyword.pop(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, if(name, do: [name: name], else: [])) + end + + @doc "Asks the running cutter for another pass. A no-op when it is not running." + @spec refresh(GenServer.server()) :: :ok + def refresh(server \\ __MODULE__) do + case GenServer.whereis(server) do + pid when is_pid(pid) -> send(pid, :refresh) + _ -> :ok + end + + :ok + end + + @doc false + def handle_theme_reload(_event, _measurements, _metadata, pid), do: send(pid, :refresh) + + @impl GenServer + def init(opts) do + attach_reload_handler() + # The first pass runs after `init/1` has returned, so the supervisor, and + # with it the boot, never waits on ImageMagick. + {:ok, %{opts: opts, told_no_tool?: false}, {:continue, :refresh}} + end + + @impl GenServer + def handle_continue(:refresh, state), do: {:noreply, run_pass(state)} + + @impl GenServer + def handle_info(:refresh, state) do + # A burst of reloads is one pass: whatever queued behind this one is + # covered by it. + drain_refreshes() + # Here rather than in the telemetry handler, which runs in the process that + # called `reload/0`: that caller gets nothing slow and nothing that raises. + ProjectStatic.reload() + {:noreply, run_pass(state)} + end + + def handle_info(_message, state), do: {:noreply, state} + + # One handler per process, and a restarted process sweeps up the handlers of + # the ones before it rather than trapping exits to detach its own. + defp attach_reload_handler do + for %{id: {__MODULE__, pid} = id} <- :telemetry.list_handlers(@reload_event), + not Process.alive?(pid), + do: :telemetry.detach(id) + + :telemetry.attach( + {__MODULE__, self()}, + @reload_event, + &__MODULE__.handle_theme_reload/4, + self() + ) + end + + defp drain_refreshes do + receive do + :refresh -> drain_refreshes() + after + 0 -> :ok + end + end + + defp run_pass(state) do + case cut_overlay(state.opts) do + {:ok, results} -> + announce(results) + state + + {:error, :no_imagemagick} -> + unless state.told_no_tool? do + Logger.info( + "Responsive images: ImageMagick (magick) not found, so the theme's \"widths\" " <> + "variants are not cut for the project's static files. Pages serve the " <> + "full-size images; install ImageMagick and restart to have them cut." + ) + end + + %{state | told_no_tool?: true} + end + rescue + error -> + Logger.error("Responsive images: " <> Exception.format(:error, error, __STACKTRACE__)) + state + catch + kind, reason -> + Logger.error("Responsive images: #{inspect({kind, reason})}") + state + end + + defp announce([]), do: :ok + + defp announce(results) do + Enum.each(results, fn + {{{:failed, output}, rel}, _url} -> + Logger.warning("Responsive images: could not cut #{rel}: #{String.trim(output)}") + + _ -> + :ok + end) + + generated = for {{:generated, _rel}, url} <- results, do: url + + if generated != [] do + # The pages were rendered, and their `?v=` hashes taken, while these + # files did not exist. + ProjectStatic.bump_generation() + Logger.info("Responsive images: cut #{length(generated)} variant(s) in the static dir") + end + + :ok + end + + ## Runtime pass + + @doc """ + Cuts the variants missing from the project's static overlay, for the theme's + images whose originals live there. + + Answers `{:ok, [{result, variant_url}]}`, empty when there is nothing to do, + or `{:error, :no_imagemagick}` when there is work and no tool to do it. + Options: `:tool` (default: `find_tool/0`) and `:theme` (default: the current + theme config, untranslated). + """ + @spec cut_overlay(keyword()) :: + {:ok, [{result(), String.t()}]} | {:error, :no_imagemagick} + def cut_overlay(opts \\ []) do + overlay = ProjectStatic.dirs() |> Enum.filter(&writable_project_dir?/1) + theme = Keyword.get_lazy(opts, :theme, ¤t_theme/0) + + case missing_in(planned_variants(theme), overlay) do + [] -> {:ok, []} + work -> cut(work, Keyword.get_lazy(opts, :tool, &find_tool/0)) + end + end + + defp cut(_work, nil), do: {:error, :no_imagemagick} + + defp cut(work, tool) do + {:ok, + Enum.map(work, fn {{_source, variant, _width} = planned, root} -> + {resolve(planned, root, tool: tool), variant} + end)} + end + + # Each variant whose original the overlay serves, and which that same + # directory does not have yet. A variant somewhere else is no use: the + # renderer only offers one from the original's own directory. + defp missing_in(_planned, []), do: [] + + defp missing_in(planned, overlay) do + for {source, variant, _width} = item <- planned, + {root, _file} <- [ProjectStatic.lookup(source)], + root in overlay, + not File.regular?(Path.join(root, variant)), + do: {item, root} + end + + # Never inside the release's own files (`lib/`, where every app's priv is, + # `releases/`, `erts-*`): a deploy replaces them, and they may not be the + # project's to change. A `static/` a project keeps beside `bin/`, in a + # release unpacked into the project folder, is the project's. The apps' own + # priv dirs are already left out of the overlay; this is for a + # GAMEND_CONTENT_STATIC_DIRS entry pointed somewhere below one. + defp writable_project_dir?(dir) do + root = Path.expand(to_string(:code.root_dir())) + + inside_release? = + String.starts_with?(dir, Path.join(root, "erts-")) or + Enum.any?([to_string(:code.lib_dir()), Path.join(root, "releases")], fn release_dir -> + release_dir = Path.expand(release_dir) + dir == release_dir or String.starts_with?(dir, release_dir <> "/") + end) + + not inside_release? and + match?( + {:ok, %File.Stat{type: :directory, access: access}} when access in [:read_write, :write], + File.stat(dir) + ) + end + + defp current_theme do + theme_mod = Application.get_env(:gamend_web, :theme_module, Gamend.Theme.JSONConfig) + + cond do + not Code.ensure_loaded?(theme_mod) -> %{} + function_exported?(theme_mod, :raw_theme, 0) -> theme_mod.raw_theme() + function_exported?(theme_mod, :get_theme, 0) -> theme_mod.get_theme() + true -> %{} + end + end + + ## Shared with `mix host.responsive_images` + + @doc """ + Every `{source, variant, width}` the config's `widths` declarations imply. + + Takes the JSON text or the decoded map. Pure, so it can be checked against a + config without touching disk. + """ + @spec planned_variants(String.t() | map()) :: [planned()] + def planned_variants(json) when is_binary(json), + do: json |> Jason.decode!() |> planned_variants() + + def planned_variants(config) when is_map(config) do + config + |> collect_images() + |> Enum.flat_map(fn image -> + widths = + image |> Map.get("widths", []) |> List.wrap() |> Enum.filter(&(is_integer(&1) and &1 > 0)) + + for path <- Enum.filter([image["light"], image["dark"]], &(is_binary(&1) and &1 != "")), + width <- Enum.uniq(widths), + do: {path, variant_path(path, width), width} + end) + |> Enum.uniq() + |> Enum.sort() + end + + # Walks the whole config rather than the known page shapes: hero and section + # images live at different depths, and a new block that carries an "image" is + # picked up without touching this. + defp collect_images(value) when is_map(value) do + own = if is_map(value["image"]), do: [value["image"]], else: [] + own ++ Enum.flat_map(Map.values(value), &collect_images/1) + end + + defp collect_images(value) when is_list(value), do: Enum.flat_map(value, &collect_images/1) + defp collect_images(_value), do: [] + + defp variant_path(path, width) do + ext = Path.extname(path) + String.replace_suffix(path, ext, "-#{width}#{ext}") + end + + @doc """ + The ImageMagick to cut with: `magick` (v7) when it is on `PATH`, else v6's + `convert` and `identify` outside Windows, whose own `convert.exe` formats + disks. `nil` when there is none. + """ + @spec find_tool() :: tool() | nil + def find_tool do + case System.find_executable("magick") do + nil -> legacy_tool(:os.type()) + magick -> {:magick, magick} + end + end + + defp legacy_tool({:win32, _}), do: nil + + defp legacy_tool(_os) do + with convert when is_binary(convert) <- System.find_executable("convert"), + identify when is_binary(identify) <- System.find_executable("identify") do + {:legacy, convert, identify} + end + end + + @doc """ + Brings one variant up to date under `static_root` and says what it did. + + Options: `:check` (only report whether the variant exists, write nothing) + and `:tool` (required unless checking, or unless the source is missing). + A variant is never wider than its source, never older than it, and never + heavier: a cut that weighs more than the original is deleted. + """ + @spec resolve(planned(), Path.t(), keyword()) :: result() + def resolve({source, variant, width}, static_root, opts \\ []) do + check? = Keyword.get(opts, :check, false) + source_file = Path.join(static_root, source) + variant_file = Path.join(static_root, variant) + + cond do + not File.regular?(source_file) -> + {:missing_source, variant} + + check? -> + if File.regular?(variant_file), do: {:current, variant}, else: {:stale, variant} + + # Never upscale: a variant wider than the source is the source, and + # writing it anyway would put a bigger file behind a smaller descriptor. + width >= source_width(source_file, Keyword.fetch!(opts, :tool)) -> + {:skipped_upscale, variant} + + fresh?(source_file, variant_file) -> + {:current, variant} + + true -> + generate(source_file, variant_file, width, static_root, Keyword.fetch!(opts, :tool)) + end + end + + defp fresh?(source_file, variant_file) do + with {:ok, %{mtime: variant_mtime}} <- File.stat(variant_file, time: :posix), + {:ok, %{mtime: source_mtime}} <- File.stat(source_file, time: :posix) do + variant_mtime >= source_mtime + else + _ -> false + end + end + + defp generate(source_file, variant_file, width, static_root, tool) do + File.mkdir_p!(Path.dirname(variant_file)) + rel = Path.relative_to(variant_file, static_root) + + args = + case Path.extname(variant_file) do + ".webp" -> + [ + source_file, + "-resize", + "#{width}x", + "-quality", + @webp_quality, + "-define", + "webp:method=6", + variant_file + ] + + _ -> + [source_file, "-resize", "#{width}x", "-strip", variant_file] + end + + {exe, prefix} = convert_command(tool) + + case System.cmd(exe, prefix ++ args, stderr_to_stdout: true) do + {_output, 0} -> + shrink_png(variant_file) + verify_smaller(source_file, variant_file, rel) + + {output, _status} -> + {{:failed, output}, rel} + end + end + + # ImageMagick writes truecolor PNG, but the sources are pngquant palettes — + # so a naive 960-wide cut of a 1440 capture came out 25% BIGGER than the + # original. Requantise to the same recipe host.optimize_images uses. + defp shrink_png(variant_file) do + if Path.extname(variant_file) == ".png" do + pngquant = System.find_executable("pngquant") + optipng = System.find_executable("optipng") + tmp = variant_file <> ".quant.png" + + if pngquant do + case System.cmd( + pngquant, + [ + "--quality", + @png_quality, + "--speed", + "1", + "--force", + "--output", + tmp, + variant_file + ], + stderr_to_stdout: true + ) do + {_output, 0} -> File.rename!(tmp, variant_file) + _ -> File.rm(tmp) + end + end + + if optipng, do: System.cmd(optipng, ["-quiet", "-o3", "-strip", "all", variant_file]) + end + + :ok + end + + # A narrower cut that weighs more than the full-size original is worse than + # having no variant at all: the browser would pick it on a small screen and + # download more than it would have. Drop it rather than ship it. + defp verify_smaller(source_file, variant_file, rel) do + if File.stat!(variant_file).size < File.stat!(source_file).size do + {:generated, rel} + else + File.rm!(variant_file) + {:skipped_bigger, rel} + end + end + + defp source_width(source_file, tool) do + {exe, prefix} = identify_command(tool) + + case System.cmd(exe, prefix ++ ["-format", "%w", source_file], stderr_to_stdout: true) do + {output, 0} -> + case Integer.parse(String.trim(output)) do + {width, _rest} -> width + :error -> 0 + end + + _ -> + 0 + end + end + + defp convert_command({:magick, magick}), do: {magick, []} + defp convert_command({:legacy, convert, _identify}), do: {convert, []} + + defp identify_command({:magick, magick}), do: {magick, ["identify"]} + defp identify_command({:legacy, _convert, identify}), do: {identify, []} +end diff --git a/apps/gamend_web/lib/gamend_web/sri.ex b/apps/gamend_web/lib/gamend_web/sri.ex index 146372c2d..85796402d 100644 --- a/apps/gamend_web/lib/gamend_web/sri.ex +++ b/apps/gamend_web/lib/gamend_web/sri.ex @@ -4,7 +4,8 @@ defmodule GamendWeb.SRI do Returns a `sha384-` string suitable for the `integrity` attribute on `