Compare commits

...
24 Commits
Author SHA1 Message Date
EmilNielsenandClaude Opus 5.5 85336f109f Hand-off: second measurements, what's done and what's left
Re-measured on a quiet GPU (Chrome 155, Metal): claim and redrill show in
13–15 ms in the Preact client at 0 and +300 ms (classic 30 ms and 330–347
ms), every deploy click counts, no dropped frames for either; main-thread
time per claim: 11 ms of script against 25 ms. Side-by-side screenshots
match. The open list is now the other sections, the actions that still
wait for the server, and keeping the harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:42:16 +07:00
EmilNielsenandClaude Opus 5.5 a5385a53a7 Preact client: past moons, search, multi-well drills, moon sale, better well
The rest of the Moons slice from the hand-off's list:
- Moon list: Active/Past tabs, the search box, past moons (what each gave,
  when it finished) a page at a time; a past moon's page with its haul.
- Wells table: the multi-well row (what's left on the moon, its drills,
  claim-wide), and each well's drill bars include the multi-well drills
  that mine it. Their readiness is now local like the others' (derive).
- Moon resale under the table: the offer against a new moon, the wells'
  values, the hold countdown (the session wakes at holdEnd), and the API's
  confirm text in the classic dialog (dialog.jsx, the classic ui.js).
- Well panel: "Better: … well, N free · Deploy there" (it moves the view,
  so it waits for the server), multi-well drill lines (reach, here / in
  all) with their claim and claim-and-redeploy, and the store link.
- Effects: the relay click when following a link, the sale's gold ring and
  cash count, and the mined-out sound and Past tab flash (onAnswer, from
  the server's fx.finished, also after a predicted claim).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:19:07 +07:00
JosephFerano 0846c017ae Hand-off notes for the Preact client experiment 2026-10-20 08:42:29 +07:00
JosephFerano b4e5846dff Keep the built Preact client out of the Docker context 2026-10-20 08:34:14 +07:00
JosephFerano 3afd892487 Preact client: .value-in-JSX check, docs, Docker build, template test
scripts/check-signals-jsx.mjs (in make check) flags signal reads inside JSX; make check
also builds the client. The Dockerfile fetches esbuild and runs make app. AGENTS.md
describes web/app and its rules; /app is in the template check and the endpoint list.
2026-10-20 08:33:46 +07:00
JosephFerano cc1005c99e Preact client: moon viewport in a worker on an OffscreenCanvas
The classic MoonScene runs in scene-worker.js against stand-ins for the canvas, its
container, ResizeObserver and the document; the page forwards pointer and wheel events
and the size. Falls back to the classic scene on the page's thread (?mainscene forces it).
Also: FLIP positions relative to their container, so scrolling isn't a move.
2026-10-20 08:31:35 +07:00
JosephFerano c998f8fda0 Preact client at /app: session with prediction, Moons section, well panel, viewport
Signals over /api/state, an outbox sending one request at a time with clientSeq, the
Defold client's predictors (claim, stake, redrill) applied at the click and replaced by
the server's state. Bundled by esbuild (make app) into web/static/app; the classic
client's scene, effects and sounds are loaded by URL. MM_API_DELAY_MS (dev) delays game
requests for both clients.
2026-10-20 08:19:58 +07:00
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
JosephFerano bc34f98e4c api.md: Texts section after the market chart 2026-10-19 18:09:22 +07:00
JosephFerano e6088994ef API: web texts beside reason codes, confirm dialogs, rating label/top, banner ids and end, skippable catalog
- Ratings everywhere carry label and top (as the reveal card).
- Banner items: kind, id, end, left; live events: id (matches the banner).
- Reason codes get the web's text (noSaleText, whyText, blockedText, reasonText, ...);
  confirm dialogs (sell moon/tool/attachment, buy seat/slot, new game) as {title, text, ok, danger}.
  The dynamic confirm and tooltip texts move from the templates into view-model methods
  (same output).
- Other labels: bonus chips, effect texts, reach wording (catalog.reach), well drill labels,
  drill fate lines, past moon dates.
- catalog=0 or catalog=<catalogVersion> leaves the catalog out.
2026-10-19 18:07:53 +07:00
JosephFerano 474542abc7 API: help note and clientSeq docs; test that section blocks come only with their section 2026-10-19 16:36:42 +07:00
JosephFerano c7073e53b9 JSON API: every section's state, reveal and buy modal, prediction rules, catalog, clientSeq
GET /api/state adds section blocks for s= (and include=): equipment with the
drill panel, store, refinery, advisors, leaderboard, help, the new-moon teaser
and the market chart; the moon sale offer in moon. Always: rules (pressure
curve, refinery timing, bonuses, live boosts, supply scale), catalog (image
URLs, tool codes), refinery jobs, advisor bonuses, live events, showNav.
Act responses carry the reveal card after buy-moon, the deploy suggestions
after buy-tool, and echo clientSeq. Reasons are codes. Mapped from the
existing view models; HTML unchanged.
2026-10-19 16:34:58 +07:00
JosephFerano 66705d9aba api.md: document GET /api/moon-settings for the Defold client 2026-10-19 14:43:01 +07:00
JosephFerano 0d2a790506 API new-game: one Set-Cookie, for the new player 2026-10-19 13:39:54 +07:00
JosephFerano 960a03a4f6 JSON API for game clients: GET /api/state, POST /api/act/<action>
The action switch moves from handleAction into runAction, shared by the htmx
handler and the API. The state maps the view models to JSON (unix ms times).
Player id from X-MM-User or the cookie, else a new player. api.md documents it;
api_test.go builds it for every test view and runs in make check.
2026-10-19 13:38:23 +07:00
JosephFerano d8a6ae1419 Workspace layout: repo in server/, worktrees beside it; pin compose project name 2026-10-19 13:26:28 +07:00
JosephFerano 85c8219c0d Refinery: shorter batch times (1/2/3/5/6s) and a stronger bulk bonus (n^0.6) 2026-10-18 22:55:44 +07:00
EmilNielsenandClaude Opus 5.5 1b595a413e Leaderboard section: top miners by credits, with the player's own place
Revealed with the store gate (like Help). Banned players no longer rank,
in the section or in /api/leaderboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:32:16 +07:00
63 changed files with 6824 additions and 196 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ node_modules
.DS_Store .DS_Store
web/static/css/app.css web/static/css/app.css
art art
web/static/app
+1
View File
@@ -2,6 +2,7 @@
/bin/ /bin/
/moonminers /moonminers
/web/static/css/app.css /web/static/css/app.css
/web/static/app/
# Local env files (never commit) # Local env files (never commit)
.env .env
+55 -10
View File
@@ -10,7 +10,7 @@ they can sell for credits (₥) or refine into refined items (Refinery section:
in slots; refined items sell for more and are parts for higher tool upgrades). Drill 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 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 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 (JSON only, no UI yet). 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`).
## Stack ## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main` - Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
@@ -18,6 +18,10 @@ refinery for the whole account. There is also a leaderboard (JSON only, no UI ye
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions - 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 (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. state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
committed). See "web/app" below.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`). - 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 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 (`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
@@ -25,19 +29,34 @@ refinery for the whole account. There is also a leaderboard (JSON only, no UI ye
- Postgres 16, stock image. No functions or extensions in the database. - Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Layout
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env` - `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis). 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 - `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 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 `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 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. 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 - `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers. `/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body - `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 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 (htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way. 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 - `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 supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded. name, a placeholder description and resource `affinities`, refinery), embedded.
@@ -83,15 +102,17 @@ refinery for the whole account. There is also a leaderboard (JSON only, no UI ye
and unequip any time, they count from the next stake), store cards and the drill panel 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` (`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped). (`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy - `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`. 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` - `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`, 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 `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, 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 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 tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `handleAction` refuses locked actions (`actionGates`). locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there. sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players` - `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
@@ -127,14 +148,38 @@ refinery for the whole account. There is also a leaderboard (JSON only, no UI ye
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied 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`). 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`. - `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–015): - `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
and the store link to the classic client. `session.js`: like the Defold client's session
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
id; corrections change its playback rate). `fx.js`: effects at the click from the
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
for what differs.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check` flags `.value` inside JSX).
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
changes when the server answers); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `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. 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. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
## Commands ## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching. - `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
game request that long, to try both clients on a slow connection.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`, - `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. moon traits check, the Preact client's `.value` check and build), `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 - 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; 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. when you add a page, section or view model branch, make sure its states/views reach it.
@@ -163,8 +208,8 @@ commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync. the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section` - 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.handleAction` plus a button with template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically, `/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. 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 - 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 prediction; the server is the authority. Pages don't reload on timers: countdown labels
+4
View File
@@ -5,10 +5,14 @@ ARG TAILWIND_VERSION=v4.1.14
RUN curl -fsSL -o /usr/local/bin/tailwindcss \ RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \ https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
&& chmod +x /usr/local/bin/tailwindcss && chmod +x /usr/local/bin/tailwindcss
ARG ESBUILD_VERSION=0.28.2
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \ RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
&& make app ESBUILD=/usr/local/bin/esbuild \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers . && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM scratch FROM scratch
+169
View File
@@ -0,0 +1,169 @@
# Hand-off: Preact web client experiment (branch `s-preact`)
## Goal
Find out whether a web client can feel as smooth and instant as the Defold client (which
predicts click results locally and eases corrections). It's built with Preact + JSX +
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
next to the classic htmx client at `/`. The user will compare them by feel: fast
claim/redrill/deploy clicks, and animations that never hitch.
## What's built
- `web/app/` (about 2,400 lines without the vendored code):
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
the `shown`/`live` computeds, and an outbox that sends one request at a time with
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
`nextEnd` and keeps the URL in sync.
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
difference: readiness is local (`s.ready || s.end <= now`).
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
- `ui.jsx`:
- `Num`: eased number, written by the ticker.
- `Countdown`.
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
`playbackRate` so the bar lands on the true end without jumping.
- `SupplyBar`: clip-path.
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
waits for the server).
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
for the API's `confirm` texts (the moon sale).
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
`sfx.js`.
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
zoom were verified. If the worker fails it falls back to the page's thread;
`?mainscene` forces that.
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
holds the path aliases, including `three`, used by the worker bundle.
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
- Build:
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
gitignored and in `.dockerignore`.
- `make dev` watches the client.
- `make build` depends on `app`.
- The Dockerfile fetches esbuild and runs `make app`.
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
inside JSX, and builds the client.
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
`test-endpoints.restclient`.
## How to try it
```
cd wt-s-preact && make app && go build -o bin/moonminers .
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
```
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
gitignored. **The servers are stopped now**; restart them with the commands above.
## Measurements
### Second run (Oct 20, after the Moons slice was finished)
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
the median of 3 alternating runs; latency from pointerdown to the first frame where the
side panel's drill line count changes. Each test starts from a known state set through the
API and admin endpoints (the wells topped up, one drone finished on the well). No other
engine was running; the machine's load average was 6 to 9 from other work.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
1.5 s after, 0 ms delay, median of 3):
| | script | layout | style | all tasks |
|---|---|---|---|---|
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
| app | 11 ms | 8 ms | 10 ms | 62 ms |
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
the first gesture, as below); the others 24–25 ms.
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
other engine.
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
for the header's Classic/Admin link.
### First run
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
where the side panel's drill lines change.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
- The classic client drops clicks while a request runs: `body.is-busy` sets
`pointer-events: none`.
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
was using about 460% CPU and the GPU.
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
than on scripts.
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
under this load.
- The first click in each run has a long task of about 120 ms in both clients. It comes
from `sfx.js` creating the AudioContext on the first gesture.
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
`runall.sh`.
## Unfinished / next steps
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
side-by-side screenshots. What's left:
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
The API has every section's state and the prediction rules for sells and refinery
batches (api.md "Prediction").
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
would need the server's split of multi-well claims and the sale's payout rules.
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
around a claim). Worth keeping in `scripts/` if the comparison continues.
4. Report: rough line counts are above. Hard parts:
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
- easing bar corrections (playbackRate);
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
`Event.type`);
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
empty spans).
## Gotchas
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
which is allowed. Never read `.value` inside JSX.
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
last `shown` change, because Preact re-renders asynchronously.
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
only for the main-thread fallback.
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
`web/static/js/scene.js` too.
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
(the sale lines' `value`): destructure them under another name.
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
+23 -6
View File
@@ -1,20 +1,31 @@
TAILWIND_VERSION := v4.1.14 TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss TAILWIND := bin/tailwindcss
CSS := web/static/css/app.css CSS := web/static/css/app.css
ESBUILD_VERSION := 0.28.2
ESBUILD := bin/esbuild
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
# (/static/js/...) by URL.
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
.PHONY: build dev css migrate load-config check .PHONY: build dev css app migrate load-config check
build: css build: css app
go build -o bin/moonminers . go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS rebuilds on change. # Server on :3000 that reloads templates and static files from disk; CSS and the Preact
dev: $(TAILWIND) # client rebuild on change.
dev: $(TAILWIND) $(ESBUILD)
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \ $(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
trap 'kill $$!' EXIT; MM_DEV=1 go run . serve $(APP) --sourcemap --watch=forever & \
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
css: $(TAILWIND) css: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify $(TAILWIND) -i web/css/app.css -o $(CSS) --minify
app: $(ESBUILD)
$(APP) --minify
migrate: migrate:
go run . migrate go run . migrate
@@ -23,9 +34,15 @@ load-config:
check: check:
test -z "$$(gofmt -l .)" && go vet ./... test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^TestTemplates$$' -count=1 . go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs node --no-warnings scripts/check-moon-traits.mjs
node scripts/check-signals-jsx.mjs web/app
$(APP) --minify --log-level=warning
$(ESBUILD):
mkdir -p bin
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
$(TAILWIND): $(TAILWIND):
mkdir -p bin mkdir -p bin
+298 -27
View File
@@ -1,12 +1,19 @@
package main package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill // Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments). Each // speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like // Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats. // refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -63,39 +70,199 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
return seats, err return seats, err
} }
// hireAdvisor hires an advisor into a free seat. // AdvisorHire is a hired advisor and their seat (0: not seated).
type AdvisorHire struct {
Name string
Seat int
}
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return 0, nil, err
}
rows, err := tx.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire
where user_id = $1 order by hired_at, advisor for update`, userID)
if err != nil {
return 0, nil, err
}
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
return seats, hires, err
}
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
for i := range hires {
if hires[i].Name == name {
return &hires[i]
}
}
return nil
}
// freeSeat is the lowest empty seat, 0 if every seat is taken.
func freeSeat(seats int, hires []AdvisorHire) int {
for n := 1; n <= seats; n++ {
taken := false
for _, h := range hires {
taken = taken || h.Seat == n
}
if !taken {
return n
}
}
return 0
}
// hiredOne finds a hired advisor by name, or the error for one who isn't.
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
if h := findHire(hires, name); h != nil {
return h, nil
}
if cfg().advisor(name) == nil {
return nil, gameErr("Unknown advisor")
}
return nil, gameErr("%s doesn't work for you", name)
}
// setSeat moves a hired advisor to a seat (0: unseated).
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
userID, name, seat)
return err
}
// hireAdvisor hires an advisor into the lowest free seat.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error { func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name) a := cfg().advisor(name)
if a == nil { if a == nil {
return gameErr("Unknown advisor") return gameErr("Unknown advisor")
} }
return inTx(ctx, db, func(tx pgx.Tx) error { return inTx(ctx, db, func(tx pgx.Tx) error {
seats, err := lockAdvisors(ctx, tx, userID) seats, hires, err := lockHires(ctx, tx, userID)
if err != nil { if err != nil {
return err return err
} }
var hired int if findHire(hires, a.Name) != nil {
var already bool
err = tx.QueryRow(ctx, `
select count(*), coalesce(bool_or(advisor = $2), false) from advisor_hire where user_id = $1`,
userID, a.Name).Scan(&hired, &already)
if err != nil {
return err
}
if already {
return gameErr("%s already works for you", a.Name) return gameErr("%s already works for you", a.Name)
} }
if hired >= seats { seat := freeSeat(seats, hires)
if seat == 0 {
return gameErr("All advisor seats are taken. Buy another seat first") return gameErr("All advisor seats are taken. Buy another seat first")
} }
if err := debit(ctx, tx, userID, a.Price); err != nil { if err := debit(ctx, tx, userID, a.Price); err != nil {
return err return err
} }
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor) values ($1, $2)", userID, a.Name) _, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
if err != nil { if err != nil {
return err return err
} }
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price}) return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
})
}
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if seat < 1 || seat > seats {
return gameErr("No such seat")
}
if h.Seat == seat {
return gameErr("%s already sits there", name)
}
for _, o := range hires {
if o.Seat == seat {
return gameErr("Seat %d is taken by %s", seat, o.Name)
}
}
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
return err
}
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
})
}
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if h.Seat == 0 {
return gameErr("%s isn't seated", name)
}
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
return err
}
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
// other then gives up their seat to them).
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
a, err := hiredOne(hires, name)
if err != nil {
return err
}
b, err := hiredOne(hires, other)
if err != nil {
return err
}
if a.Name == b.Name {
return gameErr("Pick two different advisors")
}
if a.Seat == 0 && b.Seat == 0 {
return gameErr("Neither of them is seated")
}
// One at a time: the seats are unique.
for _, step := range []struct {
name string
seat int
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
return err
}
}
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
"seat": b.Seat, "withSeat": a.Seat})
})
}
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
return err
}
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
}) })
} }
@@ -129,39 +296,143 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct { type AdvisorsPage struct {
Cards []AdvisorCard Cards []AdvisorCard
Seats, Hired int Seats int
Hired int // seats filled
Benched int // hired, not seated
SeatList []SeatSlot
Bench []AdvisorCard // hired, not seated
FreeSeat int // the lowest empty seat, 0 if none
MaxSeats int MaxSeats int
NextSeat int // number of the next seat to buy NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap NextSeatPrice float64 // 0 at the cap
CanBuySeat bool CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped Bonuses []BonusRow // the combined advisor bonus, capped
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
}
// SeatSlot is a seat and who sits in it (nil: empty).
type SeatSlot struct {
N int
Advisor *AdvisorCard
} }
type AdvisorCard struct { type AdvisorCard struct {
*Advisor *Advisor
Hired bool Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool CanAfford bool
} }
func (s *State) advisorsPage() *AdvisorsPage { // AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
// dismissing them.
type AdvisorPanel struct {
AdvisorCard
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
Dismiss string // the dismiss dialog's question
}
func (s *State) advisorsPage(v View) *AdvisorsPage {
c := cfg() c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), MaxSeats: 1 + len(c.Advisors.SeatPrices), p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1, NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()} Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) { if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n] p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice p.CanBuySeat = s.Balance >= p.NextSeatPrice
} }
for i := range c.Advisors.Items { card := func(a *Advisor) AdvisorCard {
a := &c.Advisors.Items[i]
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price} card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors { for _, h := range s.Hires {
card.Hired = card.Hired || h == a.Name if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
}
} }
p.Cards = append(p.Cards, card) return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
}
// Hired advisors, also those since removed from the config (no bonus).
hired := func(name string) AdvisorCard {
if a := c.advisor(name); a != nil {
return card(a)
}
return card(&Advisor{Name: name, Title: "Advisor"})
}
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
for i := range p.SeatList {
p.SeatList[i].N = i + 1
}
for _, h := range s.Hires {
hc := hired(h.Name)
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
p.SeatList[h.Seat-1].Advisor = &hc
p.Hired++
} else {
p.Bench = append(p.Bench, hc)
p.Benched++
}
}
for _, slot := range p.SeatList {
if slot.Advisor == nil {
p.FreeSeat = slot.N
break
}
}
if v.Adv != "" && s.hire(v.Adv) != nil {
pc := hired(v.Adv)
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
for _, slot := range p.SeatList {
if slot.N != pc.Seat {
panel.Moves = append(panel.Moves, slot)
}
}
if pc.Seat > 0 {
panel.Swaps = p.Bench
}
p.Panel = panel
} }
return p return p
} }
// hire finds a hired advisor by name.
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
// dismissConfirm is the question before dismissing an advisor.
func dismissConfirm(c AdvisorCard) string {
if c.Price > 0 {
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
c.Name, formatCredits(c.Price))
}
return fmt.Sprintf("Dismiss %s?", c.Name)
}
// SeatFree reports whether the player has an empty advisor seat. // SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats } func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
// SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string {
return fmt.Sprintf("Add advisor seat %d for %s?", p.NextSeat, formatCredits(p.NextSeatPrice))
}
// HireWhy is why an advisor can't be hired (the Hire button's tooltip), "" if they can.
func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
switch {
case !p.SeatFree() && p.NextSeatPrice > 0:
return "All seats taken. Buy another seat first"
case !p.SeatFree():
return "All seats taken"
case !c.CanAfford:
return shortBy(c.Price - balance)
}
return ""
}
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
func (p *AdvisorsPage) SeatWhy() string {
if p.SeatFree() {
return ""
}
return "All seats taken. Unseat someone or buy another seat"
}
+867
View File
@@ -0,0 +1,867 @@
package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the
// views build and the same actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"mime"
"net/http"
"strconv"
"strings"
"time"
)
// userHeader carries the player id for clients without cookies; responses echo it.
const userHeader = "X-MM-User"
// apiFeatures are the onboarding features the state reports (onboarding.go).
var apiFeatures = []string{"moons", "equipment", "market", "store", "refinery", "advisors",
"leaderboard", "help", "new-moon", "claim-all", "redrill", "redeploy"}
// ------------------------------------------------------------------ state
type APIState struct {
UserID string `json:"userId"`
Now int64 `json:"now"`
View APIView `json:"view"` // the resolved view (actions may move it)
Credits float64 `json:"credits"`
Resources []APIAmount `json:"resources"`
Refined []APIAmount `json:"refined"`
MoonPrice float64 `json:"moonPrice"`
Unlocked map[string]bool `json:"unlocked"`
Shine []string `json:"shine"` // revealed tabs not opened yet
Ready int `json:"ready"` // finished drills, all moons
Idle int `json:"idle"` // tools not mining
NextEnd *int64 `json:"nextEnd"`
Moons []APIMoonListItem `json:"moons"` // active moons
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
PastPage APIPastPage `json:"pastPage"`
Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon
Well *APIWell `json:"well"` // s=moons with a well selected
Market *APIMarket `json:"market"` // once the market is unlocked
Banner []APIBanner `json:"banner"`
// Always present (api_sections.go).
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
Jobs []APIJob `json:"jobs"` // refinery batches, once the refinery is open
Bonuses []APIBonusRow `json:"bonuses"` // active advisor bonuses (the bonus chips)
LiveEvents []APILiveEvent `json:"liveEvents"`
Rules *APIRules `json:"rules"`
Catalog *APICatalog `json:"catalog,omitempty"` // left out with catalog=0 or catalog=<catalogVersion>
// A hash of the catalog: send it back as catalog= to skip the catalog while it's unchanged.
CatalogVersion string `json:"catalogVersion"`
// Section blocks: present for the view's section (s=) and the sections in include=.
Equipment *APIEquipment `json:"equipment,omitempty"`
Store *APIStore `json:"store,omitempty"`
Refinery *APIRefinery `json:"refinery,omitempty"`
Advisors *APIAdvisors `json:"advisors,omitempty"`
Leaderboard *APILeaderboard `json:"leaderboard,omitempty"`
Help *APIHelp `json:"help,omitempty"`
NewMoon *APINewMoon `json:"newMoon,omitempty"`
}
type APIView struct {
Section string `json:"section"`
Moon string `json:"moon"`
Well string `json:"well"`
Past bool `json:"past"`
Pick string `json:"pick"`
Drill string `json:"drill"`
Page int `json:"page"` // the page of past moons (from 1)
Adv string `json:"adv"` // the selected hired advisor (advisors section)
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
}
// APIPastPage is the page of past moons in past: request others with page=<n>.
type APIPastPage struct {
Page int `json:"page"` // from 1, most recently finished first
Pages int `json:"pages"` // at least 1
Total int `json:"total"` // past moons in all
PerPage int `json:"perPage"`
}
type APIAmount struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
}
// APIRating is a moon's rating as the web shows it: the tier and its label, the stars,
// the percentile and "top N%" (the share of moons at least this good).
type APIRating struct {
Tier string `json:"tier"`
Label string `json:"label"`
Stars int `json:"stars"`
Pct int `json:"pct"`
Top int `json:"top"`
}
type APIWellSupply struct {
ID string `json:"id"`
Resource string `json:"resource"`
Supply float64 `json:"supply"`
}
type APIMoonListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Rating *APIRating `json:"rating"`
Wells []APIWellSupply `json:"wells"`
Drills int `json:"drills"`
Ready int `json:"ready"`
Over bool `json:"over"` // a well has more drills than supply
Selected bool `json:"selected"`
CreatedAt int64 `json:"createdAt"`
Finished bool `json:"finished"`
FinishedAt *int64 `json:"finishedAt"`
Mined []APIAmount `json:"mined,omitempty"` // past moons: claimed from it
FinishedOn string `json:"finishedOn,omitempty"` // past moons: the web's date, e.g. "Oct 18"
}
type APITool struct {
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
}
type APIStake struct {
ID string `json:"id"`
MoonID string `json:"moonId"`
WellID string `json:"wellId"` // the first well it mines
ToolID string `json:"toolId"`
Resource string `json:"resource"`
Amount float64 `json:"amount"`
Reach int `json:"reach"`
Start int64 `json:"start"`
End int64 `json:"end"`
Secs int `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
ReachText string `json:"reachText"`
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
// Moon detail only (projection, claims oldest first): what it gets in all and from
// this well, and whether that is its full amount (ok, short, none).
Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"`
State string `json:"state,omitempty"`
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
}
type APIDrillGroup struct {
Drills []APIStake `json:"drills"`
Ready int `json:"ready"`
ReadyAmount float64 `json:"readyAmount"` // what claiming the ready ones gets
NextEnd *int64 `json:"nextEnd"`
Cap float64 `json:"cap"`
Label string `json:"label"` // the web's compact label of its drills, e.g. "PD-1 ×3 +1"
}
type APIMoon struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Rating *APIRating `json:"rating"`
Finished bool `json:"finished"`
FinishedOn string `json:"finishedOn,omitempty"` // past moon: the web's date, e.g. "Oct 18"
Ready int `json:"ready"`
NextEnd *int64 `json:"nextEnd"`
Wells []APIWellRow `json:"wells"`
Wide *APIDrillGroup `json:"wide"` // drills that mine several wells (claim-wide)
Sale *APIMoonSale `json:"sale"` // the resale offer, once unlocked.store (null for a past moon)
}
type APIWellRow struct {
APIWellSupply
APIDrillGroup // drills that mine only this well (claim-well)
Wide []string `json:"wide"` // ids of the moon's multi-well drills that mine it too
Selected bool `json:"selected"`
Redrill int `json:"redrill"` // idle tools that last mined it
Demand float64 `json:"demand"`
Remain float64 `json:"remain"`
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
DryAt *int64 `json:"dryAt"`
// Shared supply, as the web's well note: over (drills want more than it holds), full
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
}
type APIWell struct {
ID string `json:"id"`
Resource string `json:"resource"`
Rate float64 `json:"rate"` // credits per unit now
Idle []APIToolGroup `json:"idle"` // tools to deploy here
Better *APIBetterWell `json:"better"`
}
type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Tier int `json:"tier"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Attachments []string `json:"attachments"`
}
type APIBetterWell struct {
MoonID string `json:"moonId"`
WellID string `json:"wellId"`
Resource string `json:"resource"`
Free float64 `json:"free"`
ToolID string `json:"toolId"`
}
type APIMarket struct {
Quotes []APIQuote `json:"quotes"`
Selected string `json:"selected"`
Total float64 `json:"total"` // value of everything held
Held int `json:"held"` // kinds held
Best string `json:"best"` // biggest 24h gain
Worst string `json:"worst"` // biggest 24h loss
Chart *APIChart `json:"chart"` // s=market (or include=market): the selected quote's 24h history
}
type APIQuote struct {
Item string `json:"item"`
Refined bool `json:"refined"`
Balance float64 `json:"balance"`
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
Rate float64 `json:"rate"`
Boost float64 `json:"boost"`
Advisor float64 `json:"advisor"`
Pressure float64 `json:"pressure"`
Factor float64 `json:"factor"`
Open float64 `json:"open"` // market rate 24h ago
Change float64 `json:"change"` // percent over 24h
High float64 `json:"high"`
Low float64 `json:"low"`
Value float64 `json:"value"`
// For the sell ticket: the price before selling pressure, the base price (pressure is
// counted in base-price credits) and what selling everything held pays now.
Symbol string `json:"symbol"`
Unpressed float64 `json:"unpressed"`
Base float64 `json:"base"`
SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
// text is "" when its flag is false.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
PressureText string `json:"pressureText"`
Boosted bool `json:"boosted"`
BoostText string `json:"boostText"`
Advised bool `json:"advised"`
AdvisorText string `json:"advisorText"`
}
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
type APIBanner struct {
Level string `json:"level"`
Text string `json:"text"`
Left string `json:"left"` // the web's "3h 20m left" (events only, else "")
Kind string `json:"kind"` // announcement, event
ID int `json:"id"`
End int64 `json:"end"`
}
func unixMS(t time.Time) *int64 {
if t.IsZero() {
return nil
}
ms := t.UnixMilli()
return &ms
}
func apiRating(r *Rating) *APIRating {
if r == nil {
return nil
}
return &APIRating{Tier: r.Tier, Label: r.Label, Stars: r.Stars, Pct: r.Pct, Top: r.Top()}
}
func apiAmounts(rows []ResourceBalance) []APIAmount {
out := []APIAmount{}
for _, r := range rows {
out = append(out, APIAmount{Name: r.Resource, Amount: r.Balance})
}
return out
}
func apiGear(gear []*Attachment) []string {
out := []string{}
for _, a := range gear {
out = append(out, a.Name)
}
return out
}
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
if projected {
gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
}
return a
}
func apiGroup(g DrillGroup) APIDrillGroup {
a := APIDrillGroup{Drills: []APIStake{}, Ready: g.Ready, ReadyAmount: g.ReadyAmount, NextEnd: unixMS(g.NextEnd), Cap: g.Cap,
Label: g.DrillLabel()}
for _, d := range g.Drills {
a.Drills = append(a.Drills, apiStake(d, true))
}
return a
}
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
out := []APIMoonListItem{}
for _, r := range rows {
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
for _, w := range r.Wells {
m.Wells = append(m.Wells, APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply})
}
if r.Finished {
m.Mined, m.FinishedOn = apiAmounts(r.Mined), r.FinishedOn()
}
out = append(out, m)
}
return out
}
// apiState is the JSON state for a page the views built (State.page). extra are pages of
// other sections (include=) whose blocks it adds.
func apiState(p *Page, extra ...*Page) *APIState {
s, v := p.S, p.V
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
Banner: []APIBanner{}}
for _, r := range s.Refined {
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
}
for _, f := range apiFeatures {
switch f {
case "redrill":
a.Unlocked[f] = s.Unlocked("market")
case "redeploy":
a.Unlocked[f] = s.Unlocked("store")
default:
a.Unlocked[f] = s.Unlocked(f)
}
}
for _, tab := range onboardingTabs {
if s.Shine(tab) {
a.Shine = append(a.Shine, tab)
}
}
for _, t := range s.Tools {
item := s.StoreItem(t.StoreItemID)
if item == nil {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
tool.StakeID = st.ID
}
a.Tools = append(a.Tools, tool)
}
for _, st := range s.Stakes {
a.Stakes = append(a.Stakes, apiStake(s.stakeRow(st), false))
}
if d := p.Moon; d != nil {
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Description: d.Description, Rating: apiRating(d.Rating),
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
if d.Wide != nil {
g := apiGroup(*d.Wide)
m.Wide = &g
}
for _, w := range d.Wells {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
m.Wells = append(m.Wells, row)
}
if s.Unlocked("store") {
m.Sale = apiMoonSale(d.Sale)
}
a.Moon = m
}
if w := p.Well; w != nil {
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
if b.Tool != nil {
well.Better.ToolID = b.Tool.ID
}
}
a.Well = well
}
// The market is part of the core loop, so it comes with every state once unlocked.
if trade := p.Trade; trade != nil || s.Unlocked("market") {
if trade == nil {
var rows []MarketRow
for _, r := range s.Resources {
rows = append(rows, MarketRow{ResourceBalance: r})
}
if s.Unlocked("refinery") {
rows = append(rows, s.refinedMarketRows()...)
}
trade = s.marketPage(rows, v)
}
a.Market = apiMarket(trade, false)
}
for _, b := range p.Banner {
a.Banner = append(a.Banner, APIBanner{Level: b.Level, Text: b.Text, Left: b.Left, Kind: b.Kind, ID: b.ID,
End: b.EndsAt.UnixMilli()})
}
a.ShowNav, a.Jobs, a.Bonuses = s.ShowNav(), []APIJob{}, apiBonusRows(s.Bonus())
a.LiveEvents, a.Rules, a.Catalog = []APILiveEvent{}, apiRules(p), apiCatalog(s)
a.CatalogVersion = a.Catalog.version()
if s.Unlocked("refinery") {
for _, j := range s.Jobs {
a.Jobs = append(a.Jobs, apiJob(j, a.Now))
}
}
for _, page := range append([]*Page{p}, extra...) {
a.addSection(page)
}
return a
}
// addSection adds the block of a page's section, unless an earlier page added it.
func (a *APIState) addSection(p *Page) {
switch p.V.Section {
case "equipment":
if a.Equipment == nil {
a.Equipment = apiEquipment(p)
}
case "store":
if a.Store == nil {
a.Store = apiStore(p)
}
case "refinery":
if a.Refinery == nil && p.Refinery != nil {
a.Refinery = apiRefinery(p)
}
case "advisors":
if a.Advisors == nil && p.Advisors != nil {
a.Advisors = apiAdvisors(p)
}
case "leaderboard":
if a.Leaderboard == nil && p.Ranking != nil {
a.Leaderboard = apiLeaderboard(p.Ranking)
}
case "help":
if a.Help == nil {
a.Help = apiHelp(p.S)
}
case "new-moon":
if a.NewMoon == nil && p.NewMoon != nil {
a.NewMoon = apiNewMoon(p.NewMoon)
}
case "market":
if p.Trade != nil && (a.Market == nil || a.Market.Chart == nil) {
a.Market = apiMarket(p.Trade, true)
}
}
}
// apiMarket maps the market; full adds the chart and the sparklines.
func apiMarket(trade *MarketPage, full bool) *APIMarket {
c := cfg()
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
if trade.Selected != nil {
mk.Selected = trade.Selected.Resource
}
if trade.Best != nil {
mk.Best = trade.Best.Resource
}
if trade.Worst != nil {
mk.Worst = trade.Worst.Resource
}
for _, q := range trade.Quotes {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
if aq.Pressed {
aq.PressureText = q.PressureText()
}
if aq.Boosted {
aq.BoostText = q.BoostText()
}
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
if full {
aq.Spark = spark(q.series)
}
mk.Quotes = append(mk.Quotes, aq)
}
if full && trade.Selected != nil {
mk.Chart = &APIChart{Item: trade.Selected.Resource, Start: trade.Chart.Start, Step: trade.Chart.Step,
Prices: append([]float64{}, trade.Selected.series...)}
}
return mk
}
// ------------------------------------------------------------------ handlers
type APIResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
UserID string `json:"userId,omitempty"`
New bool `json:"newPlayer,omitempty"` // the request had no known player: one was created
Toast *APIToast `json:"toast,omitempty"`
FX *APIFX `json:"fx,omitempty"`
// One-shot results of an action, as the web shows them: the reveal card after buy-moon,
// the deploy suggestions after buy-tool.
Reveal *APIReveal `json:"reveal,omitempty"`
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
State *APIState `json:"state,omitempty"`
}
type APIToast struct {
Message string `json:"message"`
Error bool `json:"error"`
Kind string `json:"kind"`
}
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
// out and what a sale earned.
type APIFX struct {
Kind string `json:"kind"`
Finished []string `json:"finished"`
Credits float64 `json:"credits"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: %v", err)
}
}
func apiError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, APIResponse{Error: message})
}
func apiServerError(w http.ResponseWriter, err error) {
log.Printf("error: %v", err)
apiError(w, http.StatusInternalServerError, "Something went wrong")
}
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
// writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context()
userID = r.Header.Get(userHeader)
if userID == "" {
if c, err := r.Cookie(userCookie); err == nil {
userID = c.Value
}
}
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false
}
var err error
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return "", false
}
created = true
} else if isBanned(ctx, s.db, userID) {
apiError(w, http.StatusForbidden, "This account is suspended")
return "", false
}
w.Header().Set(userHeader, userID)
setUserCookie(w, userID)
return userID, created
}
// apiPage loads the player's state and builds the state for a view. get reads the
// request's include= and catalog= (see apiStateWith).
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
state, v, err := loadView(ctx, s.db, userID, v)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
return nil, err
}
return s.apiStateWith(ctx, page, get)
}
// apiPageFor builds the page for a resolved view, with what the server loads for it.
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
return nil, err
}
return page, nil
}
// apiStateWith maps a page and the include= sections, the banner and live events. include
// lists other sections (comma-separated, or "all") whose blocks to add; catalog=0 leaves the
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
page.Banner = s.banner(ctx)
var extra []*Page
for _, section := range apiIncludes(get("include")) {
if section == page.V.Section {
continue
}
v := page.V
v.Section = section
if v = v.resolve(page.S); v.Section != section {
continue // locked
}
p, err := s.apiPageFor(ctx, page.S, v)
if err != nil {
return nil, err
}
extra = append(extra, p)
}
a := apiState(page, extra...)
a.skipCatalog(get("catalog"))
events, err := listLiveEvents(ctx, s.db)
if err != nil {
return nil, err
}
for _, e := range events {
if e.Active(page.S.Now) {
a.LiveEvents = append(a.LiveEvents, APILiveEvent{ID: e.ID, Name: e.Name, Kind: e.Kind, Resource: e.Resource,
Multiplier: e.Multiplier, Start: e.StartsAt.UnixMilli(), End: e.EndsAt.UnixMilli(), Headline: e.Headline()})
}
}
return a, nil
}
// skipCatalog leaves the catalog out for catalog=0, or catalog=<version> while it's that one.
func (a *APIState) skipCatalog(param string) {
if param != "" && (param == "0" || param == a.CatalogVersion) {
a.Catalog = nil
}
}
// apiIncludes reads include=: section names, comma-separated, or "all".
func apiIncludes(include string) []string {
if include == "all" {
return []string{"equipment", "market", "store", "refinery", "advisors", "leaderboard", "help", "new-moon"}
}
var out []string
for _, name := range strings.Split(include, ",") {
if name = strings.TrimSpace(name); sections[name] && name != "moons" {
out = append(out, name)
}
}
return out
}
// handleAPIState is GET /api/state: the state for a view (s, moon, well, list, pick, drill).
func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
// Opening a newly revealed tab stops its shine, as on the page.
if err := markSeen(ctx, s.db, userID, r.FormValue("s")); err != nil {
apiServerError(w, err)
return
}
state, err := s.apiPage(ctx, userID, viewFromForm(r.FormValue), r.FormValue)
if err != nil {
apiServerError(w, err)
return
}
s.logSession(ctx, userID)
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
}
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r)
if err != nil {
apiError(w, http.StatusBadRequest, "Bad request")
return
}
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
if errors.Is(err, errUnknownAction) {
apiError(w, http.StatusNotFound, "Unknown action")
return
}
if err != nil {
apiServerError(w, err)
return
}
if res.Action == "new-game" {
// A fresh player; the client stores the new id.
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return
}
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return
}
w.Header().Set(userHeader, userID)
w.Header().Del("Set-Cookie") // the old player's
setUserCookie(w, userID)
created, res.View, res.Message = true, View{}, "New game started"
}
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
apiServerError(w, err)
return
}
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
if !toast.Error {
if res.BoughtTool != "" {
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
}
if res.Action == "buy-moon" {
resp.Reveal = apiReveal(page.moonReveal())
}
}
if resp.State, err = s.apiStateWith(ctx, page, get); err != nil {
apiServerError(w, err)
return
}
status := http.StatusOK
if toast.Error {
resp.Error, status = toast.Message, http.StatusUnprocessableEntity
} else {
resp.FX = &APIFX{Kind: toast.Kind, Finished: res.Finished, Credits: toast.Credits}
if resp.FX.Finished == nil {
resp.FX.Finished = []string{}
}
}
writeJSON(w, status, resp)
}
// apiParams reads an action's parameters from the query and form (parsed by limitBody) or
// a JSON object body; JSON numbers and booleans become strings (true is "1").
func apiParams(r *http.Request) (func(string) string, error) {
ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if ct != "application/json" {
return r.FormValue, nil
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
vals := map[string]string{}
for k, v := range body {
switch v := v.(type) {
case string:
vals[k] = v
case float64:
vals[k] = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
vals[k] = "1"
}
case nil:
default:
return nil, errors.New("api: parameters must be strings, numbers or booleans")
}
}
return func(k string) string {
if v, ok := vals[k]; ok {
return v
}
return r.FormValue(k)
}, nil
}
// apiSeq is a client's clientSeq as it echoes back: an integer, else the string, else nil.
func apiSeq(seq string) any {
if seq == "" {
return nil
}
if n, err := strconv.ParseInt(seq, 10, 64); err == nil {
return n
}
return seq
}
+495
View File
@@ -0,0 +1,495 @@
# Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the
web game: `GET /api/state` is built from the same view models as `GET /`, and
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
server.go), onboarding gates included. Code: `api.go`.
Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
server's clock: compute time left as `end - now` (plus the time since the response).
- Amounts and prices are raw numbers; the client formats them (currency symbol ₥, see
[Texts](#texts) for the web's formats).
- Reasons are codes, each with the web's player-facing text next to it (`why` and
`whyText`, `noSale` and `noSaleText`, …), and actions the web confirms carry the dialog's
words (`confirm`, see [Texts](#texts)). Other display text: names, descriptions, toast
messages, banner text and the few labels listed with their fields.
## Auth
The player id is read from the `X-MM-User` header, else the `mm_user` cookie. If neither
names an existing player, a new one is created (at most 10 per minute per IP, else
`429`), with `newPlayer: true` in the body. Every successful response returns the id in
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
Banned players get `403`.
## GET /api/state
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
clears its shine.
`catalog`: `0` leaves `state.catalog` out; the `catalogVersion` of the catalog the client
has leaves it out while it's unchanged (it changes when admins change the config). Without
it the catalog comes as before. `state.catalogVersion` is always there.
Response `200`: `{ok: true, userId, newPlayer?, state}`.
## POST /api/act/&lt;action&gt;
Body: form (`application/x-www-form-urlencoded`) or a JSON object (strings, numbers,
booleans; `true` is sent as `"1"`, `false` as unset). Query params work too. Include the
current view (`s`, `moon`, `well`, …, `include` and `catalog`) to get the state for it back.
`clientSeq` (optional, any string or integer) comes back as `clientSeq` in the response
(an integer comes back as a number, so `"007"` becomes `7`), on 200 and 422, so a client can match responses to the
predictions it queued.
Core-loop actions and their params:
| action | params | notes |
|---|---|---|
| `buy-moon` | – | view moves to the new moon |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy` |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well |
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
The other sections' actions:
| action | params | notes |
|---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
| `buy-attachment` | `item` (attachment name) | |
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
| `sell-attachment` | `item` | a spare one |
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
| `buy-seat` | – | advisor seat |
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
Responses:
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
`kind` is the action (for sounds and effects); `finished` lists moons the action mined
out; `credits` is what a market sale earned. `reveal` (after `buy-moon`) and `buyModal`
(after `buy-tool`) are one-shot results, see below.
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
A locked action (its part of the game not revealed yet) says "Not available yet".
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
`500` `{ok: false, error: "Something went wrong"}`.
## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
session; the web page also reads it once per page load.
## State
| key | |
|---|---|
| `userId`, `now` | |
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `credits` | balance |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
`newMoon`) are left out unless their section is the view's (`s=`) or in `include=`.
Codes and texts: where the web shows a reason (why a button is disabled), the API gives a
short code, listed with each field, and the web's text next to it.
## Sections
### equipment (`s=equipment`)
```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
drill}
```
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
`shortText` the line under it when credits are short.
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`).
### store (`s=store`)
```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
`yield` and `cycleSecs` are the base stats the card shows; `rate` is credits per hour it
would earn with the player's advisors, `bestRate` the same for their best tool (0: none).
### refinery (`s=refinery`)
```
{slots: [{n, job: {id, slot, item, qty, start, end, ready, progress} | null}], slotCount, maxSlots,
nextSlot, nextSlotPrice, canBuySlot, free, ready, maxBatch,
recipes: [{item, tier, seconds, price, sellPrice, inputs: [{name, need, have}], inputValue, gain,
maxQty, bulkQty, bulkSecs, bulkBonus, owned, why, whyText}],
stock: [{name, amount}], buySlot, buySlotWhyText}
```
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
"+ Slot" is disabled. Refined items sell through `sell` like resources.
### advisors (`s=advisors`)
```
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
bench, bonuses, buySeat, buySeatWhyText, selected,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
```
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
dismissed).
The web's panel for a hired advisor (any client may do the same): for every other seat,
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
### leaderboard (`s=leaderboard`)
`{players, top: [{rank, name, credits, me}], you, me: {rank, credits}}`: `you` is the
player's row when they're not in `top` (else `null`); `me` their place either way.
### help (`s=help`)
`{steps: [string], note, newGame}`: the "How to play" steps (the web's text; `note` says
progress is saved on this device). Its button is `new-game`, `newGame` its dialog.
### newMoon (`s=new-moon`)
`{price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: `after` the
balance after buying, `need` the credits missing, `whyText` why the button is disabled, `types` the moon types it can roll,
`resources` what its wells can hold. Buy with `buy-moon`.
### reveal (in the `buy-moon` response)
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
### buyModal (in the `buy-tool` response)
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value").
### moon.sale (with `s=moons`, once `unlocked.store`)
```
{total, market, replace, delta, capped, factor, lines: [{resource, supply, rate, value}],
last, running, ready, holdEnd, canSell, why, whyText, confirm}
```
`total` is the offer (`market` × `factor`, capped), `replace` a new moon's price after the
sale, `delta` = total − replace; `ready` finished drills are claimed in the sale.
`why`: `last` (only moon), `running` (drills on it), `hold` (sellable at `holdEnd`) or `""`;
`whyText` the web's text ("Wait for 2 drills to finish", "Just bought: you can sell it in
4:27", at `now`); `confirm` the sell-moon dialog.
### market chart (`s=market`)
`market.chart`: `{item, start, step, prices}`, the selected quote's 24h market rate (before
events, advisors and pressure), one point every `step` ms from `start` (289 points, the last
is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkline).
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
## Prediction
The client may show results before the server answers; the next state replaces them.
- Stake: a drill takes `tool.cycleSecs` and gets `tool.yield` (× `rules.boosts.yield` of
the well's resource, if any). Each stake carries `start`, `end` and `take` (what claiming
it would get now).
- Sell: `amount` units pay `amount × unpressed × avg`, with
`avg = floor + (1 − floor) × scale × (e^(−p/scale) − e^(−(p+d)/scale)) / d`,
`p` the quote's `pressure`, `d = amount × base` and `floor`, `scale` from
`rules.pressure` (`avg` is `factor` when `d` is 0). `sellAll` is that for the whole
balance. Pressure then becomes `min(p + d, rules.pressure.cap)` and halves every
`halfLifeSecs`.
- Refine: a batch of `qty` takes `round(seconds × qty^bulkExponent / speed)` seconds
(`seconds` per recipe, the rest in `rules.refinery`).
- Every response's `state.now` is the server clock; `clientSeq` matches a response to its
request.
## Example
```
POST /api/act/stake
X-MM-User: 29dea5d5-709d-4c55-a269-cd90e4379fe9
Content-Type: application/json
{"tool": "0fbfcb4f-…", "target": "b9d643c4-…", "moon": "54daa782-…", "well": "b9d643c4-…", "clientSeq": 12}
```
```json
{
"ok": true,
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"clientSeq": 12,
"toast": {"message": "Drill deployed", "error": false, "kind": "stake"},
"fx": {"kind": "stake", "finished": [], "credits": 0},
"state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
"moonPrice": 400,
"unlocked": {"moons": true, "equipment": true, "market": true, "store": true, "refinery": false, "advisors": false,
"leaderboard": true, "help": true, "new-moon": true, "claim-all": false, "redrill": true, "redeploy": true},
"shine": ["equipment", "store"],
"ready": 0, "idle": 0, "nextEnd": 1792391819259,
"moons": [{"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70},
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47}],
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
"finished": false, "finishedAt": null}],
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
"market": {"quotes": [{"item": "Azurium", "refined": false, "balance": 0, "price": 6.864, "rate": 6.864, "boost": 1,
"advisor": 1, "pressure": 0, "factor": 1, "open": 6.899, "change": -0.501,
"high": 7.151, "low": 5.786, "value": 0}],
"selected": "Azurium", "total": 0},
"banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
}
}
```
## Section examples
From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
```
`s=store`, `state.store`:
```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
```
`s=refinery`, `state.refinery`:
```json
{"slots": [{"n": 1, "job": {"id": "1155…", "slot": 1, "item": "Dusk Alloy", "qty": 3, "start": 1792402355103,
"end": 1792402357103, "ready": false, "progress": 0.004}}],
"slotCount": 1, "maxSlots": 4, "nextSlot": 2, "nextSlotPrice": 1500, "canBuySlot": true, "free": 0, "ready": 0, "maxBatch": 25,
"recipes": [{"item": "Dusk Alloy", "tier": 1, "seconds": 1, "price": 30, "sellPrice": 29.52,
"inputs": [{"name": "Shadowstone", "need": 2, "have": 494}, {"name": "Sollux", "need": 2, "have": 494}],
"inputValue": 15, "gain": 100, "maxQty": 25, "bulkQty": 25, "bulkSecs": 7, "bulkBonus": 72.4,
"owned": 0, "why": "busy"}, "…"],
"stock": []}
```
`s=advisors`, `state.advisors`:
```json
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
"buySeatWhyText": "",
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
"canSeat": false, "seatWhy": "", "seatWhyText": "",
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
"ok": "Dismiss", "danger": true}, "removed": false},
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
`s=new-moon`, `state.newMoon`:
```json
{"price": 400, "canAfford": true, "after": 73179.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 5}
```
`POST /api/act/buy-moon`, `reveal`:
```json
{"moonId": "9b79…", "name": "Lithara-η", "kind": "Lumineer", "description": "…",
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
"traits": [], "maxSupply": 90, "fullAt": 84,
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
```
`POST /api/act/buy-tool`, `buyModal`:
```json
{"toolId": "e79d…", "storeItemId": 1, "cycleSecs": 4, "yield": 2, "reach": 1, "noMoons": false, "allEmpty": false,
"suggestions": [{"moonId": "9b79…", "moonName": "Lithara-η", "wellId": "92cf…", "resource": "Azurium", "supply": 37,
"credits": 13.6, "perMin": 204.0, "idle": true, "reason": "best-value"}, "…"]}
```
`s=moons&moon=…`, `state.moon.sale`:
```json
{"total": 91, "market": 919.54, "replace": 400, "delta": -309, "capped": false, "factor": 0.1,
"lines": [{"resource": "Nebulance", "supply": 64, "rate": 10.55, "value": 675.48}, "…"],
"last": false, "running": 0, "ready": 0, "holdEnd": 1792402983084, "canSell": false, "why": "hold"}
```
`s=market&pick=Azurium`, `state.market` (one quote):
```json
{"selected": "Azurium", "held": 6, "best": "Cryo Cell", "worst": "Dusk Alloy", "total": 17038.73,
"chart": {"item": "Azurium", "start": 1792315800000, "step": 300000, "prices": [7.03, "… 289 points"]},
"quotes": [{"item": "Azurium", "refined": false, "balance": 500, "price": 6.80, "rate": 6.80, "boost": 1, "advisor": 1,
"pressure": 0, "factor": 1, "open": 7.03, "change": -3.33, "high": 7.15, "low": 5.79, "value": 3400.28,
"symbol": "AZU", "unpressed": 6.80, "base": 6.5, "sellAll": 3384.06, "spark": [7.03, "… 97 points"]}, "…"]}
```
+1049
View File
File diff suppressed because it is too large. Load diff
+304
View File
@@ -0,0 +1,304 @@
package main
// API check (make check): builds the JSON state (api.go) for every view of the template
// check's in-memory states and checks a few fields. Run with `go test -run API`.
import (
"encoding/json"
"testing"
)
func TestAPI(t *testing.T) {
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
a := apiState(p)
if _, err := json.Marshal(a); err != nil {
t.Fatalf("marshal %+v: %v", v, err)
}
checkAPISections(t, s, p.V, a)
checkAPITexts(t, p.V, a)
for section, present := range map[string]bool{"equipment": a.Equipment != nil, "store": a.Store != nil,
"refinery": a.Refinery != nil, "advisors": a.Advisors != nil, "leaderboard": a.Leaderboard != nil,
"help": a.Help != nil, "new-moon": a.NewMoon != nil} {
if present != (p.V.Section == section) {
t.Errorf("view %+v: %s block present %v", v, section, present)
}
}
if a.Rules == nil || a.Catalog == nil || len(a.Catalog.Tools) != len(s.Config.StoreItems) {
t.Errorf("view %+v: rules or catalog missing", v)
}
if (len(a.Jobs) > 0) && !s.Unlocked("refinery") {
t.Errorf("view %+v: refinery jobs before the refinery is open", v)
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
}
}
if (a.Market.Chart != nil) != (p.V.Section == "market") {
t.Errorf("view %+v: market chart %v", v, a.Market.Chart != nil)
}
if c := a.Market.Chart; c != nil && (len(c.Prices) != marketPoints || c.Item != a.Market.Selected) {
t.Errorf("view %+v: chart of %s with %d points", v, c.Item, len(c.Prices))
}
}
if a.Moon != nil && (a.Moon.Sale != nil) != (s.Unlocked("store") && p.Moon.Sale != nil) {
t.Errorf("view %+v: moon sale %v", v, a.Moon.Sale != nil)
}
if p.Moon != nil {
// The reveal card and the buy modal, as an action response maps them.
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
t.Errorf("view %+v: reveal %+v", v, r)
}
}
for _, tool := range s.Tools {
if m := apiBuyModal(s.buySuggestions(tool.ID)); m == nil || m.ToolID != tool.ID {
t.Errorf("buy modal for %s: %+v", tool.ID, m)
}
}
// Every section block at once (include=all).
var extra []*Page
for _, section := range apiIncludes("all") {
ev := p.V
ev.Section = section
if ev = ev.resolve(s); ev.Section == section {
ep := s.page(ev)
testRanking(ep)
extra = append(extra, ep)
}
}
all := apiState(p, extra...)
for _, ep := range extra {
checkAPISections(t, s, ep.V, all)
}
checkAPITexts(t, p.V, all)
if _, err := json.Marshal(all); err != nil {
t.Fatalf("marshal %+v with every section: %v", v, err)
}
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
p.Advisors != nil && p.Advisors.Panel != nil
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
}
asides[a.View.Aside] = true
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
t.Errorf("view %+v: state has %+v", p.V, a.View)
}
if len(a.Stakes) != len(s.Stakes) || len(a.Tools) != len(s.Tools) {
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
}
for _, st := range a.Stakes {
if st.End-st.Start != int64(st.Secs)*1000 {
t.Errorf("stake %s: end - start is not its duration", st.ID)
}
}
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
t.Errorf("view %+v: moon or well missing", v)
}
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
}
if (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
}
}
}
if !asides[true] || !asides[false] {
t.Errorf("views with and without the aside: %v", asides)
}
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
}
// The catalog: a stable version, skipped with catalog=0 or catalog=<version> only.
s := veteranState()
a, b := apiState(s.page(View{}.resolve(s))), apiState(s.page(View{}.resolve(s)))
if a.CatalogVersion == "" || a.CatalogVersion != b.CatalogVersion {
t.Errorf("catalog versions %q, %q", a.CatalogVersion, b.CatalogVersion)
}
for param, kept := range map[string]bool{"": true, "1": true, "stale": true, "0": false, a.CatalogVersion: false} {
c := apiState(s.page(View{}.resolve(s)))
if c.skipCatalog(param); (c.Catalog != nil) != kept {
t.Errorf("catalog=%q: catalog present %v", param, c.Catalog != nil)
}
}
}
// checkAPITexts checks that ratings carry their label and "top N%", every reason code its
// text and every confirm its question, as the web shows them.
func checkAPITexts(t *testing.T, v View, a *APIState) {
t.Helper()
fail := func(what string, x any) { t.Errorf("view %+v: %s: %+v", v, what, x) }
rating := func(r *APIRating) {
if r != nil && (r.Label == "" || r.Top < 1) {
fail("rating", r)
}
}
for _, m := range append(append([]APIMoonListItem{}, a.Moons...), a.Past...) {
rating(m.Rating)
if m.Finished && m.FinishedOn == "" {
fail("past moon date", m)
}
}
if m := a.Moon; m != nil {
rating(m.Rating)
for _, w := range m.Wells {
if len(w.Drills) > 0 && w.Label == "" {
fail("well label", w)
}
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
fail("well note", w)
}
for _, d := range w.Drills {
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
fail("drill fate", d)
}
}
}
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
fail("moon sale", sale)
}
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
fail("quote texts", q)
}
}
}
for _, b := range a.Bonuses {
if b.Label == "" || b.Text == "" {
fail("bonus", b)
}
}
if c := a.Catalog; c != nil {
if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach)
}
for _, at := range c.Attachments {
if at.EffectText == "" {
fail("attachment effect", at)
}
}
}
if e := a.Equipment; e != nil {
for _, r := range e.Tools {
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
fail("tool row texts", r)
}
}
if d := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d)
}
for _, sp := range d.Spares {
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
fail("spare texts", sp)
}
}
}
}
if st := a.Store; st != nil {
for _, tool := range st.Tools {
if tool.CanAfford == (tool.WhyText != "") {
fail("store tool text", tool)
}
}
}
if r := a.Refinery; r != nil {
if (r.NextSlotPrice > 0) != (r.BuySlot != nil) {
fail("buy slot confirm", r.BuySlot)
}
for _, rc := range r.Recipes {
if (rc.Why != "") != (rc.WhyText != "") {
fail("recipe texts", rc)
}
}
}
if ad := a.Advisors; ad != nil {
if (ad.NextSeatPrice > 0) != (ad.BuySeat != nil) {
fail("buy seat confirm", ad.BuySeat)
}
for _, up := range ad.Advisors {
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
fail("advisor texts", up)
}
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
fail("advisor seat texts", up)
}
}
}
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
fail("new game confirm", h.NewGame)
}
}
// testRanking stands in for the leaderboard the server loads (loadRanking).
func testRanking(p *Page) {
if p.V.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
}
}
// checkAPISections checks that the block of a view's section is in the state, as the
// section's view model has it.
func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Helper()
switch v.Section {
case "equipment":
if a.Equipment == nil || len(a.Equipment.Tools) == 0 && len(s.Tools) > 0 {
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
t.Errorf("view %+v: %d slots", v, len(d.Slots))
}
case "store":
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
t.Errorf("view %+v: store %+v", v, a.Store)
}
case "refinery":
if a.Refinery == nil || len(a.Refinery.Recipes) != len(cfg().Refinery.Items) || len(a.Refinery.Slots) < s.RefinerySlots {
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
}
case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
}
for _, h := range s.Hires {
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
}
}
case "leaderboard":
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
}
case "help":
if a.Help == nil || len(a.Help.Steps) == 0 {
t.Errorf("view %+v: help %+v", v, a.Help)
}
case "new-moon":
if a.NewMoon == nil || a.NewMoon.Price != s.MoonPrice() || a.NewMoon.WellsMin < 1 {
t.Errorf("view %+v: new moon %+v", v, a.NewMoon)
}
case "market":
if a.Market == nil || a.Market.Chart == nil {
t.Errorf("view %+v: market chart missing", v)
}
}
}
+32 -5
View File
@@ -10,6 +10,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -230,16 +231,42 @@ type SpareGear struct {
Why string // why it can't be equipped Why string // why it can't be equipped
} }
func (s *State) drillPanel(row ToolRow) *DrillPanel { // noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)} func (s *State) noSale(row ToolRow) string {
switch { switch {
case len(s.Tools) <= 1: case len(s.Tools) <= 1:
d.NoSale = "You can't sell your only tool" return "only-tool"
case row.Stake != nil && row.Stake.Ready: case row.Stake != nil && row.Stake.Ready:
d.NoSale = "Claim its haul first" return "claim-first"
case row.Stake != nil: case row.Stake != nil:
d.NoSale = "Still mining. Sell it once it's idle" return "mining"
} }
return ""
}
// Confirm is the question before selling one (the Sell button's confirm dialog).
func (sg SpareGear) Confirm() string {
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
if len(d.Gear) > 0 {
q += " Its attachments go back to your inventory."
}
return q
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)}
d.NoSale = noSaleText[s.noSale(row)]
for i := range cfg().Attachments.SlotsPerTool { for i := range cfg().Attachments.SlotsPerTool {
slot := SlotGear{N: i + 1} slot := SlotGear{N: i + 1}
if i < len(row.Gear) { if i < len(row.Gear) {
+13 -5
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction // ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's advisors and, with a tool id, the tool's attachments, and // loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are // combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action. // share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) { func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, ` rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by advisor for share`, userID) select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil { if err != nil {
return noBonus, err return noBonus, err
} }
@@ -143,20 +143,28 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ state // ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors and advisor seats into the state. // loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error { func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats) err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil { if err != nil {
return err return err
} }
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select advisor from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID) select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
if err != nil { if err != nil {
return err return err
} }
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil { if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
return err return err
} }
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, ` rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID) where user_id = $1 order by item`, s.UserID)
+6 -6
View File
@@ -189,21 +189,21 @@
"refinery": { "refinery": {
"slotPrices": [1500, 4000, 10000], "slotPrices": [1500, 4000, 10000],
"maxBatch": 25, "maxBatch": 25,
"bulkExponent": 0.7, "bulkExponent": 0.6,
"items": [ "items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 30, "seconds": 3, {"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 30, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2}, "inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."}, "description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 63, "seconds": 5, {"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 63, "seconds": 2,
"inputs": {"Azurium": 3, "Novafor": 3}, "inputs": {"Azurium": 3, "Novafor": 3},
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."}, "description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 70, "seconds": 8, {"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 70, "seconds": 3,
"inputs": {"Nebulance": 4, "Sollux": 2}, "inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."}, "description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 320, "seconds": 15, {"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 320, "seconds": 5,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5}, "inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."}, "description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 310, "seconds": 20, {"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 310, "seconds": 6,
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4}, "inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."} "description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
] ]
+2
View File
@@ -1,3 +1,5 @@
name: moonminers
# Local development database. Run the app itself with `make dev`. # Local development database. Run the app itself with `make dev`.
services: services:
db: db:
+7 -2
View File
@@ -177,6 +177,10 @@ type BannerItem struct {
Level string // info, warning, success, event Level string // info, warning, success, event
Text string Text string
Left string // "3h left" Left string // "3h left"
// For API clients: the announcement or live event it shows and when it ends.
Kind string // announcement, event
ID int
EndsAt time.Time
} }
// loadBanner returns the active announcements, then the active events. // loadBanner returns the active announcements, then the active events.
@@ -194,7 +198,7 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
} }
var items []BannerItem var items []BannerItem
for _, a := range anns { for _, a := range anns {
items = append(items, BannerItem{Level: a.Level, Text: a.Message}) items = append(items, BannerItem{Level: a.Level, Text: a.Message, Kind: "announcement", ID: a.ID, EndsAt: a.EndsAt})
} }
rows, err = q.Query(ctx, "select "+liveEventColumns+` from live_event rows, err = q.Query(ctx, "select "+liveEventColumns+` from live_event
@@ -207,7 +211,8 @@ func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
return nil, err return nil, err
} }
for _, e := range events { for _, e := range events {
items = append(items, BannerItem{Level: "event", Text: e.Headline(), Left: timeLeft(e.EndsAt.Sub(now))}) items = append(items, BannerItem{Level: "event", Text: e.Headline(), Left: timeLeft(e.EndsAt.Sub(now)),
Kind: "event", ID: e.ID, EndsAt: e.EndsAt})
} }
return items, nil return items, nil
} }
+97 -12
View File
@@ -113,6 +113,12 @@ type State struct {
Balance float64 Balance float64
Resources []ResourceBalance Resources []ResourceBalance
Moons []Moon Moons []Moon
// Past moons (finished): how many, the page shown (View.Page, from 1) and its moons,
// most recently finished first. Only those (and a selected past moon) have FinishedAt
// and Mined.
PastTotal int
PastPage int
PastIDs []string
Tools []Tool Tools []Tool
Stakes []Stake Stakes []Stake
Now time.Time Now time.Time
@@ -130,10 +136,12 @@ type State struct {
Pressure map[string]float64 // the player's selling pressure by item, now (market.go) Pressure map[string]float64 // the player's selling pressure by item, now (market.go)
// Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind), // Bonuses (bonus.go): attachments equipped on each tool and spare ones (by kind),
// advisors hired, advisor seats owned. // seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
Fitted map[string][]string Fitted map[string][]string
Spare map[string]int Spare map[string]int
Advisors []string Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int AdvisorSeats int
// Onboarding flags (onboarding.go): which parts of the game are revealed. // Onboarding flags (onboarding.go): which parts of the game are revealed.
@@ -410,9 +418,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
for _, st := range s.Stakes { for _, st := range s.Stakes {
delete(s.LastWell, st.ToolID) delete(s.LastWell, st.ToolID)
} }
if err := loadPastMoons(ctx, db, s); err != nil { loadPastMoons(s)
return nil, err
}
if err := loadRefinery(ctx, db, s); err != nil { if err := loadRefinery(ctx, db, s); err != nil {
return nil, err return nil, err
} }
@@ -434,14 +440,31 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return s, nil return s, nil
} }
// loadPastMoons marks the finished moons and adds what was mined on each. Supply only goes // loadView loads the player's state for a view (with the page of past moons it shows) and
// down and every stake gets claimed, so a finished moon stays finished. // resolves the view.
func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error { func loadView(ctx context.Context, db *pgxpool.Pool, userID string, v View) (*State, View, error) {
s, err := loadState(ctx, db, userID)
if err != nil {
return nil, v, err
}
if err := loadPastPage(ctx, db, s, v.Page, v.Moon); err != nil {
return nil, v, err
}
return s, v.resolve(s), nil
}
// pastPerPage is how many past moons the moon list shows at a time (View.Page).
const pastPerPage = 10
// loadPastMoons marks the finished moons and counts them. Supply only goes down and every
// stake gets claimed, so a finished moon stays finished. What was mined on them is loaded
// a page at a time (loadPastPage).
func loadPastMoons(s *State) {
busy := map[string]bool{} busy := map[string]bool{}
for _, st := range s.Stakes { for _, st := range s.Stakes {
busy[st.MoonID] = true busy[st.MoonID] = true
} }
past := map[string]*Moon{} s.PastTotal = 0
for i := range s.Moons { for i := range s.Moons {
m := &s.Moons[i] m := &s.Moons[i]
m.Finished = len(m.Wells) > 0 && !busy[m.ID] m.Finished = len(m.Wells) > 0 && !busy[m.ID]
@@ -451,13 +474,51 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
} }
} }
if m.Finished { if m.Finished {
past[m.ID] = m s.PastTotal++
} }
} }
if len(past) == 0 { }
// pastPages is how many pages of past moons there are (at least 1).
func (s *State) pastPages() int { return max(1, (s.PastTotal+pastPerPage-1)/pastPerPage) }
// loadPastPage loads one page of past moons, most recently finished first (page 1), and
// what was mined on each; also on the selected moon if it's a past one on another page.
// The page is clamped to the ones there are.
func loadPastPage(ctx context.Context, db *pgxpool.Pool, s *State, page int, selected string) error {
s.PastPage = min(max(page, 1), s.pastPages())
s.PastIDs = []string{}
past := map[string]*Moon{}
var ids []string
for i := range s.Moons {
if s.Moons[i].Finished {
past[s.Moons[i].ID] = &s.Moons[i]
ids = append(ids, s.Moons[i].ID)
}
}
if len(ids) == 0 {
return nil return nil
} }
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select ids.id
from unnest($1::text[]) ids(id)
left join resource_well on resource_well.source_id::text = ids.id
left join staking_event on staking_event.well_id = resource_well.id
left join claim_event on claim_event.staking_event_id = staking_event.id
group by ids.id
order by max(claim_event.created_at) desc nulls last, ids.id
limit $2 offset $3`, ids, pastPerPage, (s.PastPage-1)*pastPerPage)
if err != nil {
return err
}
if s.PastIDs, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return err
}
load := slices.Clone(s.PastIDs)
if past[selected] != nil && !slices.Contains(load, selected) {
load = append(load, selected)
}
rows, err = db.Query(ctx, `
select resource_well.source_id, resource.name, select resource_well.source_id, resource.name,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at) sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
from claim_event from claim_event
@@ -465,9 +526,9 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
left join claim_well on claim_well.claim_event_id = claim_event.id left join claim_well on claim_well.claim_event_id = claim_event.id
join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id) join resource_well on resource_well.id = coalesce(claim_well.well_id, staking_event.well_id)
join resource on resource.id = resource_well.resource_id join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 where staking_event.user_id = $1 and resource_well.source_id::text = any($2)
group by resource_well.source_id, resource.name group by resource_well.source_id, resource.name
order by resource.name`, s.UserID) order by resource.name`, s.UserID, load)
if err != nil { if err != nil {
return err return err
} }
@@ -486,6 +547,29 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
return err return err
} }
// pagePastInMemory picks a page of past moons by FinishedAt, as loadPastPage does in SQL
// (for states built without a database).
func (s *State) pagePastInMemory(page int) {
s.PastTotal = 0
for _, m := range s.Moons {
if m.Finished {
s.PastTotal++
}
}
s.PastPage = min(max(page, 1), s.pastPages())
var past []*Moon
for i := range s.Moons {
if s.Moons[i].Finished {
past = append(past, &s.Moons[i])
}
}
slices.SortStableFunc(past, func(a, b *Moon) int { return b.FinishedAt.Compare(a.FinishedAt) })
s.PastIDs = []string{}
for _, m := range past[min(len(past), (s.PastPage-1)*pastPerPage):min(len(past), s.PastPage*pastPerPage)] {
s.PastIDs = append(s.PastIDs, m.ID)
}
}
// moonFinished reports whether a moon has just been mined out: its wells are empty and // moonFinished reports whether a moon has just been mined out: its wells are empty and
// nothing on it is left to claim. // nothing on it is left to claim.
func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) { func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) {
@@ -510,6 +594,7 @@ func leaderboard(ctx context.Context, db *pgxpool.Pool, limit int) ([]Leaderboar
rows, err := db.Query(ctx, ` rows, err := db.Query(ctx, `
select users.name, bank_account.balance select users.name, bank_account.balance
from users join bank_account on bank_account.user_id = users.id from users join bank_account on bank_account.user_id = users.id
where users.banned_at is null
order by balance desc limit $1`, limit) order by balance desc limit $1`, limit)
if err != nil { if err != nil {
return nil, err return nil, err
+60
View File
@@ -0,0 +1,60 @@
package main
// The Leaderboard section: the richest miners by credit balance, with the player's own
// place below the top when they're not in it. Banned players don't rank. Template:
// leaderboard-section in app.html.
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const leaderboardSize = 10
type LeaderboardPage struct {
Top []LeaderboardRow
You *LeaderboardRow // the player, when they're not in Top
Players int // miners ranked
}
type LeaderboardRow struct {
Rank int
Name string
Balance float64
Me bool
}
// loadLeaderboard reads the top of the ranking and the player's own row. Ties go to whoever
// joined first, so every miner has one place.
func loadLeaderboard(ctx context.Context, db *pgxpool.Pool, userID string) (*LeaderboardPage, error) {
rows, err := db.Query(ctx, `
select rank, name, balance, id = $1, players from (
select users.id, users.name, bank_account.balance,
row_number() over (order by bank_account.balance desc, users.created_at, users.id) as rank,
count(*) over () as players
from users join bank_account on bank_account.user_id = users.id
where users.banned_at is null
) ranked
where rank <= $2 or id = $1
order by rank`, userID, leaderboardSize)
if err != nil {
return nil, err
}
p := &LeaderboardPage{}
var r LeaderboardRow
_, err = pgx.ForEachRow(rows, []any{&r.Rank, &r.Name, &r.Balance, &r.Me, &p.Players}, func() error {
if r.Rank <= leaderboardSize {
p.Top = append(p.Top, r)
} else {
you := r
p.You = &you
}
return nil
})
if err != nil {
return nil, err
}
return p, nil
}
+5 -4
View File
@@ -12,7 +12,7 @@ package main
// - seen:<tab>, the revealed tab was opened (no more shine); // - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all"). // - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon", // Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
// Help, moon sale, "Claim and redeploy", upgrades and the // Help, Leaderboard, moon sale, "Claim and redeploy", upgrades and the
// drill panel (attachments); Claim all also needs 2 tools. // drill panel (attachments); Claim all also needs 2 tools.
import ( import (
@@ -40,7 +40,7 @@ func (s *State) Unlocked(feature string) bool {
// mined out. // mined out.
active := slices.ContainsFunc(s.Moons, func(m Moon) bool { return !m.Finished }) active := slices.ContainsFunc(s.Moons, func(m Moon) bool { return !m.Finished })
return !active || slices.Contains(s.Onboarding, "store") return !active || slices.Contains(s.Onboarding, "store")
case "help": case "help", "leaderboard":
feature = "store" feature = "store"
case "claim-all": case "claim-all":
return slices.Contains(s.Onboarding, "store") && len(s.Tools) >= 2 return slices.Contains(s.Onboarding, "store") && len(s.Tools) >= 2
@@ -134,14 +134,15 @@ func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) err
return err return err
} }
// actionGates is the feature each action needs; handleAction refuses the rest. // actionGates is the feature each action needs; runAction refuses the rest.
var actionGates = map[string]string{ var actionGates = map[string]string{
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store", "sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store", "buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help", "equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market", "sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery", "refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
"hire": "advisors", "buy-seat": "advisors", "hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
"buy-moon": "new-moon", "buy-moon": "new-moon",
} }
+16
View File
@@ -553,6 +553,22 @@ type RefineryPage struct {
Stock []RefinedBalance // refined items owned, in config order Stock []RefinedBalance // refined items owned, in config order
} }
// SlotConfirm is the question before buying the next slot.
func (p *RefineryPage) SlotConfirm() string {
return fmt.Sprintf("Add refinery slot %d for %s? Each slot runs one batch at a time.", p.NextSlot, formatCredits(p.NextSlotPrice))
}
// Why is why a recipe can't be started (its buttons' tooltip), "" if it can.
func (p *RefineryPage) Why(card RecipeCard) string {
switch {
case p.Free == 0:
return "All slots are busy"
case card.MaxQty == 0:
return "Not enough inputs"
}
return ""
}
type SlotRow struct { type SlotRow struct {
N int N int
Job *JobRow Job *JobRow
+29 -6
View File
@@ -52,6 +52,17 @@ type MoonReveal struct {
Traits []string // notable traits and their rarity Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar MaxSupply float64 // the biggest well a moon can have: a full bar
FullAt float64 // wells from here count as full FullAt float64 // wells from here count as full
Next NextMoon // buying another one right away
}
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
// "".
type NextMoon struct {
Price float64
CanBuy bool
Why string
WhyText string
} }
var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"} var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
@@ -68,7 +79,14 @@ func (p *Page) moonReveal() *MoonReveal {
for i, w := range p.Moon.Wells { for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply} wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
} }
r := &MoonReveal{MoonDetail: p.Moon} r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
switch {
case !p.S.Unlocked("store"):
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
case p.S.Balance < r.Next.Price:
r.Next.Why, r.Next.WhyText = "credits", shortBy(r.Next.Price-p.S.Balance)
}
r.Next.CanBuy = r.Next.Why == ""
if p.Moon.Rating != nil { if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating r.Rating = *p.Moon.Rating
} else { } else {
@@ -161,7 +179,15 @@ func (p *Page) moonReveal() *MoonReveal {
// WellRange is how many wells a new moon can have, e.g. "2–5". // WellRange is how many wells a new moon can have, e.g. "2–5".
func (s *State) WellRange() string { func (s *State) WellRange() string {
lo, hi := 0, 0 lo, hi := wellCountRange()
if lo == hi {
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
}
// wellCountRange is the fewest and most wells a new moon can have.
func wellCountRange() (lo, hi int) {
for n, w := range cfg().WellCountWeights { for n, w := range cfg().WellCountWeights {
if w > 0 { if w > 0 {
if lo == 0 || n < lo { if lo == 0 || n < lo {
@@ -170,8 +196,5 @@ func (s *State) WellRange() string {
hi = max(hi, n) hi = max(hi, n)
} }
} }
if lo == hi { return lo, hi
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
} }
+48
View File
@@ -0,0 +1,48 @@
// The Preact client's rule (AGENTS.md, "web/app"): pass signals to JSX as they are and
// read `.value` only in logic, computed() and effects. A `.value` read inside JSX makes the
// whole component re-render on every change instead of Preact updating one text node or
// attribute.
//
// Flags `.value` inside a JSX expression container in web/app/**/*.jsx (`>{x.value}`,
// `attr={x.value}`, a `{x.value …}` child on its own line), up to the first function in
// it: event handlers (`onClick={() => x.value++}`) are fine, `{xs.value.map(…)}` isn't.
// A guard rail, not a parser: it reads the source line by line, so keep JSX in .jsx files
// and Prettier's style (`attr={…}` without spaces; plain JS assignments as `x = {…}`).
//
// Usage: node scripts/check-signals-jsx.mjs <dir>
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const dir = process.argv[2] ?? "web/app";
// A container: opened right after a tag's `>`, an attribute's `=`, or at the start of a
// line (a child), up to its closing brace; nested `${…}` is allowed inside.
const container = /(?:>|=|^\s*)\{((?:[^{}]|\{[^{}]*\})*)\}?/g;
const read = /\.value\b(?!\s*=[^=])/;
function* files(d) {
for (const name of readdirSync(d)) {
const p = join(d, name);
if (name === "vendor") continue;
if (statSync(p).isDirectory()) yield* files(p);
else if (p.endsWith(".jsx")) yield p;
}
}
let bad = 0;
for (const file of files(dir)) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (/^\s*(\/\/|\*)/.test(line)) return;
for (const m of line.matchAll(container)) {
// What runs during render: the part before any function in it.
const body = m[1].split(/=>|\bfunction\b/)[0];
if (read.test(body)) {
console.error(`${file}:${i + 1}: .value read inside JSX; pass the signal, or read it in a computed: ${line.trim()}`);
bad++;
}
}
});
}
process.exit(bad ? 1 : 0);
+172 -63
View File
@@ -10,6 +10,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/netip"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -74,11 +75,19 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings) mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings) mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard) mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
// The Preact client (web/app, built into web/static/app), next to the htmx one.
mux.HandleFunc("GET /app", func(w http.ResponseWriter, r *http.Request) { s.render(w, "spa", nil) })
var handler http.Handler = mux
if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
handler = delayGame(handler, time.Duration(ms)*time.Millisecond)
}
addr := ":" + env("PORT", "3000") addr := ":" + env("PORT", "3000")
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: logRequests(secureHeaders(limitBody(mux))), Handler: logRequests(secureHeaders(limitBody(handler))),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
@@ -120,6 +129,19 @@ func (s *server) render(w http.ResponseWriter, name string, data any) {
} }
} }
// delayGame holds every game request (the htmx page's swaps and actions, the JSON API) for
// d before answering, to try the clients with a slow connection (MM_API_DELAY_MS, dev only).
func delayGame(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if strings.HasPrefix(p, "/act/") || strings.HasPrefix(p, "/api/act/") || p == "/api/state" ||
(p == "/" && r.Header.Get("HX-Request") == "true") {
time.Sleep(d)
}
next.ServeHTTP(w, r)
})
}
func logRequests(next http.Handler) http.Handler { func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
@@ -216,21 +238,35 @@ func (l *rateLimiter) allow(r *http.Request) bool {
} }
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw // clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw
// (the last X-Forwarded-For entry; earlier ones are client-supplied). // (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
if os.Getenv("TRUST_PROXY") == "1" { if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 { if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",") parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" { if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return ip return normalIP(ip)
} }
} }
} }
host, _, err := net.SplitHostPort(r.RemoteAddr) return normalIP(r.RemoteAddr)
if err != nil { }
return r.RemoteAddr
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
// Anything unparsable is kept as it is.
func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
} }
return host ip, err := netip.ParseAddr(s)
if err != nil {
return s
}
ip = ip.Unmap().WithZone("")
if ip.IsLoopback() {
return "loopback"
}
return ip.String()
} }
func serverError(w http.ResponseWriter, err error) { func serverError(w http.ResponseWriter, err error) {
@@ -316,12 +352,16 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return return
} }
state, err := loadState(r.Context(), s.db, userID) state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
page := state.page(viewFromForm(r.FormValue).resolve(state)) page := state.page(v)
if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err)
return
}
page.Banner = s.banner(r.Context()) page.Banner = s.banner(r.Context())
if htmx { if htmx {
page.OOB = true page.OOB = true
@@ -332,21 +372,34 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
s.render(w, "page", page) s.render(w, "page", page)
} }
// handleAction runs a game action and re-renders the screen. The request carries the // actionResult is what runAction did, for the response (HTML regions or JSON).
// current view (s, moon, well, tab), which stays unless the action moves on. type actionResult struct {
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) { Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
ctx := r.Context() Err error // a GameError: the action was refused or broke a rule
view := viewFromForm(r.FormValue) Message string
View View // the view to show next (the request's, unless the action moves on)
BoughtTool string // a tool just bought (the buy modal)
Upgraded string // a tool just upgraded (its toast)
Finished []string // moons a claim mined out
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect)
}
// errUnknownAction is runAction's error for an action that doesn't exist.
var errUnknownAction = errors.New("unknown action")
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads
// the request's parameters, including the current view (s, moon, well, tab), which stays
// unless the action moves on. new-game only passes the gate; the caller creates the player.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get)
var message, boughtTool, upgraded string var message, boughtTool, upgraded string
var finished []string // moons a claim mined out var finished []string
var saleCredits float64 // what a market sale earned (the sell ticket's payout effect) var saleCredits float64
var err error var err error
action := r.PathValue("action")
// Actions of parts of the game not revealed yet are refused (onboarding.go). // Actions of parts of the game not revealed yet are refused (onboarding.go).
if err = actionAllowed(ctx, s.db, userID, action, r.FormValue("redeploy") == "1"); err != nil && !isGameError(err) { if err = actionAllowed(ctx, s.db, userID, action, get("redeploy") == "1"); err != nil && !isGameError(err) {
serverError(w, err) return actionResult{}, err
return
} }
if err != nil { if err != nil {
action = "refused" action = "refused"
@@ -361,45 +414,45 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
case "sell-moon": case "sell-moon":
var name string var name string
var earned float64 var earned float64
if name, earned, err = sellMoon(ctx, s.db, userID, r.FormValue("sell-moon")); err == nil { if name, earned, err = sellMoon(ctx, s.db, userID, get("sell-moon")); err == nil {
message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"} message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"}
} }
case "sell-tool": case "sell-tool":
var name string var name string
var earned float64 var earned float64
if name, earned, err = sellTool(ctx, s.db, userID, r.FormValue("tool")); err == nil { if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)) message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned))
} }
case "sell-attachment": case "sell-attachment":
item := r.FormValue("item") item := get("item")
var earned float64 var earned float64
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil { if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned)) message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
} }
case "buy-tool": case "buy-tool":
id, _ := strconv.Atoi(r.FormValue("id")) id, _ := strconv.Atoi(get("id"))
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil { if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil {
message = "Tool purchased" message = "Tool purchased"
} }
case "upgrade": case "upgrade":
var tool string var tool string
if tool, err = upgradeTool(ctx, s.db, userID, r.FormValue("tool")); err == nil { if tool, err = upgradeTool(ctx, s.db, userID, get("tool")); err == nil {
message, upgraded = "Tool upgraded", tool message, upgraded = "Tool upgraded", tool
view.Drill = followTool(view.Drill, r.FormValue("tool"), tool) view.Drill = followTool(view.Drill, get("tool"), tool)
} }
case "stake": case "stake":
if err = stake(ctx, s.db, userID, r.FormValue("tool"), r.FormValue("target")); err == nil { if err = stake(ctx, s.db, userID, get("tool"), get("target")); err == nil {
message = "Drill deployed" message = "Drill deployed"
} }
case "claim": case "claim":
// Claims every finished drill on the given well or moon (or everywhere); claim-wide // Claims every finished drill on the given well or moon (or everywhere); claim-wide
// claims a moon's drills that mine several of its wells. // claims a moon's drills that mine several of its wells.
scope := ClaimScope{ scope := ClaimScope{
MoonID: r.FormValue("claim-moon"), MoonID: get("claim-moon"),
WellID: r.FormValue("claim-well"), WellID: get("claim-well"),
Redeploy: r.FormValue("redeploy") == "1", Redeploy: get("redeploy") == "1",
} }
if id := r.FormValue("claim-wide"); id != "" { if id := get("claim-wide"); id != "" {
scope.MoonID, scope.Wide = id, true scope.MoonID, scope.Wide = id, true
} }
var res ClaimResult var res ClaimResult
@@ -449,13 +502,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
} }
case "redrill": case "redrill":
var n int var n int
if n, err = redrill(ctx, s.db, userID, r.FormValue("redrill-well")); err == nil { if n, err = redrill(ctx, s.db, userID, get("redrill-well")); err == nil {
message = fmt.Sprintf("Redrilled %s", plural(n, "drill")) message = fmt.Sprintf("Redrilled %s", plural(n, "drill"))
} }
case "sell": case "sell":
resource := r.FormValue("resource") resource := get("resource")
all := r.FormValue("all") == "1" all := get("all") == "1"
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) amount, _ := strconv.ParseFloat(get("amount"), 64)
var earned float64 var earned float64
if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil { if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil {
message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned)) message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned))
@@ -463,15 +516,15 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
} }
case "refine": case "refine":
// Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible. // Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible.
recipe := r.FormValue("recipe") recipe := get("recipe")
qty, _ := strconv.Atoi(r.FormValue("qty")) qty, _ := strconv.Atoi(get("qty"))
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, r.FormValue("all") == "1"); err == nil { if qty, err = startRefine(ctx, s.db, userID, recipe, qty, get("all") == "1"); err == nil {
message = fmt.Sprintf("Refining %d %s", qty, recipe) message = fmt.Sprintf("Refining %d %s", qty, recipe)
} }
case "collect": case "collect":
// Collects one finished batch (job), or all of them. // Collects one finished batch (job), or all of them.
var done []RefineryJob var done []RefineryJob
if done, err = collectRefinery(ctx, s.db, userID, r.FormValue("job")); err == nil { if done, err = collectRefinery(ctx, s.db, userID, get("job")); err == nil {
if len(done) == 1 { if len(done) == 1 {
message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item) message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item)
} else { } else {
@@ -484,14 +537,14 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
message = fmt.Sprintf("Refinery slot %d online", n) message = fmt.Sprintf("Refinery slot %d online", n)
} }
case "buy-attachment": case "buy-attachment":
item := r.FormValue("item") item := get("item")
if err = buyAttachment(ctx, s.db, userID, item); err == nil { if err = buyAttachment(ctx, s.db, userID, item); err == nil {
message = item + " added to your inventory" message = item + " added to your inventory"
} }
case "equip", "unequip": case "equip", "unequip":
// Puts an attachment (item, by name) on a tool (to), or takes it off. // Puts an attachment (item, by name) on a tool (to), or takes it off.
// A busy tool's idle copy may get it instead; the panel follows that one. // A busy tool's idle copy may get it instead; the panel follows that one.
item, tool := r.FormValue("item"), r.FormValue("to") item, tool := get("item"), get("to")
var used string var used string
if action == "equip" { if action == "equip" {
used, err = equipAttachment(ctx, s.db, userID, item, tool) used, err = equipAttachment(ctx, s.db, userID, item, tool)
@@ -504,58 +557,106 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
view.Drill = followTool(view.Drill, tool, used) view.Drill = followTool(view.Drill, tool, used)
} }
case "hire": case "hire":
name := r.FormValue("advisor") name := get("advisor")
if err = hireAdvisor(ctx, s.db, userID, name); err == nil { if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " hired" message = name + " hired"
} }
case "seat-advisor":
// Puts a hired advisor (advisor) in an empty seat (seat, from 1).
name := get("advisor")
seat, _ := strconv.Atoi(get("seat"))
if err = seatAdvisor(ctx, s.db, userID, name, seat); err == nil {
message = fmt.Sprintf("%s takes seat %d", name, seat)
}
case "unseat-advisor":
name := get("advisor")
if err = unseatAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " left their seat"
}
case "swap-advisors":
// Exchanges the seats of two hired advisors (advisor, with).
name, other := get("advisor"), get("with")
if err = swapAdvisors(ctx, s.db, userID, name, other); err == nil {
message = fmt.Sprintf("%s and %s swapped seats", name, other)
}
case "dismiss-advisor":
name := get("advisor")
if err = dismissAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " dismissed"
}
case "buy-seat": case "buy-seat":
var n int var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil { if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Advisor seat %d added", n) message = fmt.Sprintf("Advisor seat %d added", n)
} }
case "new-game": case "new-game":
default:
return actionResult{}, errUnknownAction
}
if err != nil && !isGameError(err) {
return actionResult{}, err
}
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool,
Upgraded: upgraded, Finished: finished, SaleCredits: saleCredits}, nil
}
// toast is the action's toast, for the state after it.
func (res actionResult) toast(state *State) *Toast {
if res.Err != nil {
return &Toast{Message: res.Err.Error(), Error: true, Kind: "error"}
}
toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
if t := state.tool(res.Upgraded); t != nil {
toast.Message = state.upgradedText(*t)
}
return toast
}
// handleAction runs a game action and re-renders the screen.
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
if errors.Is(err, errUnknownAction) {
http.NotFound(w, r)
return
}
if err != nil {
serverError(w, err)
return
}
if res.Action == "new-game" {
if !s.newPlayers.allow(r) { if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests) http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return return
} }
var newID string newID, err := createUser(ctx, s.db, newPlayerName())
if newID, err = createUser(ctx, s.db, newPlayerName()); err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
setUserCookie(w, newID) setUserCookie(w, newID)
w.Header().Set("HX-Redirect", "/") w.Header().Set("HX-Redirect", "/")
return return
default:
http.NotFound(w, r)
return
} }
toast := &Toast{Message: message, Kind: action, Finished: strings.Join(finished, ","), Credits: saleCredits} state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
if !isGameError(err) {
serverError(w, err)
return
}
toast = &Toast{Message: err.Error(), Error: true, Kind: "error"}
}
state, err := loadState(ctx, s.db, userID)
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
} }
if t := state.tool(upgraded); t != nil && !toast.Error { toast := res.toast(state)
toast.Message = state.upgradedText(*t) page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
} }
page := state.page(view.resolve(state))
page.Banner = s.banner(ctx) page.Banner = s.banner(ctx)
page.OOB = true page.OOB = true
page.Toast = toast page.Toast = toast
if boughtTool != "" { if res.BoughtTool != "" {
page.Modal = state.buySuggestions(boughtTool) page.Modal = state.buySuggestions(res.BoughtTool)
} }
if action == "buy-moon" && !toast.Error { if res.Action == "buy-moon" && !toast.Error {
page.Reveal = page.moonReveal() page.Reveal = page.moonReveal()
} }
s.render(w, "regions", page) s.render(w, "regions", page)
@@ -590,6 +691,14 @@ func (s *server) handleUtilsAction(w http.ResponseWriter, r *http.Request, userI
fmt.Fprint(w, "<p>Done.</p>") fmt.Fprint(w, "<p>Done.</p>")
} }
// loadRanking fills in the leaderboard section, the one section read from other players.
func (s *server) loadRanking(ctx context.Context, page *Page) (err error) {
if page.V.Section == "leaderboard" {
page.Ranking, err = loadLeaderboard(ctx, s.db, page.S.UserID)
}
return err
}
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) { func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
limit, err := strconv.Atoi(r.FormValue("limit")) limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil || limit <= 0 || limit > 100 { if err != nil || limit <= 0 || limit > 100 {
+18
View File
@@ -0,0 +1,18 @@
-- Advisors sit in numbered seats (advisors.go); seat null is hired but not seated. Hires
-- from before take seats 1, 2, ... in hiring order, once: only when the column is added,
-- so advisors unseated since stay unseated when this runs again.
do $$
begin
if not exists (select 1 from information_schema.columns
where table_name = 'advisor_hire' and column_name = 'seat') then
alter table advisor_hire add column seat smallint;
update advisor_hire set seat = numbered.n
from (select user_id, advisor, row_number() over (partition by user_id order by hired_at, advisor) as n
from advisor_hire) numbered
where advisor_hire.user_id = numbered.user_id and advisor_hire.advisor = numbered.advisor;
end if;
end
$$;
create unique index if not exists advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+4 -1
View File
@@ -237,10 +237,13 @@ create index attachment_user on attachment (user_id);
create unique index attachment_once on attachment (inventory_item_id, item) create unique index attachment_once on attachment (inventory_item_id, item)
where inventory_item_id is not null; where inventory_item_id is not null;
-- Advisors hired (advisors.go), by config name. -- Advisors hired (advisors.go), by config name. Seated ones (seat 1..users.advisor_seats)
-- give their bonus; seat null: hired but not seated.
create table advisor_hire ( create table advisor_hire (
user_id uuid not null references users(id) on delete cascade, user_id uuid not null references users(id) on delete cascade,
advisor text not null, advisor text not null,
hired_at timestamp with time zone not null default now(), hired_at timestamp with time zone not null default now(),
seat smallint,
primary key (user_id, advisor) primary key (user_id, advisor)
); );
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+55 -3
View File
@@ -32,10 +32,15 @@ func TestTemplates(t *testing.T) {
unlocked := newPlayerState() // everything open but no moon yet unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"} unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} { for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) { for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s) v = v.resolve(s)
p := s.page(v) p := s.page(v)
if v.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
}
render("page", p) render("page", p)
// An action response: every region out of band, with a toast, the buy modal // An action response: every region out of band, with a toast, the buy modal
// and the moon reveal. // and the moon reveal.
@@ -49,12 +54,16 @@ func TestTemplates(t *testing.T) {
render("regions", p) render("regions", p)
} }
// The buy modal with no moons or only empty wells. // The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s)) p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true} p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
render("regions", p) render("regions", p)
} }
// The Preact client's page (/app).
render("spa", nil)
// Admin pages and the pages outside the game, with and without permission. // Admin pages and the pages outside the game, with and without permission.
s := veteranState() s := veteranState()
now := time.Now() now := time.Now()
@@ -162,6 +171,18 @@ func testViews(s *State) []View {
for _, r := range s.Refined { for _, r := range s.Refined {
views = append(views, View{Section: "market", Pick: r.Item}) views = append(views, View{Section: "market", Pick: r.Item})
} }
for _, h := range s.Hires {
views = append(views, View{Section: "advisors", Adv: h.Name})
}
// The Past tab, its pages (one past the last: clamped) and a past moon from another page.
for page := range 4 {
views = append(views, View{Section: "moons", Past: true, Page: page})
}
for _, m := range s.Moons {
if m.Finished {
views = append(views, View{Section: "moons", Past: true, Page: 2, Moon: m.ID})
}
}
return views return views
} }
@@ -261,9 +282,40 @@ func veteranState() *State {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name} s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
s.Spare[c.Attachments.Items[1].Name] = 2 s.Spare[c.Attachments.Items[1].Name] = 2
} }
if len(c.Advisors.Items) > 0 { // Advisors: one seated, one hired but not seated, a free seat.
if len(c.Advisors.Items) > 1 {
s.Hires = []AdvisorHire{{Name: c.Advisors.Items[0].Name, Seat: 1}, {Name: c.Advisors.Items[1].Name}}
s.Advisors = []string{c.Advisors.Items[0].Name} s.Advisors = []string{c.Advisors.Items[0].Name}
s.AdvisorSeats = 1 s.AdvisorSeats = 2
} }
// More past moons than fit a page (pastPerPage).
for n := range pastPerPage + 3 {
s.Moons = append(s.Moons, Moon{ID: testID("p", n), Name: fmt.Sprintf("Past %d", n), Kind: "Old",
Wells: []Well{{ID: testID("y", n), Resource: wells[0].Resource}}, CreatedAt: now.Add(-72 * time.Hour),
Finished: true, FinishedAt: now.Add(-time.Duration(n+2) * time.Hour),
Mined: []ResourceBalance{{Resource: wells[0].Resource, Balance: float64(n)}}})
}
s.pagePastInMemory(1)
return s
}
// fullBoardState is a veteran whose seats are all taken, with advisors waiting for one
// (and one no longer in the config).
func fullBoardState() *State {
c := cfg()
s := veteranState()
s.UserID = testID("u", 3)
s.Hires, s.Advisors = nil, nil
s.AdvisorSeats = 1 + len(c.Advisors.SeatPrices)
for i, a := range c.Advisors.Items {
seat := i + 1
if seat > s.AdvisorSeats {
seat = 0
} else {
s.Advisors = append(s.Advisors, a.Name)
}
s.Hires = append(s.Hires, AdvisorHire{Name: a.Name, Seat: seat})
}
s.Hires = append(s.Hires, AdvisorHire{Name: "Retired Advisor"})
return s return s
} }
+144 -2
View File
@@ -14,13 +14,23 @@ Content-Type: application/x-www-form-urlencoded
userId=:user_id userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|help|new-moon, moon=<id>, well=<id>, # A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's attachment panel), # drill=<tool id> (equipment: the tool's attachment panel),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab, # pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows) # the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
GET :host/?s=store GET :host/?s=store
:headers :headers
# The Preact client (web/app): a page that runs on the JSON API below; same view params
GET :host/app?s=moons
GET :host/?list=past&page=2
:headers
GET :host/?s=advisors&adv=Ada+Voss
:headers
# Buy a moon # Buy a moon
POST :host/act/buy-moon POST :host/act/buy-moon
:headers :headers
@@ -118,6 +128,26 @@ advisor=Mira+Castellanos
POST :host/act/buy-seat POST :host/act/buy-seat
:headers :headers
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
POST :host/act/seat-advisor
:headers
advisor=Mira+Castellanos&seat=2
# Take a seated advisor off their seat (stays hired, no bonus)
POST :host/act/unseat-advisor
:headers
advisor=Mira+Castellanos
# Swap two hired advisors' seats (one may be unseated)
POST :host/act/swap-advisors
:headers
advisor=Mira+Castellanos&with=Ada+Voss
# Dismiss a hired advisor (frees their seat, nothing refunded)
POST :host/act/dismiss-advisor
:headers
advisor=Mira+Castellanos
# Start over as a new player (sets a new cookie) # Start over as a new player (sets a new cookie)
POST :host/act/new-game POST :host/act/new-game
:headers :headers
@@ -125,6 +155,118 @@ POST :host/act/new-game
# Leaderboard (JSON): [{"name", "balance"}], richest first # Leaderboard (JSON): [{"name", "balance"}], richest first
GET :host/api/leaderboard?limit=10 GET :host/api/leaderboard?limit=10
# JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie);
# without a known one a player is created and its id comes back in X-MM-User and "userId".
# Game state for a view (same params as GET /): {ok, userId, newPlayer?, state}
GET :host/api/state?moon=<moon id>&well=<well id>
X-MM-User: :user_id
# Run an action (any /act/<action> above, same params, form or JSON body):
# {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state
POST :host/api/act/stake
X-MM-User: :user_id
Content-Type: application/json
{"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"}
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as for /act/claim)
POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/x-www-form-urlencoded
# Sell resources (amount=<n> or all=1)
POST :host/api/act/sell
X-MM-User: :user_id
Content-Type: application/json
{"resource": "Sollux", "all": true}
# Section blocks: s=<section> adds that section's block (equipment with drill=<tool id>,
# store, refinery, advisors, leaderboard, help, new-moon, market with the chart);
# include=<sections>|all adds others
GET :host/api/state?s=equipment&drill=<tool id>&include=store,refinery
X-MM-User: :user_id
GET :host/api/state?s=moons&include=all
X-MM-User: :user_id
# Skip the catalog: catalog=0, or catalog=<state.catalogVersion> (left out while unchanged);
# the same param works on /api/act/<action>
GET :host/api/state?catalog=0
X-MM-User: :user_id
GET :host/api/state?catalog=<catalog version>
X-MM-User: :user_id
POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/json
{"catalog": 0, "clientSeq": 2}
# Other sections' actions (clientSeq is echoed back): buy-tool (id), upgrade (tool),
# sell-tool (tool), buy-attachment (item), equip/unequip (item, to), sell-attachment (item),
# refine (recipe, qty or all), collect (job or none), buy-slot, hire (advisor), buy-seat,
# seat-advisor (advisor, seat), unseat-advisor (advisor), swap-advisors (advisor, with),
# dismiss-advisor (advisor), sell-moon (sell-moon)
POST :host/api/act/buy-tool
X-MM-User: :user_id
Content-Type: application/json
{"id": 1, "s": "store", "clientSeq": 1}
POST :host/api/act/equip
X-MM-User: :user_id
Content-Type: application/json
{"item": "Turbo Core", "to": "<tool id>", "s": "equipment", "drill": "<tool id>"}
POST :host/api/act/refine
X-MM-User: :user_id
Content-Type: application/json
{"recipe": "Dusk Alloy", "qty": 3, "s": "refinery"}
POST :host/api/act/hire
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/seat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "seat": 2, "s": "advisors"}
POST :host/api/act/unseat-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
POST :host/api/act/swap-advisors
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "with": "Mira Castellanos", "s": "advisors"}
POST :host/api/act/dismiss-advisor
X-MM-User: :user_id
Content-Type: application/json
{"advisor": "Ada Voss", "s": "advisors"}
# Past moons, a page at a time (state.pastPage)
GET :host/api/state?list=past&page=2
X-MM-User: :user_id
POST :host/api/act/sell-moon
X-MM-User: :user_id
Content-Type: application/json
{"sell-moon": "<moon id>", "moon": "<moon id>"}
# Admin: sync the store items in the database with the game config (admin players only) # Admin: sync the store items in the database with the game config (admin players only)
POST :host/utils/load-config POST :host/utils/load-config
:headers :headers
+94 -19
View File
@@ -138,21 +138,24 @@ func formatCycle(secs int) string {
// ------------------------------------------------------------------ view // ------------------------------------------------------------------ view
// View is what the player is looking at, carried in the URL: // View is what the player is looking at, carried in the URL:
// /?s=<section>&moon=<id>&well=<id>&list=past. // /?s=<section>&moon=<id>&well=<id>&list=past&page=<n>.
type View struct { type View struct {
Section string // moons, equipment, market, store, refinery, advisors, help, new-moon Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon
Moon string Moon string
Past bool // the player opened the moon list's Past tab: a past moon may be on screen Past bool // the player opened the moon list's Past tab: a past moon may be on screen
Page int // the page of past moons (1: the most recent), from page=
Well string Well string
Pick string // the selected resource or refined item (market section) Pick string // the selected resource or refined item (market section)
Drill string // the selected tool (equipment section) Drill string // the selected tool (equipment section)
Adv string // the selected hired advisor (advisors section), from adv=
} }
var sections = map[string]bool{"moons": true, "equipment": true, "market": true, "store": true, "help": true, "refinery": true, "advisors": true, "new-moon": true} var sections = map[string]bool{"moons": true, "equipment": true, "market": true, "store": true, "help": true, "refinery": true, "advisors": true, "leaderboard": true, "new-moon": true}
func viewFromForm(get func(string) string) View { func viewFromForm(get func(string) string) View {
page, _ := strconv.Atoi(get("page"))
return View{Section: get("s"), Moon: get("moon"), Well: get("well"), Pick: get("pick"), return View{Section: get("s"), Moon: get("moon"), Well: get("well"), Pick: get("pick"),
Drill: get("drill"), Past: get("list") == "past"} Drill: get("drill"), Past: get("list") == "past", Page: page, Adv: get("adv")}
} }
// resolve fills in defaults and drops ids that don't belong to the player. // resolve fills in defaults and drops ids that don't belong to the player.
@@ -170,13 +173,9 @@ func (v View) resolve(s *State) View {
if moon != nil && moon.Finished != v.Past { if moon != nil && moon.Finished != v.Past {
moon = nil moon = nil
} }
if moon == nil && v.Past { if moon == nil && v.Past && len(s.PastIDs) > 0 {
// The Past tab: the last moon finished. // The Past tab: the last moon finished (on its page).
for i := range s.Moons { moon = s.moon(s.PastIDs[0])
if s.Moons[i].Finished && (moon == nil || s.Moons[i].FinishedAt.After(moon.FinishedAt)) {
moon = &s.Moons[i]
}
}
} }
if moon == nil { if moon == nil {
v.Past = false v.Past = false
@@ -201,6 +200,10 @@ func (v View) resolve(s *State) View {
v.Drill = "" v.Drill = ""
} }
v.Drill = s.idleCopy(v.Drill) v.Drill = s.idleCopy(v.Drill)
if v.Section != "advisors" || s.hire(v.Adv) == nil {
v.Adv = ""
}
v.Page = s.PastPage
return v return v
} }
@@ -214,6 +217,9 @@ func (v View) URL() string {
} }
if v.Past && v.Section == "moons" { if v.Past && v.Section == "moons" {
q.Set("list", "past") q.Set("list", "past")
if v.Page > 1 {
q.Set("page", strconv.Itoa(v.Page))
}
} }
if v.Well != "" { if v.Well != "" {
q.Set("well", v.Well) q.Set("well", v.Well)
@@ -224,6 +230,9 @@ func (v View) URL() string {
if v.Drill != "" { if v.Drill != "" {
q.Set("drill", v.Drill) q.Set("drill", v.Drill)
} }
if v.Adv != "" {
q.Set("adv", v.Adv)
}
if len(q) == 0 { if len(q) == 0 {
return "/" return "/"
} }
@@ -239,15 +248,20 @@ func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL() return View{Section: "moons", Moon: id}.URL()
} }
// ToPastMoon opens a finished moon from the Past tab. // ToPastMoon opens a finished moon from the Past tab (on the current page of it).
func (v View) ToPastMoon(id string) string { func (v View) ToPastMoon(id string) string {
return View{Section: "moons", Moon: id, Past: true}.URL() return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
} }
func (v View) ToWell(id string) string { func (v View) ToWell(id string) string {
return View{Section: "moons", Moon: v.Moon, Well: id}.URL() return View{Section: "moons", Moon: v.Moon, Well: id}.URL()
} }
// ToAdvisor opens a hired advisor's panel in the advisors section.
func (v View) ToAdvisor(name string) string {
return View{Section: "advisors", Moon: v.Moon, Adv: name}.URL()
}
// ToDrill opens a tool's panel in the equipment section. // ToDrill opens a tool's panel in the equipment section.
func (v View) ToDrill(id string) string { func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL() return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
@@ -285,6 +299,7 @@ type Page struct {
Gear []AttachmentCard // store section (attachments.go) Gear []AttachmentCard // store section (attachments.go)
Drill *DrillPanel // equipment section, with a tool selected Drill *DrillPanel // equipment section, with a tool selected
Advisors *AdvisorsPage // advisors section (advisors.go) Advisors *AdvisorsPage // advisors section (advisors.go)
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
NewMoon *NewMoonTeaser // new-moon section (reveal.go) NewMoon *NewMoonTeaser // new-moon section (reveal.go)
Reveal *MoonReveal // the moon just bought (reveal.go) Reveal *MoonReveal // the moon just bought (reveal.go)
} }
@@ -350,10 +365,28 @@ func (p *Page) ActiveURL() string {
} }
func (p *Page) PastURL() string { func (p *Page) PastURL() string {
if len(p.Past) == 0 { if p.S.PastTotal == 0 {
return "" return ""
} }
return p.V.ToPastMoon(p.Past[0].ID) return View{Section: "moons", Past: true}.URL()
}
// PastPaging is the Past tab's paging: the page shown, how many there are, and links to
// the newer and older pages ("" at either end).
type PastPaging struct {
Page, Pages, Total int
Newer, Older string
}
func (p *Page) PastPaging() PastPaging {
pg := PastPaging{Page: p.S.PastPage, Pages: p.S.pastPages(), Total: p.S.PastTotal}
if pg.Page > 1 {
pg.Newer = View{Section: "moons", Past: true, Page: pg.Page - 1}.URL()
}
if pg.Page < pg.Pages {
pg.Older = View{Section: "moons", Past: true, Page: pg.Page + 1}.URL()
}
return pg
} }
type MoonDetail struct { type MoonDetail struct {
@@ -741,7 +774,9 @@ func (s *State) page(v View) *Page {
row.Pending = endsAttr(pending) row.Pending = endsAttr(pending)
p.Ready += row.Ready p.Ready += row.Ready
if m.Finished { if m.Finished {
p.Past = append(p.Past, row) if slices.Contains(s.PastIDs, m.ID) {
p.Past = append(p.Past, row)
}
} else { } else {
p.Moons = append(p.Moons, row) p.Moons = append(p.Moons, row)
} }
@@ -751,7 +786,10 @@ func (s *State) page(v View) *Page {
} }
p.Pending = endsAttr(allPending) p.Pending = endsAttr(allPending)
p.PastView = v.Past p.PastView = v.Past
sort.SliceStable(p.Past, func(i, j int) bool { return p.Past[i].FinishedAt.After(p.Past[j].FinishedAt) }) // In the page's order (most recently finished first).
sort.SliceStable(p.Past, func(i, j int) bool {
return slices.Index(s.PastIDs, p.Past[i].ID) < slices.Index(s.PastIDs, p.Past[j].ID)
})
switch v.Section { switch v.Section {
case "moons": case "moons":
@@ -896,7 +934,14 @@ func (s *State) page(v View) *Page {
p.Refinery = s.refineryPage() p.Refinery = s.refineryPage()
case "advisors": case "advisors":
p.Advisors = s.advisorsPage() p.Advisors = s.advisorsPage(v)
// The advisor panel goes in the side panel, under the current moon.
if p.Advisors.Panel != nil {
p.Scene.Select = map[string]string{}
if v.Moon != "" {
p.Scene.Select["moonId"] = v.Moon
}
}
} }
return p return p
} }
@@ -1349,6 +1394,31 @@ func (r WellRow) Full() bool {
return r.Supply > 0 && (r.Demand > 0 && r.Demand >= r.Supply-1e-9 || r.Remain <= 1e-9 && r.Taken() > 0) return r.Supply > 0 && (r.Demand > 0 && r.Demand >= r.Supply-1e-9 || r.Remain <= 1e-9 && r.Taken() > 0)
} }
// StatusText is the well note's lead (template "well-note"): "Over-drilled — next claims
// will come up short", "Fully committed" or "".
func (r WellRow) StatusText() string {
switch {
case r.Over():
return "Over-drilled — next claims will come up short"
case r.Full():
return "Fully committed"
}
return ""
}
// NoteText is the rest of the well note, as the template words it: "47 left · 2 committed ·
// 45 free after" or "… · runs dry after 3 claims"; "" when no drill wants anything from it.
func (r WellRow) NoteText() string {
if r.Demand == 0 {
return ""
}
t := formatMoney(r.Supply) + " left · " + formatMoney(r.Demand) + " committed"
if r.DryAfter > 0 {
return t + " · runs dry after " + plural(r.DryAfter, "claim")
}
return t + " · " + formatMoney(r.Remain) + " free after"
}
// BarStack is a wells table row's timer bars (template "drill-stack"): the first drills, // BarStack is a wells table row's timer bars (template "drill-stack"): the first drills,
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page // oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill). // (the morph matches them per drill).
@@ -1386,7 +1456,12 @@ func (r StakeRow) Code() string {
if r.Item == nil { if r.Item == nil {
return "Tool" return "Tool"
} }
f := strings.Fields(r.Item.Name) return toolCode(r.Item.Name)
}
// toolCode is a tool's model code, the last word of its name ("PD-1").
func toolCode(name string) string {
f := strings.Fields(name)
if len(f) == 0 { if len(f) == 0 {
return "Tool" return "Tool"
} }
+22
View File
@@ -0,0 +1,22 @@
// Buttons that change state in place (never replaced while they animate).
import { useRef, useLayoutEffect } from "preact/hooks";
import { busy, clockCaused } from "./session.js";
// A Claim button: disabled until `ready`. When the clock (not a new state) makes it ready,
// it fires "mm:ready" (the classic client's fx.js plays the ready sound, pop and sparks).
// busyKey: shows the spinner while an unpredicted action with that key is on its way.
export function ReadyButton({ ready = true, busyKey = "", class: cls, children, ...rest }) {
const ref = useRef(null);
const was = useRef(ready);
useLayoutEffect(() => {
if (ready && !was.current && clockCaused()) ref.current?.dispatchEvent(new CustomEvent("mm:ready", { bubbles: true }));
was.current = ready;
}, [ready]);
const spinning = busyKey && busy.value[busyKey];
return (
<button ref={ref} type="button" data-claim class={`${cls}${spinning ? " htmx-request" : ""}`} disabled={!ready} {...rest}>
{children}
</button>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Lookups in the static catalog (state.catalog) and the player's tools.
import { base } from "./session.js";
export const colorOf = (resource) =>
base.peek()?.catalog?.resources?.find((r) => r.name === resource)?.color ??
base.peek()?.catalog?.refined?.find((r) => r.name === resource)?.color ??
"#e2e8f0";
export const toolOf = (st, id) => st.tools.find((t) => t.id === id);
export const imageOf = (st, storeItemId) => st.catalog?.tools?.find((t) => t.storeItemId === storeItemId)?.image ?? "";
+105
View File
@@ -0,0 +1,105 @@
// The server's clock, the frame loop and eased numbers (display only).
//
// - now(): the server's clock in unix ms (its state.now plus the time since the response).
// - edge: a signal bumped when a drill's end passes (scheduled with a timer for the next
// one), so readiness-dependent computeds re-run then and not every frame.
// - The frame loop runs tickers (countdown texts, eased numbers) registered by components;
// they write textContent directly, so no signal changes per frame.
// - Eased numbers (like client/modules/tween_numbers.lua): a value keyed by name ("credits",
// "res:Sollux") that moves from what is shown to its new value over DURATION instead of
// snapping, whether the change came from a prediction or the server correcting one.
import { signal } from "@preact/signals";
let offset = 0; // server clock minus ours
export const now = () => Date.now() + offset;
export function syncClock(serverNow, receivedAt) {
offset = serverNow - receivedAt;
}
// ---------------------------------------------------------------- edges
export const edge = signal(0);
// When the last edge passed (performance.now()).
export let lastEdge = -Infinity;
let edgeTimer = 0;
let edgeAt = Infinity;
// Wake at the soonest of these ends (unix ms, server clock).
export function armEdge(ends) {
const t = now();
let next = Infinity;
for (const e of ends) if (e > t && e < next) next = e;
if (next === edgeAt) return;
clearTimeout(edgeTimer);
edgeAt = next;
if (next === Infinity) return;
edgeTimer = setTimeout(() => {
edgeAt = Infinity;
lastEdge = performance.now();
edge.value++;
}, Math.max(0, next - t) + 1);
}
// ---------------------------------------------------------------- frame loop
const tickers = new Set();
let frame = 0;
export function addTicker(fn) {
tickers.add(fn);
fn(performance.now());
if (!frame) frame = requestAnimationFrame(loop);
return () => tickers.delete(fn);
}
function loop(t) {
for (const fn of tickers) fn(t);
frame = tickers.size ? requestAnimationFrame(loop) : 0;
}
// ---------------------------------------------------------------- eased numbers
export const DURATION = 420;
const ease = (k) => 1 - Math.pow(1 - k, 3); // out cubic
const vals = new Map(); // key -> {shown, from, to, t0, hold}
// The value to show for key now, given its true value (which may have just changed).
export function eased(key, truth, t = performance.now()) {
let e = vals.get(key);
if (!e) {
e = { shown: truth, from: truth, to: truth, t0: 0, hold: false };
vals.set(key, e);
return truth;
}
if (e.hold) return e.shown;
if (e.to !== truth) {
e.from = e.shown;
e.to = truth;
e.t0 = t;
}
const k = Math.min(1, (t - e.t0) / DURATION);
e.shown = k >= 1 ? e.to : e.from + (e.to - e.from) * ease(k);
return e.shown;
}
export const moving = (key) => {
const e = vals.get(key);
return !!e && (e.hold || e.shown !== e.to);
};
// Keep showing key's current value until release (a claim's resources wait for their
// particles to land in the wallet).
export function hold(key) {
const e = vals.get(key);
if (e) e.hold = true;
}
export function release(key) {
const e = vals.get(key);
if (!e || !e.hold) return;
e.hold = false;
e.from = e.shown;
e.t0 = performance.now();
e.to = NaN; // restart from what is shown towards the truth on the next frame
}
+41
View File
@@ -0,0 +1,41 @@
// The confirm dialog: the classic client's markup (admin.html "confirm-dialog"), opened and
// closed by its ui.js (closing animation, Escape, backdrop) with its sounds.
import { useRef, useLayoutEffect } from "preact/hooks";
import { ask, wireDialog } from "/static/js/ui.js";
import * as sfx from "/static/js/sfx.js";
let dialog = null;
export function ConfirmDialog() {
const ref = useRef(null);
useLayoutEffect(() => {
dialog = ref.current;
wireDialog(dialog, { onClose: (value) => sfx.play(value === "ok" ? "confirm" : "close") });
}, []);
return (
<dialog ref={ref} id="confirm" class="dialog frame" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title" class="dialog-title">
Confirm
</h2>
<p class="dialog-message" />
<div class="dialog-actions">
<button value="cancel" class="btn">
Cancel
</button>
<button value="ok" class="btn btn-primary" autofocus>
Confirm
</button>
</div>
</form>
</dialog>
);
}
// Asks the API's confirm ({title, text, ok, danger}); resolves true on OK.
export function confirm(c) {
if (!dialog) return Promise.resolve(false);
sfx.play("open");
return ask(dialog, { message: c.text, title: c.title, ok: c.ok, danger: c.danger });
}
+34
View File
@@ -0,0 +1,34 @@
// FLIP for rows that move: children with data-key slide from where they were to where a
// render put them (transform only, Web Animations API). New children don't move.
// Positions are relative to the container, so scrolling or the container moving doesn't
// count as a move.
import { useRef, useLayoutEffect } from "preact/hooks";
export function useFlip(duration = 260) {
const ref = useRef(null);
const last = useRef(new Map());
useLayoutEffect(() => {
const box = ref.current;
if (!box) return;
const next = new Map();
const b = box.getBoundingClientRect();
for (const el of box.querySelectorAll(":scope > [data-key]")) {
const e = el.getBoundingClientRect();
const r = { left: e.left - b.left, top: e.top - b.top };
next.set(el.dataset.key, r);
const was = last.current.get(el.dataset.key);
if (!was) continue;
const dx = was.left - r.left;
const dy = was.top - r.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], {
duration,
easing: "cubic-bezier(.2,.8,.3,1)",
composite: "add",
});
}
last.current = next;
});
return ref;
}
+69
View File
@@ -0,0 +1,69 @@
// The web's formats (views.go, api.md "Texts").
export const currency = "₥";
// formatMoney in views.go: thousands commas, up to 2 decimals.
export function money(n) {
const v = Math.round(Math.abs(n) * 100) / 100;
const [whole, frac] = v.toFixed(2).split(".");
const f = frac.replace(/0+$/, "");
return (n < 0 && v !== 0 ? "-" : "") + whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (f ? "." + f : "");
}
export const cur = (n) => currency + money(n);
// money with a fixed number of decimals (a number mid-count keeps its width).
export function fixed(n, digits) {
const v = Math.abs(n).toFixed(digits);
const [whole, frac] = v.split(".");
return (n < 0 && Number(v) !== 0 ? "-" : "") + whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (frac ? "." + frac : "");
}
export const decimals = (n) => (money(n).split(".")[1] ?? "").length;
// Countdowns: m:ss, h:mm:ss.
export function left(ms) {
const total = Math.ceil(Math.max(0, ms) / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
// Cycles: 3s, 2m, 2m 30s.
export function secs(n) {
n = Math.round(n);
if (n < 60) return `${n}s`;
return n % 60 === 0 ? `${n / 60}m` : `${Math.floor(n / 60)}m ${n % 60}s`;
}
export const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
// A moon thumbnail's hue from its id (FNV-1a, like views.go "planet").
export function hue(id) {
let h = 0x811c9dc5;
for (const b of new TextEncoder().encode(id)) {
h ^= b;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h % 360;
}
// The tool's model code, the last word of its name ("PD-1").
export const code = (name) => name?.trim().split(/\s+/).pop() || "Tool";
// A row's drills, compactly ("PD-1 T1", "PD-1 ×3", "PD-1 ×3 +1"), like drillLabel in views.go.
export function drillLabel(drills, toolOf) {
if (!drills.length) return "";
const codeOf = (d) => toolOf(d.toolId)?.code ?? "Tool";
if (drills.length === 1) return `${codeOf(drills[0])} T${toolOf(drills[0].toolId)?.tier ?? 0}`;
const count = new Map();
let top = codeOf(drills[0]);
for (const d of drills) {
const c = codeOf(d);
count.set(c, (count.get(c) ?? 0) + 1);
if (count.get(c) > count.get(top)) top = c;
}
const rest = drills.length - count.get(top);
return `${top} ×${count.get(top)}${rest > 0 ? ` +${rest}` : ""}`;
}
+93
View File
@@ -0,0 +1,93 @@
// Effects for actions, played at the click from the predicted change (the server's answer
// only corrects the numbers). The particles, floating numbers and sounds are the classic
// client's (web/static/js/fx.js, sfx.js).
import { hooks } from "./session.js";
import { hold, release } from "./clock.js";
import { burst, floatText, stream, mark, centerOf, formatMoney } from "/static/js/fx.js";
import * as sfx from "/static/js/sfx.js";
const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const CYAN = () => token("--cyan", "#5fd4ff");
const CYAN_HI = () => token("--cyan-hi", "#b3ecff");
const GOLD = () => token("--ready", "#ffd166");
const GOLD_HI = () => token("--ready-hi", "#fff1c2");
const amounts = (st) => new Map((st?.resources ?? []).map((r) => [r.name, r.amount]));
const colorOf = (st, name) => st?.catalog?.resources?.find((r) => r.name === name)?.color ?? CYAN();
hooks.onAction = (kind, prev, next, at) => {
const from = at ?? centerOf(document.getElementById("main"));
if (kind === "claim") {
const before = amounts(prev);
const gains = [];
for (const [name, v] of amounts(next)) {
const d = v - (before.get(name) ?? 0);
if (d > 0) gains.push({ name, d, color: colorOf(next, name) });
}
if (!gains.length) return;
sfx.claim();
burst(from.x, from.y, gains.map((g) => g.color), 46);
gains.forEach((g, i) => {
const key = `res:${g.name}`;
hold(key);
floatText(`+${formatMoney(g.d)} ${g.name}`, from.x, from.y - 10 - i * 30, g.color, i * 90);
const chip = document.querySelector(`[data-chip="${CSS.escape(g.name)}"]`);
const to = centerOf(chip);
stream(from, to, g.color, 14, 120 + i * 90, () => {
release(key);
sfx.arrive(i);
const num = chip?.querySelector(".num");
if (num) mark(num, "fx-gain");
burst(to.x, to.y, [g.color], 14, 0.5);
});
});
// Claim and redeploy: the new drills too.
flashNewDrills(prev, next);
return;
}
if (kind === "stake" || kind === "redrill") {
sfx.play("deploy");
burst(from.x, from.y, [CYAN(), CYAN_HI()], 26);
flashNewDrills(prev, next);
return;
}
// A moon sold (answered, not predicted): a gold ring where it was offered, the credits
// counting up in the wallet.
if (kind === "sell-moon") {
sfx.play("shimmer");
burst(from.x, from.y, [GOLD(), GOLD_HI(), CYAN_HI()], 60, 1.2);
const credits = (next?.credits ?? 0) - (prev?.credits ?? 0);
if (credits > 0) {
sfx.cash(credits);
floatText(`+₥${formatMoney(credits)}`, from.x, from.y - 10, GOLD());
}
}
};
// What only the server knows: a claim that mined a moon out (its entry leaves for the
// Past tab).
hooks.onAnswer = (kind, data) => {
if (!data?.fx?.finished?.length) return;
sfx.minedOut(450);
requestAnimationFrame(() => mark(document.querySelector("#moonlist [data-past-tab]"), "fx-past"));
};
// Following a link (moon, well, tab, page): the classic client's relay click.
hooks.onNavigate = () => sfx.play("nav");
// Drill lines for tools that weren't mining before light up (after the render).
function flashNewDrills(prev, next) {
const was = new Set((prev?.stakes ?? []).map((s) => s.toolId));
const added = (next?.stakes ?? []).filter((s) => !was.has(s.toolId)).map((s) => s.toolId);
if (!added.length) return;
requestAnimationFrame(() => {
for (const id of added) for (const el of document.querySelectorAll(`[data-tool="${CSS.escape(id)}"]`)) mark(el, "fx-new");
});
}
hooks.onError = (at) => {
sfx.play("error");
const el = at?.el?.isConnected ? at.el : at && document.elementFromPoint(at.x, at.y)?.closest(".btn");
if (el) mark(el, "fx-shake");
};
+95
View File
@@ -0,0 +1,95 @@
// Header: section tabs (Moons here, the rest open the classic client), Claim all, the
// wallet with eased counts, sound and theme toggles.
import { useComputed } from "@preact/signals";
import { live, act } from "./session.js";
import { Num, Countdown, Dot } from "./ui.jsx";
import { colorOf } from "./catalog.js";
import { ReadyButton } from "./buttons.jsx";
const TABS = [
["equipment", "Equipment"],
["store", "Store"],
["market", "Market"],
["refinery", "Refinery"],
["advisors", "Advisors"],
["leaderboard", "Leaderboard"],
["help", "Help"],
];
export function Header() {
const st = live.value;
const credits = useComputed(() => live.value?.credits ?? 0);
return (
<header id="top" class="top">
<span class="brand">
<b>MOON MINERS</b>
<span class="cap">Extraction command</span>
</span>
<nav aria-label="Main" class="nav">
<a id="tab-moons" href="/app" aria-current="page">
Moons{" "}
<span class="badge" hidden={!st.ready}>
{st.ready}
</span>
</a>
{TABS.filter(([s]) => st.unlocked[s]).map(([s, label]) => (
<a key={s} href={`/?s=${s}`} title="Opens the classic client">
{label}
</a>
))}
{st.unlocked["claim-all"] && (st.ready > 0 || st.nextEnd) && <ClaimAll ready={st.ready} nextEnd={st.nextEnd} />}
</nav>
<div class="wallet">
{st.resources.map((r) => (
<Chip key={r.name} name={r.name} />
))}
<Num class="balance frame num" value={credits} k="credits" prefix="₥" />
</div>
<div class="top-ctl">
<button type="button" class="top-btn sfx-toggle" data-sfx-toggle aria-label="Mute sound" aria-pressed="false" title="Sound on">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" />
<path class="sfx-on" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11" />
<path class="sfx-off" d="M16 9.5l5 5M21 9.5l-5 5" />
</svg>
</button>
<button type="button" class="top-btn theme-toggle" data-theme-toggle aria-label="Switch light or dark theme" title="Light / dark theme">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<g class="theme-sun">
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8" />
</g>
<path class="theme-moon" d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" />
</svg>
</button>
<a href="/" class="top-btn" title="The htmx client">
Classic
</a>
</div>
</header>
);
}
function Chip({ name }) {
const amount = useComputed(() => live.value?.resources.find((r) => r.name === name)?.amount ?? 0);
return (
<span class="chip" title={name} data-chip={name}>
<Dot color={colorOf(name)} />
<Num class="num" value={amount} k={`res:${name}`} />
</span>
);
}
function ClaimAll({ ready, nextEnd }) {
return (
<ReadyButton class="btn btn-primary claim-all" ready={ready > 0} onClick={(e) => act("claim", {}, e.currentTarget)}>
Claim all{ready > 0 ? "" : " "}
{ready > 0 ? null : <Countdown end={nextEnd} />}
<span class="badge" hidden={!ready}>
{ready}
</span>
</ReadyButton>
);
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"preact": [
"vendor/preact/preact.mjs"
],
"preact/hooks": [
"vendor/preact/hooks.mjs"
],
"preact/jsx-runtime": [
"vendor/preact/jsxRuntime.mjs"
],
"@preact/signals": [
"vendor/signals/signals.mjs"
],
"@preact/signals-core": [
"vendor/signals/signals-core.mjs"
],
"three": [
"../static/vendor/three/three.module.min.js"
],
"three/addons/*": [
"../static/vendor/three/addons/*"
]
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// The Preact client at /app: the Moons slice of the game (header and wallet, moon list,
// wells table, well panel, viewport, toasts) on the JSON API (api.md), predicting every
// core-loop click locally like the Defold client. Other sections open the classic client.
import { render } from "preact";
import { live, boot, toasts, start } from "./session.js";
import { Header } from "./header.jsx";
import { MoonList, MoonsSection } from "./moons.jsx";
import { Side } from "./side.jsx";
import { Viewport } from "./viewport.jsx";
import { ConfirmDialog } from "./dialog.jsx";
import "./fx.js";
import "./preact.css";
function App() {
const st = live.value;
if (!st) {
return (
<div class="splash">
<div>
<h1>Moon Miners</h1>
<p>{boot}</p>
</div>
</div>
);
}
const aside = !!st.moon;
return (
<>
<div class="app">
<Header />
<div class="app-body">
<MoonList />
<main id="main" class="main frame">
<MoonsSection />
</main>
<aside id="aside" class="aside frame" hidden={!aside}>
<Viewport />
<section id="side" class={`side${st.well && (st.moon?.wells.find((w) => w.id === st.well.id)?.supply ?? 1) <= 0 ? " depleted" : ""}`}>
<Side />
</section>
</aside>
</div>
</div>
<Toasts />
<ConfirmDialog />
</>
);
}
function Toasts() {
const list = toasts.value;
return (
<div id="toasts" class="toasts" aria-live="polite">
{list.map((t) => (
<div key={t.id} class={`toast${t.error ? " toast-error" : ""}${t.out ? " toast-out" : ""}`} data-fx={t.kind}>
{t.message}
</div>
))}
</div>
);
}
render(<App />, document.getElementById("root"));
start();
// sfx.js syncs its mute button on this event (the classic client's htmx fires it).
requestAnimationFrame(() => document.dispatchEvent(new Event("htmx:load")));
+14
View File
@@ -0,0 +1,14 @@
// A component that renders once (no preact/compat): its props never change it.
import { Component, h } from "preact";
export function memo(render) {
return class Once extends Component {
shouldComponentUpdate() {
return false;
}
render(props) {
return h(render, props);
}
};
}
+447
View File
@@ -0,0 +1,447 @@
// The moon list (active and past moons, a search box) and the Moons section (wells table
// with drill bars, claim and redrill, multi-well drills, the moon's resale; a past moon's
// haul).
import { signal } from "@preact/signals";
import { live, act, navigate, viewURL, hooks, busy } from "./session.js";
import { Countdown, DrillBar, Dot, Planet, RatingChip, SupplyBar, Num } from "./ui.jsx";
import { ReadyButton } from "./buttons.jsx";
import { confirm } from "./dialog.jsx";
import { colorOf, toolOf } from "./catalog.js";
import { money, cur, plural, drillLabel } from "./format.js";
import { useFlip } from "./flip.js";
import { now } from "./clock.js";
import { isReady, find } from "./predict.js";
// A link to a view that navigates in place.
export function ViewLink({ to, children, ...rest }) {
return (
<a
href={viewURL(to)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
hooks.onNavigate();
navigate(to);
}}
{...rest}
>
{children}
</a>
);
}
// The search box's text: it stays while the list changes under it.
const query = signal("");
const onFind = (e) => (query.value = e.currentTarget.value);
const minedTotal = (m) => (m.mined ?? []).reduce((sum, a) => sum + a.amount, 0);
export function MoonList() {
const st = live.value;
const ref = useFlip();
const past = !!st.view.past;
const store = st.unlocked.store;
const total = st.pastPage?.total ?? 0;
const list = past ? st.past ?? [] : st.moons;
const q = query.value.trim().toLowerCase();
const found = q ? list.filter((m) => m.name.toLowerCase().includes(q)) : list;
const page = st.pastPage ?? { page: 1, pages: 1 };
const activeMoon = st.moons.find((m) => m.selected)?.id ?? "";
return (
<section id="moonlist" class="moonlist frame" aria-label="Your moons" ref={ref}>
{store && (st.moons.length > 0 || total > 0) && (
<nav class="tabs list-tabs" aria-label="Moon list">
<ViewLink to={{ section: "moons", moon: activeMoon }} aria-current={past ? undefined : "page"}>
Active <span class="muted num">{st.moons.length}</span>
</ViewLink>
{total > 0 ? (
<ViewLink to={{ section: "moons", past: true, page: 1 }} aria-current={past ? "page" : undefined} data-past-tab>
Past <span class="muted num">{total}</span>
</ViewLink>
) : (
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>
Past <span class="muted num">0</span>
</a>
)}
</nav>
)}
{store && list.length > 0 && (
<>
<label for="find" class="label">
Find a moon
</label>
<input id="find" type="search" placeholder="Name" class="input" value={query} onInput={onFind} />
</>
)}
{past ? (
<>
{found.map((m) => (
<ViewLink
key={m.id}
to={{ section: "moons", moon: m.id, past: true, page: page.page }}
id={`moon-${m.id}`}
data-key={m.id}
class="moon past"
aria-current={m.selected && st.view.section === "moons" ? "true" : undefined}
>
<Planet id={m.id} />
<span class="grow">
<b>{m.name}</b>
<RatingChip r={m.rating} />
<br />
<span class="muted small num">
Mined {money(minedTotal(m))}
{m.finishedOn && ` · ${m.finishedOn}`}
</span>
</span>
</ViewLink>
))}
{page.pages > 1 && (
<nav class="past-paging small" aria-label="Past moon pages">
{page.page > 1 ? (
<ViewLink to={{ section: "moons", past: true, page: page.page - 1 }} class="btn btn-sm" rel="prev">
‹ Newer
</ViewLink>
) : (
<span class="btn btn-sm" aria-disabled="true">
‹ Newer
</span>
)}
<span class="muted num grow">
{page.page} / {page.pages}
</span>
{page.page < page.pages ? (
<ViewLink to={{ section: "moons", past: true, page: page.page + 1 }} class="btn btn-sm" rel="next">
Older ›
</ViewLink>
) : (
<span class="btn btn-sm" aria-disabled="true">
Older ›
</span>
)}
</nav>
)}
</>
) : (
<>
{found.map((m) => {
const dry = m.wells.length > 0 && m.wells.every((w) => w.supply <= 0);
return (
<ViewLink
key={m.id}
to={{ section: "moons", moon: m.id }}
id={`moon-${m.id}`}
data-key={m.id}
class={`moon${dry ? " depleted" : ""}`}
aria-current={m.selected && st.view.section === "moons" ? "true" : undefined}
>
<Planet id={m.id} />
<span class="grow">
<b>{m.name}</b>
<RatingChip r={m.rating} />
<br />
<span class="muted small">
{m.over && <i class="warn-dot" title="A well here has more drills than supply" />}
{m.drills ? plural(m.drills, "drill") : dry ? "Tapped out" : "Idle"}
</span>
</span>
<span class="badge" title={plural(m.ready, "drill") + " ready"} hidden={!m.ready}>
{m.ready}
</span>
</ViewLink>
);
})}
{!st.moons.length && total > 0 && <p class="muted small">Every moon you own is mined out.</p>}
</>
)}
{st.unlocked["new-moon"] && (
<a class="btn btn-dashed new-moon" href="/?s=new-moon" title="Opens the classic client">
+ New moon · <span class="num accent">₥{money(st.moonPrice)}</span>
</a>
)}
</section>
);
}
export function MoonsSection() {
const st = live.value;
const moon = st.moon;
if (!moon) {
const r = st.rules;
return (
<div class="empty">
<h1>Claim your first moon</h1>
<p class="muted measure">
A moon comes with {r.wellsMin === r.wellsMax ? r.wellsMin : `${r.wellsMin}–${r.wellsMax}`} resource wells. Deploy mining tools on its
wells, claim what they dig up, and sell it in the market.
</p>
<a class="btn btn-primary" href="/?s=new-moon" title="Opens the classic client">
Scan for a moon · <span class="num">{cur(st.moonPrice)}</span>
</a>
</div>
);
}
if (moon.finished) return <PastMoon st={st} moon={moon} />;
const max = st.rules.maxSupply;
return (
<>
<div class="heading">
<div class="grow">
<div class="num muted small">{moon.id}</div>
<h1>{moon.name}</h1>
{(moon.kind && moon.kind !== moon.name) || moon.rating ? (
<p class="cap muted small">
{moon.kind && moon.kind !== moon.name && `${moon.kind} class`}
<RatingChip r={moon.rating} />
</p>
) : null}
<p class="muted measure">{moon.description || " "}</p>
</div>
{(moon.ready > 0 || moon.nextEnd) && (
<ReadyButton class="btn btn-primary" ready={moon.ready > 0} onClick={(e) => act("claim", { "claim-moon": moon.id }, e.currentTarget)}>
{moon.ready > 0 ? `Claim ${plural(moon.ready, "drill")}` : <>Claim <Countdown end={moon.nextEnd} /></>}
</ReadyButton>
)}
</div>
<div class="table-box">
<table class="table wells-table">
<thead>
<tr>
<th>Well</th>
<th class="w-supply">Supply left</th>
<th class="col-drill">Drill</th>
<th class="right">Status</th>
</tr>
</thead>
<tbody>
{moon.wells.map((w) => (
<WellRow key={w.id} st={st} moon={moon} w={w} max={max} />
))}
{/* Drills that mine several wells (reach) are claimed together, from the moon. */}
{moon.wide?.drills?.length > 0 && (
<tr class="wide-row">
<td>
<span class="cap small">Multi-well</span>
</td>
<td class="cell-wrap">
<span class="num muted">{money(moon.wide.cap)}</span> <span class="muted small">left on the moon</span>
</td>
<td class="col-drill">
<DrillCell st={st} drills={moon.wide.drills} />
</td>
<td class="right">
<div class="row-actions">
<WideClaim moon={moon} small />
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{st.unlocked.store && <MoonSale moon={moon} />}
</>
);
}
// A finished moon, read-only: what it gave (its entry in the past list has the haul).
function PastMoon({ st, moon }) {
const entry = find(st.past, moon.id);
const mined = entry?.mined ?? [];
return (
<>
<div class="heading">
<div class="grow">
<div class="num muted small">{moon.id}</div>
<h1>{moon.name}</h1>
<p class="tapped">
Mined out{moon.finishedOn && ` · ${moon.finishedOn}`}
</p>
</div>
</div>
<div class="table-box past-moon">
<table class="table">
<thead>
<tr>
<th>Resource</th>
<th class="right">Mined</th>
</tr>
</thead>
<tbody>
{mined.map((a) => (
<tr key={a.name}>
<td>
<Dot color={colorOf(a.name)} />
{a.name}
</td>
<td class="right num">{money(a.amount)}</td>
</tr>
))}
<tr class="total">
<td>Total</td>
<td class="right num">{money(minedTotal({ mined }))}</td>
</tr>
</tbody>
</table>
</div>
<p class="muted small measure">Its wells are empty. It still counts toward the price of your next moon.</p>
</>
);
}
// Claim for the moon's multi-well drills (claim-wide: the server splits what they get
// between their wells, so it isn't predicted; the button spins until it answers).
export function WideClaim({ moon, small = false }) {
const wide = moon.wide;
if (!wide || !(wide.ready > 0 || wide.nextEnd)) return null;
return (
<ReadyButton
class={`btn btn-primary${small ? " btn-sm" : ""}`}
ready={wide.ready > 0}
busyKey="claim-wide"
onClick={(e) => act("claim", { "claim-wide": moon.id }, e.currentTarget, "claim-wide")}
>
Claim {wide.ready > 0 ? <Num class="num" n={wide.readyAmount} k={`wide:${moon.id}`} /> : <Countdown end={wide.nextEnd} />}
</ReadyButton>
);
}
// Selling the moon back: the offer against a new moon's price, and what's left in its
// wells. A just-bought moon becomes sellable at holdEnd (the session wakes then).
function MoonSale({ moon }) {
const sale = moon.sale;
if (!sale) return null;
const held = sale.why === "hold" && sale.holdEnd && now() < sale.holdEnd;
const can = sale.canSell || (sale.why === "hold" && !held);
const profit = sale.delta > 0;
const spinning = !!busy.value["sell-moon"];
const sell = (e) => {
// The button sits in the summary: no toggling the details.
e.preventDefault();
const el = e.currentTarget;
confirm(sale.confirm).then((yes) => yes && act("sell-moon", { "sell-moon": moon.id }, el, "sell-moon"));
};
return (
<details class="resale">
<summary>
<span class="grow">
Sell moon <span class="muted">· offer</span> <span class="num">{cur(sale.total)}</span>{" "}
<span class={`num resale-delta ${profit ? "up" : "down"}`}>{profit ? `+${cur(sale.delta)} profit` : `−${cur(-sale.delta)} vs a new moon`}</span>
</span>
<button type="button" class={`btn btn-sm btn-danger${spinning ? " htmx-request" : ""}`} disabled={!can || spinning} title={can ? undefined : sale.whyText} onClick={sell}>
Sell moon
</button>
</summary>
<div class="resale-body">
<p class="muted small">
What's left in its wells at today's prices, ×{sale.factor}
{sale.capped && `, capped at ${cur(sale.total)}`}. A new moon would cost {cur(sale.replace)} after the sale.
{sale.ready > 0 && ` ${plural(sale.ready, "finished drill")} claimed first.`}
{held && (
<>
{" "}
Sellable in <Countdown end={sale.holdEnd} done="0:00" />.
</>
)}
</p>
<table class="table">
<thead>
<tr>
<th>Well</th>
<th class="right">Left</th>
<th class="right">Price</th>
<th class="right">Value</th>
</tr>
</thead>
<tbody>
{sale.lines.map(({ resource, supply, rate, value: worth }) => (
<tr key={resource}>
<td>
<Dot color={colorOf(resource)} />
{resource}
</td>
<td class="right num">{money(supply)}</td>
<td class="right num">{cur(rate)}</td>
<td class="right num">{cur(worth)}</td>
</tr>
))}
<tr class="resale-total">
<td>Market value</td>
<td />
<td />
<td class="right num">{cur(sale.market)}</td>
</tr>
</tbody>
</table>
</div>
</details>
);
}
// The moon's multi-well drills that mine well w too.
export const wideOn = (moon, w) => (moon.wide?.drills ?? []).filter((d) => w.wide?.includes(d.id));
function WellRow({ st, moon, w, max }) {
const dry = w.supply <= 0;
const to = { section: "moons", moon: moon.id, well: w.id };
const mining = w.drills.length > 0;
const drills = [...w.drills, ...wideOn(moon, w)];
return (
<tr class={`${w.selected ? "selected" : ""}${dry ? " depleted" : ""}`}>
<td>
<ViewLink to={to} class="row-link">
<Dot color={colorOf(w.resource)} />
{w.resource}
</ViewLink>
</td>
<td>
{dry ? (
<span class="tapped">Tapped out</span>
) : (
<SupplyBar supply={w.supply} demand={w.demand} max={max} color={colorOf(w.resource)} full={w.full} showNum={w.id} />
)}
</td>
<td class="col-drill">
{drills.length ? <DrillCell st={st} drills={drills} /> : <span class="muted">Empty</span>}
</td>
<td class="right">
<div class="row-actions">
{mining ? (
<ReadyButton class="btn btn-primary btn-sm" ready={w.ready > 0} onClick={(e) => act("claim", { "claim-well": w.id }, e.currentTarget)}>
Claim {w.ready > 0 ? <Num class="num" n={w.readyAmount} k={`ready:${w.id}`} /> : <Countdown end={w.nextEnd} />}
</ReadyButton>
) : (
w.redrill > 0 &&
!dry &&
st.unlocked.market && (
<button type="button" class="btn btn-sm btn-redrill" onClick={(e) => act("redrill", { "redrill-well": w.id }, e.currentTarget)}>
Redrill
</button>
)
)}
<ViewLink to={to} class="btn btn-sm">
{dry ? "View" : "Manage"}
</ViewLink>
</div>
</td>
</tr>
);
}
const MAX_BARS = 4;
export function DrillCell({ st, drills }) {
const t = now();
return (
<div class="drill-cell" title={drills.map((d) => d.fate ?? "").join("\n")}>
<span class="drill-bars">
{drills.slice(0, MAX_BARS).map((d) => (
<DrillBar key={d.toolId} start={d.start} end={d.end} state={d.state ?? "ok"} ready={isReady(d, t)} wide={(d.reach ?? 1) > 1} />
))}
</span>
{drills.length > MAX_BARS && <span class="drill-more num">+{drills.length - MAX_BARS}</span>}
<span class="drill-label">{drillLabel(drills, (id) => toolOf(st, id))}</span>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
/* The Preact client (/app): only what its markup does differently from the classic
client's (web/css/app.css, loaded first). Moving things use transform or opacity. */
/* Drill timer bars: an inner fill scaled by a Web Animation instead of --p widths. */
.pa .drill-bar.pa-bar::before {
display: none;
}
.pa .drill-bar .pa-fill {
position: absolute;
inset: 0;
background: var(--c);
transform-origin: left center;
transform: scaleX(0);
will-change: transform;
}
.pa .drill-bar.is-none .pa-fill {
opacity: 0.7;
}
.pa .drill-bar.is-ready .pa-fill {
box-shadow: 0 0 5px rgb(var(--c-rgb) / calc(0.7 * var(--glow-k)));
}
/* Supply bars: both parts span the bar, clipped to their share. */
.pa .bar.pa-split {
position: relative;
display: block;
}
.pa .bar.pa-split > span {
position: absolute;
inset: 0;
transition: clip-path 0.35s cubic-bezier(0.2, 0.8, 0.3, 1);
}
/* Claim buttons breathe while ready and pop in when a drill finishes (the classic CSS
matches them by hx-post). */
.pa button[data-claim]:not(:disabled) {
animation: fx-ready-glow 1.8s ease-in-out infinite;
}
.pa button[data-claim].fx-ready-pop {
animation:
fx-ready-pop 0.6s cubic-bezier(0.2, 0.9, 0.3, 1.4),
fx-ready-glow 1.8s 0.6s ease-in-out infinite;
}
/* Wallet numbers pop in place. */
.pa .wallet .num,
.pa .balance {
display: inline-block;
}
+352
View File
@@ -0,0 +1,352 @@
// Client-side prediction, ported from the Defold client (client/modules/predict.lua).
//
// The server still decides. A predictor only reuses numbers the server sent (a stake's
// `take`, a tool's `cycleSecs`/`yield`, see api.md "Prediction"); it never invents game
// rules. The session 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 predictor: fn(st, params, ctx) -> true when it predicted (and changed st), false when
// it can't (the button then waits for the server). st is a copy of the shown state, ctx
// {now: server ms at the click, seq}. It must be deterministic in (st, params, ctx): it
// runs again on every new base, so it reads the time from ctx.now, never the clock.
//
// One difference from the Lua: readiness is local. A stake counts as ready once its end
// has passed on the server's clock (`isReady`), so a Claim pressed at local zero predicts
// without waiting for a refetch.
export const registry = {};
// What the client learned from server states (not rules): the well each tool last mined.
export const memo = { lastWell: new Map() };
export const isReady = (s, now) => s.ready || s.end <= now;
// ---------------------------------------------------------------- state helpers
const SHARED = new Set(["catalog", "rules"]);
export function copy(st) {
const out = {};
for (const k in st) out[k] = SHARED.has(k) ? st[k] : structuredClone(st[k]);
return out;
}
export const find = (list, id, key = "id") => (list ?? []).find((x) => x[key] === id);
const indexOf = (list, id, key = "id") => (list ?? []).findIndex((x) => x[key] === id);
// Every well entry with this id: the moon list's and the selected moon's.
function wells(st, id) {
const out = [];
for (const list of [st.moons, st.past]) {
for (const moon of list ?? []) {
const w = find(moon.wells, id);
if (w) out.push(w);
}
}
const w = st.moon && find(st.moon.wells, id);
if (w) out.push(w);
return out;
}
export const supply = (st, wellId) => wells(st, wellId)[0]?.supply ?? 0;
function addSupply(st, wellId, delta) {
for (const w of wells(st, wellId)) w.supply = Math.max(0, w.supply + delta);
}
// Add delta of a resource or refined item (balance and its market quote).
function addItem(st, name, delta) {
let e = find(st.resources, name, "name") ?? find(st.refined, name, "name");
if (!e) {
e = { name, amount: 0 };
(st.refined ??= []).push(e);
}
e.amount = Math.max(0, e.amount + delta);
const q = st.market && find(st.market.quotes, name, "item");
if (q) {
q.balance = e.amount;
q.value = q.balance * q.price;
}
}
// ---------------------------------------------------------------- tools and stakes
// The tool to deploy for id: it, or an idle copy of it (same store item and tier).
function idleCopy(st, id) {
const t = find(st.tools, id);
if (!t || !t.stakeId) return t;
return st.tools.find((x) => !x.stakeId && x.storeItemId === t.storeItemId && x.tier === t.tier);
}
const yieldBoost = (st, resource) => st.rules?.boosts?.yield?.[resource] ?? 1;
const sameGroup = (g, tool) => g.storeItemId === tool.storeItemId && g.tier === tool.tier;
// A new stake of tool on well starting at ctx.now. group: the well panel's idle group for
// it (cycle and yield already for this well).
function addStake(st, tool, moonId, well, ctx, group) {
const cycle = group ? group.cycleSecs : tool.cycleSecs;
const amount = group ? group.yield : tool.yield * yieldBoost(st, well.resource);
let committed = 0;
for (const s of st.stakes) if (s.wellId === well.id) committed += s.take ?? 0;
const take = Math.max(0, Math.min(amount, supply(st, well.id) - committed));
const s = {
id: `pred:${ctx.seq}:${tool.id}`,
moonId,
wellId: well.id,
toolId: tool.id,
resource: well.resource,
amount,
reach: tool.reach ?? 1,
reachText: "",
start: ctx.now,
end: ctx.now + cycle * 1000,
durationSecs: cycle,
ready: false,
take,
predicted: true,
};
st.stakes.push(s);
tool.stakeId = s.id;
// Off the selected well's idle list; what the next one would get there shrinks.
if (st.well) {
const i = (st.well.idle ?? []).findIndex((g) => sameGroup(g, tool));
if (i >= 0) {
const g = st.well.idle[i];
g.count--;
if (g.toolId === tool.id) {
const next = st.tools.find((x) => !x.stakeId && sameGroup(g, x));
if (next) g.toolId = next.id;
}
if (g.count <= 0) st.well.idle.splice(i, 1);
}
if (st.well.id === well.id) {
for (const g of st.well.idle ?? []) {
g.free = Math.max(0, g.free - take);
g.here = Math.min(g.yield, g.free);
g.short = g.free < g.yield;
}
}
}
// It no longer counts for Redrill on the well it last mined.
const last = memo.lastWell.get(tool.id);
const w = last && st.moon && find(st.moon.wells, last);
if (w && (w.redrill ?? 0) > 0) w.redrill--;
return s;
}
// Take a stake off (claimed); its tool becomes idle.
function removeStake(st, s) {
const i = indexOf(st.stakes, s.id);
if (i >= 0) st.stakes.splice(i, 1);
const tool = find(st.tools, s.toolId);
if (!tool) return;
delete tool.stakeId;
const w = st.moon && find(st.moon.wells, s.wellId);
if (w) w.redrill = (w.redrill ?? 0) + 1;
if (st.well?.idle) {
const g = st.well.idle.findLast((x) => sameGroup(x, tool));
if (g) g.count++;
else {
const free = supply(st, st.well.id);
const y = tool.yield * yieldBoost(st, st.well.resource);
st.well.idle.push({
toolId: tool.id,
storeItemId: tool.storeItemId,
name: tool.name,
tier: tool.tier,
count: 1,
cycleSecs: tool.cycleSecs,
yield: y,
reach: tool.reach,
here: Math.min(y, free),
free,
short: free < y,
attachments: tool.attachments ?? [],
});
}
}
}
// Recompute what the server derives from stakes, tools and supplies (counts, the claim
// buttons' amounts, next ends), as of server time now. Wide drills (reach > 1) keep the
// server's projection (what each gets); only their readiness is local.
export function derive(st, now) {
let ready = 0;
let idle = 0;
let nextEnd = null;
const perMoon = new Map();
for (const s of st.stakes ?? []) {
const m = perMoon.get(s.moonId) ?? { drills: 0, ready: 0 };
perMoon.set(s.moonId, m);
m.drills++;
if (isReady(s, now)) {
ready++;
m.ready++;
} else if (nextEnd === null || s.end < nextEnd) nextEnd = s.end;
}
for (const t of st.tools ?? []) if (!t.stakeId) idle++;
st.ready = ready;
st.idle = idle;
st.nextEnd = nextEnd;
for (const moon of st.moons ?? []) {
const m = perMoon.get(moon.id) ?? { drills: 0, ready: 0 };
moon.drills = m.drills;
moon.ready = m.ready;
}
const moon = st.moon;
if (!moon) return st;
let mready = 0;
let mnext = null;
for (const w of moon.wells ?? []) {
const old = new Map((w.drills ?? []).map((d) => [d.id, d]));
const drills = [];
let wready = 0;
let amount = 0;
let wnext = null;
let demand = 0;
for (const s of st.stakes ?? []) {
if (s.wellId !== w.id || (s.reach ?? 1) > 1) continue;
let d = old.get(s.id);
if (!d) {
d = { ...s, gets: s.take, here: s.take };
d.state = s.take <= 0 ? "none" : s.take < s.amount ? "short" : "ok";
}
drills.push(d);
demand += d.here ?? s.take ?? 0;
if (isReady(s, now)) {
wready++;
amount += s.take ?? 0;
} else if (wnext === null || s.end < wnext) wnext = s.end;
}
drills.sort((a, b) => a.start - b.start);
w.drills = drills;
w.ready = wready;
w.readyAmount = amount;
w.nextEnd = wnext;
w.demand = demand;
w.remain = Math.max(0, w.supply - demand);
mready += wready;
if (wnext !== null && (mnext === null || wnext < mnext)) mnext = wnext;
}
if (moon.wide) {
let wready = 0;
let amount = 0;
let wnext = null;
for (const d of moon.wide.drills ?? []) {
if (isReady(d, now)) {
wready++;
amount += d.gets ?? d.take ?? 0;
} else if (wnext === null || d.end < wnext) wnext = d.end;
}
moon.wide.ready = wready;
moon.wide.readyAmount = amount;
moon.wide.nextEnd = wnext;
mready += wready;
if (wnext !== null && (mnext === null || wnext < mnext)) mnext = wnext;
}
moon.ready = mready;
moon.nextEnd = mnext;
return st;
}
// ---------------------------------------------------------------- queue
// Learn from a server state (the well each running or unclaimed drill is on).
export function learn(st) {
for (const s of st.stakes ?? []) memo.lastWell.set(s.toolId, s.wellId);
}
// Run one pending entry ({fn, params, at, seq}) on st; true when it predicted.
export function run(st, e) {
let res = false;
try {
res = !!e.fn(st, e.params, { now: e.at, seq: e.seq });
} catch (err) {
console.warn(`predict: ${e.name} failed`, err);
return false;
}
if (res) derive(st, e.at);
return res;
}
// The state to show: base with every pending prediction applied in order (base itself
// when nothing is pending).
export function apply(base, pending) {
if (!pending.length) return base;
const st = copy(base);
for (const e of pending) run(st, e);
return st;
}
// ---------------------------------------------------------------- core-loop predictors
const moonOfWell = (st, wellId) => {
for (const moon of st.moons ?? []) {
const w = find(moon.wells, wellId);
if (w) return [moon, w];
}
return [];
};
const groupFor = (st, wellId, tool) =>
st.well && st.well.id === wellId ? (st.well.idle ?? []).findLast((g) => sameGroup(g, tool)) : undefined;
// claim: claim-well / claim-moon / everything ready; redeploy=1 restarts the same tools.
registry.claim = (st, p, ctx) => {
if (p["claim-wide"]) return false;
const list = [];
for (const s of st.stakes) {
if (!isReady(s, ctx.now)) continue;
if (p["claim-well"] && s.wellId !== p["claim-well"]) continue;
if (p["claim-moon"] && s.moonId !== p["claim-moon"]) continue;
if ((s.reach ?? 1) > 1) return false; // multi-well drills: the server splits them
list.push(s);
}
if (!list.length) return false;
for (const s of list) {
addItem(st, s.resource, s.take ?? 0);
addSupply(st, s.wellId, -(s.take ?? 0));
removeStake(st, s);
}
if (p.redeploy) {
for (const s of list) {
const tool = find(st.tools, s.toolId);
const w = wells(st, s.wellId)[0];
if (tool && !tool.stakeId && w && w.supply > 0) {
addStake(st, tool, s.moonId, w, ctx, groupFor(st, w.id, tool));
const detail = st.moon && find(st.moon.wells, s.wellId);
if (detail && memo.lastWell.get(tool.id) !== s.wellId && (detail.redrill ?? 0) > 0) detail.redrill--;
}
}
}
return true;
};
// stake (deploy): the deploy card's tool on the target well, timer from now.
registry.stake = (st, p, ctx) => {
const [moon, w] = moonOfWell(st, p.target);
if (!moon || w.supply <= 0) return false;
// "Deploy there": the view moves to the other well, wait for it.
if (st.view && ((p.moon && p.moon !== st.view.moon) || (p.well && p.well !== st.view.well))) return false;
const tool = idleCopy(st, p.tool);
if (!tool || (tool.reach ?? 1) > 1) return false;
addStake(st, tool, moon.id, w, ctx, groupFor(st, w.id, tool));
return true;
};
// redrill: the idle tools that last mined the well (as far as the client saw).
registry.redrill = (st, p, ctx) => {
const id = p["redrill-well"];
const [moon, w] = moonOfWell(st, id);
if (!moon || w.supply <= 0) return false;
const detail = st.moon && find(st.moon.wells, id);
const n = detail?.redrill ?? 0;
const tools = [];
for (const t of st.tools) {
if (tools.length < n && !t.stakeId && memo.lastWell.get(t.id) === id && (t.reach ?? 1) <= 1) tools.push(t);
}
if (!tools.length) return false;
for (const t of tools) addStake(st, t, moon.id, w, ctx, groupFor(st, id, t));
if (detail) detail.redrill = 0;
return true;
};
+108
View File
@@ -0,0 +1,108 @@
// The moon viewport rendered in a worker on an OffscreenCanvas, so the page's thread never
// waits on three.js. Runs the classic client's MoonScene (web/static/js/scene.js, bundled
// here with three.js) against a stand-in for the DOM it expects: the canvas element, its
// container, ResizeObserver and the document. The page (scene.js) forwards pointer and
// wheel events and the container's size.
//
// Messages in: {type: "init", canvas, width, height, dpr}, {type: "size", width, height,
// left, top}, {type: "event", event: {...}}, {type: "show", seed, settings},
// {type: "visible", hidden}. Out: {type: "ready"}, {type: "error", message}.
let canvas = null;
let rect = { left: 0, top: 0, width: 1, height: 1 };
const resizers = new Set();
const root = new EventTarget(); // canvas.getRootNode(): OrbitControls listens for keys there
// ---------------------------------------------------------------- DOM stand-ins
self.window = self;
self.document = {
hidden: false,
createElementNS: () => takeCanvas(),
createElement: (tag) => (tag === "canvas" ? new OffscreenCanvas(1, 1) : {}),
addEventListener() {},
removeEventListener() {},
};
self.ResizeObserver = class {
constructor(fn) {
this.fn = fn;
}
observe() {
resizers.add(this.fn);
}
disconnect() {
resizers.delete(this.fn);
}
};
// The first canvas three.js asks for is the page's (transferred); later ones are scratch.
let given = false;
function takeCanvas() {
if (given) return new OffscreenCanvas(1, 1);
given = true;
return canvas;
}
function dressCanvas(c) {
c.style = {};
c.className = "";
c.getRootNode = () => root;
c.setPointerCapture = () => {};
c.releasePointerCapture = () => {};
c.focus = () => {};
Object.defineProperties(c, {
clientWidth: { get: () => rect.width },
clientHeight: { get: () => rect.height },
});
c.getBoundingClientRect = () => ({ ...rect, x: rect.left, y: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height });
}
const container = {
get clientWidth() {
return rect.width;
},
get clientHeight() {
return rect.height;
},
get offsetWidth() {
return rect.width;
},
dataset: {},
prepend() {},
};
// ---------------------------------------------------------------- messages
let scene = null;
let wanted = null; // a show that came before the scene was up
self.onmessage = async ({ data }) => {
try {
if (data.type === "init") {
canvas = data.canvas;
self.devicePixelRatio = data.dpr;
rect = { left: 0, top: 0, width: data.width, height: data.height };
dressCanvas(canvas);
const { MoonScene } = await import("../static/js/scene.js");
scene = new MoonScene(container, {
onContextChange: (lost) => self.postMessage({ type: "context", lost }),
});
if (wanted) scene.show(wanted);
self.postMessage({ type: "ready" });
} else if (data.type === "size") {
rect = { left: data.left, top: data.top, width: data.width, height: data.height };
for (const fn of resizers) fn();
} else if (data.type === "event") {
const { type, ...fields } = data.event;
const e = new Event(type, { cancelable: true });
Object.assign(e, fields);
e.preventDefault = () => {};
canvas?.dispatchEvent(e);
} else if (data.type === "show") {
wanted = { seed: data.seed, settings: data.settings };
scene?.show(wanted);
}
} catch (error) {
self.postMessage({ type: "error", message: String(error?.message ?? error) });
}
};
+112
View File
@@ -0,0 +1,112 @@
// The moon in the viewport. startViewport(container) -> {show(moonId), dispose()}.
//
// Preferred: the classic client's MoonScene rendered in a worker on an OffscreenCanvas
// (scene-worker.js), with the canvas's pointer and wheel events and its size forwarded.
// Without OffscreenCanvas, or if the worker fails to start, the classic client's scene runs
// on this thread (web/static/js/scene.js, three.js through the page's import map).
import { mergeSettings } from "/static/js/terrain.js";
// The moon look tuned by admins (/utils/moons), merged over the defaults.
const settings = fetch("/api/moon-settings")
.then((res) => (res.ok ? res.json() : null))
.catch(() => null)
.then((saved) => mergeSettings(saved));
// Where the viewport runs, for the measurements: "worker" or "main".
export let mode = "";
export function startViewport(container) {
const offscreen = "transferControlToOffscreen" in HTMLCanvasElement.prototype && !new URLSearchParams(location.search).has("mainscene");
return offscreen ? workerViewport(container) : mainViewport(container);
}
function mainViewport(container) {
mode = "main";
let seed = "";
let scene = null;
import("/static/js/scene.js").then(({ startScene }) => {
scene = startScene(container, {
onStatus(message) {
if (message) console.warn("viewport:", message);
},
});
if (seed) show(seed);
});
const show = (s) => {
seed = s;
if (scene) settings.then((set) => scene.show({ seed: s, settings: set }));
};
return { show, dispose: () => scene?.dispose() };
}
const FIELDS = ["pointerId", "pointerType", "button", "buttons", "clientX", "clientY", "pageX", "pageY", "deltaX", "deltaY", "deltaMode", "ctrlKey", "metaKey", "shiftKey", "altKey"];
function workerViewport(container) {
mode = "worker";
const canvas = document.createElement("canvas");
canvas.className = "game-canvas";
canvas.style.touchAction = "none";
container.prepend(canvas);
const worker = new Worker(new URL("./scene-worker.js", import.meta.url), { type: "module" });
let failed = null;
let seed = "";
const fail = (why) => {
if (failed) return;
console.warn("viewport: worker failed, drawing on the page's thread:", why);
worker.terminate();
stop();
canvas.remove();
failed = mainViewport(container);
if (seed) failed.show(seed);
};
worker.onerror = (e) => {
e.preventDefault?.();
fail(e.message);
};
worker.onmessage = ({ data }) => {
if (data.type === "error") fail(data.message);
};
const box = () => container.getBoundingClientRect();
const r = box();
const off = canvas.transferControlToOffscreen();
worker.postMessage(
{ type: "init", canvas: off, width: r.width, height: r.height, dpr: Math.min(devicePixelRatio, 2) },
[off]
);
const size = () => {
const b = box();
worker.postMessage({ type: "size", width: b.width, height: b.height, left: b.left, top: b.top });
};
const resize = new ResizeObserver(size);
resize.observe(container);
// OrbitControls listens on the canvas; it captures the pointer while dragging.
const forward = (e) => {
if (e.type === "pointerdown") {
canvas.setPointerCapture(e.pointerId);
size(); // its rect is read on drag start
}
if (e.type === "wheel" || e.type === "contextmenu") e.preventDefault();
const msg = { type: e.type };
for (const k of FIELDS) if (k in e) msg[k] = e[k];
worker.postMessage({ type: "event", event: msg });
};
const types = ["pointerdown", "pointermove", "pointerup", "pointercancel", "wheel", "contextmenu"];
for (const t of types) canvas.addEventListener(t, forward, { passive: t !== "wheel" && t !== "contextmenu" });
const stop = () => {
resize.disconnect();
for (const t of types) canvas.removeEventListener(t, forward);
};
return {
show(s) {
seed = s;
if (failed) return failed.show(s);
settings.then((set) => worker.postMessage({ type: "show", seed: s, settings: set }));
},
dispose() {
stop();
worker.terminate();
failed?.dispose();
},
};
}
+352
View File
@@ -0,0 +1,352 @@
// The session with the server, like the Defold client's (client/screens/game/core/session.lua).
//
// State: `base` (the last server state), `pending` (predicted actions and navigations
// not answered yet), `shown` (base + pending, predict.apply) and `live` (shown with the
// counts that depend on the clock re-derived at every drill end).
// Requests (state fetches and actions) go out one at a time in click order from the
// outbox, so the server runs actions in click order and each response's state is the
// base for the predictions still queued behind it. A response replaces the base and drops
// its own prediction (a 422 or a network error rolls it back). Nothing waits for the
// server to show a result: a predicted action's effects play at the click.
import { signal, computed, batch, effect } from "@preact/signals";
import * as P from "./predict.js";
import { now, syncClock, edge, armEdge, lastEdge } from "./clock.js";
export const base = signal(null);
export const pending = signal([]);
export const shown = computed(() => base.value && P.apply(base.value, pending.value));
export const live = computed(() => {
edge.value;
const st = shown.value;
if (!st) return null;
P.derive(st, now());
return { ...st };
});
// Whether the latest change on screen came from the clock (a drill finishing) rather than
// a new state: a Claim button enabled by the clock plays the ready effect.
let lastShown = -Infinity;
effect(() => {
shown.value;
lastShown = performance.now();
});
export const clockCaused = () => lastEdge > lastShown;
export const toasts = signal([]);
// Buttons waiting for an answer they couldn't predict (key -> seq): they show a spinner.
export const busy = signal({});
export const boot = signal("Preparing your mining rig…");
// Wake at every drill end (readiness is local) and when a just-bought moon becomes
// sellable, and refetch shortly after the soonest drill end as a safety net, like the
// Defold client's arm_refetch.
effect(() => {
const st = live.value;
if (!st) return;
const ends = st.stakes.filter((s) => !P.isReady(s, now())).map((s) => s.end);
if (st.moon?.sale?.holdEnd) ends.push(st.moon.sale.holdEnd);
armEdge(ends);
armRefetch(st.nextEnd);
});
// Effects hooks (fx.js sets them): onAction(kind, prev, next, origin) when an action is
// predicted (or answered without a prediction), onAnswer(kind, data) on every accepted
// action (what only the server knows, like a moon mined out), onError(origin) on a
// refusal, onNavigate() when the player follows a link.
export const hooks = { onAction() {}, onAnswer() {}, onError() {}, onToast() {}, onNavigate() {} };
let seq = 0;
let applied = 0;
let catalog = null;
let catalogVersion = "";
const outbox = [];
let sending = null;
// ---------------------------------------------------------------- views
const VIEW_KEYS = { section: "s", moon: "moon", well: "well", pick: "pick", drill: "drill", adv: "adv" };
export function viewParams(v, extra = {}) {
const p = {};
for (const [k, q] of Object.entries(VIEW_KEYS)) if (v?.[k]) p[q] = v[k];
if (v?.past) p.list = "past";
if (v?.past && v.page > 1) p.page = String(v.page);
return { ...p, ...extra };
}
export function viewURL(v) {
const q = new URLSearchParams(viewParams(v));
const s = q.toString();
return "/app" + (s ? "?" + s : "");
}
function viewFromURL() {
const q = new URLSearchParams(location.search);
return {
section: q.get("s") ?? "",
moon: q.get("moon") ?? "",
well: q.get("well") ?? "",
past: q.get("list") === "past",
page: Number(q.get("page")) || 1,
};
}
// The view in effect for the next request: the shown one (a predicted navigation counts).
const currentView = () => shown.value?.view ?? viewFromURL();
// Keep the address bar on the shown view.
let fromHistory = false;
effect(() => {
const v = shown.value?.view;
if (!v) return;
const url = viewURL(v);
if (url === location.pathname + location.search) return;
if (fromHistory) history.replaceState(null, "", url);
else history.pushState(null, "", url);
});
addEventListener("popstate", () => {
fromHistory = true;
navigate(viewFromURL());
fromHistory = false;
});
// ---------------------------------------------------------------- requests
function enqueue(r) {
outbox.push(r);
pump();
}
async function pump() {
if (sending || !outbox.length) return;
const r = (sending = outbox.shift());
const params = { ...r.params, catalog: catalogVersion || undefined };
if (!catalogVersion) delete params.catalog;
let data = null;
let status = 0;
try {
const res =
r.kind === "get"
? await fetch("/api/state?" + new URLSearchParams(params), { credentials: "same-origin" })
: await fetch(`/api/act/${r.kind}`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...params, clientSeq: r.seq }),
});
status = res.status;
data = await res.json().catch(() => null);
} catch {
status = 0;
}
sending = null;
receive(r, data, status, Date.now());
pump();
}
function dropPending(s) {
const list = pending.value;
const i = list.findIndex((e) => e.seq === s);
if (i < 0) return false;
pending.value = list.toSpliced(i, 1);
return true;
}
function setBase(st, recv) {
if (st.catalog) {
catalog = st.catalog;
catalogVersion = st.catalogVersion;
} else st.catalog = catalog;
syncClock(st.now, recv);
P.learn(st);
remember(st);
base.value = st;
}
function receive(r, data, status, recv) {
const prev = shown.value;
let predicted = false;
batch(() => {
predicted = dropPending(r.seq);
if (data?.state && r.seq > applied) {
applied = r.seq;
setBase(data.state, recv);
}
if (r.busyKey && busy.value[r.busyKey] === r.seq) {
const b = { ...busy.value };
delete b[r.busyKey];
busy.value = b;
}
});
if (!base.value) {
boot.value =
status === 403 ? "This account is suspended." : `Could not start the game: ${status ? "HTTP " + status : "Can't reach the server"}`;
if (status !== 403) setTimeout(() => navigate(viewFromURL(), { quiet: true }), 3000);
return;
}
boot.value = "";
if (status === 403) {
location.reload();
return;
}
if (r.kind === "get") {
if (!data?.ok && status !== 200) toast(status ? "Something went wrong" : "Can't reach the server", "error", true);
return;
}
const ok = !!data?.ok;
if (!ok) hooks.onError(r.origin);
else if (!predicted) hooks.onAction(r.kind, prev, shown.value, r.origin, data);
if (ok) hooks.onAnswer(r.kind, data);
if (data?.toast) toast(data.toast.message, data.toast.kind, data.toast.error);
else if (!ok) toast(status ? "Something went wrong" : "Can't reach the server", "error", true);
}
let refetchTimer = 0;
let refetchAt = 0;
function armRefetch(due) {
if (!due || due === refetchAt) return;
clearTimeout(refetchTimer);
refetchAt = due;
const left = due - now();
if (left < -2000) return;
refetchTimer = setTimeout(() => {
refetchAt = 0;
if (!sending && !outbox.length) enqueue({ kind: "get", params: viewParams(currentView()), seq: ++seq });
}, Math.max(0, left) + 300);
}
// ---------------------------------------------------------------- toasts
let toastId = 0;
export function toast(message, kind = "", error = false) {
const t = { id: ++toastId, message, kind: error ? "error" : kind, error };
toasts.value = [...toasts.value.slice(-4), t];
hooks.onToast(t);
setTimeout(() => (toasts.value = toasts.value.map((x) => (x.id === t.id ? { ...x, out: true } : x))), 3200);
setTimeout(() => (toasts.value = toasts.value.filter((x) => x.id !== t.id)), 3800);
}
// ---------------------------------------------------------------- actions
// Run an action with the current view. origin: the clicked element (effects start there).
// Returns true when the result was predicted (shown now).
export function act(name, params = {}, origin = null, busyKey = "") {
const s = ++seq;
const p = { ...viewParams(currentView()), ...params };
const fn = P.registry[name];
let predicted = false;
const prev = shown.value;
const at = now();
if (fn && prev) {
const e = { seq: s, name, params: p, at, fn };
const st = P.copy(prev);
if (P.run(st, e)) {
pending.value = [...pending.value, e];
predicted = true;
hooks.onAction(name, prev, shown.value, rectOf(origin));
}
}
if (!predicted && busyKey) busy.value = { ...busy.value, [busyKey]: s };
enqueue({ kind: name, params: p, seq: s, origin: rectOf(origin), busyKey: predicted ? "" : busyKey });
return predicted;
}
const rectOf = (el) => {
if (!el?.getBoundingClientRect) return null;
const r = el.getBoundingClientRect();
const box = (el.closest?.(".line, tr, .moon, .heading, .deploy-card") ?? el).getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, box, el };
};
// ---------------------------------------------------------------- navigation
// Details seen in server states, by id, for predicted navigation.
const seenMoons = new Map();
function remember(st) {
if (st.moon) seenMoons.set(st.moon.id, st.moon);
}
// Go to a view; the moon and well show at once from what the client has (the moon list,
// the stakes and tools, details seen before), and the server's answer fills in the rest.
export function navigate(v, { quiet = false } = {}) {
const s = ++seq;
const target = { section: "moons", moon: "", well: "", past: false, page: 1, ...v };
const st = shown.value;
if (st) {
const e = { seq: s, name: "nav", params: target, at: now(), fn: predictNav };
const copy = P.copy(st);
if (P.run(copy, e)) pending.value = [...pending.value, e];
}
enqueue({ kind: "get", params: viewParams(target), seq: s, quiet });
}
function predictNav(st, v) {
if (v.section !== "moons" || v.past) return false;
const entry = P.find(st.moons, v.moon) ?? (!v.moon && st.moons?.[0]);
if (!entry) return false;
const seen = seenMoons.get(entry.id);
const wellId = v.well && P.find(entry.wells, v.well) ? v.well : "";
st.view = { ...st.view, section: "moons", moon: entry.id, well: wellId, past: false, aside: true };
for (const m of st.moons) m.selected = m.id === entry.id;
if (st.moon?.id !== entry.id) {
st.moon = {
id: entry.id,
name: entry.name,
kind: entry.kind,
description: seen?.description ?? "",
rating: entry.rating,
finished: false,
wells: entry.wells.map((w) => ({ ...w, drills: [], wide: [], label: "", redrill: 0 })),
wide: seen?.wide ?? null,
sale: null,
};
// Redrill: idle tools that last mined each well.
for (const w of st.moon.wells) {
w.redrill = st.tools.filter((t) => !t.stakeId && P.memo.lastWell.get(t.id) === w.id && (t.reach ?? 1) <= 1).length;
}
}
for (const w of st.moon.wells) w.selected = w.id === wellId;
if (!wellId) st.well = null;
else if (st.well?.id !== wellId) st.well = predictWell(st, P.find(entry.wells, wellId));
return true;
}
// The well panel for a well: the idle tools grouped like the server's deploy cards.
function predictWell(st, w) {
let committed = 0;
for (const s of st.stakes) if (s.wellId === w.id) committed += s.take ?? 0;
const free = Math.max(0, w.supply - committed);
const boost = st.rules?.boosts?.yield?.[w.resource] ?? 1;
const groups = new Map();
for (const t of st.tools) {
if (t.stakeId) continue;
const key = `${t.storeItemId}:${t.tier}:${(t.attachments ?? []).join("+")}`;
const g = groups.get(key);
if (g) {
g.count++;
continue;
}
const y = t.yield * boost;
groups.set(key, {
toolId: t.id,
storeItemId: t.storeItemId,
name: t.name,
tier: t.tier,
count: 1,
cycleSecs: t.cycleSecs,
yield: y,
reach: t.reach,
here: Math.min(y, free),
free,
short: free < y,
attachments: t.attachments ?? [],
});
}
const quote = st.market && P.find(st.market.quotes, w.resource, "item");
return { id: w.id, resource: w.resource, rate: quote?.price ?? 0, idle: [...groups.values()], better: null };
}
// ---------------------------------------------------------------- start
export function start() {
enqueue({ kind: "get", params: viewParams(viewFromURL()), seq: ++seq });
}
+267
View File
@@ -0,0 +1,267 @@
// The side panel next to the viewport: the selected well (deploy cards, a better well to
// deploy on, drill lines with the moon's multi-well drills that mine it too, claim,
// redrill) or the moon.
import { live, act, busy } from "./session.js";
import { Countdown, Dot, Num, SupplyBar } from "./ui.jsx";
import { ReadyButton } from "./buttons.jsx";
import { ViewLink, WideClaim, wideOn } from "./moons.jsx";
import { colorOf, imageOf, toolOf } from "./catalog.js";
import { money, cur, secs } from "./format.js";
import { useFlip } from "./flip.js";
import { now } from "./clock.js";
import { isReady, find } from "./predict.js";
export function Side() {
const st = live.value;
const moon = st.moon;
const well = st.well;
if (!moon) return null;
const w = well && find(moon.wells, well.id);
if (!well || !w) {
return (
<>
<h2>{moon.name}</h2>
{moon.finished ? (
<p class="tapped-note">Mined out. Nothing left to dig.</p>
) : (
<p class="muted small">Pick a well in the table to deploy tools and claim resources.</p>
)}
</>
);
}
return <WellPanel st={st} moon={moon} well={well} w={w} />;
}
function WellPanel({ st, moon, well, w }) {
const dry = w.supply <= 0;
const color = colorOf(w.resource);
const t = now();
const cards = useFlip();
const lines = useFlip();
const mining = w.drills.length > 0;
const wide = wideOn(moon, w);
return (
<>
<h2>
<Dot color={color} />
{w.resource} well
</h2>
{dry ? (
<>
<div class="bar" />
<p class="tapped-note">Nothing left to mine here.</p>
</>
) : (
<>
<SupplyBar supply={w.supply} demand={w.demand} max={st.rules.maxSupply} color={color} full={w.full} />
{w.demand > 0 && (
<span class={`well-note small${w.full ? " warn" : ""}`}>
<span class="num">{money(w.supply)}</span> left · <span class="num">{money(w.demand)}</span> committed ·{" "}
<span class="num">{money(w.remain)}</span> free after
</span>
)}
</>
)}
<dl class="facts">
<div>
<dt>Supply left</dt>
<dd class="num">
<Num n={w.supply} k={`side-supply:${w.id}`} />
</dd>
</div>
{w.demand > 0 && (
<div>
<dt>After current drills</dt>
<dd class={`num${w.full ? " warn" : ""}`}>{money(w.remain)}</dd>
</div>
)}
<div>
<dt>Sells for</dt>
<dd class="num">{cur(well.rate)} each</dd>
</div>
</dl>
{!dry && (
<>
<h3>Deploy a tool</h3>
{well.idle.length ? (
<div class="deploy-cards" ref={cards}>
{well.idle.map((g) => (
<DeployCard key={`${g.storeItemId}:${g.tier}:${(g.attachments ?? []).join("+")}`} st={st} g={g} wellId={well.id} />
))}
</div>
) : (
<p class="muted small">
No idle tools.
{st.unlocked.store && (
<>
{" "}
<a href="/?s=store" title="Opens the classic client">
Buy one in the store
</a>
.
</>
)}
</p>
)}
{well.idle.length > 0 && well.better && <BetterWell st={st} moon={moon} b={well.better} />}
</>
)}
<h3>
Drills
{w.drills.length > 1 && <span class="muted small"> · {w.drills.length} share this well, oldest claims first</span>}
</h3>
<div ref={lines}>
{[...w.drills, ...wide].map((d) => {
const tool = toolOf(st, d.toolId);
const ready = isReady(d, t);
return (
<div key={d.toolId} data-key={d.toolId} data-tool={d.toolId} class={`line drill-line is-${d.state ?? "ok"}`}>
<span class="drill-thumb">{tool && <img src={tool.image} alt="" />}</span>
<span class="drill-main">
<span class="drill-name">
{tool?.name ?? "Tool"} <span class="muted">T{tool?.tier ?? 0}</span>
</span>
<span class="drill-meta small">
<span class="muted">
<span class="num">+{money(d.amount)}</span>/cycle
{d.reachText && ` · ${d.reachText}`}
</span>
{d.state === "none" ? (
<span class="drill-tag">Nothing left</span>
) : (
<span class="drill-gets">
{" "}
· Gets <span class="num">{money(d.here ?? d.take)}</span>
{(d.reach ?? 1) > 1 && " here"}
{(d.reach ?? 1) > 1 && d.gets != null && d.gets !== d.here && (
<>
{" "}
· <span class="num">{money(d.gets)}</span> in all
</>
)}
{d.state === "short" && (
<>
{" "}
of <span class="num">{money(d.amount)}</span>
</>
)}
</span>
)}
</span>
</span>
{ready ? <span class="ready small">Ready</span> : <Countdown end={d.end} class="num muted small" />}
</div>
);
})}
</div>
{!mining && !wide.length && <p class="muted small">Nothing is mining here yet.</p>}
{mining && (
<div class="actions">
<ReadyButton class="btn btn-primary" ready={w.ready > 0} onClick={(e) => act("claim", { "claim-well": w.id }, e.currentTarget)}>
Claim {w.ready > 0 ? <Num class="num" n={w.readyAmount} k={`side-ready:${w.id}`} /> : <Countdown end={w.nextEnd} />}
</ReadyButton>
{w.ready > 0 && !dry && st.unlocked.store && (
<button type="button" class="btn" onClick={(e) => act("claim", { "claim-well": w.id, redeploy: "1" }, e.currentTarget)}>
Claim and redeploy
</button>
)}
</div>
)}
{wide.length > 0 && (
<div class="actions">
<WideClaim moon={moon} />
{moon.wide.ready > 0 && st.unlocked.store && (
<button type="button" class="btn" onClick={(e) => act("claim", { "claim-wide": moon.id, redeploy: "1" }, e.currentTarget)}>
Claim and redeploy
</button>
)}
</div>
)}
{w.redrill > 0 && !dry && st.unlocked.market && (
<div class="actions">
<button type="button" class="btn btn-redrill" onClick={(e) => act("redrill", { "redrill-well": w.id }, e.currentTarget)}>
Redrill{w.redrill > 1 ? ` ${w.redrill}` : ""}
</button>
</div>
)}
</>
);
}
function DeployCard({ st, g, wellId }) {
const key = `${g.storeItemId}:${g.tier}:${(g.attachments ?? []).join("+")}`;
const cls = g.short ? (g.here > 0 ? " is-short" : " is-spoken") : "";
return (
<button
type="button"
data-key={key}
class={`btn deploy-card${cls}`}
title={`Deploy ${g.name} T${g.tier}`}
onClick={(e) => act("stake", { tool: g.toolId, target: wellId }, e.currentTarget, `stake:${key}`)}
>
<span class="deploy-card-media">
<img src={imageOf(st, g.storeItemId)} alt="" />
{g.attachments?.length > 0 && (
<span class="deploy-card-pips pips">
{g.attachments.map((a, i) => (
<i key={i} class="pip is-on" />
))}
</span>
)}
{g.count > 1 && <span class="deploy-card-count num">×{g.count}</span>}
</span>
<span class="deploy-card-info">
<span class="deploy-card-name">
<span class="deploy-card-title">{g.name}</span> <span class="tier-chip num">T{g.tier}</span>
</span>
<span class="deploy-card-meta">
<span class="num">
+{money(g.here)} / {secs(g.cycleSecs)}
</span>
<span class="deploy-card-go">Deploy</span>
</span>
{g.short && (
<span class="deploy-card-note">
{g.here > 0 ? (
<>
Only <span class="num">{money(g.free)}</span> left after current drills
</>
) : (
"Well spoken for: nothing left after current drills"
)}
</span>
)}
</span>
</button>
);
}
// A better well for the idle tools (more free supply), maybe on another moon: deploying
// there moves the view to it, so it waits for the server (the button spins).
function BetterWell({ st, moon, b }) {
const other = b.moonId !== moon.id ? find(st.moons, b.moonId)?.name : null;
const spinning = !!busy.value["stake:better"];
const deploy = (e) => act("stake", { tool: b.toolId, target: b.wellId, s: "moons", moon: b.moonId, well: b.wellId }, e.currentTarget, "stake:better");
return (
<div class="line better-well">
<span class="grow small">
Better:{" "}
<ViewLink to={{ section: "moons", moon: b.moonId, well: b.wellId }}>
<Dot color={colorOf(b.resource)} />
{b.resource} well
</ViewLink>
{other && ` on ${other}`}, <span class="num">{money(b.free)}</span> free
</span>
{b.toolId && (
<button type="button" class={`btn btn-sm btn-primary${spinning ? " htmx-request" : ""}`} disabled={spinning} title="Deploy on that well" onClick={deploy}>
Deploy there
</button>
)}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
// Small display pieces shared by the sections. Values that change every frame (eased
// numbers, countdowns, drill bars) are written by the frame loop or the Web Animations
// API on elements Preact created once, so a re-render never replaces or restarts them.
import { useRef, useEffect, useLayoutEffect } from "preact/hooks";
import { addTicker, eased, moving, now } from "./clock.js";
import { money, fixed, decimals, left, hue } from "./format.js";
import { mark } from "/static/js/fx.js";
// A number that eases to its new value (key: what it is, e.g. "credits"). value: a signal
// or computed, or n: a plain number (the component re-renders with it). A gain pops, a loss nudges (CSS fx-gain / fx-loss), unless something holds
// it (a claim's resources wait for their particles).
export function Num({ value, n, k, prefix = "", class: cls, ...rest }) {
const ref = useRef(null);
const src = useRef(null);
src.current = value ?? n;
const read = () => (typeof src.current === "number" ? src.current : src.current.peek());
useLayoutEffect(() => {
let last = read();
let digits = decimals(last);
return addTicker((t) => {
const el = ref.current;
if (!el) return;
const truth = read();
if (truth !== last) {
if (!moving(k)) mark(el, truth > last ? "fx-gain" : "fx-loss");
digits = Math.max(decimals(last), decimals(truth));
last = truth;
}
const v = eased(k, truth, t);
// Whole counts tick as whole numbers; others keep their decimals while moving.
const text = prefix + (v === truth ? money(truth) : Number.isInteger(truth) ? money(Math.round(v)) : fixed(v, digits));
if (el.textContent !== text) el.textContent = text;
});
}, [k]);
return <span ref={ref} class={cls} data-num={k} {...rest} />;
}
// Time left until end (unix ms, server clock), then `done`.
export function Countdown({ end, done = "", class: cls = "num" }) {
const ref = useRef(null);
const endRef = useRef(end);
endRef.current = end;
useLayoutEffect(
() =>
addTicker(() => {
const el = ref.current;
if (!el) return;
const ms = endRef.current - now();
const text = ms > 0 ? left(ms) : done;
if (el.textContent !== text) el.textContent = text;
}),
[]
);
return <time ref={ref} class={cls} />;
}
// A drill's timer bar (keyed by tool id, so the server's correction of a predicted stake
// keeps the element). The fill is a compositor animation (transform); a correction
// changes its playback rate so it lands on the true end instead of jumping.
export function DrillBar({ start, end, state, ready, wide }) {
const fill = useRef(null);
const anim = useRef(null);
useLayoutEffect(() => {
const el = fill.current;
const d = Math.max(1, end - start);
const t = now();
let a = anim.current;
if (!a) {
a = anim.current = el.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
duration: d,
fill: "both",
easing: "linear",
});
a.currentTime = Math.min(d, Math.max(0, t - start));
return;
}
// Correction: keep the progress shown, finish at the true end.
const timing = a.effect.getComputedTiming();
const p = Math.min(1, (timing.progress ?? (a.playState === "finished" ? 1 : 0)));
a.effect.updateTiming({ duration: d });
a.currentTime = p * d;
const rest = end - t;
if (rest <= 0 || p >= 1) {
a.updatePlaybackRate(1);
a.currentTime = rest <= 0 ? d : a.currentTime;
return;
}
a.updatePlaybackRate(Math.min(4, Math.max(0.25, ((1 - p) * d) / rest)));
}, [start, end]);
useEffect(() => () => anim.current?.cancel(), []);
return (
<i class={`drill-bar pa-bar is-${state}${wide ? " is-wide" : ""}${ready ? " is-ready" : ""}`}>
<b ref={fill} class="pa-fill" />
</i>
);
}
export const Dot = ({ color }) => <span class="dot" style={{ background: color ?? "#e2e8f0" }} aria-hidden="true" />;
export const Planet = ({ id }) => <span class="planet" style={{ "--hue": hue(id) }} aria-hidden="true" />;
export function RatingChip({ r }) {
if (!r) return null;
return (
<span class={`rating-chip tier-${r.tier}`} title={`${r.label} moon · top ${r.top}%`} aria-label={`${r.label} moon, ${r.stars} of 5 stars`}>
★{r.stars}
</span>
);
}
// A supply bar: what stays after the drills (solid), then what they will take (striped).
// Both parts span the bar and are clipped (paint only, no layout), so a change glides
// without stretching the cells or stripes.
export function SupplyBar({ supply, demand, max, color, full, showNum }) {
const m = Math.max(1, max);
const remain = Math.max(0, supply - demand);
const taken = Math.min(demand, supply);
const r = Math.min(1, remain / m);
const u = Math.min(1 - r, taken / m);
const pct = (x) => `${(x * 100).toFixed(2)}%`;
return (
<div class={`supply${full ? " is-full" : ""}`}>
<div class="bar bar-split pa-split">
<span style={{ clipPath: `inset(0 ${pct(1 - r)} 0 0)`, background: color }} />
<span class="bar-used" style={{ clipPath: `inset(0 ${pct(1 - r - u)} 0 ${pct(r)})`, background: color }} />
</div>
{showNum && <Num class="num muted" n={supply} k={`supply:${showNum}`} />}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present Jason Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2
View File
@@ -0,0 +1,2 @@
import{options as n}from"preact";var t,r,u,i,o=Object.is,f=0,c=[],e=[],a=n,v=a.__b,l=a.__r,m=a.diffed,s=a.__c,h=a.unmount,p=a.__;function y(n,t){a.__h&&a.__h(r,n,f||t),f=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function d(n){return f=1,_(G,n)}function _(n,u,i){var f=y(t++,2);if(f.t=n,!f.__c&&(f.__=[i?i(u):G(void 0,u),function(n){var t=f.__N?f.__N[0]:f.__[0],r=f.t(t,n);o(t,r)||(f.__N=[r,f.__[1]],f.__c.setState({}))}],f.__c=r,!r.__f)){r.__f=!0;var c=r.shouldComponentUpdate;r.shouldComponentUpdate=function(n,t,r){var u=this.__H;if(!u)return!0;var i=!1,f=this.props!=n;if(u.__.some(function(n){n.__N&&(i=!0,o(n.__[0],n.__N[0])||(f=!0))}),c){var e=c.call(this,n,t,r);return i?e||f:e}return!i||f}}return f.__}function A(n,u){var i=y(t++,3);!a.__s&&E(i.__H,u)&&(i.__P=!0,i.__=n,i.u=u,r.__H.__h.push(i))}function F(n,u){var i=y(t++,4);!a.__s&&E(i.__H,u)&&(i.__P=!1,i.__=n,i.u=u,r.__h.push(i))}function T(n){return f=5,b(function(){return{current:n}},[])}function q(n,t,r){f=6,F(function(){if("function"==typeof n){var r=n(t());return function(){n(null),r&&"function"==typeof r&&r()}}if(n)return n.current=t(),function(){return n.current=null}},null==r?r:r.concat(n))}function b(n,r){var u=y(t++,7);return E(u.__H,r)&&(u.__=n(),u.__H=r),u.__}function j(n,t){return f=8,b(function(){return n},t)}function w(n){var u=r.context[n.__c],i=y(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function x(n,t){a.useDebugValue&&a.useDebugValue(t?t(n):n)}function O(n){var u=y(t++,10),i=d();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function P(){var n=y(t++,11);if(!n.__){for(var u=r.__v;!u.__m&&u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function g(){var n;do{for(;n=e.shift();)try{C(n)}catch(t){a.__e(t,{__:(n=n.__P)&&n.__v})}for(;n=c.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(C),t.__h.some(D),t.__h=[]}catch(r){t.__h=[],a.__e(r,n.__v)}}}while(e.length)}a.__b=function(n){r=null,v&&v(n)},a.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t)},a.__r=function(n){l&&l(n),t=0;var i=(r=n.__c).__H;i&&(u==r?r.__h=[]:(i.__h.some(C),i.__h.some(D),t=0),i.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})),u=r},a.diffed=function(n){m&&m(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&B(c.push(t)),t.__H.__.some(function(n){n.u&&(n.__H=n.u)})),u=r=null},a.__c=function(n,t){t.some(function(n){try{n.__h.some(C),n.__h=n.__h.filter(function(n){return!n.__||D(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],a.__e(r,n.__v)}}),s&&s(n,t)},a.unmount=function(n){h&&h(n);var t,r,u=n.__c;u&&u.__H&&(u.__H.__.some(function(u){try{if(u.__P&&u.__c){if(void 0===r){for(r=n.__;r&&(!r.__c||!r.__c.__P);)r=r.__;r=r&&r.__c}u.__P=r,B(e.push(u))}else C(u)}catch(n){t=n}}),u.__H=void 0,t&&a.__e(t,u.__v))};var k="function"==typeof requestAnimationFrame;function z(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r))}function B(n){1!=n&&i==a.requestAnimationFrame||((i=a.requestAnimationFrame)||z)(g)}function C(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function D(n){var t=r;n.__c=n.__(),r=t}function E(n,t){return!n||n.length!=t.length||t.some(function(t,r){return!o(t,n[r])})}function G(n,t){return"function"==typeof t?t(n):t}export{j as useCallback,w as useContext,x as useDebugValue,A as useEffect,O as useErrorBoundary,P as useId,q as useImperativeHandle,F as useLayoutEffect,b as useMemo,_ as useReducer,T as useRef,d as useState};
//# sourceMappingURL=hooks.mjs.map
+2
View File
@@ -0,0 +1,2 @@
import{options as r,Fragment as t}from"preact";export{Fragment}from"preact";var e=/["&<]/;function n(r){if(!r.length||!e.test(r))return r;for(var t=0,n=0,o="",f="";n<r.length;n++){switch(r.charCodeAt(n)){case 34:f="&quot;";break;case 38:f="&amp;";break;case 60:f="&lt;";break;default:continue}n!=t&&(o+=r.slice(t,n)),o+=f,t=n+1}return n!=t&&(o+=r.slice(t,n)),o}var o=0,f=Array.isArray;function u(t,e,n,f,u,i){e||(e={});var a,c,l=e;if("ref"in l&&"function"!=typeof t)for(c in l={},e)"ref"==c?a=e[c]:l[c]=e[c];var p={type:t,props:l,key:n,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--o,__i:-1,__u:0};return(u||i)&&(p.__source=u,p.__self=i),r.vnode&&r.vnode(p),p}function i(r){var e=u(t,{tpl:r,exprs:[].slice.call(arguments,1)});return e.key=e.__v,e}var a={},c=/[A-Z]/g;function l(t,e){if(r.attr){var o=r.attr(t,e);if("string"==typeof o)return o}if(e=function(r){return null!=r&&"object"==typeof r&&"function"==typeof r.valueOf?r.valueOf():r}(e),"ref"==t||"key"==t)return"";if("style"==t&&"object"==typeof e){var f="";for(var u in e){var i=e[u];null!=i&&""!==i&&(f=f+("-"==u[0]?u:a[u]||(a[u]=u.replace(c,"-$&").toLowerCase()))+":"+i+";")}return t+'="'+n(f)+'"'}return null==e||!1===e||"function"==typeof e||"object"==typeof e?"":!0===e?t:t+'="'+n(""+e)+'"'}function p(r){if(null==r||"boolean"==typeof r||"function"==typeof r)return null;if("object"==typeof r){if(void 0===r.constructor)return r;if(f(r)){for(var t=0;t<r.length;t++)r[t]=p(r[t]);return r}}return n(""+r)}export{u as jsx,l as jsxAttr,u as jsxDEV,p as jsxEscape,i as jsxTemplate,u as jsxs};
//# sourceMappingURL=jsxRuntime.mjs.map
+2
View File
@@ -0,0 +1,2 @@
var n,t,i,r,u,f,o,e,l,c,a,s,h={},p=[],v=/^m(i|n|o|s|text|space)$/,y=Array.isArray,d=p.slice,w=Object.assign;function _(n){n&&n.parentNode&&n.remove()}function g(n,t,i){var r,u,f,o={},e=arguments.length;for(f in t)"key"==f?r=t[f]:"ref"==f&&"function"!=typeof n?u=t[f]:o[f]=t[f];return e>2&&(o.children=e>3?d.call(arguments,2):i),b(n,o,r,u,null)}function k(n,t,i){var r,u,f,o=w({},n.props),e=arguments.length;for(f in t)"key"==f?r=t[f]:"ref"==f&&"function"!=typeof n.type?u=t[f]:o[f]=t[f];return e>2&&(o.children=e>3?d.call(arguments,2):i),b(n.type,o,void 0!==r?r:n.key,void 0!==u?u:n.ref,null)}function b(i,r,u,f,o){var e={type:i,props:r,key:u,ref:f,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&n.vnode&&n.vnode(e),e}function m(){return{current:null}}function M(n){return n.children}function $(n,t){this.props=n,this.context=t,this.__g=0}function x(n,t){if(null==t)return n.__?x(n.__,n.__i+1):null;for(var i;t<n.__k.length;t++)if((i=n.__k[t])&&i.__e)return i.__e;return"function"!=typeof n.type||n.props.__P?null:x(n)}function S(n){if((n=n.__)&&n.__c&&!n.props.__P)return n.__e=null,n.__k.some(function(t){return t&&(n.__e=t.__e)}),S(n)}function C(t){(8&t.__g||!(t.__g|=8)||!r.push(t)||f++)&&u==n.debounceRendering||((u=n.debounceRendering)||queueMicrotask)(j)}function j(){var t,i,u,e,l,c,a,s,h;try{for(i=1;r.length;)r.length>i&&r.sort(o),t=r.shift(),i=r.length,8&t.__g&&(e=void 0,l=void 0,c=(l=(u=t).__v).__e,a=[],s=[],(h=u.__P)&&((e=w({constructor:void 0},l)).__v=l.__v+1,n.vnode&&n.vnode(e),N(h,e,l,u.__n,h.namespaceURI,32&l.__u?[c]:null,a,c||x(l),32&l.__u,s),e.__v=l.__v,e.__.__k[e.__i]=e,z(a,e,s),l.__=l.__e=null,e.__e!=c&&S(e)))}finally{r.length=f=0}}function L(n,t,i,r,u,f,o,e,l,c,a){var s,v,y,d,w,_,g=r.__k||p,k=t.length;for(l=H(i,t,g,l,k),s=0;s<k;s++)null!=(y=i.__k[s])&&(v=~y.__i&&g[y.__i]||h,y.__i=s,_=N(n,y,v,u,f,o,e,l,c,a),d=y.__e,(v.ref!=y.ref||8&v.__u)&&(v.ref&&D(v.ref,null,y,v),y.ref&&a.push(y.ref,y.__c||d,y)),w=w||d,4&y.__u?(l=I(y,l,n,!v.__v),v.__e&&(v.__e=null)):"function"==typeof y.type&&void 0!==_?l=_:d&&(l=d.nextSibling),y.__u&=-7);return i.__e=w,l}function H(n,t,i,r,u){var f,o,e,l,c,a,s,h,p,v,d=i.length,w=d,_=0,g=!1,k=n.__k=Array(u);for(f=0;f<u;f++)null!=(o=t[f])&&"boolean"!=typeof o&&"function"!=typeof o?("object"!=typeof o||o.constructor==String?o=k[f]=b(null,o):y(o)?o=k[f]=b(M,{children:o}):void 0===o.constructor&&o.__b?o=k[f]=b(o.type,o.props,o.key,o.ref,o.__v):k[f]=o,l=f+_,o.__=n,o.__b=n.__b+1,e=null,~(c=o.__i=O(o,i,l,w))&&(w--,(e=i[c])&&(e.__u|=2)),e&&e.__v?(o.__u|=2,c==l-1?_--:c==l+1?_++:c!=l&&(c>l?_--:_++,g=!0)):(~c||(u>d?_--:u<d&&_++),"function"!=typeof o.type&&(o.__u|=4))):k[f]=null;if(g){for(a=[],s=[],f=0;f<u;f++)if((o=k[f])&&2&o.__u){for(h=0,p=a.length;h<p;)a[v=h+p>>1]<o.__i?h=v+1:p=v;a[h]=o.__i,s[f]=h+1}for(_=a.length;f--;)s[f]&&(s[f]==_?_--:k[f].__u|=4)}if(w)for(f=0;f<d;f++)!(e=i[f])||2&e.__u||(e.__e==r&&(r=x(e)),E(e,e));return r}function I(n,t,i,r){var u,f,o;if("function"==typeof n.type){if(n.props.__P)return t;if(u=n.__k)for(f=0;f<u.length;f++)u[f]&&(u[f].__=n,t=I(u[f],t,i,!1));return t}for(t&&!t.parentNode&&(t=x(n))&&!t.parentNode&&(t=null),o=t;o&&8==o.nodeType;)o=o.nextSibling;for(n.__e!=o&&(!r&&i.moveBefore&&n.__e.parentNode?i.moveBefore(n.__e,t):i.insertBefore(n.__e,t||null)),t=n.__e;(t=t&&t.nextSibling)&&8==t.nodeType;);return t}function A(n,t){return t=t||[],null!=n&&"boolean"!=typeof n&&(y(n)?n.some(function(n){A(n,t)}):t.push(n)),t}function O(n,t,i,r){var u,f,o,e=n.key,l=n.type,c=t[i],a=c&&!(2&c.__u);if(null===c&&null==e||a&&e==c.key&&l==c.type)return i;if(r>(a?1:0))for(u=i-1,f=i+1;u>=0||f<t.length;)if((c=t[o=u>=0?u--:f++])&&!(2&c.__u)&&e==c.key&&l==c.type)return o;return-1}function P(n,t,i){null==i&&(i=""),"-"==t[0]?n.setProperty(t,i):n[t]=i}function T(n,t,i,r,u){var f,o;n:if("style"==t)if("string"==typeof i)n.style.cssText=i;else{if("string"==typeof r&&(n.style.cssText=r=""),r)for(t in r)i&&t in i||P(n.style,t,"");if(i)for(t in i)r&&i[t]==r[t]||P(n.style,t,i[t])}else if("o"==t[0]&&"n"==t[1])(n.__e||(n.__e={}))[t]=i,i&&r||(o=a[t]||(a[t]=q(t)),(n.__a||(n.__a={}))[t]=c,f=t!=(t=t.replace(l,"$1")),(t=t.slice(2))[0]<"a"&&(t=t.toLowerCase()),i?n.addEventListener(t,o,f):n.removeEventListener(t,o,f));else{if("http://www.w3.org/2000/svg"==u)t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=t&&"height"!=t&&"href"!=t&&"list"!=t&&"form"!=t&&"tabIndex"!=t&&"download"!=t&&"rowSpan"!=t&&"colSpan"!=t&&"role"!=t&&"popover"!=t&&t in n)try{n[t]=null==i?"":i;break n}catch(n){}"function"==typeof i||(null==i||!1===i&&"-"!=t[4]?n.removeAttribute(t):n.setAttribute(t,"popover"==t&&1==i?"":i))}}function q(t){return function(i){if(this.__e){var r=this.__e[t];if(null==i[e])i[e]=c++;else if(i[e]<this.__a[t])return;return r(n.event?n.event(i):i)}}}function N(t,i,r,u,f,o,e,l,c,a){var s,h,v,d,g,k,b,m,S,C,j,H,I,A,O,P,T,q,N,z,D=i.type;if(void 0!==i.constructor)return null;if(128&r.__u&&(c=32&r.__u,s=r.__c.__z)){if(i.__u|=c,h=o=[],8==s.nodeType)for(v=1,d=s.Line truncated
//# sourceMappingURL=preact.mjs.map
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022-present Preact Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
const i=Symbol.for("preact-signals");function t(){if(e>1){e--;return}let i,t=!1;!function(){let i=f;f=void 0;while(void 0!==i){const t=i.S;if(t.v===i.v)for(let n=t.t;void 0!==n;n=n.x)if(n.i===i.i)n.i=t.i;i=i.o}}();while(void 0!==h){let n=h;h=void 0;c++;while(void 0!==n){const o=n.u;n.u=void 0;n.f&=-3;if(!(8&n.f)&&w(n))try{n.c()}catch(n){if(!t){i=n;t=!0}}n=o}}c=0;e--;if(t)throw i}function n(i){if(e>0)return i();d=++u;e++;try{return i()}finally{t()}}let o,s,h;function r(i){const t=o,n=s;o=void 0;s=void 0;try{return i()}finally{o=t;s=n}}let f,e=0,c=0,u=0,d=0,v=0;function l(i){if(void 0===o)return;let t=i.n;if(void 0===t||t.t!==o){t={i:0,S:i,p:o.s,n:void 0,t:o,e:void 0,x:void 0,r:t};if(void 0!==o.s)o.s.n=t;o.s=t;i.n=t;if(32&o.f)i.S(t);return t}else if(-1===t.i){t.i=0;if(void 0!==t.n){t.n.p=t.p;if(void 0!==t.p)t.p.n=t.n;t.p=o.s;t.n=void 0;o.s.n=t;o.s=t}return t}}function a(i,t){this.v=i;this.i=0;this.n=void 0;this.t=void 0;this.l=0;this.W=null==t?void 0:t.watched;this.Z=null==t?void 0:t.unwatched;this.name=null==t?void 0:t.name}a.prototype.brand=i;a.prototype.h=function(){return!0};a.prototype.S=function(i){const t=this.t;if(t!==i&&void 0===i.e){i.x=t;this.t=i;if(void 0!==t)t.e=i;else r(()=>{var i;null==(i=this.W)||i.call(this)})}};a.prototype.U=function(i){if(void 0!==this.t){const t=i.e,n=i.x;if(void 0!==t){t.x=n;i.e=void 0}if(void 0!==n){n.e=t;i.x=void 0}if(i===this.t){this.t=n;if(void 0===n)r(()=>{var i;null==(i=this.Z)||i.call(this)})}}};a.prototype.subscribe=function(i){return j(()=>{const t=this.value;r(()=>i(t))},{name:"sub"})};a.prototype.valueOf=function(){return this.value};a.prototype.toString=function(){return this.value+""};a.prototype.toJSON=function(){return this.value};a.prototype.peek=function(){return r(()=>this.value)};Object.defineProperty(a.prototype,"value",{get(){const i=l(this);if(void 0!==i)i.i=this.i;return this.v},set(i){if(i!==this.v){if(c>100)throw new Error("Cycle detected");!function(i){if(0!==e&&0===c)if(i.l!==d){i.l=d;f={S:i,v:i.v,i:i.i,o:f}}}(this);this.v=i;this.i++;v++;e++;try{for(let i=this.t;void 0!==i;i=i.x)i.t.N()}finally{t()}}}});function y(i,t){return new a(i,t)}function w(i){for(let t=i.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function _(i){for(let t=i.s;void 0!==t;t=t.n){const n=t.S.n;if(void 0!==n)t.r=n;t.S.n=t;t.i=-1;if(void 0===t.n){i.s=t;break}}}function b(i){let t,n=i.s;while(void 0!==n){const i=n.p;if(-1===n.i){n.S.U(n);if(void 0!==i)i.n=n.n;if(void 0!==n.n)n.n.p=i}else t=n;n.S.n=n.r;if(void 0!==n.r)n.r=void 0;n=i}i.s=t}function p(i,t){a.call(this,void 0,t);this.x=i;this.s=void 0;this.g=v-1;this.f=4}p.prototype=new a;p.prototype.h=function(){this.f&=-3;if(1&this.f)return!1;if(32==(36&this.f))return!0;this.f&=-5;if(this.g===v)return!0;this.g=v;this.f|=1;if(this.i>0&&!w(this)){this.f&=-2;return!0}const i=o;try{_(this);o=this;const i=this.x();if(16&this.f||this.v!==i||0===this.i){this.v=i;this.f&=-17;this.i++}}catch(i){this.v=i;this.f|=16;this.i++}o=i;b(this);this.f&=-2;return!0};p.prototype.S=function(i){if(void 0===this.t){this.f|=36;for(let i=this.s;void 0!==i;i=i.n)i.S.S(i)}a.prototype.S.call(this,i)};p.prototype.U=function(i){if(void 0!==this.t){a.prototype.U.call(this,i);if(void 0===this.t){this.f&=-33;for(let i=this.s;void 0!==i;i=i.n)i.S.U(i)}}};p.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(let i=this.t;void 0!==i;i=i.x)i.t.N()}};Object.defineProperty(p.prototype,"value",{get(){if(1&this.f)throw new Error("Cycle detected");const i=l(this);this.h();if(void 0!==i)i.i=this.i;if(16&this.f)throw this.v;return this.v}});function g(i,t){return new p(i,t)}function S(i){const n=i.m;i.m=void 0;if("function"==typeof n){e++;const s=o;o=void 0;try{n()}catch(t){i.f&=-2;i.f|=8;m(i);throw t}finally{o=s;t()}}}function m(i){for(let t=i.s;void 0!==t;t=t.n)t.S.U(t);i.x=void 0;i.s=void 0;S(i)}function x(i){if(o!==this)throw new Error("Out-of-order effect");b(this);o=i;this.f&=-2;if(8&this.f)m(this);t()}function E(i,t){this.x=i;this.m=void 0;this.s=void 0;this.u=void 0;this.f=32;this.name=null==t?void 0:t.name;if(s)s.push(this)}E.prototype.c=function(){const i=this.S();try{if(8&this.f)return;if(void 0===this.x)return;const t=this.x();if("function"==typeof t)this.m=t}finally{i()}};E.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1;this.f&=-9;S(this);_(this);e++;const i=o;o=this;return x.bind(this,i)};E.prototype.N=function(){if(!(2&this.f)){this.f|=2;this.u=h;h=this}};E.prototype.d=function(){this.f|=8;if(!(1&this.f))m(this)};E.prototype.dispose=function(){this.d()};function j(i,t){const n=new E(i,t);try{n.c()}catch(i){n.d();throw i}const o=n.d.bind(n);o[Symbol.dispose]=o;return o}function C(i){return function(...t){return n(()=>r(()=>i.apply(this,t)))}}const O=i=>{for(const t in i){const n=i[t];if("function"==typeof n)i[t]=C(n);else if("object"==typeof n&&null!==n&&!("brand"in n))O(n)}};function k(i){return function(...t){let n,o;const h=function(){let i=s;s=[];return function(){let t=s;if(s&&i)i=i.concat(sLine truncated
+1
View File
@@ -0,0 +1 @@
import{Component as t,options as i,isValidElement as n,Fragment as e}from"preact";import{useMemo as o,useRef as f,useEffect as r}from"preact/hooks";import{effect as u,Signal as c,computed as s,signal as l,batch as a}from"@preact/signals-core";export{Signal,action,batch,computed,createModel,effect,signal,untracked}from"@preact/signals-core";const h="undefined"!=typeof window&&!!window.__PREACT_SIGNALS_DEVTOOLS__;let d,p,m,v=[],_=[];u(function(){d=this.N})();function g(t,n){i[t]=n.bind(null,i[t]||(()=>{}))}function b(t){if(m){const t=m;m=void 0;t()}m=t&&t.S()}function y({data:t}){const i=useSignal(t);i.name="ReactiveDom";i.value=t;const[e,f]=o(()=>{let t=this,e=this.__v;while(e=e.__)if(e.__c){e.__c.__$f|=4;break}const o=s(()=>{let t=i.value.value;return 0===t?0:!0===t?"":t||""}),f=s(()=>!Array.isArray(o.value)&&!n(o.value)),r=u(function(){this.N=F;if(f.value){const i=o.value;if(t.__v&&t.__v.__e&&3===t.__v.__e.nodeType)t.__v.__e.data=i}}),c=this.__$u.d;this.__$u.d=function(){r();c.call(this)};return[f,o]},[]);return e.value?f.peek():f.value}y.displayName="ReactiveTextNode";Object.defineProperties(c.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:y},props:{configurable:!0,get(){const t=this;return{data:{get value(){return t.value}}}}},__b:{configurable:!0,value:1}});g("__b",(t,i)=>{b();p=void 0;if("string"==typeof i.type){let t,n=i.props;for(let e in n){if("children"===e)continue;let o=n[e];if(o instanceof c){if(!t)i.__np=t={};t[e]=o;n[e]=o.peek()}}}t(i)});g("__r",(t,i)=>{t(i);if(i.type!==e){b();let t,n=i.__c;if(n){n.__$f&=-2;t=n.__$u;if(void 0===t)n.__$u=t=function(t,i){let n;u(function(){n=this},{name:i});n.c=t;return n}(function(t){return function(){var i;if(h)null==(i=this.y)||i.call(this);t.__$f|=1;t.setState({})}}(n),"function"==typeof i.type?i.type.displayName||i.type.name:"")}p=n;b(t)}});g("__e",(t,i,n,e)=>{b();p=void 0;t(i,n,e)});g("diffed",(t,i)=>{b();p=void 0;let n;if("string"==typeof i.type&&(n=i.__e)){let t=i.__np,e=i.props,o=n.U;if(o)for(let i in o){let n=o[i];if(!(void 0===n||t&&i in t)){n.d();o[i]=void 0}}if(t){if(!o){o={};n.U=o}for(let i in t){let f=o[i],r=t[i];if(void 0===f){f=w(n,i,r,e);o[i]=f}else f.o(r,e)}}}t(i)});function w(t,i,n,e){const o=i in t&&void 0===t.ownerSVGElement,f=l(n);return{o:(t,i)=>{f.value=t;e=i},d:u(function(){this.N=F;const n=f.value.value;if(e[i]!==n){e[i]=n;if(o)t[i]=n;else if(null!=n&&(!1!==n||"-"===i[4]))t.setAttribute(i,n);else t.removeAttribute(i)}})}}g("unmount",(t,i)=>{if("string"==typeof i.type){let t=i.__e;if(t){const i=t.U;if(i){t.U=void 0;for(let t in i){let n=i[t];if(n)n.d()}}}let n=i.__np;if(n){let t=i.props;for(let i in n)t[i]=n[i]}i.__np=void 0}else{let t=i.__c;if(t){t.__$f|=8;const i=t.__$u;if(i){t.__$u=void 0;i.d()}}}t(i)});g("__h",(t,i,n,e)=>{if(e<3)i.__$f|=2;t(i,n,e)});t.prototype.shouldComponentUpdate=function(t,i){if(this.__R)return!0;const n=this.__$u,e=n&&void 0!==n.s;for(let t in i)return!0;if(this.__f||"boolean"==typeof this.u&&!0===this.u){const t=2&this.__$f;if(!(e||t||4&this.__$f))return!0;if(1&this.__$f)return!0}else{if(!(e||4&this.__$f))return!0;if(3&this.__$f)return!0}for(let i in t)if("__source"!==i&&t[i]!==this.props[i])return!0;for(let i in this.props)if(!(i in t))return!0;return!1};function useSignal(t,i){return o(()=>l(t,i),[])}function useComputed(t,i){const n=f(t);n.current=t;p.__$f|=4;return o(()=>s(()=>n.current(),i),[])}const k="undefined"==typeof requestAnimationFrame?setTimeout:function(t){const i=()=>{clearTimeout(n);cancelAnimationFrame(e);t()},n=setTimeout(i,35),e=requestAnimationFrame(i)},q=t=>{queueMicrotask(()=>{queueMicrotask(t)})};function A(){a(()=>{let t;while(t=v.shift())d.call(t)})}function T(){if(1===v.push(this))(i.requestAnimationFrame||k)(A)}function x(){a(()=>{let t;while(t=_.shift())d.call(t)})}function F(){if(1===_.push(this))(i.requestAnimationFrame||q)(x)}function useSignalEffect(t,i){const n=f(t);n.current=t;const e=p;r(()=>u(function(){this.N=T;if(!(e&&8&e.__$f))return n.current()},i),[])}function M(t){const i=o(()=>t(),[]);r(()=>i[Symbol.dispose],[i]);return i}export{useComputed,M as useModel,useSignal,useSignalEffect};//# sourceMappingURL=signals.mjs.map
+32
View File
@@ -0,0 +1,32 @@
// The moon viewport: the classic client's three.js scene (scene.js, terrain.js), shown
// for the selected moon. Its element is rendered once and never touched by Preact again.
import { useRef, useEffect } from "preact/hooks";
import { effect } from "@preact/signals";
import { memo } from "./memo.js";
import { shown } from "./session.js";
import { startViewport } from "./scene.js";
export const Viewport = memo(function Viewport() {
const ref = useRef(null);
useEffect(() => {
const view = startViewport(ref.current);
let last = "";
const stop = effect(() => {
const id = shown.value?.moon?.id ?? "";
if (id && id !== last) {
last = id;
view.show(id);
}
});
return () => {
stop();
view.dispose();
};
}, []);
return (
<figure id="viewport" class="viewport" ref={ref}>
<figcaption class="cap">Orbital scan · drag to rotate</figcaption>
</figure>
);
});
+82 -1
View File
@@ -1288,6 +1288,74 @@ a.htmx-request::after {
.advisor-card .card-buy.ready { .advisor-card .card-buy.ready {
text-align: center; text-align: center;
} }
/* Advisor seats (who sits where), the hired advisors without a seat, and the advisor
panel's portrait. */
.adv-seats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 12px;
margin: 16px 0;
padding: 0;
list-style: none;
}
.adv-seat-in {
display: flex;
align-items: center;
gap: 12px;
min-height: 76px;
padding: 10px 12px;
border: 1px solid rgb(var(--ready-rgb) / 0.4);
background: var(--panel);
color: inherit;
text-decoration: none;
transition: border-color 0.15s;
}
.adv-seat.is-empty .adv-seat-in {
border: 1px dashed var(--edge);
background: transparent;
}
a.adv-seat-in:hover,
a.adv-seat-in[aria-current] {
border-color: rgb(var(--ready-rgb) / 0.9);
}
.adv-seat-in img {
width: 54px;
height: 54px;
object-fit: contain;
background: var(--bg);
}
.adv-bench-title {
margin-top: 8px;
}
.adv-bench {
margin-bottom: 16px;
}
.adv-bench a {
color: inherit;
text-decoration: none;
}
.adv-portrait {
display: flex;
justify-content: center;
height: 140px;
margin: 10px 0;
background: var(--bg);
}
.adv-portrait img {
height: 100%;
aspect-ratio: 1;
object-fit: contain;
}
.bonus-chips { .bonus-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4570,6 +4638,19 @@ textarea.eco-input {
filter: none; filter: none;
} }
.past-paging {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
text-align: center;
}
.past-paging [aria-disabled] {
opacity: 0.4;
pointer-events: none;
}
.past-moon .total td { .past-moon .total td {
color: var(--text); color: var(--text);
font-weight: 600; font-weight: 600;
@@ -4916,7 +4997,7 @@ textarea.eco-input {
gap: 12px; gap: 12px;
} }
.reveal-foot .btn { .reveal-foot .btn:first-child {
margin-left: auto; margin-left: auto;
} }
+11 -7
View File
@@ -5,7 +5,7 @@
// bits of the DOM; after it settles we read them again and play effects for the // bits of the DOM; after it settles we read them again and play effects for the
// difference: count-ups, floating numbers, sparks and particles flying to the wallet, // difference: count-ups, floating numbers, sparks and particles flying to the wallet,
// scan sweeps on what was bought, a pulse on new drills and on Claim buttons as their // scan sweeps on what was bought, a pulse on new drills and on Claim buttons as their
// drills finish. // drills finish. The Preact client (web/app/fx.js) reuses the exported pieces.
import * as sfx from "./sfx.js"; import * as sfx from "./sfx.js";
import { updateEstimate, setHero } from "./market.js"; import { updateEstimate, setHero } from "./market.js";
@@ -168,7 +168,7 @@ function play(kind, toast, prev, at) {
}); });
} }
if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat"].includes(kind)) { if (["buy-tool", "upgrade", "buy-moon", "buy-attachment", "equip", "hire", "buy-seat", "seat-advisor", "swap-advisors"].includes(kind)) {
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34); if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 34);
// The page may change under a new moon, so sweep its list entry instead. // The page may change under a new moon, so sweep its list entry instead.
const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]"); const moon = kind === "buy-moon" && document.querySelector("#moonlist .moon[aria-current]");
@@ -210,6 +210,10 @@ function play(kind, toast, prev, at) {
equip: "upgrade", equip: "upgrade",
unequip: "toggle", unequip: "toggle",
hire: "confirm", hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy", "buy-seat": "buy",
"buy-slot": "buy", "buy-slot": "buy",
upgrade: "upgrade", upgrade: "upgrade",
@@ -375,14 +379,14 @@ function gradient(i) {
const currency = "₥"; const currency = "₥";
// Same as formatMoney in views.go. // Same as formatMoney in views.go.
function formatMoney(n) { export function formatMoney(n) {
const v = Math.round(Math.abs(n) * 100) / 100; const v = Math.round(Math.abs(n) * 100) / 100;
const [whole, frac] = v.toFixed(2).split("."); const [whole, frac] = v.toFixed(2).split(".");
const f = frac.replace(/0+$/, ""); const f = frac.replace(/0+$/, "");
return (n < 0 && v !== 0 ? "-" : "") + whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (f ? "." + f : ""); return (n < 0 && v !== 0 ? "-" : "") + whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (f ? "." + f : "");
} }
function centerOf(el) { export function centerOf(el) {
const r = el?.getBoundingClientRect() ?? { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 }; const r = el?.getBoundingClientRect() ?? { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 };
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
} }
@@ -418,7 +422,7 @@ function moonOut(id, row, delay) {
} }
// Restart a CSS animation class on an element. // Restart a CSS animation class on an element.
function mark(el, cls) { export function mark(el, cls) {
if (!el) return; if (!el) return;
el.classList.remove(cls); el.classList.remove(cls);
void el.offsetWidth; void el.offsetWidth;
@@ -486,7 +490,7 @@ function shake(at) {
} }
// A big number that pops out of a point and drifts up (dir -1) or down (dir 1). // A big number that pops out of a point and drifts up (dir -1) or down (dir 1).
function floatText(text, x, y, color, delay = 0, big = false, dir = -1) { export function floatText(text, x, y, color, delay = 0, big = false, dir = -1) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = `fx-float${big ? " fx-float-big" : ""}`; el.className = `fx-float${big ? " fx-float-big" : ""}`;
el.textContent = text; el.textContent = text;
@@ -639,7 +643,7 @@ export function burst(x, y, colors, n, scale = 1) {
} }
// Glowing motes that stream along a curve into a target, calling onArrive with the first. // Glowing motes that stream along a curve into a target, calling onArrive with the first.
function stream(from, to, color, n, delay, onArrive) { export function stream(from, to, color, n, delay, onArrive) {
if (reduced()) return onArrive(); if (reduced()) return onArrive();
ensureCanvas(); ensureCanvas();
let arrived = false; let arrived = false;
+2 -2
View File
@@ -120,9 +120,9 @@ window.addEventListener("popstate", () => {
// Actions keep the current view: send it along unless the button set its own. // Actions keep the current view: send it along unless the button set its own.
document.body.addEventListener("htmx:configRequest", (event) => { document.body.addEventListener("htmx:configRequest", (event) => {
if (event.detail.verb !== "post") return; if (event.detail.verb !== "post") return;
const { s, moon, well, drill, list } = view(); const { s, moon, well, drill, list, page, adv } = view();
const params = event.detail.parameters; const params = event.detail.parameters;
for (const [key, value] of Object.entries({ s, moon, well, drill, list })) { for (const [key, value] of Object.entries({ s, moon, well, drill, list, page, adv })) {
// params is a FormData proxy: `in` never sees its keys, a missing one reads undefined. // params is a FormData proxy: `in` never sees its keys, a missing one reads undefined.
if (params[key] === undefined && value) params[key] = value; if (params[key] === undefined && value) params[key] = value;
} }
+16 -1
View File
@@ -11,7 +11,8 @@
// balance down ("mm:revealed"). // balance down ("mm:revealed").
// //
// A click or key skips ahead: during the charge to the burst, during the card to its end // A click or key skips ahead: during the charge to the burst, during the card to its end
// state; once the card is complete it closes it. With prefers-reduced-motion the moon // state; once the card is complete it closes it. "Buy another" closes it and buys the next
// moon (buy-moon), whose response plays a new reveal. With prefers-reduced-motion the moon
// swaps in and the card fades. // swaps in and the card fades.
import * as sfx from "./sfx.js"; import * as sfx from "./sfx.js";
@@ -77,6 +78,7 @@ export function playReveal({ scene, seed, settings, source }) {
let stage = "charge"; let stage = "charge";
let card = null; let card = null;
let cardDone = false; let cardDone = false;
let buyAnother = false;
// Narrow screens: the viewport sits below the main area; bring it into view (on phones // Narrow screens: the viewport sits below the main area; bring it into view (on phones
// at the top, the card comes up from the bottom). // at the top, the card comes up from the bottom).
@@ -325,6 +327,7 @@ export function playReveal({ scene, seed, settings, source }) {
); );
} }
document.dispatchEvent(new CustomEvent("mm:revealed")); document.dispatchEvent(new CustomEvent("mm:revealed"));
if (buyAnother) htmx.ajax("POST", "/act/buy-moon", { source: document.body, swap: "none" });
}; };
sfx.play("close"); sfx.play("close");
layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done; layer.animate([{ opacity: 1 }, { opacity: 0 }], { duration: quick ? 150 : 420, easing: "ease-in", fill: "forwards" }).onfinish = done;
@@ -362,9 +365,19 @@ export function playReveal({ scene, seed, settings, source }) {
else close(); else close();
} }
// "Buy another": once this reveal is gone (the next one replaces the card).
function buy(button) {
if (!cardDone || button.disabled) return false;
buyAnother = true;
close();
return true;
}
function onPointer(event) { function onPointer(event) {
event.preventDefault(); event.preventDefault();
if (event.target.closest?.("[data-reveal-close]") && cardDone) return close(); if (event.target.closest?.("[data-reveal-close]") && cardDone) return close();
const more = event.target.closest?.("[data-reveal-buy]");
if (more && buy(more)) return;
advance(); advance();
} }
function onKey(event) { function onKey(event) {
@@ -372,6 +385,8 @@ export function playReveal({ scene, seed, settings, source }) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.key === "Escape" && stage === "card") return close(); if (event.key === "Escape" && stage === "card") return close();
const more = document.activeElement?.closest?.("[data-reveal-buy]");
if (event.key !== "Escape" && more && buy(more)) return;
advance(); advance();
} }
layer.addEventListener("pointerdown", onPointer); layer.addEventListener("pointerdown", onPointer);
+95 -7
View File
@@ -1,15 +1,17 @@
{{/* The advisors section (advisors.go): the combined bonus, the seats, and every advisor {{/* The advisors section (advisors.go): the combined bonus, the seats and who sits in them,
with a Hire button. */}} the hired advisors without a seat, and every advisor with a Hire button. A hired
advisor opens their panel (advisor-panel, in the side panel) to move, swap, unseat or
dismiss them. */}}
{{define "advisors-section"}}{{with .Advisors}} {{define "advisors-section"}}{{$p := .}}{{with .Advisors}}
<div class="heading"> <div class="heading">
<div class="grow"> <div class="grow">
<h1>Advisors</h1> <h1>Advisors</h1>
<p class="muted measure">Each advisor takes one seat and stays for good.</p> <p class="muted measure">Each seated advisor boosts your whole operation. Click one to move, swap or dismiss them.</p>
</div> </div>
{{if .NextSeatPrice}} {{if .NextSeatPrice}}
<button class="btn btn-dashed" hx-post="/act/buy-seat" <button class="btn btn-dashed" hx-post="/act/buy-seat"
hx-confirm="Add advisor seat {{.NextSeat}} for {{cur .NextSeatPrice}}?" hx-confirm="{{.SeatConfirm}}"
data-confirm-title="New seat" data-confirm-ok="Buy seat" data-confirm-title="New seat" data-confirm-ok="Buy seat"
{{if not .CanBuySeat}}disabled title="{{short $.S.Balance .NextSeatPrice}}"{{end}}>+ Seat · <span class="num accent">{{cur .NextSeatPrice}}</span></button> {{if not .CanBuySeat}}disabled title="{{short $.S.Balance .NextSeatPrice}}"{{end}}>+ Seat · <span class="num accent">{{cur .NextSeatPrice}}</span></button>
{{end}} {{end}}
@@ -20,6 +22,36 @@
<div class="mk-stat frame"><dt class="cap">Active bonuses</dt><dd>{{with .Bonuses}}{{template "bonus-chips" .}}{{else}}<span class="muted">None yet</span>{{end}}</dd></div> <div class="mk-stat frame"><dt class="cap">Active bonuses</dt><dd>{{with .Bonuses}}{{template "bonus-chips" .}}{{else}}<span class="muted">None yet</span>{{end}}</dd></div>
</dl> </dl>
<ol class="adv-seats" aria-label="Advisor seats">
{{range .SeatList}}{{$n := .N}}
<li id="seat-{{.N}}" class="adv-seat{{if not .Advisor}} is-empty{{end}}">
{{with .Advisor}}
{{$u := $p.V.ToAdvisor .Name}}
<a href="{{$u}}" hx-get="{{$u}}" class="adv-seat-in" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}>
<img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'">
<span class="grow"><span class="cap num">Seat {{$n}}</span><br><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></span>
</a>
{{else}}
<span class="adv-seat-in"><span class="grow"><span class="cap num">Seat {{.N}}</span><br><span class="muted">Empty</span></span></span>
{{end}}
</li>
{{end}}
</ol>
{{with .Bench}}
<h3 class="cap adv-bench-title">Hired, not seated <span class="muted">· no bonus</span></h3>
<div class="adv-bench">
{{range .}}
{{$u := $p.V.ToAdvisor .Name}}
<div class="line">
<a class="grow" href="{{$u}}" hx-get="{{$u}}" {{if eq $p.V.Adv .Name}}aria-current="true"{{end}}><b>{{.Name}}</b><br><span class="muted small num">{{.Effect}}</span></a>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" .Name "seat" (print $p.Advisors.FreeSeat)}}"
{{with $p.Advisors.SeatWhy}}disabled title="{{.}}"{{end}}>Seat</button>
</div>
{{end}}
</div>
{{end}}
<div class="cards"> <div class="cards">
{{range .Cards}} {{range .Cards}}
<article class="card advisor-card {{if .Hired}}is-hired{{end}}"> <article class="card advisor-card {{if .Hired}}is-hired{{end}}">
@@ -30,13 +62,69 @@
<p class="mod-effect num">{{.Effect}}</p> <p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p> <p class="card-desc">{{.Description}}</p>
{{if .Hired}} {{if .Hired}}
<p class="ready card-buy">On your board</p> {{$u := $p.V.ToAdvisor .Name}}
<a class="btn card-buy" href="{{$u}}" hx-get="{{$u}}">{{if .Seat}}Seat {{.Seat}}{{else}}Not seated{{end}} · Manage</a>
{{else}} {{else}}
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}" <button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
{{if not $.Advisors.SeatFree}}disabled title="All seats taken{{if $.Advisors.NextSeatPrice}}. Buy another seat first{{end}}"{{else if not .CanAfford}}disabled title="{{short $.S.Balance .Price}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button> {{with $p.Advisors.HireWhy . $p.S.Balance}}disabled title="{{.}}"{{end}}>Hire · <span class="num">{{cur .Price}}</span></button>
{{end}} {{end}}
</div> </div>
</article> </article>
{{end}} {{end}}
</div> </div>
{{end}}{{end}} {{end}}{{end}}
{{/* A hired advisor's panel (View.Adv), in the side panel. Called with the Page. */}}
{{define "advisor-panel"}}{{$p := .}}{{with .Advisors.Panel}}
<h2>{{.Name}}</h2>
<p class="cap">{{.Title}}</p>
<div class="adv-portrait"><img src="/static/assets/advisors/{{.Image}}" alt="" onerror="this.style.visibility='hidden'"></div>
{{with .Effect}}<p class="mod-effect num">{{.}}</p>{{end}}
<p class="muted small">{{.Description}}</p>
<dl class="facts">
<div><dt>Seat</dt><dd class="num">{{if .Seat}}{{.Seat}}{{else}}Not seated{{end}}</dd></div>
<div><dt>Bonus</dt><dd>{{if .Seat}}Active{{else}}<span class="muted">Off until seated</span>{{end}}</dd></div>
</dl>
<p class="muted small">Changes count from your next drill, sale or batch.</p>
{{$name := .Name}}
{{with .Moves}}
<h3>{{if $p.Advisors.Panel.Seat}}Move to{{else}}Seat in{{end}}</h3>
{{range .}}
<div class="line">
<span class="cap num">{{.N}}</span>
{{with .Advisor}}
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">{{if $p.Advisors.Panel.Seat}}Swap{{else}}Replace{{end}}</button>
{{else}}
<span class="grow muted">Empty</span>
<button class="btn btn-sm btn-primary" hx-post="/act/seat-advisor" hx-vals="{{vals "advisor" $name "seat" (print .N)}}">{{if $p.Advisors.Panel.Seat}}Move{{else}}Seat{{end}}</button>
{{end}}
</div>
{{end}}
{{end}}
{{if .Seat}}
{{with .Swaps}}
<h3>Give the seat to</h3>
{{range .}}
<div class="line">
<span class="grow">{{.Name}}<br><span class="muted small num">{{.Effect}}</span></span>
<button class="btn btn-sm" hx-post="/act/swap-advisors" hx-vals="{{vals "advisor" $name "with" .Name}}">Swap in</button>
</div>
{{end}}
{{end}}
<h3>Unseat</h3>
<div class="line">
<span class="grow muted small">Frees seat {{.Seat}}. {{.Name}} stays hired, without a bonus, until seated again.</span>
<button class="btn btn-sm" hx-post="/act/unseat-advisor" hx-vals="{{vals "advisor" .Name}}">Unseat</button>
</div>
{{end}}
<h3>Dismiss</h3>
<div class="line">
<span class="grow muted small">Lets {{.Name}} go{{if .Seat}} and frees seat {{.Seat}}{{end}}. Nothing is refunded.</span>
<button class="btn btn-sm btn-danger" hx-post="/act/dismiss-advisor" hx-vals="{{vals "advisor" .Name}}"
hx-confirm="{{.Dismiss}}" data-confirm-title="Dismiss advisor" data-confirm-ok="Dismiss" data-confirm-danger>Dismiss</button>
</div>
{{end}}{{end}}
+45 -4
View File
@@ -28,7 +28,7 @@
data-now (the server's clock) lines the countdowns up with the server. */}} data-now (the server's clock) lines the countdowns up with the server. */}}
{{define "view"}} {{define "view"}}
<div id="view" hidden {{template "oob-replace" .}} data-url="{{.V.URL}}" data-s="{{.V.Section}}" <div id="view" hidden {{template "oob-replace" .}} data-url="{{.V.URL}}" data-s="{{.V.Section}}"
data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-now="{{.S.Now.UnixMilli}}"></div> data-moon="{{.V.Moon}}" data-list="{{if .V.Past}}past{{end}}" data-well="{{.V.Well}}" data-drill="{{.V.Drill}}" data-adv="{{.V.Adv}}" data-page="{{if and .V.Past (gt .V.Page 1)}}{{.V.Page}}{{end}}" data-now="{{.S.Now.UnixMilli}}"></div>
{{end}} {{end}}
{{define "scene"}} {{define "scene"}}
@@ -105,6 +105,7 @@
{{if .S.Unlocked "market"}}<a id="tab-market" href="{{.V.ToSection "market"}}" hx-get="{{.V.ToSection "market"}}" {{if eq $s "market"}}aria-current="page"{{end}} data-reveal="market" {{if .S.Shine "market"}}data-shine{{end}}>Market</a>{{end}} {{if .S.Unlocked "market"}}<a id="tab-market" href="{{.V.ToSection "market"}}" hx-get="{{.V.ToSection "market"}}" {{if eq $s "market"}}aria-current="page"{{end}} data-reveal="market" {{if .S.Shine "market"}}data-shine{{end}}>Market</a>{{end}}
{{if .S.Unlocked "refinery"}}<a id="tab-refinery" href="{{.V.ToSection "refinery"}}" hx-get="{{.V.ToSection "refinery"}}" {{if eq $s "refinery"}}aria-current="page"{{end}} data-ready="{{.S.RefineryReady}}" data-pending="{{.S.RefineryPending}}" data-reveal="refinery" {{if .S.Shine "refinery"}}data-shine{{end}}>Refinery <span class="badge" {{if not .S.RefineryReady}}hidden{{end}}>{{.S.RefineryReady}}</span></a>{{end}} {{if .S.Unlocked "refinery"}}<a id="tab-refinery" href="{{.V.ToSection "refinery"}}" hx-get="{{.V.ToSection "refinery"}}" {{if eq $s "refinery"}}aria-current="page"{{end}} data-ready="{{.S.RefineryReady}}" data-pending="{{.S.RefineryPending}}" data-reveal="refinery" {{if .S.Shine "refinery"}}data-shine{{end}}>Refinery <span class="badge" {{if not .S.RefineryReady}}hidden{{end}}>{{.S.RefineryReady}}</span></a>{{end}}
{{if .S.Unlocked "advisors"}}<a id="tab-advisors" href="{{.V.ToSection "advisors"}}" hx-get="{{.V.ToSection "advisors"}}" {{if eq $s "advisors"}}aria-current="page"{{end}} data-reveal="advisors" {{if .S.Shine "advisors"}}data-shine{{end}}>Advisors</a>{{end}} {{if .S.Unlocked "advisors"}}<a id="tab-advisors" href="{{.V.ToSection "advisors"}}" hx-get="{{.V.ToSection "advisors"}}" {{if eq $s "advisors"}}aria-current="page"{{end}} data-reveal="advisors" {{if .S.Shine "advisors"}}data-shine{{end}}>Advisors</a>{{end}}
{{if .S.Unlocked "leaderboard"}}<a id="tab-leaderboard" href="{{.V.ToSection "leaderboard"}}" hx-get="{{.V.ToSection "leaderboard"}}" {{if eq $s "leaderboard"}}aria-current="page"{{end}} data-reveal="leaderboard">Leaderboard</a>{{end}}
{{if .S.Unlocked "help"}}<a id="tab-help" href="{{.V.ToSection "help"}}" hx-get="{{.V.ToSection "help"}}" {{if eq $s "help"}}aria-current="page"{{end}} title="How to play" data-reveal="help">Help</a>{{end}} {{if .S.Unlocked "help"}}<a id="tab-help" href="{{.V.ToSection "help"}}" hx-get="{{.V.ToSection "help"}}" {{if eq $s "help"}}aria-current="page"{{end}} title="How to play" data-reveal="help">Help</a>{{end}}
{{if .S.Unlocked "claim-all"}}{{template "claim-all" .}}{{end}} {{if .S.Unlocked "claim-all"}}{{template "claim-all" .}}{{end}}
</nav> </nav>
@@ -152,7 +153,7 @@
{{$active := .ActiveURL}} {{$active := .ActiveURL}}
<a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a> <a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a>
{{with .PastURL}} {{with .PastURL}}
<a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{len $.Past}}</span></a> <a href="{{.}}" hx-get="{{.}}" {{if $.PastView}}aria-current="page"{{end}} data-past-tab>Past <span class="muted num">{{$.S.PastTotal}}</span></a>
{{else}} {{else}}
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a> <a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a>
{{end}} {{end}}
@@ -171,6 +172,13 @@
<span class="grow"><b>{{.Name}}</b>{{template "rating-chip" .Rating}}<br><span class="muted small num">Mined {{money .TotalMined}}{{with .FinishedOn}} · {{.}}{{end}}</span></span> <span class="grow"><b>{{.Name}}</b>{{template "rating-chip" .Rating}}<br><span class="muted small num">Mined {{money .TotalMined}}{{with .FinishedOn}} · {{.}}{{end}}</span></span>
</a> </a>
{{end}} {{end}}
{{with .PastPaging}}{{if gt .Pages 1}}
<nav class="past-paging small" aria-label="Past moon pages">
{{with .Newer}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="prev">‹ Newer</a>{{else}}<span class="btn btn-sm" aria-disabled="true">‹ Newer</span>{{end}}
<span class="muted num grow">{{.Page}} / {{.Pages}}</span>
{{with .Older}}<a class="btn btn-sm" href="{{.}}" hx-get="{{.}}" rel="next">Older ›</a>{{else}}<span class="btn btn-sm" aria-disabled="true">Older ›</span>{{end}}
</nav>
{{end}}{{end}}
{{else}} {{else}}
{{range .Moons}} {{range .Moons}}
{{$url := $.V.ToMoon .ID}} {{$url := $.V.ToMoon .ID}}
@@ -203,6 +211,7 @@
{{else if eq .V.Section "store"}}{{template "store-section" .}} {{else if eq .V.Section "store"}}{{template "store-section" .}}
{{else if eq .V.Section "refinery"}}{{template "refinery-section" .}} {{else if eq .V.Section "refinery"}}{{template "refinery-section" .}}
{{else if eq .V.Section "advisors"}}{{template "advisors-section" .}} {{else if eq .V.Section "advisors"}}{{template "advisors-section" .}}
{{else if eq .V.Section "leaderboard"}}{{template "leaderboard-section" .}}
{{else if eq .V.Section "new-moon"}}{{template "new-moon-section" .}} {{else if eq .V.Section "new-moon"}}{{template "new-moon-section" .}}
{{else}}{{template "help-section" .}} {{else}}{{template "help-section" .}}
{{end}} {{end}}
@@ -583,12 +592,43 @@
{{end}} {{end}}
</ul> </ul>
<div class="reveal-foot"> <div class="reveal-foot">
{{with .Next}}{{if ne .Why "locked"}}
<button type="button" class="btn" data-reveal-buy {{if not .CanBuy}}disabled title="{{.WhyText}}"{{end}}>Buy another · <span class="num">{{cur .Price}}</span></button>
{{end}}{{end}}
<button type="button" class="btn btn-primary" data-reveal-close>Start mining</button> <button type="button" class="btn btn-primary" data-reveal-close>Start mining</button>
</div> </div>
</section> </section>
</div> </div>
{{end}} {{end}}
{{/* The leaderboard (leaderboard.go): the top miners by credits, and the player's own
place under them when they're further down. */}}
{{define "leaderboard-section"}}{{with .Ranking}}
<div class="heading">
<div class="grow">
<h1>Leaderboard</h1>
<p class="muted measure">The richest miners by credits on hand, out of {{.Players}}.</p>
</div>
</div>
<div class="table-box">
<table class="table">
<thead><tr><th class="right">Rank</th><th>Miner</th><th class="right">Credits</th></tr></thead>
<tbody>
{{range .Top}}{{template "leaderboard-row" .}}{{end}}
{{with .You}}<tr><td class="right muted" colspan="3" aria-hidden="true">⋯</td></tr>{{template "leaderboard-row" .}}{{end}}
</tbody>
</table>
</div>
{{end}}{{end}}
{{define "leaderboard-row"}}
<tr {{if .Me}}class="selected"{{end}}>
<td class="right num muted">{{.Rank}}</td>
<td>{{.Name}}{{if .Me}} <span class="accent small">You</span>{{end}}</td>
<td class="right num">{{cur .Balance}}</td>
</tr>
{{end}}
{{define "help-section"}} {{define "help-section"}}
<div class="heading"><div class="grow"><h1>How to play</h1></div></div> <div class="heading"><div class="grow"><h1>How to play</h1></div></div>
<ol class="steps"> <ol class="steps">
@@ -610,6 +650,7 @@
{{define "side"}} {{define "side"}}
<section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}> <section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}>
{{with .Drill}}{{template "drill-panel" .}}{{end}} {{with .Drill}}{{template "drill-panel" .}}{{end}}
{{with .Advisors}}{{with .Panel}}{{template "advisor-panel" $}}{{end}}{{end}}
{{with .Well}} {{with .Well}}
{{$dry := le .Supply 0.0}} {{$dry := le .Supply 0.0}}
<h2>{{template "dot" .Resource}}{{.Resource}} well</h2> <h2>{{template "dot" .Resource}}{{.Resource}} well</h2>
@@ -783,7 +824,7 @@
<span class="muted small num">{{.Effect}} · then {{secs .Secs}}, +{{money .Yield}}</span></span> <span class="muted small num">{{.Effect}} · then {{secs .Secs}}, +{{money .Yield}}</span></span>
<button class="btn btn-sm btn-primary" hx-post="/act/equip" hx-vals="{{vals "item" .Name "to" $.ID}}" {{if not .CanEquip}}disabled title="{{.Why}}"{{end}}>Equip</button> <button class="btn btn-sm btn-primary" hx-post="/act/equip" hx-vals="{{vals "item" .Name "to" $.ID}}" {{if not .CanEquip}}disabled title="{{.Why}}"{{end}}>Equip</button>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-attachment" hx-vals="{{vals "item" .Name}}" <button class="btn btn-sm btn-danger" hx-post="/act/sell-attachment" hx-vals="{{vals "item" .Name}}"
hx-confirm="Sell a spare {{.Name}} for {{cur .Resale}}? You paid {{cur .Price}}." data-confirm-title="Sell {{.Name}}" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger hx-confirm="{{.Confirm}}" data-confirm-title="Sell {{.Name}}" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
title="Sell one for {{cur .Resale}}">Sell</button> title="Sell one for {{cur .Resale}}">Sell</button>
</div> </div>
{{else}} {{else}}
@@ -795,7 +836,7 @@
<span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br> <span class="grow">Sells for <span class="num">{{cur .Resale}}</span><br>
<span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded{{with .Gear}}; its attachments go back to your inventory{{end}}.</span></span> <span class="muted small">Part of what it and its upgrades cost. Refined parts aren't refunded{{with .Gear}}; its attachments go back to your inventory{{end}}.</span></span>
<button class="btn btn-sm btn-danger" hx-post="/act/sell-tool" hx-vals="{{vals "tool" .ID}}" <button class="btn btn-sm btn-danger" hx-post="/act/sell-tool" hx-vals="{{vals "tool" .ID}}"
hx-confirm="Sell this {{.Item.Name}} (T{{.Tier}}) for {{cur .Resale}}?{{with .Gear}} Its attachments go back to your inventory.{{end}}" data-confirm-title="Sell drill" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger hx-confirm="{{.SellConfirm}}" data-confirm-title="Sell drill" data-confirm-ok="Sell for {{cur .Resale}}" data-confirm-danger
{{with .NoSale}}disabled title="{{.}}"{{end}}>Sell</button> {{with .NoSale}}disabled title="{{.}}"{{end}}>Sell</button>
</div> </div>
{{end}} {{end}}
+2 -3
View File
@@ -37,7 +37,7 @@
{{end}} {{end}}
{{if .NextSlotPrice}} {{if .NextSlotPrice}}
<button class="btn btn-dashed refinery-buy-slot" hx-post="/act/buy-slot" <button class="btn btn-dashed refinery-buy-slot" hx-post="/act/buy-slot"
hx-confirm="Add refinery slot {{.NextSlot}} for {{cur .NextSlotPrice}}? Each slot runs one batch at a time." hx-confirm="{{.SlotConfirm}}"
data-confirm-title="New refinery slot" data-confirm-ok="Buy slot" data-confirm-title="New refinery slot" data-confirm-ok="Buy slot"
{{if not .CanBuySlot}}disabled title="{{short $.S.Balance .NextSlotPrice}}"{{end}}> {{if not .CanBuySlot}}disabled title="{{short $.S.Balance .NextSlotPrice}}"{{end}}>
+ Slot {{.NextSlot}} · <span class="num accent">{{cur .NextSlotPrice}}</span></button> + Slot {{.NextSlot}} · <span class="num accent">{{cur .NextSlotPrice}}</span></button>
@@ -47,8 +47,7 @@
<h2 class="refinery-label cap">Recipes</h2> <h2 class="refinery-label cap">Recipes</h2>
<div class="cards"> <div class="cards">
{{range .Recipes}} {{range .Recipes}}
{{$why := ""}} {{$why := $rp.Why .}}
{{if eq $rp.Free 0}}{{$why = "All slots are busy"}}{{else if eq .MaxQty 0}}{{$why = "Not enough inputs"}}{{end}}
<article class="card recipe"> <article class="card recipe">
<div class="card-body"> <div class="card-body">
<div class="recipe-head"> <div class="recipe-head">
+17
View File
@@ -0,0 +1,17 @@
{{/* The Preact client at /app (web/app, built by esbuild into web/static/app). Same look
(app.css) and the classic client's scene, effects and sounds, loaded as modules. */}}
{{define "spa"}}{{template "head"}}
<link rel="stylesheet" href="/static/app/app.css">
<script type="importmap">
{"imports": {
"three": "/static/vendor/three/three.module.min.js",
"three/addons/": "/static/vendor/three/addons/"
}}
</script>
<script type="module" src="/static/app/app.js"></script>
</head>
<body class="pa">
<div id="root"></div>
</body>
</html>
{{end}}