Compare commits

...
2 Commits
Author SHA1 Message Date
JosephFerano f0d396c2eb Fix paths for the server/ layout
- Shared AGENTS.md and README.md at the root; server/AGENTS.md and
  server/README.md keep the server-specific parts.
- docker-compose.prod.yml builds ./server (services, networks, volumes unchanged).
- .gitignore split into the root and server/; .dockerignore no longer lists client.
- The server reads .env from its working directory, else ../.env (the repo root).
- Client: server references point at ../server (AGENTS.md, predict.lua,
  moon-check.mjs default); druid-notes.md is ../../druid-notes.md.
2026-10-20 10:06:42 +07:00
JosephFerano 09aa0978bc Move the Go server into server/
Pure renames: the Go package, web/, sql/, config/, scripts/, art/, Makefile,
Dockerfile, .dockerignore, api.md, test-endpoints.restclient, AGENTS.md and
README.md move under server/. Paths are fixed in the next commit.
2026-10-20 10:01:37 +07:00
160 changed files with 543 additions and 446 deletions

No files matched your search

+1 -11
View File
@@ -1,8 +1,3 @@
# Build output
/bin/
/moonminers
/web/static/css/app.css
# Local env files (never commit)
.env
.env*.local
@@ -12,14 +7,9 @@
.DS_Store
# Personal files
/sql/create-users.sql
/.dir-locals.el
sql/queries.sql
/project.todo
# Blender files (generated by art/equipment scripts)
# Blender files
*.blend
*.blend1
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+34 -188
View File
@@ -1,195 +1,41 @@
# AGENTS.md — Moon Miners
## What this is
Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a
player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the
refinery for the whole account. There is also a leaderboard (Leaderboard section, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`).
Moon Miners is an idle/mining game. Players join anonymously: the server creates a
player and keeps its id in the `mm_user` cookie (or `X-MM-User` for the API). Players own
moons ("staking sources") with resource wells, buy and upgrade mining tools (store items)
and stake them on wells. When a stake finishes (drill cycles are seconds:
`completionTimeInSecs`, `duration_in_secs`) they claim resources (Sollux, Shadowstone,
Azurium, Novafor, Nebulance), which they can sell for credits (₥) or refine into refined
items (Refinery section: timed batches in slots; refined items sell for more and are parts
for higher tool upgrades). Drill attachments (bought in the Store, equipped on a drill from
its panel in Equipment) give it a modifier that makes it faster or bigger; advisors
(Advisors section, one per seat) boost drill speed, yield, sell prices or the refinery for
the whole account. There is also a leaderboard (Leaderboard section: top miners by credits,
banned players left out).
## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- Postgres 16, stock image. No functions or extensions in the database.
## How the parts fit
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
Details: `server/AGENTS.md`.
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
Details: `client/AGENTS.md`.
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
## Layout
- The repo is `game/` in the `MoonMiners/` workspace; agent worktrees go next to it
(`git worktree add ../wt-<task> -b <task>`). The Defold desktop client is `client/` (its own
AGENTS.md).
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded.
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
hold less); a first moon
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
## Workspace
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- Agent worktrees go in the workspace, next to `game/`:
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it.
## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
moon traits check), `make migrate`, `make load-config`. `go run . sim` prints the economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
templates like the server and renders every page and region against in-memory game states;
when you add a page, section or view model branch, make sure its states/views reach it.
- Local DB: `podman-compose up -d db` then `make migrate` (see README).
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
## Deployment
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
`pg_dump --schema-only`.
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
## Conventions (all parts)
- The server is the authority: clients show the state it sends and send actions; no game
rules on the client side beyond what `server/api.md` "Prediction" allows.
- Work in one part at a time: a client task doesn't change `server/`, and the other way
round, unless the task says so.
- Keep changes small. Don't refactor or move files unless asked.
+29 -234
View File
@@ -1,252 +1,47 @@
# Moon Miners
A browser mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
An idle mining game. Players own moons with resource wells, buy and upgrade mining tools,
stake them on wells, claim resources when the drills finish, and sell or refine them for
credits (₥). Drill cycles last seconds, so play is active. Players join anonymously; no
wallet or sign-up.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
| Directory | What it is |
|---|---|
| `server/` | The Go server (game rules in Postgres transactions, admin pages, JSON API) and the web client it renders as HTML (htmx, a three.js moon). See `server/README.md`. |
| `client/` | The Defold client (desktop, and a WebAssembly build), talking to the server's JSON API (`server/api.md`). See `client/AGENTS.md`. |
| `docker-compose.yml` | Local Postgres for development. |
| `docker-compose.prod.yml` | Production stack (Dokploy): `db`, `migrate`, `app`, built from `server/`. |
Players join anonymously: on the first visit the server creates a player and stores its id in
a cookie. No wallet or sign-up needed.
## Run the server
## Requirements
Needs Go 1.25+, Podman or Docker (for Postgres), and Node.js for `make check`.
- Go 1.25+
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
## Environment
The server reads `.env` in the project root (gitignored, never commit it). Real environment
variables take precedence.
```dotenv
DB_USER=moonminers # app user: read/write access to the tables
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
## Local development
1. Create `.env` as above.
2. Start Postgres and set up the database (roles, schema, migrations, store items from
`config/game.json`):
1. Create `.env` (in the repo root or in `server/`; the variables are in `server/README.md`).
2. Start the database and set it up:
```bash
podman-compose up -d db # or: docker compose up -d db
cd server
make migrate
```
3. Run the server and open http://localhost:3000:
3. `make dev` (in `server/`) and open http://localhost:3000.
```bash
make dev
```
`make build` and `make check` also run in `server/`.
Template, CSS and JS changes show up on reload; Go changes need a restart.
## Run the Defold client
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
Needs Java 21+; see `client/AGENTS.md` for the tools the scripts use.
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
Reset the local database: `podman-compose down -v && podman-compose up -d db && make migrate`.
```bash
cd client
make run # against http://localhost:3000
make run ARGS="--config=mm.api_base=http://localhost:3001"
make web && make serve-web # browser build on :8080, proxying to :3000
```
## Database changes
## Deploy
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
store items with the game config.
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
database and one migrated from the pre-Go schema through every migration must end up with the
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
number; a player never gets the same name twice), and the refinery
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
it started with (`bonus.go`).
It's embedded in the binary, so changes need a rebuild.
Store items are also kept in the database (`migrate` and `make load-config` sync them;
items removed from the file are deleted unless a player owns one).
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
| Endpoint | Actions |
|---|---|
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
and store items are re-synced.
### Live ops
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
resources claimed. Built from `event_log`, which every game action writes to.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
(schema checks back this up); a stake is claimed at most once (unique index).
- Errors that aren't rule violations are logged and answered with a generic 500.
## Deployment (Dokploy)
`docker-compose.prod.yml` runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
First deploy:
1. Dokploy: create a **Docker Compose** service from this repository, compose path
`docker-compose.prod.yml`.
2. **Environment**:
```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
```
Never set `MM_DEV` in production: it makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
To test the production stack locally:
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
(add a `ports: ["3100:3000"]` override for `app` to reach it).
## Endpoints
See `test-endpoints.restclient` (Emacs restclient format).
Dokploy: a Docker Compose service from this repository, compose path
`docker-compose.prod.yml`. Environment, domains and first-deploy steps: `server/README.md`
"Deployment".
+5 -5
View File
@@ -2,7 +2,7 @@
## What this is
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
server (the repo root `..`, see `../AGENTS.md`). Same layout and HUD look as the web build
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header with sections, resources and credits; moon list on the left; the current section
in the middle; aside with the moon viewport and the selected well), made game-native.
Target: 16:10 at 1280x800 (Steam Deck); bigger windows show the UI at native pixel size
@@ -11,7 +11,7 @@ with more room, smaller ones rearrange (see Scaling); mouse now, gamepad focus l
The server is the authority. The client shows the state the server sends and sends
actions. It may show an action's result before the answer (prediction, see Conventions),
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
`rules`; `../api.md` "Prediction"); the next response replaces it. No other game
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
rules in Lua. Countdowns may tick locally as display only.
## Stack
@@ -28,7 +28,7 @@ rules in Lua. Countdowns may tick locally as display only.
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
`../druid-notes.md`. Druid source: `~/Development/Refold/druid`, Defold source:
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
`~/Development/Refold/Defold` (read-only).
## Game screen: core, regions, sections
@@ -517,7 +517,7 @@ the others on the command line.
dialog) and images split font batches.
- Outline textures use slice9 2 (not 1).
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
status)`. The player id travels in `X-MM-User`. API: `../api.md`.
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
- The server is the only source of truth: every response's `state` replaces ours and
`state.view` becomes our view; actions send the current view plus their params and
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
@@ -535,4 +535,4 @@ the others on the command line.
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
it. Run `make check` before calling it done.
- Keep changes small; don't touch the server (`..`) from here.
- Keep changes small; don't touch the server (`../server`) from here.
+1 -1
View File
@@ -2,7 +2,7 @@
--
-- The server still decides. A predictor only reuses numbers the server sent (stake
-- `take`, the tool's `cycleSecs`/`yield`, the market quote and `rules.pressure`, ...,
-- see ../api.md "Prediction"); it never invents game rules. The game screen keeps
-- see ../server/api.md "Prediction"); it never invents game rules. The game screen keeps
-- the last server state as the base and shows base + every pending prediction, re-applied
-- in click order whenever a response brings a new base. A response drops its own
-- prediction (200: the server's state has the real result; 422/network error: rolled back).
+1 -1
View File
@@ -10,7 +10,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const server = process.env.MM_SERVER || path.join(root, "..");
const server = process.env.MM_SERVER || path.join(root, "..", "server");
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
const [res = "32", ...rest] = process.argv.slice(2);
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9"];
+2 -2
View File
@@ -9,7 +9,7 @@ x-db-env: &db-env
services:
app:
build: .
build: ./server
restart: unless-stopped
environment:
<<: *db-env
@@ -45,7 +45,7 @@ services:
# One-shot, before the app starts on every deploy: roles, schema, migrations and the
# store item sync from config/game.json (see migrate.go).
migrate:
build: .
build: ./server
command: ["migrate"]
restart: "no"
environment:
-1
View File
@@ -7,4 +7,3 @@ node_modules
.DS_Store
web/static/css/app.css
art
client
+18
View File
@@ -0,0 +1,18 @@
# Build output
/bin/
/moonminers
/web/static/css/app.css
# Local env files (never commit)
.env
.env*.local
# Personal files
/sql/create-users.sql
sql/queries.sql
/.dir-locals.el
# Generated by the art scripts
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+188
View File
@@ -0,0 +1,188 @@
# AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout
- This is `server/` in the repo (`../AGENTS.md`); the Defold client is `../client` (its own
AGENTS.md), the compose files are at the repo root.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded.
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
hold less); a first moon
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `art/`: sources of the store, attachment and advisor images (Blender scripts, prompts);
they write into `web/static/assets`.
## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
moon traits check), `make migrate`, `make load-config`. `go run . sim` prints the economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
templates like the server and renders every page and region against in-memory game states;
when you add a page, section or view model branch, make sure its states/views reach it.
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
deployed with Dokploy): `app`, `db` (stock
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
`pg_dump --schema-only`.
- Keep `../docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
View File
File renamed without changes.
View File
File renamed without changes.
+256
View File
@@ -0,0 +1,256 @@
# Moon Miners server
A browser mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
Players join anonymously: on the first visit the server creates a player and stores its id in
a cookie. No wallet or sign-up needed.
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
from this directory.
## Requirements
- Go 1.25+
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
## Environment
The server reads `.env` from its working directory (`server/`), or if there is none, from the
repo root (`../.env`); only one of them is read. It's gitignored, never commit it. Real
environment variables take precedence.
```dotenv
DB_USER=moonminers # app user: read/write access to the tables
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
## Local development
1. Create `.env` as above.
2. Start Postgres and set up the database (roles, schema, migrations, store items from
`config/game.json`):
```bash
(cd .. && podman-compose up -d db) # or: docker compose up -d db, at the repo root
make migrate
```
3. Run the server and open http://localhost:3000:
```bash
make dev
```
Template, CSS and JS changes show up on reload; Go changes need a restart.
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
## Database changes
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
store items with the game config.
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
database and one migrated from the pre-Go schema through every migration must end up with the
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
number; a player never gets the same name twice), and the refinery
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
it started with (`bonus.go`).
It's embedded in the binary, so changes need a rebuild.
Store items are also kept in the database (`migrate` and `make load-config` sync them;
items removed from the file are deleted unless a player owns one).
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
| Endpoint | Actions |
|---|---|
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
and store items are re-synced.
### Live ops
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
resources claimed. Built from `event_log`, which every game action writes to.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
(schema checks back this up); a stake is claimed at most once (unique index).
- Errors that aren't rule violations are logged and answered with a generic 500.
## Deployment (Dokploy)
`docker-compose.prod.yml` (at the repo root; it builds the image from `server/`) runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
First deploy:
1. Dokploy: create a **Docker Compose** service from this repository, compose path
`docker-compose.prod.yml`.
2. **Environment**:
```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
```
Never set `MM_DEV` in production: it makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
To test the production stack locally, at the repo root:
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
(add a `ports: ["3100:3000"]` override for `app` to reach it).
## Endpoints
See `test-endpoints.restclient` (Emacs restclient format).
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
+8 -3
View File
@@ -23,7 +23,10 @@ import (
var embedded embed.FS
func main() {
loadDotEnv(".env")
// .env in the working directory (server/), else the repo root's.
if !loadDotEnv(".env") {
loadDotEnv("../.env")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -65,10 +68,11 @@ func runLoadConfig(ctx context.Context) error {
}
// loadDotEnv sets variables from a KEY=VALUE file, without overriding the real environment.
func loadDotEnv(path string) {
// It reports whether the file exists.
func loadDotEnv(path string) bool {
f, err := os.Open(path)
if err != nil {
return
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
@@ -83,6 +87,7 @@ func loadDotEnv(path string) {
os.Setenv(key, strings.Trim(strings.TrimSpace(value), `"'`))
}
}
return true
}
func env(key, fallback string) string {
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more