- TypeScript 98.2%
- Shell 0.9%
- JavaScript 0.5%
- CSS 0.3%
- Dockerfile 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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]> |
||
| .claude | ||
| apps | ||
| deploy | ||
| docs | ||
| e2e | ||
| packages | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| eslint.config.js | ||
| LICENSE | ||
| package.json | ||
| playwright.config.ts | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
| tsconfig.json | ||
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/tcpon Ubuntu, ifufwis 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://inspectin 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.