Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d09cbc52 | ||
|
|
97ed243b34 | ||
|
|
63d8f633d3 | ||
|
|
daa6c9bc5a | ||
|
|
850e2a136a | ||
|
|
48bc94ad76 | ||
|
|
508ff12e57 | ||
|
|
30a2548b2a | ||
|
|
00391580ce | ||
|
|
103d4b4ac2 | ||
|
|
59812479cf | ||
|
|
34b8846976 | ||
|
|
f52f28d2e9 | ||
|
|
4a02faeee4 | ||
|
|
45246e8399 | ||
|
|
f5a65d53cb | ||
|
|
d800baf9a9 | ||
|
|
ffbe30fa78 | ||
|
|
1462e2e9f1 | ||
|
|
ef4eea8147 | ||
|
|
da27a9ca02 | ||
|
|
e040e3b986 | ||
|
|
6dd70e4cae | ||
|
|
450c371e43 | ||
|
|
8bbe20ba25 | ||
|
|
1487f1bdcd | ||
|
|
63360022c8 | ||
|
|
9d6f912f13 |
No files matched your search
@@ -13,3 +13,6 @@
|
||||
# Blender files
|
||||
*.blend
|
||||
*.blend1
|
||||
|
||||
# Agent worktree state (scripts/wt)
|
||||
/.wt/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<` `&`). `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`.
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 877 B |
@@ -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.
|
||||
@@ -31,6 +31,9 @@ images {
|
||||
images {
|
||||
image: "/assets/images/sun.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/gear.png"
|
||||
}
|
||||
images {
|
||||
image: "/assets/images/moon.png"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() }
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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 = '';
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |