Compare commits

...
28 Commits
Author SHA1 Message Date
JosephFerano 09d09cbc52 Merge branch 'c-lint' 2026-10-21 13:47:33 +07:00
JosephFerano 97ed243b34 Client: layout dump and UI lint (drive.py dump/lint, make lint, clicker check), reserved slots and text fit helpers in flex_gui; keep tab count and need-line places; fix filled chip stray square 2026-10-21 13:47:12 +07:00
JosephFerano 63d8f633d3 server/AGENTS.md: the sim plays two clicker players now 2026-10-21 13:43:02 +07:00
JosephFerano daa6c9bc5a Clicker pacing: drill cycles 11-15 s, grade prices x10 per grade with wells to match, upgrades a bit better per credit than copies; sim plays two active players (copies, boosts) under a click budget 2026-10-21 13:42:28 +07:00
JosephFerano 850e2a136a Client: moon grades (grade picker on the new-moon panel, grade toggle on + New moon, grade badges, per-moon supply scale) 2026-10-21 13:17:17 +07:00
JosephFerano 48bc94ad76 Merge main into grades 2026-10-21 12:49:32 +07:00
JosephFerano 508ff12e57 Moon grades D–S with their own prices and wells, buy-moon takes a grade; upgrades speed drills up; drill, boost and refinery rebalance; active/idle balance sim 2026-10-21 12:49:07 +07:00
JosephFerano 30a2548b2a Trim client/AGENTS.md to rules and a map; document scripts/wt and make run/reload 2026-10-21 12:48:05 +07:00
JosephFerano 00391580ce Client: make run starts the game from build/default in the foreground so make reload works on it 2026-10-21 12:45:37 +07:00
JosephFerano 103d4b4ac2 Merge branch 'main' into tooling 2026-10-21 12:44:12 +07:00
JosephFerano 59812479cf scripts/wt: agent worktrees with their own server, database and Defold build cache 2026-10-21 12:44:12 +07:00
JosephFerano 34b8846976 Merge branch 'saves' 2026-10-21 10:41:09 +07:00
JosephFerano f52f28d2e9 Client: Save code and Restore from code in the settings menu, the save code nudge as an announcement, savecode scenario 2026-10-21 10:40:48 +07:00
JosephFerano 4a02faeee4 Server: a refused restore carries an error toast 2026-10-21 10:40:48 +07:00
JosephFerano 45246e8399 Server: restoring a banned player's code is a 422 2026-10-21 10:30:25 +07:00
JosephFerano f5a65d53cb Server: save codes (GET /api/save-code, actions save-code and restore, hashed in save_code, restore rate limit, a one-time nudge after the first sale) 2026-10-21 10:28:40 +07:00
JosephFerano d800baf9a9 Tool art per tier: generated drill images per tool and tier, image fields in the API
scripts/tool-art.mjs (from s-drillart) draws every tool at every tier into
web/static/assets/items (tier N: <image>-tN). StoreItem.ImageAt picks it, falling back to
the base image. API: state.tools and equipment rows show the tool's tier art; catalog
tiers get an image. TestAPI checks every tool has art at every tier.
2026-10-21 10:23:39 +07:00
JosephFerano ffbe30fa78 Merge branch 'c-reveal'
# Conflicts:
#	client/AGENTS.md
2026-10-21 10:12:18 +07:00
JosephFerano 1462e2e9f1 Client: note the reveal scenario 2026-10-21 10:11:41 +07:00
JosephFerano ef4eea8147 Client: buying a moon reveals it in a centered window with a drawer for its stats; + New moon at the top of the moon list buys at once; toasts listed in an announcements pane under the moon list; deep build-up and a bigger burst sound, Perlin shake; the price counts down at the purchase 2026-10-21 10:11:35 +07:00
JosephFerano da27a9ca02 Client: leaderboard pane in Market, settings gear menu, Claim all keeps its place
The header loses the Leaderboard and Help tabs and its small settings buttons.
The Market shows the top miners in a narrow pane on the right (under the market
when the panel is too narrow); old leaderboard links open the market. One gear
at the right opens a dropdown: How to play, UI scale, fullscreen, sound, theme,
reduced motion, Grant. Claim all's place is reserved once unlocked so the tabs
never move; when the row is short the resource chips go under the balance.
2026-10-21 10:07:29 +07:00
JosephFerano e040e3b986 Merge branch 'c-stable' 2026-10-21 09:53:49 +07:00
JosephFerano 6dd70e4cae Client: buttons stay put under repeated clicks
Well panel: the note's room is kept and After current drills always shows, so the
first deploy no longer pushes the deploy cards down; deploy cards are a grid of
web-style cards (picture on top, name, yield, Deploy, note line), all one height.
Refinery: batches show oldest first from the left, idle slots after; collecting
the leftmost slides the rest left (the frame glides, the Collect hit box sits at
the final spot at once), so repeated clicks on one spot collect each batch.
H.action takes an optional hit node. AGENTS.md: the clicker rule.
2026-10-21 09:53:36 +07:00
JosephFerano 450c371e43 Merge branch 'overdrill' 2026-10-21 09:52:00 +07:00
JosephFerano 8bbe20ba25 Wells: refuse a stake on a fully committed well; client prediction ports the server's well projection
Server: stakeInTx refuses a drill that would get nothing after the drills already on
the moon ("Nothing left to mine on this well"); redrill and claim+redeploy leave those
tools idle. State: moon.wells[].fullyCommitted, well.idle[].blocked. The web disables
the deploy card. Wells sort by resource then id everywhere (claims too), stakes by
created_at then id, so ties split the same in the projection and the claim.

Client: derive recomputed a well's remain from its one-well drills only, dropping the
multi-well drills' share, so placing a drill on a well shared with a multi-well drill
made the committed part shrink. predict.lua now ports the projection (demand, remain,
full, notes, drills' gets, deploy cards); stake take is what claiming now gets; claims
go in turn from what is left and stop drills on an emptied well; a claim no longer
counts its tool twice for Redrill; stake/redrill/redeploy aren't predicted on a well
with no room. The deploy card is disabled when blocked.
2026-10-21 09:51:21 +07:00
JosephFerano 1487f1bdcd Client: the moon sale sits at the bottom of the moon pane 2026-10-21 09:47:33 +07:00
JosephFerano 63360022c8 An owned moon always opens with a well selected (the first, when none or a stale one is asked for) 2026-10-21 09:46:59 +07:00
JosephFerano 9d6f912f13 Client: store cards keep the shortfall line's space, so Buy is at the same height in every card 2026-10-21 09:40:57 +07:00
131 changed files with 5384 additions and 1766 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@
# Blender files
*.blend
*.blend1
# Agent worktree state (scripts/wt)
/.wt/
+7 -2
View File
@@ -28,8 +28,13 @@ banned players left out).
## Workspace
- The repo is `game/` in the `MoonMiners/` workspace. Design notes live in the workspace,
outside git (`../project.todo`, `../FAQ.org`, `../druid-notes.md`).
- Agent worktrees go in the workspace, next to `game/`:
`git -C game worktree add ../wt-<task> -b <task>`. Remove them when merged.
- Agent worktrees go in the workspace, next to `game/`. Make them with `scripts/wt` (from
the workspace: `game/scripts/wt up <task>`), not by hand: it makes `../wt-<task>` on
branch `<task>`, copies the Defold build cache (no remote engine build) and `.env`,
creates and migrates the database `mm_<task>`, and starts a server (MM_DEV=1) on a free
port; it prints the `MM_API` to test against. `wt status` lists them, `wt restart <task>`
rebuilds the server, `wt down <task>` stops it and drops the database, `wt rm <task>`
also removes the worktree (and the branch if merged). State in `<worktree>/.wt/`.
- `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it.
+183 -626
View File
@@ -1,637 +1,194 @@
# AGENTS.md — Moon Miners client (Defold)
## What this is
A native client for Moon Miners, the idle mining game whose rules and state live in the Go
server (`../server`, see `../server/AGENTS.md`; the whole repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header with sections, resources and credits; moon list on the left; the current section
in the middle; aside with the moon viewport and the selected well), 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 (see Scaling); mouse now, gamepad focus later.
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
selected well), 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
map.
The server is the authority. The client shows the state the server sends and sends
actions. It may show an action's result before the answer (prediction, see Conventions),
computed only from numbers the server sent (`take`, the tool's `cycleSecs`/`yield`, quotes,
`rules`; `../server/api.md` "Prediction"); the next response replaces it. No other game
rules in Lua. Countdowns may tick locally as display only.
## Rules
- The server is the authority. Show the state it sends, send actions. Prediction may show
an action's result early, computed only from numbers the server sent (`take`, the tool's
`cycleSecs`/`yield`, quotes, `rules`; `../server/api.md` "Prediction"); the next response
replaces it. No other game rules in Lua. Countdowns tick locally as display only.
- Keep changes small; don't touch `../server` from here.
- Flex-first: new or reworked UI is laid out with `modules/flex.lua` / `flex_gui.lua`, no
hand-placed coordinates (`26 + ui.text_width(name) + 7`). When you touch a hand-placed
part, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Slot rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a
label); animations own what's inside (CSS layout vs transform). Never give a box a node
that something animates by position or size. Widgets built at a size (ui.button, bars,
dashed edges) are built after the solve at their box's rect; text is cut after it
(`ui.fit_text`). Kept blocks aren't re-laid out: solve only what you build.
- Clicker rule: buttons never move under the pointer while the player clicks. Nothing above
a button changes its position (reserve room: fixed heights, placeholders), and when a
thing is used up the next of its kind slides into the same spot (refinery slots, deploy
cards), so repeated clicks at one place keep working.
- Input focus: the game screen takes input focus once in `init`. Druid never touches it
(`[druid] no_auto_input = 1`: a Druid instance's `final()` would release focus for the
whole gui script, so closing a dialog left the game deaf). Don't post
acquire/release_input_focus anywhere else. The dialog's input invariant
(`dialog:check`) logs `ERROR:MM: input invariant broken` when repaired; check.py fails on
`ERROR:MM`.
- One Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog), never screens.
One Druid instance per gui_script (the dialog has its own). Interaction goes through
Druid via the components; a raw `druid:new_button` is mouse-only and has no test id.
- Every interactive element registers a stable test id (button.bind, tabs.tab, row, field
do; ids in `modules/test.lua`'s header). Give new interactive things an id: focus,
keys, fx and the test driver depend on it.
- Build from templates cloned at runtime (`ui.clone`; new kinds: a template in
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
layout is top-left based (x right, y down).
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
a rebuild lives on `g` or on a layer outside `g.layout`; never keep a layout node across
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
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
(`ui.credits`, `ui.label`). `ui.text` escapes markup unless the style has `markup = true`;
server text is safe to pass. Add missing characters to the `.font` (make check names them).
- 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
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.
- Scaling: layout size = window / (UI scale × display scale); below 960x600 it scales down;
below 1250 wide the aside gets its own row and the page scrolls. Content caps
(`theme.size`) are all above what fits at 1280x800; keep it that way (compare shots).
- Web: use dt or the clock for motion (variable frame rate); after render changes check the
browser console (WebGL feedback loops, Firefox target resizes).
- Native code (`mmwindow/`, `mmterrain/`): user rule, C only (`.c`, Lua C API); C++ only
where the dmSDK forces it, as a C-style shim (free functions and plain structs: no
classes, methods, templates, STL, references, own namespaces, exceptions, RTTI). The
extender compiles `.c` as C++, so the C must also be valid C++, with `extern "C"` across
files. Changing a native extension (or the dependencies) sends a remote engine build.
- `[mm]` in `game.project` holds only the keys in `scripts/check.py` `MM_DEFAULTS` at their
shipped values; pass others with `--config=mm.<key>=<value>`. Don't change
`display.width/height`.
- Never kill processes by name; scripts stop the game by its pid.
## Stack
- Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`; `bob.jar` of the same sha in `tools/`).
- Dependencies in `game.project`, pinned release zips: Druid 1.4.1 (UI components),
Monarch 6.0.3 (the screen), Defold Event 21 (required by Druid). Defold Event has a
native extension, so a build sends the engine to Defold's build server once (needs
network; later builds reuse it until the dependencies change). Dependencies are fetched
only when `game.project` changes (`make resolve` forces it).
- Input: `/builtins/input/all.input_binding` (what Druid expects: `touch`, `key_esc`, ...).
The game screen takes its input focus once in `init`; Druid never touches it (`[druid]
no_auto_input = 1`): a Druid instance's `final()` would post `release_input_focus` for
the whole gui script, so closing a dialog (its own instance) left the game deaf to mouse
and keys until the next rebuild. Don't post acquire/release_input_focus anywhere else.
- Druid and Defold GUI findings (Layout, Data List, hover, limits, markup):
`../../druid-notes.md` (in the workspace, outside the repo). Druid source: `~/Development/Refold/druid`, Defold source:
`~/Development/Refold/Defold` (read-only).
## Game screen: core, regions, sections
`screens/game/game.gui_script` only forwards the lifecycle to `screens/game/core/main.lua`
(and on a hot reload re-requires it, see Fast iteration). The screen is rebuilt from the
state: every new state (a response, a prediction) or a window/UI-scale change sets
`g.dirty`, and the next update builds again what changed. The regions (banner, header,
moon list, main panel, aside) and costly parts inside them (the market's ticker, chart and
sparklines; Store, Equipment and Advisors cards through `card.grid`'s `key`/`sig`) are
blocks (`modules/blocks.lua`): one whose signature (a serialization of everything it
shows: its state keys, the view, busy buttons, ready counts on our clock) is unchanged
keeps its nodes and Druid components. A full rebuild (drops the Druid instance and every
layout node) happens on the first state, a size change, the narrow layout and
`g.dirty_all` (theme, reduced motion, mute, scroll offsets set by code, hot reload). What
a region shows from the state is listed in `core/main.lua` (`SECTION_KEYS`, `ASIDE_KEYS`,
the header's and moon list's sigs): a section that reads a new state key must add it
there, or it won't update. `g` is the gui script's `self`.
Core (`screens/game/core/`):
- `main.lua`: init (layers, session, helpers, autoplay), update (rebuild when dirty,
tickers, effects, dialog, Druid, module `update`s, the test hook), input (a dialog takes
all input while open; mouse moves are replayed after a rebuild so hover shows at once),
the build orchestration (banner, header, moon list, main panel, aside; widths from
`theme.size`), entrances (below) and `on_reload`. Section modules: `M.SECTIONS`.
The aside shows only when the view selects something for it (`M.aside_shown`, the
web's `Scene.Select`: a moon, the teaser, the drill panel, an advisor's panel);
otherwise the main panel takes its width. The start screen (`g.boot` from session.lua:
the web's boot and suspended pages) shows until the first state.
- `ready.lua`: ready counts on our clock (the web's data-pending): badges, Claim all,
moon rows, "Claim N drills" and "Claim <amount>" count up as drills and batches finish
before the refetch; `header.lua`'s `update` rebuilds when the next end passes.
- `session.lua`: the state and the server: `g.base`, `g.pending`, `g.state`, `g.view`,
the one-at-a-time request queue `g.outbox`, prediction hookup, `fetch`, `navigate`,
`act`, the refetch when `nextEnd` is due, the server clock `now`. Errors in the web's
words ("Something went wrong", "Can't reach the server") with the error sound; a
failing fetch retries (3 s doubling to 30 s; at start as `g.boot` says); a 403 is the
suspended screen.
- `ticks.lua`: display-only values that change between responses: `tick_text` (countdown,
optional `on_done` at zero), `tick_bar` (timer bar, optional `on_full`), `tick_num`
(eased number); keyed so values carry over a rebuild.
- `keys.lua`: Tab / Shift+Tab focus (modules/focus), Enter / Space press the focused
element, Enter in a field with a submit (`g.inputs`, made by `modules/field` with
`on_submit` and the refinery's quantity; reset every build), back / forward
(`session.go`: mouse buttons 4/5, Alt+Left/Right, Backspace outside fields; silent);
arrows, Page Up/Down, Home/End, and Space / Shift+Space with no focus ring, scroll the
scroll area under the mouse (else the focused one's, else the biggest;
`g.scroll_areas[].scroll`); in a number field (digits only) Up/Down step it like
type=number (`step = { min, max }` on its `g.inputs` entry; `field.new` opts `min`,
`max`), Esc empties a field made with `esc_clears` (the moon search); F11 fullscreen;
Ctrl +/-/0 the UI scale.
`session.lua` keeps the view history (`g.history`, like the web's pushState).
- `hooks.lua`: the helpers modules get as `H` (= `g.h`): `action`, `link`, `claim_wait`,
`navigate`, `act`, `now`, `text_link` (cyan text that links to a view), `para`, `heading`, `fact`, `tick_text`, `tick_bar`, `tick_num`,
`badge` (a gold count badge inside a button, the web's Claim all `.badge`).
Every enabled Claim button (`H.action` "claim") breathes (gold ring and glow, 1.8 s,
clock-driven so rebuilds don't restart it). `claim_wait(..., {params, moon, done})`
turns into the enabled Claim at its countdown's zero (our clock, before the refetch):
pop .6→1.12, gold burst, ready sound (`fx:ready`); the refetch's button continues the
pop (`g.ready_at`). It is clickable from its first frame: an unscaled box takes the
clicks while it pops, and the claim predictor counts a drill whose end has passed on
our clock as ready. `opts.also = {label, params}`: a second claim beside it (the well
panel's Claim and redeploy). The waiting button has test id `wait:<action key>`.
Regions (one module each, so agents can work on them separately): `banner.lua`,
`header.lua` (tabs with the ink, badges, Claim all, display settings, reduced motion,
theme and mute toggles, wallet counted by fx), `banner.lua` (wraps; an event's time left
in the server's words, counted from its `end`),
`moonlist.lua` (Active/Past tabs, Find a moon, the virtualized rows, the Past list a page
at a time with Newer / Older (`pastPage`, `page=` in the view), "+ New moon"),
`aside.lua` (viewport; the well panel, scrolling; or the section's aside; `row`: the
narrow layout's row, viewport left), `settings.lua` (the display dialog: UI scale,
fullscreen),
`modules/toasts.lua` (toasts, on their own layer). Sections: `moons.lua` (the moon, wells
table, past moon, first-moon pitch; well helpers `supply_bar`/`supply_max`/`tool_name`),
`market.lua`, `refinery.lua`, `leaderboard.lua`, `help.lua`, `equipment.lua`, `store.lua`,
`advisors.lua`, the new-moon teaser in `reveal.lua`.
**Module contract** (regions and sections):
- `M.build(g, root, x, y, w, h, H)`: build everything under `root`, top-left based
(`ui.*` helpers: x right, y down). Sections get the main panel's body as `root`, its
padding as `(x, y)` (= `main_pad_x`, `main_pad_y`) and the panel size `w x h`; they
center their own capped content. Regions get their box; the header and banner return
their height. `H` = `g.h`.
- optional `M.aside(g, root, w, h, H)` (section): fills the aside instead of the moon
viewport, return true when it did; or `M.aside_below(g, root, pad, cy, inner, limit, H)`
for content under the viewport.
- optional `M.update(g, dt)`: every frame. optional `M.teardown(g)`: before its region is
built again and when the screen goes away (cancel timers, drop references to nodes).
- What a module gets on `g`: `state`, `view`, `size` ({W, H, ui scale}), `druid` (made
again by a full rebuild), `fx`, `toasts`, `reveal`, `dialog`, `tween`, `fields` (text field
state), `scroll_pos` (scroll offsets by key), `pointers` (mouse-move handlers, reset
every build), `frames` (cards for the main panel's staggered entrance), `moon_nodes`,
`well_nodes`, `tab_nodes` (for effects), `busy`, `inflight`.
- What must survive a rebuild lives on `g` (offsets, fields, eased values) or on a layer
outside `g.layout` (toasts, reveal, dialog, effects). Never keep a layout node across
builds outside its block.
- Per-build registries (test ids and focus, tooltips, tickers, `g.inputs`,
`g.scroll_areas`, `g.pointers`, fx anchors, autoplay) are reset by every build; a kept
block makes its registrations again from a journal: a new registry must note them
(`blocks.note(fn, ...)`, or `blocks.push`/`blocks.put` for a list or map on g). Code
that builds outside a build (a list making rows as it scrolls, a field's on_change)
runs through `blocks.bound(fn)` so what it makes belongs to its block. A kept block
moved under another parent finds its buttons' clip stencil again.
- A new section: a module in `screens/game/`, an entry in `M.SECTIONS`
(`core/main.lua`), and a tab name in `header.lua` `SECTIONS` if it has one.
Entrances (enter.js): the main panel enters when the section or moon changes, the aside
when the well or drill changes, both on the first state and after the reveal closes
(`g.enter_all`); cards registered in `g.frames` (components/card) follow,
staggered. Not on other rebuilds. While the reveal card flies into the moon list (Start
mining) a new state waits for it to land.
## Components (`components/`)
Build new UI from these instead of coordinate math. Each file's header documents its API.
- `button.lua`: variants `primary`, `secondary` (default), `redrill`, `danger`, `ghost`
(dashed "+ New moon" look, `on` lights it), color overrides (`fill`, `edge`, `text`);
eased hover and press, disabled, busy (dimmed + spinner). `ui.button(...)` draws it
(variants in `theme.button`), `button.bind(g, root, nodes, {on_click, id})` gives it
behavior, `button.new` does both. `H.action`/`H.link` bind through it, so every action
or link button has hover and a test id. After a rebuild the button under the mouse
shows its hover at once, and a press the rebuild interrupted carries over (any Druid
button with a test id: `button.carry`, the one with the same id under the pointer is
pressed in the new build), so the release still clicks it.
- `dialog.lua`: the modal (`g.dialog:open({title, message, ok = {label, price, danger,
act, params | on_ok}, cancel, w, h, build})`), in the game gui with its own Druid
instance: survives rebuilds, runs actions in the game script, drops page hovers
(`reset_hovers`) and restores them. Open/close animation, scan line, staggered content,
sounds, Esc/backdrop cancel. `cards.confirm`/`cards.confirm_button` use it. This is the
only way to show a dialog (the Monarch popup screen is gone). Input invariant
(`dialog:check`, every frame from core/main.lua `check_input`): the backdrop and the
dialog's Druid exist only while it is open or closing, and a close ends within 1 s; a
break is repaired and logged as `ERROR:MM: input invariant broken: ...` (check.py fails
a log with `ERROR:MM`).
- `screens/game/grant.lua`: the quick grant dialog for admins (`state.admin`: header "+"
button, G key): item and amount pickers, then `admin-grant`.
- `panel.lua`: the `.frame` panel with a body group and the entrance (`p:enter`).
- `card.lua`: image column + text column laid out with flex (below; measured text, `title`,
`text`, `para`, `block` (fixed height, or measured: `block(fn)` with fn returning its
height), `gap`, `footer` pinned to the bottom, `after_bind` for links inside a card), `card.grid` (columns,
equal row heights, hover edge, entrance stagger; `selected`, `edge_color` for a hired
advisor). Store, Equipment and Advisors.
- `tabs.lua`: tab strips with the sliding ink (`tabs.revealed`/`tabs.reveal`: a tab
revealed after load enters once) (`tabs.ink(g, parent, key)` before the
tabs, `ink:to(x, y, w, h)` on the active one) and `tabs.tab` (hover, click, test id).
Header and moon list.
- `list.lua`: long lists on Druid Data List (only rows in view exist; fixed row height;
offset kept per key). The moon list. Use it for any list that can grow (leaderboard,
history).
- `scroll.lua`: a scrolling area for variable content (`scroll.area(g, parent, x, y, w,
h, key)`, build into `.content`, `:finish(height)`); thin bar, thumb lights on hover.
Card sections, drill panel, well panel. An eased key scroll (Page, Home, End) that a
rebuild cuts short lands at its goal (`g.scroll_goal`). Keys and tooltips find what's
under the mouse with `ui.shown_at` (pick_node that respects the parents' stencils).
- `bar.lua`: timer bar (`bar.timer`: fills to its end, then the ready glow), static bar,
`bar.ready_label` (gold "Ready" with a pulsing glow).
- `chip.lua` (badges, tier chips, tags), `stat.lua` (`stat.inline` "YIELD 3", `stat.row`
label/value across a width = `H.fact`), `row.lua` (list rows: hover wash, selection
bar, click; wells table; `row.fresh`/`mark`/`enter`: a new row's entrance, kept by id
and time on g so it picks up on the rebuilt row).
- `modules/tooltip.lua`: the web's `title`: `tooltip.add(node, text)` while building
(any node, widgets too; `\n` for lines); shows after a 0.5 s rest, kept inside the
window, hidden by a click or a dialog; on its own layer. Give information-only text a
tooltip like the web does.
- `modules/focus.lua`: keyboard focus over everything registered with the test hook
(`test.register` forwards), in build order; the ring shows after a key; Enter/Space
press through Druid at the element's center. Scopes: page, dialog, reveal (the new-moon
card once complete). A long list (components/list with `focus_id`) is walked item by
item, scrolling, since its rows out of view don't exist. So anything interactive must register
(button.bind, tabs.tab, row, field do); a raw `druid:new_button` is mouse-only.
- Lower level, still the base of all of these: `modules/ui.lua` (`ui.clone`, `ui.text`,
`ui.rect`, `ui.outline`, `ui.panel`, `ui.button`, `ui.credits`, `ui.label`/`ui.rich`/
`ui.wrap` for text with `₥`, `★`, Greek, `ui.fit_text`, `ui.runs`, formats).
Druid Layout patterns (druid-notes.md §2): the layout node is an invisible box (no
slice9: its slice9 would become padding), pivot NW; Layout never sizes children (set
widths yourself); refresh innermost layouts first, synchronously, after building
(`refresh_layout(true)`); text nodes are measured by their text; a hugging layout reports
its size with `get_size()`. Data List for long lists, Druid hover (`button.hover.
on_mouse_hover`) for hover looks; Druid gives the last created component input first,
so make a clickable container before the buttons on it.
## Layout (flex) (`modules/flex.lua`, `modules/flex_gui.lua`)
Prefer it to coordinate math (`26 + ui.text_width(name) + 7`) for new or reworked UI.
Every section, the aside (well panel, drill panel, advisor panel), header, moon list,
banner, toasts, dialogs (and the buy modal), the new-moon teaser and the reveal card are
laid out with it; component internals (chips, stat rows, charts, moon rows) still place
their own parts.
- `flex.lua` is a pure solver (no Defold calls; unit tests `scripts/flex_test.lua`, in
`make check`): a tree of `{ style, children, measure = fn(avail_w) -> w, h, node, id }`;
CSS names in snake_case (`direction`, `gap`/`row_gap`/`column_gap`, `padding`,
`justify`, `align`/`align_self`, `grow`/`shrink`/`basis`, `width`/`height` px, "N%",
"auto", `min_*`/`max_*`, `wrap`, `display = "none"`). `flex.solve(tree, x, y, w, h)`
sets absolute `x, y, w, h` on every box: top-left origin, y down (like `ui.*` calls);
`flex.gui_pos(box, pivot)` gives the GUI position. Deviations from CSS (border-box, min
sizes 0, auto = fit-content, no margins) are at the top of the file.
- `flex.apply(tree, nodes, { glide = secs })` (flex_gui) sets position (by the node's
pivot) and size (unless `box.size == false`; divided by scale) on each box's `node`,
relative to the nearest ancestor box with a node, only when it changed; it never
creates, deletes or reparents. `glide` tweens moved nodes (FLIP), off under reduced
motion; default snap. `flex_gui.text_measure(node[, lh])` measures a text node.
- Rule: layout owns an outer slot node per box (an `anim.group`, a card's root, a label);
animations own what's inside (like CSS transform vs layout). Never give a box a node
that something animates by position or size. Kept blocks (modules/blocks) aren't
re-laid out: solve only what you build.
- Widgets whose look is built at a size (ui.button, bars, dashed edges) are built after
the solve, at their box's rect (`{ style = { grow = 1 } }` without a node, then read
`box.x/w`); text is cut (`ui.fit_text(node, box.w)`) after it.
- New or reworked UI is flex-first; don't add hand-placed coordinates. When you touch a
hand-placed section, port it. The point is fewer layout bugs and less rework, not fewer lines.
- Flex-first (the Refinery): describe the whole section as one tree with
`flex_gui.label`/`para` leaves and boxes with `make = fn(parent, x, y, w, h)` for what is
built at a size, `flex.grid` for card grids, then `flex_gui.build(flex.solve(tree, x, y),
parent)` makes the nodes in tree order (= focus order).
- flex_gui tree pieces: `box/row/col/space/fill/made`; `label` (rich text when it has
symbols), `text_para` (H.para's leading), `node_label` (an existing text node);
`button` (a ui.button box, width from a probe, then `bind(b, nodes, w)`); `inline(parent,
h, fn)` for one-line components that return their width (chips, ui.credits, stat
rows: fn(probe, 0, 0, true) must register nothing); `measured(parent, fn)` for one that
returns its height; `flow(parent, x, y, w, parts)` mixes trees with components that lay
themselves out in place (card.grid's kept cards). `H.claim_wait_w` gives a claim
countdown's width. `make` rects are whole pixels; leaves aren't rounded.
- Beyond flexbox (flex.lua header): `columns = {...}` makes a box a CSS grid (tracks px,
"N%", "auto", "Nfr"; `span`, `rows`, `justify_items`/`justify_self`; row height from
the tallest cell): tables. `position = "absolute"` with `left/right/top/bottom` and
`translate = {fx, fy}` places a child over its parent's box; `flex.anchor(rect, box)`
places a box that way against any solved rect (badges, floating numbers, bursts).
`aspect_ratio` (image boxes). `overflow = "scroll"` (children don't shrink) and
`flex.content_size(box)` for a scroll area's content size.
- Patterns: a label is a leaf of its line-box height (pivot W lands on its middle); a
pinned footer is a `grow = 1` spacer before it; equal-height card rows are `wrap = true`
with the default `align = "stretch"`; grid columns: give items the column width.
## Animation (`modules/anim.lua`)
Easing curves matching the web (`anim.ease.out` = enter.js `cubic-bezier(.2,.8,.2,1)`,
`overshoot` = dialog-in `(.2,.9,.3,1.2)`, `ink` = tabs `(.3,1.25,.5,1)`, `toast`, `pop`,
`ease_in`, `ease_out`, `back`, `sine`, `linear`, `toast_in`, `ready_pop`, `gain`;
`anim.fn.*` the same as Lua functions), `keys` (CSS keyframe tracks, eased per segment),
`scale_about` (scale a pivot-NW node about its middle, like CSS),
`anim.to`, `anim.set` (any property incl. `color.w`), frame-driven `anim.tween` (stepped
by `anim.update`, called from core), `enter_frame`/`enter` (brackets spread, unclip,
content fades in from under a veil and rises, stagger), `group` (invisible container),
`veil`, `rise`, `breathe` (pingpong forever), `pulse`, `pop`, `shake`, `noise_shake`
(decaying value noise: the reveal burst shakes the aside and header), `flash`, `sweep`
(scan line), `count`.
- GUI nodes don't pass alpha to children (inherit_alpha is off and most containers are
translucent fills), so content fades in from under a veil (a box in the background
color, `theme.color.veil` for panels) instead of fading a subtree.
- Reduced motion: `anim.reduced` (the header toggle, or `--config=mm.reduced_motion=1|0`,
which forces it and keeps the toggle from saving; `anim.set_reduced_motion(on, save)`,
saved in the settings file unless save is false): every helper skips movement and keeps
short fades; fx has no sparks, flashes, motes or coins (numbers land at once, no counter
ticks); the reveal skips the charge and shows the card complete; the ticker stands
still. New animations must honor it.
- Effects for action results stay in `modules/fx.lua` (particles, floating numbers:
quick repeats of the same gain merge into one number; others stack past it; wallet
numbers pop as their count starts: gain 1.35 white + cyan glow, loss 2 px down red; an
error shakes the button it came from, found after the rebuild by its test id
(`fx:button`, registered by `H.action`)).
- Toasts (`modules/toasts.lua`): slide in from -60 px with squash, overshoot and a flash,
scan line, fade + slide out; their nodes inherit alpha (`ui.collect` gathers what a
build cloned); a claim's toast takes the resource color (`fx:take_claim_color`).
## Text and markup
GUI text nodes parse the engine's markup (`<color=#rrggbb>`, `<outline>`, `<shadow>`,
`<shake>`, `<wave>`, `<link>`, `<size>`, entities `&lt;` `&amp;`). `ui.style_text` (so
every `ui.text`) escapes the text (`ui.esc`) unless the style has `markup = true`; server
text is always safe to pass. For colored runs in one node use `ui.runs({ {text, color},
... })` with a style `{ ..., color = "white", markup = true }` (the node color multiplies
the markup colors). Fields refuse `<` and `&`. Measuring (`ui.text_width`,
`resource.get_text_metrics`) understands markup.
## Layout
- `game.project`: settings; `[mm]` holds the client's own config (see below).
- `main/main.collection`: bootstrap object (`main.script`) plus the Monarch screen proxy
of `game`; `main.script` shows it, applies the saved fullscreen and listens to the
window (focus lost or minimized: `sfx.set_hidden`). The `sfx` object holds the sound
components.
- `mmwindow/`: our native extension, `mmwindow.set_fullscreen(on)` / `is_fullscreen()`
(the engine's GLFW window to its monitor and back; Defold has no runtime fullscreen).
On desktop it also makes the window's mouse buttons and keys sticky (GLFW), so a click
or key press shorter than a frame isn't lost (the engine polls their states once a frame).
Changing it rebuilds the engine on the build server. On the web it builds as stubs; the
browser's Fullscreen API is used instead (`modules/screen.lua`).
- `mmterrain/`: native extension, the moon generator in C (`src/terrain.c`, a port of
terrain.js's generation, same doubles in the same order; there is no Lua generator).
User rule: C only (`.c`, Lua C API); C++ only where the dmSDK forces it,
as a C-style shim (free functions and plain structs only: no classes, methods,
templates, STL, references, own namespaces, exceptions or RTTI; `src/buffer_shim.cpp`:
the buffer API). The extender compiles `.c`
files as C++ ("treating 'c' input as 'c++'"), so the C must also be valid C++ and
what links across files is `extern "C"`; the registration (`DM_DECLARE_EXTENSION` is
C++) is written out in `mmterrain.c`. Lua: `mmterrain.new(terrain.native_params(p))`,
`start` (a thread of its own where there are threads: desktop), `step(gen, secs)` (the
web: no threads), `done`, `vertices`, `fill(gen, buf[, from, to])` (the triangle strip
straight into the mesh buffer's streams), `result` (the flat arrays), `free` (header of
`mmterrain.c`). `moon.script` makes every moon mesh with it.
- `web/index.html`: the web build's page (`[html5] htmlfile`; see Web build).
- `screens/game/`: the game screen (above). Also `market.lua` (`ticker.lua` (pauses on hover; items not in the Tab order: `test.register(..., no_focus)`), `sale.lua` (the sell power-down, kept
by time on g and rebound every build; fx.lua asks `g.on_sale` for the coins' origin),
`chart.lua`
with crosshair and event spans), `moon_sale.lua` (the sell-moon offer and its confirm),
`refinery.lua` (refined stock chips, slots, recipes, quantity field; `next_end`
makes the state refetch), `leaderboard.lua`, `help.lua` (Start a new game asks through
the dialog), `equipment.lua` (tool cards, the drill panel in the aside for `drill=`),
`store.lua` (cards, the buy modal), `advisors.lua` (numbered seats, the hired-not-seated
list, cards; a hired advisor's panel in the aside for `adv=`: move, swap, give the seat,
unseat, dismiss; an unhired one's details on a card click),
`cards.lua` (what the card sections share: text formats, stat_up, pips, parts, confirm),
`reveal.lua` (the new-moon teaser and the reveal after `buy-moon`: charge, burst, card,
flight into the list; `g.reveal:hiding()` while it plays; "Buy another" from
`reveal.next`; the card's buttons take focus in scope "reveal"), `autoplay.lua`,
`autoplay_refinery.lua`, `predict_demo.lua` (scripted runs, below).
- `modules/fx.lua`: effects per action result (a port of the web's fx.js; `fx:on_response`
whenever the shown state changes: at the click for a predicted action, in the request
callback for an answer (`quiet` when it was predicted)), sparks, rings, motes into the
wallet, coins, floating numbers, sweeps, pulses; wallet counters (`fx:text`, `fx:bind`;
hold until particles land); the onboarding reticle on shining tabs. Sweeps cover the
clicked button's box (fx.js `closest(".card, .line, tr, ...")`): cards, rows and the
moon sale register with `fx:box(node)` while built; give a new card-like container one.
- `modules/sfx.lua`: sounds (the web's sfx.js): `sfx.play(name, {rate, gain, delay})`,
mute (saved), `mute()/unmute()` for ads, `set_hidden` (window unfocused). Files in `assets/sfx/` (`CREDITS.txt`); ZzFX
sounds pre-rendered by `node scripts/zzfx-render.mjs`.
- The last moon shown is saved per server with its player (`moon-<server>`, session.lua)
and asked for on start (the web keeps it in the URL; a fresh web start opens the first
moon). View links carry the current moon like the web's (`View.ToSection`): without
one the server picks the first moon.
- `modules/settings.lua`: the one settings file (`moonminers/settings`: mute, volume,
theme, reduced motion; reduced motion moves over once from the old `MoonMiners` folder).
- `modules/`: `theme.lua` (colors from app.css, resource colors, fonts, text styles,
sizes, button variants), `ui.lua` (above), `api.lua` (server API), `field.lua` (text
fields whose text and focus survive rebuilds), `screen.lua` (layout size, UI scale, display scale, fullscreen; saved in the settings
file),
`toasts.lua`, `predict.lua`, `tween_numbers.lua`, `images.lua` (server images on box
nodes, one texture per URL per scene), `anim.lua`, `test.lua` (test hook), `hot.lua`
(hot reload).
- `gui/templates/*.gui`: the building blocks (panel, rect, label, button, tab, chip,
moon_row, well_row), instanced once under the screen's disabled `protos` node; code
clones them (`ui.clone`).
- `widgets/moon_row.lua`: the moon list row (Druid widget).
- `assets/fonts/`: Rajdhani (sans), Oxanium (numbers), Inter SemiBold (`sym`: symbols the
other two lack: `₥`, `★`, `◆`, `▲`, `▼`, `●`, `›`, `≈`, Greek); distance field, built
at 40px and scaled. OFL 1.1. `assets/ui.atlas`: white, frame, outline, sphere, glow,
fade, ring, spark, speaker, sun and moon icons; tinted per node.
- The moon viewport: `modules/moon/terrain.lua` ports the settings side of the server's
`terrain.js` (defaults, resolve, traits, colors, the cube-sphere and its strip), and
`mmterrain/` the generation, in C (`scripts/moon-check.mjs` compares them with
terrain.js, many ids and settings; in `make check`), `modules/moon/moon.lua` is the API
(`moon.show(id, node)`, `hide`, `mystery`, `reveal`, `skip_reveal`, `revealing`,
`prefetch`: reveal.lua starts a bought moon's mesh as the buy-moon answer arrives),
`moon/moon.collection` (`moon.script`: meshes, spin, drag, zoom, shroud, reveal),
`moon/materials` (port of scene.js). `render/mm.render_script` draws the moon into an
HDR target with bloom; the viewport node shows it through the `moon` gui material.
View-vector lighting: `moon_glow` (atmosphere rim) and the halo compute it in view space
(`-mv.xyz`); `moon_surface` uses the `cam_pos` constant, verified to match the view
matrix (a debug render of |V(cam_pos) − V(view matrix)| was 0 over the whole moon).
- `scripts/`: `shot.sh` (headless screenshot), `views.sh` (every section, any build),
`fixture.py` (a test player), `drive.py` + `scenarios/` (test driver), `dev.py` (hot
reload), `check.py` (make check), `refinery-play.sh`, `moon-check.mjs`,
`zzfx-render.mjs`, `serve-web.py` and `web-shots.mjs` (Web build).
## Web build
`make web` bundles for the browser (`wasm-web`, release; `WEB_VARIANT=debug` keeps logs
and the test hook) into `dist/web/MoonMiners/` (release ~4.3 MB, ~2.4 MB gzipped: engine wasm 2.9 MB,
archive 1.1 MB, page and loader).
`make serve-web` (`scripts/serve-web.py`, `UPSTREAM=` default http://localhost:3000,
`WEB_PORT=` 8080) serves it and proxies `/api/*` and `/static/*` (everything the client
fetches) to the server, so the game talks to its own origin; the server stays unchanged.
- The page (`web/index.html`): the canvas fills the window in device pixels and follows
its size (the layout is native-pixel like desktop); dark loading screen; "WebGL 2
needed" without WebGL 2 (the moon's shaders don't build for WebGL 1). `[html5]` in
game.project: `stretch`, no Defold buttons or banner, heap 256 MB.
- On html5 (`if html5 then`): `api.base` is the page's origin (`?api=<url>` picks another
server, which must allow CORS); the player id and settings are saved with sys.save
(IndexedDB), X-MM-User sent as on desktop (the cookie works too, same origin).
Fullscreen (F11, display dialog) through the browser's Fullscreen API, not saved or
restored (the browser allows it only after a click or key). Moon generation is the
mmterrain extension in wasm (no threads there): stepped with a share of each frame
(30% of the frame time, 2–12 ms: ~2 ms at 144 Hz), the buffer filled over the next
frames, grid capped at 128 (`WEB_RESOLUTION` in moon.script). Moon targets fall back to 8 bits without EXT_color_buffer_float.
Variable frame rate there (`sys.set_update_frequency(0)` in main.script): the browser's
requestAnimationFrame paces at the display's rate; `display.update_frequency = 60`
(desktop's timer pacing) made the engine skip callbacks: 60 fps in a 2/3 cadence on
120/144 Hz displays, dropped frames on jittery 60 Hz ones. Use dt or the clock for motion.
- WebGL: every sampler of a bound program must not be the framebuffer being drawn
("feedback loop", the draw is dropped), and Firefox aborts on resizing a target with a
depth buffer: the render script binds every bloom sampler and makes targets again at a
new size. Check the console after render changes.
- Test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py --port 8161 --upstream <dev
server>`, then `node scripts/web-shots.mjs http://127.0.0.1:8161/ fx.json <out>
[chromium|firefox] [WxH]` (Playwright; fx.json from `scripts/fixture.py`): every section
by real mouse clicks (element rects from the test hook: the page pushes commands onto
`window.mmTestQueue`, replies are `mm-test` console lines), the fullscreen button, a
claim; fails on console errors.
## Testing
- Use your own server and database: `../scripts/wt up <task>` (see `../AGENTS.md`) prints
the `MM_API` to use. Never test against the dev server on :3000, never touch the user's
running game or their settings file (`~/.local/share/moonminers/settings`): give any game
you start its own `XDG_DATA_HOME` (drive.py does: `<out>/data`).
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`scripts/views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you
touch it. Add a scenario per feature (`scripts/scenarios/*.py`; each file's docstring
says what it checks and needs). Run `make check` (with `MM_API` set to your test
server; it makes a fixture player there) before calling it done.
- Drive commands (`scripts/drive.py`, through the test hook): `click(id | prefix |
"label=<text>")`, `hover`, `scroll_to`, `key`, `type`, `navigate`, `wait_for(lua expr)`,
`idle()`, `eval`, `elements()`, `state()`, `set(...)`, `shot(name)`, `log_has`,
`expect`, `restart()`, `cmd("frames", "reset")`; real X input: `os_click`, `os_move`,
`os_key`, `os_point`. `--attach $(cat dist/dev/port)` drives a `make dev`/`make run` game.
- `scripts/fixture.py <api>` makes a player with every section filled (needs an MM_DEV=1
server, which `wt up` starts).
- Layout lint instead of judging screenshots: `make lint MM_API=...` lints every section at
1280x800 and 1920x1080 and checks the clicker rule on a few actions (claim, collect,
sell, buy, upgrade); issues in `dist/drive/lint-<size>/lint-summary.json`. One screen:
`drive.py [--attach PORT] dump "{section='store'}"`. In a scenario: `game.lint(name)`,
`game.click_check(id)`, `game.dump(name)` (the test hook's `dump`: every shown node's
rect, kind, test id, text, clip). Rules and output: `scripts/ui_lint.py` header (overlap,
overflow, off_screen, small_hit, text_cut; moved, clicked_moved, clicked_gone). Add a
click_check to a feature's scenario when it has buttons people click repeatedly.
## Commands
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/` (`build/` is
bob's output; `build/x86_64-linux/dmengine` is the engine with our extensions).
- `make run ARGS="--config=mm.api_base=http://localhost:3001"`: build and start.
- `make web`, `make serve-web`: the browser build (Web build).
- `make shot`: run under `xvfb-run` at 1280x800, save `dist/shot.png`, quit. `SHOT=`,
`POPUP=1` (a dialog open), `SETTLE=5`, `SCREEN=1920x1080 make shot
ARGS="--config=mm.window=1920x1080"`. Log in `dist/shot.log`. Give 2560x1600
`SETTLE=15` (software GL). Silent unless `SOUND=1`.
- `make play`: the scripted play-through as a new player (`mm.autoplay`), a shot per
stage in `dist/play/`. `make play ARGS="--config=mm.api_base=http://localhost:3102"`.
- `make drive SCENARIO=<name> MM_API=<server> DRIVE="--fixture"`: a test-driver scenario
(below). `make dev`, `make reload`, `make dev-stop`: hot reload (below).
- `make check`: checks (below).
- Before/after screenshots of every section (works on any build, also one from before a
change): `scripts/fixture.py <api> > fx.json`, then
`MM_API=<api> scripts/views.sh <binary> <out dir> fx.json [WxH]` for each binary (keep
the old bundle: `cp -a dist/x86_64-linux/MoonMiners dist/baseline/`). Run both soon
after each other (the fixture's drills and market move on).
- Prediction check: see `predict_demo.lua` (`mm.predict_demo=1`, `mm.fake_latency_ms`,
`mm.fake_reject`, shots via `SHOTS_DIR`).
- Never kill processes by name (scripts stop the game by its pid).
- `make build`: `bob build bundle` (debug) into `dist/x86_64-linux/MoonMiners/`; `build/` is
bob's output, `build/x86_64-linux/dmengine` the engine with our extensions.
- `make run ARGS="--config=mm.api_base=http://localhost:3101"`: build, then run the game
from `build/default` in the foreground (loose files, engine service on), so `make reload`
works on it.
- `make dev` (same, in the background; `HEADLESS=1` under its own Xvfb), `make reload`,
`make dev-stop`: hot reload, below. State in `dist/dev/` (pid, port, display, game.log).
- `make shot`, `make play`, `make drive`, `make check`, `make web`, `make serve-web`: see
the Makefile comments. Web build test: `make web WEB_VARIANT=debug`, `scripts/serve-web.py`,
`node scripts/web-shots.mjs` (Playwright; header of the script).
## Test driver (`modules/test.lua`, `scripts/drive.py`)
The engine's debug HTTP service (debug builds; port from the log line `Engine service
started on port N`, `DM_SERVICE_PORT=dynamic`) runs Lua sent as `@system run_script`
(POST `/post/@system/run_script`, body: the `RunScript` protobuf, at most 1 KB). That Lua
runs in the same Lua state as the gui script but outside it, so it only pushes commands
onto the global `mm_test_queue`; the game screen runs them in its update (gui context)
and prints `mm-test <id> <json>` (long replies in `mm-test-part` lines first). drive.py
passes `mm.test_dir=<out>`: replies go to `<out>/mm-test-<id>.json` and the line says `@`.
The game it starts saves its files under `<out>/data` (XDG_DATA_HOME, or
`$MM_DRIVE_DATA`), never the player's own settings.
- Elements: every interactive component registers a stable id while it's built:
`act:<action>?<sorted params>` (H.action), `nav:<section>[/<moon>][/<well>]...`
(H.link), `tab:<section>`, `list:active|past`, `moon:<id>`, `well:<id>`,
`field:<key>`, `drill:<tool id>` (equipment cards), `confirm:<action>` (buttons that
ask first), `dialog:ok`, `dialog:cancel`, `dialog:<...>` (custom dialog buttons),
`mute`, `theme`, `motion`, `idle-copy`, `better-well`. A component given `id` uses it; give new interactive things an id.
- Commands (`drive.py` methods): `click(id | prefix | "label=<text>")` (press and release
over three frames through Druid: real input, hover included), `hover(id)`,
`scroll_to(id)`, `key("key_esc")`, `type(text)` (into the focused field),
`navigate("{section='market', pick='Sollux'}")`, `wait_for(lua expr, timeout)` (env:
`g`, `st`, `view`, `busy`, `el(id)`), `idle()` (no request in flight, built),
`eval(expr)`, `elements()`, `state()`, `set("reduced_motion", "1")`,
`set("scroll:<key>", px)`, `shot(name)`, `log_has(regex)`, `expect(cond, msg)`,
`restart()` (quit and start the game again, same options and saved files),
`cmd("frames", "reset")` (frame times since the last reset: fps, dt, the game screen's
update and its rebuilds in ms; the web harness reads it the same way). F3 in a debug
build shows the engine's profiler.
Those go through the test hook, which calls the screen's input function directly, so
they work even when the engine sends the game no input. Real input through the X server
(xdotool on the driver's Xvfb, its own display from `Xvfb -displayfd`: `xvfb-run -a` raced
when two runs started together; key and button held 80 ms like a hand; the engine polls
once a frame, and mmwindow's sticky keys and buttons keep a press shorter than a frame): `os_click(id | prefix)`, `os_move(x, y)`, `os_key("Escape")`, `os_point(id)`.
`g.engine_inputs` counts the engine's own `on_input` calls, `g.builds` the rebuilds.
- Run: `scripts/drive.py --api <server> [--fixture | --fixture-file fx.json] [--size
2560x1600] [--config mm.x=y] <scenario> [args]`. It starts the engine from
`build/default` (`--bundle`: the bundle) under its own Xvfb (silent), drives it, and
writes screenshots and `game.log` to `dist/drive/<scenario>/`. `--attach $(cat
dist/dev/port)` drives the `make dev` game instead (screenshots from its display).
- Scenarios (`scripts/scenarios/*.py`, `run(game, args)`): `smoke` (clicks: tab, moon
rows, well, a predicted claim, Esc), `sections` (a shot of every section and panel),
`dialog` (confirm: open, Esc, OK), `shot` (one screenshot), `feedback` (with
`--config mm.fake_reject=claim:1`: error shake and toast, claim toast and wallet pop,
a spend, Claim ready at zero, reduced motion), `keys` (Tab focus and Enter, history
keys, Enter in the sell and refine fields, tooltips, the drill panel under the
viewport), `cards` (tool card opens the drill panel, advisor card the aside, hover),
`texts` (the server's confirms: sell attachment, buy seat, buy slot, new game; the theme
toggle), `lists` (depleted and past rows, mined-out collapse, new rows, deploy cards;
changes its own dev database through podman: `MM_DB`), `sell` (power-down;
`reduced`), `tabreveal` (a `--plain` fixture file), `wellpanel`, `polish` (wells table bars and "+N",
well panel order, local ready counts with `--config mm.fake_latency_ms=4000` and arg
`ready`, the reduced-motion toggle and reveal, links, market hover, recipes), `aside`
(where the aside shows; keys scroll the Store), `small` (`--size 1024x640`: the aside's
row, the page scroll, banner wrap), `display` (the display dialog, Ctrl zoom keys, F11,
saved settings, silent while hidden), `errors` (through `scripts/errproxy.py`, a proxy
that fails on demand: boot 500/429, a 500 and a timeout in play, a ban), `boot` (the
start screen: suspended, rate limited, no server), `advisors` (seats, panel, swap,
unseat, seat, dismiss), `buyanother` (the reveal card's Buy another, by keyboard;
disabled when short), `pastpage` (Tab through a long Active list, past pages; `MM_DB`),
`servertexts` (well note, reach, market texts), `shell` (Claim all badge, rating chips,
search kept and Esc, wrapping toast, warning banner, Up/Down in the sell amount, ticker
out of the Tab order, Space scroll, silent back), `moonview` (wells table bars and Manage,
drills that get nothing, a tapped-out well, the moon sale opened; `MM_DB`), `finalcheck`
(market chart labels, stats, Max, the selling note; refinery ready slot; leaderboard;
buy modal; confirm OK focus; new game without effects), `inputlock` (every way a dialog,
the buy modal, the reveal card or a tooltip goes away, then a real click must reach the
page at once; the invariant's repair; args `reduced`, and run it with `--config
mm.fake_latency_ms=1500`), `navmemory` (a market pick lights one card, no entrance, same
moon; the Moons tab returns to the last moon after other sections, back / forward, a
refetch and a restart), `clickfast` (real clicks at 0–500 ms after Claim, Claim all,
Claim N drills, Claim and redeploy, Redrill and Collect turn on: sent and predicted at
once; a rebuild between press and release; args: cases, `reduced`, `edge`; run it also
with `--config mm.fake_latency_ms=1500`). drive.py runs save under their output
folder (`<out>/data`), never the real settings file. Run with `--config
mm.theme=light` for the light theme (forced: not saved). Add one per feature.
- `scripts/fixture.py <api>` makes a player with every section filled (MM_DEV=1 server:
grants through `/utils/players`); prints its ids as JSON.
## Fast iteration (hot reload)
`make dev` builds once and starts the game from the build folder (engine
`build/x86_64-linux/dmengine`, `--config=resource.uri=build/default`: loose files, not the
bundle's archive) in the background (`HEADLESS=1` under its own Xvfb, silent;
`ARGS="--config=mm.api_base=... --config=mm.player=..."`). State in `dist/dev/` (pid,
port, display, game.log). Then edit and `make reload`: `bob build` (no bundle, ~5 s) and
`scripts/dev.py reload` posts every changed file to the engine's resource reload (what
the editor does: `/post/@resource/reload`, one path per message): new Lua modules are
registered first (a `run_script` listing them), then `.luac` files, then the game's
`.gui_scriptc`, whose chunk runs again: `modules/hot.lua` drops our modules from
`package.loaded`, so its requires load the new code, and `on_reload` rebinds `g.fx`,
`g.toasts`, `g.tween`, `g.reveal`, `g.dialog` to the new module tables, makes missing
layers (`ensure`) and rebuilds. The screen keeps its state (player, view, scroll offsets).
- Stays loaded across a reload (module state shared with other scripts): `modules.moon.*`,
`modules.api`, `modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`. Changes
to those, to collections, game objects, `main.script`, materials, shaders or the render
script need a restart: `make dev-stop dev` (reload says so).
- New fields that `init` sets on `g` don't exist after a reload; make them in `ensure`
## Hot reload (`make reload`, `scripts/dev.py`)
`make reload` runs `bob build` (no bundle, a few seconds) and posts every changed file in
`build/default` to the running debug game's resource reload (`/post/@resource/reload`, as
the editor does); the game's gui script runs again and rebuilds, keeping its state (player,
view, scroll offsets). It fails loudly on a compile error and keeps the old code running.
- Reloads: our Lua modules (`modules/`, `screens/`, `widgets/`, `components/`) and the
game's `.gui_script` (tested). Other changed resources (`.gui`, atlases, fonts) are sent
too; restart if one looks wrong.
- Needs a restart (reload says so; `make dev-stop dev`, or quit and `make run`):
collections, game objects, `main.script`, materials, shaders, the render script; modules
that hold state shared with other scripts (`modules.moon.*`, `modules.api`,
`modules.sfx`, `modules.images`, `modules.screen`, `modules.hot`); `game.project`;
native extensions and dependencies (a new engine).
- New fields that `init` sets on `g` don't exist after a reload: make them in `ensure`
(core/main.lua) or lazily.
- `make reload` fails loudly on a compile error (and keeps the old code running).
- Look with `scripts/drive.py --attach $(cat dist/dev/port) --out dist/dev shot <name>`.
## Checks (`make check`, scripts/check.py)
Fails on: the flex solver's unit tests (`luajit scripts/flex_test.lua`); Lua syntax errors (every `.lua`, `.script`, `.gui_script`, `.render_script`,
luajit); stale debug flags (`[mm]` keys or values in `game.project` other than the shipped
defaults, `DEBUG`/`XXX`/`HACK` markers in sources and shaders); characters the fonts lack
(non-ASCII in Lua string literals: in the sym font or in the text fonts; server texts of
a fixture player in every section: symbols in sym, the rest in the text fonts); the moon
generator (`node scripts/moon-check.mjs [resolution] [seeds...]`:
`mmterrain/src/terrain.c`, built for the host with `cc` and loaded into luajit through the
FFI, and terrain.lua's settings side, against terrain.js; needs node, luajit, cc); and engine warnings in a `sections` scenario log (font batches, characters, text entries,
nodes, animations, stencil, invalid markup tags, script errors). The scenario runs
against `MM_API` (required, no default: the checks make a fixture player there, so use a test server, never the dev one) when it answers; otherwise the newest
`dist/drive/*/game.log` is checked. `scripts/check.py --static` skips it.
## Map
Game screen (`screens/game/`); `game.gui_script` forwards to `core/main.lua`. Module
contract: `M.build(g, root, x, y, w, h, H)`, optional `M.aside`, `M.aside_below`,
`M.update(g, dt)`, `M.teardown(g)` (header of core/main.lua). A new section: a module here,
an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
- `core/main.lua`: lifecycle, rebuilds, build orchestration, entrances, `on_reload`.
- `core/session.lua`: state, server requests (one at a time), prediction hookup, history.
- `core/hooks.lua`: the `H` helpers (`action`, `link`, `claim_wait`, text blocks).
- `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).
- 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`
(shared by card sections).
- Scripted runs: `autoplay.lua`, `autoplay_refinery.lua`, `predict_demo.lua`.
## Config (`[mm]` in game.project, or `--config=mm.<key>=<value>` at launch)
- `api_base`: server URL (default `http://localhost:3000`; on the web the page's origin). The player id is saved per
server.
- `new_player`: `1` forgets the saved player on start. `player`: play as this id.
- `autoplay`: `1` runs the scripted play-through (`make play`); `autoplay_script=refinery`.
- `quit_after`: exit after N seconds. `popup`: `1` opens a dialog on start (screenshots).
- `moon_id`: show this moon in the viewport; `moon_spin`: `0` stops the spin.
- `window`: `WxH` resizes the window on start. Don't change `display.width/height`.
- `view`: the view to open, `key=value&...` of `state.view` keys
(`section=equipment&drill=<id>`).
- `reduced_motion`: `1` on, `0` off (default: the saved setting).
- `theme`: `light` or `dark` (default: the saved setting, else dark).
- `test`: `0` turns the test hook off in a debug build (it's never in release).
- Debug, prediction: `fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`,
`tween_ms`, `predict_demo`. `reveal_freeze`, `sfx_debug`.
- `ui_scale`: force the UI scale (window pixels per layout unit, times the display's
scale; not saved). Default: the saved setting (the display dialog), else 1.
- `display_scale`: pretend the display has this scale (HiDPI checks: `--size 2560x1600
--config mm.display_scale=2` looks like 1280x800).
Only the keys listed in `scripts/check.py` `MM_DEFAULTS` belong in `game.project`; pass
the others on the command line.
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.
## Conventions
- The game is one Monarch screen (`game`). Dialogs are `g.dialog` (components/dialog),
not screens.
- Every gui_script uses one Druid instance (`druid.new(self)` and the
final/update/on_message/on_input forwarding); the dialog has its own. Use Druid for
interaction (buttons, hover, scroll, input, layout, data list) through the components.
- Build from templates cloned at runtime (`ui.clone`), not hand-placed nodes. A new kind
of element: a template in `gui/templates/`, instanced under `protos` in `game.gui`.
- Template roots are pivot NW; layout code is top-left based (x right, y down). Sizes and
colors come from `theme.lua`.
- Scaling (`modules/screen.lua`): the layout size is the window size / (UI scale x the
display's scale) (a 2560x1600 window gets a 2560x1600 layout: same-size text, more
room; on a 2x display, 1280x800); below 960x600 it scales down. Below 1250 wide
(app.css's breakpoint) the aside, when shown, gets its own row under the moon list and
main panel (viewport left, side panel right) and the page scrolls (`page` scroll area;
picking a well or drill scrolls to it). Card grids: 250 px columns like the web; cards
narrower than `card_min_w` put the image on top. Moon list 15% within `moonlist_w`..`moonlist_w_max` (`moonlist_w_min` when narrow), aside 33%
within `aside_w_min`..`aside_w_max`, main panel the rest; content capped
(`content_max`, `market_max`, `market_panel_max`, `text_max`, `cards_max`,
`refinery_max`, `leaderboard_max`) and centered; lists show more rows when taller. At
1280x800 every cap is above what fits; keep it that way (compare shots).
- Theme: dark and light palettes (`theme.palettes`, from app.css); `theme.set` changes the
`theme.color` vectors in place and the header toggle saves it (settings file). Every color
is a token: no hex or vector colors in code (template node colors via `theme.template`,
config colors via `theme.item`, glows via `theme.glow_blend()`: additive on dark, paint
on light). The moon viewport stays dark (`viewport`, `space_*` tokens).
- Text: styles from `theme.text`; `₥`, `★` and the other symbols through the `sym` font
(`ui.credits`, `ui.label`). Add characters to a `.font`'s `characters` (make check
tells you which).
- Node budget: `max_nodes: 4096` in `game.gui`, `graphics.max_font_batches` and
`max_characters` raised in `game.project`. Text past the font batch limit silently
doesn't render (make check catches the warning); stencils (scroll areas, lists, the
dialog) and images split font batches.
- Outline textures use slice9 2 (not 1).
- `api.lua`: `api.get_state(params, cb)`, `api.act(name, params, cb)`; `cb(err, data,
status)`. The player id travels in `X-MM-User`. API: `../server/api.md`.
- The server is the only source of truth: every response's `state` replaces ours and
`state.view` becomes our view; actions send the current view plus their params and
`clientSeq`. Action buttons go through `H.action(g, b, nodes, name, params, on_done,
predictor, id)`. Countdowns tick locally (display only); the state is fetched again
when `nextEnd` is due.
- Prediction: `g.base` + `g.pending` re-applied in order (`predict.apply`); requests go
out one at a time in click order. Predicted: claim (incl. redeploy; a drill counts as
ready once its end passed on our clock; claim-well leaves multi-well drills alone, like
the server; a plain claim raises the well's Redrill count), stake, redrill, sell,
refine, collect. Others (and predictors that return false) show a busy button.
A predictor: `predict.register("<action>", function(st, params, ctx) ... end)`, using
`ctx.now`, the helpers in `predict.lua`, only numbers the server sent.
- Numbers that change ease (`H.tick_num`, keyed `tick_bar`/`tick_text`); the wallet
counts through fx.
- After a visual change, look at it: `make drive SCENARIO=sections MM_API=...` (or
`views.sh` against a baseline) at 1280x800 and 2560x1600, and the dialog when you touch
it. Run `make check` before calling it done.
- Keep changes small; don't touch the server (`../server`) from here.
Modules (`modules/`):
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
(applies it to nodes, tree builders), `blocks.lua` (kept blocks, journal). For text and
parts that come and go, use flex_gui's guards: `fg.reserve(style, kid_or_nil)` (a fixed
slot whether its content shows: a "Need ₥ more" line, a count, a note), `fg.label(...,
{ fit = true, shrink_text = 0.8 })` (one line: shrink, then cut with …), `fg.para(...,
{ lines = 2 })` (at most n lines, cut with …).
- `ui.lua` (clone, text, rects, markup), `theme.lua`, `anim.lua`, `fx.lua`, `sfx.lua`,
`toasts.lua`, `tooltip.lua`, `focus.lua`, `field.lua`, `screen.lua` (scaling, fullscreen),
`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).
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`).
- `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.
- `scripts/`: `dev.py` (run/hot reload), `drive.py` + `scenarios/`, `check.py`,
`fixture.py`, `shot.sh`, `views.sh`, `serve-web.py`, `web-shots.mjs`, `errproxy.py`.
## Stack
Defold 1.14.0 beta (editor in `/opt/Defold`, engine sha1
`6823d775883a04f4210bf04cd54cbffc468ac0ad`, `tools/bob.jar` of the same sha). Dependencies
(pinned in `game.project`): Druid 1.4.1, Monarch 6.0.3, Defold Event 21 (native, so the
engine comes from Defold's build server; bob caches it in `.internal/cache` until the
native inputs change). Druid and Defold GUI findings: `../../druid-notes.md`. Sources
(read-only): `~/Development/Refold/druid`, `~/Development/Refold/Defold`.
## Config (`--config=mm.<key>=<value>`)
`api_base` (server; default `http://localhost:3000`, on the web the page's origin; player id
saved per server), `player`, `new_player=1`, `view` (`section=equipment&drill=<id>`),
`window=WxH`, `ui_scale`, `display_scale`, `theme`, `reduced_motion`, `moon_id`,
`moon_spin=0`, `autoplay`, `autoplay_script`, `quit_after`, `popup`, `test=0`; debug:
`fake_latency_ms`, `fake_reject=<action>[:n]`, `predict_skew`, `tween_ms`, `predict_demo`,
`reveal_freeze`, `sfx_debug`.
+13 -3
View File
@@ -11,7 +11,7 @@ SHOT ?= dist/shot.png
# Extra engine args, e.g. ARGS="--config=mm.api_base=http://localhost:3001"
ARGS ?=
.PHONY: build run shot play clean resolve dev reload dev-stop drive check web serve-web
.PHONY: build run shot play clean resolve dev reload dev-stop drive lint check web serve-web
$(BOB):
mkdir -p tools
@@ -52,7 +52,7 @@ reload: $(BOB)
@mkdir -p dist/dev
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/dev/bob.log 2>&1 \
|| { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
scripts/dev.py reload
@scripts/dev.py reload
dev-stop:
scripts/dev.py stop
@@ -67,6 +67,14 @@ drive: $(BOB) .internal/lib/dependencies.json
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO)
# Layout lint of every section at 1280x800 and 1920x1080 (scenarios/lint.py, ui_lint.py):
# dist/drive/lint-<size>/. Needs MM_API (a test server).
lint: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist
@java -jar $(BOB) --platform $(PLATFORM) --variant $(VARIANT) build >dist/bob-drive.log 2>&1 \
|| { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
@for s in 1280x800 1920x1080; do scripts/drive.py --fixture --size $$s --out dist/drive/lint-$$s lint $$s; done
# Checks (scripts/check.py): Lua syntax, debug flags, font coverage, and the log of a
# scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json
@@ -75,8 +83,10 @@ check: $(BOB) .internal/lib/dependencies.json
|| { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py
# The game from build/default (loose files, engine service on), so `make reload` works on it.
# The bundle in dist/ is for shot, play and shipping.
run: build
$(BIN) $(ARGS)
scripts/dev.py run $(ARGS)
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build
Binary file not shown.

After

Width:  |  Height:  |  Size: 877 B

+2 -1
View File
@@ -27,7 +27,8 @@ The claim whir, counter ticks, claim blips, credit blips and the moon reveal sou
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js).
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
reveal's build-up and burst, client only) are synthesized by the same script.
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -31,6 +31,9 @@ images {
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/gear.png"
}
images {
image: "/assets/images/moon.png"
}
+20 -2
View File
@@ -8,6 +8,8 @@
-- y is the chip's middle; opts: color (edge; the text too unless opts.text), alpha (edge
-- alpha), text, fill (fill alpha, in the edge color), font, px, tracking, upper, h (18),
-- pad (6 each side), min_w, right (x is the right edge).
--
-- chip.grade(parent, "B", x, y, { h = 16 }) -- a moon grade badge (D..S), GRADE_W wide
local ui = require("modules.ui")
@@ -26,11 +28,27 @@ function M.new(parent, label, x, y, opts)
local left = opts.right and (x - w) or x
local box = ui.outline(parent, left, y - h / 2, w, h, color, opts.alpha)
if opts.fill then
local f = ui.rect(box, 0, 0, w, h, color, opts.fill)
gui.move_below(f, box)
-- A plain box holds the fill and the edge above it (gui.move_below on a child would
-- move the fill out to box's parent, at its 0, 0).
local edge = box
box = ui.rect(parent, left, y - h / 2, w, h, color, opts.fill)
gui.set_parent(edge, box, false)
gui.set_position(edge, vmath.vector3(0, 0, 0))
end
ui.text(box, label, style, w / 2, h / 2, gui.PIVOT_CENTER)
return box, w
end
-- Moon grade badges: edge/text color per grade, cheapest to best.
M.GRADE = { D = "line_hi", C = "text_2", B = "cyan", A = "violet_hi", S = "ready" }
M.GRADE_W = 18
function M.grade(parent, grade, x, y, opts)
opts = opts or {}
local color = M.GRADE[grade] or "line_hi"
return M.new(parent, grade, x, y, { color = color, text = grade == "D" and "muted" or color,
font = "mono_semi", px = opts.px or 11, h = opts.h or 16, pad = 3, min_w = opts.w or M.GRADE_W,
right = opts.right })
end
return M
+19 -3
View File
@@ -7,6 +7,9 @@
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
-- backdrop (a click outside still closes it), no title or buttons, its top-right
-- corner `right` from the page's right edge and `top` down; build makes the content.
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
@@ -107,12 +110,13 @@ function M:open(opts)
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
local menu = opts.menu
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1)
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
@@ -139,6 +143,13 @@ function M:open(opts)
d:close("cancel")
end)
if menu then
opts.build(self, box, w, h)
test.set_scope("page")
sfx.play("toggle")
return
end
-- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0)
local g_body = anim.group(box, 0, 0)
@@ -309,8 +320,13 @@ function M:layout(W, H)
gui.set_size(self.root, vec(W, H))
gui.set_size(self.scrim, vec(W, H))
gui.cancel_animations(self.holder, "position.y")
local menu = self.opts and self.opts.menu
if menu then
gui.set_position(self.holder, vec(W - menu.right - self.w / 2, -(menu.top + self.h / 2)))
else
gui.set_position(self.holder, vec(W / 2, -H / 2))
end
end
function M:update(dt)
if self.druid then
+10
View File
@@ -50,6 +50,16 @@ embedded_instances {
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"rumble\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"reveal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
+38 -2
View File
@@ -15,8 +15,9 @@
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
-- decaying noise shake (sell power-down, reveal burst)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
-- reveal burst) or anim.noise_shake for a
-- decaying value-noise shake (sell power-down)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
@@ -389,6 +390,41 @@ function M.noise_shake(node, amp, dur, freq)
end)
end
-- 1D Perlin (gradient) noise, two octaves: smooth, about -1..1, 0 at whole x. The reveal's
-- screen shake.
local function grad(i, seed)
local s = math.sin((i + seed) * 12.9898) * 43758.5453
return (s - math.floor(s)) * 2 - 1
end
local function perlin1(x, seed)
local i = math.floor(x)
local f = x - i
local u = f * f * f * (f * (f * 6 - 15) + 10)
local a, b = grad(i, seed) * f, grad(i + 1, seed) * (f - 1)
return (a + (b - a) * u) * 2
end
function M.perlin(x, seed)
return (perlin1(x, seed) + 0.5 * perlin1(x * 2.03 + 17.3, seed + 7.1)) / 1.2
end
-- A decaying Perlin shake in x and y: amp px, dur s, freq noise cells per second.
function M.perlin_shake(node, amp, dur, freq)
if M.reduced or not node then
return
end
local p = gui.get_position(node)
local seed = math.random() * 100
M.tween(node, dur, function(k, n)
local a = amp * (1 - k) ^ 2
local t = k * dur * freq
gui.set_position(n, v3(p.x + M.perlin(t, seed) * a, p.y + M.perlin(t, seed + 31.7) * a, p.z))
end, 0, function()
pcall(gui.set_position, node, p)
end)
end
-- Tint to `color` and back over dur (a flash).
function M.flash(node, color, dur)
local c = gui.get_color(node)
+6
View File
@@ -179,6 +179,12 @@ function M.get_state(params, cb)
request("GET", "/api/state", params, cb)
end
-- Whether the player has a save code: data.saveCode.madeAt (unix ms, 0: none). The code
-- itself comes only from the save-code action; restore is the action restore (code).
function M.get_save_code(cb)
request("GET", "/api/save-code", nil, 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
+73 -10
View File
@@ -104,7 +104,8 @@ end
-- One line of text (ui.text, pivot W: its middle at the box's) in a line box h tall, as
-- wide as its text (trailing spaces count). opts: more box style; opts.fit cuts the text
-- with ... to the width it gets. Text with symbols (ui.needs_rich) goes through ui.rich
-- with ... to the width it gets; opts.shrink_text = k (0..1) first scales it down to fit,
-- to k of its size at most (then cuts). Text with symbols (ui.needs_rich) goes through ui.rich
-- (not cut).
function M.label(parent, str, style, h, opts)
if ui.needs_rich(str) and not (type(style) == "table" and style.markup) then -- (symbols: ui.rich runs, a container whose y is the middle)
@@ -124,20 +125,79 @@ function M.label(parent, str, style, h, opts)
gui.set_text(n, t)
end
local s = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = n, size = false, fit = fit, style = s, measure = function() return w, h end }
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h
return { node = n, size = false, fit = fit, shrink = shrink, style = s, measure = function() return w, h end }
end
-- A paragraph wrapped to the width it gets, lines lh apart (CSS line-height). opts: more
-- box style (max_width, ...).
-- box style (max_width, ...); opts.lines = n: at most n lines, the rest cut with "…" (so
-- long text can't push what's under it).
function M.para(parent, str, style, lh, opts)
local n = ui.text(parent, str, style, 0, 0, gui.PIVOT_NW)
gui.set_line_break(n, true)
local one = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), "Ag")
local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line)
return { node = n, shift = (lh - line) / 2, style = opts or {}, measure = M.text_measure(n, lh) }
local s = opts or {}
local lines = s.lines
s.lines = nil
local measure = M.text_measure(n, lh)
return { node = n, shift = (lh - line) / 2, style = s, clamp = lines and { lines, lh },
measure = lines and function(avail)
local w, h = measure(avail)
return w, math.min(h, lines * lh)
end or measure }
end
-- Cut a wrapping text node's text with "…" (whole words, then letters) until it takes at
-- most n lines lh apart at width w.
function M.clamp_lines(node, n, lh, w)
local measure = M.text_measure(node, lh)
local text = gui.get_text(node)
local function over()
local _, h = measure(w)
return h > n * lh + 0.5
end
if not over() then
return
end
while #text > 1 do
local cut = text:gsub("%s*%S+%s*$", "")
if cut == "" or cut == text then -- (one word left: a letter at a time, whole UTF-8 chars)
cut = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", "")
end
text = cut:gsub("[%s,;:.]+$", "")
gui.set_text(node, text .. "…")
if not over() then
return
end
end
end
-- Fit one line of text to max_w: scaled down to `shrink` (0..1) of its size at most, if
-- given, then cut with "…" (ui.fit_text).
function M.fit_line(node, max_w, shrink)
local w = ui.text_width(node)
if shrink and w > max_w then
local sc = gui.get_scale(node)
local k = math.max(shrink, max_w / w)
gui.set_scale(node, vmath.vector3(sc.x * k, sc.y * k, sc.z))
end
ui.fit_text(node, max_w)
end
-- A reserved slot: a box of a fixed size whether its content shows or not (the "Need ₥
-- more" line under Buy, a note above cards, Claim all's place), so what shows in it can
-- never move what's around it. style: its size (width and/or height) and more box style;
-- kid: the content box, or nil (shown empty). The content is laid out inside the slot
-- and cut to it if it's bigger (clip text with fit/shrink/lines).
function M.reserve(style, kid)
style = style or {}
if style.shrink == nil then
style.shrink = 0
end
return M.box(style, { kid })
end
-- Tree pieces (shared by the flex-first sections). Children lists may have holes
@@ -164,9 +224,9 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
-- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts)
local s = opts or {}
local fit = s.fit
s.fit, s.height = nil, h
return { node = node, size = false, fit = fit, style = s, measure = function() return ui.text_width(node), h end }
local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h
return { node = node, size = false, fit = fit, shrink = shrink, style = s, measure = function() return ui.text_width(node), h end }
end
-- A paragraph with the font's own leading (what H.para draws), pivot NW, wrapped to the
@@ -290,7 +350,10 @@ function M.build(tree, parent)
gui.set_size(node, vmath.vector3(b.w / sc.x, b.h / sc.y, 0))
end
if b.fit then
ui.fit_text(node, b.w)
M.fit_line(node, b.w, b.shrink)
end
if b.clamp then
M.clamp_lines(node, b.clamp[1], b.clamp[2], b.w)
end
p, ox, oy = node, b.x, b.y
end
+4 -6
View File
@@ -102,6 +102,8 @@ end
-- A box an action's sweep covers when its button is inside (fx.js: the button's .card,
-- .line, row or heading): cards and list rows register while they're built.
-- Sweeps cover the clicked button's box (fx.js closest(".card, .line, tr, ...")): cards,
-- rows and the moon sale register here while built; a new card-like container must too.
function M:box(node)
blocks.push(self, "boxes", node)
end
@@ -878,7 +880,8 @@ function M:on_response(g, kind, data, prev, st, opts)
if credits > 0 then
self:count("credits", self:value("credits", prev.credits), st.credits, { held = not quiet or self:holding("credits") })
elseif credits < 0 then
self:count("credits", self:value("credits", prev.credits), st.credits, { held = revealing or self:holding("credits") })
-- (a bought moon's price too: it counts down at the purchase, while the reveal plays)
self:count("credits", self:value("credits", prev.credits), st.credits, { held = self:holding("credits") })
end
if quiet then
@@ -1005,13 +1008,8 @@ function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
{ key = "spend", amount = -credits, fmt = function(a) return "-" .. theme.currency .. ui.money(a) end })
end
end
if revealing then
self.waiting.revealed = self.waiting.revealed or {}
table.insert(self.waiting.revealed, spend)
else
spend()
end
end
if BUY_KINDS[k] then
if at and not revealing then
+6 -5
View File
@@ -6,7 +6,8 @@
-- call again after the layout moves the node (it reads the node's
-- screen rect). The node gets the "moon" material (listed in the
-- .gui's materials), which draws the rendered moon.
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
-- moon.move(node[, quiet]) the node moved (the narrow layout's page scroll, the reveal's
-- window sliding and shaking): follow it; quiet: no log line.
-- moon.hide() no moon (the node keeps its own look).
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
@@ -71,12 +72,12 @@ local function node_rect(node)
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
end
local function set_node(node)
local function set_node(node, quiet)
if node then
local old = M.rect
M.rect, M.unit = node_rect(node)
local r = M.rect
if not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h then
if not quiet and (not old or old.x ~= r.x or old.y ~= r.y or old.w ~= r.w or old.h ~= r.h) then
print(string.format("moon: viewport %d,%d %dx%d (window pixels, from bottom-left)", r.x, r.y, r.w, r.h))
end
gui.set_material(node, "moon")
@@ -98,9 +99,9 @@ function M.show(id, node)
end
-- The viewport node moved (a scrolling page): the moon follows it, nothing else changes.
function M.move(node)
function M.move(node, quiet)
if node and (M.id or M.mode) then
set_node(node)
set_node(node, quiet)
end
end
+246 -36
View File
@@ -189,18 +189,172 @@ local function yield_boost(st, resource)
return b[resource] or 1
end
---------------------------------------------------------------- shared wells
-- Ports of the server's well split (game.go coverWells, splitClaim; views.go split,
-- project, takeAfter), api.md "Prediction": claims go oldest first, each from what the
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
local EPS = 1e-9
-- The wells a stake with this reach mines: its anchor first, then the richest (ties by
-- order), leaving out empty ones.
local function cover_wells(anchor, supply, reach)
local others = {}
for i, x in ipairs(supply) do
if i ~= anchor and x > 0 then
others[#others + 1] = i
end
end
table.sort(others, function(a, b)
if supply[a] ~= supply[b] then
return supply[a] > supply[b]
end
return a < b
end)
local out = {}
if anchor and supply[anchor] and supply[anchor] > 0 then
out[1] = anchor
end
for _, i in ipairs(others) do
out[#out + 1] = i
end
local n = math.min(#out, math.max(1, reach or 1))
for i = #out, n + 1, -1 do
out[i] = nil
end
return out
end
local function well_index(moon, id)
for i, w in ipairs(moon.wells or {}) do
if w.id == id then
return i
end
end
end
-- What claiming a stake (wellId, amount, reach) takes from each well, given supply.
local function split(moon, s, supply)
local take = {}
for i = 1, #supply do
take[i] = 0
end
local anchor = well_index(moon, s.wellId)
if (s.reach or 1) <= 1 then
if anchor then
take[anchor] = math.max(0, math.min(s.amount, supply[anchor]))
end
return take
end
local amount = s.amount
for _, i in ipairs(cover_wells(anchor, supply, s.reach)) do
take[i] = math.max(0, math.min(amount, supply[i]))
amount = amount - take[i]
if amount <= 0 then
break
end
end
return take
end
local function supplies(moon)
local out = {}
for i, w in ipairs(moon.wells or {}) do
out[i] = w.supply
end
return out
end
local function sum(t)
local n = 0
for _, x in ipairs(t) do
n = n + x
end
return n
end
-- The moon's stakes, oldest first (ties in the server's order).
local function moon_stakes(st, moon)
local list = {}
for i, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
list[#list + 1] = { s = s, i = i }
end
end
table.sort(list, function(a, b)
if a.s.start ~= b.s.start then
return a.s.start < b.s.start
end
return a.i < b.i
end)
for i, x in ipairs(list) do
list[i] = x.s
end
return list
end
-- The server's projection (views.go project) of the moon's unclaimed drills.
function M.project(st, moon)
local n = #(moon.wells or {})
local p = { take = {}, gets = {}, demand = {}, remain = supplies(moon), claims = {}, dry_after = {}, dry_at = {} }
for i = 1, n do
p.demand[i], p.claims[i], p.dry_after[i] = 0, 0, 0
end
local current, last = supplies(moon), {}
for _, s in ipairs(moon_stakes(st, moon)) do
if (s.reach or 1) <= 1 then
local i = well_index(moon, s.wellId)
if i then
p.demand[i] = p.demand[i] + s.amount
p.claims[i] = p.claims[i] + 1
last[i] = math.max(last[i] or 0, s["end"])
end
else
for i, t in ipairs(split(moon, s, current)) do
p.demand[i] = p.demand[i] + t
end
end
local take = split(moon, s, p.remain)
p.take[s.id], p.gets[s.id] = take, sum(take)
for i, t in ipairs(take) do
if (s.reach or 1) > 1 and t > 0 then
p.claims[i] = p.claims[i] + 1
last[i] = math.max(last[i] or 0, s["end"])
end
p.remain[i] = p.remain[i] - t
if t > 0 and p.remain[i] <= EPS and p.dry_after[i] == 0 then
p.remain[i] = 0
p.dry_after[i], p.dry_at[i] = p.claims[i], last[i]
end
end
end
return p
end
-- What a new stake (amount, reach) on the well would get after the moon's drills (the
-- server's takeAfter; 0: the server refuses it, "Nothing left to mine on this well").
function M.take_after(moon, p, well_id, amount, reach)
return sum(split(moon, { wellId = well_id, amount = amount, reach = reach }, p.remain))
end
-- Room for a new one-well stake of amount on the well (0 when fully committed); nil when
-- the well isn't on the selected moon (no projection to go by).
function M.room(st, well_id, amount, reach)
local moon = st.moon
if not moon or not well_index(moon, well_id) then
return nil
end
return M.take_after(moon, M.project(st, moon), well_id, amount, reach)
end
-- A new stake of tool on well (a moon list or moon detail entry) starting at ctx.now.
-- group: the well panel's idle group for it (cycle and yield already for this well).
function M.add_stake(st, tool, moon_id, well, ctx, group)
local cycle = group and group.cycleSecs or tool.cycleSecs
local amount = group and group.yield or tool.yield * yield_boost(st, well.resource)
local committed = 0
for _, s in ipairs(st.stakes) do
if s.wellId == well.id then
committed = committed + (s.take or 0)
end
end
local take = math.max(0, math.min(amount, M.supply(st, well.id) - committed))
-- take: what claiming it now would get, as the server's stake.take (its share after
-- the other drills is derive's projection).
local take = math.max(0, math.min(amount, M.supply(st, well.id)))
local s = {
id = "pred:" .. ctx.seq .. ":" .. tool.id, moonId = moon_id, wellId = well.id, toolId = tool.id,
resource = well.resource, amount = amount, reach = tool.reach or 1,
@@ -279,7 +433,8 @@ function M.remove_stake(st, s)
end
-- Recompute what the server derives from stakes, tools and supplies (counts, the claim
-- buttons' amounts, next ends). Wide drills (reach > 1) are left as the server sent them.
-- buttons' amounts, next ends) and the selected moon's well projection (M.project: demand,
-- remain, full, notes, deploy cards). The wide group (reach > 1) is left as the server sent it.
function M.derive(st)
local ready, idle, next_end = 0, 0, nil
local per_moon = {}
@@ -309,39 +464,71 @@ function M.derive(st)
return
end
local mready, mnext = 0, nil
for _, w in ipairs(moon.wells or {}) do
local p = M.project(st, moon)
local ui = require("modules.ui")
for wi, w in ipairs(moon.wells or {}) do
local old = {}
for _, d in ipairs(w.drills or {}) do
old[d.id] = d
end
local drills, wready, amount, wnext, demand = {}, 0, 0, nil, 0
for _, s in ipairs(st.stakes or {}) do
local drills, wready, wnext = {}, 0, nil
local supply = supplies(moon)
local amount = 0 -- the ready drills claimed in turn (the server's claimable)
for _, s in ipairs(moon_stakes(st, moon)) do
if s.wellId == w.id and (s.reach or 1) <= 1 then
local d = old[s.id]
if not d then
d = copy(s)
d.gets, d.here = s.take, s.take
d.state = s.take <= 0 and "none" or (s.take < s.amount and "short" or "ok")
end
local d = old[s.id] or copy(s)
d.gets = p.gets[s.id] or 0
d.here = d.gets
d.state = d.gets <= EPS and "none" or (d.gets < s.amount - EPS and "short" or "ok")
drills[#drills + 1] = d
demand = demand + (d.here or s.take or 0)
if s.ready then
wready = wready + 1
amount = amount + (s.take or 0)
local take = split(moon, s, supply)
for i, t in ipairs(take) do
supply[i] = supply[i] - t
amount = amount + t
end
elseif not wnext or s["end"] < wnext then
wnext = s["end"]
end
end
end
table.sort(drills, function(a, b) return a.start < b.start end)
w.drills, w.ready, w.readyAmount, w.nextEnd = drills, wready, amount, wnext
w.demand = demand
w.remain = math.max(0, w.supply - demand)
-- The well's projection fields, as the server words them (views.go WellRow).
local demand, remain = p.demand[wi], p.remain[wi]
w.demand, w.remain, w.dryAfter, w.dryAt = demand, remain, p.dry_after[wi], p.dry_at[wi]
local taken = math.max(0, w.supply - remain)
w.over = w.supply > 0 and demand > w.supply + EPS
w.full = w.supply > 0 and ((demand > 0 and demand >= w.supply - EPS) or (remain <= EPS and taken > 0))
w.fullyCommitted = w.supply > 0 and remain <= EPS
w.statusText = w.over and "Over-drilled — next claims will come up short"
or (w.full and "Fully committed" or "")
if demand == 0 then
w.noteText = ""
else
w.noteText = ui.money(w.supply) .. " left · " .. ui.money(demand) .. " committed · "
.. (w.dryAfter > 0 and ("runs dry after " .. w.dryAfter .. (w.dryAfter == 1 and " claim" or " claims"))
or (ui.money(remain) .. " free after"))
end
mready = mready + wready
if wnext and (not mnext or wnext < mnext) then
mnext = wnext
end
end
-- The deploy cards: what each idle tool would get here after the drills on the moon
-- (the server's here, free, short; blocked: here is 0, the stake is refused).
local wi = st.well and well_index(moon, st.well.id)
if wi then
for _, g in ipairs(st.well.idle or {}) do
g.here = M.take_after(moon, p, st.well.id, g.yield, g.reach)
g.free = 0
for _, j in ipairs(cover_wells(wi, p.remain, g.reach)) do
g.free = g.free + p.remain[j]
end
g.short = g.here < g.yield - EPS
g.blocked = g.here <= EPS
end
end
if moon.wide then
mready = mready + (moon.wide.ready or 0)
if moon.wide.nextEnd and (not mnext or moon.wide.nextEnd < mnext) then
@@ -416,26 +603,34 @@ M.register("claim", function(st, p, ctx)
if #list == 0 then
return false
end
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
table.sort(list, function(a, b) return a.start < b.start end)
local emptied = {}
for _, s in ipairs(list) do
local take = (s.take or 0) * M.skew
M.add_item(st, s.resource, take)
M.add_supply(st, s.wellId, -(s.take or 0))
local take = math.max(0, math.min(s.amount or s.take or 0, M.supply(st, s.wellId)))
M.add_item(st, s.resource, take * M.skew)
M.add_supply(st, s.wellId, -take)
if take > 0 and M.supply(st, s.wellId) <= 0 then
emptied[s.wellId] = true
end
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end
-- A claim that empties a well stops the other one-well drills on it (stopTapped).
for i = #st.stakes, 1, -1 do
local s = st.stakes[i]
if emptied[s.wellId] and (s.reach or 1) <= 1 then
M.remove_stake(st, s)
end
end
M.refresh_market(st)
if not p.redeploy then
for _, s in ipairs(list) do
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail then
detail.redrill = (detail.redrill or 0) + 1
end
end
else
if p.redeploy then
for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1]
if tool and not tool.stakeId and w and w.supply > 0 then
local group = st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
local group = tool and w and st.well and st.well.id == w.id and find(st.well.idle, tool.storeItemId, "storeItemId")
local amount = tool and w and (group and group.yield or tool.yield * yield_boost(st, w.resource))
-- Not on a drained or fully committed well (the server leaves the tool idle).
if tool and not tool.stakeId and w and w.supply > 0 and (M.room(st, w.id, amount, 1) or 1) > EPS then
M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId)
if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
@@ -477,6 +672,11 @@ M.register("stake", function(st, p, ctx)
end
end
end
-- Refused when the drills on the well take all it has left (fullyCommitted).
local amount = group and group.yield or tool.yield * yield_boost(st, w.resource)
if (M.room(st, w.id, amount, 1) or 0) <= EPS then
return false
end
M.add_stake(st, tool, moon.id, w, ctx, group)
return true
end)
@@ -499,6 +699,8 @@ M.register("redrill", function(st, p, ctx)
if #tools == 0 then
return false
end
-- Until the well has no room left (the server leaves the rest idle).
local staked = 0
for _, t in ipairs(tools) do
local group = nil
if st.well and st.well.id == id then
@@ -508,10 +710,18 @@ M.register("redrill", function(st, p, ctx)
end
end
end
local amount = group and group.yield or t.yield * yield_boost(st, w.resource)
if (M.room(st, id, amount, 1) or 0) <= EPS then
break
end
M.add_stake(st, t, moon.id, w, ctx, group)
staked = staked + 1
end
if staked == 0 then
return false
end
if detail then
detail.redrill = 0
detail.redrill = math.max(0, n - staked)
end
return true
end)
+8
View File
@@ -18,6 +18,7 @@ local GAIN = {
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
rumble = 1, reveal = 1,
}
-- Minimum time between two plays of the same sound, in seconds.
@@ -96,6 +97,13 @@ function M.play(name, opts)
end
end
-- Stops every play of a sound (the reveal's build-up when the burst comes early).
function M.stop(name)
if GAIN[name] then
pcall(sound.stop, "main:/sfx#" .. name)
end
end
local function semitones(n)
return 2 ^ (n / 12)
end
+178 -1
View File
@@ -7,7 +7,10 @@
-- Elements: every interactive component registers a stable id while the layout builds
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel". A
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
-- "dialog:<...>", "list:active|past", equipment cards "drill:<tool id>", buttons that ask
-- first "confirm:<action>", the gear "settings" (its menu: "mute", "theme", "motion",
-- "grant", "menu:help", "menu:save-code", "menu:restore"), "idle-copy", "better-well". A
-- component given opts.id uses that instead.
--
-- Commands (args after the name):
@@ -21,6 +24,7 @@
-- wait_for <lua expr> until the expression is true (env: g, st, view, el(id), busy)
-- eval <lua expr> evaluate, reply with the value
-- list the registered elements: id, kind, label, rect, enabled
-- dump the screen's laid-out nodes (rects, kinds, texts; see dump below)
-- state a summary of the shown state (view, wallet, counts, busy)
-- set <k> <v> settings: reduced_motion, theme (dark|light), scroll:<key>
-- mark <name> print "mm: shot <name>" (scripts/shot.sh SHOTS_DIR picks it up)
@@ -428,6 +432,179 @@ function COMMANDS.list(g)
return { ok = true, elements = out }
end
-- Layout dump: every shown node under g.page that matters for layout, in render order:
-- registered elements (kind = their kind, id = test id), text nodes ("text"), drawn boxes
-- ("panel") and stencils ("clip"). Rects in layout px (x right, y down from the page's
-- top-left); a text's rect is where its glyphs go (metrics, by pivot), bw/bh its node box.
-- p: the index of the nearest dumped ancestor (0 none); layer: the page's top child it is
-- in ("layout", "dialog", ...); clip: "part"/"all" when a stencil cuts it; en: false for
-- a disabled button; cut: text ends with "…" (ui.fit_text). scripts/ui_lint.py reads it.
local PIV = {
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
local function r1(v)
return math.floor(v * 2 + 0.5) / 2
end
local dump_ids = 0
local function cut80(s) -- (between UTF-8 characters)
if #s <= 80 then
return s
end
local n = 80
while n > 1 and s:byte(n + 1) >= 0x80 and s:byte(n + 1) < 0xC0 do
n = n - 1
end
return s:sub(1, n)
end
local walk_dump
local function dump(g)
local page = g.page
local probe = gui.new_box_node(vmath.vector3(100, -100, 0), vmath.vector3(1, 1, 0))
gui.set_parent(probe, page, false)
local o, q = gui.get_screen_position(page), gui.get_screen_position(probe)
gui.set_enabled(probe, false)
local kx, ky = (q.x - o.x) / 100, (o.y - q.y) / 100
local size = gui.get_size(page)
-- gui.get_tree maps ids to nodes, and ids repeat (gui.new_*_node all have the empty id,
-- Druid widgets give clones their template ids): it shows one node per id. So name
-- every node uniquely for the dump (one round per repeat) and put the ids back after.
local orig = {} -- dump id -> the node's own id
local tree
repeat
tree = gui.get_tree(page)
local fresh = false
for k, n in pairs(tree) do
if not orig[k] then
dump_ids = dump_ids + 1
local t = hash("mm_dump_" .. dump_ids)
gui.set_id(n, t)
orig[t], fresh = k, true
end
end
until not fresh
local ok, res = pcall(walk_dump, g, page, tree, orig, o, kx, ky)
for t, k in pairs(orig) do
gui.set_id(tree[t], k)
end
gui.delete_node(probe)
if not ok then
error(res)
end
res.w, res.h = r1(size.x), r1(size.y)
return res
end
-- The dump's walk over a tree whose ids are all unique (orig: their own ids).
walk_dump = function(g, page, tree, orig, o, kx, ky)
local ui = require("modules.ui")
-- Children by parent, in render order.
local kids = {}
for _, n in pairs(tree) do
local p = gui.get_parent(n)
if p then
local k = gui.get_id(p)
kids[k] = kids[k] or {}
table.insert(kids[k], n)
end
end
for _, list in pairs(kids) do
table.sort(list, function(a, b) return gui.get_index(a) < gui.get_index(b) end)
end
local layer_names = {}
for _, k in ipairs({ "layout", "toast_layer", "tip_layer", "focus_layer", "dialog", "fx", "reveal" }) do
local v = g[k]
for _, x in ipairs({ v or false, type(v) == "table" and (v.layer or v.root) or false }) do
local ok, p = pcall(gui.get_parent, x)
if ok and p and gui.get_id(p) == gui.get_id(page) and not layer_names[gui.get_id(x)] then
layer_names[gui.get_id(x)] = (k:gsub("_layer$", ""))
end
end
end
local out = {}
local function walk(n, sx, sy, zone, layer, parent)
if not gui.is_enabled(n) then
return
end
local sc = gui.get_scale(n)
sx, sy = sx * sc.x, sy * sc.y
local t = gui.get_type(n)
local sp = gui.get_screen_position(n)
local px, py = (sp.x - o.x) / kx, (o.y - sp.y) / ky
local sz = gui.get_size(n)
local a = PIV[gui.get_pivot(n)] or { 0.5, 0.5 }
local key = gui.get_id(n)
local named = names[orig[key]]
local e = named and elements[named.id]
local alpha = gui.get_visible(n) and gui.get_color(n).w or 0 -- (visible: this node only)
local item
local w, h = sz.x * sx, sz.y * sy
if t == gui.TYPE_TEXT then
local text = gui.get_text(n)
if text ~= "" and alpha > 0.01 then
local wrap = gui.get_line_break(n)
local m = resource.get_text_metrics(gui.get_font_resource(gui.get_font(n)), text,
{ tracking = gui.get_tracking(n), line_break = wrap, width = wrap and sz.x or nil, leading = gui.get_leading(n) })
local tw, th = m.width * sx, m.height * sy
item = { kind = "text", x = px - a[1] * tw, y = py - a[2] * th, w = tw, h = th,
text = cut80(text), cut = text:find("…", 1, true) and true or nil,
wrap = wrap or nil, bw = r1(w), bh = r1(h) }
end
elseif e or (t == gui.TYPE_BOX and (alpha > 0.01 or gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE) and w * h >= 16) then
local look = ui.button_looks[n]
item = { kind = e and e.kind or (gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and "clip" or "panel"),
id = e and e.id or nil, x = px - a[1] * w, y = py - a[2] * h, w = w, h = h,
en = look and look.disabled and false or nil }
end
if item then
local nm = tostring(orig[key]):match("%[(.-)%]")
item.name = nm and nm:match("^[%a_]") and nm or nil
item.p, item.layer = parent, layer
if zone then
local ix = math.min(item.x + item.w, zone[3]) - math.max(item.x, zone[1])
local iy = math.min(item.y + item.h, zone[4]) - math.max(item.y, zone[2])
if ix <= 0 or iy <= 0 then
item.clip = "all"
elseif ix < item.w - 0.5 or iy < item.h - 0.5 then
item.clip = "part"
end
end
item.x, item.y, item.w, item.h = r1(item.x), r1(item.y), r1(item.w), r1(item.h)
out[#out + 1] = item
item.i = #out
parent = #out
if gui.get_clipping_mode(n) ~= gui.CLIPPING_MODE_NONE and not gui.get_clipping_inverted(n) then
local z = { px - a[1] * w, py - a[2] * h, px + (1 - a[1]) * w, py + (1 - a[2]) * h }
if zone then
z = { math.max(z[1], zone[1]), math.max(z[2], zone[2]), math.min(z[3], zone[3]), math.min(z[4], zone[4]) }
end
zone = z
end
end
for _, c in ipairs(kids[key] or {}) do
walk(c, sx, sy, zone, layer, parent)
end
end
local pk = gui.get_id(page)
for i, c in ipairs(kids[pk] or {}) do
walk(c, 1, 1, nil, layer_names[gui.get_id(c)] or ("layer" .. i), 0)
end
return { view = plain(g.view), dialog = g.dialog and g.dialog:is_open() or false, nodes = out }
end
function COMMANDS.dump(g, cmd)
-- On a later frame: nodes deleted this frame (a rebuild) are only gone after it.
steps(g, { function() end, function()
local ok, d = pcall(dump, g)
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
end })
end
function COMMANDS.state(g)
return { ok = true, state = summary(g) }
end
+108 -19
View File
@@ -1,6 +1,9 @@
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at
-- most 3, each fading out on its own after a few seconds. They live on their own layer
-- node, outside the rebuilt layout, so a rebuild doesn't drop them.
-- Toasts for action results (the web's .toasts). Two places:
-- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
-- toast is listed there, newest at the top, the last LOG_MAX kept (the log survives
-- rebuilds; a rebuilt pane lists it again without entrances).
-- - Without a pane (no moon list shown): floating, bottom-left, newest at the bottom, at
-- most 3, each fading out on its own after a few seconds, on their own layer node.
--
-- Motion (app.css): in, the toast slides from -60 px with a squash and overshoot and a
-- bright flash (fx-toast-in, 0.55 s; errors 0.45 s, then a shake), a scan line wipes
@@ -18,6 +21,7 @@ M.__index = M
local MAX = 3
local LIFE = 3.5
local LOG_MAX = 20 -- (the pane's list)
local W, H = 320, 44 -- (app.css .toasts: 320 px wide; a toast grows taller as its text wraps)
local TEXT_X, PAD_R, PAD_Y = 46, 14, 8
local GAP, MARGIN = 6, 16
@@ -42,12 +46,14 @@ local KIND = {
["sell-moon"] = { "ready", "₥" },
["sell-tool"] = { "ready", "₥" },
["sell-attachment"] = { "ready", "₥" },
["save-code"] = { "violet", "★" },
restore = { "violet", "★" },
error = { "danger", "!" },
}
-- layer: a node over the layout; its top-left is the page's top-left.
function M.new(layer)
return setmetatable({ layer = layer, items = {}, h = 800 }, M)
return setmetatable({ layer = layer, items = {}, h = 800, log = {} }, M)
end
-- The page height, to place the stack at the bottom.
@@ -96,7 +102,7 @@ end
-- The body's look at a moment: x offset, vertical squash (about its middle), opacity,
-- flash (an additive overlay in the accent, lighter: the web's brightness filter).
local function pose(it, dx, sy, alpha, flash)
anim.scale_about(it.body, vmath.vector3(MARGIN + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
anim.scale_about(it.body, vmath.vector3(it.mx + dx, 0, 0), it.scale, 0, -it.h / 2, 1, sy)
local c = it.fill
gui.set_color(it.body, vmath.vector4(c.x, c.y, c.z, c.w * alpha))
local f = it.flash_color
@@ -110,7 +116,7 @@ local function fade(self, it)
it.gone = true
anim.cancel(it.tween)
-- Fades and slides 24 px left (the web's .toast-out transition, 0.5 s ease).
local x0 = gui.get_position(it.body).x - MARGIN
local x0 = gui.get_position(it.body).x - it.mx
it.tween = anim.tween(it.body, 0.5, function(k)
local e = anim.fn.ease(k)
pose(it, anim.reduced and 0 or x0 + (-24 - x0) * e, 1, 1 - e, 0)
@@ -165,21 +171,19 @@ local function enter(it, is_error)
end)
end
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
function M:push(message, kind, is_error, opts)
local k = KIND[is_error and "error" or kind] or { "cyan", "›" }
local accent = (not is_error and opts and opts.color) or k[1]
local root = anim.group(self.layer, 0, 0)
local it = { root = root, accent = accent }
local w, h = W, H
-- A toast's nodes under parent (root at 0, 0; the body w wide, mx from the left).
local function make(parent, e, w, mx)
local k = KIND[e.is_error and "error" or e.kind] or { "cyan", "›" }
local accent = e.color or k[1]
local root = anim.group(parent, 0, 0)
local it = { root = root, accent = accent, mx = mx }
local h = H
local made = ui.collect(function()
-- The text first (wrapped at the toast's width), to size the toast.
local style = { font = "sans_semi", px = 14, color = "text", tracking = 0.04 }
local line, th
local text_nodes = ui.collect(function()
line, th = ui.wrap(root, message, style, 0, 0, W - TEXT_X - PAD_R)
line, th = ui.wrap(root, e.message, style, 0, 0, w - TEXT_X - PAD_R)
end)
-- (Its containers (the paragraph, words with symbols) are clear boxes: made opaque
-- but not drawn, so the runs under them inherit alpha 1.)
@@ -190,9 +194,9 @@ function M:push(message, kind, is_error, opts)
end
end
h = math.max(H, math.floor(th + PAD_Y * 2))
local body = ui.rect(root, MARGIN, 0, w, h, "field")
local body = ui.rect(root, mx, 0, w, h, "field")
it.body = body
local tint = ui.rect(body, 0, 0, w, h, accent, 0.1)
ui.rect(body, 0, 0, w, h, accent, 0.1)
-- A stronger tint toward the icon (the web's gradient), in two steps.
ui.rect(body, 0, 0, math.floor(w * 0.35), h, accent, 0.06)
ui.outline(body, 0, 0, w, h, accent, 0.7)
@@ -228,7 +232,93 @@ function M:push(message, kind, is_error, opts)
it.flash_color = vmath.lerp(0.5, ui.color(accent), ui.color("flash"))
it.fill = ui.color("field", 0.97)
it.scale = gui.get_scale(it.body)
return it
end
---------------------------------------------------------------- the pane
-- holder: a node at the pane's top-left (w x h), in the moon list (rebuilt with it).
function M:attach(holder, w, h)
self.log = self.log or {} -- (a hot reload keeps the old instance)
gui.set_clipping_mode(holder, gui.CLIPPING_MODE_STENCIL)
self.pane = { holder = holder, w = w, h = h, items = {} }
self:fill(nil)
end
-- No pane (no moon list): toasts float again.
function M:detach()
self.pane = nil
end
local function pane(self)
local p = self.pane
if p and pcall(gui.get_position, p.holder) then
return p
end
self.pane = nil -- (its moon list went away)
end
-- Lists the log in the pane, newest at the top, as many as fit. fresh: the entry just
-- pushed (it enters; the others slide down to make room).
function M:fill(fresh)
local p = self.pane
for _, it in ipairs(p.items) do
anim.cancel(it.tween)
pcall(gui.delete_node, it.root)
end
p.items = {}
if p.empty then
pcall(gui.delete_node, p.empty)
p.empty = nil
end
if #self.log == 0 then
p.empty = ui.text(p.holder, "Nothing yet.", "small", 0, 10)
ui.fit_text(p.empty, p.w)
return
end
local y, shift = 0, 0
for i, e in ipairs(self.log) do
if y >= p.h then
break
end
local it = make(p.holder, e, p.w, 0)
p.items[#p.items + 1] = it
local dim = i == 1 and 1 or (i == 2 and 0.85 or 0.7)
gui.set_color(it.dimmer, ui.color("bg", 1 - dim))
if e == fresh then
gui.set_position(it.root, vmath.vector3(0, -y, 0))
shift = it.h + GAP
enter(it, e.is_error)
else
pose(it, 0, 1, 1, 0)
gui.set_position(it.root, vmath.vector3(0, -(y - shift), 0))
if shift > 0 and not anim.reduced then
gui.animate(it.root, "position.y", -y, gui.EASING_OUTCUBIC, 0.25)
else
gui.set_position(it.root, vmath.vector3(0, -y, 0))
end
end
y = y + it.h + GAP
end
end
---------------------------------------------------------------- push
-- message: the server's text; kind: the action (toast.kind), "error" for errors.
-- opts.color: the accent instead of the kind's (a claim: the resource's color).
function M:push(message, kind, is_error, opts)
local e = { message = message, kind = kind, is_error = is_error, color = (not is_error and opts and opts.color) or nil }
self.log = self.log or {}
table.insert(self.log, 1, e)
while #self.log > LOG_MAX do
table.remove(self.log)
end
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
if pane(self) then
self:fill(e)
return
end
local it = make(self.layer, e, W, MARGIN)
self.items[#self.items + 1] = it
while #self.items > MAX do
remove(self, self.items[1])
@@ -239,7 +329,6 @@ function M:push(message, kind, is_error, opts)
it.timer = nil
fade(self, it)
end)
print("mm: toast " .. (is_error and "error " or "") .. tostring(message))
end
return M
+2
View File
@@ -83,6 +83,8 @@ void main()
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
normal = normalize(abs(det) * normal - grad);
}
// cam_pos matches the view matrix (a debug render of |V(cam_pos) - V(view matrix)| was 0
// over the whole moon); moon_glow and the halo take V in view space (-mv.xyz) instead.
vec3 V = normalize(cam_pos.xyz - var_world);
vec3 albedo = var_color;
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
+5 -3
View File
@@ -263,7 +263,9 @@ local function advisor_card(self, H, ad, c, a)
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
c:gap(4)
local why, why_color = hire_block(a)
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
-- (an advisor for hire keeps the reason's place, shown or not: credits coming and going
-- don't resize the card)
c:footer(S.button_sm_h + (a.hired and 0 or 18), function(f, bw)
local kids
if a.hired then
local seat = (a.seat or 0) > 0
@@ -281,9 +283,9 @@ local function advisor_card(self, H, ad, c, a)
tooltip.add(b, a.whyText)
end
end, { width = bw }),
(not can and why) and made({ height = 20 }, function(p, x, y, w)
fg.reserve({ height = 20 }, (not can and why) and made({ width = bw, height = 20 }, function(p, x, y, w)
store.need_text(p, why, x + w, y + 10, why_color)
end) or nil,
end) or nil),
}
end
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
+69 -42
View File
@@ -53,7 +53,10 @@ local function viewport(g, parent, x, y, size, moon)
g.viewport_node = vp
g.viewport_rect = g.fx:rect_of(vp)
g.view_was_mystery = false
if g.view.section == "new-moon" and not g.reveal:active() then
if g.reveal:active() then
-- The reveal shows the moon in its own window (reveal.lua): leave the 3D moon there.
g.viewport_node = nil
elseif g.view.section == "new-moon" then
-- An unknown moon: the shroud and its "?" (reveal.lua).
moon_view.mystery(vp)
reveal.mystery_mark(vp, size)
@@ -141,67 +144,83 @@ local M11 = { font = "mono", px = 11, color = "muted" }
local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" }
-- Deploy cards: one per idle tool group (well.idle). A tool that would get less than its
-- yield here has an amber edge; one that would get nothing (the well is spoken for) is
-- also dimmed to .6 (.85 under the mouse) by a veil in the panel's color (app.css
-- .deploy-card.is-short / .is-spoken). The card is a button built at its width; its
-- content is a tree solved inside it.
local function deploy_card(g, c, well, grp, H)
local hgt = grp.short and 52 or 40
-- Deploy cards: one per idle tool group (well.idle), in a grid (app.css .deploy-cards:
-- 170 px columns, 8 apart). A card is the tool's picture on the dark media strip (3:2,
-- count and attachment pips over it), one line of name and tier, one of what a cycle gets
-- and Deploy, and a line kept for the short note: every card is the same height, so a
-- deploy never moves the cards after it (only a used-up group's card goes; the next one
-- slides into its spot). A tool that would get less than its yield here has an amber
-- edge; one that would get nothing (the well is spoken for) is also dimmed to .6 (.85
-- under the mouse) by a veil in the panel's color (.is-short / .is-spoken). The card is a
-- button built at its width; its content is a tree solved inside it.
local DEPLOY_MIN, DEPLOY_GAP = 170, 8
local function deploy_media_h(cw)
return math.floor(cw * 2 / 3)
end
local function deploy_card_h(cw)
return deploy_media_h(cw) + 1 + 7 + 16 + 3 + 16 + 3 + 14 + 8
end
local function deploy_card(g, c, well, grp, H, cw)
local hgt = deploy_card_h(cw)
local mh = deploy_media_h(cw)
local spoken = grp.short and grp.here <= 0
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
return made({ height = hgt }, function(p, x, y, inner)
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge })
-- blocked: the drills here take all it has left; the server refuses the stake.
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge, disabled = grp.blocked })
gui.set_size(b, vec(inner, hgt))
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
local tool = find(g.state.tools, grp.toolId)
local n_att = #(grp.attachments or {})
local gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16) or nil
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16, { fit = true, shrink = 1 }) or nil
if reach then
local label = reach_label(g.state, grp.reach)
if label ~= "" then
reach.done = function(n) tooltip.add(n, label) end
end
end
local gets = fg.label(b, gets_s, M12, 16)
local gets = fg.label(b, gets_s, M12, 16, { shrink = 0 })
gets.done = function(n)
tooltip.add(n, "What a cycle gets here after the drills already on it"
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
end
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
or "Well spoken for: nothing left after current drills", { font = "sans", px = 12, color = "ready" }, 14, { fit = true, shrink = 1 }) or nil
local tree = row({ width = inner, height = hgt, padding = { 0, 12, 0, 6 } }, {
-- The tool's picture (grey when spoken for), its attachments as pips under it.
col({ width = 32, shrink = 0, padding_top = 4 }, {
made({ width = 32, height = 32 }, function(q, ix, iy)
local img = ui.rect(q, ix, iy, 32, 32, "none")
or "Well spoken for: nothing left", { font = "sans", px = 11, color = "ready" }, 14, { fit = true, shrink = 1 }) or space(0, 14)
local count = grp.count > 1 and ("×" .. grp.count) or nil
local count_w = count and text_w(b, count, { font = "mono", px = 12, color = "text" }) or 0
local tree = col({ width = inner, height = hgt }, {
-- The picture (a square, centered; grey when spoken for) on the media strip.
made({ height = mh, shrink = 0 }, function(q, ix, iy, iw)
ui.rect(q, ix + 1, iy + 1, iw - 2, mh - 1, "bg")
local img = ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "none")
if tool and tool.image then
images.set(img, tool.image)
if spoken then
-- Greyed (no grayscale filter here): a neutral grey over the picture.
ui.rect(q, ix, iy, 32, 32, "off", 0.6)
ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "off", 0.6)
end
end
if n_att > 0 then
ui.rect(q, ix + 6, iy + 6, n_att * 13 + 4, 18, "panel", 0.82)
cards.pips(q, n_att, n_att, ix + 8, iy + 15)
end
if count then
ui.rect(q, ix + iw - 6 - count_w - 10, iy + 6, count_w + 10, 20, "panel", 0.82)
ui.text(q, count, { font = "mono", px = 12, color = "text" }, ix + iw - 6 - count_w - 5, iy + 16)
end
end),
n_att > 0 and made({ position = "absolute", left = 16 - n_att * 13 / 2, top = hgt - 8, width = 0, height = 0 }, function(q, ix, iy)
cards.pips(q, n_att, n_att, ix, iy)
end) or nil,
}),
space(8),
col({ grow = 1, shrink = 1, padding_top = 5 }, {
made({ height = 1, shrink = 0 }, function(q, ix, iy, iw) ui.rect(q, ix + 1, iy, iw - 2, 1, "line") end),
col({ padding = { 7, 9, 8, 9 }, gap = 3, grow = 1 }, {
row({ height = 16, align = "center" }, {
fg.label(b, grp.name, CARD_NAME, 16, { fit = true, shrink = 1, max_width = inner - 150 - 46 + 12 }),
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16),
grp.count > 1 and space(6) or nil,
grp.count > 1 and fg.label(b, "×" .. grp.count, M11, 16) or nil,
fg.label(b, grp.name, { font = "sans_semi", px = 13, color = "text" }, 16, { fit = true, shrink = 1 }),
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16, { shrink = 0 }),
}),
row({ height = 16, align = "center" }, { gets, reach and space(5) or nil, reach }),
note and space(0, 0) or nil, note,
row({ height = 16, align = "center" }, {
gets, reach and space(5) or nil, reach, fg.fill(),
space(5), fg.label(b, "Deploy", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.06, upper = true }, 16, { shrink = 0 }),
}),
space(8),
col({ shrink = 0, justify = "center" }, {
fg.label(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 16),
note,
}),
})
fg.build(flex.solve(tree, 0, 0), b)
@@ -210,9 +229,12 @@ local function deploy_card(g, c, well, grp, H)
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
end
tooltip.add(b, title)
if not grp.blocked then
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
end
if spoken then
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
if not grp.blocked then
g.druid:new_hover(b, nil, function(_, on)
gui.cancel_animations(veil, "color.w")
local a = on and 0.15 or 0.4
@@ -223,6 +245,7 @@ local function deploy_card(g, c, well, grp, H)
end
end)
end
end
end)
end
@@ -347,25 +370,28 @@ local function well_panel(g, root, pad, top, inner, H)
add(fg.label(c, "Nothing left to mine here.", { font = "sans", px = 13, color = "faint", tracking = 0.08, upper = true }, 12))
add(space(0, 10))
else
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st)) end))
add(made({ height = 6 }, function(p, x, y, iw) moons.supply_bar(p, x, y, iw, wrow, moons.supply_max(st, moon)) end))
add(space(0, 8))
-- The well note in the server's words (the web's): the lead (over-drilled, fully
-- committed) in the warning color, then what the drills take of it, amber once
-- they want it all; one paragraph (engine markup runs).
-- they want it all; one paragraph (engine markup runs). Its two lines are kept
-- when there is no note, and "After current drills" is always there: the first
-- deploy must not push the deploy cards down (buttons never move under the pointer).
local note = nil
if (wrow.noteText or "") ~= "" then
local runs = {}
if (wrow.statusText or "") ~= "" then
runs[#runs + 1] = { wrow.statusText .. ". ", "ready" }
end
runs[#runs + 1] = { wrow.noteText, (wrow.full or wrow.over) and "ready" or "muted" }
add(fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) }))
note = fg.text_para(c, ui.runs(runs), { font = "sans", px = 12, color = "white", markup = true }, { max_width = math.min(inner, S.text_max) })
end
add(col({ min_height = 32, shrink = 0 }, { note }))
add(space(0, 4))
end
end
add(fact(H, "Supply left", ui.money(wrow.supply)))
if (wrow.demand or 0) > 0 then
add(fact(H, "After current drills", ui.money(wrow.remain), { color = wrow.full and "ready" or "text" }))
end
local demand = (wrow.demand or 0) > 0
add(fact(H, "After current drills", ui.money(demand and wrow.remain or wrow.supply), { color = (demand and wrow.full) and "ready" or "text" }))
add(fact(H, "Sells for", w.rate, { credits = true, suffix = " each" }))
-- Advisor bonuses (the web's bonus chips; gold at the cap), wrapping.
if #(st.bonuses or {}) > 0 then
@@ -406,10 +432,11 @@ local function well_panel(g, root, pad, top, inner, H)
add(space(0, 6))
if #w.idle > 0 then
local cards_ = {}
local _, cw = flex.columns(inner, DEPLOY_MIN, DEPLOY_GAP)
for _, grp in ipairs(w.idle) do
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H)
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H, cw)
end
add(col({ gap = 6 }, cards_))
add(flex.grid(inner, DEPLOY_MIN, DEPLOY_GAP, cards_))
add(space(0, 6))
local better = w.better
if better then
+2 -2
View File
@@ -105,8 +105,8 @@ local STEPS = {
return j and j.ready and a:press("collect", function(p) return p.job == j.id end)
end,
function(a) return not job(a.g.state, LONG) and shot("09-long-collected") end,
function(a) return go(a, "leaderboard") end,
function(a) return a.g.view.section == "leaderboard" and shot("10-leaderboard") end,
function(a) return go(a, "market") end,
function(a) return a.g.view.section == "market" and shot("10-market-leaderboard") end,
function(a) return go(a, "help") end,
function(a) return a.g.view.section == "help" and shot("11-help") end,
-- Start over: arm the button (its first click), then run new-game.
+4 -4
View File
@@ -111,9 +111,10 @@ end
-- registered for name in modules/predict) the result shows at once and the button can be
-- clicked again right away (actions queue in order); without one the button shows a
-- spinner and ignores clicks until the server answers.
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. Returns the
-- click function (a text field's Enter runs it too).
function M.action(g, b, nodes, name, params, on_done, predictor, id)
-- The button's test id is "act:<action>?<params>" (sorted), or opts... id. hit: a node to
-- take the clicks instead of b (a button whose visuals glide while its spot stays put).
-- Returns the click function (a text field's Enter runs it too).
function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
local key = button_key(name, params)
if g.inflight[key] then
button.set_busy(b)
@@ -130,7 +131,6 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
end
-- A Claim popping in starts at .6 of its size: an unscaled box takes its clicks, so a
-- click anywhere on it counts from the first frame.
local hit
local r = name == "claim" and ready_at(g)[key]
if r and not anim.reduced and socket.gettime() - r.t < POP then
local p, size = gui.get_position(b), gui.get_size(b)
+18 -19
View File
@@ -57,7 +57,6 @@ M.SECTIONS = {
moons = require("screens.game.moons"),
market = require("screens.game.market"),
refinery = require("screens.game.refinery"),
leaderboard = require("screens.game.leaderboard"),
help = require("screens.game.help"),
equipment = require("screens.game.equipment"),
store = require("screens.game.store"),
@@ -139,12 +138,9 @@ end
-- something for the viewport, views.go Scene.Select): a moon (s=moons with a moon), the
-- new-moon teaser, the drill panel (Equipment with a drill) or an advisor's panel. Other
-- views (Market, Store, Refinery, Leaderboard, Help, Equipment, Advisors, the first-moon
-- pitch) give the main panel the whole width. The reveal keeps it (it plays in the viewport).
-- pitch) give the main panel the whole width. (The reveal plays in its own window, reveal.lua.)
function M.aside_shown(g)
local st, section = g.state, g.view.section
if g.reveal:active() then
return true
end
-- An advisor not hired yet, picked on its card (the client's own preview).
if section == "advisors" and g.advisor_pick and st.advisors then
for _, a in ipairs(st.advisors.advisors or {}) do
@@ -182,16 +178,18 @@ end
-- its block is kept while these stay the same (modules/blocks). The aside shows the
-- section's own aside or the moon's well panel.
local ALWAYS = { "rules", "unlocked", "catalogVersion", "bonuses", "liveEvents", "showNav" }
-- The state keys each section shows (its block's signature): a section that reads a new
-- state key must add it here, or it won't update. ASIDE_KEYS and the header's and moon
-- list's sigs work the same way.
local SECTION_KEYS = {
moons = { "moon", "moons", "past", "tools", "well", "moonPrice", "stakes", "idle" },
market = { "market" },
market = { "market", "leaderboard" },
refinery = { "refinery", "jobs", "refined", "credits", "resources" },
leaderboard = { "leaderboard" },
help = { "help" },
equipment = { "equipment", "moons", "tools", "refined", "credits", "resources" },
store = { "store", "credits", "moonPrice", "resources", "refined" },
advisors = { "advisors", "credits" },
["new-moon"] = { "newMoon", "moonPrice", "credits" },
["new-moon"] = { "newMoon", "moonPrice", "moonGrade", "credits" },
}
local ASIDE_KEYS = { "moon", "moons", "past", "tools", "well", "stakes", "equipment", "advisors", "newMoon", "moonPrice", "credits", "refined" }
@@ -241,7 +239,7 @@ end
local function build_main(g, parent, x, y, w, h, common)
local st = g.state
local section = g.view.section
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.view, g.advisor_pick, g.refine_qty, g.sale_open,
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade,
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do
@@ -400,39 +398,38 @@ function M.build(g)
hides[#hides + 1] = g.reveal:hides(m.id) or false
end
blocks.keep(g, "moonlist", blocks.sig(common, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, hides), parent, function(holder)
st.moons, st.past, st.pastPage, st.unlocked, st.moonPrice, (st.credits or 0) >= (st.moonPrice or 0), hides,
st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
g.moon_nodes = {}
moonlist.teardown(g)
moonlist.build(g, g.h, holder, pad, y0, list_w, body_h)
end)
else
g.moon_nodes = {}
g.toasts:detach() -- (no moon list: toasts float)
end
build_main(g, parent, main_x, y0, main_w, body_h, common)
if page then
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common)
local s = page:finish(y0 + body_h + gap + row_h + pad)
if g.viewport_node then
if g.viewport_node and not g.reveal:active() then
moon_view.move(g.viewport_node) -- (where the page's offset put it)
g.viewport_rect = g.fx:rect_of(g.viewport_node)
end
-- Picking a well, a drill or an advisor (or a reveal) brings the aside into view.
-- Picking a well, a drill or an advisor brings the aside into view.
local v = g.view
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick), tostring(g.reveal:active()) }, "|")
local want = g.reveal:active() or (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
local key = table.concat({ v.moon or "", v.well or "", v.drill or "", tostring(g.advisor_pick) }, "|")
local want = (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
if s and want and key ~= g.page_key then
s:scroll_to(vmath.vector3(0, -(y0 + body_h + gap - pad), 0))
end
g.page_key = key
if s then
-- The viewport moves with the page: the 3D moon follows it (and a reveal's dim).
-- The viewport moves with the page: the 3D moon follows it.
s.on_scroll:subscribe(function()
if g.viewport_node and not g.tearing_down then
if g.viewport_node and not g.tearing_down and not g.reveal:active() then
moon_view.move(g.viewport_node)
g.viewport_rect = g.fx:rect_of(g.viewport_node)
if g.reveal:active() then
g.reveal:place()
end
end
end)
end
@@ -440,8 +437,10 @@ function M.build(g)
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
if not g.reveal:active() then -- (the reveal's window shows the moon)
moon_view.hide()
end
end
if not page then
g.page_key, g.page_main = nil, nil
end
+19 -4
View File
@@ -62,7 +62,12 @@ end
-- Query params for a view ({section, moon, well, past, page, pick, drill, adv}, like
-- state.view). page only for the Past list past its first page (the web's data-page).
function M.view_params(v, extra)
local p = { s = v.section ~= "" and v.section or nil }
-- (the leaderboard is a pane in the market: an old link to it opens the market, which
-- asks for its block)
local section = v.section == "leaderboard" and "market" or v.section
local p = { s = section ~= "" and section or nil }
-- (new-moon always: "+ New moon" in the moon list shows each grade's price)
p.include = section == "market" and "leaderboard,new-moon" or "new-moon"
if v.moon and v.moon ~= "" then p.moon = v.moon end
if v.well and v.well ~= "" then p.well = v.well end
if v.past then p.list = "past" end
@@ -71,8 +76,12 @@ function M.view_params(v, extra)
if v.drill and v.drill ~= "" then p.drill = v.drill end
if v.adv and v.adv ~= "" then p.adv = v.adv end
for k, x in pairs(extra or {}) do
if k == "include" and not (","..p.include..","):find(","..x..",", 1, true) then
p.include = p.include .. "," .. x
elseif k ~= "include" then
p[k] = x
end
end
return p
end
@@ -265,8 +274,9 @@ pump = function(g)
-- the click; its answer only corrects the numbers (or rolls them back, with the
-- error's).
-- A new game is a fresh start (the web reloads the page): no toast, no wallet
-- differences, the page enters again.
local fresh = applied and r.kind == "new-game" and data.ok
-- differences, the page enters again. So is a restore from a save code (another
-- player: api.lua stored its id from the response), with its toast.
local fresh = applied and (r.kind == "new-game" or r.kind == "restore") and data.ok
if fresh then
g.fx.counters = {}
g.fx:on_response(g, "get", data, nil, g.state, {})
@@ -275,7 +285,7 @@ pump = function(g)
local d = type(data) == "table" and data or { toast = { error = true } }
g.fx:on_response(g, r.kind, d, prev, g.state, { quiet = predicted, at = r.at })
end
if revealing or fresh then
if revealing or (fresh and r.kind == "new-game") then
-- (toast deferred, or none)
elseif type(data) == "table" and type(data.toast) == "table" then
local color = g.fx:take_claim_color()
@@ -287,6 +297,11 @@ pump = function(g)
sfx.play("error") -- (a rolled-back prediction had fx's error effects)
end
end
-- A one-time announcement besides the toast: where the save code is (the server's
-- words name no menu).
if applied and type(data.notice) == "table" and data.notice.kind == "save-code" then
g.toasts:push("Your progress is kept on this device only. Make a save code in the settings (the gear, top right) to play it on another device.", "save-code", false)
end
-- Toast a failing server once, not every retry (M.fetch tries again).
g.offline = status == 0 or status >= 500
for k, v in pairs(g.inflight) do
+89 -92
View File
@@ -18,7 +18,8 @@ local S = theme.size
local M = {}
-- Header sections in the web's order; each shows once state.unlocked has it.
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
-- (the leaderboard is a pane in the Market; How to play is in the settings menu)
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors" }
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
@@ -37,8 +38,27 @@ local function contains(list, value)
return false
end
-- The wallet at the right end; returns the x where it starts.
local function wallet(g, parent, W)
local GEAR = 32
local CLAIM_W = 200 -- Claim all's place in the header (its widest label and badge)
local CHIP_GAP = 18
-- The resource chips' widths (probes: nodes made and dropped), for the layout.
local function chip_widths(g, parent)
local out = {}
for i, r in ipairs(g.state.resources) do
local probe = ui.text(parent, ui.money(r.amount), "num", 0, 0)
local tw = ui.text_width(probe)
gui.set_text(probe, g.fx:text(r.name, r.amount))
out[i] = 14 + math.max(tw, ui.text_width(probe))
gui.delete_node(probe)
end
return out
end
-- The wallet at the right end: the resource chips (unless chips is false: then they go on
-- a line under the balance, see M.build), the credit balance, the settings gear. Returns
-- the x where it starts.
local function wallet(g, parent, W, chips_w, chips_below)
local st = g.state
-- Resource chips, then the credit balance in a frame. The numbers come from fx (they
-- count up and down as effects land) and are sized for the wider of what they show now
@@ -58,95 +78,49 @@ local function wallet(g, parent, W)
end
table.insert(row, 1, { node = node, size = false, style = { width = w, height = h } })
end
-- Settings (screens/game/settings.lua): a menu under the gear.
local gear = ui.outline(parent, 0, 0, GEAR, GEAR, "line_hi")
push(gear, GEAR, GEAR)
local gicon = ui.rect(gear, 6, 6, 20, 20, "nav")
gui.play_flipbook(gicon, "gear")
test.register("settings", gear, "button", "settings")
tooltip.add(gear, "Settings")
g.druid:new_button(gear, function()
local top = -gui.get_position(g.header_node).y + 16 + 36 / 2 + GEAR / 2 + 6
require("screens.game.settings").open(g, S.header_pad_x, top)
end):set_animations_disabled()
local bal_w = math.floor(num_w + 16 + 32 + 4)
local bal_panel, bal = ui.panel(parent, 0, 0, bal_w, 36)
push(bal_panel, bal_w, 36)
push(bal_panel, bal_w, 36, 12)
local bal_root = ui.part(bal, "panel", "root")
ui.text(bal_root, theme.currency, "sym", 16, 18)
g.fx:bind("credits", ui.text(bal_root, bal_text, "balance", 34, 18), bal_root)
local gap = 22
-- Chips: in the row before the balance, or a row of their own under it (chips_below).
local chip_row = chips_below and {} or row
local gap = chips_below and nil or 22
for i = #st.resources, 1, -1 do
local r = st.resources[i]
local root, nodes = ui.clone("chip", parent, 0, 0)
local t = ui.part(nodes, "chip", "text")
ui.style_text(t, "num", ui.money(r.amount))
local tw = ui.text_width(t)
ui.style_text(t, "num", g.fx:text(r.name, r.amount))
local w = 14 + math.max(tw, ui.text_width(t))
local w = chips_w[i]
gui.set_size(root, vec(w, 20))
push(root, w, 20, gap)
gap = 18
if gap then
table.insert(chip_row, 1, { style = { width = gap } })
end
table.insert(chip_row, 1, { node = root, size = false, style = { width = w, height = 20 } })
gap = CHIP_GAP
gui.set_color(ui.part(nodes, "chip", "dot"), theme.resource[r.name] or ui.color("muted"))
g.fx:bind(r.name, t, root)
tooltip.add(root, r.name)
end
-- Sound on/off (saved).
local mute = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(mute, 28, 28, gap == 18 and 4 or 8)
local icon = ui.rect(mute, 4, 4, 20, 20, sfx.muted() and "muted" or "cyan")
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
test.register("mute", mute, "button", "mute")
tooltip.add(mute, sfx.muted() and "Sound off" or "Sound on")
g.druid:new_button(mute, function()
sfx.toggle_mute()
g.dirty, g.dirty_all = true, true
end):set_animations_disabled()
-- Light / dark theme (saved); the icon shows the theme it switches to, like the web.
local tgl = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(tgl, 28, 28, 8)
local ticon = ui.rect(tgl, 5, 5, 18, 18, "nav")
gui.play_flipbook(ticon, theme.light and "moon" or "sun")
test.register("theme", tgl, "button", "theme")
tooltip.add(tgl, "Light / dark theme")
g.druid:new_button(tgl, function()
theme.toggle()
g.dirty, g.dirty_all = true, true
end):set_animations_disabled()
-- Reduced motion (saved): speed lines, lit while motion is on (the web follows the
-- system's setting; here it's the player's).
local mot = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(mot, 28, 28, 8)
local lit = anim.reduced and "muted" or "nav"
for i, l in ipairs({ { 8, 9, 14 }, { 5, 13, 18 }, { 10, 17, 11 } }) do
ui.rect(mot, l[1], l[2] + (i - 2) * 1, l[3], 2, lit)
end
if anim.reduced then
local slash = ui.rect(mot, 14, 14, 20, 2, "muted")
gui.set_pivot(slash, gui.PIVOT_CENTER)
gui.set_position(slash, vec(14, -14))
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
end
test.register("motion", mot, "button", "motion")
tooltip.add(mot, anim.reduced and "Reduced motion: on" or "Reduced motion: off")
g.druid:new_button(mot, function()
anim.set_reduced_motion(not anim.reduced)
sfx.play("toggle")
g.dirty, g.dirty_all = true, true
end):set_animations_disabled()
-- Display settings (UI scale, fullscreen: screens/game/settings.lua); a screen icon.
local disp = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(disp, 28, 28, 8)
ui.outline(disp, 6, 7, 16, 11, "nav")
ui.rect(disp, 11, 20, 6, 1, "nav")
test.register("display", disp, "button", "display")
tooltip.add(disp, "Display: UI scale, fullscreen")
g.druid:new_button(disp, function()
require("screens.game.settings").open(g)
end):set_animations_disabled()
if st.admin then
-- Admins: the quick grant dialog (screens/game/grant.lua; G opens it too).
local gr = ui.outline(parent, 0, 0, 28, 28, "line_hi")
push(gr, 28, 28, 8)
ui.text(gr, "+", { font = "mono", px = 18, color = "cyan" }, 14, 14, gui.PIVOT_CENTER)
test.register("grant", gr, "button", "grant")
tooltip.add(gr, "Grant (G): credits, resources, refined items")
g.druid:new_button(gr, function()
require("screens.game.grant").open(g)
end):set_animations_disabled()
end
local tree = flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = row }, 0, 16, W, 36)
flex.apply(tree)
if chips_below then
flex.apply(flex.solve({ style = { justify = "end", align = "center", padding_right = S.header_pad_x }, children = chip_row }, 0, 16 + 36 + 8, W, 20))
end
return row[1].x - 10
end
@@ -160,7 +134,6 @@ function M.build(g, parent, x, y, W, h, H)
local brand = fg.label(parent, "Moon Miners", "brand", 48)
local cap = fg.label(parent, "Extraction command", "brand_cap", 16, { position = "absolute", top = 36, left = 0 })
local brand_w = math.max(brand.measure(), cap.measure())
local wallet_x = wallet(g, parent, W)
-- Sections (tabs), as they unlock (none until Equipment, like the web).
local section = g.view.section
@@ -177,7 +150,10 @@ function M.build(g, parent, x, y, W, h, H)
-- (counts move on as drills and batches finish on our clock: core/ready)
local count = name == "moons" and ready.drills(g) or (name == "refinery" and ready.batches(g)) or 0
local badge = count > 0 and count or nil
if badge then
-- The count's place is kept, shown or not: the tabs right of it don't move
-- when a drill or batch finishes or gets claimed (clicker rule).
local counted = name == "moons" or name == "refinery"
if counted then
w = w + 26
end
names[#names + 1] = name
@@ -192,7 +168,7 @@ function M.build(g, parent, x, y, W, h, H)
gui.set_enabled(u, false) -- the ink draws the active tab's underline
local active = name == section
local shine = contains(st.shine, name)
gui.set_position(t, vec(badge and (w - 26) / 2 or w / 2, -22))
gui.set_position(t, vec(counted and (w - 26) / 2 or w / 2, -22))
gui.set_color(t, ui.color(active and "cyan" or (shine and "cyan_hi" or "nav")))
if active then
ink:to(tx, ty, w, 44)
@@ -213,9 +189,6 @@ function M.build(g, parent, x, y, W, h, H)
H.navigate(g, { section = name, moon = g.view.moon })
end,
})
if name == "help" then
tooltip.add(root, "How to play")
end
g.tab_nodes[name] = root
tabs_c.reveal(g, "nav", name, root, t, w, 44)
end }
@@ -227,13 +200,16 @@ function M.build(g, parent, x, y, W, h, H)
-- every state is seen (the moon list may not be shown yet).
tabs_c.revealed(g, "newmoon", st.unlocked["new-moon"] and { "new-moon" } or {})
-- Claim all: every finished drill; waits with a countdown while none is ready.
-- Claim all: every finished drill; waits with a countdown while none is ready. Its
-- place is kept (CLAIM_W) once it's unlocked, shown or not, whatever its label, so the
-- tabs never move for it.
local n_ready = ready.drills(g)
local claim_all = st.unlocked["claim-all"] and (n_ready > 0 or st.nextEnd)
if claim_all then
nav_w = nav_w + 170
local reserve = st.unlocked["claim-all"]
local claim_all = reserve and (n_ready > 0 or st.nextEnd)
if reserve then
nav_w = nav_w + 10 + CLAIM_W
end
local claim = claim_all and { style = {}, make = function(p, cx, cy)
local claim = reserve and { style = { width = CLAIM_W, height = 44, shrink = 0 }, make = claim_all and function(p, cx, cy)
if st.ready > 0 then
local b, nodes = ui.button(p, "Claim all", cx, cy + 6, { primary = true, small = true })
H.badge(b, nodes, n_ready)
@@ -242,16 +218,37 @@ function M.build(g, parent, x, y, W, h, H)
H.claim_wait(g, p, cx, cy + 6, st.nextEnd, { small = true, key = "wait:all", params = {},
wait = "Claim all", done = "Claim all", badge = n_ready > 0 and n_ready or nil })
end
end } or nil
end or nil } or nil
local nav = row({ align = "start" }, { row({ gap = 2 }, tabs), claim and space(10) or nil, claim })
-- On the brand's row when it fits before the wallet, else on a row of its own.
local fits = pad + brand_w + 28 + nav_w <= wallet_x
local header_h = fits and S.header_h or S.header_h + 40
-- One row: brand, tabs and Claim all's place, the wallet. When the wallet doesn't fit
-- with its chips in the row, the chips go on a line under the balance (the header grows
-- by it); then, if still short, the brand goes. Last resort (a very high UI scale):
-- the tabs on a row of their own. None of it depends on Claim all being shown.
local chips_w = chip_widths(g, parent)
local chips_total = 22
for _, cw in ipairs(chips_w) do
chips_total = chips_total + cw + CHIP_GAP
end
local bal_probe = ui.text(parent, ui.money(math.max(st.credits, 0)), "balance", 0, 0)
local wallet_min = math.floor(ui.text_width(bal_probe) + 52) + 12 + GEAR + pad + 10
gui.delete_node(bal_probe)
local left = pad + brand_w + 28 + nav_w
local chips_below = left + chips_total + wallet_min > W
local fits = left + wallet_min <= W
local show_brand = fits or pad + nav_w + wallet_min > W -- (two rows: the brand stays)
fits = fits or not show_brand
if not show_brand then
gui.delete_node(brand.node)
gui.delete_node(cap.node)
end
wallet(g, parent, W, chips_w, chips_below)
local base_h = chips_below and S.header_h + 16 or S.header_h
local header_h = fits and base_h or base_h + 40
local tree = col({ width = W, height = header_h }, {
row({ height = fits and header_h or S.header_h - 8, padding_left = pad, align = "start" }, {
box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }),
fits and space(28) or nil,
row({ height = fits and header_h or base_h - 8, padding_left = pad, align = "start" }, {
show_brand and box({ width = brand_w, height = 48, shrink = 0 }, { brand, cap }) or nil,
fits and show_brand and space(28) or nil,
fits and col({ padding_top = 10 }, { nav }) or nil,
}),
not fits and row({ padding_left = pad - 12 }, { nav }) or nil,
+43 -50
View File
@@ -1,81 +1,74 @@
-- The Leaderboard section (state.leaderboard): the top miners by credits, and the
-- player's own place under them when they're further down (`you`). Centered, capped; the
-- rows scroll when the panel is too short for them (components/list).
-- The leaderboard (state.leaderboard) as a narrow pane in the Market (market.lua): the top
-- miners by credits in compact rows, and the player's own row under them when they're
-- further down (`you`). The rows scroll when the pane is too short (components/list).
local theme = require("modules.theme")
local ui = require("modules.ui")
local list = require("components.list")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local S = theme.size
local M = {}
local ROW_H = 38
local ROW_H = 30
local PAD = 12
local HEAD_H = 52 -- title and "Your place" line
-- A row (ROW_H tall, w wide) at (x, y): rank right-aligned in 52, the name (and "You"),
-- credits at the right; the player's own row lit.
-- A row (ROW_H tall, w wide) at (x, y): rank, the name, credits at the right; the
-- player's own row lit.
local function row(root, r, x, y, w)
local bg = ui.rect(root, x, y, w, ROW_H, "cyan", r.me and 0.08 or 0)
if r.me then
ui.rect(bg, 0, 0, 2, ROW_H - 1, "cyan")
end
ui.rect(bg, 0, ROW_H - 1, w, 1, "line")
local h = ROW_H
fg.build(flex.solve(fg.row({ width = w, height = h, padding_right = 14, align = "center" }, {
fg.row({ width = 52, justify = "end", shrink = 0 }, {
fg.label(bg, r.rank, { font = "mono", px = 13, color = r.rank <= 3 and "ready" or "muted" }, h),
fg.build(flex.solve(fg.row({ width = w, height = ROW_H, padding_right = PAD, align = "center" }, {
fg.row({ width = 30, justify = "end", shrink = 0 }, {
fg.label(bg, r.rank, { font = "mono", px = 12, color = r.rank <= 3 and "ready" or "muted" }, ROW_H),
}),
fg.space(24),
fg.label(bg, r.name, { font = "sans", px = 15, color = "text" }, h, { fit = true, shrink = 1, max_width = w - 76 - 180 }),
r.me and fg.space(10) or nil,
r.me and fg.label(bg, "You", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, h) or nil,
fg.space(10),
fg.label(bg, r.name, { font = "sans", px = 13, color = r.me and "cyan" or "text" }, ROW_H, { fit = true, shrink = 1, max_width = w - 52 - 90 }),
fg.fill(),
fg.inline(bg, h, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 14 })) end),
fg.space(8),
fg.inline(bg, ROW_H, function(p, cx, cy) return (ui.credits(p, r.credits, cx, cy, { px = 12 })) end),
}), 0, 0), bg)
end
function M.build(self, root, px, py, w, h, H)
local lb = self.state.leaderboard
local avail = w - px * 2
local inner = math.min(avail, S.leaderboard_max)
local x = px + math.floor((avail - inner) / 2)
local fix = function(b) b.style.shrink = 0; return b end
-- The list shrinks (and scrolls) when the panel is too short for every row; what's
-- under it stays in view.
local tree = fg.col({ width = inner, height = h - py * 2 }, {
-- (the line starts 6 into the heading's 44; 16 under it after the line's 6)
fix(fg.col({ height = 38 }, { fg.label(root, "Leaderboard", "h1", 40, { shrink = 0 }) })),
fix(fg.text_para(root, "The richest miners by credits on hand, out of " .. tostring(lb.players) .. ".",
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) })),
fg.space(0, 22),
fix(fg.row({ height = 16, align = "start" }, {
fg.row({ width = 52, justify = "end", shrink = 0 }, { fg.label(root, "Rank", "cap", 12) }),
fg.space(24), fg.label(root, "Miner", "cap", 12), fg.fill(),
fg.label(root, "Credits", "cap", 12), fg.space(14),
})),
#lb.top > 0 and fg.made({ height = #lb.top * ROW_H, min_height = ROW_H }, function(p, lx, ly, lw, lh)
ui.rect(p, lx, ly, lw, 1, "edge")
-- Builds the pane at (x, y), w wide; h: the most it may take (rows scroll), or nil for
-- every row.
-- Returns its height.
function M.pane(g, root, x, y, w, h)
local lb = g.state.leaderboard
local n = math.max(#lb.top, 1)
local foot = lb.you and ROW_H * 2 or 0
local natural = HEAD_H + n * ROW_H + foot + PAD
h = h and math.min(h, natural) or natural
local _, nodes = ui.panel(root, x, y, w, h)
local p = ui.part(nodes, "panel", "root")
ui.text(p, "Top miners", "cap", PAD, 18)
local place = lb.me and ("Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players)) or (tostring(lb.players) .. " miners")
ui.text(p, place, "small", PAD, 38)
local ly = HEAD_H
local lh = math.max(ROW_H, h - HEAD_H - foot - PAD)
ui.rect(p, 0, ly - 1, w, 1, "edge")
if #lb.top > 0 then
local l
l = list.new(self, p, lx, ly, lw, lh, {
l = list.new(g, p, 0, ly, w, lh, {
key = "leaderboard", row_h = ROW_H,
create = function(r)
local holder = ui.rect(l.content, 0, 0, lw, ROW_H, "none")
row(holder, r, 0, 0, lw)
local holder = ui.rect(l.content, 0, 0, w, ROW_H, "none")
row(holder, r, 0, 0, w)
return holder
end,
})
l:set_data(lb.top)
end) or fg.made({ height = ROW_H }, function(p, lx, ly, lw) ui.rect(p, lx, ly, lw, 1, "edge") end),
end
if lb.you then
-- (the web's "⋯": no font has it; three middle dots look the same)
lb.you and fix(fg.row({ width = 52, height = ROW_H, justify = "end" }, {
fg.label(root, "···", { font = "sans", px = 15, color = "muted" }, ROW_H),
})) or nil,
lb.you and fg.made({ height = ROW_H, shrink = 0 }, function(p, rx, ry, rw) row(p, lb.you, rx, ry, rw) end) or nil,
lb.me and fix(fg.col({}, { fg.space(0, 14), fg.label(root, "Your place: #" .. lb.me.rank .. " of " .. tostring(lb.players), "small", 16) })) or nil,
fg.fill(),
})
fg.build(flex.solve(tree, x, py), root)
local fy = ly + lh
ui.text(p, "···", { font = "sans", px = 13, color = "muted" }, 30, fy + ROW_H / 2, gui.PIVOT_E)
row(p, lb.you, 0, fy + ROW_H, w)
end
return h
end
return M
+45 -18
View File
@@ -16,6 +16,7 @@ local ticker = require("screens.game.ticker")
local sale = require("screens.game.sale")
local scroll = require("components.scroll")
local blocks = require("modules.blocks")
local leaderboard = require("screens.game.leaderboard")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
@@ -242,32 +243,28 @@ local function stat(c, label, value)
return col({ align = "start", shrink = 0 }, { fg.label(c, label, "cap", 12), space(0, 4), value })
end
function M.build(self, root, px, py, w, h, H, opts)
opts = opts or {}
-- The market itself in w (its content centered in it); full_w: the ticker's width (the
-- whole panel). Returns the bottom of what it built and the y under the ticker.
local function build_market(self, root, px, py, w, h, H, stacked, full_w)
local st = self.state
local mk = st.market
local stacked = self.size and self.size[1] <= STACK_W
local area
if stacked then
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
root = area.content
end
local c = root
local inner = math.min(w - px * 2, theme.size.market_max)
local ox = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
self.quote_nodes, self.quote_edges = {}, {}
-- Ticker tape across the panel (kept while the quotes are the same).
local tape = {}
local tape, tick_bottom = {}, 14
for i, q in ipairs(mk.quotes) do
tape[i] = { q.item, q.symbol, q.price, q.up, q.change, q.changeText }
end
local function ticker_part(cy)
return cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, w, tape), root, function(r)
return ticker.build(self, r, 14, cy, w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
tick_bottom = cy + blocks.keep(self, "mk:ticker", blocks.sig(cy, full_w, tape), root, function(r)
return ticker.build(self, r, 14, cy, full_w - 28, mk.quotes, function(item) return color_of(st, item) end, function(item)
H.navigate(self, quote_view(self, item))
end)
end) + 8
return tick_bottom
end
-- Top mover and biggest drop: links to their quote; in the heading row when there's
@@ -360,10 +357,7 @@ function M.build(self, root, px, py, w, h, H, opts)
show_refined and col({}, { space(0, 4), cards(self, H, c, inner, refined, "Refined items") }) or nil,
})
if not q then
if area then
area:finish(cy + 18)
end
return
return cy, tick_bottom
end
cy = cy + 6
@@ -646,9 +640,6 @@ function M.build(self, root, px, py, w, h, H, opts)
local panels = stacked and col({ gap = 12 }, { detail, ticket }) or row({ gap = 12, align = "start" }, { detail, ticket })
fg.build(flex.solve(panels, ox, cy, inner), c)
if area then
area:finish(panels.y + panels.h + 18)
end
-- A sale (fx.lua calls this after the build that shows it, with what it earned): the
-- ticket powers down; the coins leave from the payout.
@@ -664,6 +655,42 @@ function M.build(self, root, px, py, w, h, H, opts)
if sale_n then
sale.bind(self, sale_n) -- (a rebuild while it plays)
end
return panels.y + panels.h, tick_bottom
end
-- The leaderboard (state.leaderboard, asked for with the market: session.view_params) in
-- a narrow pane right of the market when there's room, else under it (the section then
-- scrolls).
local LB_W, LB_GAP = 220, 12
function M.build(self, root, px, py, w, h, H, opts)
local lb = self.state.leaderboard
local stacked = self.size and self.size[1] <= STACK_W
local avail = w - px * 2
local side = lb and avail >= 660 + LB_GAP + LB_W
local area
if stacked or (lb and not side) then
area = scroll.area(self, root, 0, 2, w, h - 4, "market")
root = area.content
end
local bottom
if side then
-- Market and pane centered together; the ticker spans the panel.
local inner = math.min(avail - LB_GAP - LB_W, theme.size.market_max)
local ox = px + math.floor((avail - inner - LB_GAP - LB_W) / 2)
local tick
bottom, tick = build_market(self, root, ox, py, ox * 2 + inner, h, H, stacked, w)
leaderboard.pane(self, root, ox + inner + LB_GAP, tick + 6, LB_W, h - tick - 6 - py)
else
bottom = build_market(self, root, px, py, w, h, H, stacked, w)
if lb then
local inner = math.min(avail, theme.size.market_max)
bottom = bottom + 18 + leaderboard.pane(self, root, px + math.floor((avail - inner) / 2), bottom + 18, inner)
end
end
if area then
area:finish(bottom + 18)
end
end
function M.update(self)
+74 -18
View File
@@ -1,7 +1,8 @@
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
-- ready badge; a virtualized list, components/list) and "+ New moon" pinned to the bottom.
-- ready badge; a virtualized list, components/list), "+ New moon" at the top (buys one) and
-- the announcements pane (modules/toasts) in the lower half.
-- The Past list comes a page at a time (state.pastPage, page= in the view): Newer / Older
-- under it, the total on its tab.
-- Effects (fx.js): a new moon's row slides in (components/row enter), a bought one swells
@@ -18,6 +19,7 @@ local field = require("modules.field")
local moon_row = require("widgets.moon_row")
local ready = require("screens.game.core.ready")
local tabs = require("components.tabs")
local button = require("components.button")
local list_c = require("components.list")
local row_c = require("components.row")
local blocks = require("modules.blocks")
@@ -260,6 +262,16 @@ function M.teardown(self)
self.ml_ghosts = nil
end
-- The grade "+ New moon" buys: the toggle's pick if the server still lists it, else
-- state.moonGrade.
function M.grade(g)
local st = g.state
for _, e in ipairs(st.newMoon and st.newMoon.grades or {}) do
if e.grade == g.nm_grade then return g.nm_grade end
end
return st.moonGrade
end
function M.build(self, H, parent, x, y, w, h)
local st = self.state
local v = self.view
@@ -269,6 +281,55 @@ function M.build(self, H, parent, x, y, w, h)
local inner = w - pad * 2
local kids = {} -- the panel, top to bottom (one tree; full width, rows padded)
-- "+ New moon" (dashed in the web build), at the top: buys a moon straight away (the
-- new-moon section's Claim and reveal); disabled with the reason when short.
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
if st.unlocked["new-moon"] then
-- The grade it buys: the one picked with the toggle at its right (g.nm_grade, kept
-- across builds), else the server's default (state.moonGrade).
local grades = st.newMoon and st.newMoon.grades or {}
local grade = M.grade(self)
local entry
for _, e in ipairs(grades) do
if e.grade == grade then entry = e end
end
local price = entry and entry.price or st.moonPrice or 0
local ok = entry and entry.canAfford or (not entry and (st.credits or 0) >= price)
local why = entry and (entry.whyText or "") ~= "" and entry.whyText
or ("Need " .. theme.currency .. ui.money(math.max(0, price - (st.credits or 0))) .. " more")
local label = grade and ("+ New " .. grade .. " moon") or "+ New moon"
local tw = #grades > 1 and 34 or 0
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad }, gap = tw > 0 and 6 or 0 }, {
fg.button(root, label, { price = price, variant = "ghost", on = v.section == "new-moon", disabled = not ok }, function(b, nodes)
if ok then
H.action(self, b, nodes, "buy-moon", { grade = grade, include = "new-moon" }, nil, nil, "newmoon:buy")
else
tooltip.add(b, why)
end
local extra = { ui.part(nodes, "button", "edge") }
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
if i > 1 then
extra[#extra + 1] = part[1] -- (the price)
end
end
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner - tw - (tw > 0 and 6 or 0), S.button_h, extra)
end, { grow = 1, shrink = 1 }),
-- The grade toggle: steps through the grades (cheapest first), stays in place.
tw > 0 and fg.button(root, grade or "?", { variant = "ghost" }, function(b)
button.bind(self, b, nil, { id = "newmoon:grade", label = "Grade " .. (grade or ""), on_click = function()
local at = 0
for i, e in ipairs(grades) do
if e.grade == grade then at = i end
end
self.nm_grade = grades[at % #grades + 1].grade
self.dirty = true
end })
tooltip.add(b, "Grade to buy: " .. (grade or "") .. " (click to change)")
end, { width = tw }) or nil,
})
kids[#kids + 1] = fg.space(0, 12)
end
-- Active / Past tabs (once the store is open, like the web): a tab is its label and
-- count in a hit box 4 wider on the left; a line under them.
if (#st.moons + #st.past) > 0 and st.unlocked.store then
@@ -416,7 +477,7 @@ function M.build(self, H, parent, x, y, w, h)
fg.space(0, 12),
})
end
-- The rows (a virtualized list) take the room down to the pager and "+ New moon".
-- The rows (a virtualized list) take the room down to the pager and the announcements.
kids[#kids + 1] = fg.made({ grow = 1, shrink = 1 }, function(_, _, ly, _, lh)
rows_y, list_h = ly, lh
build_rows(find.text, ly)
@@ -443,23 +504,18 @@ function M.build(self, H, parent, x, y, w, h)
kids[#kids + 1] = fg.space(0, 4)
end
-- "+ New moon" (dashed in the web build): pinned to the bottom of the panel.
-- Revealed after load (onboarding; header.lua tracks it): the tab entrance (data-reveal).
if st.unlocked["new-moon"] then
kids[#kids + 1] = fg.space(0, 10)
kids[#kids + 1] = fg.row({ padding = { 0, pad, 0, pad } }, {
fg.button(root, "+ New moon", { price = st.moonPrice, variant = "ghost", on = v.section == "new-moon" }, function(b, nodes)
H.link(self, b, { section = "new-moon", moon = v.moon })
local extra = { ui.part(nodes, "button", "edge") }
for i, part in ipairs(ui.button_looks[b] and ui.button_looks[b].parts or {}) do
if i > 1 then
extra[#extra + 1] = part[1] -- (the price)
end
end
tabs.reveal(self, "newmoon", "new-moon", b, ui.part(nodes, "button", "text"), inner, S.button_h, extra)
end, { width = inner }),
-- The announcements pane: the panel's lower half (modules/toasts lists the toasts there).
kids[#kids + 1] = fg.space(0, 12)
kids[#kids + 1] = fg.col({ height = math.floor(h / 2), padding = { 0, pad, 0, pad } }, {
fg.made({ height = 1 }, function(p, lx, ly, lw)
ui.rect(p, lx, ly, lw, 1, "line")
end),
fg.space(0, 10),
fg.label(root, "Announcements", "cap", 12), fg.space(0, 8),
fg.made({ grow = 1, shrink = 1 }, function(p, px, py, pw, ph)
self.toasts:attach(ui.rect(p, px, py, pw, ph, "none"), pw, ph)
end),
})
end
for _, k in ipairs(kids) do
k.style.shrink = k.style.shrink or 0
end
+24 -13
View File
@@ -10,6 +10,7 @@ local tooltip = require("modules.tooltip")
local button = require("components.button")
local moon_sale = require("screens.game.moon_sale")
local bar = require("components.bar")
local chip = require("components.chip")
local row_c = require("components.row")
local scroll = require("components.scroll")
local ready = require("screens.game.core.ready")
@@ -32,9 +33,12 @@ local function find(list, id)
end
end
-- A full supply bar: the server's rules.maxSupply (the config's wellSupplyMax, or the
-- A full supply bar: the moon's maxSupply (its grade's), else the server's rules.maxSupply (the config's wellSupplyMax, or the
-- biggest well the player has if bigger); without it, the biggest well we know of.
function M.supply_max(st)
function M.supply_max(st, moon)
if moon and (moon.maxSupply or 0) > 0 then
return moon.maxSupply -- (its grade's biggest well)
end
if st.rules and (st.rules.maxSupply or 0) > 0 then
return st.rules.maxSupply
end
@@ -397,8 +401,11 @@ local function rating_chip(c, r)
local cw = math.floor(tw) + 10
return made({ width = cw, height = 16, shrink = 0 }, function(p, x, y)
local b = ui.outline(p, x, y, cw, 16, edge, alpha)
if fill then
gui.move_below(ui.rect(b, 0, 0, cw, 16, edge, fill), b)
if fill then -- (the fill holds the edge: gui.move_below on a child would move it to p's 0, 0)
local e = b
b = ui.rect(p, x, y, cw, 16, edge, fill)
gui.set_parent(e, b, false)
gui.set_position(e, vmath.vector3(0, 0, 0))
end
ui.label(b, "★" .. r.stars, style, 5, 8)
tooltip.add(b, (r.label or r.tier) .. " moon · top " .. (r.top or (100 - (r.pct or 0))) .. "%")
@@ -406,8 +413,9 @@ local function rating_chip(c, r)
end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- wells table, then the sale. Returns its bottom.
local function moon_view(g, c, px, py, inner, H)
-- wells table, then the sale at the bottom of the pane (min_h tall at least). Returns its
-- bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
@@ -415,11 +423,14 @@ local function moon_view(g, c, px, py, inner, H)
-- before moon names), like the web.
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
local head = { fg.label(c, moon.id, MONO13, 16), space(0, 2), fg.label(c, moon.name, "h1", 40) }
if has_class or moon.rating then
local grade = (moon.grade or "") ~= "" and moon.grade or nil
if has_class or moon.rating or grade then
head[#head + 1] = space(0, 2)
head[#head + 1] = row({ height = 16, align = "center" }, {
head[#head + 1] = row({ height = 16, align = "center", gap = 6 }, {
grade and made({ width = chip.GRADE_W, height = 16, shrink = 0 }, function(p, x, y)
tooltip.add((chip.grade(p, grade, x, y + 8)), "Grade " .. grade .. " moon")
end) or nil,
has_class and fg.label(c, moon.kind .. " class", "cap", 12) or nil,
has_class and moon.rating and space(6) or nil,
moon.rating and rating_chip(c, moon.rating) or nil,
})
head[#head + 1] = space(0, 6)
@@ -447,7 +458,7 @@ local function moon_view(g, c, px, py, inner, H)
end
-- Wells table: the header, an edge line, a row per well and the multi-well group.
local max = M.supply_max(st)
local max = M.supply_max(st, moon)
local cols = table_cols(inner)
local enters = {}
local tbl = {
@@ -468,11 +479,11 @@ local function moon_view(g, c, px, py, inner, H)
-- Selling the moon (once the store is open).
local sale = st.unlocked.store and moon.sale
local page = col({ width = inner }, {
local page = col({ width = inner, min_height = min_h }, {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
col({}, tbl),
sale and space(0, 18) or nil,
sale and col({ grow = 1, min_height = 18 }, {}) or nil,
sale and moon_sale.box(g, H, c, moon, inner) or nil,
})
fg.build(flex.solve(page, px, py), c)
@@ -497,7 +508,7 @@ function M.build(g, root, px, py, w, h, H)
-- The moon scrolls when it doesn't fit (many wells, the sale's lines open), like
-- the web's page.
local area = scroll.area(g, root, 0, 0, w, h, "moon:" .. st.moon.id)
local bottom = moon_view(g, area.content, px, py, inner, H)
local bottom = moon_view(g, area.content, px, py, inner, h - py * 2, H)
area:finish(bottom + py)
end
end
+55 -8
View File
@@ -198,7 +198,7 @@ function M.update(self)
end
function M.teardown(self)
self.rf_bars = nil
self.rf_bars = nil -- (rf_now stays: the next build glides the batches from it)
end
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off).
@@ -236,15 +236,23 @@ local function frame(self, p, x, y, w, h, edge_color)
end
-- A slot (.refinery-slot, at least 112 tall, padding 12 + border): idle, running or ready.
local function slot_card(self, H, c, st, slot)
-- Batches are shown oldest first from the left, idle slots after them ("Slot n" is the
-- position), so a new order lands right of the others and collecting the leftmost slides
-- the rest left: repeated clicks on one spot collect one batch after another. A batch's
-- card glides from where it was (GLIDE): the layout's slot node (outer) sits at the new
-- rect at once, the frame inside it moves; the Collect button's clicks go to a box in the
-- slot node at the button's final rect, so a click there counts during the glide.
local GLIDE = 0.18
local function slot_card(self, H, c, st, slot, n)
local job = slot.job
local ready = job and job.ready
local edge = job and (ready and ui.color("ready", 0.5) or ui.color("line_hi")) or ui.color("line")
local outer -- (the card's slot node, made first: the Collect button's hit box goes in it)
local kids
if not job then
kids = { fg.label(c, "Slot " .. slot.n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
kids = { fg.label(c, "Slot " .. n, "cap", 16), space(0, 8.5), fg.para(c, "Idle. Pick a recipe below.", "small", 19.5) }
else
local head = fg.label(c, "Slot " .. slot.n, "cap", 22)
local head = fg.label(c, "Slot " .. n, "cap", 22)
local right
if ready then
right = { style = { height = 22 }, make = function(p, x, y, w, h)
@@ -271,15 +279,38 @@ local function slot_card(self, H, c, st, slot)
cell_bar(self, H, color, job),
{ style = { grow = 1, min_height = ready and 8 or 0 } },
ready and btn("Collect " .. job.qty, BTN_SM, { primary = true }, function(b, nodes)
H.action(self, b, nodes, "collect", { job = job.id })
local p, size = gui.get_position(b), gui.get_size(b)
local hit = ui.rect(outer, p.x, -p.y, size.x, size.y, "none")
H.action(self, b, nodes, "collect", { job = job.id }, nil, nil, nil, hit)
end, { width = "100%" }) or nil,
}
end
return col({ padding = 13, min_height = 112 }, kids), function(p, x, y, w, h)
local f = frame(self, p, x, y, w, h, edge)
outer = ui.rect(p, x, y, w, h, "none")
local f = frame(self, outer, 0, 0, w, h, edge)
if ready then
inset_glow(f, w, h, "ready", 0.08)
end
if job then
-- Where the card is drawn now (its last rect, less what its glide has done).
local was = self.rf_was and self.rf_was[job.id]
local now = socket.gettime()
local fx, fy = 0, 0
if was and not anim.reduced then
local u = math.min(1, (now - was.t) / GLIDE)
local k = 1 - (1 - u) * (1 - u) -- (EASING_OUTQUAD)
fx = was.x + was.fx * (1 - k) - x
fy = was.y + was.fy * (1 - k) - y
if math.abs(fx) < 0.5 and math.abs(fy) < 0.5 then
fx, fy = 0, 0
end
end
self.rf_now[job.id] = { x = x, y = y, fx = fx, fy = fy, t = now }
if fx ~= 0 or fy ~= 0 then
gui.set_position(f, vmath.vector3(fx, -fy, 0))
gui.animate(f, "position", vmath.vector3(0, 0, 0), gui.EASING_OUTQUAD, GLIDE)
end
end
return f
end
end
@@ -509,8 +540,24 @@ function M.build(self, root, px, py, w, h, H)
-- Slots (.refinery-slots: minmax(200px, 1fr), gap 12; a row as tall as its tallest).
local slots = {}
for _, slot in ipairs(rf.slots) do
local tree, make = slot_card(self, H, c, st, slot)
local order = {}
for i, slot in ipairs(rf.slots) do
order[i] = { slot = slot, i = i }
end
table.sort(order, function(a, b)
local ja, jb = a.slot.job, b.slot.job
if (ja ~= nil) ~= (jb ~= nil) then
return ja ~= nil
end
local sa, sb = ja and ja.start or 0, jb and jb.start or 0
if sa ~= sb then
return sa < sb
end
return a.i < b.i
end)
self.rf_was, self.rf_now = self.rf_now, {}
for n, o in ipairs(order) do
local tree, make = slot_card(self, H, c, st, o.slot, n)
tree.make = make
slots[#slots + 1] = tree
end
+292 -253
View File
@@ -6,20 +6,25 @@
-- the moon itself is known before the purchase.
--
-- The reveal (reveal.new(g, layer), started with :start(data) by a buy-moon response's
-- `reveal`), all on its own layer over the layout:
-- 1. Charge: the page dims around the viewport, the shroud charges up and shakes (moon.script).
-- 2. Burst: when the moon's mesh is ready, a flash, sparks and a thump; the moon pops out.
-- 3. Card: name, class, the verdict and wells pop in one by one, then the badge stamps down
-- (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet note).
-- 4. Back: the card flies into the moon list, the page comes back and the balance counts
-- down (fx "revealed"), the toast shows.
-- `reveal`), all on its own layer over the dimmed page, in a window at the center of the
-- screen (the same size for every moon):
-- 1. Charge: the moon's stage (the 3D moon in its own node) shows the shroud charging up
-- (moon.script), the "?" trembling, a low rumble building.
-- 2. Burst: when the moon's mesh is ready, a flash, sparks, the reveal boom and a decaying
-- Perlin-noise shake of the window and the page; the moon pops out.
-- 3. Drawer: the stage slides right while a drawer slides out from behind it to the left;
-- in it name, class, the verdict and wells pop in one by one, then the badge stamps
-- down (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet
-- note). Buy another and Start mining sit in fixed slots at the drawer's foot.
-- 4. Close: the window fades and sinks, the page comes back, the new moon's row is marked
-- (fx "revealed"), the toast shows. (The balance counts down at the purchase, fx.lua.)
-- While it plays, the main area, the well panel and the new moon's list entry stay hidden
-- (g.reveal:hiding(), :hides(id)): nothing about the wells shows before the card does.
-- A click or Space/Enter skips ahead (charge -> burst, card -> its end state), then closes;
-- Esc closes. "Buy another" (reveal.next: hidden while more moons are locked, disabled
-- with the server's reason when unaffordable) closes the card and buys the next moon,
-- whose response plays a new reveal. The card's buttons take keyboard focus (scope
-- "reveal", modules/focus) once it's complete.
-- (g.reveal:hiding(), :hides(id)), and the aside's viewport leaves the 3D moon to the
-- stage. A click or Space/Enter skips ahead (charge -> burst, drawer -> its end state),
-- then closes; Esc closes. "Buy another" (reveal.next: hidden while more moons are locked,
-- disabled with the server's reason when unaffordable) closes the window and buys the
-- next moon, whose response plays a new reveal. The buttons take keyboard focus (scope
-- "reveal", modules/focus) once the drawer is complete.
--
-- Times follow moon.revealing.t (the reveal clock, which mm.reveal_freeze can hold for
-- screenshots), so every frame of the card can be captured.
@@ -33,6 +38,7 @@ local test = require("modules.test")
local tooltip = require("modules.tooltip")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local chip = require("components.chip")
local M = {}
M.__index = M
@@ -41,8 +47,6 @@ local C = theme.color
local GOLD_HI = C.ready_hi
local FLASH = C.flash
local MIST = C.mist
local CARD_W = 440
local CARD_PAD = 22
local function v3(x, y)
return vmath.vector3(x, y, 0)
@@ -98,57 +102,68 @@ function M.build_teaser(g, root, px, py, inner)
gui.animate(n, "color.w", 0.45, gui.EASING_INOUTSINE, 1.2, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
end
-- Grid: the claim frame (1fr, at least 220) and the hints (2fr).
-- The grade picker: a row per grade (cheapest first, the server's order), its price,
-- wells and units per well, and its Claim button at the right (the same spot whatever
-- is affordable; disabled with the server's reason). The grade "+ New moon" buys
-- (moonlist.grade) has the primary button.
local gap = 14
local bw = math.max(220, math.floor((inner - gap) / 3))
local hw = inner - bw - gap
local bh = 196
local small = "small"
local left
if nm.canAfford then
left = { fg.label(root, "Leaves you", small, 16), space(6), credits(nm.after, { px = 13, color = "muted" }, 16) }
else
left = { fg.label(root, "You need", small, 16), space(6), credits(nm.need, { px = 13, color = "ready" }, 16), space(6),
fg.label(root, "more", small, 16) }
local grades = nm.grades or {}
local pick = st.moonGrade
for _, e in ipairs(grades) do
if e.grade == g.nm_grade then pick = e.grade end
end
local buy = panel_box({ width = bw, height = bh, padding = 18, shrink = 0 }, {
made({ position = "absolute", left = bw - 150, top = -40, width = 180, height = 150 }, function(p, x, y, w, h)
gui.play_flipbook(ui.rect(p, x, y, w, h, "violet", 0.16), "glow")
local function range(lo, hi, money)
local f = money and ui.money or tostring
return lo == hi and f(lo) or (f(lo) .. "–" .. f(hi))
end
local BW = 128
local rows = {}
for i, e in ipairs(grades) do
local info = range(e.wellsMin, e.wellsMax) .. (e.wellsMin == 1 and e.wellsMax == 1 and " well" or " wells")
.. " · " .. range(e.supplyMin, e.supplyMax, true) .. " units each"
local why = not e.canAfford and ((e.whyText or "") ~= "" and e.whyText or "Not enough credits") or nil
rows[#rows + 1] = row({ height = 52, align = "center", padding = { 0, 16, 0, 16 }, gap = 12 }, {
made({ width = 28, height = 22, shrink = 0 }, function(p, x, y)
chip.grade(p, e.grade, x, y + 11, { h = 22, w = 28, px = 14 })
return nil
end),
fg.label(root, "Claim price", "cap", 12), space(0, 18),
credits(nm.price, { px = 34, color = "text", font = "mono_semi" }, 20), space(0, 16),
row({ height = 16, align = "center" }, left),
fg.fill(),
fg.button(root, "Claim and reveal", { primary = true, disabled = not nm.canAfford }, function(b, nodes)
if not nm.canAfford then
tooltip.add(b, (nm.whyText or "") ~= "" and nm.whyText or "Not enough credits") -- (the web's title)
col({ grow = 1, shrink = 1, gap = 4 }, {
row({ height = 18, align = "center", gap = 10 }, {
credits(e.price, { px = 16, color = e.canAfford and "text" or "muted", font = "mono_semi" }, 18),
why and fg.label(root, why, { font = "sans", px = 13, color = "ready" }, 18, { fit = true, shrink = 1 }) or nil,
}),
fg.label(root, info, { font = "sans", px = 13, color = "text_2" }, 16, { fit = true, shrink = 1 }),
}),
fg.button(root, "Claim " .. e.grade, { primary = e.grade == pick, disabled = not e.canAfford }, function(b, nodes)
if not e.canAfford then
tooltip.add(b, why)
return
end
-- The web's nm-glow: a breathing halo around the button.
local halo = ui.rect(b, -12, -12, bw - 36 + 24, S.button_h + 24, "cyan", 0.0)
gui.play_flipbook(halo, "glow")
gui.move_below(halo, ui.part(nodes, "button", "edge"))
gui.animate(halo, "color.w", 0.35, gui.EASING_INOUTSINE, 1, 0, nil, gui.PLAYBACK_LOOP_PINGPONG)
g.h.action(g, b, nodes, "buy-moon", { include = "new-moon" })
end, { width = bw - 36 }),
-- (then "+ New moon" follows the grade just bought: state.moonGrade)
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, include = "new-moon" },
function() g.nm_grade = nil end, nil, "newmoon:grade:" .. e.grade)
end, { width = BW }),
}, i > 1 and { make = function(p, x, y, w)
ui.rect(p, x + 16, y, w - 32, 1, "edge")
return nil
end } or nil)
end
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }),
space(0, 4),
col({}, rows),
})
-- Hints: wells (and what they can hold), then the classes it could be (full width).
local half = math.floor((hw - gap) / 2)
local ww = half < 230 and hw or half
-- (the web's WellRange: "3" when the bounds are equal)
local range = (nm.wellsMin and nm.wellsMax) and (nm.wellsMin == nm.wellsMax and tostring(nm.wellsMin)
or (nm.wellsMin .. "–" .. nm.wellsMax)) or "2–5"
-- Hints: what the wells can hold, then the classes it could be.
local bw = math.max(220, math.floor((inner - gap) / 3))
local hw = inner - bw - gap
local dots = {}
for _, r in ipairs(nm.resources or {}) do
dots[#dots + 1] = made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, r) end)
end
local wells = panel_box({ width = ww, height = 92, padding = { 16, 16, 0, 16 } }, {
local wells = panel_box({ width = bw, height = 92, padding = { 16, 16, 0, 16 }, shrink = 0 }, {
fg.label(root, "Wells", "cap", 12), space(0, 12),
row({ height = 20, align = "start" }, {
fg.label(root, range, { font = "mono_semi", px = 22, color = "text" }, 20), space(8),
fg.label(root, "of these resources", { font = "sans", px = 14, color = "text_2" }, 24, { fit = true, shrink = 1 }),
}),
fg.label(root, "Any of these resources", { font = "sans", px = 14, color = "text_2" }, 20, { fit = true, shrink = 1 }),
space(0, 10),
row({ gap = 4 }, dots),
})
@@ -161,7 +176,7 @@ function M.build_teaser(g, root, px, py, inner)
ui.outline(p, x, y, w, h, "line_hi")
end })
end
local classes = panel_box({ width = hw, min_height = bh - 92 - gap, padding = { 16, 16, 14, 16 } }, {
local classes = panel_box({ width = hw, min_height = 92, padding = { 16, 16, 14, 16 } }, {
fg.label(root, "Its class could be", "cap", 12), space(0, 10),
row({ wrap = true, column_gap = 6, row_gap = 6 }, chips),
})
@@ -172,7 +187,8 @@ function M.build_teaser(g, root, px, py, inner)
fg.text_para(root, "Something is out there. Its surface, its wells and how rich they run stay hidden until you claim it. What will you find?",
{ font = "sans", px = 15, color = "text_2" }, { max_width = math.min(inner, S.text_max) }),
space(0, 22),
row({ gap = gap, align = "start" }, { buy, col({ width = hw, gap = gap, shrink = 0 }, { wells, classes }) }),
picker, space(0, gap),
row({ gap = gap, align = "start" }, { wells, classes }),
})
fg.build(flex.solve(tree, px, py), root)
end
@@ -309,77 +325,112 @@ local function name_letters(parent, name, x, y, px)
return letters, cx - x - tracking
end
-- The window (fixed size for every moon: nothing about it hints at the moon before the
-- drawer shows): the moon's square stage, centered while it charges and bursts; then the
-- drawer slides out to its left while the stage slides right, the whole window centered.
local STAGE = 500 -- the stage's side (= the window's and the drawer's height)
local DRAWER_W = 400
local PAD = 20
local SLIDE = 0.55 -- the drawer slides out (s)
local LEAD = 0.3 -- the drawer's content starts this long into the slide (s)
local CLOSE_W, ANOTHER_W = 150, 200 -- fixed: the buttons sit in the same spot every time
local MAX_WELLS = 5 -- (one per resource: the wells' rows take this room always)
local HOLD = 0.65 -- the moon alone, centered, after the burst (s)
local SHAKE_AMP, SHAKE_DUR, SHAKE_FREQ = 18, 0.8, 9 -- the burst's Perlin shake
-- Starts the reveal for a buy-moon response's `reveal` (toast: shown when it closes).
function M:start(data, toast)
if self.r then
self:finish(true)
end
local g = self.g
local r = { data = data, toast = toast, stage = "charge", events = {}, offset = 0, quiet = false }
local r = { data = data, toast = toast, stage = "charge", events = {}, offset = 0, quiet = false, k = 0 }
self.r = r
self.bound, self.card_bound = nil, nil
-- Reduced motion (reveal.js quick): no charge-up, no particles or shake, the card fades
-- in complete, silently; the moon only fades out of its shroud (moon.script).
-- Reduced motion (reveal.js quick): no charge-up, slide, particles or shake; the window
-- is laid out complete, the drawer fades in, silently; the moon only fades out of its
-- shroud (moon.script).
r.reduced = anim.reduced
moon_view.prefetch(data.moonId) -- (its mesh starts now, whether the viewport shows yet or not)
if g.viewport_node then
moon_view.reveal(data.moonId, g.viewport_node)
if moon_view.revealing then
moon_view.revealing.reduced = r.reduced
if r.reduced then
r.k = 1
end
else
r.need_view = true -- (bought from a view without the aside: it shows on the rebuild)
end
-- Nothing else on the layer: a full-screen click target, the dim with a hole for the
-- viewport, the viewport's charge glow, the "?" leaving, then the card.
local W, H = g.size[1], g.size[2]
-- On the layer: a full-screen click target, the dim, the window (shaken as a whole):
-- the drawer (under the stage: it slides out from behind it), the stage (halo, the moon
-- node, edge, charge ring, the "?").
r.root = group(self.layer, 0, 0)
gui.set_inherit_alpha(r.root, false) -- the layer node itself is transparent
gui.set_color(r.root, vmath.vector4(1, 1, 1, 0))
r.hit = node_box(r.root, 0, 0, W, H, vmath.vector4(0, 0, 0, 0))
r.dim = {}
for i = 1, 4 do
r.dim[i] = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.82))
end
r.ring = node_box(r.root, 0, 0, 1, 1, rgba(MIST, 0), "outline")
gui.set_slice9(r.ring, vmath.vector4(2, 2, 2, 2))
r.halo = node_box(r.root, 0, 0, 1, 1, rgba(C.violet, 0), "glow")
r.hit = node_box(r.root, 0, 0, 1, 1, vmath.vector4(0, 0, 0, 0))
r.dim = node_box(r.root, 0, 0, 1, 1, rgba(C.bg, 0.86))
r.win = group(r.root, 0, 0)
r.drawer = group(r.win, 0, 0)
r.stage_n = group(r.win, 0, 0)
r.halo = node_box(r.stage_n, -STAGE * 0.2, -STAGE * 0.2, STAGE * 1.4, STAGE * 1.4, rgba(C.violet, 0), "glow")
gui.set_blend_mode(r.halo, theme.glow_blend())
gui.move_below(r.halo, r.ring)
if g.view_was_mystery then
local vr = g.viewport_rect
r.question = text(r.root, "?", { font = "mono_semi", px = math.floor(math.max(64, math.min(140, vr.w * 0.32))), color = MIST },
vr.x + vr.w / 2, vr.y + vr.h / 2, gui.PIVOT_CENTER)
r.vp = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.viewport, 1))
local edge = node_box(r.stage_n, 0, 0, STAGE, STAGE, rgba(C.line_hi, 0.8), "outline")
gui.set_slice9(edge, vmath.vector4(2, 2, 2, 2))
r.ring = node_box(r.stage_n, -2, -2, STAGE + 4, STAGE + 4, rgba(MIST, 0), "outline")
gui.set_slice9(r.ring, vmath.vector4(2, 2, 2, 2))
r.question = text(r.stage_n, "?", { font = "mono_semi", px = 140, color = MIST }, STAGE / 2, STAGE / 2, gui.PIVOT_CENTER)
r.question_scale = gui.get_scale(r.question).x
end
gui.set_color(r.question, rgba(MIST, r.reduced and 0.85 or 0.5))
self:place()
moon_view.prefetch(data.moonId) -- (its mesh starts now)
moon_view.reveal(data.moonId, r.vp)
if moon_view.revealing then
moon_view.revealing.reduced = r.reduced
end
if not r.reduced then
sfx.play("charge")
sfx.play("rumble")
end
self:bind()
print("mm: reveal " .. tostring(data.name) .. " (" .. tostring(data.rating and data.rating.tier) .. ")")
end
-- Puts the dim around the viewport (it moves with the layout).
-- The window's place for the layout size (centered; it moves with nothing else).
function M:place()
local r = self.r
local g = self.g
local W, H = g.size[1], g.size[2]
local v = g.viewport_rect or { x = W - 400, y = 100, w = 360, h = 360 }
gui.set_size(r.hit, v3(W, H))
local rects = {
{ 0, 0, W, v.y }, { 0, v.y + v.h, W, H - v.y - v.h },
{ 0, v.y, v.x, v.h }, { v.x + v.w, v.y, W - v.x - v.w, v.h },
}
for i, q in ipairs(rects) do
gui.set_position(r.dim[i], v3(q[1], -q[2]))
gui.set_size(r.dim[i], v3(math.max(0, q[3]), math.max(0, q[4])))
gui.set_position(r.dim, v3(-80, 80)) -- (bigger than the screen: the shake moves it)
gui.set_size(r.dim, v3(W + 160, H + 160))
r.L = math.floor((W - DRAWER_W - STAGE) / 2)
r.T = math.floor((H - STAGE) / 2)
self:pose()
end
-- Positions at the slide's progress r.k, with the shake, a verdict's jolt and the close's
-- drop; the 3D moon follows its node.
function M:pose()
local r = self.r
local half = DRAWER_W / 2
gui.set_position(r.stage_n, v3(r.L + half * (1 + r.k), -r.T))
local jx, jy = 0, 0
if r.jolt_at then
local j = (socket.gettime() - r.jolt_at) / 0.18
if j < 1 then
local q = math.sin(j * math.pi)
jx, jy = 2 * q, 4 * q
end
end
gui.set_position(r.drawer, v3(r.L + half * (1 - r.k) + jx, -(r.T + jy)))
local ox, oy = 0, 0
if r.shake_at then
local e = socket.gettime() - r.shake_at
if e < SHAKE_DUR then
local a = SHAKE_AMP * (1 - e / SHAKE_DUR) ^ 2
ox = anim.perlin(e * SHAKE_FREQ, r.seed) * a
oy = anim.perlin(e * SHAKE_FREQ, r.seed + 31.7) * a
end
end
gui.set_position(r.win, v3(ox, oy - (r.drop or 0)))
local at = gui.get_screen_position(r.vp)
if moon_view.revealing and at ~= r.vp_at then
r.vp_at = at
moon_view.move(r.vp, true)
end
gui.set_position(r.ring, v3(v.x - 2, -(v.y - 2)))
gui.set_size(r.ring, v3(v.w + 4, v.h + 4))
gui.set_position(r.halo, v3(v.x - v.w * 0.18, -(v.y - v.h * 0.18)))
gui.set_size(r.halo, v3(v.w * 1.36, v.h * 1.36))
r.v = v
end
-- Druid components for the layer: once per Druid instance (the game screen's lives until
@@ -445,13 +496,6 @@ end
function M:after_build()
if self.r then
if self.r.need_view and self.g.viewport_node then
self.r.need_view = nil
moon_view.reveal(self.r.data.moonId, self.g.viewport_node)
if moon_view.revealing then
moon_view.revealing.reduced = self.r.reduced
end
end
self:place()
self:bind()
end
@@ -464,7 +508,7 @@ function M:card_t()
if not (mr and r.card_at) then
return -1
end
return mr.t - r.card_at + r.offset
return mr.t - r.card_at + r.offset - (r.reduced and 0 or LEAD)
end
-- Skip ahead: charge -> burst, card -> its end state; then close.
@@ -543,48 +587,72 @@ function M:on_burst()
local r = self.r
local g = self.g
r.stage = "burst"
local v = r.v
local cx, cy = v.x + v.w / 2, v.y + v.h / 2
local cx, cy = r.L + DRAWER_W / 2 * (1 + r.k) + STAGE / 2, r.T + STAGE / 2
local colors = {}
for _, w in ipairs(r.data.wells or {}) do
colors[#colors + 1] = theme.resource[w.resource] or C.cyan
end
self:play("burst")
self:play("moon", { delay = 80 })
g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 90, 1.8)
g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 40, 1.1)
g.fx:flash(cx, cy, math.max(v.w, v.h) * 0.75, 0.55)
r.shake_at = socket.gettime()
-- The burst shakes the aside and the header (reveal.js, 340 ms of decaying noise).
for _, n in ipairs({ g.aside_panel and g.aside_panel.root, g.header_node }) do
anim.noise_shake(n, 5, 0.34)
sfx.stop("rumble")
self:play("reveal")
self:play("moon", { delay = 60, gain = 0.55 })
g.fx:burst(cx, cy, { FLASH, C.cyan_hi, C.cyan, unpack(colors) }, 110, 2.1)
g.fx:burst(cx, cy, #colors > 0 and colors or { C.cyan }, 50, 1.3)
g.fx:flash(cx, cy, STAGE * 0.8, 0.6)
r.q_out = socket.gettime()
if not r.reduced then
-- A strong, smooth shake (two octaves of Perlin noise, decaying): the window and,
-- under the dim, the page.
r.shake_at, r.seed = socket.gettime(), math.random() * 100
anim.perlin_shake(g.layout, SHAKE_AMP * 0.6, SHAKE_DUR, SHAKE_FREQ)
end
local mr = moon_view.revealing
r.card_at = (mr and mr.t or 0) + (r.reduced and 0 or 0.42)
r.card_at = (mr and mr.t or 0) + (r.reduced and 0 or HOLD)
end
---------------------------------------------------------------- the card
-- Cuts a paragraph node's text at a word to fit maxh (with "...").
local function fit_para(node, str, w, maxh)
local s = gui.get_scale(node).x
local font = gui.get_font_resource(gui.get_font(node))
local function height(t)
return resource.get_text_metrics(font, ui.esc(t), { line_break = true, width = w / s, leading = 1 }).height * s
end
if height(str) <= maxh then
return
end
local words = {}
for wd in str:gmatch("%S+") do
words[#words + 1] = wd
end
for n = #words - 1, 1, -1 do
local t = table.concat(words, " ", 1, n) .. "..."
if height(t) <= maxh then
gui.set_text(node, ui.esc(t))
return
end
end
gui.set_text(node, "")
end
-- The drawer's content (built when it starts sliding out), laid out at the drawer's fixed
-- size: the rows of wells always take MAX_WELLS rows, the description the room left.
function M:build_card()
local r = self.r
local g = self.g
local d = r.data
local st = g.state
local W, H = g.size[1], g.size[2]
local tier = d.rating and d.rating.tier or "fair"
local tc, thi = tier_colors(tier)
local big = tier == "great" or tier == "exceptional"
local w = CARD_W
local inner = w - CARD_PAD * 2
local w, h = DRAWER_W, STAGE
local inner = w - PAD * 2
-- The card: a group at its center (it scales from there), the body top-left inside.
local card = group(r.root, 0, 0)
local body = group(card, 0, 0)
local bg = node_box(body, 0, 0, w, 10, rgba(C.field, 0.97))
local body = group(r.drawer, 0, 0)
node_box(body, 0, 0, w, h, rgba(C.field, 1))
local glow = node_box(body, w * 0.1, -40, w * 0.8, 160, rgba(big and C.ready or C.cyan, tier == "poor" and 0 or 0.12), "glow")
local edge = node_box(body, 0, 0, w, 10, rgba(tier == "poor" and C.line_hi or tc, tier == "exceptional" and 0.7 or 0.45), "outline")
gui.set_blend_mode(glow, theme.glow_blend())
local edge = node_box(body, 0, 0, w, h, rgba(tier == "poor" and C.line_hi or tc, tier == "exceptional" and 0.7 or 0.45), "outline")
gui.set_slice9(edge, vmath.vector4(2, 2, 2, 2))
local corners = node_box(body, 0, 0, w, 10, rgba(tc, 0.9), "frame")
local corners = node_box(body, 0, 0, w, h, rgba(tc, 0.9), "frame")
gui.set_slice9(corners, vmath.vector4(10, 10, 10, 10))
local el = {} -- animated elements: { node, kind, delay, dur, base }
@@ -599,24 +667,32 @@ function M:build_card()
local probe = ui.text(body, label, { font = "sans_semi", px = 12, tracking = 0.16, upper = true }, 0, 0)
local bw, bh = math.max(88, math.ceil(ui.text_width(probe)) + 20), 46
gui.delete_node(probe)
-- Trait chips (their widths), the description (made now, placed after), the wells.
-- Trait chips (their widths): one line, as many as fit.
local trait_style = { font = "sans", px = 13, color = thi }
local trait_boxes = {}
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
local trait_boxes, trait_names = {}, {}
local tx = 0
for _, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
local t = text(body, tr, trait_style, 0, 0)
ui.fit_text(t, inner - 16)
trait_boxes[i] = fixed(ui.text_width(t) + 16, 22)
local tw = ui.text_width(t) + 16
gui.delete_node(t)
if tx + tw > inner then
break
end
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
trait_boxes[#trait_boxes + 1] = fixed(tw, 22)
trait_names[#trait_names + 1] = tr
tx = tx + tw + 6
end
local _, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
local well_boxes = {}
for i = 1, #(d.wells or {}) do
well_boxes[i] = fixed(inner, 26)
for i = 1, MAX_WELLS do
well_boxes[i] = fixed(inner, 22)
end
-- The foot's buttons are made now (in a group placed after the solve): their widths.
-- The foot's buttons (fixed widths, in fixed slots: Buy another's stays empty when
-- there's no next moon).
local foot = group(body, 0, 0)
local close_b, close_nodes, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true })
local close_b, close_nodes = ui.button(foot, "Start mining", 0, 0, { primary = true, w = CLOSE_W })
for _, n in pairs(close_nodes) do
gui.set_inherit_alpha(n, true)
end
@@ -627,7 +703,8 @@ function M:build_card()
local price = next_moon.price
local ok = next_moon.canBuy == true
local ab, an
ab, an, aw = ui.button(foot, "Buy another", 0, 0, { disabled = not ok })
local label = (next_moon.grade or "") ~= "" and ("Buy another " .. next_moon.grade) or "Buy another"
ab, an = ui.button(foot, label, 0, 0, { disabled = not ok, w = ANOTHER_W })
for _, n in pairs(an) do
gui.set_inherit_alpha(n, true)
end
@@ -640,14 +717,14 @@ function M:build_card()
local sep = text(ab, "·", { font = "sans_semi", px = 15, color = ok and "text" or "muted" }, 0, 0)
local sym = text(ab, theme.currency, { font = "sym", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local num = text(ab, ui.money(price), { font = "mono", px = 14, color = ok and "cyan" or "muted" }, 0, 0)
local row = B({ align = "center", height = hgt, padding = { 0, 20 } }, {
local row = B({ align = "center", height = hgt, padding = { 0, 16 } }, {
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(8, 0),
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(8, 0),
{ node = sym, size = false, style = { width = ui.text_width(sym), height = hgt } }, fixed(1, 0),
{ node = num, size = false, style = { width = ui.text_width(num), height = hgt } },
})
flex.solve(row, 0, 0)
aw = math.floor(row.w)
aw = math.max(ANOTHER_W, math.floor(row.w)) -- (a huge price grows it to the left)
for _, k in ipairs(row.children) do
if k.node then
gui.set_position(k.node, v3(k.x + (aw - row.w) / 2, -hgt / 2))
@@ -659,26 +736,23 @@ function M:build_card()
end
local has_class = d.kind and d.kind ~= "" and d.kind ~= d.name
local tree = B({ direction = "column", width = w, padding = CARD_PAD }, {
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(14),
B({ height = 52, shrink = 0 }, {
B({ width = 52, height = 52, shrink = 0 }, {}, "planet"), fixed(14, 0),
B({ direction = "column", grow = 1 }, { B({ height = 36 }, {}, "name"), B({ height = 12 }, {}, "class") }),
}),
vspace(16),
local tree = B({ direction = "column", width = w, height = h, padding = PAD }, {
B({ height = 12, shrink = 0 }, {}, "kicker"), vspace(10),
B({ height = 36, shrink = 0 }, {}, "name"), vspace(2),
B({ height = 12, shrink = 0 }, {}, "class"), vspace(14),
B({ height = 56, shrink = 0 }, {
B({ width = bw, height = 52, shrink = 0 }, {}, "badge"), fixed(12, 0),
B({ grow = 1, shrink = 1 }, {}, "head"),
}),
vspace(14),
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(8),
vspace(12),
B({ height = 18, shrink = 0 }, {}, "worth"), vspace(10),
#trait_boxes > 0 and B({ direction = "column", shrink = 0 }, {
B({ wrap = true, column_gap = 6, row_gap = 6 }, trait_boxes, "traits"), vspace(10),
B({ column_gap = 6 }, trait_boxes, "traits"), vspace(10),
}) or vspace(0),
B({ height = dh, shrink = 0 }, {}, "desc"), vspace(14),
B({ grow = 1, shrink = 1, basis = 0 }, {}, "desc"), vspace(10),
B({ direction = "column", shrink = 0 }, well_boxes), vspace(12),
B({ height = theme.size.button_h, justify = "end", shrink = 0 }, {
aw and fixed(aw, theme.size.button_h) or fixed(0, 0), fixed(aw and 10 or 0, 0), fixed(close_w, theme.size.button_h),
fixed(ANOTHER_W, theme.size.button_h), fixed(10, 0), fixed(CLOSE_W, theme.size.button_h),
}, "foot"),
})
flex.solve(tree, 0, 0)
@@ -686,14 +760,18 @@ function M:build_card()
local kicker = text(body, "New moon claimed", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.2, upper = true }, at.kicker.x, at.kicker.y + 6)
el[#el + 1] = { node = kicker, kind = "rise", delay = 0.06 }
if (d.grade or "") ~= "" then
-- Its grade badge after the kicker (fades with its box: inherit_alpha down the chip).
local gb
for _, n in ipairs(ui.collect(function()
gb = chip.grade(body, d.grade, at.kicker.x + ui.text_width(kicker) + 10, at.kicker.y + 6)
end)) do
gui.set_inherit_alpha(n, true)
end
el[#el + 1] = { node = gb, kind = "rise", delay = 0.06 }
end
-- Head: the planet and the name (typed in), its class under it.
local pb = at.planet
local planet = group(body, pb.x, pb.y)
local pglow = node_box(planet, -14, 14, 80, 80, rgba(C.cyan, 0.3), "glow")
gui.set_blend_mode(pglow, theme.glow_blend())
node_box(planet, 0, 0, 52, 52, ui.moon_tint(d.moonId), "sphere")
el[#el + 1] = { node = planet, kind = "rise", delay = 0.12 }
-- The name (typed in), its class under it.
local nb = at.name
local px = 30
local letters, nw = name_letters(body, d.name or "", nb.x, nb.y + nb.h / 2, px)
@@ -717,9 +795,9 @@ function M:build_card()
end
-- The verdict: badge (stars and tier), headline and sub. Placed now, shown last.
local verdict_y = at.badge.y
local badge = group(body, at.badge.x + bw / 2, at.badge.y + 26)
local bbg = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba(tc, (tier == "poor" or tier == "fair") and 0 or 0.1))
r.badge_c = { x = at.badge.x + bw / 2, y = at.badge.y + 26 }
node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba(tc, (tier == "poor" or tier == "fair") and 0 or 0.1))
local bedge = node_box(badge, -bw / 2, -bh / 2, bw, bh, rgba((tier == "poor" or tier == "fair") and C.line_hi or tc, 1), "outline")
gui.set_slice9(bedge, vmath.vector4(2, 2, 2, 2))
local stars = d.rating and d.rating.stars or 0
@@ -762,7 +840,7 @@ function M:build_card()
-- Traits: chips in the tier's color.
local traits = {}
for i, tr in ipairs(type(d.traits) == "table" and d.traits or {}) do
for i, tr in ipairs(trait_names) do
local tb = trait_boxes[i]
local chip = group(body, tb.x, tb.y)
local t = text(chip, tr, trait_style, 8, 11)
@@ -772,17 +850,21 @@ function M:build_card()
traits[#traits + 1] = chip
end
-- Description.
-- Description, cut to its room.
gui.set_position(desc, v3(at.desc.x, -at.desc.y))
fit_para(desc, d.description or "", inner, at.desc.h)
el[#el + 1] = { node = desc, kind = "rise", delay = 0.52 }
-- Wells: they pop in one by one, their bars filling.
local wells = {}
local max = (d.maxSupply and d.maxSupply > 0) and d.maxSupply or 90
local bar_x, num_w = 132, 64
local bar_x, num_w = 120, 64
local bar_w = inner - bar_x - num_w - 10
for i, wl in ipairs(d.wells or {}) do
local wb = well_boxes[i]
if not wb then
break
end
local row = group(body, wb.x, wb.y)
local full = wl.full
node_box(row, 0, 7, 8, 8, theme.resource[wl.resource] or C.cyan)
@@ -804,13 +886,13 @@ function M:build_card()
end)
end
-- Foot: Buy another (the next moon, straight away) and Start mining.
-- Foot: Buy another (the next moon, straight away) and Start mining, in their slots.
local fb = at.foot
gui.set_position(foot, v3(fb.x, -fb.y))
local fk = fb.children
gui.set_position(close_b, v3(fk[3].x - fb.x, 0))
if r.another_btn then
gui.set_position(r.another_btn, v3(fk[1].x - fb.x, 0))
gui.set_position(r.another_btn, v3(fk[1].x - fb.x + ANOTHER_W - aw, 0))
end
-- Keyboard focus: Start mining first (Enter after Tab closes, as a click would).
test.begin_build("reveal")
@@ -820,24 +902,6 @@ function M:build_card()
test.register("reveal:buy", r.another_btn, "button", "Buy another")
end
test.set_scope("page")
local h = tree.h
for _, n in ipairs({ bg, edge, corners }) do
gui.set_size(n, v3(w, h))
end
gui.set_position(body, v3(-w / 2, h / 2))
-- Beside the viewport where there's room (so the moon stays in sight), else centered.
local v = r.v
local cx = W / 2
if v.x >= w + 32 then
cx = v.x / 2
elseif W - (v.x + v.w) >= w + 32 then
cx = (v.x + v.w + W) / 2
end
local ccy = math.max(h / 2 + 16, math.min(H - h / 2 - 16, H / 2))
gui.set_position(card, v3(cx, -ccy))
r.card_pos = vmath.vector3(cx, -ccy, 0)
-- The verdict lands last.
local n = #wells
@@ -852,9 +916,8 @@ function M:build_card()
local quiet_tier = tier == "poor" or tier == "fair"
el[#el + 1] = { node = badge, kind = quiet_tier and "fade" or "stamp", delay = wend, dur = quiet_tier and 0.36 or (big and 0.38 or 0.32), big = big }
local hit = wend + (quiet_tier and 0 or (big and 0.27 or 0.22))
r.verdict_y = verdict_y
self:at(hit, function()
self:verdict(tier, badge, card, h)
self:verdict(tier)
end)
-- Complete once the foot is in: the next click closes it.
self:at(r.card_end, function()
@@ -867,7 +930,7 @@ function M:build_card()
e.base = gui.get_position(e.node)
e.scale = gui.get_scale(e.node)
end
r.card, r.body, r.el, r.wells, r.h = card, body, el, wells, h
r.card, r.el, r.wells = body, el, wells
if r.reduced then
-- Complete at once: no typed name, pops, stamp or verdict sounds.
r.events, r.shimmer, r.done = {}, nil, true
@@ -877,20 +940,17 @@ function M:build_card()
end
-- The badge stamps down: sounds and sparks by tier (reveal.js).
function M:verdict(tier, badge, card, h)
function M:verdict(tier)
local r = self.r
local g = self.g
local fx = g.fx
local cp = r.card_pos
local bp = gui.get_position(badge)
local body = gui.get_position(r.body)
local c = { x = cp.x + body.x + bp.x, y = -(cp.y + body.y + bp.y) }
local top = { x = cp.x - CARD_W / 2, y = -cp.y - h / 2 }
local big = tier == "great" or tier == "exceptional"
local gold = { C.ready, GOLD_HI }
local fx = self.g.fx
if r.quiet then
return
end
-- (the drawer is out by now: its place is the window's left)
local c = { x = r.L + r.badge_c.x, y = r.T + r.badge_c.y }
local top = { x = r.L, y = r.T }
local big = tier == "great" or tier == "exceptional"
local gold = { C.ready, GOLD_HI }
if big then
self:play("burst", { rate = 1.5, gain = 0.5 })
self:play("shimmer", { delay = 60 })
@@ -901,7 +961,7 @@ function M:verdict(tier, badge, card, h)
local t = self:card_t() + 0.08 + i * 0.07
self:at(t, function()
if not self.r.quiet then
fx:burst(top.x + CARD_W * (i + 0.5) / n, top.y, gold, 18, 0.8)
fx:burst(top.x + DRAWER_W * (i + 0.5) / n, top.y, gold, 18, 0.8)
end
end)
end
@@ -928,29 +988,9 @@ function M:verdict(tier, badge, card, h)
end
end
-- Positions, scales and alphas of the card's parts at card time t.
-- Positions, scales and alphas of the drawer's parts at card time t.
function M:animate_card(t)
local r = self.r
-- The card: scale 0.7 -> 1.04 (60%) -> 1, fading in, over 0.48 s.
local k = prog(t, 0, 0.48)
local s, a
if k < 0.6 then
local e = ease(k / 0.6)
s, a = 0.7 + 0.34 * e, e
else
s, a = 1.04 - 0.04 * ease((k - 0.6) / 0.4), 1
end
local pos = r.card_pos
if r.jolt_at then
local j = (socket.gettime() - r.jolt_at) / 0.18
if j < 1 then
local q = math.sin(j * math.pi)
pos = pos + v3(2 * q, -4 * q)
end
end
gui.set_position(r.card, pos)
gui.set_scale(r.card, v3(s, s))
gui.set_color(r.card, vmath.vector4(1, 1, 1, a))
-- Letters: rise, shrink from 1.4 and go from white to the text color.
for i, l in ipairs(r.letters) do
local lk = ease(prog(t, 0.18 + (i - 1) * 0.04, 0.26))
@@ -1026,37 +1066,41 @@ function M:update(dt)
if not r then
return
end
local mr = moon_view.revealing
if r.closing then
self:step_close()
return
end
if r.need_view then
return
end
local mr = moon_view.revealing
if not mr then
-- The viewport moved on (another moon shown): end at once.
-- The moon view moved on (another moon shown): end at once.
self:finish(true)
return
end
local T = mr.t
-- The dim fades in over 0.4 s; the viewport's edge charges up over 1.1 s.
-- The dim and the window fade in over 0.4 s; the stage's edge charges up over 1.1 s.
gui.set_color(r.root, vmath.vector4(1, 1, 1, math.min(1, T / (r.reduced and 0.12 or 0.4))))
if r.stage == "charge" then
local k = r.reduced and 0 or math.min(1, T / 1.1)
gui.set_color(r.ring, rgba(vmath.lerp(k, C.cyan, MIST), 0.9 * k * k))
gui.set_color(r.halo, rgba(C.violet, 0.55 * k * k))
if r.question and not r.reduced then
-- The "?" brightens and trembles as the charge builds.
local s = r.question_scale * (1 + 0.08 * k + 0.025 * k * k * math.sin(T * 47))
gui.set_scale(r.question, v3(s, s))
gui.set_color(r.question, rgba(MIST, 0.5 + 0.45 * k))
end
if mr.stage ~= "charge" then
gui.set_color(r.ring, rgba(MIST, 0))
gui.set_color(r.halo, rgba(C.violet, 0))
self:on_burst()
end
end
if r.question then
local q = math.min(1, T / 0.5)
local s = r.question_scale * (r.reduced and 1 or (1 + 0.8 * q * q))
if r.question and r.q_out then
-- The burst blows the "?" away.
local q = math.min(1, (socket.gettime() - r.q_out) / 0.35)
local s = r.question_scale * (r.reduced and 1 or (1.08 + 1.2 * q * q))
gui.set_scale(r.question, v3(s, s))
gui.set_color(r.question, rgba(MIST, 0.85 * (1 - q)))
gui.set_color(r.question, rgba(MIST, 0.95 * (1 - q)))
if q >= 1 then
gui.delete_node(r.question)
r.question = nil
@@ -1067,6 +1111,9 @@ function M:update(dt)
self:build_card()
end
if r.card then
if not r.reduced then
r.k = ease(prog(T - r.card_at + r.offset, 0, SLIDE))
end
local t = self:card_t()
self:run_events(t)
if self.r and r.reduced then
@@ -1074,9 +1121,12 @@ function M:update(dt)
self:animate_card(math.huge)
gui.set_color(r.card, vmath.vector4(1, 1, 1, math.min(1, (socket.gettime() - r.faded_at) / 0.2)))
elseif self.r then
self:animate_card(self:card_t())
self:animate_card(t)
end
end
if self.r then
self:pose()
end
end
function M:close()
@@ -1086,40 +1136,27 @@ function M:close()
end
if r.card and not r.done then
self:flush()
r.k = 1
end
r.closing = socket.gettime()
sfx.stop("rumble")
sfx.play("close")
-- The card flies into the moon's list entry (or shrinks away).
local g = self.g
local row = g.moon_nodes and g.moon_nodes[r.data.moonId]
if r.card and row then
local rr = g.fx:rect_of(row)
r.fly_to, r.fly_scale = v3(rr.x + rr.w / 2, -(rr.y + rr.h / 2)), 0.12
elseif r.card then
r.fly_to, r.fly_scale = r.card_pos + v3(0, -30), 0.6 -- (reveal.js: down 30 px, scale .6)
end
end
-- The window fades and sinks away (reveal.js: down 30 px); the page comes back.
function M:step_close()
local r = self.r
local k = math.min(1, (socket.gettime() - r.closing) / (r.reduced and 0.2 or 0.42))
local k = math.min(1, (socket.gettime() - r.closing) / (r.reduced and 0.2 or 0.4))
gui.set_color(r.root, vmath.vector4(1, 1, 1, 1 - k * k))
if r.card and r.reduced then
gui.set_color(r.card, vmath.vector4(1, 1, 1, 1 - k)) -- (fades where it is)
elseif r.card then
local q = k * k * k -- cubic-bezier(0.5, 0, 0.75, 0): eases in
gui.set_position(r.card, vmath.lerp(q, r.card_pos, r.fly_to))
local s = 1 - (1 - (r.fly_scale or 0.12)) * q
gui.set_scale(r.card, v3(s, s))
gui.set_color(r.card, vmath.vector4(1, 1, 1, 1 - q))
end
r.drop = r.reduced and 0 or 30 * k * k * k
self:pose()
if k >= 1 then
self:finish(false)
end
end
-- Ends the reveal: removes the layer and brings the page back. quick: no entrance (another
-- reveal starts, or the viewport moved on).
-- reveal starts, or the moon view moved on).
function M:finish(quick)
local r = self.r
if not r then
@@ -1145,7 +1182,9 @@ function M:finish(quick)
end
g.fx:signal("revealed")
if r.buy_next then
g.h.act(g, "buy-moon", { include = "new-moon" })
local grade = r.data.next and r.data.next.grade
g.nm_grade = nil -- ("+ New moon" follows the grade just bought: state.moonGrade)
g.h.act(g, "buy-moon", { grade = (grade or "") ~= "" and grade or nil, include = "new-moon" })
end
end
+122
View File
@@ -0,0 +1,122 @@
-- Save codes (the settings menu's "Save code" and "Restore from code"; ../server/api.md
-- "Save codes"): a code brings the player's progress to another device, or back here if
-- the saved player id is gone. The server keeps only a hash, so a code shows once, when
-- it is made; making a new one turns the old one off.
-- open_save(g): asks the server whether there is a code (GET /api/save-code), then a
-- dialog to make one (action save-code), then the code (Copy, "dialog:ok", only in a
-- browser: Defold has no clipboard on desktop).
-- open_restore(g): a text field and Restore (action restore): the response is the other
-- player's state and id (session.lua treats it as a fresh start; api.lua stores the id).
-- Test ids: "dialog:ok" (Make a code / Restore / Copy), "field:restore-code".
local ui = require("modules.ui")
local api = require("modules.api")
local test = require("modules.test")
local M = {}
local W = 480
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local TEXT = { font = "sans", px = 15, color = "text_2" }
-- The height a wrapped text takes in the dialog (laid out first: the dialog's height
-- comes from it).
local function text_h(g, str)
local probe, h = ui.wrap(g.dialog.layer, str, TEXT, 0, 0, W - 48)
gui.delete_node(probe)
return h
end
local function show_code(g, code)
local note = "Write it down. On another device: settings (the gear), Restore from code. It is shown only this once; anyone with it can play as you."
local nh = text_h(g, note)
g.dialog:open({
title = "Your save code",
w = W,
h = 58 + 40 + 14 + nh + 24 + 44 + 24,
cancel = "Close",
ok = html5 and { label = "Copy", on_ok = function()
pcall(html5.run, "navigator.clipboard && navigator.clipboard.writeText('" .. code .. "')")
g.toasts:push("Save code copied", "save-code", false)
end } or nil,
build = function(d, box)
ui.text(box, code, { font = "mono", px = 26, color = "cyan" }, W / 2, 58 + 20, gui.PIVOT_CENTER)
ui.wrap(box, note, TEXT, 24, 58 + 40 + 14, W - 48)
end,
})
end
local function make(g)
g.h.act(g, "save-code", {}, function(err, data)
local sc = type(data) == "table" and data.saveCode
if not err and type(sc) == "table" and type(sc.code) == "string" then
g.save_code = sc.code -- (the last code made, for the test driver)
show_code(g, sc.code)
end
end)
end
function M.open_save(g)
api.get_save_code(function(err, data)
local made = not err and type(data) == "table" and type(data.saveCode) == "table" and tonumber(data.saveCode.madeAt) or 0
if err then
g.toasts:push("Can't reach the server", "error", true)
return
end
local message
if made > 0 then
message = "You made a save code on " .. os.date("%Y-%m-%d", math.floor(made / 1000)) ..
". It was shown only then. Lost it? Make a new one; the old one stops working."
else
message = "A save code brings your progress to another device, or back here if this one forgets you. Keep it safe: anyone with it can play as you."
end
g.dialog:open({
title = "Save code",
w = W,
message = message,
ok = { label = made > 0 and "Make a new code" or "Make a code", on_ok = function() make(g) end },
})
end)
end
function M.open_restore(g)
local code = ""
local msg = "Enter a save code to play as that miner on this device. This device's current miner can only come back with its own code."
local mh = text_h(g, msg)
local fy, fw, fh = 58 + mh + 14, W - 48, 40
g.dialog:open({
title = "Restore from code",
w = W,
h = fy + fh + 24 + 44 + 24,
ok = { label = "Restore", on_ok = function()
if code:match("%w") then
g.h.act(g, "restore", { code = code })
end
end },
build = function(d, box)
ui.wrap(box, msg, TEXT, 24, 58, W - 48)
local fbox = ui.rect(box, 24, fy, fw, fh, "field")
local edge = ui.outline(fbox, 0, 0, fw, fh, "cyan")
local hint = ui.text(fbox, "ABCD-EFGH-JKMN-PQRS", { font = "mono", px = 16, color = "faint" }, 12, fh / 2)
local txt = ui.text(fbox, "", { font = "mono", px = 16, color = "text" }, 12, fh / 2)
gui.set_size(txt, vec((fw - 24) / gui.get_scale(txt).x, 40))
local input = d.druid:new_input(fbox, txt, gui.KEYBOARD_TYPE_DEFAULT)
input:set_allowed_characters("[%w%- ]")
input:set_max_length(24)
input.style.on_select = function() gui.set_color(edge, ui.color("cyan")) end
input.style.on_unselect = function() gui.set_color(edge, ui.color("line_hi")) end
input.on_input_text:subscribe(function(_, text)
code = text
gui.set_enabled(hint, text == "")
end)
test.register("field:restore-code", fbox, "field", "Save code")
input:select()
end,
})
end
return M
+156 -40
View File
@@ -1,61 +1,177 @@
-- Display settings (the header's display button): the UI scale (window pixels per layout
-- unit, times the display's own scale; the browser's zoom) and fullscreen. Both are saved
-- in the settings file (modules/screen). Keys: Ctrl + / Ctrl - / Ctrl 0, F11 (core/keys).
-- The settings menu (the header's gear): a dropdown (components/dialog `menu`) under the
-- 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.
-- 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.
local ui = require("modules.ui")
local screen = require("modules.screen")
local sfx = require("modules.sfx")
local theme = require("modules.theme")
local anim = require("modules.anim")
local M = {}
local W = 260
local ROW_H = 34
local PAD = 6
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function pct(s)
return string.format("%d%%", math.floor(s * 100 + 0.5))
end
function M.open(g)
g.dialog:open({
title = "Display",
w = 440,
h = 340,
build = function(d, box, w)
local x, y = 24, 64
local value
local function show()
ui.style_text(value, { font = "mono", px = 15, color = "text" }, pct(screen.ui_scale))
-- Icons, drawn in a 20x20 box at (x, y) of p.
local ICONS = {
help = function(p, x, y)
ui.outline(p, x, y, 20, 20, "nav")
ui.text(p, "?", { font = "sans_semi", px = 14, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
scale = function(p, x, y)
ui.text(p, "A", { font = "sans_semi", px = 11, color = "nav" }, x + 5, y + 13, gui.PIVOT_CENTER)
ui.text(p, "A", { font = "sans_semi", px = 17, color = "nav" }, x + 14, y + 11, gui.PIVOT_CENTER)
end,
fullscreen = function(p, x, y)
for _, c in ipairs({ { 1, 1, 1, 1 }, { 13, 1, -1, 1 }, { 1, 13, 1, -1 }, { 13, 13, -1, -1 } }) do
local cx, cy = x + c[1] + (c[3] < 0 and 6 or 0), y + c[2] + (c[4] < 0 and 6 or 0)
ui.rect(p, c[3] > 0 and cx or cx - 6, c[4] > 0 and cy or cy - 1, 6, 2, "nav")
ui.rect(p, c[3] > 0 and cx or cx - 1 - 1, c[4] > 0 and cy or cy - 6, 2, 6, "nav")
end
end,
sound = function(p, x, y)
local icon = ui.rect(p, x, y, 20, 20, sfx.muted() and "muted" or "cyan")
gui.play_flipbook(icon, sfx.muted() and "speaker_off" or "speaker")
end,
theme = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "nav")
gui.play_flipbook(icon, theme.light and "moon" or "sun")
end,
motion = function(p, x, y)
local lit = anim.reduced and "muted" or "nav"
for i, l in ipairs({ { 4, 5, 14 }, { 1, 9, 18 }, { 6, 13, 11 } }) do
ui.rect(p, x + l[1], y + l[2] + (i - 2), l[3], 2, lit)
end
if anim.reduced then
local slash = ui.rect(p, x, y, 20, 2, "muted")
gui.set_pivot(slash, gui.PIVOT_CENTER)
gui.set_position(slash, vec(x + 10, -(y + 10)))
gui.set_rotation(slash, vmath.quat_rotation_z(math.rad(45)))
end
end,
-- A floppy disk; restore: the disk with an arrow into it.
save = function(p, x, y)
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
ui.rect(p, x + 6, y + 2, 8, 5, "nav")
ui.rect(p, x + 5, y + 11, 10, 5, "nav")
end,
restore = function(p, x, y)
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,
grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
}
-- A menu row: a text button the menu's width, the label left, the icon right. Returns the
-- button's label node.
local function item(d, box, y, label, icon, id, on_click, opts)
opts = opts or {}
local w = W - PAD * 2
local b, nodes = d:button(box, label, PAD, y, { variant = "ghost", w = w, h = ROW_H, id = id, disabled = opts.disabled,
fill = { "cyan", 0 }, edge = { "cyan", 0 }, on_click = on_click })
local t = ui.part(nodes, "button", "text")
ui.style_text(t, { font = "sans", px = 14, color = opts.disabled and "muted" or "text" }, label)
gui.set_pivot(t, gui.PIVOT_W)
gui.set_position(t, vec(12, -ROW_H / 2))
ICONS[icon](b, w - 12 - 20, (ROW_H - 20) / 2)
return t, b
end
-- Opens the menu with its top-right corner at `right` from the page's right edge, `top`
-- down (under the gear).
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 h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({
w = W,
h = h,
menu = { right = right or 16, top = top or 60 },
build = function(d, box)
local y = PAD
local function close_then(fn)
return function()
d:close("quiet")
fn()
end
end
local function toggled()
g.dirty, g.dirty_all = true, true
end
if help then
item(d, box, y, "How to play", "help", "menu:help", close_then(function()
g.h.navigate(g, { section = "help", moon = g.view.moon })
end))
y = y + ROW_H
end
-- UI scale: the row's label shows it; − and + on its right (the icon's place).
local w = W - PAD * 2
-- (Ctrl 0 resets it)
local t = ui.text(box, "", { font = "sans", px = 14, color = "text" }, PAD + 12, y + ROW_H / 2)
ICONS.scale(box, PAD + w - 12 - 20, y + (ROW_H - 20) / 2)
local function show()
ui.style_text(t, { font = "sans", px = 14, color = "text" }, "UI scale " .. pct(screen.ui_scale))
end
show()
local function step(dir)
return function()
screen.step_ui_scale(dir)
show()
end
end
ui.text(box, "UI scale", "cap", x, y + 6)
ui.text(box, "Bigger text and controls; the window keeps its size.", { font = "sans", px = 14, color = "muted" }, x, y + 26)
local by = y + 44
local _, _, mw = d:button(box, "−", x, by, { w = 40, id = "dialog:scale-down", on_click = step(-1) })
value = ui.text(box, "", { font = "mono", px = 15, color = "text" }, x + mw + 34, by + 18, gui.PIVOT_CENTER)
show()
local _, _, pw = d:button(box, "+", x + mw + 68, by, { w = 40, id = "dialog:scale-up", on_click = step(1) })
d:button(box, "Reset", x + mw + 68 + pw + 10, by, { id = "dialog:scale-reset", on_click = step(0) })
y = by + 58
ui.text(box, "Fullscreen", "cap", x, y + 6)
ui.text(box, screen.can_fullscreen() and "F11 switches it too." or "Not available in this build.",
{ font = "sans", px = 14, color = "muted" }, x, y + 26)
local fnodes
local function label()
return screen.fullscreen() and "Leave fullscreen" or "Go fullscreen"
d:button(box, "+", PAD + w - 40 - 40, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-up", on_click = step(1) })
d:button(box, "−", PAD + w - 40 - 80, y + 4, { small = true, w = 34, h = ROW_H - 8, id = "dialog:scale-down", on_click = step(-1) })
y = y + ROW_H
item(d, box, y, screen.fullscreen() and "Leave fullscreen" or "Fullscreen", "fullscreen", "dialog:fullscreen",
close_then(screen.toggle_fullscreen), { disabled = not screen.can_fullscreen() })
y = y + ROW_H
item(d, box, y, sfx.muted() and "Sound: off" or "Sound: on", "sound", "mute", close_then(function()
sfx.toggle_mute()
toggled()
end))
y = y + ROW_H
item(d, box, y, theme.light and "Dark theme" or "Light theme", "theme", "theme", close_then(function()
theme.toggle()
toggled()
end))
y = y + ROW_H
item(d, box, y, anim.reduced and "Reduced motion: on" or "Reduced motion: off", "motion", "motion", close_then(function()
anim.set_reduced_motion(not anim.reduced)
sfx.play("toggle")
toggled()
end))
y = y + ROW_H
-- Save codes (savecode.lua): progress on another device.
item(d, box, y, "Save code", "save", "menu:save-code", close_then(function()
require("screens.game.savecode").open_save(g)
end), { disabled = not st })
y = y + ROW_H
item(d, box, y, "Restore from code", "restore", "menu:restore", close_then(function()
require("screens.game.savecode").open_restore(g)
end), { disabled = not st })
y = y + ROW_H
if st and st.admin then
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
require("screens.game.grant").open(g)
end))
end
fnodes = select(2, d:button(box, label(), x, y + 44, { w = 180, id = "dialog:fullscreen", disabled = not screen.can_fullscreen(),
on_click = function()
screen.toggle_fullscreen()
-- (the label changes: centered again in the button)
local t = ui.part(fnodes, "button", "text")
local color = gui.get_color(t)
ui.style_text(t, { font = "sans_semi", px = 15, color = "text", tracking = 0.12, upper = true }, label())
local p = gui.get_position(t)
gui.set_position(t, vmath.vector3(math.floor((180 - ui.text_width(t)) / 2), p.y, p.z))
gui.set_color(t, color)
end }))
end,
})
end
+9 -4
View File
@@ -129,7 +129,8 @@ local function buy_modal(self, modal, item)
end
-- A store card's footer, bw wide (a tree): a rule, the stats (30 tall), Buy (its price;
-- disabled when short, with why under it, right-aligned). bind(b, nodes).
-- disabled when short, with why under it, right-aligned; the line's space is always kept).
-- bind(b, nodes).
function M.buy_footer(f, bw, stats, price, short, why, bind)
local items = {}
for _, it in ipairs(stats) do
@@ -141,7 +142,11 @@ function M.buy_footer(f, bw, stats, price, short, why, bind)
end),
fg.row({ height = 30, column_gap = 16 }, items),
fg.button(f, "Buy", { primary = true, small = true, price = price, disabled = short }, bind, { width = bw }),
short and fg.made({ height = 20 }, function(p, x, y, w) M.need_text(p, why, x + w, y + 10) end) or nil,
-- The need line's place, kept whether it shows or not: Buy sits at the same height in
-- every card, and a card doesn't change size when credits come and go.
fg.reserve({ height = 20 }, short and fg.made({ width = bw, height = 20 }, function(p, x, y, w)
M.need_text(p, why, x + w, y + 10)
end) or nil),
}), 0, 0), f)
end
@@ -162,7 +167,7 @@ local function tool_card(self, H, st, c, t)
c:text(line, { font = "mono", px = 12, color = "muted" })
end
local short = not t.canAfford
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
M.buy_footer(f, bw, {
{ "Yield", "+" .. ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) }, { "Owned", t.owned },
}, t.price, short, (t.whyText or "") ~= "" and t.whyText or ("Need " .. theme.currency .. ui.money(t.price - st.credits) .. " more"),
@@ -187,7 +192,7 @@ local function gear_card(self, H, st, c, a)
c:para(a.description, { font = "sans", px = 13, color = "text_2" })
c:gap(4)
local short = not a.canAfford
c:footer(30 + S.button_sm_h + (short and 18 or 0), function(f, bw)
c:footer(30 + S.button_sm_h + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
function(b, nodes)
+37 -1
View File
@@ -11,6 +11,8 @@
first, then the game's gui_script (its
on_reload rebuilds the screen; modules/hot.lua)
(--all: every Lua module again)
scripts/dev.py run [engine args...] the same in the foreground, output also on the
terminal (`make run`); reload works on it too
scripts/dev.py stop stop it (by its pid)
scripts/dev.py status
@@ -86,7 +88,7 @@ def post(port, path, body):
return urllib.request.urlopen(req, timeout=5).read()
def start(args):
def start(args, fg=False):
os.makedirs(DEV, exist_ok=True)
pid = read("pid")
if pid and alive(pid):
@@ -125,6 +127,9 @@ def start(args):
except FileNotFoundError:
pass
log = os.path.join(DEV, "game.log")
if fg:
run_fg(cmd, env, log)
return
with open(log, "w") as f:
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=f, stderr=subprocess.STDOUT,
start_new_session=True)
@@ -148,6 +153,35 @@ def start(args):
print("dev game pid %d, engine service port %s, log %s" % (proc.pid, port, log))
def run_fg(cmd, env, log):
# Output to the terminal and the log; Ctrl+C stops the game.
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True)
with open(os.path.join(DEV, "pid"), "w") as f:
f.write(str(proc.pid))
try:
with open(log, "wb") as f:
for line in proc.stdout:
f.write(line)
f.flush()
sys.stdout.buffer.write(line)
sys.stdout.buffer.flush()
m = re.search(rb"Engine service started on port (\d+)", line)
if m:
with open(os.path.join(DEV, "port"), "w") as p:
p.write(m.group(1).decode())
with open(SNAP, "w") as p:
json.dump(snapshot(), p)
except KeyboardInterrupt:
proc.terminate()
proc.wait()
for n in ("pid", "port"):
try:
os.remove(os.path.join(DEV, n))
except FileNotFoundError:
pass
def reload(everything=False):
port, pid = read("port"), read("pid")
if not port or not alive(pid):
@@ -227,6 +261,8 @@ def main():
cmd = sys.argv[1]
if cmd == "start":
start(sys.argv[2:])
elif cmd == "run":
start(sys.argv[2:], fg=True)
elif cmd == "reload":
reload("--all" in sys.argv)
elif cmd == "stop":
+38
View File
@@ -34,6 +34,10 @@ In a scenario:
game.eval("g.view.section"), game.elements(), game.state(), game.set(k, v),
game.shot(name), game.sleep(s), game.log_has(regex), game.expect(cond, message),
game.lua(src) (raw run_script, <1 KB).
Layout (scripts/ui_lint.py has the rules): game.dump(name) (the screen's laid-out nodes,
saved as <out>/<name>.layout.json), game.lint(name) (dump + lint, prints the issues,
saves <name>.lint.json), game.click_check(id, allow=[prefixes]) (dump, click, idle,
dump: the clicker rule). Issues so far: game.issues. Scenarios `dump` and `lint`.
"""
import argparse
import importlib.util
@@ -48,6 +52,8 @@ import urllib.request
HERE = os.path.dirname(os.path.realpath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ui_lint # noqa: E402
def varint(n):
@@ -86,6 +92,7 @@ class Game:
self.window_id = None
self.test_dir = None # where the game writes its replies (mm.test_dir)
self.args = None # the driver's options, when it started the game (restart)
self.issues = [] # ui_lint issues from lint / click_check, each with "where"
# ------------------------------------------------------------ transport
def lua(self, src):
@@ -173,6 +180,37 @@ class Game:
def state(self):
return self.ok(self.cmd("state"), "state")["state"]
# ------------------------------------------------------------ layout lint
def dump(self, name=None):
d = self.ok(self.cmd("dump"), "dump")["dump"]
if name:
with open(os.path.join(self.out, name + ".layout.json"), "w") as f:
json.dump(d, f, ensure_ascii=False)
return d
def _report(self, name, issues):
for i in issues:
i["where"] = name
self.issues += issues
with open(os.path.join(self.out, name + ".lint.json"), "w") as f:
json.dump(issues, f, indent=1, ensure_ascii=False)
print(" lint %s: %d issue(s)" % (name, len(issues)))
for line in ui_lint.text(issues).splitlines() if issues else []:
print(" " + line)
return issues
def lint(self, name="screen", min_hit=24):
return self._report(name, ui_lint.lint(self.dump(name), min_hit))
def click_check(self, id_, allow=(), settle=1.0, name=None):
name = name or "click-" + re.sub(r"[^A-Za-z0-9_.-]+", "_", id_)
before = self.dump(name + ".before")
r = self.click(id_)
self.idle()
time.sleep(settle)
after = self.dump(name + ".after")
return self._report(name, ui_lint.diff(before, after, r.get("id") or id_, allow))
def set(self, k, v):
return self.ok(self.cmd("set", k, v), "set " + k)
+2 -2
View File
@@ -26,7 +26,7 @@ def run(game, args):
game.navigate("{section='new-moon'}")
game.idle()
n0 = game.eval("#st.moons")
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
first = game.eval("g.reveal.r.data.moonId")
els = [e["id"] for e in game.elements()]
@@ -53,7 +53,7 @@ def run(game, args):
admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
+7 -6
View File
@@ -1,7 +1,7 @@
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
directory: it saves the settings file): the header's display button opens the dialog;
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
dialog's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
directory: it saves the settings file): the header's gear opens the settings menu;
+ / - change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
silent while the window is hidden."""
@@ -9,9 +9,9 @@ def run(game, args):
pre = (args[0] + "-") if args else ""
game.idle()
W0 = game.eval("g.size[1]")
game.click("display")
game.click("settings")
game.wait_for("g.dialog:is_open()", timeout=5)
game.shot(pre + "display-dialog", settle=0.8)
game.shot(pre + "settings-menu", settle=0.8)
game.click("dialog:scale-up")
game.click("dialog:scale-up")
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
@@ -19,7 +19,8 @@ def run(game, args):
W1 = game.eval("g.size[1]")
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
game.shot(pre + "display-125", settle=0.8)
game.click("dialog:scale-reset")
game.click("dialog:scale-down")
game.click("dialog:scale-down")
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
game.key("key_esc")
game.wait_for("not g.dialog:is_open()", timeout=5)
+13
View File
@@ -0,0 +1,13 @@
"""The current screen's layout dump and its lint (no fixture needed; works with --attach).
Args: [view] [name]: a view to go to first ("{section='market'}"), the file name (default
"screen"). Writes <out>/<name>.layout.json and <name>.lint.json, prints the issues."""
def run(game, args):
game.idle()
if args and args[0].startswith("{"):
game.navigate(args[0])
game.idle()
game.sleep(1.0)
args = args[1:]
game.lint(args[0] if args else "screen")
+41
View File
@@ -0,0 +1,41 @@
"""Moon grades (needs --fixture and an MM_DEV=1 server: grants through the admin page).
The new-moon panel lists a row per grade with its Claim button (shot); the grade toggle
next to "+ New moon" changes the grade it buys and its price without moving it; it buys
that grade, the reveal shows the grade and Buy another names it (shot); the moon list
row and the moon heading show the grade badge (shot)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin # noqa: E402
def run(game, args):
game.idle()
admin(game, "credits", amount=5000, op="grant")
game.navigate("{section='new-moon'}")
game.idle()
grades = game.eval("(function() local t = {} for _, e in ipairs(st.newMoon.grades) do t[#t+1] = e.grade .. (e.canAfford and '+' or '-') end return table.concat(t, ',') end)()")
print("grades:", grades)
els = [e["id"] for e in game.elements()]
game.expect(any(i.startswith("newmoon:grade:") for i in els), "grade rows registered")
game.shot("grades-panel", settle=0.4)
g0 = game.eval("require('screens.game.moonlist').grade(g)")
game.click("newmoon:grade")
game.idle()
g1 = game.eval("require('screens.game.moonlist').grade(g)")
game.expect(g0 != g1, "toggle changes the grade: %s -> %s" % (g0, g1))
game.click("newmoon:grade")
game.idle()
game.shot("grades-toggled", settle=0.3)
gb = game.eval("require('screens.game.moonlist').grade(g)")
game.click("newmoon:buy")
game.wait_for("g.reveal:active() and g.reveal:card_done()", 40)
game.sleep(0.4)
got = game.eval("g.reveal.r.data.grade .. '|' .. (g.reveal.r.data.next.grade or '')")
game.expect(got == gb + "|" + gb, "bought grade %s: %s" % (gb, got))
game.shot("grades-reveal", settle=0.3)
game.key("key_esc")
game.wait_for("not g.reveal:active()", 10)
game.idle()
game.shot("grades-moon", settle=0.4)
+3 -3
View File
@@ -61,10 +61,10 @@ def open_confirm(game, fx):
def open_display(game):
"""The header's display button, a real click (again if a rebuild dropped it)."""
"""The header's settings gear (its menu), a real click (again if a rebuild dropped it)."""
for _ in range(3):
game.idle()
game.os_click("display")
game.os_click("settings")
for _ in range(15):
if game.eval("g.dialog:is_open()"):
game.sleep(0.4)
@@ -210,7 +210,7 @@ def run(game, args):
for how in ("close", "esc"):
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
game.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4)
if how == "close":
+52
View File
@@ -0,0 +1,52 @@
"""Layout lint of every section (needs --fixture), then the clicker rule on a few actions
(claim a well, collect a batch, sell, buy a tool, upgrade).
Run at each size (`make lint` does 1280x800 and 1920x1080). Writes <out>/<view>.lint.json
and lint-summary.json; prints the issues. Args: an optional name prefix (the size)."""
import json
import os
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
moon, well, drill = fx["moon"], fx["well"], fx["drill_mining"]
views = [
("moons", "{section='moons', moon='%s'}" % moon),
("well", "{section='moons', moon='%s', well='%s'}" % (moon, well)),
("equipment", "{section='equipment'}"),
("drill", "{section='equipment', drill='%s'}" % drill),
("store", "{section='store'}"),
("market", "{section='market', pick='Sollux'}"),
("refinery", "{section='refinery'}"),
("advisors", "{section='advisors'}"),
("leaderboard", "{section='leaderboard'}"),
("help", "{section='help'}"),
("new-moon", "{section='new-moon'}"),
]
game.set("reduced_motion", "true")
game.idle()
for name, view in views:
game.navigate(view)
game.idle()
game.sleep(0.5)
game.lint(pre + name)
# The clicker rule: the first of each, where the fixture has one.
clicks = [
("moons", "act:claim?claim-well"),
("refinery", "act:collect?job"),
("market", "act:sell"),
("store", "act:buy-tool?id"),
("equipment", "act:upgrade?tool"),
]
for name, id_ in clicks:
game.navigate(dict(views)[name])
game.idle()
game.sleep(0.5)
if game.eval("el(%r)" % id_):
game.click_check(id_, name=pre + "click-" + name)
with open(os.path.join(game.out, pre + "lint-summary.json"), "w") as f:
json.dump(game.issues, f, indent=1, ensure_ascii=False)
counts = {}
for i in game.issues:
counts[i["rule"]] = counts.get(i["rule"], 0) + 1
print("lint total:", counts)
+1
View File
@@ -111,6 +111,7 @@ def motion(game):
game.navigate("{section='market', pick='Sollux'}")
game.idle()
game.shot("motion-off-market", settle=0.5)
game.click("settings")
game.click("motion")
game.idle()
if game.eval('require("modules.anim").reduced') is not True:
+57
View File
@@ -0,0 +1,57 @@
"""The new-moon reveal window (needs --fixture and an MM_DEV=1 server: grants through the
admin page). "+ New moon" at the top of the moon list buys a moon at once; the reveal plays
in the centered window (shots: charge, the drawer out); the credits count down during it;
Start mining and Buy another are in the same spot on two reveals; after closing, the
announcements pane lists the toast. Then without the credits for a moon the button is
disabled."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
POS = "(function() local r = g.reveal.r local p = gui.get_screen_position(r.close_btn) local q = r.another_btn and gui.get_screen_position(r.another_btn) return string.format('%d,%d|%s', p.x, p.y, q and string.format('%d,%d', q.x, q.y) or '-') end)()"
def run(game, args):
game.idle()
admin(game, "credits", amount=50000, op="grant")
game.navigate("{section='moons'}")
game.idle()
n0 = game.eval("#st.moons")
game.click("newmoon:buy")
game.wait_for("g.reveal:active()", 10)
game.shot("reveal-charge", settle=0.4)
game.wait_for("g.reveal.r and g.reveal.r.card ~= nil", 20)
game.sleep(1.0)
shown = game.eval("g.fx:value('credits', st.credits)")
real = game.eval("st.credits")
game.expect(abs(shown - real) < 1, "credits counted down during the reveal: shown %s, state %s" % (shown, real))
game.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4)
game.shot("reveal-drawer", settle=0.3)
first = game.eval(POS)
first_id = game.eval("g.reveal.r.data.moonId")
game.click("reveal:buy")
game.wait_for("g.reveal.r and g.reveal.r.data.moonId ~= '%s' and g.reveal:card_done()" % first_id, 40)
game.sleep(0.4)
second = game.eval(POS)
game.expect(first == second, "buttons in the same spot: %s vs %s" % (first, second))
game.shot("reveal-second", settle=0.3)
game.click("reveal:close")
game.wait_for("not g.reveal:active()", 10)
game.idle()
game.expect(game.eval("#st.moons") == n0 + 2, "two moons bought")
game.expect(game.eval("#g.toasts.log") >= 1, "the toast is in the announcements log")
game.sleep(0.8)
game.shot("announcements", settle=0.3)
st = call(game, "GET", "/api/state?catalog=0")["state"]
extra = st["credits"] - st["moonPrice"] + 5
if extra > 0:
admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='moons'}")
game.idle()
els = [e["id"] for e in game.elements()]
game.expect("newmoon:buy" not in els, "short: the button is disabled")
game.shot("newmoon-short", settle=0.3)
+64
View File
@@ -0,0 +1,64 @@
"""Save codes (a new player, no fixture): the gear menu's Save code makes one and shows it;
Restore from code with another player's code (made here through the API) switches to that
player; a wrong code is an error toast and keeps the player."""
import json
import urllib.request
def api(game, path, user=None, body=None):
h = {"Content-Type": "application/json"}
if user:
h["X-MM-User"] = user
req = urllib.request.Request(game.api + path, headers=h, method="POST" if body is not None else "GET",
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req) as r:
return json.load(r)
def menu(game, item, title):
game.idle()
game.click("settings")
game.wait_for("g.dialog:is_open()")
game.sleep(0.3)
game.click(item)
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == '%s'" % title)
game.sleep(0.5)
def run(game, args):
game.idle()
me = game.eval("require('modules.api').player_id()")
menu(game, "menu:save-code", "Save code")
game.shot("save-code", settle=0.3)
game.click("dialog:ok")
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == 'Your save code'")
code = game.eval("g.save_code")
game.expect(isinstance(code, str) and len(code) == 19, "a code is shown: %r" % code)
game.shot("save-code-shown", settle=0.5)
game.key("key_esc")
game.wait_for("not g.dialog.root")
# A wrong code: an error, same player.
menu(game, "menu:restore", "Restore from code")
game.type("AAAA-AAAA-AAAA-AAAA")
game.click("dialog:ok")
game.wait_for("not g.dialog.root and not g.busy")
game.expect(game.log_has(r"doesn't match"), "a wrong code says so")
game.expect(game.eval("require('modules.api').player_id()") == me, "a wrong code keeps the player")
# Another player's code switches to it.
other = api(game, "/api/state?catalog=0")["userId"]
other_code = api(game, "/api/act/save-code?catalog=0", other, {})["saveCode"]["code"]
menu(game, "menu:restore", "Restore from code")
game.type(other_code.lower())
game.shot("restore", settle=0.3)
game.click("dialog:ok")
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % other)
game.expect(game.log_has(r"Welcome back"), "restoring says welcome back")
game.shot("restored", settle=0.5)
# The old code still brings the first player back.
menu(game, "menu:restore", "Restore from code")
game.type(code)
game.click("dialog:ok")
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % me)
+2
View File
@@ -38,9 +38,11 @@ def run(game, args):
game.idle()
confirm(game, "confirm:new-game", pre + "texts-new-game")
before = game.eval("require('modules.theme').name")
game.click("settings")
game.click("theme")
game.wait_for("require('modules.theme').name ~= '%s'" % before)
game.idle()
game.shot(pre + "texts-theme-toggled", settle=0.6)
game.click("settings")
game.click("theme")
game.wait_for("require('modules.theme').name == '%s'" % before)
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Lint a layout dump (the test hook's `dump`; drive.py game.dump / the `dump` scenario).
Usage:
scripts/ui_lint.py DUMP.json lint one screen
scripts/ui_lint.py BEFORE.json AFTER.json --clicked ID [--allow PREFIX ...]
the clicker rule across an action
--json print the issues as JSON (default: one line each)
--min N minimum hit size in layout px (default 24)
Rules (one screen):
overlap (warn) two texts/buttons on top of each other (neither inside the other)
overflow (warn) a text or button sticking out of the panel/button it's in, or a
wrapped text taller than its box
off_screen (warn) a shown text/button not inside the window
small_hit (warn) a button/link/tab smaller than --min in width or height
text_cut (info) text ended with "…" (cut to fit; fine if meant)
Rules (before/after an action):
clicked_moved (error) the clicked button is still shown but moved
moved (warn) another button stayed shown but moved (--allow PREFIX for ids
that may: a declared animation; the next of the clicked one's
kind sliding into its spot is fine)
clicked_gone (info) the clicked button went and nothing of its kind took its spot
Dump format: modules/test.lua (dump). Each issue: {rule, sev, msg, nodes: [...]}.
"""
import argparse
import json
import sys
INTERACTIVE_SKIP = {"text", "panel", "clip"}
TOL = 1.5
# A text's rect is its font's full line box (ascent + descent); glyphs fill less of it.
TEXT_INSET = 0.15
MIN_HIT = {"button": 24, "tab": 24, "field": 24} # others (links, rows): 18
def interactive(n):
return n["kind"] not in INTERACTIVE_SKIP
def shown(n):
return n.get("clip") != "all"
def desc(n):
who = n.get("id") or (repr(n["text"][:40]) if n.get("text") else None) or n.get("name") or "#%d" % n["i"]
return "%s %s [%d,%d %dx%d]" % (n["kind"], who, n["x"], n["y"], n["w"], n["h"])
def brief(n):
out = {k: n[k] for k in ("i", "kind", "id", "name", "text", "layer") if n.get(k) is not None}
out["rect"] = [n["x"], n["y"], n["w"], n["h"]]
return out
def issue(rule, sev, msg, *nodes):
return {"rule": rule, "sev": sev, "msg": msg, "nodes": [brief(n) for n in nodes]}
def inter(a, b):
a, b = glyphs(a), glyphs(b)
return (min(a["x"] + a["w"], b["x"] + b["w"]) - max(a["x"], b["x"]),
min(a["y"] + a["h"], b["y"] + b["h"]) - max(a["y"], b["y"]))
def glyphs(n):
if n["kind"] != "text":
return n
d = n["h"] * TEXT_INSET
return dict(n, y=n["y"] + d, h=n["h"] - 2 * d)
def ancestors(nodes, n):
seen = set()
p = n.get("p", 0)
while p:
seen.add(p)
p = nodes[p - 1].get("p", 0)
return seen
def container(nodes, n):
"""The nearest panel/button above n that is bigger than it."""
p = n.get("p", 0)
while p:
c = nodes[p - 1]
if c["kind"] != "text" and c["kind"] != "clip" and c["w"] * c["h"] > n["w"] * n["h"] and c["w"] > 4 and c["h"] > 4:
return c
p = c.get("p", 0)
return None
def lint(dump, min_hit=24):
nodes = dump["nodes"]
W, H = dump["w"], dump["h"]
out = []
cands = [n for n in nodes if shown(n) and n["w"] > 0 and n["h"] > 0 and (n["kind"] == "text" or interactive(n))]
anc = {n["i"]: ancestors(nodes, n) for n in cands}
# overlap
for i, a in enumerate(cands):
for b in cands[i + 1:]:
if a.get("id") and a.get("id") == b.get("id"):
continue # (a node that takes the clicks for its element)
if a.get("layer") != b.get("layer") or a["i"] in anc[b["i"]] or b["i"] in anc[a["i"]]:
continue
ix, iy = inter(a, b)
if ix <= 2 or iy <= 2:
continue
if (a["kind"] == "text") != (b["kind"] == "text"):
t, e = (a, b) if a["kind"] == "text" else (b, a)
if ix >= t["w"] - TOL and iy >= glyphs(t)["h"] - TOL:
continue # (a label on its button)
if a.get("text") and a.get("text") == b.get("text") and abs(a["x"] - b["x"]) < 3 and abs(a["y"] - b["y"]) < 3:
continue # (a shadow or glow copy)
out.append(issue("overlap", "warn", "%s overlaps %s by %dx%d" % (desc(a), desc(b), ix, iy), a, b))
for n in cands:
if n["kind"] == "text" and n.get("wrap") and n.get("bh", 0) >= 8 and n["h"] > n["bh"] + TOL:
out.append(issue("overflow", "warn", "%s is %d px taller than its box (%d)" % (desc(n), n["h"] - n["bh"], n["bh"]), n))
c = container(nodes, n)
if c and n.get("clip") != "part":
l, t = c["x"] - n["x"], c["y"] - n["y"]
r, b = n["x"] + n["w"] - c["x"] - c["w"], n["y"] + n["h"] - c["y"] - c["h"]
if max(l, t, r, b) > TOL:
out.append(issue("overflow", "warn", "%s sticks out of %s (left %d, top %d, right %d, bottom %d)" % (
desc(n), desc(c), max(l, 0), max(t, 0), max(r, 0), max(b, 0)), n, c))
if n.get("clip") is None and (n["x"] < -TOL or n["y"] < -TOL or n["x"] + n["w"] > W + TOL or n["y"] + n["h"] > H + TOL):
out.append(issue("off_screen", "warn", "%s is outside the %dx%d window" % (desc(n), W, H), n))
m = min_hit if n["kind"] in MIN_HIT else min(min_hit, 18)
if interactive(n) and (n["w"] < m or n["h"] < m):
out.append(issue("small_hit", "warn", "%s is smaller than %d px" % (desc(n), m), n))
if n.get("cut"):
out.append(issue("text_cut", "info", "%s was cut to fit" % desc(n), n))
return out
def by_id(dump):
return {n["id"]: n for n in dump["nodes"] if n.get("id") and shown(n)}
def family(id_):
return id_.split("?")[0].split("#")[0] if "?" in id_ else id_.rsplit(":", 1)[0]
def contains(m, x, y):
return m["x"] <= x <= m["x"] + m["w"] and m["y"] <= y <= m["y"] + m["h"]
def diff(before, after, clicked=None, allow=()):
"""The clicker rule across an action."""
a, b = by_id(before), by_id(after)
out = []
spot = None # the clicked button's center, when it went (the next of its kind may slide in)
if clicked and clicked in a and clicked not in b:
n = a[clicked]
spot = (n["x"] + n["w"] / 2, n["y"] + n["h"] / 2, family(clicked))
for id_, n in a.items():
m = b.get(id_)
if not m or any(id_.startswith(p) for p in allow if id_ != clicked):
continue
if spot and family(id_) == spot[2] and contains(m, spot[0], spot[1]):
continue # (took the clicked one's place)
dx, dy = m["x"] - n["x"], m["y"] - n["y"]
if abs(dx) > TOL or abs(dy) > TOL:
rule, sev = ("clicked_moved", "error") if id_ == clicked else ("moved", "warn")
out.append(issue(rule, sev, "%s moved by %+d,%+d" % (desc(n), dx, dy), n, m))
if clicked and clicked in a and clicked not in b:
n = a[clicked]
cx, cy = n["x"] + n["w"] / 2, n["y"] + n["h"] / 2
fam = family(clicked)
hit = [m for m in b.values() if family(m["id"]) == fam and m["x"] <= cx <= m["x"] + m["w"] and m["y"] <= cy <= m["y"] + m["h"]]
if not hit:
under = [m for m in b.values() if m["x"] <= cx <= m["x"] + m["w"] and m["y"] <= cy <= m["y"] + m["h"]]
out.append(issue("clicked_gone", "info", "%s went; at its spot now: %s" % (
desc(n), ", ".join(desc(m) for m in under) or "nothing"), n, *under))
return out
def text(issues):
return "\n".join("%-5s %-13s %s" % (i["sev"], i["rule"], i["msg"]) for i in issues) or "no issues"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dump")
ap.add_argument("after", nargs="?")
ap.add_argument("--clicked")
ap.add_argument("--allow", action="append", default=[])
ap.add_argument("--json", action="store_true")
ap.add_argument("--min", type=int, default=24)
args = ap.parse_args()
d = json.load(open(args.dump))
issues = diff(d, json.load(open(args.after)), args.clicked, args.allow) if args.after else lint(d, args.min)
print(json.dumps(issues, indent=1, ensure_ascii=False) if args.json else text(issues))
sys.exit(1 if any(i["sev"] == "error" for i in issues) else 0)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -122,7 +122,7 @@ await shot("new-moon");
// The display dialog: its fullscreen button goes through the browser's Fullscreen API.
await cmd("navigate", `{section='moons', moon='${fx.moon}'}`);
await idle();
await click("display");
await click("settings");
await shot("display");
await click("dialog:fullscreen");
await page.waitForTimeout(1000);
+86
View File
@@ -21,6 +21,84 @@ const SYNTH = {
const RATE = ZZFX.sampleRate;
// Client-only sounds, synthesized here (not in the web's table): the new-moon reveal's
// build-up and its burst (screens/game/reveal.lua). Deterministic (a seeded noise).
function noiseGen(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return (s / 4294967296) * 2 - 1;
};
}
function lowpass(cut) {
const a = 1 - Math.exp((-2 * Math.PI * cut) / RATE);
let y = 0;
return (x) => (y += a * (x - y));
}
// rumble: a low, deep build (~1.3 s): a sub tone rising 36 -> 72 Hz with its octave and a
// soft-clipped edge (so small speakers hear it), filtered noise like a far quake, a
// tremolo that speeds up, all swelling to the burst at 1.1 s, then a short fade.
function rumble() {
const len = 1.35, peak = 1.1;
const n = Math.floor(len * RATE);
const out = new Float64Array(n);
const rnd = noiseGen(7);
const lp = lowpass(140), lp2 = lowpass(140);
let ph = 0, trem = 0;
for (let i = 0; i < n; i++) {
const t = i / RATE;
const k = Math.min(1, t / peak);
const f = 36 + 36 * k * k;
ph += (2 * Math.PI * f) / RATE;
trem += (2 * Math.PI * (5 + 17 * k * k)) / RATE;
const tone = Math.sin(ph) + 0.45 * k * Math.sin(2 * ph) + 0.2 * k * Math.sin(3 * ph + 0.3);
const quake = lp2(lp(rnd())) * 9;
let x = Math.tanh((tone * 0.8 + quake * (0.4 + 0.6 * k)) * (1.2 + 1.6 * k));
x *= 1 - 0.35 * k * (0.5 + 0.5 * Math.sin(trem));
const env = Math.min(1, t / 0.08) * (0.25 + 0.75 * k * k) * (t > peak ? Math.max(0, 1 - (t - peak) / (len - peak)) : 1);
out[i] = x * env;
}
return out;
}
// reveal: the burst: a sub drop (150 -> 34 Hz) that hits hard, a noise crack opening into
// a long low roar, and a bright chord swelling out of it (a bell-like shimmer) for the
// "there it is" moment (~2.6 s).
function reveal() {
const len = 2.6;
const n = Math.floor(len * RATE);
const out = new Float64Array(n);
const rnd = noiseGen(11);
const crackLp = lowpass(2400), roarLp = lowpass(180), roarLp2 = lowpass(180);
const chord = [130.81, 196.0, 261.63, 329.63, 392.0, 523.25]; // C major, spread
const phs = chord.map(() => 0);
let ph = 0;
for (let i = 0; i < n; i++) {
const t = i / RATE;
const f = 34 + 116 * Math.exp(-t / 0.07);
ph += (2 * Math.PI * f) / RATE;
const sub = Math.sin(ph) * Math.exp(-t / 0.55) * Math.min(1, t / 0.003);
const w = rnd();
const crack = crackLp(w) * Math.exp(-t / 0.06) * 1.6;
const roar = roarLp2(roarLp(w)) * 7 * Math.exp(-t / 0.9) * Math.min(1, t / 0.02);
let bell = 0;
for (let j = 0; j < chord.length; j++) {
const fj = chord[j] * (1 + 0.002 * Math.sin(2 * Math.PI * (0.7 + j * 0.3) * t));
phs[j] += (2 * Math.PI * fj) / RATE;
bell += Math.sin(phs[j]) * (j < 2 ? 0.6 : 0.4);
}
const swell = t < 0.06 ? 0 : Math.min(1, (t - 0.06) / 0.25) * Math.exp(-(t - 0.06) / 1.1);
const x = Math.tanh((sub * 1.3 + crack + roar) * 1.4) + bell * 0.16 * swell;
const tail = t > len - 0.3 ? (len - t) / 0.3 : 1;
out[i] = x * tail;
}
return out;
}
// name: [peak, render]: normalized to that peak.
const CUSTOM = { rumble: [0.27, rumble], reveal: [0.55, reveal] }; // (RMS about -20 and -15 dB)
function wav(samples) {
const n = samples.length;
const buf = Buffer.alloc(44 + n * 2);
@@ -44,6 +122,14 @@ function wav(samples) {
return buf;
}
for (const [name, [peak, render]] of Object.entries(CUSTOM)) {
const raw = render();
const max = raw.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
const samples = Array.from(raw, (v) => (v / max) * peak);
writeFileSync(new URL(`../assets/sfx/${name}.wav`, import.meta.url), wav(samples));
console.log(`${name}.wav: ${samples.length} samples (${(samples.length / RATE).toFixed(2)} s), peak ${peak}`);
}
for (const [name, [gain, params]] of Object.entries(SYNTH)) {
const samples = ZZFX.buildSamples(...params).map((v) => v * gain);
const peak = samples.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
+5
View File
@@ -47,6 +47,11 @@ function M:init(moon, on_select, opts)
local p = gui.get_position(name)
gui.set_enabled(name, false)
local _, name_w = ui.label(self.content, moon.name, "moon", p.x, -p.y)
if (moon.grade or "") ~= "" then
local gb = chip.grade(self.content, moon.grade, p.x + name_w + 8, -p.y)
tooltip.add(gb, "Grade " .. moon.grade .. " moon")
name_w = name_w + 8 + chip.GRADE_W
end
if moon.rating then
-- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing.
local tier = moon.rating.tier
Executable
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Agent worktrees with their own server and database. Run from anywhere, e.g. from the
# workspace: game/scripts/wt up <task>
#
# up <task> worktree ../wt-<task> on branch <task> (made, or reused), the main
# checkout's Defold build cache and bob.jar, .env, database mm_<task>
# (migrated), and a server (MM_DEV=1) on a free port; prints MM_API
# down <task> stop that server and the worktree's dev game (their recorded pids only),
# drop the database, delete the copied .env
# rm <task> down, remove the worktree, delete the branch if it's merged
# status worktrees with their port, pid and database
# restart <task> rebuild and restart the server (keeps the database)
#
# State in <worktree>/.wt/: port, pid, db, server.log.
set -euo pipefail
MAIN=$(dirname "$(git -C "$(dirname "$(realpath "$0")")" rev-parse --path-format=absolute --git-common-dir)")
WS=$(dirname "$MAIN")
die() { echo "wt: $*" >&2; exit 1; }
task_vars() {
TASK=${1:-}
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -"
WT=$WS/wt-$TASK
DB=mm_${TASK//-/_}
ST=$WT/.wt
}
# psql as the admin user from the main checkout's .env (never printed).
admin_sql() {
(
set -a
# shellcheck disable=SC1091
. "$MAIN/.env"
set +a
PGPASSWORD=${ADMIN_DB_PASSWORD:-} psql -X -q -v ON_ERROR_STOP=1 -tA \
-h "${DB_HOST:-localhost}" -p "${DB_PORT:-5432}" -U "${ADMIN_DB_USER:-postgres}" -d postgres -c "$1"
)
}
alive() { [[ -n ${1:-} ]] && kill -0 "$1" 2>/dev/null; }
port_free() { ! ss -ltnH "( sport = :$1 )" 2>/dev/null | grep -q .; }
pick_port() {
local p used
used=$(cat "$WS"/wt-*/.wt/port 2>/dev/null || true)
for p in $(seq 3101 3199); do
grep -qx "$p" <<<"$used" && continue
port_free "$p" && { echo "$p"; return; }
done
die "no free port in 3101-3199"
}
start_server() {
local port pid
port=$(cat "$ST/port" 2>/dev/null || true)
if [[ -z $port ]] || ! port_free "$port"; then port=$(pick_port); fi
echo "$port" >"$ST/port"
(cd "$WT/server" && go build -o bin/moonminers .) || die "server build failed"
(cd "$WT/server" && DB_DATABASE=$DB bin/moonminers migrate) >"$ST/migrate.log" 2>&1 ||
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
rm -f "$ST/pid"
# Its own session (not killed with the caller's), pid written by the process itself.
(cd "$WT/server" && DB_DATABASE=$DB PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
>"$ST/server.log" 2>&1 </dev/null)
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done
pid=$(cat "$ST/pid")
for _ in $(seq 50); do
port_free "$port" || break
alive "$pid" || { tail -5 "$ST/server.log"; die "server exited (log $ST/server.log)"; }
sleep 0.2
done
port_free "$port" && die "server not listening on $port (log $ST/server.log)"
echo "server pid $pid, log $ST/server.log"
}
stop_server() {
local pid
pid=$(cat "$ST/pid" 2>/dev/null || true)
if alive "$pid" && grep -q moonminers "/proc/$pid/cmdline" 2>/dev/null; then
kill "$pid"
for _ in $(seq 25); do alive "$pid" || break; sleep 0.2; done
echo "stopped server $pid"
fi
rm -f "$ST/pid"
}
up() {
task_vars "$1"
if [[ ! -d $WT ]]; then
if git -C "$MAIN" show-ref -q --verify "refs/heads/$TASK"; then
git -C "$MAIN" worktree add "$WT" "$TASK"
else
git -C "$MAIN" worktree add "$WT" -b "$TASK"
fi
fi
mkdir -p "$ST"
# Defold: the main checkout's build cache, so bob reuses its engine (no remote build
# while the native extensions are unchanged), and its bob.jar.
local c=$WT/client m=$MAIN/client d
for d in build .internal; do
[[ -d $c/$d || ! -d $m/$d ]] || cp -a "$m/$d" "$c/$d"
done
# The extender cache (.internal/cache/.buildcache) is keyed by the absolute path of each
# cached build.zip: point the copied entries at this worktree.
[[ ! -f $c/.internal/cache/.buildcache ]] || grep -q "^$c/" "$c/.internal/cache/.buildcache" ||
sed -i "s|^$m/\(.*\)\$|&\n$c/\1|" "$c/.internal/cache/.buildcache"
mkdir -p "$c/tools"
[[ -e $c/tools/bob.jar || ! -e $m/tools/bob.jar ]] || ln -s "$m/tools/bob.jar" "$c/tools/bob.jar"
# Web client CSS and the Preact bundle (built, not committed), for the server's pages.
local s=$WT/server ms=$MAIN/server
[[ -e $s/web/static/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app"
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
echo "$DB" >"$ST/db"
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
admin_sql "create database $DB"
echo "created database $DB"
fi
if alive "$(cat "$ST/pid" 2>/dev/null)"; then
echo "server already running (pid $(cat "$ST/pid"))"
else
start_server
fi
echo "worktree $WT"
echo "MM_API=http://localhost:$(cat "$ST/port")"
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
}
down() {
task_vars "$1"
[[ -d $WT ]] || die "no worktree $WT"
stop_server
if [[ -f $WT/client/dist/dev/pid ]]; then
(cd "$WT/client" && scripts/dev.py stop) || true
fi
if [[ -n $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
admin_sql "drop database $DB with (force)"
echo "dropped database $DB"
fi
rm -f "$WT/.env" "$ST/db"
}
remove() {
task_vars "$1"
down "$1"
rm -rf "$ST"
git -C "$MAIN" worktree remove "$WT" || die "worktree not removed (uncommitted changes?)"
if git -C "$MAIN" branch -d "$TASK" 2>/dev/null; then
echo "deleted branch $TASK"
else
echo "kept branch $TASK (not merged)"
fi
}
status() {
local path branch st pid run
printf '%-28s %-16s %-6s %-14s %s\n' WORKTREE BRANCH PORT PID DB
git -C "$MAIN" worktree list --porcelain | while read -r k v; do
case $k in
worktree) path=$v; branch=- ;;
branch) branch=${v#refs/heads/} ;;
"")
st=$path/.wt
pid=$(cat "$st/pid" 2>/dev/null || true)
run=-
[[ -n $pid ]] && { alive "$pid" && run=$pid || run="$pid(dead)"; }
printf '%-28s %-16s %-6s %-14s %s\n' "${path#"$WS"/}" "$branch" \
"$(cat "$st/port" 2>/dev/null || echo -)" "$run" "$(cat "$st/db" 2>/dev/null || echo -)"
;;
esac
done
}
case ${1:-} in
up) up "${2:-}" ;;
down) down "${2:-}" ;;
rm) remove "${2:-}" ;;
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://localhost:$(cat "$ST/port")" ;;
status) status ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
+30 -31
View File
@@ -23,12 +23,12 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
- This is `server/` in the repo (`../AGENTS.md`); the Defold client is `../client` (its own
AGENTS.md), the compose files are at the repo root.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
loading. `balance.go`: the `sim` command (`gradesim.go`: grade table and the two clicker
players (copies, boosts; 1 click/s), then the `sim.go` analysis). Results: `BALANCE-NOTES.md`.
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
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
@@ -48,13 +48,17 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
`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:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded.
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
hold less); a first moon
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
resource prices, store items with upgrade tiers, moon types: a name, a placeholder
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
cheapest first) are moon vendors: a price, `wellCountWeights`, a well supply range and
affinities that multiply the type's. The grade is `staking_source.grade` (migration 018;
older moons are C); `buy-moon` takes `grade=`, else the newest moon's grade
(`State.MoonGrade`), else the lowest. `rollWells` (game.go) rolls a new moon's wells: the
count by its grade's weights, resources by the affinities, each well's supply in the
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
Upgrade tiers set a cycle `speed` (read from cfg(), not stored; `StoreItem.ToolSecs`). A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
@@ -62,6 +66,10 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
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
`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
`save_code`, only a hash; restore tries limited per IP; the once-only nudge after the
first sale, onboarding flag `nudge:save-code`, as `notice` in the API response).
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. The quote (`marketQuote`) is shared, the same for every
player: config price × a deterministic drift (name + 5-minute tick, within
@@ -104,7 +112,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
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,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
attachments, moon sale, Redrill), buying a moon of `refinery.unlockGrade` (B) or higher Refinery, buying a
tool or attachment 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
@@ -142,29 +150,19 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
- `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`).
- `scripts/tool-art.mjs`: draws the tool images (`web/static/assets/items`, one per tool and
tier: `<image>-tN`, `StoreItem.ImageAt`; the API's tool `image` fields); rerun it after
adding a tool or tier (needs rsvg-convert and ImageMagick). A new tool needs a drawing there.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
and the store link to the classic client. `session.js`: like the Defold client's session
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
id; corrections change its playback rate). `fx.js`: effects at the click from the
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
for what differs.
- `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` flags `.value` inside JSX).
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
changes when the server answers); never swap an element that animates for another.
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–016):
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–017):
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.
- `art/`: sources of the store, attachment and advisor images (Blender scripts, prompts);
@@ -181,6 +179,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
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.
- 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).
+87
View File
@@ -0,0 +1,87 @@
# Balance notes: moon grades, clicker pacing (2026-10-21)
Moon Miners is a clicker: the player is always clicking. `go run . sim` prints the grade
table, a table of each drill against its boosts, and two active sim players (gradesim.go).
Both claim (claim all, with redeploy) as soon as a drill is ready (at most every 3 s), stake
drills whose well ran dry, sell every 30 s (a click per resource) and buy, within a budget of
1 click/s. "copies" buys only drill copies; "boosts" buys whatever adds the most ₥/minute
per ₥. Both save up to 10 min of income for a purchase and buy a moon when their drills are
about to run dry (the dearest grade costing at most half an hour of income).
Clicks: one claim per distinct drill cycle (copies of one drill finish together), a stake
when a drill's well runs dry (a multi-well drill: its moon), sells, purchases (attachments
and seats 2). So a copy adds ₥ to every claim; a speed boost adds claims.
## Grades (5000 rolls each, base prices)
Prices ×10 per grade (were ×4), wells scaled with the price, so the value multiples stay.
| grade | price | wells | units | ₥/unit | value × price (mean, p10–p90) | drill meant for it |
|---|---|---|---|---|---|---|
| D | 150 | 1.6 | 38 | 6.5 | 1.62 (0.64–2.84) | PD-1, 6.0 claims per well, 12 s |
| C | 1.5k | 2.5 | 280 | 9.3 | 1.73 (0.79–2.79) | PX-4, 5.6, 12 s |
| B | 15k | 3.0 | 1988 | 12.7 | 1.68 (0.82–2.59) | L100, 6.0, 13 s |
| A | 150k | 4.0 | 17.3k | 13.4 | 1.54 (1.01–2.01) | L300, 6.4, 14 s |
| S | 1.5M | 4.5 | 161k | 13.2 | 1.42 (1.13–1.69) | LRE 6.3 (15 s), EDE 4.9 (13 s), LCE 3.8 (11 s) |
Drills: cycles 11–15 s (were 10–90 s); claim amounts scaled with their grade's wells; each
tier ~6% more units/s per ₥ than the one below (PD-1 1.2k … LCE 11M). Upgrades cost
(speed gained × drill price) / 1.31 minus the parts.
## Boosts against copies (a drill alone on its grade, per ₥ spent)
| drill | upgrades t1–5: ₥/min, ₥/click vs a copy | Turbo Core | Diamond Bit | Cryo Cooler | Seam Scanner |
|---|---|---|---|---|---|
| PD-1 (D) | ×1.23, ×0.38 | ×0.25, ×0.10 | ×0.19, ×0.20 | ~0 | ~0 |
| PX-4 (C) | ×1.22, ×0.38 | **×1.21, ×0.49** | **×0.90, ×0.97** | ~0 | ~0 |
| L100 (B) | ×1.22, ×0.50 | ×5.2, ×2.8 | ×4.3, ×4.5 | ×0.09 | ×0.09 |
| L300 (A) | ×1.24, ×0.63 | ×52 | ×46 | **×1.30, ×0.74** | **×0.97, ×0.98** |
| LRE–LCE (S) | ×1.22–1.23, ×0.53–0.70 | ×500+ | ×500+ | ×13–46 | ×11–37 |
Attachments have one price for every drill: Turbo Core 900 / Diamond Bit 950 are set for
PX-4, Cryo Cooler 85k / Seam Scanner 90k for L300; on bigger drills they're nearly free.
## Runs (final config)
| | copies | boosts |
|---|---|---|
| first B / A / S moon | 1h08 / 1h50 / 3h18 | 1h03 / 1h53 / 2h26 |
| clicks by the first S moon | 6.4k | 5.4k |
| income at 30 min / 1h / 2h / 3h | 37k / 119k / 1.5M / 15M ₥/h | 47k / 100k / 3.5M / 47M ₥/h |
| income at 6h | 108M ₥/h | 146M ₥/h (click budget full) |
| ₥ per click at 1h / 3h | 72 / 5.2k | 61 / 15.8k |
| purchases, ₥/min added per 1k₥ | 24 copies, 292 | 21 copies 370, 90 upgrades 842, 37 attachments 13k, 3 advisors 932 |
| longest stretch with nothing to click | 12 s | 12 s |
| longest stretch with nothing affordable to buy | 9.5 min (at 7 min: the 3rd PD-1) | 4 min |
| claims to drain a well (first 20 per grade) | D 6.2, C 27, B 40, A 82, S 77 | D 6.3, C 21, B 38, A 41, S 83 |
- Upgrades are ×1.22–1.24 a copy's ₥/min per ₥ and ×0.38–0.70 its ₥/click, on every drill.
- The two paths reach B and A within ~5 min of each other; S is 52 min apart.
- Selling pressure `scale` 200k → 5M (incomes are ~10× bigger); advisor seats 4k/15k/50k →
20k/400k/8M; starting credits 700 → 1.5k (the second PD-1 at ~5 min).
## Iterations
1. Cycles 10–15 s, grades ×10, drills on the 6% ladder, upgrades for ×1.22: S at 3h15/4h05,
but one PD-1 for the first 20 min (~30 ₥/min net).
2. Starting credits 1.5k, seats dearer: early game moves; upgrades measured ×1.07 (the
half-second wait for a claim and whole-second cycles), so
3. upgrades ~12% cheaper: ×1.22.
## Doubts
- Fast early and a few hours to S pull against each other: a drill's payback is the same at
every tier (or faster, to keep drones from winning), so only the grade price ladder (now ×10)
stretches the game. Early copies still take ~10 min each.
- Yield boosts (Diamond Bit, Seam Scanner, Rook) add ₥/click as much as ₥/min, so they can't
beat copies on one and lose on the other; they sit at about a copy's worth.
- Attachments and advisors have one price for all drills; past their target drill they're
nearly free (attachments ×50–2500 a copy on S drills).
- Stakes: a one-well drill needs a stake click every ~6 claims on its own grade, so many
copies on small wells cost clicks too.
- After S the fleet empties an S moon every few seconds; moons and stakes fill the 1 click/s
budget and purchases stop (income flat from ~5h at ~150M ₥/h).
- Short cycles in whole seconds: at 6–8 s a +10% speed step can round to nothing (e.g. L100
t3 with two speed attachments).
- The sim's first moon is a C (1.5k credits, cheap next to income); the economy page's sim
(sim.go) is the old one.
+68 -11
View File
@@ -34,6 +34,7 @@ type APIState struct {
Resources []APIAmount `json:"resources"`
Refined []APIAmount `json:"refined"`
MoonPrice float64 `json:"moonPrice"`
MoonGrade string `json:"moonGrade"` // the grade buy-moon gets without one (moonPrice is its price)
Unlocked map[string]bool `json:"unlocked"`
Shine []string `json:"shine"` // revealed tabs not opened yet
Ready int `json:"ready"` // finished drills, all moons
@@ -115,6 +116,8 @@ type APIMoonListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Grade string `json:"grade"`
MaxSupply float64 `json:"maxSupply"` // the biggest well of its grade: a full bar
Rating *APIRating `json:"rating"`
Wells []APIWellSupply `json:"wells"`
Drills int `json:"drills"`
@@ -180,6 +183,8 @@ type APIMoon struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Grade string `json:"grade"`
MaxSupply float64 `json:"maxSupply"` // the biggest well of its grade: a full bar
Description string `json:"description"`
Rating *APIRating `json:"rating"`
Finished bool `json:"finished"`
@@ -207,6 +212,9 @@ type APIWellRow struct {
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
// FullyCommitted: its drills will take all it has left (remain 0); a one-well drill
// can't be deployed on it (stake refused, "Nothing left to mine on this well").
FullyCommitted bool `json:"fullyCommitted"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
}
@@ -231,6 +239,7 @@ type APIToolGroup struct {
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused
Attachments []string `json:"attachments"`
}
@@ -356,7 +365,7 @@ func apiGroup(g DrillGroup) APIDrillGroup {
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
out := []APIMoonListItem{}
for _, r := range rows {
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Grade: r.Grade, MaxSupply: r.GradeConfig().WellSupplyMax, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
for _, w := range r.Wells {
@@ -378,7 +387,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(), MoonGrade: s.MoonGrade().Grade,
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
Banner: []APIBanner{}, Admin: p.Admin}
@@ -406,7 +415,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
@@ -419,7 +428,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
}
if d := p.Moon; d != nil {
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Description: d.Description, Rating: apiRating(d.Rating),
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Grade: d.Grade, MaxSupply: d.GradeConfig().WellSupplyMax, Description: d.Description, Rating: apiRating(d.Rating),
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
if d.Wide != nil {
g := apiGroup(*d.Wide)
@@ -429,7 +438,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
Over: w.Over(), Full: w.Full(), FullyCommitted: w.FullyCommitted(), StatusText: w.StatusText(), NoteText: w.NoteText()}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
@@ -445,7 +454,7 @@ func apiState(p *Page, extra ...*Page) *APIState {
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
@@ -581,9 +590,24 @@ type APIResponse struct {
Reveal *APIReveal `json:"reveal,omitempty"`
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
SaveCode *APISaveCode `json:"saveCode,omitempty"` // save-code: the new code (shown once)
Notice *APINotice `json:"notice,omitempty"` // a one-time announcement besides the toast
State *APIState `json:"state,omitempty"`
}
// APISaveCode is a player's save code (savecode.go). Code is only set when it was just
// made; madeAt is unix ms (0: the player has none).
type APISaveCode struct {
Code string `json:"code,omitempty"`
MadeAt int64 `json:"madeAt"`
}
// APINotice is a one-time announcement: kind "save-code" points the player to save codes.
type APINotice struct {
Kind string `json:"kind"`
Text string `json:"text"`
}
type APIToast struct {
Message string `json:"message"`
Error bool `json:"error"`
@@ -757,12 +781,36 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
apiError(w, http.StatusBadRequest, "Bad request")
return
}
userID, created := s.apiUser(w, r)
if userID == "" {
ctx := r.Context()
action := r.PathValue("action")
var userID string
var created bool
var res actionResult
var saveCode *APISaveCode
switch action {
case "restore":
// Switches to the save code's player (savecode.go); no current player needed.
if userID = s.apiRestore(w, r, get("code")); userID == "" {
return
}
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
res = actionResult{Action: action, Message: "Welcome back"}
case "save-code":
if userID, created = s.apiUser(w, r); userID == "" {
return
}
code, made, err := makeSaveCode(ctx, s.db, userID)
if err != nil {
apiServerError(w, err)
return
}
saveCode = &APISaveCode{Code: code, MadeAt: made.UnixMilli()}
res = actionResult{Action: action, Message: "Save code made", View: viewFromForm(get)}
default:
if userID, created = s.apiUser(w, r); userID == "" {
return
}
res, err = s.runAction(ctx, userID, action, get)
}
if errors.Is(err, errUnknownAction) {
apiError(w, http.StatusNotFound, "Unknown action")
return
@@ -803,7 +851,16 @@ func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
return
}
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}, SaveCode: saveCode}
if !toast.Error && res.Action == "sell" {
// After the first sale, once: where the save code is (savecode.go).
if nudge, err := saveCodeNudge(ctx, s.db, userID); err != nil {
apiServerError(w, err)
return
} else if nudge {
resp.Notice = &APINotice{Kind: "save-code", Text: saveCodeNudgeText}
}
}
if !toast.Error {
if res.BoughtTool != "" {
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
+72 -24
View File
@@ -23,6 +23,27 @@ names an existing player, a new one is created (at most 10 per minute per IP, el
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
Banned players get `403`.
### Save codes
A player can get back to its progress from another device (or after the id is lost) with
a save code: 16 characters in groups of four, e.g. `D5RN-N5WQ-5TVC-H6V7`, from
`ABCDEFGHJKMNPQRSTVWXYZ23456789` (no 0/O, 1/I/L, U). The server keeps only a hash, so the
code is shown once, when it is made. One code per player; making a new one turns the old
one off.
- `GET /api/save-code`: `{ok, userId, saveCode: {madeAt}}`, `madeAt` unix ms of the current
code, `0` when the player has none.
- `POST /api/act/save-code`: makes a new code; the response (as any action's, `toast.kind`
`save-code`) carries `saveCode: {code, madeAt}`.
- `POST /api/act/restore` with `code` (case, spaces and dashes don't matter): switches to the
code's player, no current player needed. `200` as any action, with the new id in
`X-MM-User`, the cookie and `userId` (store it, drop the old state and view: the state
is the restored player's first moon), `toast.kind` `restore`. `422` (with an error `toast`, no `state`) "That save code
doesn't match any miner", `429` after 5 tries a minute per IP; a banned player's code is a `422` ("That miner is
suspended"), not a `403`.
- The first successful `sell` of a player without a code returns, once,
`notice: {kind: "save-code", text}` beside the toast: an announcement pointing to save
codes (a client may word it for where its menu is).
## GET /api/state
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
@@ -53,10 +74,10 @@ Core-loop actions and their params:
| action | params | notes |
|---|---|---|
| `buy-moon` | – | view moves to the new moon |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy` |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well |
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left (as `stake`; the rest stay idle, refused if none fits) |
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
The other sections' actions:
@@ -80,10 +101,12 @@ The other sections' actions:
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
| `save-code` | – | a new save code (see Save codes), in `saveCode` |
| `restore` | `code` | switches to the save code's player (see Save codes) |
| `admin-grant` | `grant` (`credits`, a resource or a refined item name), `amount` | admins only (`state.admin`), else `403`; logged as `admin_grant` |
Responses:
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, saveCode?, notice?, state}`.
`kind` is the action (for sounds and effects); `finished` lists moons the action mined
out; `credits` is what a market sale earned. `reveal` (after `buy-moon`) and `buyModal`
(after `buy-tool`) are one-shot results, see below.
@@ -109,24 +132,25 @@ session; the web page also reads it once per page load.
| `credits` | balance |
| `admin` | the player is an admin (`ADMIN_USER_IDS`, or everyone with `MM_DEV=1`): may use `admin-grant` |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `moonPrice` | price of the next moon at `moonGrade` |
| `moonGrade` | the grade `buy-moon` gets without a `grade`: the newest moon's, else the lowest |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, grade, maxSupply, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18"); `grade` its moon grade, `maxSupply` its grade's biggest well (a full supply bar) |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, blocked, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, speed, cycleSecs, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art at that tier); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
@@ -145,6 +169,7 @@ short code, listed with each field, and the web's text next to it.
drill}
```
- `image`: the tool's art at its tier, like `state.tools`.
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
@@ -239,22 +264,26 @@ progress is saved on this device). Its button is `new-game`, `newGame` its dialo
### newMoon (`s=new-moon`)
`{price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: `after` the
`{grade, grades, price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: the
fields besides `grades` are for the default `grade` (`state.moonGrade`): `after` the
balance after buying, `need` the credits missing, `whyText` why the button is disabled, `types` the moon types it can roll,
`resources` what its wells can hold. Buy with `buy-moon`.
`resources` what its wells can hold. `grades` lists every moon grade, cheapest first:
`[{grade, price, canAfford, wellsMin, wellsMax, supplyMin, supplyMax, default, whyText}]` (a
well holds `supplyMin` up to just under `supplyMax`; `default` is the one `buy-moon` buys
without a grade). Buy with `buy-moon` and `grade=<grade>`.
### reveal (in the `buy-moon` response)
```
{moonId, name, kind, description, rating, headline, sub,
{moonId, name, kind, grade, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
next: {grade, price, canBuy, why, whyText}}
```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
`rating`, `maxSupply` and `fullAt` are against moons of its `grade`. `headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` with `grade=<next.grade>`, the
grade just bought): its price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
### buyModal (in the `buy-tool` response)
@@ -311,7 +340,23 @@ The web's formats:
The client may show results before the server answers; the next state replaces them.
- Stake: a drill takes `tool.cycleSecs` and gets `tool.yield` (× `rules.boosts.yield` of
the well's resource, if any). Each stake carries `start`, `end` and `take` (what claiming
it would get now).
it would get now: at most `amount`, from what its wells hold).
- Shared wells: the selected moon's well fields (`demand`, `remain`, `dryAfter`, `over`,
`full`, `fullyCommitted`, the notes, its drills' `gets`/`here`/`state`, `readyAmount`) and
the deploy cards' `here`/`free`/`short`/`blocked` come from a projection the client may
recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed
in turn, each from what the earlier ones left (`remain`, starting at `supply`). A one-well
stake takes `min(amount, remain)` of its well; a multi-well stake (`reach` > 1) covers its
own well (if not empty) then the others with something left, most first (ties in
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left
until it has `amount`. `demand` is the one-well amounts on the well plus what each
multi-well stake would take from it out of `supply`; `dryAfter` the claim (among those
taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake
of its `yield` and `reach` on the well gets after all of them (`free` the `remain` of
the wells it covers); 0 is `blocked`, refused by the server. `readyAmount` is the ready
one-well drills claimed in turn from `supply`. A claim takes `min(amount, supply)` in
that order, and one that empties a well stops (idle, claim of 0) the other one-well
drills on it.
- Sell: `amount` units pay `amount × unpressed × avg`, with
`avg = floor + (1 − floor) × scale × (e^(−p/scale) − e^(−(p+d)/scale)) / d`,
`p` the quote's `pressure`, `d = amount × base` and `floor`, `scale` from
@@ -347,7 +392,8 @@ Content-Type: application/json
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
"moonPrice": 400,
"moonPrice": 2500,
"moonGrade": "B",
"unlocked": {"moons": true, "equipment": true, "market": true, "store": true, "refinery": false, "advisors": false,
"leaderboard": true, "help": true, "new-moon": true, "claim-all": false, "redrill": true, "redeploy": true},
"shine": ["equipment", "store"],
@@ -393,7 +439,7 @@ From a live server (long ids and arrays shortened).
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
@@ -455,8 +501,10 @@ From a live server (long ids and arrays shortened).
`s=new-moon`, `state.newMoon`:
```json
{"price": 400, "canAfford": true, "after": 73179.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 5}
{"grade": "B", "grades": [{"grade": "D", "price": 150, "canAfford": true, "wellsMin": 1, "wellsMax": 2,
"supplyMin": 16, "supplyMax": 32, "default": false, "whyText": ""}, "…"],
"price": 2500, "canAfford": true, "after": 71079.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 4}
```
`POST /api/act/buy-moon`, `reveal`:
+46 -11
View File
@@ -61,8 +61,11 @@ type APITierUp struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
ClaimBoost float64 `json:"claimBoost"`
Speed float64 `json:"speed"` // cycle speed multiplier at this tier (1: none)
CycleSecs int `json:"cycleSecs"` // the tier's cycle before bonuses
Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
}
type APIEffect struct {
@@ -147,7 +150,9 @@ func apiCatalog(s *State) *APICatalog {
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier),
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier)
}
cat.Tools = append(cat.Tools, t)
}
@@ -203,8 +208,8 @@ type APIConfirm struct {
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
type APIRules struct {
WellSupplyMax float64 `json:"wellSupplyMax"` // config max: a full reveal bar is this × the well scale
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars: the selected moon's grade
SlotsPerTool int `json:"slotsPerTool"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
@@ -235,8 +240,8 @@ type APIRefineryRules struct {
func apiRules(p *Page) *APIRules {
s, c := p.S, cfg()
b := s.Bonus()
lo, hi := wellCountRange()
r := &APIRules{WellSupplyMax: c.WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
lo, hi := s.wellCountRange()
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
WellsMin: lo, WellsMax: hi,
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
@@ -452,7 +457,7 @@ func apiEquipment(p *Page) *APIEquipment {
for _, row := range p.Equipment {
b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
@@ -904,6 +909,8 @@ func apiHelp(s *State) *APIHelp {
}
type APINewMoon struct {
Grade string `json:"grade"` // the default grade (buy-moon without a grade); the fields below are its
Grades []APIMoonGrade `json:"grades"` // every grade, cheapest first: buy-moon with grade=<grade>
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
After float64 `json:"after"` // balance after buying
@@ -915,10 +922,36 @@ type APINewMoon struct {
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
}
// APIMoonGrade is one moon vendor in the new-moon panel.
type APIMoonGrade struct {
Grade string `json:"grade"`
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
SupplyMin float64 `json:"supplyMin"` // units per well, from supplyMin to just under supplyMax
SupplyMax float64 `json:"supplyMax"`
Default bool `json:"default"` // what buy-moon without a grade buys
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
}
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
out := []APIMoonGrade{}
for _, g := range gs {
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default}
if !g.CanAfford {
a.WhyText = "Not enough credits"
}
out = append(out, a)
}
return out
}
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
lo, hi := wellCountRange()
a := &APINewMoon{Price: t.Price, CanAfford: t.CanAfford, After: t.After, Need: t.Need, Types: []string{},
Resources: []string{}, WellsMin: lo, WellsMax: hi}
lo, hi := cfg().grade(t.Grade).wellCountRange()
a := &APINewMoon{Grade: t.Grade, Grades: apiMoonGrades(t.Grades), Price: t.Price, CanAfford: t.CanAfford, After: t.After,
Need: t.Need, Types: []string{}, Resources: []string{}, WellsMin: lo, WellsMax: hi}
if !t.CanAfford {
a.WhyText = "Not enough credits"
}
@@ -932,8 +965,9 @@ type APIReveal struct {
MoonID string `json:"moonId"`
Name string `json:"name"`
Kind string `json:"kind"`
Grade string `json:"grade"`
Description string `json:"description"`
Rating APIRating `json:"rating"`
Rating APIRating `json:"rating"` // against moons of its grade
Headline string `json:"headline"`
Sub string `json:"sub"`
Worth float64 `json:"worth"` // its wells at market prices now
@@ -947,6 +981,7 @@ type APIReveal struct {
// APINextMoon is the reveal card's "Buy another" button.
type APINextMoon struct {
Grade string `json:"grade"` // the grade just bought: buy-moon with grade=<grade>
Price float64 `json:"price"` // the next moon's price
CanBuy bool `json:"canBuy"` // affordable and available
Why string `json:"why"` // locked (hide the button), credits; "" can buy
@@ -964,11 +999,11 @@ func apiReveal(r *MoonReveal) *APIReveal {
if r == nil {
return nil
}
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Grade: r.Grade, Description: r.Description,
Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{},
Next: APINextMoon{Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
Next: APINextMoon{Grade: r.Next.Grade, Price: r.Next.Price, CanBuy: r.Next.CanBuy, Why: r.Next.Why, WhyText: r.Next.WhyText}}
a.Traits = append(a.Traits, r.Traits...)
for _, w := range r.Wells {
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
+10
View File
@@ -5,11 +5,21 @@ package main
import (
"encoding/json"
"io/fs"
"math"
"testing"
)
func TestAPI(t *testing.T) {
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
for _, item := range game.StoreItems {
for tier := 0; tier <= len(item.Upgrades); tier++ {
name := item.ImageAt(tier)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
}
}
}
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
asides := map[bool]bool{}
+1 -1
View File
@@ -282,7 +282,7 @@ func (s *State) drillPanel(row ToolRow) *DrillPanel {
}
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
names := append([]string{a.Name}, s.Fitted[row.ID]...)
sg.Secs, sg.Yield = drillStats(row.Item.CompletionTimeInSecs, row.Item.ToolYield(row.Tier),
sg.Secs, sg.Yield = drillStats(row.Item.ToolSecs(row.Tier), row.Item.ToolYield(row.Tier),
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
for _, g := range row.Gear {
if g.Name == a.Name {
+16 -27
View File
@@ -20,9 +20,8 @@ func runSim(args []string) error {
}
}
a := analyze(&c, nil, 0)
fmt.Printf("avg price %.2f avg well %.0f units (%.2f wells) moon: price %s, ore %.0f units worth %s (%.2f×)\n",
a.AvgPrice, a.AvgSupply, a.AvgWells, formatCredits(c.MoonPrice), a.MoonUnits, formatCredits(a.MoonValue),
a.MoonValue/max(1, c.MoonPrice))
printGradeSim(&c)
fmt.Printf("\navg price %.2f default grade: avg well %.0f units (%.2f wells)\n", a.AvgPrice, a.AvgSupply, a.AvgWells)
fmt.Println("(on moon: what a claim gets on average from a fresh moon, given what the wells in its reach hold; ₥/h at that)")
fmt.Printf("\n%-38s %8s %5s %6s %5s %7s %8s %8s %7s\n", "tool", "price", "secs", "claim", "wells", "on moon", "₥/h", "payback", "ratio")
for _, t := range a.Tools {
@@ -41,30 +40,8 @@ func runSim(args []string) error {
for _, s := range a.Refining.Slots {
fmt.Printf(" slot %d %s payback %s\n", s.Slot, formatCredits(s.Price), s.Payback)
}
s := a.Sim
fmt.Println("\nsim")
for _, t := range s.Tools {
fmt.Printf(" %-38s afford %8s bought %8s ×%d\n", t.Name, t.AffordAt, t.BoughtAt, t.Count)
}
for _, m := range s.Attachments {
fmt.Printf(" attachment %-27s bought %8s ×%d\n", m.Name, m.BoughtAt, m.Count)
}
for _, a := range s.Advisors {
fmt.Printf(" advisor %-29s hired %8s\n", a.Name, a.BoughtAt)
}
fmt.Printf(" advisor seats %d\n", s.Seats)
for _, m := range s.Moons {
fmt.Printf(" moon #%d at %s\n", m.Count, m.At)
}
for _, sn := range s.Snapshots {
fmt.Printf(" %s credits %10.0f earned %10.0f rate %9.0f/h tools %3d moons %3d (+%d) pressure %s\n",
sn.At, sn.Credits, sn.Earned, sn.RatePerHour, sn.Tools, sn.Moons, sn.MoonsBought, sn.PressureText())
}
if os.Getenv("EVENTS") != "" {
for _, e := range s.Events {
fmt.Println(" " + e)
}
fmt.Printf(" (+%d more)\n", s.MoreEvents)
if os.Getenv("OLDSIM") != "" {
printOldSim(a.Sim)
}
fmt.Println("\nwarnings")
for _, w := range a.Warnings {
@@ -72,3 +49,15 @@ func runSim(args []string) error {
}
return nil
}
// printOldSim prints the economy page's progression sim (sim.go).
func printOldSim(s *SimResult) {
fmt.Println("\nsim")
for _, t := range s.Tools {
fmt.Printf(" %-38s afford %8s bought %8s ×%d\n", t.Name, t.AffordAt, t.BoughtAt, t.Count)
}
for _, sn := range s.Snapshots {
fmt.Printf(" %s credits %10.0f earned %10.0f rate %9.0f/h tools %3d moons %3d (+%d) pressure %s\n",
sn.At, sn.Credits, sn.Earned, sn.RatePerHour, sn.Tools, sn.Moons, sn.MoonsBought, sn.PressureText())
}
}
+1 -1
View File
@@ -194,7 +194,7 @@ func (s *State) toolBonus(toolID string) Bonus {
// drill is what a stake of the tool on a well of the resource would take and return now.
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
return drillStats(item.CompletionTimeInSecs, item.ToolYield(t.Tier), s.toolBonus(t.ID),
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID),
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
}
+75 -38
View File
@@ -13,7 +13,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
@@ -27,17 +27,8 @@ type GameConfig struct {
StartingCredits float64 `json:"startingCredits"`
StartingResources float64 `json:"startingResources"` // of each resource
StartingTools []int `json:"startingTools"` // store item ids, given free
MoonPrice float64 `json:"moonPrice"`
// Each moon a player owns multiplies the price of their next one (1 or unset: flat).
MoonPriceGrowth float64 `json:"moonPriceGrowth,omitempty"`
// Likewise for the supply of its wells: later moons are bigger, so big drills fit.
WellSupplyGrowth float64 `json:"wellSupplyGrowth,omitempty"`
// Each well of a new moon has a random supply in [wellSupplyMin, wellSupplyMax)
// (rollWells in game.go).
WellSupplyMin float64 `json:"wellSupplyMin"`
WellSupplyMax float64 `json:"wellSupplyMax"`
// How many wells a new moon has: weight by count, e.g. {"2": 30, "5": 8}.
WellCountWeights map[int]float64 `json:"wellCountWeights"`
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
MoonGrades []MoonGrade `json:"moonGrades"`
// Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"`
// Selling tools and attachments back (resale.go).
@@ -60,20 +51,60 @@ type GameConfig struct {
Advisors AdvisorConfig `json:"advisors"`
}
// moonPriceFor is the price of a player's next moon when they own `owned` moons.
func (cfg *GameConfig) moonPriceFor(owned int) float64 {
if cfg.MoonPriceGrowth <= 1 {
return cfg.MoonPrice
}
return math.Round(cfg.MoonPrice * math.Pow(cfg.MoonPriceGrowth, float64(owned)))
// MoonGrade is a moon vendor: its price and the moons it sells. Each well of its moons has
// a random supply in [wellSupplyMin, wellSupplyMax); its affinities multiply the moon
// type's, so higher grades lean to rarer resources.
type MoonGrade struct {
Grade string `json:"grade"` // "D", "C", …
Price float64 `json:"price"`
WellSupplyMin float64 `json:"wellSupplyMin"`
WellSupplyMax float64 `json:"wellSupplyMax"`
// How many wells its moons have: weight by count, e.g. {"2": 30, "5": 8}.
WellCountWeights map[int]float64 `json:"wellCountWeights"`
Affinities map[string]float64 `json:"affinities,omitempty"`
}
// wellScale multiplies the well supply of a player's next moon when they own `owned` moons.
func (cfg *GameConfig) wellScale(owned int) float64 {
if cfg.WellSupplyGrowth <= 1 {
return 1
// grade is the moon grade with that name, else the first (lowest) one.
func (cfg *GameConfig) grade(name string) *MoonGrade {
if i := cfg.gradeIndex(name); i >= 0 {
return &cfg.MoonGrades[i]
}
return math.Pow(cfg.WellSupplyGrowth, float64(owned))
return &cfg.MoonGrades[0]
}
// gradeIndex is the position of the grade in moonGrades (0: lowest), -1 if unknown.
func (cfg *GameConfig) gradeIndex(name string) int {
for i := range cfg.MoonGrades {
if cfg.MoonGrades[i].Grade == name {
return i
}
}
return -1
}
// MeanSupply is the average supply of one of its wells.
func (g *MoonGrade) MeanSupply() float64 { return (g.WellSupplyMin + g.WellSupplyMax) / 2 }
// WellRange is how many wells its moons can have, e.g. "2–5".
func (g *MoonGrade) WellRange() string {
lo, hi := g.wellCountRange()
if lo == hi {
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
}
// wellCountRange is the fewest and most wells its moons can have.
func (g *MoonGrade) wellCountRange() (lo, hi int) {
for n, w := range g.WellCountWeights {
if w > 0 {
if lo == 0 || n < lo {
lo = n
}
hi = max(hi, n)
}
}
return lo, hi
}
// MoonType is a kind of moon players can buy; a bought moon gets its name. Its look is
@@ -105,12 +136,8 @@ func (cfg *GameConfig) validate() error {
if len(cfg.Moons) == 0 {
return fmt.Errorf("no moons")
}
if cfg.WellSupplyMin > cfg.WellSupplyMax {
return fmt.Errorf("wellSupplyMin is above wellSupplyMax")
}
for name, v := range map[string]float64{"startingCredits": cfg.StartingCredits,
"startingResources": cfg.StartingResources, "moonPrice": cfg.MoonPrice,
"wellSupplyMin": cfg.WellSupplyMin} {
"startingResources": cfg.StartingResources} {
if v < 0 {
return fmt.Errorf("%s is negative", name)
}
@@ -121,12 +148,6 @@ func (cfg *GameConfig) validate() error {
if err := cfg.MoonNames.validate(); err != nil {
return err
}
if cfg.MoonPriceGrowth != 0 && cfg.MoonPriceGrowth < 1 {
return fmt.Errorf("moonPriceGrowth must be at least 1")
}
if cfg.WellSupplyGrowth != 0 && cfg.WellSupplyGrowth < 1 {
return fmt.Errorf("wellSupplyGrowth must be at least 1")
}
if cfg.MarketVolatility < 0 || cfg.MarketVolatility >= 1 {
return fmt.Errorf("marketVolatility must be at least 0 and below 1")
}
@@ -211,18 +232,34 @@ func (n MoonNames) validate() error {
}
func (cfg *GameConfig) validateWells() error {
if len(cfg.MoonGrades) == 0 {
return fmt.Errorf("moonGrades: give at least one grade")
}
for i, g := range cfg.MoonGrades {
if g.Grade == "" || cfg.gradeIndex(g.Grade) != i {
return fmt.Errorf("moonGrades: grade names must be set and unique (%q)", g.Grade)
}
if g.Price < 0 || g.WellSupplyMin < 0 || g.WellSupplyMin > g.WellSupplyMax {
return fmt.Errorf("moon grade %s: price and supply can't be negative, wellSupplyMin can't be above wellSupplyMax", g.Grade)
}
total := 0.0
for n, w := range cfg.WellCountWeights {
for n, w := range g.WellCountWeights {
if n < 1 || n > len(cfg.ResourcePrices) {
return fmt.Errorf("wellCountWeights: a moon can have 1 to %d wells, not %d", len(cfg.ResourcePrices), n)
return fmt.Errorf("moon grade %s: a moon can have 1 to %d wells, not %d", g.Grade, len(cfg.ResourcePrices), n)
}
if w < 0 {
return fmt.Errorf("wellCountWeights: the weight for %d wells is negative", n)
return fmt.Errorf("moon grade %s: the weight for %d wells is negative", g.Grade, n)
}
total += w
}
if total <= 0 {
return fmt.Errorf("wellCountWeights: give at least one well count a weight")
return fmt.Errorf("moon grade %s: give at least one well count a weight", g.Grade)
}
for name, w := range g.Affinities {
if _, ok := cfg.ResourcePrices[name]; !ok || w <= 0 {
return fmt.Errorf("moon grade %s: affinity for %q must be a resource with a positive weight", g.Grade, name)
}
}
}
for _, m := range cfg.Moons {
for name, w := range m.Affinities {
+95 -87
View File
@@ -1,23 +1,29 @@
{
"startingCredits": 2000,
"startingCredits": 1500,
"startingResources": 0,
"startingTools": [1],
"moonPrice": 400,
"moonPriceGrowth": 1,
"wellSupplyMin": 30,
"wellSupplyMax": 90,
"wellSupplyGrowth": 1,
"wellCountWeights": {"2": 30, "3": 35, "4": 27, "5": 8},
"moonResale": {"factor": 0.1, "maxRatio": 0.9, "minHoldSecs": 600},
"moonGrades": [
{"grade": "D", "price": 150, "wellSupplyMin": 16, "wellSupplyMax": 32, "wellCountWeights": {"1": 40, "2": 60},
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 1500, "wellSupplyMin": 75, "wellSupplyMax": 150, "wellCountWeights": {"2": 50, "3": 50},
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
{"grade": "B", "price": 15000, "wellSupplyMin": 360, "wellSupplyMax": 960, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
{"grade": "A", "price": 150000, "wellSupplyMin": 2400, "wellSupplyMax": 6300, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
{"grade": "S", "price": 1500000, "wellSupplyMin": 20600, "wellSupplyMax": 50600, "wellCountWeights": {"4": 50, "5": 50},
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
"equipmentResale": {"factor": 0.5},
"marketVolatility": 0.15,
"marketPressure": {"floor": 0.85, "scale": 50000, "halfLifeSecs": 300},
"marketVolatility": 0.25,
"marketPressure": {"floor": 0.6, "scale": 5000000, "halfLifeSecs": 300},
"resourcePrices": {
"Azurium": 6.5,
"Nebulance": 11,
"Novafor": 8.5,
"Shadowstone": 4.5,
"Sollux": 3
"Azurium": 11,
"Nebulance": 26,
"Novafor": 16,
"Shadowstone": 6,
"Sollux": 4
},
"storeItems": [
{
@@ -25,15 +31,15 @@
"name": "Prospector Drone PD-1",
"description": "A hovering scout drone that skims ore off the surface.",
"image": "shovel.png",
"price": 1500,
"claimAmount": 2,
"completionTimeInSecs": 4,
"price": 1200,
"claimAmount": 4,
"completionTimeInSecs": 12,
"upgrades": [
{"tier": 1, "price": 850, "claimBoost": 1},
{"tier": 2, "price": 900, "claimBoost": 2},
{"tier": 3, "price": 950, "claimBoost": 3, "parts": {"Dusk Alloy": 1}},
{"tier": 4, "price": 1000, "claimBoost": 4, "parts": {"Dusk Alloy": 2}},
{"tier": 5, "price": 1050, "claimBoost": 5, "parts": {"Cryo Cell": 1}}
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 80, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 1}},
{"tier": 4, "price": 110, "claimBoost": 0, "speed": 1.6, "parts": {"Dusk Alloy": 1}},
{"tier": 5, "price": 120, "claimBoost": 0, "speed": 1.8, "parts": {"Dusk Alloy": 2}}
]
},
{
@@ -41,15 +47,15 @@
"name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png",
"price": 2400,
"claimAmount": 4,
"completionTimeInSecs": 5,
"price": 5700,
"claimAmount": 20,
"completionTimeInSecs": 12,
"upgrades": [
{"tier": 1, "price": 650, "claimBoost": 1},
{"tier": 2, "price": 700, "claimBoost": 2},
{"tier": 3, "price": 750, "claimBoost": 3, "parts": {"Dusk Alloy": 2}},
{"tier": 4, "price": 800, "claimBoost": 4, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 850, "claimBoost": 5, "parts": {"Plasma Weave": 1}}
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 500, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 2}},
{"tier": 4, "price": 610, "claimBoost": 0, "speed": 1.6, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 780, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 1}}
]
},
{
@@ -57,15 +63,15 @@
"name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg",
"price": 4800,
"claimAmount": 16,
"completionTimeInSecs": 10,
"price": 27000,
"claimAmount": 110,
"completionTimeInSecs": 13,
"upgrades": [
{"tier": 1, "price": 650, "claimBoost": 2},
{"tier": 2, "price": 1100, "claimBoost": 5},
{"tier": 3, "price": 1100, "claimBoost": 8, "parts": {"Cryo Cell": 1}},
{"tier": 4, "price": 1550, "claimBoost": 12, "parts": {"Plasma Weave": 2}},
{"tier": 5, "price": 1600, "claimBoost": 16, "parts": {"Plasma Weave": 3}}
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 2900, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 1}},
{"tier": 4, "price": 3400, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 2}},
{"tier": 5, "price": 4300, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 3}}
]
},
{
@@ -73,15 +79,16 @@
"name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png",
"price": 9100,
"claimAmount": 70,
"completionTimeInSecs": 20,
"price": 290000,
"claimAmount": 1350,
"completionTimeInSecs": 14,
"reach": 2,
"upgrades": [
{"tier": 1, "price": 1450, "claimBoost": 10},
{"tier": 2, "price": 1700, "claimBoost": 21, "reach": 2},
{"tier": 3, "price": 2200, "claimBoost": 35, "parts": {"Cryo Cell": 2}},
{"tier": 4, "price": 2900, "claimBoost": 52, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 3200, "claimBoost": 70, "parts": {"Void Core": 1}, "reach": 3}
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 23000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 28000, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 2}},
{"tier": 4, "price": 34000, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 42000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 1}}
]
},
{
@@ -89,16 +96,16 @@
"name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg",
"price": 18000,
"claimAmount": 110,
"completionTimeInSecs": 16,
"price": 3200000,
"claimAmount": 17000,
"completionTimeInSecs": 15,
"reach": 3,
"upgrades": [
{"tier": 1, "price": 2050, "claimBoost": 12},
{"tier": 2, "price": 2250, "claimBoost": 27},
{"tier": 3, "price": 2250, "claimBoost": 44, "parts": {"Plasma Weave": 3}},
{"tier": 4, "price": 2300, "claimBoost": 64, "parts": {"Void Core": 1}},
{"tier": 5, "price": 2500, "claimBoost": 90, "parts": {"Stellar Lattice": 1}}
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 230000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 280000, "claimBoost": 0, "speed": 1.4, "parts": {"Plasma Weave": 3}},
{"tier": 4, "price": 740000, "claimBoost": 0, "speed": 1.6, "parts": {"Void Core": 1}},
{"tier": 5, "price": 510000, "claimBoost": 0, "speed": 1.8, "parts": {"Stellar Lattice": 1}}
]
},
{
@@ -106,16 +113,16 @@
"name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png",
"price": 36000,
"claimAmount": 120,
"completionTimeInSecs": 9,
"reach": 4,
"price": 7400000,
"claimAmount": 36000,
"completionTimeInSecs": 13,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 3900, "claimBoost": 14},
{"tier": 2, "price": 4200, "claimBoost": 30},
{"tier": 3, "price": 4300, "claimBoost": 47, "parts": {"Void Core": 1}},
{"tier": 4, "price": 4600, "claimBoost": 70, "parts": {"Stellar Lattice": 1}},
{"tier": 5, "price": 4600, "claimBoost": 100, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1200000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 820000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 1}},
{"tier": 4, "price": 1000000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 1}},
{"tier": 5, "price": 1300000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]
},
{
@@ -123,16 +130,16 @@
"name": "Lunar Core Extractor LCE-360",
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
"image": "lunarcoreextractor.png",
"price": 61000,
"claimAmount": 130,
"completionTimeInSecs": 5,
"price": 11000000,
"claimAmount": 47000,
"completionTimeInSecs": 11,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 5950, "claimBoost": 15},
{"tier": 2, "price": 6450, "claimBoost": 31},
{"tier": 3, "price": 6450, "claimBoost": 50, "parts": {"Void Core": 2}},
{"tier": 4, "price": 6900, "claimBoost": 77, "parts": {"Stellar Lattice": 2}},
{"tier": 5, "price": 6900, "claimBoost": 110, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1000000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 1300000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 2}},
{"tier": 4, "price": 1600000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 2}},
{"tier": 5, "price": 2200000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]
}
],
@@ -190,20 +197,21 @@
"slotPrices": [1500, 4000, 10000],
"maxBatch": 25,
"bulkExponent": 0.6,
"unlockGrade": "B",
"items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 30, "seconds": 1,
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 40, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 63, "seconds": 2,
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 113, "seconds": 2,
"inputs": {"Azurium": 3, "Novafor": 3},
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 70, "seconds": 3,
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 157, "seconds": 3,
"inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 320, "seconds": 5,
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 580, "seconds": 5,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 310, "seconds": 6,
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 630, "seconds": 6,
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
]
@@ -212,28 +220,28 @@
"attachments": {
"slotsPerTool": 2,
"items": [
{"name": "Turbo Core", "image": "turbo-core.png", "price": 3000, "speed": 1.2,
{"name": "Turbo Core", "image": "turbo-core.png", "price": 900, "speed": 1.2,
"description": "An overdriven motor core. Runs hot, cycles fast."},
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 4000, "yield": 1.15,
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 950, "yield": 1.15,
"description": "A synthetic-diamond cutting face that keeps more ore in every bite."},
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 25000, "speed": 1.35,
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 85000, "speed": 1.35,
"description": "Wraps the spindle in coolant so the drill never stops to cool down."},
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 30000, "yield": 1.3,
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 90000, "yield": 1.3,
"description": "Ground radar that steers the head into the richest part of the seam."}
]
},
"advisors": {
"seatPrices": [10000, 40000, 120000],
"seatPrices": [20000, 400000, 8000000],
"items": [
{"name": "Ada Voss", "image": "ada-voss.png", "title": "Shift Foreman", "price": 8000, "speed": 1.1,
{"name": "Ada Voss", "image": "ada-voss.png", "title": "Shift Foreman", "price": 2500, "speed": 1.1,
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch."},
{"name": "Rook Halvorsen", "image": "rook-halvorsen.png", "title": "Field Geologist", "price": 9000, "yield": 1.1,
{"name": "Rook Halvorsen", "image": "rook-halvorsen.png", "title": "Field Geologist", "price": 3000, "yield": 1.1,
"description": "Reads core samples like a book and knows where the ore runs thick."},
{"name": "Mira Castellanos", "image": "mira-castellanos.png", "title": "Commodities Broker", "price": 12000, "sell": 1.08,
{"name": "Mira Castellanos", "image": "mira-castellanos.png", "title": "Commodities Broker", "price": 4000, "sell": 1.08,
"description": "Has a buyer for everything and never takes the first offer."},
{"name": "Tobias Kel", "image": "tobias-kel.png", "title": "Refinery Chief", "price": 5000, "refine": 1.25,
{"name": "Tobias Kel", "image": "tobias-kel.png", "title": "Refinery Chief", "price": 2000, "refine": 1.25,
"description": "Keeps the furnaces at temperature and the batches moving."},
{"name": "Juno Park", "image": "juno-park.png", "title": "Operations Director", "price": 30000, "speed": 1.05, "yield": 1.05,
{"name": "Juno Park", "image": "juno-park.png", "title": "Operations Director", "price": 10000, "speed": 1.05, "yield": 1.05,
"description": "Sees the whole operation at once and trims waste wherever she looks."}
]
}
+167 -38
View File
@@ -5,15 +5,19 @@ package main
// `check (balance >= 0)` constraints in tables.sql are the safety net underneath.
import (
"cmp"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"maps"
"math"
mrand "math/rand/v2"
"path"
"slices"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -28,6 +32,9 @@ type Upgrade struct {
Parts map[string]float64 `json:"parts,omitempty"`
// Wells mined per cycle from this tier on (see StoreItem.Reach); 0 keeps the tool's.
Reach int `json:"reach,omitempty"`
// Cycle speed multiplier at this tier (1.25: cycles take 1/1.25 as long); 0 or 1: none.
// Like claimBoost it is the tier's total, not added to the tiers below. Read from cfg().
Speed float64 `json:"speed,omitempty"`
}
type StoreItem struct {
@@ -67,13 +74,14 @@ type Moon struct {
ID string
Name string
Kind string // its moon type (config moons); for older moons, also its name
Grade string // its moon grade (config moonGrades)
Description string
Wells []Well
CreatedAt time.Time // bought at (resale.go: minimum hold)
Rating *Rating // how good it was when bought (rating.go); nil for older moons
// A moon is finished (a past moon) when its wells are empty and nothing on it is
// running or unclaimed. It still counts as owned (moon price, well scale).
// running or unclaimed. It still counts as owned (moon names).
Finished bool
FinishedAt time.Time // its last claim
Mined []ResourceBalance // claimed from it, by resource
@@ -148,8 +156,20 @@ type State struct {
Onboarding []string
}
// MoonPrice is what the player's next moon costs.
func (s *State) MoonPrice() float64 { return cfg().moonPriceFor(len(s.Moons)) }
// MoonGrade is the grade a buy without one gets: the grade of the player's newest moon,
// else the lowest.
func (s *State) MoonGrade() *MoonGrade {
if n := len(s.Moons); n > 0 {
return cfg().grade(s.Moons[n-1].Grade)
}
return &cfg().MoonGrades[0]
}
// MoonPrice is what the player's next moon costs (at the default grade).
func (s *State) MoonPrice() float64 { return s.MoonGrade().Price }
// GradeConfig is the config of the moon's grade.
func (m *Moon) GradeConfig() *MoonGrade { return cfg().grade(m.Grade) }
func (s *State) StoreItem(id int) *StoreItem {
for i := range s.Config.StoreItems {
@@ -179,6 +199,15 @@ func (item *StoreItem) ToolYield(tier int) float64 {
return item.ClaimAmount
}
// ToolSecs is a stake's cycle at the given tier, before bonuses: the tool's cycle over the
// tier's speed.
func (item *StoreItem) ToolSecs(tier int) int {
if u := item.upgrade(tier); u != nil && u.Speed > 1 {
return max(1, int(math.Round(float64(item.CompletionTimeInSecs)/u.Speed)))
}
return item.CompletionTimeInSecs
}
// ToolReach is how many wells a stake mines at the given tier: the reach of the highest
// tier up to it that sets one, else the tool's, at least 1.
func (item *StoreItem) ToolReach(tier int) int {
@@ -191,6 +220,20 @@ func (item *StoreItem) ToolReach(tier int) int {
return max(1, reach)
}
// ImageAt is the tool's image at a tier: tier N is the image's name with "-tN" before the
// extension (drawn by scripts/tool-art.mjs), else the tier 0 image when that file is missing.
func (item *StoreItem) ImageAt(tier int) string {
if tier <= 0 {
return item.Image
}
ext := path.Ext(item.Image)
name := fmt.Sprintf("%s-t%d%s", strings.TrimSuffix(item.Image, ext), tier, ext)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil {
return item.Image
}
return name
}
func (item *StoreItem) NextUpgrade(tier int) *Upgrade {
for i := range item.Upgrades {
if item.Upgrades[i].Tier == tier+1 {
@@ -246,7 +289,7 @@ func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
}
return nil
})
// Reach isn't in the tables: it comes from the game config.
// Reach and upgrade speed aren't in the tables: they come from the game config.
for i := range cfg.StoreItems {
item := &cfg.StoreItems[i]
if c := configItem(item.ID); c != nil {
@@ -254,6 +297,7 @@ func loadConfig(ctx context.Context, db *pgxpool.Pool) (Config, error) {
for j := range item.Upgrades {
if up := c.upgrade(item.Upgrades[j].Tier); up != nil {
item.Upgrades[j].Reach = up.Reach
item.Upgrades[j].Speed = up.Speed
}
}
}
@@ -325,7 +369,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
}
rows, err = db.Query(ctx, `
select id, name, kind, description, coalesce(created_at, 'epoch'), rating from staking_source
select id, name, kind, grade, description, coalesce(created_at, 'epoch'), rating from staking_source
where user_id = $1 order by created_at, id`, userID)
if err != nil {
return nil, err
@@ -333,7 +377,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
s.Moons, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (Moon, error) {
var m Moon
var rating *int
err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Description, &m.CreatedAt, &rating)
err := row.Scan(&m.ID, &m.Name, &m.Kind, &m.Grade, &m.Description, &m.CreatedAt, &rating)
if rating != nil {
r := ratingOf(*rating)
m.Rating = &r
@@ -350,7 +394,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
join resource on resource.id = resource_well.resource_id
join staking_source on staking_source.id = resource_well.source_id
where staking_source.user_id = $1
order by resource.name`, userID)
order by resource.name, resource_well.id`, userID)
if err != nil {
return nil, err
}
@@ -388,7 +432,7 @@ func loadState(ctx context.Context, db *pgxpool.Pool, userID string) (*State, er
join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)
order by staking_event.created_at`, userID)
order by staking_event.created_at, staking_event.id`, userID)
if err != nil {
return nil, err
}
@@ -667,19 +711,22 @@ func createUser(ctx context.Context, db *pgxpool.Pool, name string) (string, err
return userID, err
}
// rollWells rolls the wells of a new moon of the given type. The count comes from
// wellCountWeights and the resources from the type's affinities (drawn without
// replacement). A player's first moon (first) always has every raw input of some recipe,
// so the refinery can open. Each well's supply is rolled on its own in
// [wellSupplyMin, wellSupplyMax) (times scale, the growth for the buyer's moon count), so
// a moon with fewer wells holds less. rnd is uniform in [0, 1).
func rollWells(c *GameConfig, kind MoonType, scale float64, first bool, rnd func() float64) []wellSupply {
// rollWells rolls the wells of a new moon of the given grade and type. The count comes from
// the grade's wellCountWeights and the resources from the type's affinities times the
// grade's (drawn without replacement). A player's first moon (first) always has every raw
// input of some recipe. Each well's supply is rolled on its own in the grade's
// [wellSupplyMin, wellSupplyMax).
func rollWells(c *GameConfig, g *MoonGrade, kind MoonType, first bool, rnd func() float64) []wellSupply {
names := sortedKeys(c.ResourcePrices)
weight := func(name string) float64 {
if w, ok := kind.Affinities[name]; ok {
return w
w := 1.0
if a, ok := kind.Affinities[name]; ok {
w = a
}
return 1
if a, ok := g.Affinities[name]; ok {
w *= a
}
return w
}
// pick returns a weighted random index into weights.
pick := func(weights []float64) int {
@@ -697,10 +744,10 @@ func rollWells(c *GameConfig, kind MoonType, scale float64, first bool, rnd func
return len(weights) - 1
}
counts := slices.Sorted(maps.Keys(c.WellCountWeights))
counts := slices.Sorted(maps.Keys(g.WellCountWeights))
var cw []float64
for _, n := range counts {
cw = append(cw, c.WellCountWeights[n])
cw = append(cw, g.WellCountWeights[n])
}
count := min(counts[pick(cw)], len(names))
@@ -740,7 +787,7 @@ func rollWells(c *GameConfig, kind MoonType, scale float64, first bool, rnd func
wells := make([]wellSupply, len(chosen))
for i, name := range chosen {
units := (c.WellSupplyMin + rnd()*(c.WellSupplyMax-c.WellSupplyMin)) * scale
units := g.WellSupplyMin + rnd()*(g.WellSupplyMax-g.WellSupplyMin)
wells[i] = wellSupply{Resource: name, Supply: math.Floor(units)}
}
return wells
@@ -771,21 +818,33 @@ func rollMoonName(n MoonNames, rnd func() float64) string {
}
// buyMoon claims a random moon; rollMoonName names it and rollWells picks its wells.
func buyMoon(ctx context.Context, db *pgxpool.Pool, userID string) (string, error) {
// grade is the moon grade's name ("D", …); "" buys the grade of the player's newest moon,
// or the lowest.
func buyMoon(ctx context.Context, db *pgxpool.Pool, userID, grade string) (string, error) {
var moonID string
if grade != "" && cfg().gradeIndex(grade) < 0 {
return "", gameErr("Unknown moon grade")
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The price grows with moons owned; locking the player's row keeps two buys from
// both paying the same price.
// Locking the player's row keeps moon names unique and the moon count right.
_, err := tx.Exec(ctx, "select 1 from users where id = $1 for no key update", userID)
if err != nil {
return err
}
var owned int
err = tx.QueryRow(ctx, "select count(*) from staking_source where user_id = $1", userID).Scan(&owned)
var last string
err = tx.QueryRow(ctx, `
select count(*), coalesce((select grade from staking_source where user_id = $1
order by created_at desc, id desc limit 1), '')
from staking_source where user_id = $1`, userID).Scan(&owned, &last)
if err != nil {
return err
}
price := cfg().moonPriceFor(owned)
if grade == "" {
grade = last
}
g := cfg().grade(grade)
price := g.Price
if err := debit(ctx, tx, userID, price); err != nil {
return err
}
@@ -809,15 +868,15 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID string) (string, erro
}
}
err = tx.QueryRow(ctx, `
insert into staking_source(user_id, name, kind, description, address)
values ($1, $2, $3, $4, $5) returning id`,
userID, name, kind.Name, kind.Description, "0x"+randomHex(8)).Scan(&moonID)
insert into staking_source(user_id, name, kind, grade, description, address)
values ($1, $2, $3, $4, $5, $6) returning id`,
userID, name, kind.Name, g.Grade, kind.Description, "0x"+randomHex(8)).Scan(&moonID)
if err != nil {
return err
}
wells := rollWells(cfg(), kind, cfg().wellScale(owned), owned == 0, mrand.Float64)
rating := moonRating(wells, cfg().wellScale(owned))
wells := rollWells(cfg(), g, kind, owned == 0, mrand.Float64)
rating := moonRating(g, wells)
for _, w := range wells {
_, err = tx.Exec(ctx, `
insert into resource_well(source_id, resource_id, supply)
@@ -828,7 +887,7 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID string) (string, erro
}
}
// Describe the moon from its look and wells (moondesc.go).
desc, err := moonDescription(ctx, tx, moonID, cfg().wellScale(owned))
desc, err := moonDescription(ctx, tx, moonID, g.MeanSupply())
if err != nil {
return err
}
@@ -837,7 +896,10 @@ func buyMoon(ctx context.Context, db *pgxpool.Pool, userID string) (string, erro
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_moon", map[string]any{"moon": moonID, "price": price, "rating": rating})
if err := unlockRefineryAt(ctx, tx, userID, g.Grade); err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_moon", map[string]any{"moon": moonID, "price": price, "rating": rating, "grade": g.Grade})
})
return moonID, err
}
@@ -983,6 +1045,42 @@ func pickTool(ctx context.Context, tx pgx.Tx, userID, toolID string, anyTier boo
var errWellEmpty = gameErr("That well is tapped out")
// errWellCommitted: the drills already on the well will take everything it has left.
var errWellCommitted = gameErr("Nothing left to mine on this well")
// moonInTx loads a moon's wells (in loadState's order) and its unclaimed stakes (oldest
// first, as loadState), for the projection (State.project).
func moonInTx(ctx context.Context, tx pgx.Tx, userID, moonID string) (*Moon, []Stake, error) {
m := &Moon{ID: moonID}
rows, err := tx.Query(ctx, `
select resource_well.id, resource.name, resource_well.supply
from resource_well join resource on resource.id = resource_well.resource_id
where resource_well.source_id = $1
order by resource.name, resource_well.id`, moonID)
if err != nil {
return nil, nil, err
}
m.Wells, err = pgx.CollectRows(rows, pgx.RowToStructByPos[Well])
if err != nil {
return nil, nil, err
}
rows, err = tx.Query(ctx, `
select staking_event.id, resource_well.source_id, well_id, inventory_item_id,
resource.name, stake_amount, staking_event.created_at, duration_in_secs,
staking_event.reach
from staking_event
join resource_well on resource_well.id = well_id
join resource on resource.id = resource_well.resource_id
where staking_event.user_id = $1 and resource_well.source_id = $2
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)
order by staking_event.created_at, staking_event.id`, userID, moonID)
if err != nil {
return nil, nil, err
}
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[Stake])
return m, stakes, err
}
func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) error {
if !uuidPattern.MatchString(toolID) {
return gameErr("Tool not found")
@@ -990,9 +1088,20 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if !uuidPattern.MatchString(wellID) {
return gameErr("Well not found")
}
// Lock the moon's wells first, in id order (as claims do): what its drills will take
// decides whether there is room for this one.
_, err := tx.Exec(ctx, `
select 1 from resource_well where source_id = (
select resource_well.source_id from resource_well
join staking_source on staking_source.id = resource_well.source_id
where resource_well.id = $1 and staking_source.user_id = $2)
order by id for update`, wellID, userID)
if err != nil {
return err
}
var duration, storeItemID, tier int
var amount float64
err := tx.QueryRow(ctx, `
err = tx.QueryRow(ctx, `
select store_item.completion_time_in_secs,
store_item.claim_amount + coalesce(upgrade_item.claim_boost, 0),
store_item.id, inventory_item.tier
@@ -1068,8 +1177,21 @@ func stakeInTx(ctx context.Context, tx pgx.Tx, userID, toolID, wellID string) er
if err != nil {
return err
}
if item := configItem(storeItemID); item != nil {
duration = item.ToolSecs(tier)
}
duration, amount = drillStats(duration, amount, bonus, speed, yield)
// The drills already on the well (and multi-well ones reaching it) will take all it
// has left: this one would get nothing.
m, stakes, err := moonInTx(ctx, tx, userID, moonID)
if err != nil {
return err
}
if newStakeTake(m, stakes, wellID, amount, reach) <= 1e-9 {
return errWellCommitted
}
_, err = tx.Exec(ctx, `
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach)
values ($1, $2, $3, $4, $5, $6)`, userID, wellID, toolID, duration, amount, reach)
@@ -1134,7 +1256,7 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
select staking_event.id, staking_event.inventory_item_id, staking_event.well_id,
resource_well.source_id`+inScope+`
and resource_well.source_id::text = any($5)
order by staking_event.created_at
order by staking_event.created_at, staking_event.id
for update of staking_event skip locked`,
userID, scope.MoonID, scope.WellID, scope.Wide, moonIDs)
if err != nil {
@@ -1170,7 +1292,7 @@ func claimReady(ctx context.Context, db *pgxpool.Pool, userID string, scope Clai
switch err := stakeInTx(ctx, tx, userID, st.ToolID, st.WellID); err {
case nil:
res.Redeployed++
case errWellEmpty:
case errWellEmpty, errWellCommitted:
default:
return err
}
@@ -1226,12 +1348,17 @@ func redrill(ctx context.Context, db *pgxpool.Pool, userID, wellID string) (int,
if len(tools) == 0 {
return gameErr("No idle drills to redrill here")
}
n = 0
// Until the well has no room left: the rest stay idle (none fits: the error).
for _, id := range tools {
if err := stakeInTx(ctx, tx, userID, id, wellID); err != nil {
err := stakeInTx(ctx, tx, userID, id, wellID)
if (err == errWellCommitted || err == errWellEmpty) && n > 0 {
break
} else if err != nil {
return err
}
n++
}
n = len(tools)
return logEvent(ctx, tx, userID, "redrill", map[string]any{"well": wellID, "drills": n})
})
return n, err
@@ -1332,6 +1459,8 @@ func claimInTx(ctx context.Context, tx pgx.Tx, userID, stakeID string) (float64,
if err != nil {
return 0, 0, err
}
// Ties between wells go as in the projection (State.project): loadState's order.
slices.SortStableFunc(wells, func(a, b claimWell) int { return cmp.Or(cmp.Compare(a.Resource, b.Resource), cmp.Compare(a.ID, b.ID)) })
supply := make([]float64, len(wells))
anchor := -1
for i, w := range wells {
+708
View File
@@ -0,0 +1,708 @@
package main
// The balance sim for moon grades (`moonminers sim`). Moon Miners is a clicker, so both sim
// players are active: they claim (claim all with redeploy) as soon as a drill is ready (at
// most every gsClaimGap), stake drills whose well ran dry, sell every half minute and buy, all within a
// click budget (gsClicksPerSec). The "copies" player buys only drill copies (and moons); the
// "boosts" player buys whatever adds the most ₥/minute per ₥ (upgrades, attachments,
// advisors or copies). Both buy a moon when their drills are about to run dry. Every report
// row lists the best buy of each kind with what it adds per ₥ spent, in ₥/minute and in
// ₥/click, and the summary compares the boosts with the copies.
import (
"fmt"
"math"
"math/rand/v2"
"slices"
"sort"
"strings"
)
type gsPlayer struct {
Name string
All bool // buys boosts too; else drill copies (and moons) only
}
var gsPlayers = []gsPlayer{{"copies", false}, {"boosts", true}}
// gsReportAt are the report times, in minutes.
var gsReportAt = []int{2, 5, 10, 15, 20, 30, 45, 60, 90, 120, 150, 180, 240, 300, 360, 480}
const (
gsMaxTools = 300
gsHorizon = 600 // seconds of income a player saves up for a purchase
gsClicksPerSec = 1.0 // the click budget: claims, stakes, sells and purchases
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
)
type gsTool struct {
item, tier int
mods []string
doneAt int // busy until then; -1 idle
take []gsTake
}
type gsTake struct {
moon, well int
units float64
}
type gsWell struct {
res string
left, booked float64
claims int
}
type gsMoon struct {
grade int
wells []gsWell
free float64 // ore not yet taken or booked
}
// gsGroup is the drills of one kind (store item, tier, attachments); tool is one of them.
type gsGroup struct {
item, tier int
mods []string
n, tool int
}
func gsKey(item, tier int, mods []string) string { return fmt.Sprint(item, tier, mods) }
// GSBuy is a candidate purchase: what it adds and what it costs.
type GSBuy struct {
Kind, Name string
Cost float64 // ₥
Gain, Click float64 // ₥/hour added, ₥/click added
tool int // upgrade or attachment: which tool; copy: which store item
}
func (b GSBuy) Payback() string {
if b.Gain <= 0 {
return "never"
}
return SimTime(int(b.Cost / b.Gain * 3600)).String()
}
// PerMin is the ₥/minute added per 1k₥ spent; PerClick the ₥/click added per 1k₥.
func (b GSBuy) PerMin() float64 { return b.Gain / 60 / b.Cost * 1000 }
func (b GSBuy) PerClick() float64 { return b.Click / b.Cost * 1000 }
// GSRow is one report line.
type GSRow struct {
At SimTime
Credits float64
Income float64 // ₥/hour since the last row
PerClick float64 // ₥ per click since the last row
ClickMin float64 // clicks per minute since the last row
Tools int
Grade string // of the moon bought last
Best map[string]GSBuy // by ₥/minute per ₥
BestKind string
}
// GSResult is one player's run.
type GSResult struct {
Player gsPlayer
Rows []GSRow
FirstGrade map[string]SimTime // first moon of each grade
GradeClicks map[string]int // clicks made by then
Claims map[string]float64 // average claims to drain a well, by grade (its first 20 wells)
Bought map[string]int // purchases by kind
Spent map[string]float64 // ₥ spent by kind (moons left out)
GainMin map[string]float64 // ₥/minute those purchases added, as the player saw it
GainClick map[string]float64 // ₥/click they added
Idle SimTime // the longest stretch with nothing to click
BuyGap SimTime // the longest stretch with nothing to buy (moons left out)
BuyGapAt SimTime
BuyGapPreS SimTime // the same, before the first moon of the top grade
IdleAt SimTime
}
// GradeStat is the value of a grade's moons at base prices.
type GradeStat struct {
Grade string
Price float64
Wells, Units, Unit float64 // average wells, units, value per unit
Mean, P10, P90 float64 // value / price
Drill string // the drill meant for it (suitedGrade)
ClaimsPerWell float64 // for that drill, an average well
}
func gradeStats(c *GameConfig) []GradeStat {
rng := rand.New(rand.NewPCG(7, 8))
var out []GradeStat
for gi := range c.MoonGrades {
g := &c.MoonGrades[gi]
st := GradeStat{Grade: g.Grade, Price: g.Price}
var mult []float64
const n = 5000
for i := range n {
wells := rollWells(c, g, c.Moons[i%len(c.Moons)], false, rng.Float64)
v, u := 0.0, 0.0
for _, w := range wells {
v += w.Supply * c.ResourcePrices[w.Resource]
u += w.Supply
}
st.Wells += float64(len(wells)) / n
st.Units += u / n
st.Unit += v / n
mult = append(mult, v/max(1, g.Price))
}
st.Unit /= max(1, st.Units)
slices.Sort(mult)
for _, m := range mult {
st.Mean += m / n
}
st.P10, st.P90 = mult[n/10], mult[n*9/10]
for i := range c.StoreItems {
item := &c.StoreItems[i]
if suitedGrade(c, item) == g {
if st.Drill != "" {
st.Drill += ", "
}
perWell := item.ClaimAmount / float64(item.ToolReach(0))
st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
item.CompletionTimeInSecs)
}
}
out = append(out, st)
}
return out
}
// gsEval is the ₥/hour and clicks/hour of a set of drills on moons of the grade:
// a claim (claim all) whenever a drill is ready, at most every gsClaimGap; drills of one
// kind finish together, so each distinct cycle is a claim; a stake whenever a drill's well
// (a multi-well drill: its moon) runs dry; a sell round every gsSellEvery; past the click
// budget everything slows down.
func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
st := stats[grade]
stakes := 0.0
cycles := map[int]bool{}
for _, x := range gs {
if x.n == 0 {
continue
}
it := &c.StoreItems[x.item]
b := c.bonusesFor(adv, x.mods)
secs, y := drillStats(it.ToolSecs(x.tier), it.ToolYield(x.tier), b, 1, 1)
reach := it.ToolReach(x.tier)
cycles[max(secs, gsClaimGap)] = true
p := float64(max(secs, gsClaimGap)) + 0.5 // waits half a tick on average
n := float64(x.n)
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
per := st.Units / max(1, st.Wells) / y
if reach > 1 {
per = st.Units / y
}
stakes += n * 3600 / (p * max(1, per))
}
claims := 0.0
for secs := range cycles {
claims += 3600 / float64(secs)
}
clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
if budget := 3600 * gsClicksPerSec; clk > budget {
inc *= budget / clk
clk = budget
}
return inc, clk
}
func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
rng := rand.New(rand.NewPCG(simSeed[0], simSeed[1]))
res := &GSResult{Player: pl, FirstGrade: map[string]SimTime{}, GradeClicks: map[string]int{},
Claims: map[string]float64{}, Bought: map[string]int{},
Spent: map[string]float64{}, GainMin: map[string]float64{}, GainClick: map[string]float64{}}
claimSum, claimN := map[string]float64{}, map[string]float64{}
credits := c.StartingCredits
var tools []gsTool
var moons []*gsMoon
var advisors []string
seats := 1
refinery := c.Refinery.UnlockGrade == ""
grade := 0 // of the last moon bought
pressure := map[string]float64{}
stock := map[string]float64{}
var resources []string
for name := range c.ResourcePrices {
resources = append(resources, name)
}
sort.Strings(resources)
itemIdx := map[int]int{}
for i, it := range c.StoreItems {
itemIdx[it.ID] = i
}
for _, id := range c.StartingTools {
tools = append(tools, gsTool{item: itemIdx[id], doneAt: -1})
}
capsBy := map[int]ReachCaps{}
caps := func(g int) ReachCaps {
if capsBy[g] == nil {
capsBy[g] = reachCaps(c, &c.MoonGrades[g])
}
return capsBy[g]
}
groups := func() []gsGroup {
idx := map[string]int{}
var out []gsGroup
for j, t := range tools {
k := gsKey(t.item, t.tier, t.mods)
if i, ok := idx[k]; ok {
out[i].n++
continue
}
idx[k] = len(out)
out = append(out, gsGroup{t.item, t.tier, t.mods, 1, j})
}
return out
}
// with is gs with one drill of group k (none if k < 0) turned into (item, tier, mods).
with := func(gs []gsGroup, k, item, tier int, mods []string) []gsGroup {
out := slices.Clone(gs)
if k >= 0 {
out[k].n--
}
key := gsKey(item, tier, mods)
for i := range out {
if gsKey(out[i].item, out[i].tier, out[i].mods) == key {
out[i].n++
return out
}
}
return append(out, gsGroup{item, tier, mods, 1, -1})
}
eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
partsCost := func(parts map[string]float64) (float64, bool) {
if len(parts) > 0 && !refinery {
return 0, false
}
return partsValue(c, parts), true
}
// candidates lists every purchase that adds income now.
candidates := func() []GSBuy {
gs := groups()
inc0, clk0 := eval(gs, advisors)
pc0 := inc0 / max(1, clk0)
buy := func(kind, name string, cost float64, tool int, gs2 []gsGroup, adv []string) GSBuy {
inc, clk := eval(gs2, adv)
return GSBuy{Kind: kind, Name: name, Cost: cost, Gain: inc - inc0, Click: inc/max(1, clk) - pc0, tool: tool}
}
var out []GSBuy
if len(tools) < gsMaxTools {
for i, it := range c.StoreItems {
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, 0, nil), advisors))
}
}
for k, x := range gs {
it := &c.StoreItems[x.item]
if u := it.NextUpgrade(x.tier); u != nil {
if pc, ok := partsCost(u.Parts); ok {
out = append(out, buy("upgrade", fmt.Sprintf("%s t%d", toolCode(it.Name), u.Tier), u.Price+pc,
x.tool, with(gs, k, x.item, u.Tier, x.mods), advisors))
}
}
if len(x.mods) < c.Attachments.SlotsPerTool {
for _, m := range c.Attachments.Items {
if slices.Contains(x.mods, m.Name) {
continue
}
mods := append(slices.Clone(x.mods), m.Name)
out = append(out, buy("attachment", m.Name+" on "+toolCode(it.Name), m.Price, x.tool,
with(gs, k, x.item, x.tier, mods), advisors))
}
}
}
if len(advisors) < seats || seats-1 < len(c.Advisors.SeatPrices) {
for _, a := range c.Advisors.Items {
if slices.Contains(advisors, a.Name) {
continue
}
cost := a.Price
if len(advisors) >= seats {
cost += c.Advisors.SeatPrices[seats-1]
}
out = append(out, buy("advisor", a.Name, cost, -1, gs, append(slices.Clone(advisors), a.Name)))
}
}
return out
}
// nextGrade is the moon grade the player buys: the dearest one costing at most half an
// hour of income (and affordable), else the cheapest.
nextGrade := func(income float64) int {
g := 0
for i := range c.MoonGrades {
if c.MoonGrades[i].Price <= max(income/2, c.MoonGrades[0].Price) && c.MoonGrades[i].Price <= credits {
g = i
}
}
return g
}
freeOre := 0.0
var open []int // moons with free ore
need := func() float64 { // what the drills take in two cycles
sum := 0.0
for _, t := range tools {
it := &c.StoreItems[t.item]
_, y := drillStats(it.ToolSecs(t.tier), it.ToolYield(t.tier), c.bonusesFor(advisors, t.mods), 1, 1)
sum += 2 * y
}
return sum
}
bank, clicks, lastClick := gsClickBurst, 0, 0
click := func(t int) bool {
if bank < 1 {
return false
}
bank--
clicks++
if gap := SimTime(t - lastClick); gap > res.Idle {
res.Idle, res.IdleAt = gap, SimTime(lastClick)
}
lastClick = t
return true
}
buyMoon := func(t int, g int) bool {
gr := &c.MoonGrades[g]
if credits < gr.Price || bank < 1 {
return false
}
click(t)
credits -= gr.Price
m := &gsMoon{grade: g}
for _, w := range rollWells(c, gr, c.Moons[rng.IntN(len(c.Moons))], len(moons) == 0, rng.Float64) {
m.wells = append(m.wells, gsWell{res: w.Resource, left: w.Supply})
m.free += w.Supply
}
freeOre += m.free
open = append(open, len(moons))
moons = append(moons, m)
grade = g
if _, ok := res.FirstGrade[gr.Grade]; !ok {
res.FirstGrade[gr.Grade] = SimTime(t)
res.GradeClicks[gr.Grade] = clicks
}
if c.gradeIndex(c.Refinery.UnlockGrade) <= g {
refinery = true
}
res.Bought["moon "+gr.Grade]++
return true
}
// book stakes a tool on moon mi: on well only (a one-well redeploy), else its fullest wells.
book := func(t int, tl *gsTool, mi, only int) bool {
it := &c.StoreItems[tl.item]
secs, y := drillStats(it.ToolSecs(tl.tier), it.ToolYield(tl.tier), c.bonusesFor(advisors, tl.mods), 1, 1)
m := moons[mi]
var order []int
if only >= 0 {
order = []int{only}
} else {
for i := range m.wells {
order = append(order, i)
}
slices.SortFunc(order, func(a, b int) int {
return cmpDesc(m.wells[a].left-m.wells[a].booked, m.wells[b].left-m.wells[b].booked)
})
order = order[:min(it.ToolReach(tl.tier), len(order))]
}
share := y / float64(it.ToolReach(tl.tier))
tl.take = tl.take[:0]
for _, wi := range order {
w := &m.wells[wi]
u := min(share, w.left-w.booked)
if u > 1e-9 {
w.booked += u
m.free -= u
freeOre -= u
tl.take = append(tl.take, gsTake{mi, wi, u})
}
}
if len(tl.take) == 0 {
return false
}
tl.doneAt = t + secs
return true
}
// deploy stakes an idle tool on the moon with the most free ore.
deploy := func(t int, tl *gsTool) bool {
best, bestFree := -1, 0.0
open = slices.DeleteFunc(open, func(i int) bool { return moons[i].free <= 1e-6 })
for _, i := range open {
if f := moons[i].free; f > bestFree {
best, bestFree = i, f
}
}
return best >= 0 && book(t, tl, best, -1)
}
end := gsReportAt[len(gsReportAt)-1] * 60
next, earned, lastEarned, lastAt, lastClicks := 0, 0.0, 0.0, 0, 0
lastClaim, lastSell := math.MinInt/2, 0
wantBuy := true
lastBuy := 0
for t := 0; t <= end; t++ {
bank = min(bank+gsClicksPerSec, gsClickBurst)
// Claim all (with redeploy) when a drill is ready, at most every gsClaimGap.
ready := slices.ContainsFunc(tools, func(tl gsTool) bool { return tl.doneAt >= 0 && tl.doneAt <= t })
if ready && t-lastClaim >= gsClaimGap && click(t) {
lastClaim = t
for i := range tools {
tl := &tools[i]
if tl.doneAt < 0 || tl.doneAt > t {
continue
}
prev := slices.Clone(tl.take)
for _, tk := range tl.take {
w := &moons[tk.moon].wells[tk.well]
w.booked -= tk.units
w.left -= tk.units
w.claims++
stock[w.res] += tk.units
if gr := c.MoonGrades[moons[tk.moon].grade].Grade; w.left <= 1e-9 && claimN[gr] < 20 {
claimSum[gr] += float64(w.claims)
claimN[gr]++
}
}
tl.doneAt, tl.take = -1, nil
// Redeploy: the same well for a one-well drill, the same moon for the others.
if len(prev) > 0 {
only := -1
if c.StoreItems[tl.item].ToolReach(tl.tier) == 1 {
only = prev[0].well
}
book(t, tl, prev[0].moon, only)
}
}
}
// Stake idle drills, a click each.
for i := range tools {
if tools[i].doneAt < 0 && bank >= 1 && deploy(t, &tools[i]) {
click(t)
}
}
// Sell every gsSellEvery seconds, a click per resource.
for name := range pressure {
pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
}
if t-lastSell >= gsSellEvery {
sell := c.bonusesFor(advisors, nil).Sell
tick := int64(t / 300)
left := false
for _, name := range resources {
units := stock[name]
if units <= 0 {
continue
}
if !click(t) {
left = true
break
}
base := c.ResourcePrices[name]
quote := base * (1 + c.MarketVolatility*marketDrift(name, tick))
d := units * base
pay := units * quote * sell * c.MarketPressure.Average(pressure[name], d)
pressure[name] = min(pressure[name]+d, pressureCap*c.MarketPressure.Scale)
credits += pay
earned += pay
stock[name] = 0
wantBuy = true
}
if !left {
lastSell = t
}
}
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
if !wantBuy && !report && freeOre >= need() {
continue
}
// Buy: a moon when the drills are about to run dry, then the best buy per ₥ (saving
// for it if it's out of reach, keeping enough for the next moon).
income, _ := eval(groups(), advisors)
for range 4 {
if freeOre >= need() || !buyMoon(t, nextGrade(income)) {
break
}
}
cands := candidates()
// Something to buy: a purchase that adds income and that the player can pay now.
if slices.ContainsFunc(cands, func(x GSBuy) bool { return x.Gain > 0 && x.Cost <= credits && (pl.All || x.Kind == "copy") }) {
if gap := SimTime(t - lastBuy); gap > res.BuyGap {
res.BuyGap, res.BuyGapAt = gap, SimTime(lastBuy)
}
if _, top := res.FirstGrade[c.MoonGrades[len(c.MoonGrades)-1].Grade]; !top {
res.BuyGapPreS = max(res.BuyGapPreS, SimTime(t-lastBuy))
}
lastBuy = t
}
best := func(kind string, all bool) GSBuy {
var b GSBuy
reach := max(credits+income*gsHorizon/3600, c.StoreItems[0].Price)
for _, x := range cands {
if x.Cost > reach || (!all && x.Kind != "copy") {
continue
}
if (kind == "" || x.Kind == kind) && x.Gain > 0 && (b.Kind == "" || x.Gain/x.Cost > b.Gain/b.Cost) {
b = x
}
}
return b
}
if report {
dt := float64(max(1, t-lastAt))
row := GSRow{At: SimTime(t), Credits: credits, Tools: len(tools), Grade: c.MoonGrades[grade].Grade,
Income: (earned - lastEarned) * 3600 / dt, PerClick: (earned - lastEarned) / float64(max(1, clicks-lastClicks)),
ClickMin: float64(clicks-lastClicks) * 60 / dt, Best: map[string]GSBuy{}, BestKind: best("", pl.All).Kind}
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
row.Best[k] = best(k, true)
}
res.Rows = append(res.Rows, row)
lastEarned, lastAt, lastClicks = earned, t, clicks
next++
}
wantBuy = false
for range 50 {
b := best("", pl.All)
// Keep enough for the next moon, unless the drills have plenty of ore.
reserve := 0.0
if freeOre < 20*need() {
reserve = c.MoonGrades[nextGrade(income)].Price
}
if b.Kind == "" || credits-b.Cost < reserve {
break
}
cost := 1.0
if b.Kind == "attachment" || (b.Kind == "advisor" && len(advisors) >= seats) {
cost = 2 // buy and equip, buy a seat and hire
}
if bank < cost {
wantBuy = true
break
}
for range int(cost) {
click(t)
}
credits -= b.Cost
res.Bought[b.Kind]++
res.Spent[b.Kind] += b.Cost
res.GainMin[b.Kind] += b.Gain / 60
res.GainClick[b.Kind] += b.Click
switch b.Kind {
case "copy":
tools = append(tools, gsTool{item: b.tool, doneAt: -1})
case "upgrade":
tools[b.tool].tier++
case "attachment":
name := b.Name[:strings.Index(b.Name, " on ")]
tools[b.tool].mods = append(slices.Clone(tools[b.tool].mods), name)
case "advisor":
if len(advisors) >= seats {
seats++
}
advisors = append(advisors, b.Name)
}
cands = candidates()
}
}
for gr, n := range claimN {
res.Claims[gr] = claimSum[gr] / n
}
return res
}
// printGradeSim prints the grade table and both players' runs.
func printGradeSim(c *GameConfig) {
fmt.Printf("%-5s %7s %5s %6s %5s %6s %6s %6s %s\n", "grade", "price", "wells", "units", "₥/u", "value×", "p10×", "p90×", "drills meant for it (claims per average well, cycle)")
stats := gradeStats(c)
for _, g := range stats {
fmt.Printf("%-5s %7.0f %5.1f %6.0f %5.1f %6.2f %6.2f %6.2f %s\n", g.Grade, g.Price, g.Wells, g.Units, g.Unit, g.Mean, g.P10, g.P90, g.Drill)
}
printBoostTable(c, stats)
fmt.Printf("\nclick budget %.0f/s; per kind: ₥/min and ₥/click added per 1k₥ spent\n", gsClicksPerSec)
for _, pl := range gsPlayers {
r := simulatePlayer(c, pl, stats)
fmt.Printf("\n%s player\n", pl.Name)
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-26s %-34s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
"best copy (/min, /clk)", "best upgrade", "best attachment", "best advisor", "buys")
cell := func(b GSBuy) string {
if b.Kind == "" {
return "-"
}
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
}
for _, row := range r.Rows {
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-26s %-34s %-28s %s\n", row.At, row.Credits, row.Income,
row.PerClick, row.ClickMin, row.Tools, row.Grade, cell(row.Best["copy"]), cell(row.Best["upgrade"]),
cell(row.Best["attachment"]), cell(row.Best["advisor"]), row.BestKind)
}
fmt.Print("first moon of each grade (clicks by then):")
for _, g := range c.MoonGrades {
if at, ok := r.FirstGrade[g.Grade]; ok {
fmt.Printf(" %s %s (%d)", g.Grade, at, r.GradeClicks[g.Grade])
}
}
fmt.Print("\nclaims to drain a well:")
for _, g := range c.MoonGrades {
if n, ok := r.Claims[g.Grade]; ok {
fmt.Printf(" %s %.1f", g.Grade, n)
}
}
fmt.Printf("\nlongest stretch with nothing to click: %s (from %s); with nothing to buy: %s (from %s), before the top grade %s\nbought: %v\n",
r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):")
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
if sp := r.Spent[k]; sp > 0 {
fmt.Printf(" %s %d %s %.3g %.3g;", k, r.Bought[k], formatCompact(sp), r.GainMin[k]/sp*1000, r.GainClick[k]/sp*1000)
}
}
fmt.Println()
}
}
// printBoostTable compares, for each drill alone on the grade it's meant for, a second copy
// with its boosts: what each adds per 1k₥, in ₥/minute and ₥/click; boosts as a ratio to
// the copy.
func printBoostTable(c *GameConfig, stats []GradeStat) {
capsBy := map[int]ReachCaps{}
caps := func(g int) ReachCaps {
if capsBy[g] == nil {
capsBy[g] = reachCaps(c, &c.MoonGrades[g])
}
return capsBy[g]
}
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), boosts ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %13s", "drill", "gr", "copy", "upgrades t1-5")
for _, m := range c.Attachments.Items {
fmt.Printf(" %13s", m.Name)
}
fmt.Println()
for i := range c.StoreItems {
it := &c.StoreItems[i]
g := c.gradeIndex(suitedGrade(c, it).Grade)
base := []gsGroup{{i, 0, nil, 1, 0}}
inc0, clk0 := gsEval(c, stats, caps, g, base, nil)
per := func(gs []gsGroup, cost float64) (float64, float64) {
inc, clk := gsEval(c, stats, caps, g, gs, nil)
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000
}
cm, cc := per([]gsGroup{{i, 0, nil, 2, 0}}, it.Price)
fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
cost, top := 0.0, 0
for _, u := range it.Upgrades {
cost += u.Price + partsValue(c, u.Parts)
top = max(top, u.Tier)
}
if top > 0 {
m, k := per([]gsGroup{{i, top, nil, 1, 0}}, cost)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
for _, a := range c.Attachments.Items {
m, k := per([]gsGroup{{i, 0, []string{a.Name}, 1, 0}}, a.Price)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
fmt.Println()
}
}
+19 -17
View File
@@ -268,8 +268,8 @@ 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
// (times wellScale, the growth for the buyer's moon count). Thresholds were checked by eye in the moon lab (/utils/moons).
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, wellScale float64) moonTraits {
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the moon lab (/utils/moons).
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, meanWell float64) moonTraits {
var t moonTraits
hsl := rgbToHSL(hexToRGB(r.Mid))
@@ -348,19 +348,18 @@ func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, wellScale f
}
}
t.Richest = best.Resource
t.Wealth = moonWealth(wells, wellScale)
t.Wealth = moonWealth(wells, meanWell)
return t
}
// moonWealth is rich, modest or sparse: the wells' supply against the expected supply of
// as many new wells (times wellScale).
func moonWealth(wells []wellSupply, wellScale float64) string {
c := cfg()
// as many new wells of its grade (meanWell: the grade's average well).
func moonWealth(wells []wellSupply, meanWell float64) string {
total := 0.0
for _, w := range wells {
total += w.Supply
}
expected := float64(len(wells)) * (c.WellSupplyMin + c.WellSupplyMax) / 2 * wellScale
expected := float64(len(wells)) * meanWell
switch {
case expected <= 0 || total > expected*1.12:
return "rich"
@@ -508,9 +507,9 @@ func capitalize(s string) string {
// ---------------------------------------------------------------- database
// moonDescription generates the description of a moon whose wells exist; wellScale is the
// supply growth its wells were created with (1 if unknown).
func moonDescription(ctx context.Context, q querier, moonID string, wellScale float64) (string, error) {
// moonDescription generates the description of a moon whose wells exist; meanWell is the
// average well of its grade (MoonGrade.MeanSupply).
func moonDescription(ctx context.Context, q querier, moonID string, meanWell float64) (string, error) {
var raw []byte
err := q.QueryRow(ctx, "select settings from moon_settings where id = 1").Scan(&raw)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -527,7 +526,7 @@ func moonDescription(ctx context.Context, q querier, moonID string, wellScale fl
return "", err
}
look := parseMoonLook(raw)
return describeMoon(moonID, classifyMoon(look, resolveMoonLook(look, moonID), wells, wellScale)), nil
return describeMoon(moonID, classifyMoon(look, resolveMoonLook(look, moonID), wells, meanWell)), nil
}
// backfillMoonDescriptions gives trait-based descriptions to moons that still have a
@@ -540,16 +539,18 @@ func backfillMoonDescriptions(ctx context.Context, db *pgxpool.Pool) error {
}
}
rows, err := db.Query(ctx, `
select id::text from staking_source where description = '' or description = any($1)`, old)
select id::text, grade from staking_source where description = '' or description = any($1)`, old)
if err != nil {
return err
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
type moonRow struct{ ID, Grade string }
ids, err := pgx.CollectRows(rows, pgx.RowToStructByPos[moonRow])
if err != nil {
return err
}
for _, id := range ids {
desc, err := moonDescription(ctx, db, id, 1)
for _, m := range ids {
id := m.ID
desc, err := moonDescription(ctx, db, id, cfg().grade(m.Grade).MeanSupply())
if err != nil {
return err
}
@@ -592,8 +593,9 @@ func runMoonTraits() error {
}
wr := moonRng(moonHash(seed) ^ 0xbeef)
kind := cfg().Moons[int(wr()*float64(len(cfg().Moons)))]
wells := rollWells(cfg(), kind, 1, false, wr)
t := classifyMoon(look, res, wells, 1)
g := &cfg().MoonGrades[0]
wells := rollWells(cfg(), g, kind, false, wr)
t := classifyMoon(look, res, wells, g.MeanSupply())
out = append(out, map[string]any{"seed": seed, "look": res, "traits": t, "text": describeMoon(seed, t)})
}
enc := json.NewEncoder(os.Stdout)
+11 -22
View File
@@ -7,8 +7,9 @@ package main
//
// users.onboarding holds flags, which are only ever added:
// - a gate passed: equipment (first deploy), market (first claim), store (first sale),
// refinery (claimed every raw input of a recipe), advisors (bought a tool or attachment);
// - claimed:<resource>, for the refinery gate;
// refinery (bought a moon of refinery.unlockGrade or higher), advisors (bought a tool or
// attachment);
// - claimed:<resource>, the resources claimed so far;
// - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
@@ -95,32 +96,20 @@ func unlock(ctx context.Context, tx pgx.Tx, userID string, flags ...string) erro
return err
}
// unlockClaim records a claim: the market opens, and the refinery once the player has
// claimed every raw input of some recipe.
// unlockClaim records a claim: the market opens.
func unlockClaim(ctx context.Context, tx pgx.Tx, userID, resource string) error {
if err := unlock(ctx, tx, userID, "market", "claimed:"+resource); err != nil {
return err
return unlock(ctx, tx, userID, "market", "claimed:"+resource)
}
var have []string
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&have); err != nil {
return err
}
if slices.Contains(have, "all") || slices.Contains(have, "refinery") {
// unlockRefineryAt opens the refinery when the player buys a moon of refinery.unlockGrade
// or higher.
func unlockRefineryAt(ctx context.Context, tx pgx.Tx, userID, grade string) error {
c := cfg()
if c.gradeIndex(grade) < c.gradeIndex(c.Refinery.UnlockGrade) {
return nil
}
for _, item := range cfg().Refinery.Items {
paired := len(item.Inputs) > 0
for input := range item.Inputs {
if _, raw := cfg().ResourcePrices[input]; !raw || !slices.Contains(have, "claimed:"+input) {
paired = false
}
}
if paired {
return unlock(ctx, tx, userID, "refinery")
}
}
return nil
}
// markSeen clears the shine on a revealed tab once the player opens it.
func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) error {
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"fmt"
"math"
"math/rand"
"testing"
"time"
)
// Shared wells: the projection (State.project) with overdrilled wells, multi-well drills
// and new drills, and the room left for another (takeAfter, errWellCommitted).
func odMoon(supply ...float64) *Moon {
m := &Moon{ID: "m"}
for i, s := range supply {
m.Wells = append(m.Wells, Well{ID: fmt.Sprintf("w%d", i), Resource: fmt.Sprintf("R%d", i), Supply: s})
}
return m
}
var odT0 = time.Unix(1_000_000, 0)
func odStake(n int, well string, amount float64, reach int) Stake {
return Stake{ID: fmt.Sprintf("s%d", n), MoonID: "m", WellID: well, Amount: amount, Reach: reach,
Start: odT0.Add(time.Duration(n) * time.Second), DurationInSecs: 10 + n}
}
func near(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
func TestOverdrillSingle(t *testing.T) {
m := odMoon(10)
s := &State{Stakes: []Stake{odStake(1, "w0", 15, 1)}}
p := s.project(m)
if !near(p.Gets["s1"], 10) || !near(p.Remain[0], 0) || !near(p.Demand[0], 15) {
t.Fatalf("gets %v remain %v demand %v", p.Gets["s1"], p.Remain[0], p.Demand[0])
}
row := s.wellRow(m, m.Wells[0], "")
if !row.Over() || !row.FullyCommitted() {
t.Fatalf("over %v fully %v", row.Over(), row.FullyCommitted())
}
if got := newStakeTake(m, s.Stakes, "w0", 3, 1); got != 0 {
t.Fatalf("room on an overdrilled well: %v", got)
}
// Exactly drained counts too; a drill that leaves a bit leaves room.
if newStakeTake(m, []Stake{odStake(1, "w0", 10, 1)}, "w0", 3, 1) != 0 {
t.Fatal("room on an exactly drained well")
}
if got := newStakeTake(m, []Stake{odStake(1, "w0", 8, 1)}, "w0", 3, 1); !near(got, 2) {
t.Fatalf("take after 8 of 10: %v", got)
}
}
func TestDrillsOnWideWell(t *testing.T) {
// A whole-moon drill on w0 (12 of 10 + 5 + 4) takes w0 then the richest (w1).
m := odMoon(10, 5, 4)
stakes := []Stake{odStake(1, "w0", 12, 3)}
s := &State{Stakes: stakes}
p := s.project(m)
if !near(p.Take["s1"][0], 10) || !near(p.Take["s1"][1], 2) || p.Take["s1"][2] != 0 {
t.Fatalf("wide take %v", p.Take["s1"])
}
// w0 is spoken for by the wide drill: a one-well drill there gets nothing.
if got := newStakeTake(m, stakes, "w0", 5, 1); got != 0 {
t.Fatalf("room on w0: %v", got)
}
// w1 has 3 left after the wide drill's 2.
if got := newStakeTake(m, stakes, "w1", 5, 1); !near(got, 3) {
t.Fatalf("room on w1: %v", got)
}
stakes = append(stakes, odStake(2, "w1", 5, 1))
if got := newStakeTake(m, stakes, "w1", 5, 1); got != 0 {
t.Fatalf("room on w1 after a second drill: %v", got)
}
s = &State{Stakes: stakes}
row := s.wellRow(m, m.Wells[1], "")
if !near(row.Remain, 0) || !near(row.Taken(), 5) || !row.FullyCommitted() {
t.Fatalf("w1 remain %v taken %v", row.Remain, row.Taken())
}
// A multi-well drill still has room on the moon (w2), also deployed on a full well.
if got := newStakeTake(m, stakes, "w0", 6, 3); !near(got, 4) {
t.Fatalf("wide room: %v", got)
}
}
// A new drill (the newest stake, so claimed last) never leaves more of any well unclaimed.
func TestNewDrillNeverFreesSupply(t *testing.T) {
rnd := rand.New(rand.NewSource(7))
for trial := 0; trial < 3000; trial++ {
n := 2 + rnd.Intn(4)
supply := make([]float64, n)
for i := range supply {
supply[i] = float64(rnd.Intn(30))
}
m := odMoon(supply...)
var stakes []Stake
for k := 0; k < rnd.Intn(6); k++ {
stakes = append(stakes, odStake(k, fmt.Sprintf("w%d", rnd.Intn(n)), float64(1+rnd.Intn(20)), 1+rnd.Intn(3)*rnd.Intn(2)))
}
before := (&State{Stakes: stakes}).project(m).Remain
well := rnd.Intn(n)
add := odStake(10, fmt.Sprintf("w%d", well), float64(1+rnd.Intn(20)), 1+rnd.Intn(3))
room := newStakeTake(m, stakes, add.WellID, add.Amount, add.Reach)
p := (&State{Stakes: append(stakes, add)}).project(m)
freed := 0.0
for i := range before {
if p.Remain[i] > before[i]+1e-9 {
t.Fatalf("trial %d: well %d remain %v -> %v", trial, i, before[i], p.Remain[i])
}
freed += before[i] - p.Remain[i]
}
if !near(freed, room) || !near(p.Gets[add.ID], room) {
t.Fatalf("trial %d: room %v gets %v used %v", trial, room, p.Gets[add.ID], freed)
}
}
}
// Several drills on one well: claimed in turn (claimable, as claimInTx goes), what each
// gets adds up to the supply at most, and the ready amount matches the projection when all
// are ready.
func TestSeveralDrillsOneWell(t *testing.T) {
m := odMoon(20)
stakes := []Stake{odStake(1, "w0", 8, 1), odStake(2, "w0", 8, 1), odStake(3, "w0", 8, 1)}
s := &State{Stakes: stakes, Now: odT0.Add(time.Hour)}
p := s.project(m)
if !near(p.Gets["s1"], 8) || !near(p.Gets["s2"], 8) || !near(p.Gets["s3"], 4) {
t.Fatalf("gets %v", p.Gets)
}
row := s.wellRow(m, m.Wells[0], "")
if !near(row.ReadyAmount, 20) || row.DryAfter != 3 {
t.Fatalf("ready %v dryAfter %v", row.ReadyAmount, row.DryAfter)
}
// Only the last one ready: claiming it now gets its whole amount (others not done yet).
s.Now = odT0.Add(5 * time.Second)
stakes[2].DurationInSecs = 1
row = s.wellRow(m, m.Wells[0], "")
if !near(row.ReadyAmount, 8) {
t.Fatalf("ready amount out of order: %v", row.ReadyAmount)
}
}
// Equal wells: a claim (claimInTx sorts its wells like loadState) and the projection pick
// the same one.
func TestWideTieOrder(t *testing.T) {
m := odMoon(5, 7, 7)
got := splitClaim(9, 0, m.supply(), 2)
if got[1] != 4 || got[2] != 0 {
t.Fatalf("tie split %v", got)
}
}
+22 -22
View File
@@ -1,8 +1,8 @@
package main
// Moon ratings: how a new moon compares with every moon a purchase can roll. A moon's
// value is its wells at base prices (unscaled by wellScale); its rating is the share of
// possible moons worth less, from a fixed sample of rollWells drawn once per config (an
// Moon ratings: how a new moon compares with every moon of its grade a purchase can roll.
// A moon's value is its wells at base prices; its rating is the share of possible moons of
// the grade worth less, from a fixed sample of rollWells drawn once per config and grade (an
// economy override gives a new config, and a new sample). buyMoon stores it on the moon
// (staking_source.rating, 0–100); the reveal card and the moon list show its tier.
@@ -26,23 +26,27 @@ type moonOdds struct {
var oddsCache struct {
sync.Mutex
cfg *GameConfig
odds *moonOdds
odds map[string]*moonOdds // by grade
}
// currentOdds samples the moons the current config can roll (cached until it changes).
func currentOdds() *moonOdds {
// currentOdds samples the moons of the grade the current config can roll (cached until it
// changes).
func currentOdds(g *MoonGrade) *moonOdds {
c := cfg()
oddsCache.Lock()
defer oddsCache.Unlock()
if oddsCache.cfg == c && oddsCache.odds != nil {
return oddsCache.odds
if oddsCache.cfg != c {
oddsCache.cfg, oddsCache.odds = c, map[string]*moonOdds{}
}
if o := oddsCache.odds[g.Grade]; o != nil {
return o
}
o := &moonOdds{wells: map[string][]float64{}, count: map[int]float64{}}
rnd := mrand.New(mrand.NewPCG(1, 2))
if len(c.Moons) > 0 {
for i := range ratingSamples {
wells := rollWells(c, c.Moons[i%len(c.Moons)], 1, false, rnd.Float64)
o.values = append(o.values, wellsValue(c, wells, 1))
wells := rollWells(c, g, c.Moons[i%len(c.Moons)], false, rnd.Float64)
o.values = append(o.values, wellsValue(c, wells))
o.count[len(wells)] += 1.0 / ratingSamples
for _, w := range wells {
o.wells[w.Resource] = append(o.wells[w.Resource], w.Supply)
@@ -53,12 +57,11 @@ func currentOdds() *moonOdds {
for _, s := range o.wells {
slices.Sort(s)
}
oddsCache.cfg, oddsCache.odds = c, o
oddsCache.odds[g.Grade] = o
return o
}
// richShare is the share of moons with a well of the resource holding at least supply
// (unscaled).
// richShare is the share of moons with a well of the resource holding at least supply.
func (o *moonOdds) richShare(resource string, supply float64) float64 {
s := o.wells[resource]
if len(o.values) == 0 {
@@ -67,25 +70,22 @@ func (o *moonOdds) richShare(resource string, supply float64) float64 {
return float64(len(s)-sort.SearchFloat64s(s, supply)) / float64(len(o.values))
}
// wellsValue is what a moon's wells are worth at base prices, per unit of well scale.
func wellsValue(c *GameConfig, wells []wellSupply, scale float64) float64 {
// wellsValue is what a moon's wells are worth at base prices.
func wellsValue(c *GameConfig, wells []wellSupply) float64 {
v := 0.0
for _, w := range wells {
v += w.Supply * c.ResourcePrices[w.Resource]
}
if scale > 0 {
v /= scale
}
return v
}
// moonRating is the percentile (0–100) of a moon's value among possible moons.
func moonRating(wells []wellSupply, scale float64) int {
o := currentOdds()
// moonRating is the percentile (0–100) of a moon's value among possible moons of its grade.
func moonRating(g *MoonGrade, wells []wellSupply) int {
o := currentOdds(g)
if len(o.values) == 0 {
return 50
}
v := wellsValue(cfg(), wells, scale)
v := wellsValue(cfg(), wells)
below := sort.SearchFloat64s(o.values, v)
return min(100, int(math.Floor(float64(below)*100/float64(len(o.values)))))
}
+6
View File
@@ -30,6 +30,9 @@ type RefineryConfig struct {
// batches take less time per unit.
BulkExponent float64 `json:"bulkExponent"`
Items []RefinedItem `json:"items"`
// The refinery opens when the player buys a moon of this grade or higher (onboarding.go);
// "" or an unknown grade: with any moon.
UnlockGrade string `json:"unlockGrade,omitempty"`
}
// batchSecs is how long a batch of qty units of item takes, before live events.
@@ -76,6 +79,9 @@ func (c *GameConfig) unitPrice(name string) float64 {
func (cfg *GameConfig) validateRefinery() error {
r := &cfg.Refinery
if r.UnlockGrade != "" && cfg.gradeIndex(r.UnlockGrade) < 0 {
return fmt.Errorf("refinery: unlockGrade %q is not a moon grade", r.UnlockGrade)
}
for i, p := range r.SlotPrices {
if p < 0 {
return fmt.Errorf("refinery slot %d: price is negative", i+2)
+11 -9
View File
@@ -3,8 +3,8 @@ package main
// Selling a moon back. A moon resells for what is left in its wells at today's market
// prices (with live price events, without the player's own selling pressure or advisor
// bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a
// replacement moon would cost. It is compared with that replacement price: after a sale
// the player owns one moon fewer, so their next moon costs moonPriceFor(owned-1).
// 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 (
@@ -59,10 +59,11 @@ type MoonOffer struct {
func (o *MoonOffer) Delta() float64 { return o.Total - o.Replace }
func (o *MoonOffer) Profit() bool { return o.Delta() > 0 }
// moonOffer prices wells (supply left by resource). owned is the moons the player owns.
func moonOffer(wells []wellSupply, owned int, now time.Time, boost func(string) float64) MoonOffer {
// moonOffer prices wells (supply left by resource). replace is the price of a new moon of
// its grade.
func moonOffer(wells []wellSupply, replace float64, now time.Time, boost func(string) float64) MoonOffer {
r := cfg().MoonResale
o := MoonOffer{Replace: cfg().moonPriceFor(owned - 1)}
o := MoonOffer{Replace: replace}
for _, w := range wells {
rate := marketQuote(w.Resource, now, boost(w.Resource))
line := ResaleLine{Resource: w.Resource, Supply: w.Supply, Rate: rate, Value: w.Supply * rate}
@@ -151,7 +152,7 @@ func (s *State) moonSale(moon *Moon) *MoonSale {
if end := moon.CreatedAt.Add(time.Duration(cfg().MoonResale.MinHoldSecs) * time.Second); s.Now.Before(end) {
m.HoldEnd = end
}
m.MoonOffer = moonOffer(wells, len(s.Moons), s.Now, s.PriceBoosts.For)
m.MoonOffer = moonOffer(wells, moon.GradeConfig().Price, s.Now, s.PriceBoosts.For)
return m
}
@@ -175,10 +176,11 @@ func sellMoon(ctx context.Context, db *pgxpool.Pool, userID, moonID string) (str
return err
}
var holdLeft float64
var grade string
err = tx.QueryRow(ctx, `
select name, extract(epoch from coalesce(created_at, 'epoch') + $3 * interval '1 second' - now())
select name, grade, extract(epoch from coalesce(created_at, 'epoch') + $3 * interval '1 second' - now())
from staking_source where id = $1 and user_id = $2
for update`, moonID, userID, cfg().MoonResale.MinHoldSecs).Scan(&name, &holdLeft)
for update`, moonID, userID, cfg().MoonResale.MinHoldSecs).Scan(&name, &grade, &holdLeft)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Moon not found")
} else if err != nil {
@@ -267,7 +269,7 @@ func sellMoon(ctx context.Context, db *pgxpool.Pool, userID, moonID string) (str
return err
}
}
offer := moonOffer(wells, owned, time.Now(), func(r string) float64 { return boosts[r] })
offer := moonOffer(wells, cfg().grade(grade).Price, time.Now(), func(r string) float64 { return boosts[r] })
earned = offer.Total
tag, err := tx.Exec(ctx, "delete from staking_source where id = $1 and user_id = $2", moonID, userID)
+46 -33
View File
@@ -14,8 +14,11 @@ import (
)
// NewMoonTeaser is what the player can know before buying: the price and the odds, not
// the moon.
// the moon. Price, Grade and Wells are the default grade's (State.MoonGrade); Grades lists
// every grade.
type NewMoonTeaser struct {
Grade string
Grades []GradeOffer
Price float64
CanAfford bool
After float64 // balance after buying
@@ -25,8 +28,34 @@ type NewMoonTeaser struct {
Wells string // how many wells it can have, e.g. "2–5"
}
// GradeOffer is one moon grade on sale: its price and what its moons hold.
type GradeOffer struct {
Grade string
Price float64
CanAfford bool
WellsMin, WellsMax int
SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax
SupplyMax float64
Default bool // what a buy without a grade gets
}
// GradeOffers lists every moon grade, cheapest first.
func (s *State) GradeOffers() []GradeOffer {
def := s.MoonGrade().Grade
var out []GradeOffer
for _, g := range cfg().MoonGrades {
lo, hi := g.wellCountRange()
out = append(out, GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax,
Default: g.Grade == def})
}
return out
}
func (s *State) newMoonTeaser() *NewMoonTeaser {
t := &NewMoonTeaser{
Grade: s.MoonGrade().Grade,
Grades: s.GradeOffers(),
Price: s.MoonPrice(),
Resources: s.Config.Resources,
Wells: s.WellRange(),
@@ -55,10 +84,11 @@ type MoonReveal struct {
Next NextMoon // buying another one right away
}
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
// "".
// NextMoon is the card's "Buy another": the next moon's grade (the one just bought) and
// price, and whether the player can buy it now. Why: "locked" (more moons come with the
// store, onboarding.go), "credits" or "".
type NextMoon struct {
Grade string
Price float64
CanBuy bool
Why string
@@ -73,13 +103,12 @@ func (p *Page) moonReveal() *MoonReveal {
return nil
}
c := cfg()
owned := len(p.S.Moons) - 1
scale := c.wellScale(owned)
g := p.Moon.GradeConfig()
wells := make([]wellSupply, len(p.Moon.Wells))
for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
}
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Grade: p.S.MoonGrade().Grade, Price: p.S.MoonPrice()}}
switch {
case !p.S.Unlocked("store"):
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
@@ -90,16 +119,16 @@ func (p *Page) moonReveal() *MoonReveal {
if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating
} else {
r.Rating = ratingOf(moonRating(wells, scale))
r.Rating = ratingOf(moonRating(g, wells))
}
// Bars are out of the biggest possible well; the top tenth of the range counts as full.
r.MaxSupply = c.WellSupplyMax * scale
r.FullAt = math.Floor((c.WellSupplyMax - (c.WellSupplyMax-c.WellSupplyMin)*0.1) * scale)
r.MaxSupply = g.WellSupplyMax
r.FullAt = math.Floor(g.WellSupplyMax - (g.WellSupplyMax-g.WellSupplyMin)*0.1)
for _, w := range wells {
r.Worth += w.Supply * marketRate(w.Resource, p.S.Now)
}
r.Worth = math.Round(r.Worth)
if price := c.moonPriceFor(owned); price > 0 {
if price := g.Price; price > 0 {
r.Multiple = r.Worth / price
}
@@ -129,7 +158,7 @@ func (p *Page) moonReveal() *MoonReveal {
r.Sub = "Not much down there. Better luck next time."
}
odds := currentOdds()
odds := currentOdds(g)
// More wells than usual, if that's uncommon.
avg := 0.0
for k, share := range odds.count {
@@ -167,7 +196,7 @@ func (p *Page) moonReveal() *MoonReveal {
if i := slices.Index(prices, w.Resource); i < 0 || i > 1 {
continue
}
if share := odds.richShare(w.Resource, w.Supply/scale); share < rareShare {
if share := odds.richShare(w.Resource, w.Supply); share < rareShare {
rare, rareShare = w, share
}
}
@@ -177,24 +206,8 @@ func (p *Page) moonReveal() *MoonReveal {
return r
}
// WellRange is how many wells a new moon can have, e.g. "2–5".
func (s *State) WellRange() string {
lo, hi := wellCountRange()
if lo == hi {
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
}
// WellRange is how many wells a new moon of the default grade can have, e.g. "2–5".
func (s *State) WellRange() string { return s.MoonGrade().WellRange() }
// wellCountRange is the fewest and most wells a new moon can have.
func wellCountRange() (lo, hi int) {
for n, w := range cfg().WellCountWeights {
if w > 0 {
if lo == 0 || n < lo {
lo = n
}
hi = max(hi, n)
}
}
return lo, hi
}
// wellCountRange is the fewest and most wells a new moon of the default grade can have.
func (s *State) wellCountRange() (lo, hi int) { return s.MoonGrade().wellCountRange() }
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"math/big"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Save codes: a player's way back to it after the cookie (or the Defold client's settings
// file) is gone, or from another device. One code per player (table save_code), random,
// in groups of four (ABCD-EFGH-JKMN-PQRS) from letters and digits that can't be mistaken
// for each other. Only its SHA-256 is stored: the code has ~78 bits of randomness, so a
// plain hash is enough and lets restore look it up. The code is shown once, when it is
// made; making a new one replaces the old one.
// saveCodeAlphabet has no 0/O, 1/I/L or U (V).
const saveCodeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789"
const saveCodeLen = 16
// newSaveCodeText is a fresh random code, with dashes.
func newSaveCodeText() string {
var b strings.Builder
n := big.NewInt(int64(len(saveCodeAlphabet)))
for i := 0; i < saveCodeLen; i++ {
if i > 0 && i%4 == 0 {
b.WriteByte('-')
}
k, err := rand.Int(rand.Reader, n)
if err != nil {
panic(err)
}
b.WriteByte(saveCodeAlphabet[k.Int64()])
}
return b.String()
}
// normalSaveCode is a typed code without spaces or dashes, in upper case, or false if it
// can't be a code.
func normalSaveCode(s string) (string, bool) {
var b strings.Builder
for _, c := range strings.ToUpper(s) {
switch {
case c == '-' || c == ' ' || c == '\t':
case c < 128 && strings.IndexByte(saveCodeAlphabet, byte(c)) >= 0:
b.WriteRune(c)
default:
return "", false
}
}
if b.Len() != saveCodeLen {
return "", false
}
return b.String(), true
}
func hashSaveCode(normal string) string {
h := sha256.Sum256([]byte(normal))
return hex.EncodeToString(h[:])
}
// makeSaveCode gives the player a new code (the old one stops working) and returns it.
func makeSaveCode(ctx context.Context, db *pgxpool.Pool, userID string) (string, time.Time, error) {
code := newSaveCodeText()
normal, _ := normalSaveCode(code)
var made time.Time
err := inTx(ctx, db, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
insert into save_code (user_id, code_hash) values ($1, $2)
on conflict (user_id) do update set code_hash = excluded.code_hash, made_at = now()
returning made_at`, userID, hashSaveCode(normal)).Scan(&made)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "save_code", nil)
})
return code, made, err
}
// saveCodeMade is when the player's current code was made (zero: none).
func saveCodeMade(ctx context.Context, db *pgxpool.Pool, userID string) (time.Time, error) {
var made time.Time
err := db.QueryRow(ctx, "select made_at from save_code where user_id = $1", userID).Scan(&made)
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, nil
}
return made, err
}
// restoreSaveCode is the player a code belongs to ("" if none), logged as a restore.
func restoreSaveCode(ctx context.Context, db *pgxpool.Pool, code string) (string, error) {
normal, ok := normalSaveCode(code)
if !ok {
return "", nil
}
var userID string
err := db.QueryRow(ctx, "select user_id from save_code where code_hash = $1", hashSaveCode(normal)).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return userID, logEvent(ctx, db, userID, "save_code_restore", nil)
}
// saveCodeNudge reports, once per player, that it is time to point them to save codes: it
// marks the onboarding flag nudge:save-code and is true only the first time, and never for
// a player who has a code already.
func saveCodeNudge(ctx context.Context, db *pgxpool.Pool, userID string) (bool, error) {
tag, err := db.Exec(ctx, `
update users set onboarding = onboarding || '{nudge:save-code}'::text[]
where id = $1 and not ('nudge:save-code' = any(onboarding))
and not exists (select 1 from save_code where user_id = $1)`, userID)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
// saveCodeNudgeText is the nudge's text (clients may word it for where their menu is).
const saveCodeNudgeText = "Your progress is kept on this device only. Make a save code in the settings to get it back on another device."
// apiRestore is the player a save code belongs to, after the restore limit; it sets the
// X-MM-User header and the cookie to it. On a refusal it writes the error and returns "".
func (s *server) apiRestore(w http.ResponseWriter, r *http.Request, code string) string {
if !s.restores.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many tries, wait a minute and try again")
return ""
}
userID, err := restoreSaveCode(r.Context(), s.db, code)
if err != nil {
apiServerError(w, err)
return ""
}
if userID == "" {
restoreRefused(w, "That save code doesn't match any miner")
return ""
}
if isBanned(r.Context(), s.db, userID) {
// (422, not 403: a client shows 403 as its own player suspended)
restoreRefused(w, "That miner is suspended")
return ""
}
w.Header().Set(userHeader, userID)
setUserCookie(w, userID)
return userID
}
// restoreRefused is a restore's 422, with a toast like a refused action's (no state).
func restoreRefused(w http.ResponseWriter, message string) {
writeJSON(w, http.StatusUnprocessableEntity, APIResponse{Error: message,
Toast: &APIToast{Message: message, Error: true, Kind: "error"}})
}
// handleAPISaveCode is GET /api/save-code: whether the player has a code and since when
// (the code itself is only shown when made, by POST /api/act/save-code).
func (s *server) handleAPISaveCode(w http.ResponseWriter, r *http.Request) {
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
made, err := saveCodeMade(r.Context(), s.db, userID)
if err != nil {
apiServerError(w, err)
return
}
sc := &APISaveCode{}
if !made.IsZero() {
sc.MadeAt = made.UnixMilli()
}
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, SaveCode: sc})
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"regexp"
"testing"
)
func TestSaveCodes(t *testing.T) {
shape := regexp.MustCompile(`^[` + saveCodeAlphabet + `]{4}(-[` + saveCodeAlphabet + `]{4}){3}$`)
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
c := newSaveCodeText()
if !shape.MatchString(c) {
t.Fatalf("bad code %q", c)
}
if seen[c] {
t.Fatalf("repeated code %q", c)
}
seen[c] = true
n, ok := normalSaveCode(c)
if !ok || len(n) != saveCodeLen {
t.Fatalf("normalSaveCode(%q) = %q, %v", c, n, ok)
}
}
for in, want := range map[string]string{
"abcd-efgh-jkmn-pqrs": "ABCDEFGHJKMNPQRS",
" ABCD EFGH JKMN PQRS ": "ABCDEFGHJKMNPQRS",
"ABCDEFGHJKMNPQRS": "ABCDEFGHJKMNPQRS",
"ABCD-EFGH-JKMN-PQR0": "", // 0 isn't in the alphabet
"ABCD-EFGH-JKMN": "", // short
"ABCD-EFGH-JKMN-PQRS-T": "", // long
"ABCD-EFGH-JKMN-PQRS'--": "",
} {
got, ok := normalSaveCode(in)
if got != want || ok != (want != "") {
t.Errorf("normalSaveCode(%q) = %q, %v; want %q", in, got, ok, want)
}
}
if hashSaveCode("A") == hashSaveCode("B") {
t.Error("hash collision")
}
}
+545
View File
@@ -0,0 +1,545 @@
// Tool art: draws every store item (drill) at every tier as an SVG and rasterizes it into
// web/static/assets/items (rsvg-convert + ImageMagick). Tier 0 keeps the item's `image` name
// from config/game.json; tier N is the same name with "-tN" before the extension (StoreItem.ImageAt
// in game.go). Run from server/: node scripts/tool-art.mjs [--svg dir]
//
// Each tool has its own silhouette and color family; tiers grow the rig, add parts, light
// up a ring of tier segments around its base and strengthen the glow.
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
const SIZE = 512;
const OUT = "web/static/assets/items";
// ------------------------------------------------------------------ helpers
const C = Math.cos(Math.PI / 6);
const R2 = Math.SQRT2;
const f = (n) => +n.toFixed(1);
// Isometric projection: X runs down-right, Y down-left, Z up.
// While BB is set, every projected point grows it (the rig's bounding box, for fitting).
let BB = null;
function grow(x, y, r = 0) {
if (!BB) return;
BB.x0 = Math.min(BB.x0, x - r); BB.x1 = Math.max(BB.x1, x + r);
BB.y0 = Math.min(BB.y0, y - r); BB.y1 = Math.max(BB.y1, y + r);
}
const P = (x, y, z) => {
const p = [f((x - y) * C), f((x + y) * 0.5 - z)];
grow(...p);
return p;
};
const pts = (list) => list.map((p) => p.join(",")).join(" ");
function hex(c) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
const [x, y] = [hex(a), hex(b)];
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("");
}
const shade = (c, k) => (k >= 0 ? mix(c, "#ffffff", k) : mix(c, "#000000", -k));
// A seeded random, so the rocks stay put between runs.
function rng(seed) {
let s = seed * 2654435761 % 4294967296;
return () => ((s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296);
}
// Materials: hull plating, dark machinery, and the tool's own color.
const HULL = "#b9c8db";
const IRON = "#3a4a63";
class Art {
constructor(color) {
this.color = color;
this.out = [];
}
add(s) {
this.out.push(s);
}
poly(list, fill, extra = "") {
this.add(`<polygon points="${pts(list)}" fill="${fill}" ${extra}/>`);
}
line(a, b, stroke, w = 2, extra = "") {
this.add(`<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${extra}/>`);
}
// Box from its min corner (x, y, z), size w (X), d (Y), h (Z): top, right (+X), left (+Y).
box(x, y, z, w, d, h, col, edge = true) {
const st = edge ? `stroke="${shade(col, -0.55)}" stroke-width="1" stroke-linejoin="round"` : "";
this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, -0.18), st);
this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], shade(col, -0.45), st);
this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, 0.22), st);
}
cbox(cx, cy, z, w, d, h, col, edge) {
this.box(cx - w / 2, cy - d / 2, z, w, d, h, col, edge);
}
// Vertical cylinder centered on (cx, cy), from z up h.
cyl(cx, cy, z, r, h, col) {
const [bx, by] = P(cx, cy, z);
const [, ty] = P(cx, cy, z + h);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
const g = this.grad(col);
this.add(`<path d="M${bx - rx},${ty} L${bx - rx},${by} A${rx},${ry} 0 0 0 ${bx + rx},${by} L${bx + rx},${ty} Z" fill="url(#${g})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
this.add(`<ellipse cx="${bx}" cy="${ty}" rx="${rx}" ry="${ry}" fill="${shade(col, 0.25)}" stroke="${shade(col, -0.5)}" stroke-width="1"/>`);
}
// A flat disc (ellipse) lying at height z.
disc(cx, cy, z, r, fill, extra = "") {
const [x, y] = P(cx, cy, z);
this.add(`<ellipse cx="${x}" cy="${y}" rx="${f(r * C * R2)}" ry="${f(r * R2 * 0.5)}" fill="${fill}" ${extra}/>`);
}
// Cone (drill bit) from a disc of radius r at z1 down to a point at z2.
cone(cx, cy, z1, z2, r, col) {
const [x, y1] = P(cx, cy, z1);
const [, y2] = P(cx, cy, z2);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
this.add(`<path d="M${x - rx},${y1} A${rx},${ry} 0 0 0 ${x + rx},${y1} L${x},${y2} Z" fill="url(#${this.grad(col)})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
}
// Horizontal shading gradient for round parts (lit from the left).
grad(col) {
const id = "g" + col.slice(1);
this.defs ??= new Map();
if (!this.defs.has(id)) {
this.defs.set(id, `<linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="${shade(col, -0.25)}"/><stop offset="0.3" stop-color="${shade(col, 0.3)}"/>
<stop offset="0.65" stop-color="${shade(col, -0.2)}"/><stop offset="1" stop-color="${shade(col, -0.6)}"/></linearGradient>`);
}
return id;
}
// A light: a bright core with a soft halo in the tool's color.
light(p, r, k = 1, col = this.color) {
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${f(r * 2.6)}" fill="${col}" opacity="${f(0.35 * k * 10) / 10}" filter="url(#blur)"/>`);
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${r}" fill="${mix(col, "#ffffff", 0.6)}"/>`);
}
// Lattice between two square frames (a tower section): legs plus X bracing.
lattice(cx, cy, z1, z2, half1, half2, col, w = 3, bays = 4) {
const corner = (h, z) => [[cx - h, cy - h, z], [cx + h, cy - h, z], [cx + h, cy + h, z], [cx - h, cy + h, z]];
const at = (t) => corner(half1 + (half2 - half1) * t, z1 + (z2 - z1) * t);
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
if (i === 0) continue; // the back face would only add clutter
for (let b = 0; b < bays; b++) {
const lo = at(b / bays), hi = at((b + 1) / bays);
this.line(P(...lo[i]), P(...hi[j]), shade(col, -0.25), w * 0.55);
this.line(P(...lo[j]), P(...hi[i]), shade(col, -0.25), w * 0.55);
this.line(P(...hi[i]), P(...hi[j]), shade(col, -0.1), w * 0.6);
}
}
const lo = at(0), hi = at(1);
for (let i = 0; i < 4; i++) this.line(P(...lo[i]), P(...hi[i]), i === 2 ? shade(col, 0.2) : col, w);
}
// Diagonal warning stripes on the +Y face of a box.
stripes(x, y, z, w, d, h, n, col) {
for (let i = 0; i < n; i++) {
const a = x + (w / n) * i, b = a + (w / n) * 0.5;
this.poly([P(a, y + d, z), P(b, y + d, z), P(b + h * 0.4, y + d, z + h), P(a + h * 0.4, y + d, z + h)], col, 'opacity="0.85"');
}
}
}
// ------------------------------------------------------------------ the tools
// Each draw(a, t) adds the rig's shapes around the ground origin (0, 0, 0); t is the tier.
const TOOLS = {
// Prospector Drone PD-1: a hovering quad-rotor that pulls ore up with a tractor beam.
1: {
color: "#7cf06a",
size: 0.8,
draw(a, t) {
const c = a.color, z = 92;
// tractor beam and its pool on the ground
a.disc(0, 0, 0, 34, c, `opacity="${0.18 + t * 0.05}" filter="url(#blur)"`);
const [bx, by] = P(0, 0, z), [, gy] = P(0, 0, 0);
a.add(`<polygon points="${bx - 14},${by} ${bx + 14},${by} ${bx + 34},${gy} ${bx - 34},${gy}" fill="url(#beam)" opacity="${0.45 + t * 0.08}"/>`);
const arms = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
const rotor = (sx, sy) => {
const x = sx * 58, y = sy * 58;
a.cyl(x, y, z + 6, 7, 10, IRON);
a.disc(x, y, z + 17, 30, shade(c, 0.4), `opacity="0.18"`);
a.disc(x, y, z + 17, 30, "none", `stroke="${c}" stroke-width="${t >= 4 ? 3 : 1.5}" opacity="0.9"`);
a.light(P(x, y, z + 17), 2.5, 0.6);
};
// back rotors and arms first
for (const [sx, sy] of arms.slice(0, 1)) rotor(sx, sy);
for (const [sx, sy] of arms) a.line(P(0, 0, z + 10), P(sx * 58, sy * 58, z + 10), shade(HULL, -0.3), 7);
for (const [sx, sy] of arms.slice(1, 3)) rotor(sx, sy);
// body: a squat saucer with a dome
a.cyl(0, 0, z - 6, 30, 16, HULL);
a.cyl(0, 0, z + 10, 22, 6, IRON);
const [dx, dy] = P(0, 0, z + 16);
a.add(`<path d="M${dx - 22},${dy} A22,14 0 0 1 ${dx + 22},${dy} A22,9 0 0 1 ${dx - 22},${dy}" fill="${mix(c, "#0a2020", 0.55)}" stroke="${c}" stroke-width="1.5"/>`);
a.light([dx, dy - 4], 4, 0.8 + t * 0.1);
// side lamps grow in number with the tier
for (let i = 0; i < 2 + Math.min(t, 3); i++) a.light(P(26, -18 + i * 10, z + 2), 2.2, 0.7);
if (t >= 2) {
a.line(P(-8, -8, z + 18), P(-14, -14, z + 50), shade(HULL, -0.2), 2);
a.light(P(-14, -14, z + 50), 2.5, 1);
}
if (t >= 3) {
a.cyl(-6, 34, z - 14, 9, 22, c);
a.cyl(34, -6, z - 14, 9, 22, c);
}
rotor(1, 1);
},
},
// Percussion Drill PX-4: a tracked crawler with a hammer mast at its nose.
2: {
color: "#ff9a3d",
size: 0.86,
draw(a, t) {
const c = a.color;
// shock rings where the hammer bites
for (let i = 0; i < 1 + Math.ceil(t / 2); i++) {
a.disc(70, 0, 0, 22 + i * 14, "none", `stroke="${c}" stroke-width="2" opacity="${0.7 - i * 0.18}"`);
}
// tracks
a.box(-70, -48, 0, 120, 22, 26, IRON);
a.box(-70, 26, 0, 120, 22, 26, IRON);
for (let i = 0; i < 5; i++) {
const [x, y] = P(-60 + i * 25, 48, 13);
a.add(`<ellipse cx="${x}" cy="${y}" rx="8" ry="10" fill="${shade(IRON, -0.4)}" stroke="${shade(IRON, 0.3)}" stroke-width="1.5"/>`);
}
// hull, cab and the stack
a.box(-64, -40, 26, 108, 80, 26, HULL);
a.stripes(-64, -40, 26, 108, 80, 26, 7, c);
a.box(-60, -34, 52, 48, 56, 34 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-12, -26, 60), P(-12, 16, 60), P(-12, 16, 80), P(-12, -26, 80)], mix(c, "#04121a", 0.5));
a.cyl(-50, -24, 86 + (t >= 3 ? 10 : 0), 6, 26, IRON);
if (t >= 2) a.light(P(-50, -24, 118 + (t >= 3 ? 10 : 0)), 3, 0.8);
// the mast: a tall column with a piston block
const top = 150 + t * 10;
a.box(44, -12, 26, 16, 24, top - 26, shade(HULL, -0.1));
a.stripes(44, -12, 30, 16, 24, top - 34, 6, c);
a.cyl(70, 0, 30, 9, top - 40, shade(HULL, 0.1));
a.cbox(70, 0, 46 + (top - 80) * 0.45, 30, 30, 34, c);
a.cbox(70, 0, top - 10, 26, 26, 14, IRON);
a.cone(70, 0, 30, -6, 10, shade(HULL, -0.2));
a.light(P(70, 0, 2), 4, 0.6 + t * 0.1);
if (t >= 4) {
a.cyl(0, -8, 52, 12, 22, c);
a.light(P(0, -8, 74), 3, 1);
}
a.light(P(70, 0, top + 4), 3, 0.9);
},
},
// LunarLite L100: a light tripod with a spiral rotary bit.
3: {
color: "#5fd4ff",
size: 0.84,
draw(a, t) {
const c = a.color, hub = 130;
a.disc(0, 0, 0, 22, c, `opacity="${0.3 + t * 0.07}" filter="url(#blur)"`);
const legs = [[-70, -10], [20, -66], [40, 60]];
a.line(P(...legs[0], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
a.line(P(...legs[1], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
// the bit: a spiral auger into the ground
a.cone(0, 0, 76, -14, 14, shade(HULL, 0.05));
for (let i = 0; i < 6; i++) {
const z = 70 - i * 13, r = 14 * (z + 14) / 90;
const [x, y] = P(0, 0, z);
a.add(`<path d="M${f(x - r * 1.2)},${y} q${f(r * 1.2)},${f(r * 0.7)} ${f(r * 2.4)},-5" fill="none" stroke="${c}" stroke-width="2" opacity="0.9"/>`);
}
// motor housing
a.cyl(0, 0, 76, 16, 14, IRON);
a.cyl(0, 0, 90, 24, 40 + (t >= 3 ? 8 : 0), HULL);
a.disc(0, 0, 108, 24.5, "none", `stroke="${c}" stroke-width="3"`);
if (t >= 1) a.disc(0, 0, 98, 24.5, "none", `stroke="${c}" stroke-width="2" opacity="0.7"`);
a.cyl(0, 0, 130 + (t >= 3 ? 8 : 0), 10, 10, IRON);
a.light(P(0, 0, 146 + (t >= 3 ? 8 : 0)), 3, 0.8 + t * 0.1);
if (t >= 2) {
a.cbox(-34, 18, 0, 22, 22, 26, c);
a.line(P(-34, 18, 26), P(0, 0, 100), IRON, 2);
}
if (t >= 4) a.cyl(30, -30, 0, 9, 34, c);
a.line(P(...legs[2], 0), P(0, 0, hub), shade(HULL, -0.1), 6);
for (const l of legs) a.disc(l[0], l[1], 0, 7, IRON);
},
},
// LunarLite L300: a tall derrick with a laser cutter firing straight down.
4: {
color: "#ff5fd2",
size: 0.94,
draw(a, t) {
const c = a.color, top = 230 + t * 12;
// laser impact
a.disc(0, 0, 0, 30 + t * 4, c, `opacity="0.55" filter="url(#blur)"`);
a.disc(0, 0, 0, 12, "#ffffff", `opacity="0.8" filter="url(#blur)"`);
// power unit at the back
a.box(-96, -70, 0, 40, 36, 34, HULL);
a.stripes(-96, -70, 0, 40, 36, 34, 4, c);
a.box(-46, -50, 0, 92, 100, 10, IRON);
a.lattice(0, 0, 10, top, 42, 13, shade(HULL, -0.15), 4, 5);
// emitter and beam
const ez = 96;
const [bx, by] = P(0, 0, ez), [, gy] = P(0, 0, 0);
a.add(`<rect x="${bx - 8}" y="${by}" width="16" height="${gy - by}" fill="${c}" opacity="0.9" filter="url(#blur)"/>`);
a.add(`<rect x="${bx - 1.8}" y="${by}" width="4" height="${gy - by}" fill="#ffe6f8"/>`);
a.cbox(0, 0, ez, 26, 26, 30, HULL);
a.disc(0, 0, ez + 12, 18.5, "none", `stroke="${c}" stroke-width="3"`);
a.cone(0, 0, ez, ez - 16, 9, IRON);
if (t >= 2) a.cbox(0, 0, ez + 30, 16, 16, 24, c);
// crown block
a.cbox(0, 0, top, 34, 34, 14, c);
a.light(P(0, 0, top + 22), 3.5, 0.9 + t * 0.1);
if (t >= 3) {
a.cyl(56, 30, 0, 12, 44, c);
a.light(P(56, 30, 48), 3, 0.8);
}
if (t >= 4) {
for (const z of [ez + 70, ez + 110]) a.disc(0, 0, z, 30, "none", `stroke="${c}" stroke-width="2.5" opacity="0.85"`);
}
},
},
// Lunar Regolith Extractor LRE-3000: a bucket-wheel excavator on tracks.
5: {
color: "#ffd24a",
size: 0.95,
draw(a, t) {
const c = a.color;
// counterweight boom (behind)
a.line(P(-10, 0, 80), P(-120, 0, 106), shade(HULL, -0.35), 12);
a.line(P(-30, 0, 120), P(-120, 0, 122), shade(HULL, 0.05), 3);
a.cbox(-120, 0, 92, 30, 34, 30, IRON);
// tracks and turntable
a.box(-60, -56, 0, 100, 26, 24, IRON);
a.box(-60, 30, 0, 100, 26, 24, IRON);
a.cyl(-10, 0, 24, 40, 10, IRON);
// superstructure
a.box(-50, -34, 34, 74, 68, 46, HULL);
a.stripes(-50, -34, 34, 74, 68, 16, 6, c);
a.box(-36, -24, 80, 34, 40, 26 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-2, -18, 88), P(-2, 10, 88), P(-2, 10, 100), P(-2, -18, 100)], mix(c, "#141004", 0.4));
// boom out to the wheel
const wx = 130, wz = 74;
a.line(P(10, 0, 70), P(wx, 0, wz + 6), shade(HULL, -0.1), 12);
a.line(P(10, 0, 116), P(wx - 6, 0, wz + 26), shade(HULL, 0.1), 3);
a.line(P(-20, 0, 110), P(10, 0, 116), shade(HULL, 0.1), 3);
// the bucket wheel, facing the viewer
const [x, y] = P(wx, 0, wz);
const R = 52 + t * 3, n = 8 + t * 2;
grow(x, y, R + 9);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${shade(HULL, -0.2)}" stroke-width="9"/>`);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${c}" stroke-width="2"/>`);
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const ex = x + Math.cos(ang) * R, ey = y + Math.sin(ang) * R;
a.line([x, y], [f(ex), f(ey)], shade(HULL, -0.3), 2.5);
a.add(`<rect x="${f(ex - 8)}" y="${f(ey - 7)}" width="16" height="14" rx="3" fill="${c}" stroke="${shade(c, -0.6)}" stroke-width="1" transform="rotate(${f((ang * 180) / Math.PI + 90)} ${f(ex)} ${f(ey)})"/>`);
}
a.add(`<circle cx="${x}" cy="${y}" r="12" fill="${IRON}" stroke="${c}" stroke-width="2"/>`);
a.light([x, y], 4, 0.7 + t * 0.1);
if (t >= 2) a.light(P(-36, -24, 108 + (t >= 3 ? 10 : 0)), 3, 0.9);
if (t >= 4) {
a.cyl(22, 40, 34, 10, 30, c);
a.light(P(22, 40, 66), 2.5, 1);
}
// dust thrown up by the wheel
const r = rng(5);
for (let i = 0; i < 6 + t * 3; i++) {
a.add(`<circle cx="${f(x - 20 + r() * 70)}" cy="${f(y + R * 0.4 + r() * 50)}" r="${f(1 + r() * 2.5)}" fill="${mix(c, "#ffffff", 0.3)}" opacity="${f(0.3 + r() * 0.5)}"/>`);
}
},
},
// Electrostatic Dust Excavator EDE-700: a coil tower of charged plates with arcs.
6: {
color: "#a98bff",
size: 0.97,
draw(a, t) {
const c = a.color, plates = 3 + Math.min(t, 3);
a.disc(0, 0, 0, 80, c, `opacity="${0.15 + t * 0.05}" filter="url(#blur)"`);
// base platform with feet
a.cyl(0, 0, 0, 62, 16, IRON);
for (const [x, y] of [[-60, 0], [0, -60], [60, 0], [0, 60]]) a.cbox(x, y, 0, 18, 18, 22, HULL);
a.cyl(0, 0, 16, 30, 26, HULL);
// the column and its plates, widest at the bottom
const h = 60 + plates * 30;
a.cyl(0, 0, 40, 13, h, shade(HULL, -0.05));
const r = rng(6);
for (let i = 0; i < plates; i++) {
const z = 58 + i * 30, rad = 54 - i * 5;
a.cyl(0, 0, z, rad, 6, HULL);
a.disc(0, 0, z + 6, rad + 0.5, "none", `stroke="${c}" stroke-width="2.5"`);
a.disc(0, 0, z + 6, rad, c, `opacity="0.25" filter="url(#blur)"`);
a.cyl(0, 0, z + 6, 14, 24, shade(HULL, -0.05));
}
// arcs between the plates
for (let i = 0; i < Math.min(plates - 1, 1 + t); i++) {
const z = 64 + i * 30, side = i % 2 ? 1 : -1;
let [x, y] = P(side * 36, side * -10, z);
let d = `M${x},${y}`;
for (let k = 0; k < 4; k++) d += ` L${f(x + side * (4 + r() * 10))},${f(y - 7 - k * 6)}`;
a.add(`<path d="${d}" fill="none" stroke="${c}" stroke-width="5" opacity="0.5" filter="url(#blur)"/>`);
a.add(`<path d="${d}" fill="none" stroke="#f2ecff" stroke-width="1.5"/>`);
}
// the top sphere
const [tx, ty] = P(0, 0, 40 + h + 14);
grow(tx, ty, 16 + t);
a.add(`<circle cx="${tx}" cy="${ty}" r="${16 + t}" fill="url(#orb)" stroke="${c}" stroke-width="1.5"/>`);
a.light([tx, ty], 5, 0.8 + t * 0.12);
// dust rising into the field
for (let i = 0; i < 8 + t * 4; i++) {
const ang = r() * Math.PI * 2, rr = 50 + r() * 50;
const [px, py] = P(Math.cos(ang) * rr, Math.sin(ang) * rr, r() * h);
a.add(`<circle cx="${px}" cy="${py}" r="${f(1 + r() * 2)}" fill="${mix(c, "#ffffff", 0.4)}" opacity="${f(0.3 + r() * 0.6)}"/>`);
}
},
},
// Lunar Core Extractor LCE-360: a heavy rig on legs over a glowing bore, with a tall tower.
7: {
color: "#ff5a3c",
size: 1,
draw(a, t) {
const c = a.color;
// the bore under the rig
a.disc(0, 0, 0, 52, c, `opacity="0.75" filter="url(#blur)"`);
a.disc(0, 0, 0, 30, "#ffd0a0", `opacity="0.85" filter="url(#blur)"`);
a.disc(0, 0, 0, 44, "none", `stroke="${shade(c, 0.2)}" stroke-width="3"`);
// legs
for (const [x, y] of [[-80, -80], [80, -80], [-80, 80]]) a.cbox(x, y, 0, 14, 14, 56, IRON);
// drill string into the bore
a.cyl(0, 0, -4, 8, 60, shade(HULL, -0.2));
// deck
a.box(-92, -92, 56, 184, 184, 16, HULL);
a.stripes(-92, -92, 56, 184, 184, 16, 12, c);
// tanks and a crane
a.cyl(-60, 60, 72, 18, 40, HULL);
a.disc(-60, 60, 96, 18.5, "none", `stroke="${c}" stroke-width="2.5"`);
if (t >= 2) {
a.cyl(60, -60, 72, 16, 34, HULL);
a.disc(60, -60, 92, 16.5, "none", `stroke="${c}" stroke-width="2.5"`);
}
a.box(30, 30, 72, 52, 44, 40 + (t >= 3 ? 14 : 0), HULL);
a.poly([P(82, 38, 92), P(82, 66, 92), P(82, 66, 104), P(82, 38, 104)], mix(c, "#1a0604", 0.4));
const top = 260 + t * 12;
a.lattice(0, 0, 72, top, 36, 12, shade(HULL, -0.1), 4, 6);
a.cbox(0, 0, top, 30, 30, 12, c);
a.light(P(0, 0, top + 20), 4, 0.9 + t * 0.1);
a.line(P(-70, -40, 72), P(-110, 10, 170 + t * 6), shade(HULL, -0.2), 5);
a.line(P(-110, 10, 170 + t * 6), P(-110, 10, 120), IRON, 1.5);
a.cbox(-110, 10, 108, 10, 10, 12, c);
if (t >= 4) for (const z of [140, 200]) a.disc(0, 0, z, 26, "none", `stroke="${c}" stroke-width="2.5" opacity="0.9"`);
// the front leg last
a.cbox(80, 80, 0, 14, 14, 56, IRON);
a.light(P(92, 92, 72), 3, 0.9);
},
},
};
// ------------------------------------------------------------------ scene
function svg(id, tier, maxTier) {
const tool = TOOLS[id];
const c = tool.color;
const gx = 256, gy = 338; // ground origin on the image; deploy cards crop to about y 51..392
// Fit the top tier into the frame (up to y 72, 440 wide); lower tiers are smaller, and
// smaller tools stay smaller (tool.size).
BB = { x0: 0, x1: 0, y0: 0, y1: 0 };
tool.draw(new Art(c), maxTier);
const box = BB;
BB = null;
const fit = Math.min((gy - 72) / -box.y0, 440 / (box.x1 - box.x0), 1.8);
const a = new Art(c);
tool.draw(a, tier);
const k = maxTier ? tier / maxTier : 0;
const scale = fit * tool.size * (0.8 + 0.2 * k);
const shift = f(-((box.x0 + box.x1) / 2) * scale);
// The ground disc, rocks, and a ring of tier segments around the base.
const r = rng(id * 31 + 7);
let rocks = "";
for (let i = 0; i < 26; i++) {
const ang = r() * Math.PI * 2, d = 0.25 + r() * 0.75;
const x = f(Math.cos(ang) * 190 * d), y = f(Math.sin(ang) * 50 * d);
const s = f(2 + r() * 6);
rocks += `<ellipse cx="${x}" cy="${y}" rx="${s}" ry="${f(s * 0.6)}" fill="#2a3446" opacity="${f(0.5 + r() * 0.5)}"/>`;
}
const rx = 168, ry = 44;
const circ = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
const seg = circ / Math.max(maxTier, 1);
const lit = Array.from({ length: maxTier }, (_, i) => (i < tier ? `${f(seg * 0.82)} ${f(seg * 0.18)}` : `0 ${f(seg)}`)).join(" ");
const ring = `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-opacity="0.18" stroke-width="3" stroke-dasharray="${f(seg * 0.82)} ${f(seg * 0.18)}" transform="rotate(180)"/>`
+ (tier ? `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="7" opacity="0.6" filter="url(#blur)" stroke-dasharray="${lit}" transform="rotate(180)"/>
<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${mix(c, "#ffffff", 0.35)}" stroke-width="3" stroke-dasharray="${lit}" transform="rotate(180)"/>` : "");
// Sparkles at the top tier.
let sparkles = "";
if (tier === maxTier && maxTier > 0) {
const s = rng(id * 13);
for (let i = 0; i < 14; i++) {
const x = f(80 + s() * 352), y = f(70 + s() * 240), q = f(2 + s() * 4);
sparkles += `<path d="M${x},${y - q * 2} L${x + q * 0.4},${y} L${x},${y + q * 2} L${x - q * 0.4},${y} Z M${x - q * 2},${y} L${x},${y + q * 0.4} L${x + q * 2},${y} L${x},${y - q * 0.4} Z" fill="${mix(c, "#ffffff", 0.6)}" opacity="${f(0.4 + s() * 0.6)}"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<radialGradient id="bg" cx="0.5" cy="0.45" r="0.75"><stop offset="0" stop-color="#0e2446"/><stop offset="1" stop-color="#040a16"/></radialGradient>
<radialGradient id="aura" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${c}" stop-opacity="${f((0.16 + 0.3 * k) * 100) / 100}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>
<radialGradient id="ground" cx="0.5" cy="0.4" r="0.6"><stop offset="0" stop-color="#1c2638"/><stop offset="0.75" stop-color="#121a28"/><stop offset="1" stop-color="#0a101c" stop-opacity="0"/></radialGradient>
<linearGradient id="beam" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.9"/><stop offset="1" stop-color="${c}" stop-opacity="0.15"/></linearGradient>
<radialGradient id="orb" cx="0.35" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="${mix(c, "#ffffff", 0.4)}"/><stop offset="1" stop-color="${shade(c, -0.5)}"/></radialGradient>
<filter id="blur" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="halo" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="${f(2 + 4 * k)}" result="b"/><feFlood flood-color="${c}" flood-opacity="${f((0.25 + 0.5 * k) * 100) / 100}"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>
${[...(a.defs?.values() ?? [])].join("\n ")}
</defs>
<rect width="${SIZE}" height="${SIZE}" fill="url(#bg)"/>
<g stroke="#5fd4ff" stroke-opacity="0.06" stroke-width="1">${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 36 + 4}" y1="0" x2="${i * 36 + 4}" y2="${SIZE}"/><line x1="0" y1="${i * 36 + 4}" x2="${SIZE}" y2="${i * 36 + 4}"/>`).join("")}</g>
<ellipse cx="${gx}" cy="${gy - 110}" rx="${f(150 + 60 * k)}" ry="${f(150 + 60 * k)}" fill="url(#aura)"/>
<g transform="translate(${gx} ${gy})">
<ellipse rx="230" ry="70" fill="url(#ground)"/>
${rocks}
${ring}
<ellipse rx="${f(110 * scale)}" ry="${f(38 * scale)}" fill="#000" opacity="0.45" filter="url(#blur)"/>
<g transform="translate(${shift} 0) scale(${f(scale * 1000) / 1000})" filter="url(#halo)">
${a.out.join("\n ")}
</g>
</g>
${sparkles}
</svg>`;
}
// ------------------------------------------------------------------ main
export function tierImage(image, tier) {
if (!tier) return image;
const dot = image.lastIndexOf(".");
return `${image.slice(0, dot)}-t${tier}${image.slice(dot)}`;
}
const game = JSON.parse(readFileSync("config/game.json", "utf8"));
const svgDir = process.argv.indexOf("--svg") > 0 ? process.argv[process.argv.indexOf("--svg") + 1] : null;
const tmp = mkdtempSync(join(tmpdir(), "tool-art-"));
if (svgDir) mkdirSync(svgDir, { recursive: true });
try {
for (const item of game.storeItems) {
if (!TOOLS[item.id]) {
console.warn(`no art for store item ${item.id} (${item.name}), skipped`);
continue;
}
const maxTier = item.upgrades?.length ?? 0;
for (let tier = 0; tier <= maxTier; tier++) {
const name = tierImage(item.image, tier);
const src = join(svgDir ?? tmp, name.replace(/\.\w+$/, ".svg"));
writeFileSync(src, svg(item.id, tier, maxTier));
const png = join(tmp, "out.png");
execFileSync("rsvg-convert", ["-w", String(SIZE), "-h", String(SIZE), "-o", png, src]);
const dst = join(OUT, name);
const opts = name.endsWith(".jpg") ? ["-strip", "-quality", "88", "-interlace", "Plane"] : ["-strip", "-colors", "256", "PNG8:" + dst];
execFileSync("magick", [png, ...opts, ...(name.endsWith(".jpg") ? [dst] : [])]);
console.log(dst);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
+5 -2
View File
@@ -33,6 +33,8 @@ type server struct {
static fs.FS
// newPlayers limits how fast one IP can create players.
newPlayers *rateLimiter
// restores limits save code tries per IP (savecode.go).
restores *rateLimiter
}
func serve(ctx context.Context) error {
@@ -46,7 +48,7 @@ func serve(ctx context.Context) error {
}
go watchOverride(ctx, db)
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10)}
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5)}
if s.dev {
s.static = os.DirFS("web/static")
} else {
@@ -77,6 +79,7 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
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) })
@@ -412,7 +415,7 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
case "refused":
case "buy-moon":
var moonID string
if moonID, err = buyMoon(ctx, s.db, userID); err == nil {
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil {
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
}
case "sell-moon":
+59 -30
View File
@@ -37,8 +37,8 @@ type ReachCaps []reachCap
type reachCap struct{ caps, sums []float64 } // sums[i]: caps[:i] added up
// reachCaps rolls capRolls moons from a fixed seed (no well growth).
func reachCaps(c *GameConfig) ReachCaps {
// reachCaps rolls capRolls moons of the grade from a fixed seed.
func reachCaps(c *GameConfig, g *MoonGrade) ReachCaps {
n := max(1, len(c.ResourcePrices))
caps := make([][]float64, n)
rc := make(ReachCaps, n)
@@ -49,7 +49,7 @@ func reachCaps(c *GameConfig) ReachCaps {
for range capRolls {
kind := c.Moons[rng.IntN(len(c.Moons))]
var supply []float64
for _, w := range rollWells(c, kind, 1, false, rng.Float64) {
for _, w := range rollWells(c, g, kind, false, rng.Float64) {
supply = append(supply, w.Supply)
}
slices.SortFunc(supply, cmpDesc)
@@ -158,10 +158,10 @@ func avgPrice(c *GameConfig) float64 {
return sum / float64(len(c.ResourcePrices))
}
// avgWells is the expected number of wells on a moon (wellCountWeights).
func avgWells(c *GameConfig) float64 {
// avgWells is the expected number of wells on a moon of the grade (wellCountWeights).
func avgWells(c *GameConfig, g *MoonGrade) float64 {
sum, n := 0.0, 0.0
for count, w := range c.WellCountWeights {
for count, w := range g.WellCountWeights {
sum += w
n += w * float64(min(count, len(c.ResourcePrices)))
}
@@ -171,6 +171,30 @@ func avgWells(c *GameConfig) float64 {
return n / sum
}
// suitedGrade is the moon grade a tool is meant for: the one whose average well takes it
// closest to 6.5 claims (each of its reach wells gets a share of a claim).
func suitedGrade(c *GameConfig, item *StoreItem) *MoonGrade {
best, bestD := &c.MoonGrades[0], math.Inf(1)
perWell := item.ClaimAmount / float64(item.ToolReach(0))
for i := range c.MoonGrades {
g := &c.MoonGrades[i]
if d := math.Abs(math.Log(g.MeanSupply() / max(1e-9, perWell) / 6.5)); d < bestD {
best, bestD = g, d
}
}
return best
}
// configItemIn is the store item with the id in config c.
func configItemIn(c *GameConfig, id int) *StoreItem {
for i := range c.StoreItems {
if c.StoreItems[i].ID == id {
return &c.StoreItems[i]
}
}
return &StoreItem{ClaimAmount: 1}
}
func perHour(units float64, secs int) float64 {
if secs <= 0 {
return 0
@@ -215,27 +239,16 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
if len(errs) > 0 {
return a
}
g0 := &c.MoonGrades[0]
a.AvgPrice = avgPrice(c)
a.AvgWells = avgWells(c)
a.AvgSupply = (c.WellSupplyMin + c.WellSupplyMax) / 2
a.AvgWells = avgWells(c, g0)
a.AvgSupply = g0.MeanSupply()
a.MoonUnits = a.AvgSupply * a.AvgWells
a.MoonValue = a.MoonUnits * a.AvgPrice
a.Sim = simulate(c)
caps := reachCaps(c)
// Wells grow with moons owned (wellSupplyGrowth), so a tool is too big if it outclaims
// the biggest wells its reach covers on the next moon at the point the sim player buys it.
wellMax := func(id int) float64 {
moons := 0
if n := len(a.Sim.Snapshots); n > 0 {
moons = a.Sim.Snapshots[n-1].Moons
}
for i, item := range c.StoreItems {
if item.ID == id && a.Sim.Tools[i].BoughtAt >= 0 {
moons = a.Sim.Tools[i].Moons
}
}
return c.WellSupplyMax * c.wellScale(moons)
}
// Each tool is measured on moons of the grade it suits (suitedGrade); it is too big if it
// outclaims the biggest wells its reach covers there.
wellMax := func(id int) float64 { return suitedGrade(c, configItemIn(c, id)).WellSupplyMax }
items := slices.Clone(c.StoreItems)
slices.SortStableFunc(items, func(x, y StoreItem) int {
@@ -248,6 +261,7 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
return 0
})
for i, item := range items {
caps := reachCaps(c, suitedGrade(c, &item))
t := ToolStat{ID: item.ID, Name: item.Name, Price: item.Price, Claim: item.ClaimAmount,
Secs: item.CompletionTimeInSecs, Reach: item.ToolReach(0)}
t.Eff = caps.Yield(item.ClaimAmount, t.Reach)
@@ -272,7 +286,7 @@ func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
for _, u := range item.Upgrades {
yield, reach := item.ClaimAmount+u.ClaimBoost, item.ToolReach(u.Tier)
eff := caps.Yield(yield, reach)
rate := perHour(eff, item.CompletionTimeInSecs) * a.AvgPrice
rate := perHour(eff, item.ToolSecs(u.Tier)) * a.AvgPrice
prevRate := t.CreditsPerHour
if len(t.Tiers) > 0 {
prevRate = t.Tiers[len(t.Tiers)-1].CreditsPerHour
@@ -437,14 +451,28 @@ func simulate(c *GameConfig) *SimResult {
// that finishes between the player's actions waits for the next one.
stats := func(item, tier int, mods, adv []string) (int, float64) {
it := &c.StoreItems[item]
secs, y := drillStats(it.CompletionTimeInSecs, it.ToolYield(tier), c.bonusesFor(adv, mods), 1, 1)
secs, y := drillStats(it.ToolSecs(tier), it.ToolYield(tier), c.bonusesFor(adv, mods), 1, 1)
return (secs + simReaction - 1) / simReaction * simReaction, y
}
// rate is a tool's credits per hour, with what its reach gets from a fresh moon.
caps := reachCaps(c)
capsBy := map[string]ReachCaps{}
rate := func(item, tier int, mods, adv []string) float64 {
secs, y := stats(item, tier, mods, adv)
return perHour(caps.Yield(y, c.StoreItems[item].ToolReach(tier)), secs) * price * c.bonusesFor(adv, nil).Sell
g := suitedGrade(c, &c.StoreItems[item])
if capsBy[g.Grade] == nil {
capsBy[g.Grade] = reachCaps(c, g)
}
return perHour(capsBy[g.Grade].Yield(y, c.StoreItems[item].ToolReach(tier)), secs) * price * c.bonusesFor(adv, nil).Sell
}
// The grade of the next moon: the dearest one the player could buy twice.
nextGrade := func(credits float64) *MoonGrade {
g := &c.MoonGrades[0]
for i := range c.MoonGrades {
if c.MoonGrades[i].Price*2 <= credits {
g = &c.MoonGrades[i]
}
}
return g
}
reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach(t.tier) }
yield := func(t *simTool) float64 { _, y := stats(t.item, t.tier, t.mods, advisors); return y }
@@ -546,7 +574,8 @@ func simulate(c *GameConfig) *SimResult {
capped(t)
break
}
moonPrice := c.moonPriceFor(moons)
g := nextGrade(credits)
moonPrice := g.Price
if credits < moonPrice {
break
}
@@ -554,7 +583,7 @@ func simulate(c *GameConfig) *SimResult {
// Wells in units at the average price, which is what the sim sells at.
kind := c.Moons[rng.IntN(len(c.Moons))]
var moon []float64
for _, w := range rollWells(c, kind, c.wellScale(moons), moons == 0, rng.Float64) {
for _, w := range rollWells(c, g, kind, moons == 0, rng.Float64) {
units := w.Supply
if price > 0 {
units *= c.ResourcePrices[w.Resource] / price
@@ -639,7 +668,7 @@ func simulate(c *GameConfig) *SimResult {
consider(ch, income(append(slices.Clone(advisors), a.Name))-now)
}
// Keep enough for the next moon: with dry wells and no credits, nothing earns.
if best.kind == "" || credits-best.cost < c.moonPriceFor(moons) {
if best.kind == "" || credits-best.cost < nextGrade(credits).Price {
break
}
credits -= best.cost
+6
View File
@@ -0,0 +1,6 @@
-- Save codes (savecode.go): one per player, only a hash of the code is kept.
create table if not exists save_code (
user_id uuid primary key references users(id) on delete cascade,
code_hash text not null unique,
made_at timestamp with time zone not null default now()
);
+5
View File
@@ -0,0 +1,5 @@
-- A moon's grade (moonGrades in game.json: D, C, B, A, S). Moons from before grades were
-- rolled like today's C moons, so they get C. Safe to repeat.
alter table staking_source add column if not exists grade varchar not null default '';
update staking_source set grade = 'C' where grade = '';
+9
View File
@@ -45,6 +45,7 @@ create table staking_source (
created_at timestamp with time zone default now(),
kind varchar not null default '',
rating smallint,
grade varchar not null default '',
constraint fk_user_staking_source foreign key(user_id)
references users(id) on delete cascade
);
@@ -247,3 +248,11 @@ create table advisor_hire (
primary key (user_id, advisor)
);
create unique index advisor_seat on advisor_hire (user_id, seat) where seat is not null;
-- Save codes (savecode.go): one per player, to get back to it from another device or
-- after the cookie is gone. Only a hash of the code is kept.
create table save_code (
user_id uuid primary key references users(id) on delete cascade,
code_hash text not null unique,
made_at timestamp with time zone not null default now()
);
+19
View File
@@ -35,6 +35,11 @@ GET :host/?s=advisors&adv=Ada+Voss
POST :host/act/buy-moon
:headers
# Buy a moon of a grade (D, C, B, A, S; without one: the newest moon's grade)
POST :host/act/buy-moon
:headers
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
@@ -181,6 +186,20 @@ Content-Type: application/json
{"grant": "credits", "amount": 1000}
# Save code: whether the player has one ({ok, userId, saveCode: {madeAt}}; 0: none)
GET :host/api/save-code
X-MM-User: :user_id
# Make a new save code (the old one stops working): {..., saveCode: {code, madeAt}, state}
POST :host/api/act/save-code
X-MM-User: :user_id
# Restore from a save code: switches to its player (X-MM-User, cookie, userId); 422 no match, 429 too many tries
POST :host/api/act/restore
Content-Type: application/json
{"code": "ABCD-EFGH-JKMN-PQRS"}
# Sell resources (amount=<n> or all=1)
POST :host/api/act/sell
X-MM-User: :user_id
+46 -16
View File
@@ -193,8 +193,10 @@ func (v View) resolve(s *State) View {
if moon != nil {
v.Moon = moon.ID
}
if moon == nil || moon.Finished || moon.well(v.Well) == nil {
if moon == nil || moon.Finished || len(moon.Wells) == 0 {
v.Well = ""
} else if moon.well(v.Well) == nil {
v.Well = moon.Wells[0].ID // an owned moon always opens with a well selected
}
if v.Section != "equipment" || s.tool(v.Drill) == nil || !s.Unlocked("store") {
v.Drill = ""
@@ -304,7 +306,7 @@ type Page struct {
Store []StoreRow
Market []MarketRow
Trade *MarketPage // market section: quotes, chart, portfolio (market.go)
MaxSupply float64
MaxSupply float64 // a full supply bar: the selected moon's grade (else the default grade)
Refinery *RefineryPage // refinery section (refinery.go)
Gear []AttachmentCard // store section (attachments.go)
Drill *DrillPanel // equipment section, with a tool selected
@@ -756,7 +758,7 @@ func (s *State) toolGroups(idleOnly bool, resource string) []ToolGroup {
// page builds the screen for a view (already resolved).
func (s *State) page(v View) *Page {
p := &Page{S: s, V: v, MaxSupply: cfg().WellSupplyMax}
p := &Page{S: s, V: v, MaxSupply: s.MoonGrade().WellSupplyMax}
for _, t := range s.Tools {
if s.StakeForTool(t.ID) == nil {
p.Idle++
@@ -790,9 +792,6 @@ func (s *State) page(v View) *Page {
} else {
p.Moons = append(p.Moons, row)
}
for _, w := range m.Wells {
p.MaxSupply = max(p.MaxSupply, w.Supply)
}
}
p.Pending = endsAttr(allPending)
p.PastView = v.Past
@@ -825,9 +824,7 @@ func (s *State) page(v View) *Page {
short, wi := -1, slices.IndexFunc(moon.Wells, func(x Well) bool { return x.ID == w.ID })
for i := range p.Well.Idle {
g := &p.Well.Idle[i]
for _, t := range moon.split(Stake{WellID: w.ID, Amount: g.Yield, Reach: g.Reach}, remain) {
g.Here += t
}
g.Here = moon.takeAfter(remain, w.ID, g.Yield, g.Reach)
for _, j := range coverWells(wi, remain, g.Reach) {
g.Free += remain[j]
}
@@ -854,6 +851,10 @@ func (s *State) page(v View) *Page {
d.Sale = s.moonSale(moon)
}
p.Moon = d
p.MaxSupply = moon.GradeConfig().WellSupplyMax
for _, w := range moon.Wells {
p.MaxSupply = max(p.MaxSupply, w.Supply)
}
p.Scene.Select = map[string]string{"moonId": moon.ID}
if v.Well != "" {
p.Scene.Select["wellId"] = v.Well
@@ -1170,7 +1171,7 @@ type TierRow struct {
// tierStats is what a stake of the tool would take and return at the given tier, with its
// attachments, the player's advisors and live events (s.drill at its own tier).
func (s *State) tierStats(t Tool, item *StoreItem, tier int) (int, float64, int) {
secs, yield := drillStats(item.CompletionTimeInSecs, item.ToolYield(tier), s.toolBonus(t.ID),
secs, yield := drillStats(item.ToolSecs(tier), item.ToolYield(tier), s.toolBonus(t.ID),
s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
return secs, yield, item.ToolReach(tier)
}
@@ -1230,7 +1231,8 @@ func (s *State) bestToolRate() float64 {
return best
}
// creditsPerHour is what a tool earns per hour on an average fresh moon, deployed on its
// creditsPerHour is what a tool earns per hour on an average fresh moon of the player's
// default grade (State.MoonGrade), deployed on its
// richest well (what its reach can hold, as the economy page's sim counts it), sold at
// the average resource price now (market, live events, advisors).
func (s *State) creditsPerHour(yield float64, secs, reach int) float64 {
@@ -1243,23 +1245,26 @@ func (s *State) creditsPerHour(yield float64, secs, reach int) float64 {
price += s.sellPrice(name)
}
price /= float64(len(c.ResourcePrices))
return perHour(cachedReachCaps(c).Yield(yield, reach), secs) * price
return perHour(cachedReachCaps(c, s.MoonGrade()).Yield(yield, reach), secs) * price
}
// reachCaps rolls thousands of moons, so keep the current config's.
var reachCapsCache struct {
sync.Mutex
cfg *GameConfig
caps ReachCaps
caps map[string]ReachCaps // by grade
}
func cachedReachCaps(c *GameConfig) ReachCaps {
func cachedReachCaps(c *GameConfig, g *MoonGrade) ReachCaps {
reachCapsCache.Lock()
defer reachCapsCache.Unlock()
if reachCapsCache.cfg != c {
reachCapsCache.cfg, reachCapsCache.caps = c, reachCaps(c)
reachCapsCache.cfg, reachCapsCache.caps = c, map[string]ReachCaps{}
}
return reachCapsCache.caps
if reachCapsCache.caps[g.Grade] == nil {
reachCapsCache.caps[g.Grade] = reachCaps(c, g)
}
return reachCapsCache.caps[g.Grade]
}
// formatCompact formats credits short: "₥950", "₥1.2k", "₥19k", "₥1.4M".
@@ -1362,6 +1367,27 @@ func (s *State) project(m *Moon) projection {
return p
}
// takeAfter is what a new stake (amount, reach) on the well would get from the moon after
// the drills already there are claimed (remain: the projection's Remain). Nothing (0) and
// the stake is refused (stakeInTx: errWellCommitted).
func (m *Moon) takeAfter(remain []float64, wellID string, amount float64, reach int) float64 {
total := 0.0
for _, t := range m.split(Stake{WellID: wellID, Amount: amount, Reach: reach}, remain) {
total += t
}
return total
}
// newStakeTake is takeAfter for a moon and its unclaimed stakes (oldest first).
func newStakeTake(m *Moon, stakes []Stake, wellID string, amount float64, reach int) float64 {
s := &State{Stakes: stakes}
return m.takeAfter(s.project(m).Remain, wellID, amount, reach)
}
// Blocked is a deploy card whose tool would get nothing here: the drills already on the
// moon take all that is left (the stake is refused).
func (g ToolGroup) Blocked() bool { return g.Here <= 1e-9 }
func maxTime(a, b time.Time) time.Time {
if b.After(a) {
return b
@@ -1404,6 +1430,10 @@ func (r WellRow) Full() bool {
return r.Supply > 0 && (r.Demand > 0 && r.Demand >= r.Supply-1e-9 || r.Remain <= 1e-9 && r.Taken() > 0)
}
// FullyCommitted is a well its drills will empty: nothing is left for another one-well
// drill (stakeInTx refuses it).
func (r WellRow) FullyCommitted() bool { return r.Supply > 0 && r.Remain <= 1e-9 }
// StatusText is the well note's lead (template "well-note"): "Over-drilled — next claims
// will come up short", "Fully committed" or "".
func (r WellRow) StatusText() string {
+20
View File
@@ -4771,6 +4771,26 @@ textarea.eco-input {
}
}
.nm-grades {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 18px;
}
.nm-grade-list {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
}
.nm-grade-list li {
display: flex;
flex-direction: column;
gap: 4px;
}
.nm-hints {
display: grid;
grid-template-columns: 1fr 1fr;
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more