a browser game of strategy
  • TypeScript 98.2%
  • Shell 0.9%
  • JavaScript 0.5%
  • CSS 0.3%
  • Dockerfile 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Benni Baermann e9a89e7ac5 Document the git forge at git.aymargeddon.de
OPERATIONS.md gains a section on the Forgejo instance: where it lives,
how pushes reach it (its own SSH server on 2222, the one published port
besides Caddy's), the hardening (closed registration, 2FA for admins, no
HTTP basic auth with passwords, no outbound integrations, version
hidden), the fail2ban jail in DOCKER-USER, its private `git` network,
and that the nightly backup now includes a forgejo dump.

ROADMAP.md and CLAUDE.md pointed at git.sij.ai as the remote; origin is
now the new forge. CI is still open, and Actions stays disabled there
until a runner exists.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 16:57:55 +02:00
.claude Format the feature skill 2026-08-31 08:37:51 +02:00
apps Back up the git forge in the nightly server backup 2026-09-18 16:57:55 +02:00
deploy Stop the container fetching pnpm from the registry on every start 2026-09-01 09:57:36 +02:00
docs Document the git forge at git.aymargeddon.de 2026-09-18 16:57:55 +02:00
e2e Stop the admin column scrolling sideways, and give it more width 2026-09-05 15:45:50 +02:00
packages Take the development database password out of the repository 2026-09-17 13:33:58 +02:00
scripts Back up the git forge in the nightly server backup 2026-09-18 16:57:55 +02:00
.dockerignore Deploy the game with git as the transport 2026-08-31 17:38:25 +02:00
.env.example Take the development database password out of the repository 2026-09-17 13:33:58 +02:00
.gitignore Add a browser end-to-end suite (Playwright, production build) 2026-08-31 10:06:42 +02:00
.prettierignore Formatierung: prettier ueber das ganze Repo, plus .prettierignore 2026-08-22 15:32:03 +02:00
CLAUDE.md Document the git forge at git.aymargeddon.de 2026-09-18 16:57:55 +02:00
docker-compose.yml Take the development database password out of the repository 2026-09-17 13:33:58 +02:00
eslint.config.js Try every unit badge when picking a move for the browser suite 2026-08-31 10:10:10 +02:00
LICENSE Rename: Projekt heißt wieder Aymargeddon statt Fateshard 2026-08-20 12:09:07 +02:00
package.json Add a browser end-to-end suite (Playwright, production build) 2026-08-31 10:06:42 +02:00
playwright.config.ts Add a browser end-to-end suite (Playwright, production build) 2026-08-31 10:06:42 +02:00
pnpm-lock.yaml Add a browser end-to-end suite (Playwright, production build) 2026-08-31 10:06:42 +02:00
pnpm-workspace.yaml Formatierung: prettier ueber das ganze Repo, plus .prettierignore 2026-08-22 15:32:03 +02:00
README.md Take the development database password out of the repository 2026-09-17 13:33:58 +02:00
tsconfig.base.json Phase 1: Monorepo-Grundgerüst (apps/web, apps/server, packages/shared) 2026-08-07 14:21:05 +02:00
tsconfig.json Add a browser end-to-end suite (Playwright, production build) 2026-08-31 10:06:42 +02:00

Aymargeddon

Modernization of the old Aymargeddon browser game. See docs/ROADMAP.md for which phase is currently in progress.

For project background, design decisions, and requirements, start with CLAUDE.md — it links to every other living document in this repo.

Local Development Setup

Prerequisites: Node.js ≥ 20, pnpm (see packageManager in package.json for the exact version), and Docker (Postgres runs locally via Docker from day one — see docs/TECH-STACK.md §Database — not optional, the test suite depends on it).

# 1. Install dependencies (also generates the Prisma client via postinstall)
pnpm install

# 2. Give the development database a password of its own, and point both packages/db (Prisma)
#    and apps/server (pg-boss) at it — the same Postgres instance (see docs/TECH-STACK.md
#    §Database). None of these files is committed; the password never is either.
password="$(openssl rand -hex 24)"
echo "POSTGRES_PASSWORD=$password" > .env
sed "s/<POSTGRES_PASSWORD>/$password/" packages/db/.env.example > packages/db/.env
sed "s/<POSTGRES_PASSWORD>/$password/" apps/server/.env.example > apps/server/.env

# 3. Start Postgres and create the schema in it
docker compose up -d
pnpm db:migrate

# 4. Start both dev servers (API on :4000, web app on :5173)
pnpm dev:start

Registration is invite-only (docs/REQUIREMENTS.md §Authentication), and a database with no administrator has nobody who could invite anybody — so the server mints an invitation that grants admin and prints it on the way up. Take it out of the log:

grep INVITE-CODE-FOR-FIRST-ADMIN .dev-logs/server.log

Then open http://localhost:5173, sign up with that code, and you are the admin — no email verification, and no isAdmin to set by hand. Press "New game": that button is the one way a game comes into existence (docs/GAME-DESIGN.md §Lobby & Joining), and it fills the roster with bots you can play against right away. Any further account needs an invitation too, minted from the admin panel. docs/REQUIREMENTS.md §Authentication has the whole admin/player split.

Changing the development database password

Postgres takes POSTGRES_PASSWORD only when it creates its data volume, so on an existing database a new value in .env changes nothing by itself. Set it in the database as well as in the three files — with the container running:

password="$(openssl rand -hex 24)"
touch .env && sed -i '/^POSTGRES_PASSWORD=/d' .env && echo "POSTGRES_PASSWORD=$password" >> .env
docker compose exec -T postgres psql -U fateshard -d fateshard \
  -c "ALTER USER fateshard PASSWORD '$password'"
sed -i -E "s#(postgresql://fateshard:)[^@]*@#\1$password@#" packages/db/.env apps/server/.env

That covers the test and e2e databases too: they live in the same container under the same user. A clone set up before 2026-09-17 should run this once — the password it has was written into this repository, and the development database once ran with it on the public server.

Running the dev servers

scripts/dev-servers.sh drives the API server (:4000) and the web app (:5173) as one unit:

Command Effect
pnpm dev:start Start both, unless something already holds their port
pnpm dev:status Which of the two is listening, with pids and start times
pnpm dev:stop Stop both, whole process tree, and wait for the ports to actually free up
pnpm dev (or pnpm dev:restart) Stop, then start — the everyday command while working

Both servers are detached (setsid), so they survive closing the terminal that started them, and they log to .dev-logs/server.log / .dev-logs/web.log instead of to that terminal. Prefer these over starting each server by hand: killing the API server alone only makes tsx watch respawn it, and starting a spare leaves two competing for the same port and both running the bot ticks.

Looking into the database

Prisma Studio (pnpm --filter @aymargeddon/db studio) is the browsable view. For a quick one-off read — which game is live, whether it is paused, who holds which Role — psql inside the container is faster than writing a script:

docker exec fateshard-postgres-1 psql -U fateshard -d fateshard \
  -c 'select id, size, speed, paused, "botsPaused" from "Game";'

The test database is fateshard_test on the same instance (-d fateshard_test) — the suites own it, so read it rather than writing to it while they might run.

Verifying the setup

pnpm -r test
pnpm -r typecheck
pnpm lint

Note what's not in that list: starting Postgres and applying migrations. packages/db's test/test:watch scripts have a pretest/pretest:watch hook (docker compose up -d --wait plus scripts/ensure-test-db.mjs) that does both automatically — pnpm -r test brings up the container and migrates the schema itself if needed, idempotently (a few hundred ms once it's already running). That script targets a separate fateshard_test database on the same container — internal Postgres/Docker naming from before the 2026-08-20 rename back to "Aymargeddon", deliberately left as-is rather than migrating a live database (docs/TECH-STACK.md §Open Questions has the "if this is ever worth doing" note) — not your dev DB (see docs/TECH-STACK.md §Open Questions, "Test/dev DB separation"), so the tests won't collide with whatever you're poking at in Prisma Studio.

Everyday commands

Command Effect
pnpm dev / dev:start / dev:stop / dev:status Both dev servers as one unit, see above
pnpm -r test Run every package's test suite (starts/migrates Postgres itself if needed, see above)
pnpm -r test:watch Same, in watch mode
pnpm test:e2e Run the browser suite (e2e/) against a production build and its own server/database — see below
pnpm sim:invariants Play a whole game with the bots, checking packages/db's invariants.ts after every turn (--ticks/--earthlings to vary it) — a net for state bugs no single test aims at, worth a run after a feature that touches game state
pnpm -r typecheck tsc --noEmit across all packages
pnpm lint / pnpm format ESLint / Prettier across the repo
pnpm --filter @aymargeddon/server rules Render docs/rules/*.md into the static pages the server hands out at /rules (runs automatically on the server's own dev/build)
pnpm --filter @aymargeddon/db studio Prisma Studio (GUI for inspecting local game state)
pnpm db:migrate Apply existing migrations to the dev database (prisma migrate deploy) — idempotent, and what setup step 3 runs
pnpm --filter @aymargeddon/db migrate Author a new migration after a schema change (prisma migrate dev, prompts for a name) — for applying existing ones see pnpm db:migrate above (dev DB) and pretest (test DB)
pnpm --filter @aymargeddon/db reset-dev Resets the dev-stub game (dev.bootstrap's account) back to its fresh seed — use whenever manual testing (combat, movement) has left it in a state you don't want to keep poking at. Same effect as the web app's "Reset to test configuration" button; this CLI form is for when the server isn't running.
Web app's "Make this the new test configuration" button Overwrites packages/db/src/dev-seed.json with the current game's party layout — the seed the button above (and reset-dev) resets to from then on. Lets the manual-testing starting point evolve as new features need different setups, without hand-editing that file. Commit the resulting dev-seed.json diff like any other change.
pnpm --filter @aymargeddon/server dev / pnpm --filter web dev One server on its own, in the foreground — for when you want its output live in the terminal. Both are what dev:start runs behind the scenes.

Testing on a phone

docs/REQUIREMENTS.md requires the game to run just as well on mobile as on desktop, so it's worth regularly checking how an iteration actually feels on a real phone, not just in a desktop browser. No extra tooling needed — the ordinary pnpm dev:start servers already work unmodified for this. Vite runs with --host, so it listens on every network interface (not just localhost) and prints a Network: URL alongside the usual Local: one — in .dev-logs/web.log, or straight in the terminal if you run that one server in the foreground (pnpm --filter web dev):

➜  Local:   http://localhost:5173/
➜  Network: http://192.168.1.23:5173/

On a phone connected to the same Wi-Fi as the machine running these commands, open that Network: URL directly. No environment variables to set: trpcClient.ts derives the API's address from whatever host the page itself was loaded from (window.location.hostname), and with WEB_ORIGIN unset both the CORS setup and Better Auth's trustedOrigins accept any loopback or private-network origin on any port (packages/db/src/webOrigins.ts) — a LAN IP works exactly like localhost, so this is genuinely zero-configuration per iteration. A deployment sets WEB_ORIGIN instead, and is then restricted to exactly the origins it names.

If the phone can't reach it:

  • Check the machine's firewall allows inbound connections on the printed port and on 4000 (e.g. sudo ufw allow 5173/tcp / 4000/tcp on Ubuntu, if ufw is active).
  • Some routers isolate Wi-Fi clients from each other ("AP/client isolation") — mostly a guest-Wi-Fi setting, rarely the main network.
  • To see the phone browser's own console/errors: Android + Chrome supports remote debugging over USB via chrome://inspect in Chrome on the desktop; iOS + Safari needs a Mac with Safari's Web Inspector.

No HTTPS needed for this — that only becomes relevant for PWA installability ("Add to Home Screen"), a later concern (docs/ROADMAP.md Phase 9).

Prefer a Chromium-based browser (Chrome, Edge, Brave, …; Android's default is already Chrome) when judging responsiveness specifically — measured noticeably choppier frame timing in Firefox against the same build, most likely engine-level (SVG rendering, GC pauses under this app's per-render allocation pattern), not something fixable in this codebase — see docs/CHANGELOG.md's 2026-08-12 "Chromium vs. Firefox" entry for the numbers. Also test against a production build (pnpm --filter web build && pnpm --filter web preview, then open that port's Network: URL) when judging real-world feel — the dev server's unminified React (prop-type validation, dev-only warnings) measurably inflates CPU cost over what an actual build delivers.

Running tests for a single package

pnpm -r test runs all three suites together. To run just one package's tests, use its workspace name (name field in that package's package.json) with --filter:

Package Command Needs Postgres?
packages/shared pnpm --filter @aymargeddon/shared test No
apps/server pnpm --filter @aymargeddon/server test Started/migrated automatically via pretest (docs/ROADMAP.md Phase 4a½ added a real DB/scheduler integration test)
packages/db pnpm --filter @aymargeddon/db test Started/migrated automatically via pretest

To run a single test file or narrow to a test name, pass args through to Vitest directly (--filter selects the package, everything after -- goes to the underlying test script):

pnpm --filter @aymargeddon/db test -- src/index.test.ts
pnpm --filter @aymargeddon/db test -- -t "writes and reads a row"

Or cd into the package and run pnpm test -- <args> / pnpm vitest directly — same effect, sometimes more convenient when iterating on one package.

The browser suite (end-to-end)

e2e/ holds the tests that drive a real browser (Playwright, Chromium) through the real app. They cover what no unit test can: cookies travelling between the web app and the API, CORS, pointer gestures with real hit testing, and a move actually resolving through pg-boss.

pnpm test:e2e            # everything below, in one go
pnpm test:e2e:report     # open the HTML report of the last run

That script runs scripts/e2e-stack.sh prepare and then playwright test. The stack it builds is separate from pnpm dev's in every respect — its own database (fateshard_e2e, dropped and re-seeded per run), its own ports (API 4100, web 5273), and much shorter move/production delays — so a run can never touch the game you are playing in the browser, and needs no dev server stopped. The web app is always a vite build + vite preview, never the dev server: VITE_SERVER_URL is baked in at build time, and the development React build is a different program from the one players get.

Running pnpm exec playwright test on its own skips prepare and reuses whatever the last one left behind — useful while iterating on a spec, but the fixture is then no longer fresh (the lobby's free Roles in particular are used up).

The browsers themselves are downloaded once with pnpm exec playwright install chromium.

Deploying

The deployment is deploy/docker-compose.yml: a Postgres plus two images built from this repository (deploy/Dockerfile). It expects a host with Docker and a reverse proxy in front of it, and assumes nothing else about that host.

Service What it is
db The game's own Postgres. On no shared network, publishing no port
server apps/server — the API, Better Auth, the scheduler and the five job workers
web The built single-page app on a small Caddy of its own, static files only

The server runs the TypeScript sources under tsx, the way the dev server does, rather than a tsc build: the workspace packages are imported as source, so a compiled apps/server emits imports Node cannot resolve. Bundling instead is a later change that needs nothing else moved.

Configuration is one file, deploy/.env, copied from deploy/env.example and chmod 600 — two generated secrets, the database URL and the public origin. It sits next to the Compose file rather than anywhere else because Compose reads it twice: once for the variables it substitutes itself, once as the server container's own environment.

The public origin is baked into the web bundle at build time (VITE_SERVER_URL, taken from WEB_ORIGIN), so app and API share one origin and the browser makes no cross-site request at all. Splitting them across two origins works too, but then the session cookie becomes a cross-site one.

Migrations are not a separate step. The server container applies them in its own entrypoint before it serves anything, so a failed migration stops the deployment instead of leaving a server running against a schema it does not match.

The reverse proxy terminates TLS and splits the traffic two ways:

Path Goes to
/trpc/*, /api/auth/*, /rules* the server container on port 4000
everything else the web container on port 80

X-Forwarded-For has to reach the game holding exactly one address. Better Auth counts its rate limits per client IP and refuses to trust a chain, so a proxy that appends to whatever a client sent lets anyone put every player into one shared bucket (packages/db/src/authRateLimit.ts). Caddy already does the right thing — it replaces the header, since no client is a trusted proxy — so this is a thing not to configure away rather than a thing to configure.

A deployment is a git checkout moving to another commit. scripts/deploy.sh runs on the host, against a checkout of this repository (/srv/aymargeddon unless CHECKOUT says otherwise): it fetches, checks the commit out detached, rebuilds and restarts, and refuses a checkout with local edits — so the running version is always a commit somebody can name.

deploy.sh            # to origin's current main
deploy.sh v1.2.3     # any commit, tag or branch
NO_BUILD=1 deploy.sh # restart, don't rebuild

Pulling rather than pushing images means no registry and no credentials on the host, as long as the repository can be read without them. The price is that a deployment can only be of what the remote has already seen: push first.

The first administrator makes itself. Registration is invite-only, so a fresh database would otherwise have nobody able to sign up and nobody able to invite. The server notices it has no administrator, mints a single-use invitation that grants admin, and prints it; deploy.sh shows that line at the end of a first deployment, as a link to open. Until it is redeemed, every start prints the same one.

Deploying is a command somebody runs, not a pipeline: CI is still not set up, see docs/ROADMAP.md Phase 1.

How the one public instance is run — what shares its host, which timers keep it current, how it is backed up — is docs/OPERATIONS.md, and is about that machine rather than about this repository.