Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:19:07 +07:00
9 changed files with 491 additions and 49 deletions

No files matched your search

+6 -3
View File
@@ -149,8 +149,10 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
wallet, Claim all, moon list, wells table, well panel, viewport, toasts) on the JSON API;
other sections link to the classic client. `session.js`: like the Defold client's session
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,
@@ -159,7 +161,8 @@ refinery for the whole account. There is also a leaderboard (Leaderboard section
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`). Same classes as the classic markup (app.css); `preact.css` only
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).
+59 -13
View File
@@ -8,7 +8,7 @@ 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,000 lines without the vendored code):
- `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
@@ -26,7 +26,13 @@ claim/redrill/deploy clicks, and animations that never hitch.
- `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.
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`.
@@ -63,6 +69,40 @@ cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.e
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
@@ -93,17 +133,20 @@ where the side panel's drill lines change.
`runall.sh`.
## Unfinished / next steps
1. Re-run the measurements on an idle machine. Also record a Performance trace to compare
main-thread time per claim.
2. The well panel lacks the "Better well / Deploy there" line, wide (multi-well) drill
rows and claim-wide, the moon sale, and the past moons tab and search. Other sections
link to the classic client.
3. Sounds and effects are covered for claim, stake, redrill and error only. Navigation has
no sound.
4. Side-by-side screenshots: re-shoot `/app` vs `/` at the same state. Earlier shots looked
nearly identical; the only differences were the moon list's missing Active/Past tabs and
the Classic link instead of Admin.
5. Report: rough line counts are above. Hard parts:
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
@@ -121,3 +164,6 @@ where the side panel's drill lines change.
- 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`.
+41
View File
@@ -0,0 +1,41 @@
// The confirm dialog: the classic client's markup (admin.html "confirm-dialog"), opened and
// closed by its ui.js (closing animation, Escape, backdrop) with its sounds.
import { useRef, useLayoutEffect } from "preact/hooks";
import { ask, wireDialog } from "/static/js/ui.js";
import * as sfx from "/static/js/sfx.js";
let dialog = null;
export function ConfirmDialog() {
const ref = useRef(null);
useLayoutEffect(() => {
dialog = ref.current;
wireDialog(dialog, { onClose: (value) => sfx.play(value === "ok" ? "confirm" : "close") });
}, []);
return (
<dialog ref={ref} id="confirm" class="dialog frame" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title" class="dialog-title">
Confirm
</h2>
<p class="dialog-message" />
<div class="dialog-actions">
<button value="cancel" class="btn">
Cancel
</button>
<button value="ok" class="btn btn-primary" autofocus>
Confirm
</button>
</div>
</form>
</dialog>
);
}
// Asks the API's confirm ({title, text, ok, danger}); resolves true on OK.
export function confirm(c) {
if (!dialog) return Promise.resolve(false);
sfx.play("open");
return ask(dialog, { message: c.text, title: c.title, ok: c.ok, danger: c.danger });
}
+25
View File
@@ -10,6 +10,8 @@ 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();
@@ -48,9 +50,32 @@ hooks.onAction = (kind, prev, next, at) => {
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));
+2
View File
@@ -8,6 +8,7 @@ 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";
@@ -42,6 +43,7 @@ function App() {
</div>
</div>
<Toasts />
<ConfirmDialog />
</>
);
}
+269 -19
View File
@@ -1,13 +1,17 @@
// The moon list and the Moons section (wells table with drill bars, claim and redrill).
// 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 { live, act, navigate, viewURL } from "./session.js";
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, plural, drillLabel } from "./format.js";
import { money, cur, plural, drillLabel } from "./format.js";
import { useFlip } from "./flip.js";
import { now } from "./clock.js";
import { isReady } from "./predict.js";
import { isReady, find } from "./predict.js";
// A link to a view that navigates in place.
export function ViewLink({ to, children, ...rest }) {
@@ -17,6 +21,7 @@ export function ViewLink({ to, children, ...rest }) {
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
hooks.onNavigate();
navigate(to);
}}
{...rest}
@@ -26,12 +31,101 @@ export function ViewLink({ to, children, ...rest }) {
);
}
// 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}>
{st.moons.map((m) => {
{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
@@ -58,6 +152,9 @@ export function MoonList() {
</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>
@@ -71,26 +168,21 @@ 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">Buy a moon in the classic client to start mining.</p>
<a class="btn btn-primary" href="/?s=new-moon">
Scan for a moon
<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 (
<div class="heading">
<div class="grow">
<h1>{moon.name}</h1>
<p class="tapped">Mined out</p>
</div>
</div>
);
}
if (moon.finished) return <PastMoon st={st} moon={moon} />;
const max = st.rules.maxSupply;
return (
<>
@@ -126,17 +218,175 @@ export function MoonsSection() {
{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>
@@ -153,7 +403,7 @@ function WellRow({ st, moon, w, max }) {
)}
</td>
<td class="col-drill">
{mining ? <DrillCell st={st} drills={w.drills} /> : <span class="muted">Empty</span>}
{drills.length ? <DrillCell st={st} drills={drills} /> : <span class="muted">Empty</span>}
</td>
<td class="right">
<div class="row-actions">
+16 -4
View File
@@ -168,8 +168,8 @@ function removeStake(st, s) {
}
// 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) are left
// as the server sent them.
// 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;
@@ -229,8 +229,20 @@ export function derive(st, now) {
if (wnext !== null && (mnext === null || wnext < mnext)) mnext = wnext;
}
if (moon.wide) {
mready += moon.wide.ready ?? 0;
if (moon.wide.nextEnd && (mnext === null || moon.wide.nextEnd < mnext)) mnext = moon.wide.nextEnd;
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;
+9 -4
View File
@@ -36,19 +36,23 @@ export const toasts = signal([]);
export const busy = signal({});
export const boot = signal("Preparing your mining rig…");
// Wake at every drill end (readiness is local), and refetch shortly after the soonest one
// as a safety net, like the Defold client's arm_refetch.
// 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), onError(origin) on a refusal.
export const hooks = { onAction() {}, onError() {}, onToast() {} };
// 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;
@@ -191,6 +195,7 @@ function receive(r, data, status, recv) {
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);
}
+64 -6
View File
@@ -1,9 +1,11 @@
// The side panel next to the viewport: the selected well (deploy cards, drill lines,
// claim, redrill) or the moon.
// 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 } from "./session.js";
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";
@@ -38,6 +40,7 @@ function WellPanel({ st, moon, well, w }) {
const cards = useFlip();
const lines = useFlip();
const mining = w.drills.length > 0;
const wide = wideOn(moon, w);
return (
<>
<h2>
@@ -89,8 +92,20 @@ function WellPanel({ st, moon, well, w }) {
))}
</div>
) : (
<p class="muted small">No idle tools.</p>
<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} />}
</>
)}
@@ -99,7 +114,7 @@ function WellPanel({ st, moon, well, w }) {
{w.drills.length > 1 && <span class="muted small"> · {w.drills.length} share this well, oldest claims first</span>}
</h3>
<div ref={lines}>
{w.drills.map((d) => {
{[...w.drills, ...wide].map((d) => {
const tool = toolOf(st, d.toolId);
const ready = isReady(d, t);
return (
@@ -112,6 +127,7 @@ function WellPanel({ st, moon, well, w }) {
<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>
@@ -119,6 +135,13 @@ function WellPanel({ st, moon, well, w }) {
<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" && (
<>
{" "}
@@ -134,7 +157,7 @@ function WellPanel({ st, moon, well, w }) {
);
})}
</div>
{!mining && <p class="muted small">Nothing is mining here yet.</p>}
{!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)}>
@@ -147,6 +170,16 @@ function WellPanel({ st, moon, well, w }) {
)}
</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)}>
@@ -207,3 +240,28 @@ function DeployCard({ st, g, wellId }) {
}
// 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>
);
}