Compare commits
14
Commits
s-drillart
...
s-preact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85336f109f | ||
|
|
a5385a53a7 | ||
|
|
0846c017ae | ||
|
|
b4e5846dff | ||
|
|
3afd892487 | ||
|
|
cc1005c99e | ||
|
|
c998f8fda0 | ||
|
|
25f315e8dc | ||
|
|
571075c5ff | ||
|
|
fbde1b79ae | ||
|
|
4e7e2d010d | ||
|
|
020d1f7197 | ||
|
|
1fd1059aa7 | ||
|
|
a8bf63ffe4 |
No files matched your search
@@ -7,3 +7,4 @@ node_modules
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -2,6 +2,7 @@
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+2
@@ -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
|
||||
Vendored
+2
@@ -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=""";break;case 38:f="&";break;case 60:f="<";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
|
||||
Vendored
+2
@@ -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
|
||||
Vendored
+21
@@ -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
@@ -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
|
||||
Vendored
+1
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user