Compare commits

14 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
56 changed files with 3952 additions and 170 deletions

No files matched your search

+1
View File
@@ -7,3 +7,4 @@ node_modules
.DS_Store
web/static/css/app.css
art
web/static/app
+1
View File
@@ -2,6 +2,7 @@
/bin/
/moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit)
.env
+38 -6
View File
@@ -18,6 +18,10 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- 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`).
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
@@ -33,7 +37,9 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold.
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
@@ -96,8 +102,10 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire and buy
seat rules, Advisors section. Tables `advisor_hire`, `users.advisor_seats`.
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
@@ -140,14 +148,38 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–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.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
## 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`,
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
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.
+4
View File
@@ -5,10 +5,14 @@ ARG TAILWIND_VERSION=v4.1.14
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
&& 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 ./
RUN go mod download
COPY . .
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 .
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`.
+22 -5
View File
@@ -1,20 +1,31 @@
TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss
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 .
# Server on :3000 that reloads templates and static files from disk; CSS rebuilds on change.
dev: $(TAILWIND)
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
# client rebuild on change.
dev: $(TAILWIND) $(ESBUILD)
$(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)
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
app: $(ESBUILD)
$(APP) --minify
migrate:
go run . migrate
@@ -26,6 +37,12 @@ check:
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done
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):
mkdir -p bin
+279 -27
View File
@@ -1,9 +1,15 @@
package main
// 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
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire and users.advisor_seats.
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// 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 (
"context"
@@ -64,39 +70,199 @@ func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
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 {
a := cfg().advisor(name)
if a == nil {
return gameErr("Unknown advisor")
}
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 {
return err
}
var hired int
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 {
if findHire(hires, a.Name) != nil {
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")
}
if err := debit(ctx, tx, userID, a.Price); err != nil {
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 {
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})
})
}
@@ -130,42 +296,120 @@ func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int,
type AdvisorsPage struct {
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
NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap
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 {
*Advisor
Hired bool
Seat int // 0: not seated (or not hired)
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()
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,
Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
card := func(a *Advisor) AdvisorCard {
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors {
card.Hired = card.Hired || h == a.Name
for _, h := range s.Hires {
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
}
// 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.
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 {
@@ -184,3 +428,11 @@ func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
}
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"
}
+70 -23
View File
@@ -40,7 +40,8 @@ type APIState struct {
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
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
@@ -75,6 +76,17 @@ type APIView struct {
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 {
@@ -131,19 +143,21 @@ type APITool struct {
}
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
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
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"`
@@ -186,6 +200,14 @@ type APIWellRow struct {
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 {
@@ -251,6 +273,18 @@ type APIQuote struct {
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).
@@ -297,7 +331,7 @@ func apiGear(gear []*Attachment) []string {
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}
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()
@@ -336,8 +370,10 @@ func apiMoonList(rows []MoonListRow) []APIMoonListItem {
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},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
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{}}
@@ -387,7 +423,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
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)}
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)
}
@@ -502,7 +539,17 @@ func apiMarket(trade *MarketPage, full bool) *APIMarket {
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()}
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 {
@@ -600,11 +647,11 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
// 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, err := loadState(ctx, s.db, userID)
state, v, err := loadView(ctx, s.db, userID, v)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v.resolve(state))
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
return nil, err
}
@@ -736,13 +783,13 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
created, res.View, res.Message = true, View{}, "New game started"
}
state, err := loadState(ctx, s.db, userID)
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, res.View.resolve(state))
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
apiServerError(w, err)
return
+57 -24
View File
@@ -27,7 +27,9 @@ Banned players get `403`.
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), `pick` (market: selected quote), `drill`.
`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=`
@@ -70,8 +72,12 @@ The other sections' actions:
| `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 |
| `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 |
@@ -98,19 +104,20 @@ session; the web page also reads it once per page load.
| key | |
|---|---|
| `userId`, `now` | |
| `view` | `{section, moon, well, past, pick, drill}`, resolved |
| `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 |
| `moons`, `past` | `[{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") |
| `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, start, end, durationSecs, ready, take, covers?}` |
| `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"); `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` |
| `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?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `chart` and `spark` only for `s=market` (or `include=market`) |
| `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) |
@@ -192,14 +199,31 @@ inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText`
### advisors (`s=advisors`)
```
{seats, hired, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford,
canHire, why, whyText}]}
{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`: `hired` ("On your board"), `seats` ("All seats taken. Buy
another seat first"), `credits` ("Need ₥X more") or `""`.
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`)
@@ -221,12 +245,15 @@ balance after buying, `need` the credits missing, `whyText` why the button is di
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}]}
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`.
`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)
@@ -257,7 +284,7 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `newGame`): `{title, text, ok, danger}`,
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
@@ -266,11 +293,8 @@ a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.no
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`).
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
@@ -317,7 +341,7 @@ Content-Type: application/json
"state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "pick": "", "drill": ""},
"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": [],
@@ -331,6 +355,7 @@ Content-Type: application/json
"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-…"}],
@@ -408,12 +433,19 @@ From a live server (long ids and arrays shortened).
`s=advisors`, `state.advisors`:
```json
{"seats": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true,
"bonuses": [],
{"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}, "hired": false, "canAfford": true,
"canHire": true}, "…"]}
"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}}`
@@ -432,7 +464,8 @@ From a live server (long ids and arrays shortened).
"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}, "…"]}
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
```
`POST /api/act/buy-tool`, `buyModal`:
+84 -10
View File
@@ -726,16 +726,27 @@ func apiRefinery(p *Page) *APIRefinery {
type APIAdvisors struct {
Seats int `json:"seats"`
Hired int `json:"hired"`
Hired int `json:"hired"` // seats filled
Benched int `json:"benched"` // hired, not seated
MaxSeats int `json:"maxSeats"`
NextSeat int `json:"nextSeat"`
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
CanBuySeat bool `json:"canBuySeat"`
SeatFree bool `json:"seatFree"`
FreeSeat int `json:"freeSeat"` // the lowest empty seat, 0 if none
SeatList []APISeat `json:"seatList"` // every seat, from 1
Bench []string `json:"bench"` // hired, not seated, in the web's order (hire order)
Bonuses []APIBonusRow `json:"bonuses"`
Advisors []APIAdvisorUp `json:"advisors"`
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
Selected string `json:"selected"` // the advisor of the web's panel (view.adv), "" if none
}
// APISeat is a seat and who sits in it ("" empty).
type APISeat struct {
N int `json:"n"`
Advisor string `json:"advisor"`
}
type APIAdvisorUp struct {
@@ -746,26 +757,48 @@ type APIAdvisorUp struct {
Price float64 `json:"price"`
Effect APIEffect `json:"effect"`
Hired bool `json:"hired"`
Seat int `json:"seat"` // hired: their seat, 0 if not seated (no bonus)
CanAfford bool `json:"canAfford"`
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
EffectText string `json:"effectText"`
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
WhyText string `json:"whyText"`
// Hired and not seated: whether seat-advisor can seat them now (in freeSeat);
// seatWhy "seats" (none free) or "".
CanSeat bool `json:"canSeat"`
SeatWhy string `json:"seatWhy"`
SeatWhyText string `json:"seatWhyText"`
Dismiss *APIConfirm `json:"dismiss"` // hired: the dismiss-advisor dialog; null otherwise
Removed bool `json:"removed"` // hired but no longer in the config: no card on the web
}
func apiAdvisors(p *Page) *APIAdvisors {
ap := p.Advisors
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, Benched: ap.Benched, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(), FreeSeat: ap.FreeSeat,
SeatList: []APISeat{}, Bench: []string{}, Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}, Selected: p.V.Adv}
for _, slot := range ap.SeatList {
seat := APISeat{N: slot.N}
if slot.Advisor != nil {
seat.Advisor = slot.Advisor.Name
}
a.SeatList = append(a.SeatList, seat)
}
for _, c := range ap.Bench {
a.Bench = append(a.Bench, c.Name)
}
if ap.NextSeatPrice > 0 {
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
}
for _, c := range ap.Cards {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
up := func(c AdvisorCard) APIAdvisorUp {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Description: c.Description, Price: c.Price,
Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, Seat: c.Seat, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(),
EffectText: c.Advisor.Effect()}
if c.Image != "" {
up.Image = advisorImages + c.Image
}
switch {
case c.Hired:
up.Why, up.WhyText = "hired", "On your board"
@@ -774,11 +807,42 @@ func apiAdvisors(p *Page) *APIAdvisors {
case !c.CanAfford:
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
}
a.Advisors = append(a.Advisors, up)
if c.Hired {
up.Dismiss = &APIConfirm{Title: "Dismiss advisor", Text: dismissConfirm(c), OK: "Dismiss", Danger: true}
if c.Seat == 0 {
up.CanSeat = ap.SeatFree()
if !up.CanSeat {
up.SeatWhy, up.SeatWhyText = "seats", ap.SeatWhy()
}
}
}
return up
}
for _, c := range ap.Cards {
a.Advisors = append(a.Advisors, up(c))
}
// Hired advisors no longer in the config (no bonus) come last.
for _, c := range append(seated(ap.SeatList), ap.Bench...) {
if cfg().advisor(c.Name) == nil {
r := up(c)
r.Removed = true
a.Advisors = append(a.Advisors, r)
}
}
return a
}
// seated lists the advisors in the seats.
func seated(seats []SeatSlot) []AdvisorCard {
var out []AdvisorCard
for _, slot := range seats {
if slot.Advisor != nil {
out = append(out, *slot.Advisor)
}
}
return out
}
// ------------------------------------------------------------------ leaderboard, help, new moon
type APILeaderboard struct {
@@ -878,6 +942,15 @@ type APIReveal struct {
MaxSupply float64 `json:"maxSupply"` // a full bar
FullAt float64 `json:"fullAt"` // wells from here count as full
Wells []APIRevealWell `json:"wells"`
Next APINextMoon `json:"next"` // "Buy another": buy-moon again
}
// APINextMoon is the reveal card's "Buy another" button.
type APINextMoon struct {
Price float64 `json:"price"` // the next moon's price
CanBuy bool `json:"canBuy"` // affordable and available
Why string `json:"why"` // locked (hide the button), credits; "" can buy
WhyText string `json:"whyText"`
}
type APIRevealWell struct {
@@ -894,7 +967,8 @@ func apiReveal(r *MoonReveal) *APIReveal {
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{}}
FullAt: r.FullAt, Wells: []APIRevealWell{},
Next: APINextMoon{Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
a.Traits = append(a.Traits, r.Traits...)
for _, w := range r.Wells {
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
+41 -4
View File
@@ -11,8 +11,10 @@ import (
func TestAPI(t *testing.T) {
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
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)
@@ -52,7 +54,8 @@ func TestAPI(t *testing.T) {
}
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 == "" {
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)
}
}
@@ -80,6 +83,13 @@ func TestAPI(t *testing.T) {
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)
}
@@ -94,11 +104,18 @@ func TestAPI(t *testing.T) {
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)
@@ -140,8 +157,11 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
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 == "" {
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
fail("drill fate", d)
}
}
@@ -150,6 +170,13 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
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)
@@ -207,6 +234,10 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
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 {
@@ -244,9 +275,15 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
}
case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) != len(cfg().Advisors.Items) {
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)
+13 -5
View File
@@ -114,12 +114,12 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ 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
// 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) {
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 {
return noBonus, err
}
@@ -143,20 +143,28 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
// ------------------------------------------------------------------ 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 {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil {
return err
}
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 {
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
}
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
+96 -12
View File
@@ -113,6 +113,12 @@ type State struct {
Balance float64
Resources []ResourceBalance
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
Stakes []Stake
Now time.Time
@@ -130,10 +136,12 @@ type State struct {
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),
// advisors hired, advisor seats owned.
// seated advisors (their bonus counts), every advisor hired with their seat, advisor
// seats owned.
Fitted map[string][]string
Spare map[string]int
Advisors []string
Hires []AdvisorHire // seated first, by seat
AdvisorSeats int
// 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 {
delete(s.LastWell, st.ToolID)
}
if err := loadPastMoons(ctx, db, s); err != nil {
return nil, err
}
loadPastMoons(s)
if err := loadRefinery(ctx, db, s); err != nil {
return nil, err
}
@@ -434,14 +440,31 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
return s, nil
}
// loadPastMoons marks the finished moons and adds what was mined on each. Supply only goes
// down and every stake gets claimed, so a finished moon stays finished.
func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
// loadView loads the player's state for a view (with the page of past moons it shows) and
// resolves the view.
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{}
for _, st := range s.Stakes {
busy[st.MoonID] = true
}
past := map[string]*Moon{}
s.PastTotal = 0
for i := range s.Moons {
m := &s.Moons[i]
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 {
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
}
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,
sum(coalesce(claim_well.amount, claim_event.claim_amount)), max(claim_event.created_at)
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
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
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
order by resource.name`, s.UserID)
order by resource.name`, s.UserID, load)
if err != nil {
return err
}
@@ -486,6 +547,29 @@ func loadPastMoons(ctx context.Context, db *pgxpool.Pool, s *State) error {
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
// nothing on it is left to claim.
func moonFinished(ctx context.Context, tx pgx.Tx, moonID string) (bool, error) {
+2 -1
View File
@@ -141,7 +141,8 @@ var actionGates = map[string]string{
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market",
"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",
}
+19 -1
View File
@@ -52,6 +52,17 @@ type MoonReveal struct {
Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar
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"}
@@ -68,7 +79,14 @@ func (p *Page) moonReveal() *MoonReveal {
for i, w := range p.Moon.Wells {
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 {
r.Rating = *p.Moon.Rating
} else {
+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);
+68 -11
View File
@@ -10,6 +10,7 @@ import (
"log"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strconv"
@@ -76,11 +77,17 @@ func serve(ctx context.Context) error {
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")
srv := &http.Server{
Addr: addr,
Handler: logRequests(secureHeaders(limitBody(mux))),
Handler: logRequests(secureHeaders(limitBody(handler))),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
@@ -122,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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
@@ -218,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
// (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 {
if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return ip
return normalIP(ip)
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
return normalIP(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) {
@@ -318,12 +352,12 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
state, err := loadState(r.Context(), s.db, userID)
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil {
serverError(w, err)
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
@@ -527,6 +561,29 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
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":
var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
@@ -582,13 +639,13 @@ func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID str
return
}
state, err := loadState(ctx, s.db, userID)
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(res.View.resolve(state))
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
+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)
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 (
user_id uuid not null references users(id) on delete cascade,
advisor text not null,
hired_at timestamp with time zone not null default now(),
seat smallint,
primary key (user_id, advisor)
);
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
+51 -3
View File
@@ -32,8 +32,9 @@ func TestTemplates(t *testing.T) {
unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
if v.Section == "leaderboard" {
@@ -53,12 +54,16 @@ func TestTemplates(t *testing.T) {
render("regions", p)
}
// The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
render("regions", p)
}
// The Preact client's page (/app).
render("spa", nil)
// Admin pages and the pages outside the game, with and without permission.
s := veteranState()
now := time.Now()
@@ -166,6 +171,18 @@ func testViews(s *State) []View {
for _, r := range s.Refined {
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
}
@@ -265,9 +282,40 @@ func veteranState() *State {
s.Fitted[s.Tools[0].ID] = []string{c.Attachments.Items[0].Name}
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.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
}
+61 -2
View File
@@ -17,10 +17,20 @@ userId=:user_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),
# 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
: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
POST :host/act/buy-moon
:headers
@@ -118,6 +128,26 @@ advisor=Mira+Castellanos
POST :host/act/buy-seat
: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)
POST :host/act/new-game
:headers
@@ -177,7 +207,8 @@ Content-Type: application/json
# 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,
# sell-moon (sell-moon)
# 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
@@ -202,6 +233,34 @@ 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
+85 -16
View File
@@ -138,21 +138,24 @@ func formatCycle(secs int) string {
// ------------------------------------------------------------------ view
// 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 {
Section string // moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon
Moon string
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
Pick string // the selected resource or refined item (market 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, "leaderboard": true, "new-moon": true}
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"),
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.
@@ -170,13 +173,9 @@ func (v View) resolve(s *State) View {
if moon != nil && moon.Finished != v.Past {
moon = nil
}
if moon == nil && v.Past {
// The Past tab: the last moon finished.
for i := range s.Moons {
if s.Moons[i].Finished && (moon == nil || s.Moons[i].FinishedAt.After(moon.FinishedAt)) {
moon = &s.Moons[i]
}
}
if moon == nil && v.Past && len(s.PastIDs) > 0 {
// The Past tab: the last moon finished (on its page).
moon = s.moon(s.PastIDs[0])
}
if moon == nil {
v.Past = false
@@ -201,6 +200,10 @@ func (v View) resolve(s *State) View {
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
}
@@ -214,6 +217,9 @@ func (v View) URL() string {
}
if v.Past && v.Section == "moons" {
q.Set("list", "past")
if v.Page > 1 {
q.Set("page", strconv.Itoa(v.Page))
}
}
if v.Well != "" {
q.Set("well", v.Well)
@@ -224,6 +230,9 @@ func (v View) URL() string {
if v.Drill != "" {
q.Set("drill", v.Drill)
}
if v.Adv != "" {
q.Set("adv", v.Adv)
}
if len(q) == 0 {
return "/"
}
@@ -239,15 +248,20 @@ func (v View) ToMoon(id string) string {
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 {
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 {
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.
func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
@@ -351,10 +365,28 @@ func (p *Page) ActiveURL() string {
}
func (p *Page) PastURL() string {
if len(p.Past) == 0 {
if p.S.PastTotal == 0 {
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 {
@@ -742,7 +774,9 @@ func (s *State) page(v View) *Page {
row.Pending = endsAttr(pending)
p.Ready += row.Ready
if m.Finished {
p.Past = append(p.Past, row)
if slices.Contains(s.PastIDs, m.ID) {
p.Past = append(p.Past, row)
}
} else {
p.Moons = append(p.Moons, row)
}
@@ -752,7 +786,10 @@ func (s *State) page(v View) *Page {
}
p.Pending = endsAttr(allPending)
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 {
case "moons":
@@ -897,7 +934,14 @@ func (s *State) page(v View) *Page {
p.Refinery = s.refineryPage()
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
}
@@ -1350,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)
}
// 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,
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill).
+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 {
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 {
display: flex;
flex-wrap: wrap;
@@ -4570,6 +4638,19 @@ textarea.eco-input {
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 {
color: var(--text);
font-weight: 600;
@@ -4916,7 +4997,7 @@ textarea.eco-input {
gap: 12px;
}
.reveal-foot .btn {
.reveal-foot .btn:first-child {
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
// 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
// drills finish.
// drills finish. The Preact client (web/app/fx.js) reuses the exported pieces.
import * as sfx from "./sfx.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);
// 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]");
@@ -210,6 +210,10 @@ function play(kind, toast, prev, at) {
equip: "upgrade",
unequip: "toggle",
hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy",
"buy-slot": "buy",
upgrade: "upgrade",
@@ -375,14 +379,14 @@ function gradient(i) {
const currency = "₥";
// Same as formatMoney in views.go.
function formatMoney(n) {
export function formatMoney(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 : "");
}
function centerOf(el) {
export function centerOf(el) {
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 };
}
@@ -418,7 +422,7 @@ function moonOut(id, row, delay) {
}
// Restart a CSS animation class on an element.
function mark(el, cls) {
export function mark(el, cls) {
if (!el) return;
el.classList.remove(cls);
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).
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");
el.className = `fx-float${big ? " fx-float-big" : ""}`;
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.
function stream(from, to, color, n, delay, onArrive) {
export function stream(from, to, color, n, delay, onArrive) {
if (reduced()) return onArrive();
ensureCanvas();
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.
document.body.addEventListener("htmx:configRequest", (event) => {
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;
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.
if (params[key] === undefined && value) params[key] = value;
}
+16 -1
View File
@@ -11,7 +11,8 @@
// balance down ("mm:revealed").
//
// 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.
import * as sfx from "./sfx.js";
@@ -77,6 +78,7 @@ export function playReveal({ scene, seed, settings, source }) {
let stage = "charge";
let card = null;
let cardDone = false;
let buyAnother = false;
// 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).
@@ -325,6 +327,7 @@ export function playReveal({ scene, seed, settings, source }) {
);
}
document.dispatchEvent(new CustomEvent("mm:revealed"));
if (buyAnother) htmx.ajax("POST", "/act/buy-moon", { source: document.body, swap: "none" });
};
sfx.play("close");
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();
}
// "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) {
event.preventDefault();
if (event.target.closest?.("[data-reveal-close]") && cardDone) return close();
const more = event.target.closest?.("[data-reveal-buy]");
if (more && buy(more)) return;
advance();
}
function onKey(event) {
@@ -372,6 +385,8 @@ export function playReveal({ scene, seed, settings, source }) {
event.preventDefault();
event.stopPropagation();
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();
}
layer.addEventListener("pointerdown", onPointer);
+94 -6
View File
@@ -1,11 +1,13 @@
{{/* The advisors section (advisors.go): the combined bonus, the seats, and every advisor
with a Hire button. */}}
{{/* The advisors section (advisors.go): the combined bonus, the seats and who sits in them,
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="grow">
<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>
{{if .NextSeatPrice}}
<button class="btn btn-dashed" hx-post="/act/buy-seat"
@@ -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>
</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">
{{range .Cards}}
<article class="card advisor-card {{if .Hired}}is-hired{{end}}">
@@ -30,13 +62,69 @@
<p class="mod-effect num">{{.Effect}}</p>
<p class="card-desc">{{.Description}}</p>
{{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}}
<button class="btn btn-primary card-buy" hx-post="/act/hire" hx-vals="{{vals "advisor" .Name}}"
{{with $.Advisors.HireWhy . $.S.Balance}}disabled title="{{.}}"{{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}}
</div>
</article>
{{end}}
</div>
{{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}}
+13 -2
View File
@@ -28,7 +28,7 @@
data-now (the server's clock) lines the countdowns up with the server. */}}
{{define "view"}}
<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}}
{{define "scene"}}
@@ -153,7 +153,7 @@
{{$active := .ActiveURL}}
<a href="{{$active}}" hx-get="{{$active}}" {{if not .PastView}}aria-current="page"{{end}}>Active <span class="muted num">{{len .Moons}}</span></a>
{{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}}
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>Past <span class="muted num">0</span></a>
{{end}}
@@ -172,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>
</a>
{{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}}
{{range .Moons}}
{{$url := $.V.ToMoon .ID}}
@@ -585,6 +592,9 @@
{{end}}
</ul>
<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>
</div>
</section>
@@ -640,6 +650,7 @@
{{define "side"}}
<section id="side" class="side{{with .Well}}{{if le .Supply 0.0}} depleted{{end}}{{end}}" {{template "oob" .}}>
{{with .Drill}}{{template "drill-panel" .}}{{end}}
{{with .Advisors}}{{with .Panel}}{{template "advisor-panel" $}}{{end}}{{end}}
{{with .Well}}
{{$dry := le .Supply 0.0}}
<h2>{{template "dot" .Resource}}{{.Resource}} well</h2>
+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}}