Compare commits
2
Commits
0846c017ae
...
85336f109f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85336f109f | ||
|
|
a5385a53a7 |
No files matched your search
@@ -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
@@ -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`.
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+294
-44
@@ -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,38 +31,130 @@ 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) => {
|
||||
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>
|
||||
{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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user