Compare commits

...
14 Commits
Author SHA1 Message Date
JosephFerano 4c04651c52 Progression: advisor payback counts a full moon of the newest drill (one per well) 2026-10-22 22:30:52 +07:00
JosephFerano f136a2a3c4 Economy curves: legend; cache reach caps; note that bands follow the draft 2026-10-22 22:23:05 +07:00
JosephFerano 58be6c2387 server/AGENTS.md: progression charts 2026-10-22 22:21:21 +07:00
JosephFerano 1aab2394ab Economy: curves per drill tier, moon grade and upgrade level (small multiples) 2026-10-22 22:21:13 +07:00
JosephFerano be1aab1cf8 Economy: clicker sim timeline (credits, income, purchases, grades, idle stretches)
Runs on demand from the Analysis tab (POST /utils/economy/sim) for the saved
config and the draft; the sim records samples, purchases and idle stretches.
2026-10-22 22:19:32 +07:00
JosephFerano 64fe6ba6c5 Economy: payback-per-purchase chart in the Analysis tab (saved vs draft)
Server-drawn SVG charts (chart.go). payLadder lists every purchase in buying
order by moon grade, with minutes to earn it back and jumps over 2x the step
before. Dev-only ?draft= (merge patch) and ?theme=light for screenshots.
2026-10-22 22:16:58 +07:00
JosephFerano 36a10a1a67 Client checks: drop moon-check (terrain.js is gone) 2026-10-22 22:07:21 +07:00
JosephFerano a938cba25e Merge branch 'admin' 2026-10-22 22:06:33 +07:00
JosephFerano 4f1671cc48 Docs: the server is the API and the admin; Defold is the only client 2026-10-22 22:04:25 +07:00
JosephFerano e5328d4178 Moon lab: no ± in the variation note (not in the fonts) 2026-10-22 22:02:31 +07:00
JosephFerano 50231812c8 Admin login: ADMIN_PASSWORD guards /utils
A login form at /utils/login sets a random session token (mm_admin cookie,
HttpOnly, SameSite Lax, kept in memory); /utils pages redirect to it without a
session and actions get a 401. No login with MM_DEV=1; no password means the
admin is locked. Passwords are compared in constant time, a wrong one waits a
second, tries are limited per IP. POST /api/moon-settings takes the admin
cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV.

Admin actions log "admin" instead of the admin's player id. Dropped
POST /utils/clear-data (reset the admin's own player) and /utils/load-config
(the load-config command does it), and the "You" marker and self-ban check
on the player page.
2026-10-22 22:02:09 +07:00
JosephFerano 11081486dc Client: admin area with a moon lab (live moon settings editor, Save to /api/moon-settings) 2026-10-22 22:00:01 +07:00
JosephFerano 8d0579f2c3 Delete the htmx game client; keep the admin pages
The game is the Defold client on the JSON API now. GET / redirects to /utils.
Removed the game page, POST /session and /act/, the game templates and JS,
three.js, idiomorph, ZzFX, the sounds, the web moon lab (/utils/moons) and
Tailwind. The admin pages get their own plain CSS (web/static/css/admin.css),
cut from app.css.
2026-10-22 21:58:34 +07:00
JosephFerano d05d5747de Delete the Preact web client experiment 2026-10-22 21:50:49 +07:00
131 changed files with 4405 additions and 17644 deletions

No files matched your search

+6 -5
View File
@@ -15,13 +15,14 @@ the whole account. There is also a leaderboard (Leaderboard section: top miners
banned players left out).
## How the parts fit
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it
renders as HTML (htmx, three.js moon). The server is the only authority on game state.
Details: `server/AGENTS.md`.
- `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
(`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
authority on game state. Details: `server/AGENTS.md`.
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's
JSON API (`/api/state`, `/api/act/<action>`, documented in `server/api.md`) and shows
what it sends; its moon generator is a port of `server/web/static/js/terrain.js`.
Details: `client/AGENTS.md`.
what it sends. It is the only game client, and its moon generator
(`client/modules/moon/terrain.lua`, native `mmterrain`) is the only one. Details:
`client/AGENTS.md`.
- Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`.
+3 -3
View File
@@ -2,10 +2,10 @@
An idle mining game: own moons, drill their wells, claim resources, sell or refine them.
- `server/`: Go server, JSON API (`server/api.md`) and the web client (htmx + three.js).
- `server/`: Go server: the JSON API (`server/api.md`) and the admin pages (`/utils`).
- `client/`: Defold client, for desktop and the browser (WebAssembly).
## Server and web client
## Server
Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`).
@@ -14,7 +14,7 @@ Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
podman-compose up -d db # or: docker compose up -d db
cd server
make migrate
make dev # http://localhost:3000
make dev # admin at http://localhost:3000/utils
```
After pulling changes, run `make migrate` again before starting the server: it applies new
+11 -9
View File
@@ -1,8 +1,8 @@
# AGENTS.md — Moon Miners client (Defold)
A native client for Moon Miners (rules and state live in the Go server: `../server`,
`../server/AGENTS.md`; the repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header, moon list left, the section in the middle, aside with the moon viewport and the
`../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
look come from the old web client (header, moon list left, the section in the middle, aside with the moon viewport and the
selected well; on Moons the aside only shows, every action is on the well cards), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
show the UI at native pixel size with more room, smaller ones rearrange. Mouse now,
gamepad later. Details live in each file's header comment; this file has the rules and a
@@ -47,7 +47,7 @@ map.
builds outside its block. A new per-build registry notes its entries in the block journal
(`blocks.note`/`push`/`put`); code that builds outside a build runs through
`blocks.bound(fn)`.
- Theme: every color is a token (`theme.color`, dark and light from app.css): no hex or
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
@@ -56,7 +56,7 @@ map.
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
doesn't render (make check catches the warning); stencils and images split batches.
Outline textures use slice9 2.
- Motion: easing curves match the web (`modules/anim.lua`). GUI nodes don't pass alpha to
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
children: fade content in from under a veil, not by fading a subtree. Every new
animation honors reduced motion (`anim.reduced`). Effects for action results go in
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
@@ -140,7 +140,8 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
menu), `savecode.lua`, `grant.lua` (admin grant dialog).
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
@@ -149,7 +150,7 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
Components (`components/`, API in each header; build new UI from these): `button`,
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
`scroll`, `bar`, `chip`, `stat`, `row`. `widgets/moon_row.lua`: the moon list row.
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
Modules (`modules/`):
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
@@ -163,14 +164,15 @@ Modules (`modules/`):
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
`hot.lua` (hot reload).
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, terrain.js settings port).
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
Elsewhere:
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
`render/mm.render_script`: HDR moon pass with bloom.
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
game's only one (it began as a C port of the web client's terrain.js, now deleted).
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
- `web/index.html`: the web build's page.
+99
View File
@@ -0,0 +1,99 @@
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
-- exactly (screen position mapped into the track, not summed deltas).
--
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
-- on_change = fn(v), on_release = fn(v) })
-- s:set(v) -- move it without on_change (another control changed the value)
--
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
local ui = require("modules.ui")
local test = require("modules.test")
local M = {}
M.__index = M
local TOUCH = hash("touch")
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function snap(v, o)
local n = math.floor((v - o.min) / o.step + 0.5)
v = o.min + n * o.step
-- (decimal steps: drop float noise like 0.30000000000000004)
v = tonumber(string.format("%.6f", v))
return math.max(o.min, math.min(o.max, v))
end
function M.new(g, parent, x, y, w, h, opts)
local self = setmetatable({ opts = opts, w = w }, M)
local hit = ui.rect(parent, x, y, w, h, "none")
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
gui.set_pivot(knob, gui.PIVOT_CENTER)
gui.set_position(knob, vec(0, -h / 2))
self.knob = knob
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
local v = snap(opts.min + f * (opts.max - opts.min), opts)
self:_fill(f)
if v ~= self.value then
self.value = v
if opts.on_change then
opts.on_change(v)
end
end
end)
self.s = s
local zone = opts.zone
s.on_input = function(sl, action_id, action)
if action_id ~= TOUCH or not action.screen_x then
return false
end
if not sl.is_drag then
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
and (not zone or gui.pick_node(zone, action.x, action.y))) then
return false
end
sl.is_drag = true
end
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
local f = math.max(0, math.min(1, p.x / w))
if f ~= sl.value then
sl:set(f)
end
if action.released then
sl.is_drag = false
if opts.on_release then
opts.on_release(self.value)
end
end
return true
end
self:set(opts.value)
test.register(opts.id, hit, "slider", opts.label, zone, true)
return self
end
function M:_fill(f)
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
end
function M:set(v)
local o = self.opts
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
self.value = v
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
self.s:set(f, true)
self:_fill(f)
end
return M
+13 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
elseif data == nil and status ~= 204 and method == "POST" then
err = "bad JSON"
elseif data == nil and method == "GET" and response.response ~= "null" then
err = "bad JSON"
end
done(err, data, status, recv)
@@ -185,6 +187,16 @@ function M.get_save_code(cb)
request("GET", "/api/save-code", nil, cb)
end
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
function M.get_moon_settings(cb)
request("GET", "/api/moon-settings", nil, cb)
end
function M.save_moon_settings(settings, cb)
request("POST", "/api/moon-settings", settings, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
+8 -2
View File
@@ -25,7 +25,7 @@
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
--
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
-- defaults like the web does; until the request answers nothing is generated.
--
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
@@ -39,9 +39,15 @@ M.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
-- Settings: nil until loaded (or failed: then the defaults).
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
M.settings = nil
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
M.building = false
M.last_build = nil
-- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false }
+1 -1
View File
@@ -1,4 +1,4 @@
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
+5 -2
View File
@@ -195,7 +195,7 @@ local function start_job(self, id, key, params)
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION
end
local job = { id = id, key = key, params = params }
local job = { id = id, key = key, params = params, t0 = now() }
job.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen)
self.job = job
@@ -252,7 +252,9 @@ end
local function job_done(self, job)
local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res)
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
local ms = (now() - job.t0) * 1000
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
if job.key == self.want_key then
set_mesh(self, job.key, res, job.params)
end
@@ -450,6 +452,7 @@ function update(self, dt)
if #self.orphans > 0 then
step_orphans(self)
end
moon.building = self.job ~= nil or #self.orphans > 0
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false
return
+98
View File
@@ -0,0 +1,98 @@
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
-- builds it under the header instead of the moon list, the section and the aside; a bar
-- on top lists the screens and leads back to the game (so does any navigation: a header
-- tab, Back).
--
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
-- Test ids: "admin:<screen>", "admin:back".
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local button = require("components.button")
local S = theme.size
local M = {}
local SCREENS = {
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
}
local function find(id)
for _, s in ipairs(SCREENS) do
if s.id == id then
return s
end
end
end
function M.allowed(g)
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
end
function M.open(g, id)
local s = find(id or SCREENS[1].id)
if not s then
return
end
if g.admin and g.admin ~= s.id then
M.close(g)
end
if g.admin ~= s.id then
g.admin = s.id
if s.mod.open then
s.mod.open(g)
end
end
g.dirty, g.dirty_all = true, true
end
function M.close(g)
local s = g.admin and find(g.admin)
if s and s.mod.close then
s.mod.close(g)
end
g.admin = nil
g.dirty, g.dirty_all = true, true
end
-- The block's inputs besides the rect.
function M.sig(g)
local s = find(g.admin)
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
end
-- The bar and the screen in (x, y, w, h).
function M.build(g, parent, x, y, w, h)
local s = find(g.admin)
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
for _, sc in ipairs(SCREENS) do
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
function(b, nodes)
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
end)
end
kids[#kids + 1] = fg.fill()
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
end)
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
flex.solve(bar, x, y)
fg.build(bar, parent)
local top = y + bar.h + S.gap
if s then
s.mod.build(g, parent, x, top, w, y + h - top)
end
end
function M.update(g, dt)
local s = g.admin and find(g.admin)
if s and s.mod.update then
s.mod.update(g, dt)
end
end
return M
+23
View File
@@ -45,6 +45,7 @@ local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks")
local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
@@ -374,6 +375,25 @@ function M.build(g)
local pad, gap = S.body_pad, S.gap
local top = header_h + pad
local body_h = H - top - pad
if g.admin then
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
g.toasts:detach()
g.moon_nodes, g.well_nodes = {}, {}
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
g.page_key, g.page_main, g.side_w = nil, nil, 0
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
admin.build(g, holder, pad, top, W - pad * 2, body_h)
end)
blocks.finish(g)
g.tween:finish()
ticks.run(g)
g.dirty = false
g.fx:after_build(g)
g.reveal:after_build()
g.rehover = true
return
end
local has_aside = M.aside_shown(g)
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
-- under the moon list and the main panel, the viewport and the side panel side by
@@ -659,6 +679,9 @@ function M.update(g, dt)
m.update(g, dt)
end
end)
if g.admin then
admin.update(g, dt)
end
g.druid:update(dt)
if g.rehover then
-- After a rebuild: what is under the mouse shows its hover at once (the new
+3
View File
@@ -404,6 +404,9 @@ function M.navigate(g, v)
end
g.nav_view = v
g.ask = nil -- (a sell asking in place: cards.ask_button)
if g.admin then
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
end
M.fetch(g, v, v)
end
+466
View File
@@ -0,0 +1,466 @@
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
-- object, as terrain.lua's DEFAULTS shape it).
--
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
-- the saved settings), the save status and Save; under it the settings in a scroll area,
-- grouped like the web page: a slider and a number field per number, a hex field and a
-- swatch per color, a toggle per flag, a button row for the technique.
--
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
-- once (moon.script's cache).
--
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
-- technique) or the size does: a slider's drag never rebuilds it.
--
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local field = require("modules.field")
local api = require("modules.api")
local test = require("modules.test")
local terrain = require("modules.moon.terrain")
local moon_view = require("modules.moon.moon")
local panel = require("components.panel")
local scroll = require("components.scroll")
local button = require("components.button")
local slider = require("components.slider")
local S = theme.size
local M = {}
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
-- [key, "bool", label], [key, "select", label] (the technique).
local SCHEMA = {
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
{ title = "Shape noise", path = "shape", fields = {
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
} },
{ title = "Ridges", path = "ridge", fields = {
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
} },
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
} },
{ title = "Craters", path = "craters", fields = {
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
} },
{ title = "Shading", path = "shading", fields = {
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
} },
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
} },
{ title = "Render", path = "render", fields = {
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
} },
}
local SEED = "moon-lab"
local ROW_H = 30
local LABEL_W = 150
local NUM_W = 76
local PAD = S.panel_pad
local CTRL_MIN, CTRL_MAX = 440, 560
local STATS_H = 22
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function clone(t)
if type(t) ~= "table" then
return t
end
local c = {}
for k, v in pairs(t) do
c[k] = clone(v)
end
return c
end
-- "minValue" -> "Min value" (the web's label()).
local function label_of(key)
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
return s:sub(1, 1):upper() .. s:sub(2)
end
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
local function fmt(v, step)
if step >= 1 then
return string.format("%d", math.floor(v + 0.5))
end
local dec = #(tostring(step):match("%.(%d+)") or "00")
return string.format("%." .. math.min(4, dec) .. "f", v)
end
local function group_of(settings, path)
return path and settings[path] or settings
end
---------------------------------------------------------------- state
local function lab(g)
g.lab = g.lab or { seed = SEED, rev = 0 }
return g.lab
end
local function status(L)
if L.saving then
return "Saving…"
elseif L.error then
return L.error
elseif not L.loaded then
return "Loading…"
end
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
end
local function show_status(L)
if L.w and L.w.status then
ui.style_text(L.w.status, "small", status(L))
ui.fit_text(L.w.status, L.w.status_w)
end
end
-- A value changed: the moon gets it on the next free frame (update).
local function changed(L)
L.pending = true
L.error = nil
show_status(L)
end
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
local function rebuild(g)
local L = lab(g)
L.rev = L.rev + 1
L.pending = true
L.error = nil
g.dirty = true
end
function M.open(g)
local L = lab(g)
L.saved = terrain.merge_settings(moon_view.settings)
L.draft = clone(L.saved)
L.loaded, L.error, L.saving = false, nil, false
L.pending = true
api.get_moon_settings(function(err, data)
if g.lab ~= L then
return
end
if err then
L.loaded = true
L.error = "Couldn't load the saved settings (" .. err .. ")"
show_status(L)
return
end
L.saved = terrain.merge_settings(data)
L.draft = clone(L.saved)
L.loaded = true
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
rebuild(g)
end)
end
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
-- for the lab).
function M.close(g)
local L = g.lab
if L and L.saved then
moon_view.settings = clone(L.saved)
end
g.lab = nil
end
local function save(g)
local L = lab(g)
if L.saving then
return
end
L.saving, L.error = true, nil
show_status(L)
local sent = clone(L.draft)
api.save_moon_settings(sent, function(err)
if g.lab ~= L then
return
end
L.saving = false
if err then
L.error = "Save failed: " .. err
g.toasts:push("Moon settings not saved: " .. err, "error", true)
else
L.saved = sent
moon_view.settings = clone(sent)
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
end
show_status(L)
end)
end
---------------------------------------------------------------- controls
local function small_button(parent, label, id, on_click, opts)
opts = opts or {}
local bopts = { small = true, variant = opts.variant, on = opts.on }
return fg.button(parent, label, bopts, function(b, nodes)
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
end, opts.style)
end
-- One setting's row: label, then its control.
local function setting_row(g, L, parent, zone, path, f)
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
local kind = type(a) == "string" and a or "number"
local text = (kind ~= "number" and b or name) or label_of(key)
local gid = (path or "root") .. "." .. key
local target = group_of(L.draft, path)
local kids = {}
if kind ~= "select" then
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
end
if kind == "number" then
local o = { min = a, max = b, step = step }
local fkey = "lab:" .. gid
local f_state = field.get(g, fkey)
f_state.text = fmt(target[key], step)
local ctl = {}
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
id = "slider:" .. gid, label = text, zone = zone,
on_change = function(v)
target[key] = v
if ctl.input then
ctl.syncing = true
ctl.input:set_text(fmt(v, step))
f_state.text = fmt(v, step)
ctl.syncing = false
end
changed(L)
end })
end)
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
min = o.min, max = o.max,
on_change = function(t)
local v = tonumber(t)
if ctl.syncing or not v then
return
end
v = math.max(o.min, math.min(o.max, v))
target[key] = v
ctl.slider:set(v)
changed(L)
end })
end)
elseif kind == "color" then
local fkey = "lab:" .. gid
field.get(g, fkey).text = target[key]
local sw
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
sw = ui.rect(p, x, y, w, h, "line_hi")
-- (the setting's own color, not a theme color: the swatch shows the data)
local c = terrain.hex_to_rgb(target[key])
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
end)
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
on_change = function(t)
if t:match("^#%x%x%x%x%x%x$") then
target[key] = t:lower()
local c = terrain.hex_to_rgb(t)
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
changed(L)
end
end })
end)
elseif kind == "bool" then
local on = target[key] == true
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
target[key] = not target[key]
rebuild(g)
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
else -- the technique
local row = {}
for _, t in ipairs(terrain.TECHNIQUES) do
local cur = target[key] == t
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
target[key] = t
rebuild(g)
end, { g = g, variant = cur and "primary" or nil })
end
return fg.col({ gap = 6 }, {
fg.label(parent, text, "small", 20, { fit = true }),
fg.box({ wrap = true, gap = 6 }, row),
})
end
return fg.row({ height = ROW_H, align = "center" }, kids)
end
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
local function settings_list(g, L, parent, x, y, w, h)
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
local p = area.content
local groups = {}
for _, sec in ipairs(SCHEMA) do
local rows = { fg.label(p, sec.title, "h3", 22) }
if sec.note then
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
end
for _, f in ipairs(sec.fields) do
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
end
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
end
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
flex.solve(tree, 0, 0)
fg.build(tree, p)
area:finish(tree.h)
end
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
-- Returns its height.
local function toolbar(g, L, parent, x, y, w)
local st = g.state or {}
field.get(g, "lab-seed").text = L.seed
local function seed_to(s)
L.seed = s
field.get(g, "lab-seed").text = s
rebuild(g)
end
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
for i, m in ipairs(st.moons or {}) do
if i > 3 then
break
end
local id = tostring(m.id)
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
{ g = g, style = { max_width = 160 } })
end
local status_w = 170
local tree = fg.col({ width = w, gap = 10 }, {
fg.label(parent, "Seed (moon id)", "cap", 14),
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
on_change = function(t)
L.seed = t ~= "" and t or SEED
L.pending = true
end })
end),
small_button(parent, "New seed", "lab:seed-new", function()
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
end, { g = g }),
}),
fg.box({ wrap = true, gap = 6 }, presets),
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
small_button(parent, "Defaults", "lab:defaults", function()
L.draft = clone(terrain.DEFAULTS)
rebuild(g)
end, { g = g }),
small_button(parent, "Reset", "lab:reset", function()
L.draft = clone(L.saved)
rebuild(g)
end, { g = g }),
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
L.w.status_w = math.min(status_w, bw - 8)
show_status(L)
end),
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
end, { width = 110 }),
}),
})
flex.solve(tree, x, y)
fg.build(tree, parent)
return tree.h
end
---------------------------------------------------------------- build
-- The block's inputs (core/main keeps it while they stay the same).
function M.sig(g)
local L = lab(g)
return { L.rev, L.loaded }
end
function M.build(g, parent, x, y, w, h)
local L = lab(g)
L.w = {}
local gap = S.gap
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
local mw = w - cw - gap
-- The moon.
local mp = panel.new(parent, x, y, mw, h)
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
local vx = math.floor((mw - size) / 2)
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
L.vp = vp
moon_view.show(L.seed, vp)
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
L.w.stats_w = size
L.shown_build = nil
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
-- The settings.
local cp = panel.new(parent, x + mw + gap, y, cw, h)
local inner = cw - PAD * 2
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
local ly = PAD + th + 12
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
end
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
-- show the last build.
function M.update(g, dt)
local L = g.lab
if not L or not L.vp then
return
end
if L.pending and not moon_view.building then
L.pending = false
moon_view.settings = clone(L.draft)
moon_view.show(L.seed, L.vp)
moon_view.building = true -- (until moon.script looks: one handed over at a time)
end
local b = moon_view.last_build
if b and b ~= L.shown_build and L.w and L.w.stats then
L.shown_build = b
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
end
end
return M
+15 -2
View File
@@ -2,7 +2,8 @@
-- gear, a text button per setting or action with its icon on the right: How to play (the
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
-- code (savecode.lua), and Grant for admins.
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
-- admins and in debug builds.
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
@@ -73,6 +74,10 @@ local ICONS = {
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
lab = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
gui.play_flipbook(icon, "moon")
end,
grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
@@ -98,7 +103,9 @@ end
function M.open(g, right, top)
local st = g.state
local help = st and st.unlocked and st.unlocked.help
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0)
local admin = require("screens.game.admin")
local lab = st and admin.allowed(g)
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
local h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({
w = W,
@@ -171,6 +178,12 @@ function M.open(g, right, top)
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
require("screens.game.grant").open(g)
end))
y = y + ROW_H
end
if lab then
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
admin.open(g, "moonlab")
end), { disabled = g.reveal:active() })
end
end,
})
-12
View File
@@ -8,8 +8,6 @@
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
sample of server texts (GET /api/state of a fixture player, every section) must be in
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
- the moon generator (scripts/moon-check.mjs: the mmterrain extension's C, built for the
host, and terrain.lua's settings side, against the server's terrain.js)
- engine warnings in a scenario run's log: font batches / characters / text entries
over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors
@@ -231,15 +229,6 @@ def check_scenario(api):
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
def check_moon():
r = subprocess.run(["node", os.path.join(HERE, "moon-check.mjs")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
fail("moon-check.mjs failed:\n" + "\n".join(l for l in r.stdout.splitlines() if not l.startswith("ok "))[-2000:])
else:
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
def check_flex():
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
@@ -257,7 +246,6 @@ def main():
check_lua()
check_flags()
check_flex()
check_moon()
check_fonts(api)
if "--static" not in sys.argv:
check_scenario(api)
-143
View File
@@ -1,143 +0,0 @@
// Checks the moon generator against the server's terrain.js: the native one
// (mmterrain/src/terrain.c, built here for the host and loaded into luajit through the
// FFI) gives the same mesh within float32 rounding, and the Lua settings side
// (modules/moon/terrain.lua: random draws, traits, resolve, the strip) matches too.
// Every seed runs with the default settings and a few others (each technique, many
// craters, no ridges or patches). Needs node, luajit and a C compiler (cc).
//
// node scripts/moon-check.mjs [resolution] [seeds...]
//
// terrain.js is read from $MM_SERVER (default ../server).
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const server = process.env.MM_SERVER || path.join(root, "..", "server");
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
const [res = "32", ...rest] = process.argv.slice(2);
// The fixed seeds, plus moon-id-like ones (deterministic).
const ids = [];
{
let h = 0x9e3779b9;
const next = () => ((h = (Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0), h.toString(16).padStart(8, "0"));
for (let i = 0; i < 24; i++) ids.push(`${next()}-${next().slice(0, 4)}-4${next().slice(0, 3)}-a${next().slice(0, 3)}-${next()}${next().slice(0, 4)}`);
}
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9", ...ids];
const fixed = (t) => ({ technique: t, variation: { technique: false } });
const SETTINGS = [
{}, fixed("fbm"), fixed("ridged"), fixed("warped"), fixed("billow"), fixed("craterField"),
{ craters: { count: 1500, smoothness: 0, maxRadius: 0.4 } }, { ridge: { strength: 0 }, shading: { patchAmount: 0 } },
];
// Each case: a seed and a settings index (the default settings for every seed, the
// others over a rotating subset).
const cases = [];
seeds.forEach((seed, i) => {
cases.push([seed, 0]);
for (let s = 1; s < SETTINGS.length; s++) if ((i + s) % 4 === 0 || rest.length) cases.push([seed, s]);
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mmterrain-"));
const lib = path.join(dir, "libmmterrain.so");
execFileSync("cc", ["-O2", "-std=c99", "-shared", "-fPIC", "-o", lib, path.join(root, "mmterrain/src/terrain.c"), "-lm"]);
const luaValue = (v) => (typeof v === "object" ? "{" + Object.entries(v).map(([k, x]) => `${k}=${luaValue(x)}`).join(",") + "}" : JSON.stringify(v));
const lua = `
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
local T = require("modules.moon.terrain")
local SETTINGS = { ${SETTINGS.map(luaValue).join(", ")} }
local ffi = require("ffi")
ffi.cdef[[
typedef struct mt_params mt_params;
typedef struct mt_gen mt_gen;
int mt_params_from_array(const double* a, int n, mt_params* p);
mt_gen* mt_new(const mt_params* p);
void mt_free(mt_gen* g);
int mt_step(mt_gen* g, double budget);
int mt_count(const mt_gen* g);
int mt_n(const mt_gen* g);
const double* mt_positions(const mt_gen* g);
const double* mt_normals(const mt_gen* g);
const double* mt_colors(const mt_gen* g);
int mt_strip_len(int n);
void mt_strip(int n, int* out);
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
]]
local N = ffi.load(${JSON.stringify(lib)})
local function num(x) return string.format("%.17g", x) end
local function carr(p, n) local o = {} for k = 0, n - 1 do o[k + 1] = num(p[k]) end return "[" .. table.concat(o, ",") .. "]" end
local out = {}
for i = 2, #arg, 2 do -- arg[1]: resolution, then seed, settings index pairs
local seed, si = arg[i], tonumber(arg[i + 1])
local s = T.merge_settings(SETTINGS[si + 1])
local r = T.rng(T.hash_string(seed))
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
local tr = T.traits(s, seed)
local p = T.resolve(s, seed)
p.resolution = tonumber(arg[1])
do
local a = T.native_params(p)
local buf = ffi.new("double[?]", #a, a)
local params = ffi.new("char[1024]") -- (larger than mt_params)
assert(N.mt_params_from_array(buf, #a, ffi.cast("mt_params*", params)) == 1)
local g = N.mt_new(ffi.cast("mt_params*", params))
local steps = 0
repeat steps = steps + 1 until N.mt_step(g, 0.0005) == 1
local c = N.mt_count(g) * 3
-- The strip: the native order equals the Lua's (terrain.strip), and its fill writes
-- those vertices.
local strip = T.strip(N.mt_n(g))
local len = N.mt_strip_len(N.mt_n(g))
local sidx = ffi.new("int[?]", len)
N.mt_strip(N.mt_n(g), sidx)
local same = len == #strip
for k = 1, #strip do if not same then break end same = sidx[k - 1] == strip[k] end
local fill = ffi.new("float[?]", len * 9)
local half = math.floor(len / 2) -- (in two ranges, like the web's chunks)
N.mt_fill(g, fill, fill + 3, fill + 6, 9, 0, half)
N.mt_fill(g, fill, fill + 3, fill + 6, 9, half, len)
local P = N.mt_positions(g)
local fdiff = 0
for k = 1, #strip do
local v = strip[k]
for d = 0, 2 do fdiff = math.max(fdiff, math.abs(fill[(k - 1) * 9 + d] - P[v * 3 + d])) end
end
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s,"strip":%s,"fill":%s,"steps":%d}',
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
num(tr.maxRadius), carr(N.mt_positions(g), c), carr(N.mt_normals(g), c), carr(N.mt_colors(g), c),
same and "true" or "false", num(fdiff), steps)
N.mt_free(g)
end
end
print("[" .. table.concat(out, ",") .. "]")
`;
const got = JSON.parse(execFileSync("luajit", ["-", res, ...cases.flat().map(String)], { input: lua, maxBuffer: 1 << 30 }).toString());
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
const close = (m, g) => {
const d = { positions: maxDiff(m.positions, g.positions), normals: maxDiff(m.normals, g.normals), colors: maxDiff(m.colors, g.colors) };
return [d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4, d];
};
const fmt = (d) => `positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`;
let failed = false;
cases.forEach(([seed, si], i) => {
const s = T.mergeSettings(SETTINGS[si]);
const r = T.rng(T.hashString(seed));
const draws = Array.from({ length: 16 }, () => r());
const t = T.moonTraits(s, seed);
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
const p = T.resolveMoon(s, seed);
p.resolution = Number(res);
const m = T.generateMoon(p);
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
const nat = got[i];
const [natOk, d] = close(m, nat);
const ok = exact && natOk && nat.strip && nat.fill < 1e-5;
const line = `draws and traits ${exact ? "identical" : "DIFFER"} | native: ${fmt(d)}; strip ${nat.strip ? "same" : "DIFFERS"}, fill ${nat.fill.toExponential(1)}, ${nat.steps} steps`;
failed ||= !ok;
console.log(`${ok ? "ok " : "FAIL"} ${seed} [settings ${si}] ${t.technique}: ${line}`);
});
console.log(`${cases.length} cases at resolution ${res}: ${failed ? "FAILED" : "all ok"}`);
process.exit(failed ? 1 : 0);
+66
View File
@@ -0,0 +1,66 @@
"""Moon lab (admin area, screens/game/moonlab.lua): the gear menu's Moon lab opens it (needs
an MM_DEV=1 server, where everyone is an admin, or a debug build), the saved settings load,
the layout lints, a real mouse drag on a slider follows the pointer and rebuilds the moon
(the build times are printed: "moon: ... built, N vertices, M ms"), Save posts the
settings (GET answers them back), and Back to the game leaves the lab with the game's
moon on the saved settings."""
import json
import time
import urllib.request
M = "require('modules.moon.moon')"
def built(game):
game.wait_for("not %s.building and not g.lab.pending" % M)
return game.eval("%s.last_build and %s.last_build.ms" % (M, M))
def run(game, args):
game.idle()
game.click("settings")
game.wait_for("g.dialog:is_open()")
game.sleep(0.3)
game.click("menu:moon-lab")
game.wait_for("g.admin == 'moonlab' and g.lab and g.lab.loaded")
game.idle()
ms0 = built(game)
game.shot("moonlab", settle=0.5)
game.lint("moonlab")
# A click on a slider's track: the value under the pointer (the middle).
game.scroll_to("slider:craters.count")
game.click("slider:craters.count")
game.expect(game.eval("g.lab.draft.craters.count") == 1000, "a click in the middle sets the middle")
ms1 = built(game)
# A real drag across a slider: the knob follows, the moon rebuilds as it goes.
game.scroll_to("slider:shape.strength")
x, y = game.os_point("slider:shape.strength")
game.os_move(x - 40, y)
game.xdo("mousedown", "1")
t0 = time.time()
for i in range(20):
game.os_move(x - 40 + i * 8, y)
time.sleep(0.03)
game.xdo("mouseup", "1")
game.idle()
v = game.eval("g.lab.draft.shape.strength")
ms2 = built(game)
print("moonlab: build ms first %s, click %s, after drag %s (drag ended at strength %s, %.1f s)" % (ms0, ms1, ms2, v, time.time() - t0))
game.expect(v and v > 0.1, "the drag moved the slider right of the middle: %r" % v)
game.shot("moonlab-dragged", settle=0.3)
game.click_check("lab:save")
game.wait_for("not g.lab.saving")
game.expect(game.eval("g.lab.error") is None, "saved")
with urllib.request.urlopen(game.api + "/api/moon-settings") as r:
saved = json.load(r)
game.expect(saved["craters"]["count"] == 1000, "the server has the new settings")
game.click("admin:back")
game.wait_for("g.admin == nil")
game.idle()
game.expect(game.eval("%s.settings.craters.count" % M) == 1000, "the game's moon uses the saved settings")
game.shot("moonlab-back", settle=0.5)
+1
View File
@@ -13,6 +13,7 @@ services:
restart: unless-stopped
environment:
<<: *db-env
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose:
-2
View File
@@ -5,6 +5,4 @@ node_modules
.env*
.vscode
.DS_Store
web/static/css/app.css
art
web/static/app
-2
View File
@@ -1,8 +1,6 @@
# Build output
/bin/
/moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit)
.env
+72 -95
View File
@@ -1,22 +1,17 @@
# AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
every command below from this directory (`server/`).
## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
- 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
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
light themes). No bundler, no TypeScript.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout
@@ -31,23 +26,25 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they 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.
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
the `Page` view model, formatting helpers, the admin templates' functions.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
limits with forms parsed up front, a per-IP limit on new players), `runAction` (every game
action).
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
`admin_test.go`: the gate.
- `api.go`, `api.md`: the JSON API: `GET /api/state` (the view models mapped to JSON DTOs;
times in unix ms) and `POST /api/act/<action>` (`runAction`, gates included;
`{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
`include=`; reasons are codes with the player-facing text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
for every test view (the test states are in `templates_test.go`).
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
@@ -64,7 +61,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim; `progression.go`: the Analysis tab's charts, saved config against the draft: payback per purchase, the clicker sim timeline (on demand, `POST /utils/economy/sim`), curves; drawn as SVG by `chart.go`). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
@@ -80,13 +77,12 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
average over the pressure it adds). Personal modifiers never change the quote, cards,
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
for you", "−4% from your recent selling"). View model for the Market section (ticker,
watchlist, chart, sell ticket; `pick=` in the URL selects a quote).
`web/static/js/market.js`: chart crosshair and ticket estimate.
watchlist, chart, sell ticket; `pick=` selects a quote).
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
drill on it is running; finished drills are claimed in the sale.
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
its track levels; refined parts not refunded): only idle tools with nothing to claim,
never the last tool.
@@ -95,7 +91,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
`refined_account`, `refinery_job`, `users.refinery_slots`.
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (cycles to a tenth of a second, at least 1 s) (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
@@ -106,65 +102,47 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
ladder, on the Equipment card and in the drill panel: `drill=` in the URL, Equipment
ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
section, in the aside; the toast text).
- `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`.
- `advisors.go`: 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 view, 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
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
`ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
Banned players (`users.banned_at`) get a 403 from the API.
`web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
(`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
- `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
`enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
- `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
- `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
`ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
`go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
any more) and the wells.
- `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
use; the tools show their original images.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
like the Defold client's session (`base`, `pending`, outbox with `clientSeq`, rollback on
422), `predict.js` a port of the Defold `predict.lua`. Details in each file's header.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
(drills by tool id, not stake id); 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–020):
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.
@@ -172,19 +150,20 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
they write into `web/static/assets`.
## Commands
- `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, the Preact client's `.value` check and build), `make migrate`,
`make load-config`. `go run . sim` prints the economy analysis.
- `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
long, to try the client on a slow connection.
- `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
every `web/static/js/*.js`), `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.
admin templates like the server and renders every page against in-memory data; when you
add a page or a branch, make sure its data reaches it.
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
Other tests: `go test ./...` (game rules on in-memory states, no database);
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
resale odds, sim seeds).
## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
@@ -200,23 +179,21 @@ distribution, value by well count, resale odds, sim seeds).
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
player is an admin.
## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- New section: add it to `sections` and `State.page` (views.go) and map it in the API
(`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
`api.md`. The client sends the current view (s, moon, well, drill) with every action, so
action parameters must use other names.
- The server is the authority; clients predict only what `api.md` "Prediction" allows.
- New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
+1 -10
View File
@@ -1,19 +1,10 @@
# Production image: one Go binary with the templates, static files and SQL embedded.
FROM docker.io/library/golang:1.25-bookworm AS build
WORKDIR /src
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 .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM scratch
COPY --from=build /moonminers /moonminers
-169
View File
@@ -1,169 +0,0 @@
# 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`.
+6 -37
View File
@@ -1,30 +1,11 @@
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 migrate load-config check
.PHONY: build dev css app migrate load-config check
build: css app
build:
go build -o bin/moonminers .
# 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 & \
$(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
# Server on :3000 that reloads templates and static files from disk.
dev:
MM_DEV=1 go run . serve
migrate:
go run . migrate
@@ -34,17 +15,5 @@ load-config:
check:
test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes)$$' -count=1 .
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes|TestAdminGate)$$' -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
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x $@
+51 -61
View File
@@ -1,19 +1,17 @@
# Moon Miners server
A browser mining game. Players own moons (staking sources) with resource wells, buy and
The server of a mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
the higher levels of the drills' upgrade tracks.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
It's a Go server: game rules run in Go, one Postgres transaction per action, behind a JSON
API (`api.md`) that the Defold client (`../client`) plays on. The admin pages (`/utils`) are
server-rendered HTML with htmx. `GET /` redirects to `/utils` for now.
Players join anonymously: on the first visit the server creates a player and stores its id in
a cookie. No wallet or sign-up needed.
Players join anonymously: on the first request the server creates a player and sends its id
back (`X-MM-User`, also a cookie). No wallet or sign-up needed.
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
from this directory.
@@ -24,8 +22,6 @@ from this directory.
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI and esbuild into `bin/` on first use.
## Environment
The server reads `.env` from its working directory (`server/`), or if there is none, from the
@@ -42,11 +38,13 @@ ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
login, and every player is an admin), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit by
the last `X-Forwarded-For` address instead of the connection's, and mark the admin cookie
Secure on HTTPS; new players are limited to 10 a minute per IP).
## Local development
@@ -59,7 +57,8 @@ limited to 10 a minute per IP).
make migrate
```
3. Run the server and open http://localhost:3000:
3. Run the server (`MM_DEV=1`: no admin login) and open the admin at
http://localhost:3000/utils, or point the Defold client at it:
```bash
make dev
@@ -69,14 +68,15 @@ limited to 10 a minute per IP).
pulling changes (or editing `config/game.json`), run `make migrate` again: new
migrations and the store items' numbers only reach the database through it.
Other commands: `make build` (CSS, the Preact bundle and `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
Other commands: `make build` (`bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every admin page without a database, the API and
admin login tests, JS syntax), `make load-config` (sync the store items in the database with
`config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
and `moon-traits` (JSON on stdin; prints the moon looks `moondesc.go` derives, to compare
with the client's `terrain.lua`).
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
## Database changes
@@ -138,17 +138,20 @@ Admins can change these numbers without a deploy on `/utils/economy` (see Admin
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
Server-rendered pages (Go templates, htmx, `web/static/css/admin.css`). Sign in at
`/utils/login` with `ADMIN_PASSWORD`: the session is a random token in the `mm_admin` cookie
(HttpOnly, SameSite=Lax, 7 days), kept in memory, so a restart signs admins out. A wrong
password waits a second; tries are limited to 10 a minute per IP. Without a session, pages
redirect to the login and actions get a 401. With `MM_DEV=1` there is no login, so never set
it in production; with no `ADMIN_PASSWORD` (and no `MM_DEV`) the admin is locked. Every admin
page shares one nav: Players · Economy · Live ops · Log out.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils` | Redirects to `/utils/players` (`/` redirects here). |
| `/utils/login` | The login form; `POST /utils/logout` signs out. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
@@ -160,22 +163,22 @@ Actions (all `POST`, confirmed in the page's own dialog):
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` saves it: the admin cookie, an `X-MM-User` in `ADMIN_USER_IDS`, or `MM_DEV=1` |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
Every admin action is logged in `event_log` (`admin_*` kinds, which the live-ops stats
ignore) with `"admin": "admin"` (the admin-grant API action logs the admin player's id).
Banned players get a 403 from the API ("This account is suspended").
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
Each moon's look is generated from its id (terrain technique, noise, craters, colors) by the
Defold client. The shared settings every moon starts from are stored as one JSON row in
`moon_settings` (`/api/moon-settings`) and merged over the defaults in the client's
`modules/moon/terrain.lua`.
### Economy
@@ -193,31 +196,19 @@ and store items are re-synced.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
Live events and announcements reach the game in the API's `banner`. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
The admin pages' JS (`web/static/js/`): `tabs.js` (sliding tab highlight), `enter.js`
(entrance animations), `ui.js` (confirm dialog), `liveops.js`, `economy.js` (the economy
editor).
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Players are identified only by their id (`X-MM-User`, or the `mm_user` cookie: HttpOnly,
SameSite=Lax). New players are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind
a proxy).
- The admin pages need the admin login (`ADMIN_PASSWORD`, see Admin panels); without a
password they're locked.
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
@@ -239,16 +230,15 @@ First deploy:
```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
ADMIN_PASSWORD=... # strong; the admin pages at /utils
ADMIN_USER_IDS= # optional: player ids that are admins in the game API
```
Never set `MM_DEV` in production: it makes every player an admin.
Never set `MM_DEV` in production: it turns off the admin login and makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
5. Deploy. The admin pages are at `/utils` (sign in with `ADMIN_PASSWORD`).
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
+170
View File
@@ -0,0 +1,170 @@
package main
// Admin login for /utils/*: one shared password (ADMIN_PASSWORD), a login form at
// /utils/login and a random session token in the mm_admin cookie (kept in memory, so a
// restart signs admins out). With MM_DEV=1 there is no login. Without a password (and
// without MM_DEV) the admin is locked.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"os"
"strings"
"sync"
"time"
)
const (
adminCookie = "mm_admin"
adminSessionTTL = 7 * 24 * time.Hour
// adminLabel stands for the admin in event_log ("admin" in admin_* events).
adminLabel = "admin"
)
// loginFailDelay slows down password guessing (tests set it to 0).
var loginFailDelay = time.Second
// adminSessions are the signed-in admins' tokens and when they expire.
type adminSessions struct {
mu sync.Mutex
tokens map[string]time.Time
}
func (a *adminSessions) add() string {
b := make([]byte, 32)
rand.Read(b)
token := hex.EncodeToString(b)
a.mu.Lock()
defer a.mu.Unlock()
if a.tokens == nil {
a.tokens = map[string]time.Time{}
}
now := time.Now()
for t, exp := range a.tokens {
if now.After(exp) {
delete(a.tokens, t)
}
}
a.tokens[token] = now.Add(adminSessionTTL)
return token
}
func (a *adminSessions) valid(token string) bool {
a.mu.Lock()
defer a.mu.Unlock()
exp, ok := a.tokens[token]
return ok && time.Now().Before(exp)
}
func (a *adminSessions) remove(token string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.tokens, token)
}
// adminLocked: no password set and not in dev, so nobody can sign in.
func (s *server) adminLocked() bool { return !s.dev && s.adminPassword == "" }
// adminSession reports whether the request carries a signed-in admin's cookie.
func (s *server) adminSession(r *http.Request) bool {
if s.adminLocked() {
return false
}
c, err := r.Cookie(adminCookie)
return err == nil && s.admins.valid(c.Value)
}
// requireAdmin lets signed-in admins (or anyone with MM_DEV=1) through. Others get the
// login page: a redirect for page loads, 401 with HX-Redirect for htmx and other requests.
func (s *server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.dev || s.adminSession(r) {
next.ServeHTTP(w, r)
return
}
login := "/utils/login"
if r.Method == http.MethodGet && r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, login+"?next="+r.URL.EscapedPath(), http.StatusSeeOther)
return
}
w.Header().Set("HX-Redirect", login)
http.Error(w, "Sign in to the admin", http.StatusUnauthorized)
})
}
// LoginPage is the login form's data.
type LoginPage struct {
Locked bool
Error string
Next string
}
// safeNext is where to go after signing in: a /utils page, else the admin hub.
func safeNext(next string) string {
if next == "/utils" || strings.HasPrefix(next, "/utils/") && !strings.HasPrefix(next, "/utils/login") {
return next
}
return "/utils"
}
func (s *server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.dev || s.adminSession(r) {
http.Redirect(w, r, safeNext(r.FormValue("next")), http.StatusSeeOther)
return
}
s.render(w, "login", &LoginPage{Locked: s.adminLocked(), Next: safeNext(r.FormValue("next"))})
}
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.PostFormValue("next"))
if s.adminLocked() {
s.renderLogin(w, http.StatusForbidden, &LoginPage{Locked: true, Next: next})
return
}
if s.logins != nil && !s.logins.allow(r) {
s.renderLogin(w, http.StatusTooManyRequests, &LoginPage{Error: "Too many tries, wait a minute.", Next: next})
return
}
// Hashes, so the comparison doesn't depend on the lengths either.
got := sha256.Sum256([]byte(r.PostFormValue("password")))
want := sha256.Sum256([]byte(s.adminPassword))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
time.Sleep(loginFailDelay)
s.renderLogin(w, http.StatusUnauthorized, &LoginPage{Error: "Wrong password.", Next: next})
return
}
http.SetCookie(w, &http.Cookie{
Name: adminCookie,
Value: s.admins.add(),
Path: "/",
MaxAge: int(adminSessionTTL / time.Second),
HttpOnly: true,
Secure: secureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *server) renderLogin(w http.ResponseWriter, status int, page *LoginPage) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
s.render(w, "login", page)
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(adminCookie); err == nil {
s.admins.remove(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
Secure: secureRequest(r), SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/utils/login", http.StatusSeeOther)
}
// secureRequest: the request came over HTTPS (directly, or through the proxy with
// TRUST_PROXY=1), so the cookie can be Secure.
func secureRequest(r *http.Request) bool {
return r.TLS != nil || os.Getenv("TRUST_PROXY") == "1" && r.Header.Get("X-Forwarded-Proto") == "https"
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// The admin login gate: locked without a password, open in dev, and a redirect to the
// login without a session.
func TestAdminGate(t *testing.T) {
loginFailDelay = 0
tmpl, err := (&server{}).parseTemplates()
if err != nil {
t.Fatalf("parse: %v", err)
}
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
get := func(s *server, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
s.requireAdmin(ok).ServeHTTP(w, r)
return w
}
login := func(s *server, password string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/utils/login", strings.NewReader(url.Values{"password": {password}, "next": {"/utils/economy"}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleLogin(w, r)
return w
}
// Dev: no login.
if w := get(&server{dev: true}, "/utils/players"); w.Code != http.StatusTeapot {
t.Errorf("dev: %d, want through", w.Code)
}
// No password: locked, even with an empty password.
locked := &server{templates: tmpl}
if w := get(locked, "/utils/players"); w.Code != http.StatusSeeOther {
t.Errorf("locked: %d, want a redirect to the login", w.Code)
}
if w := login(locked, ""); w.Code != http.StatusForbidden || len(w.Result().Cookies()) > 0 {
t.Errorf("locked login: %d %v, want 403 and no cookie", w.Code, w.Result().Cookies())
}
// A password: no session redirects to the login, htmx and actions get 401.
s := &server{templates: tmpl, adminPassword: "secret"}
w := get(s, "/utils/players?q=x")
if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/utils/login?next=/utils/players") {
t.Errorf("no session: %d %q, want a redirect to the login", w.Code, w.Header().Get("Location"))
}
r := httptest.NewRequest("POST", "/utils/economy/save", nil)
w = httptest.NewRecorder()
s.requireAdmin(ok).ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized || w.Header().Get("HX-Redirect") != "/utils/login" {
t.Errorf("no session, POST: %d, want 401", w.Code)
}
if w := get(s, "/utils/players", &http.Cookie{Name: adminCookie, Value: "made-up"}); w.Code != http.StatusSeeOther {
t.Errorf("unknown session: %d, want a redirect", w.Code)
}
// Wrong password: no cookie. Right one: a session that gets through.
if w := login(s, "wrong"); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) > 0 {
t.Errorf("wrong password: %d, want 401 and no cookie", w.Code)
}
w = login(s, "secret")
cookies := w.Result().Cookies()
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/utils/economy" || len(cookies) != 1 {
t.Fatalf("login: %d %q %v", w.Code, w.Header().Get("Location"), cookies)
}
if c := cookies[0]; c.Name != adminCookie || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
t.Errorf("cookie %+v, want HttpOnly, SameSite Lax", c)
}
if w := get(s, "/utils/players", cookies[0]); w.Code != http.StatusTeapot {
t.Errorf("with session: %d, want through", w.Code)
}
// Only /utils pages after the login.
for next, want := range map[string]string{"/utils/players": "/utils/players", "https://evil": "/utils",
"//evil": "/utils", "/api/state": "/utils", "/utils": "/utils", "/utils/login": "/utils"} {
if got := safeNext(next); got != want {
t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
}
}
}
+5 -6
View File
@@ -1,7 +1,6 @@
package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the
// views build and the same actions, run through runAction (server.go). Times are unix
// JSON API for the game client (Defold): the state the views build (views.go) and the actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
import (
@@ -605,8 +604,8 @@ type APIToast struct {
Kind string `json:"kind"`
}
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
// out and what a sale earned.
// APIFX is what the client plays effects from: the action, the moons it mined out and what a
// sale earned.
type APIFX struct {
Kind string `json:"kind"`
Finished []string `json:"finished"`
@@ -632,7 +631,7 @@ func apiServerError(w http.ResponseWriter, err error) {
}
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
// (rate-limited per IP). It echoes the id in the header and cookie; on a refusal it
// writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context()
@@ -764,7 +763,7 @@ func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
}
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
// handleAPIAction is POST /api/act/{action}: runs an action (runAction) and
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r)
+12 -9
View File
@@ -1,9 +1,9 @@
# Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the
web game: `GET /api/state` is built from the same view models as `GET /`, and
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
server.go), onboarding gates included. Code: `api.go`.
The game's API, for the Defold client. `GET /api/state` is built from the view models
(`views.go`), and `POST /api/act/<action>` runs the game rules through `runAction`
(server.go), onboarding gates included. Code: `api.go`. "The web" below is the former htmx
client: the API keeps its texts and formats.
Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
@@ -115,11 +115,14 @@ Responses:
## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
session; the web page also reads it once per page load.
The procedural-moon look shared by every moon (no player needed): the saved JSON object, or
`null` when none is saved. Merge it over the defaults (`DEFAULTS` in the Defold client's
`modules/moon/terrain.lua`; unknown keys and wrong types are ignored, see
`merge_settings`), then build each moon from its id. Cache it for the session.
`POST /api/moon-settings` saves it (a JSON object, up to 64 KB; 204): for a signed-in admin
(the `mm_admin` cookie), a player in `ADMIN_USER_IDS` (`X-MM-User`), or anyone with
`MM_DEV=1`; others get a 403.
## State
+313
View File
@@ -0,0 +1,313 @@
package main
// Small server-drawn SVG charts for the admin pages (the Economy page's progression
// charts): line series with a point per value and a tooltip (<title>) on each, a linear or
// log y axis, shaded x bands, vertical markers, tick marks under the plot and a strip of
// ranges. Colors come from CSS classes (admin.css, "charts"), so both themes work.
import (
"fmt"
"html/template"
"math"
"strings"
)
type Chart struct {
Title string
XLabel, YLabel string
LogY, LogX bool
XTickVals []float64 // x ticks; default: about 8 even steps
YFmt func(float64) string // tick labels; default %g
XFmt func(float64) string
Series []ChartSeries
Bands []ChartBand // shaded x ranges with a label at the top
VLines []ChartVLine // vertical markers
Ticks []ChartTick // marks under the plot (one row per series)
Strip []ChartRange // ranges in a strip under the plot
StripLabel string
Width, Height float64 // of the plot area's viewBox; defaults 1000×260
NoPoints bool // lines only
}
type ChartSeries struct {
Name string
Class string // ch-saved, ch-draft
Points []ChartPoint
}
type ChartPoint struct {
X, Y float64
Title string // tooltip
Mark bool // drawn as a jump marker
Label string // short text drawn next to the point
Alt bool // drawn as a hollow diamond, off the line
}
type ChartBand struct {
X0, X1 float64
Label string
}
type ChartVLine struct {
X float64
Class string
Label string
Title string
}
type ChartTick struct {
X float64
Row int // series index
Class string
Title string
}
type ChartRange struct {
X0, X1 float64
Row int
Class string
Title string
}
const (
chartLeft = 64.0
chartRight = 14.0
chartTop = 30.0
chartBottom = 34.0
)
// skip: a point the axes can't place.
func (ch *Chart) skip(p ChartPoint) bool {
return ch.LogY && p.Y <= 0 || ch.LogX && p.X <= 0 || math.IsInf(p.Y, 0) || math.IsNaN(p.Y)
}
// SVG draws the chart. An empty chart draws a note instead.
func (ch *Chart) SVG() template.HTML {
w, h := ch.Width, ch.Height
if w == 0 {
w = 1000
}
if h == 0 {
h = 260
}
tickRows := 0
for _, t := range ch.Ticks {
tickRows = max(tickRows, t.Row+1)
}
stripRows := 0
for _, r := range ch.Strip {
stripRows = max(stripRows, r.Row+1)
}
extra := float64(tickRows)*8 + float64(stripRows)*8
if tickRows > 0 || stripRows > 0 {
extra += 6
}
total := h + extra
// Ranges.
x0, x1 := math.Inf(1), math.Inf(-1)
y0, y1 := math.Inf(1), math.Inf(-1)
for _, s := range ch.Series {
for _, p := range s.Points {
if ch.skip(p) {
continue
}
x0, x1 = min(x0, p.X), max(x1, p.X)
y0, y1 = min(y0, p.Y), max(y1, p.Y)
}
}
for _, b := range ch.Bands {
x0, x1 = min(x0, b.X0), max(x1, b.X1)
}
if math.IsInf(x0, 0) || math.IsInf(y0, 0) {
return template.HTML(`<p class="muted small">No data.</p>`)
}
if x1 == x0 {
x1 = x0 + 1
}
if ch.LogY {
y0 = math.Pow(10, math.Floor(math.Log10(y0)))
y1 = math.Pow(10, math.Ceil(math.Log10(y1)))
if y1 == y0 {
y1 = y0 * 10
}
} else {
y0 = min(0, y0)
if y1 <= y0 {
y1 = y0 + 1
}
y1 = niceCeil(y1)
}
pw, ph := w-chartLeft-chartRight, h-chartTop-chartBottom
px := func(x float64) float64 {
if ch.LogX {
return chartLeft + (math.Log10(max(x, x0))-math.Log10(x0))/(math.Log10(x1)-math.Log10(x0))*pw
}
return chartLeft + (x-x0)/(x1-x0)*pw
}
py := func(y float64) float64 {
if ch.LogY {
return chartTop + ph - (math.Log10(y)-math.Log10(y0))/(math.Log10(y1)-math.Log10(y0))*ph
}
return chartTop + ph - (y-y0)/(y1-y0)*ph
}
yf := ch.YFmt
if yf == nil {
yf = func(v float64) string { return fmt.Sprintf("%g", v) }
}
xf := ch.XFmt
if xf == nil {
xf = func(v float64) string { return fmt.Sprintf("%g", v) }
}
var b strings.Builder
esc := template.HTMLEscapeString
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" role="img" aria-label="%s">`, w, total, esc(ch.Title))
// Bands.
for i, band := range ch.Bands {
if i%2 == 0 {
fmt.Fprintf(&b, `<rect class="ch-band" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
px(band.X0), chartTop, max(0, px(band.X1)-px(band.X0)), ph)
}
fmt.Fprintf(&b, `<text class="ch-band-label" x="%.1f" y="%.1f">%s</text>`, px(band.X0)+3, chartTop-4, esc(band.Label))
}
// Y grid and labels.
for _, v := range yTicks(y0, y1, ch.LogY) {
y := py(v)
fmt.Fprintf(&b, `<line class="ch-grid" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, y, y)
fmt.Fprintf(&b, `<text class="ch-ylab" x="%.1f" y="%.1f">%s</text>`, chartLeft-6, y+3.5, esc(yf(v)))
}
// X axis and labels.
fmt.Fprintf(&b, `<line class="ch-axis" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, chartTop+ph, chartTop+ph)
xt := ch.XTickVals
if xt == nil {
xt = xTicks(x0, x1)
}
for _, v := range xt {
if v < x0 || v > x1 {
continue
}
fmt.Fprintf(&b, `<text class="ch-xlab" x="%.1f" y="%.1f">%s</text>`, px(v), chartTop+ph+13, esc(xf(v)))
}
if ch.XLabel != "" {
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f" text-anchor="end">%s</text>`, chartLeft+pw, chartTop+ph+27, esc(ch.XLabel))
}
if ch.YLabel != "" {
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f">%s</text>`, 4.0, 10.0, esc(ch.YLabel))
}
// Vertical markers.
for _, v := range ch.VLines {
x := px(v.X)
fmt.Fprintf(&b, `<g class="ch-vline %s"><title>%s</title><line x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/><text x="%.1f" y="%.1f">%s</text></g>`,
esc(v.Class), esc(v.Title), x, x, chartTop, chartTop+ph, x+2, chartTop+9, esc(v.Label))
}
// Series: lines, then points.
for _, s := range ch.Series {
var d strings.Builder
for _, p := range s.Points {
if p.Alt || ch.skip(p) {
continue
}
if d.Len() == 0 {
d.WriteString("M")
} else {
d.WriteString("L")
}
fmt.Fprintf(&d, "%.1f %.1f", px(p.X), py(p.Y))
}
fmt.Fprintf(&b, `<path class="ch-line %s" d="%s"/>`, esc(s.Class), d.String())
}
for _, s := range ch.Series {
fmt.Fprintf(&b, `<g class="%s">`, esc(s.Class))
for _, p := range s.Points {
if ch.skip(p) {
continue
}
x, y := px(p.X), py(p.Y)
switch {
case p.Alt:
fmt.Fprintf(&b, `<path class="ch-alt" d="M%.1f %.1fl4.5 4.5-4.5 4.5-4.5-4.5z"><title>%s</title></path>`, x, y-4.5, esc(p.Title))
case p.Mark:
fmt.Fprintf(&b, `<g class="ch-jump"><title>%s</title><circle cx="%.1f" cy="%.1f" r="7"/><circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"/></g>`,
esc(p.Title), x, y, x, y)
case !ch.NoPoints:
fmt.Fprintf(&b, `<circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"><title>%s</title></circle>`, x, y, esc(p.Title))
}
if p.Label != "" {
fmt.Fprintf(&b, `<text class="ch-plab" x="%.1f" y="%.1f">%s</text>`, x+6, y-6, esc(p.Label))
}
}
b.WriteString(`</g>`)
}
// Ticks and strip under the plot.
under := h + 2
for _, t := range ch.Ticks {
x, y := px(t.X), under+float64(t.Row)*8
fmt.Fprintf(&b, `<line class="ch-tick %s" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"><title>%s</title></line>`,
esc(t.Class), x, x, y, y+6, esc(t.Title))
}
under += float64(tickRows) * 8
if tickRows > 0 {
under += 2
}
for _, r := range ch.Strip {
y := under + float64(r.Row)*8
fmt.Fprintf(&b, `<rect class="ch-range %s" x="%.1f" y="%.1f" width="%.1f" height="6"><title>%s</title></rect>`,
esc(r.Class), px(r.X0), y, max(1.5, px(r.X1)-px(r.X0)), esc(r.Title))
}
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
// Legend is the chart's series names with their swatches.
func (ch *Chart) Legend() template.HTML {
var b strings.Builder
b.WriteString(`<span class="ch-legend">`)
for _, s := range ch.Series {
fmt.Fprintf(&b, `<span class="%s"><i class="ch-swatch"></i>%s</span>`, template.HTMLEscapeString(s.Class), template.HTMLEscapeString(s.Name))
}
b.WriteString(`</span>`)
return template.HTML(b.String())
}
func niceCeil(v float64) float64 {
if v <= 0 {
return 1
}
p := math.Pow(10, math.Floor(math.Log10(v)))
for _, m := range []float64{1, 2, 2.5, 5, 10} {
if m*p >= v {
return m * p
}
}
return 10 * p
}
func yTicks(y0, y1 float64, log bool) []float64 {
var out []float64
if log {
decades := math.Log10(y1) - math.Log10(y0)
for e := math.Log10(y0); e <= math.Log10(y1)+1e-9; e++ {
v := math.Pow(10, math.Round(e))
out = append(out, v)
if decades <= 3 && e+1 <= math.Log10(y1)+1e-9 {
out = append(out, 3*v)
}
}
return out
}
step := niceCeil((y1 - y0) / 5)
for v := y0; v <= y1+step*1e-9; v += step {
out = append(out, v)
}
return out
}
func xTicks(x0, x1 float64) []float64 {
step := niceCeil((x1 - x0) / 8)
var out []float64
for v := math.Ceil(x0/step) * step; v <= x1+step*1e-9; v += step {
out = append(out, v)
}
return out
}
+29 -18
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"math"
"net/http"
@@ -490,10 +491,6 @@ func (s *server) economyRoutes(mux *http.ServeMux) {
}
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "economy", &EconomyPage{})
return
}
ctx := r.Context()
// Pick up a save made by another instance before showing the editor.
if err := loadOverride(ctx, s.db); err != nil {
@@ -508,15 +505,23 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
base := baseConfig()
c := cfg()
quotes := marketQuotes(c, time.Now())
data := map[string]any{
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"market": quotes,
"colors": resourceColors,
}
// Dev only: ?draft=<JSON merge patch> opens the editor with that draft (screenshots).
if raw := r.FormValue("draft"); s.dev && raw != "" {
var patch any
if err := json.Unmarshal([]byte(raw), &patch); err == nil {
data["draft"] = mergePatch(configDoc(c), patch)
}
}
s.render(w, "economy", &EconomyPage{
Allowed: true,
Data: map[string]any{
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"market": quotes,
"colors": resourceColors,
},
Data: data,
Quotes: quotes,
History: history,
Version: overrideVersion.Load(),
@@ -529,11 +534,6 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
// {"errors": [...]}.
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
userID := s.currentUser(r)
if !s.isAdmin(userID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
@@ -549,7 +549,18 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
return
}
s.render(w, "economy-analysis", analyze(&draft, nil, diffConfig(cfg(), &draft).ChangeCount()))
n := diffConfig(cfg(), &draft).ChangeCount()
a := analyze(&draft, nil, n)
a.Progress = progression(cfg(), &draft, n == 0)
s.render(w, "economy-analysis", a)
return
case "sim":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
if err != nil {
fmt.Fprintf(w, `<p class="eco-bad">%s</p>`, template.HTMLEscapeString(err.Error()))
return
}
s.render(w, "economy-sim", simTimeline(cfg(), &draft, diffConfig(cfg(), &draft).ChangeCount() == 0))
return
case "save":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
@@ -584,7 +595,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
id, err := saveOverride(ctx, s.db, ov, note, userID)
id, err := saveOverride(ctx, s.db, ov, note, "")
if err != nil {
if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
+26
View File
@@ -34,6 +34,8 @@ const (
gsClickBurst = 4.0 // clicks a player can bank for a burst
gsSellEvery = 30 // seconds between sell rounds (one click per resource)
gsClaimGap = 3 // seconds a player waits at least between two claims
gsSampleEvery = 30 // seconds between timeline samples
gsIdleShow = 5 // shortest stretch with nothing to click the timeline shows
)
type gsTool struct {
@@ -116,6 +118,23 @@ type GSResult struct {
BuyGapAt SimTime
BuyGapPreS SimTime // the same, before the first moon of the top grade
IdleAt SimTime
// For the timeline chart (progression.go):
Samples []GSSample // every gsSampleEvery seconds
Buys []GSEvent // copies, upgrades and advisors (moons: FirstGrade)
Idles [][2]int // stretches of gsIdleShow seconds or more with nothing to click, [from, to)
}
// GSSample is the player's credits and total earnings at a time (seconds).
type GSSample struct {
At int
Credits, Earned float64
}
// GSEvent is a purchase at a time (seconds).
type GSEvent struct {
At int
Kind, Name string
Cost float64
}
// GradeStat is the value of a grade's moons at base prices.
@@ -354,6 +373,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if gap := SimTime(t - lastClick); gap > res.Idle {
res.Idle, res.IdleAt = gap, SimTime(lastClick)
}
if t-lastClick >= gsIdleShow {
res.Idles = append(res.Idles, [2]int{lastClick, t})
}
lastClick = t
return true
}
@@ -507,6 +529,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
lastSell = t
}
}
if t%gsSampleEvery == 0 {
res.Samples = append(res.Samples, GSSample{t, credits, earned})
}
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
if !wantBuy && !report && freeOre >= need() {
continue
@@ -579,6 +604,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
}
credits -= b.Cost
res.Bought[b.Kind]++
res.Buys = append(res.Buys, GSEvent{t, b.Kind, b.Name, b.Cost})
res.Spent[b.Kind] += b.Cost
res.GainMin[b.Kind] += b.Gain / 60
res.GainClick[b.Kind] += b.Click
+1 -2
View File
@@ -1,8 +1,7 @@
package main
// The Leaderboard section: the richest miners by credit balance, with the player's own
// place below the top when they're not in it. Banned players don't rank. Template:
// leaderboard-section in app.html.
// place below the top when they're not in it. Banned players don't rank.
import (
"context"
-8
View File
@@ -223,10 +223,6 @@ func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
}
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "liveops", &LiveOps{})
return
}
lo, err := s.liveOps(r)
if err != nil {
serverError(w, err)
@@ -257,10 +253,6 @@ func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
// that panel (#lo-events or #lo-announcements).
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
what, action := r.PathValue("what"), r.PathValue("action")
+2 -2
View File
@@ -1,6 +1,6 @@
// Moon Miners: a server-rendered browser mining game.
// Moon Miners: the game server (JSON API for the Defold client) and its admin pages.
//
// moonminers [serve] run the web server (default)
// moonminers [serve] run the server (default)
// moonminers migrate create roles/schema, apply migrations, load the game config
// moonminers load-config sync the store items in the database with the game config
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
+3 -4
View File
@@ -267,7 +267,7 @@ func (q Quote) PressureText() string {
}
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
// the sell ticket (market.js) applies the pressure curve to its amount.
// the sell ticket applies the pressure curve to its amount.
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
// Unit is what one unit pays the player now (a tiny sale).
@@ -278,8 +278,7 @@ func (q Quote) SellAll() float64 {
return sellPayout(q.Balance, q.Price, q.Advisor, cfg().MarketPressure.Average(q.Pressure, q.Balance*q.Base()))
}
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
// AdvisorText is e.g. "+8%".
func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
@@ -314,7 +313,7 @@ func marketSymbol(name string) string {
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
type MarketChart struct {
Line, Area string
Prices string // comma-separated, for the crosshair (market.js)
Prices string // comma-separated, for the crosshair
Start, Step int64 // unix ms of the first point, ms between points
HighY, LowY float64
Grid []GridLine
+10 -7
View File
@@ -1,12 +1,15 @@
package main
// Moon descriptions: one sentence generated from how the moon looks (the same seed-derived
// parameters web/static/js/terrain.js renders) and from its wells.
// parameters the Defold client's moon generator renders, client/modules/moon/terrain.lua)
// and from its wells.
//
// resolveMoonLook mirrors the part of terrain.js's resolveMoon that matters for the text
// (same hash, same PRNG, same draw order, same color math). terrain.js's moonTraits returns
// the same fields; scripts/check-moon-traits.mjs (run by `make check`) compares the two for
// fixed ids so they can't drift.
// resolveMoonLook mirrors the part of terrain.lua's resolve that matters for the text
// (same hash, same PRNG, same draw order, same color math). terrain.lua's traits returns
// the same fields (`moonminers moon-traits` prints Go's side for comparing). The function
// names below (resolveMoon, moonTraits, hashString, rng, adjustColor, mergeSettings) are
// those of the old terrain.js, which terrain.lua ports (resolve, traits, hash_string, rng,
// adjust_color, merge_settings).
//
// Settings: like the client, the saved admin settings (moon_settings) are merged over the
// defaults. The description is computed once, when the moon is bought (or backfilled by
@@ -268,7 +271,7 @@ type wellSupply struct {
// classifyMoon turns resolved look parameters (and the defaults they vary from) plus the
// wells into traits. Wealth is judged against the expected supply of new wells
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the moon lab (/utils/moons).
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the old web moon lab.
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, meanWell float64) moonTraits {
var t moonTraits
@@ -566,7 +569,7 @@ func backfillMoonDescriptions(ctx context.Context, db *pgxpool.Pool) error {
// ---------------------------------------------------------------- check / samples
// runMoonTraits is the `moon-traits` command, used by scripts/check-moon-traits.mjs.
// runMoonTraits is the `moon-traits` command, for comparing with terrain.lua's traits.
// It reads {"settings": {...} | null, "seeds": [...]} on stdin and prints the resolved
// look of each seed as JSON. With "samples": true it instead prints each seed's traits and
// description, with random wells.
+9 -14
View File
@@ -13,13 +13,15 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// Moon look: the procedural-moon settings tuned on /utils/moons (web/static/js/terrain.js
// documents the fields). Stored as one JSON row; the game merges it over its defaults.
// Moon look: the procedural-moon settings (the Defold client's moon generator,
// client/modules/moon/terrain.lua, documents the fields). Stored as one JSON row; the client
// merges it over its defaults.
const maxMoonSettingsBytes = 64 << 10
// isAdmin: players listed in ADMIN_USER_IDS (comma separated), or anyone with MM_DEV=1.
// With neither, nobody is an admin.
// With neither, no player is an admin. It's for the API (state.admin, admin-grant,
// saving moon settings); the admin pages use the admin login instead (admin.go).
func (s *server) isAdmin(userID string) bool {
if s.dev {
return true
@@ -53,15 +55,6 @@ func saveMoonSettings(ctx context.Context, db *pgxpool.Pool, settings json.RawMe
return err
}
// handleMoonsAdmin renders the moon editor.
func (s *server) handleMoonsAdmin(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "moons-admin", map[string]any{"Allowed": false})
return
}
s.render(w, "moons-admin", map[string]any{"Allowed": true})
}
// handleGetMoonSettings returns the saved settings, or null.
func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
settings, err := loadMoonSettings(r.Context(), s.db)
@@ -77,9 +70,11 @@ func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
w.Write(settings)
}
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only.
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only: a
// signed-in admin (the mm_admin cookie), an API player in ADMIN_USER_IDS (X-MM-User, as the
// Defold client sends it) or anyone with MM_DEV=1.
func (s *server) handleSaveMoonSettings(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
if !s.adminSession(r) && !s.isAdmin(r.Header.Get(userHeader)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
+8 -55
View File
@@ -2,8 +2,9 @@ package main
// Players admin (/utils/players, admins only): find a player, see everything they own and
// their event history, and fix things up (grants, finishing drills, reset, onboarding, ban). Every
// action runs in a transaction and is logged in event_log as admin_* with the admin's id.
// Banned players get a plain "suspended" page instead of the game (notBanned).
// action runs in a transaction and is logged in event_log as admin_* with "admin": "admin"
// (the game's quick grant, admin-grant, logs the admin player's id instead).
// Banned players get a 403 from the API (apiUser).
import (
"context"
@@ -40,26 +41,6 @@ func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) bool {
return err == nil && banned
}
// notBanned answers game routes with "This account is suspended" for banned players.
// htmx requests reload the page, which then shows the suspended page.
func (s *server) notBanned(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(userCookie)
if err == nil && uuidPattern.MatchString(c.Value) && isBanned(r.Context(), s.db, c.Value) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Refresh", "true")
http.Error(w, "This account is suspended", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
s.render(w, "suspended", nil)
return
}
h(w, r)
}
}
// ------------------------------------------------------------------ list
type PlayerRow struct {
@@ -160,10 +141,6 @@ type PlayersPage struct {
}
func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "players", &PlayersPage{})
return
}
ctx := r.Context()
p := &PlayersPage{Allowed: true, Query: r.FormValue("q"), Sort: r.FormValue("sort")}
if _, ok := playerSorts[p.Sort]; !ok {
@@ -190,13 +167,6 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
s.render(w, "players", p)
}
// forbidden renders an admin page's "missing permission" variant with a 403.
func (s *server) forbidden(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
s.render(w, name, data)
}
// ------------------------------------------------------------------ detail
type PlayerTool struct {
@@ -239,7 +209,6 @@ func (p EventsPage) Next() int { return p.Page + 1 }
type PlayerPage struct {
Allowed bool
Self bool // the admin looking at their own player
P PlayerRow
State *State
Tools []PlayerTool
@@ -271,7 +240,7 @@ func loadPlayerEvents(ctx context.Context, db *pgxpool.Pool, userID string, page
}
// loadPlayerPage returns nil if there is no such player.
func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*PlayerPage, error) {
func (s *server) loadPlayerPage(ctx context.Context, userID string) (*PlayerPage, error) {
rows, err := s.db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+
" where users.id = $1", userID)
if err != nil {
@@ -283,7 +252,7 @@ func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*P
} else if err != nil {
return nil, err
}
page := &PlayerPage{Allowed: true, P: p, Self: strings.EqualFold(userID, adminID)}
page := &PlayerPage{Allowed: true, P: p}
if page.State, err = loadState(ctx, s.db, userID); err != nil {
return nil, err
}
@@ -326,17 +295,12 @@ func (p *PlayerPage) StakesOn(wellID string) int {
}
func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) {
adminID := s.currentUser(r)
if !s.isAdmin(adminID) {
s.forbidden(w, "player", &PlayerPage{})
return
}
id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) {
http.NotFound(w, r)
return
}
page, err := s.loadPlayerPage(r.Context(), id, adminID)
page, err := s.loadPlayerPage(r.Context(), id)
if err != nil {
serverError(w, err)
return
@@ -349,10 +313,6 @@ func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) {
}
func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) {
http.NotFound(w, r)
@@ -371,11 +331,7 @@ func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
// handlePlayerAction runs an admin action on a player and re-renders the detail (#pl-detail).
func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
adminID := s.currentUser(r)
if !s.isAdmin(adminID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
adminID := adminLabel
ctx := r.Context()
id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) {
@@ -442,7 +398,7 @@ func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
page, perr := s.loadPlayerPage(ctx, id, adminID)
page, perr := s.loadPlayerPage(ctx, id)
if perr != nil {
serverError(w, perr)
return
@@ -618,9 +574,6 @@ func adminReset(ctx context.Context, db *pgxpool.Pool, adminID, userID string) e
}
func adminBan(ctx context.Context, db *pgxpool.Pool, adminID, userID string, ban bool) error {
if ban && strings.EqualFold(adminID, userID) {
return gameErr("You can't suspend yourself")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
banned, err := lockPlayer(ctx, tx, userID)
if err != nil {
+526
View File
@@ -0,0 +1,526 @@
package main
// Progression charts for the Economy page's Analysis tab: each chart draws the saved config
// and the draft. The payback ladder lists every purchase in the order a player would buy
// it, with the minutes of active play it takes to earn its price back.
import (
"fmt"
"math"
"slices"
"strings"
)
// jumpRatio: a purchase whose payback is more than this many times its predecessor's is a
// jump (a wall) on the ladder.
const jumpRatio = 2.0
// Purchase is one step of the payback ladder.
type Purchase struct {
Order int // 1 on
Kind string // moon, drill, yield, cycle, advisor
Name string // e.g. "Moon C", "PX-4", "PX-4 yield 2", "Ada Voss"
Grade string // the bracket: the moon grade it belongs with
Cost float64
GainPerMin float64 // ₥/min it adds (a moon: what its drill earns on it)
Payback float64 // minutes; +Inf if it adds nothing
Jump float64 // payback ÷ the previous purchase's (advisors left out), when over jumpRatio; else 0
Note string
}
// Ladder is a config's purchases in buying order, and what was left out.
type Ladder struct {
Steps []Purchase
Skipped []string // purchases with no direct income or no seat
Stats []GradeStat
}
// payLadder builds the ladder. Order: for each moon grade (cheapest first), its moon, then
// each drill meant for it (suitedGrade, by price), each followed by its track levels
// (the cheaper next level of the two tracks first), then the advisors the player can afford
// by then (best payback first; the first seat is free, each later one adds its seat price).
// Income is one drill on moons of its grade, net of what the moons cost (gradeStats):
// a drill's own rate; a level, what it adds over the drill's levels bought so far; an
// advisor, what its bonus adds to a full moon (one per well) of the newest drill; a moon, its price over the rate of the
// first drill meant for it (the drill bought so far if none is).
func payLadder(c *GameConfig) Ladder {
var l Ladder
if len(c.MoonGrades) == 0 || len(c.ResourcePrices) == 0 {
return l
}
// Per grade: what claims get (reach caps), the value of a unit and the share of it
// left after paying for the moon (1 - price/value).
stats := gradeStats(c)
l.Stats = stats
caps := map[*MoonGrade]ReachCaps{}
rate := func(it *StoreItem, lv Levels, b Bonus) float64 {
g := suitedGrade(c, it)
if caps[g] == nil {
caps[g] = reachCaps(c, g)
}
st := stats[c.gradeIndex(g.Grade)]
net := 0.0
if st.Mean > 0 {
net = max(0, 1-1/st.Mean)
}
secs, y := drillStats(it.ToolSecs(lv), it.ToolYield(lv), b, 1, 1)
return perHour(caps[g].Yield(y, it.ToolReach()), secs) * st.Unit * net * b.Sell / 60
}
byGrade := map[string][]int{}
order := make([]int, len(c.StoreItems))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
return cmpAsc(c.StoreItems[a].Price, c.StoreItems[b].Price)
})
for _, i := range order {
g := suitedGrade(c, &c.StoreItems[i]).Grade
byGrade[g] = append(byGrade[g], i)
}
advisors := map[string]bool{}
for _, a := range c.Advisors.Items {
advisors[a.Name] = true
}
hired := 0
last := -1 // the newest drill
levels := map[int]Levels{}
add := func(p Purchase) {
p.Order = len(l.Steps) + 1
p.Payback = math.Inf(1)
if p.GainPerMin > 0 {
p.Payback = p.Cost / p.GainPerMin
}
// Advisors are account-wide, so they're left out of the jumps.
if p.Kind != "advisor" {
if prev := prevPayback(l.Steps); prev > 0 && !math.IsInf(prev, 0) && p.Payback > jumpRatio*prev {
p.Jump = p.Payback / prev
}
}
l.Steps = append(l.Steps, p)
}
for gi, g := range c.MoonGrades {
// The moon.
moonRate := 0.0
if ds := byGrade[g.Grade]; len(ds) > 0 {
moonRate = rate(&c.StoreItems[ds[0]], Levels{}, noBonus)
} else if last >= 0 {
moonRate = rate(&c.StoreItems[last], levels[last], noBonus)
}
add(Purchase{Kind: "moon", Name: "Moon " + g.Grade, Grade: g.Grade, Cost: g.Price, GainPerMin: moonRate})
// Its drills and their levels.
for _, i := range byGrade[g.Grade] {
it := &c.StoreItems[i]
code := toolCode(it.Name)
add(Purchase{Kind: "drill", Name: code, Grade: g.Grade, Cost: it.Price, GainPerMin: rate(it, Levels{}, noBonus)})
last = i
lv := Levels{}
for {
kind := ""
var cost float64
for _, k := range trackKinds {
if u := it.TrackLevel(k, lv.Of(k)+1); u != nil {
if p := u.Price + partsValue(c, u.Parts); kind == "" || p < cost {
kind, cost = k, p
}
}
}
if kind == "" {
break
}
next := lv.With(kind, lv.Of(kind)+1)
add(Purchase{Kind: kind, Name: fmt.Sprintf("%s %s %d", code, kind, next.Of(kind)), Grade: g.Grade,
Cost: cost, GainPerMin: rate(it, next, noBonus) - rate(it, lv, noBonus)})
lv = next
levels[i] = lv
}
}
// Advisors affordable before the next moon grade (all of them at the last grade).
limit := math.Inf(1)
if gi+1 < len(c.MoonGrades) {
limit = c.MoonGrades[gi+1].Price
}
// Advisors are account-wide: count their bonus on a full moon's worth of the newest
// drill (one per well, the grade's average well count).
fleet := max(1, math.Round(stats[gi].Wells))
for last >= 0 {
seat := 0.0
if hired > 0 {
if hired-1 >= len(c.Advisors.SeatPrices) {
break
}
seat = c.Advisors.SeatPrices[hired-1]
}
it := &c.StoreItems[last]
base := rate(it, levels[last], noBonus)
best, bestGain := -1, 0.0
for ai, a := range c.Advisors.Items {
if !advisors[a.Name] {
continue
}
gain := rate(it, levels[last], c.bonusesFor([]string{a.Name})) - base
if gain > 0 && (best < 0 || (a.Price+seat)/gain < (c.Advisors.Items[best].Price+seat)/bestGain) {
best, bestGain = ai, gain
}
}
if best < 0 || c.Advisors.Items[best].Price+seat >= limit {
break
}
a := c.Advisors.Items[best]
p := Purchase{Kind: "advisor", Name: a.Name, Grade: g.Grade, Cost: a.Price + seat, GainPerMin: bestGain * fleet}
p.Note = fmt.Sprintf("on %.0f drills", fleet)
if seat > 0 {
p.Note += fmt.Sprintf(", with seat %d (%s)", hired+1, formatCredits(seat))
}
add(p)
delete(advisors, a.Name)
hired++
}
}
for _, a := range c.Advisors.Items {
if advisors[a.Name] {
l.Skipped = append(l.Skipped, a.Name+" (advisor: no ₥ income, or no seat left)")
}
}
return l
}
// prevPayback is the payback of the last step that isn't an advisor, 0 if none.
func prevPayback(steps []Purchase) float64 {
for i := len(steps) - 1; i >= 0; i-- {
if steps[i].Kind != "advisor" {
return steps[i].Payback
}
}
return 0
}
func cmpAsc(a, b float64) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// minutesText is a payback for labels: "40s", "12 min", "3.5 h".
func minutesText(m float64) string {
switch {
case math.IsInf(m, 0):
return "never"
case m < 1:
return fmt.Sprintf("%.0fs", m*60)
case m < 120:
return fmt.Sprintf("%.3g min", m)
}
return fmt.Sprintf("%.3g h", m/60)
}
// Progression is the chart view for the Analysis tab.
type Progression struct {
Payback *Chart
Jumps []Purchase // the draft's
SavedJumps int
Skipped []string
Same bool // the draft is the saved config
JumpRatio float64
Tiers []*Chart // per drill tier: price, yield, cycle, units/s
Grades []*Chart // per moon grade: price, value, value ÷ price
Levels []*Chart // per track and drill: payback per level
}
const (
savedClass = "ch-saved"
draftClass = "ch-draft"
)
func progression(saved, draft *GameConfig, same bool) *Progression {
ls, ld := payLadder(saved), payLadder(draft)
p := &Progression{Same: same, Skipped: ld.Skipped, JumpRatio: jumpRatio}
ch := &Chart{Title: "Payback per purchase", XLabel: "buying order →", YLabel: "minutes to earn it back",
LogY: true, YFmt: func(v float64) string { return minutesText(v) }, Height: 300}
series := func(name, class string, l Ladder) ChartSeries {
s := ChartSeries{Name: name, Class: class}
for _, st := range l.Steps {
title := fmt.Sprintf("#%d %s · %s · adds %s/min · pays back in %s", st.Order, st.Name,
formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(st.Payback))
if st.Note != "" {
title += " · " + st.Note
}
pt := ChartPoint{X: float64(st.Order), Y: st.Payback, Title: title, Alt: st.Kind == "advisor"}
if st.Jump > 0 {
pt.Mark = true
pt.Title += fmt.Sprintf(" · ×%.1f the step before", st.Jump)
}
s.Points = append(s.Points, pt)
}
return s
}
draftName := "Draft"
if same {
draftName = "Draft (same as saved)"
}
ch.Series = []ChartSeries{series("Saved", savedClass, ls), series(draftName, draftClass, ld)}
// Bands: the draft's grade brackets.
for i, st := range ld.Steps {
if st.Kind == "moon" {
end := float64(len(ld.Steps)) + 0.5
for _, nx := range ld.Steps[i+1:] {
if nx.Kind == "moon" {
end = float64(nx.Order) - 0.5
break
}
}
ch.Bands = append(ch.Bands, ChartBand{X0: float64(st.Order) - 0.5, X1: end, Label: st.Grade})
}
}
// Label the draft's jumps.
for i, st := range ld.Steps {
if st.Jump > 0 {
ch.Series[1].Points[i].Label = st.Name
p.Jumps = append(p.Jumps, st)
}
}
for _, st := range ls.Steps {
if st.Jump > 0 {
p.SavedJumps++
}
}
p.Payback = ch
p.Tiers, p.Grades, p.Levels = curves(saved, draft, ls, ld, draftName)
return p
}
// JumpText is a jump for the list under the chart.
func (p Purchase) JumpText() string {
return fmt.Sprintf("#%d %s: %s, pays back in %s (×%.1f the step before)", p.Order, p.Name,
formatCredits(p.Cost), minutesText(p.Payback), p.Jump)
}
// Timeline is the clicker sim (the "boosts" player, gradesim.go) for the saved config and
// the draft: credits and income over time, purchases, first moon of each grade, stretches
// with nothing to click.
type Timeline struct {
Credits, Income *Chart
Rows []TimelineRow // saved, draft
Same bool
Grades []string
}
type TimelineRow struct {
Name string
FirstGrade []string // by grade, "" if never
Idle string // longest stretch with nothing to click
IdleCount int // stretches of gsIdleShow s or more
BuyGap string // longest stretch with nothing to buy
Buys int
End string // credits earned by the end
}
func simTimeline(saved, draft *GameConfig, same bool) *Timeline {
pl := gsPlayers[1]
rs := simulatePlayer(saved, pl, gradeStats(saved))
rd := rs
if !same {
rd = simulatePlayer(draft, pl, gradeStats(draft))
}
tl := &Timeline{Same: same}
for _, g := range draft.MoonGrades {
tl.Grades = append(tl.Grades, g.Grade)
}
minFmt := func(v float64) string {
if v >= 60 && math.Mod(v, 60) == 0 {
return fmt.Sprintf("%.0fh", v/60)
}
if v >= 60 {
return fmt.Sprintf("%.1fh", v/60)
}
return fmt.Sprintf("%.0fm", v)
}
ticks := []float64{1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 360, 480}
tl.Credits = &Chart{Title: "Credits over time", XLabel: "time played (log scale) →", YLabel: "credits on hand", LogY: true,
LogX: true, XTickVals: ticks,
NoPoints: true, YFmt: formatCompact, XFmt: minFmt, StripLabel: "nothing to click"}
tl.Income = &Chart{Title: "Income per minute", XLabel: "time played (log scale) →", LogX: true, XTickVals: ticks, YLabel: "₥ earned per minute (2-minute average)",
LogY: true, NoPoints: true, YFmt: formatCompact, XFmt: minFmt, Height: 220}
names := []string{"Saved", "Draft"}
if same {
names[1] = "Draft (same as saved)"
}
for i, r := range []*GSResult{rs, rd} {
class := []string{savedClass, draftClass}[i]
credits := ChartSeries{Name: names[i], Class: class}
income := ChartSeries{Name: names[i], Class: class}
const window = 120 / gsSampleEvery
for k, s := range r.Samples {
credits.Points = append(credits.Points, ChartPoint{X: float64(s.At) / 60, Y: max(s.Credits, 10)})
if k >= window {
d := s.Earned - r.Samples[k-window].Earned
income.Points = append(income.Points, ChartPoint{X: float64(s.At) / 60, Y: max(d/2, 1)})
}
}
tl.Credits.Series = append(tl.Credits.Series, credits)
tl.Income.Series = append(tl.Income.Series, income)
for _, b := range r.Buys {
tl.Credits.Ticks = append(tl.Credits.Ticks, ChartTick{X: float64(b.At) / 60, Row: i, Class: class,
Title: fmt.Sprintf("%s · %s: %s (%s) %s", names[i], SimTime(b.At), b.Name, b.Kind, formatCredits(b.Cost))})
}
for _, x := range r.Idles {
tl.Credits.Strip = append(tl.Credits.Strip, ChartRange{X0: float64(x[0]) / 60, X1: float64(x[1]) / 60, Row: i, Class: class,
Title: fmt.Sprintf("%s · nothing to click from %s for %ds", names[i], SimTime(x[0]), x[1]-x[0])})
}
row := TimelineRow{Name: names[i], Idle: r.Idle.String(), IdleCount: len(r.Idles), BuyGap: r.BuyGap.String(), Buys: len(r.Buys)}
if n := len(r.Samples); n > 0 {
row.End = formatCompact(r.Samples[n-1].Earned)
}
for _, g := range tl.Grades {
at, ok := r.FirstGrade[g]
if !ok {
row.FirstGrade = append(row.FirstGrade, "")
continue
}
row.FirstGrade = append(row.FirstGrade, at.String())
for _, ch := range []*Chart{tl.Credits, tl.Income} {
ch.VLines = append(ch.VLines, ChartVLine{X: float64(at) / 60, Class: class, Label: g,
Title: fmt.Sprintf("%s: first %s moon at %s", names[i], g, at)})
}
}
tl.Rows = append(tl.Rows, row)
}
return tl
}
// catChart is a small chart of one value per category (drill tiers, grades, levels), saved
// against the draft. Labels name the draft's categories.
func catChart(title, ylabel string, logY bool, labels []string, saved, draft []float64, yf func(float64) string, tip func(i int, v float64) string, draftName string) *Chart {
ch := &Chart{Title: title, YLabel: ylabel, LogY: logY, YFmt: yf, Width: 360, Height: 200,
XFmt: func(v float64) string {
if i := int(v) - 1; i >= 0 && i < len(labels) && float64(i+1) == v {
return labels[i]
}
return ""
}}
for i := range labels {
ch.XTickVals = append(ch.XTickVals, float64(i+1))
}
for k, vals := range [][]float64{saved, draft} {
s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]}
for i, v := range vals {
s.Points = append(s.Points, ChartPoint{X: float64(i + 1), Y: v, Title: s.Name + " · " + tip(i, v)})
}
ch.Series = append(ch.Series, s)
}
return ch
}
// curves builds the small multiples: per drill tier (by price), per moon grade, and per drill
// and track the payback of each level (from the ladders).
func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, grades, levels []*Chart) {
type tier struct {
code string
price, claim, secs, perSec float64
}
tiersOf := func(c *GameConfig) []tier {
capsBy := map[*MoonGrade]ReachCaps{}
var out []tier
for _, it := range c.StoreItems {
g := suitedGrade(c, &it)
if capsBy[g] == nil {
capsBy[g] = reachCaps(c, g)
}
caps := capsBy[g]
secs := float64(it.CompletionTimeInSecs)
out = append(out, tier{toolCode(it.Name), it.Price, it.ClaimAmount, secs,
caps.Yield(it.ClaimAmount, it.ToolReach()) / max(secs, 1)})
}
slices.SortStableFunc(out, func(a, b tier) int { return cmpAsc(a.price, b.price) })
return out
}
ts, td := tiersOf(saved), tiersOf(draft)
var labels []string
for _, t := range td {
labels = append(labels, t.code)
}
pick := func(ts []tier, f func(tier) float64) []float64 {
var out []float64
for _, t := range ts {
out = append(out, f(t))
}
return out
}
tierChart := func(title, ylabel string, logY bool, f func(tier) float64, yf func(float64) string) *Chart {
return catChart(title, ylabel, logY, labels, pick(ts, f), pick(td, f), yf,
func(i int, v float64) string { return fmt.Sprintf("%s: %s", labels[min(i, len(labels)-1)], yf(v)) }, draftName)
}
num := func(v float64) string { return formatCompact(v)[len(currency):] }
tiers = []*Chart{
tierChart("Price", "₥", true, func(t tier) float64 { return t.price }, formatCompact),
tierChart("Yield per cycle", "units", true, func(t tier) float64 { return t.claim }, num),
tierChart("Cycle time", "seconds", false, func(t tier) float64 { return t.secs }, func(v float64) string { return fmt.Sprintf("%gs", v) }),
tierChart("Units per second", "on a moon of its grade", true, func(t tier) float64 { return t.perSec }, num),
}
var glabels []string
for _, g := range draft.MoonGrades {
glabels = append(glabels, g.Grade)
}
gvals := func(l Ladder, f func(GradeStat) float64) []float64 {
var out []float64
for _, st := range l.Stats {
out = append(out, f(st))
}
return out
}
gtip := func(i int, v float64) string {
return fmt.Sprintf("%s: %s", glabels[min(i, len(glabels)-1)], formatCompact(v))
}
grades = []*Chart{
catChart("Moon price", "₥", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Price }),
gvals(ld, func(g GradeStat) float64 { return g.Price }), formatCompact, gtip, draftName),
catChart("Expected value", "₥ of ore at base prices", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean * g.Price }),
gvals(ld, func(g GradeStat) float64 { return g.Mean * g.Price }), formatCompact, gtip, draftName),
catChart("Value ÷ price", "× (p10 to p90 in the tooltip)", false, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean }),
gvals(ld, func(g GradeStat) float64 { return g.Mean }), func(v float64) string { return fmt.Sprintf("%.2g×", v) },
func(i int, v float64) string {
if i < len(ld.Stats) {
return fmt.Sprintf("%s: %.2f× (draft p10 %.2f×, p90 %.2f×)", glabels[min(i, len(glabels)-1)], v, ld.Stats[i].P10, ld.Stats[i].P90)
}
return fmt.Sprintf("%.2f×", v)
}, draftName),
}
// Levels: per drill (the draft's, by price) and track, cost ÷ gain of each level.
levelVals := func(l Ladder, code, kind string) ([]float64, []Purchase) {
var out []float64
var steps []Purchase
for _, st := range l.Steps {
if st.Kind == kind && strings.HasPrefix(st.Name, code+" ") {
out = append(out, st.Payback)
steps = append(steps, st)
}
}
return out, steps
}
for _, kind := range trackKinds {
for _, t := range td {
vs, _ := levelVals(ls, t.code, kind)
vd, steps := levelVals(ld, t.code, kind)
var lab []string
for i := range max(len(vs), len(vd)) {
lab = append(lab, fmt.Sprint(i+1))
}
ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(i int, v float64) string {
if i < len(steps) {
st := steps[i]
return fmt.Sprintf("level %d: %s for +%s/min, pays back in %s", i+1, formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(v))
}
return fmt.Sprintf("level %d: %s", i+1, minutesText(v))
}, draftName)
ch.Width, ch.Height = 280, 170
levels = append(levels, ch)
}
}
return tiers, grades, levels
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"math"
"strconv"
"strings"
"testing"
)
func TestPayLadderOrder(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
if len(l.Steps) == 0 || l.Steps[0].Name != "Moon "+c.MoonGrades[0].Grade {
t.Fatalf("ladder should start with the cheapest moon: %+v", l.Steps[:1])
}
seen := map[string]bool{}
level := map[string]int{} // "PD-1 yield" -> last level
grade := -1
for i, st := range l.Steps {
if st.Order != i+1 {
t.Fatalf("step %d has order %d", i, st.Order)
}
switch st.Kind {
case "moon":
g := c.gradeIndex(st.Grade)
if g != grade+1 {
t.Fatalf("moon %s out of order", st.Grade)
}
grade = g
case "drill":
seen[st.Name] = true
case "yield", "cycle":
f := strings.Fields(st.Name) // code kind n
if !seen[f[0]] {
t.Errorf("%s before its drill", st.Name)
}
key := f[0] + " " + f[1]
level[key]++
if want := f[0] + " " + f[1] + " " + itoa(level[key]); st.Name != want {
t.Errorf("got %s, want %s", st.Name, want)
}
}
if c.gradeIndex(st.Grade) != grade {
t.Errorf("%s in bracket %s after moon %d", st.Name, st.Grade, grade)
}
}
if grade != len(c.MoonGrades)-1 {
t.Errorf("not every moon grade: %d", grade)
}
for _, it := range c.StoreItems {
if !seen[toolCode(it.Name)] {
t.Errorf("drill %s missing", it.Name)
}
top := it.MaxLevels()
if level[toolCode(it.Name)+" yield"] != top.Yield || level[toolCode(it.Name)+" cycle"] != top.Cycle {
t.Errorf("%s: not every level", it.Name)
}
}
}
func itoa(n int) string { return strconv.Itoa(n) }
func TestPayLadderPayback(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
it := &c.StoreItems[0] // PD-1, meant for grade D
g := suitedGrade(&c, it)
st := gradeStats(&c)[c.gradeIndex(g.Grade)]
perMin := reachCaps(&c, g).Yield(it.ClaimAmount, 1) * 3600 / float64(it.CompletionTimeInSecs) / 60 * st.Unit * (1 - 1/st.Mean)
for _, p := range l.Steps {
if p.Name == toolCode(it.Name) {
if want := it.Price / perMin; math.Abs(p.Payback-want) > 1e-6*want {
t.Errorf("PD-1 payback %v min, want %v", p.Payback, want)
}
if p.Payback <= 0 || p.Payback > 60 {
t.Errorf("PD-1 payback %v min out of range", p.Payback)
}
return
}
}
t.Fatal("no PD-1 step")
}
func TestPayLadderJumps(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
for i, st := range l.Steps {
if i == 0 {
if st.Jump != 0 {
t.Error("first step can't jump")
}
continue
}
prev := prevPayback(l.Steps[:i])
want := st.Kind != "advisor" && !math.IsInf(prev, 0) && st.Payback > jumpRatio*prev
if (st.Jump > 0) != want {
t.Errorf("%s: jump %v, payback %v after %v", st.Name, st.Jump, st.Payback, prev)
}
}
// A drill 100× dearer is a wall.
c.StoreItems[1].Price *= 100
for _, st := range payLadder(&c).Steps {
if st.Name == toolCode(c.StoreItems[1].Name) {
if st.Jump < jumpRatio {
t.Errorf("%s at 100× price: jump %v", st.Name, st.Jump)
}
return
}
}
t.Fatal("drill missing")
}
-1
View File
@@ -5,7 +5,6 @@ package main
// bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a
// replacement moon would cost. It is compared with that replacement price: a new moon of the
// same grade.
// Template: "moon-sale" in app.html.
import (
"context"
+2 -3
View File
@@ -1,8 +1,7 @@
package main
// The new-moon section (a teaser for an unknown moon) and the reveal after a purchase.
// Templates: "new-moon-section" and "moon-reveal" in app.html; web/static/js/reveal.js
// plays the reveal from the data the server sends.
// The new-moon section (a teaser for an unknown moon) and the reveal after a purchase
// (the act response's reveal; the client plays it from the data the server sends).
import (
"cmp"
-44
View File
@@ -1,44 +0,0 @@
// Checks that moondesc.go (resolveMoonLook) derives the same moon look as terrain.js
// (moonTraits) for fixed ids, with default and with custom settings. Run by `make check`.
import { execFileSync } from "node:child_process";
import { mergeSettings, moonTraits } from "../web/static/js/terrain.js";
const seeds = [
"00000000-0000-0000-0000-000000000000", "30f76186-83ff-4b26-a429-357bc1ee126f",
"6f1c2a9e-0b7d-4c1e-9a52-3d8e4f7a1b20", "a3b2c1d0-1111-4222-8333-944455556666",
"d9e8f7a6-b5c4-4d3e-8f2a-1b0c9d8e7f6a", "123e4567-e89b-12d3-a456-426614174000",
"f47ac10b-58cc-4372-a567-0e02b2c3d479", "9b2e5c3d-7a1f-4e8b-b6d4-2c0f1e3a5b7d",
"c56a4180-65aa-42ec-a945-5fd21dec0538", "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
"7d444840-9dc0-11d1-b245-5ffdce74fad2", "e902893a-9d22-3c7e-a7b8-d6e313b71d9f",
"b3d4e5f6-0718-4a2b-9c3d-4e5f60718293", "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"0f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", "8a7b6c5d-4e3f-4a1b-9c8d-7e6f5a4b3c2d",
"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "fedcba98-7654-4321-8fed-cba987654321",
"moon-lab", "üñí-çødé-🌑",
];
const custom = {
technique: "billow",
shading: { mid: "#3a5f8c" },
shape: { strength: 0.05 },
craters: { count: 90 },
variation: { hue: 40, saturation: 1.3, lightness: 0.3, shape: 0.9, craters: 0.2, technique: false },
};
let failed = 0;
for (const saved of [null, custom]) {
const settings = mergeSettings(saved);
const input = JSON.stringify({ settings: saved, seeds });
const go = JSON.parse(execFileSync("go", ["run", ".", "moon-traits"], { input, encoding: "utf8" }));
seeds.forEach((seed, i) => {
const js = moonTraits(settings, seed);
for (const [key, want] of Object.entries(js)) {
const got = go[i][key];
const same = typeof want === "number" ? Math.abs(got - want) <= 1e-12 * Math.max(1, Math.abs(want)) : got === want;
if (!same) {
failed++;
console.error(`moon traits differ for ${seed} (${saved ? "custom" : "default"} settings): ${key} js=${want} go=${got}`);
}
}
});
}
if (failed) process.exit(1);
console.log(`moon traits match for ${seeds.length} ids x 2 settings`);
-48
View File
@@ -1,48 +0,0 @@
// 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);
+37 -182
View File
@@ -35,6 +35,10 @@ type server struct {
newPlayers *rateLimiter
// restores limits save code tries per IP (savecode.go).
restores *rateLimiter
// The admin login (admin.go): ADMIN_PASSWORD, signed-in sessions, tries per IP.
adminPassword string
admins adminSessions
logins *rateLimiter
}
func serve(ctx context.Context) error {
@@ -48,7 +52,8 @@ func serve(ctx context.Context) error {
}
go watchOverride(ctx, db)
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5)}
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5),
adminPassword: os.Getenv("ADMIN_PASSWORD"), logins: newRateLimiter(10)}
if s.dev {
s.static = os.DirFS("web/static")
} else {
@@ -63,25 +68,23 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.static, "favicon.ico")
})
mux.HandleFunc("GET /{$}", s.notBanned(s.handleIndex))
mux.HandleFunc("POST /session", s.handleSession)
mux.HandleFunc("POST /act/{action}", s.notBanned(s.withUser(s.handleAction)))
mux.HandleFunc("GET /utils", s.withUser(s.handleUtils))
mux.HandleFunc("POST /utils/{action}", s.withUser(s.handleUtilsAction))
mux.HandleFunc("GET /utils/moons", s.handleMoonsAdmin)
s.economyRoutes(mux)
s.playerRoutes(mux)
mux.HandleFunc("GET /utils/liveops", s.handleLiveOps)
mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction)
mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction)
// A Defold web build will be served here later; for now the admin.
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/utils", http.StatusSeeOther)
})
mux.HandleFunc("GET /utils/login", s.handleLoginPage)
mux.HandleFunc("POST /utils/login", s.handleLogin)
mux.HandleFunc("POST /utils/logout", s.handleLogout)
// Every other /utils page and action needs an admin session (admin.go).
admin := s.adminRoutes()
mux.Handle("/utils", s.requireAdmin(admin))
mux.Handle("/utils/", s.requireAdmin(admin))
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
mux.HandleFunc("GET /api/save-code", s.handleAPISaveCode)
// 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 {
@@ -132,13 +135,12 @@ 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).
// delayGame holds every game request (the JSON API's state and actions) for d before
// answering, to try the client 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") {
if strings.HasPrefix(p, "/api/act/") || p == "/api/state" {
time.Sleep(d)
}
next.ServeHTTP(w, r)
@@ -279,15 +281,6 @@ func serverError(w http.ResponseWriter, err error) {
// ------------------------------------------------------------------ players
// currentUser returns the player id from the cookie, if it names an existing player.
func (s *server) currentUser(r *http.Request) string {
c, err := r.Cookie(userCookie)
if err != nil || !uuidPattern.MatchString(c.Value) || !userExists(r.Context(), s.db, c.Value) {
return ""
}
return c.Value
}
func setUserCookie(w http.ResponseWriter, userID string) {
http.SetCookie(w, &http.Cookie{
Name: userCookie,
@@ -299,84 +292,13 @@ func setUserCookie(w http.ResponseWriter, userID string) {
})
}
func (s *server) withUser(h func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := s.currentUser(r)
if userID == "" {
// The page reloads and the boot script signs the player in again.
w.Header().Set("HX-Refresh", "true")
http.Error(w, "Not signed in", http.StatusUnauthorized)
return
}
h(w, r, userID)
}
}
func newPlayerName() string {
return "Miner-" + randomHex(3)
}
// handleSession signs the browser in. Browsers from before the server-rendered UI keep
// their player id in localStorage and send it here; otherwise a new player is created.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
userID := r.FormValue("userId")
if !uuidPattern.MatchString(userID) || !userExists(r.Context(), s.db, userID) {
if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return
}
var err error
if userID, err = createUser(r.Context(), s.db, newPlayerName()); err != nil {
serverError(w, err)
return
}
}
setUserCookie(w, userID)
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------------------ actions
// ------------------------------------------------------------------ game page
// handleIndex renders the game. Plain requests get the whole page; htmx requests (links
// inside the page) get every region as an out-of-band swap.
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
htmx := r.Header.Get("HX-Request") == "true"
userID := s.currentUser(r)
if userID == "" {
if htmx {
w.Header().Set("HX-Refresh", "true")
return
}
s.render(w, "boot", nil)
return
}
// Opening a newly revealed tab stops its shine (onboarding.go).
if err := markSeen(r.Context(), s.db, userID, r.FormValue("s")); err != nil {
serverError(w, err)
return
}
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil {
serverError(w, err)
return
}
page := state.page(v)
if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err)
return
}
page.Banner = s.banner(r.Context())
page.Admin = s.isAdmin(userID)
if htmx {
page.OOB = true
s.render(w, "regions", page)
return
}
s.logSession(r.Context(), userID)
s.render(w, "page", page)
}
// actionResult is what runAction did, for the response (HTML regions or JSON).
// actionResult is what runAction did, for the API response.
type actionResult struct {
Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
Err error // a GameError: the action was refused or broke a rule
@@ -395,8 +317,8 @@ var errUnknownAction = errors.New("unknown action")
// errForbidden is runAction's error for an admin action run by a player who isn't one (403).
var errForbidden = errors.New("missing permission")
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads
// the request's parameters, including the current view (s, moon, well, tab), which stays
// runAction runs a game action (POST /api/act/<action>): its onboarding gate, then its rule
// in game.go. get reads the request's parameters, including the current view (s, moon, well, tab), which stays
// unless the action moves on. new-game only passes the gate; the caller creates the player.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get)
@@ -615,88 +537,21 @@ func (res actionResult) toast(state *State) *Toast {
return toast
}
// handleAction runs a game action and re-renders the screen.
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
if errors.Is(err, errUnknownAction) {
http.NotFound(w, r)
return
}
if errors.Is(err, errForbidden) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
if err != nil {
serverError(w, err)
return
}
if res.Action == "new-game" {
if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return
}
newID, err := createUser(ctx, s.db, newPlayerName())
if err != nil {
serverError(w, err)
return
}
setUserCookie(w, newID)
w.Header().Set("HX-Redirect", "/")
return
}
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
}
page.Banner = s.banner(ctx)
page.Admin = s.isAdmin(userID)
page.OOB = true
page.Toast = toast
if res.BoughtTool != "" {
page.Modal = state.buySuggestions(res.BoughtTool)
}
if res.Action == "buy-moon" && !toast.Error {
page.Reveal = page.moonReveal()
}
s.render(w, "regions", page)
}
// ------------------------------------------------------------------ admin
// handleUtils is the admin hub; it opens the Players page (players.go).
func (s *server) handleUtils(w http.ResponseWriter, r *http.Request, userID string) {
http.Redirect(w, r, "/utils/players", http.StatusSeeOther)
}
func (s *server) handleUtilsAction(w http.ResponseWriter, r *http.Request, userID string) {
if !s.isAdmin(userID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
var err error
switch r.PathValue("action") {
case "clear-data":
err = clearUserData(r.Context(), s.db, userID)
case "load-config":
err = syncStoreItems(r.Context(), s.db)
default:
http.NotFound(w, r)
return
}
if err != nil {
serverError(w, err)
return
}
fmt.Fprint(w, "<p>Done.</p>")
// adminRoutes are the admin pages and actions, behind requireAdmin.
func (s *server) adminRoutes() *http.ServeMux {
mux := http.NewServeMux()
// The admin hub opens the Players page (players.go).
mux.HandleFunc("GET /utils", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/utils/players", http.StatusSeeOther)
})
s.economyRoutes(mux)
s.playerRoutes(mux)
mux.HandleFunc("GET /utils/liveops", s.handleLiveOps)
mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction)
mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction)
return mux
}
// loadRanking fills in the leaderboard section, the one section read from other players.
+1
View File
@@ -139,6 +139,7 @@ type Analysis struct {
Tools []ToolStat
Refining *RefineryStats
Sim *SimResult
Progress *Progression // charts, saved config against this one (the handler sets it)
// Flags are the warnings by row, for the editor grid: "item:<id>" or "refined:<name>".
Flags map[string][]string
}
+8 -46
View File
@@ -1,9 +1,10 @@
package main
// Template check (make check): parses the embedded templates the way the server does, fails
// on a {{template "x"}} that names no template, and renders every page and region against
// in-memory game states (no database) so missing fields, methods and functions show up
// here instead of in the live game. Run with `go test -run Templates`.
// Template check (make check): parses the embedded templates (the admin pages) the way the
// server does, fails on a {{template "x"}} that names no template, and renders every page
// and region against in-memory states (no database) so missing fields, methods and
// functions show up here instead of in production. Run with `go test -run Templates`.
// The game states below are also used by api_test.go.
import (
"fmt"
@@ -30,51 +31,14 @@ func TestTemplates(t *testing.T) {
}
}
unlocked := newPlayerState() // everything open but no moon yet
unlocked.Onboarding = []string{"all"}
early := veteranState() // Equipment open, the Store not yet (a drill panel without its Store parts)
early.Onboarding = []string{"equipment", "market"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState(), early} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
p.Admin = len(s.Tools) > 0 // the grant dialog and button, for some states
if v.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
}
render("page", p)
// An action response: every region out of band, with a toast, the buy modal
// and the moon reveal.
p.OOB = true
p.Banner = []BannerItem{{Level: "event", Text: "Double yield", Left: "3h left"}}
p.Toast = &Toast{Message: "Claimed", Kind: "claim", Finished: "m", Credits: 12}
if len(s.Tools) > 0 {
p.Modal = s.buySuggestions(s.Tools[0].ID)
}
p.Reveal = p.moonReveal()
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.
// The admin pages, with and without permission.
s := veteranState()
now := time.Now()
player := PlayerRow{ID: s.UserID, Name: "Test Miner", CreatedAt: now, LastSeen: &now, BannedAt: &now}
events := EventsPage{PlayerID: s.UserID, Events: []PlayerEvent{{ID: 1, Kind: "claim", Data: `{"credits": 1}`, At: now},
{ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true}
stake := s.Stakes[0]
playerPage := &PlayerPage{Allowed: true, Self: true, P: player, State: s, Running: 1, Events: events,
playerPage := &PlayerPage{Allowed: true, P: player, State: s, Running: 1, Events: events,
Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], Max: Levels{4, 5}, Stake: &stake,
MoonName: "Moon", Remaining: time.Minute}, {Tool: s.Tools[1], Item: &s.Config.StoreItems[1]}},
StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true}
@@ -94,9 +58,6 @@ func TestTemplates(t *testing.T) {
name string
allowed, none any
}{
{"boot", nil, nil},
{"suspended", nil, nil},
{"moons-admin", map[string]any{"Allowed": true}, map[string]any{"Allowed": false}},
{"players", playersPage, &PlayersPage{}},
{"pl-list", playersPage, &PlayersPage{}},
{"player", playerPage, &PlayerPage{}},
@@ -107,6 +68,7 @@ func TestTemplates(t *testing.T) {
{"lo-announcements", liveOps, &LiveOps{}},
{"economy", economy, &EconomyPage{}},
{"economy-analysis", analyze(cfg(), nil, 2), analyze(cfg(), []string{"bad config"}, 0)},
{"login", &LoginPage{Error: "Wrong password.", Next: "/utils"}, &LoginPage{Locked: true}},
} {
render(page.name, page.allowed)
render(page.name, page.none)
+74 -87
View File
@@ -1,68 +1,55 @@
# Endpoints, in Emacs restclient format. The player is identified by the mm_user cookie;
# set :user_id to a player id. Game endpoints return HTML fragments for htmx.
# Endpoints, in Emacs restclient format. The game is the JSON API (/api/, api.md): the player
# is identified by X-MM-User (or the mm_user cookie); set :user_id to a player id. The admin
# pages (/utils/) answer HTML (fragments with HX-Request: true).
:user_id = 595ab570-fa74-4c6c-980e-4c80d1064dd1
:host = http://localhost:3000
:api = <<
Content-Type: application/x-www-form-urlencoded
X-MM-User: :user_id
#
:admin_session = <the mm_admin cookie from POST /utils/login>
:headers = <<
Content-Type: application/x-www-form-urlencoded
Cookie: mm_user=:user_id
Cookie: mm_admin=:admin_session
HX-Request: true
#
# Sign in as an existing player (or create a new one if userId is missing/unknown)
POST :host/session
Content-Type: application/x-www-form-urlencoded
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 panel, its upgrade tracks),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# 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
# Game actions (JSON API, api.md): the player's view goes along as s, moon, well, drill, pick,
# list, page, adv (s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon).
# Answers {ok, error?, toast, fx?, state}.
# Buy a moon
POST :host/act/buy-moon
:headers
POST :host/api/act/buy-moon
:api
# Buy a moon of a grade (D, C, B, A, S; without one: the newest moon's grade)
POST :host/act/buy-moon
:headers
POST :host/api/act/buy-moon
:api
grade=B
# Sell a moon (not the last one, not right after buying it, no running drills on it)
POST :host/act/sell-moon
:headers
POST :host/api/act/sell-moon
:api
sell-moon=<moon id>
# Sell an idle tool (not the only one)
POST :host/act/sell-tool
:headers
POST :host/api/act/sell-tool
:api
tool=<tool id>
# Buy a tool
POST :host/act/buy-tool
:headers
POST :host/api/act/buy-tool
:api
id=1
# Upgrade a tool: one level on its yield or cycle track (track=yield|cycle)
POST :host/act/upgrade
:headers
POST :host/api/act/upgrade
:api
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield
# Deploy a tool on a well
POST :host/act/stake
:headers
POST :host/api/act/stake
:api
tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Claim finished drills on a well (claim-well), a moon (claim-moon) or everywhere (neither).
@@ -70,88 +57,88 @@ tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad
# drills that mine several of its wells (reach).
# redeploy=1 puts the tools straight back to work on the same well (drills on a drained well
# stay idle; the toast says how many; a multi-well drill moves to the moon's fullest well).
POST :host/act/claim
:headers
POST :host/api/act/claim
:api
claim-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca&redeploy=1
# Put the idle tools whose last drill was on a well back to work on it
POST :host/act/redrill
:headers
POST :host/api/act/redrill
:api
redrill-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Sell a resource at the current market price (all=1 sells the whole balance; pick keeps the
# market selection)
POST :host/act/sell
:headers
POST :host/api/act/sell
:api
resource=Sollux&amount=10
# Sell a refined item (same action; all=1 sells the whole balance)
POST :host/act/sell
:headers
POST :host/api/act/sell
:api
resource=Dusk+Alloy&amount=1
# Refinery: start a batch in the lowest free slot (recipe = refined item name; all=1 makes
# as many as the inputs allow). Inputs are taken now; collect when it finishes.
POST :host/act/refine
:headers
POST :host/api/act/refine
:api
recipe=Dusk+Alloy&qty=3
# Refinery: collect one finished batch (job id), or every finished one without job
POST :host/act/collect
:headers
POST :host/api/act/collect
:api
job=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Refinery: buy the next slot (prices in config refinery.slotPrices)
POST :host/act/buy-slot
:headers
POST :host/api/act/buy-slot
:api
# Raise the refinery's batch size to the next upgrade (refinery.batchUpgrades)
POST :host/act/upgrade-batch
:headers
POST :host/api/act/upgrade-batch
:api
# Hire an advisor (config name) into a free seat
POST :host/act/hire
:headers
POST :host/api/act/hire
:api
advisor=Mira+Castellanos
# Buy the next advisor seat (prices in config advisors.seatPrices)
POST :host/act/buy-seat
:headers
POST :host/api/act/buy-seat
:api
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
POST :host/act/seat-advisor
:headers
POST :host/api/act/seat-advisor
:api
advisor=Mira+Castellanos&seat=2
# Take a seated advisor off their seat (stays hired, no bonus)
POST :host/act/unseat-advisor
:headers
POST :host/api/act/unseat-advisor
:api
advisor=Mira+Castellanos
# Swap two hired advisors' seats (one may be unseated)
POST :host/act/swap-advisors
:headers
POST :host/api/act/swap-advisors
:api
advisor=Mira+Castellanos&with=Ada+Voss
# Dismiss a hired advisor (frees their seat, nothing refunded)
POST :host/act/dismiss-advisor
:headers
POST :host/api/act/dismiss-advisor
:api
advisor=Mira+Castellanos
# Start over as a new player (sets a new cookie)
POST :host/act/new-game
:headers
# Start over as a new player (a new id in X-MM-User)
POST :host/api/act/new-game
:api
# Leaderboard (JSON): [{"name", "balance"}], richest first
GET :host/api/leaderboard?limit=10
# JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie);
# without a known one a player is created and its id comes back in X-MM-User and "userId".
# Game state for a view (same params as GET /): {ok, userId, newPlayer?, state}
# Game state for a view (the view params above): {ok, userId, newPlayer?, state}
GET :host/api/state?moon=<moon id>&well=<well id>
X-MM-User: :user_id
# Run an action (any /act/<action> above, same params, form or JSON body):
# Actions take a form or JSON body:
# {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state
POST :host/api/act/stake
X-MM-User: :user_id
@@ -159,7 +146,7 @@ Content-Type: application/json
{"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"}
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as for /act/claim)
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as above)
POST :host/api/act/claim
X-MM-User: :user_id
Content-Type: application/x-www-form-urlencoded
@@ -277,13 +264,16 @@ Content-Type: application/json
{"sell-moon": "<moon id>", "moon": "<moon id>"}
# Admin: sync the store items in the database with the game config (admin players only)
POST :host/utils/load-config
:headers
# Admin login (ADMIN_PASSWORD): sets the mm_admin cookie and redirects to next (a /utils page).
# Without a session, /utils pages redirect here (actions get 401); with MM_DEV=1 no login.
POST :host/utils/login
Content-Type: application/x-www-form-urlencoded
# Admin: reset your own progress
POST :host/utils/clear-data
:headers
password=<ADMIN_PASSWORD>&next=/utils/players
# Admin logout
POST :host/utils/logout
Cookie: mm_admin=:admin_session
# Admin hub: redirects to the Players page
GET :host/utils
@@ -339,23 +329,20 @@ POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/ban
POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/unban
:headers
# Admin: moon lab page, a live editor for the procedural moon settings (admins or MM_DEV=1)
GET :host/utils/moons
:headers
# Moon settings saved from the moon lab (JSON, null if none); the game merges them over its defaults
# Moon settings (JSON, null if none); the Defold client merges them over its defaults
GET :host/api/moon-settings
# Admin: save moon settings (a JSON object; see DEFAULTS in web/static/js/terrain.js)
# Admin: save moon settings (a JSON object; see DEFAULTS in client/modules/moon/terrain.lua).
# Allowed with the admin cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV=1.
POST :host/api/moon-settings
Content-Type: application/json
Cookie: mm_user=:user_id
X-MM-User: :user_id
{"craters": {"count": 400}}
# Admin: live ops page (dashboard, timed events, announcements; HTML)
GET :host/utils/liveops
Cookie: mm_user=:user_id
Cookie: mm_admin=:admin_session
# Admin: create (or, with id, update) a timed event. kind: yield_multiplier |
# price_multiplier | cycle_multiplier; resource: a resource name or all; times are UTC.
-11
View File
@@ -270,20 +270,9 @@ type TrackStep struct {
Current, Next bool
}
// Cycle is a cycle track.
func (r TrackRow) Cycle() bool { return r.Kind == "cycle" }
// RateGain is what the next level adds per hour.
func (r TrackRow) RateGain() float64 { return r.RateTo - r.RateFrom }
// Payback is how long the next level's extra income takes to repay its cost, "" if never.
func (r TrackRow) Payback() string {
if gain := r.RateGain(); gain > 0 {
return formatSpan(r.Cost / gain * 3600)
}
return ""
}
// Label is the button's text: "Twin rotors 2" (the level it buys).
func (r TrackRow) Label() string { return fmt.Sprintf("%s %d", r.Name, r.Level+1) }
+8 -258
View File
@@ -3,9 +3,7 @@ package main
// View models for the templates (web/templates/*.html).
import (
"encoding/json"
"fmt"
"hash/fnv"
"html/template"
"math"
"net/url"
@@ -32,22 +30,11 @@ var resourceColors = map[string]string{
// colorPattern is a CSS hex color, the only form config colors may take.
var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
// templateFuncs are the admin templates' functions.
var templateFuncs = template.FuncMap{
"money": formatMoney,
"cur": formatCredits,
// short is the tooltip on a button the player can't afford.
"short": func(balance, price float64) string { return shortBy(price - balance) },
"mins": func(m int) string { return formatDuration(time.Duration(m) * time.Minute) },
"secs": secsText,
// A track's value (tracks.go): a cycle ("2.6s") or a yield per cycle ("+5").
"trackval": func(cycle bool, v float64) string {
if cycle {
return formatCycle(v)
}
return "+" + formatMoney(v)
},
"curk": formatCompact,
"wells": wellsText,
"dur": formatDuration,
"color": func(resource string) template.CSS {
c, ok := resourceColors[resource]
@@ -60,30 +47,7 @@ var templateFuncs = template.FuncMap{
}
return template.CSS("background:" + c)
},
// planet is the inline style for a moon thumbnail: a hue derived from its id.
"planet": func(id string) template.CSS {
h := fnv.New32a()
h.Write([]byte(id))
return template.CSS(fmt.Sprintf("--hue:%d", h.Sum32()%360))
},
// pct is value/total as a CSS width, capped at 100%.
"pct": func(value, total float64) template.CSS {
if total <= 0 {
return "width:0%"
}
return template.CSS(fmt.Sprintf("width:%.0f%%", math.Min(100, value/total*100)))
},
"plural": plural,
// vals builds an hx-vals JSON object from key/value pairs.
"vals": func(pairs ...string) string {
m := map[string]string{}
for i := 0; i+1 < len(pairs); i += 2 {
m[pairs[i]] = pairs[i+1]
}
b, _ := json.Marshal(m)
return string(b)
},
"str": func(v any) string { return fmt.Sprint(v) },
}
// plural returns e.g. "1 drill" or "3 drills".
@@ -261,54 +225,13 @@ func (v View) URL() string {
return "/?" + q.Encode()
}
// Links to related views, for the templates.
func (v View) ToSection(s string) string {
return View{Section: s, Moon: v.Moon}.URL()
}
func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL()
}
// 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, 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()
}
// ------------------------------------------------------------------ page
// Page is the data for the whole screen. Every htmx response re-renders all of it as
// out-of-band swaps (see web/templates/app.html).
// RefinedNames lists every refined item, for the grant dialog.
func (p *Page) RefinedNames() []string {
var names []string
for _, item := range cfg().Refinery.Items {
names = append(names, item.Name)
}
return names
}
// Page is the data for one view of the game, mapped to JSON by the API (api.go).
type Page struct {
S *State
V View
OOB bool
Toast *Toast
Scene SceneData
Modal *BuyModal // opens after buying a tool
Banner []BannerItem // active announcements and live events (events.go)
Admin bool // the player is an admin: the quick grant dialog (admin-grant)
@@ -332,21 +255,20 @@ type Page struct {
Advisors *AdvisorsPage // advisors section (advisors.go)
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
NewMoon *NewMoonTeaser // new-moon section (reveal.go)
Reveal *MoonReveal // the moon just bought (reveal.go)
}
type Toast struct {
Message string
Error bool
Kind string // the action, or "error"; main.js/fx.js plays effects for it
// Finished lists the moons the action mined out (comma-separated ids), for fx.js.
Kind string // the action, or "error"; the client plays effects for it
// Finished lists the moons the action mined out (comma-separated ids).
Finished string
// Credits is what a sale earned, for the sell ticket's payout effect (fx.js).
// Credits is what a sale earned, for the sell ticket's payout effect.
Credits float64
}
// SceneData is handed to the three.js viewport (web/static/js/main.js): the moon to show
// (its id is the seed) and the selected well.
// SceneData is the moon viewport's selection (the API's view.aside): the moon to show (its
// id is the seed) and the selected well.
type SceneData struct {
Select map[string]string `json:"select"` // null hides the viewport
}
@@ -387,39 +309,6 @@ func (m Moon) FinishedOn() string {
return m.FinishedAt.Format("Jan 2")
}
// ActiveURL and PastURL are the moon list's tabs: the first moon of each list.
func (p *Page) ActiveURL() string {
if len(p.Moons) == 0 {
return p.V.ToSection("new-moon")
}
return p.V.ToMoon(p.Moons[0].ID)
}
func (p *Page) PastURL() string {
if p.S.PastTotal == 0 {
return ""
}
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 {
Moon
Wells []WellRow
@@ -443,16 +332,6 @@ type DrillGroup struct {
func (g DrillGroup) Mining() bool { return len(g.Drills) > g.Ready }
// Yields lists each drill as "end:amount" (end in unix ms), so main.js can show the
// amount a Claim button will get as drills finish (display only, capped at the supply).
func (g DrillGroup) Yields() string {
parts := make([]string, len(g.Drills))
for i, d := range g.Drills {
parts[i] = strconv.FormatInt(d.End().UnixMilli(), 10) + ":" + strconv.FormatFloat(d.Take, 'f', -1, 64)
}
return strings.Join(parts, ",")
}
func (g *DrillGroup) add(sr StakeRow) {
g.Drills = append(g.Drills, sr)
if sr.Ready {
@@ -476,17 +355,6 @@ type WellRow struct {
DryAt time.Time // when that claim can happen
}
// Pending lists when each unfinished drill on the moon finishes (see endsAttr).
func (d *MoonDetail) Pending() string {
var times []time.Time
for _, st := range d.Stakes {
if !st.Ready {
times = append(times, st.End())
}
}
return endsAttr(times)
}
type WellDetail struct {
WellRow
MoonWide *DrillGroup // the moon's multi-well drills (claimed together)
@@ -529,20 +397,6 @@ type ToolGroup struct {
// Short is a deploy card's tool getting less than its yield here (others claim first).
func (g ToolGroup) Short() bool { return g.Here < g.Yield-1e-9 }
// ReachText says how many wells it mines ("" for one).
func (g ToolGroup) ReachText() string { return reachLabel(g.Reach) }
// ReachShort is the reach for a small card: "3 wells", "whole moon" ("" for one).
func (g ToolGroup) ReachShort() string {
switch {
case g.Reach <= 1:
return ""
case g.Reach >= len(cfg().ResourcePrices):
return "whole moon"
}
return fmt.Sprintf("%d wells", g.Reach)
}
// reachLabel is "Mines 3 wells" or "Strips the whole moon" (a reach as big as a moon can
// get); "" for one well.
func reachLabel(reach int) string {
@@ -571,12 +425,6 @@ type StoreRow struct {
BestRate float64 // the same for the player's best tool; 0 with none
}
// RateGain is how much more per hour it earns than the player's best tool.
func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate }
// ReachText says how many wells the tool mines ("" for one).
func (r StoreRow) ReachText() string { return reachLabel(r.Item.ToolReach()) }
type MarketRow struct {
ResourceBalance
Refined bool // a refined item (refinery.go)
@@ -948,14 +796,6 @@ func (s *State) page(v View) *Page {
return p
}
// Busy is how many of the row's copies are mining.
func (r ToolRow) Busy() int { return r.Count - r.Idle }
// copyKey is the same for copies of a tool: same type and track levels.
func (s *State) copyKey(t Tool) string {
return fmt.Sprint(t.StoreItemID, t.Levels())
}
// followTool keeps the selected drill on the tool an action used: the action may have
// used an idle copy (pickTool) of the one asked for.
func followTool(selected, asked, used string) string {
@@ -965,11 +805,6 @@ func followTool(selected, asked, used string) string {
return selected
}
// StakeURL opens the well a stake is on.
func (p *Page) StakeURL(st *StakeRow) string {
return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL()
}
// ------------------------------------------------------------------ after buying a tool
// BuyModal opens after a tool purchase and suggests wells to deploy it on.
@@ -1063,9 +898,6 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
return m
}
// ReachText says how many wells the tool mines ("" for one).
func (m *BuyModal) ReachText() string { return reachLabel(m.Reach) }
// bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour).
func (s *State) bestToolRate() float64 {
best := 0.0
@@ -1128,19 +960,6 @@ func formatCompact(n float64) string {
return currency + formatMoney(math.Round(n))
}
// formatSpan formats seconds as "45s", "3m 20s" or "2h 5m".
func formatSpan(secs float64) string {
n := int(math.Ceil(secs))
if n < 3600 {
return formatCycle(float64(n))
}
h, m := n/3600, n%3600/60
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh %dm", h, m)
}
// wellsText is "1 well", "2 wells" or "whole moon".
func wellsText(reach int) string {
switch {
@@ -1306,38 +1125,8 @@ func (r WellRow) NoteText() string {
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).
type BarStack struct {
Key string
Bars []StakeRow
More int
}
// Stack is the well's timer bars; phone for the copy shown under the supply bar on phones.
func (r WellRow) Stack(phone bool) BarStack { return barStack("w"+r.ID, r.Queue(), phone) }
// Stack is the multi-well drills' timer bars.
func (g DrillGroup) Stack(phone bool) BarStack { return barStack("m"+g.MoonID, g.Drills, phone) }
func barStack(key string, q []StakeRow, phone bool) BarStack {
if phone {
key = "p" + key
}
return BarStack{Key: key, Bars: q[:min(len(q), maxBars)], More: max(0, len(q)-maxBars)}
}
const maxBars = 4
// Pct is how far through its cycle the drill is (0-100).
func (r StakeRow) Pct() float64 {
if r.Ready || r.DurationInSecs <= 0 {
return 100
}
return min(100, max(0, 100*(1-r.Remaining.Seconds()/r.DurationInSecs)))
}
// Code is the tool's model code, the last word of its name ("PD-1").
func (r StakeRow) Code() string {
if r.Item == nil {
@@ -1381,23 +1170,9 @@ func (r StakeRow) Fate() string {
return s
}
// DrillsTitle is the tooltip of a row's timer bars: each drill and what it gets.
func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) }
func (g DrillGroup) DrillsTitle() string { return drillsTitle(g.Drills) }
// DrillLabel names a row's own drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1");
// a well's multi-well drills show only as diamond-capped bars.
func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) }
// DrillLabel names the group's drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1").
func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) }
func drillsTitle(q []StakeRow) string {
lines := make([]string, len(q))
for i, d := range q {
lines[i] = d.Fate()
}
return strings.Join(lines, "\n")
}
func drillLabel(q []StakeRow) string {
if len(q) == 0 {
return ""
@@ -1460,31 +1235,6 @@ func (s *State) betterWell(skip string, free float64) *BetterWell {
return best
}
// URL opens the well.
func (b *BetterWell) URL() string {
return View{Section: "moons", Moon: b.MoonID, Well: b.WellID}.URL()
}
// Queue is every drill on the well, in the order the projection claims them (oldest first).
func (r WellRow) Queue() []StakeRow {
q := append(append([]StakeRow{}, r.Drills...), r.Wide...)
sort.SliceStable(q, func(i, j int) bool { return q[i].Start.Before(q[j].Start) })
return q
}
// SupplyBar is a well's supply bar with its template's settings (template "well-supply").
type SupplyBar struct {
W WellRow
Max float64
Now time.Time
Panel bool
}
// Bar is the well's supply bar for the wells table or (panel) the side panel.
func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar {
return SupplyBar{W: r, Max: max, Now: now, Panel: panel}
}
// secsText is formatCycle for a template value, int or float64 seconds.
func secsText(v any) string {
switch n := v.(type) {
+1 -23
View File
@@ -1,18 +1,10 @@
package main
import (
"bytes"
"strings"
"testing"
)
import "testing"
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades,
// selling) show only once the Store is.
func TestViewDrillPanelOnboarding(t *testing.T) {
tmpl, err := (&server{}).parseTemplates()
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, c := range []struct {
flags []string
open, store bool
@@ -37,20 +29,6 @@ func TestViewDrillPanelOnboarding(t *testing.T) {
p := s.page(v)
if p.Drill == nil || p.Drill.Store != c.store {
t.Errorf("%v: panel %+v, want store %v", c.flags, p.Drill, c.store)
continue
}
var b bytes.Buffer
if err := tmpl.ExecuteTemplate(&b, "drill-panel", p.Drill); err != nil {
t.Fatalf("render: %v", err)
}
html := b.String()
if strings.Count(html, "track-ladder") != 2 {
t.Errorf("%v: no track ladders", c.flags)
}
for _, part := range []string{"/act/sell-tool", "/act/upgrade"} {
if strings.Contains(html, part) != c.store {
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, c.store)
}
}
}
}
-22
View File
@@ -1,22 +0,0 @@
// Buttons that change state in place (never replaced while they animate).
import { useRef, useLayoutEffect } from "preact/hooks";
import { busy, clockCaused } from "./session.js";
// A Claim button: disabled until `ready`. When the clock (not a new state) makes it ready,
// it fires "mm:ready" (the classic client's fx.js plays the ready sound, pop and sparks).
// busyKey: shows the spinner while an unpredicted action with that key is on its way.
export function ReadyButton({ ready = true, busyKey = "", class: cls, children, ...rest }) {
const ref = useRef(null);
const was = useRef(ready);
useLayoutEffect(() => {
if (ready && !was.current && clockCaused()) ref.current?.dispatchEvent(new CustomEvent("mm:ready", { bubbles: true }));
was.current = ready;
}, [ready]);
const spinning = busyKey && busy.value[busyKey];
return (
<button ref={ref} type="button" data-claim class={`${cls}${spinning ? " htmx-request" : ""}`} disabled={!ready} {...rest}>
{children}
</button>
);
}
-12
View File
@@ -1,12 +0,0 @@
// Lookups in the static catalog (state.catalog) and the player's tools.
import { base } from "./session.js";
export const colorOf = (resource) =>
base.peek()?.catalog?.resources?.find((r) => r.name === resource)?.color ??
base.peek()?.catalog?.refined?.find((r) => r.name === resource)?.color ??
"#e2e8f0";
export const toolOf = (st, id) => st.tools.find((t) => t.id === id);
export const imageOf = (st, storeItemId) => st.catalog?.tools?.find((t) => t.storeItemId === storeItemId)?.image ?? "";
-105
View File
@@ -1,105 +0,0 @@
// The server's clock, the frame loop and eased numbers (display only).
//
// - now(): the server's clock in unix ms (its state.now plus the time since the response).
// - edge: a signal bumped when a drill's end passes (scheduled with a timer for the next
// one), so readiness-dependent computeds re-run then and not every frame.
// - The frame loop runs tickers (countdown texts, eased numbers) registered by components;
// they write textContent directly, so no signal changes per frame.
// - Eased numbers (like client/modules/tween_numbers.lua): a value keyed by name ("credits",
// "res:Sollux") that moves from what is shown to its new value over DURATION instead of
// snapping, whether the change came from a prediction or the server correcting one.
import { signal } from "@preact/signals";
let offset = 0; // server clock minus ours
export const now = () => Date.now() + offset;
export function syncClock(serverNow, receivedAt) {
offset = serverNow - receivedAt;
}
// ---------------------------------------------------------------- edges
export const edge = signal(0);
// When the last edge passed (performance.now()).
export let lastEdge = -Infinity;
let edgeTimer = 0;
let edgeAt = Infinity;
// Wake at the soonest of these ends (unix ms, server clock).
export function armEdge(ends) {
const t = now();
let next = Infinity;
for (const e of ends) if (e > t && e < next) next = e;
if (next === edgeAt) return;
clearTimeout(edgeTimer);
edgeAt = next;
if (next === Infinity) return;
edgeTimer = setTimeout(() => {
edgeAt = Infinity;
lastEdge = performance.now();
edge.value++;
}, Math.max(0, next - t) + 1);
}
// ---------------------------------------------------------------- frame loop
const tickers = new Set();
let frame = 0;
export function addTicker(fn) {
tickers.add(fn);
fn(performance.now());
if (!frame) frame = requestAnimationFrame(loop);
return () => tickers.delete(fn);
}
function loop(t) {
for (const fn of tickers) fn(t);
frame = tickers.size ? requestAnimationFrame(loop) : 0;
}
// ---------------------------------------------------------------- eased numbers
export const DURATION = 420;
const ease = (k) => 1 - Math.pow(1 - k, 3); // out cubic
const vals = new Map(); // key -> {shown, from, to, t0, hold}
// The value to show for key now, given its true value (which may have just changed).
export function eased(key, truth, t = performance.now()) {
let e = vals.get(key);
if (!e) {
e = { shown: truth, from: truth, to: truth, t0: 0, hold: false };
vals.set(key, e);
return truth;
}
if (e.hold) return e.shown;
if (e.to !== truth) {
e.from = e.shown;
e.to = truth;
e.t0 = t;
}
const k = Math.min(1, (t - e.t0) / DURATION);
e.shown = k >= 1 ? e.to : e.from + (e.to - e.from) * ease(k);
return e.shown;
}
export const moving = (key) => {
const e = vals.get(key);
return !!e && (e.hold || e.shown !== e.to);
};
// Keep showing key's current value until release (a claim's resources wait for their
// particles to land in the wallet).
export function hold(key) {
const e = vals.get(key);
if (e) e.hold = true;
}
export function release(key) {
const e = vals.get(key);
if (!e || !e.hold) return;
e.hold = false;
e.from = e.shown;
e.t0 = performance.now();
e.to = NaN; // restart from what is shown towards the truth on the next frame
}
-41
View File
@@ -1,41 +0,0 @@
// The confirm dialog: the classic client's markup (admin.html "confirm-dialog"), opened and
// closed by its ui.js (closing animation, Escape, backdrop) with its sounds.
import { useRef, useLayoutEffect } from "preact/hooks";
import { ask, wireDialog } from "/static/js/ui.js";
import * as sfx from "/static/js/sfx.js";
let dialog = null;
export function ConfirmDialog() {
const ref = useRef(null);
useLayoutEffect(() => {
dialog = ref.current;
wireDialog(dialog, { onClose: (value) => sfx.play(value === "ok" ? "confirm" : "close") });
}, []);
return (
<dialog ref={ref} id="confirm" class="dialog frame" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title" class="dialog-title">
Confirm
</h2>
<p class="dialog-message" />
<div class="dialog-actions">
<button value="cancel" class="btn">
Cancel
</button>
<button value="ok" class="btn btn-primary" autofocus>
Confirm
</button>
</div>
</form>
</dialog>
);
}
// Asks the API's confirm ({title, text, ok, danger}); resolves true on OK.
export function confirm(c) {
if (!dialog) return Promise.resolve(false);
sfx.play("open");
return ask(dialog, { message: c.text, title: c.title, ok: c.ok, danger: c.danger });
}
-34
View File
@@ -1,34 +0,0 @@
// 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;
}
-77
View File
@@ -1,77 +0,0 @@
// 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) {
if (n < 60 && n !== Math.trunc(n)) return `${Math.round(n * 10) / 10}s`; // tenths, as formatCycle
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 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1"), like drillLabel in views.go.
// A tool's track levels after its name (" Y2 C1"), "" for a base tool: Levels.Tag in tracks.go.
export function levelsTag(t) {
const y = t?.yieldLevel ?? 0;
const c = t?.cycleLevel ?? 0;
return y || c ? ` Y${y} C${c}` : "";
}
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]) + levelsTag(toolOf(drills[0].toolId));
const count = new Map();
let top = codeOf(drills[0]);
for (const d of drills) {
const c = codeOf(d);
count.set(c, (count.get(c) ?? 0) + 1);
if (count.get(c) > count.get(top)) top = c;
}
const rest = drills.length - count.get(top);
return `${top} ×${count.get(top)}${rest > 0 ? ` +${rest}` : ""}`;
}
-93
View File
@@ -1,93 +0,0 @@
// Effects for actions, played at the click from the predicted change (the server's answer
// only corrects the numbers). The particles, floating numbers and sounds are the classic
// client's (web/static/js/fx.js, sfx.js).
import { hooks } from "./session.js";
import { hold, release } from "./clock.js";
import { burst, floatText, stream, mark, centerOf, formatMoney } from "/static/js/fx.js";
import * as sfx from "/static/js/sfx.js";
const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const CYAN = () => token("--cyan", "#5fd4ff");
const CYAN_HI = () => token("--cyan-hi", "#b3ecff");
const GOLD = () => token("--ready", "#ffd166");
const GOLD_HI = () => token("--ready-hi", "#fff1c2");
const amounts = (st) => new Map((st?.resources ?? []).map((r) => [r.name, r.amount]));
const colorOf = (st, name) => st?.catalog?.resources?.find((r) => r.name === name)?.color ?? CYAN();
hooks.onAction = (kind, prev, next, at) => {
const from = at ?? centerOf(document.getElementById("main"));
if (kind === "claim") {
const before = amounts(prev);
const gains = [];
for (const [name, v] of amounts(next)) {
const d = v - (before.get(name) ?? 0);
if (d > 0) gains.push({ name, d, color: colorOf(next, name) });
}
if (!gains.length) return;
sfx.claim();
burst(from.x, from.y, gains.map((g) => g.color), 46);
gains.forEach((g, i) => {
const key = `res:${g.name}`;
hold(key);
floatText(`+${formatMoney(g.d)} ${g.name}`, from.x, from.y - 10 - i * 30, g.color, i * 90);
const chip = document.querySelector(`[data-chip="${CSS.escape(g.name)}"]`);
const to = centerOf(chip);
stream(from, to, g.color, 14, 120 + i * 90, () => {
release(key);
sfx.arrive(i);
const num = chip?.querySelector(".num");
if (num) mark(num, "fx-gain");
burst(to.x, to.y, [g.color], 14, 0.5);
});
});
// Claim and redeploy: the new drills too.
flashNewDrills(prev, next);
return;
}
if (kind === "stake" || kind === "redrill") {
sfx.play("deploy");
burst(from.x, from.y, [CYAN(), CYAN_HI()], 26);
flashNewDrills(prev, next);
return;
}
// A moon sold (answered, not predicted): a gold ring where it was offered, the credits
// counting up in the wallet.
if (kind === "sell-moon") {
sfx.play("shimmer");
burst(from.x, from.y, [GOLD(), GOLD_HI(), CYAN_HI()], 60, 1.2);
const credits = (next?.credits ?? 0) - (prev?.credits ?? 0);
if (credits > 0) {
sfx.cash(credits);
floatText(`+₥${formatMoney(credits)}`, from.x, from.y - 10, GOLD());
}
}
};
// What only the server knows: a claim that mined a moon out (its entry leaves for the
// Past tab).
hooks.onAnswer = (kind, data) => {
if (!data?.fx?.finished?.length) return;
sfx.minedOut(450);
requestAnimationFrame(() => mark(document.querySelector("#moonlist [data-past-tab]"), "fx-past"));
};
// Following a link (moon, well, tab, page): the classic client's relay click.
hooks.onNavigate = () => sfx.play("nav");
// Drill lines for tools that weren't mining before light up (after the render).
function flashNewDrills(prev, next) {
const was = new Set((prev?.stakes ?? []).map((s) => s.toolId));
const added = (next?.stakes ?? []).filter((s) => !was.has(s.toolId)).map((s) => s.toolId);
if (!added.length) return;
requestAnimationFrame(() => {
for (const id of added) for (const el of document.querySelectorAll(`[data-tool="${CSS.escape(id)}"]`)) mark(el, "fx-new");
});
}
hooks.onError = (at) => {
sfx.play("error");
const el = at?.el?.isConnected ? at.el : at && document.elementFromPoint(at.x, at.y)?.closest(".btn");
if (el) mark(el, "fx-shake");
};
-95
View File
@@ -1,95 +0,0 @@
// Header: section tabs (Moons here, the rest open the classic client), Claim all, the
// wallet with eased counts, sound and theme toggles.
import { useComputed } from "@preact/signals";
import { live, act } from "./session.js";
import { Num, Countdown, Dot } from "./ui.jsx";
import { colorOf } from "./catalog.js";
import { ReadyButton } from "./buttons.jsx";
const TABS = [
["equipment", "Equipment"],
["store", "Store"],
["market", "Market"],
["refinery", "Refinery"],
["advisors", "Advisors"],
["leaderboard", "Leaderboard"],
["help", "Help"],
];
export function Header() {
const st = live.value;
const credits = useComputed(() => live.value?.credits ?? 0);
return (
<header id="top" class="top">
<span class="brand">
<b>MOON MINERS</b>
<span class="cap">Extraction command</span>
</span>
<nav aria-label="Main" class="nav">
<a id="tab-moons" href="/app" aria-current="page">
Moons{" "}
<span class="badge" hidden={!st.ready}>
{st.ready}
</span>
</a>
{TABS.filter(([s]) => st.unlocked[s]).map(([s, label]) => (
<a key={s} href={`/?s=${s}`} title="Opens the classic client">
{label}
</a>
))}
{st.unlocked["claim-all"] && (st.ready > 0 || st.nextEnd) && <ClaimAll ready={st.ready} nextEnd={st.nextEnd} />}
</nav>
<div class="wallet">
{st.resources.map((r) => (
<Chip key={r.name} name={r.name} />
))}
<Num class="balance frame num" value={credits} k="credits" prefix="₥" />
</div>
<div class="top-ctl">
<button type="button" class="top-btn sfx-toggle" data-sfx-toggle aria-label="Mute sound" aria-pressed="false" title="Sound on">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" />
<path class="sfx-on" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11" />
<path class="sfx-off" d="M16 9.5l5 5M21 9.5l-5 5" />
</svg>
</button>
<button type="button" class="top-btn theme-toggle" data-theme-toggle aria-label="Switch light or dark theme" title="Light / dark theme">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<g class="theme-sun">
<circle cx="12" cy="12" r="4" />
<path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8" />
</g>
<path class="theme-moon" d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" />
</svg>
</button>
<a href="/" class="top-btn" title="The htmx client">
Classic
</a>
</div>
</header>
);
}
function Chip({ name }) {
const amount = useComputed(() => live.value?.resources.find((r) => r.name === name)?.amount ?? 0);
return (
<span class="chip" title={name} data-chip={name}>
<Dot color={colorOf(name)} />
<Num class="num" value={amount} k={`res:${name}`} />
</span>
);
}
function ClaimAll({ ready, nextEnd }) {
return (
<ReadyButton class="btn btn-primary claim-all" ready={ready > 0} onClick={(e) => act("claim", {}, e.currentTarget)}>
Claim all{ready > 0 ? "" : " "}
{ready > 0 ? null : <Countdown end={nextEnd} />}
<span class="badge" hidden={!ready}>
{ready}
</span>
</ReadyButton>
);
}
-30
View File
@@ -1,30 +0,0 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"preact": [
"vendor/preact/preact.mjs"
],
"preact/hooks": [
"vendor/preact/hooks.mjs"
],
"preact/jsx-runtime": [
"vendor/preact/jsxRuntime.mjs"
],
"@preact/signals": [
"vendor/signals/signals.mjs"
],
"@preact/signals-core": [
"vendor/signals/signals-core.mjs"
],
"three": [
"../static/vendor/three/three.module.min.js"
],
"three/addons/*": [
"../static/vendor/three/addons/*"
]
}
}
}
-67
View File
@@ -1,67 +0,0 @@
// The Preact client at /app: the Moons slice of the game (header and wallet, moon list,
// wells table, well panel, viewport, toasts) on the JSON API (api.md), predicting every
// core-loop click locally like the Defold client. Other sections open the classic client.
import { render } from "preact";
import { live, boot, toasts, start } from "./session.js";
import { Header } from "./header.jsx";
import { MoonList, MoonsSection } from "./moons.jsx";
import { Side } from "./side.jsx";
import { Viewport } from "./viewport.jsx";
import { ConfirmDialog } from "./dialog.jsx";
import "./fx.js";
import "./preact.css";
function App() {
const st = live.value;
if (!st) {
return (
<div class="splash">
<div>
<h1>Moon Miners</h1>
<p>{boot}</p>
</div>
</div>
);
}
const aside = !!st.moon;
return (
<>
<div class="app">
<Header />
<div class="app-body">
<MoonList />
<main id="main" class="main frame">
<MoonsSection />
</main>
<aside id="aside" class="aside frame" hidden={!aside}>
<Viewport />
<section id="side" class={`side${st.well && (st.moon?.wells.find((w) => w.id === st.well.id)?.supply ?? 1) <= 0 ? " depleted" : ""}`}>
<Side />
</section>
</aside>
</div>
</div>
<Toasts />
<ConfirmDialog />
</>
);
}
function Toasts() {
const list = toasts.value;
return (
<div id="toasts" class="toasts" aria-live="polite">
{list.map((t) => (
<div key={t.id} class={`toast${t.error ? " toast-error" : ""}${t.out ? " toast-out" : ""}`} data-fx={t.kind}>
{t.message}
</div>
))}
</div>
);
}
render(<App />, document.getElementById("root"));
start();
// sfx.js syncs its mute button on this event (the classic client's htmx fires it).
requestAnimationFrame(() => document.dispatchEvent(new Event("htmx:load")));
-14
View File
@@ -1,14 +0,0 @@
// A component that renders once (no preact/compat): its props never change it.
import { Component, h } from "preact";
export function memo(render) {
return class Once extends Component {
shouldComponentUpdate() {
return false;
}
render(props) {
return h(render, props);
}
};
}
-447
View File
@@ -1,447 +0,0 @@
// The moon list (active and past moons, a search box) and the Moons section (wells table
// with drill bars, claim and redrill, multi-well drills, the moon's resale; a past moon's
// haul).
import { signal } from "@preact/signals";
import { live, act, navigate, viewURL, hooks, busy } from "./session.js";
import { Countdown, DrillBar, Dot, Planet, RatingChip, SupplyBar, Num } from "./ui.jsx";
import { ReadyButton } from "./buttons.jsx";
import { confirm } from "./dialog.jsx";
import { colorOf, toolOf } from "./catalog.js";
import { money, cur, plural, drillLabel } from "./format.js";
import { useFlip } from "./flip.js";
import { now } from "./clock.js";
import { isReady, find } from "./predict.js";
// A link to a view that navigates in place.
export function ViewLink({ to, children, ...rest }) {
return (
<a
href={viewURL(to)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
hooks.onNavigate();
navigate(to);
}}
{...rest}
>
{children}
</a>
);
}
// The search box's text: it stays while the list changes under it.
const query = signal("");
const onFind = (e) => (query.value = e.currentTarget.value);
const minedTotal = (m) => (m.mined ?? []).reduce((sum, a) => sum + a.amount, 0);
export function MoonList() {
const st = live.value;
const ref = useFlip();
const past = !!st.view.past;
const store = st.unlocked.store;
const total = st.pastPage?.total ?? 0;
const list = past ? st.past ?? [] : st.moons;
const q = query.value.trim().toLowerCase();
const found = q ? list.filter((m) => m.name.toLowerCase().includes(q)) : list;
const page = st.pastPage ?? { page: 1, pages: 1 };
const activeMoon = st.moons.find((m) => m.selected)?.id ?? "";
return (
<section id="moonlist" class="moonlist frame" aria-label="Your moons" ref={ref}>
{store && (st.moons.length > 0 || total > 0) && (
<nav class="tabs list-tabs" aria-label="Moon list">
<ViewLink to={{ section: "moons", moon: activeMoon }} aria-current={past ? undefined : "page"}>
Active <span class="muted num">{st.moons.length}</span>
</ViewLink>
{total > 0 ? (
<ViewLink to={{ section: "moons", past: true, page: 1 }} aria-current={past ? "page" : undefined} data-past-tab>
Past <span class="muted num">{total}</span>
</ViewLink>
) : (
<a aria-disabled="true" title="Moons you mine out go here" data-past-tab>
Past <span class="muted num">0</span>
</a>
)}
</nav>
)}
{store && list.length > 0 && (
<>
<label for="find" class="label">
Find a moon
</label>
<input id="find" type="search" placeholder="Name" class="input" value={query} onInput={onFind} />
</>
)}
{past ? (
<>
{found.map((m) => (
<ViewLink
key={m.id}
to={{ section: "moons", moon: m.id, past: true, page: page.page }}
id={`moon-${m.id}`}
data-key={m.id}
class="moon past"
aria-current={m.selected && st.view.section === "moons" ? "true" : undefined}
>
<Planet id={m.id} />
<span class="grow">
<b>{m.name}</b>
<RatingChip r={m.rating} />
<br />
<span class="muted small num">
Mined {money(minedTotal(m))}
{m.finishedOn && ` · ${m.finishedOn}`}
</span>
</span>
</ViewLink>
))}
{page.pages > 1 && (
<nav class="past-paging small" aria-label="Past moon pages">
{page.page > 1 ? (
<ViewLink to={{ section: "moons", past: true, page: page.page - 1 }} class="btn btn-sm" rel="prev">
‹ Newer
</ViewLink>
) : (
<span class="btn btn-sm" aria-disabled="true">
‹ Newer
</span>
)}
<span class="muted num grow">
{page.page} / {page.pages}
</span>
{page.page < page.pages ? (
<ViewLink to={{ section: "moons", past: true, page: page.page + 1 }} class="btn btn-sm" rel="next">
Older ›
</ViewLink>
) : (
<span class="btn btn-sm" aria-disabled="true">
Older ›
</span>
)}
</nav>
)}
</>
) : (
<>
{found.map((m) => {
const dry = m.wells.length > 0 && m.wells.every((w) => w.supply <= 0);
return (
<ViewLink
key={m.id}
to={{ section: "moons", moon: m.id }}
id={`moon-${m.id}`}
data-key={m.id}
class={`moon${dry ? " depleted" : ""}`}
aria-current={m.selected && st.view.section === "moons" ? "true" : undefined}
>
<Planet id={m.id} />
<span class="grow">
<b>{m.name}</b>
<RatingChip r={m.rating} />
<br />
<span class="muted small">
{m.over && <i class="warn-dot" title="A well here has more drills than supply" />}
{m.drills ? plural(m.drills, "drill") : dry ? "Tapped out" : "Idle"}
</span>
</span>
<span class="badge" title={plural(m.ready, "drill") + " ready"} hidden={!m.ready}>
{m.ready}
</span>
</ViewLink>
);
})}
{!st.moons.length && total > 0 && <p class="muted small">Every moon you own is mined out.</p>}
</>
)}
{st.unlocked["new-moon"] && (
<a class="btn btn-dashed new-moon" href="/?s=new-moon" title="Opens the classic client">
+ New moon · <span class="num accent">₥{money(st.moonPrice)}</span>
</a>
)}
</section>
);
}
export function MoonsSection() {
const st = live.value;
const moon = st.moon;
if (!moon) {
const r = st.rules;
return (
<div class="empty">
<h1>Claim your first moon</h1>
<p class="muted measure">
A moon comes with {r.wellsMin === r.wellsMax ? r.wellsMin : `${r.wellsMin}–${r.wellsMax}`} resource wells. Deploy mining tools on its
wells, claim what they dig up, and sell it in the market.
</p>
<a class="btn btn-primary" href="/?s=new-moon" title="Opens the classic client">
Scan for a moon · <span class="num">{cur(st.moonPrice)}</span>
</a>
</div>
);
}
if (moon.finished) return <PastMoon st={st} moon={moon} />;
const max = st.rules.maxSupply;
return (
<>
<div class="heading">
<div class="grow">
<div class="num muted small">{moon.id}</div>
<h1>{moon.name}</h1>
{(moon.kind && moon.kind !== moon.name) || moon.rating ? (
<p class="cap muted small">
{moon.kind && moon.kind !== moon.name && `${moon.kind} class`}
<RatingChip r={moon.rating} />
</p>
) : null}
<p class="muted measure">{moon.description || " "}</p>
</div>
{(moon.ready > 0 || moon.nextEnd) && (
<ReadyButton class="btn btn-primary" ready={moon.ready > 0} onClick={(e) => act("claim", { "claim-moon": moon.id }, e.currentTarget)}>
{moon.ready > 0 ? `Claim ${plural(moon.ready, "drill")}` : <>Claim <Countdown end={moon.nextEnd} /></>}
</ReadyButton>
)}
</div>
<div class="table-box">
<table class="table wells-table">
<thead>
<tr>
<th>Well</th>
<th class="w-supply">Supply left</th>
<th class="col-drill">Drill</th>
<th class="right">Status</th>
</tr>
</thead>
<tbody>
{moon.wells.map((w) => (
<WellRow key={w.id} st={st} moon={moon} w={w} max={max} />
))}
{/* Drills that mine several wells (reach) are claimed together, from the moon. */}
{moon.wide?.drills?.length > 0 && (
<tr class="wide-row">
<td>
<span class="cap small">Multi-well</span>
</td>
<td class="cell-wrap">
<span class="num muted">{money(moon.wide.cap)}</span> <span class="muted small">left on the moon</span>
</td>
<td class="col-drill">
<DrillCell st={st} drills={moon.wide.drills} />
</td>
<td class="right">
<div class="row-actions">
<WideClaim moon={moon} small />
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{st.unlocked.store && <MoonSale moon={moon} />}
</>
);
}
// A finished moon, read-only: what it gave (its entry in the past list has the haul).
function PastMoon({ st, moon }) {
const entry = find(st.past, moon.id);
const mined = entry?.mined ?? [];
return (
<>
<div class="heading">
<div class="grow">
<div class="num muted small">{moon.id}</div>
<h1>{moon.name}</h1>
<p class="tapped">
Mined out{moon.finishedOn && ` · ${moon.finishedOn}`}
</p>
</div>
</div>
<div class="table-box past-moon">
<table class="table">
<thead>
<tr>
<th>Resource</th>
<th class="right">Mined</th>
</tr>
</thead>
<tbody>
{mined.map((a) => (
<tr key={a.name}>
<td>
<Dot color={colorOf(a.name)} />
{a.name}
</td>
<td class="right num">{money(a.amount)}</td>
</tr>
))}
<tr class="total">
<td>Total</td>
<td class="right num">{money(minedTotal({ mined }))}</td>
</tr>
</tbody>
</table>
</div>
<p class="muted small measure">Its wells are empty. It still counts toward the price of your next moon.</p>
</>
);
}
// Claim for the moon's multi-well drills (claim-wide: the server splits what they get
// between their wells, so it isn't predicted; the button spins until it answers).
export function WideClaim({ moon, small = false }) {
const wide = moon.wide;
if (!wide || !(wide.ready > 0 || wide.nextEnd)) return null;
return (
<ReadyButton
class={`btn btn-primary${small ? " btn-sm" : ""}`}
ready={wide.ready > 0}
busyKey="claim-wide"
onClick={(e) => act("claim", { "claim-wide": moon.id }, e.currentTarget, "claim-wide")}
>
Claim {wide.ready > 0 ? <Num class="num" n={wide.readyAmount} k={`wide:${moon.id}`} /> : <Countdown end={wide.nextEnd} />}
</ReadyButton>
);
}
// Selling the moon back: the offer against a new moon's price, and what's left in its
// wells. A just-bought moon becomes sellable at holdEnd (the session wakes then).
function MoonSale({ moon }) {
const sale = moon.sale;
if (!sale) return null;
const held = sale.why === "hold" && sale.holdEnd && now() < sale.holdEnd;
const can = sale.canSell || (sale.why === "hold" && !held);
const profit = sale.delta > 0;
const spinning = !!busy.value["sell-moon"];
const sell = (e) => {
// The button sits in the summary: no toggling the details.
e.preventDefault();
const el = e.currentTarget;
confirm(sale.confirm).then((yes) => yes && act("sell-moon", { "sell-moon": moon.id }, el, "sell-moon"));
};
return (
<details class="resale">
<summary>
<span class="grow">
Sell moon <span class="muted">· offer</span> <span class="num">{cur(sale.total)}</span>{" "}
<span class={`num resale-delta ${profit ? "up" : "down"}`}>{profit ? `+${cur(sale.delta)} profit` : `−${cur(-sale.delta)} vs a new moon`}</span>
</span>
<button type="button" class={`btn btn-sm btn-danger${spinning ? " htmx-request" : ""}`} disabled={!can || spinning} title={can ? undefined : sale.whyText} onClick={sell}>
Sell moon
</button>
</summary>
<div class="resale-body">
<p class="muted small">
What's left in its wells at today's prices, ×{sale.factor}
{sale.capped && `, capped at ${cur(sale.total)}`}. A new moon would cost {cur(sale.replace)} after the sale.
{sale.ready > 0 && ` ${plural(sale.ready, "finished drill")} claimed first.`}
{held && (
<>
{" "}
Sellable in <Countdown end={sale.holdEnd} done="0:00" />.
</>
)}
</p>
<table class="table">
<thead>
<tr>
<th>Well</th>
<th class="right">Left</th>
<th class="right">Price</th>
<th class="right">Value</th>
</tr>
</thead>
<tbody>
{sale.lines.map(({ resource, supply, rate, value: worth }) => (
<tr key={resource}>
<td>
<Dot color={colorOf(resource)} />
{resource}
</td>
<td class="right num">{money(supply)}</td>
<td class="right num">{cur(rate)}</td>
<td class="right num">{cur(worth)}</td>
</tr>
))}
<tr class="resale-total">
<td>Market value</td>
<td />
<td />
<td class="right num">{cur(sale.market)}</td>
</tr>
</tbody>
</table>
</div>
</details>
);
}
// The moon's multi-well drills that mine well w too.
export const wideOn = (moon, w) => (moon.wide?.drills ?? []).filter((d) => w.wide?.includes(d.id));
function WellRow({ st, moon, w, max }) {
const dry = w.supply <= 0;
const to = { section: "moons", moon: moon.id, well: w.id };
const mining = w.drills.length > 0;
const drills = [...w.drills, ...wideOn(moon, w)];
return (
<tr class={`${w.selected ? "selected" : ""}${dry ? " depleted" : ""}`}>
<td>
<ViewLink to={to} class="row-link">
<Dot color={colorOf(w.resource)} />
{w.resource}
</ViewLink>
</td>
<td>
{dry ? (
<span class="tapped">Tapped out</span>
) : (
<SupplyBar supply={w.supply} demand={w.demand} max={max} color={colorOf(w.resource)} full={w.full} showNum={w.id} />
)}
</td>
<td class="col-drill">
{drills.length ? <DrillCell st={st} drills={drills} /> : <span class="muted">Empty</span>}
</td>
<td class="right">
<div class="row-actions">
{mining ? (
<ReadyButton class="btn btn-primary btn-sm" ready={w.ready > 0} onClick={(e) => act("claim", { "claim-well": w.id }, e.currentTarget)}>
Claim {w.ready > 0 ? <Num class="num" n={w.readyAmount} k={`ready:${w.id}`} /> : <Countdown end={w.nextEnd} />}
</ReadyButton>
) : (
w.redrill > 0 &&
!dry &&
st.unlocked.market && (
<button type="button" class="btn btn-sm btn-redrill" onClick={(e) => act("redrill", { "redrill-well": w.id }, e.currentTarget)}>
Redrill
</button>
)
)}
<ViewLink to={to} class="btn btn-sm">
{dry ? "View" : "Manage"}
</ViewLink>
</div>
</td>
</tr>
);
}
const MAX_BARS = 4;
export function DrillCell({ st, drills }) {
const t = now();
return (
<div class="drill-cell" title={drills.map((d) => d.fate ?? "").join("\n")}>
<span class="drill-bars">
{drills.slice(0, MAX_BARS).map((d) => (
<DrillBar key={d.toolId} start={d.start} end={d.end} state={d.state ?? "ok"} ready={isReady(d, t)} wide={(d.reach ?? 1) > 1} />
))}
</span>
{drills.length > MAX_BARS && <span class="drill-more num">+{drills.length - MAX_BARS}</span>}
<span class="drill-label">{drillLabel(drills, (id) => toolOf(st, id))}</span>
</div>
);
}
-54
View File
@@ -1,54 +0,0 @@
/* 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;
}
-353
View File
@@ -1,353 +0,0 @@
// 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 track levels).
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.yieldLevel === t.yieldLevel && x.cycleLevel === t.cycleLevel);
}
const yieldBoost = (st, resource) => st.rules?.boosts?.yield?.[resource] ?? 1;
const sameGroup = (g, tool) =>
g.storeItemId === tool.storeItemId && g.yieldLevel === tool.yieldLevel && g.cycleLevel === tool.cycleLevel;
// 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,
yieldLevel: tool.yieldLevel,
cycleLevel: tool.cycleLevel,
count: 1,
cycleSecs: tool.cycleSecs,
yield: y,
reach: tool.reach,
here: Math.min(y, free),
free,
short: free < y,
});
}
}
}
// Recompute what the server derives from stakes, tools and supplies (counts, the claim
// buttons' amounts, next ends), as of server time now. Wide drills (reach > 1) keep the
// server's projection (what each gets); only their readiness is local.
export function derive(st, now) {
let ready = 0;
let idle = 0;
let nextEnd = null;
const perMoon = new Map();
for (const s of st.stakes ?? []) {
const m = perMoon.get(s.moonId) ?? { drills: 0, ready: 0 };
perMoon.set(s.moonId, m);
m.drills++;
if (isReady(s, now)) {
ready++;
m.ready++;
} else if (nextEnd === null || s.end < nextEnd) nextEnd = s.end;
}
for (const t of st.tools ?? []) if (!t.stakeId) idle++;
st.ready = ready;
st.idle = idle;
st.nextEnd = nextEnd;
for (const moon of st.moons ?? []) {
const m = perMoon.get(moon.id) ?? { drills: 0, ready: 0 };
moon.drills = m.drills;
moon.ready = m.ready;
}
const moon = st.moon;
if (!moon) return st;
let mready = 0;
let mnext = null;
for (const w of moon.wells ?? []) {
const old = new Map((w.drills ?? []).map((d) => [d.id, d]));
const drills = [];
let wready = 0;
let amount = 0;
let wnext = null;
let demand = 0;
for (const s of st.stakes ?? []) {
if (s.wellId !== w.id || (s.reach ?? 1) > 1) continue;
let d = old.get(s.id);
if (!d) {
d = { ...s, gets: s.take, here: s.take };
d.state = s.take <= 0 ? "none" : s.take < s.amount ? "short" : "ok";
}
drills.push(d);
demand += d.here ?? s.take ?? 0;
if (isReady(s, now)) {
wready++;
amount += s.take ?? 0;
} else if (wnext === null || s.end < wnext) wnext = s.end;
}
drills.sort((a, b) => a.start - b.start);
w.drills = drills;
w.ready = wready;
w.readyAmount = amount;
w.nextEnd = wnext;
w.demand = demand;
w.remain = Math.max(0, w.supply - demand);
mready += wready;
if (wnext !== null && (mnext === null || wnext < mnext)) mnext = wnext;
}
if (moon.wide) {
let wready = 0;
let amount = 0;
let wnext = null;
for (const d of moon.wide.drills ?? []) {
if (isReady(d, now)) {
wready++;
amount += d.gets ?? d.take ?? 0;
} else if (wnext === null || d.end < wnext) wnext = d.end;
}
moon.wide.ready = wready;
moon.wide.readyAmount = amount;
moon.wide.nextEnd = wnext;
mready += wready;
if (wnext !== null && (mnext === null || wnext < mnext)) mnext = wnext;
}
moon.ready = mready;
moon.nextEnd = mnext;
return st;
}
// ---------------------------------------------------------------- queue
// Learn from a server state (the well each running or unclaimed drill is on).
export function learn(st) {
for (const s of st.stakes ?? []) memo.lastWell.set(s.toolId, s.wellId);
}
// Run one pending entry ({fn, params, at, seq}) on st; true when it predicted.
export function run(st, e) {
let res = false;
try {
res = !!e.fn(st, e.params, { now: e.at, seq: e.seq });
} catch (err) {
console.warn(`predict: ${e.name} failed`, err);
return false;
}
if (res) derive(st, e.at);
return res;
}
// The state to show: base with every pending prediction applied in order (base itself
// when nothing is pending).
export function apply(base, pending) {
if (!pending.length) return base;
const st = copy(base);
for (const e of pending) run(st, e);
return st;
}
// ---------------------------------------------------------------- core-loop predictors
const moonOfWell = (st, wellId) => {
for (const moon of st.moons ?? []) {
const w = find(moon.wells, wellId);
if (w) return [moon, w];
}
return [];
};
const groupFor = (st, wellId, tool) =>
st.well && st.well.id === wellId ? (st.well.idle ?? []).findLast((g) => sameGroup(g, tool)) : undefined;
// claim: claim-well / claim-moon / everything ready; redeploy=1 restarts the same tools.
registry.claim = (st, p, ctx) => {
if (p["claim-wide"]) return false;
const list = [];
for (const s of st.stakes) {
if (!isReady(s, ctx.now)) continue;
if (p["claim-well"] && s.wellId !== p["claim-well"]) continue;
if (p["claim-moon"] && s.moonId !== p["claim-moon"]) continue;
if ((s.reach ?? 1) > 1) return false; // multi-well drills: the server splits them
list.push(s);
}
if (!list.length) return false;
for (const s of list) {
addItem(st, s.resource, s.take ?? 0);
addSupply(st, s.wellId, -(s.take ?? 0));
removeStake(st, s);
}
if (p.redeploy) {
for (const s of list) {
const tool = find(st.tools, s.toolId);
const w = wells(st, s.wellId)[0];
if (tool && !tool.stakeId && w && w.supply > 0) {
addStake(st, tool, s.moonId, w, ctx, groupFor(st, w.id, tool));
const detail = st.moon && find(st.moon.wells, s.wellId);
if (detail && memo.lastWell.get(tool.id) !== s.wellId && (detail.redrill ?? 0) > 0) detail.redrill--;
}
}
}
return true;
};
// stake (deploy): the deploy card's tool on the target well, timer from now.
registry.stake = (st, p, ctx) => {
const [moon, w] = moonOfWell(st, p.target);
if (!moon || w.supply <= 0) return false;
// "Deploy there": the view moves to the other well, wait for it.
if (st.view && ((p.moon && p.moon !== st.view.moon) || (p.well && p.well !== st.view.well))) return false;
const tool = idleCopy(st, p.tool);
if (!tool || (tool.reach ?? 1) > 1) return false;
addStake(st, tool, moon.id, w, ctx, groupFor(st, w.id, tool));
return true;
};
// redrill: the idle tools that last mined the well (as far as the client saw).
registry.redrill = (st, p, ctx) => {
const id = p["redrill-well"];
const [moon, w] = moonOfWell(st, id);
if (!moon || w.supply <= 0) return false;
const detail = st.moon && find(st.moon.wells, id);
const n = detail?.redrill ?? 0;
const tools = [];
for (const t of st.tools) {
if (tools.length < n && !t.stakeId && memo.lastWell.get(t.id) === id && (t.reach ?? 1) <= 1) tools.push(t);
}
if (!tools.length) return false;
for (const t of tools) addStake(st, t, moon.id, w, ctx, groupFor(st, id, t));
if (detail) detail.redrill = 0;
return true;
};
-108
View File
@@ -1,108 +0,0 @@
// The moon viewport rendered in a worker on an OffscreenCanvas, so the page's thread never
// waits on three.js. Runs the classic client's MoonScene (web/static/js/scene.js, bundled
// here with three.js) against a stand-in for the DOM it expects: the canvas element, its
// container, ResizeObserver and the document. The page (scene.js) forwards pointer and
// wheel events and the container's size.
//
// Messages in: {type: "init", canvas, width, height, dpr}, {type: "size", width, height,
// left, top}, {type: "event", event: {...}}, {type: "show", seed, settings},
// {type: "visible", hidden}. Out: {type: "ready"}, {type: "error", message}.
let canvas = null;
let rect = { left: 0, top: 0, width: 1, height: 1 };
const resizers = new Set();
const root = new EventTarget(); // canvas.getRootNode(): OrbitControls listens for keys there
// ---------------------------------------------------------------- DOM stand-ins
self.window = self;
self.document = {
hidden: false,
createElementNS: () => takeCanvas(),
createElement: (tag) => (tag === "canvas" ? new OffscreenCanvas(1, 1) : {}),
addEventListener() {},
removeEventListener() {},
};
self.ResizeObserver = class {
constructor(fn) {
this.fn = fn;
}
observe() {
resizers.add(this.fn);
}
disconnect() {
resizers.delete(this.fn);
}
};
// The first canvas three.js asks for is the page's (transferred); later ones are scratch.
let given = false;
function takeCanvas() {
if (given) return new OffscreenCanvas(1, 1);
given = true;
return canvas;
}
function dressCanvas(c) {
c.style = {};
c.className = "";
c.getRootNode = () => root;
c.setPointerCapture = () => {};
c.releasePointerCapture = () => {};
c.focus = () => {};
Object.defineProperties(c, {
clientWidth: { get: () => rect.width },
clientHeight: { get: () => rect.height },
});
c.getBoundingClientRect = () => ({ ...rect, x: rect.left, y: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height });
}
const container = {
get clientWidth() {
return rect.width;
},
get clientHeight() {
return rect.height;
},
get offsetWidth() {
return rect.width;
},
dataset: {},
prepend() {},
};
// ---------------------------------------------------------------- messages
let scene = null;
let wanted = null; // a show that came before the scene was up
self.onmessage = async ({ data }) => {
try {
if (data.type === "init") {
canvas = data.canvas;
self.devicePixelRatio = data.dpr;
rect = { left: 0, top: 0, width: data.width, height: data.height };
dressCanvas(canvas);
const { MoonScene } = await import("../static/js/scene.js");
scene = new MoonScene(container, {
onContextChange: (lost) => self.postMessage({ type: "context", lost }),
});
if (wanted) scene.show(wanted);
self.postMessage({ type: "ready" });
} else if (data.type === "size") {
rect = { left: data.left, top: data.top, width: data.width, height: data.height };
for (const fn of resizers) fn();
} else if (data.type === "event") {
const { type, ...fields } = data.event;
const e = new Event(type, { cancelable: true });
Object.assign(e, fields);
e.preventDefault = () => {};
canvas?.dispatchEvent(e);
} else if (data.type === "show") {
wanted = { seed: data.seed, settings: data.settings };
scene?.show(wanted);
}
} catch (error) {
self.postMessage({ type: "error", message: String(error?.message ?? error) });
}
};
-112
View File
@@ -1,112 +0,0 @@
// The moon in the viewport. startViewport(container) -> {show(moonId), dispose()}.
//
// Preferred: the classic client's MoonScene rendered in a worker on an OffscreenCanvas
// (scene-worker.js), with the canvas's pointer and wheel events and its size forwarded.
// Without OffscreenCanvas, or if the worker fails to start, the classic client's scene runs
// on this thread (web/static/js/scene.js, three.js through the page's import map).
import { mergeSettings } from "/static/js/terrain.js";
// The moon look tuned by admins (/utils/moons), merged over the defaults.
const settings = fetch("/api/moon-settings")
.then((res) => (res.ok ? res.json() : null))
.catch(() => null)
.then((saved) => mergeSettings(saved));
// Where the viewport runs, for the measurements: "worker" or "main".
export let mode = "";
export function startViewport(container) {
const offscreen = "transferControlToOffscreen" in HTMLCanvasElement.prototype && !new URLSearchParams(location.search).has("mainscene");
return offscreen ? workerViewport(container) : mainViewport(container);
}
function mainViewport(container) {
mode = "main";
let seed = "";
let scene = null;
import("/static/js/scene.js").then(({ startScene }) => {
scene = startScene(container, {
onStatus(message) {
if (message) console.warn("viewport:", message);
},
});
if (seed) show(seed);
});
const show = (s) => {
seed = s;
if (scene) settings.then((set) => scene.show({ seed: s, settings: set }));
};
return { show, dispose: () => scene?.dispose() };
}
const FIELDS = ["pointerId", "pointerType", "button", "buttons", "clientX", "clientY", "pageX", "pageY", "deltaX", "deltaY", "deltaMode", "ctrlKey", "metaKey", "shiftKey", "altKey"];
function workerViewport(container) {
mode = "worker";
const canvas = document.createElement("canvas");
canvas.className = "game-canvas";
canvas.style.touchAction = "none";
container.prepend(canvas);
const worker = new Worker(new URL("./scene-worker.js", import.meta.url), { type: "module" });
let failed = null;
let seed = "";
const fail = (why) => {
if (failed) return;
console.warn("viewport: worker failed, drawing on the page's thread:", why);
worker.terminate();
stop();
canvas.remove();
failed = mainViewport(container);
if (seed) failed.show(seed);
};
worker.onerror = (e) => {
e.preventDefault?.();
fail(e.message);
};
worker.onmessage = ({ data }) => {
if (data.type === "error") fail(data.message);
};
const box = () => container.getBoundingClientRect();
const r = box();
const off = canvas.transferControlToOffscreen();
worker.postMessage(
{ type: "init", canvas: off, width: r.width, height: r.height, dpr: Math.min(devicePixelRatio, 2) },
[off]
);
const size = () => {
const b = box();
worker.postMessage({ type: "size", width: b.width, height: b.height, left: b.left, top: b.top });
};
const resize = new ResizeObserver(size);
resize.observe(container);
// OrbitControls listens on the canvas; it captures the pointer while dragging.
const forward = (e) => {
if (e.type === "pointerdown") {
canvas.setPointerCapture(e.pointerId);
size(); // its rect is read on drag start
}
if (e.type === "wheel" || e.type === "contextmenu") e.preventDefault();
const msg = { type: e.type };
for (const k of FIELDS) if (k in e) msg[k] = e[k];
worker.postMessage({ type: "event", event: msg });
};
const types = ["pointerdown", "pointermove", "pointerup", "pointercancel", "wheel", "contextmenu"];
for (const t of types) canvas.addEventListener(t, forward, { passive: t !== "wheel" && t !== "contextmenu" });
const stop = () => {
resize.disconnect();
for (const t of types) canvas.removeEventListener(t, forward);
};
return {
show(s) {
seed = s;
if (failed) return failed.show(s);
settings.then((set) => worker.postMessage({ type: "show", seed: s, settings: set }));
},
dispose() {
stop();
worker.terminate();
failed?.dispose();
},
};
}
-352
View File
@@ -1,352 +0,0 @@
// 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.yieldLevel}:${t.cycleLevel}`;
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,
yieldLevel: t.yieldLevel,
cycleLevel: t.cycleLevel,
count: 1,
cycleSecs: t.cycleSecs,
yield: y,
reach: t.reach,
here: Math.min(y, free),
free,
short: free < y,
});
}
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 });
}
-262
View File
@@ -1,262 +0,0 @@
// 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, levelsTag } 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.yieldLevel}:${g.cycleLevel}`} 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"}
{levelsTag(tool) && <span class="muted">{levelsTag(tool)}</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.yieldLevel}:${g.cycleLevel}`;
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}${levelsTag(g)}`}
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.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>
{levelsTag(g) && <span class="tier-chip num">{levelsTag(g)}</span>}
</span>
<span class="deploy-card-meta">
<span class="num">
+{money(g.here)} / {secs(g.cycleSecs)}
</span>
<span class="deploy-card-go">Deploy</span>
</span>
{g.short && (
<span class="deploy-card-note">
{g.here > 0 ? (
<>
Only <span class="num">{money(g.free)}</span> left after current drills
</>
) : (
"Well spoken for: nothing left after current drills"
)}
</span>
)}
</span>
</button>
);
}
// A better well for the idle tools (more free supply), maybe on another moon: deploying
// there moves the view to it, so it waits for the server (the button spins).
function BetterWell({ st, moon, b }) {
const other = b.moonId !== moon.id ? find(st.moons, b.moonId)?.name : null;
const spinning = !!busy.value["stake:better"];
const deploy = (e) => act("stake", { tool: b.toolId, target: b.wellId, s: "moons", moon: b.moonId, well: b.wellId }, e.currentTarget, "stake:better");
return (
<div class="line better-well">
<span class="grow small">
Better:{" "}
<ViewLink to={{ section: "moons", moon: b.moonId, well: b.wellId }}>
<Dot color={colorOf(b.resource)} />
{b.resource} well
</ViewLink>
{other && ` on ${other}`}, <span class="num">{money(b.free)}</span> free
</span>
{b.toolId && (
<button type="button" class={`btn btn-sm btn-primary${spinning ? " htmx-request" : ""}`} disabled={spinning} title="Deploy on that well" onClick={deploy}>
Deploy there
</button>
)}
</div>
);
}
-130
View File
@@ -1,130 +0,0 @@
// Small display pieces shared by the sections. Values that change every frame (eased
// numbers, countdowns, drill bars) are written by the frame loop or the Web Animations
// API on elements Preact created once, so a re-render never replaces or restarts them.
import { useRef, useEffect, useLayoutEffect } from "preact/hooks";
import { addTicker, eased, moving, now } from "./clock.js";
import { money, fixed, decimals, left, hue } from "./format.js";
import { mark } from "/static/js/fx.js";
// A number that eases to its new value (key: what it is, e.g. "credits"). value: a signal
// or computed, or n: a plain number (the component re-renders with it). A gain pops, a loss nudges (CSS fx-gain / fx-loss), unless something holds
// it (a claim's resources wait for their particles).
export function Num({ value, n, k, prefix = "", class: cls, ...rest }) {
const ref = useRef(null);
const src = useRef(null);
src.current = value ?? n;
const read = () => (typeof src.current === "number" ? src.current : src.current.peek());
useLayoutEffect(() => {
let last = read();
let digits = decimals(last);
return addTicker((t) => {
const el = ref.current;
if (!el) return;
const truth = read();
if (truth !== last) {
if (!moving(k)) mark(el, truth > last ? "fx-gain" : "fx-loss");
digits = Math.max(decimals(last), decimals(truth));
last = truth;
}
const v = eased(k, truth, t);
// Whole counts tick as whole numbers; others keep their decimals while moving.
const text = prefix + (v === truth ? money(truth) : Number.isInteger(truth) ? money(Math.round(v)) : fixed(v, digits));
if (el.textContent !== text) el.textContent = text;
});
}, [k]);
return <span ref={ref} class={cls} data-num={k} {...rest} />;
}
// Time left until end (unix ms, server clock), then `done`.
export function Countdown({ end, done = "", class: cls = "num" }) {
const ref = useRef(null);
const endRef = useRef(end);
endRef.current = end;
useLayoutEffect(
() =>
addTicker(() => {
const el = ref.current;
if (!el) return;
const ms = endRef.current - now();
const text = ms > 0 ? left(ms) : done;
if (el.textContent !== text) el.textContent = text;
}),
[]
);
return <time ref={ref} class={cls} />;
}
// A drill's timer bar (keyed by tool id, so the server's correction of a predicted stake
// keeps the element). The fill is a compositor animation (transform); a correction
// changes its playback rate so it lands on the true end instead of jumping.
export function DrillBar({ start, end, state, ready, wide }) {
const fill = useRef(null);
const anim = useRef(null);
useLayoutEffect(() => {
const el = fill.current;
const d = Math.max(1, end - start);
const t = now();
let a = anim.current;
if (!a) {
a = anim.current = el.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], {
duration: d,
fill: "both",
easing: "linear",
});
a.currentTime = Math.min(d, Math.max(0, t - start));
return;
}
// Correction: keep the progress shown, finish at the true end.
const timing = a.effect.getComputedTiming();
const p = Math.min(1, (timing.progress ?? (a.playState === "finished" ? 1 : 0)));
a.effect.updateTiming({ duration: d });
a.currentTime = p * d;
const rest = end - t;
if (rest <= 0 || p >= 1) {
a.updatePlaybackRate(1);
a.currentTime = rest <= 0 ? d : a.currentTime;
return;
}
a.updatePlaybackRate(Math.min(4, Math.max(0.25, ((1 - p) * d) / rest)));
}, [start, end]);
useEffect(() => () => anim.current?.cancel(), []);
return (
<i class={`drill-bar pa-bar is-${state}${wide ? " is-wide" : ""}${ready ? " is-ready" : ""}`}>
<b ref={fill} class="pa-fill" />
</i>
);
}
export const Dot = ({ color }) => <span class="dot" style={{ background: color ?? "#e2e8f0" }} aria-hidden="true" />;
export const Planet = ({ id }) => <span class="planet" style={{ "--hue": hue(id) }} aria-hidden="true" />;
export function RatingChip({ r }) {
if (!r) return null;
return (
<span class={`rating-chip tier-${r.tier}`} title={`${r.label} moon · top ${r.top}%`} aria-label={`${r.label} moon, ${r.stars} of 5 stars`}>
★{r.stars}
</span>
);
}
// A supply bar: what stays after the drills (solid), then what they will take (striped).
// Both parts span the bar and are clipped (paint only, no layout), so a change glides
// without stretching the cells or stripes.
export function SupplyBar({ supply, demand, max, color, full, showNum }) {
const m = Math.max(1, max);
const remain = Math.max(0, supply - demand);
const taken = Math.min(demand, supply);
const r = Math.min(1, remain / m);
const u = Math.min(1 - r, taken / m);
const pct = (x) => `${(x * 100).toFixed(2)}%`;
return (
<div class={`supply${full ? " is-full" : ""}`}>
<div class="bar bar-split pa-split">
<span style={{ clipPath: `inset(0 ${pct(1 - r)} 0 0)`, background: color }} />
<span class="bar-used" style={{ clipPath: `inset(0 ${pct(1 - r - u)} 0 ${pct(r)})`, background: color }} />
</div>
{showNum && <Num class="num muted" n={supply} k={`supply:${showNum}`} />}
</div>
);
}
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015-present Jason Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-2
View File
@@ -1,2 +0,0 @@
import{options as n}from"preact";var t,r,u,i,o=Object.is,f=0,c=[],e=[],a=n,v=a.__b,l=a.__r,m=a.diffed,s=a.__c,h=a.unmount,p=a.__;function y(n,t){a.__h&&a.__h(r,n,f||t),f=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function d(n){return f=1,_(G,n)}function _(n,u,i){var f=y(t++,2);if(f.t=n,!f.__c&&(f.__=[i?i(u):G(void 0,u),function(n){var t=f.__N?f.__N[0]:f.__[0],r=f.t(t,n);o(t,r)||(f.__N=[r,f.__[1]],f.__c.setState({}))}],f.__c=r,!r.__f)){r.__f=!0;var c=r.shouldComponentUpdate;r.shouldComponentUpdate=function(n,t,r){var u=this.__H;if(!u)return!0;var i=!1,f=this.props!=n;if(u.__.some(function(n){n.__N&&(i=!0,o(n.__[0],n.__N[0])||(f=!0))}),c){var e=c.call(this,n,t,r);return i?e||f:e}return!i||f}}return f.__}function A(n,u){var i=y(t++,3);!a.__s&&E(i.__H,u)&&(i.__P=!0,i.__=n,i.u=u,r.__H.__h.push(i))}function F(n,u){var i=y(t++,4);!a.__s&&E(i.__H,u)&&(i.__P=!1,i.__=n,i.u=u,r.__h.push(i))}function T(n){return f=5,b(function(){return{current:n}},[])}function q(n,t,r){f=6,F(function(){if("function"==typeof n){var r=n(t());return function(){n(null),r&&"function"==typeof r&&r()}}if(n)return n.current=t(),function(){return n.current=null}},null==r?r:r.concat(n))}function b(n,r){var u=y(t++,7);return E(u.__H,r)&&(u.__=n(),u.__H=r),u.__}function j(n,t){return f=8,b(function(){return n},t)}function w(n){var u=r.context[n.__c],i=y(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function x(n,t){a.useDebugValue&&a.useDebugValue(t?t(n):n)}function O(n){var u=y(t++,10),i=d();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function P(){var n=y(t++,11);if(!n.__){for(var u=r.__v;!u.__m&&u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function g(){var n;do{for(;n=e.shift();)try{C(n)}catch(t){a.__e(t,{__:(n=n.__P)&&n.__v})}for(;n=c.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(C),t.__h.some(D),t.__h=[]}catch(r){t.__h=[],a.__e(r,n.__v)}}}while(e.length)}a.__b=function(n){r=null,v&&v(n)},a.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t)},a.__r=function(n){l&&l(n),t=0;var i=(r=n.__c).__H;i&&(u==r?r.__h=[]:(i.__h.some(C),i.__h.some(D),t=0),i.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})),u=r},a.diffed=function(n){m&&m(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&B(c.push(t)),t.__H.__.some(function(n){n.u&&(n.__H=n.u)})),u=r=null},a.__c=function(n,t){t.some(function(n){try{n.__h.some(C),n.__h=n.__h.filter(function(n){return!n.__||D(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],a.__e(r,n.__v)}}),s&&s(n,t)},a.unmount=function(n){h&&h(n);var t,r,u=n.__c;u&&u.__H&&(u.__H.__.some(function(u){try{if(u.__P&&u.__c){if(void 0===r){for(r=n.__;r&&(!r.__c||!r.__c.__P);)r=r.__;r=r&&r.__c}u.__P=r,B(e.push(u))}else C(u)}catch(n){t=n}}),u.__H=void 0,t&&a.__e(t,u.__v))};var k="function"==typeof requestAnimationFrame;function z(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r))}function B(n){1!=n&&i==a.requestAnimationFrame||((i=a.requestAnimationFrame)||z)(g)}function C(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function D(n){var t=r;n.__c=n.__(),r=t}function E(n,t){return!n||n.length!=t.length||t.some(function(t,r){return!o(t,n[r])})}function G(n,t){return"function"==typeof t?t(n):t}export{j as useCallback,w as useContext,x as useDebugValue,A as useEffect,O as useErrorBoundary,P as useId,q as useImperativeHandle,F as useLayoutEffect,b as useMemo,_ as useReducer,T as useRef,d as useState};
//# sourceMappingURL=hooks.mjs.map
-2
View File
@@ -1,2 +0,0 @@
import{options as r,Fragment as t}from"preact";export{Fragment}from"preact";var e=/["&<]/;function n(r){if(!r.length||!e.test(r))return r;for(var t=0,n=0,o="",f="";n<r.length;n++){switch(r.charCodeAt(n)){case 34:f="&quot;";break;case 38:f="&amp;";break;case 60:f="&lt;";break;default:continue}n!=t&&(o+=r.slice(t,n)),o+=f,t=n+1}return n!=t&&(o+=r.slice(t,n)),o}var o=0,f=Array.isArray;function u(t,e,n,f,u,i){e||(e={});var a,c,l=e;if("ref"in l&&"function"!=typeof t)for(c in l={},e)"ref"==c?a=e[c]:l[c]=e[c];var p={type:t,props:l,key:n,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--o,__i:-1,__u:0};return(u||i)&&(p.__source=u,p.__self=i),r.vnode&&r.vnode(p),p}function i(r){var e=u(t,{tpl:r,exprs:[].slice.call(arguments,1)});return e.key=e.__v,e}var a={},c=/[A-Z]/g;function l(t,e){if(r.attr){var o=r.attr(t,e);if("string"==typeof o)return o}if(e=function(r){return null!=r&&"object"==typeof r&&"function"==typeof r.valueOf?r.valueOf():r}(e),"ref"==t||"key"==t)return"";if("style"==t&&"object"==typeof e){var f="";for(var u in e){var i=e[u];null!=i&&""!==i&&(f=f+("-"==u[0]?u:a[u]||(a[u]=u.replace(c,"-$&").toLowerCase()))+":"+i+";")}return t+'="'+n(f)+'"'}return null==e||!1===e||"function"==typeof e||"object"==typeof e?"":!0===e?t:t+'="'+n(""+e)+'"'}function p(r){if(null==r||"boolean"==typeof r||"function"==typeof r)return null;if("object"==typeof r){if(void 0===r.constructor)return r;if(f(r)){for(var t=0;t<r.length;t++)r[t]=p(r[t]);return r}}return n(""+r)}export{u as jsx,l as jsxAttr,u as jsxDEV,p as jsxEscape,i as jsxTemplate,u as jsxs};
//# sourceMappingURL=jsxRuntime.mjs.map
-2
View File
@@ -1,2 +0,0 @@
var n,t,i,r,u,f,o,e,l,c,a,s,h={},p=[],v=/^m(i|n|o|s|text|space)$/,y=Array.isArray,d=p.slice,w=Object.assign;function _(n){n&&n.parentNode&&n.remove()}function g(n,t,i){var r,u,f,o={},e=arguments.length;for(f in t)"key"==f?r=t[f]:"ref"==f&&"function"!=typeof n?u=t[f]:o[f]=t[f];return e>2&&(o.children=e>3?d.call(arguments,2):i),b(n,o,r,u,null)}function k(n,t,i){var r,u,f,o=w({},n.props),e=arguments.length;for(f in t)"key"==f?r=t[f]:"ref"==f&&"function"!=typeof n.type?u=t[f]:o[f]=t[f];return e>2&&(o.children=e>3?d.call(arguments,2):i),b(n.type,o,void 0!==r?r:n.key,void 0!==u?u:n.ref,null)}function b(i,r,u,f,o){var e={type:i,props:r,key:u,ref:f,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&n.vnode&&n.vnode(e),e}function m(){return{current:null}}function M(n){return n.children}function $(n,t){this.props=n,this.context=t,this.__g=0}function x(n,t){if(null==t)return n.__?x(n.__,n.__i+1):null;for(var i;t<n.__k.length;t++)if((i=n.__k[t])&&i.__e)return i.__e;return"function"!=typeof n.type||n.props.__P?null:x(n)}function S(n){if((n=n.__)&&n.__c&&!n.props.__P)return n.__e=null,n.__k.some(function(t){return t&&(n.__e=t.__e)}),S(n)}function C(t){(8&t.__g||!(t.__g|=8)||!r.push(t)||f++)&&u==n.debounceRendering||((u=n.debounceRendering)||queueMicrotask)(j)}function j(){var t,i,u,e,l,c,a,s,h;try{for(i=1;r.length;)r.length>i&&r.sort(o),t=r.shift(),i=r.length,8&t.__g&&(e=void 0,l=void 0,c=(l=(u=t).__v).__e,a=[],s=[],(h=u.__P)&&((e=w({constructor:void 0},l)).__v=l.__v+1,n.vnode&&n.vnode(e),N(h,e,l,u.__n,h.namespaceURI,32&l.__u?[c]:null,a,c||x(l),32&l.__u,s),e.__v=l.__v,e.__.__k[e.__i]=e,z(a,e,s),l.__=l.__e=null,e.__e!=c&&S(e)))}finally{r.length=f=0}}function L(n,t,i,r,u,f,o,e,l,c,a){var s,v,y,d,w,_,g=r.__k||p,k=t.length;for(l=H(i,t,g,l,k),s=0;s<k;s++)null!=(y=i.__k[s])&&(v=~y.__i&&g[y.__i]||h,y.__i=s,_=N(n,y,v,u,f,o,e,l,c,a),d=y.__e,(v.ref!=y.ref||8&v.__u)&&(v.ref&&D(v.ref,null,y,v),y.ref&&a.push(y.ref,y.__c||d,y)),w=w||d,4&y.__u?(l=I(y,l,n,!v.__v),v.__e&&(v.__e=null)):"function"==typeof y.type&&void 0!==_?l=_:d&&(l=d.nextSibling),y.__u&=-7);return i.__e=w,l}function H(n,t,i,r,u){var f,o,e,l,c,a,s,h,p,v,d=i.length,w=d,_=0,g=!1,k=n.__k=Array(u);for(f=0;f<u;f++)null!=(o=t[f])&&"boolean"!=typeof o&&"function"!=typeof o?("object"!=typeof o||o.constructor==String?o=k[f]=b(null,o):y(o)?o=k[f]=b(M,{children:o}):void 0===o.constructor&&o.__b?o=k[f]=b(o.type,o.props,o.key,o.ref,o.__v):k[f]=o,l=f+_,o.__=n,o.__b=n.__b+1,e=null,~(c=o.__i=O(o,i,l,w))&&(w--,(e=i[c])&&(e.__u|=2)),e&&e.__v?(o.__u|=2,c==l-1?_--:c==l+1?_++:c!=l&&(c>l?_--:_++,g=!0)):(~c||(u>d?_--:u<d&&_++),"function"!=typeof o.type&&(o.__u|=4))):k[f]=null;if(g){for(a=[],s=[],f=0;f<u;f++)if((o=k[f])&&2&o.__u){for(h=0,p=a.length;h<p;)a[v=h+p>>1]<o.__i?h=v+1:p=v;a[h]=o.__i,s[f]=h+1}for(_=a.length;f--;)s[f]&&(s[f]==_?_--:k[f].__u|=4)}if(w)for(f=0;f<d;f++)!(e=i[f])||2&e.__u||(e.__e==r&&(r=x(e)),E(e,e));return r}function I(n,t,i,r){var u,f,o;if("function"==typeof n.type){if(n.props.__P)return t;if(u=n.__k)for(f=0;f<u.length;f++)u[f]&&(u[f].__=n,t=I(u[f],t,i,!1));return t}for(t&&!t.parentNode&&(t=x(n))&&!t.parentNode&&(t=null),o=t;o&&8==o.nodeType;)o=o.nextSibling;for(n.__e!=o&&(!r&&i.moveBefore&&n.__e.parentNode?i.moveBefore(n.__e,t):i.insertBefore(n.__e,t||null)),t=n.__e;(t=t&&t.nextSibling)&&8==t.nodeType;);return t}function A(n,t){return t=t||[],null!=n&&"boolean"!=typeof n&&(y(n)?n.some(function(n){A(n,t)}):t.push(n)),t}function O(n,t,i,r){var u,f,o,e=n.key,l=n.type,c=t[i],a=c&&!(2&c.__u);if(null===c&&null==e||a&&e==c.key&&l==c.type)return i;if(r>(a?1:0))for(u=i-1,f=i+1;u>=0||f<t.length;)if((c=t[o=u>=0?u--:f++])&&!(2&c.__u)&&e==c.key&&l==c.type)return o;return-1}function P(n,t,i){null==i&&(i=""),"-"==t[0]?n.setProperty(t,i):n[t]=i}function T(n,t,i,r,u){var f,o;n:if("style"==t)if("string"==typeof i)n.style.cssText=i;else{if("string"==typeof r&&(n.style.cssText=r=""),r)for(t in r)i&&t in i||P(n.style,t,"");if(i)for(t in i)r&&i[t]==r[t]||P(n.style,t,i[t])}else if("o"==t[0]&&"n"==t[1])(n.__e||(n.__e={}))[t]=i,i&&r||(o=a[t]||(a[t]=q(t)),(n.__a||(n.__a={}))[t]=c,f=t!=(t=t.replace(l,"$1")),(t=t.slice(2))[0]<"a"&&(t=t.toLowerCase()),i?n.addEventListener(t,o,f):n.removeEventListener(t,o,f));else{if("http://www.w3.org/2000/svg"==u)t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=t&&"height"!=t&&"href"!=t&&"list"!=t&&"form"!=t&&"tabIndex"!=t&&"download"!=t&&"rowSpan"!=t&&"colSpan"!=t&&"role"!=t&&"popover"!=t&&t in n)try{n[t]=null==i?"":i;break n}catch(n){}"function"==typeof i||(null==i||!1===i&&"-"!=t[4]?n.removeAttribute(t):n.setAttribute(t,"popover"==t&&1==i?"":i))}}function q(t){return function(i){if(this.__e){var r=this.__e[t];if(null==i[e])i[e]=c++;else if(i[e]<this.__a[t])return;return r(n.event?n.event(i):i)}}}function N(t,i,r,u,f,o,e,l,c,a){var s,h,v,d,g,k,b,m,S,C,j,H,I,A,O,P,T,q,N,z,D=i.type;if(void 0!==i.constructor)return null;if(128&r.__u&&(c=32&r.__u,s=r.__c.__z)){if(i.__u|=c,h=o=[],8==s.nodeType)for(v=1,d=s.Line truncated
//# sourceMappingURL=preact.mjs.map
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2022-present Preact Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-1
View File
@@ -1 +0,0 @@
const i=Symbol.for("preact-signals");function t(){if(e>1){e--;return}let i,t=!1;!function(){let i=f;f=void 0;while(void 0!==i){const t=i.S;if(t.v===i.v)for(let n=t.t;void 0!==n;n=n.x)if(n.i===i.i)n.i=t.i;i=i.o}}();while(void 0!==h){let n=h;h=void 0;c++;while(void 0!==n){const o=n.u;n.u=void 0;n.f&=-3;if(!(8&n.f)&&w(n))try{n.c()}catch(n){if(!t){i=n;t=!0}}n=o}}c=0;e--;if(t)throw i}function n(i){if(e>0)return i();d=++u;e++;try{return i()}finally{t()}}let o,s,h;function r(i){const t=o,n=s;o=void 0;s=void 0;try{return i()}finally{o=t;s=n}}let f,e=0,c=0,u=0,d=0,v=0;function l(i){if(void 0===o)return;let t=i.n;if(void 0===t||t.t!==o){t={i:0,S:i,p:o.s,n:void 0,t:o,e:void 0,x:void 0,r:t};if(void 0!==o.s)o.s.n=t;o.s=t;i.n=t;if(32&o.f)i.S(t);return t}else if(-1===t.i){t.i=0;if(void 0!==t.n){t.n.p=t.p;if(void 0!==t.p)t.p.n=t.n;t.p=o.s;t.n=void 0;o.s.n=t;o.s=t}return t}}function a(i,t){this.v=i;this.i=0;this.n=void 0;this.t=void 0;this.l=0;this.W=null==t?void 0:t.watched;this.Z=null==t?void 0:t.unwatched;this.name=null==t?void 0:t.name}a.prototype.brand=i;a.prototype.h=function(){return!0};a.prototype.S=function(i){const t=this.t;if(t!==i&&void 0===i.e){i.x=t;this.t=i;if(void 0!==t)t.e=i;else r(()=>{var i;null==(i=this.W)||i.call(this)})}};a.prototype.U=function(i){if(void 0!==this.t){const t=i.e,n=i.x;if(void 0!==t){t.x=n;i.e=void 0}if(void 0!==n){n.e=t;i.x=void 0}if(i===this.t){this.t=n;if(void 0===n)r(()=>{var i;null==(i=this.Z)||i.call(this)})}}};a.prototype.subscribe=function(i){return j(()=>{const t=this.value;r(()=>i(t))},{name:"sub"})};a.prototype.valueOf=function(){return this.value};a.prototype.toString=function(){return this.value+""};a.prototype.toJSON=function(){return this.value};a.prototype.peek=function(){return r(()=>this.value)};Object.defineProperty(a.prototype,"value",{get(){const i=l(this);if(void 0!==i)i.i=this.i;return this.v},set(i){if(i!==this.v){if(c>100)throw new Error("Cycle detected");!function(i){if(0!==e&&0===c)if(i.l!==d){i.l=d;f={S:i,v:i.v,i:i.i,o:f}}}(this);this.v=i;this.i++;v++;e++;try{for(let i=this.t;void 0!==i;i=i.x)i.t.N()}finally{t()}}}});function y(i,t){return new a(i,t)}function w(i){for(let t=i.s;void 0!==t;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function _(i){for(let t=i.s;void 0!==t;t=t.n){const n=t.S.n;if(void 0!==n)t.r=n;t.S.n=t;t.i=-1;if(void 0===t.n){i.s=t;break}}}function b(i){let t,n=i.s;while(void 0!==n){const i=n.p;if(-1===n.i){n.S.U(n);if(void 0!==i)i.n=n.n;if(void 0!==n.n)n.n.p=i}else t=n;n.S.n=n.r;if(void 0!==n.r)n.r=void 0;n=i}i.s=t}function p(i,t){a.call(this,void 0,t);this.x=i;this.s=void 0;this.g=v-1;this.f=4}p.prototype=new a;p.prototype.h=function(){this.f&=-3;if(1&this.f)return!1;if(32==(36&this.f))return!0;this.f&=-5;if(this.g===v)return!0;this.g=v;this.f|=1;if(this.i>0&&!w(this)){this.f&=-2;return!0}const i=o;try{_(this);o=this;const i=this.x();if(16&this.f||this.v!==i||0===this.i){this.v=i;this.f&=-17;this.i++}}catch(i){this.v=i;this.f|=16;this.i++}o=i;b(this);this.f&=-2;return!0};p.prototype.S=function(i){if(void 0===this.t){this.f|=36;for(let i=this.s;void 0!==i;i=i.n)i.S.S(i)}a.prototype.S.call(this,i)};p.prototype.U=function(i){if(void 0!==this.t){a.prototype.U.call(this,i);if(void 0===this.t){this.f&=-33;for(let i=this.s;void 0!==i;i=i.n)i.S.U(i)}}};p.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(let i=this.t;void 0!==i;i=i.x)i.t.N()}};Object.defineProperty(p.prototype,"value",{get(){if(1&this.f)throw new Error("Cycle detected");const i=l(this);this.h();if(void 0!==i)i.i=this.i;if(16&this.f)throw this.v;return this.v}});function g(i,t){return new p(i,t)}function S(i){const n=i.m;i.m=void 0;if("function"==typeof n){e++;const s=o;o=void 0;try{n()}catch(t){i.f&=-2;i.f|=8;m(i);throw t}finally{o=s;t()}}}function m(i){for(let t=i.s;void 0!==t;t=t.n)t.S.U(t);i.x=void 0;i.s=void 0;S(i)}function x(i){if(o!==this)throw new Error("Out-of-order effect");b(this);o=i;this.f&=-2;if(8&this.f)m(this);t()}function E(i,t){this.x=i;this.m=void 0;this.s=void 0;this.u=void 0;this.f=32;this.name=null==t?void 0:t.name;if(s)s.push(this)}E.prototype.c=function(){const i=this.S();try{if(8&this.f)return;if(void 0===this.x)return;const t=this.x();if("function"==typeof t)this.m=t}finally{i()}};E.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1;this.f&=-9;S(this);_(this);e++;const i=o;o=this;return x.bind(this,i)};E.prototype.N=function(){if(!(2&this.f)){this.f|=2;this.u=h;h=this}};E.prototype.d=function(){this.f|=8;if(!(1&this.f))m(this)};E.prototype.dispose=function(){this.d()};function j(i,t){const n=new E(i,t);try{n.c()}catch(i){n.d();throw i}const o=n.d.bind(n);o[Symbol.dispose]=o;return o}function C(i){return function(...t){return n(()=>r(()=>i.apply(this,t)))}}const O=i=>{for(const t in i){const n=i[t];if("function"==typeof n)i[t]=C(n);else if("object"==typeof n&&null!==n&&!("brand"in n))O(n)}};function k(i){return function(...t){let n,o;const h=function(){let i=s;s=[];return function(){let t=s;if(s&&i)i=i.concat(sLine truncated
-1
View File
@@ -1 +0,0 @@
import{Component as t,options as i,isValidElement as n,Fragment as e}from"preact";import{useMemo as o,useRef as f,useEffect as r}from"preact/hooks";import{effect as u,Signal as c,computed as s,signal as l,batch as a}from"@preact/signals-core";export{Signal,action,batch,computed,createModel,effect,signal,untracked}from"@preact/signals-core";const h="undefined"!=typeof window&&!!window.__PREACT_SIGNALS_DEVTOOLS__;let d,p,m,v=[],_=[];u(function(){d=this.N})();function g(t,n){i[t]=n.bind(null,i[t]||(()=>{}))}function b(t){if(m){const t=m;m=void 0;t()}m=t&&t.S()}function y({data:t}){const i=useSignal(t);i.name="ReactiveDom";i.value=t;const[e,f]=o(()=>{let t=this,e=this.__v;while(e=e.__)if(e.__c){e.__c.__$f|=4;break}const o=s(()=>{let t=i.value.value;return 0===t?0:!0===t?"":t||""}),f=s(()=>!Array.isArray(o.value)&&!n(o.value)),r=u(function(){this.N=F;if(f.value){const i=o.value;if(t.__v&&t.__v.__e&&3===t.__v.__e.nodeType)t.__v.__e.data=i}}),c=this.__$u.d;this.__$u.d=function(){r();c.call(this)};return[f,o]},[]);return e.value?f.peek():f.value}y.displayName="ReactiveTextNode";Object.defineProperties(c.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:y},props:{configurable:!0,get(){const t=this;return{data:{get value(){return t.value}}}}},__b:{configurable:!0,value:1}});g("__b",(t,i)=>{b();p=void 0;if("string"==typeof i.type){let t,n=i.props;for(let e in n){if("children"===e)continue;let o=n[e];if(o instanceof c){if(!t)i.__np=t={};t[e]=o;n[e]=o.peek()}}}t(i)});g("__r",(t,i)=>{t(i);if(i.type!==e){b();let t,n=i.__c;if(n){n.__$f&=-2;t=n.__$u;if(void 0===t)n.__$u=t=function(t,i){let n;u(function(){n=this},{name:i});n.c=t;return n}(function(t){return function(){var i;if(h)null==(i=this.y)||i.call(this);t.__$f|=1;t.setState({})}}(n),"function"==typeof i.type?i.type.displayName||i.type.name:"")}p=n;b(t)}});g("__e",(t,i,n,e)=>{b();p=void 0;t(i,n,e)});g("diffed",(t,i)=>{b();p=void 0;let n;if("string"==typeof i.type&&(n=i.__e)){let t=i.__np,e=i.props,o=n.U;if(o)for(let i in o){let n=o[i];if(!(void 0===n||t&&i in t)){n.d();o[i]=void 0}}if(t){if(!o){o={};n.U=o}for(let i in t){let f=o[i],r=t[i];if(void 0===f){f=w(n,i,r,e);o[i]=f}else f.o(r,e)}}}t(i)});function w(t,i,n,e){const o=i in t&&void 0===t.ownerSVGElement,f=l(n);return{o:(t,i)=>{f.value=t;e=i},d:u(function(){this.N=F;const n=f.value.value;if(e[i]!==n){e[i]=n;if(o)t[i]=n;else if(null!=n&&(!1!==n||"-"===i[4]))t.setAttribute(i,n);else t.removeAttribute(i)}})}}g("unmount",(t,i)=>{if("string"==typeof i.type){let t=i.__e;if(t){const i=t.U;if(i){t.U=void 0;for(let t in i){let n=i[t];if(n)n.d()}}}let n=i.__np;if(n){let t=i.props;for(let i in n)t[i]=n[i]}i.__np=void 0}else{let t=i.__c;if(t){t.__$f|=8;const i=t.__$u;if(i){t.__$u=void 0;i.d()}}}t(i)});g("__h",(t,i,n,e)=>{if(e<3)i.__$f|=2;t(i,n,e)});t.prototype.shouldComponentUpdate=function(t,i){if(this.__R)return!0;const n=this.__$u,e=n&&void 0!==n.s;for(let t in i)return!0;if(this.__f||"boolean"==typeof this.u&&!0===this.u){const t=2&this.__$f;if(!(e||t||4&this.__$f))return!0;if(1&this.__$f)return!0}else{if(!(e||4&this.__$f))return!0;if(3&this.__$f)return!0}for(let i in t)if("__source"!==i&&t[i]!==this.props[i])return!0;for(let i in this.props)if(!(i in t))return!0;return!1};function useSignal(t,i){return o(()=>l(t,i),[])}function useComputed(t,i){const n=f(t);n.current=t;p.__$f|=4;return o(()=>s(()=>n.current(),i),[])}const k="undefined"==typeof requestAnimationFrame?setTimeout:function(t){const i=()=>{clearTimeout(n);cancelAnimationFrame(e);t()},n=setTimeout(i,35),e=requestAnimationFrame(i)},q=t=>{queueMicrotask(()=>{queueMicrotask(t)})};function A(){a(()=>{let t;while(t=v.shift())d.call(t)})}function T(){if(1===v.push(this))(i.requestAnimationFrame||k)(A)}function x(){a(()=>{let t;while(t=_.shift())d.call(t)})}function F(){if(1===_.push(this))(i.requestAnimationFrame||q)(x)}function useSignalEffect(t,i){const n=f(t);n.current=t;const e=p;r(()=>u(function(){this.N=T;if(!(e&&8&e.__$f))return n.current()},i),[])}function M(t){const i=o(()=>t(),[]);r(()=>i[Symbol.dispose],[i]);return i}export{useComputed,M as useModel,useSignal,useSignalEffect};//# sourceMappingURL=signals.mjs.map
-32
View File
@@ -1,32 +0,0 @@
// 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>
);
});
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
Moon Miners sound effects
Clips in this folder are built (trimmed, pitched down, filtered, layered with synth tones
made in SoX, loudness-matched, re-encoded) from Kenney's audio packs, www.kenney.nl.
License: Creative Commons Zero (CC0), http://creativecommons.org/publicdomain/zero/1.0/
(credit not required, given anyway).
nav.wav Interface Sounds switch_007
toggle.wav Interface Sounds switch_005
open.wav Sci-Fi Sounds doorOpen_001 + synth servo tone
close.wav Sci-Fi Sounds doorClose_001 + synth servo tone
buy.mp3 Sci-Fi Sounds impactMetal_000 + Impact Sounds impactMetal_medium_001
+ synth beep
upgrade.mp3 Sci-Fi Sounds forceField_000 + Impact Sounds impactMetal_heavy_002
+ synth sweep
moon.mp3 Sci-Fi Sounds lowFrequency_explosion_001 + forceField_003 + synth ping
deploy.mp3 Impact Sounds impactMining_004 + Sci-Fi Sounds engineCircular_002
refine.mp3 Sci-Fi Sounds forceField_001 + computerNoise_000
collect.mp3 Sci-Fi Sounds doorOpen_002 + impactMetal_004 + synth beeps
modal.wav, confirm.mp3, error.wav and ready.wav are synthesized with SoX (no samples).
Packs: https://kenney.nl/assets/interface-sounds, https://kenney.nl/assets/sci-fi-sounds,
https://kenney.nl/assets/impact-sounds
The claim whir, counter ticks, claim blips, credit blips and the moon reveal sounds are
synthesized in the browser with ZzFX by Frank Force (MIT, web/static/vendor/zzfx/LICENSE),
https://github.com/KilledByAPixel/ZzFX
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -15,7 +15,7 @@ function init() {
const clone = (v) => JSON.parse(JSON.stringify(v));
const saved = data.saved;
const base = data.base;
let draft = clone(saved);
let draft = clone(data.draft ?? saved); // data.draft: dev-only ?draft= patch
// ---------------------------------------------------------------- helpers
@@ -881,11 +881,34 @@ function init() {
}
}
// The sim timeline runs on demand; its result stays while the draft is the one it ran on.
let simRun = { raw: null, html: "" };
$("#tab-analysis").addEventListener("click", async (event) => {
const button = event.target.closest("[data-sim-run]");
if (!button) return;
const box = button.closest("[data-sim]");
const raw = JSON.stringify(draft);
box.innerHTML = '<p class="muted">Running the sim…</p>';
try {
const res = await post("/utils/economy/sim", { raw });
const html = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
simRun = { raw, html };
if (raw === JSON.stringify(draft)) box.innerHTML = html;
} catch (err) {
box.innerHTML = `<p class="eco-bad">Sim failed: ${esc(err.message)}</p>`;
}
});
async function analyze() {
const pane = $("#tab-analysis");
try {
const res = await post("/utils/economy/analyze", { raw: JSON.stringify(draft) });
const raw = JSON.stringify(draft);
const res = await post("/utils/economy/analyze", { raw });
pane.innerHTML = await res.text();
const simBox = pane.querySelector("[data-sim]");
if (simBox && simRun.raw === raw) simBox.innerHTML = simRun.html;
else if (simBox && new URLSearchParams(location.search).has("sim")) simBox.querySelector("[data-sim-run]")?.click();
flags = JSON.parse(pane.querySelector(".eco-flags")?.textContent || "null") ?? {};
const errors = JSON.parse(pane.querySelector(".eco-errors")?.textContent || "null") ?? [];
mapErrors(errors);
-760
View File
@@ -1,760 +0,0 @@
// Game feel: effects for action results (display only; the server is the authority).
//
// Every action response carries a toast with data-fx="<action>" (or "error"). Before a
// response is swapped in we read the header's wallet (resources and ₥) and a few other
// 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. The Preact client (web/app/fx.js) reuses the exported pieces.
import * as sfx from "./sfx.js";
import { updateEstimate, setHero } from "./market.js";
const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// Theme colors (web/css/app.css tokens) for the canvas and inline styles, read again when
// the player switches theme (page.html dispatches mm:theme).
export const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
// A glow color for inline box-shadows: the token's channels, alpha scaled like the CSS glows.
export const glow = (name, alpha) => `rgb(${token(name + "-rgb", "95 212 255")} / ${alpha * (Number(token("--glow-k", "1")) || 0)})`;
let CYAN, CYAN_HI, GOLD, GOLD_HI, LOSS, FLASH, LIGHT;
function readTheme() {
CYAN = token("--cyan", "#5fd4ff");
CYAN_HI = token("--cyan-hi", "#b3ecff");
GOLD = token("--ready", "#ffd166");
GOLD_HI = token("--ready-hi", "#fff1c2");
LOSS = token("--loss", "#ff8a96");
FLASH = token("--flash", "#fff");
LIGHT = document.documentElement.dataset.theme === "light";
}
readTheme();
addEventListener("mm:theme", readTheme);
// ---------------------------------------------------------------- DOM reads
const parse = (text) => Number(text.replace(/[^0-9.-]/g, "")) || 0;
// A number mid count-up (or held at its old value) reads as the value the server sent;
// shownOf is what it shows now, where the next count starts.
const valueOf = (el) => parse(el?.fxFinal ?? el?.textContent ?? "");
const shownOf = (el) => parse(el?.textContent ?? "");
// Morph hook (main.js): a wallet number the server changed keeps showing the old value,
// with the new one in fxFinal, until play() counts it up (or down). Returns false to skip
// the morph of that element.
export function morphCount(old, next) {
if (!old.matches("#top .wallet .num, #top .balance")) return true;
if (old.fxFinal === undefined && old.textContent === next.textContent) return true;
old.fxFinal = next.textContent;
return false;
}
function readWallet() {
const top = document.getElementById("top");
const resources = {};
for (const chip of top?.querySelectorAll(".chip[title]") ?? []) {
const num = chip.querySelector(".num");
const dot = chip.querySelector(".dot");
resources[chip.title] = {
value: valueOf(num),
shown: shownOf(num),
num,
color: dot ? getComputedStyle(dot).backgroundColor : CYAN,
};
}
const balance = top?.querySelector(".balance");
return { resources, balance, credits: valueOf(balance), creditsShown: shownOf(balance) };
}
// Drill rows in the side panel and the wells table, keyed (by their text without the
// countdowns) so new ones can be found.
const drillRows = () => document.querySelectorAll("#side .line, #main tbody tr");
const drillKey = (el) => el.textContent.replace(/\d+:\d\d(:\d\d)?( left)?/g, "").replace(/\s+/g, " ").trim();
function drillKeys() {
const keys = new Map();
for (const el of drillRows()) {
const key = drillKey(el);
keys.set(key, (keys.get(key) ?? 0) + 1);
}
return keys;
}
// ---------------------------------------------------------------- htmx hooks
let origin = null; // where the click happened: {x, y, box} (the button is replaced by then)
let before = null;
document.body.addEventListener("htmx:beforeRequest", (event) => {
if (event.detail.requestConfig?.verb !== "post") return;
let el = event.detail.elt;
if (el.tagName === "FORM") el = el.querySelector("button:focus, button:not([type=button])") ?? el;
const r = el.getBoundingClientRect();
const boxEl = el.closest(".card, .line, tr, .moon, .heading") ?? el;
origin = { x: r.left + r.width / 2, y: r.top + r.height / 2, box: boxEl.getBoundingClientRect(), card: !!el.closest(".card") };
});
document.body.addEventListener("htmx:beforeSwap", () => {
before = { wallet: readWallet(), drills: drillKeys(), moons: moonRows() };
});
// After the regions are swapped in (once per response).
document.body.addEventListener("htmx:afterSwap", () => {
if (!before) return;
const prev = before;
before = null;
const toasts = document.querySelectorAll("#toasts .toast[data-fx]:not([data-fx-done])");
const toast = toasts[toasts.length - 1];
toast?.setAttribute("data-fx-done", "");
const at = origin;
origin = null;
play(toast?.dataset.fx ?? "", toast, prev, at);
});
// ---------------------------------------------------------------- orchestration
function play(kind, toast, prev, at) {
const now = readWallet();
const gains = [];
for (const [name, r] of Object.entries(now.resources)) {
const old = prev.wallet.resources[name];
if (!old || old.value === r.value) continue;
const delta = r.value - old.value;
if (delta > 0) gains.push({ name, delta, from: old.shown, ...r });
else countTo(r.num, old.shown, r.value, { delay: 0, down: true });
}
const credits = now.credits - prev.wallet.credits;
// A market sale plays on the sell ticket, and its credits fly out of the payout number.
const sold = kind === "sell" ? saleEffect(Number(toast?.dataset.credits)) : null;
const from = sold ?? at ?? centerOf(document.getElementById("main"));
// A bought moon is revealed first (reveal.js); its effects wait until the player is back.
const revealing = kind === "buy-moon" && !!document.querySelector("#reveal[data-moon]");
const afterReveal = (fn) => (revealing ? document.addEventListener("mm:revealed", fn, { once: true }) : fn());
if (kind === "claim" && gains.length) {
toast?.style.setProperty("--fx-color", gains[0].color);
sfx.claim();
burst(from.x, from.y, gains.map((g) => g.color), 46);
gains.forEach((g, i) => {
floatText(`+${formatMoney(g.delta)} ${g.name}`, from.x, from.y - 10 - i * 30, g.color, i * 90);
const to = centerOf(g.num.closest(".chip"));
stream(from, to, g.color, 14, 120 + i * 90, () => {
sfx.arrive(i);
countTo(g.num, g.from, g.value, { up: true });
burst(to.x, to.y, [g.color], 14, 0.5);
});
});
} else {
gains.forEach((g, i) => {
if (kind === "collect") sfx.arrive(i);
countTo(g.num, g.from, g.value, { up: true });
});
}
if (credits > 0 && now.balance) {
const to = centerOf(now.balance);
if (!sold) floatText(`+${currency}${formatMoney(credits)}`, from.x, from.y - 10, GOLD, 0, true);
burst(from.x, from.y, [GOLD, GOLD_HI], 30);
coins(from, to, 10, () => {
sfx.cash(credits);
countTo(now.balance, prev.wallet.creditsShown, now.credits, { up: true, prefix: currency });
glint(now.balance);
burst(to.x, to.y, [GOLD], 16, 0.6);
});
} else if (credits < 0 && now.balance) {
afterReveal(() => {
countTo(now.balance, prev.wallet.creditsShown, now.credits, { down: true, prefix: currency });
const b = centerOf(now.balance);
floatText(`−${currency}${formatMoney(-credits)}`, b.x, b.y + 22, LOSS, 0, false, 1);
});
}
if (["buy-tool", "upgrade", "upgrade-batch", "buy-moon", "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]");
if (moon) {
afterReveal(() => {
const el = document.querySelector("#moonlist .moon[aria-current]");
if (!el) return;
mark(el, "fx-new");
sweep(el);
});
} else if (at) sweep(at.box);
}
if (kind === "stake") {
for (const el of newDrills(prev.drills)) mark(el, "fx-new");
if (at) burst(at.x, at.y, [CYAN, CYAN_HI], 26);
}
// A moon sold: an extra gold ring where it was offered (the credits fly in above).
if (kind === "sell-moon" && at) {
burst(at.x, at.y, [GOLD, GOLD_HI, CYAN_HI], 60, 1.2);
sweep(at.box);
}
if (kind === "error" && at) shake(at);
// A claim mined a moon out: its list entry leaves for the Past tab.
if (toast?.dataset.finished) {
const ids = toast.dataset.finished.split(",");
ids.forEach((id, i) => moonOut(id, prev.moons.get(id), i * 160));
sfx.minedOut(450);
mark(document.querySelector("#moonlist [data-past-tab]"), "fx-past");
}
// Claim and sell sound as their numbers arrive (above).
const sound = {
"buy-tool": "buy",
hire: "confirm",
"seat-advisor": "upgrade",
"unseat-advisor": "toggle",
"swap-advisors": "toggle",
"dismiss-advisor": "powerdown",
"buy-seat": "buy",
"buy-slot": "buy",
"upgrade-batch": "upgrade",
upgrade: "upgrade",
"buy-moon": "moon",
"sell-moon": "shimmer",
stake: "deploy",
refine: "refine",
collect: "collect",
error: "error",
}[kind];
if (sound && !revealing) sfx.play(sound);
}
// A Claim button enabled by its countdown (main.js) as its drill finishes. Several buttons
// can fire at once (header, moon, well); the sound plays once (sfx.js spaces repeats).
document.addEventListener("mm:ready", (event) => {
sfx.play("ready");
mark(event.target, "fx-ready-pop");
const c = centerOf(event.target);
burst(c.x, c.y, [GOLD, GOLD_HI], 20, 0.7);
});
// A newly revealed tab's reticle locking on (main.js): a quick blip as the brackets snap in.
document.addEventListener("mm:lockon", (event) => {
const delay = event.target.classList.contains("is-revealed") ? 750 : 300;
sfx.play("toggle", { rate: 1.35, delay });
sfx.play("tick", { rate: 1.6, delay: delay + 70 });
});
function newDrills(prevKeys) {
const seen = new Map(prevKeys);
const added = [];
for (const el of drillRows()) {
const key = drillKey(el);
const n = seen.get(key) ?? 0;
if (n > 0) seen.set(key, n - 1);
else added.push(el);
}
return added;
}
// ---------------------------------------------------------------- the sell ticket
// A sale on the market's sell ticket: the payout number (now what the server says the sale
// earned) swells, then drains to 0 as the coins fly to the balance, while the card powers
// down: a noise shake that dies out, a flicker and scan lines. Then the ticket's estimate
// comes back. One at a time: a new sale, a request or touching the ticket ends it.
let sale = null;
function saleEffect(earned) {
sale?.cancel();
const form = document.querySelector("#main .mk-ticket");
const out = form?.querySelector("[data-est]");
if (!out || !(earned > 0)) return null;
form.elements.amount.value = ""; // sold (the morph keeps what was typed)
const from = centerOf(out);
// Fixed width while it counts, so the size doesn't change with the digits.
setHero(out, currency + formatFixed(earned, 2));
const text = (n) => (out.textContent = currency + formatFixed(n, 2));
let frame = 0;
let timer = 0;
let scan = null;
const cancel = () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
scan?.remove();
form.classList.remove("fx-selling");
form.style.removeProperty("transform");
form.style.removeProperty("filter");
out.style.removeProperty("transform");
out.style.removeProperty("--glow");
out.setAttribute("aria-live", "polite");
if (sale?.cancel === cancel) sale = null;
if (form.isConnected) updateEstimate(form);
};
sale = { cancel };
out.setAttribute("aria-live", "off"); // not every frame of the count
text(earned);
form.classList.add("fx-selling");
sfx.powerDown();
if (reduced()) {
timer = setTimeout(cancel, 1400);
return from;
}
scan = document.createElement("div");
scan.className = "fx-scan";
scan.setAttribute("aria-hidden", "true");
form.append(scan);
const SWELL = 260, DRAIN = 820, END = 1300;
// Up to 1.4x, less where the number already fills the ticket (phones), so it stays on screen.
const room = out.parentElement.clientWidth / Math.max(1, out.offsetWidth);
const peak = Math.min(0.4, Math.max(0.1, room * 1.1 - 1));
const seed = Math.random() * 1000;
let start = 0;
const step = (now) => {
if (!form.isConnected) return cancel();
start ||= now;
const t = now - start;
// The number: swells with a glow, then drains to 0 and goes back to size.
let scale, glow;
if (t < SWELL) {
const k = t / SWELL;
scale = 1 + peak * backOut(k);
glow = k;
text(earned);
} else {
const k = Math.min(1, (t - SWELL) / DRAIN);
scale = 1 + peak * (1 - easeInOut(k));
glow = 1 - k;
text(earned * (1 - k * k));
}
out.style.transform = `scale(${scale.toFixed(3)})`;
out.style.setProperty("--glow", glow.toFixed(3));
// The card: a shake from smooth noise, its strength decaying; a dip in brightness
// with a noisy flicker while the scan lines are up.
const amp = t < 120 ? (t / 120) * 10 : 10 * Math.exp(-(t - 120) / 380);
const n = t * 0.018 + seed;
const x = amp * noise(n);
const y = amp * 0.6 * noise(n + 57.3);
const r = amp * 0.12 * noise(n + 131.7);
const dim = t > 300 && t < 1100 ? Math.sin(((t - 300) / 800) * Math.PI) : 0;
const bright = 1 + 0.25 * (1 - Math.min(1, t / 200)) - 0.4 * dim + 0.18 * dim * noise(t * 0.06 + seed);
form.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) rotate(${r.toFixed(3)}deg) scale(${(1 - 0.012 * dim).toFixed(4)})`;
form.style.filter = `brightness(${bright.toFixed(3)}) saturate(${(1 - 0.5 * dim).toFixed(3)})`;
if (t >= END) return cancel();
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return from;
}
// Typing an amount, a quick button, or the next sale ends the effect first (capture: before
// market.js updates the estimate).
for (const type of ["input", "click"]) {
document.addEventListener(type, (e) => e.target.closest?.(".mk-ticket") && sale?.cancel(), true);
}
document.body.addEventListener("htmx:beforeRequest", (e) => {
if (e.detail.requestConfig?.verb === "post") sale?.cancel();
});
const easeInOut = (k) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
const backOut = (k) => 1 + 2.70158 * Math.pow(k - 1, 3) + 1.70158 * Math.pow(k - 1, 2);
// 1D gradient (Perlin) noise, about −1..1: smooth, unlike Math.random jitter.
function noise(x) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * f * (f * (f * 6 - 15) + 10);
return 2 * (gradient(i) * f * (1 - u) + gradient(i + 1) * (f - 1) * u);
}
function gradient(i) {
let h = Math.imul(i ^ 0x9e3779b9, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return (h & 0xffff) / 0x8000 - 1;
}
// ---------------------------------------------------------------- small effects
const currency = "₥";
// Same as formatMoney in views.go.
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 : "");
}
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 };
}
// The moon list entries before a swap, by moon id: a copy and the entry after it.
function moonRows() {
const rows = new Map();
for (const el of document.querySelectorAll("#moonlist .moon[data-id]")) {
rows.set(el.dataset.id, { el: el.cloneNode(true), next: el.nextElementSibling?.dataset.id });
}
return rows;
}
// A moon just mined out: a copy of its old entry greys out and collapses where it was in
// the Active list. If the list now shows Past moons, its new entry there lights up instead.
function moonOut(id, row, delay) {
const past = document.querySelector(`#moonlist .moon.past[data-id="${id}"]`);
if (past) return mark(past, "fx-new");
const list = document.getElementById("moonlist");
if (!row || !list || reduced()) return;
const { el } = row;
el.removeAttribute("href");
el.removeAttribute("hx-get");
el.removeAttribute("aria-current");
el.removeAttribute("data-id");
el.id = `gone-${id}`; // its own id: a morph doesn't match it to another entry
el.classList.add("fx-moon-out");
el.style.animationDelay = `${delay}ms`;
const next = (row.next && list.querySelector(`.moon[data-id="${row.next}"]`)) || list.querySelector(".new-moon");
list.insertBefore(el, next);
el.addEventListener("animationend", (e) => e.animationName === "fx-moon-out" && el.remove());
setTimeout(() => el.remove(), 3000 + delay);
}
// Restart a CSS animation class on an element.
export function mark(el, cls) {
if (!el) return;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
const done = (e) => {
if (e.target !== el || !e.animationName.startsWith("fx-")) return;
el.classList.remove(cls);
el.removeEventListener("animationend", done);
};
el.addEventListener("animationend", done);
}
// Count a number up (or down) to the value the server rendered (fxFinal, see morphCount;
// a response during the count updates it).
function countTo(el, from, to, { delay = 0, up = false, down = false, prefix = "" } = {}) {
if (!el) return;
// One count per element: a new one takes over from the number shown now.
cancelAnimationFrame(el.fxFrame);
el.fxFinal ??= el.textContent; // read by valueOf until the count ends
if (reduced()) {
el.textContent = el.fxFinal;
delete el.fxFinal;
mark(el, up ? "fx-gain" : "fx-loss");
return;
}
mark(el, up ? "fx-gain" : down ? "fx-loss" : "fx-gain");
// Fixed decimals while counting, so the width doesn't jump every frame.
const digits = Math.max(decimals(from), decimals(to));
const show = (n) => prefix + formatFixed(n, digits);
const ms = 750;
let start = 0;
el.textContent = show(from);
const step = (t) => {
if (!el.isConnected) return;
start ||= t + delay;
const k = Math.min(1, Math.max(0, (t - start) / ms));
const e = 1 - Math.pow(1 - k, 3);
if (k >= 1) {
el.textContent = el.fxFinal;
delete el.fxFinal;
return;
}
el.textContent = show(from + (to - from) * e);
el.fxFrame = requestAnimationFrame(step);
};
el.fxFrame = requestAnimationFrame(step);
}
const decimals = (n) => (formatMoney(n).split(".")[1] ?? "").length;
// formatMoney with a fixed number of decimals.
function formatFixed(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 : "");
}
function glint(el) {
mark(el, "fx-glint");
}
function shake(at) {
const el = document.elementFromPoint(at.x, at.y)?.closest(".btn");
if (el) mark(el, "fx-shake");
}
// A big number that pops out of a point and drifts up (dir -1) or down (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;
el.style.setProperty("--fx-color", color);
layer().append(el);
// Keep it on screen (buttons sit near the right edge).
const half = el.offsetWidth / 2 + 16;
el.style.left = `${Math.min(Math.max(x, half), innerWidth - half)}px`;
el.style.top = `${Math.max(y, 100)}px`;
const rise = 70 * dir;
const anim = reduced()
? el.animate([{ opacity: 0 }, { opacity: 1, offset: 0.2 }, { opacity: 1, offset: 0.7 }, { opacity: 0 }], {
duration: 1400,
delay,
fill: "both",
})
: el.animate(
[
{ opacity: 0, transform: "translate(-50%, -50%) scale(0.4)", filter: "blur(4px)" },
{ opacity: 1, transform: `translate(-50%, calc(-50% + ${rise * 0.25}px)) scale(1.25)`, filter: "blur(0)", offset: 0.15 },
{ opacity: 1, transform: `translate(-50%, calc(-50% + ${rise * 0.55}px)) scale(1)`, offset: 0.6 },
{ opacity: 0, transform: `translate(-50%, calc(-50% + ${rise}px)) scale(0.95)` },
],
{ duration: 1500, delay, easing: "cubic-bezier(.2,.7,.3,1)", fill: "both" }
);
anim.onfinish = () => el.remove();
}
// A holographic frame with a scan line that sweeps over a box (a store card, a row), or
// inside an element (it then follows the element as it moves, animates or scrolls).
function sweep(box) {
const el = document.createElement("div");
el.className = "fx-sweep";
if (box instanceof Element) {
el.classList.add("fx-sweep-in");
box.append(el);
} else {
Object.assign(el.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
layer().append(el);
}
el.addEventListener("animationend", (e) => e.target === el && e.animationName === "fx-sweep-frame" && el.remove());
}
// Ghost ₥ coins that arc from a point into the credits counter.
function coins(from, to, n, onArrive) {
if (reduced()) return onArrive();
let arrived = false;
for (let i = 0; i < n; i++) {
const el = document.createElement("div");
el.className = "fx-coin";
el.textContent = currency;
layer().append(el);
const a = Math.random() * Math.PI * 2;
const start = { x: from.x + Math.cos(a) * 24, y: from.y + Math.sin(a) * 14 };
const path = arc(start, to, (Math.random() - 0.5) * 0.9, 0.1);
const anim = el.animate(
path.map((p, k) => ({
transform: `translate(${p.x}px, ${p.y}px) translate(-50%, -50%) scale(${k === 0 ? 0.3 : 1 - (k / path.length) * 0.5})`,
opacity: k === 0 ? 0 : 1,
})),
{ duration: 650 + i * 45, delay: 120 + i * 35, easing: "cubic-bezier(.5,0,.75,.4)", fill: "both" }
);
anim.onfinish = () => {
el.remove();
if (!arrived) {
arrived = true;
onArrive();
}
};
}
}
// Points on a curve from a to b that bows sideways by `bend` (a share of the distance)
// and up by `lift`.
function arc(a, b, bend, lift = 0.25) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 1;
const c = {
x: (a.x + b.x) / 2 - (dy / dist) * bend * dist,
y: (a.y + b.y) / 2 + (dx / dist) * bend * dist - lift * dist,
};
const pts = [];
for (let i = 0; i <= 16; i++) {
const t = i / 16;
const u = 1 - t;
pts.push({ x: u * u * a.x + 2 * u * t * c.x + t * t * b.x, y: u * u * a.y + 2 * u * t * c.y + t * t * b.y });
}
return pts;
}
let fxLayer;
function layer() {
if (!fxLayer?.isConnected) {
fxLayer = document.createElement("div");
fxLayer.className = "fx-layer";
fxLayer.setAttribute("aria-hidden", "true");
document.body.append(fxLayer);
}
return fxLayer;
}
// ---------------------------------------------------------------- particles (canvas)
let canvas, ctx;
let particles = [];
let running = false;
function ensureCanvas() {
if (canvas?.isConnected) return;
canvas = document.createElement("canvas");
canvas.className = "fx-canvas";
canvas.setAttribute("aria-hidden", "true");
document.body.append(canvas);
ctx = canvas.getContext("2d");
resize();
}
function resize() {
if (!canvas) return;
const dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = innerWidth * dpr;
canvas.height = innerHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener("resize", resize);
// Sparks that fly out from a point and fade.
export function burst(x, y, colors, n, scale = 1) {
if (reduced()) return;
ensureCanvas();
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const v = (2 + Math.random() * 7) * scale;
particles.push({
kind: "spark",
x,
y,
vx: Math.cos(a) * v,
vy: Math.sin(a) * v - 1.5 * scale,
life: 0,
max: 30 + Math.random() * 30,
w: 1 + Math.random() * 1.8,
color: colors[i % colors.length],
});
}
// A shockwave ring.
particles.push({ kind: "ring", x, y, life: 0, max: 26, r: 80 * scale, color: colors[0] });
run();
}
// Glowing motes that stream along a curve into a target, calling onArrive with the first.
export function stream(from, to, color, n, delay, onArrive) {
if (reduced()) return onArrive();
ensureCanvas();
let arrived = false;
for (let i = 0; i < n; i++) {
particles.push({
kind: "mote",
path: arc(from, to, (Math.random() - 0.5) * 1.1, 0.1),
wait: (delay + i * 28) / 16.7,
life: 0,
max: 38 + Math.random() * 10,
color,
trail: [],
arrive: () => {
if (!arrived) {
arrived = true;
onArrive();
}
},
});
}
run();
}
function bezierAt(path, t) {
const f = t * (path.length - 1);
const i = Math.min(path.length - 2, Math.floor(f));
const k = f - i;
return { x: path[i].x + (path[i + 1].x - path[i].x) * k, y: path[i].y + (path[i + 1].y - path[i].y) * k };
}
let last = 0;
function run() {
if (running) return;
running = true;
canvas.hidden = false;
last = performance.now();
requestAnimationFrame(frame);
}
// Lifetimes and speeds are in 60 fps frames, scaled by the real frame time so effects
// last as long on a 144 Hz screen as on a slow one.
function frame(now) {
const dt = Math.min(4, Math.max(0, (now - last) / (1000 / 60)));
last = now;
ctx.clearRect(0, 0, innerWidth, innerHeight);
// Additive light on the dark theme; plain paint on the light one, where light vanishes.
ctx.globalCompositeOperation = LIGHT ? "source-over" : "lighter";
particles = particles.filter((p) => {
if (p.wait > 0) {
p.wait -= dt;
return true;
}
p.life = Math.min(p.max, p.life + dt);
const k = p.life / p.max;
if (p.kind === "spark") {
const drag = Math.pow(0.92, dt);
p.vx *= drag;
p.vy = p.vy * drag + 0.12 * dt;
const x2 = p.x + p.vx * dt;
const y2 = p.y + p.vy * dt;
ctx.strokeStyle = p.color;
ctx.globalAlpha = 1 - k;
ctx.lineWidth = p.w;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(p.x - p.vx * 2, p.y - p.vy * 2);
ctx.lineTo(x2, y2);
ctx.stroke();
p.x = x2;
p.y = y2;
} else if (p.kind === "ring") {
ctx.strokeStyle = p.color;
ctx.globalAlpha = (1 - k) * 0.8;
ctx.lineWidth = 2 * (1 - k) + 0.5;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (1 - Math.pow(1 - k, 3)), 0, Math.PI * 2);
ctx.stroke();
} else if (p.kind === "mote") {
const e = k * k * (3 - 2 * k);
const pos = bezierAt(p.path, Math.min(1, e));
p.trail.push(pos);
if (p.trail.length > 8) p.trail.shift();
ctx.strokeStyle = p.color;
ctx.lineCap = "round";
for (let i = 1; i < p.trail.length; i++) {
ctx.globalAlpha = i / p.trail.length;
ctx.lineWidth = (i / p.trail.length) * 3;
ctx.beginPath();
ctx.moveTo(p.trail[i - 1].x, p.trail[i - 1].y);
ctx.lineTo(p.trail[i].x, p.trail[i].y);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.fillStyle = FLASH;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 1.6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = p.color;
ctx.globalAlpha = 0.35;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 5, 0, Math.PI * 2);
ctx.fill();
if (p.life >= p.max) p.arrive();
}
return p.life < p.max;
});
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
if (particles.length) requestAnimationFrame(frame);
else {
ctx.clearRect(0, 0, innerWidth, innerHeight);
canvas.hidden = true; // nothing to composite while idle
running = false;
}
}
-367
View File
@@ -1,367 +0,0 @@
// Connects the server-rendered page (htmx) to the three.js viewport.
//
// Every htmx response re-renders the page regions out of band, including
// <div id="view"> (the current view and its URL) and <script id="scene-data"> (the moon
// to show in the viewport). The regions are morphed into the page (idiomorph, see "Morph
// swaps" below); #view, #scene-data, #modal and #reveal are replaced.
import { startScene } from "./scene.js";
import { mergeSettings } from "./terrain.js";
import { morphCount } from "./fx.js";
import * as sfx from "./sfx.js";
import "./market.js";
import "./tabs.js";
import "./enter.js";
import { playReveal } from "./reveal.js";
import { wireDialog, closeDialog, confirmWith, fadeToast, toast } from "./ui.js";
const aside = document.getElementById("aside");
const viewport = document.getElementById("viewport");
const splash = document.getElementById("splash");
const view = () => document.getElementById("view").dataset;
// History is handled here: htmx's own snapshots would replace the live 3D canvas.
htmx.config.historyEnabled = false;
// ---- Morph swaps ----
// idiomorph merges each region into the page, so elements stay: running animations, focus,
// scroll, typed text. The server's markup wins, except for what the JS keeps on purpose:
// - classes the JS adds (fx-* effects, the tab reveal and reticle, the tab ink's has-ink);
// - inline styles on elements the server doesn't style (effects, reticle phase, the payout);
// - elements the JS inserted (the tab ink, effect overlays, a mined-out moon leaving);
// - text typed in inputs the server renders empty (the sell amount, the moon search),
// except the sell amount when the ticket switches to another quote, and in the input
// being typed in (a refinery batch size);
// - the wallet numbers, which fx.js counts up to the new value (morphCount).
const jsClass = /^(fx-|is-revealed$|is-locking$|has-ink$)/;
Object.assign(Idiomorph.defaults.callbacks, {
beforeNodeMorphed(old, next) {
if (old.nodeType !== 1 || next.nodeType !== 1) return true;
if (!morphCount(old, next)) return false;
for (const c of old.classList) if (jsClass.test(c)) next.classList.add(c);
if (old.hasAttribute("style") && !next.hasAttribute("style")) next.setAttribute("style", old.getAttribute("style"));
if (old.matches(".mk-ticket") && old.elements.resource?.value !== next.querySelector("[name=resource]")?.value) {
old.elements.amount.value = "";
}
return true;
},
beforeNodeRemoved: (node) => !(node.nodeType === 1 && node.matches(".tab-ink, .fx-sweep, .fx-scan, .fx-moon-out")),
beforeAttributeUpdated: (name, el, how) =>
!(name === "value" && el instanceof HTMLInputElement && (how === "remove" || el === document.activeElement)),
});
const go = (url) => htmx.ajax("GET", url, { source: document.body, swap: "none" });
const scene = startScene(viewport, {
onStatus(message, { reload }) {
splash.hidden = message === null;
splash.replaceChildren();
if (message === null) return;
const box = document.createElement("div");
box.innerHTML = "<h1>Moon Miners</h1><p></p>";
box.querySelector("p").textContent = message;
if (reload) {
const actions = document.createElement("p");
actions.className = "splash-actions";
const button = document.createElement("button");
button.className = "btn";
button.textContent = "Reload page";
button.onclick = () => location.reload();
actions.append(button);
box.append(actions);
}
splash.append(box);
},
});
// Moon look (admin-tunable, see /utils/moons); the defaults if none are saved.
const moonSettings = fetch("/api/moon-settings")
.then((res) => (res.ok ? res.json() : null))
.catch(() => null)
.then((saved) => mergeSettings(saved));
function applySceneData() {
const data = JSON.parse(document.getElementById("scene-data").textContent || "null");
if (!data) return;
aside.hidden = !data.select;
if (data.select?.mystery) {
moonSettings.then((settings) => scene.show({ mystery: true, settings }));
return;
}
const moonId = data.select?.moonId;
if (!moonId) return;
// A moon just bought (buy-moon responses send #reveal before the scene data): reveal it.
const reveal = document.querySelector("#reveal[data-moon]");
if (reveal?.dataset.moon === moonId && !("playing" in reveal.dataset)) {
reveal.dataset.playing = "";
moonSettings.then((settings) => playReveal({ scene, seed: moonId, settings, source: reveal }));
return;
}
moonSettings.then((settings) => scene.show({ seed: moonId, settings }));
}
applySceneData();
// Keep the address bar on the current view (and the back button working).
let fromHistory = false;
function syncURL() {
const url = view().url;
if (!fromHistory && url !== location.pathname + location.search) {
history.pushState(null, "", url);
}
fromHistory = false;
}
window.addEventListener("popstate", () => {
fromHistory = true;
go(location.pathname + location.search);
});
// 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, page, adv } = view();
const params = event.detail.parameters;
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;
}
});
// hx-confirm asks in our own dialog (ui.js); the buy modal uses the same dialog styles.
const dialog = document.getElementById("confirm");
const dialogSound = (value) => sfx.play(value === "ok" ? "confirm" : "close");
wireDialog(dialog, { onClose: dialogSound });
confirmWith(dialog, { onOpen: () => sfx.play("open") });
// Admins: the quick grant dialog (#grant), from the header's Grant button or the G key.
const grant = document.getElementById("grant");
if (grant) {
const openGrant = () => {
if (document.querySelector("dialog[open]")) return;
grant.showModal();
sfx.play("open");
};
wireDialog(grant, {
onClose: (value) => {
dialogSound(value);
if (value !== "ok") return;
const f = grant.querySelector("form");
htmx.ajax("POST", "/act/admin-grant", { source: f, swap: "none",
values: { grant: f.elements.grant.value, amount: f.elements.amount.value } });
},
});
grant.querySelector("button[value=cancel]").addEventListener("click", () => closeDialog(grant, "cancel"));
document.addEventListener("click", (event) => {
if (event.target.closest("[data-grant-open]")) openGrant();
});
addEventListener("keydown", (event) => {
if (event.key !== "g" && event.key !== "G") return;
if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest?.("input, select, textarea, [contenteditable]")) return;
openGrant();
});
}
// Instant feedback: everything is disabled while requests run (CSS: .is-busy).
let inFlight = 0;
document.body.addEventListener("htmx:beforeRequest", () => {
inFlight++;
document.body.classList.add("is-busy");
});
document.body.addEventListener("htmx:afterRequest", (event) => {
inFlight = Math.max(0, inFlight - 1);
if (inFlight === 0) document.body.classList.remove("is-busy");
// A button in the after-purchase modal (Deploy, Claim moon) closes it once done.
const modal = event.detail.elt?.closest?.("#modal dialog[open]");
if (modal) closeDialog(modal, "ok", { quiet: true }); // the action has its own sound
});
document.body.addEventListener("htmx:oobAfterSwap", (event) => {
const id = event.detail.target?.id;
if (id === "scene-data") applySceneData();
else if (id === "view") {
syncURL();
syncClock();
}
else if (id === "modal") {
const modal = document.querySelector("#modal dialog[data-open]");
if (modal) {
wireDialog(modal, { onClose: dialogSound });
modal.showModal();
sfx.play("modal", { delay: 150 }); // after the sound of the action that opened it
}
}
});
document.body.addEventListener("htmx:responseError", () => {
toast("Something went wrong", true);
sfx.play("error");
});
document.body.addEventListener("htmx:sendError", () => {
toast("Can't reach the server", true);
sfx.play("error");
});
// Toasts fade out on their own.
htmx.onLoad((el) => {
const toasts = el.classList?.contains("toast") ? [el] : (el.querySelectorAll?.(".toast") ?? []);
for (const t of toasts) fadeToast(t);
});
// Onboarding (onboarding.go): what's revealed after the page loaded (a new tab, "+ New
// moon") plays its entrance once (CSS: .is-revealed), by data-reveal name.
const revealed = new Set([...document.querySelectorAll("[data-reveal]")].map((el) => el.dataset.reveal));
htmx.onLoad(() => {
for (const el of document.querySelectorAll("[data-reveal]")) {
if (revealed.has(el.dataset.reveal)) continue;
revealed.add(el.dataset.reveal);
el.classList.add("is-revealed");
}
});
// A tab that starts shining after the page loaded locks its reticle on once (CSS:
// .is-locking, sound in fx.js); tabs that shine together breathe in step.
const shining = new Set([...document.querySelectorAll("[data-shine]")].map((el) => el.dataset.reveal));
const syncShine = () => {
const phase = `${-Math.round(performance.now() % 3600)}ms`;
for (const el of document.querySelectorAll("[data-shine]")) {
if (!el.style.getPropertyValue("--reticle-phase")) el.style.setProperty("--reticle-phase", phase);
if (shining.has(el.dataset.reveal)) continue;
shining.add(el.dataset.reveal);
el.classList.add("is-locking");
el.dispatchEvent(new CustomEvent("mm:lockon", { bubbles: true }));
}
};
syncShine();
htmx.onLoad(syncShine);
// The moon search box filters the list as you type, and again after a swap (the query
// stays, the list comes back unfiltered).
function filter(input) {
const query = input.value.trim().toLowerCase();
for (const el of document.querySelectorAll(input.dataset.filter)) {
el.hidden = query !== "" && !el.dataset.name.toLowerCase().includes(query);
}
}
document.addEventListener("input", (event) => {
const input = event.target.closest("[data-filter]");
if (input) filter(input);
});
document.body.addEventListener("htmx:afterSwap", () => document.querySelectorAll("[data-filter]").forEach(filter));
// ---- Countdown labels and ready badges (display only) ----
// Elements with data-ends (unix ms) show the time left; the server-rendered text is the
// starting value. At zero they show data-done (default "Ready"), and a disabled Claim
// button around them (data-ready-at) is enabled and fires "mm:ready" (fx.js). Elements
// with data-ready (a count) and data-pending (end times, comma-separated) show the count
// plus the pending ones that have finished in their .badge, hidden at zero. Claim buttons with
// data-yield or data-plural get their amount or drill count the same way. Drills take seconds, so nothing reloads for them: the server decides what is
// actually claimable when the button is pressed. Times are on the server's clock (#view
// data-now). The DOM is queried on every tick, so swapped-in elements need no setup.
let skew = 0; // server clock minus ours, in ms
function syncClock() {
const now = Number(view().now);
if (now) skew = now - Date.now();
}
syncClock();
function formatLeft(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}`;
}
// Same as formatMoney in views.go.
const formatMoney = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 2 });
let reloading = false;
function tickCountdowns() {
const now = Date.now() + skew;
let reload = false;
for (const el of document.querySelectorAll("[data-ends]")) {
const left = Number(el.dataset.ends) - now;
if (left > 0) {
el.textContent = formatLeft(left) + (el.dataset.suffix ?? "");
} else {
el.textContent = el.dataset.done ?? "Ready";
el.classList.replace("muted", "ready");
delete el.dataset.ends;
const button = el.closest("button[data-ready-at]");
if (button) {
delete button.dataset.readyAt;
button.disabled = false;
button.dispatchEvent(new CustomEvent("mm:ready", { bubbles: true }));
}
// Refinery batches (data-reload) aren't in the scene data: reload for Collect.
if ("reload" in el.dataset) reload = true;
}
}
for (const el of document.querySelectorAll("[data-pending]")) {
const n = Number(el.dataset.ready) + el.dataset.pending.split(",").filter((t) => t && Number(t) <= now).length;
// A Claim button with data-plural reads "Claim N drills" once its countdown is done.
if (el.dataset.plural) {
const label = `Claim ${n} ${el.dataset.plural}${n === 1 ? "" : "s"}`;
if (n > 0 && !el.querySelector("[data-ends]") && el.textContent !== label) el.textContent = label;
continue;
}
const badge = el.querySelector(".badge");
if (!badge) continue;
if (badge.textContent !== String(n)) {
badge.textContent = n;
if (badge.title) badge.title = `${n} drill${n === 1 ? "" : "s"} ready`;
}
if (badge.hidden !== (n === 0)) badge.hidden = n === 0;
}
// Claim buttons with data-yield ("end:amount" per drill) show what the finished drills
// will give, capped at data-supply, once their countdown is done.
for (const el of document.querySelectorAll("button[data-yield]")) {
const label = el.querySelector(".num");
if (!label || label.dataset.ends) continue;
let sum = 0;
for (const part of el.dataset.yield.split(",")) {
const [end, amount] = part.split(":").map(Number);
if (end <= now) sum += amount;
}
const text = formatMoney(Math.min(sum, Number(el.dataset.supply)));
if (sum > 0 && label.textContent !== text) label.textContent = text;
}
// Banner items (announcements, live events) go at their end; the banner hides when empty.
for (const el of document.querySelectorAll("[data-banner-end]")) {
if (Number(el.dataset.bannerEnd) > now) continue;
const banner = el.parentElement;
el.remove();
if (banner && !banner.querySelector(".banner-item")) banner.hidden = true;
}
if (reload && !reloading) {
reloading = true;
window.setTimeout(() => {
reloading = false;
go(view().url);
}, 500);
}
}
tickCountdowns();
window.setInterval(tickCountdowns, 250);
// ---- Drill timer bars (wells table, display only) ----
// Elements with data-bar ("start:end", unix ms on the server's clock) fill (--p) as the
// drill's cycle runs and get is-ready once it's done. Separate from data-ends, which
// rewrites its element's text.
function tickBars() {
const now = Date.now() + skew;
for (const el of document.querySelectorAll("[data-bar]")) {
const [start, end] = el.dataset.bar.split(":").map(Number);
const p = end > start ? Math.min(1, Math.max(0, (now - start) / (end - start))) : 1;
el.style.setProperty("--p", `${(p * 100).toFixed(1)}%`);
if (p >= 1) {
el.classList.add("is-ready");
delete el.dataset.bar;
}
}
}
tickBars();
window.setInterval(tickBars, 250);
-104
View File
@@ -1,104 +0,0 @@
// Market section (display only): the chart crosshair, and the sell ticket's quick
// amounts and payout estimate. Prices come from the server; the sell itself is the
// ticket's normal htmx post. Listeners are delegated, so swapped-in markup just works.
const credits = (n) => "₥" + n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Selling pressure (market.go): the price factor averaged over the pressure a sale of
// `amount` adds, the same curve the server uses.
function pressureFactor(form, amount) {
const floor = Number(form.dataset.floor), scale = Number(form.dataset.scale);
const p = Math.max(0, Number(form.dataset.pressure) || 0);
const d = amount * Number(form.dataset.base);
if (!(scale > 0) || !(floor > 0)) return 1;
if (!(d > 0)) return floor + (1 - floor) * Math.exp(-p / scale);
return floor + ((1 - floor) * scale * (Math.exp(-p / scale) - Math.exp(-(p + d) / scale))) / d;
}
// The payout hero: what the amount typed would earn, or with none what Sell all would. fx.js
// also calls it to hand the number back after the sale effect.
export function updateEstimate(form) {
const out = form.querySelector("[data-est]");
const typed = form.elements.amount?.value.trim();
const balance = Number(form.dataset.balance) || 0;
const amount = typed ? Number(typed) || 0 : balance;
const factor = pressureFactor(form, amount);
const unit = Number(form.dataset.price) * factor;
if (out) setHero(out, credits(amount * unit));
const label = form.querySelector("[data-est-label]");
if (label) label.textContent = typed || !(balance > 0) ? "You receive" : "Sell all pays";
const per = form.querySelector("[data-unit]");
if (per) per.textContent = `${credits(unit)} / unit`;
const note = form.querySelector("[data-press]");
if (note) {
const pct = Math.round((1 - factor) * 100);
note.hidden = pct < 1;
note.textContent = `−${pct}% from your recent selling`;
}
}
// The hero's text, with its length for the CSS that shrinks long numbers to fit.
export function setHero(out, text) {
out.textContent = text;
out.style.setProperty("--chars", String(text.length));
}
// After every swap, except on a ticket playing a sale (fx.js hands it back when done).
const refresh = () => document.querySelectorAll(".mk-ticket:not(.fx-selling)").forEach(updateEstimate);
refresh();
document.body.addEventListener("htmx:afterSwap", refresh);
document.addEventListener("click", (event) => {
const button = event.target.closest(".mk-quick-btn");
if (!button) return;
const form = button.closest(".mk-ticket");
const balance = Number(form.dataset.balance);
const frac = Number(button.dataset.frac);
// Max sends the exact balance; fractions round down to 2 decimals.
form.elements.amount.value = frac >= 1 ? form.dataset.balance : String(Math.floor(balance * frac * 100) / 100);
updateEstimate(form);
});
document.addEventListener("input", (event) => {
const form = event.target.closest(".mk-ticket");
if (form) updateEstimate(form);
});
// ---- Crosshair ----
function moveCross(chart, clientX) {
// Parsed once per series (the chart element stays across swaps; its data changes).
if (chart._series !== chart.dataset.prices) {
chart._series = chart.dataset.prices;
chart._prices = chart._series.split(",").map(Number);
}
const prices = chart._prices;
const rect = chart.querySelector("svg").getBoundingClientRect();
const f = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
const i = Math.round(f * (prices.length - 1));
const lo = Math.min(...prices), hi = Math.max(...prices);
const pad = Math.max((hi - lo) * 0.12, prices[prices.length - 1] * 0.005);
const x = (i / (prices.length - 1)) * rect.width;
const y = (1 - (prices[i] - (lo - pad)) / (hi + pad - (lo - pad))) * rect.height;
const cross = chart.querySelector(".mk-cross");
cross.hidden = false;
cross.style.setProperty("--x", x + "px");
cross.style.setProperty("--y", y + "px");
const when = new Date(Number(chart.dataset.start) + i * Number(chart.dataset.step));
const tip = cross.querySelector(".mk-cross-tip");
tip.textContent = `${credits(prices[i])} · ${when.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
tip.classList.toggle("is-left", f > 0.6);
}
document.addEventListener("pointermove", (event) => {
const chart = event.target.closest?.(".mk-chart");
if (chart?.dataset.prices) moveCross(chart, event.clientX);
});
document.addEventListener(
"pointerleave",
(event) => {
if (event.target.classList?.contains("mk-chart")) event.target.querySelector(".mk-cross").hidden = true;
},
true,
);
-12
View File
@@ -1,12 +0,0 @@
// Builds moon meshes off the main thread (see generate in scene.js).
import { generateMoon } from "./terrain.js";
self.onmessage = (event) => {
const { id, params } = event.data;
try {
const mesh = generateMoon(params);
self.postMessage({ id, mesh }, [mesh.positions.buffer, mesh.normals.buffer, mesh.colors.buffer, mesh.index.buffer]);
} catch (error) {
self.postMessage({ id, error: String(error?.message ?? error) });
}
};
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more