Compare commits

...
185 Commits
Author SHA1 Message Date
JosephFerano 64e1d8c0f4 Fix endpoint issue 2026-10-27 17:57:58 +07:00
JosephFeranoandClaude Opus 5.5 de05c7215f Merge opt-rebuild: cheap signatures, Moons panel in kept parts, fewer rebuilds on the server's answer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:28:03 +07:00
JosephFeranoandClaude Opus 5.5 864f357fd2 Aside facts are their own block on Moons, the moon list's sig is what its rows show, drills ending on our clock don't rebuild banner, header or moon list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 986012a80d Moons: heading, well cards, drill and deploy slots, the multi-well card and tray tiles are kept parts; a deploy rebuilds only what it changed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 4f0f2f13e3 blocks.sig writes a frozen state's parts as content tokens, once per state; kept blocks renew their Druid uids so a kept button keeps input priority
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:27:15 +07:00
JosephFeranoandClaude Opus 5.5 1c5d1f372a Merge opt-market: market series cached per tick, config cached, moon descriptions only where sent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:22:51 +07:00
JosephFeranoandClaude Opus 5.5 9ad12796e1 Merge opt-state: indexes (migration 022), index-friendly state queries, grouped stakes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:58 +07:00
JosephFeranoandClaude Opus 5.5 7980b7bc23 State loads: indexes for the per-player reads (migration 022), uuid[] past-moon queries that use them, last well per tool, wells and stakes grouped once per request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:03:12 +07:00
JosephFeranoandClaude Opus 5.5 95d3f6e2ce Merge opt-claim: claim-all in a fixed number of statements, proven equal by a golden test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:51 +07:00
JosephFeranoandClaude Opus 5.5 bfcf2a8d9a Claim-all timing test: a crowded case, 900 drills on 25 moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:20 +07:00
JosephFeranoandClaude Opus 5.5 b5373c8c62 Merge opt-equip: Equipment as a long list of kept rows; ladders sent once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:56:10 +07:00
JosephFeranoandClaude Opus 5.5 3f1e706741 Test the market series cache against computed rates and a config change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:55:16 +07:00
JosephFeranoandClaude Opus 5.5 20278f588f wt: a worktree's .env names its own database (mm_<task>); AGENTS.md: never git stash in a worktree (the stash list is shared)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:54:48 +07:00
JosephFeranoandClaude Opus 5.5 38a7140f26 AGENTS.md: the claim-all database test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:07 +07:00
JosephFeranoandClaude Opus 5.5 445436f943 Equipment rows are a long list (only the rows in view exist) and each row is kept while what it shows stays the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:00 +07:00
JosephFeranoandClaude Opus 5.5 e140bc30fc Claim-all in a few statements: lock and read the moons once, play the claims and redeploys through in memory, write each table with one statement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:49:19 +07:00
JosephFeranoandClaude Opus 5.5 4446cad274 Claim-all database test: golden scenarios of the current claim rules and a timing test (MM_TEST_DATABASE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:42:45 +07:00
JosephFeranoandClaude Opus 5.5 681927fc23 Equipment sends each upgrade ladder once per store item and levels (equipment.ladders, rows name theirs by ladderKey)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:39:49 +07:00
JosephFeranoandClaude Opus 5.5 ff656930c8 Moon descriptions: loadState leaves them out; the moon view loads the selected moon's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:41 +07:00
JosephFeranoandClaude Opus 5.5 78f611fd8c loadConfig: cache resources and store items per config snapshot (re-read every 30 s for other processes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:33 +07:00
JosephFeranoandClaude Opus 5.5 0dbe349da5 Market: cache each item's 24h series per tick and config snapshot, shared by all requests; drop the unsent SVG chart and sparkline paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:19 +07:00
JosephFeranoandClaude Opus 5.5 429199f462 Client builds run bob.jar with the Defold editor's own Java (found on Linux, macOS and Windows; DEFOLD, JAVA override), else java from PATH
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:51:52 +07:00
JosephFeranoandClaude Opus 5.5 b8f877fb45 Admin: plain look, no motion: no entrance or tab-slide scripts, no dialog animations, no glows, gradients, blur or corner brackets, system fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:07:43 +07:00
JosephFeranoandClaude Opus 5.5 ec6edefd24 READMEs: the browser game at :3000 (served by the server), admin at /utils, MM_WEB_DIR, migrate after config edits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:05:17 +07:00
JosephFeranoandClaude Opus 5.5 dac62506b6 The game server serves the web build at / (client/dist/web/MoonMiners, MM_WEB_DIR); the admin stays at /utils
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:04:44 +07:00
JosephFeranoandClaude Opus 5.5 4bb2188564 Moons: the greyed deploy slot's line is the plain divider color (its own faint alpha)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:58:01 +07:00
JosephFeranoandClaude Opus 5.5 de0977f7a4 Moons: a well card's deploy slot that can't deploy (no idle drills, no room) is drawn greyed: a plain line, no fill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:57:51 +07:00
JosephFeranoandClaude Opus 5.5 8a90adb515 Dusk Alloy sells for 28 (was 24): its refining earns per second about what Cryo Cell's does; Void Core 460 (was 450), keeping its gain over its inputs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:50:02 +07:00
JosephFeranoandClaude Opus 5.5 cad48d8856 Refinery collect: motes land on the owned number's text (its pivot and text width), not right of and under it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:43:11 +07:00
JosephFeranoandClaude Opus 5.5 7da4ab8103 Client: predict deploys and claims of multi-well drills too (every drill is one now), so deploys and claims show at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:33:38 +07:00
JosephFeranoandClaude Opus 5.5 80cb1ffb07 Moon packs: 5, 10 or 20 moons of a grade at once (5, 10, 15% off; each moon worth at least 1.4, 1.5, 1.6x its price), once the store is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:32:48 +07:00
JosephFeranoandClaude Opus 5.5 9b61acd61a Every drill reaches the whole moon: a claim takes what its well lacks from the moon's next wells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:23:25 +07:00
JosephFeranoandClaude Opus 5.5 b0bc8135bb D moons: wells hold 28–56 units (was 23–46), about a fifth more
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:32 +07:00
JosephFeranoandClaude Opus 5.5 1510bf10a3 Drill upgrades cost credits only: no refined parts on any track level
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:10:09 +07:00
JosephFeranoandClaude Opus 5.5 75f1820e83 Equipment: a selected drill's panel takes the whole side pane (no moon over it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:08:25 +07:00
JosephFeranoandClaude Opus 5.5 8f57dedfe6 Equipment rows: the drill picture, square, takes the row's whole height; Status a little narrower so names fit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:04:32 +07:00
JosephFeranoandClaude Opus 5.5 afb4dc4cca Settings menu rows take real clicks again: the moon viewport ignores the mouse while a dialog is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:03:00 +07:00
JosephFeranoandClaude Opus 5.5 956d6c506e shot.sh keeps the game's saved files beside its screenshots; make play needs MM_API and passes it
make play ignored MM_API and ran with mm.new_player=1 against localhost:3000 and the
player's own save folder, clearing their saved player id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:51:48 +07:00
JosephFeranoandClaude Opus 5.5 329b954e31 Merge fix-native-build: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 b837a90f49 Merge fix-cl-ui: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 d4cf789906 Merge fix-cl-screens: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 4a8f6a5013 Merge fix-cl-moons: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 f5c796631f Merge fix-cl-core: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 623766b535 Merge fix-srv-sim: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 69ac96522c Merge fix-srv-admin: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 8ba5c6c9e2 Merge fix-srv-http: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 75faa787fd Merge fix-srv-econ: code review fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:02:34 +07:00
JosephFeranoandClaude Opus 5.5 1e98c01cf3 Autoplay: press the reveal's Buy another (reveal-buy) instead of the hidden + New moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 0c4c955ddf Autoplay: confirm buttons register their action and their confirm, so the refinery script reaches new-game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 ebe72d9ee5 Moon lab: a save that finishes after leaving the lab still updates the game's moons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 e31a597f82 Market chart: measure the crosshair's hit box on every move, so scrolling doesn't leave it stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:51 +07:00
JosephFeranoandClaude Opus 5.5 330da8bb17 Config: refuse resource prices for resources the database doesn't have; fail a moon buy whose well isn't stored
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:58:55 +07:00
JosephFeranoandClaude Opus 5.5 42e11b1d7e Clicker sim: reserve the dearest moon grade bought, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 4c2426370d Clicker sim: value purchases on the dearest moon grade bought, not the last
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:57:56 +07:00
JosephFeranoandClaude Opus 5.5 3595ff48e9 Economy sim: keep the dearest moon grade bought as the reserve, not one picked from credits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:56:49 +07:00
JosephFeranoandClaude Opus 5.5 fc2eea4681 fit_text: cut whole UTF-8 characters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 caa632fc18 Shake: a new shake on a shaking node keeps its resting place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 e48b3fb512 Flex labels: copy the caller's opts so a shared table cuts every label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 0135e66636 Focus: a remade list row drops its dead entry and keeps the plain key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:52 +07:00
JosephFeranoandClaude Opus 5.5 05579a8fef Client: the redrill prediction picks drills by their amount with the yield event
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 8ea2989441 Client: the redeploy prediction finds the drill's own group (track levels too)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:53:46 +07:00
JosephFeranoandClaude Opus 5.5 803788cb72 Client: the well panel shows a multi-well drill's take from this well as here
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 1b6410ebfd Client: compact picks its unit after rounding (1M, not 1000k)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:32 +07:00
JosephFeranoandClaude Opus 5.5 22b592a533 Announcements: count the 200-character limit in characters, as the form does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 d3ff69a5eb Live ops dashboard: leave players whose return day is still running out of D1 and D7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 cbce3fab46 Live ops dashboard: count batch upgrades as spent and moon and tool sales as earned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:52:03 +07:00
JosephFeranoandClaude Opus 5.5 e3af1799bf Economy analysis: name saved tier and grade points from the saved config, no panic with no drills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 cfa617ed93 Players page: send the whole page on an htmx history restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 dfec5ec57a Economy editor: show only the newest analysis when replies come back out of order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 6d8f55c21a Economy editor: refuse a save from a page loaded before a newer save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:52 +07:00
JosephFeranoandClaude Opus 5.5 e34f06e1bc Config: refuse a game config with no store items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:21 +07:00
JosephFeranoandClaude Opus 5.5 3aa4017525 Economy sim: buy what the player can pay now even with no income
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:09 +07:00
JosephFeranoandClaude Opus 5.5 549de045b0 Server: HOST picks the listen address; wt servers (MM_DEV=1, no admin login) listen on 127.0.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 f3be4066e8 wt: refuse task names over 60 characters (mm_<task> must fit Postgres's 63-byte names)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:50:25 +07:00
JosephFeranoandClaude Opus 5.5 a66e32acef dev.py: a pid counts as the dev game or its Xvfb only if its command line says so
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:27 +07:00
JosephFeranoandClaude Opus 5.5 bd36b8fc4f Buy modal: suggest only wells with something left after current drills, and label the highest value among those shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 228e7af4c9 API: answer 500 when the player or ban lookup fails, instead of making a new player or skipping the ban
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 8263491301 New-player limit: count an IPv6 client by its /64
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 c7ff9b27c7 Server: wait for running requests to finish before closing the database on shutdown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:12 +07:00
JosephFeranoandClaude Opus 5.5 1bc3677f50 drive.py --keep: leave the Xvfb display the kept game draws on running
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:08 +07:00
JosephFeranoandClaude Opus 5.5 2a0eb04b12 buy-moon checks the new-moon gate again under the player's row lock, so two buys at once can't both pass it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:03 +07:00
JosephFeranoandClaude Opus 5.5 9ec5436fb8 drive.py and views.sh: no default server, MM_API or --api is required
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:00 +07:00
JosephFeranoandClaude Opus 5.5 61b696b08a Moon viewport: round its size once so it doesn't flip by a pixel while it moves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 7556795a65 Local Postgres: publish its port on 127.0.0.1 only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:36 +07:00
JosephFeranoandClaude Opus 5.5 9eb20f9e15 Client test hook: a failing multi-frame step replies with its error instead of failing every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:48:05 +07:00
JosephFeranoandClaude Opus 5.5 1258b774c3 Client: each recipe's Refine and the market's Sell get their own busy key and test id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:51 +07:00
JosephFeranoandClaude Opus 5.5 430b560347 Redrill on a full well tries every idle drill, not only those before the first that can't go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:48 +07:00
JosephFeranoandClaude Opus 5.5 738cb32eb1 Stake and redrill lock the moon's wells before the tools, as claims do, so they can't deadlock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:38 +07:00
JosephFeranoandClaude Opus 5.5 74ce60805a Reveal card: a well bigger than every sampled one reads 'under 1 in N moons', not '1 in 0'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:28 +07:00
JosephFeranoandClaude Opus 5.5 bc95fce37a Client: view history moves only when the back/forward answer comes, and not on a failed one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:47:26 +07:00
JosephFeranoandClaude Opus 5.5 bd363b3bde Count a live event on all resources once in the cycle and refinery speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:46:06 +07:00
JosephFeranoandClaude Opus 5.5 fac922ddd0 Count a speed event once in PriceBoosts.For("all")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:45:09 +07:00
JosephFerano 4c04651c52 Progression: advisor payback counts a full moon of the newest drill (one per well) 2026-10-22 22:30:52 +07:00
JosephFerano f136a2a3c4 Economy curves: legend; cache reach caps; note that bands follow the draft 2026-10-22 22:23:05 +07:00
JosephFerano 58be6c2387 server/AGENTS.md: progression charts 2026-10-22 22:21:21 +07:00
JosephFerano 1aab2394ab Economy: curves per drill tier, moon grade and upgrade level (small multiples) 2026-10-22 22:21:13 +07:00
JosephFerano be1aab1cf8 Economy: clicker sim timeline (credits, income, purchases, grades, idle stretches)
Runs on demand from the Analysis tab (POST /utils/economy/sim) for the saved
config and the draft; the sim records samples, purchases and idle stretches.
2026-10-22 22:19:32 +07:00
JosephFerano 64fe6ba6c5 Economy: payback-per-purchase chart in the Analysis tab (saved vs draft)
Server-drawn SVG charts (chart.go). payLadder lists every purchase in buying
order by moon grade, with minutes to earn it back and jumps over 2x the step
before. Dev-only ?draft= (merge patch) and ?theme=light for screenshots.
2026-10-22 22:16:58 +07:00
JosephFerano 36a10a1a67 Client checks: drop moon-check (terrain.js is gone) 2026-10-22 22:07:21 +07:00
JosephFerano a938cba25e Merge branch 'admin' 2026-10-22 22:06:33 +07:00
JosephFerano 4f1671cc48 Docs: the server is the API and the admin; Defold is the only client 2026-10-22 22:04:25 +07:00
JosephFerano e5328d4178 Moon lab: no ± in the variation note (not in the fonts) 2026-10-22 22:02:31 +07:00
JosephFerano 50231812c8 Admin login: ADMIN_PASSWORD guards /utils
A login form at /utils/login sets a random session token (mm_admin cookie,
HttpOnly, SameSite Lax, kept in memory); /utils pages redirect to it without a
session and actions get a 401. No login with MM_DEV=1; no password means the
admin is locked. Passwords are compared in constant time, a wrong one waits a
second, tries are limited per IP. POST /api/moon-settings takes the admin
cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV.

Admin actions log "admin" instead of the admin's player id. Dropped
POST /utils/clear-data (reset the admin's own player) and /utils/load-config
(the load-config command does it), and the "You" marker and self-ban check
on the player page.
2026-10-22 22:02:09 +07:00
JosephFerano 11081486dc Client: admin area with a moon lab (live moon settings editor, Save to /api/moon-settings) 2026-10-22 22:00:01 +07:00
JosephFerano 8d0579f2c3 Delete the htmx game client; keep the admin pages
The game is the Defold client on the JSON API now. GET / redirects to /utils.
Removed the game page, POST /session and /act/, the game templates and JS,
three.js, idiomorph, ZzFX, the sounds, the web moon lab (/utils/moons) and
Tailwind. The admin pages get their own plain CSS (web/static/css/admin.css),
cut from app.css.
2026-10-22 21:58:34 +07:00
JosephFerano d05d5747de Delete the Preact web client experiment 2026-10-22 21:50:49 +07:00
JosephFerano 9b6b0b1204 Merge branch 'equip-rows' 2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 8058077454 Client: Moons content as wide as a card grid (wide windows fit 5 well cards); a well card's + Deploy sends multi-well drills too (one way to place every drill); the sale row's label and offer keep their size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:11:26 +07:00
JosephFeranoandClaude Opus 5.5 4a060f8a91 Equipment is a table: a row per drill (image, name with copy number and levels, status, yield and cycle with level pips, each track's Upgrade and Sell under them, short notes kept under those); a row opens the drill panel, Sell asks in place, before the Store the upgrades and Sell stay hidden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:10:00 +07:00
JosephFeranoandClaude Opus 5.5 17a2cfbfa5 Multi-well drills mine their own well then the next ones in the moon's order (wrapping round), not the richest; an empty or soon-tapped well is passed over; the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:45:41 +07:00
JosephFeranoandClaude Opus 5.5 c59b8d9140 Refinery: batches start at 100; upgrades 250, 500, 1000, 2500, 5000, 10000 at the same prices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:39:56 +07:00
JosephFeranoandClaude Opus 5.5 ba77879350 Equipment lists every tool on its own, copies too; upgrade and sell act on that tool (no idle-copy swap)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:37:57 +07:00
JosephFeranoandClaude Opus 5.5 6363a40ba3 Client: recipe cards show what you own as a heading (OWNED over a big number in the item's color, at 0 too); a collect's motes fly into it and it counts up
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:30:51 +07:00
JosephFeranoandClaude Opus 5.5 15f8ad539b Refinery: shorter batches: bulkLog 1.0 (was 1.83) and seconds Dusk 1, Cryo 2, Plasma 2, Void 3, Stellar 3: a full batch of 50 Cryo Cells takes about 10 s (was 16), 5000 about 19 s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:22:11 +07:00
JosephFeranoandClaude Opus 5.5 dd028afd3e Balance: wider steps: grades and their drills scaled together per grade (C x2, B x3, A x5, S x8: prices, wells, yields, upgrades), so the jump to the next grade outgrows income; refined items gain about 20% over inputs (was 40-100%). Sim: first B 30m (was 15m), S 2h33 (was 1h04)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:20:44 +07:00
JosephFeranoandClaude Opus 5.5 4fbef903ec Client: recipe cards put Max (as many as you can) full width and primary, Refine beside the quantity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:13:23 +07:00
JosephFeranoandClaude Opus 5.5 f79ac6f3b9 Refinery: batches start at 50 (C moons hold far more now); upgrades 100, 250, 500, 1000, 2500, 5000 for 6k to 15M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:12:34 +07:00
JosephFeranoandClaude Opus 5.5 844b181644 Refinery: Void Core and Stellar Lattice show (and refine) once a B moon or higher is bought (refined item unlockGrade; onboarding flags grade:<G>, backfilled by migration 021)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:11:56 +07:00
JosephFeranoandClaude Opus 5.5 6f90df026a Client: the main panel's entrance replays when the section changes, and the moon only on Moons: Claim all finishing a moon (the view moves to another) no longer replays the Refinery's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:07:10 +07:00
JosephFeranoandClaude Opus 5.5 0091d2a099 Client: why Sell is off shows in place, not only in a tooltip (the drill panel: "Still mining. Sell it once it's idle" for its note; the moon sale: a line under its row)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:05:07 +07:00
JosephFeranoandClaude Opus 5.5 55acf50692 Client: Sell moon (destructive) moves to the foot of the side pane, compact there; a tray drill deploys straight to the selected well on click (no picking); no Redrill on a well its drills already take in full
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:55:51 +07:00
JosephFeranoandClaude Opus 5.5 8b07425d25 Client: a well card's ready drills show Claim only (no Redeploy) when claiming leaves the well tapped out or its rest taken by other drills; the same for the multi-well card and the moon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:46:40 +07:00
JosephFeranoandClaude Opus 5.5 04dee47571 Balance: progression pass: each drill takes about 4-5 claims per well on its grade and rips the grade below (yields PD-1 8, PX-4 60, L100 300, L300 4000, LRE 48000, EDE 110000, LCE 200000); drills priced so each tier earns a bit more per credit; upgrade levels repriced against copies
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:34:20 +07:00
JosephFeranoandClaude Opus 5.5 d0716bf89a Merge main (per-drill upgrade tracks) into drill-cards
Keeps the well cards and the status-only aside; names follow the tracks model (code and
levels tag, e.g. "PD-1 Y2 C1"), no tiers or attachment tags on cards, tray or pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:28:21 +07:00
JosephFeranoandClaude Opus 5.5 c278865a24 Moons: well cards with every action in the main panel; the aside only shows
Each well is a card: supply and bar, a deploy slot (sends the drill picked in the new
tray of idle drills, else the smallest one-well drill with room), a slot per drill (time
and bar; when done, gold with Claim and Redeploy on it), and an idle slot with Redrill.
Multi-well drills get a card across the grid; Sell moon moves to the foot of the main
panel. The aside keeps the viewport and shows the well's or the moon's facts, no buttons;
picking a well no longer scrolls the narrow layout to it. Scenario wellcards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:21:35 +07:00
JosephFeranoandClaude Opus 5.5 b76a189d22 Client: Equipment cards and the drill panel show the two upgrade tracks (value and next value, Upgrade or Max level, a kept need line) and each track's ladder; attachments, slots, spares and tiers removed; prediction groups copies by track levels
Scenario tracks.py (click_check on both upgrade buttons); fixture, drillonboard, sellask,
texts and others updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
JosephFeranoandClaude Opus 5.5 069bf39ad2 Drill upgrade tracks replace attachments and the tier ladder: each drill has a yield track and a cycle track of its own (flat values, 4-5 levels, refined parts from level 3, per tool); upgrade takes track=yield|cycle
Migration 020: a tool's tier N becomes cycle level N, attachments are refunded at their
price (logged refund_attachments) and dropped, upgrade_item is dropped, stake cycles are
kept to a tenth of a second. Attachments are gone from the config, actions, Store, drill
panel, cards, onboarding, sims, economy and players pages and the Preact client. Redrill
picks drills by their track yield. Prices calibrated with the sim: a first level is ~1.03x
a copy per credit; boosts player C 13m18, B 23m39, A 1h05m50, S 3h01m59 (was 13m15,
22m26, 1h08m39, 3h00m21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:59:18 +07:00
JosephFeranoandClaude Opus 5.5 7e18f99796 Redrill sends the smallest drill that finishes the well's remaining room alone, else the biggest and again (two 4s and a 20 on a well with 3 left: a 4); the client predicts the same
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:49:40 +07:00
JosephFeranoandClaude Opus 5.5 eda38810cd Refinery unlocks with the first C moon (was B)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:30:57 +07:00
JosephFeranoandClaude Opus 5.5 91c71fd9c8 Balance: no duds early: D and C moons are never worth under 1.3x their price (minValue re-rolls the poorer ones), with wells 30% smaller; B and up keep their duds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:02:46 +07:00
JosephFerano 55f28caa14 Merge branch 'drill-onboard' 2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 71dc1d1b1d Equipment: a tool card opens its drill panel as soon as Equipment is unlocked (onboarding); the panel's Store parts (upgrade, slots, spares, sale) wait for the Store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:41:47 +07:00
JosephFeranoandClaude Opus 5.5 2e6872a824 Balance: moons cost twice as much (D 300 ... S 3M) and their wells hold 2.05x (worth about 2.5% more for the price); sim: C 23 m, B 37 m, A 1 h 08, S 2 h 52-2 h 58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:38:12 +07:00
JosephFeranoandClaude Opus 5.5 fa18e8c1ed Balance: drill cycles 3-16 s (PD-1 3, PX-4 6, L100 8, L300 10, LRE 12, EDE 14, LCE 16) with the same claim sizes: income 2-4x, progression about twice as fast in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:36:08 +07:00
JosephFeranoandClaude Opus 5.5 a199fdce0c Client: moon list rows put the grade and rating chips on the second line, before the status, so they don't run into the ready badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:34:04 +07:00
JosephFeranoandClaude Opus 5.5 5bc0a3deab Balance: C moons cost 3000 with wells of 150-300 (value unchanged at ~1.7x), so the starting 1500 no longer buys one; first C at ~35 min in the sim
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:32:31 +07:00
JosephFeranoandClaude Opus 5.5 b51a6539d3 Client: the mystery moon's shroud and motes turn slowly (0.25 rad/s; off with mm.moon_spin=0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:10:58 +07:00
JosephFeranoandClaude Opus 5.5 583da23d32 Client: the side panel (Top miners) shows on the Market only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:09:29 +07:00
JosephFeranoandClaude Opus 5.5 f43f0be1a8 Client: the side panel (Top miners) never shows beside the moon pane
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:09:12 +07:00
JosephFeranoandClaude Opus 5.5 a47c4ba716 Client: the reveal card widens for big moons so the wells' supply/max numbers have room (S: +41 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:59:37 +07:00
JosephFeranoandClaude Opus 5.5 1897fc9905 READMEs: rerun make migrate after pulling (migrations and config sync), esbuild download, make build, migrations 014-019
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:23 +07:00
JosephFeranoandClaude Opus 5.5 7ec439f248 Refinery: up to 10 slots (slot prices to 2.5M); the recipe card's Refine gets its own full-width row under the quantity and Max
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:52:49 +07:00
JosephFeranoandClaude Opus 5.5 8faa1b131f Client: the announcements pane keeps the last 5 and is sized to them (the moon list gets the rest)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:47:00 +07:00
JosephFeranoandClaude Opus 5.5 6caae5d3b4 Client: tab counts are a small badge on the tab's top-right corner, so Moons and Refinery don't carry an empty gap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:45:29 +07:00
JosephFeranoandClaude Opus 5.5 dbbc194052 Client: Buy tools pinned to the Equipment panel's top-right corner, not the capped column's edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:35:40 +07:00
JosephFeranoandClaude Opus 5.5 d3ea0bfc89 Refinery: batch time grows with the log of the batch (seconds × min(n, 1 + bulkLog × ln n), bulkLog 1.83: 25 unchanged, 2500 takes 15× one unit instead of 109×)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:29:59 +07:00
JosephFeranoandClaude Opus 5.5 bafa5e0bce Client: a kept block keeps the keyed blocks inside it (a kept Equipment section lost its cards until the next rebuild); Equipment's heading row is full width so Buy tools sits at its right edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:28:55 +07:00
JosephFeranoandClaude Opus 5.5 fe86565b55 Client: Sell moon moves to the moon pane (the aside, under the selected well)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:22:14 +07:00
JosephFerano f564890ac0 Merge branch 'refinery-juice' 2026-10-21 18:15:28 +07:00
JosephFerano ef17e3b1d9 Merge branch 'market-ui'
# Conflicts:
#	client/screens/game/core/main.lua
2026-10-21 18:15:28 +07:00
JosephFeranoandClaude Opus 5.5 909cdc7412 Refinery: batch size upgrades (refinery.batchUpgrades, users.refinery_batch_level, upgrade-batch); finish and collect feedback in the client; stock chips row removed from the client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:09 +07:00
JosephFeranoandClaude Opus 5.5 07b4185cc3 Client: bigger market cards with the held amount prominent; leaderboard as a right side panel (sidepanel.lua, room for more); sells ask in place instead of a dialog (attachment, drill, moon)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:09 +07:00
JosephFeranoandClaude Opus 5.5 dd57a34a6d Client: the main panel rebuilds only for the timers it shows (no Refinery rebuild per drill); one refetch for ends within a second; refetch at a banner item's end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 0db237c1a5 Client: equipment cards keep the need line's place so buttons line up; reveal card buttons fit the card and brighten on hover instead of growing from a corner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 2723bbd842 A moon opens on its first well with supply left
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 76293f2cc1 Banner items leave at their end: the web drops them on its clock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:15:05 +07:00
JosephFeranoandClaude Opus 5.5 bfc066a1b3 Tools show their original images again: drop the generated per-tier art
The tier art from scripts/tool-art.mjs is out; StoreItem.ImageAt falls back to the base
image when no -tN file exists.

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 877 B

+2 -1
View File
@@ -27,7 +27,8 @@ The claim whir, counter ticks, claim blips, credit blips and the moon reveal sou
(harvest.wav, tick.wav, arrive.wav, coin.wav, charge.wav, burst.wav, powerdown.wav,
shimmer.wav) are synthesized with ZzFX by Frank Force (MIT, LICENSE-ZzFX.txt),
https://github.com/KilledByAPixel/ZzFX, pre-rendered by scripts/zzfx-render.mjs with the
web build's parameters (server/web/static/js/sfx.js).
web build's parameters (server/web/static/js/sfx.js). rumble.wav and reveal.wav (the new-moon
reveal's build-up and burst, client only) are synthesized by the same script.
Copied from the web build (server/web/static/assets/sfx); the .mp3 clips were re-encoded
to Ogg Vorbis (Defold plays .wav and .ogg) with ffmpeg -c:a libvorbis -q:a 6.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -31,6 +31,9 @@ images {
images {
image: "/assets/images/sun.png"
}
images {
image: "/assets/images/gear.png"
}
images {
image: "/assets/images/moon.png"
}
+1 -1
View File
@@ -5,7 +5,7 @@
-- on top instead (the web's card). card.grid lays cards out in columns (as many as
-- card_col_min allows, like the web's grid; centered and capped by the caller), equal
-- heights per row, and
-- registers them for the panel entrance (g.frames, staggered). Store, Equipment and
-- registers them for the panel entrance (g.frames, staggered). Store and
-- Advisors use it; the Refinery's recipes too (opts media = 0: no image).
--
-- y = card.grid(g, parent, x, y, inner, items, function(c, item)
+22 -4
View File
@@ -1,13 +1,15 @@
-- Chip / badge (the web's .badge, tier chips, attachment tags): a 1 px outlined box with
-- Chip / badge (the web's .badge, level chips, advisor tags): a 1 px outlined box with
-- a short label, optionally filled. Returns the box node and its width.
--
-- chip.new(parent, "T2/5", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, "Y2 C1", x, y, { color = "line_hi", text = "text_2", font = "mono" })
-- chip.new(parent, 3, x, y, { color = "ready", font = "mono", px = 12, min_w = 22 })
-- chip.new(parent, "Turbo core", x, y, { color = "violet", text = "violet_hi", upper = true })
--
-- 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
+20 -4
View File
@@ -7,6 +7,9 @@
-- cancel = "Cancel" (the other button; "Close" without ok),
-- build = function(d, box, w, h) ... end, -- custom content instead of the message
-- title_x = 24 }) -- the title's x (room for an image left of it)
-- menu = { right, top }: a dropdown instead (the header's settings menu): no dimmed
-- backdrop (a click outside still closes it), no title or buttons, its top-right
-- corner `right` from the page's right edge and `top` down; build makes the content.
-- g.dialog:close("ok" | "cancel")
-- g.dialog:is_open()
--
@@ -107,12 +110,13 @@ function M:open(opts)
-- Backdrop (a click on it cancels), then the panel in a clip that unclips from its middle.
local root = ui.rect(self.layer, 0, 0, W, H, "none")
self.root = root
local scrim = ui.rect(root, 0, 0, W, H, SCRIM)
local menu = opts.menu
local scrim = ui.rect(root, 0, 0, W, H, menu and "none" or SCRIM)
self.scrim = scrim
local probe, msg_h = ui.wrap(root, opts.message or "", { font = "sans", px = 15, color = "text_2" }, 0, 0, w - 48)
gui.delete_node(probe)
local h = math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, W / 2, H / 2, 1, 1)
local h = menu and opts.h or math.max(opts.h or H0, 64 + msg_h + 24 + 44 + 24)
local holder = anim.group(root, menu and W - menu.right - w / 2 or W / 2, menu and menu.top + h / 2 or H / 2, 1, 1)
gui.set_pivot(holder, gui.PIVOT_CENTER)
self.holder = holder
local clip = ui.rect(holder, -w / 2, -h / 2, w, h, "text")
@@ -139,6 +143,13 @@ function M:open(opts)
d:close("cancel")
end)
if menu then
opts.build(self, box, w, h)
test.set_scope("page")
sfx.play("toggle")
return
end
-- Title, message (or custom content), buttons: three groups that rise in turn.
local g_title = anim.group(box, 0, 0)
local g_body = anim.group(box, 0, 0)
@@ -309,7 +320,12 @@ 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")
gui.set_position(self.holder, vec(W / 2, -H / 2))
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)
+16 -5
View File
@@ -6,9 +6,12 @@
-- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end,
-- focus_id = function(item) return "moon:" .. item.id end }) -- (optional) the test
-- focus_id = function(item) return "moon:" .. item.id end, -- (optional) the test
-- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus)
-- on_remove = function(node, item) ... end }) -- (optional) a row scrolled out, before
-- -- its node is deleted (not when the
-- -- list itself goes in a rebuild)
-- l:set_data(items) -- (again with a filtered list: the rows are made again)
--
-- create() builds a row (pivot NW, w wide) under l.content and returns its root; the grid
@@ -43,6 +46,13 @@ function M.new(g, parent, x, y, w, h, opts)
l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index)
end)
if opts.on_remove then
l.data.on_element_remove:subscribe(function(_, _, node, _, item)
if not g.tearing_down then
opts.on_remove(node, item)
end
end)
end
l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset)
@@ -55,15 +65,16 @@ function M.new(g, parent, x, y, w, h, opts)
end
function M:set_data(items)
local saved = self.g.scroll_pos[self.key] or 0 -- (setting the data scrolls to the top)
local saved = self.g.scroll_pos[self.key] or 0
self.items = items
self.data:set_data(items)
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the
-- scroll event).
-- At the saved offset first (Druid clamps it to the rows), so the data list makes the
-- rows there, not the top ones only to drop them again.
local s = self.scroll
s:set_size(self.grid:get_size_for(#items))
s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y
self.data:set_data(items)
local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then
+99
View File
@@ -0,0 +1,99 @@
-- Slider (the web's input type=range): a thin track, its filled part and a knob. Press
-- anywhere on the track's row to jump there, drag to move; the value follows the pointer
-- exactly (screen position mapped into the track, not summed deltas).
--
-- local s = slider.new(g, parent, x, y, w, h, { min, max, step, value, id, zone,
-- on_change = fn(v), on_release = fn(v) })
-- s:set(v) -- move it without on_change (another control changed the value)
--
-- Built on Druid's slider (input priority high, so a scroll area under it doesn't take
-- the drag); its input is replaced by the pointer mapping above. zone: a node the press
-- must be inside (a scroll area's view: hidden rows can't be grabbed). on_change gets
-- the value snapped to step; on_release when the drag ends. Test id: opts.id (kind
-- "slider", no keyboard focus: the number field beside it is the keyboard's way in).
local ui = require("modules.ui")
local test = require("modules.test")
local M = {}
M.__index = M
local TOUCH = hash("touch")
local KNOB_W, KNOB_H, TRACK_H = 10, 16, 4
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function snap(v, o)
local n = math.floor((v - o.min) / o.step + 0.5)
v = o.min + n * o.step
-- (decimal steps: drop float noise like 0.30000000000000004)
v = tonumber(string.format("%.6f", v))
return math.max(o.min, math.min(o.max, v))
end
function M.new(g, parent, x, y, w, h, opts)
local self = setmetatable({ opts = opts, w = w }, M)
local hit = ui.rect(parent, x, y, w, h, "none")
ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), w, TRACK_H, "line_hi")
self.fill = ui.rect(hit, 0, math.floor((h - TRACK_H) / 2), 0, TRACK_H, "cyan", 0.7)
local knob = ui.rect(hit, 0, 0, KNOB_W, KNOB_H, "cyan")
gui.set_pivot(knob, gui.PIVOT_CENTER)
gui.set_position(knob, vec(0, -h / 2))
self.knob = knob
local s = g.druid:new_slider(knob, vec(w, -h / 2), function(_, f)
local v = snap(opts.min + f * (opts.max - opts.min), opts)
self:_fill(f)
if v ~= self.value then
self.value = v
if opts.on_change then
opts.on_change(v)
end
end
end)
self.s = s
local zone = opts.zone
s.on_input = function(sl, action_id, action)
if action_id ~= TOUCH or not action.screen_x then
return false
end
if not sl.is_drag then
if not (action.pressed and gui.pick_node(hit, action.x, action.y)
and (not zone or gui.pick_node(zone, action.x, action.y))) then
return false
end
sl.is_drag = true
end
local p = gui.screen_to_local(knob, vec(action.screen_x, action.screen_y))
local f = math.max(0, math.min(1, p.x / w))
if f ~= sl.value then
sl:set(f)
end
if action.released then
sl.is_drag = false
if opts.on_release then
opts.on_release(self.value)
end
end
return true
end
self:set(opts.value)
test.register(opts.id, hit, "slider", opts.label, zone, true)
return self
end
function M:_fill(f)
gui.set_size(self.fill, vec(math.floor(self.w * f + 0.5), TRACK_H))
end
function M:set(v)
local o = self.opts
v = math.max(o.min, math.min(o.max, tonumber(v) or o.min))
self.value = v
local f = o.max > o.min and (v - o.min) / (o.max - o.min) or 0
self.s:set(f, true)
self:_fill(f)
end
return M
+10
View File
@@ -50,6 +50,16 @@ embedded_instances {
" data: \"sound: \\\"/assets/sfx/charge.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"rumble\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/rumble.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"reveal\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/reveal.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
"}\n"
"embedded_components {\n"
" id: \"close\"\n"
" type: \"sound\"\n"
" data: \"sound: \\\"/assets/sfx/close.wav\\\"\\nlooping: 0\\ngroup: \\\"sfx\\\"\\ngain: 1.0\\n\"\n"
+63 -7
View File
@@ -15,8 +15,9 @@
-- anim.pop(node, from, dur) scale in with overshoot (a Claim that turns ready)
-- anim.keys(frames, k, ease) a CSS keyframe track at progress k ({ {t, v}, ... })
-- anim.scale_about(node, p, base, dx, dy, sx, sy) scale about a point (NW pivots)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.noise_shake for a
-- decaying noise shake (sell power-down, reveal burst)
-- anim.shake(node, amp, dur, delay) horizontal shake (an error); anim.perlin_shake (the
-- reveal burst) or anim.noise_shake for a
-- decaying value-noise shake (sell power-down)
-- anim.flash(node, color, dur) tint to a color and back
-- anim.sweep(parent, w, h, color, dur) a scan line down a box
-- anim.count(node, from, to, dur, fmt) a number counting
@@ -159,6 +160,7 @@ end
-- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now()
return socket.gettime()
@@ -207,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
shaking = {}
end
---------------------------------------------------------------- fading content
@@ -342,21 +345,39 @@ end
-- A short horizontal shake (an error: app.css fx-shake, 0.4 s: -6, 5, -3, 2 px at 20/40/
-- 60/80 %). amp scales it (6 = the web's). Returns the tween.
-- A shake that starts while another runs on the node replaces it and keeps its resting
-- place (read mid-shake, the position is off by up to amp and would stay so).
local SHAKE = { { 0, 0 }, { 0.2, -1 }, { 0.4, 5 / 6 }, { 0.6, -0.5 }, { 0.8, 1 / 3 }, { 1, 0 } }
function M.shake(node, amp, dur, delay)
if M.reduced then
return
end
amp, dur = amp or 6, dur or 0.4
local p
return M.tween(node, dur, function(k, n)
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay)
local rest = {}
local keep = {}
for _, s in ipairs(shaking) do
if not s.t.dead then
local ok, same = pcall(function() return s.node == node end)
if ok and same then
M.cancel(s.t)
rest = s.rest
else
keep[#keep + 1] = s
end
end
end
shaking = keep
local t = M.tween(node, dur, function(k, n)
rest.p = rest.p or gui.get_position(n) -- where it is when the shake starts (after a delay)
local p = rest.p
gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function()
if p then
pcall(gui.set_position, node, p)
if rest.p then
pcall(gui.set_position, node, rest.p)
end
end)
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
return t
end
-- 1D value noise (smooth), for shakes.
@@ -389,6 +410,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)
+19 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
elseif data == nil and status ~= 204 and method == "POST" then
err = "bad JSON"
elseif data == nil and method == "GET" and response.response ~= "null" then
err = "bad JSON"
end
done(err, data, status, recv)
@@ -179,6 +181,22 @@ 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
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
function M.get_moon_settings(cb)
request("GET", "/api/moon-settings", nil, cb)
end
function M.save_moon_settings(settings, cb)
request("POST", "/api/moon-settings", settings, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
+109 -10
View File
@@ -36,9 +36,50 @@ end
---------------------------------------------------------------- signatures
local function ser(v, buf)
-- Frozen tables (M.freeze: a state's parts, which nothing changes once shown) are written
-- as a token of their content, worked out once per table: two tables with the same
-- content get the same token, so a block whose inputs the server's answer left as the
-- prediction had them is kept, and a list as long as the moons is serialized once per
-- state, not once per block and build. (A frozen table must not change.)
local frozen = setmetatable({}, { __mode = "k" }) -- table -> true (not worked out yet) or its token
local tokens, token_bytes, token_n = {}, 0, 0 -- content -> token; the content strings' size
local TOKEN_BYTES = 4 * 1024 * 1024 -- (then forgotten: equal content gets a new token once)
local ser
local function token_of(v)
local tok = frozen[v]
if tok ~= true then
return tok
end
local buf = {}
frozen[v] = false -- (while it's written: a table inside itself is written out)
ser(v, buf, true)
local s = table.concat(buf)
tok = tokens[s]
if not tok then
if token_bytes > TOKEN_BYTES then
tokens, token_bytes = {}, 0
end
token_n = token_n + 1
tok = "#" .. token_n
tokens[s], token_bytes = tok, token_bytes + #s
end
frozen[v] = tok
return tok
end
local function key_lt(a, b)
return tostring(a) < tostring(b)
end
ser = function(v, buf, own)
local t = type(v)
if t == "table" then
if not own and frozen[v] then
buf[#buf + 1] = token_of(v)
return
end
buf[#buf + 1] = "{"
local n = #v
for i = 1, n do
@@ -55,7 +96,7 @@ local function ser(v, buf)
end
if keys then
if mixed then
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
table.sort(keys, key_lt)
else
table.sort(keys)
end
@@ -68,7 +109,9 @@ local function ser(v, buf)
end
buf[#buf + 1] = "}"
elseif t == "string" then
buf[#buf + 1] = #v .. ":" .. v
buf[#buf + 1] = #v
buf[#buf + 1] = ":"
buf[#buf + 1] = v
elseif t == "function" then
buf[#buf + 1] = "f"
else
@@ -76,7 +119,8 @@ local function ser(v, buf)
end
end
-- A string that is the same exactly when the values are (tables by content, keys sorted).
-- A string that is the same exactly when the values are (tables by content, keys sorted;
-- frozen ones by their token, the same for the same content).
function M.sig(...)
local buf = {}
for i = 1, select("#", ...) do
@@ -86,6 +130,23 @@ function M.sig(...)
return table.concat(buf)
end
-- Freeze the tables in t (a state: its lists and records, one level down; t itself too)
-- for M.sig: nothing may change them afterwards (a prediction changes its copy before).
function M.freeze(t)
if type(t) ~= "table" then
return t
end
if frozen[t] == nil then
frozen[t] = true
end
for _, v in pairs(t) do
if type(v) == "table" and frozen[v] == nil then
frozen[v] = true
end
end
return t
end
---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that
@@ -130,19 +191,25 @@ end
local replay
-- A kept block inside the one being built: noted in it, its registrations made again.
local function adopt(b)
local function adopt(reg, b)
local top = stack[#stack]
if top then
top.journal[#top.journal + 1] = { block = b }
end
replay(b)
replay(reg, b)
end
replay = function(b)
-- (the keyed blocks inside a kept one are used in this build too, or finish() would
-- destroy them: a kept Equipment section lost its cards)
replay = function(reg, b)
for _, e in ipairs(b.journal) do
if e.block then
if not e.block.dead then
replay(e.block)
local c = e.block
if not c.dead then
if c.key and reg.by_key[c.key] == c then
reg.used[c.key] = true
end
replay(reg, c)
end
else
replaying = true
@@ -210,6 +277,37 @@ local function rezone(b)
end
end
-- After a kept block is taken: its Druid components (and those of the blocks inside it)
-- get new uids, in their order, as if made now. Druid hands input to the newest first
-- (by uid: druid_instance's sort_input_comparator), so a kept button inside a box rebuilt
-- around it (a slot in a well card, which selects the well when clicked) still gets its
-- clicks first, as a rebuilt one would.
local new_uid = require("druid.component").create_uid
local function renew(c)
local cc = c._component
if cc and cc._uid then
cc._uid = new_uid()
cc._is_input_priority_changed = true -- (the instance sorts its input stack again)
end
local kids = c.get_childrens and c:get_childrens() or {}
for i = 1, #kids do -- (already the whole subtree, in its order)
local k = kids[i]._component
if k and k._uid then
k._uid = new_uid()
end
end
end
local function reorder(b)
for _, c in ipairs(b.comps) do
pcall(renew, c)
end
for _, e in ipairs(b.journal) do
if e.block and not e.block.dead then
reorder(e.block)
end
end
end
-- True while a kept block's registrations are made again (test.register looks for its
-- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying()
@@ -312,7 +410,8 @@ function M.open(g, key, sig, parent)
reg.used[key] = true
gui.set_parent(old.root, parent, false)
rezone(old)
adopt(old)
reorder(old)
adopt(reg, old)
return old, true
end
if old then
+86 -13
View File
@@ -28,6 +28,16 @@ local M = {}
local EPS = 0.01
-- A copy of a caller's opts (the label leaves change their copy: one opts table can serve
-- several labels).
local function copy(opts)
local s = {}
for k, v in pairs(opts or {}) do
s[k] = v
end
return s
end
local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end
@@ -104,13 +114,14 @@ 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)
local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W)
local s = opts or {}
local s = copy(opts)
s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end }
end
@@ -123,21 +134,80 @@ function M.label(parent, str, style, h, opts)
w = w - ui.text_width(n)
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 s = copy(opts)
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 = copy(opts)
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
@@ -163,10 +233,10 @@ function M.made(style, make, kids) return M.box(style, kids, { make = make }) en
-- A leaf for a text node that already exists (a template's part): pivot W, its middle at
-- 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 s = copy(opts)
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 +360,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
+35 -4
View File
@@ -24,7 +24,7 @@ local M = {}
local entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page"
local seen = {} -- key -> count in this build (duplicate ids get "#n")
local seen = {} -- base -> entries with it (duplicate ids get "#n")
local function alive(node)
return pcall(gui.get_id, node)
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
return
end
local base = scope .. ":" .. (id or ("label:" .. tostring(label)))
local key = base
if (seen[base] or 0) > 0 then
-- A row made again (a long list scrolled back, a search, a kept block replayed)
-- leaves its deleted node's entry behind: drop those, so the live row keeps the
-- plain key that list_step and find look for.
local keep, taken = {}, {}
for _, e in ipairs(entries) do
if e.base ~= base then
keep[#keep + 1] = e
elseif alive(e.node) then
keep[#keep + 1] = e
taken[e.key] = true
else
seen[base] = seen[base] - 1
end
end
entries = keep
local n = 1
while taken[key] do
n = n + 1
key = base .. "#" .. n
end
end
seen[base] = (seen[base] or 0) + 1
local key = seen[base] > 1 and (base .. "#" .. seen[base]) or base
entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end
@@ -225,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead:
-- the item before / after the focused row (or the first / last when coming from outside),
-- scrolled into view; the rebuild makes its row, which then has the focus. Returns true
-- when it moved the focus.
-- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
-- in view Tab goes in order (a row's own buttons too); from a button inside a row it steps
-- from that row (the one under it). Returns true when it moved the focus.
local function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur)
local items, i
if a then
if list_of(g, e) == a then
return false -- (the next element is in view in the same list)
end
items = a.list.items
local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
i = n + dir
end
end
if not i and a.list.row_h then
local r, v = M.rect(cur.node), M.rect(a.view)
local pos = g.scroll_pos and g.scroll_pos[a.key] or 0
i = math.floor((r.y + r.h / 2 - v.y + pos) / a.list.row_h) + 1 + dir
end
if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page)
end
+123 -13
View File
@@ -40,17 +40,17 @@ local GOLD_HI = C.ready_hi
local LOSS = C.loss
local FLASH = C.flash
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true, ["buy-attachment"] = true,
equip = true, hire = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
local BUY_KINDS = { ["buy-tool"] = true, upgrade = true, ["buy-moon"] = true,
hire = true, ["upgrade-batch"] = true, ["buy-seat"] = true, ["seat-advisor"] = true, ["swap-advisors"] = true }
-- Sounds per action kind (fx.js); claim and sell sound as their numbers arrive.
local SOUND = {
["buy-tool"] = "buy", ["buy-attachment"] = "buy", equip = "upgrade", unequip = "toggle",
["buy-tool"] = "buy",
hire = "confirm", ["seat-advisor"] = "upgrade", ["unseat-advisor"] = "toggle", ["swap-advisors"] = "toggle",
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade",
["dismiss-advisor"] = "powerdown", ["buy-seat"] = "buy", ["buy-slot"] = "buy", upgrade = "upgrade", ["upgrade-batch"] = "upgrade",
["buy-moon"] = "moon", ["sell-moon"] = "shimmer", stake = "deploy", redrill = "deploy",
refine = "refine", collect = "collect", error = "error",
-- (sell-tool and sell-attachment: the dialog's confirm, then the cash as the coins land)
-- (sell-tool: the inline confirm's (cards.ask_button), then the cash as the coins land)
}
local function v3(x, y)
@@ -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
@@ -831,6 +833,16 @@ local function index(list)
return m
end
-- A refined item's color (catalog.refined, as refinery.lua's item_color).
local function refined_color(st, name)
for _, r in ipairs(st.catalog and st.catalog.refined or {}) do
if r.name == name and r.color then
return theme.item(r.color)
end
end
return theme.resource[name] or C.violet
end
-- After a response's state is applied (g: the game screen's self). kind: the action
-- name, or "get".
function M:on_response(g, kind, data, prev, st, opts)
@@ -878,7 +890,25 @@ 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
-- Refined stock (the Refinery's recipe cards' owned numbers, keys "refined:<name>"):
-- what a collect brings is held until its motes land there; other changes count only if
-- one is running.
local refined = {}
if prev.refinery and st.refinery then
local rwas = by_name(prev.refinery.stock)
for _, r in ipairs(st.refinery.stock or {}) do
local key, old = "refined:" .. r.name, rwas[r.name] or 0
if r.amount > old and k == "collect" then
refined[#refined + 1] = { name = r.name, key = key, delta = r.amount - old, color = refined_color(st, r.name) }
self:count(key, self:value(key, old), r.amount, { held = not quiet or self:holding(key) })
elseif r.amount ~= old and self.counters[key] then
self:count(key, self:value(key, old), r.amount, { held = self:holding(key) })
end
end
end
if quiet then
@@ -915,12 +945,97 @@ function M:on_response(g, kind, data, prev, st, opts)
-- The rest needs the new layout's positions: after the next build.
self.pending[#self.pending + 1] = function()
self:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
self:collected(g, refined, at)
end
end
-- Refined items a collect brought (on_response): the amount pops up in the item's color
-- above the slot it came from (clear of the batch sliding into it), and motes stream from
-- the Collect button into the recipe card's owned number, which counts up as they land
-- (an arrival blip; the "collect" sound plays with the action). Without that number on
-- screen it just counts.
function M:collected(g, refined, at)
if #refined == 0 then
return
end
local from = at and center(at) or { x = g.size[1] / 2, y = g.size[2] / 2 }
local above = at and at.box and { x = at.box.x + at.box.w / 2, y = at.box.y - 8 } or { x = from.x, y = from.y - 10 }
local colors = {}
for i, rg in ipairs(refined) do
colors[#colors + 1] = rg.color
self:float_text("+" .. ui.money(rg.delta) .. " " .. rg.name, above.x, above.y - (i - 1) * 30, rg.color, (i - 1) * 0.09, false, -1,
{ key = "gain:" .. rg.key, amount = rg.delta, fmt = function(a) return "+" .. ui.money(a) .. " " .. rg.name end })
local r = self:box_of(rg.key)
if r then
local to = center(r)
self:stream(from, to, rg.color, 10, 120 + (i - 1) * 90, function()
sfx.arrive(i - 1, anim.reduced)
self:release(rg.key)
self:burst(to.x, to.y, { rg.color }, 12, 0.5)
self:glint(r)
end)
else
self:release(rg.key)
end
end
colors[#colors + 1] = GOLD_HI
self:burst(from.x, from.y, colors, 30, 0.8)
end
-- Refinery batches that finished on our clock (core/ready), whichever section is open:
-- the ready sound (once for batches finishing together or with a drill), then, after the
-- build that shows them, with the Refinery open each slot's edge lights up (a gold pulse)
-- and sparks leave the full end of its bar (refinery.lua pops its Ready label and Collect
-- button and runs a light along the bar); elsewhere the Refinery tab pulses and sparks
-- leave its badge (header.lua pops the badge).
function M:batch_ready(g, jobs)
local t = now()
if not (self.ready_sound and t - self.ready_sound < 1) then
sfx.play("ready")
self.ready_sound = t
end
self.pending[#self.pending + 1] = function()
if g.view and g.view.section == "refinery" then
for _, j in ipairs(jobs) do
local slot = g.rf_slots and g.rf_slots[j.id]
if slot then
self:pulse(self:rect_of(slot.frame), GOLD, 0.9)
local b = self:rect_of(slot.bar)
self:burst(b.x + b.w, b.y + b.h / 2, { GOLD, GOLD_HI }, 18, 0.6)
end
end
else
local tab = g.tab_nodes and g.tab_nodes.refinery
if tab then
local r = self:rect_of(tab)
self:pulse(r, GOLD, 0.8)
self:burst(r.x + r.w - 9, r.y + 9, { GOLD, GOLD_HI }, 14, 0.5) -- (the badge on its top-right corner)
end
end
end
end
-- (a text node's position is its pivot point and its size not its text's: the box is the
-- text's own, round that point)
local PIVOT = { -- (fraction of the box left of / above the pivot point)
[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 },
}
function M:box_of(key)
local b = self.binds[key]
return b and self:rect_of(b.box or b.node)
if not b then
return nil
end
if b.box or gui.get_type(b.node) ~= gui.TYPE_TEXT then
return self:rect_of(b.box or b.node)
end
local r = self:rect_of(b.node)
local k = PIVOT[gui.get_pivot(b.node)] or { 0, 0 }
local w = ui.text_width(b.node)
local h = r.h * gui.get_scale(b.node).y
return { x = r.x - k[1] * w, y = r.y - k[2] * h, w = w, h = h }
end
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready)
@@ -1005,12 +1120,7 @@ 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
spend()
end
if BUY_KINDS[k] then
+21 -10
View File
@@ -6,7 +6,8 @@
-- call again after the layout moves the node (it reads the node's
-- screen rect). The node gets the "moon" material (listed in the
-- .gui's materials), which draws the rendered moon.
-- moon.move(node) the node moved (the narrow layout's page scroll): follow it.
-- moon.move(node[, quiet]) the node moved (the narrow layout's page scroll, the reveal's
-- window sliding and shaking): follow it; quiet: no log line.
-- moon.hide() no moon (the node keeps its own look).
-- moon.mystery(node) the shroud of an unknown moon (the new-moon section) in `node`.
-- moon.reveal(id, node) the reveal of a moon just bought: the shroud (shown now, or
@@ -24,7 +25,7 @@
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
--
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
-- defaults like the web does; until the request answers nothing is generated.
--
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
@@ -37,10 +38,17 @@ local M = {}
M.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left
M.unit = 1 -- window pixels per GUI reference pixel (the web's CSS pixel)
M.covered = false -- a dialog is open over the page: the viewport takes no drag or wheel
-- Settings: nil until loaded (or failed: then the defaults).
-- Settings: nil until loaded (or failed: then the defaults). Replace the table to change
-- them (moon.script compares by identity; the moon lab does, and Save sets the saved ones).
M.settings = nil
-- Written by moon.script: a mesh is being made (moon.building), and the last one made
-- ({ ms, vertices, technique }, moon.last_build). The moon lab paces its rebuilds by them.
M.building = false
M.last_build = nil
-- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false }
@@ -66,17 +74,20 @@ local function node_rect(node)
gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe)
gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5)
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5)
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x)
-- The size is rounded once (not as the difference of two rounded corners), so it stays
-- the same while the node moves by fractions of a pixel: every new size remakes the
-- render targets.
local w, h = math.floor(br.x - tl.x + 0.5), math.floor(tl.y - br.y + 0.5)
local x0, y0 = math.floor(tl.x + 0.5), math.floor(br.y + 0.5)
return { x = x0, y = y0, w = w, h = h }, w / 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 +109,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
+318 -59
View File
@@ -53,6 +53,12 @@ function M.copy(st)
return copy(st, true)
end
-- Copies of a tool: the same store item and track levels (tools, well.idle groups).
local function same_copy(a, b)
return a.storeItemId == b.storeItemId and (a.yieldLevel or 0) == (b.yieldLevel or 0)
and (a.cycleLevel or 0) == (b.cycleLevel or 0)
end
local function find(list, id, key)
key = key or "id"
for i, x in ipairs(list or {}) do
@@ -168,7 +174,7 @@ function M.tool(st, id)
return find(st.tools, id)
end
-- The tool to deploy for id: it, or an idle copy of it (same store item and tier).
-- The tool to deploy for id: it, or an idle copy of it (same store item and track levels).
function M.idle_copy(st, id)
local t = find(st.tools, id)
if not t then
@@ -178,7 +184,7 @@ function M.idle_copy(st, id)
return t
end
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == t.storeItemId and x.tier == t.tier then
if not x.stakeId and same_copy(x, t) then
return x
end
end
@@ -189,18 +195,162 @@ 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 next wells in the
-- moon's order (wrapping round), leaving out empty ones (game.go coverWells).
local function cover_wells(anchor, supply, reach)
local n = #supply
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
local out = {}
local want = math.max(1, reach or 1)
for k = 0, n - 1 do
if #out >= want then
break
end
local i = (start - 1 + k) % n + 1
if supply[i] > 0 then
out[#out + 1] = i
end
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,
@@ -212,11 +362,11 @@ function M.add_stake(st, tool, moon_id, well, ctx, group)
-- Off the selected well's idle list.
if st.well then
for i, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
g.count = g.count - 1
if g.toolId == tool.id then
for _, x in ipairs(st.tools) do
if not x.stakeId and x.storeItemId == g.storeItemId and x.tier == g.tier then
if not x.stakeId and same_copy(x, g) then
g.toolId = x.id
break
end
@@ -260,7 +410,7 @@ function M.remove_stake(st, s)
if st.well and st.well.idle then
local g = nil
for _, x in ipairs(st.well.idle) do
if x.storeItemId == tool.storeItemId and x.tier == tool.tier then
if same_copy(x, tool) then
g = x
end
end
@@ -270,16 +420,17 @@ function M.remove_stake(st, s)
local free = M.supply(st, st.well.id)
local y = tool.yield * yield_boost(st, st.well.resource)
st.well.idle[#st.well.idle + 1] = {
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, tier = tool.tier, count = 1,
toolId = tool.id, storeItemId = tool.storeItemId, name = tool.name, yieldLevel = tool.yieldLevel, cycleLevel = tool.cycleLevel, count = 1,
cycleSecs = tool.cycleSecs, yield = y, reach = tool.reach, here = math.min(y, free), free = free,
short = free < y, attachments = tool.attachments or {},
short = free < y,
}
end
end
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 +460,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
@@ -399,43 +582,77 @@ end
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {}
for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1
if done(s, ctx) and (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then
if wide then
return false -- multi-well drills: the server splits them
end
local scope
if p["claim-wide"] then
scope = wide and s.moonId == p["claim-wide"]
else
scope = (not p["claim-well"] or (s.wellId == p["claim-well"] and not wide))
and (not p["claim-moon"] or s.moonId == p["claim-moon"])
end
if done(s, ctx) and scope then
list[#list + 1] = s
end
end
if #list == 0 then
return false
end
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply).
-- A multi-well drill takes from its well, then the moon's next ones (split).
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))
M.remove_stake(st, s)
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
local moon = find(st.moons, s.moonId)
if (s.reach or 1) > 1 and moon then
local sup = {}
for i, w in ipairs(moon.wells or {}) do
sup[i] = M.supply(st, w.id)
end
for i, take in ipairs(split(moon, s, sup)) do
local w = moon.wells[i]
if take > 0 then
M.add_item(st, w.resource or s.resource, take * M.skew)
M.add_supply(st, w.id, -take)
if M.supply(st, w.id) <= 0 then
emptied[w.id] = true
end
end
end
else
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
end
else
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 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 = nil
if tool and w and st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if same_copy(g, tool) then
group = g
end
end
end
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, tool.reach or 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
@@ -466,17 +683,22 @@ M.register("stake", function(st, p, ctx)
return false -- "Deploy there": the view moves to the other well, wait for it
end
local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then
if not tool then
return false
end
local group = nil
if st.well and st.well.id == w.id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == tool.storeItemId and g.tier == tool.tier then
if same_copy(g, tool) then
group = g
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, tool.reach or 1) or 0) <= EPS then
return false
end
M.add_stake(st, tool, moon.id, w, ctx, group)
return true
end)
@@ -499,19 +721,54 @@ M.register("redrill", function(st, p, ctx)
if #tools == 0 then
return false
end
for _, t in ipairs(tools) do
local group = nil
-- Until the well has no room left, like the server's redrill (game.go redrillPick):
-- the smallest drill that fills what is left alone, else the biggest and again; the
-- rest stay idle.
local function group_of(t)
if st.well and st.well.id == id then
for _, g in ipairs(st.well.idle or {}) do
if g.storeItemId == t.storeItemId and g.tier == t.tier then
group = g
if same_copy(g, t) then
return g
end
end
end
end
local staked = 0
while #tools > 0 do
local left = M.room(st, id, 1e18, 1) or 0
if left <= EPS then
break
end
-- What each takes a claim, with the well's yield event (the server's t.Amount).
local amounts = {}
for i, t in ipairs(tools) do
local g = group_of(t)
amounts[i] = g and g.yield or t.yield * yield_boost(st, w.resource)
end
local pick
for i, a in ipairs(amounts) do
if a >= left - EPS and (not pick or a < amounts[pick]) then
pick = i
end
end
if not pick then
pick = 1
for i, a in ipairs(amounts) do
if a > amounts[pick] then
pick = i
end
end
end
local t = table.remove(tools, pick)
local group = group_of(t)
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)
@@ -602,7 +859,9 @@ M.register("refine", function(st, p, ctx)
return false
end
local rr = st.rules.refinery
local secs = math.floor(r.seconds * qty ^ rr.bulkExponent / (rr.speed or 1) + 0.5)
-- (refinery.go bulkUnits: seconds × min(n, 1 + bulkLog × ln n))
local units = math.min(qty, 1 + (rr.bulkLog or 0) * math.log(math.max(1, qty)))
local secs = math.floor(r.seconds * units / (rr.speed or 1) + 0.5)
local job = { id = "pred:" .. ctx.seq, slot = slot.n, item = r.item, qty = qty, start = ctx.now,
["end"] = ctx.now + secs * 1000, ready = false, progress = 0, predicted = true }
slot.job = job
+8
View File
@@ -18,6 +18,7 @@ local GAIN = {
nav = 0.8, toggle = 0.8, open = 0.8, close = 0.8, modal = 0.9, confirm = 0.8, error = 0.9,
buy = 1, upgrade = 1, moon = 1, deploy = 1, refine = 1, ready = 1, collect = 1,
tick = 1, arrive = 1, coin = 1, harvest = 1, charge = 1, burst = 1, powerdown = 1, shimmer = 1,
rumble = 1, reveal = 1,
}
-- Minimum time between two plays of the same sound, in seconds.
@@ -96,6 +97,13 @@ function M.play(name, opts)
end
end
-- Stops every play of a sound (the reveal's build-up when the burst comes early).
function M.stop(name)
if GAIN[name] then
pcall(sound.stop, "main:/sfx#" .. name)
end
end
local function semitones(n)
return 2 ^ (n / 12)
end
+200 -9
View File
@@ -7,7 +7,10 @@
-- Elements: every interactive component registers a stable id while the layout builds
-- (M.register; components/ and core/hooks do it): buttons "act:<action>?<params>",
-- links "nav:<section>[/<what>]", tabs "tab:<section>", moon rows "moon:<id>", wells
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel". A
-- "well:<id>", fields "field:<key>", dialog buttons "dialog:ok" / "dialog:cancel" /
-- "dialog:<...>", "list:active|past", equipment rows "drill:<tool id>", buttons that ask
-- first "confirm:<action>" (a row's Sell "confirm:sell-tool:<tool id>"), 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)
@@ -92,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
if not M.enabled or not id then
return
end
if elements[id] then
local old = elements[id]
if old and not pcall(gui.get_id, old.node) then
-- (its node went: a long list's row scrolled out and back keeps its plain id)
old.node, old.kind, old.label, old.zone, old.scope = node, kind or "button", label, zone, scope
return id
end
if old then
local n = 2
while elements[id .. "#" .. n] do
n = n + 1
@@ -194,10 +204,11 @@ local function visible(node)
return true
end
-- A run of commands spread over frames: { frame_fn, ... }.
local function steps(g, list, done)
-- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
-- error reply when a step fails.
local function steps(g, list, done, id)
g.test_steps = g.test_steps or {}
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done }
g.test_steps[#g.test_steps + 1] = { list = list, i = 1, done = done, id = id }
end
-- mm.test_dir (drive.py passes its output folder): replies go to files there.
@@ -339,7 +350,7 @@ function COMMANDS.click(g, cmd, input)
}
steps(g, list, function()
reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
end)
end, cmd.id)
end
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds.
@@ -381,7 +392,7 @@ function COMMANDS.key(g, cmd, input)
function() input(k, { released = true }) end,
}, function()
reply(cmd.id, { ok = true })
end)
end, cmd.id)
end
function COMMANDS.type(g, cmd, input)
@@ -428,6 +439,182 @@ 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"
-- (what shows of it: the lint's overlap rule only counts that)
local vx, vy = math.max(item.x, zone[1]), math.max(item.y, zone[2])
item.vis = { r1(vx), r1(vy), r1(ix), r1(iy) }
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 }, nil, cmd.id)
end
function COMMANDS.state(g)
return { ok = true, state = summary(g) }
end
@@ -512,9 +699,13 @@ function M.update(g, input, api)
-- Running multi-frame steps.
if g.test_steps and #g.test_steps > 0 then
local s = g.test_steps[1]
s.list[s.i]()
local ok, err = pcall(s.list[s.i])
s.i = s.i + 1
if s.i > #s.list then
if not ok then
-- (a failing step ends its run: reply with the error, or the command never answers)
table.remove(g.test_steps, 1)
reply(s.id, { ok = false, error = tostring(err) })
elseif s.i > #s.list then
table.remove(g.test_steps, 1)
if s.done then
s.done()
+4 -4
View File
@@ -1,4 +1,4 @@
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values.
-- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files;
-- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
@@ -49,7 +49,7 @@ M.palettes = {
loss = { "#ff8a96" },
teal = { "#2dd4bf" },
violet = { "#b69cff" },
violet_hi = { "#dccfff" }, -- attachment and advisor effects
violet_hi = { "#dccfff" }, -- advisor effects
stat_up = { "#c4a8ff" }, -- a tool stat its next upgrade raises (not money)
up = { "#3ee6a0" },
flash = { "#ffffff" }, -- peak of a gain flash or a scan line
@@ -168,10 +168,10 @@ M.size = {
aside_w_min = 400, -- the aside: clamp(400px, 33vw, aside_w_max) (the web caps it at 580)
aside_w_max = 580,
content_max = 960, -- widest a section's content gets (tables, rows) in the main area
market_max = 960, -- the market's (quotes, detail and ticket)
market_max = 1320, -- the market's (quotes, detail and ticket)
market_panel_max = 460, -- tallest the market's detail and ticket panels get
text_max = 720, -- widest a paragraph gets
cards_max = 1400, -- widest a card grid gets (Store, Equipment, Advisors), centered
cards_max = 1400, -- widest a card grid gets (Store, Advisors), centered
card_col_min = 250, -- narrowest card column (the web's minmax(250px, 1fr)); more as it widens
card_min_w = 420, -- narrowest card with the image beside the text (narrower: image on top)
panel_pad = 14,
+111 -23
View File
@@ -1,6 +1,9 @@
-- Toasts for action results (the web's .toasts): bottom-left, newest at the bottom, at
-- most 3, each fading out on its own after a few seconds. They live on their own layer
-- node, outside the rebuilt layout, so a rebuild doesn't drop them.
-- Toasts for action results (the web's .toasts). Two places:
-- - The announcements pane (the moon list's lower half, moonlist.lua: toasts:attach): every
-- toast is listed there, newest at the top, the last LOG_MAX (5) 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 = 5 -- (the pane's list: the last 5)
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
@@ -31,23 +35,21 @@ local KIND = {
redrill = { "cyan", "▼" },
upgrade = { "cyan", "▲" },
["buy-tool"] = { "cyan", "+" },
["buy-attachment"] = { "cyan", "+" },
["buy-seat"] = { "cyan", "+" },
["buy-slot"] = { "cyan", "+" },
equip = { "cyan", "▲" },
unequip = { "muted", "▼" },
hire = { "violet", "●" },
refine = { "violet", "◆" },
collect = { "teal", "◆" },
["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 +98,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 +112,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 +167,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 +190,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 +228,96 @@ 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
-- The list's height for the last LOG_MAX one-line toasts (the pane is sized to it).
M.LIST_H = LOG_MAX * (H + GAP) - GAP
-- 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 +328,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
+27 -8
View File
@@ -180,7 +180,7 @@ M.button_looks = setmetatable({}, { __mode = "k" })
-- A button's nodes (components/button.lua gives it behavior: hover, press, busy, clicks;
-- or H.action / H.link). opts: variant ("primary", "secondary", "redrill", "danger",
-- "ghost"; theme.button), primary (= variant "primary"), small, disabled, w, h, price,
-- accent, on (a lit ghost), fill / edge / text (override the variant's colors). Returns
-- accent, bare (the price right after the label, no "·"), on (a lit ghost), fill / edge / text (override the variant's colors). Returns
-- root, nodes, width.
function M.button(parent, label, x, y, opts)
opts = opts or {}
@@ -199,12 +199,16 @@ function M.button(parent, label, x, y, opts)
local parts = { { text, M.text_width(text) } }
if opts.price then
local accent = opts.accent or v.price
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
local sym = M.text(root, theme.currency, { font = "sym", px = px - 1, color = accent }, 0, 0)
local num = M.text(root, M.money(opts.price), { font = "mono", px = px - 1, color = accent }, 0, 0)
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
if opts.bare then -- (no "·" between: "+ ₥290")
parts[#parts + 1] = { sym, M.text_width(sym), 5 }
else
local sep = M.text(root, "·", { font = "sans_semi", px = px, color = "text" }, 0, 0)
gui.set_color(sep, M.color_spec(tc))
parts[#parts + 1] = { sep, M.text_width(sep), 8 }
parts[#parts + 1] = { sym, M.text_width(sym), 8 }
end
parts[#parts + 1] = { num, M.text_width(num), 1 }
end
local content = 0
@@ -270,8 +274,8 @@ function M.fit_text(node, max_w)
return
end
while #text > 1 do
-- (a cut entity like "&am" would show as text)
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "")
-- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
gui.set_text(node, text .. "…")
if M.text_width(node) <= max_w then
return
@@ -333,8 +337,13 @@ function M.dur(ms)
return string.format("%d:%02d", m, sec)
end
-- A drill cycle like the web's secs: "4s", "2m", "2m 30s".
-- A drill cycle like the web's secs: "4s", "2.6s" (tenths), "2m", "2m 30s".
function M.cycle(secs)
if secs < 60 and secs ~= math.floor(secs) then
local t = math.floor(secs * 10 + 0.5) / 10
return (t == math.floor(t) and string.format("%d", t) or string.format("%.1f", t)) .. "s"
end
secs = math.floor(secs + 0.5)
if secs < 60 then
return secs .. "s"
elseif secs % 60 == 0 then
@@ -343,6 +352,16 @@ function M.cycle(secs)
return string.format("%dm %ds", math.floor(secs / 60), secs % 60)
end
-- A tool's track levels after its name, like the web's Levels.Tag: " Y2 C1", "" for a
-- base tool (both levels 0). t: anything with yieldLevel and cycleLevel.
function M.levels_tag(t)
local y, c = t and t.yieldLevel or 0, t and t.cycleLevel or 0
if y == 0 and c == 0 then
return ""
end
return " Y" .. y .. " C" .. c
end
function M.plural(n, word)
return n .. " " .. word .. (n == 1 and "" or "s")
end
+2
View File
@@ -83,6 +83,8 @@ void main()
vec3 grad = sign(det) * (dhdx * r1 + dhdy * r2);
normal = normalize(abs(det) * normal - grad);
}
// cam_pos matches the view matrix (a debug render of |V(cam_pos) - V(view matrix)| was 0
// over the whole moon); moon_glow and the halo take V in view space (-mv.xyz) instead.
vec3 V = normalize(cam_pos.xyz - var_world);
vec3 albedo = var_color;
vec3 col = direct(sun_dir.xyz, sun_color.rgb, normal, V, albedo);
+10 -4
View File
@@ -33,6 +33,7 @@ local BURST = 0.9 -- reveal: the burst itself (s)
local TIMEOUT = 4 -- reveal: burst without the mesh after CHARGE + this (s)
local HOLD = 3 -- mm.reveal_freeze: seconds each hold lasts
local SHROUD_ROT = vmath.quat_rotation_x(0.28) * vmath.quat_rotation_z(-0.22)
local SHROUD_SPIN = 0.25 -- radians a second the shroud turns
local SUN_DIR = vmath.normalize(vmath.vector3(-1, 0.45, 0.5))
local FILL_DIR = vmath.normalize(vmath.vector3(5, -2, -4))
@@ -194,7 +195,7 @@ local function start_job(self, id, key, params)
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION
end
local job = { id = id, key = key, params = params }
local job = { id = id, key = key, params = params, t0 = now() }
job.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen)
self.job = job
@@ -251,7 +252,9 @@ end
local function job_done(self, job)
local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res)
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
local ms = (now() - job.t0) * 1000
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
if job.key == self.want_key then
set_mesh(self, job.key, res, job.params)
end
@@ -449,6 +452,7 @@ function update(self, dt)
if #self.orphans > 0 then
step_orphans(self)
end
moon.building = self.job ~= nil or #self.orphans > 0
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false
return
@@ -565,10 +569,12 @@ function update(self, dt)
if shroud then
local pos = vmath.vector3(shroud.x or 0, shroud.y or 0, 0)
local sc = vmath.vector3(shroud.scale, shroud.scale, shroud.scale)
-- The shroud and its motes turn slowly, like a moon (the halo, a glow around it, stays).
local turn = spin_enabled and vmath.quat_rotation_y(self.shroud_t * SHROUD_SPIN) or vmath.quat()
for _, id in ipairs({ "shroud", "halo", "motes" }) do
go.set_position(pos, id)
go.set_scale(sc, id)
go.set_rotation(SHROUD_ROT, id)
go.set_rotation(id == "halo" and SHROUD_ROT or SHROUD_ROT * turn, id)
end
f.shroud = vmath.vector4(self.shroud_t, shroud.charge, shroud.fade, 0)
else
@@ -599,7 +605,7 @@ function update(self, dt)
end
function on_input(self, action_id, action)
local rect = moon.frame.visible and moon.rect
local rect = moon.frame.visible and not moon.covered and moon.rect
if not rect then
self.dragging = false
return false
+98
View File
@@ -0,0 +1,98 @@
-- Admin area: tools for tuning the game, opened from the gear menu (settings.lua) for
-- admins (state.admin) and in any debug build. While g.admin names a screen, core/main
-- builds it under the header instead of the moon list, the section and the aside; a bar
-- on top lists the screens and leads back to the game (so does any navigation: a header
-- tab, Back).
--
-- A screen module: build(g, parent, x, y, w, h), sig(g) (its block is kept while it and
-- the size stay the same), optional open(g), close(g), update(g, dt). Add one to SCREENS.
-- Test ids: "admin:<screen>", "admin:back".
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local button = require("components.button")
local S = theme.size
local M = {}
local SCREENS = {
{ id = "moonlab", label = "Moon lab", mod = require("screens.game.moonlab") },
}
local function find(id)
for _, s in ipairs(SCREENS) do
if s.id == id then
return s
end
end
end
function M.allowed(g)
return (g.state and g.state.admin) or sys.get_engine_info().is_debug
end
function M.open(g, id)
local s = find(id or SCREENS[1].id)
if not s then
return
end
if g.admin and g.admin ~= s.id then
M.close(g)
end
if g.admin ~= s.id then
g.admin = s.id
if s.mod.open then
s.mod.open(g)
end
end
g.dirty, g.dirty_all = true, true
end
function M.close(g)
local s = g.admin and find(g.admin)
if s and s.mod.close then
s.mod.close(g)
end
g.admin = nil
g.dirty, g.dirty_all = true, true
end
-- The block's inputs besides the rect.
function M.sig(g)
local s = find(g.admin)
return { g.admin, s and s.mod.sig and s.mod.sig(g) or false }
end
-- The bar and the screen in (x, y, w, h).
function M.build(g, parent, x, y, w, h)
local s = find(g.admin)
local kids = { fg.label(parent, "Admin", "cap", S.button_sm_h, { padding_right = 8 }) }
for _, sc in ipairs(SCREENS) do
kids[#kids + 1] = fg.button(parent, sc.label, { small = true, variant = sc.id == g.admin and "primary" or nil },
function(b, nodes)
button.bind(g, b, nodes, { id = "admin:" .. sc.id, on_click = function() M.open(g, sc.id) end })
end)
end
kids[#kids + 1] = fg.fill()
kids[#kids + 1] = fg.button(parent, "Back to the game", { small = true }, function(b, nodes)
button.bind(g, b, nodes, { id = "admin:back", on_click = function() M.close(g) end })
end)
local bar = fg.row({ width = w, height = S.button_sm_h, gap = 8, align = "center" }, kids)
flex.solve(bar, x, y)
fg.build(bar, parent)
local top = y + bar.h + S.gap
if s then
s.mod.build(g, parent, x, top, w, y + h - top)
end
end
function M.update(g, dt)
local s = g.admin and find(g.admin)
if s and s.mod.update then
s.mod.update(g, dt)
end
end
return M
+5 -3
View File
@@ -263,7 +263,9 @@ local function advisor_card(self, H, ad, c, a)
c:para(a.description, { font = "sans", px = 13, color = "text_2" }, 2)
c:gap(4)
local why, why_color = hire_block(a)
c:footer(S.button_sm_h + (why and 18 or 0), function(f, bw)
-- (an advisor for hire keeps the reason's place, shown or not: credits coming and going
-- don't resize the card)
c:footer(S.button_sm_h + (a.hired and 0 or 18), function(f, bw)
local kids
if a.hired then
local seat = (a.seat or 0) > 0
@@ -281,9 +283,9 @@ local function advisor_card(self, H, ad, c, a)
tooltip.add(b, a.whyText)
end
end, { width = bw }),
(not can and why) and made({ height = 20 }, function(p, x, y, w)
fg.reserve({ height = 20 }, (not can and why) and made({ width = bw, height = 20 }, function(p, x, y, w)
store.need_text(p, why, x + w, y + 10, why_color)
end) or nil,
end) or nil),
}
end
fg.build(flex.solve(col({ width = bw }, kids), 0, 0), f)
+201 -264
View File
@@ -1,27 +1,29 @@
-- The aside (region, right column): the moon viewport (the selected moon in every
-- section, the shroud on New moon, a grid when there is none) and under it the selected
-- well's panel (supply, claim, redrill, deploy cards, drills), or what the section puts
-- there (a section module's aside / aside_below, see core/main.lua), always under the
-- viewport.
-- well's facts (supply, what the drills leave, price, its drills and their times), the
-- moon's when no well is picked, or what the section puts there (a section module's
-- aside / aside_below, see core/main.lua), always under the viewport. On the Moons
-- section it only shows, but for destructive actions (Sell moon, at its foot): every
-- other action is in the main panel (moons.lua's well cards).
local theme = require("modules.theme")
local ui = require("modules.ui")
local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale")
local predict = require("modules.predict")
local panel = require("components.panel")
local scroll = require("components.scroll")
local bar = require("components.bar")
local tooltip = require("modules.tooltip")
local row_c = require("components.row")
local button = require("components.button")
local chip = require("components.chip")
local anim = require("modules.anim")
local images = require("modules.images")
local cards = require("screens.game.cards")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local anim = require("modules.anim")
local S = theme.size
@@ -53,7 +55,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)
@@ -90,45 +95,8 @@ local function reach_short(st, n)
return pick and pick.short ~= "" and pick.short or (n .. " wells")
end
-- The long wording of a reach (catalog.reach label): "Mines 3 wells", "Strips the whole
-- moon"; "" for one.
local function reach_label(st, n)
local list = st.catalog and st.catalog.reach or {}
local pick = list[#list]
for _, r in ipairs(list) do
if r.reach == n then
pick = r
end
end
return pick and pick.label or ""
end
-- An attachment's effect ("+20% speed", catalog), for its tag's tooltip.
local function effect_of(st, name)
for _, a in ipairs(st.catalog and st.catalog.attachments or {}) do
if a.name == name then
return a.effectText
end
end
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The width of a text in a style (a probe).
local function text_w(c, str, style)
local t = ui.text(c, str, style, 0, 0)
local w = ui.text_width(t)
gui.delete_node(t)
return w
end
-- A text link (H.text_link) as a box h tall.
local function link_box(g, c, H, str, style, h, v, id)
return made({ width = text_w(c, str, style), height = h, shrink = 0 }, function(p, x, y)
H.text_link(g, p, str, style, x, y + h / 2, v, id)
end)
end
-- A stat line (H.fact: cap label, value at the right; 20 tall).
local function fact(H, label, value, opts)
return made({ height = 20 }, function(p, x, y, w)
@@ -141,95 +109,24 @@ 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
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 })
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
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)
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")
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)
end
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 }, {
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,
}),
row({ height = 16, align = "center" }, { gets, reach and space(5) or nil, reach }),
note and space(0, 0) or nil, note,
}),
space(8),
col({ shrink = 0, justify = "center" }, {
fg.label(b, "Deploy", { font = "sans_semi", px = 12, color = "cyan", tracking = 0.12, upper = true }, 16),
}),
})
fg.build(flex.solve(tree, 0, 0), b)
local title = "Deploy " .. grp.name .. " T" .. grp.tier
for _, a in ipairs(grp.attachments or {}) do
title = title .. " · " .. (type(a) == "table" and a.name or tostring(a))
end
tooltip.add(b, title)
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
if spoken then
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
g.druid:new_hover(b, nil, function(_, on)
gui.cancel_animations(veil, "color.w")
local a = on and 0.15 or 0.4
if anim.reduced or button.instant then
anim.set(veil, "color.w", a)
else
gui.animate(veil, "color.w", a, gui.EASING_OUTQUAD, 0.15)
end
end)
end
end)
-- Selling the moon (once the store is open): a line, then the sale (moon_sale.lua). Tree
-- pieces for the foot of the well or moon facts.
local function sale_kids(g, c, moon, inner, H)
if not (g.state.unlocked.store and moon.sale) then
return {}
end
return {
space(0, 14),
made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end),
space(0, 12),
moon_sale.box(g, H, c, moon, inner),
}
end
-- A drill on the well (34 tall; it slides in when the drill was just deployed):
-- thumbnail, name, tier and the attachments' tags; what it gets; time left or Ready.
-- thumbnail, name and track levels; what it gets; time left or Ready.
local function drill_line(g, c, st, d, H, enters)
local name, tier = moons.tool_name(st, d.toolId)
local name, tag = moons.tool_name(st, d.toolId)
local tool = find(st.tools, d.toolId)
return made({ height = 34 }, function(p, x, y, inner)
local dr = ui.rect(p, x, y, inner, 34, "none")
@@ -244,29 +141,11 @@ local function drill_line(g, c, st, d, H, enters)
end
local x0 = 36
local nt = ui.text(dr, name, CARD_NAME, x0, 8)
local tt = ui.text(dr, "T" .. tier, M11, x0 + ui.text_width(nt) + 6, 8)
-- Tags while they fit (and leave 50 for the time).
local head = { fg.node_label(nt, 16), space(6), fg.node_label(tt, 16), space(8) }
local tx = x0 + ui.text_width(nt) + 6 + ui.text_width(tt) + 8
for _, a in ipairs(tool and tool.attachments or {}) do
if tx + 60 > inner - 50 then
break
end
local probe = ui.rect(dr, 0, 0, 1, 1, "none")
local aw = cards.tag(probe, a, 0, 0)
gui.delete_node(probe)
if tx + aw > inner - 50 then
break
end
head[#head + 1] = made({ width = aw, height = 16, shrink = 0 }, function(q, ax, ay)
cards.tag(q, a, ax, ay + 8)
local fx_text = effect_of(st, a)
if fx_text then
tooltip.add(ui.rect(q, ax, ay, aw, 16, "none"), fx_text)
end
end)
head[#head + 1] = space(4)
tx = tx + aw + 4
local head = { fg.node_label(nt, 16) }
if tag ~= "" then
local tt = ui.text(dr, tag:sub(2), M11, x0 + ui.text_width(nt) + 6, 8)
head[#head + 1] = space(6)
head[#head + 1] = fg.node_label(tt, 16)
end
-- The amount it gets in ready when it comes up short (engine markup runs).
local reach = (d.reach or 1) > 1 and (" · " .. ((d.reachText or "") ~= "" and d.reachText or reach_short(st, d.reach))) or ""
@@ -311,11 +190,6 @@ local function drill_line(g, c, st, d, H, enters)
end)
end
-- A row of claim buttons (Claim / its countdown, and Claim and redeploy beside it).
local function claim_row(kids)
return row({ height = S.button_h, gap = 8 }, kids)
end
-- The selected well under the viewport (one tree; it scrolls); returns its bottom.
local function well_panel(g, root, pad, top, inner, H)
local st = g.state
@@ -347,25 +221,27 @@ 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, so a deploy doesn't make the facts jump.
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) }))
add(space(0, 4))
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
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
@@ -393,53 +269,27 @@ local function well_panel(g, root, pad, top, inner, H)
local wide = moon.wide
local wide_here = {}
if wide and #(wrow.wide or {}) > 0 then
for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then
wide_here[#wide_here + 1] = d
-- The server's here on these is the whole moon (what it gets in all): this well's
-- share comes from the projection (the take from this well).
local p = predict.project(st, moon)
local wi
for i, x in ipairs(moon.wells or {}) do
if x.id == w.id then
wi = i
end
end
end
-- The web's order: deploy, drills, then the claims and Redrill.
if not dry then
add(fg.label(c, "Deploy a tool", "h3", 12))
add(space(0, 6))
if #w.idle > 0 then
local cards_ = {}
for _, grp in ipairs(w.idle) do
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H)
for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then
local dd = {}
for k, v in pairs(d) do
dd[k] = v
end
local take = p.take[d.id]
if take and wi then
dd.here = take[wi] or 0
end
wide_here[#wide_here + 1] = dd
end
add(col({ gap = 6 }, cards_))
add(space(0, 6))
local better = w.better
if better then
-- "Better: <resource> well on <moon>, X free": the well is a link.
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
local rest = (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free"
local name, tier = moons.tool_name(st, better.toolId)
add(row({ height = 40, align = "start" }, {
row({ height = S.button_sm_h, grow = 1, shrink = 1, align = "center", padding_right = 8 }, {
fg.label(c, "Better:", "small", 16), space(4),
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, better.resource) end),
space(4),
link_box(g, c, H, better.resource .. " well", "small", 16,
{ section = "moons", moon = better.moonId, well = better.wellId }, "better-well"),
fg.label(c, rest, "small", 16, { fit = not ui.needs_rich(rest), shrink = 1 }),
}),
fg.button(c, "Deploy there", { small = true, primary = true }, function(b, bn)
H.action(g, b, bn, "stake", { tool = better.toolId, target = better.wellId, s = "moons", moon = better.moonId, well = better.wellId })
tooltip.add(b, "Deploy " .. name .. " T" .. tier .. " on that well")
end),
}))
end
else
add(row({ height = 16, align = "center" }, {
fg.label(c, "No idle tools.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.store and link_box(g, c, H, "Buy one in the store", { font = "sans", px = 13 }, 16, { section = "store", moon = g.view.moon }) or nil,
st.unlocked.store and fg.label(c, ".", "small", 16) or nil,
}))
add(space(0, 6))
end
end
@@ -471,54 +321,25 @@ local function well_panel(g, root, pad, top, inner, H)
add(col({ gap = 2 }, lines))
add(space(0, #lines > 0 and 10 or 8))
-- Claim (ready), Claim countdown (mining), Claim and redeploy; the multi-well claim;
-- Redrill. Amounts move on as more drills finish on our clock (core/ready).
local claims = {}
local amount = ready.amount(g, wrow, wrow.supply)
if wrow.ready > 0 then
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(amount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id })
end),
(not dry and st.unlocked.redeploy) and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-well"] = w.id, redeploy = "1" })
end) or nil,
})
elseif wrow.nextEnd then
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wrow.nextEnd, { key = "wait:well", params = { ["claim-well"] = w.id }, moon = moon.id,
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil,
also = not dry and st.unlocked.redeploy
and { label = "Claim and redeploy", params = { ["claim-well"] = w.id, redeploy = "1" } } or nil })
end)
-- Idle drills that last mined it (Redrill on its card), and a better well for the
-- idle drills (the server's pick), as text.
if (wrow.redrill or 0) > 0 then
add(fact(H, "Idle, last mined here", tostring(wrow.redrill)))
add(space(0, 6))
end
if #wide_here > 0 then
local wamount = ready.amount(g, wide, wide.cap)
if wide.ready > 0 then
claims[#claims + 1] = claim_row({
fg.button(c, "Claim " .. ui.money(wamount), { primary = true }, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id })
end),
st.unlocked.redeploy and fg.button(c, "Claim and redeploy", {}, function(b, bn)
H.action(g, b, bn, "claim", { ["claim-wide"] = moon.id, redeploy = "1" })
end) or nil,
})
elseif wide.nextEnd then
claims[#claims + 1] = made({ height = S.button_h }, function(p, x, y)
H.claim_wait(g, p, x, y, wide.nextEnd, { params = { ["claim-wide"] = moon.id }, moon = moon.id,
done = wamount > 0 and ("Claim " .. ui.money(wamount)) or nil })
end)
end
local better = w.better
if better and not dry then
local where = better.moonId ~= moon.id and find(st.moons, better.moonId)
local name, tag = moons.tool_name(st, better.toolId)
add(fg.text_para(c, "Better for " .. name .. tag .. ": the " .. better.resource .. " well"
.. (where and (" on " .. where.name) or "") .. ", " .. ui.money(better.free) .. " free.", "small", { max_width = math.min(inner, S.text_max) }))
add(space(0, 6))
end
if (wrow.redrill or 0) > 0 and not dry and st.unlocked.redrill then
claims[#claims + 1] = claim_row({
fg.button(c, "Redrill" .. (wrow.redrill > 1 and (" " .. wrow.redrill) or ""), { variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = w.id })
end),
})
end
add(col({ gap = 10, padding_bottom = #claims > 0 and 10 or 0 }, claims))
-- (no buttons here but Sell moon: every action on the well is on its card)
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
add(k)
end
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, pad, top), c)
for _, f in ipairs(enters) do
@@ -554,6 +375,47 @@ function M.row_viewport(w)
return math.floor(math.max(240, math.min(560, 380 + (inner - 16 - 660) / 2)))
end
-- The selected moon's facts under the viewport when no well is picked (read only).
local function moon_status(g, c, x, y, inner, H)
local st = g.state
local moon = st.moon
local supply, dry, mining, done = 0, 0, 0, 0
for _, w in ipairs(moon.wells or {}) do
supply = supply + w.supply
dry = dry + (w.supply <= 0 and 1 or 0)
end
local now = H.now(g)
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
local idle = 0
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
local kids = {
fg.label(c, moon.name, "h2", 18), space(0, 10),
fg.text_para(c, "Click a well's card for its details.", "small", { max_width = math.min(inner, S.text_max) }),
space(0, 10),
fact(H, "Wells", tostring(#(moon.wells or {})) .. (dry > 0 and (" · " .. dry .. " tapped out") or "")),
fact(H, "Supply left", ui.money(supply)),
fact(H, "Drills mining", tostring(mining)),
fact(H, "Ready to claim", tostring(done), { color = done > 0 and "ready" or "text" }),
fact(H, "Idle drills", tostring(idle)),
}
for _, k in ipairs(sale_kids(g, c, moon, inner, H)) do
kids[#kids + 1] = k
end
local tree = col({ width = inner }, kids)
fg.build(flex.solve(tree, x, y), c)
return tree.y + tree.h
end
-- A heading (h2) and a line under the viewport.
local function caption(c, x, y, inner, title, text)
fg.build(flex.solve(col({ width = inner }, {
@@ -562,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
}), x, y), c)
end
-- The facts under the viewport on the Moons section, built into root (at where.root's
-- origin; where: what M.build returned with the panel): the selected well's panel, the moon's status, or a mined-out caption. A
-- block of their own (core/main.lua), kept while M.facts_sig stays.
function M.facts(g, root, where, H)
local st = g.state
local moon = st.moon
local top, w, h, pad, inner = where.top, where.w, where.h, where.pad, where.inner
if st.well then
-- The well panel scrolls under the viewport (many drills).
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon.finished then
caption(root, pad, where.cy, inner, moon.name, "Mined out. Nothing left to dig.")
else
-- (scrolls like the well panel: Sell moon at its foot)
local area = scroll.area(g, root, 0, top, w, h - top - 4, "moon:" .. moon.id)
area:finish(moon_status(g, area.content, pad, 8, inner, H) + pad)
end
end
-- What M.facts shows (its block's signature, with where): from the state and our clock.
function M.facts_sig(g, where, H)
local st = g.state
local moon = st.moon
local now = H.now(g)
local sale = st.unlocked.store and moon.sale or false
local hold = sale and sale.holdEnd and sale.holdEnd > now and math.floor((sale.holdEnd - now) / 1000) or false
local common = { where.top, where.cy, where.w, where.h, where.inner, moon.id, moon.name, moon.finished or false,
sale, hold, g.sale_open and g.sale_open[moon.id] or false, g.ask and g.ask.id or false, st.catalog or false, st.catalogVersion or false }
if moon.finished then
return blocks.sig(common)
end
local supplies = {}
for i, x in ipairs(moon.wells or {}) do
supplies[i] = x.supply
end
if st.well then
local w = st.well
local wrow = find(moon.wells, w.id) or false
local tools = {}
for i, d in ipairs(wrow and wrow.drills or {}) do
local t = find(st.tools, d.toolId)
tools[i] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local wide = wrow and #(wrow.wide or {}) > 0 and moon.wide or false
for _, d in ipairs(wide and wide.drills or {}) do
local t = find(st.tools, d.toolId)
tools[#tools + 1] = t and { t.name or false, ui.levels_tag(t), t.image or false } or false
end
local better = w.better or false
local bt = better and find(st.tools, better.toolId)
local bm = better and find(st.moons, better.moonId)
return blocks.sig(common, w.id, w.resource, w.rate, better, bt and { bt.name or false, ui.levels_tag(bt) } or false,
bm and bm.name or false, wrow, wide, wide and moon.wells or false, tools, st.bonuses or false, moon.maxSupply or false,
st.rules and st.rules.maxSupply or false, moons.supply_max(st, moon), anim.reduced)
end
local mining, done, idle = 0, 0, 0
for _, s in ipairs(st.stakes or {}) do
if s.moonId == moon.id then
if s.ready or (s["end"] or math.huge) <= now then
done = done + 1
else
mining = mining + 1
end
end
end
for _, t in ipairs(st.tools or {}) do
idle = idle + (t.stakeId and 0 or 1)
end
return blocks.sig(common, supplies, mining, done, idle)
end
-- Region contract (AGENTS.md, "Game screen"): builds the aside panel at (x, y), w x h.
-- mod: the current section's module (its aside / aside_below, if any). row: the narrow
-- layout's row (core/main.lua): the viewport on the left, the side panel beside it.
-- Returns the panel (components/panel: its entrance).
-- Returns the panel (components/panel: its entrance) and, on the Moons section with a
-- moon, where its facts go (M.facts).
function M.build(g, parent, x, y, w, h, H, mod, row)
local st = g.state
local p = panel.new(parent, x, y, w, h)
@@ -575,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
local section = g.view.section
local moon = section == "moons" and st.moon or nil
local cy
if row then
-- A section panel that wants the whole aside (the drill panel): no moon over it.
local full = mod and mod.aside_full and st[section] and mod.aside_full(g)
if full then
moon_view.hide()
g.viewport_node, g.viewport_rect = nil, nil
cy = 8
elseif row then
local vp = M.row_viewport(w)
viewport(g, root, pad, pad, vp, shown_moon(g))
-- The side panel gets the rest of the row as its own box.
@@ -608,14 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
end
gui.delete_node(box)
end
if moon and st.well then
-- The well panel scrolls under the viewport (many deploy cards or drills).
local top = cy - 8
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id)
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon then
caption(root, pad, cy, inner, moon.name, moon.finished and "Mined out. Nothing left to dig."
or "Pick a well in the table to deploy tools and claim resources.")
if moon then
-- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
-- viewport doesn't show keeps the panel and its moon.
return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then
+2 -2
View File
@@ -103,8 +103,8 @@ local STEPS = {
function(a) return a.g.view.section == "new-moon" and shot("11-new-moon") end,
function(a) return a:press("buy-moon") end,
function(a) return a.g.reveal:card_done() and shot("12-reveal") end,
-- Buy another, straight from the card (its button registers as buy-moon).
function(a) return a:press("buy-moon") end,
-- Buy another, straight from the card (its button registers as reveal-buy).
function(a) return a:press("reveal-buy") end,
function(a) return #a.g.state.moons > 2 and a.g.reveal:card_done() and shot("12-reveal-another") end,
function(a) return a:press("reveal-close") end,
function(a) return not a.g.reveal:active() and #a.g.state.moons > 1 and shot("12-second-moon") end,
+5 -9
View File
@@ -105,17 +105,13 @@ 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.
function(a)
a.g.help_armed = a.g.h.now(a.g) + 30000
a.g.dirty = true
return true
end,
function() return shot("12-help-confirm") end,
-- Start over: open its confirm, then run new-game (as its OK).
function(a) return a:press("confirm:new-game") end,
function(a) return a.g.dialog.root and shot("12-help-confirm") end,
function(a) return a:press("new-game") end,
function(a) return a.g.state.view.section ~= "help" or true end,
function() return shot("13-new-game") end,
+180 -23
View File
@@ -12,6 +12,8 @@ local scroll = require("components.scroll")
local button = require("components.button")
local chip = require("components.chip")
local stat = require("components.stat")
local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local S = theme.size
@@ -30,11 +32,12 @@ function M.compact(n)
local function one(f)
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
end
if a >= 1e6 then
-- The unit is picked after rounding (999500 is 1M, not 1000k; 999.6 is 1k).
if a >= 1e6 - 500 then
return one(n / 1e6) .. "M"
elseif a >= 1e4 then
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k"
elseif a >= 1e3 then
elseif a >= 1e3 - 0.5 then
return one(n / 1e3) .. "k"
end
return ui.money(math.floor(n + 0.5))
@@ -47,14 +50,13 @@ function M.pct(m)
end
local EFFECT_WORDS = {
attachment = { { "speed", "speed" }, { "yield", "yield" } },
advisor = { { "speed", "drill speed" }, { "yield", "yield" }, { "sell", "sell prices" }, { "refine", "refinery speed" } },
}
-- "+20% speed", "+5% drill speed, +5% yield" (kind: "attachment" or "advisor").
-- "+5% drill speed, +5% yield" (kind: "advisor").
function M.effect(effect, kind)
local parts = {}
for _, e in ipairs(EFFECT_WORDS[kind or "attachment"]) do
for _, e in ipairs(EFFECT_WORDS[kind or "advisor"]) do
local m = effect and effect[e[1]] or 1
if m > 1 then
parts[#parts + 1] = M.pct(m) .. " " .. e[2]
@@ -186,29 +188,12 @@ function M.stat_up(parent, label, from, to, x, y)
return w
end
-- A violet tag (attachment names); returns its width.
-- A violet tag (advisor labels); returns its width and the box.
function M.tag(parent, text, x, y, color)
local box, w = chip.new(parent, text, x, y, { color = color or "violet", alpha = 0.5, text = color or "violet_hi", upper = true })
return w, box
end
-- Attachment slot pips: filled diamonds for used slots. Returns the width.
function M.pips(parent, used, slots, x, y)
for i = 1, slots do
local p = ui.rect(parent, x + (i - 1) * 13 + 5, y, 8, 8, "violet", i <= used and 1 or 0)
gui.set_pivot(p, gui.PIVOT_CENTER)
gui.set_position(p, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(p, vmath.vector3(0, 0, 45))
if i > used then
local o = ui.outline(parent, 0, 0, 8, 8, "violet")
gui.set_pivot(o, gui.PIVOT_CENTER)
gui.set_position(o, vec(x + (i - 1) * 13 + 5, -y))
gui.set_rotation(o, vmath.vector3(0, 0, 45))
end
end
return slots * 13
end
-- Refined parts an upgrade needs: "● 3/5 Dusk Alloy" chips, red when short. Returns the
-- height used.
function M.parts(st, parent, parts, x, y, w)
@@ -277,6 +262,178 @@ function M.confirm_server_button(self, b, c, act, params, id)
end
end })
self.fx:button(id, b)
-- Autoplay presses `act` as if confirmed: it runs the action (through the dialog when
-- its confirm is open), and presses `id` to open the confirm.
if self.autoplay then
local function confirmed()
local d = self.dialog
if d.root and not d.closing and d.opts and d.opts.ok and d.opts.ok.act == act then
d:act(act, params)
else
self.fx:origin(b, id)
self.autoplay.fns.act(self, act, params or {}) -- (session.act)
end
end
local function ask()
M.confirm_server(self, c, act, params, opener(self, b, id))
end
local function register()
if self.autoplay then
self.autoplay:register(act, params, confirmed)
self.autoplay:register(id, params, ask)
end
end
register()
blocks.note(register) -- (a kept block registers again)
end
end
---------------------------------------------------------------- inline confirm
-- Sells ask in place, not in a dialog (sell-tool, sell-moon): the first
-- click on the button (M.ask_button) puts it in its asking state, which its section builds
-- instead of the button in the same footprint (M.asking, M.ask_buttons, M.ask_text): the
-- button's spot becomes Cancel, Confirm (the server's OK words, "Sell · ₥750") sits just
-- left of it, so a small move left confirms, and the server's question takes the room of
-- what was left of it. Nothing else moves. Confirm takes the keyboard focus (soft, like
-- the dialog's OK: Enter confirms). It cancels on Esc, a click anywhere else, ASK_IDLE
-- seconds without a key or button press, navigating, and when what it asks about changes (its words or
-- params: core/main and session call M.cancel_ask). State: self.ask = { id, act, params,
-- key, t, nodes }.
M.ASK_IDLE = 6
local function ask_key(c, params)
local parts = { c and c.text or "", c and c.ok or "" }
local ks = {}
for k in pairs(params or {}) do
ks[#ks + 1] = k
end
table.sort(ks)
for _, k in ipairs(ks) do
parts[#parts + 1] = k .. "=" .. tostring(params[k])
end
return table.concat(parts, "|")
end
function M.cancel_ask(self)
if self.ask then
self.ask = nil
self.dirty = true
end
end
-- The ask in progress for the button `id`, when its words and params are still the same
-- (else it is dropped: the state changed under it).
function M.asking(self, id, c, params)
local a = self.ask
if not a or a.id ~= id then
return nil
end
if a.key ~= ask_key(c, params) then
self.ask = nil
return nil
end
a.nodes = {}
return a
end
-- Every frame (core/main): the idle timeout.
function M.ask_update(self)
if self.ask and socket.gettime() - self.ask.t > M.ASK_IDLE then
M.cancel_ask(self)
end
end
-- Input (core/main, before the page gets it): a key or button press keeps the ask alive
-- (moves don't: a rebuild replays the last one); Esc and a press outside its buttons
-- cancel it. Returns true when the input is used up (Esc).
function M.ask_input(self, action_id, action)
local a = self.ask
if not a then
return false
end
if action.pressed then
a.t = socket.gettime()
end
if action_id == hash("key_esc") and action.pressed then
M.cancel_ask(self)
return true
end
if action_id == hash("touch") and action.pressed and action.x then
for _, n in ipairs(a.nodes or {}) do
if pcall(gui.get_id, n) and gui.pick_node(n, action.x, action.y) then
return false
end
end
M.cancel_ask(self)
end
return false
end
-- The button that asks (bound like confirm_server_button, test id `id`).
function M.ask_button(self, b, c, act, params, id)
id = id or ("confirm:" .. act)
button.bind(self, b, nil, { id = id, on_click = function()
if self.busy then
return
end
self.ask = { id = id, act = act, params = params, key = ask_key(c, params), t = socket.gettime() }
-- Confirm takes the focus (soft after a mouse click: Enter confirms, no ring).
self.focus = self.focus or { index = 0 }
self.focus.key = "page:" .. id .. ":ok"
self.focus.soft = not self.focus.visible
require("modules.sfx").play("open")
self.dirty = true
end })
self.fx:button(id, b)
end
-- The asking state's buttons, tree pieces for a row (made under parent): Confirm then
-- Cancel (cancel_w: the asking button's width: Cancel is at least as wide, so set at the
-- row's right end it covers the button's spot).
function M.ask_buttons(self, parent, c, cancel_w, opts)
local a = self.ask
opts = opts or {}
local label, price = (c.ok or "Confirm"), nil
local head, num = label:match("^(.-)%s*₥%s*([%d,%.]+)$")
if head then
label = head:gsub("%s+for$", "")
price = tonumber((num:gsub(",", "")))
end
local small = opts.small ~= false
local bopts = { small = small, variant = "danger", price = price, fill = { "danger", 0.32 }, edge = { "danger", 1 } }
local ok = fg.button(parent, label, bopts, function(b, nodes)
a.nodes[#a.nodes + 1] = b
button.bind(self, b, nodes, { id = a.id .. ":ok", on_click = function()
if self.ask ~= a then
return
end
self.ask = nil
-- (the effects start where the asking button was, like the dialog's opener)
self.fx:origin(b, a.id)
require("modules.sfx").play("confirm")
self.h.act(self, a.act, a.params or {}) -- (core/session, through the H table: no require cycle)
self.dirty = true
end })
self.fx:button(a.id, b)
end)
local cancel = fg.button(parent, "Cancel", { small = small }, function(b, nodes)
a.nodes[#a.nodes + 1] = b
button.bind(self, b, nodes, { id = a.id .. ":cancel", on_click = function()
require("modules.sfx").play("close")
M.cancel_ask(self)
end })
end, { width = math.max(cancel_w, fg.button_w(parent, "Cancel", { small = small })) })
return ok, cancel
end
-- The server's question, compact: at most `lines` lines (default 2) in what room is left.
function M.ask_text(c, parent, opts)
opts = opts or {}
local t = fg.para(parent, c.text or "", { font = "sans", px = 12, color = "text_2" }, 15,
{ lines = opts.lines or 2, grow = 1, shrink = 1, basis = 0 })
t.done = function(n) tooltip.add(n, c.text) end -- (the whole question, when it's cut)
return t
end
-- A button that asks first (cards.confirm). id: its test id (default "confirm:<act>").
+2 -2
View File
@@ -168,10 +168,10 @@ function M.chart(parent, x, y, w, h, data, opts)
local tip_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
gui.set_enabled(cross, false)
local rect
local shown = -1
local function move(action)
rect = rect or screen_rect(box, cw, ch)
-- Measured on every move: scrolling or the entrance slide moves the box.
local rect = screen_rect(box, cw, ch)
local inside = action.screen_x >= rect.x0 and action.screen_x <= rect.x1
and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
if not inside then
+9 -7
View File
@@ -111,10 +111,13 @@ 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)
local key = button_key(name, params)
-- 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).
-- With params a function, pass id ("act:<action>?<its fixed params>"): it is also the
-- busy key, so each such button has its own (else they all share the action's name).
-- 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 = type(params) == "function" and id and (id:gsub("^act:", "")) or button_key(name, params)
if g.inflight[key] then
button.set_busy(b)
end
@@ -130,7 +133,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)
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id)
if g.autoplay then
local p = type(params) == "function" and params() or params
g.autoplay:register(name, p, fire)
blocks.note(function() -- (a kept block registers again)
blocks.note(function() -- (a kept block registers again; params made now)
if g.autoplay then
g.autoplay:register(name, p, fire)
g.autoplay:register(name, type(params) == "function" and params() or params, fire)
end
end)
end
+116 -39
View File
@@ -10,7 +10,9 @@
-- Druid instance and every layout node first. What must survive a rebuild lives on g
-- (scroll offsets g.scroll_pos, fields g.fields, eased values g.tween, effects g.fx and
-- toasts g.toasts on their own layers). Regions are laid out here: banner and header
-- across the top, then the moon list, the main panel (the current section) and the aside.
-- across the top, then the moon list, the main panel (the current section), the right
-- side panel (sidepanel.lua: the leaderboard; folded away when there's no room) and the
-- aside.
local druid = require("druid.druid")
local theme = require("modules.theme")
@@ -36,11 +38,14 @@ local banner = require("screens.game.banner")
local header = require("screens.game.header")
local moonlist = require("screens.game.moonlist")
local aside = require("screens.game.aside")
local sidepanel = require("screens.game.sidepanel")
local cards = require("screens.game.cards")
local reveal = require("screens.game.reveal")
local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks")
local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
@@ -57,7 +62,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"),
@@ -84,14 +88,16 @@ end
---------------------------------------------------------------- build
-- Entrances (enter.js): the main panel when the section or moon changes, the aside when
-- Entrances (enter.js): the main panel when the section (or on Moons the moon) changes, the aside when
-- the well changes (or the drill: the client's drill panel is in the aside); nothing else
-- in the view (pick, list, page, adv) makes an entrance. Links keep the moon like the
-- web's (View.ToSection), else the server's first-moon default would change it. Both on
-- the first state and after the reveal closes (g.enter_all).
function enter(g)
local v = g.view
local main_key = v.section .. "|" .. (v.moon or "")
-- (the moon only for the Moons section: elsewhere an action that moves the view's moon,
-- Claim all finishing one, mustn't replay the section's entrance)
local main_key = v.section .. "|" .. (v.section == "moons" and (v.moon or "") or "")
local well_key = (v.moon or "") .. "|" .. (v.well or "") .. "|" .. (v.drill or "") -- (the drill panel is in the aside)
local all = g.enter_all or not g.entered
if g.reveal:hiding() then
@@ -139,12 +145,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 +185,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" }
@@ -211,11 +216,13 @@ local function pick_keys(st, ...)
end
-- Drills and batches whose end passed on our clock (core/ready counts them before the
-- refetch): the counts they show change with it.
local function ready_key(g)
-- refetch): the counts they show change with it. kinds: the state lists to look at
-- (default both).
local function ready_key(g, kinds)
local st, now = g.state, session.now(g)
local out = {}
for _, list in ipairs({ st.stakes or {}, st.jobs or {} }) do
for _, k in ipairs(kinds or { "stakes", "jobs" }) do
local list = st[k] or {}
for _, x in ipairs(list) do
if not x.ready and x["end"] and x["end"] <= now then
out[#out + 1] = tostring(x.id or x.toolId) .. "@" .. x["end"]
@@ -227,10 +234,15 @@ end
-- What every region depends on besides the state: busy buttons, Claims that turned ready
-- on our clock, the toggles.
local function local_sig(g)
return blocks.sig(g.inflight, g.ready_at, ready_key(g), sfx.muted(), anim.reduced, theme.name)
local function local_sig(g, kinds)
return blocks.sig(g.inflight, g.ready_at, ready_key(g, kinds), sfx.muted(), anim.reduced, theme.name, g.ask and g.ask.id or false)
end
-- The ready lists each section shows: a drill turning ready doesn't rebuild the Refinery
-- (a 60-90 ms build, twice per drill: on our clock, then at the refetch), nor a batch the
-- Moons. Sections not listed show neither.
local MAIN_READY = { moons = { "stakes" }, equipment = { "stakes" }, refinery = { "jobs" } }
-- The reveal hides the main panel, the aside's content and the new moon's row.
local function reveal_sig(g)
local r = g.reveal
@@ -238,10 +250,11 @@ local function reveal_sig(g)
end
-- The section's block in the main panel.
local function build_main(g, parent, x, y, w, h, common)
local function build_main(g, parent, x, y, w, h)
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 common = local_sig(g, MAIN_READY[section] or {})
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, g.side_w, g.view, g.advisor_pick, g.refine_qty, g.sale_open, g.nm_grade, g.deploy_pick or false,
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}))
blocks.keep(g, "main", sig, parent, function(holder)
for _, m in pairs(M.SECTIONS) do
@@ -270,14 +283,25 @@ local function build_main(g, parent, x, y, w, h, common)
end
-- The aside's block (narrow: the row under the panels).
local function build_aside(g, parent, x, y, w, h, row, common)
local function build_aside(g, parent, x, y, w, h, row, common, still)
local st = g.state
local section = g.view.section
local sig = blocks.sig(common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
return blocks.keep(g, "aside", sig, parent, function(holder)
-- (on Moons with a moon the panel only shows the viewport: its facts are a block of
-- their own, below)
local moon_only = section == "moons" and st.moon ~= nil
local sig = blocks.sig(moon_only and still or common, reveal_sig(g), x, y, w, h, row, g.view, g.advisor_pick, g.sale_open,
moon_only and blocks.sig(st.moon.id, st.view and st.view.aside, st.catalogVersion or st.catalog or false)
or pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS))
local p, facts = blocks.keep(g, "aside", sig, parent, function(holder)
return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
end)
if facts then
-- The moon's facts: a block of their own inside the panel (aside.facts).
blocks.keep(g, "aside:facts", aside.facts_sig(g, facts, g.h), facts.root, function(holder)
aside.facts(g, holder, facts, g.h)
end)
end
return p
end
-- A full rebuild drops every node and component and builds them all again; otherwise the
@@ -347,11 +371,15 @@ function M.build(g)
g.layout_state = true
local st = g.state
local common = local_sig(g)
-- (without the drills and batches that ended on our clock: the banner, header and moon
-- list show those counts through their own signatures, and a drill ending mustn't
-- rebuild them)
local still = local_sig(g, {})
-- Announcements and live events above the header.
local banner_h = blocks.keep(g, "banner", blocks.sig(common, W, st.banner), g.layout, function(holder)
local banner_h = blocks.keep(g, "banner", blocks.sig(still, W, st.banner), g.layout, function(holder)
return banner.build(g, g.h, holder, W)
end)
local header_h = blocks.keep(g, "header", blocks.sig(common, W, banner_h, g.view.section, st.credits, st.resources,
local header_h = blocks.keep(g, "header", blocks.sig(still, W, banner_h, g.view.section, st.credits, st.resources,
st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
@@ -362,6 +390,25 @@ function M.build(g)
local pad, gap = S.body_pad, S.gap
local top = header_h + pad
local body_h = H - top - pad
if g.admin then
-- The admin area (admin.lua, the gear menu) instead of the moon list, the section and
-- the aside; kept while its own inputs stay (a state update doesn't touch it).
g.toasts:detach()
g.moon_nodes, g.well_nodes = {}, {}
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
g.page_key, g.page_main, g.side_w = nil, nil, 0
blocks.keep(g, "admin", blocks.sig(W, H, top, theme.name, anim.reduced, admin.sig(g)), g.layout, function(holder)
admin.build(g, holder, pad, top, W - pad * 2, body_h)
end)
blocks.finish(g)
g.tween:finish()
ticks.run(g)
g.dirty = false
g.fx:after_build(g)
g.reveal:after_build()
g.rehover = true
return
end
local has_aside = M.aside_shown(g)
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row
-- under the moon list and the main panel, the viewport and the side panel side by
@@ -377,6 +424,13 @@ function M.build(g)
end
local main_x = pad + (list_w > 0 and (list_w + gap) or 0)
local main_w = W - pad - main_x - ((has_aside and not narrow) and (aside_w + gap) or 0)
-- The right side panel takes its width from the main panel's, or folds away.
-- (the side panel is the Market's: Top miners beside it)
local side_w = sidepanel.width(g, W, main_w, narrow or has_aside or g.view.section ~= "market")
g.side_w = side_w
if side_w > 0 then
main_w = main_w - side_w - gap
end
local parent, y0, page = g.layout, top, nil
local row_h
if has_aside and narrow then
@@ -399,48 +453,61 @@ function M.build(g)
for _, m in ipairs(st.moons) do
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)
blocks.keep(g, "moonlist", blocks.sig(still, reveal_sig(g), pad, y0, list_w, body_h, v.section, v.past, v.page, v.moon,
moonlist.rows_sig(g), 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)
if side_w > 0 then
sidepanel.build(g, parent, main_x + main_w + gap, y0, side_w, body_h, common)
if sidepanel.missing(g) then
if not g.side_asked then
g.side_asked = true
session.fetch(g, g.nav_view or g.view)
end
else
g.side_asked = false
end
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)
g.aside_panel = build_aside(g, parent, pad, y0 + body_h + gap, aside_w, row_h, true, common, still)
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 drill or an advisor brings the aside into view (not a well: its card,
-- where its actions are, stays in view; the aside only shows its facts).
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.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
elseif has_aside then
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common)
g.aside_panel = build_aside(g, parent, W - pad - aside_w, top, aside_w, body_h, nil, common, still)
else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false
moon_view.hide()
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
@@ -551,6 +618,10 @@ function input(g, action_id, action)
if action_id == TOUCH and action.released then
button.release() -- (carried only while the mouse is down; after this Druid clicks)
end
-- A sell asking in place (cards.ask_button): Esc or a press elsewhere cancels it.
if not g.dialog:is_open() and cards.ask_input(g, action_id, action) then
return true
end
-- Tab focus, Enter/Space on the focused element, back/forward (core/keys).
if keys.before(g, action_id, action, function(id, a) return input(g, id, a) end) then
return true
@@ -614,6 +685,9 @@ function M.update(g, dt)
g.fx:update(dt)
g.reveal:update(dt)
g.dialog:update(dt)
-- (moon.script takes input before the GUI: a menu over the viewport must get its clicks)
moon_view.covered = g.dialog:is_open()
cards.ask_update(g)
check_input(g)
tooltip.update(g.dialog:is_open())
focus.update(g, g.focus_layer)
@@ -622,6 +696,9 @@ function M.update(g, dt)
m.update(g, dt)
end
end)
if g.admin then
admin.update(g, dt)
end
g.druid:update(dt)
if g.rehover then
-- After a rebuild: what is under the mouse shows its hover at once (the new
+49
View File
@@ -33,6 +33,25 @@ function M.drills(g, moon_id)
return (st.moon and st.moon.id == moon_id and st.moon.ready or 0) + n
end
-- A batch shown as finished: ready in the state or its end passed on our clock.
function M.batch_done(g, j)
return j.ready or crossed(j, session.now(g))
end
-- Seconds since batch j finished (0 when it just did and the watch below hasn't seen it
-- yet), or nil when it isn't finished or already was when first seen: the effects that
-- follow a batch's end through rebuilds go by it.
function M.batch_age(g, j)
if not g.batches_done or not M.batch_done(g, j) then
return nil
end
local t = g.batches_done[j.id]
if t == nil then
return 0
end
return t > 0 and socket.gettime() - t or nil
end
-- Finished refinery batches (the Refinery tab's badge).
function M.batches(g)
local now = session.now(g)
@@ -92,7 +111,37 @@ function M.watch(g)
g.ready_due = M.next_due(g)
end
-- Batches that finished since the last look (ready in the state, or their end passed on
-- our clock), whichever section is open: each one's moment goes in g.batches_done[id]
-- (seconds; 0 for those already finished when first seen, which get no effect), for
-- effects that carry on through rebuilds (refinery.lua's slot, header.lua's badge), and
-- fx:batch_ready plays once for them (sound, the slot or the Refinery tab).
local function watch_batches(g)
local st = g.state
if not st or not st.jobs then
return
end
local now, t = session.now(g), socket.gettime()
local seen, out, fresh = g.batches_done, {}, nil
for _, j in ipairs(st.jobs) do
if j.id and (j.ready or crossed(j, now)) then
out[j.id] = seen and (seen[j.id] or t) or 0
if seen and not seen[j.id] then
fresh = fresh or {}
fresh[#fresh + 1] = j
end
end
end
g.batches_done = out
if fresh then
g.batch_ready_t = t
g.dirty = true
g.fx:batch_ready(g, fresh)
end
end
function M.update(g)
watch_batches(g)
if g.ready_due and g.state and session.now(g) >= g.ready_due then
g.ready_due = nil
g.dirty = true
+94 -22
View File
@@ -14,6 +14,8 @@ local api = require("modules.api")
local predict = require("modules.predict")
local sfx = require("modules.sfx")
local refinery = require("screens.game.refinery")
local sidepanel = require("screens.game.sidepanel")
local blocks = require("modules.blocks")
local M = {}
@@ -61,8 +63,20 @@ 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 }
-- g: the blocks the right side panel shows are asked for too (sidepanel.includes), on
-- every request while it shows, so they never drop out of the state.
function M.view_params(v, extra, g)
-- (the leaderboard is a side panel, or a pane in the market when that is folded away:
-- 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"
for _, x in ipairs(g and sidepanel.includes(g) or {}) do
if not (","..p.include..","):find(","..x..",", 1, true) then
p.include = p.include .. "," .. x
end
end
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,7 +85,11 @@ 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
p[k] = x
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
@@ -111,6 +129,9 @@ local function suspend(g)
g.refetch = nil
end
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {}
if g.history then
g.history.want = nil -- (its request was in the outbox)
end
g.boot = { text = SUSPENDED }
if g.dialog and g.dialog:is_open() then
g.dialog:close("cancel")
@@ -118,24 +139,49 @@ local function suspend(g)
g.dirty = true
end
-- Refetch when the next drill or refinery batch is due (the server decides what is ready).
-- Refetch when the next drill or refinery batch is due (the server decides what is ready),
-- or a banner item ends (the server's state no longer has it).
local function arm_refetch(g)
if g.refetch then
timer.cancel(g.refetch)
g.refetch = nil
end
local due = g.state.nextEnd
local batch = refinery.next_end(g.state)
if batch and (not due or batch < due) then
due = batch
-- (a time already past is skipped: the state we have is after it)
local now = M.now(g)
local due
local function consider(t)
if t and t > 0 and t - now >= -2000 and (not due or t < due) then
due = t
end
end
consider(g.state.nextEnd)
consider(refinery.next_end(g.state))
for _, b in ipairs(g.state.banner or {}) do
consider(b["end"])
end
if not due then
return
end
local left = due - M.now(g)
if left < -2000 then
return -- already past: the state we have is after it
-- Ends within a second after it wait for the last of them: one refetch and rebuild,
-- not one each (four batches started together end a few ms apart).
local first = due
local function join(t)
if t and t > due and t <= first + 1000 then
due = t
end
end
join(g.state.nextEnd)
for _, j in ipairs(g.state.jobs or {}) do
if not j.ready then
join(j["end"])
end
end
for _, s in ipairs(g.state.stakes or {}) do
if not s.ready then
join(s["end"])
end
end
local left = due - now
g.refetch = timer.delay(math.max(0, left) / 1000 + 0.3, false, function()
g.refetch = nil
-- (a navigation still on its way: its view, or this answer would take it back)
@@ -145,7 +191,8 @@ end
-- What we show: the last server state (g.base) with the pending predictions on top.
local function show(g)
g.state = predict.apply(g.base, g.pending)
-- (frozen: what is shown never changes, so blocks.sig writes each part once per state)
g.state = blocks.freeze(predict.apply(g.base, g.pending))
g.view = g.state.view
arm_refetch(g)
g.dirty = true
@@ -173,7 +220,9 @@ end
-- The views shown, like the web's browser history (main.js syncURL: every response whose
-- view differs from the current entry pushes one; back and forward fetch an entry again
-- without pushing). g.history = { list = { view, ... }, i = current }.
-- without pushing). g.history = { list = { view, ... }, i = current, want = the index a
-- back/forward on its way goes to }. i moves when that answer comes; until then answers
-- to requests sent before it (an action, a refetch) push nothing.
local VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
local function view_key(v)
@@ -200,7 +249,14 @@ local function remember(g, from_history)
if from_history then
if h.list[from_history] then
h.list[from_history] = v
h.i = from_history
end
if h.want == from_history then
h.want = nil
end
return
end
if h.want then
return
end
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then
@@ -248,6 +304,10 @@ pump = function(g)
elseif g.base then
show(g) -- rolled back
end
-- (a back/forward that failed: the history stays where it was)
if not applied and r.nav and r.nav.from_history and history(g).want == r.nav.from_history then
history(g).want = nil
end
if status == 403 then
suspend(g)
g.inflight, g.busy, g.nav_view = {}, false, nil
@@ -265,17 +325,19 @@ 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.batches_done = nil -- (another player's batches: no ready effects for them)
g.fx:on_response(g, "get", data, nil, g.state, {})
g.enter_all = true
elseif g.state and g.state ~= prev then
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 +349,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
@@ -314,7 +381,7 @@ end
-- 5xx) it tries again: at start as g.boot says (and a 429 too), in play every 3 s,
-- doubling up to 30 s, until it answers; the shown state stays meanwhile.
function M.fetch(g, view, nav)
request(g, "get", M.view_params(view or {}), function(err, data, status)
request(g, "get", M.view_params(view or {}, nil, g), function(err, data, status)
if g.retry then
timer.cancel(g.retry)
g.retry = nil
@@ -354,6 +421,10 @@ function M.navigate(g, v)
sfx.play("nav")
end
g.nav_view = v
g.ask = nil -- (a sell asking in place: cards.ask_button)
if g.admin then
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
end
M.fetch(g, v, v)
end
@@ -361,12 +432,13 @@ end
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
function M.go(g, dir)
local h = history(g)
local v = h.list[h.i + dir]
local i = (h.want or h.i) + dir
local v = h.list[i]
if not v then
return false
end
h.i = h.i + dir
local to = { from_history = h.i }
h.want = i
local to = { from_history = i }
for k, x in pairs(v) do
to[k] = x
end
@@ -380,7 +452,7 @@ end
function M.act(g, name, params, on_done, fn)
g.seq = g.seq + 1
local seq = g.seq
local p = M.view_params(g.nav_view or g.view, params)
local p = M.view_params(g.nav_view or g.view, params, g)
p.clientSeq = seq
fn = fn or predict.registry[name]
local predicted = false
@@ -390,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
if predict.run(st, e) then
g.pending[#g.pending + 1] = e
local prev = g.state
g.state = st
g.state = blocks.freeze(st)
g.dirty = true
predicted = true
-- The action's effects play now, from the predicted change (fx.lua).
File diff suppressed because it is too large. Load diff
+112 -95
View File
@@ -18,7 +18,8 @@ local S = theme.size
local M = {}
-- Header sections in the web's order; each shows once state.unlocked has it.
M.SECTIONS = { "moons", "equipment", "store", "market", "refinery", "advisors", "leaderboard", "help" }
-- (the leaderboard is the right side panel, sidepanel.lua; 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,47 @@ 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)
-- The Refinery tab's badge pops in (fx-ready-pop, 0.6 s) when a batch has just finished
-- (core/ready), by the clock, so the refetch's rebuild carries on. Reduced motion: none.
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.25 }, { 1, 1 } }
local function pop_badge(g, node, w)
local age
for _, j in ipairs(g.state.jobs or {}) do
local a = ready.batch_age(g, j)
if a and (not age or a < age) then
age = a
end
end
if anim.reduced or not age or age >= 0.6 then
return
end
local p, base = gui.get_position(node), gui.get_scale(node)
anim.tween(node, 0.6, function(k, n)
anim.scale_about(n, p, base, w / 2, -7.5, anim.keys(POP_SCALE, k, anim.fn.ready_pop)) -- (about its center: 15 tall)
end, -age)
end
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 +98,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 +154,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,9 +170,8 @@ 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
w = w + 26
end
-- The count is a small badge on the tab's top-right corner: it takes no width,
-- so the tabs don't move when it comes or goes (clicker rule).
names[#names + 1] = name
nav_w = nav_w + w + 2
tabs[#tabs + 1] = { style = { width = w, height = 44, shrink = 0 }, make = function(p, tx, ty)
@@ -192,7 +184,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(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)
@@ -204,7 +196,11 @@ function M.build(g, parent, x, y, W, h, H)
g.fx:reticle(root, w, 44, name)
end
if badge then
chip.new(root, badge, w - 34, 22, { color = "ready", font = "mono", px = 12, min_w = 22, pad = 0 })
local cb, cw = chip.new(root, badge, w - 1, 9, { right = true, color = "ready", text = "bg", fill = 1,
font = "mono_semi", px = 10, h = 15, min_w = 15, pad = 3 })
if name == "refinery" then
pop_badge(g, cb, cw)
end
end
tabs_c.tab(g, root, t, {
active = active, color = shine and not active and "cyan_hi" or nil, id = "tab:" .. name, label = name,
@@ -213,9 +209,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 +220,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 +238,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,
+85 -57
View File
@@ -1,81 +1,109 @@
-- 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): 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
-- room is too short (components/list). Shown in the right side panel (sidepanel.lua; this
-- module is one of its panels), or, when that panel is folded away (narrow windows), as a
-- pane under the Market (market.lua, M.pane).
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.
-- The side panel's contract (sidepanel.lua).
M.key = "leaderboard"
M.include = "leaderboard" -- the state block it shows (asked for while the panel shows)
function M.unlocked(g)
local u = g.state and g.state.unlocked
return u ~= nil and u.leaderboard == true
end
function M.sig(g)
return g.state.leaderboard or false
end
-- 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")
local l
l = list.new(self, p, lx, ly, lw, 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)
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),
-- The height the leaderboard takes with every row shown.
local function natural(lb)
return HEAD_H + math.max(#lb.top, 1) * ROW_H + (lb.you and ROW_H * 2 or 0) + PAD
end
-- The leaderboard's content in p (a panel's root) w x h: title, place, the rows (they
-- scroll when h is short). Without the state block yet (it comes with the next state:
-- sidepanel.lua asks for it), the title and a placeholder.
local function body(g, p, w, h)
local lb = g.state.leaderboard
ui.text(p, "Top miners", "cap", PAD, 18)
if not lb then
ui.text(p, "Loading…", "small", PAD, 38)
return
end
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 foot = lb.you and ROW_H * 2 or 0
local ly = HEAD_H
local lh = math.max(ROW_H, math.min(h, natural(lb)) - HEAD_H - foot - PAD)
ui.rect(p, 0, ly - 1, w, 1, "edge")
if #lb.top > 0 then
local l
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, w, ROW_H, "none")
row(holder, r, 0, 0, w)
return holder
end,
})
l:set_data(lb.top)
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
end
-- As a side panel (sidepanel.lua): its content in root (the panel's), w x h.
function M.build(g, root, x, y, w, h)
local p = ui.rect(root, x, y, w, h, "none")
body(g, p, w, h)
end
-- As a pane of its own at (x, y), w wide, in its own frame (the Market, when the side
-- panel is folded away); 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 n = natural(g.state.leaderboard)
h = h and math.min(h, n) or n
local _, nodes = ui.panel(root, x, y, w, h)
body(g, ui.part(nodes, "panel", "root"), w, h)
return h
end
return M
+73 -57
View File
@@ -16,6 +16,7 @@ local ticker = require("screens.game.ticker")
local sale = require("screens.game.sale")
local scroll = require("components.scroll")
local blocks = require("modules.blocks")
local leaderboard = require("screens.game.leaderboard")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
@@ -145,54 +146,60 @@ local function credits_box(c, amount, opts, h)
return fg.inline(c, h, function(p, x, y) return (ui.credits(p, amount, x, y, opts)) end)
end
-- A quote card (w x h): name (or symbol when narrow), what you hold, price, change and
-- A quote card's sizes for its width: the amount held (the card's big number) and the
-- card's height (every card of a grid is as tall). Wide cards (w >= 150) show the name and
-- what the holding is worth over it, price and change under it; narrow ones the symbol and
-- the change over it, the price under it (the worth in the name's tooltip).
local function card_size(w)
local wide = w >= 150
local big = w >= 220 and 40 or w >= 160 and 34 or 30
return wide, big, 10 + 16 + 4 + big + 4 + 14 + 6 + 16 + 8
end
-- A quote card (w x h): name (or symbol when narrow) and what the holding is worth, the
-- amount held in large type (how much you own reads at a glance), price, change and
-- sparkline. A tree piece; the card's node takes the hover and the click.
local function card(self, H, c, w, h, q, on)
local st = self.state
local empty = q.balance <= 0
local wide = w >= 150
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
or { font = "mono_semi", px = 12, color = empty and "muted" or "text", tracking = 0.06 }, 14, { fit = true, shrink = 1 })
local hold = fg.label(c, ui.money(q.balance), { font = "mono", px = 11, color = empty and "faint" or "text_2" }, 14)
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
-- What it's worth (≈ ₥X) under the holding; "Refined" under the name when there's room.
local wide, big = card_size(w)
local muted = empty and "faint" or "muted"
local value = credits_box(c, q.value or 0, { px = 10, color = muted }, 14)
local vw = value.style.width
local sub
if wide and w - 34 - vw - 20 > 50 then
sub = fg.label(c, (q.symbol or "") .. (q.refined and " · Refined" or ""), { font = "mono", px = 10, color = "faint", tracking = 0.06 }, 14,
{ fit = true, shrink = 1, max_width = w - 34 - vw - 20 })
else
name.done = function(n) tooltip.add(n, q.refined and (q.item .. " · Refined") or q.item) end
local name = fg.label(c, wide and q.item or (q.symbol or q.item), wide and { font = "sans_semi", px = 15, color = empty and "muted" or "text" }
or { font = "mono_semi", px = 13, color = empty and "muted" or "text" }, 16, { fit = true, shrink = 1 })
name.done = function(n)
tooltip.add(n, (q.refined and (q.item .. " · Refined") or q.item)
.. (wide and "" or (" · worth ≈ " .. theme.currency .. ui.money(q.value or 0))))
end
-- What it's worth (≈ ₥X), at the name's right on a wide card.
local value = wide and credits_box(c, q.value or 0, { px = 11, color = muted }, 16) or nil
-- The amount held: the card's big number (shrinks, then cuts, when it is very long).
local hold = fg.label(c, ui.money(q.balance), { font = "mono_semi", px = big, color = empty and "faint" or "text" }, big,
{ fit = true, shrink = 1, shrink_text = 0.6 })
hold.done = function(n) H.tick_num(self, n, "mk:bal:" .. q.item, q.balance) end
local owned = w >= 170 and fg.label(c, "owned", { font = "sans_semi", px = 10, color = "faint", tracking = 0.2, upper = true }, 14,
{ shrink = 0 }) or nil
local boosted = q.boosted or (q.boosted == nil and q.boost and q.boost > 1.001)
local boost = boosted and fg.label(c, boost_text(q), { font = "mono", px = 11, color = "ready" }, 14) or nil
local price = credits_box(c, q.price, { px = 13, color = "text" }, 14)
local lines
-- Price and change on one line when the card is wide, else on two.
if wide then
lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(), change_box(c, q, 14, { px = 11 }) }) }
else
lines = {
row({ height = 14 }, { price }), space(0, 2),
row({ height = 14 }, { change_box(c, q, 14, { px = 11 }), fg.fill(), boost }),
}
end
local price = credits_box(c, q.price, { px = 13, color = "text_2" }, 14)
-- Price (and the change, on a wide card) under the amount.
local lines = { row({ height = 14 }, { price, boost and space(6) or nil, boost, fg.fill(),
wide and change_box(c, q, 14, { px = 11 }) or nil }) }
local spark_color = (q.change >= 0) and "up" or "danger"
local kids = {
row({ height = 14, align = "center" }, {
row({ height = 16, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(p, x, y) ui.rect(p, x, y, 8, 8, color_of(st, q.item)) end),
space(6), name, space(6), fg.fill(), hold,
space(6), name, space(6), fg.fill(),
wide and fg.label(c, "≈", { font = "sym", px = 11, color = muted }, 16) or nil, wide and space(3) or nil, value,
(not wide) and change_box(c, q, 16, { px = 10 }) or nil,
}),
row({ height = 14, padding_left = 14 }, {
sub, fg.fill(), fg.label(c, "≈", { font = "sym", px = 10, color = muted }, 14), space(3), value,
}),
space(0, 5),
space(0, 4),
row({ height = big, align = "end" }, { hold, owned and space(6) or nil,
owned and col({ padding_bottom = math.floor(big * 0.12), shrink = 0 }, { owned }) or nil }),
space(0, 4),
-- (kept while the series is the same: it moves on with the 5-minute tick)
made({ position = "absolute", left = 10, top = h - 20, width = w - 20, height = 13 }, function(p, x, y)
made({ position = "absolute", left = 10, top = h - 24, width = w - 20, height = 16 }, function(p, x, y)
blocks.keep(self, "mk:spark:" .. q.item, blocks.sig(w, h, q.spark, spark_color), p, function(r)
chart.spark(r, x, y, w - 20, 13, q.spark, spark_color)
chart.spark(r, x, y, w - 20, 16, q.spark, spark_color)
end)
end),
}
@@ -217,12 +224,13 @@ local function card(self, H, c, w, h, q, on)
end })
end
-- A group of cards ("Resources", "Refined items"), up to 5 per row: a grid.
-- A group of cards ("Resources", "Refined items"), up to 5 per row (3 when that would
-- make them too narrow): a grid.
local function cards(self, H, c, inner, quotes, title)
local per = math.min(5, #quotes)
local gap = 8
local gap = 10
local per = math.min(#quotes, (inner - gap * 4) / 5 >= 112 and 5 or 3)
local w = math.floor((inner - gap * (per - 1)) / per)
local h = w >= 150 and 82 or 90
local _, _, h = card_size(w)
local cols, kids = {}, {}
for i = 1, per do
cols[i] = w
@@ -242,32 +250,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 +364,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
@@ -626,7 +627,7 @@ function M.build(self, root, px, py, w, h, H, opts)
if not empty then
sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item }
end, clear)
end, clear, nil, "act:sell?pick=" .. q.item .. "&resource=" .. q.item)
end
end, { width = half }),
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
@@ -646,9 +647,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 +662,24 @@ 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 is a side panel (sidepanel.lua); when that is folded away (narrow
-- windows) it is a pane under the market here (state.leaderboard, asked for with the
-- market: session.view_params) and the section scrolls. The section scrolls too when the
-- window is too short for the cards and the panels.
function M.build(self, root, px, py, w, h, H)
local lb = (self.side_w or 0) == 0 and self.state.leaderboard or nil
local stacked = self.size and self.size[1] <= STACK_W
local area = scroll.area(self, root, 0, 2, w, h - 4, "market")
local bottom = build_market(self, area.content, px, py, w, h, H, stacked, w)
if lb then
local avail = w - px * 2
local inner = math.min(avail, theme.size.market_max)
bottom = bottom + 18 + leaderboard.pane(self, area.content, px + math.floor((avail - inner) / 2), bottom + 18, inner)
end
area:finish(bottom + 14) -- (the area is 4 shorter than the panel: what fits doesn't scroll)
end
function M.update(self)
+51 -15
View File
@@ -1,6 +1,7 @@
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel under the wells table):
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
-- Moons section's main panel, moons.lua):
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
-- the server's confirm dialog; why it can't be sold yet is its tooltip), and on demand the
-- place with the server's question: cards.ask_button; why it can't be sold yet is its tooltip), and on demand the
-- note (with the hold countdown, "Sellable in") and the lines: what's left in each well at
-- market prices, × the factor, capped.
@@ -20,7 +21,7 @@ end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- The panel, w wide: a tree piece (the Moons section puts it under the wells table).
-- The panel, w wide: a tree piece (the aside puts it at the foot of the moon's facts).
function M.box(self, H, c, moon, w)
local sale = moon.sale
self.sale_open = self.sale_open or {}
@@ -29,8 +30,27 @@ function M.box(self, H, c, moon, w)
-- Summary: toggle (the row left of the button), offer, delta; Sell moon on the right.
local profit = sale.delta > 0
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta)) .. (profit and " profit" or " vs a new moon")
local summary = box({ height = 48 }, {
-- (narrow, in the aside: no "· offer", and the delta without its words; its tooltip has them)
local narrow = w < 460
local dtxt = (profit and "+" or "−") .. theme.currency .. ui.money(math.abs(sale.delta))
.. (narrow and "" or (profit and " profit" or " vs a new moon"))
-- Sell moon off: why, on a line under the summary (not only in the button's tooltip).
local why = (not sale.canSell) and (sale.whyText or "") ~= "" and sale.whyText or nil
-- Sell moon asks in place (cards.ask_button): the question where the offer was, Confirm
-- left of the button's spot, Cancel on it (the row's toggle is off meanwhile).
local asking = sale.canSell and cards.asking(self, "sell-moon", sale.confirm, { ["sell-moon"] = moon.id })
local sell_opts = { small = true, disabled = not sale.canSell,
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
edge = sale.canSell and { "danger", 0.8 } or nil }
local ask_row
if asking then
local sell_w = fg.button_w(c, "Sell moon", sell_opts)
local ok, cancel = cards.ask_buttons(self, c, sale.confirm, sell_w)
ask_row = row({ position = "absolute", left = 30, right = 10, top = 0, height = 48, align = "center", gap = 6 }, {
cards.ask_text(sale.confirm, c), space(4), ok, cancel,
})
end
local summary = box({ height = 48 }, asking and { ask_row } or {
made({ position = "absolute", left = 0, top = 0, width = w - 140, height = 48 }, function(p, x, y, hw, hh)
local hit = ui.rect(p, x, y, hw, hh, "none")
self.druid:new_button(hit, function()
@@ -45,20 +65,31 @@ function M.box(self, H, c, moon, w)
gui.set_euler(chev, vmath.vector3(0, 0, -90))
end
end),
row({ height = 48, padding = { 0, 140, 0, 30 }, align = "center" }, {
fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16), space(8),
fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16), space(8),
inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end), space(10),
fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 }),
row({ height = 48, padding = { 0, 156, 0, 30 }, align = "center" }, {
-- (the label and the offer keep their size: the delta gives way, cut to fit)
(function()
local l = fg.label(c, "Sell moon", { font = "sans_semi", px = 14, color = "text", tracking = 0.12, upper = true }, 16)
l.style.shrink = 0
return l
end)(), space(10),
(not narrow) and fg.label(c, "· offer", { font = "sans", px = 14, color = "muted" }, 16) or nil, (not narrow) and space(8) or nil,
(function()
local b = inline(function(p, x, y) return (ui.credits(p, sale.total, x, y, { px = 15, color = "text", font = "mono_semi" })) end)
b.style.shrink = 0
return b
end)(), space(10),
(function()
local l = fg.label(c, dtxt, { font = "mono", px = 12, color = profit and "up" or "danger" }, 16, { fit = true, shrink = 1 })
l.done = function(n) tooltip.add(n, profit and "Profit over what it cost" or "Less than a new moon costs") end
return l
end)(),
}),
fg.button(c, "Sell moon", { small = true, disabled = not sale.canSell,
variant = sale.canSell and "danger" or nil, fill = sale.canSell and { "danger", 0.12 } or nil,
edge = sale.canSell and { "danger", 0.8 } or nil }, function(b)
fg.button(c, "Sell moon", sell_opts, function(b)
if not sale.canSell and (sale.whyText or "") ~= "" then
tooltip.add(b, sale.whyText)
end
if sale.canSell then
cards.confirm_server_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
cards.ask_button(self, b, sale.confirm, "sell-moon", { ["sell-moon"] = moon.id }, "sell-moon")
if self.autoplay then
local function reg()
if self.autoplay then
@@ -148,7 +179,12 @@ function M.box(self, H, c, moon, w)
})
end
return col({ width = w, padding_bottom = 4 }, { summary, details }, { make = function(p, x, y, bw, bh)
local why_line = (why and not asking) and col({ padding = { 0, 14, 10, 30 } }, {
fg.measured(c, function(p, x, y, bw)
return cards.para(p, why, { font = "sans", px = 12, color = "ready" }, x, y, bw)
end),
}) or nil
return col({ width = w, padding_bottom = 4 }, { summary, why_line, details }, { make = function(p, x, y, bw, bh)
local b = ui.rect(p, x, y, bw, bh, "cyan", 0.03)
ui.outline(b, 0, 0, bw, bh, "edge")
self.fx:box(b) -- (the sale's sweep covers the panel)
+470
View File
@@ -0,0 +1,470 @@
-- Moon lab (admin area, screens/game/admin.lua): tune the procedural moon settings live
-- and save them for the game. Replaces the web's /utils/moons (moons-admin.js): the same
-- settings, groups, ranges and saved JSON (POST /api/moon-settings: the whole settings
-- object, as terrain.lua's DEFAULTS shape it).
--
-- Left: the moon (modules/moon, the game's own viewport and generator) with the last
-- build's technique, vertices and time. Right, a fixed bar: the seed (a moon id: a field,
-- New seed, and presets: the lab's seed and the player's moons), Defaults, Reset (back to
-- the saved settings), the save status and Save; under it the settings in a scroll area,
-- grouped like the web page: a slider and a number field per number, a hex field and a
-- swatch per color, a toggle per flag, a button row for the technique.
--
-- Rebuilds: every change marks the moon pending; update hands the settings to the moon
-- (a new table: moon.script compares by identity) once the moon isn't making a mesh
-- (moon.building), so a drag rebuilds as often as a build takes and once more after the
-- last move. Render values (bloom, atmosphere, ...) don't change the mesh: they show at
-- once (moon.script's cache).
--
-- Opening reads GET /api/moon-settings; Save posts and makes them the game's settings
-- (moon.settings), so the game's viewport shows them at once. Closing without Save puts
-- the saved settings back. State on g.lab (survives rebuilds); the controls are one kept
-- block, rebuilt only when g.lab.rev changes (load, Defaults, Reset, a toggle or the
-- technique) or the size does: a slider's drag never rebuilds it.
--
-- Test ids: "lab:seed-new", "lab:seed:<seed>", "lab:defaults", "lab:reset", "lab:save",
-- "slider:<group>.<key>" (group "root" for top-level keys), "field:lab:<group>.<key>",
-- "field:lab-seed", "lab:tech:<name>", "lab:bool:<group>.<key>".
local ui = require("modules.ui")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local theme = require("modules.theme")
local field = require("modules.field")
local api = require("modules.api")
local test = require("modules.test")
local terrain = require("modules.moon.terrain")
local moon_view = require("modules.moon.moon")
local panel = require("components.panel")
local scroll = require("components.scroll")
local button = require("components.button")
local slider = require("components.slider")
local S = theme.size
local M = {}
-- The web page's schema: [key, min, max, step, label] for sliders, [key, "color", label],
-- [key, "bool", label], [key, "select", label] (the technique).
local SCHEMA = {
{ title = "Mesh", fields = { { "resolution", 16, 256, 8 }, { "technique", "select", "Base technique" } } },
{ title = "Shape noise", path = "shape", fields = {
{ "strength", 0, 0.2, 0.001 }, { "frequency", 0.1, 5, 0.01 }, { "octaves", 1, 8, 1 },
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "minValue", 0, 1, 0.01, "Flatten below" },
} },
{ title = "Ridges", path = "ridge", fields = {
{ "strength", 0, 0.15, 0.001 }, { "frequency", 0.1, 6, 0.01 }, { "octaves", 1, 8, 1 },
{ "persistence", 0, 1, 0.01 }, { "lacunarity", 1, 4, 0.01 }, { "sharpness", 0.5, 6, 0.05 },
{ "gain", 0, 2, 0.01, "Octave weight" }, { "minValue", 0, 1, 0.01, "Flatten below" },
} },
{ title = "Domain warp", note = "Used by the warped technique.", path = "warp", fields = {
{ "strength", 0, 2, 0.01 }, { "frequency", 0.1, 4, 0.01 }, { "octaves", 1, 5, 1 },
} },
{ title = "Craters", path = "craters", fields = {
{ "count", 0, 2000, 1 }, { "minRadius", 0.005, 0.1, 0.001 }, { "maxRadius", 0.02, 0.6, 0.001 },
{ "sizeBias", 1, 8, 0.05, "Size bias (power law)" }, { "depth", 0, 1.5, 0.01 }, { "rimWidth", 0, 2, 0.01 },
{ "rimSteepness", 0, 1.5, 0.01 }, { "floorHeight", -1, 0, 0.01 }, { "smoothness", 0, 1, 0.01 },
{ "ejecta", 0, 1, 0.01, "Ejecta halo" },
} },
{ title = "Shading", path = "shading", fields = {
{ "low", "color", "Low ground" }, { "mid", "color", "Mid" }, { "high", "color", "High ground" },
{ "steep", "color", "Steep slopes" }, { "steepThreshold", 0, 0.6, 0.005 }, { "steepBlend", 0, 0.3, 0.005 },
{ "patch", "color", "Dark plains" }, { "patchAmount", 0, 1, 0.01, "Plains amount" },
{ "patchFrequency", 0.2, 5, 0.01, "Plains frequency" }, { "grain", 0, 0.3, 0.005 },
} },
{ title = "Per-moon variation", note = "Each moon is varied by its id by up to these amounts, either way.", path = "variation", fields = {
{ "hue", 0, 180, 1, "Hue (degrees)" }, { "saturation", 0, 1, 0.01 }, { "lightness", 0, 0.4, 0.005 },
{ "shape", 0, 1, 0.01, "Noise strength" }, { "frequency", 0, 1, 0.01, "Noise frequency" },
{ "craters", 0, 1, 0.01, "Crater count and size" }, { "technique", "bool", "Pick technique per moon" },
} },
{ title = "Render", path = "render", fields = {
{ "bloomStrength", 0, 2, 0.01 }, { "bloomRadius", 0, 1, 0.01 }, { "bloomThreshold", 0, 1.5, 0.01 },
{ "atmosphere", "color", "Atmosphere" }, { "atmosphereIntensity", 0, 3, 0.01 },
{ "atmosphereFollowsHue", "bool", "Atmosphere follows moon hue" }, { "sunIntensity", 0, 8, 0.05 },
{ "ambient", 0, 1, 0.01 }, { "exposure", 0.2, 3, 0.01 }, { "detail", 0, 0.03, 0.0005, "Surface detail" },
{ "rotationSpeed", 0, 1, 0.005 }, { "stars", "bool", "Stars" },
} },
}
local SEED = "moon-lab"
local ROW_H = 30
local LABEL_W = 150
local NUM_W = 76
local PAD = S.panel_pad
local CTRL_MIN, CTRL_MAX = 440, 560
local STATS_H = 22
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local function clone(t)
if type(t) ~= "table" then
return t
end
local c = {}
for k, v in pairs(t) do
c[k] = clone(v)
end
return c
end
-- "minValue" -> "Min value" (the web's label()).
local function label_of(key)
local s = key:gsub("(%u)", function(c) return " " .. c:lower() end)
return s:sub(1, 1):upper() .. s:sub(2)
end
-- A value as the web's <output> shows it: whole for whole steps, else the step's decimals
-- (at most 3... the web's rule; 0.0005 shows 4 so the step is visible).
local function fmt(v, step)
if step >= 1 then
return string.format("%d", math.floor(v + 0.5))
end
local dec = #(tostring(step):match("%.(%d+)") or "00")
return string.format("%." .. math.min(4, dec) .. "f", v)
end
local function group_of(settings, path)
return path and settings[path] or settings
end
---------------------------------------------------------------- state
local function lab(g)
g.lab = g.lab or { seed = SEED, rev = 0 }
return g.lab
end
local function status(L)
if L.saving then
return "Saving…"
elseif L.error then
return L.error
elseif not L.loaded then
return "Loading…"
end
return terrain.serialize(L.draft) == terrain.serialize(L.saved) and "Saved" or "Unsaved changes"
end
local function show_status(L)
if L.w and L.w.status then
ui.style_text(L.w.status, "small", status(L))
ui.fit_text(L.w.status, L.w.status_w)
end
end
-- A value changed: the moon gets it on the next free frame (update).
local function changed(L)
L.pending = true
L.error = nil
show_status(L)
end
-- The controls show other values (loaded, Defaults, Reset, a toggle): rebuild them.
local function rebuild(g)
local L = lab(g)
L.rev = L.rev + 1
L.pending = true
L.error = nil
g.dirty = true
end
function M.open(g)
local L = lab(g)
L.saved = terrain.merge_settings(moon_view.settings)
L.draft = clone(L.saved)
L.loaded, L.error, L.saving = false, nil, false
L.pending = true
api.get_moon_settings(function(err, data)
if g.lab ~= L then
return
end
if err then
L.loaded = true
L.error = "Couldn't load the saved settings (" .. err .. ")"
show_status(L)
return
end
L.saved = terrain.merge_settings(data)
L.draft = clone(L.saved)
L.loaded = true
moon_view.settings = clone(L.saved) -- (fresher than the game's copy)
rebuild(g)
end)
end
-- Leaving: the game's moon goes back to the saved settings (unsaved tweaks were only
-- for the lab).
function M.close(g)
local L = g.lab
if L and L.saved then
moon_view.settings = clone(L.saved)
end
g.lab = nil
end
local function save(g)
local L = lab(g)
if L.saving then
return
end
L.saving, L.error = true, nil
show_status(L)
local sent = clone(L.draft)
api.save_moon_settings(sent, function(err)
-- The game's moons take the saved settings even if the lab was left meanwhile.
if err then
g.toasts:push("Moon settings not saved: " .. err, "error", true)
else
moon_view.settings = clone(sent)
g.toasts:push("Moon settings saved: the game's moons use them now", "save-code", false)
end
if g.lab ~= L then
return
end
L.saving = false
if err then
L.error = "Save failed: " .. err
else
L.saved = sent
end
show_status(L)
end)
end
---------------------------------------------------------------- controls
local function small_button(parent, label, id, on_click, opts)
opts = opts or {}
local bopts = { small = true, variant = opts.variant, on = opts.on }
return fg.button(parent, label, bopts, function(b, nodes)
button.bind(opts.g, b, nodes, { id = id, on_click = on_click })
end, opts.style)
end
-- One setting's row: label, then its control.
local function setting_row(g, L, parent, zone, path, f)
local key, a, b, step, name = f[1], f[2], f[3], f[4], f[5]
local kind = type(a) == "string" and a or "number"
local text = (kind ~= "number" and b or name) or label_of(key)
local gid = (path or "root") .. "." .. key
local target = group_of(L.draft, path)
local kids = {}
if kind ~= "select" then
kids[1] = fg.label(parent, text, "small", ROW_H, { width = LABEL_W, shrink = 0, fit = true })
end
if kind == "number" then
local o = { min = a, max = b, step = step }
local fkey = "lab:" .. gid
local f_state = field.get(g, fkey)
f_state.text = fmt(target[key], step)
local ctl = {}
kids[#kids + 1] = fg.made({ grow = 1, height = ROW_H, padding_right = 10 }, function(p, x, y, w, h)
ctl.slider = slider.new(g, p, x + 6, y, w - 16, h, { min = a, max = b, step = step, value = target[key],
id = "slider:" .. gid, label = text, zone = zone,
on_change = function(v)
target[key] = v
if ctl.input then
ctl.syncing = true
ctl.input:set_text(fmt(v, step))
f_state.text = fmt(v, step)
ctl.syncing = false
end
changed(L)
end })
end)
kids[#kids + 1] = fg.made({ width = NUM_W, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
ctl.input = field.new(g, p, fkey, x, y, w, h, { font = "mono", px = 13, allowed = "[%d%.%-]", max_len = 9,
min = o.min, max = o.max,
on_change = function(t)
local v = tonumber(t)
if ctl.syncing or not v then
return
end
v = math.max(o.min, math.min(o.max, v))
target[key] = v
ctl.slider:set(v)
changed(L)
end })
end)
elseif kind == "color" then
local fkey = "lab:" .. gid
field.get(g, fkey).text = target[key]
local sw
kids[#kids + 1] = fg.made({ width = 26, height = 22, shrink = 0 }, function(p, x, y, w, h)
sw = ui.rect(p, x, y, w, h, "line_hi")
-- (the setting's own color, not a theme color: the swatch shows the data)
local c = terrain.hex_to_rgb(target[key])
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
end)
kids[#kids + 1] = fg.made({ width = 100, height = ROW_H - 4, shrink = 0 }, function(p, x, y, w, h)
field.new(g, p, fkey, x + 10, y, w - 10, h, { font = "mono", px = 13, allowed = "[#%x]", max_len = 7,
on_change = function(t)
if t:match("^#%x%x%x%x%x%x$") then
target[key] = t:lower()
local c = terrain.hex_to_rgb(t)
gui.set_color(sw, vmath.vector4(c[1], c[2], c[3], 1))
changed(L)
end
end })
end)
elseif kind == "bool" then
local on = target[key] == true
kids[#kids + 1] = small_button(parent, on and "On" or "Off", "lab:bool:" .. gid, function()
target[key] = not target[key]
rebuild(g)
end, { g = g, on = on, variant = on and nil or "ghost", style = { width = 72 } })
else -- the technique
local row = {}
for _, t in ipairs(terrain.TECHNIQUES) do
local cur = target[key] == t
row[#row + 1] = small_button(parent, t, "lab:tech:" .. t, function()
target[key] = t
rebuild(g)
end, { g = g, variant = cur and "primary" or nil })
end
return fg.col({ gap = 6 }, {
fg.label(parent, text, "small", 20, { fit = true }),
fg.box({ wrap = true, gap = 6 }, row),
})
end
return fg.row({ height = ROW_H, align = "center" }, kids)
end
-- The settings, grouped like the web page, in a scroll area at (x, y), w x h.
local function settings_list(g, L, parent, x, y, w, h)
local area = scroll.area(g, parent, x, y, w, h, "moonlab")
local p = area.content
local groups = {}
for _, sec in ipairs(SCHEMA) do
local rows = { fg.label(p, sec.title, "h3", 22) }
if sec.note then
rows[#rows + 1] = fg.label(p, sec.note, "small", 20, { fit = true })
end
for _, f in ipairs(sec.fields) do
rows[#rows + 1] = setting_row(g, L, p, area.view, sec.path, f)
end
groups[#groups + 1] = fg.col({ gap = 4 }, rows)
end
local tree = fg.col({ gap = 18, width = w, padding = { 4, 14, 16, 2 } }, groups)
flex.solve(tree, 0, 0)
fg.build(tree, p)
area:finish(tree.h)
end
-- The fixed bar over the settings: seed, presets, Defaults / Reset / status / Save.
-- Returns its height.
local function toolbar(g, L, parent, x, y, w)
local st = g.state or {}
field.get(g, "lab-seed").text = L.seed
local function seed_to(s)
L.seed = s
field.get(g, "lab-seed").text = s
rebuild(g)
end
local presets = { small_button(parent, SEED, "lab:seed:" .. SEED, function() seed_to(SEED) end, { g = g }) }
for i, m in ipairs(st.moons or {}) do
if i > 3 then
break
end
local id = tostring(m.id)
presets[#presets + 1] = small_button(parent, m.name or id, "lab:seed:" .. id, function() seed_to(id) end,
{ g = g, style = { max_width = 160 } })
end
local status_w = 170
local tree = fg.col({ width = w, gap = 10 }, {
fg.label(parent, "Seed (moon id)", "cap", 14),
fg.row({ gap = 8, height = S.button_sm_h, align = "center" }, {
fg.made({ grow = 1, height = S.button_sm_h }, function(p, bx, by, bw, bh)
field.new(g, p, "lab-seed", bx, by, bw, bh, { font = "mono", px = 13, hint = SEED, max_len = 64,
on_change = function(t)
L.seed = t ~= "" and t or SEED
L.pending = true
end })
end),
small_button(parent, "New seed", "lab:seed-new", function()
seed_to(string.format("%08x", math.random(0, 0x7fffffff)))
end, { g = g }),
}),
fg.box({ wrap = true, gap = 6 }, presets),
fg.row({ gap = 8, height = S.button_h, align = "center" }, {
small_button(parent, "Defaults", "lab:defaults", function()
L.draft = clone(terrain.DEFAULTS)
rebuild(g)
end, { g = g }),
small_button(parent, "Reset", "lab:reset", function()
L.draft = clone(L.saved)
rebuild(g)
end, { g = g }),
fg.made({ grow = 1, height = 20, shrink = 1 }, function(p, bx, by, bw, bh)
L.w.status = ui.text(p, "", "small", bx + bw - 8, by + bh / 2, gui.PIVOT_E)
L.w.status_w = math.min(status_w, bw - 8)
show_status(L)
end),
fg.button(parent, "Save", { variant = "primary" }, function(b, nodes)
button.bind(g, b, nodes, { id = "lab:save", on_click = function() save(g) end })
end, { width = 110 }),
}),
})
flex.solve(tree, x, y)
fg.build(tree, parent)
return tree.h
end
---------------------------------------------------------------- build
-- The block's inputs (core/main keeps it while they stay the same).
function M.sig(g)
local L = lab(g)
return { L.rev, L.loaded }
end
function M.build(g, parent, x, y, w, h)
local L = lab(g)
L.w = {}
local gap = S.gap
local cw = math.max(CTRL_MIN, math.min(CTRL_MAX, math.floor(w * 0.4)))
local mw = w - cw - gap
-- The moon.
local mp = panel.new(parent, x, y, mw, h)
local size = math.max(100, math.min(mw - PAD * 2, h - PAD * 3 - STATS_H))
local vx = math.floor((mw - size) / 2)
local vp = ui.rect(mp.body, vx, PAD, size, size, "viewport")
L.vp = vp
moon_view.show(L.seed, vp)
L.w.stats = ui.text(mp.body, "", "num_small", vx, PAD + size + PAD / 2 + STATS_H / 2)
L.w.stats_w = size
L.shown_build = nil
ui.text(mp.body, "Drag to rotate · scroll to zoom", "cap", vx + size, PAD + size + PAD / 2 + STATS_H / 2, gui.PIVOT_E)
-- The settings.
local cp = panel.new(parent, x + mw + gap, y, cw, h)
local inner = cw - PAD * 2
local th = toolbar(g, L, cp.body, PAD, PAD, inner)
local ly = PAD + th + 12
ui.rect(cp.body, PAD, ly - 6, inner, 1, "line")
settings_list(g, L, cp.body, PAD, ly, inner + PAD - 4, h - ly - PAD)
end
-- Every frame while the lab is shown: hand the moon pending settings when it's free, and
-- show the last build.
function M.update(g, dt)
local L = g.lab
if not L or not L.vp then
return
end
if L.pending and not moon_view.building then
L.pending = false
moon_view.settings = clone(L.draft)
moon_view.show(L.seed, L.vp)
moon_view.building = true -- (until moon.script looks: one handed over at a time)
end
local b = moon_view.last_build
if b and b ~= L.shown_build and L.w and L.w.stats then
L.shown_build = b
local verts = tostring(b.vertices):reverse():gsub("(%d%d%d)", "%1,"):reverse():gsub("^,", "")
ui.style_text(L.w.stats, "num_small", string.format("%s · %s vertices · %.0f ms", b.technique, verts, b.ms))
end
end
return M
+106 -19
View File
@@ -1,7 +1,8 @@
-- The moon list (left panel): Active / Past tabs, "Find a moon" (filters the rows by
-- name as you type; the field keeps its text and focus across rebuilds), a row per moon
-- (widgets/moon_row: planet tinted from the id, name, ★ rating, drills or what it gave,
-- ready badge; a virtualized list, components/list) and "+ New moon" pinned to the bottom.
-- ready badge; a virtualized list, components/list), "+ New moon" at the top (buys one) and
-- the announcements pane (modules/toasts, the last 5) at the foot.
-- 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,11 +19,13 @@ 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")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local toasts = require("modules.toasts")
local M = {}
@@ -260,6 +263,45 @@ 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
-- What the rows show (widgets/moon_row: name, grade, rating, status, warning, ready
-- badge, selection), both lists: the moon list's block signature has this instead of the
-- moons themselves, so a change the rows don't show (a well's projection, a drill's end)
-- keeps the list.
function M.rows_sig(self)
local st, v = self.state, self.view
local out, n = {}, 0
for _, list in ipairs({ st.moons, st.past }) do
for _, m in ipairs(list) do
local dry = not m.finished and #(m.wells or {}) > 0
if dry then
for _, w in ipairs(m.wells) do
if w.supply > 0 then
dry = false
break
end
end
end
n = n + 1
out[n] = { m.id, m.name, m.grade or false, m.rating or false, m.finished or false, m.mined or false,
m.finishedOn or false, m.finishedAt or false, dry, m.drills or 0, m.over or false,
not m.finished and ready.drills(self, m.id) or m.ready or 0, (m.selected and v.section == "moons") or false }
end
n = n + 1
out[n] = "|"
end
return blocks.sig(out)
end
function M.build(self, H, parent, x, y, w, h)
local st = self.state
local v = self.view
@@ -269,6 +311,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 +507,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 +534,19 @@ 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 }),
})
end
-- The announcements pane at the panel's foot: room for the last 5 (modules/toasts lists
-- them there), at most half the panel; the moon list gets the rest.
kids[#kids + 1] = fg.space(0, 12)
kids[#kids + 1] = fg.col({ height = math.min(math.floor(h / 2), 31 + toasts.LIST_H), 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),
})
for _, k in ipairs(kids) do
k.style.shrink = k.style.shrink or 0
end
File diff suppressed because it is too large. Load diff
+219 -66
View File
@@ -1,5 +1,5 @@
-- The Refinery section (state.refinery), like the web's refinery.html: the heading (Collect
-- all when more than one batch is ready), the refined stock chips, the slot cards (running:
-- all when more than one batch is ready), the slot cards (running:
-- time left and a segmented bar filling in real time against the server clock; ready:
-- Collect; idle; "+ Slot n" while more can be bought) and the recipe cards (glyph, tier
-- and owned, description, inputs, stats, the bulk batch, quantity, Refine and Max). The
@@ -27,9 +27,30 @@ local M = {}
-- components.bar needs core/ticks, which needs core/session, which needs this module:
-- required when used (by a name bob doesn't follow).
local BAR = "components.bar"
local READY = "screens.game.core.ready"
local S = theme.size
-- A batch that finishes (core/ready: on our clock, before the refetch says so) turns its
-- slot ready at once: a light runs along its bar (SWEEP s), the inset glow flares and
-- settles (FLARE s), the Ready label and Collect pop in (POP s, fx-ready-pop); fx.lua's
-- batch_ready lights the edge and plays the sound. All go by the clock from the batch's
-- end (ready.batch_age), so the refetch's rebuild carries on where they were. Reduced
-- motion: no pop and no sweep.
local POP, SWEEP, FLARE = 0.6, 0.7, 1.4
local POP_SCALE = { { 0, 0.6 }, { 0.6, 1.12 }, { 1, 1 } }
-- Pops node in about the point (dx, dy) from its position, age seconds in.
local function pop(node, dx, dy, age)
if anim.reduced or not age or age >= POP then
return
end
local p, base = gui.get_position(node), gui.get_scale(node)
anim.tween(node, POP, function(k, n)
anim.scale_about(n, p, base, dx, dy, anim.keys(POP_SCALE, k, anim.fn.ready_pop))
end, -age)
end
-- Soonest end of a running batch (state.jobs), for the refetch; nil when none runs.
function M.next_end(st)
local due
@@ -80,7 +101,6 @@ local WHY = { busy = "All slots busy", inputs = "Not enough inputs" }
-- Styles (app.css).
local CAP_NUM = { font = "mono", px = 11, color = "muted", tracking = 0.2 }
local BODY14 = { font = "sans", px = 14, color = "text" }
local MONO14 = { font = "mono", px = 14, color = "text" }
local MONO15 = { font = "mono", px = 15, color = "text" }
local BTN = { font = "sans_semi", px = 15, tracking = 0.12, upper = true }
local BTN_SM = 34 -- .btn-sm height
@@ -156,12 +176,45 @@ local function section_label(c, str, num)
})
end
-- The Slots label and, on its right, the batch size and its upgrade (the whole refinery:
-- every slot makes batches up to rf.maxBatch; upgrade-batch raises it to rf.nextBatch).
-- One button-high row whether or not the upgrade shows; the button keeps about one width
-- from level to level, so repeated clicks stay on it (clicker rule).
local function slots_label(self, H, c, rf)
local kids = {
row({ grow = 1, align = "center" }, {
fg.label(c, "Slots ", "cap", 16.5), fg.label(c, rf.slotCount .. "/" .. rf.maxSlots, CAP_NUM, 16.5),
}),
fg.label(c, "Batch size ", "cap", 16.5), fg.label(c, ui.money(rf.maxBatch), CAP_NUM, 16.5),
space(12),
}
if (rf.nextBatch or 0) > 0 then
local ok = rf.canUpgradeBatch
local label = "Upgrade to " .. ui.money(rf.nextBatch)
local opts = { small = true, price = rf.nextBatchPrice, disabled = not ok }
kids[#kids + 1] = fg.button(c, label, opts, function(b, nodes)
if not ok then
tooltip.add(b, rf.upgradeBatchWhyText or "")
else
cards.pulse(nodes)
H.action(self, b, nodes, "upgrade-batch", {})
tooltip.add(b, "Every slot makes batches of up to " .. ui.money(rf.nextBatch))
end
end, { width = math.max(240, fg.button_w(c, label, opts)) })
else
kids[#kids + 1] = fg.label(c, "Biggest batches", "small", 20)
end
return col({ padding = { 20, 0, 10, 0 } }, { row({ align = "center", height = S.button_sm_h }, kids) })
end
---------------------------------------------------------------- slot cards
-- The web's .bar: 8 px of 6 px cells (2 px apart) in a faint cyan, the fill masked into
-- the same cells. The fill follows a hidden probe the timer ticks (H.tick_bar); M.update
-- lights the cells from its width.
local function cell_bar(self, H, color, job)
-- ready: finished (in the state or on our clock); age: seconds since it finished, when
-- that was just now (the sweep).
local function cell_bar(self, H, color, job, ready, age)
return { style = { height = 8 }, make = function(p, x, y, w)
local cells = {}
for cx = 0, w - 1, 8 do
@@ -169,15 +222,39 @@ local function cell_bar(self, H, color, job)
ui.rect(p, x + cx, y, cw, 8, "cyan", 0.14)
cells[#cells + 1] = { node = ui.rect(p, x + cx, y, cw, 8, color), x = cx, w = cw }
end
local bar = { cells = cells, full = job.ready }
if not job.ready then
local bar = { cells = cells, full = ready, w = w, color = ui.color(color) }
if not ready then
bar.probe = ui.rect(p, x, y, w, 8, "none")
H.tick_bar(self, bar.probe, job.start, job["end"], w)
elseif age and age < SWEEP and not anim.reduced then
bar.sweep = socket.gettime() - age
end
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
self.rf_slots[job.id].bar = ui.rect(p, x, y, w, 8, "none") -- (fx: where its sparks leave)
self.rf_bars[#self.rf_bars + 1] = bar
end }
end
-- The sweep: a bright head runs left to right over the full cells, lighting each to the
-- flash color as it passes, then the cells settle back to the item's color.
local function sweep_cells(b)
local k = (socket.gettime() - b.sweep) / SWEEP
if k >= 1 then
b.sweep = nil
for _, c in ipairs(b.cells) do
gui.set_color(c.node, b.color)
end
return
end
local head = 1.3 * (1 - (1 - k) ^ 2) - 0.15
local flash = theme.color.flash
for _, c in ipairs(b.cells) do
local d = (c.x + c.w / 2) / b.w
local lit = math.max(0, 1 - math.abs(d - head) * 7) + (d < head and 0.35 * (1 - k) or 0)
gui.set_color(c.node, vmath.lerp(math.min(1, lit), b.color, vmath.vector4(flash.x, flash.y, flash.z, b.color.w)))
end
end
local function light_cells(b)
local fw = b.full and math.huge or gui.get_size(b.probe).x
for _, c in ipairs(b.cells) do
@@ -193,12 +270,15 @@ function M.update(self)
for _, b in ipairs(self.rf_bars or {}) do
if not b.full then
light_cells(b)
elseif b.sweep then
sweep_cells(b)
end
end
end
function M.teardown(self)
self.rf_bars = nil
self.rf_bars = nil -- (rf_now stays: the next build glides the batches from it)
self.rf_slots = nil
end
-- A dashed 1 px border (border-style: dashed; 3 on, 2 off).
@@ -216,6 +296,7 @@ end
-- An inset glow (box-shadow: inset 0 0 18px): the fade texture along each edge, inwards.
local function inset_glow(p, w, h, color, alpha)
local d = 14
local nodes = {}
for _, e in ipairs({ { w / 2, d / 2, w, 0 }, { w / 2, h - d / 2, w, 180 }, { d / 2, h / 2, h, 90 }, { w - d / 2, h / 2, h, -90 } }) do
local n = ui.rect(p, 0, 0, e[3], d, color, alpha)
gui.play_flipbook(n, "fade")
@@ -223,7 +304,9 @@ local function inset_glow(p, w, h, color, alpha)
gui.set_position(n, vmath.vector3(e[1], -e[2], 0))
gui.set_rotation(n, vmath.quat_rotation_z(math.rad(e[4])))
gui.set_blend_mode(n, theme.glow_blend())
nodes[#nodes + 1] = n
end
return nodes
end
-- A panel frame (fill and 1 px edge), registered for the entrance and the action sweep.
@@ -236,19 +319,32 @@ 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
-- Every slot is as tall as a ready one (its Collect button under the bar), so a batch
-- finishing doesn't push the recipes down under the pointer (clicker rule).
local SLOT_H = 13 * 2 + 22.5 + 8 + 22 + 8.5 + 8 + 8 + BTN_SM
local function slot_card(self, H, c, st, slot, n)
local job = slot.job
local ready = job and job.ready
local age = job and require(READY).batch_age(self, job)
local ready = job and require(READY).batch_done(self, job) -- (on our clock: display only)
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)
require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
local t = require(BAR).ready_label(p, "Ready", x + w, y + h / 2, gui.PIVOT_E, 15)
pop(t, -ui.text_width(t) / 2, 0, age)
end }
else
local left = function(ms) return ui.dur(ms) .. " left" end
@@ -268,17 +364,52 @@ local function slot_card(self, H, c, st, slot)
}),
}),
space(0, 8.5),
cell_bar(self, H, color, job),
cell_bar(self, H, color, job, ready, age),
{ 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)
pop(b, size.x / 2, -size.y / 2, age)
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)
return col({ padding = 13, min_height = SLOT_H }, kids), function(p, x, y, w, h)
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)
local glow = inset_glow(f, w, h, "ready", 0.08)
if age and age < FLARE then
-- Just finished: the glow flares and settles to its rest.
anim.tween(glow[1], FLARE, function(k)
local a = 0.08 + 0.32 * (1 - k) ^ 2
for _, n in ipairs(glow) do
anim.set(n, "color.w", a)
end
end, -age)
end
end
if job then
self.rf_slots[job.id] = self.rf_slots[job.id] or {}
self.rf_slots[job.id].frame = f
-- 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
@@ -354,6 +485,15 @@ local function stat(c, label, value)
return col({ grow = 1, basis = 0, align = "start" }, { fg.label(c, label, "cap", 16.5), space(0, 2), value })
end
-- "Owned N" on the recipe cards: fx counters ("refined:<item>", bound after the build), so
-- a collect's motes land there and the number counts up. Laid out at the final amount.
local counted = {}
local function own_label(c, r, color)
local l = fg.label(c, ui.money(r.owned or 0), { font = "mono_semi", px = 24, color = color }, 26)
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned or 0 }
return l
end
-- One recipe card (the web's .card.recipe).
local function recipe_card(self, H, c, st, rf, r)
local color = item_color(st, r.item)
@@ -361,8 +501,9 @@ local function recipe_card(self, H, c, st, rf, r)
local why = (r.whyText or "") ~= "" and r.whyText or WHY[r.why] or r.why
local owned = (r.owned or 0) > 0
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N ·
-- owned X" over the name.
-- Head: the faceted crystal (.recipe-glyph: a 36 px pentagon, a faint rim), "Tier N" over
-- the name, and on the right what you own, big, in the item's color (a collect's motes
-- fly into it and it counts up), shown at 0 too.
local glyph = { style = { width = 36, height = 36, shrink = 0 }, make = function(p, x, y)
local g = ui.rect(p, x, y, 36, 36, color, 0.85)
gui.play_flipbook(g, "pentagon")
@@ -370,13 +511,14 @@ local function recipe_card(self, H, c, st, rf, r)
end }
local head = row({ height = 43.3, gap = 12, align = "start" }, {
col({ padding_top = 3.6 }, { glyph }),
col({ grow = 1, align = "start" }, {
row({}, {
fg.label(c, "Tier " .. r.tier .. (owned and " · owned " or ""), "cap", 24),
owned and fg.label(c, ui.money(r.owned), CAP_NUM, 24) or nil,
}),
col({ grow = 1, shrink = 1, align = "start" }, {
fg.label(c, "Tier " .. r.tier, "cap", 24),
fg.label(c, r.item, { font = "sans_semi", px = 16, color = "text", tracking = 0.14, upper = true }, 17.8, { fit = true }),
}),
col({ align = "end", shrink = 0 }, {
fg.label(c, "Owned", "cap", 14),
own_label(c, r, owned and color or "muted"),
}),
})
-- Inputs per unit: need and name, what you hold on the right (red when short).
@@ -418,29 +560,33 @@ local function recipe_card(self, H, c, st, rf, r)
return n
end })
-- The form: quantity, Refine (takes the rest), Max.
-- The form: the quantity and Refine (that many) on a row, Max (as many as you can, the
-- usual click) the card's width under them, primary.
local qty, input = qty_field(self, r, off, rf.maxBatch)
qty.style.width, qty.style.grow = nil, 1
local refine
local form = row({ height = BTN_SM, gap = 6 }, {
qty,
btn("Refine", BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end)
end, { grow = 1 }),
btn(off and "Max" or ("Max " .. r.maxQty), BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end),
local refine_btn = btn("Refine", BTN_SM, { disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") }
end, nil, nil, "act:refine?recipe=" .. r.item)
tooltip.add(b, "Refine the number in the box")
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end)
local max_btn = btn(off and "Max" or ("Max · " .. ui.money(r.maxQty)), BTN_SM, { primary = true, disabled = off }, function(b, nodes)
if off then
return tooltip.add(b, why)
end
H.action(self, b, nodes, "refine", { recipe = r.item, all = "1" })
tooltip.add(b, "Refine as many as you can (" .. ui.money(r.maxQty) .. ")")
end, { grow = 1 })
local form = col({ shrink = 0 }, {
row({ height = BTN_SM, gap = 6, shrink = 0 }, { qty, refine_btn }),
space(0, 6), row({ height = BTN_SM, shrink = 0 }, { max_btn }),
})
local desc = catalog_desc(st, r.item)
@@ -452,9 +598,9 @@ local function recipe_card(self, H, c, st, rf, r)
col({ gap = 4 }, inputs),
space(0, 8),
fill(), -- (the footer at the bottom: buttons line up across a row)
col({ height = 126 }, {
col({ height = 162 }, { -- (rule, stats, batch line, the form's two rows)
{ style = { height = 1 }, make = function(p, x, y, w) ui.rect(p, x, y, w, 1, "line") end },
space(0, 12), stats, space(0, 8), batch, fill(), form,
space(0, 12), stats, space(0, 8), batch, fill(), space(0, 8), form,
}),
}), function(p, x, y, w, h)
-- The card (.card): edge brightens on hover (0.15 s); bound before the footer's
@@ -475,6 +621,7 @@ function M.build(self, root, px, py, w, h, H)
local st = self.state
local rf = st.refinery
self.rf_bars = {}
self.rf_slots = {} -- by batch id: { frame, bar } (fx.lua's batch_ready)
self.frames = self.frames or {}
local area, x, inner, y = cards.page(self, root, px, py, w, h, "refinery", math.huge)
local c = area.content
@@ -492,36 +639,38 @@ function M.build(self, root, px, py, w, h, H)
end) or nil,
})
-- Refined items owned (.refinery-stock: a swatch, the name and the amount), wrapping.
local chips = {}
for _, s in ipairs(rf.stock) do
if s.amount > 0 then
chips[#chips + 1] = box({ height = 21, align = "center" }, {
swatch(item_color(st, s.name)), space(6),
fg.label(c, s.name .. " ", BODY14, 21), fg.label(c, ui.money(s.amount), MONO14, 21),
}, { make = function(p, ...)
local n = group(p, ...)
tooltip.add(n, s.name)
return n
end })
end
end
-- 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
if (rf.nextSlotPrice or 0) > 0 then
local b = buy_slot(self, H, c, rf)
b.style.min_height = 112
b.style.min_height = SLOT_H
slots[#slots + 1] = b
end
-- Recipes (.cards).
local recipes = {}
counted = {}
for _, r in ipairs(rf.recipes) do
local tree, make = recipe_card(self, H, c, st, rf, r)
tree.make = make
@@ -530,13 +679,17 @@ function M.build(self, root, px, py, w, h, H)
local page = col({ gap = 18, width = inner }, {
heading,
#chips > 0 and row({ wrap = true, column_gap = 16, row_gap = 8, align = "start" }, chips) or nil,
section_label(c, "Slots", rf.slotCount .. "/" .. rf.maxSlots),
slots_label(self, H, c, rf),
flex.grid(inner, 200, 12, slots),
section_label(c, "Recipes"),
flex.grid(inner, S.card_col_min, S.gap, recipes),
})
fg.build(flex.solve(page, x, y), c)
-- The recipes' owned numbers count what a collect brings (fx.lua collected).
for _, e in ipairs(counted) do
self.fx:bind(e.key, e.node)
gui.set_text(e.node, self.fx:text(e.key, e.amount))
end
area:finish(page.y + page.h + py)
for _, b in ipairs(self.rf_bars) do
light_cells(b)
+369 -265
View File
@@ -6,20 +6,25 @@
-- the moon itself is known before the purchase.
--
-- The reveal (reveal.new(g, layer), started with :start(data) by a buy-moon response's
-- `reveal`), all on its own layer over the layout:
-- 1. Charge: the page dims around the viewport, the shroud charges up and shakes (moon.script).
-- 2. Burst: when the moon's mesh is ready, a flash, sparks and a thump; the moon pops out.
-- 3. Card: name, class, the verdict and wells pop in one by one, then the badge stamps down
-- (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet note).
-- 4. Back: the card flies into the moon list, the page comes back and the balance counts
-- down (fx "revealed"), the toast shows.
-- `reveal`), all on its own layer over the dimmed page, in a window at the center of the
-- screen (the same size for every moon):
-- 1. Charge: the moon's stage (the 3D moon in its own node) shows the shroud charging up
-- (moon.script), the "?" trembling, a low rumble building.
-- 2. Burst: when the moon's mesh is ready, a flash, sparks, the reveal boom and a decaying
-- Perlin-noise shake of the window and the page; the moon pops out.
-- 3. Drawer: the stage slides right while a drawer slides out from behind it to the left;
-- in it name, class, the verdict and wells pop in one by one, then the badge stamps
-- down (great and exceptional moons get gold sparks and a fanfare, poor ones a quiet
-- note). Buy another and Start mining sit in fixed slots at the drawer's foot.
-- 4. Close: the window fades and sinks, the page comes back, the new moon's row is marked
-- (fx "revealed"), the toast shows. (The balance counts down at the purchase, fx.lua.)
-- While it plays, the main area, the well panel and the new moon's list entry stay hidden
-- (g.reveal:hiding(), :hides(id)): nothing about the wells shows before the card does.
-- A click or Space/Enter skips ahead (charge -> burst, card -> its end state), then closes;
-- Esc closes. "Buy another" (reveal.next: hidden while more moons are locked, disabled
-- with the server's reason when unaffordable) closes the card and buys the next moon,
-- whose response plays a new reveal. The card's buttons take keyboard focus (scope
-- "reveal", modules/focus) once it's complete.
-- (g.reveal:hiding(), :hides(id)), and the aside's viewport leaves the 3D moon to the
-- stage. A click or Space/Enter skips ahead (charge -> burst, drawer -> its end state),
-- then closes; Esc closes. "Buy another" (reveal.next: hidden while more moons are locked,
-- disabled with the server's reason when unaffordable) closes the window and buys the
-- next moon, whose response plays a new reveal. The buttons take keyboard focus (scope
-- "reveal", modules/focus) once the drawer is complete.
--
-- Times follow moon.revealing.t (the reveal clock, which mm.reveal_freeze can hold for
-- screenshots), so every frame of the card can be captured.
@@ -33,6 +38,7 @@ local test = require("modules.test")
local tooltip = require("modules.tooltip")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local chip = require("components.chip")
local M = {}
M.__index = M
@@ -41,8 +47,6 @@ local C = theme.color
local GOLD_HI = C.ready_hi
local FLASH = C.flash
local MIST = C.mist
local CARD_W = 440
local CARD_PAD = 22
local function v3(x, y)
return vmath.vector3(x, y, 0)
@@ -98,57 +102,87 @@ 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")
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)
return
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),
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
-- (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)
-- Moon packs (once the store is open): several at once, cheaper and better, a button
-- each under the grade's facts.
if #(e.packs or {}) > 0 then
local packs = {}
for _, pk in ipairs(e.packs) do
local label = pk.count .. " moons · " .. theme.currency .. ui.money(pk.price)
packs[#packs + 1] = fg.button(root, label, { small = true, disabled = not pk.canAfford }, function(b, nodes)
local save = math.floor(pk.discount * 100 + 0.5)
tooltip.add(b, pk.canAfford and (pk.count .. " " .. e.grade .. " moons for " .. theme.currency .. ui.money(pk.price)
.. " (" .. save .. "% off), each a little richer than a single one")
or ("Need " .. theme.currency .. ui.money(pk.price - st.credits) .. " more"))
if pk.canAfford then
g.h.action(g, b, nodes, "buy-moon", { grade = e.grade, count = pk.count, include = "new-moon" },
function() g.nm_grade = nil end, nil, "newmoon:pack:" .. e.grade .. ":" .. pk.count)
end
end)
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 }),
rows[#rows + 1] = row({ height = 40, align = "start", padding = { 0, 16, 10, 56 }, gap = 8 }, packs)
end
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 +195,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 +206,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 +344,130 @@ 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 -- (with numbers up to "960/960"; bigger moons widen it: drawer_w)
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
end
else
r.need_view = true -- (bought from a view without the aside: it shows on the rebuild)
if r.reduced then
r.k = 1
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.question_scale = gui.get_scale(r.question).x
end
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
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 drawer's width for this moon: the wells' "supply/max" column fits the grade's
-- biggest numbers ("50,440/50,600" on an S moon), and the drawer grows by what it needs.
local NUM_W = 64
local function drawer_w(r)
if r.dw then
return r.dw, r.num_w
end
local d = r.data
if not d then
return DRAWER_W, NUM_W
end
local max = (d.maxSupply and d.maxSupply > 0) and d.maxSupply or 90
local n = #ui.money(max)
r.num_w = math.max(NUM_W, math.ceil(n * 8 + (n + 1) * 7.3 + 6)) -- (mono 13 px, then "/max" in mono 12 px)
r.dw = DRAWER_W + r.num_w - NUM_W
return r.dw, r.num_w
end
-- 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(r) - 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(r) / 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
@@ -419,23 +507,37 @@ function M:bind()
end
if r.card and self.card_bound ~= g.druid then
self.card_bound = g.druid
self.components[#self.components + 1] = first(g.druid:new_button(r.close_btn, function()
-- No Druid scale on hover (it grows from the corner): the fill brightens instead.
local function lit(b, root)
local base = gui.get_color(root)
b.hover.on_mouse_hover:subscribe(function(_, on)
local a = gui.get_color(root).w
local c = on and vmath.vector4(math.min(1, base.x * 1.15 + 0.04), math.min(1, base.y * 1.15 + 0.04), math.min(1, base.z * 1.15 + 0.04), a)
or vmath.vector4(base.x, base.y, base.z, a)
gui.animate(root, "color", c, gui.EASING_OUTQUAD, 0.12)
end)
return b
end
self.components[#self.components + 1] = first(lit(g.druid:new_button(r.close_btn, function()
if r.done then
self:close()
else
self:advance()
end
end))
end):set_animations_disabled(), r.close_btn))
if r.another_btn and r.another_ok then
self.components[#self.components + 1] = first(g.druid:new_button(r.another_btn, function()
self.components[#self.components + 1] = first(lit(g.druid:new_button(r.another_btn, function()
self:buy_another()
end))
end):set_animations_disabled(), r.another_btn))
end
end
-- (every build resets these)
if r.card then
if g.autoplay then
g.autoplay:register("reveal-close", {}, function() self:close() end)
if r.another_btn and r.another_ok then
g.autoplay:register("reveal-buy", {}, function() self:buy_another() end)
end
end
if r.another_btn and not r.another_ok then
tooltip.add(r.another_btn, r.another_why)
@@ -445,13 +547,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 +559,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 +638,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(r) / 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(r), 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 +718,33 @@ 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
trait_boxes[#trait_boxes + 1] = fixed(tw, 22)
trait_names[#trait_names + 1] = tr
tx = tx + tw + 6
end
local dh, desc = para(body, d.description or "", { font = "sans", px = 14, color = "text_2" }, 0, 0, inner)
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, close_w = ui.button(foot, "Start mining", 0, 0, { primary = true, w = CLOSE_W })
close_w = close_w or CLOSE_W
for _, n in pairs(close_nodes) do
gui.set_inherit_alpha(n, true)
end
@@ -627,7 +755,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 +769,27 @@ 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 } }, {
{ 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)
local function solve(pad, gap)
local row = B({ align = "center", height = hgt, padding = { 0, pad } }, {
{ node = pt, size = false, style = { width = ui.text_width(pt), height = hgt } }, fixed(gap, 0),
{ node = sep, size = false, style = { width = ui.text_width(sep), height = hgt } }, fixed(gap, 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)
return row
end
-- It must fit the card beside Start mining: else tighter, then a shorter label.
local room = inner - 10 - close_w
local row = solve(16, 8)
if row.w > room then
row = solve(12, 6)
end
if row.w > room then
gui.set_text(pt, ui.esc(string.upper((next_moon.grade or "") ~= "" and ("Another " .. next_moon.grade) or "Another")))
row = solve(12, 6)
end
aw = math.floor(math.max(math.min(ANOTHER_W, room), row.w))
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 +801,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(aw or ANOTHER_W, theme.size.button_h), fixed(10, 0), fixed(close_w, theme.size.button_h),
}, "foot"),
})
flex.solve(tree, 0, 0)
@@ -686,14 +825,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 +860,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 +905,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 +915,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, select(2, drawer_w(r))
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,7 +951,7 @@ 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
@@ -820,24 +967,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 +981,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 +995,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 +1005,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 +1026,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(self.r) * (i + 0.5) / n, top.y, gold, 18, 0.8)
end
end)
end
@@ -928,29 +1053,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 +1131,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 +1176,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 +1186,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 +1201,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 +1247,9 @@ function M:finish(quick)
end
g.fx:signal("revealed")
if r.buy_next then
g.h.act(g, "buy-moon", { include = "new-moon" })
local grade = r.data.next and r.data.next.grade
g.nm_grade = nil -- ("+ New moon" follows the grade just bought: state.moonGrade)
g.h.act(g, "buy-moon", { grade = (grade or "") ~= "" and grade or nil, include = "new-moon" })
end
end
+122
View File
@@ -0,0 +1,122 @@
-- Save codes (the settings menu's "Save code" and "Restore from code"; ../server/api.md
-- "Save codes"): a code brings the player's progress to another device, or back here if
-- the saved player id is gone. The server keeps only a hash, so a code shows once, when
-- it is made; making a new one turns the old one off.
-- open_save(g): asks the server whether there is a code (GET /api/save-code), then a
-- dialog to make one (action save-code), then the code (Copy, "dialog:ok", only in a
-- browser: Defold has no clipboard on desktop).
-- open_restore(g): a text field and Restore (action restore): the response is the other
-- player's state and id (session.lua treats it as a fresh start; api.lua stores the id).
-- Test ids: "dialog:ok" (Make a code / Restore / Copy), "field:restore-code".
local ui = require("modules.ui")
local api = require("modules.api")
local test = require("modules.test")
local M = {}
local W = 480
local function vec(x, y)
return vmath.vector3(x, y, 0)
end
local TEXT = { font = "sans", px = 15, color = "text_2" }
-- The height a wrapped text takes in the dialog (laid out first: the dialog's height
-- comes from it).
local function text_h(g, str)
local probe, h = ui.wrap(g.dialog.layer, str, TEXT, 0, 0, W - 48)
gui.delete_node(probe)
return h
end
local function show_code(g, code)
local note = "Write it down. On another device: settings (the gear), Restore from code. It is shown only this once; anyone with it can play as you."
local nh = text_h(g, note)
g.dialog:open({
title = "Your save code",
w = W,
h = 58 + 40 + 14 + nh + 24 + 44 + 24,
cancel = "Close",
ok = html5 and { label = "Copy", on_ok = function()
pcall(html5.run, "navigator.clipboard && navigator.clipboard.writeText('" .. code .. "')")
g.toasts:push("Save code copied", "save-code", false)
end } or nil,
build = function(d, box)
ui.text(box, code, { font = "mono", px = 26, color = "cyan" }, W / 2, 58 + 20, gui.PIVOT_CENTER)
ui.wrap(box, note, TEXT, 24, 58 + 40 + 14, W - 48)
end,
})
end
local function make(g)
g.h.act(g, "save-code", {}, function(err, data)
local sc = type(data) == "table" and data.saveCode
if not err and type(sc) == "table" and type(sc.code) == "string" then
g.save_code = sc.code -- (the last code made, for the test driver)
show_code(g, sc.code)
end
end)
end
function M.open_save(g)
api.get_save_code(function(err, data)
local made = not err and type(data) == "table" and type(data.saveCode) == "table" and tonumber(data.saveCode.madeAt) or 0
if err then
g.toasts:push("Can't reach the server", "error", true)
return
end
local message
if made > 0 then
message = "You made a save code on " .. os.date("%Y-%m-%d", math.floor(made / 1000)) ..
". It was shown only then. Lost it? Make a new one; the old one stops working."
else
message = "A save code brings your progress to another device, or back here if this one forgets you. Keep it safe: anyone with it can play as you."
end
g.dialog:open({
title = "Save code",
w = W,
message = message,
ok = { label = made > 0 and "Make a new code" or "Make a code", on_ok = function() make(g) end },
})
end)
end
function M.open_restore(g)
local code = ""
local msg = "Enter a save code to play as that miner on this device. This device's current miner can only come back with its own code."
local mh = text_h(g, msg)
local fy, fw, fh = 58 + mh + 14, W - 48, 40
g.dialog:open({
title = "Restore from code",
w = W,
h = fy + fh + 24 + 44 + 24,
ok = { label = "Restore", on_ok = function()
if code:match("%w") then
g.h.act(g, "restore", { code = code })
end
end },
build = function(d, box)
ui.wrap(box, msg, TEXT, 24, 58, W - 48)
local fbox = ui.rect(box, 24, fy, fw, fh, "field")
local edge = ui.outline(fbox, 0, 0, fw, fh, "cyan")
local hint = ui.text(fbox, "ABCD-EFGH-JKMN-PQRS", { font = "mono", px = 16, color = "faint" }, 12, fh / 2)
local txt = ui.text(fbox, "", { font = "mono", px = 16, color = "text" }, 12, fh / 2)
gui.set_size(txt, vec((fw - 24) / gui.get_scale(txt).x, 40))
local input = d.druid:new_input(fbox, txt, gui.KEYBOARD_TYPE_DEFAULT)
input:set_allowed_characters("[%w%- ]")
input:set_max_length(24)
input.style.on_select = function() gui.set_color(edge, ui.color("cyan")) end
input.style.on_unselect = function() gui.set_color(edge, ui.color("line_hi")) end
input.on_input_text:subscribe(function(_, text)
code = text
gui.set_enabled(hint, text == "")
end)
test.register("field:restore-code", fbox, "field", "Save code")
input:select()
end,
})
end
return M
+168 -39
View File
@@ -1,61 +1,190 @@
-- 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), Grant for admins, and Moon lab (the admin area, admin.lua) for
-- admins and in debug builds.
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
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)
-- 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,
lab = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
gui.play_flipbook(icon, "moon")
end,
grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
}
-- 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 admin = require("screens.game.admin")
local lab = st and admin.allowed(g)
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
local h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({
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))
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))
y = y + ROW_H
end
if lab then
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
admin.open(g, "moonlab")
end), { disabled = g.reveal:active() })
end
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
+90
View File
@@ -0,0 +1,90 @@
-- The right side panel (region): a column right of the main panel (left of the aside when
-- that shows), framed like the moon list, holding side panels stacked top to bottom: for
-- now the leaderboard. A side panel is a module in M.PANELS with key, include (the state
-- block it shows: core/session asks for it while the column shows), unlocked(g), sig(g)
-- (what it shows: its block is kept while this stays the same) and build(g, root, x, y,
-- w, h). A second one goes in M.PANELS; the column's height is shared evenly.
--
-- When it shows (core/main.lua lays it out, M.width): on the Market only, once a panel is
-- unlocked, never while the aside (the moon pane) shows, and only while the main panel keeps at least
-- MAIN_MIN beside it; otherwise it folds away and the main panel gets the width (the
-- Market then shows the leaderboard under its content). It depends on the window, the
-- aside and the unlocks only, never on counts, so nothing shifts while playing.
local theme = require("modules.theme")
local ui = require("modules.ui")
local blocks = require("modules.blocks")
local S = theme.size
local M = {}
M.PANELS = { require("screens.game.leaderboard") }
local MAIN_MIN = 680 -- narrowest the main panel gets with the column beside it
local W_MIN, W_MAX, W_PART = 240, 320, 0.13 -- clamp(240, 13% of the window, 320)
-- The panels unlocked now.
function M.panels(g)
local out = {}
for _, p in ipairs(M.PANELS) do
if p.unlocked(g) then
out[#out + 1] = p
end
end
return out
end
-- The column's width for a layout W wide with `room` left for the main panel and the
-- column together; 0 when it folds away (or no panel is unlocked).
function M.width(g, W, room, narrow)
if narrow or #M.panels(g) == 0 then
return 0
end
local w = math.max(W_MIN, math.min(W_MAX, math.floor(W * W_PART)))
if room - w - S.gap < MAIN_MIN then
return 0
end
return w
end
-- The state blocks the shown panels need (core/session adds them to every request). Before
-- the first layout (the first state) all of them: the column may show at once.
function M.includes(g)
local out = {}
if g.side_w == nil or g.side_w > 0 then
for _, p in ipairs(g.side_w == nil and M.PANELS or M.panels(g)) do
out[#out + 1] = p.include
end
end
return out
end
-- After a build: a shown panel whose block the state doesn't have yet (the window grew,
-- the aside closed or it just unlocked) asks for it once (fetch: core/session).
function M.missing(g)
if (g.side_w or 0) == 0 then
return false
end
for _, p in ipairs(M.panels(g)) do
if g.state[p.include] == nil then
return true
end
end
return false
end
-- Builds the column at (x, y), w x h: a framed panel per side panel, stacked.
function M.build(g, parent, x, y, w, h, common)
local list = M.panels(g)
local each = math.floor((h - S.gap * (#list - 1)) / math.max(1, #list))
for i, p in ipairs(list) do
local py = y + (i - 1) * (each + S.gap)
blocks.keep(g, "side:" .. p.key, blocks.sig(common, x, py, w, each, p.sig(g)), parent, function(holder)
local _, nodes = ui.panel(holder, x, py, w, each)
p.build(g, ui.part(nodes, "panel", "root"), 0, 0, w, each)
end)
end
end
return M
+10 -32
View File
@@ -1,4 +1,4 @@
-- The Store section (state.store): tool cards and attachment cards with their images,
-- The Store section (state.store): tool cards with their images,
-- prices, what they'd earn and how many you own. Buying a tool opens the buy modal
-- (the response's buyModal: up to 3 wells to deploy it on).
@@ -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"),
@@ -180,26 +185,6 @@ local function tool_card(self, H, st, c, t)
end)
end
local function gear_card(self, H, st, c, a)
c:image(a.image)
c:title(a.name)
c:text(a.effectText or cards.effect(a.effect, "attachment"), { font = "mono", px = 14, color = "violet_hi" }, 22)
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)
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)
if short then
tooltip.add(b, a.whyText)
else
H.action(self, b, nodes, "buy-attachment", { item = a.name })
end
end)
end)
end
function M.build(self, root, px, py, w, h, H)
local st = self.state
local sto = st.store
@@ -212,11 +197,10 @@ function M.build(self, root, px, py, w, h, H)
local function cap(label, before)
return fg.col({}, { fg.space(0, before), fg.label(c, label, "cap", 12), fg.space(0, 8) })
end
local attachments = #(sto.attachments or {}) > 0
y = fg.flow(c, x, y, inner, {
fg.col({}, {
fg.label(c, "Store", "h1", 40), fg.space(0, 4),
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Equip attachments in Equipment.",
fg.text_para(c, "Most tools mine one well at a time; the biggest mine several wells of a moon, or the whole moon, each cycle. Upgrade them in Equipment.",
{ font = "sans", px = 15, color = "muted" }, { max_width = math.min(inner, S.text_max) }),
fg.space(0, 20),
}),
@@ -226,12 +210,6 @@ function M.build(self, root, px, py, w, h, H)
tool_card(self, H, st, cd, t)
end, { key = function(t) return "store:tool:" .. tostring(t.storeItemId) end, sig = shared })
end,
attachments and cap("Attachments", 8) or nil,
attachments and function(cy)
return card.grid(self, c, x, cy, inner, sto.attachments, function(cd, a)
gear_card(self, H, st, cd, a)
end, { key = function(a) return "store:gear:" .. tostring(a.name) end, sig = shared })
end or nil,
})
area:finish(y + py)
end
-12
View File
@@ -8,8 +8,6 @@
- characters the fonts lack: non-ASCII characters in Lua string literals and in a
sample of server texts (GET /api/state of a fixture player, every section) must be in
the fonts that draw them (text fonts; the symbols ui.rich sends to the sym font)
- the moon generator (scripts/moon-check.mjs: the mmterrain extension's C, built for the
host, and terrain.lua's settings side, against the server's terrain.js)
- engine warnings in a scenario run's log: font batches / characters / text entries
over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors
@@ -231,15 +229,6 @@ def check_scenario(api):
check_log(os.path.join(ROOT, "dist/drive/check/game.log"))
def check_moon():
r = subprocess.run(["node", os.path.join(HERE, "moon-check.mjs")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
fail("moon-check.mjs failed:\n" + "\n".join(l for l in r.stdout.splitlines() if not l.startswith("ok "))[-2000:])
else:
print("moon: " + (r.stdout.strip().splitlines() or [""])[-1])
def check_flex():
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
@@ -257,7 +246,6 @@ def main():
check_lua()
check_flags()
check_flex()
check_moon()
check_fonts(api)
if "--static" not in sys.argv:
check_scenario(api)
+42 -3
View File
@@ -11,6 +11,8 @@
first, then the game's gui_script (its
on_reload rebuilds the screen; modules/hot.lua)
(--all: every Lua module again)
scripts/dev.py run [engine args...] the same in the foreground, output also on the
terminal (`make run`); reload works on it too
scripts/dev.py stop stop it (by its pid)
scripts/dev.py status
@@ -61,11 +63,14 @@ def read(name, default=None):
def alive(pid):
# Our game or Xvfb still: the pid file outlives a game that exits on its own, and its
# number can be reused by another process (stop would kill that one's group).
try:
os.kill(int(pid), 0)
return True
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
cmd = f.read()
except (OSError, ValueError, TypeError):
return False
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
def snapshot():
@@ -86,7 +91,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 +130,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 +156,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 +264,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":
+53 -10
View File
@@ -11,18 +11,19 @@ Usage:
scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
with run(game, args).
Options:
--api URL server (default http://localhost:3000; MM_API env)
--api URL server (or MM_API; required, no default: a test server, never :3000)
--size WxH window and display size (default 1280x800)
--out DIR screenshots and game.log (default dist/drive/<scenario>)
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default
--keep don't stop the game at the end (prints its pid and port)
--keep don't stop the game, or its Xvfb, at the end (prints their pids, the
port and the display)
--attach PORT don't start a game: drive the one whose engine service is on PORT
(`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api, which must
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture
--fixture make a test player first (scripts/fixture.py against --api) and play as
it; the scenario gets its ids as game.fixture
--fixture-file F use the ids saved in F (a fixture.py output) instead
The game it starts saves its files (player ids, settings) under <out>/data (or
@@ -34,6 +35,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 +53,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 +93,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 +181,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)
@@ -343,7 +382,7 @@ def main():
ap = argparse.ArgumentParser(add_help=True)
ap.add_argument("scenario")
ap.add_argument("rest", nargs="*")
ap.add_argument("--api", default=os.environ.get("MM_API", "http://localhost:3000"))
ap.add_argument("--api", default=os.environ.get("MM_API"))
ap.add_argument("--size", default="1280x800")
ap.add_argument("--out")
ap.add_argument("--bundle", action="store_true")
@@ -354,6 +393,9 @@ def main():
ap.add_argument("--fixture", action="store_true")
ap.add_argument("--fixture-file")
args = ap.parse_args()
if not args.api:
# (No default: the game makes a player and plays on whatever server this is.)
sys.exit("drive.py: pass --api or set MM_API to a test server (no default)")
if args.attach:
disp = os.path.join(ROOT, "dist/dev/display")
@@ -375,8 +417,12 @@ def main():
try:
code = subprocess.call([sys.executable] + sys.argv, env=env)
finally:
xvfb.terminate()
xvfb.wait()
if args.keep:
# The kept game draws on this display: leave it running too.
print("Xvfb pid %d, display :%s" % (xvfb.pid, num))
else:
xvfb.terminate()
xvfb.wait()
sys.exit(code)
path = find_scenario(args.scenario)
@@ -390,9 +436,6 @@ def main():
if not args.attach:
args.config.append("mm.player=" + fixture["player"])
elif args.fixture:
if "--api" not in sys.argv and not os.environ.get("MM_API"):
sys.exit("drive.py: --fixture creates a player on the server: pass --api or set MM_API "
"to a test server (no default)")
fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
args.config.append("mm.player=" + fixture["player"])
print("fixture player", fixture["player"])
+7 -7
View File
@@ -5,7 +5,7 @@ Usage: scripts/fixture.py <api base> [--plain]
Prints one JSON line: {"player", "moon", "well", "wells", "tool", "drill_mining", ...}
that scenarios (scripts/drive.py) use for views. The player: every section unlocked,
credits, resources and refined items granted, two moons, several tools (some drilling,
one ready, one idle with an attachment), a refinery batch running, an advisor hired.
one ready, one idle with upgraded tracks), a refinery batch running, an advisor hired.
--plain: just a new player (nothing granted), for the first-moon views.
"""
import json
@@ -66,8 +66,8 @@ admin("credits", amount=500000, op="grant")
for r in ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance",
"Dusk Alloy", "Cryo Cell", "Plasma Weave"]:
admin("resources", resource=r, amount=400)
for item, tier in [(1, 0), (1, 0), (2, 1), (3, 0), (5, 2)]:
admin("tool", item=item, tier=tier)
for item, yl, cl in [(1, 0, 0), (1, 0, 0), (2, 0, 1), (3, 0, 0), (5, 2, 1)]:
admin("tool", item=item, **{"yield": yl, "cycle": cl})
act("buy-moon")
d = act("buy-moon")
@@ -89,11 +89,11 @@ act("stake", tool=long_tool, target=wells[0]["id"])
act("stake", tool=by_item[2][0]["id"], target=wells[1 % len(wells)]["id"])
act("stake", tool=by_item[3][0]["id"], target=wells[-1]["id"])
act("buy-attachment", item="Turbo Core")
act("buy-attachment", item="Diamond Bit")
act("buy-attachment", item="Diamond Bit")
# The idle PD-1 a level up on each track (its yield track twice).
idle = by_item[1][1]["id"]
act("equip", item="Turbo Core", to=idle)
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="yield")
act("upgrade", tool=idle, track="cycle")
act("buy-slot")
act("refine", recipe="Dusk Alloy", qty=5)
-143
View File
@@ -1,143 +0,0 @@
// Checks the moon generator against the server's terrain.js: the native one
// (mmterrain/src/terrain.c, built here for the host and loaded into luajit through the
// FFI) gives the same mesh within float32 rounding, and the Lua settings side
// (modules/moon/terrain.lua: random draws, traits, resolve, the strip) matches too.
// Every seed runs with the default settings and a few others (each technique, many
// craters, no ridges or patches). Needs node, luajit and a C compiler (cc).
//
// node scripts/moon-check.mjs [resolution] [seeds...]
//
// terrain.js is read from $MM_SERVER (default ../server).
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const server = process.env.MM_SERVER || path.join(root, "..", "server");
const T = await import(pathToFileURL(path.join(server, "web/static/js/terrain.js")));
const [res = "32", ...rest] = process.argv.slice(2);
// The fixed seeds, plus moon-id-like ones (deterministic).
const ids = [];
{
let h = 0x9e3779b9;
const next = () => ((h = (Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + 0x6d2b79f5) >>> 0), h.toString(16).padStart(8, "0"));
for (let i = 0; i < 24; i++) ids.push(`${next()}-${next().slice(0, 4)}-4${next().slice(0, 3)}-a${next().slice(0, 3)}-${next()}${next().slice(0, 4)}`);
}
const seeds = rest.length ? rest : ["1042", "1187", "1301", "1555", "29dea5d5-709d-4c55-a269-cd90e4379fe9", ...ids];
const fixed = (t) => ({ technique: t, variation: { technique: false } });
const SETTINGS = [
{}, fixed("fbm"), fixed("ridged"), fixed("warped"), fixed("billow"), fixed("craterField"),
{ craters: { count: 1500, smoothness: 0, maxRadius: 0.4 } }, { ridge: { strength: 0 }, shading: { patchAmount: 0 } },
];
// Each case: a seed and a settings index (the default settings for every seed, the
// others over a rotating subset).
const cases = [];
seeds.forEach((seed, i) => {
cases.push([seed, 0]);
for (let s = 1; s < SETTINGS.length; s++) if ((i + s) % 4 === 0 || rest.length) cases.push([seed, s]);
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mmterrain-"));
const lib = path.join(dir, "libmmterrain.so");
execFileSync("cc", ["-O2", "-std=c99", "-shared", "-fPIC", "-o", lib, path.join(root, "mmterrain/src/terrain.c"), "-lm"]);
const luaValue = (v) => (typeof v === "object" ? "{" + Object.entries(v).map(([k, x]) => `${k}=${luaValue(x)}`).join(",") + "}" : JSON.stringify(v));
const lua = `
package.path = ${JSON.stringify(root + "/?.lua;")} .. package.path
local T = require("modules.moon.terrain")
local SETTINGS = { ${SETTINGS.map(luaValue).join(", ")} }
local ffi = require("ffi")
ffi.cdef[[
typedef struct mt_params mt_params;
typedef struct mt_gen mt_gen;
int mt_params_from_array(const double* a, int n, mt_params* p);
mt_gen* mt_new(const mt_params* p);
void mt_free(mt_gen* g);
int mt_step(mt_gen* g, double budget);
int mt_count(const mt_gen* g);
int mt_n(const mt_gen* g);
const double* mt_positions(const mt_gen* g);
const double* mt_normals(const mt_gen* g);
const double* mt_colors(const mt_gen* g);
int mt_strip_len(int n);
void mt_strip(int n, int* out);
void mt_fill(const mt_gen* g, float* pos, float* nor, float* col, int stride, int from, int to);
]]
local N = ffi.load(${JSON.stringify(lib)})
local function num(x) return string.format("%.17g", x) end
local function carr(p, n) local o = {} for k = 0, n - 1 do o[k + 1] = num(p[k]) end return "[" .. table.concat(o, ",") .. "]" end
local out = {}
for i = 2, #arg, 2 do -- arg[1]: resolution, then seed, settings index pairs
local seed, si = arg[i], tonumber(arg[i + 1])
local s = T.merge_settings(SETTINGS[si + 1])
local r = T.rng(T.hash_string(seed))
local draws = {} for k = 1, 16 do draws[k] = num(r()) end
local tr = T.traits(s, seed)
local p = T.resolve(s, seed)
p.resolution = tonumber(arg[1])
do
local a = T.native_params(p)
local buf = ffi.new("double[?]", #a, a)
local params = ffi.new("char[1024]") -- (larger than mt_params)
assert(N.mt_params_from_array(buf, #a, ffi.cast("mt_params*", params)) == 1)
local g = N.mt_new(ffi.cast("mt_params*", params))
local steps = 0
repeat steps = steps + 1 until N.mt_step(g, 0.0005) == 1
local c = N.mt_count(g) * 3
-- The strip: the native order equals the Lua's (terrain.strip), and its fill writes
-- those vertices.
local strip = T.strip(N.mt_n(g))
local len = N.mt_strip_len(N.mt_n(g))
local sidx = ffi.new("int[?]", len)
N.mt_strip(N.mt_n(g), sidx)
local same = len == #strip
for k = 1, #strip do if not same then break end same = sidx[k - 1] == strip[k] end
local fill = ffi.new("float[?]", len * 9)
local half = math.floor(len / 2) -- (in two ranges, like the web's chunks)
N.mt_fill(g, fill, fill + 3, fill + 6, 9, 0, half)
N.mt_fill(g, fill, fill + 3, fill + 6, 9, half, len)
local P = N.mt_positions(g)
local fdiff = 0
for k = 1, #strip do
local v = strip[k]
for d = 0, 2 do fdiff = math.max(fdiff, math.abs(fill[(k - 1) * 9 + d] - P[v * 3 + d])) end
end
out[#out + 1] = string.format('{"draws":[%s],"traits":[%q,%q,%s,%s,%s,%s],"positions":%s,"normals":%s,"colors":%s,"strip":%s,"fill":%s,"steps":%d}',
table.concat(draws, ","), tr.technique, tr.mid, num(tr.shapeStrength), num(tr.ridgeStrength), num(tr.craterCount),
num(tr.maxRadius), carr(N.mt_positions(g), c), carr(N.mt_normals(g), c), carr(N.mt_colors(g), c),
same and "true" or "false", num(fdiff), steps)
N.mt_free(g)
end
end
print("[" .. table.concat(out, ",") .. "]")
`;
const got = JSON.parse(execFileSync("luajit", ["-", res, ...cases.flat().map(String)], { input: lua, maxBuffer: 1 << 30 }).toString());
const maxDiff = (a, b) => (a.length !== b.length ? Infinity : a.reduce((d, v, k) => Math.max(d, Math.abs(v - b[k])), 0));
const close = (m, g) => {
const d = { positions: maxDiff(m.positions, g.positions), normals: maxDiff(m.normals, g.normals), colors: maxDiff(m.colors, g.colors) };
return [d.positions < 1e-5 && d.normals < 1e-4 && d.colors < 1e-4, d];
};
const fmt = (d) => `positions ${d.positions.toExponential(1)}, normals ${d.normals.toExponential(1)}, colors ${d.colors.toExponential(1)}`;
let failed = false;
cases.forEach(([seed, si], i) => {
const s = T.mergeSettings(SETTINGS[si]);
const r = T.rng(T.hashString(seed));
const draws = Array.from({ length: 16 }, () => r());
const t = T.moonTraits(s, seed);
const traits = [t.technique, t.mid, t.shapeStrength, t.ridgeStrength, t.craterCount, t.maxRadius];
const p = T.resolveMoon(s, seed);
p.resolution = Number(res);
const m = T.generateMoon(p);
const exact = JSON.stringify(draws) === JSON.stringify(got[i].draws) && JSON.stringify(traits) === JSON.stringify(got[i].traits);
const nat = got[i];
const [natOk, d] = close(m, nat);
const ok = exact && natOk && nat.strip && nat.fill < 1e-5;
const line = `draws and traits ${exact ? "identical" : "DIFFER"} | native: ${fmt(d)}; strip ${nat.strip ? "same" : "DIFFERS"}, fill ${nat.fill.toExponential(1)}, ${nat.steps} steps`;
failed ||= !ok;
console.log(`${ok ? "ok " : "FAIL"} ${seed} [settings ${si}] ${t.technique}: ${line}`);
});
console.log(`${cases.length} cases at resolution ${res}: ${failed ? "FAILED" : "all ok"}`);
process.exit(failed ? 1 : 0);
+2 -2
View File
@@ -26,7 +26,7 @@ def run(game, args):
game.navigate("{section='new-moon'}")
game.idle()
n0 = game.eval("#st.moons")
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
first = game.eval("g.reveal.r.data.moonId")
els = [e["id"] for e in game.elements()]
@@ -53,7 +53,7 @@ def run(game, args):
admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
card_done(game)
nxt = game.eval("(function() local n = g.reveal.r.data.next return n.why .. '|' .. n.whyText end)()")
game.expect(nxt.startswith("credits|"), "next: %s" % nxt)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Card sections (needs --fixture): a click on a tool card opens its drill panel, a click
"""Card sections (needs --fixture): a click on a tool row (Equipment) opens its drill panel, a click
on an advisor card shows it in the aside; hover lights a card's edge; seat tiles and the
close are focusable. Shots of each."""
+8 -7
View File
@@ -1,13 +1,14 @@
"""The confirm dialog (needs --fixture): open it from the drill panel's spare attachment
Sell, shots mid-open and open, Esc cancels, open again, OK sells (a toast)."""
"""The confirm dialog (needs --fixture): open it from the refinery's buy-slot button
(sells ask in place now: sellask.py), shots mid-open and open, Esc cancels, open again,
OK buys (a toast)."""
def open_sell(game, shot=None):
"""Click Sell until the dialog opens: a rebuild (a drill finishing) can drop a press."""
"""Click until the dialog opens: a rebuild (a drill finishing) can drop a press."""
for _ in range(3):
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
if shot:
game.shot(shot, settle=0.08)
shot = None
@@ -21,7 +22,7 @@ def open_sell(game, shot=None):
def run(game, args):
fx = game.fixture
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='refinery'}")
game.idle()
open_sell(game, "dialog-opening")
game.shot("dialog-open", settle=0.6)
@@ -32,5 +33,5 @@ def run(game, args):
game.sleep(0.5)
game.click("dialog:ok")
game.wait_for("not g.dialog.root and not g.busy")
game.expect(game.log_has(r"mm: toast Sold"), "selling shows a toast")
game.expect(game.log_has(r"mm: toast"), "buying shows a toast")
game.shot("dialog-after", settle=0.3)
+7 -6
View File
@@ -1,7 +1,7 @@
"""Display settings (needs --fixture; run with XDG_DATA_HOME pointing at a scratch
directory: it saves the settings file): the header's display button opens the dialog;
+ / - / Reset change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
dialog's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
directory: it saves the settings file): the header's gear opens the settings menu;
+ / - change the UI scale (the layout follows); Ctrl + / Ctrl 0 too; F11 and the
menu's button toggle fullscreen (the mmwindow extension), both saved; the sound goes
silent while the window is hidden."""
@@ -9,9 +9,9 @@ def run(game, args):
pre = (args[0] + "-") if args else ""
game.idle()
W0 = game.eval("g.size[1]")
game.click("display")
game.click("settings")
game.wait_for("g.dialog:is_open()", timeout=5)
game.shot(pre + "display-dialog", settle=0.8)
game.shot(pre + "settings-menu", settle=0.8)
game.click("dialog:scale-up")
game.click("dialog:scale-up")
game.wait_for("require('modules.screen').ui_scale == 1.25", timeout=5)
@@ -19,7 +19,8 @@ def run(game, args):
W1 = game.eval("g.size[1]")
game.expect(W1 < W0, "a bigger UI scale gives a smaller layout (%s -> %s)" % (W0, W1))
game.shot(pre + "display-125", settle=0.8)
game.click("dialog:scale-reset")
game.click("dialog:scale-down")
game.click("dialog:scale-down")
game.wait_for("require('modules.screen').ui_scale == 1", timeout=5)
game.key("key_esc")
game.wait_for("not g.dialog:is_open()", timeout=5)
+67
View File
@@ -0,0 +1,67 @@
"""The drill panel during onboarding (needs --fixture-file of a plain player:
scripts/fixture.py <api> --plain, and an MM_DEV=1 server): once the first deploy reveals
Equipment, a tool row opens its panel (stats, the tracks' ladders) without the Store's
parts (the upgrade buttons, Sell drill); after the first claim and sale reveal the Store,
the same panel has them. Shots "before" and "after", each linted."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
def labels(game):
"""Every shown text on screen, lower case."""
return {(n.get("text") or "").lower() for n in game.dump()["nodes"] if n.get("text")}
def open_panel(game, tool):
game.navigate("{section='equipment'}")
game.idle()
game.click("drill:" + tool)
game.wait_for("g.view.drill ~= '' and g.state.equipment.drill ~= nil", 10)
game.idle()
def run(game, args):
game.idle()
admin(game, "credits", amount=5000, op="grant")
r = call(game, "POST", "/api/act/buy-moon", {})
game.expect(r.get("ok"), "buy-moon: %s" % r.get("error"))
st = r["state"]
tool = st["tools"][0]["id"]
well = st["moon"]["wells"][0]["id"]
r = call(game, "POST", "/api/act/stake", {"tool": tool, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
u = r["state"]["unlocked"]
game.expect(u["equipment"] and not u["store"], "after the first deploy: %s" % u)
open_panel(game, tool)
game.sleep(2) # (the wallet counts down from the moon purchase)
game.shot("before")
game.lint("before")
texts = labels(game)
game.expect("yield per cycle" in texts and "level" in texts, "track ladders shown")
game.expect("sell drill" not in texts, "sell drill hidden before the Store")
ups = [e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")]
game.expect(not ups, "no upgrade before the Store: %s" % ups)
# First claim (Market), first sale (Store).
admin(game, "finish-stakes")
r = call(game, "POST", "/api/act/claim", {"claim-well": well})
game.expect(r.get("ok"), "claim: %s" % r.get("error"))
res = [x for x in r["state"]["resources"] if x["amount"] > 0][0]["name"]
r = call(game, "POST", "/api/act/sell", {"resource": res, "all": "1"})
game.expect(r.get("ok"), "sell: %s" % r.get("error"))
game.expect(r["state"]["unlocked"]["store"], "store unlocked after the sale")
open_panel(game, tool)
game.sleep(3) # (the sale's effects and the wallet)
game.shot("after")
game.lint("after")
texts = labels(game)
for t in ("yield per cycle", "sell drill"):
game.expect(t in texts, "%s shown after the Store" % t)
ups = {e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?")}
for kind in ("yield", "cycle"):
game.expect(any(("track=" + kind) in i for i in ups), "%s upgrade after the Store: %s" % (kind, ups))
+13
View File
@@ -0,0 +1,13 @@
"""The current screen's layout dump and its lint (no fixture needed; works with --attach).
Args: [view] [name]: a view to go to first ("{section='market'}"), the file name (default
"screen"). Writes <out>/<name>.layout.json and <name>.lint.json, prints the issues."""
def run(game, args):
game.idle()
if args and args[0].startswith("{"):
game.navigate(args[0])
game.idle()
game.sleep(1.0)
args = args[1:]
game.lint(args[0] if args else "screen")
+73
View File
@@ -0,0 +1,73 @@
"""The Equipment table (needs --fixture): a row per tool, copies too (the fixture's two
PD-1s), one mining, one ready; the idle PD-1's cycle track taken to its top (Max in place
of its button) and the credits cut to a few hundred (some upgrades short). Shots of the
table, a row's drill panel, a row's Sell asking in place (Cancel on Sell's spot), and two
clicks on an affordable upgrade button (click_check: it stays put). Each linted. Args: an
optional name prefix."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
from sellask import ask, covers, rect_of # noqa: E402
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
idle = fx["tool_idle"]
game.idle()
for _ in range(12):
r = call(game, "POST", "/api/act/upgrade", {"tool": idle, "track": "cycle"})
if not r.get("ok"):
break
st = call(game, "GET", "/api/state?s=equipment")["state"]
tr = [t for t in st["equipment"]["tools"] if t["id"] == idle][0]["tracks"][1]
game.expect(tr["next"] is None, "cycle track at its top: %s/%s" % (tr["level"], tr["max"]))
# One mining for sure (the fixture's stakes can find their well empty): an idle tool
# other than the PD-1s on the second moon.
ms = call(game, "GET", "/api/state?s=moons&moon=" + fx["moon2"])["state"]
wells = [w for w in ms["moon"]["wells"] if w["supply"] > 0]
spare = [t for t in st["equipment"]["tools"] if not t["stake"] and t["storeItemId"] != 1]
if wells and spare:
r = call(game, "POST", "/api/act/stake", {"tool": spare[0]["id"], "target": wells[0]["id"]})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
admin(game, "credits", amount=max(1, st["credits"] - 260), op="remove")
game.navigate("{section='equipment'}")
game.idle()
game.sleep(1)
game.shot(pre + "eqrows-table", settle=1.0)
game.lint(pre + "eqrows-table")
# A row opens its drill panel.
game.scroll_to("drill:" + idle)
game.idle()
game.click("drill:" + idle)
game.wait_for("view.drill == '%s' and not g.busy" % idle)
game.idle()
game.shot(pre + "eqrows-panel", settle=1.0)
game.lint(pre + "eqrows-panel")
# A row's Sell asks in place.
sell = "confirm:sell-tool:" + idle
game.scroll_to(sell)
game.idle()
before = rect_of(game, sell)
ask(game, sell)
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
game.expect(covers(rect_of(game, sell + ":cancel"), before), "Cancel takes Sell's spot")
game.shot(pre + "eqrows-asking", settle=0.3)
game.lint(pre + "eqrows-asking")
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
game.idle()
# Two clicks on an affordable upgrade keep hitting the same row's button.
ups = [e for e in game.elements() if e["id"].startswith("act:upgrade?") and e.get("enabled", True)]
game.expect(ups, "an affordable upgrade")
if ups:
id_ = ups[0]["id"]
for n in range(2):
if game.eval("el(%r)" % id_):
game.click_check(id_, name=pre + "eqrows-click-%d" % n)
game.shot(pre + "eqrows-after", settle=1.0)
+5 -4
View File
@@ -44,13 +44,14 @@ def run(game, args):
game.wait_for("not g.dialog:is_open()", 5)
game.idle()
# A confirm: OK has the focus, Enter sells (after a mouse click).
game.navigate("{section='equipment', drill='%s'}" % game.fixture["tool_idle"])
# A confirm: OK has the focus, Enter buys (after a mouse click). (Sells ask in place:
# sellask.py.)
game.navigate("{section='refinery'}")
game.idle()
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
for _ in range(3):
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
try:
game.wait_for("g.dialog:is_open()", 6)
break
+41
View File
@@ -0,0 +1,41 @@
"""Moon grades (needs --fixture and an MM_DEV=1 server: grants through the admin page).
The new-moon panel lists a row per grade with its Claim button (shot); the grade toggle
next to "+ New moon" changes the grade it buys and its price without moving it; it buys
that grade, the reveal shows the grade and Buy another names it (shot); the moon list
row and the moon heading show the grade badge (shot)."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin # noqa: E402
def run(game, args):
game.idle()
admin(game, "credits", amount=5000, op="grant")
game.navigate("{section='new-moon'}")
game.idle()
grades = game.eval("(function() local t = {} for _, e in ipairs(st.newMoon.grades) do t[#t+1] = e.grade .. (e.canAfford and '+' or '-') end return table.concat(t, ',') end)()")
print("grades:", grades)
els = [e["id"] for e in game.elements()]
game.expect(any(i.startswith("newmoon:grade:") for i in els), "grade rows registered")
game.shot("grades-panel", settle=0.4)
g0 = game.eval("require('screens.game.moonlist').grade(g)")
game.click("newmoon:grade")
game.idle()
g1 = game.eval("require('screens.game.moonlist').grade(g)")
game.expect(g0 != g1, "toggle changes the grade: %s -> %s" % (g0, g1))
game.click("newmoon:grade")
game.idle()
game.shot("grades-toggled", settle=0.3)
gb = game.eval("require('screens.game.moonlist').grade(g)")
game.click("newmoon:buy")
game.wait_for("g.reveal:active() and g.reveal:card_done()", 40)
game.sleep(0.4)
got = game.eval("g.reveal.r.data.grade .. '|' .. (g.reveal.r.data.next.grade or '')")
game.expect(got == gb + "|" + gb, "bought grade %s: %s" % (gb, got))
game.shot("grades-reveal", settle=0.3)
game.key("key_esc")
game.wait_for("not g.reveal:active()", 10)
game.idle()
game.shot("grades-moon", settle=0.4)
+6 -6
View File
@@ -45,12 +45,12 @@ def alive(game, what):
def open_confirm(game, fx):
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='refinery'}")
game.idle()
game.scroll_to("confirm:sell-attachment")
game.scroll_to("confirm:buy-slot")
game.idle()
for _ in range(3):
game.click("confirm:sell-attachment")
game.click("confirm:buy-slot")
for _ in range(30):
if game.eval("g.dialog:is_open()"):
game.sleep(0.4) # (opened: its animation done)
@@ -61,10 +61,10 @@ def open_confirm(game, fx):
def open_display(game):
"""The header's display button, a real click (again if a rebuild dropped it)."""
"""The header's settings gear (its menu), a real click (again if a rebuild dropped it)."""
for _ in range(3):
game.idle()
game.os_click("display")
game.os_click("settings")
for _ in range(15):
if game.eval("g.dialog:is_open()"):
game.sleep(0.4)
@@ -210,7 +210,7 @@ def run(game, args):
for how in ("close", "esc"):
game.navigate("{section='new-moon'}")
game.idle()
game.click("act:buy-moon?include=new-moon")
game.click("newmoon:grade:" + game.eval("st.moonGrade"))
game.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4)
if how == "close":
+52
View File
@@ -0,0 +1,52 @@
"""Layout lint of every section (needs --fixture), then the clicker rule on a few actions
(claim a well, collect a batch, sell, buy a tool, upgrade).
Run at each size (`make lint` does 1280x800 and 1920x1080). Writes <out>/<view>.lint.json
and lint-summary.json; prints the issues. Args: an optional name prefix (the size)."""
import json
import os
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
moon, well, drill = fx["moon"], fx["well"], fx["drill_mining"]
views = [
("moons", "{section='moons', moon='%s'}" % moon),
("well", "{section='moons', moon='%s', well='%s'}" % (moon, well)),
("equipment", "{section='equipment'}"),
("drill", "{section='equipment', drill='%s'}" % drill),
("store", "{section='store'}"),
("market", "{section='market', pick='Sollux'}"),
("refinery", "{section='refinery'}"),
("advisors", "{section='advisors'}"),
("leaderboard", "{section='leaderboard'}"),
("help", "{section='help'}"),
("new-moon", "{section='new-moon'}"),
]
game.set("reduced_motion", "true")
game.idle()
for name, view in views:
game.navigate(view)
game.idle()
game.sleep(0.5)
game.lint(pre + name)
# The clicker rule: the first of each, where the fixture has one.
clicks = [
("moons", "act:claim?claim-well"),
("refinery", "act:collect?job"),
("market", "act:sell?pick"),
("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)
+9 -16
View File
@@ -2,7 +2,7 @@
scenario may change: MM_DB, default mm_c_lists, in the podman container MM_DB_CONTAINER,
default moonminers_db_1). Shots: a depleted moon row (and hovered), the mined-out
collapse and the Past tab flash, the greyed Past list, a new moon row entering, a new
drill entering (wells table and well panel), deploy cards short and spoken for."""
drill entering (well card and well panel), the deploy slot short and with no room."""
import json
import os
import subprocess
@@ -98,9 +98,9 @@ def run(game, args):
burst(game, "newmoon", [0.05, 0.3, 0.6, 1.2])
game.idle()
# Deploy cards: tools idle after claiming moon 1; a well with little left: a stake
# from the well panel (the new drill enters in the table and the panel), then the
# other cards come up short or spoken for.
# Deploying: tools idle after claiming moon 1; a well with little left: a deploy from
# its card's slot (the new drill enters in the card and the well panel), then the slot
# comes up short ("Only +N left here"), then "No room here" once nothing is left.
game.navigate("{section='moons', moon='%s'}" % fx["moon"])
game.idle()
if game.eval("st.ready") > 0:
@@ -112,24 +112,17 @@ def run(game, args):
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle()
game.shot("deploy")
# (ids from the state: the elements reply can be long)
def card(i):
tool = game.eval("st.well.idle[%d] and st.well.idle[%d].toolId" % (i, i))
return tool and "act:stake?target=%s&tool=%s" % (well, tool)
first = card(1)
game.expect(first, "deploy cards are there")
game.scroll_to(first) # (the cards and the drills list under them in view)
slot = "act:stake?target=%s" % well
first = next((e["id"] for e in game.elements() if e["id"].startswith(slot)), None)
game.expect(first, "the well's deploy slot is there")
if first:
game.scroll_to(first)
game.click(first)
burst(game, "newdrill", [0.05, 0.3, 0.6, 1.2])
game.idle()
game.shot("deploy-short", settle=0.5)
# Nothing left after the drills: spoken for (dimmed; less so under the mouse).
# Nothing left after the drills: no room.
psql("update resource_well set supply = 2 where id = '%s'" % well)
game.navigate("{section='moons', moon='%s', well='%s'}" % (fx["moon"], well))
game.idle()
game.shot("deploy-spoken", settle=0.5)
n = game.eval("#st.well.idle")
if n:
game.hover(card(n))
game.shot("deploy-hover", settle=0.4)
+66
View File
@@ -0,0 +1,66 @@
"""Moon lab (admin area, screens/game/moonlab.lua): the gear menu's Moon lab opens it (needs
an MM_DEV=1 server, where everyone is an admin, or a debug build), the saved settings load,
the layout lints, a real mouse drag on a slider follows the pointer and rebuilds the moon
(the build times are printed: "moon: ... built, N vertices, M ms"), Save posts the
settings (GET answers them back), and Back to the game leaves the lab with the game's
moon on the saved settings."""
import json
import time
import urllib.request
M = "require('modules.moon.moon')"
def built(game):
game.wait_for("not %s.building and not g.lab.pending" % M)
return game.eval("%s.last_build and %s.last_build.ms" % (M, M))
def run(game, args):
game.idle()
game.click("settings")
game.wait_for("g.dialog:is_open()")
game.sleep(0.3)
game.click("menu:moon-lab")
game.wait_for("g.admin == 'moonlab' and g.lab and g.lab.loaded")
game.idle()
ms0 = built(game)
game.shot("moonlab", settle=0.5)
game.lint("moonlab")
# A click on a slider's track: the value under the pointer (the middle).
game.scroll_to("slider:craters.count")
game.click("slider:craters.count")
game.expect(game.eval("g.lab.draft.craters.count") == 1000, "a click in the middle sets the middle")
ms1 = built(game)
# A real drag across a slider: the knob follows, the moon rebuilds as it goes.
game.scroll_to("slider:shape.strength")
x, y = game.os_point("slider:shape.strength")
game.os_move(x - 40, y)
game.xdo("mousedown", "1")
t0 = time.time()
for i in range(20):
game.os_move(x - 40 + i * 8, y)
time.sleep(0.03)
game.xdo("mouseup", "1")
game.idle()
v = game.eval("g.lab.draft.shape.strength")
ms2 = built(game)
print("moonlab: build ms first %s, click %s, after drag %s (drag ended at strength %s, %.1f s)" % (ms0, ms1, ms2, v, time.time() - t0))
game.expect(v and v > 0.1, "the drag moved the slider right of the middle: %r" % v)
game.shot("moonlab-dragged", settle=0.3)
game.click_check("lab:save")
game.wait_for("not g.lab.saving")
game.expect(game.eval("g.lab.error") is None, "saved")
with urllib.request.urlopen(game.api + "/api/moon-settings") as r:
saved = json.load(r)
game.expect(saved["craters"]["count"] == 1000, "the server has the new settings")
game.click("admin:back")
game.wait_for("g.admin == nil")
game.idle()
game.expect(game.eval("%s.settings.craters.count" % M) == 1000, "the game's moon uses the saved settings")
game.shot("moonlab-back", settle=0.5)
+8 -8
View File
@@ -1,8 +1,8 @@
"""The moon view and well panel in the web's look (needs --fixture and a local dev server
whose database the scenario may change: MM_DB, see lists.py): the wells table (stacked
drill bars, Manage on every row, the rating chip), drills that get nothing (amber dashed
bars, the NOTHING LEFT tag, a greyed picture), a tapped-out well's panel, and the moon
sale opened (its lines scroll with the moon). Args: an optional name prefix."""
"""The moon view and well panel (needs --fixture and a local dev server whose database the
scenario may change: MM_DB, see lists.py): the well cards (a card per well, the rating
chip), drills that get nothing (amber dashed bars on their slots; in the aside the NOTHING
LEFT tag, a greyed picture), a tapped-out well, and the moon sale opened (its lines scroll
with the moon). Args: an optional name prefix."""
import os
import sys
@@ -19,13 +19,13 @@ def run(game, args):
game.idle()
game.shot(pre + "table", settle=0.8)
ids = [e["id"] for e in game.elements()]
wells = [w for w in fx["wells"] if ("manage:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "Manage on every well row (%d of %d)" % (len(wells), len(fx["wells"])))
wells = [w for w in fx["wells"] if ("well:" + w) in ids]
game.expect(len(wells) == len(fx["wells"]), "a card for every well (%d of %d)" % (len(wells), len(fx["wells"])))
# Drills that get nothing: six more on a well, then the well holds almost nothing.
well = fx["wells"][1 % len(fx["wells"])]
for _ in range(6):
admin(game, "tool", item=2, tier=0)
admin(game, "tool", item=2, **{"yield": 0, "cycle": 0})
st = call(game, "GET", "/api/state")["state"]
idle = [t["id"] for t in st["tools"] if t["storeItemId"] == 2 and not t.get("stakeId")][:6]
for tid in idle:
+8 -7
View File
@@ -1,13 +1,13 @@
"""Parity polish (needs --fixture). Phases (args, default all but ready):
bars: six more drills on a well: the wells table's bars with "+N" and the multi-well
diamond caps, the well panel in the web's order (deploy, drills, then the claims).
bars: six more drills on a well: their slots on the well's card, the multi-well card,
the well panel's drill lines (it only shows: the actions are on the cards).
ready: run with --config mm.fake_latency_ms=2500: drills finish while the refetch is
held back; the header badge, Claim all, moon row badge and Claim buttons count up on
our clock ("Claim N drills", "Claim <amount>").
motion: the header's reduced-motion toggle (run with --config mm.reduced_motion=0: forced,
so nothing is saved), the still ticker, a reduced reveal.
links: "N idle <tool>" opens that copy, "Better" well link, market card hover, refinery
recipes."""
links: "N idle <tool>" opens that copy, market card hover, refinery recipes (the
"Better" well is plain text in the well panel now: its link shot is skipped)."""
import json
import time
import urllib.request
@@ -47,7 +47,7 @@ def idle_tools(game, item):
def stake_many(game, item, n, well):
for _ in range(n):
admin(game, "tool", item=item, tier=0)
admin(game, "tool", item=item, **{"yield": 0, "cycle": 0})
for tid in idle_tools(game, item)[:n]:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
game.expect(r.get("ok"), "stake: %s" % r.get("error"))
@@ -84,7 +84,7 @@ def ready(game):
free = [w]
well = free[0]["id"]
for _ in range(2):
admin(game, "tool", item=3, tier=0)
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
copies = idle_tools(game, 3)[:2]
for tid in copies:
r = call(game, "POST", "/api/act/stake", {"tool": tid, "target": well})
@@ -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:
@@ -142,7 +143,7 @@ def links(game):
fx = game.fixture
# Two LunarLite L100 (10 s cycles), one mining: its panel links to the idle one.
for _ in range(2):
admin(game, "tool", item=3, tier=0)
admin(game, "tool", item=3, **{"yield": 0, "cycle": 0})
copies = idle_tools(game, 3)
r = call(game, "POST", "/api/act/stake", {"tool": copies[0], "target": fx["wells"][0]})
game.expect(r.get("ok"), "stake L100: %s" % r.get("error"))
+53
View File
@@ -0,0 +1,53 @@
"""Refinery batch size upgrade (needs --fixture): the Slots row shows the batch size and
"Upgrade to N · price"; clicking it (click_check: nothing moves under the pointer) raises
the size, the quantity field's cap follows; shots before and after. Then the upgrades are
bought up to the top (through the API) for the "Biggest batches" row."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
UPGRADE = "act:upgrade-batch?"
def run(game, args):
game.idle()
call(game, "POST", "/api/act/buy-slot", {}) # (a free slot: the recipes' fields are on)
game.navigate("{section='refinery'}")
game.idle()
before = game.eval("st.refinery.maxBatch")
nxt = game.eval("st.refinery.nextBatch")
game.expect(nxt and nxt > before, "a next batch size (%s after %s)" % (nxt, before))
game.shot("before", settle=0.4)
game.click_check(UPGRADE)
game.shot("clicked", settle=0.15)
game.wait_for("not g.busy and #g.pending == 0 and st.refinery.maxBatch == %d" % nxt, 10)
game.idle()
game.shot("after", settle=0.8)
item = game.eval("st.refinery.recipes[1].item")
game.expect(game.eval("st.rules.refinery.maxBatch") == nxt, "rules follow the batch size")
game.scroll_to("field:qty:" + item)
game.idle()
game.click("field:qty:" + item)
game.key("key_backspace")
game.type("99")
game.idle()
got = game.eval("g.refine_qty['%s']" % item)
game.expect(got == str(nxt), "the quantity caps at the new size: %s" % got)
game.key("key_esc")
# Up to the top.
admin(game, "credits", amount=20000000, op="grant")
for _ in range(10):
r = call(game, "POST", "/api/act/upgrade-batch", {})
if not r.get("ok"):
break
game.navigate("{section='market'}")
game.idle()
game.navigate("{section='refinery'}")
game.idle()
game.expect(not game.eval("st.refinery.nextBatch and st.refinery.nextBatch > 0"), "at the top")
game.key("key_home")
game.idle()
game.shot("top", settle=0.6)
+73
View File
@@ -0,0 +1,73 @@
"""Refinery feedback (needs --fixture): a short batch finishing with the Refinery open
(shots just before, during and after: the light along the bar, the edge pulse, Ready and
Collect popping in), Collect (the amount rising above the slot, motes flying into the recipe card's
owned number, which counts up), then a batch finishing while another section is open (the Refinery tab's pulse and badge
pop). With "reduced": the same with reduced motion (no pop, sweep or sparks)."""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from lists import burst, call # noqa: E402
# Seconds to the end of the newest batch (the one start_batch made).
LEFT = ("(function() local n for _,j in ipairs(g.base.jobs or {}) do if not n or j.start>n.start then n=j end end "
"return n and (n['end']-g.offset)/1000-socket.gettime() end)()")
def start_batch(game, section):
call(game, "POST", "/api/act/collect", {})
r = call(game, "POST", "/api/act/refine", {"recipe": "Dusk Alloy", "qty": "1"})
if not r.get("ok"): # (the fixture's batches hold every slot: buy one)
call(game, "POST", "/api/act/buy-slot", {})
r = call(game, "POST", "/api/act/refine", {"recipe": "Dusk Alloy", "qty": "1"})
game.expect(r.get("ok"), "refine: %s" % r.get("error"))
game.navigate("{section='%s'}" % section)
game.idle()
left = game.eval(LEFT)
game.expect(left is not None, "a running batch")
return left
def wait_until(game, left, before):
"""Sleep until `before` s ahead of the batch's end (left: seconds to it, measured now)."""
t = left - before
if t > 0:
time.sleep(t)
def run(game, args):
reduced = "reduced" in args
pre = "reduced-" if reduced else ""
game.idle()
if reduced:
game.set("reduced_motion", "1")
game.idle()
# 1. Refinery open: running batches (the fixture's) leave a slot free; collect what is
# ready first so the new batch is the only one finishing.
left = start_batch(game, "refinery")
print(" batch ends in %.1f s" % left)
game.shot(pre + "running", settle=0.3)
wait_until(game, game.eval(LEFT), 0.25)
burst(game, pre + "finish", [0.0, 0.35, 0.55, 0.8, 1.3, 2.5])
game.expect(game.eval("(function() for _,t in pairs(g.batches_done or {}) do if t>0 then return true end end end)()"),
"the batch's end was seen")
game.wait_for("(function() for _,j in ipairs(g.base.jobs or {}) do if j.ready then return true end end end)()", 10)
game.idle()
# 2. Collect: the amount flies into the recipe card's owned number.
job = game.eval("(function() for _,j in ipairs(g.base.jobs or {}) do if j.ready then return j.id end end end)()")
game.click("act:collect?job=" + job)
burst(game, pre + "collect", [0.1, 0.35, 0.6, 0.9, 1.4, 2.4])
game.idle()
# 3. Another section open: the Refinery tab reacts.
left = start_batch(game, "market")
wait_until(game, game.eval(LEFT), 0.2)
burst(game, pre + "tab", [0.0, 0.3, 0.5, 0.8, 1.5])
game.idle()
game.expect(game.eval("g.view.section") == "market", "still on the Market")
if reduced:
game.set("reduced_motion", "0")
game.expect(not game.log_has(r"ERROR:MM|SCRIPT ERROR|stack traceback"), "no script errors")
+57
View File
@@ -0,0 +1,57 @@
"""The new-moon reveal window (needs --fixture and an MM_DEV=1 server: grants through the
admin page). "+ New moon" at the top of the moon list buys a moon at once; the reveal plays
in the centered window (shots: charge, the drawer out); the credits count down during it;
Start mining and Buy another are in the same spot on two reveals; after closing, the
announcements pane lists the toast. Then without the credits for a moon the button is
disabled."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
POS = "(function() local r = g.reveal.r local p = gui.get_screen_position(r.close_btn) local q = r.another_btn and gui.get_screen_position(r.another_btn) return string.format('%d,%d|%s', p.x, p.y, q and string.format('%d,%d', q.x, q.y) or '-') end)()"
def run(game, args):
game.idle()
admin(game, "credits", amount=50000, op="grant")
game.navigate("{section='moons'}")
game.idle()
n0 = game.eval("#st.moons")
game.click("newmoon:buy")
game.wait_for("g.reveal:active()", 10)
game.shot("reveal-charge", settle=0.4)
game.wait_for("g.reveal.r and g.reveal.r.card ~= nil", 20)
game.sleep(1.0)
shown = game.eval("g.fx:value('credits', st.credits)")
real = game.eval("st.credits")
game.expect(abs(shown - real) < 1, "credits counted down during the reveal: shown %s, state %s" % (shown, real))
game.wait_for("g.reveal:card_done()", 30)
game.sleep(0.4)
game.shot("reveal-drawer", settle=0.3)
first = game.eval(POS)
first_id = game.eval("g.reveal.r.data.moonId")
game.click("reveal:buy")
game.wait_for("g.reveal.r and g.reveal.r.data.moonId ~= '%s' and g.reveal:card_done()" % first_id, 40)
game.sleep(0.4)
second = game.eval(POS)
game.expect(first == second, "buttons in the same spot: %s vs %s" % (first, second))
game.shot("reveal-second", settle=0.3)
game.click("reveal:close")
game.wait_for("not g.reveal:active()", 10)
game.idle()
game.expect(game.eval("#st.moons") == n0 + 2, "two moons bought")
game.expect(game.eval("#g.toasts.log") >= 1, "the toast is in the announcements log")
game.sleep(0.8)
game.shot("announcements", settle=0.3)
st = call(game, "GET", "/api/state?catalog=0")["state"]
extra = st["credits"] - st["moonPrice"] + 5
if extra > 0:
admin(game, "credits", amount=round(extra, 2), op="remove")
game.navigate("{section='moons'}")
game.idle()
els = [e["id"] for e in game.elements()]
game.expect("newmoon:buy" not in els, "short: the button is disabled")
game.shot("newmoon-short", settle=0.3)
+64
View File
@@ -0,0 +1,64 @@
"""Save codes (a new player, no fixture): the gear menu's Save code makes one and shows it;
Restore from code with another player's code (made here through the API) switches to that
player; a wrong code is an error toast and keeps the player."""
import json
import urllib.request
def api(game, path, user=None, body=None):
h = {"Content-Type": "application/json"}
if user:
h["X-MM-User"] = user
req = urllib.request.Request(game.api + path, headers=h, method="POST" if body is not None else "GET",
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req) as r:
return json.load(r)
def menu(game, item, title):
game.idle()
game.click("settings")
game.wait_for("g.dialog:is_open()")
game.sleep(0.3)
game.click(item)
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == '%s'" % title)
game.sleep(0.5)
def run(game, args):
game.idle()
me = game.eval("require('modules.api').player_id()")
menu(game, "menu:save-code", "Save code")
game.shot("save-code", settle=0.3)
game.click("dialog:ok")
game.wait_for("g.dialog:is_open() and g.dialog.opts.title == 'Your save code'")
code = game.eval("g.save_code")
game.expect(isinstance(code, str) and len(code) == 19, "a code is shown: %r" % code)
game.shot("save-code-shown", settle=0.5)
game.key("key_esc")
game.wait_for("not g.dialog.root")
# A wrong code: an error, same player.
menu(game, "menu:restore", "Restore from code")
game.type("AAAA-AAAA-AAAA-AAAA")
game.click("dialog:ok")
game.wait_for("not g.dialog.root and not g.busy")
game.expect(game.log_has(r"doesn't match"), "a wrong code says so")
game.expect(game.eval("require('modules.api').player_id()") == me, "a wrong code keeps the player")
# Another player's code switches to it.
other = api(game, "/api/state?catalog=0")["userId"]
other_code = api(game, "/api/act/save-code?catalog=0", other, {})["saveCode"]["code"]
menu(game, "menu:restore", "Restore from code")
game.type(other_code.lower())
game.shot("restore", settle=0.3)
game.click("dialog:ok")
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % other)
game.expect(game.log_has(r"Welcome back"), "restoring says welcome back")
game.shot("restored", settle=0.5)
# The old code still brings the first player back.
menu(game, "menu:restore", "Restore from code")
game.type(code)
game.click("dialog:ok")
game.wait_for("require('modules.api').player_id() == '%s' and not g.busy" % me)
+118
View File
@@ -0,0 +1,118 @@
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
panel's Sell (an idle copy) turns into Cancel on its spot with Confirm just left of it and
the server's question where the text was; no dialog opens; nothing else moves. It cancels
on Esc, on Cancel, on a click elsewhere and after ASK_IDLE seconds; Enter confirms (Confirm
has the soft focus) and sells the drill. Then the moon's Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
def rect_of(game, id_):
for e in game.elements():
if e["id"] == id_:
return e["rect"]
return None
def ask(game, id_):
"""Click until it asks (a rebuild can drop a press)."""
game.scroll_to(id_)
game.idle()
for i in range(3):
game.click(id_)
try:
game.wait_for("g.ask ~= nil", 5)
break
except Exception:
if i == 2:
raise
game.idle()
game.idle()
def edges(r):
"""An element's rect (the test hook gives its center x, y) as left, top, right, bottom."""
return r["x"] - r["w"] / 2, r["y"] - r["h"] / 2, r["x"] + r["w"] / 2, r["y"] + r["h"] / 2
def same_spot(a, b):
return a and b and all(abs(p - q) <= 1 for p, q in zip(edges(a), edges(b)))
def covers(outer, inner):
"""outer takes inner's spot: same row, same right edge, at least as wide."""
if not (outer and inner):
return False
o, i = edges(outer), edges(inner)
return abs(o[1] - i[1]) <= 1 and abs(o[3] - i[3]) <= 1 and abs(o[2] - i[2]) <= 1 and o[0] <= i[0] + 1
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
sell = "confirm:sell-tool"
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
game.scroll_to(sell)
before = rect_of(game, sell)
game.shot(pre + "sellask-before", settle=0.3)
ask(game, sell)
game.expect(not game.eval("g.dialog:is_open()"), "no dialog opens")
cancel, ok = rect_of(game, sell + ":cancel"), rect_of(game, sell + ":ok")
game.expect(covers(cancel, before), "Cancel takes Sell's spot (%s vs %s)" % (before, cancel))
game.expect(ok is not None and edges(ok)[2] <= edges(cancel)[0] and abs(ok["y"] - cancel["y"]) <= 1,
"Confirm sits just left of Cancel")
game.lint(pre + "sellask-asking")
game.shot(pre + "sellask-asking", settle=0.3)
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
game.idle()
game.expect(same_spot(before, rect_of(game, sell)), "Esc: Sell is back on its spot")
ask(game, sell)
game.click(sell + ":cancel")
game.wait_for("g.ask == nil", 5)
game.idle()
ask(game, sell)
game.click("settings") # (a click elsewhere: the gear's menu)
game.wait_for("g.ask == nil", 5)
game.key("key_esc")
game.wait_for("not g.dialog:is_open()", 5)
game.idle()
ask(game, sell)
game.wait_for("g.ask == nil", 12) # (idle timeout)
game.idle()
# Enter confirms: Confirm has the (soft) focus after the mouse click.
ask(game, sell)
game.expect(game.eval("g.focus and g.focus.key") == "page:" + sell + ":ok", "Confirm has the focus")
game.key("key_enter")
game.wait_for("g.ask == nil and not g.busy", 10)
game.idle()
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
game.shot(pre + "sellask-sold", settle=0.4)
# The moon's Sell moon (when it can be sold yet).
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
game.idle()
can = game.eval("g.state.moon and g.state.moon.sale and g.state.moon.sale.canSell")
if not can and game.eval("g.state.moon and g.state.moon.sale ~= nil"):
# (a fresh moon is on hold: enable the button locally for the layout only; the
# question is the server's; this run never confirms it)
game.eval("(function() g.state.moon.sale.canSell = true; g.dirty = true; return true end)()")
game.wait_for("not g.dirty", 5)
can = True
if can and rect_of(game, "sell-moon"):
game.scroll_to("sell-moon") # (at the foot of the aside's well panel, which scrolls)
game.idle()
before = rect_of(game, "sell-moon")
ask(game, "sell-moon")
game.expect(covers(rect_of(game, "sell-moon:cancel"), before), "Cancel takes Sell moon's spot")
game.lint(pre + "sellask-moon")
game.shot(pre + "sellask-moon", settle=0.3)
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
else:
print(" (moon not sellable yet: no Sell moon shot)")
+25
View File
@@ -0,0 +1,25 @@
"""The settings menu's rows take real mouse clicks (xdotool), also where the menu lies over
the moon viewport (its drag used to take the press)."""
def run(game, args):
game.idle()
checks = [
("mute", "tostring(require('modules.sfx').muted())"),
("theme", "tostring(require('modules.theme').light)"),
("motion", "tostring(require('modules.anim').reduced)"),
("dialog:fullscreen", "tostring(mmwindow.is_fullscreen())"),
("menu:save-code", "tostring(g.dialog:is_open())"),
]
for id_, expr in checks:
game.os_click("settings")
game.wait_for("g.dialog:is_open()", timeout=5)
game.sleep(0.8)
before = game.eval(expr)
game.os_click(id_)
game.sleep(1.2)
after = game.eval(expr)
print(" %-18s %s -> %s dialog open: %s" % (id_, before, after, game.eval("tostring(g.dialog:is_open())")))
game.shot("after-" + id_.replace(":", "_"), settle=0.2)
game.key("key_esc")
game.sleep(0.6)
+7 -2
View File
@@ -1,6 +1,6 @@
"""Narrow windows (run with --size 1024x640; needs --fixture): the aside gets its own row
under the moon list and the main panel and the page scrolls to it (a well, the drill
panel); sections without the aside use the whole width; the banner wraps; keys scroll
under the moon list and the main panel and the page scrolls to it (the drill panel; a
well's card stays in view); sections without the aside use the whole width; the banner wraps; keys scroll
the page. Args: an optional name prefix."""
@@ -18,6 +18,11 @@ def run(game, args):
game.click("well:" + well)
game.wait_for("view.well == '%s' and not busy" % well)
game.idle()
# (picking a well card keeps the cards in view: its actions are there)
game.expect((game.eval("g.scroll_pos.page or 0") or 0) < 1, "picking a well doesn't scroll the page")
game.shot(pre + "small-well-card", settle=0.8)
game.hover("tab:market")
game.key("key_end")
game.wait_for("(g.scroll_pos.page or 0) > 100", timeout=5)
game.shot(pre + "small-well", settle=1.0)
# (keys scroll the area under the mouse: the moon itself scrolls when it doesn't fit,
+26 -5
View File
@@ -1,6 +1,7 @@
"""Server texts in the confirm dialogs (needs --fixture): sell drill, buy seat, buy
refinery slot and start a new game open the server's confirm (each cancelled with Esc,
nothing bought); the theme toggle switches live. Shots of each."""
"""Server texts in the confirms (needs --fixture): selling an idle drill asks in place
(cards.ask_button), buy seat, buy refinery slot and start a new game open the server's
confirm dialog (each cancelled with Esc, nothing bought); the theme toggle switches live.
Shots of each."""
def confirm(game, el, shot):
@@ -21,13 +22,31 @@ def confirm(game, el, shot):
game.wait_for("not g.dialog.root")
def ask(game, el, shot):
game.scroll_to(el)
game.idle()
for i in range(3):
game.click(el)
try:
game.wait_for("g.ask ~= nil", 6)
break
except Exception:
if i == 2:
raise
game.idle()
game.idle()
game.shot(shot, settle=0.4)
game.key("key_esc")
game.wait_for("g.ask == nil")
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
confirm(game, "confirm:sell-attachment", pre + "texts-sell-attachment")
ask(game, "confirm:sell-tool", pre + "texts-sell-tool")
game.navigate("{section='advisors'}")
game.idle()
confirm(game, "confirm:buy-seat", pre + "texts-buy-seat")
@@ -38,9 +57,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)
+34
View File
@@ -0,0 +1,34 @@
"""Drill upgrade tracks (needs --fixture): the Equipment table's two track columns (yield and
cycle: value ▸ next, level pips, Upgrade or Max, the kept need line) and the
drill panel of the fixture's upgraded idle PD-1 (both boxes, both ladders, Sell drill).
Clicking each Upgrade twice in the panel keeps the buttons where they are (click_check).
Shots: tracks-equipment, tracks-panel, tracks-panel-after. Args: an optional name prefix."""
def ups(game):
return sorted(e["id"] for e in game.elements() if e["id"].startswith("act:upgrade?"))
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
game.idle()
game.navigate("{section='equipment'}")
game.idle()
game.shot(pre + "tracks-equipment", settle=1.0)
game.lint(pre + "tracks-equipment")
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
game.shot(pre + "tracks-panel", settle=1.0)
game.lint(pre + "tracks-panel")
tool = game.eval("g.state.equipment.drill.toolId")
for kind in ("yield", "cycle"):
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
game.expect(ids, "%s upgrade button in the panel: %s" % (kind, ups(game)))
for n in range(2):
ids = [i for i in ups(game) if ("tool=" + tool) in i and i.endswith("track=" + kind)]
if not ids:
break # (the top level: Max level in its place)
game.click_check(ids[-1], name=pre + "tracks-click-%s-%d" % (kind, n))
game.shot(pre + "tracks-panel-after", settle=1.0)
+129
View File
@@ -0,0 +1,129 @@
"""Well cards (needs --fixture and an MM_DEV=1 server: admin finish-stakes): every action
on a well is on its card in the main panel, the aside only shows. Buys a fresh moon, then:
the cards with their empty "+ Deploy" slots and the tray of idle drills (shot cards-empty);
a tray tile deploys straight to the selected well (cards-picked); the card's deploy slot
keeps its spot (click_check); a multi-well drill (the wide card); finishes the drills: the
ready slots, gold with Claim and Redeploy (cards-ready); Claim on a slot (click_check), the
idle slot with Redrill (cards-idle), Redrill; selling the moon is in the aside. The
aside, with a well and with none selected, has no buttons but Sell moon, the one destructive
action kept there (aside-well, aside-moon).
Args: an optional name prefix."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from lists import admin, call # noqa: E402
def aside_buttons(game):
"""Registered elements drawn inside the aside panel (below the header)."""
box = game.eval("(function() local p = g.aside_panel and g.aside_panel.root if not p then return nil end "
"local s = gui.get_screen_position(p) return {s.x, s.y} end)()")
if not box:
return []
left, top = box[0], box[1]
out = []
for e in game.elements():
r = e.get("rect")
if not r or not e.get("visible") or e.get("kind") == "wait":
continue
if r["x"] >= left - 1 and r["y"] <= top + 1 and not e["id"].startswith(("sell-moon", "moon-sale:")):
out.append(e["id"])
return out
def ids(game, prefix):
return [e["id"] for e in game.elements() if e["id"].startswith(prefix) and e.get("visible", True)]
def run(game, args):
pre = (args[0] + "-") if args else ""
game.idle()
admin(game, "credits", amount=100000, op="grant")
had = {m["id"] for m in call(game, "GET", "/api/state?catalog=0")["state"]["moons"]}
r = call(game, "POST", "/api/act/buy-moon", {})
game.expect(r.get("ok"), "buy a moon: %s" % r.get("error"))
st = call(game, "GET", "/api/state?catalog=0")["state"]
moon = next(m for m in st["moons"] if m["id"] not in had)
wells = [w["id"] for w in moon["wells"]]
game.navigate("{section='moons', moon='%s'}" % moon["id"])
game.idle()
game.wait_for("st.moon and st.moon.id == '%s'" % moon["id"], 10)
game.idle()
game.shot(pre + "cards-empty", settle=0.8)
game.lint(pre + "cards-empty")
# A tray tile deploys straight to the selected well (no picking): select the first well,
# click the lowest "stake" button there (the tray sits under the cards).
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
game.idle()
slot = "act:stake?target=" + wells[0]
btns = [e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)]
game.expect(len(btns) > 1, "the tray's tiles deploy to the selected well")
n0 = game.eval("#st.stakes")
tile = max(btns, key=lambda e: e["rect"]["y"])["id"]
game.scroll_to(tile)
game.click(tile)
game.wait_for("#st.stakes > %d" % n0, 10)
game.idle()
game.shot(pre + "cards-picked", settle=0.5)
# The card's deploy slot (topmost) keeps its spot under repeated clicks.
top = min((e for e in game.elements() if e["id"].startswith(slot) and e.get("visible", True)),
key=lambda e: e["rect"]["y"], default=None)
if top:
game.scroll_to(top["id"])
game.click_check(top["id"], name=pre + "click-deploy")
game.idle()
# A multi-well drill (bought when none is idle).
st = call(game, "GET", "/api/state")["state"]
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
if not wide:
item = next((t for t in st["catalog"]["tools"] if (t.get("reach") or 1) > 1), None)
if item:
call(game, "POST", "/api/act/buy-tool", {"id": str(item["storeItemId"])})
st = call(game, "GET", "/api/state?catalog=0")["state"]
wide = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) > 1]
if wide:
r = call(game, "POST", "/api/act/stake", {"tool": wide[0]["id"], "target": wells[-1]})
game.expect(r.get("ok"), "stake a multi-well drill: %s" % r.get("error"))
# One more one-well drill on another well, so a card has two drill slots.
one = [t for t in st["tools"] if not t.get("stakeId") and (t.get("reach") or 1) == 1]
if one and len(wells) > 1:
call(game, "POST", "/api/act/stake", {"tool": one[0]["id"], "target": wells[1]})
game.navigate("{section='moons', moon='%s', well='%s'}" % (moon["id"], wells[0]))
game.idle()
game.shot(pre + "cards-mining", settle=0.8)
game.lint(pre + "cards-mining")
left = aside_buttons(game)
game.expect(not left, "the aside (a well) has no buttons: %s" % left)
game.shot(pre + "aside-well", settle=0.3)
# Finish them: ready slots.
admin(game, "finish-stakes")
game.navigate("{section='moons', moon='%s'}" % moon["id"])
game.idle()
game.wait_for("(function() for _, w in ipairs(st.moon.wells) do if w.ready > 0 then return true end end end)()", 15)
game.idle()
game.shot(pre + "cards-ready", settle=0.8)
game.lint(pre + "cards-ready")
claims = [i for i in ids(game, "act:claim?claim-well=" + wells[0]) if "redeploy" not in i]
game.expect(len(claims) > 0, "a ready slot has Claim")
game.click_check(claims[0], name=pre + "click-claim")
game.wait_for("not g.busy and #g.pending == 0", 20)
game.idle()
game.shot(pre + "cards-idle", settle=0.8)
redrill = ids(game, "act:redrill?redrill-well=" + wells[0])
left_here = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.supply end end end)()" % wells[0])
remain = game.eval("(function() for _, w in ipairs(st.moon.wells) do if w.id == '%s' then return w.remain end end end)()" % wells[0])
# (none while the drills on it take all it has: the server would refuse it)
game.expect(len(redrill) > 0 or not left_here or not remain, "the idle slot has Redrill (the well has %s left, %s free)" % (left_here, remain))
if redrill:
game.click(redrill[0])
game.wait_for("not g.busy and #g.pending == 0", 20)
game.idle()
game.expect(ids(game, "moon-sale:" + moon["id"]) or ids(game, "sell-moon"), "selling the moon is in the aside")
game.shot(pre + "cards-redrilled", settle=0.8)
left = aside_buttons(game)
game.expect(not left, "the aside (the moon) has no buttons: %s" % left)
game.shot(pre + "aside-moon", settle=0.3)
+2 -2
View File
@@ -1,5 +1,5 @@
"""The well panel scrolled down (needs --fixture): deploy cards with pictures, pips and
reach, drill lines with thumbnails and attachment tags, the drills heading's share note;
"""The well panel scrolled down (needs --fixture; it only shows, deploying is on the well
cards): drill lines with thumbnails and level tags, the drills heading's share note;
then the market (movers, quote values, Refined tag) and a past moon in the list."""
+4
View File
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
fi
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1}
# The game's saved files (player ids, settings) go beside the screenshot, never into the
# player's own (~/.local/share/moonminers): mm.new_player=1 would clear their saved id.
export XDG_DATA_HOME=${MM_SHOT_DATA:-$(dirname "$out")/data}
mkdir -p "$XDG_DATA_HOME"
cd "$(dirname "$bin")"
log=$(dirname "$out")/shot.log
TIMEOUT=${TIMEOUT:-120}
+205
View File
@@ -0,0 +1,205 @@
#!/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 visible(n):
"""The part of n inside its stencils (dump's vis, for a partly clipped node)."""
v = n.get("vis")
return dict(n, x=v[0], y=v[1], w=v[2], h=v[3]) if v else n
def inter(a, b):
a, b = glyphs(visible(a)), glyphs(visible(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()
+2 -2
View File
@@ -5,10 +5,10 @@
# any build (no test hook needed), so an old bundle can be compared with a new one:
# scripts/views.sh dist/baseline/MoonMiners/MoonMiners.x86_64 dist/compare/before fx.json
# scripts/views.sh dist/x86_64-linux/MoonMiners/MoonMiners.x86_64 dist/compare/after fx.json
# API base: MM_API (default http://localhost:3000).
# API base: MM_API (required: a test server, no default).
set -euo pipefail
bin=$1; out=$2; fx=$3; size=${4:-1280x800}
api=${MM_API:-http://localhost:3000}
api=${MM_API:?views.sh: set MM_API to a test server (no default)}
get() { python3 -c "import json,sys;print(json.load(open('$fx'))['$1'])"; }
player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
settle=4; [ "$size" != 1280x800 ] && settle=15
+1 -1
View File
@@ -122,7 +122,7 @@ await shot("new-moon");
// The display dialog: its fullscreen button goes through the browser's Fullscreen API.
await cmd("navigate", `{section='moons', moon='${fx.moon}'}`);
await idle();
await click("display");
await click("settings");
await shot("display");
await click("dialog:fullscreen");
await page.waitForTimeout(1000);
+86
View File
@@ -21,6 +21,84 @@ const SYNTH = {
const RATE = ZZFX.sampleRate;
// Client-only sounds, synthesized here (not in the web's table): the new-moon reveal's
// build-up and its burst (screens/game/reveal.lua). Deterministic (a seeded noise).
function noiseGen(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return (s / 4294967296) * 2 - 1;
};
}
function lowpass(cut) {
const a = 1 - Math.exp((-2 * Math.PI * cut) / RATE);
let y = 0;
return (x) => (y += a * (x - y));
}
// rumble: a low, deep build (~1.3 s): a sub tone rising 36 -> 72 Hz with its octave and a
// soft-clipped edge (so small speakers hear it), filtered noise like a far quake, a
// tremolo that speeds up, all swelling to the burst at 1.1 s, then a short fade.
function rumble() {
const len = 1.35, peak = 1.1;
const n = Math.floor(len * RATE);
const out = new Float64Array(n);
const rnd = noiseGen(7);
const lp = lowpass(140), lp2 = lowpass(140);
let ph = 0, trem = 0;
for (let i = 0; i < n; i++) {
const t = i / RATE;
const k = Math.min(1, t / peak);
const f = 36 + 36 * k * k;
ph += (2 * Math.PI * f) / RATE;
trem += (2 * Math.PI * (5 + 17 * k * k)) / RATE;
const tone = Math.sin(ph) + 0.45 * k * Math.sin(2 * ph) + 0.2 * k * Math.sin(3 * ph + 0.3);
const quake = lp2(lp(rnd())) * 9;
let x = Math.tanh((tone * 0.8 + quake * (0.4 + 0.6 * k)) * (1.2 + 1.6 * k));
x *= 1 - 0.35 * k * (0.5 + 0.5 * Math.sin(trem));
const env = Math.min(1, t / 0.08) * (0.25 + 0.75 * k * k) * (t > peak ? Math.max(0, 1 - (t - peak) / (len - peak)) : 1);
out[i] = x * env;
}
return out;
}
// reveal: the burst: a sub drop (150 -> 34 Hz) that hits hard, a noise crack opening into
// a long low roar, and a bright chord swelling out of it (a bell-like shimmer) for the
// "there it is" moment (~2.6 s).
function reveal() {
const len = 2.6;
const n = Math.floor(len * RATE);
const out = new Float64Array(n);
const rnd = noiseGen(11);
const crackLp = lowpass(2400), roarLp = lowpass(180), roarLp2 = lowpass(180);
const chord = [130.81, 196.0, 261.63, 329.63, 392.0, 523.25]; // C major, spread
const phs = chord.map(() => 0);
let ph = 0;
for (let i = 0; i < n; i++) {
const t = i / RATE;
const f = 34 + 116 * Math.exp(-t / 0.07);
ph += (2 * Math.PI * f) / RATE;
const sub = Math.sin(ph) * Math.exp(-t / 0.55) * Math.min(1, t / 0.003);
const w = rnd();
const crack = crackLp(w) * Math.exp(-t / 0.06) * 1.6;
const roar = roarLp2(roarLp(w)) * 7 * Math.exp(-t / 0.9) * Math.min(1, t / 0.02);
let bell = 0;
for (let j = 0; j < chord.length; j++) {
const fj = chord[j] * (1 + 0.002 * Math.sin(2 * Math.PI * (0.7 + j * 0.3) * t));
phs[j] += (2 * Math.PI * fj) / RATE;
bell += Math.sin(phs[j]) * (j < 2 ? 0.6 : 0.4);
}
const swell = t < 0.06 ? 0 : Math.min(1, (t - 0.06) / 0.25) * Math.exp(-(t - 0.06) / 1.1);
const x = Math.tanh((sub * 1.3 + crack + roar) * 1.4) + bell * 0.16 * swell;
const tail = t > len - 0.3 ? (len - t) / 0.3 : 1;
out[i] = x * tail;
}
return out;
}
// name: [peak, render]: normalized to that peak.
const CUSTOM = { rumble: [0.27, rumble], reveal: [0.55, reveal] }; // (RMS about -20 and -15 dB)
function wav(samples) {
const n = samples.length;
const buf = Buffer.alloc(44 + n * 2);
@@ -44,6 +122,14 @@ function wav(samples) {
return buf;
}
for (const [name, [peak, render]] of Object.entries(CUSTOM)) {
const raw = render();
const max = raw.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
const samples = Array.from(raw, (v) => (v / max) * peak);
writeFileSync(new URL(`../assets/sfx/${name}.wav`, import.meta.url), wav(samples));
console.log(`${name}.wav: ${samples.length} samples (${(samples.length / RATE).toFixed(2)} s), peak ${peak}`);
}
for (const [name, [gain, params]] of Object.entries(SYNTH)) {
const samples = ZZFX.buildSamples(...params).map((v) => v * gain);
const peak = samples.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
+18 -14
View File
@@ -46,15 +46,25 @@ function M:init(moon, on_select, opts)
-- The name may hold a Greek letter (sym font): drawn as a label at the name's place.
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)
ui.label(self.content, moon.name, "moon", p.x, -p.y)
-- The second line: grade and rating chips, then the status ("1 drill"); the name has
-- the first line to itself and the ready badge the right side.
local sp = gui.get_position(sub)
local cx = sp.x
if (moon.grade or "") ~= "" then
local gb = chip.grade(self.content, moon.grade, cx, -sp.y)
tooltip.add(gb, "Grade " .. moon.grade .. " moon")
cx = cx + chip.GRADE_W + 6
end
if moon.rating then
-- The web's .rating-chip: outlined, in the tier's color; exceptional filled, glowing.
local tier = moon.rating.tier
local color = TIER[tier] or "line_hi"
local box = chip.new(self.content, "★" .. moon.rating.stars, p.x + name_w + 8, -p.y, {
local box, bw = chip.new(self.content, "★" .. moon.rating.stars, cx, -sp.y, {
color = color, alpha = tier == "good" and 0.5 or nil, text = TIER[tier] or "muted",
font = "sym", px = 10, h = 16, pad = 4, fill = tier == "exceptional" and 0.15 or nil,
})
cx = cx + bw + 6
self.chip = { box }
if tier == "exceptional" then
local bs, bp = gui.get_size(box), gui.get_position(box)
@@ -90,14 +100,17 @@ function M:init(moon, on_select, opts)
status = moon.drills > 0 and ui.plural(moon.drills, "drill") or (dry and "Tapped out" or "Idle")
end
ui.style_text(sub, "small", status)
if cx > sp.x then
cx = cx + 2 -- (a little more room before the text)
end
if moon.over and not moon.finished then
-- A well here has more drills than supply (the web's warn dot).
local sp = gui.get_position(sub)
local dot = ui.rect(self.content, sp.x, -sp.y - 4, 7, 7, "ready")
local dot = ui.rect(self.content, cx, -sp.y - 4, 7, 7, "ready")
gui.play_flipbook(dot, "sphere")
tooltip.add(dot, "A well here has more drills than supply")
gui.set_position(sub, vmath.vector3(sp.x + 12, sp.y, sp.z))
cx = cx + 12
end
gui.set_position(sub, vmath.vector3(cx, sp.y, sp.z))
-- (opts.ready: the count on our clock, core/ready)
local n_ready = opts.ready or moon.ready or 0
@@ -145,15 +158,6 @@ function M:set_size(w)
end
local badge = self:get_node("badge")
gui.set_position(badge, vmath.vector3(w - 10, -h / 2, 0))
if self.chip then
-- A narrow row: the rating chip goes rather than run into the ready badge.
local box = self.chip[1]
local right = gui.get_position(box).x + gui.get_size(box).x
local limit = w - 16 - (gui.is_enabled(badge) and gui.get_size(badge).x or 0)
for _, n in ipairs(self.chip) do
gui.set_enabled(n, right <= limit)
end
end
end
-- The veil's alpha at rest (dimmed, or lifted by hover / selection).
+1
View File
@@ -13,6 +13,7 @@ services:
restart: unless-stopped
environment:
<<: *db-env
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose:
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
Executable
+195
View File
@@ -0,0 +1,195 @@
#!/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 -"
# mm_<task> must fit Postgres's 63-byte names (longer ones are cut short silently).
((${#TASK} <= 60)) || die "task name: at most 60 characters"
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.
# On 127.0.0.1 only: MM_DEV=1 has no admin login.
(cd "$WT/server" && DB_DATABASE=$DB HOST=127.0.0.1 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"
# The copied .env names this worktree's own database, so anything run here (migrate,
# tests, the server) reaches mm_<task>, never the main checkout's.
[[ -e $WT/.env ]] || cp "$MAIN/.env" "$WT/.env"
if grep -q '^DB_DATABASE=' "$WT/.env"; then
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB/" "$WT/.env"
else
echo "DB_DATABASE=$DB" >>"$WT/.env"
fi
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://127.0.0.1:$(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://127.0.0.1:$(cat "$ST/port")" ;;
status) status ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac
-2
View File
@@ -5,6 +5,4 @@ node_modules
.env*
.vscode
.DS_Store
web/static/css/app.css
art
web/static/app
-3
View File
@@ -1,8 +1,6 @@
# Build output
/bin/
/moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit)
.env
@@ -16,4 +14,3 @@ sql/queries.sql
# Generated by the art scripts
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+124 -137
View File
@@ -1,67 +1,74 @@
# AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
every command below from this directory (`server/`).
## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
committed). See "web/app" below.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
light themes). No bundler, no TypeScript.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout
- 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
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
`loadView` (state, the past page, the resolved view). `loadState` leaves moon
descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
`loadConfig` (resources, store items) is cached per config snapshot.
- `views.go`: the `View` (section, moon, well, tab; the API's view params `s=&moon=&well=`),
the `Page` view model, formatting helpers, the admin templates' functions.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
action).
- `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
`admin_test.go`: the gate.
- `api.go`, `api.md`: the JSON API: `GET /api/state` (the view models mapped to JSON DTOs;
times in unix ms) and `POST /api/act/<action>` (`runAction`, gates included;
`{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
`include=`; reasons are codes with the player-facing text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `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
for every test view (the test states are in `templates_test.go`).
- `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder
description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
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.
Track levels set a tool's yield and cycle (read from cfg(), not stored; `StoreItem.ToolYield`, `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`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim; `progression.go`: the Analysis tab's charts, saved config against the draft: payback per purchase, the clicker sim timeline (on demand, `POST /utils/economy/sim`), curves; drawn as SVG by `chart.go`). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `savecode.go`: save codes (a player's way back from another device; `GET /api/save-code`,
actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
`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
@@ -70,119 +77,100 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
recent sales of the item, in base-price credits, decaying with a half-life, table
`market_pressure`; the factor eases from 1 towards `floor`, and a sale is priced at its
average over the pressure it adds). Personal modifiers never change the quote, cards,
ticker or 24h history (recomputed, no table); the sell ticket shows them as lines ("+8%
ticker or 24h history (recomputed, no table; each item's 24h series is cached for the
current tick and config snapshot, `marketSeriesAt`, shared by every request; live events
are applied on top per request); the sell ticket shows them as lines ("+8%
for you", "−4% from your recent selling"). View model for the Market section (ticker,
watchlist, chart, sell ticket; `pick=` in the URL selects a quote).
`web/static/js/market.js`: chart crosshair and ticket estimate.
watchlist, chart, sell ticket; `pick=` selects a quote).
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
drill on it is running; finished drills are claimed in the sale.
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price +
its track levels; refined parts not refunded): only idle tools with nothing to claim,
never the last tool.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on track levels), rules
(start batch, collect, buy slot, batch size upgrade, sell refined, upgrade parts) and its
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per
batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
(action `upgrade-batch`, `users.refinery_batch_level`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`.
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (cycles to a tenth of a second, at least 1 s) (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`,
`tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
flat, with a price and optional refined `parts` (none in game.json now: credits only). Levels are per tool
(`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next
stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
section, in the aside; the toast text).
- `advisors.go`: advisors config (`advisors`), hire, buy seat, seat/unseat/swap/dismiss
rules (only seated advisors give a bonus; dismissing refunds nothing), Advisors section
and the advisor panel (`adv=` in the view, in the aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe 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
sections and actions need a gate there.
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
`ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
Banned players (`users.banned_at`) get a 403 from the API.
`web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
(`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
- `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
`enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
- `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
- `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
`ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
`go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
any more) and the wells.
- `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `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.
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.
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):
via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
use; the tools show their original images.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
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);
- `art/`: sources of the store and advisor images (Blender scripts, prompts);
they write into `web/static/assets`.
## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
game request that long, to try both clients on a slow connection.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
moon traits check, the Preact client's `.value` check and build), `make migrate`,
`make load-config`. `go run . sim` prints the economy analysis.
- `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
long, to try the client on a slow connection.
- `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
every `web/static/js/*.js`), `make migrate`, `make load-config`. `go run . sim` prints the
economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
templates like the server and renders every page and region against in-memory game states;
when you add a page, section or view model branch, make sure its states/views reach it.
admin templates like the server and renders every page against in-memory data; when you
add a page or a branch, make sure its data reaches it.
- Local DB: `podman-compose up -d db` (from the repo root) then `make migrate` (see README).
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).
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
Other tests: `go test ./...` (game rules on in-memory states, no database);
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
resale odds, sim seeds). `claim_db_test.go` needs a database of its own
(`MM_TEST_DATABASE=mm_<task>`, from `scripts/wt`; skipped without it): `TestClaimGolden`
compares claim-all's results with `testdata/claim_golden.txt` (`MM_CLAIM_GOLDEN_WRITE=1`
rewrites it, only for a deliberate rule change), `TestClaimAllPerf -v` times it.
## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
@@ -198,23 +186,22 @@ distribution, value by well count, resale odds, sim seeds).
Postgres credentials come from `.env` (in `server/`, else the repo root's `../.env`; only
the first one found is read): `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
`ADMIN_USER_IDS` (comma-separated player ids): admins in the game API (`state.admin`,
`admin-grant`, saving moon settings). With `MM_DEV=1` there is no admin login and every
player is an admin.
`PORT` (default 3000) and `HOST` (unset: every interface; `scripts/wt` sets `127.0.0.1`).
## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- New section: add it to `sections` and `State.page` (views.go) and map it in the API
(`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
`api.md`. The client sends the current view (s, moon, well, drill) with every action, so
action parameters must use other names.
- The server is the authority; clients predict only what `api.md` "Prediction" allows.
- New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
+159
View File
@@ -0,0 +1,159 @@
# Wider steps (2026-10-22)
Playing the progression pass, the author had a LunarLite L100 (and money for more) about
33 minutes in, first C moon at 19 minutes: too fast. Their run's money: mostly raw sales
from five C moons, plus one Plasma Weave sale (₥32k, of which the refining margin was only
about ₥9k). The fix is a steeper grade ladder: each grade's price and wells, and its
drill's yield, price and upgrade prices, scaled by the same factor (C ×2, B ×3, A ×5,
S ×8), which keeps a moon's value (1.3–1.8× its price) and a drill's 4–5 claims per well on
its grade, while the jump to the next grade grows faster than income. D and the PD-1 are
unchanged. Refined items now gain about 20% over their inputs (was 40–100%).
Grades: D 300, C 12k, B 90k, A 1.5M, S 24M. Drills: PD-1 1,200 (8), PX-4 8,000 (120), L100
40,200 (900), L300 645k (20,000), LRE 9.44M (384k), EDE 17M (880k), LCE 24.8M (1.6M).
Sim, first moon of each grade: copies C 12m, B 30m, A 58m, S 2h33; boosts C 10m, B 21m,
A 40m, S 1h49 (was C 7m, B 15m, A 32m, S 1h04). A real player ran about 2.5× slower than
the sim's (C at 19m against the sim's 7m), so expect roughly C 30m, B 1h15 in play.
# Progression: each new drill rips through the current grade (2026-10-22)
The loop: on a grade, its drill needs about 3–6 claims per well; the next drill (bought
before the next grade, cheaper than that grade's moons) empties the current grade's wells
in about one claim; on the next grade it is back to 3–6. Each drill's base yield is about
its grade's average well ÷ 4.5 (multi-well drills × their reach): PD-1 8 (D), PX-4 60 (C),
L100 300 (B), L300 4,000 (A), LRE 48,000 (S); EDE 110,000 and LCE 200,000 rip S further
(more drills than grades until planets and stars). Sim, claims per average well on its own
grade: PD-1 4.3, PX-4 5.4, L100 4.5, L300 4.5, LRE 4.6, EDE 3.3, LCE 1.8.
Prices: each drill earns a bit more per credit than the one before on its grade (sim "a
drill alone on its grade", ₥/min per 1k₥: 981, 1.6k, 2k, 2.4k, 2.6k, 2.9k, 3.1k), else the
sim (and a player) just buys more of the cheapest drill: PD-1 1,200, PX-4 4,000, L100
13,400, L300 129,000, LRE 1.18M, EDE 2.12M, LCE 3.1M. Each upgrade level costs 0.95 × what
a copy would cost for the same added output (so upgrades are slightly better per credit).
Sim, first moon of each grade: copies C 7m, B 15m, A 32m, S 1h04; boosts C 6m, B 14m,
A 31m, S 49m (was about C 13m, B 22m, A 1h10, S 3h). Faster overall; the lever for a longer
game is moon and drill prices with wells scaled alike.
# Drill upgrade tracks (2026-10-22)
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own
(config `storeItems[].tracks`), 4–5 levels each, every level a flat value: yield (claim per
cycle) and cycle (seconds, to a tenth). Reach never changes. Levels cost credits only (the refined parts they once took gated the
early game behind a refinery the player doesn't have yet; the code still takes `parts`).
Prices: level k of yield costs 0.97 × drill price × (Δclaim / base claim) × (base cycle /
cycle at level k−1), cycle level k costs 0.97 × price × (claim at level k−1 / base) × Δ(base
cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
the level before. So with both tracks moving together a level adds output for about a
copy's price.
| drill | yield track | cycle track |
|---|---|---|
| PD-1 | Twin rotors 4 → 5, 6, 7, 8 | Flight computer 3 → 2.6, 2.2, 1.9, 1.7, 1.5 s |
| PX-4 | Wider chisel 20 → 24 … 40 | Pneumatic hammer 6 → 5.2, 4.5, 3.9, 3.4, 3 s |
| L100 | Wide-bore bit 110 → 130 … 220 | Spindle motor 8 → 7, 6, 5.2, 4.6, 4 s |
| L300 | Wider beam 1350 → 1600 … 2700 | Laser capacitors 10 → 8.7 … 5 s |
| LRE | Fine sieves 17k → 20k … 34k | Auger gearbox 12 → 10.4 … 6 s |
| EDE | Larger plates 36k → 43k … 72k | Charge coils 14 → 12.2 … 7 s |
| LCE | Core barrel 47k → 56k … 94k | Drill string 16 → 14 … 8 s |
Per ₥ against a copy (a drill alone on its grade): the first yield level ×1.02–1.05 (₥/min),
×1.04–1.09 (₥/click); whole yield track ×0.79–0.88; first cycle level ×0.94–1.01 (₥/click
×0.28–0.67: shorter cycles cost claims); both tracks to the top ×1.12–1.19. PD-1's cycle
levels add nothing in the sim: its player claims at most every 3 s (`gsClaimGap`).
| first moon of each grade | before (tiers, attachments) | after (tracks) |
|---|---|---|
| copies | C 13m15, B 22m26, A 1h10m37, S 2h59m52 | the same |
| boosts | C 13m15, B 22m26, A 1h08m39, S 3h00m21 (bought only copies and 2 advisors) | C 13m18, B 23m39, A 1h05m50, S 3h01m59 (233 copies, 234 levels, 2 advisors) |
At 0.85 (levels ~15% better than a copy) the boosts player reached A at 54 m and S at
2h26; at 1.0 and above it bought almost no levels.
# 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.
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more