Compare commits

..
127 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
219 changed files with 15595 additions and 21559 deletions

No files matched your search

+13 -7
View File
@@ -8,20 +8,21 @@ 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`.
@@ -35,6 +36,11 @@ banned players left out).
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.
+17 -10
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,25 +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, run `make migrate` again before starting the server: it applies new
migrations and syncs the tools with `config/game.json` (the server alone doesn't).
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`.
+17 -11
View File
@@ -1,9 +1,9 @@
# AGENTS.md — Moon Miners client (Defold)
A native client for Moon Miners (rules and state live in the Go server: `../server`,
`../server/AGENTS.md`; the repo: `../AGENTS.md`). Same layout and HUD look as the web build
(header, moon list left, the section in the middle, aside with the moon viewport and the
selected well), made game-native. Target 16:10 at 1280x800 (Steam Deck); bigger windows
`../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.
@@ -42,12 +42,16 @@ map.
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
layout is top-left based (x right, y down).
- Rebuilds: a section that reads a new state key adds it to `SECTION_KEYS` (core/main.lua;
`ASIDE_KEYS`, header and moon list sigs likewise) or it won't update. What must survive
`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 app.css): no hex or
- Theme: every color is a token (`theme.color`, dark and light, from the old web client's CSS): no hex or
vector colors in code (templates via `theme.template`, config colors via `theme.item`,
glows via `theme.glow_blend()`). The moon viewport stays dark. Sizes from `theme.size`.
- Text: styles from `theme.text`; `₥`, `★` and other symbols through the `sym` font
@@ -56,7 +60,7 @@ map.
- Node budget: `max_nodes: 4096` in `game.gui`; text past the font batch limit silently
doesn't render (make check catches the warning); stencils and images split batches.
Outline textures use slice9 2.
- Motion: easing curves match the web (`modules/anim.lua`). GUI nodes don't pass alpha to
- Motion: easing curves match the old web client (`modules/anim.lua`). GUI nodes don't pass alpha to
children: fade content in from under a veil, not by fading a subtree. Every new
animation honors reduced motion (`anim.reduced`). Effects for action results go in
`modules/fx.lua`; numbers that change ease (`H.tick_num`); the wallet counts through fx.
@@ -140,7 +144,8 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
- `core/keys.lua`: keyboard (focus, Enter/Space, scrolling keys, fields, F11, Ctrl +/-/0).
- `core/ready.lua`: ready counts on our clock. `core/ticks.lua`: countdowns, bars, eased numbers.
- Regions: `banner.lua`, `header.lua`, `moonlist.lua`, `aside.lua`, `settings.lua` (gear
menu), `savecode.lua`, `grant.lua` (admin grant dialog).
menu), `savecode.lua`, `grant.lua` (admin grant dialog), `admin.lua` (admin area from the gear
menu, instead of the page; screens: `moonlab.lua`, the moon settings editor).
- Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.lua`,
`help.lua`, `moon_sale.lua`, `reveal.lua` (new-moon teaser and reveal window), `cards.lua`
@@ -149,7 +154,7 @@ an entry in `M.SECTIONS` (core/main.lua), a tab in `header.lua` `SECTIONS`.
Components (`components/`, API in each header; build new UI from these): `button`,
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List),
`scroll`, `bar`, `chip`, `stat`, `row`. `widgets/moon_row.lua`: the moon list row.
`scroll`, `bar`, `chip`, `stat`, `row`, `slider`. `widgets/moon_row.lua`: the moon list row.
Modules (`modules/`):
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua`
@@ -163,14 +168,15 @@ Modules (`modules/`):
`settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
`hot.lua` (hot reload).
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, terrain.js settings port).
- `moon/moon.lua` + `moon/terrain.lua`: the viewport's moon (API, the moon settings and
their `DEFAULTS`, the look the server's `moondesc.go` mirrors).
Elsewhere:
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
- `moon/`: moon collection, `moon.script`, materials (port of scene.js).
`render/mm.render_script`: HDR moon pass with bloom.
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`).
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
game's only one (it began as a C port of the web client's terrain.js, now deleted).
- `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
- `web/index.html`: the web build's page.
+28 -13
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
@@ -18,25 +30,26 @@ $(BOB):
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,7 +63,7 @@ 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
@@ -63,7 +76,7 @@ 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)
@@ -71,7 +84,7 @@ drive: $(BOB) .internal/lib/dependencies.json
# 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 \
@$(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
@@ -79,7 +92,7 @@ lint: $(BOB) .internal/lib/dependencies.json
# 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
@@ -90,11 +103,13 @@ run: build
# 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
+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)
+2 -2
View File
@@ -1,7 +1,7 @@
-- 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 })
--
+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
+25 -5
View File
@@ -160,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()
@@ -208,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away).
function M.clear()
running = {}
shaking = {}
end
---------------------------------------------------------------- fading content
@@ -343,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.
+13 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error"
elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status)
elseif data == nil then
elseif data == nil and status ~= 204 and method == "POST" then
err = "bad JSON"
elseif data == nil and method == "GET" and response.response ~= "null" then
err = "bad JSON"
end
done(err, data, status, recv)
@@ -185,6 +187,16 @@ function M.get_save_code(cb)
request("GET", "/api/save-code", nil, cb)
end
-- The moon look tuned in the moon lab: data is the saved object, or nil when none is saved
-- (GET answers null). save posts the whole settings object (admins; 204 on success).
function M.get_moon_settings(cb)
request("GET", "/api/moon-settings", nil, cb)
end
function M.save_moon_settings(settings, cb)
request("POST", "/api/moon-settings", settings, cb)
end
-- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then
+97 -4
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
@@ -216,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()
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
reg.used[key] = true
gui.set_parent(old.root, parent, false)
rezone(old)
reorder(old)
adopt(reg, old)
return old, true
end
+14 -4
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
@@ -111,7 +121,7 @@ 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
@@ -124,7 +134,7 @@ 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 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 }
@@ -139,7 +149,7 @@ function M.para(parent, str, style, lh, opts)
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)
local s = opts or {}
local s = copy(opts)
local lines = s.lines
s.lines = nil
local measure = M.text_measure(n, lh)
@@ -223,7 +233,7 @@ 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 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 }
+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
+23 -5
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, ["upgrade-batch"] = 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", ["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 inline confirm's (cards.ask_button), 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)
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
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)
+15 -5
View File
@@ -25,7 +25,7 @@
-- (render/mm.render_script) draws the scene into an HDR target and binds it as
-- `moon_tex` for the GUI node's material, which tone maps it (ACES, like three.js).
--
-- Settings: GET /api/moon-settings (the values tuned on /utils/moons), merged over the
-- Settings: GET /api/moon-settings (the values tuned in the moon lab), merged over the
-- defaults like the web does; until the request answers nothing is generated.
--
-- Debug config: mm.moon_id (show this id instead), mm.moon_spin=0 (no spin, fixed angle).
@@ -38,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 }
@@ -67,9 +74,12 @@ 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, quiet)
+81 -53
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
@@ -196,31 +202,21 @@ end
-- earlier ones left. Wells are indexes into moon.wells (the server's order).
local EPS = 1e-9
-- The wells a stake with this reach mines: its anchor first, then the richest (ties by
-- order), leaving out empty ones.
-- 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 others = {}
for i, x in ipairs(supply) do
if i ~= anchor and x > 0 then
others[#others + 1] = i
end
end
table.sort(others, function(a, b)
if supply[a] ~= supply[b] then
return supply[a] > supply[b]
end
return a < b
end)
local n = #supply
local start = (anchor and anchor >= 1 and anchor <= n) and anchor or 1
local out = {}
if anchor and supply[anchor] and supply[anchor] > 0 then
out[1] = anchor
end
for _, i in ipairs(others) do
out[#out + 1] = i
end
local n = math.min(#out, math.max(1, reach or 1))
for i = #out, n + 1, -1 do
out[i] = nil
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
@@ -366,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
@@ -414,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
@@ -424,9 +420,9 @@ 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
@@ -586,17 +582,17 @@ 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
@@ -604,14 +600,33 @@ M.register("claim", function(st, p, ctx)
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 = 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
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
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end
@@ -627,10 +642,17 @@ M.register("claim", function(st, p, ctx)
for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1]
local group = tool and w and 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, 1) or 1) > EPS then
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
@@ -661,20 +683,20 @@ 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, 1) or 0) <= EPS then
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)
@@ -705,7 +727,7 @@ M.register("redrill", function(st, p, ctx)
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
if same_copy(g, t) then
return g
end
end
@@ -717,16 +739,22 @@ M.register("redrill", function(st, p, ctx)
if left <= EPS then
break
end
local pick
-- 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
if t.yield >= left - EPS and (not pick or t.yield < tools[pick].yield) then
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, t in ipairs(tools) do
if t.yield > tools[pick].yield then
for i, a in ipairs(amounts) do
if a > amounts[pick] then
pick = i
end
end
+22 -11
View File
@@ -8,8 +8,8 @@
-- (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" /
-- "dialog:<...>", "list:active|past", equipment cards "drill:<tool id>", buttons that ask
-- first "confirm:<action>", the gear "settings" (its menu: "mute", "theme", "motion",
-- "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.
--
@@ -96,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
@@ -198,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.
@@ -343,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.
@@ -385,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)
@@ -605,7 +612,7 @@ function COMMANDS.dump(g, cmd)
steps(g, { function() end, function()
local ok, d = pcall(dump, g)
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
end })
end }, nil, cmd.id)
end
function COMMANDS.state(g)
@@ -692,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()
+3 -3
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
@@ -171,7 +171,7 @@ M.size = {
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,
-4
View File
@@ -35,17 +35,13 @@ 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", "!" },
+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
+6 -3
View File
@@ -195,7 +195,7 @@ local function start_job(self, id, key, params)
if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION
end
local job = { id = id, key = key, params = params }
local job = { id = id, key = key, params = params, t0 = now() }
job.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen)
self.job = job
@@ -252,7 +252,9 @@ end
local function job_done(self, job)
local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res)
print(string.format("moon: %s (%s) built, %d vertices", job.id, job.params.technique, job.vertices))
local ms = (now() - job.t0) * 1000
moon.last_build = { ms = ms, vertices = job.vertices, technique = job.params.technique }
print(string.format("moon: %s (%s) built, %d vertices, %.0f ms", job.id, job.params.technique, job.vertices, ms))
if job.key == self.want_key then
set_mesh(self, job.key, res, job.params)
end
@@ -450,6 +452,7 @@ function update(self, dt)
if #self.orphans > 0 then
step_orphans(self)
end
moon.building = self.job ~= nil or #self.orphans > 0
if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false
return
@@ -602,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
+188 -287
View File
@@ -1,8 +1,10 @@
-- 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")
@@ -10,19 +12,18 @@ 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
@@ -94,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)
@@ -145,115 +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), in a grid (app.css .deploy-cards:
-- 170 px columns, 8 apart). A card is the tool's picture on the dark media strip (3:2,
-- count and attachment pips over it), one line of name and tier, one of what a cycle gets
-- and Deploy, and a line kept for the short note: every card is the same height, so a
-- deploy never moves the cards after it (only a used-up group's card goes; the next one
-- slides into its spot). A tool that would get less than its yield here has an amber
-- edge; one that would get nothing (the well is spoken for) is also dimmed to .6 (.85
-- under the mouse) by a veil in the panel's color (.is-short / .is-spoken). The card is a
-- button built at its width; its content is a tree solved inside it.
local DEPLOY_MIN, DEPLOY_GAP = 170, 8
local function deploy_media_h(cw)
return math.floor(cw * 2 / 3)
end
local function deploy_card_h(cw)
return deploy_media_h(cw) + 1 + 7 + 16 + 3 + 16 + 3 + 14 + 8
end
local function deploy_card(g, c, well, grp, H, cw)
local hgt = deploy_card_h(cw)
local mh = deploy_media_h(cw)
local spoken = grp.short and grp.here <= 0
local edge = grp.short and { "ready", spoken and 0.35 or 0.45 } or nil
return made({ height = hgt }, function(p, x, y, inner)
-- blocked: the drills here take all it has left; the server refuses the stake.
local b, bn = ui.button(p, "", x, y, { w = inner, small = true, edge = edge, disabled = grp.blocked })
gui.set_size(b, vec(inner, hgt))
gui.set_size(ui.part(bn, "button", "edge"), vec(inner, hgt))
local tool = find(g.state.tools, grp.toolId)
local n_att = #(grp.attachments or {})
local gets_s = "+" .. ui.money(grp.here) .. " / " .. ui.cycle(grp.cycleSecs)
local reach = (grp.reach or 1) > 1 and fg.label(b, "· " .. reach_short(g.state, grp.reach), S12, 16, { fit = true, shrink = 1 }) or nil
if reach then
local label = reach_label(g.state, grp.reach)
if label ~= "" then
reach.done = function(n) tooltip.add(n, label) end
end
end
local gets = fg.label(b, gets_s, M12, 16, { shrink = 0 })
gets.done = function(n)
tooltip.add(n, "What a cycle gets here after the drills already on it"
.. (grp.short and (" (its yield is " .. ui.money(grp.yield) .. ")") or ""))
end
local note = grp.short and fg.label(b, grp.here > 0 and ("Only " .. ui.money(grp.free) .. " left after current drills")
or "Well spoken for: nothing left", { font = "sans", px = 11, color = "ready" }, 14, { fit = true, shrink = 1 }) or space(0, 14)
local count = grp.count > 1 and ("×" .. grp.count) or nil
local count_w = count and text_w(b, count, { font = "mono", px = 12, color = "text" }) or 0
local tree = col({ width = inner, height = hgt }, {
-- The picture (a square, centered; grey when spoken for) on the media strip.
made({ height = mh, shrink = 0 }, function(q, ix, iy, iw)
ui.rect(q, ix + 1, iy + 1, iw - 2, mh - 1, "bg")
local img = ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "none")
if tool and tool.image then
images.set(img, tool.image)
if spoken then
-- Greyed (no grayscale filter here): a neutral grey over the picture.
ui.rect(q, ix + math.floor((iw - mh) / 2), iy + 1, mh - 1, mh - 1, "off", 0.6)
end
end
if n_att > 0 then
ui.rect(q, ix + 6, iy + 6, n_att * 13 + 4, 18, "panel", 0.82)
cards.pips(q, n_att, n_att, ix + 8, iy + 15)
end
if count then
ui.rect(q, ix + iw - 6 - count_w - 10, iy + 6, count_w + 10, 20, "panel", 0.82)
ui.text(q, count, { font = "mono", px = 12, color = "text" }, ix + iw - 6 - count_w - 5, iy + 16)
end
end),
made({ height = 1, shrink = 0 }, function(q, ix, iy, iw) ui.rect(q, ix + 1, iy, iw - 2, 1, "line") end),
col({ padding = { 7, 9, 8, 9 }, gap = 3, grow = 1 }, {
row({ height = 16, align = "center" }, {
fg.label(b, grp.name, { font = "sans_semi", px = 13, color = "text" }, 16, { fit = true, shrink = 1 }),
space(6), fg.label(b, "T" .. grp.tier, { font = "mono", px = 11, color = "cyan" }, 16, { shrink = 0 }),
}),
row({ height = 16, align = "center" }, {
gets, reach and space(5) or nil, reach, fg.fill(),
space(5), fg.label(b, "Deploy", { font = "sans_semi", px = 11, color = "cyan", tracking = 0.06, upper = true }, 16, { shrink = 0 }),
}),
note,
}),
})
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)
if not grp.blocked then
H.action(g, b, bn, "stake", { tool = grp.toolId, target = well.id })
end
if spoken then
local veil = ui.rect(b, 0, 0, inner, hgt, "veil", 0.4)
if not grp.blocked then
g.druid:new_hover(b, nil, function(_, on)
gui.cancel_animations(veil, "color.w")
local a = on and 0.15 or 0.4
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
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")
@@ -268,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 ""
@@ -335,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
@@ -376,8 +226,7 @@ local function well_panel(g, root, pad, top, inner, H)
-- 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). Its two lines are kept
-- when there is no note, and "After current drills" is always there: the first
-- deploy must not push the deploy cards down (buttons never move under the pointer).
-- when there is no note, so a deploy doesn't make the facts jump.
local note = nil
if (wrow.noteText or "") ~= "" then
local runs = {}
@@ -420,54 +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_ = {}
local _, cw = flex.columns(inner, DEPLOY_MIN, DEPLOY_GAP)
for _, grp in ipairs(w.idle) do
cards_[#cards_ + 1] = deploy_card(g, c, w, grp, H, cw)
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(flex.grid(inner, DEPLOY_MIN, DEPLOY_GAP, 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
@@ -499,62 +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
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))
-- Selling the moon (once the store is open), at the foot of the moon's pane.
if st.unlocked.store and moon.sale then
add(space(0, 14))
add(made({ height = 1 }, function(p, x, y, iw) ui.rect(p, x, y, iw, 1, "line") end))
add(space(0, 12))
add(moon_sale.box(g, H, c, moon, inner))
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
-- (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
@@ -590,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 }, {
@@ -598,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)
@@ -611,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.
@@ -644,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,
+3 -7
View File
@@ -109,13 +109,9 @@ local STEPS = {
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,
+32 -24
View File
@@ -13,6 +13,7 @@ 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
@@ -31,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))
@@ -48,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]
@@ -187,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)
@@ -278,11 +262,35 @@ 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-attachment, sell-moon): the first
-- 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
+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
+5 -3
View File
@@ -113,9 +113,11 @@ end
-- spinner and ignores clicks until the server answers.
-- 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 = button_key(name, params)
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
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
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
+58 -15
View File
@@ -45,6 +45,7 @@ local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks")
local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
@@ -87,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
@@ -251,7 +254,7 @@ local function build_main(g, parent, x, y, w, h)
local st = g.state
local section = g.view.section
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,
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
@@ -280,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
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
@@ -357,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")
@@ -372,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
@@ -416,8 +453,8 @@ 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, (st.credits or 0) >= (st.moonPrice or 0), hides,
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)
@@ -440,16 +477,17 @@ function M.build(g)
end
end
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 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 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) }, "|")
local want = (v.well or "") ~= "" or (v.drill or "") ~= "" or v.section == "advisors"
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
@@ -464,7 +502,7 @@ function M.build(g)
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
if not g.reveal:active() then -- (the reveal's window shows the moon)
@@ -647,6 +685,8 @@ 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())
@@ -656,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
+28 -6
View File
@@ -15,6 +15,7 @@ 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 = {}
@@ -128,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")
@@ -187,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
@@ -215,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)
@@ -242,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
@@ -290,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
@@ -404,6 +422,9 @@ function M.navigate(g, v)
end
g.nav_view = v
g.ask = nil -- (a sell asking in place: cards.ask_button)
if g.admin then
require("screens.game.admin").close(g) -- (any navigation leaves the admin area)
end
M.fetch(g, v, v)
end
@@ -411,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
@@ -440,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
+1 -1
View File
@@ -627,7 +627,7 @@ local function build_market(self, root, px, py, w, h, H, stacked, full_w)
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)
+32 -9
View File
@@ -1,5 +1,5 @@
-- Selling a moon (state.moon.sale, the web's "moon-sale" panel; here at the foot of the
-- aside's well panel, aside.lua):
-- Moons section's main panel, moons.lua):
-- the offer and how it compares with a new moon, the Sell moon button (asks first, in
-- 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
@@ -21,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 aside puts it under the selected well).
-- 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 {}
@@ -30,7 +30,12 @@ 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")
-- (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 })
@@ -60,11 +65,24 @@ 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", sell_opts, function(b)
if not sale.canSell and (sale.whyText or "") ~= "" then
@@ -161,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
+29
View File
@@ -273,6 +273,35 @@ function M.grade(g)
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
+677 -230
View File
@@ -1,8 +1,9 @@
-- The Moons section (main panel): the selected moon (id, name, class and rating stars,
-- description, Claim), its wells table (supply bars, drill timer bars, claim / countdown /
-- redrill per well, the multi-well row); a mined-out
-- moon's summary; the first-moon pitch when there is none. Also the well helpers the
-- aside's well panel shares (supply_bar, supply_max, tool_name).
-- description, Claim N drills), its wells as cards with every action on a well (deploy,
-- claim, claim and redeploy, redrill: "well cards" below), the multi-well card, the tray
-- of idle drills and selling the moon; a mined-out moon's summary; the first-moon pitch
-- when there is none. The aside only shows facts (aside.lua). Also the well helpers the
-- aside shares (supply_bar, supply_max, tool_name, short_name).
local theme = require("modules.theme")
local ui = require("modules.ui")
@@ -15,6 +16,10 @@ local scroll = require("components.scroll")
local ready = require("screens.game.core.ready")
local flex = require("modules.flex")
local fg = require("modules.flex_gui")
local images = require("modules.images")
local test = require("modules.test")
local predict = require("modules.predict")
local blocks = require("modules.blocks")
local S = theme.size
@@ -100,10 +105,10 @@ function M.track_drills(g)
end
end
-- A tool's name and tier by id.
-- A tool's name and its levels tag (ui.levels_tag: " Y2 C1", "" for a base tool) by id.
function M.tool_name(st, id)
local t = find(st.tools, id)
return t and t.name or "Tool", t and t.tier or 0
return t and t.name or "Tool", ui.levels_tag(t)
end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
@@ -169,220 +174,629 @@ local function past_moon(g, root, px, py, inner, moon, H)
})
fg.build(flex.solve(tree, px, py), root)
end
-- Timer bars for a drill queue, oldest first (the web's drill-stack): thin bars 40 wide
-- stacked, filling while it runs, full when ready, tinted by what it gets (ok, short,
-- none); a multi-well drill has a diamond cap. At most 4 bars, then "+N" beside them.
-- row: the row's key (a multi-well drill has a bar on every row it reaches). y is the
-- stack's middle.
local MAX_BARS, BAR_W, BAR_H, BAR_GAP = 4, 40, 3, 2
local MORE = { font = "mono", px = 11, color = "muted" }
local function drill_bars(g, parent, x, y, drills, row_key)
local n = math.min(#drills, MAX_BARS)
local top = y - math.floor((n * (BAR_H + BAR_GAP) - BAR_GAP) / 2)
for i = 1, n do
local d = drills[i]
local color = d.state == "short" and "ready" or "cyan"
local by = top + (i - 1) * (BAR_H + BAR_GAP)
-- Keyed by row and tool (one drill each), so a restarted or corrected drill eases.
bar.timer(g, parent, x, by, BAR_W, BAR_H, { start = d.start, ends = d["end"], ready = d.ready, color = color,
none = d.state == "none", key = "bar:" .. row_key .. ":" .. d.toolId })
if (d.reach or 1) > 1 then
-- The diamond cap (app.css .drill-bar.is-wide::after).
local cap = ui.rect(parent, 0, 0, 5, 5, d.state == "none" and "muted" or color)
gui.set_pivot(cap, gui.PIVOT_CENTER)
gui.set_position(cap, vec(x + BAR_W + 1, -(by + 1)))
gui.set_rotation(cap, vmath.quat_rotation_z(math.rad(45)))
end
---------------------------------------------------------------- well cards
-- The wells as cards (Drilling B · Well cards): every action on a well is on its card,
-- the aside only shows. A card: the resource and its supply, the supply bar and a line
-- of what the drills leave, then a stack of slots, all SLOT_H tall: the deploy slot
-- first (it keeps its spot while you deploy again and again: new drills go under it),
-- then a slot per drill, oldest first (time left and its bar; when it's done, gold with
-- Claim and Redeploy on it), then one slot for the idle drills that last mined the well
-- (Redrill). Claiming one slot claims every ready drill of the well (claim-well); the
-- next slot slides into its place. Clicking the card (not a button) selects the well:
-- the aside shows its facts.
--
-- Deploying: the tray under the cards lists the idle drills (grouped like copies: same
-- name, track levels, yield, cycle and reach). Clicking a tile deploys one of them to the
-- selected well (none selected: the first well it has room on). A well's deploy slot sends
-- the first drill in the tray that has room there, multi-well ones too (smallest first;
-- predict.room: what it would get after the moon's drills).
local SLOT_H, SLOT_GAP = 60, 6
local CARD_MIN, CARD_GAP, CARD_PAD = 210, 12, 12
local TILE_MIN, TILE_H = 150, 52
local SLOT_BTN_H = 26
local EPS = 1e-9
local NAME13 = { font = "sans_semi", px = 13, color = "text" }
local M12 = { font = "mono", px = 12, color = "muted" }
local S12 = { font = "sans", px = 12, color = "muted" }
local S11 = { font = "sans", px = 11, color = "muted" }
-- A tool's short name ("PD-1 T0"): its code (else the last word of its name), and its
-- track levels when it has any ("PD-1 Y2 C1", ui.levels_tag). t: a state.tools entry
-- (nil: "Drill").
function M.short_name(t)
if not t then
return "Drill"
end
local s = (t.code and t.code ~= "") and t.code or ((t.name or "Drill"):match("(%S+)$") or "Drill")
s = s .. ui.levels_tag(t)
return s
end
local function drill_label(st, drills)
if #drills == 0 then
return ""
elseif #drills == 1 then
local name, tier = M.tool_name(st, drills[1].toolId)
return name:match("(%S+)$") .. " T" .. tier
-- The web's short wording of a reach ("3 wells", "whole moon"; nil for one).
local function reach_short(st, n)
if not n or n <= 1 then
return nil
end
return ui.plural(#drills, "drill")
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.short ~= "" and pick.short or (n .. " wells")
end
-- The drill column of a row, built at its width (52 tall): the stacked timer bars for
-- every drill on it, multi-well ones included (the web's Queue), and the label of its
-- own drills (DrillLabel) beside them, or under them (after "+N") when there's no room
-- (decided at its width). Its tooltip (DrillsTitle): each drill, oldest first, and what
-- it gets. pad: room kept before the status buttons.
local function drill_cell(g, queue, fates, label, row_key, pad)
return made({ grow = 1, shrink = 1, padding_right = pad }, function(p, x, y, w, h)
local dw = w - pad
if #fates > 0 then
tooltip.add(ui.rect(p, x, y + 6, math.max(40, dw), h - 12, "none"), table.concat(fates, "\n"))
end
local n = math.min(#queue, MAX_BARS)
local stack_h = n * (BAR_H + BAR_GAP)
local more = #queue - n
local function bars(beside)
local more_l = beside and more > 0 and fg.label(p, "+" .. more, MORE, 16) or nil
return row({ shrink = 0, align = "center" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
more_l and space(2) or nil, more_l,
})
end
local tree
if #queue == 0 then
tree = row({ height = h, align = "center" }, {
fg.label(p, "Empty", { font = "sans", px = 14, color = "muted" }, 16, { fit = true, shrink = 1 }),
})
else
local style = { font = "sans", px = 12, color = "text_2" }
local probe = fg.label(p, label, style, 16)
local lw = probe.measure()
gui.delete_node(probe.node)
if BAR_W + 4 + (more > 0 and 18 or 0) + 6 + lw <= dw then
tree = row({ height = h, align = "center" }, {
bars(true), space(6), fg.label(p, label, style, 16, { fit = true, shrink = 1 }),
})
else
-- (narrow: "+N" leads the label under the bars)
tree = col({ height = h, padding_top = 13, align = "start" }, {
made({ width = BAR_W + 4, height = stack_h, shrink = 0 }, function(q, bx, by)
drill_bars(g, q, bx, by + math.floor(stack_h / 2), queue, row_key)
end),
space(0, 1),
fg.label(p, (more > 0 and ("+" .. more .. " ") or "") .. label, style, 16, { fit = true, shrink = 1 }),
})
-- Idle tools grouped like copies (name, levels, yield, cycle, reach; read generically), in
-- order of yield, then key: { key, tool (one idle copy), count }.
function M.idle_groups(st)
local out, by = {}, {}
for _, t in ipairs(st.tools or {}) do
if not t.stakeId then
local k = table.concat({ t.code or t.name or "", ui.levels_tag(t), tostring(t.yield),
tostring(t.cycleSecs), tostring(t.reach or 1) }, ":")
local grp = by[k]
if not grp then
grp = { key = k, tool = t, count = 0 }
by[k], out[#out + 1] = grp, grp
end
grp.count = grp.count + 1
end
fg.build(flex.solve(tree, x, y, dw), p)
end
-- (in a fixed order, not the server's list's: a claimed drill coming back idle mustn't
-- shuffle the tiles; smallest yield first, so an unpicked deploy slot sends the
-- smallest drill with room)
table.sort(out, function(a, b)
local ya, yb = a.tool.yield or 0, b.tool.yield or 0
if ya ~= yb then
return ya < yb
end
return a.key < b.key
end)
return out
end
-- What a cycle of tool t would get on the well after the moon's drills (predict.room), and
-- its full amount there (the yield with the resource's live boost).
local function gets_here(st, well, t)
local boosts = st.rules and st.rules.boosts and st.rules.boosts.yield or {}
local amount = (t.yield or 0) * (boosts[well.resource] or 1)
return predict.room(st, well.id, amount, t.reach or 1) or amount, amount
end
-- The group a well's deploy slot sends: the tray's pick (explicit = true, even when it has
-- no room there), else the first one-well group with room. Returns group, here, amount,
-- explicit.
local function deploy_choice(g, st, well, groups)
for _, grp in ipairs(groups) do
if grp.key == g.deploy_pick then
local here, amount = gets_here(st, well, grp.tool)
return grp, here, amount, true
end
end
for _, grp in ipairs(groups) do
-- (multi-well drills too: they deploy here like any other, then mine the next wells)
local here, amount = gets_here(st, well, grp.tool)
if here > EPS then
return grp, here, amount, false
end
end
end
-- An action button's test id (core/hooks: "act:<name>?<sorted params>").
local function act_id(name, params)
local keys = {}
for k, v in pairs(params) do
keys[#keys + 1] = k .. "=" .. tostring(v)
end
table.sort(keys)
return "act:" .. name .. "?" .. table.concat(keys, "&")
end
-- A one-line label box in a slot or tile (cut with … when it doesn't fit).
local function fit(p, str, style, opts)
opts = opts or {}
opts.fit, opts.shrink = true, opts.shrink or 1
return fg.label(p, str, style, 16, opts)
end
-- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
-- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
-- the cards it changed, not the panel. What a part shows is worked out first (its model,
-- from the state and our clock) and is its block's signature; the build draws only from
-- the model and the state, so a kept part shows what a rebuilt one would.
-- A kept part of the moon (key, sig: its model) at (x, y) of parent: fn(root) builds it
-- at root's origin and returns its height (also when it's kept). Parts nest: a card's
-- slots are parts of their own, so a card that changes keeps the slots that didn't.
local function part(g, key, sig, parent, x, y, fn)
local b, kept = blocks.open(g, key, sig, parent)
local h
if kept then
h = blocks.close(g, b)
else
h = blocks.close(g, b, fn(b.root))
end
gui.set_position(b.root, vec(x, -y))
return h
end
-- The busy and ready-pop marks (core/hooks: g.inflight, g.ready_at) of these action keys,
-- for a part's signature.
local function marks(g, keys)
local out = {}
for i, k in ipairs(keys) do
local r = g.ready_at and g.ready_at[k]
out[i] = (g.inflight[k] and "b" or "") .. (r and tostring(r.t) or "")
end
return out
end
-- What a well's deploy slot shows (the group it sends and its two lines).
local function deploy_view(g, st, well, groups)
local grp, here, amount, explicit = deploy_choice(g, st, well, groups)
local ok = grp ~= nil and here > EPS
local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
if #groups == 0 then
v.title, v.sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy"
elseif not grp then
v.title, v.sub = "No room here", "The drills on it take all it has"
elseif not ok then
v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
else
local t = grp.tool
local reach = reach_short(st, t.reach)
v.title = "+ Deploy " .. M.short_name(t)
v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
if here < amount - EPS then
v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
end
end
return v
end
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a
-- cycle gets here, or why it can't (no idle drills, no room). v: deploy_view. It sends
-- the group's first idle copy at the click (a kept slot outlives the drill it was built
-- with); its test id is the well's: act:stake?target=<well>.
local function deploy_slot(g, well, v, mk, H)
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
part(g, "mo:deploy:" .. well.id, blocks.sig(w, v, mk), p0, x0, y0, function(p)
local x, y = 0, 0
-- (off: a plain line and no fill, so it reads as greyed beside a live slot)
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not v.ok,
edge = not v.ok and "line" or nil, fill = not v.ok and { "cyan", 0 } or nil })
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
fit(b, v.title, { font = "sans_semi", px = 13, color = v.ok and "cyan" or "muted", tracking = 0.06, upper = true }),
fit(b, v.sub, { font = "sans", px = 12, color = v.color or "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
if v.ok then
local function params()
for _, grp in ipairs(M.idle_groups(g.state)) do
if grp.key == v.key then
return { tool = grp.tool.id, target = well.id }
end
end
return { tool = v.tool, target = well.id } -- (gone since: the server says why)
end
H.action(g, b, bn, "stake", params, nil, nil, "act:stake?target=" .. well.id)
tooltip.add(b, "Deploy " .. (v.name or "a drill") .. (v.explicit and "" or " (the smallest idle drill with room here)"))
end
end)
end)
end
-- Table columns (468 wide at 1280x800; wider tables spread the columns): the supply
-- column up to the web's 200 px, the drill column takes what the row's buttons leave.
local function table_cols(inner)
local c2 = math.max(118, math.floor(inner * 0.25))
return { c2, math.max(92, math.min(212, math.floor(inner * 0.2))), "1fr", "auto" }
-- What a drill's slot shows besides the drill d: dm = { name, due, reach } (drill_models).
-- A drill's slot (SLOT_H tall; slides in when it was just deployed): its short name and
-- time left over its timer bar and what it gets; done (ready, or its end passed on our
-- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
-- Redeploy. n: its place among the group's ready slots (the first one's buttons have
-- the plain action ids). wait: the group's next drill to finish; where its Claim and
-- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
-- button; not focusable), for the test driver.
local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
local due = dm.due
local reach = dm.reach or nil
return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
-- (a part of its own: a card that changes keeps the slots that didn't)
part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
local x, y = 0, 0
local slot = ui.rect(p, x, y, w, SLOT_H, "none")
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
local iw = w - 18
local right
local gets = d.here or d.take or d.amount or 0
if due then
-- (one that gets nothing is claimed all the same: it frees the drill)
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 })
else
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
right.done = function(t)
H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
end
end
local second
if due then
local suffix = n > 1 and ("#" .. n) or ""
local kids = {
fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
tooltip.add(b, "Claim every ready drill here")
end, { grow = 1, height = SLOT_BTN_H }),
}
if can_redeploy then
local rp = { redeploy = "1" }
for k, v in pairs(scope) do
rp[k] = v
end
kids[2] = space(6)
kids[3] = fg.button(body, "Redeploy", { small = true, h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "claim", rp, nil, nil, n > 1 and (act_id("claim", rp) .. suffix) or nil)
tooltip.add(b, "Claim, then send the drills out again")
end, { height = SLOT_BTN_H })
end
second = row({ height = SLOT_BTN_H, align = "center" }, kids)
else
local color = d.state == "short" and "ready" or "cyan"
local gets
if d.state == "none" then
gets = fit(body, "Nothing left by its turn", { font = "sans", px = 11, color = "ready" })
else
local here = ui.money(d.here or d.take or 0)
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "")
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""),
{ font = "sans", px = 11, color = d.state == "short" and "ready" or "muted" })
end
second = col({ height = SLOT_BTN_H, gap = 4, padding_top = 3 }, {
made({ height = 5, shrink = 0 }, function(q, bx, by, bw)
bar.timer(g, q, bx, by, bw, 5, { start = d.start, ends = d["end"], ready = false, color = color,
none = d.state == "none", key = "slot:" .. d.toolId })
end),
gets,
})
end
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(body, dm.name, NAME13),
reach and space(5) or nil, reach and fit(body, "· " .. reach, S12) or nil,
fg.fill(), space(6), right,
}),
second,
})
fg.build(flex.solve(tree, 0, 0), body)
if wait and not due then
local function spot(params, style)
return made(style, function(q, bx, by, bw, bh)
test.register("wait:" .. act_id("claim", params):sub(5), ui.rect(q, bx, by, bw, bh, "none"), "wait", nil, nil, true)
end)
end
local kids = { spot(scope, { grow = 1, height = SLOT_BTN_H }) }
if can_redeploy then
local rp = { redeploy = "1" }
for k, v in pairs(scope) do
rp[k] = v
end
kids[2] = space(6)
kids[3] = spot(rp, { width = fg.button_w(body, "Redeploy", { small = true, h = SLOT_BTN_H }), height = SLOT_BTN_H, shrink = 0 })
end
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body)
end
if d.fate and d.fate ~= "" then
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate)
end
enters[#enters + 1] = function()
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) })
end
end)
end)
end
-- One row of the wells table (a well, or the multi-well group): a grid row 52 tall, its
-- row node (hover, selection, entrance) in a slot. enters: row entrances to run once the
-- page is built (they veil the row's content).
local function well_row(g, c, cols, max, moon, well, H, enters)
local st, v = g.state, g.view
local group = well.wide_group or well
local key = well.id or "wide"
local function made_row(parent, x, y, w, h)
local slot = ui.rect(parent, x, y, w, h, "none")
local r = ui.rect(slot, 0, 0, w, h, "cyan", 0)
ui.rect(r, 0, h - 1, w, 1, "line")
if not well.wide_group then
-- Row click selects the well (bound first: the buttons on it take precedence).
row_c.bind(g, r, w, h, { selected = well.id == v.well, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
g.well_nodes[well.id] = r
-- The drill of a list that finishes next (not done yet), or nil.
local function next_drill(g, drills)
local now, pick = g.h.now(g), nil
for _, d in ipairs(drills or {}) do
if not d.ready and d["end"] and d["end"] > now and (not pick or d["end"] < pick["end"]) then
pick = d
end
-- A drill was just deployed here: the row slides in (fx-new).
enters[#enters + 1] = function()
row_c.enter(g, "well:" .. key, r, w, h, { base = gui.get_color(r) })
end
return r
end
return pick
end
-- Drill queue: its own drills, and multi-well ones that reach it, oldest first.
local drills = group.drills or {}
local queue = {}
for _, d in ipairs(drills) do
queue[#queue + 1] = d
-- The drills of a group (a well's, or the multi-well ones) as their slots show them:
-- { name, due, reach, n (its place among the ready ones, 0: not ready), wait }.
local function drill_models(g, st, drills, H)
local now, next_d = H.now(g), next_drill(g, drills)
local out, n = {}, 0
for i, d in ipairs(drills or {}) do
local due = d.ready or (d["end"] or math.huge) <= now
n = n + (due and 1 or 0)
out[i] = { name = M.short_name(find(st.tools, d.toolId)), due = due and true or false,
reach = reach_short(st, d.reach) or false, n = due and n or 0, wait = d == next_d }
end
if not well.wide_group and moon.wide then
local reach = {}
for _, id in ipairs(well.wide or {}) do
reach[id] = true
return out
end
-- What the idle slot of a well shows (well.redrill > 0).
local function idle_view(st, well)
local n = well.redrill or 0
local names = {}
for _, t in ipairs(st.tools or {}) do
if #names < 2 and not t.stakeId and predict.memo.last_well[t.id] == well.id then
names[#names + 1] = M.short_name(t)
end
for _, d in ipairs(moon.wide.drills or {}) do
if reach[d.id] then
queue[#queue + 1] = d
end
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill")
-- (no Redrill while the drills on the well take all it has: the server refuses it)
local full = well.supply > 0 and (well.remain or 1) <= 1e-9
return { n = n, who = who, full = full, can = (st.unlocked.redrill and well.supply > 0 and not full) and true or false }
end
-- The slot of the idle drills that last mined the well (well.redrill): how many, and
-- Redrill (sends them out again) once unlocked and the well has supply. v: idle_view.
local function idle_slot(g, well, v, H)
local n = v.n
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w)
local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
ui.outline(body, 0, 0, w, SLOT_H, "line_hi")
local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(body, v.who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6),
fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
}),
v.can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
tooltip.add(b, "Send the drills that last mined this well out again")
end, { height = SLOT_BTN_H }) or row({ height = SLOT_BTN_H, align = "center" }, {
fit(body, well.supply <= 0 and "Last mined here · the well is empty"
or (v.full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11),
}),
})
fg.build(flex.solve(tree, 0, 0), body)
end)
end
-- A well's line under its supply bar: the over-drilled lead in amber, else what the
-- drills leave ("45 free after drills"), else nothing (the line is kept).
local function well_note(c, well)
if well.supply <= 0 then
return fit(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true })
elseif (well.statusText or "") ~= "" then
-- (its lead: "Over-drilled", "Fully committed"; the aside has the rest)
return fit(c, well.statusText:match("^(.-)%s+—") or well.statusText, { font = "sans", px = 12, color = "ready" })
elseif (well.demand or 0) > 0 then
return fit(c, ui.money(well.remain or 0) .. " free after drills", { font = "sans", px = 12, color = well.full and "ready" or "muted" })
end
end
-- What a well's card shows (its block's signature with its rect): the well, its deploy
-- slot, its drills' slots, its idle slot. slots: how many (card_h).
local function card_model(g, st, well, max, groups, H)
local dry = well.supply <= 0
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the
-- other drills' on it, the server's `remain`): Claim only, no Redeploy.
local tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
local m = {
well = well, max = max, dry = dry, selected = well.id == g.view.well,
redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
deploy = not dry and deploy_view(g, st, well, groups) or false,
drills = drill_models(g, st, well.drills, H),
idle = (well.redrill or 0) > 0 and idle_view(st, well) or false,
}
m.slots = (dry and 0 or 1) + #m.drills + (m.idle and 1 or 0)
local scope = "claim?claim-well=" .. well.id
m.marks = marks(g, { scope, scope .. "&redeploy=1", "stake?target=" .. well.id, "redrill?redrill-well=" .. well.id })
return m
end
-- A card's height for its slots (its tree's, below: the head lines, then the slots).
local CARD_HEAD = 18 + 7 + 6 + 4 + 16 + 8
local function card_h(slots)
return CARD_PAD * 2 + CARD_HEAD + (slots > 0 and (slots * SLOT_H + (slots - 1) * SLOT_GAP) or 0)
end
-- One well's card, w x h at the origin of c (its node selects the well when clicked).
-- m: card_model.
local function well_card(g, c, st, moon, m, w, h, H)
local well = m.well
local dry = m.dry
local scope = { ["claim-well"] = well.id }
local enters = {}
local slots = {}
if m.deploy then
slots[#slots + 1] = deploy_slot(g, well, m.deploy, m.marks, H)
end
for i, d in ipairs(well.drills or {}) do
local dm = m.drills[i]
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
end
if m.idle then
slots[#slots + 1] = idle_slot(g, well, m.idle, H)
end
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 })
sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
local list = col({ gap = SLOT_GAP }, slots)
local kids = {
row({ height = 18, align = "center" }, {
made({ width = 8, height = 8, shrink = 0 }, function(q, x, y) ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1) end),
space(8), fit(c, well.resource, { font = "sans_semi", px = 15, color = dry and "muted" or "text" }),
fg.fill(), space(6), sup,
}),
space(0, 7),
made({ height = 6, shrink = 0 }, function(q, x, y, bw)
if dry then
ui.rect(q, x, y, bw, 6, "line")
else
M.supply_bar(q, x, y, bw, well, m.max)
end
end),
space(0, 4),
fg.reserve({ height = 16 }, well_note(c, well)),
space(0, 8),
list,
}
local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
local slot = ui.rect(parent, x, y, cw, ch, "none")
local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
row_c.bind(g, card, cw, ch, { selected = m.selected, id = "well:" .. well.id, label = well.resource,
on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
blocks.put(g, "well_nodes", well.id, card)
return card
end })
fg.build(flex.solve(tree, 0, 0, w, h), c)
if math.abs(list.y + list.h + CARD_PAD - card_h(m.slots)) > 0.5 then
print("ERROR:MM: moons: a well card's height is " .. (list.y + list.h + CARD_PAD) .. ", card_h says " .. card_h(m.slots))
end
for _, f in ipairs(enters) do
f()
end
end
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
-- card shows.
local function wide_model(g, st, moon, H)
local wide = moon.wide
-- (the moon tapped out once these are claimed: no Redeploy)
local left = 0
for _, w in ipairs(moon.wells or {}) do
left = left + w.supply
end
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
local scope = "claim?claim-wide=" .. moon.id
return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
end
-- Its card across the grid (inner wide, at the origin of c), its slots in the cards'
-- columns. Returns its height.
local function wide_card(g, c, st, moon, m, inner, H)
local wide = m.wide
local scope = { ["claim-wide"] = moon.id }
local enters = {}
local slots = {}
for i, d in ipairs(wide.drills or {}) do
local dm = m.drills[i]
slots[#slots + 1] = drill_slot(g, st, d, dm, scope, m.redeploy, dm.n, dm.wait, m.marks, H, enters)
end
local sw = inner - CARD_PAD * 2
local tree = col({ width = inner, padding = CARD_PAD }, {
row({ height = 18, align = "center" }, {
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10),
fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
}),
space(0, 10),
flex.grid(sw, CARD_MIN - CARD_PAD * 2, SLOT_GAP, slots),
}, { make = function(parent, x, y, w, h)
local card = ui.rect(parent, x, y, w, h, "cyan", 0)
ui.outline(card, 0, 0, w, h, "line_hi")
return card
end })
fg.build(flex.solve(tree, 0, 0), c)
for _, f in ipairs(enters) do
f()
end
return tree.h
end
-- What the tray of idle drills shows: a tile per group (the well it deploys to: the
-- selected one, else the first it has room on).
local function tray_model(g, st, groups)
local moon = st.moon
local function target(t)
local wells = moon and moon.wells or {}
for _, w in ipairs(wells) do
if w.id == g.view.well then
return w
end
end
for _, w in ipairs(wells) do
if w.supply > 0 and gets_here(st, w, t) > EPS then
return w
end
end
table.sort(queue, function(a, b) return (a.start or 0) < (b.start or 0) end)
end
local fates = {}
for _, d in ipairs(queue) do
fates[#fates + 1] = d.fate
local tiles, keys = {}, {}
for i, grp in ipairs(groups) do
local t = grp.tool
local well = target(t)
local here = well and gets_here(st, well, t) or 0
tiles[i] = { key = grp.key, name = M.short_name(t), count = grp.count, tool = t.id, tname = t.name or false, image = t.image or false,
gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
well = well and well.id or false, resource = well and well.resource or false,
ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
if well then
keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
end
end
local label = (group.label or "") ~= "" and group.label or drill_label(st, drills) -- (the server's DrillLabel)
return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
end
-- Name and supply cells.
local ML = { font = "mono", px = 12, color = "muted" }
local name_cell, supply_cell
if well.wide_group then
name_cell = row({ padding_left = 26, align = "center" }, { fg.label(c, "Multi-well", "cap", 16) })
-- "X left on the moon", wrapping onto a second line when the column is narrow.
supply_cell = col({ padding_right = 8, justify = "center" }, {
row({ wrap = true, column_gap = 5, row_gap = 1 }, {
fg.label(c, ui.money(group.cap), ML, 16),
fg.label(c, "left on the moon", { font = "sans", px = 12, color = "muted" }, 16, { fit = true, shrink = 1 }),
-- The tray (inner wide, at the origin of c): a tile per group (picture, short name,
-- ×count, what a cycle gets); a tile deploys one of them. m: tray_model. Returns its
-- height.
local function tray(g, c, m, inner, H)
local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#m.tiles > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
})
local tree
if #m.tiles == 0 then
tree = col({ width = inner }, {
head, space(0, 8),
row({ height = 16, align = "center" }, {
fg.label(c, "No idle drills.", "small", 16),
m.store and space(5) or nil,
m.store and made({ width = 160, height = 16, shrink = 0 }, function(p, x, y)
H.text_link(g, p, "Buy one in the store", { font = "sans", px = 13 }, x, y + 8, { section = "store", moon = m.moon })
end) or nil,
}),
})
else
local dry = well.supply <= 0
name_cell = row({ padding_left = 26, align = "center" }, {
made({ position = "absolute", left = 10, top = 22, width = 8, height = 8 }, function(q, x, y)
ui.rect(q, x, y, 8, 8, well.resource, dry and 0.4 or 1)
end),
fg.label(c, well.resource, { font = "sans", px = 15, color = dry and "muted" or "text" }, 16),
})
if dry then
supply_cell = row({ align = "center" }, {
fg.label(c, "Tapped out", { font = "sans_semi", px = 12, color = "muted", tracking = 0.1, upper = true }, 16),
})
else
local sup = fg.label(c, ui.money(well.supply), ML, 16)
sup.done = function(n) H.tick_num(g, n, "supply:" .. well.id, well.supply) end
supply_cell = col({ padding = { 20, 14, 0, 0 } }, {
made({ height = 6 }, function(q, x, y, w) M.supply_bar(q, x, y, w, well, max) end),
space(0, 2), sup,
})
local tiles = {}
for _, tv in ipairs(m.tiles) do
local picked = false
tiles[#tiles + 1] = made({ height = TILE_H }, function(p0, x0, y0, w)
part(g, "mo:tile:" .. tv.key, blocks.sig(w, tv, m.marks), p0, x0, y0, function(p)
local x, y = 0, 0
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not tv.ok })
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg")
if tv.image then
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), tv.image)
end
local tt = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(b, tv.name, NAME13), fg.fill(), space(4),
fg.label(b, "×" .. tv.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
}),
fit(b, tv.gets, { font = "mono", px = 11, color = "muted" }),
})
fg.build(flex.solve(tt, 0, 0), b)
if picked then
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end
if tv.ok then
tooltip.add(b, "Deploy " .. (tv.tname or "a drill") .. " to the " .. tv.resource .. " well")
H.action(g, b, bn, "stake", { tool = tv.tool, target = tv.well })
else
tooltip.add(b, (tv.tname or "Drill") .. (tv.well and (" · no room on the " .. tv.resource .. " well") or " · no room on this moon"))
end
end)
end)
end
tree = col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) })
end
-- Status (right): Claim (ready), Claim countdown (mining), Redrill (idle tools that
-- mined it), then Manage (View when tapped out), like the web's row actions.
-- (the amount moves on as more of its drills finish on our clock: core/ready)
local scope = well.wide_group and { ["claim-wide"] = moon.id } or { ["claim-well"] = well.id }
local amount = ready.amount(g, group, well.wide_group and group.cap or well.supply)
local status = {}
if group.ready > 0 then
status[#status + 1] = fg.button(c, "Claim " .. ui.money(amount), { primary = true, small = true }, function(b, bn)
H.action(g, b, bn, "claim", scope)
end)
elseif group.nextEnd then
local wopts = { small = true, right = true, key = "wait:" .. key, params = scope, moon = moon.id,
done = amount > 0 and ("Claim " .. ui.money(amount)) or nil }
local bw = H.claim_wait_w(g, c, group.nextEnd, wopts)
status[#status + 1] = made({ width = bw, height = S.button_sm_h, shrink = 0 }, function(q, x, y, w)
H.claim_wait(g, q, x + w, y, group.nextEnd, wopts)
end)
elseif not well.wide_group and (well.redrill or 0) > 0 and well.supply > 0 and st.unlocked.redrill then
status[#status + 1] = fg.button(c, "Redrill", { small = true, variant = "redrill" }, function(b, bn)
H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
end)
end
if not well.wide_group then
local to = { section = "moons", moon = moon.id, well = well.id }
status[#status + 1] = fg.button(c, well.supply <= 0 and "View" or "Manage", { small = true }, function(b, bn)
button.bind(g, b, bn, { id = "manage:" .. well.id, on_click = function() H.navigate(g, to) end })
end)
end
return box({ columns = cols, rows = { S.well_row_h }, padding_right = 8 }, {
name_cell, supply_cell,
drill_cell(g, queue, fates, label, key, #status > 0 and 14 or 8),
row({ gap = 6, align = "center" }, status),
}, { make = made_row })
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end
local function rating_chip(c, r)
-- The web's rating chip ("★3" in the tier's color; its tooltip the label and percentile).
local edge, alpha, fill = "line_hi", 1, nil
@@ -411,13 +825,10 @@ local function rating_chip(c, r)
end)
end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- wells table (min_h tall at least; the moon's sale is in the aside). Returns its
-- bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
-- The heading (id, name, class and rating, description; Claim at the right), inner wide
-- at the origin of c, 80 tall at least. Returns its height.
local function heading(g, c, moon, n_ready, inner, H)
-- The class line ("<type> class"), but not for moons named after their type (from
-- before moon names), like the web.
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
@@ -441,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
head[#head + 1] = space(0, 4)
end
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it).
local n_ready = ready.drills(g, moon.id)
if moon.ready > 0 then
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y)
local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
@@ -455,44 +865,81 @@ local function moon_view(g, c, px, py, inner, min_h, H)
done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
end)
end
-- Wells table: the header, an edge line, a row per well and the multi-well group.
local max = M.supply_max(st, moon)
local cols = table_cols(inner)
local enters = {}
local tbl = {
box({ columns = cols, height = 16, align = "start" }, {
fg.label(c, "Well", "cap", 12), fg.label(c, "Supply left", "cap", 12), fg.label(c, "Drill", "cap", 12),
fg.label(c, "Status", "cap", 12, { justify_self = "end" }),
}),
made({ position = "absolute", top = 16, left = 0, right = 0, height = 1 }, function(p, x, y, w)
ui.rect(p, x, y, w, 1, "edge")
end),
}
for _, well in ipairs(moon.wells) do
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, well, H, enters)
end
if moon.wide then
tbl[#tbl + 1] = well_row(g, c, cols, max, moon, { wide_group = moon.wide }, H, enters)
end
-- (selling the moon is in the aside, under the well: aside.lua)
local page = col({ width = inner, min_height = min_h }, {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
col({}, tbl),
})
fg.build(flex.solve(page, px, py), c)
for _, f in ipairs(enters) do
f()
end
return page.y + page.h
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end
-- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
-- the moon is in the aside: aside.lua), each a kept part, top to bottom from (px, py) of
-- c. min_h tall at least. Returns its bottom.
local function moon_view(g, c, px, py, inner, min_h, H)
local st = g.state
local moon = st.moon
local now = H.now(g)
local y = py
local n_ready = ready.drills(g, moon.id)
local claim = "claim?claim-moon=" .. moon.id
y = y + part(g, "mo:head", blocks.sig(inner, moon.id, moon.name, moon.kind or false, moon.grade or false,
moon.rating or false, moon.description or false, moon.ready, moon.nextEnd or false,
(moon.nextEnd or math.huge) <= now, n_ready, marks(g, { claim })), c, px, y, function(root)
return heading(g, root, moon, n_ready, inner, H)
end)
y = y + 14
-- The cards: a well each (as many columns as CARD_MIN allows; a row as tall as its
-- tallest card), the multi-well card across them, then the idle drills.
local max = M.supply_max(st, moon)
local groups = M.idle_groups(st)
local pick_ok = false
for _, grp in ipairs(groups) do
pick_ok = pick_ok or grp.key == g.deploy_pick
end
if not pick_ok then
g.deploy_pick = nil -- (used up)
end
local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
local wells = moon.wells or {}
local models = {}
for i, well in ipairs(wells) do
models[i] = card_model(g, st, well, max, groups, H)
end
for first = 1, #wells, cols do
local last = math.min(#wells, first + cols - 1)
local rh = 0
for i = first, last do
rh = math.max(rh, card_h(models[i].slots))
end
for i = first, last do
local m = models[i]
part(g, "mo:well:" .. wells[i].id, blocks.sig(cw, rh, m), c, px + (i - first) * (cw + CARD_GAP), y, function(root)
well_card(g, root, st, moon, m, cw, rh, H)
end)
end
y = y + rh + (last < #wells and CARD_GAP or 0)
end
if moon.wide and #(moon.wide.drills or {}) > 0 then
y = y + CARD_GAP
local m = wide_model(g, st, moon, H)
y = y + part(g, "mo:wide", blocks.sig(inner, moon.id, m), c, px, y, function(root)
return wide_card(g, root, st, moon, m, inner, H)
end)
end
y = y + 20
local tm = tray_model(g, st, groups)
y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
return tray(g, root, tm, inner, H)
end)
return math.max(y, py + min_h)
end
-- Section contract (AGENTS.md, "Game screen"): root is the main panel, (px, py) its
-- padding, w x h its size.
function M.build(g, root, px, py, w, h, H)
local inner = math.min(w - px * 2, S.content_max)
-- (as wide as a card grid gets: a moon's well cards, 5 across where there is room)
local inner = math.min(w - px * 2, S.cards_max)
px = px + math.floor((w - px * 2 - inner) / 2) -- capped content is centered
local st = g.state
M.track_drills(g)
+37 -33
View File
@@ -488,9 +488,9 @@ 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)
local l = fg.label(c, ui.money(r.owned), CAP_NUM, 24)
counted[#counted + 1] = { key = "refined:" .. r.item, node = l.node, amount = r.owned }
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
@@ -501,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")
@@ -510,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 own_label(c, r) 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).
@@ -558,32 +560,34 @@ local function recipe_card(self, H, c, st, rf, r)
return n
end })
-- The form: quantity and Max on a row, Refine the card's width under them (beside the
-- quantity and "Max 250" it was cramped).
-- 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 refine_btn = 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)
-- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
end, { grow = 1 })
local form = col({ shrink = 0 }, { row({ height = BTN_SM, gap = 6, shrink = 0 }, {
qty,
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) .. ")")
end),
}), space(0, 6), row({ height = BTN_SM, shrink = 0 }, { refine_btn }) })
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)
return col({ padding = 17 }, {
+22
View File
@@ -147,6 +147,25 @@ function M.build_teaser(g, root, px, py, inner)
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
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) }),
@@ -516,6 +535,9 @@ function M:bind()
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)
+15 -2
View File
@@ -2,7 +2,8 @@
-- gear, a text button per setting or action with its icon on the right: How to play (the
-- Help view), the UI scale (window pixels per layout unit, times the display's own scale;
-- the browser's zoom), fullscreen, sound, theme, reduced motion, Save code and Restore from
-- code (savecode.lua), and Grant for admins.
-- code (savecode.lua), Grant for admins, and Moon lab (the admin area, admin.lua) for
-- admins and in debug builds.
-- Saved settings live in the settings file (modules/screen, sfx, theme, anim). Keys: Ctrl
-- + / Ctrl - / Ctrl 0, F11 (core/keys). A click outside or Esc closes it.
@@ -73,6 +74,10 @@ local ICONS = {
ui.outline(p, x + 2, y + 2, 16, 16, "nav")
ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
lab = function(p, x, y)
local icon = ui.rect(p, x + 1, y + 1, 18, 18, "cyan")
gui.play_flipbook(icon, "moon")
end,
grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end,
@@ -98,7 +103,9 @@ end
function M.open(g, right, top)
local st = g.state
local help = st and st.unlocked and st.unlocked.help
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0)
local admin = require("screens.game.admin")
local lab = st and admin.allowed(g)
local rows = 7 + (help and 1 or 0) + ((st and st.admin) and 1 or 0) + (lab and 1 or 0)
local h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({
w = W,
@@ -171,6 +178,12 @@ function M.open(g, right, top)
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
require("screens.game.grant").open(g)
end))
y = y + ROW_H
end
if lab then
item(d, box, y, "Moon lab", "lab", "menu:moon-lab", close_then(function()
admin.open(g, "moonlab")
end), { disabled = g.reveal:active() })
end
end,
})
+2 -29
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).
@@ -185,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 + 18, function(f, bw) -- (the need line's place is kept: buy_footer)
M.buy_footer(f, bw, { { "Spare", a.spare }, { "Equipped", a.equipped } }, a.price, short,
(a.whyText or "") ~= "" and a.whyText or ("Need " .. theme.currency .. ui.money(a.price - st.credits) .. " more"),
function(b, nodes)
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
@@ -217,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),
}),
@@ -231,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)
+5 -2
View File
@@ -63,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():
+15 -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
@@ -381,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")
@@ -392,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")
@@ -413,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)
@@ -428,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);
+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."""
+11 -8
View File
@@ -1,8 +1,8 @@
"""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 card opens its panel (stats, tiers) without the Store's parts (upgrade,
slots, spares, Sell drill); after the first claim and sale reveal the Store, the same panel
has them. Shots "before" and "after", each linted."""
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
@@ -41,10 +41,10 @@ def run(game, args):
game.shot("before")
game.lint("before")
texts = labels(game)
game.expect("tiers" in texts, "tier ladder shown")
for t in ("slots", "spare attachments", "sell drill"):
game.expect(t not in texts, "%s hidden before the Store" % t)
game.expect(not any(t.startswith("upgrade to") for t in texts), "no upgrade before the Store")
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")
@@ -60,5 +60,8 @@ def run(game, args):
game.shot("after")
game.lint("after")
texts = labels(game)
for t in ("tiers", "slots", "spare attachments", "sell drill"):
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))
+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)
+1 -1
View File
@@ -34,7 +34,7 @@ def run(game, args):
clicks = [
("moons", "act:claim?claim-well"),
("refinery", "act:collect?job"),
("market", "act:sell"),
("market", "act:sell?pick"),
("store", "act:buy-tool?id"),
("equipment", "act:upgrade?tool"),
]
+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:
+7 -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})
@@ -143,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"))
+4 -15
View File
@@ -1,9 +1,8 @@
"""Sells ask in place, not in a dialog (needs --fixture; cards.ask_button): the drill
panel's spare attachment Sell turns into Cancel on its spot with Confirm just left of it and
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. Then the drill's own Sell (asks, cancelled) and the moon's
Sell moon (shot of its asking state, when the moon can be sold). Shots of each."""
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_):
@@ -49,9 +48,9 @@ def covers(outer, inner):
def run(game, args):
fx = game.fixture
pre = (args[0] + "-") if args else ""
sell = "confirm:sell-attachment"
sell = "confirm:sell-tool"
game.idle()
game.navigate("{section='equipment', drill='%s'}" % fx["drill_mining"])
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
game.scroll_to(sell)
before = rect_of(game, sell)
@@ -95,16 +94,6 @@ def run(game, args):
game.expect(game.log_has(r"mm: toast Sold"), "Enter sells (a toast)")
game.shot(pre + "sellask-sold", settle=0.4)
# The drill's own Sell: asks, a shot, cancelled.
game.navigate("{section='equipment', drill='%s'}" % fx["tool_idle"])
game.idle()
if rect_of(game, "confirm:sell-tool"):
ask(game, "confirm:sell-tool")
game.shot(pre + "sellask-tool", settle=0.3)
game.key("key_esc")
game.wait_for("g.ask == nil", 5)
game.idle()
# The moon's Sell moon (when it can be sold yet).
game.navigate("{section='moons', moon='%s'}" % fx["moon2"])
game.idle()
+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,
+3 -3
View File
@@ -1,4 +1,4 @@
"""Server texts in the confirms (needs --fixture): selling a spare attachment asks in place
"""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."""
@@ -44,9 +44,9 @@ 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()
ask(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")
+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}
+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
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
+13 -3
View File
@@ -22,6 +22,8 @@ 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
@@ -63,7 +65,8 @@ start_server() {
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
rm -f "$ST/pid"
# Its own session (not killed with the caller's), pid written by the process itself.
(cd "$WT/server" && DB_DATABASE=$DB PORT=$port MM_DEV=1 PIDFILE=$ST/pid \
# 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
@@ -114,7 +117,14 @@ up() {
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"
@@ -126,7 +136,7 @@ up() {
start_server
fi
echo "worktree $WT"
echo "MM_API=http://localhost:$(cat "$ST/port")"
echo "MM_API=http://127.0.0.1:$(cat "$ST/port")"
echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
}
@@ -179,7 +189,7 @@ case ${1:-} in
up) up "${2:-}" ;;
down) down "${2:-}" ;;
rm) remove "${2:-}" ;;
restart) task_vars "${2:-}"; stop_server; start_server; echo "MM_API=http://localhost:$(cat "$ST/port")" ;;
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/
+103 -117
View File
@@ -1,22 +1,17 @@
# AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`).
The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
every command below from this directory (`server/`).
## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package in `server/`; templates, static files and SQL are embedded with `go:embed`.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
committed). See "web/app" below.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
`api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
- The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
`web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
light themes). No bundler, no TypeScript.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout
@@ -30,26 +25,30 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
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.
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 upgrade tiers, moon types: a name, a placeholder
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;
@@ -58,13 +57,13 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
count by its grade's weights, resources by the affinities, each well's supply in the
grade's [`wellSupplyMin`, `wellSupplyMax`); a first moon always has every raw input of a
recipe. Ratings, reveal bars, resale and descriptions compare a moon with its grade.
Upgrade tiers set a cycle `speed` (read from cfg(), not stored; `StoreItem.ToolSecs`). A moon's name comes from `moonNames` (roots and
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
@@ -78,112 +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
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`. 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
`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), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
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`).
- `scripts/tool-art.mjs`: generated tool art per tier, not in use (its output was removed;
the tools show their original images). `StoreItem.ImageAt` uses `<image>-tN` from
`web/static/assets/items` when it exists, else the base image.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
like the Defold client's session (`base`, `pending`, outbox with `clientSeq`, rollback on
422), `predict.js` a port of the Defold `predict.lua`. Details in each file's header.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
(drills by tool id, not stake id); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–019):
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).
Agents: `../scripts/wt up <task>` gives a worktree its own database and server instead.
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
Other tests: `go test ./...` (game rules on in-memory states, no database);
`wells_test.go` (`go test -run Wells -v`: well roll distribution, value by well count,
resale odds, sim seeds). `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/`;
@@ -199,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.
+72
View File
@@ -1,3 +1,75 @@
# 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
+1 -10
View File
@@ -1,19 +1,10 @@
# Production image: one Go binary with the templates, static files and SQL embedded.
FROM docker.io/library/golang:1.25-bookworm AS build
WORKDIR /src
ARG TAILWIND_VERSION=v4.1.14
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
&& chmod +x /usr/local/bin/tailwindcss
ARG ESBUILD_VERSION=0.28.2
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
&& make app ESBUILD=/usr/local/bin/esbuild \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM scratch
COPY --from=build /moonminers /moonminers
-169
View File
@@ -1,169 +0,0 @@
# Hand-off: Preact web client experiment (branch `s-preact`)
## Goal
Find out whether a web client can feel as smooth and instant as the Defold client (which
predicts click results locally and eases corrections). It's built with Preact + JSX +
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
next to the classic htmx client at `/`. The user will compare them by feel: fast
claim/redrill/deploy clicks, and animations that never hitch.
## What's built
- `web/app/` (about 2,400 lines without the vendored code):
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
the `shown`/`live` computeds, and an outbox that sends one request at a time with
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
`nextEnd` and keeps the URL in sync.
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
difference: readiness is local (`s.ready || s.end <= now`).
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
- `ui.jsx`:
- `Num`: eased number, written by the ticker.
- `Countdown`.
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
`playbackRate` so the bar lands on the true end without jumping.
- `SupplyBar`: clip-path.
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
waits for the server).
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
for the API's `confirm` texts (the moon sale).
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
`sfx.js`.
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
zoom were verified. If the worker fails it falls back to the page's thread;
`?mainscene` forces that.
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
holds the path aliases, including `three`, used by the worker bundle.
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
- Build:
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
gitignored and in `.dockerignore`.
- `make dev` watches the client.
- `make build` depends on `app`.
- The Dockerfile fetches esbuild and runs `make app`.
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
inside JSX, and builds the client.
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
`test-endpoints.restclient`.
## How to try it
```
cd wt-s-preact && make app && go build -o bin/moonminers .
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
```
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
gitignored. **The servers are stopped now**; restart them with the commands above.
## Measurements
### Second run (Oct 20, after the Moons slice was finished)
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
the median of 3 alternating runs; latency from pointerdown to the first frame where the
side panel's drill line count changes. Each test starts from a known state set through the
API and admin endpoints (the wells topped up, one drone finished on the well). No other
engine was running; the machine's load average was 6 to 9 from other work.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
1.5 s after, 0 ms delay, median of 3):
| | script | layout | style | all tasks |
|---|---|---|---|---|
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
| app | 11 ms | 8 ms | 10 ms | 62 ms |
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
the first gesture, as below); the others 24–25 ms.
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
other engine.
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
for the header's Classic/Admin link.
### First run
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
where the side panel's drill lines change.
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|---|---|---|---|---|
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
- The classic client drops clicks while a request runs: `body.is-busy` sets
`pointer-events: none`.
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
was using about 460% CPU and the GPU.
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
than on scripts.
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
under this load.
- The first click in each run has a long task of about 120 ms in both clients. It comes
from `sfx.js` creating the AudioContext on the first gesture.
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
`runall.sh`.
## Unfinished / next steps
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
side-by-side screenshots. What's left:
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
The API has every section's state and the prediction rules for sells and refinery
batches (api.md "Prediction").
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
would need the server's split of multi-well claims and the sale's payout rules.
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
around a claim). Worth keeping in `scripts/` if the comparison continues.
4. Report: rough line counts are above. Hard parts:
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
- easing bar corrections (playbackRate);
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
`Event.type`);
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
empty spans).
## Gotchas
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
which is allowed. Never read `.value` inside JSX.
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
last `shown` change, because Preact re-renders asynchronously.
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
only for the main-thread fallback.
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
`web/static/js/scene.js` too.
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
(the sale lines' `value`): destructure them under another name.
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
+6 -37
View File
@@ -1,30 +1,11 @@
TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss
CSS := web/static/css/app.css
ESBUILD_VERSION := 0.28.2
ESBUILD := bin/esbuild
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
# (/static/js/...) by URL.
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
.PHONY: build dev migrate load-config check
.PHONY: build dev css app migrate load-config check
build: css app
build:
go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
# client rebuild on change.
dev: $(TAILWIND) $(ESBUILD)
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
$(APP) --sourcemap --watch=forever & \
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
css: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
app: $(ESBUILD)
$(APP) --minify
# Server on :3000 that reloads templates and static files from disk.
dev:
MM_DEV=1 go run . serve
migrate:
go run . migrate
@@ -34,17 +15,5 @@ load-config:
check:
test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes)$$' -count=1 .
go test -run '^(TestTemplates|TestAPI|TestSharedQuotes|TestAdminGate)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs
node scripts/check-signals-jsx.mjs web/app
$(APP) --minify --log-level=warning
$(ESBUILD):
mkdir -p bin
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
$(TAILWIND):
mkdir -p bin
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x $@
+68 -72
View File
@@ -1,19 +1,17 @@
# Moon Miners server
A browser mining game. Players own moons (staking sources) with resource wells, buy and
The server of a mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
the higher levels of the drills' upgrade tracks.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
It's a Go server: game rules run in Go, one Postgres transaction per action, behind a JSON
API (`api.md`) that the Defold client (`../client`) plays on. The admin pages (`/utils`) are
server-rendered HTML with htmx. `GET /` redirects to `/utils` for now.
Players join anonymously: on the first visit the server creates a player and stores its id in
a cookie. No wallet or sign-up needed.
Players join anonymously: on the first request the server creates a player and sends its id
back (`X-MM-User`, also a cookie). No wallet or sign-up needed.
This is `server/` in the repo (the Defold client is `../client`). Run the commands below
from this directory.
@@ -24,8 +22,6 @@ from this directory.
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI and esbuild into `bin/` on first use.
## Environment
The server reads `.env` from its working directory (`server/`), or if there is none, from the
@@ -42,11 +38,15 @@ ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
Optional: `PORT` (default 3000), `HOST` (the address to listen on, e.g. `127.0.0.1`; unset:
every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
(comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
saving moon settings), `MM_DEV=1` (reload templates and static files from disk; no admin
login, and every player is an admin), `MM_WEB_DIR` (the browser build served at `/`; default `../client/dist/web/MoonMiners`,
none there: `/` redirects to `/utils`), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit by
the last `X-Forwarded-For` address instead of the connection's, and mark the admin cookie
Secure on HTTPS; new players are limited to 10 a minute per IP).
## Local development
@@ -59,7 +59,9 @@ limited to 10 a minute per IP).
make migrate
```
3. Run the server and open http://localhost:3000:
3. Run the server (`MM_DEV=1`: no admin login). The admin is at
http://localhost:3000/utils; the game at http://localhost:3000 once the client's
`make web` has built it (then restart the server), or run the desktop client against it:
```bash
make dev
@@ -69,14 +71,15 @@ limited to 10 a minute per IP).
pulling changes (or editing `config/game.json`), run `make migrate` again: new
migrations and the store items' numbers only reach the database through it.
Other commands: `make build` (CSS, the Preact bundle and `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
Other commands: `make build` (`bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every admin page without a database, the API and
admin login tests, JS syntax), `make load-config` (sync the store items in the database with
`config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
and `moon-traits` (JSON on stdin; prints the moon looks `moondesc.go` derives, to compare
with the client's `terrain.lua`).
Reset the local database (compose commands at the repo root): `podman-compose down -v && podman-compose up -d db`, then `make migrate` here.
## Database changes
@@ -93,17 +96,22 @@ same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names), 014 drill reach, 015 moon
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
(`users.refinery_batch_level`).
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_level`,
`cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
`upgrade_item` dropped, stake cycles in tenths of a second), 022 indexes for the per-request
state reads (moons, wells, tools and stakes by player, stakes by well and by tool).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
two upgrade tracks (`tracks.yield` and `tracks.cycle`: a name and levels, each setting the
claim per cycle or the cycle in seconds flat, with a `price` and, higher up, refined `parts`;
a drill's base cycle is `completionTimeInSecs`, its reach never changes),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
@@ -117,11 +125,9 @@ function of name and time (`market.go`), which also gives the 24h charts; sells
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
Each drill's tracks are upgraded per tool, one level at a time, on its Equipment card or
in its panel, where idle drills can also be sold back for `equipmentResale.factor` of their
credit price (its price plus its track levels; refined parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
@@ -136,18 +142,21 @@ Admins can change these numbers without a deploy on `/utils/economy` (see Admin
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
Server-rendered pages (Go templates, htmx, `web/static/css/admin.css`). Sign in at
`/utils/login` with `ADMIN_PASSWORD`: the session is a random token in the `mm_admin` cookie
(HttpOnly, SameSite=Lax, 7 days), kept in memory, so a restart signs admins out. A wrong
password waits a second; tries are limited to 10 a minute per IP. Without a session, pages
redirect to the login and actions get a 401. With `MM_DEV=1` there is no login, so never set
it in production; with no `ADMIN_PASSWORD` (and no `MM_DEV`) the admin is locked. Every admin
page shares one nav: Players · Economy · Live ops · Log out.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils` | Redirects to `/utils/players` (`/` redirects here). |
| `/utils/login` | The login form; `POST /utils/logout` signs out. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
@@ -158,26 +167,26 @@ Actions (all `POST`, confirmed in the page's own dialog):
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` saves it: the admin cookie, an `X-MM-User` in `ADMIN_USER_IDS`, or `MM_DEV=1` |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
Every admin action is logged in `event_log` (`admin_*` kinds, which the live-ops stats
ignore) with `"admin": "admin"` (the admin-grant API action logs the admin player's id).
Banned players get a 403 from the API ("This account is suspended").
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
Each moon's look is generated from its id (terrain technique, noise, craters, colors) by the
Defold client. The shared settings every moon starts from are stored as one JSON row in
`moon_settings` (`/api/moon-settings`) and merged over the defaults in the client's
`modules/moon/terrain.lua`.
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
A form for the constants, resource prices, store items and their upgrade tracks (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
@@ -191,31 +200,19 @@ and store items are re-synced.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
Live events and announcements reach the game in the API's `banner`. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
The admin pages' JS (`web/static/js/`): `tabs.js` (sliding tab highlight), `enter.js`
(entrance animations), `ui.js` (confirm dialog), `liveops.js`, `economy.js` (the economy
editor).
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Players are identified only by their id (`X-MM-User`, or the `mm_user` cookie: HttpOnly,
SameSite=Lax). New players are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind
a proxy).
- The admin pages need the admin login (`ADMIN_PASSWORD`, see Admin panels); without a
password they're locked.
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
@@ -237,16 +234,15 @@ First deploy:
```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
ADMIN_PASSWORD=... # strong; the admin pages at /utils
ADMIN_USER_IDS= # optional: player ids that are admins in the game API
```
Never set `MM_DEV` in production: it makes every player an admin.
Never set `MM_DEV` in production: it turns off the admin login and makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
5. Deploy. The admin pages are at `/utils` (sign in with `ADMIN_PASSWORD`).
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
+170
View File
@@ -0,0 +1,170 @@
package main
// Admin login for /utils/*: one shared password (ADMIN_PASSWORD), a login form at
// /utils/login and a random session token in the mm_admin cookie (kept in memory, so a
// restart signs admins out). With MM_DEV=1 there is no login. Without a password (and
// without MM_DEV) the admin is locked.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"os"
"strings"
"sync"
"time"
)
const (
adminCookie = "mm_admin"
adminSessionTTL = 7 * 24 * time.Hour
// adminLabel stands for the admin in event_log ("admin" in admin_* events).
adminLabel = "admin"
)
// loginFailDelay slows down password guessing (tests set it to 0).
var loginFailDelay = time.Second
// adminSessions are the signed-in admins' tokens and when they expire.
type adminSessions struct {
mu sync.Mutex
tokens map[string]time.Time
}
func (a *adminSessions) add() string {
b := make([]byte, 32)
rand.Read(b)
token := hex.EncodeToString(b)
a.mu.Lock()
defer a.mu.Unlock()
if a.tokens == nil {
a.tokens = map[string]time.Time{}
}
now := time.Now()
for t, exp := range a.tokens {
if now.After(exp) {
delete(a.tokens, t)
}
}
a.tokens[token] = now.Add(adminSessionTTL)
return token
}
func (a *adminSessions) valid(token string) bool {
a.mu.Lock()
defer a.mu.Unlock()
exp, ok := a.tokens[token]
return ok && time.Now().Before(exp)
}
func (a *adminSessions) remove(token string) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.tokens, token)
}
// adminLocked: no password set and not in dev, so nobody can sign in.
func (s *server) adminLocked() bool { return !s.dev && s.adminPassword == "" }
// adminSession reports whether the request carries a signed-in admin's cookie.
func (s *server) adminSession(r *http.Request) bool {
if s.adminLocked() {
return false
}
c, err := r.Cookie(adminCookie)
return err == nil && s.admins.valid(c.Value)
}
// requireAdmin lets signed-in admins (or anyone with MM_DEV=1) through. Others get the
// login page: a redirect for page loads, 401 with HX-Redirect for htmx and other requests.
func (s *server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.dev || s.adminSession(r) {
next.ServeHTTP(w, r)
return
}
login := "/utils/login"
if r.Method == http.MethodGet && r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, login+"?next="+r.URL.EscapedPath(), http.StatusSeeOther)
return
}
w.Header().Set("HX-Redirect", login)
http.Error(w, "Sign in to the admin", http.StatusUnauthorized)
})
}
// LoginPage is the login form's data.
type LoginPage struct {
Locked bool
Error string
Next string
}
// safeNext is where to go after signing in: a /utils page, else the admin hub.
func safeNext(next string) string {
if next == "/utils" || strings.HasPrefix(next, "/utils/") && !strings.HasPrefix(next, "/utils/login") {
return next
}
return "/utils"
}
func (s *server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.dev || s.adminSession(r) {
http.Redirect(w, r, safeNext(r.FormValue("next")), http.StatusSeeOther)
return
}
s.render(w, "login", &LoginPage{Locked: s.adminLocked(), Next: safeNext(r.FormValue("next"))})
}
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.PostFormValue("next"))
if s.adminLocked() {
s.renderLogin(w, http.StatusForbidden, &LoginPage{Locked: true, Next: next})
return
}
if s.logins != nil && !s.logins.allow(r) {
s.renderLogin(w, http.StatusTooManyRequests, &LoginPage{Error: "Too many tries, wait a minute.", Next: next})
return
}
// Hashes, so the comparison doesn't depend on the lengths either.
got := sha256.Sum256([]byte(r.PostFormValue("password")))
want := sha256.Sum256([]byte(s.adminPassword))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
time.Sleep(loginFailDelay)
s.renderLogin(w, http.StatusUnauthorized, &LoginPage{Error: "Wrong password.", Next: next})
return
}
http.SetCookie(w, &http.Cookie{
Name: adminCookie,
Value: s.admins.add(),
Path: "/",
MaxAge: int(adminSessionTTL / time.Second),
HttpOnly: true,
Secure: secureRequest(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *server) renderLogin(w http.ResponseWriter, status int, page *LoginPage) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
s.render(w, "login", page)
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(adminCookie); err == nil {
s.admins.remove(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
Secure: secureRequest(r), SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/utils/login", http.StatusSeeOther)
}
// secureRequest: the request came over HTTPS (directly, or through the proxy with
// TRUST_PROXY=1), so the cookie can be Secure.
func secureRequest(r *http.Request) bool {
return r.TLS != nil || os.Getenv("TRUST_PROXY") == "1" && r.Header.Get("X-Forwarded-Proto") == "https"
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// The admin login gate: locked without a password, open in dev, and a redirect to the
// login without a session.
func TestAdminGate(t *testing.T) {
loginFailDelay = 0
tmpl, err := (&server{}).parseTemplates()
if err != nil {
t.Fatalf("parse: %v", err)
}
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
get := func(s *server, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
s.requireAdmin(ok).ServeHTTP(w, r)
return w
}
login := func(s *server, password string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/utils/login", strings.NewReader(url.Values{"password": {password}, "next": {"/utils/economy"}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleLogin(w, r)
return w
}
// Dev: no login.
if w := get(&server{dev: true}, "/utils/players"); w.Code != http.StatusTeapot {
t.Errorf("dev: %d, want through", w.Code)
}
// No password: locked, even with an empty password.
locked := &server{templates: tmpl}
if w := get(locked, "/utils/players"); w.Code != http.StatusSeeOther {
t.Errorf("locked: %d, want a redirect to the login", w.Code)
}
if w := login(locked, ""); w.Code != http.StatusForbidden || len(w.Result().Cookies()) > 0 {
t.Errorf("locked login: %d %v, want 403 and no cookie", w.Code, w.Result().Cookies())
}
// A password: no session redirects to the login, htmx and actions get 401.
s := &server{templates: tmpl, adminPassword: "secret"}
w := get(s, "/utils/players?q=x")
if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/utils/login?next=/utils/players") {
t.Errorf("no session: %d %q, want a redirect to the login", w.Code, w.Header().Get("Location"))
}
r := httptest.NewRequest("POST", "/utils/economy/save", nil)
w = httptest.NewRecorder()
s.requireAdmin(ok).ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized || w.Header().Get("HX-Redirect") != "/utils/login" {
t.Errorf("no session, POST: %d, want 401", w.Code)
}
if w := get(s, "/utils/players", &http.Cookie{Name: adminCookie, Value: "made-up"}); w.Code != http.StatusSeeOther {
t.Errorf("unknown session: %d, want a redirect", w.Code)
}
// Wrong password: no cookie. Right one: a session that gets through.
if w := login(s, "wrong"); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) > 0 {
t.Errorf("wrong password: %d, want 401 and no cookie", w.Code)
}
w = login(s, "secret")
cookies := w.Result().Cookies()
if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/utils/economy" || len(cookies) != 1 {
t.Fatalf("login: %d %q %v", w.Code, w.Header().Get("Location"), cookies)
}
if c := cookies[0]; c.Name != adminCookie || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
t.Errorf("cookie %+v, want HttpOnly, SameSite Lax", c)
}
if w := get(s, "/utils/players", cookies[0]); w.Code != http.StatusTeapot {
t.Errorf("with session: %d, want through", w.Code)
}
// Only /utils pages after the login.
for next, want := range map[string]string{"/utils/players": "/utils/players", "https://evil": "/utils",
"//evil": "/utils", "/api/state": "/utils", "/utils": "/utils", "/utils/login": "/utils"} {
if got := safeNext(next); got != want {
t.Errorf("safeNext(%q) = %q, want %q", next, got, want)
}
}
}
+2 -2
View File
@@ -1,13 +1,13 @@
package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// speed, yield, sell prices, refinery speed; bonus.go combines them).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// which aren't refunded either); hiring them again costs the full price. Like every bonus,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
+54 -47
View File
@@ -1,7 +1,6 @@
package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the
// views build and the same actions, run through runAction (server.go). Times are unix
// JSON API for the game client (Defold): the state the views build (views.go) and the actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
import (
@@ -132,18 +131,17 @@ type APIMoonListItem struct {
}
type APITool struct {
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
YieldLevel int `json:"yieldLevel"` // levels on its upgrade tracks (0: base)
CycleLevel int `json:"cycleLevel"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs float64 `json:"cycleSecs"` // a stake now, with bonuses and live events (tenths)
Yield float64 `json:"yield"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
}
type APIStake struct {
@@ -156,7 +154,7 @@ type APIStake struct {
Reach int `json:"reach"`
Start int64 `json:"start"`
End int64 `json:"end"`
Secs int `json:"durationSecs"`
Secs float64 `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
@@ -167,7 +165,7 @@ type APIStake struct {
Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"`
State string `json:"state,omitempty"`
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 Y1 C2: +2"
}
type APIDrillGroup struct {
@@ -228,19 +226,19 @@ type APIWell struct {
}
type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Tier int `json:"tier"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused
Attachments []string `json:"attachments"`
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
YieldLevel int `json:"yieldLevel"`
CycleLevel int `json:"cycleLevel"`
Count int `json:"count"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Blocked bool `json:"blocked"` // here is 0: the stake is refused
}
type APIBetterWell struct {
@@ -334,14 +332,6 @@ func apiAmounts(rows []ResourceBalance) []APIAmount {
return out
}
func apiGear(gear []*Attachment) []string {
out := []string{}
for _, a := range gear {
out = append(out, a.Name)
}
return out
}
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
@@ -415,8 +405,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
Image: itemImages + item.Image, YieldLevel: t.YieldLevel, CycleLevel: t.CycleLevel,
Reach: item.ToolReach()}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
tool.StakeID = st.ID
@@ -453,8 +443,8 @@ func apiState(p *Page, extra ...*Page) *APIState {
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Blocked: g.Blocked(), Attachments: apiGear(g.Gear)})
YieldLevel: g.YieldLevel, CycleLevel: g.CycleLevel, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield,
Reach: max(1, g.Reach), Here: g.Here, Free: g.Free, Short: g.Short(), Blocked: g.Blocked()})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
@@ -614,8 +604,8 @@ type APIToast struct {
Kind string `json:"kind"`
}
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
// out and what a sale earned.
// APIFX is what the client plays effects from: the action, the moons it mined out and what a
// sale earned.
type APIFX struct {
Kind string `json:"kind"`
Finished []string `json:"finished"`
@@ -641,7 +631,7 @@ func apiServerError(w http.ResponseWriter, err error) {
}
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
// (rate-limited per IP). It echoes the id in the header and cookie; on a refusal it
// writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context()
@@ -651,7 +641,15 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
userID = c.Value
}
}
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
exists := false
if uuidPattern.MatchString(userID) {
var err error
if exists, err = userExists(ctx, s.db, userID); err != nil {
apiServerError(w, err)
return "", false
}
}
if !exists {
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false
@@ -662,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
return "", false
}
created = true
} else if isBanned(ctx, s.db, userID) {
} else if banned, err := isBanned(ctx, s.db, userID); err != nil {
apiServerError(w, err)
return "", false
} else if banned {
apiError(w, http.StatusForbidden, "This account is suspended")
return "", false
}
@@ -687,6 +688,12 @@ func (s *server) apiPage(ctx context.Context, userID string, v View, get func(st
// apiPageFor builds the page for a resolved view, with what the server loads for it.
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
if v.Section == "moons" {
// The moon view is the only one that shows a description (loadState leaves them out).
if err := loadMoonDescription(ctx, s.db, state, v.Moon); err != nil {
return nil, err
}
}
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
return nil, err
@@ -773,7 +780,7 @@ func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
}
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
// handleAPIAction is POST /api/act/{action}: runs an action (runAction) and
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r)
+85 -81
View File
@@ -1,9 +1,9 @@
# Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the
web game: `GET /api/state` is built from the same view models as `GET /`, and
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
server.go), onboarding gates included. Code: `api.go`.
The game's API, for the Defold client. `GET /api/state` is built from the view models
(`views.go`), and `POST /api/act/<action>` runs the game rules through `runAction`
(server.go), onboarding gates included. Code: `api.go`. "The web" below is the former htmx
client: the API keeps its texts and formats.
Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
@@ -74,7 +74,7 @@ Core-loop actions and their params:
| action | params | notes |
|---|---|---|
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error |
| `buy-moon` | `grade` (optional: `D`, `C`, `B`, `A`, `S`; default `state.moonGrade`, the grade of the newest moon, else the lowest) | view moves to the new moon; an unknown grade is a rule error. `count` (optional): a moon pack's size (`newMoon.grades[].packs`: 5, 10, 20 once the store is open) buys that many at once, at the pack's price, each moon at least the pack's floor; the view and `reveal` show the best of them |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used. Refused ("Nothing left to mine on this well") when the drills already on the moon will take all the tool could get (its deploy card's `here` is 0, `blocked`) |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy`; a tool whose well is drained or has no room left (as `stake`) stays idle |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well, until it has no room left: each time the smallest that fills what is left alone, else the biggest (as `stake`; the rest stay idle, refused if none fits) |
@@ -85,11 +85,8 @@ The other sections' actions:
| action | params | notes |
|---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
| `buy-attachment` | `item` (attachment name) | |
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
| `sell-attachment` | `item` | a spare one |
| `upgrade` | `tool` (tool id), `track` (`yield` or `cycle`) | one level on that track of that tool (also while it mines: from its next drill) |
| `sell-tool` | `tool` | that tool: idle, nothing to claim, not the last tool (`noSale`) |
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
@@ -118,11 +115,14 @@ Responses:
## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
session; the web page also reads it once per page load.
The procedural-moon look shared by every moon (no player needed): the saved JSON object, or
`null` when none is saved. Merge it over the defaults (`DEFAULTS` in the Defold client's
`modules/moon/terrain.lua`; unknown keys and wrong types are ignored, see
`merge_settings`), then build each moon from its id. Cache it for the session.
`POST /api/moon-settings` saves it (a JSON object, up to 64 KB; 204): for a signed-in admin
(the `mm_admin` cookie), a player in `ADMIN_USER_IDS` (`X-MM-User`), or anyone with
`MM_DEV=1`; others get a 403.
## State
@@ -140,18 +140,18 @@ session; the web page also reads it once per page load.
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, grade, maxSupply, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18"); `grade` its moon grade, `maxSupply` its grade's biggest well (a full supply bar) |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `tools` | every tool: `{id, storeItemId, name, code, image, yieldLevel, cycleLevel, reach, cycleSecs, yield, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `yieldLevel`/`cycleLevel` its levels on its two upgrade tracks (0: the base tool); `cycleSecs`/`yield` at those levels with advisors and "all" live events; `cycleSecs` is in tenths of a second, e.g. `2.6`) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, blocked, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1: +2 of 3, ready"; a tool with upgrades has its levels after the name, "Prospector Drone PD-1 Y1 C2: …"); `label` is the web's compact name of the well's drills ("PD-1", "PD-1 Y1 C2", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `fullyCommitted` (`remain` is 0 with supply left: a one-well drill can't be deployed on it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, yieldLevel, cycleLevel, count, cycleSecs, yield, reach, here, free, short, blocked}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, unit, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is the shared quote (`rate` × `boost`, the same for every player; ticker, cards, `value` = balance × price, `total`); personal, on the payout only: `advisor` (sell bonus), `pressure` and its `factor` (the player's recent sales of the item), `unit` = price × advisor × factor (what one unit pays them now), `unpressed` = price × advisor, `sellAll` what selling the whole balance pays them (a sale of n pays n × unpressed × the pressure factor averaged over the n × base it adds, see `rules.pressure`); the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `boostText` "×2" when `boosted` (live event, on the quote), and the ticket's personal lines `advisorText` "+8%" when `advised` ("+8% for you") and `pressureText` "−4%" when `pressed` ("−4% from your recent selling") (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkLog, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, speed, cycleSecs, reach, parts: [{name, amount}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art at that tier); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkLog, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the selected moon's grade max, or its biggest well if bigger; with no moon selected the default grade's); `wellSupplyMax`, `wellsMin`, `wellsMax` are the default grade's (each moon carries its own `maxSupply`); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tracks: [{kind, name, levels: [{level, value, price, parts: [{name, amount}]}]}]}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); a tool's `tracks` are its yield track then its cycle track, each level's `value` the claim per cycle or the cycle in seconds from that level on, before bonuses (`reach` never changes); an advisor's `effectText` e.g. "+8% sell prices"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon"; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
@@ -165,43 +165,46 @@ short code, listed with each field, and the web's text next to it.
### equipment (`s=equipment`)
```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
drill}
```
- `image`: the tool's art at its tier, like `state.tools`.
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
`shortText` the line under it when credits are short.
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade, slots, spares
and Sell drill are Store features (`upgrade`, `equip`, `unequip`, `sell-tool` are refused
before it): show them once `unlocked.store`; before that the panel is its stats and `ladder`.
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
its drill.
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
next}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds
per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
with `tool=<id>&track=<kind>`.
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
need, have}], canAfford, blocked, short, blockedText, shortText, rateFrom, rateTo, cost,
paybackSecs}`; `label` the track name and the level it buys ("Twin rotors 2"); `blocked`
is `credits` or `parts` (`""` when it can be bought), `short` the credits missing;
`blockedText` why the button is disabled ("Need ₥750 more", "Need 2 Dusk Alloy (you have
0). Make it in the Refinery"), `shortText` the line under it when credits are short;
`rateFrom`/`rateTo` credits per hour now and after it, `cost` its price plus the parts at
sell value, `paybackSecs` null if it never pays back.
- `ladders`: each track's ladder, sent once for every tool with the same store item and
levels: `ladders[ladderKey][kind]` (`ladderKey` is `"<storeItemId>:<yieldLevel>:<cycleLevel>"`)
is every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], current,
next}]` (the other track as it is; `price` 0 for level 0).
- `resale`: what it sells for (its price and its levels' prices × `rules.equipmentResale`);
`noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
haul first").
- `bonus`: `{speed, yield}` from advisors (capped).
- `drill` (with `drill=<tool id>`, once `unlocked.equipment`, else `null`): the drill panel
`{toolId, mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo}`; its
tracks and ladders are the selected row's (`selected`). `confirm` is the sell-tool dialog.
While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy and
`deployTo` its best well (a suggestion, as in `buyModal`). Its upgrade buttons and Sell
drill are Store features (`upgrade` and `sell-tool` are refused before it): show them once
`unlocked.store`; before that the panel is its stats and the ladders.
### store (`s=store`)
```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}]}
```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
@@ -297,7 +300,9 @@ button), `credits` ("Need ₥X more", the button disabled) or `""`.
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first; `credits`
is a cycle's take after the drills already on the moon, and wells where it would get nothing
(stake refused) are left out (`allEmpty` when none is left);
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value").
@@ -324,8 +329,8 @@ is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkl
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok":
"Sell for ₥980", "danger": true}`. Texts with
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
Not sent (the client words them from the numbers, like the web's templates):
@@ -333,12 +338,13 @@ Not sent (the client words them from the numbers, like the web's templates):
supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
- upgrade payback ("pays back in 2h 5m"), "Max level", empty states ("No idle tools.").
- a tool's levels after its name, " Y2 C1" (none for a base tool).
The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- cycles: `3s`, `2.6s` (tenths under a minute), `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
@@ -354,8 +360,8 @@ The client may show results before the server answers; the next state replaces t
recompute: the moon's stakes, oldest `start` first (ties in `stakes` order), are claimed
in turn, each from what the earlier ones left (`remain`, starting at `supply`). A one-well
stake takes `min(amount, remain)` of its well; a multi-well stake (`reach` > 1) covers its
own well (if not empty) then the others with something left, most first (ties in
`moon.wells` order), up to `reach` wells, taking from each in turn as much as is left
own well (if not empty) then the next wells in `moon.wells` order (wrapping round), each
if it has something left, up to `reach` wells, taking from each in turn as much as is left
until it has `amount`. `demand` is the one-well amounts on the well plus what each
multi-well stake would take from it out of `supply`; `dryAfter` the claim (among those
taking from the well) that leaves it at 0. A deploy card's `here` is what a new stake
@@ -412,16 +418,16 @@ Content-Type: application/json
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
"image": "/static/assets/items/shovel.png", "yieldLevel": 0, "cycleLevel": 0,
"reach": 1, "cycleSecs": 3, "yield": 4, "stakeId": "efc5071d-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
@@ -431,8 +437,8 @@ Content-Type: application/json
"selected": "Azurium", "total": 0},
"banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "…": "…"},
"catalog": {"tools": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
}
}
@@ -445,20 +451,21 @@ From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
{"tools": [{"id": "13b3…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "yieldLevel": 1, "cycleLevel": 2, "count": 1, "idle": 1,
"reach": 1, "cycleSecs": 2.2, "yield": 5, "selected": true, "stake": null,
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
"ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
"ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
"cycle": ["…"]}},
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "",
"confirm": {"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 Y1 C2 for ₥980?", "ok": "Sell for ₥980", "danger": true},
"idleSame": 0, "idleBest": "", "deployTo": null}}
```
`s=store`, `state.store`:
@@ -466,10 +473,7 @@ From a live server (long ids and arrays shortened).
```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
"owned": 1, "yield": 4, "cycleSecs": 3, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"]}
```
`s=refinery`, `state.refinery`:
+191 -244
View File
@@ -9,33 +9,31 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"strings"
)
// Image URLs, as the templates build them.
const (
itemImages = "/static/assets/items/"
attachmentImages = "/static/assets/attachments/"
advisorImages = "/static/assets/advisors/"
itemImages = "/static/assets/items/"
advisorImages = "/static/assets/advisors/"
)
// ------------------------------------------------------------------ always present
// APICatalog is the static game data: every tool, attachment, advisor, resource and
// refined item with its image and color.
// APICatalog is the static game data: every tool (with its upgrade tracks), advisor,
// resource and refined item with its image and color.
type APICatalog struct {
Tools []APICatalogTool `json:"tools"`
Attachments []APICatalogAttachment `json:"attachments"`
Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"`
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
Tools []APICatalogTool `json:"tools"`
Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"`
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
}
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
// "whole moon"; "" for one), wells in the tier ladder ("1 well", "whole moon"). A reach at or
// "whole moon"; "" for one), wells ("1 well", "whole moon"). A reach at or
// above the last entry's is the whole moon.
type APIReachText struct {
Reach int `json:"reach"`
@@ -45,27 +43,31 @@ type APIReachText struct {
}
type APICatalogTool struct {
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // e.g. "PD-1"
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Yield float64 `json:"yield"` // base, tier 0
CycleSecs int `json:"cycleSecs"` // base
Reach int `json:"reach"` // tier 0
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // e.g. "PD-1"
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Yield float64 `json:"yield"` // base (level 0)
CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"`
Tracks []APICatalogTrack `json:"tracks"` // yield, then cycle
}
type APITierUp struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
ClaimBoost float64 `json:"claimBoost"`
Speed float64 `json:"speed"` // cycle speed multiplier at this tier (1: none)
CycleSecs int `json:"cycleSecs"` // the tier's cycle before bonuses
Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
// APICatalogTrack is one of a tool's upgrade tracks: its levels from 1, each setting the
// track's value (yield per cycle, or cycle seconds) before bonuses.
type APICatalogTrack struct {
Kind string `json:"kind"` // yield, cycle (upgrade with track=<kind>)
Name string `json:"name"`
Levels []APITrackLevel `json:"levels"`
}
type APITrackLevel struct {
Level int `json:"level"`
Value float64 `json:"value"`
Price float64 `json:"price"`
Parts []APIAmount `json:"parts"` // refined items it uses up
}
type APIEffect struct {
@@ -75,16 +77,6 @@ type APIEffect struct {
Refine float64 `json:"refine,omitempty"`
}
type APICatalogAttachment struct {
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"` // e.g. "+20% speed"
}
type APICatalogAdvisor struct {
Name string `json:"name"`
Title string `json:"title"`
@@ -141,27 +133,23 @@ func apiNeed(need map[string]float64) []APIAmount {
func apiCatalog(s *State) *APICatalog {
c := cfg()
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
cat := &APICatalog{Tools: []APICatalogTool{}, Advisors: []APICatalogAdvisor{},
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i]
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Speed: max(1, u.Speed), CycleSecs: item.ToolSecs(u.Tier),
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier))})
t.Tiers[len(t.Tiers)-1].Image = itemImages + item.ImageAt(u.Tier)
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount,
CycleSecs: float64(item.CompletionTimeInSecs), Reach: item.ToolReach(), Tracks: []APICatalogTrack{}}
for _, kind := range trackKinds {
tr := item.Track(kind)
ct := APICatalogTrack{Kind: kind, Name: tr.Name, Levels: []APITrackLevel{}}
for n, l := range tr.Levels {
ct.Levels = append(ct.Levels, APITrackLevel{Level: n + 1, Value: l.Value, Price: l.Price, Parts: apiNeed(l.Parts)})
}
t.Tracks = append(t.Tracks, ct)
}
cat.Tools = append(cat.Tools, t)
}
for i := range c.Attachments.Items {
a := &c.Attachments.Items[i]
cat.Attachments = append(cat.Attachments, APICatalogAttachment{Name: a.Name, Image: attachmentImages + a.Image,
Description: a.Description, Price: a.Price, Resale: a.Resale(), Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
@@ -210,7 +198,6 @@ type APIConfirm struct {
type APIRules struct {
WellSupplyMax float64 `json:"wellSupplyMax"` // the default grade's biggest well (each moon has its maxSupply)
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars: the selected moon's grade
SlotsPerTool int `json:"slotsPerTool"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
Pressure APIPressure `json:"pressure"`
@@ -218,7 +205,7 @@ type APIRules struct {
MoonResale MoonResale `json:"moonResale"`
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
BonusCaps BonusCaps `json:"bonusCaps"`
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped (tools add attachments)
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
}
@@ -241,7 +228,7 @@ func apiRules(p *Page) *APIRules {
s, c := p.S, cfg()
b := s.Bonus()
lo, hi := s.wellCountRange()
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
r := &APIRules{WellSupplyMax: s.MoonGrade().WellSupplyMax, MaxSupply: p.MaxSupply,
WellsMin: lo, WellsMax: hi,
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
@@ -317,47 +304,51 @@ type APILiveEvent struct {
// ------------------------------------------------------------------ equipment
type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
// The upgrade ladders by ladderKey (store item and levels: every tool with those has
// the same), each track's by kind; a tool row names its own (ladderKey).
Ladders map[string]map[string][]APILadderStep `json:"ladders"`
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
}
type APIToolRow struct {
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Count int `json:"count"`
Idle int `json:"idle"`
Reach int `json:"reach"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
Slots int `json:"slots"`
FreeSlots int `json:"freeSlots"`
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Next *APINextTier `json:"next"` // null at the max tier
Preview *APIPreview `json:"preview"`
Ladder []APITierRow `json:"ladder"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped
Gear []APIGearDetail `json:"gear"`
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
YieldLevel int `json:"yieldLevel"`
CycleLevel int `json:"cycleLevel"`
Count int `json:"count"`
Idle int `json:"idle"`
Reach int `json:"reach"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Tracks []APITrack `json:"tracks"` // yield, then cycle
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors, capped
}
type APIGearDetail struct {
Name string `json:"name"`
Image string `json:"image"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle,
// or the cycle in seconds), with bonuses and live events.
type APITrack struct {
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
Name string `json:"name"`
Level int `json:"level"`
Max int `json:"max"`
Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level
}
type APINextTier struct {
Tier int `json:"tier"`
type APINextLevel struct {
Level int `json:"level"`
Value float64 `json:"value"`
Label string `json:"label"` // the button's track name and level, "Twin rotors 2"
Price float64 `json:"price"`
Parts []APIPart `json:"parts"`
CanAfford bool `json:"canAfford"` // credits and parts
@@ -365,8 +356,12 @@ type APINextTier struct {
Short float64 `json:"short"` // credits missing
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
// when credits are short ("Need ₥X more").
BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"`
BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"`
RateFrom float64 `json:"rateFrom"` // credits per hour now and after it
RateTo float64 `json:"rateTo"`
Cost float64 `json:"cost"` // price plus the parts at sell value
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
}
type APIPart struct {
@@ -375,148 +370,106 @@ type APIPart struct {
Have float64 `json:"have"`
}
type APIPreview struct {
Tier int `json:"tier"`
YieldFrom float64 `json:"yieldFrom"`
YieldTo float64 `json:"yieldTo"`
SecsFrom int `json:"secsFrom"`
SecsTo int `json:"secsTo"`
ReachFrom int `json:"reachFrom"`
ReachTo int `json:"reachTo"`
RateFrom float64 `json:"rateFrom"` // credits per hour
RateTo float64 `json:"rateTo"`
Cost float64 `json:"cost"` // price plus the parts at sell value
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
}
type APITierRow struct {
Tier int `json:"tier"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour
Current bool `json:"current"`
Next bool `json:"next"`
type APILadderStep struct {
Level int `json:"level"`
Value float64 `json:"value"`
Rate float64 `json:"rate"` // credits per hour
Price float64 `json:"price"` // 0 for level 0
Parts []APIPart `json:"parts"`
Current bool `json:"current"`
Next bool `json:"next"`
}
type APIDrillPanel struct {
ToolID string `json:"toolId"`
Slots []APISlot `json:"slots"`
Spares []APISpare `json:"spares"`
Mining bool `json:"mining"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"`
NoSaleText string `json:"noSaleText"`
Confirm APIConfirm `json:"confirm"` // before sell-tool
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well
Ladder []APITierRow `json:"ladder"`
Gear []APIGearDetail `json:"gear"`
ToolID string `json:"toolId"`
Mining bool `json:"mining"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"`
NoSaleText string `json:"noSaleText"`
Confirm APIConfirm `json:"confirm"` // before sell-tool
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well
}
type APISlot struct {
N int `json:"n"`
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
func apiParts(rows []PartRow) []APIPart {
out := []APIPart{}
for _, p := range rows {
out = append(out, APIPart{Name: p.Name, Need: p.Need, Have: p.Have})
}
return out
}
type APISpare struct {
Name string `json:"name"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"` // the drill's stats with it equipped
Yield float64 `json:"yield"`
CanEquip bool `json:"canEquip"`
Why string `json:"why"` // fitted, full
WhyText string `json:"whyText"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Confirm APIConfirm `json:"confirm"` // before sell-attachment
}
func apiTierRows(rows []TierRow) []APITierRow {
out := []APITierRow{}
func apiTracks(s *State, rows []TrackRow) []APITrack {
out := []APITrack{}
for _, r := range rows {
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
Current: r.Current, Next: r.Next})
}
return out
}
func apiGearDetail(gear []*Attachment) []APIGearDetail {
out := []APIGearDetail{}
for _, a := range gear {
out = append(out, APIGearDetail{Name: a.Name, Image: attachmentImages + a.Image, Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
e := &APIEquipment{Tools: []APIToolRow{}}
for _, row := range p.Equipment {
b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
NoSaleText: noSaleText[s.noSale(row)],
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}, Gear: apiGearDetail(row.Gear)}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
t.Stake = &st
}
if n := row.Next; n != nil {
next := &APINextTier{Tier: n.Tier, Price: n.Price, Parts: []APIPart{}, CanAfford: row.CanAfford,
Short: max(0, n.Price-s.Balance), BlockedText: row.Missing, ShortText: row.Short}
for _, part := range row.Parts {
next.Parts = append(next.Parts, APIPart{Name: part.Name, Need: part.Need, Have: part.Have})
if !part.OK() && next.Blocked == "" && next.Short == 0 {
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
if n := r.Next; n != nil {
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price,
Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
BlockedText: r.Missing, ShortText: r.Short, RateFrom: r.RateFrom, RateTo: r.RateTo, Cost: r.Cost}
for _, part := range r.Parts {
if !part.OK() && next.Blocked == "" {
next.Blocked = "parts"
}
}
if next.Short > 0 {
next.Blocked = "credits"
}
if gain := r.RateGain(); gain > 0 {
secs := r.Cost / gain * 3600
next.PaybackSecs = &secs
}
t.Next = next
}
if u := row.Preview; u != nil {
pv := &APIPreview{Tier: u.Tier, YieldFrom: u.YieldFrom, YieldTo: u.YieldTo, SecsFrom: u.SecsFrom, SecsTo: u.SecsTo,
ReachFrom: max(1, u.ReachFrom), ReachTo: max(1, u.ReachTo), RateFrom: u.RateFrom, RateTo: u.RateTo, Cost: u.Cost}
if gain := u.RateGain(); gain > 0 {
secs := u.Cost / gain * 3600
pv.PaybackSecs = &secs
}
t.Preview = pv
out = append(out, t)
}
return out
}
// ladderKey names a tool's ladders: "<store item>:<yield level>:<cycle level>".
func ladderKey(storeItemID int, lv Levels) string {
return fmt.Sprintf("%d:%d:%d", storeItemID, lv.Yield, lv.Cycle)
}
// apiLadders are the item's ladders at the levels, by kind (the tracks it has).
func apiLadders(s *State, item *StoreItem, lv Levels, tracks []TrackRow) map[string][]APILadderStep {
out := map[string][]APILadderStep{}
for _, r := range tracks {
steps := []APILadderStep{}
for _, st := range s.trackLadder(item, lv, r.Kind) {
steps = append(steps, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
out[r.Kind] = steps
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
b := s.Bonus()
e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
for _, row := range p.Equipment {
key := ladderKey(row.StoreItemID, row.Levels())
if _, ok := e.Ladders[key]; !ok {
e.Ladders[key] = apiLadders(s, row.Item, row.Levels(), row.Tracks)
}
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.Image, YieldLevel: row.YieldLevel, CycleLevel: row.CycleLevel,
Count: row.Count, Idle: row.Idle, Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield,
Selected: row.Selected, Tracks: apiTracks(s, row.Tracks), LadderKey: key, Resale: row.Item.ToolResale(row.Levels()),
NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
t.Stake = &st
}
e.Tools = append(e.Tools, t)
}
if d := p.Drill; d != nil {
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder),
Gear: apiGearDetail(d.Gear),
dp := &APIDrillPanel{ToolID: d.ID, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame,
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
for _, slot := range d.Slots {
a := APISlot{N: slot.N}
if slot.Gear != nil {
a.Attachment = slot.Gear.Name
}
dp.Slots = append(dp.Slots, a)
}
for _, sg := range d.Spares {
sp := APISpare{Name: sg.Name, Count: sg.Count, CycleSecs: sg.Secs, Yield: sg.Yield, CanEquip: sg.CanEquip,
WhyText: sg.Why, Price: sg.Price, Resale: sg.Resale(),
Confirm: APIConfirm{Title: "Sell " + sg.Name, Text: sg.Confirm(), OK: "Sell for " + formatCredits(sg.Resale()), Danger: true}}
switch {
case sg.CanEquip:
case strings.HasPrefix(sg.Why, "Already"):
sp.Why = "fitted"
default:
sp.Why = "full"
}
dp.Spares = append(dp.Spares, sp)
}
if d.IdleBest != nil {
dp.IdleBest = d.IdleBest.ID
}
@@ -535,7 +488,7 @@ func apiEquipment(p *Page) *APIEquipment {
type APIBuyModal struct {
ToolID string `json:"toolId"`
StoreItemID int `json:"storeItemId"`
CycleSecs int `json:"cycleSecs"`
CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
NoMoons bool `json:"noMoons"`
@@ -579,8 +532,7 @@ func apiBuyModal(m *BuyModal) *APIBuyModal {
// ------------------------------------------------------------------ store
type APIStore struct {
Tools []APIStoreTool `json:"tools"`
Attachments []APIStoreGear `json:"attachments"`
Tools []APIStoreTool `json:"tools"`
}
type APIStoreTool struct {
@@ -593,27 +545,13 @@ type APIStoreTool struct {
CanAfford bool `json:"canAfford"`
Owned int `json:"owned"`
Yield float64 `json:"yield"` // base, as the card shows
CycleSecs int `json:"cycleSecs"` // base
CycleSecs float64 `json:"cycleSecs"` // base
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
BestRate float64 `json:"bestRate"` // the same for the player's best tool
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
}
type APIStoreGear struct {
Name string `json:"name"` // buy-attachment item=<name>
Image string `json:"image"`
Description string `json:"description"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
Spare int `json:"spare"`
Equipped int `json:"equipped"`
Resale float64 `json:"resale"`
WhyText string `json:"whyText"` // why Buy is disabled, "" if it isn't
}
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
func whyShort(canAfford bool, balance, price float64) string {
if canAfford {
@@ -624,17 +562,12 @@ func whyShort(canAfford bool, balance, price float64) string {
func apiStore(p *Page) *APIStore {
bal := p.S.Balance
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
st := &APIStore{Tools: []APIStoreTool{}}
for _, r := range p.Store {
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
}
for _, g := range p.Gear {
st.Attachments = append(st.Attachments, APIStoreGear{Name: g.Name, Image: attachmentImages + g.Image,
Description: g.Description, Effect: effectOf(g.Speed, g.Yield, 0, 0), EffectText: g.Attachment.Effect(), Price: g.Price,
CanAfford: g.CanAfford, Spare: g.Spare, Equipped: g.Equipped, Resale: g.Resale(), WhyText: whyShort(g.CanAfford, bal, g.Price)})
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: float64(r.Item.CompletionTimeInSecs),
Reach: r.Item.ToolReach(), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
}
return st
}
@@ -911,7 +844,7 @@ func apiHelp(s *State) *APIHelp {
"Pick a well on your moon and deploy a tool on it.",
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
"Sell resources in the Market, upgrade your equipment, claim more moons.",
"Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.",
"Each drill has two upgrade tracks in Equipment: one raises what a cycle mines, the other makes cycles shorter. Hire advisors to boost your whole operation.",
}, Note: "Your progress is saved on this device.",
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
}
@@ -941,13 +874,27 @@ type APIMoonGrade struct {
SupplyMax float64 `json:"supplyMax"`
Default bool `json:"default"` // what buy-moon without a grade buys
WhyText string `json:"whyText"` // why its buy button is disabled, "" if it isn't
// Moon packs (once the store is open): buy-moon with grade=<grade>&count=<count>.
Packs []APIMoonPack `json:"packs"`
}
// APIMoonPack is count moons of a grade bought at once, for price (discount off; each moon
// at least a little better than a single one's floor).
type APIMoonPack struct {
Count int `json:"count"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
CanAfford bool `json:"canAfford"`
}
func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
out := []APIMoonGrade{}
for _, g := range gs {
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax,
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default}
SupplyMin: g.SupplyMin, SupplyMax: g.SupplyMax, Default: g.Default, Packs: []APIMoonPack{}}
for _, p := range g.Packs {
a.Packs = append(a.Packs, APIMoonPack{Count: p.Count, Price: p.Price, Discount: p.Discount, CanAfford: p.CanAfford})
}
if !g.CanAfford {
a.WhyText = "Not enough credits"
}
+12 -13
View File
@@ -102,7 +102,7 @@ func TestAPI(t *testing.T) {
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
}
for _, st := range a.Stakes {
if st.End-st.Start != int64(st.Secs)*1000 {
if st.End-st.Start != int64(math.Round(st.Secs*1000)) {
t.Errorf("stake %s: end - start is not its duration", st.ID)
}
}
@@ -191,27 +191,28 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach)
}
for _, at := range c.Attachments {
if at.EffectText == "" {
fail("attachment effect", at)
for _, tool := range c.Tools {
if len(tool.Tracks) != 2 || tool.Tracks[0].Kind != "yield" || tool.Tracks[1].Kind != "cycle" {
fail("tool tracks", tool)
}
}
}
if e := a.Equipment; e != nil {
for _, r := range e.Tools {
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
if (r.NoSale != "") != (r.NoSaleText != "") {
fail("tool row texts", r)
}
for _, tr := range r.Tracks {
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
fail("track texts", tr)
}
}
}
if d := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d)
}
for _, sp := range d.Spares {
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
fail("spare texts", sp)
}
}
}
}
if st := a.Store; st != nil {
@@ -268,11 +269,9 @@ func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
t.Errorf("view %+v: %d slots", v, len(d.Slots))
}
case "store":
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) {
t.Errorf("view %+v: store %+v", v, a.Store)
}
case "refinery":
-18
View File
@@ -1,18 +0,0 @@
# Drill attachment art
The attachment images (`web/static/assets/attachments/<image>`, `image` in
`config/game.json` `attachments.items`) are rendered in Blender (4.5 LTS, Cycles on
CPU) with the equipment pipeline: `art/equipment/lib.py` for materials, the regolith
ground, world, lights, camera and render settings.
```sh
art/attachments/render_all.sh # all four
art/attachments/render_all.sh diamond-bit # just one
```
`render.py` builds and renders at 1024px, `finish.py` (system `python3` with Pillow)
writes the 512px PNGs and contact sheets in `build/`. `--res N` and `--samples N`
give quick previews.
`parts.py`: one build function per attachment and `PARTS` (key = image name, detail
scale `S`, ground seed).
-48
View File
@@ -1,48 +0,0 @@
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
python3 art/attachments/finish.py [RAW_DIR] [--sheet DIR]
Needs Pillow. Writes web/static/assets/attachments/<name>.png for every <name>.png in RAW_DIR
(the names are the `image` slugs in config/game.json).
"""
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'attachments'))
def main():
a = sys.argv[1:]
sheet_dir = None
if '--sheet' in a:
i = a.index('--sheet')
sheet_dir = a[i + 1]
del a[i:i + 2]
raw = a[0] if a else os.path.join(HERE, 'build')
os.makedirs(OUT, exist_ok=True)
imgs = []
for fname in sorted(f for f in os.listdir(raw) if f.endswith('.png') and not f.startswith('contact_sheet')):
im = Image.open(os.path.join(raw, fname)).convert('RGB').resize((512, 512), Image.LANCZOS)
out = os.path.join(OUT, fname)
im.save(out, 'PNG', optimize=True)
if os.path.getsize(out) > 200_000:
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
q.save(out, 'PNG', optimize=True)
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
imgs.append(Image.open(out).convert('RGB'))
if sheet_dir and imgs:
os.makedirs(sheet_dir, exist_ok=True)
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
pad = 8
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
for i, im in enumerate(imgs):
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
sheet.save(os.path.join(sheet_dir, fn))
print('contact sheets in', sheet_dir)
main()
-174
View File
@@ -1,174 +0,0 @@
"""The four drill attachments. Each build function draws into a lib.Builder (art/equipment).
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
Keys are the image names (`image` in config/game.json `attachments.items`, minus .png).
"""
import math
from lib import CYAN, AMBER
PI = math.pi
# Turbo Core: an overdriven motor core. Finned housing, glowing copper windings, flange.
def turbo_core(b):
z0 = 0.05
# mounting flange and feet
b.cyl(0.17, 0.035, 'graphite', (0, 0, z0 + 0.0175))
for k in range(8):
a = k / 8 * 2 * PI + PI / 8
b.cyl(0.011, 0.012, 'chrome', (math.cos(a) * 0.145, math.sin(a) * 0.145, z0 + 0.04), seg=8)
for sx in (-1, 1):
b.box((0.06, 0.32, 0.05), 'dark', (sx * 0.14, 0, 0.025), bevel=0.008)
# housing: lower stator drum with radial fins
zc = z0 + 0.035
b.cyl(0.12, 0.22, 'steel', (0, 0, zc + 0.11))
for k in range(20):
a = k / 20 * 2 * PI
with b.at((0, 0, zc + 0.11), (0, 0, a)):
b.box((0.05, 0.008, 0.2), 'graphite', (0.135, 0, 0), bevel=0.002)
for z in (zc + 0.015, zc + 0.205):
b.torus(0.155, 0.012, 'graphite', (0, 0, z))
# exposed windings: glowing core behind a cage
zw = zc + 0.22
b.cyl(0.085, 0.13, 'core_hot', (0, 0, zw + 0.065))
b.helix((0, 0), 0.092, zw + 0.008, zw + 0.122, 7, 0.008, 'brass')
for k in range(8):
a = k / 8 * 2 * PI
b.strut((math.cos(a) * 0.105, math.sin(a) * 0.105, zw), (math.cos(a) * 0.105, math.sin(a) * 0.105, zw + 0.13),
0.006, 'chrome', seg=8)
b.light((0, -0.16, zw + 0.07), AMBER, 6, 0.03)
b.light((0.16, 0.0, zw + 0.07), AMBER, 3, 0.03)
# cap with turbine fan and shaft coupling
zt = zw + 0.13
b.cyl(0.115, 0.03, 'white', (0, 0, zt + 0.015))
b.cyl(0.08, 0.02, 'dark', (0, 0, zt + 0.04))
for k in range(12):
a = k / 12 * 2 * PI
with b.at((0, 0, zt + 0.045), (0, 0, a)):
b.box((0.065, 0.004, 0.012), 'metal', (0.04, 0, 0), (0.6, 0, 0), bevel=0.001)
b.cyl(0.03, 0.08, 'chrome', (0, 0, zt + 0.08))
b.cyl(0.04, 0.02, 'graphite', (0, 0, zt + 0.125))
for k in range(4):
a = k / 4 * 2 * PI
b.box((0.012, 0.012, 0.02), 'chrome', (math.cos(a) * 0.04, math.sin(a) * 0.04, zt + 0.14), (0, 0, a), bevel=0.002)
# status lamp, label plate and cables
b.box((0.08, 0.006, 0.04), 'white', (0.0, -0.157, zc + 0.12), bevel=0.002)
b.box((0.05, 0.004, 0.006), 'amber', (0.0, -0.161, zc + 0.12), bevel=0)
b.lamp((0.1, -0.1, zt + 0.035), 0.012, 'red', housing=False)
b.tube([(-0.12, 0.08, zw + 0.03), (-0.2, 0.12, zw - 0.05), (-0.24, 0.05, 0.02)], 0.012, 'rubber')
b.tube([(-0.12, 0.03, zw + 0.06), (-0.22, 0.02, zw - 0.02), (-0.26, -0.06, 0.02)], 0.009, 'orange')
# Diamond Bit: a fluted drill bit with a diamond-studded cutting crown, upright on its shank.
def diamond_bit(b):
# shank in a coupling block on the ground
b.box((0.2, 0.2, 0.05), 'graphite', (0, 0, 0.025), bevel=0.01)
b.hazard_band(0.21, 0.21, 0.015, (0, 0, 0.055))
b.cyl(0.065, 0.08, 'steel', (0, 0, 0.1))
for k in range(6):
a = k / 6 * 2 * PI
b.box((0.018, 0.035, 0.07), 'metal', (math.cos(a) * 0.066, math.sin(a) * 0.066, 0.1), (0, 0, a), bevel=0.004)
# fluted body
z0, z1 = 0.14, 0.44
b.cyl(0.045, z1 - z0, 'graphite', (0, 0, (z0 + z1) / 2))
for k in range(2):
pts = []
n = 48
for i in range(n + 1):
t = i / n
a = k * PI + t * 2.6 * PI
pts.append((math.cos(a) * 0.055, math.sin(a) * 0.055, z0 + (z1 - z0) * t))
b.tube(pts, 0.024, 'metal', seg=12, steps=2)
# crown: carbide ring with diamond studs, cone tip
zc = z1 + 0.01
b.lathe([(0.07, zc - 0.03), (0.09, zc), (0.09, zc + 0.035), (0.08, zc + 0.045), (0.0, zc + 0.045)], 'graphite')
b.lathe([(0.078, zc + 0.045), (0.05, zc + 0.09), (0.015, zc + 0.13), (0.0, zc + 0.135)], 'steel')
for k in range(12):
a = k / 12 * 2 * PI
b.sphere(0.014, 'diamond', (math.cos(a) * 0.089, math.sin(a) * 0.089, zc + 0.02), seg=6)
for k in range(7):
a = k / 7 * 2 * PI + 0.3
b.sphere(0.011, 'diamond', (math.cos(a) * 0.05, math.sin(a) * 0.05, zc + 0.088), seg=6)
b.sphere(0.01, 'diamond', (0, 0, zc + 0.136), seg=6)
b.light((0.0, -0.15, zc + 0.06), CYAN, 3, 0.02)
# a couple of loose diamonds on the ground
for x, y in ((0.16, -0.1), (-0.13, -0.15), (0.07, -0.17)):
b.sphere(0.013, 'diamond', (x, y, 0.008), seg=6)
# Cryo Cooler: a coolant jacket that wraps the spindle. Frosted canister, glowing coolant
# windows, coiled lines and a gauge.
def cryo_cooler(b):
b.cyl(0.16, 0.03, 'graphite', (0, 0, 0.015))
b.cyl(0.15, 0.015, 'cyan_soft', (0, 0, 0.035))
# jacket
z0, z1 = 0.045, 0.34
b.cyl(0.11, z1 - z0, 'frost', (0, 0, (z0 + z1) / 2))
for z in (z0 + 0.02, (z0 + z1) / 2, z1 - 0.02):
b.torus(0.112, 0.012, 'steel', (0, 0, z))
for k in range(4):
a = k / 4 * 2 * PI + PI / 4
with b.at((0, 0, 0), (0, 0, a)):
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z0 + 0.075), bevel=0.003)
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z1 - 0.075), bevel=0.003)
b.light((0, -0.25, 0.19), CYAN, 8, 0.05)
# spindle through the middle
b.cyl(0.035, 0.12, 'chrome', (0, 0, z1 + 0.06))
b.cyl(0.075, 0.03, 'white', (0, 0, z1 + 0.015))
b.cyl(0.045, 0.02, 'graphite', (0, 0, z1 + 0.12))
# coolant coil around the jacket and feed lines
b.helix((0, 0), 0.128, z0 + 0.04, z1 - 0.04, 4, 0.007, 'chrome')
b.tube([(0.12, 0.05, z1 - 0.04), (0.2, 0.08, z1 + 0.02), (0.24, 0.12, 0.1), (0.26, 0.2, 0.02)], 0.014, 'white')
b.tube([(-0.12, 0.05, z0 + 0.05), (-0.2, 0.1, 0.05), (-0.22, 0.2, 0.02)], 0.012, 'rubber')
# pressure gauge
with b.at((-0.08, -0.1, z1 - 0.05), (0, 0, -0.6)):
b.cyl(0.03, 0.02, 'steel', (0, -0.02, 0), axis='y')
b.cyl(0.024, 0.005, 'white', (0, -0.031, 0), axis='y')
b.box((0.003, 0.002, 0.02), 'red', (0.005, -0.034, 0.004), (0, 0.6, 0), bevel=0)
# frost chips on the ground
for x, y, r in ((0.18, -0.06, 0.016), (-0.17, -0.1, 0.012), (0.08, -0.18, 0.01)):
b.sphere(r, 'ice', (x, y, r * 0.4), seg=10, scale=(1.2, 1.0, 0.6))
# Seam Scanner: ground-penetrating radar on a sled. Antenna panel facing the ground,
# rotating dish, a scan display.
def seam_scanner(b):
# sled runners
for sx in (-1, 1):
b.box((0.03, 0.42, 0.025), 'dark', (sx * 0.15, 0, 0.0125), bevel=0.008)
b.strut((sx * 0.15, -0.21, 0.012), (sx * 0.15, -0.25, 0.05), 0.013, 'dark')
# radar body with glowing underside emitter
b.box((0.36, 0.34, 0.08), 'white', (0, 0, 0.075), bevel=0.02)
b.box((0.3, 0.28, 0.02), 'cyan', (0, 0, 0.032), bevel=0.004)
b.light((0, 0, 0.01), CYAN, 5, 0.1)
b.hazard_band(0.37, 0.02, 0.02, (0, -0.17, 0.085))
# electronics box
b.box((0.22, 0.2, 0.1), 'graphite', (0.02, 0.04, 0.165), bevel=0.012)
b.vent(0.12, 0.05, (0.131, 0.06, 0.165), (0, 0, PI / 2))
# display with a scan grid
with b.at((-0.02, -0.08, 0.23), (-0.5, 0, 0)):
b.box((0.16, 0.012, 0.11), 'dark', (0, 0, 0), bevel=0.006)
b.box((0.14, 0.004, 0.09), 'glass', (0, -0.007, 0), bevel=0)
# mast with a dish
b.cyl(0.018, 0.16, 'steel', (0.08, 0.1, 0.29))
b.cyl(0.03, 0.02, 'graphite', (0.08, 0.1, 0.37))
b.lathe([(0.0, 0.0), (0.05, 0.012), (0.09, 0.04)], 'white', (0.08, 0.1, 0.39), (0.5, 0.0, -0.4))
b.strut((0.08, 0.1, 0.39), (0.08 - 0.02, 0.1 - 0.035, 0.45), 0.004, 'graphite')
b.lamp((0.06, 0.065, 0.455), 0.01, 'cyan', housing=False)
# antenna whips and lamp
b.strut((-0.13, 0.12, 0.215), (-0.15, 0.14, 0.42), 0.004, 'graphite')
b.lamp((-0.15, 0.14, 0.425), 0.009, 'amber', housing=False)
b.lamp((0.14, -0.1, 0.13), 0.01, 'amber')
# cable reel at the back
b.cyl(0.05, 0.05, 'orange', (-0.1, 0.13, 0.17), axis='x')
b.cyl(0.035, 0.055, 'rubber', (-0.1, 0.13, 0.17), axis='x')
PARTS = {
'turbo-core': {'build': turbo_core, 'S': 0.3, 'seed': 11},
'diamond-bit': {'build': diamond_bit, 'S': 0.25, 'seed': 12, 'fill': 0.8},
'cryo-cooler': {'build': cryo_cooler, 'S': 0.3, 'seed': 13},
'seam-scanner': {'build': seam_scanner, 'S': 0.3, 'seed': 14},
}
-87
View File
@@ -1,87 +0,0 @@
"""Build and render the drill attachment icons.
blender -b --factory-startup --python art/attachments/render.py -- [names...|all] [--out DIR]
[--res N] [--samples N]
Raw renders go to --out (default art/attachments/build); finish.py turns them into the
game images. Uses art/equipment/lib.py, so the parts match the store equipment.
"""
import os
import sys
import time
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, '..', 'equipment'))
import lib # noqa: E402
import parts # noqa: E402
from lib import srgb # noqa: E402
def args():
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
opts = {'out': os.path.join(HERE, 'build'), 'res': 1024, 'samples': 96}
names = []
i = 0
while i < len(a):
k = a[i]
if k == '--out':
opts['out'] = a[i + 1]; i += 2
elif k in ('--res', '--samples'):
opts[k[2:]] = int(a[i + 1]); i += 2
else:
names.append(k); i += 1
if not names or names == ['all']:
names = list(parts.PARTS)
return names, opts
def materials(S):
M = lib.make_materials(S)
M['core_hot'] = lib.emissive('core_hot', srgb('#ff8a2b'), 9.0, base=srgb('#5a2a0a'))
M['diamond'] = lib.simple('diamond', srgb('#e8f8ff'), metal=0.3, rough=0.03, coat=1.0,
emission=srgb('#9fe8ff'), estrength=0.35)
M['frost'] = lib.paint('frost', srgb('#e4f1f8'), S, rough=0.55, panels=False, wear=0.2, dirt=0.25)
M['coolant'] = lib.emissive('coolant', srgb('#5fd4ff'), 5.0, base=srgb('#10303c'))
M['ice'] = lib.simple('ice', srgb('#cfeefc'), rough=0.15, coat=1.0, emission=srgb('#5fd4ff'), estrength=0.3)
return M
def build_scene(name):
spec = parts.PARTS[name]
lib.reset()
coll = bpy.context.scene.collection
S = spec['S']
b = lib.Builder(name, materials(S), S)
spec['build'](b)
objs = b.finish(coll)
return spec, objs, coll
def main():
names, o = args()
for name in names:
t0 = time.time()
spec, objs, coll = build_scene(name)
pts = lib.machine_points(objs)
lo = [min(p[i] for p in pts) for i in range(3)]
hi = [max(p[i] for p in pts) for i in range(3)]
span = max(hi[0] - lo[0], hi[1] - lo[1])
lib.build_ground(coll, spec['S'], span * spec.get('ground', 0.9), ((lo[0], lo[1]), (hi[0], hi[1])),
seed=spec.get('seed', 3))
lib.build_world()
lib.add_lights(coll)
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.78), aim=spec.get('aim', (0.5, 0.5)))
lib.setup_render(o['res'], o['samples'])
os.makedirs(o['out'], exist_ok=True)
sc = bpy.context.scene
sc.render.filepath = os.path.join(o['out'], name + '.png')
bpy.ops.render.render(write_still=True)
print(f'[attachments] {name}: {time.time() - t0:.1f}s', flush=True)
main()
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# Render every attachments image (or the named ones) and write the game images.
# art/attachments/render_all.sh [name ...]
set -e
here=$(cd "$(dirname "$0")" && pwd)
blender=${BLENDER:-blender}
[ $# -eq 0 ] && set -- all
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" --out "$here/build"
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
-323
View File
@@ -1,323 +0,0 @@
package main
// Drill attachments: equipment bought in the Store that goes into the player's inventory,
// then is equipped on one drill in the Equipment section (the drill's side panel). An
// attachment's modifier makes the drill's stakes faster (speed) or bigger (yield). A drill
// has attachments.slotsPerTool slots and takes at most one of each kind. Equipping and
// unequipping work any time, also while the drill mines: like every bonus (bonus.go), it
// counts from the drill's next stake. Table attachment (inventory_item_id null: unequipped).
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AttachmentConfig struct {
SlotsPerTool int `json:"slotsPerTool"`
Items []Attachment `json:"items"`
}
type Attachment struct {
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"` // in web/static/assets/attachments
Price float64 `json:"price"`
Speed float64 `json:"speed,omitempty"` // modifiers (multipliers); unset is 1
Yield float64 `json:"yield,omitempty"`
}
func (c *GameConfig) attachment(name string) *Attachment {
for i := range c.Attachments.Items {
if c.Attachments.Items[i].Name == name {
return &c.Attachments.Items[i]
}
}
return nil
}
// Effect describes the modifier, e.g. "+20% speed".
func (a *Attachment) Effect() string {
var parts []string
if a.Speed > 1 {
parts = append(parts, pctText(a.Speed)+" speed")
}
if a.Yield > 1 {
parts = append(parts, pctText(a.Yield)+" yield")
}
return strings.Join(parts, ", ")
}
// buyAttachment buys an attachment into the player's inventory (unequipped).
func buyAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().attachment(name)
if a == nil {
return gameErr("That attachment isn't for sale")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err := tx.Exec(ctx, "insert into attachment(user_id, item) values ($1, $2)", userID, a.Name)
if err != nil {
return err
}
if err := unlock(ctx, tx, userID, "advisors"); err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_attachment", map[string]any{"item": a.Name, "price": a.Price})
})
}
// lockTool locks one of the player's tools.
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
if !uuidPattern.MatchString(toolID) {
return gameErr("Tool not found")
}
var id string
err := tx.QueryRow(ctx, `
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found")
}
return err
}
// equipAttachment puts one of the player's spare attachments of a kind on a tool (an idle
// copy of it, if there is one: see pickTool) and returns the tool it went on.
func equipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
a := cfg().attachment(name)
if a == nil {
return "", gameErr("Unknown attachment")
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The tool row first, then its attachments.
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
rows, err := tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 for update`, toolID)
if err != nil {
return err
}
fitted, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return err
}
for _, f := range fitted {
if f == a.Name {
return gameErr("This drill already has a %s", a.Name)
}
}
if len(fitted) >= cfg().Attachments.SlotsPerTool {
return gameErr("This drill's slots are full. Unequip one first")
}
var id string
err = tx.QueryRow(ctx, `
select id from attachment
where user_id = $1 and item = $2 and inventory_item_id is null
limit 1 for update skip locked`, userID, a.Name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("You have no spare %s. Buy one in the store", a.Name)
} else if err != nil {
return err
}
_, err = tx.Exec(ctx, "update attachment set inventory_item_id = $2 where id = $1", id, toolID)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "equip", map[string]any{"item": a.Name, "tool": toolID})
})
return toolID, err
}
// unequipAttachment takes an attachment of a kind off a tool (an idle copy of it, if there
// is one), back into the inventory, and returns the tool it came off.
func unequipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
update attachment set inventory_item_id = null
where inventory_item_id = $1 and item = $2`, toolID, name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return gameErr("That attachment isn't on this drill")
}
return logEvent(ctx, tx, userID, "unequip", map[string]any{"item": name, "tool": toolID})
})
return toolID, err
}
// ------------------------------------------------------------------ view
// gear is the attachments equipped on a tool, in config order.
func (s *State) gear(toolID string) []*Attachment {
var out []*Attachment
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
for _, name := range s.Fitted[toolID] {
if name == a.Name {
out = append(out, a)
}
}
}
return out
}
// AttachmentCard is an attachment in the store.
type AttachmentCard struct {
*Attachment
Spare, Equipped int
CanAfford bool
}
func (s *State) attachmentCards() []AttachmentCard {
var cards []AttachmentCard
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
card := AttachmentCard{Attachment: a, Spare: s.Spare[a.Name], CanAfford: s.Balance >= a.Price}
for _, names := range s.Fitted {
for _, n := range names {
if n == a.Name {
card.Equipped++
}
}
}
cards = append(cards, card)
}
return cards
}
// DrillPanel is the side panel for a drill selected in the Equipment section: its slots
// and the spare attachments that fit, with the stats they'd give.
type DrillPanel struct {
ToolRow
Slots []SlotGear
Spares []SpareGear
Mining bool
Resale float64 // what it sells for (resale.go)
NoSale string // why it can't be sold, if it can't
// While it mines: the idle tools of its type, the best one and its best well.
IdleSame int
IdleBest *Tool
DeployTo *Suggestion
IdleURL string // opens IdleBest's panel
// Store: the Store is unlocked, so the panel offers upgrades, attachments and selling
// (actions gated on "store", onboarding.go). Before that it shows stats and tiers only.
Store bool
}
// SlotGear is one attachment slot; Gear is nil when it's empty.
type SlotGear struct {
N int
Gear *Attachment
}
// SpareGear is a kind of spare attachment and the drill's stats with it equipped.
type SpareGear struct {
*Attachment
Count int
Secs int
Yield float64
CanEquip bool
Why string // why it can't be equipped
}
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
func (s *State) noSale(row ToolRow) string {
switch {
case len(s.Tools) <= 1:
return "only-tool"
case row.Stake != nil && row.Stake.Ready:
return "claim-first"
case row.Stake != nil:
return "mining"
}
return ""
}
// Confirm is the question before selling one (the Sell button's confirm dialog).
func (sg SpareGear) Confirm() string {
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
if len(d.Gear) > 0 {
q += " Its attachments go back to your inventory."
}
return q
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier),
Store: s.Unlocked("store")}
d.NoSale = noSaleText[s.noSale(row)]
for i := range cfg().Attachments.SlotsPerTool {
slot := SlotGear{N: i + 1}
if i < len(row.Gear) {
slot.Gear = row.Gear[i]
}
d.Slots = append(d.Slots, slot)
}
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
n := s.Spare[a.Name]
if n == 0 {
continue
}
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
names := append([]string{a.Name}, s.Fitted[row.ID]...)
sg.Secs, sg.Yield = drillStats(row.Item.ToolSecs(row.Tier), row.Item.ToolYield(row.Tier),
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
for _, g := range row.Gear {
if g.Name == a.Name {
sg.CanEquip, sg.Why = false, "Already on this drill"
}
}
if sg.CanEquip && row.FreeSlots == 0 {
sg.CanEquip, sg.Why = false, "Slots full. Unequip one first"
}
d.Spares = append(d.Spares, sg)
}
if d.Mining {
// Highest tier, then most attachments: the one pickTool would deploy.
for i := range s.Tools {
t := &s.Tools[i]
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
continue
}
d.IdleSame++
if b := d.IdleBest; b == nil || t.Tier > b.Tier ||
(t.Tier == b.Tier && len(s.Fitted[t.ID]) > len(s.Fitted[b.ID])) {
d.IdleBest = t
}
}
if d.IdleBest != nil {
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
d.DeployTo = &m.Suggestions[0]
}
}
}
return d
}
+3 -3
View File
@@ -27,9 +27,9 @@ func runSim(args []string) error {
for _, t := range a.Tools {
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
for _, u := range t.Tiers {
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n",
u.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback)
for _, u := range t.Levels {
fmt.Printf(" %-5s %d %8.0f + parts %5.0f yield %5.0f cycle %4.1fs on moon %5.0f %9.0f ₥/h payback %s\n",
u.Track, u.Level, u.Price, u.PartsValue, u.Yield, u.Secs, u.Eff, u.CreditsPerHour, u.Payback)
}
}
fmt.Println("\nrefinery")
+24 -72
View File
@@ -1,8 +1,7 @@
package main
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
// Player bonuses: advisors (advisors.go, for the whole operation). Each bonus is a
// multiplier (1.1 = +10%). A player's advisors multiply, then each kind is capped by bonusCaps
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
// when an action runs: a stake fixes its duration and amount when it starts.
@@ -15,7 +14,7 @@ import (
"github.com/jackc/pgx/v5"
)
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
// BonusCaps are the highest combined multipliers a player's advisors give.
type BonusCaps struct {
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
Yield float64 `json:"yield"` // resources per stake
@@ -38,8 +37,8 @@ func mult(v float64) float64 {
return v
}
// bonusesFor combines the named advisors and attachments, capped.
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
// bonusesFor combines the named advisors, capped.
func (c *GameConfig) bonusesFor(advisors []string) Bonus {
b := noBonus
for _, name := range advisors {
if a := c.advisor(name); a != nil {
@@ -49,12 +48,6 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
b.Refine *= mult(a.Refine)
}
}
for _, name := range attachments {
if m := c.attachment(name); m != nil {
b.Speed *= mult(m.Speed)
b.Yield *= mult(m.Yield)
}
}
b.Speed = min(b.Speed, c.BonusCaps.Speed)
b.Yield = min(b.Yield, c.BonusCaps.Yield)
b.Sell = min(b.Sell, c.BonusCaps.Sell)
@@ -62,10 +55,10 @@ func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
return b
}
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
// the live event multipliers. stakeInTx and the views both use it.
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
// drillStats is a tool's cycle (seconds, to a tenth, at least 1) and yield per stake with
// the player's bonus and the live event multipliers. stakeInTx and the views both use it.
func drillStats(secs, yield float64, b Bonus, eventSpeed, eventYield float64) (float64, float64) {
return max(1, roundSecs(secs/(b.Speed*eventSpeed))), yield * b.Yield * eventYield
}
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
@@ -81,25 +74,12 @@ func (cfg *GameConfig) validateBonuses() error {
}
}
okMult := func(v float64) bool { return v == 0 || v >= 1 }
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
}
names := map[string]bool{}
for _, m := range cfg.Attachments.Items {
if m.Name == "" || names[m.Name] {
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
}
names[m.Name] = true
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
}
}
for i, p := range cfg.Advisors.SeatPrices {
if p < 0 {
return fmt.Errorf("advisors: seat %d price is negative", i+2)
}
}
names = map[string]bool{}
names := map[string]bool{}
for _, a := range cfg.Advisors.Items {
if a.Name == "" || names[a.Name] {
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
@@ -114,10 +94,9 @@ func (cfg *GameConfig) validateBonuses() error {
// ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// loadBonus reads the player's seated advisors and combines them. The advisor rows are
// share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
func loadBonus(ctx context.Context, tx pgx.Tx, userID string) (Bonus, error) {
rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil {
@@ -127,23 +106,12 @@ func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, er
if err != nil {
return noBonus, err
}
var mods []string
if toolID != "" {
rows, err = tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 order by item`, toolID)
if err != nil {
return noBonus, err
}
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return noBonus, err
}
}
return cfg().bonusesFor(advisors, mods), nil
return cfg().bonusesFor(advisors), nil
}
// ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// loadBonusState loads the player's advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
@@ -165,36 +133,20 @@ func loadBonusState(ctx context.Context, db querier, s *State) error {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
if err != nil {
return err
}
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
var toolID, item string
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
if toolID == "" {
s.Spare[item]++
} else {
s.Fitted[toolID] = append(s.Fitted[toolID], item)
}
return nil
})
return err
return nil
}
// Bonus is the player's advisor bonus (no attachments).
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
func (s *State) toolBonus(toolID string) Bonus {
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
}
// Bonus is the player's advisor bonus.
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors) }
// drill is what a stake of the tool on a well of the resource would take and return now.
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
return drillStats(item.ToolSecs(t.Tier), item.ToolYield(t.Tier), s.toolBonus(t.ID),
func (s *State) drill(t Tool, item *StoreItem, resource string) (float64, float64) {
return s.drillAt(item, t.Levels(), resource)
}
// drillAt is what a stake of the item at the levels would take and return now.
func (s *State) drillAt(item *StoreItem, lv Levels, resource string) (float64, float64) {
return drillStats(item.ToolSecs(lv), item.ToolYield(lv), s.Bonus(),
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
}
+16
View File
@@ -0,0 +1,16 @@
package main
import "testing"
// For("all") counts the "all" event once (cycle events are always saved as "all").
func TestPriceBoostsFor(t *testing.T) {
b := PriceBoosts{"all": 2, "iron": 1.5}
for name, want := range map[string]float64{"all": 2, "iron": 3, "gold": 2} {
if got := b.For(name); got != want {
t.Errorf("For(%q) = %v, want %v", name, got, want)
}
}
if got := (PriceBoosts{}).For("all"); got != 1 {
t.Errorf("empty For(all) = %v, want 1", got)
}
}
+313
View File
@@ -0,0 +1,313 @@
package main
// Small server-drawn SVG charts for the admin pages (the Economy page's progression
// charts): line series with a point per value and a tooltip (<title>) on each, a linear or
// log y axis, shaded x bands, vertical markers, tick marks under the plot and a strip of
// ranges. Colors come from CSS classes (admin.css, "charts"), so both themes work.
import (
"fmt"
"html/template"
"math"
"strings"
)
type Chart struct {
Title string
XLabel, YLabel string
LogY, LogX bool
XTickVals []float64 // x ticks; default: about 8 even steps
YFmt func(float64) string // tick labels; default %g
XFmt func(float64) string
Series []ChartSeries
Bands []ChartBand // shaded x ranges with a label at the top
VLines []ChartVLine // vertical markers
Ticks []ChartTick // marks under the plot (one row per series)
Strip []ChartRange // ranges in a strip under the plot
StripLabel string
Width, Height float64 // of the plot area's viewBox; defaults 1000×260
NoPoints bool // lines only
}
type ChartSeries struct {
Name string
Class string // ch-saved, ch-draft
Points []ChartPoint
}
type ChartPoint struct {
X, Y float64
Title string // tooltip
Mark bool // drawn as a jump marker
Label string // short text drawn next to the point
Alt bool // drawn as a hollow diamond, off the line
}
type ChartBand struct {
X0, X1 float64
Label string
}
type ChartVLine struct {
X float64
Class string
Label string
Title string
}
type ChartTick struct {
X float64
Row int // series index
Class string
Title string
}
type ChartRange struct {
X0, X1 float64
Row int
Class string
Title string
}
const (
chartLeft = 64.0
chartRight = 14.0
chartTop = 30.0
chartBottom = 34.0
)
// skip: a point the axes can't place.
func (ch *Chart) skip(p ChartPoint) bool {
return ch.LogY && p.Y <= 0 || ch.LogX && p.X <= 0 || math.IsInf(p.Y, 0) || math.IsNaN(p.Y)
}
// SVG draws the chart. An empty chart draws a note instead.
func (ch *Chart) SVG() template.HTML {
w, h := ch.Width, ch.Height
if w == 0 {
w = 1000
}
if h == 0 {
h = 260
}
tickRows := 0
for _, t := range ch.Ticks {
tickRows = max(tickRows, t.Row+1)
}
stripRows := 0
for _, r := range ch.Strip {
stripRows = max(stripRows, r.Row+1)
}
extra := float64(tickRows)*8 + float64(stripRows)*8
if tickRows > 0 || stripRows > 0 {
extra += 6
}
total := h + extra
// Ranges.
x0, x1 := math.Inf(1), math.Inf(-1)
y0, y1 := math.Inf(1), math.Inf(-1)
for _, s := range ch.Series {
for _, p := range s.Points {
if ch.skip(p) {
continue
}
x0, x1 = min(x0, p.X), max(x1, p.X)
y0, y1 = min(y0, p.Y), max(y1, p.Y)
}
}
for _, b := range ch.Bands {
x0, x1 = min(x0, b.X0), max(x1, b.X1)
}
if math.IsInf(x0, 0) || math.IsInf(y0, 0) {
return template.HTML(`<p class="muted small">No data.</p>`)
}
if x1 == x0 {
x1 = x0 + 1
}
if ch.LogY {
y0 = math.Pow(10, math.Floor(math.Log10(y0)))
y1 = math.Pow(10, math.Ceil(math.Log10(y1)))
if y1 == y0 {
y1 = y0 * 10
}
} else {
y0 = min(0, y0)
if y1 <= y0 {
y1 = y0 + 1
}
y1 = niceCeil(y1)
}
pw, ph := w-chartLeft-chartRight, h-chartTop-chartBottom
px := func(x float64) float64 {
if ch.LogX {
return chartLeft + (math.Log10(max(x, x0))-math.Log10(x0))/(math.Log10(x1)-math.Log10(x0))*pw
}
return chartLeft + (x-x0)/(x1-x0)*pw
}
py := func(y float64) float64 {
if ch.LogY {
return chartTop + ph - (math.Log10(y)-math.Log10(y0))/(math.Log10(y1)-math.Log10(y0))*ph
}
return chartTop + ph - (y-y0)/(y1-y0)*ph
}
yf := ch.YFmt
if yf == nil {
yf = func(v float64) string { return fmt.Sprintf("%g", v) }
}
xf := ch.XFmt
if xf == nil {
xf = func(v float64) string { return fmt.Sprintf("%g", v) }
}
var b strings.Builder
esc := template.HTMLEscapeString
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" role="img" aria-label="%s">`, w, total, esc(ch.Title))
// Bands.
for i, band := range ch.Bands {
if i%2 == 0 {
fmt.Fprintf(&b, `<rect class="ch-band" x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
px(band.X0), chartTop, max(0, px(band.X1)-px(band.X0)), ph)
}
fmt.Fprintf(&b, `<text class="ch-band-label" x="%.1f" y="%.1f">%s</text>`, px(band.X0)+3, chartTop-4, esc(band.Label))
}
// Y grid and labels.
for _, v := range yTicks(y0, y1, ch.LogY) {
y := py(v)
fmt.Fprintf(&b, `<line class="ch-grid" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, y, y)
fmt.Fprintf(&b, `<text class="ch-ylab" x="%.1f" y="%.1f">%s</text>`, chartLeft-6, y+3.5, esc(yf(v)))
}
// X axis and labels.
fmt.Fprintf(&b, `<line class="ch-axis" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/>`, chartLeft, chartLeft+pw, chartTop+ph, chartTop+ph)
xt := ch.XTickVals
if xt == nil {
xt = xTicks(x0, x1)
}
for _, v := range xt {
if v < x0 || v > x1 {
continue
}
fmt.Fprintf(&b, `<text class="ch-xlab" x="%.1f" y="%.1f">%s</text>`, px(v), chartTop+ph+13, esc(xf(v)))
}
if ch.XLabel != "" {
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f" text-anchor="end">%s</text>`, chartLeft+pw, chartTop+ph+27, esc(ch.XLabel))
}
if ch.YLabel != "" {
fmt.Fprintf(&b, `<text class="ch-title" x="%.1f" y="%.1f">%s</text>`, 4.0, 10.0, esc(ch.YLabel))
}
// Vertical markers.
for _, v := range ch.VLines {
x := px(v.X)
fmt.Fprintf(&b, `<g class="ch-vline %s"><title>%s</title><line x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"/><text x="%.1f" y="%.1f">%s</text></g>`,
esc(v.Class), esc(v.Title), x, x, chartTop, chartTop+ph, x+2, chartTop+9, esc(v.Label))
}
// Series: lines, then points.
for _, s := range ch.Series {
var d strings.Builder
for _, p := range s.Points {
if p.Alt || ch.skip(p) {
continue
}
if d.Len() == 0 {
d.WriteString("M")
} else {
d.WriteString("L")
}
fmt.Fprintf(&d, "%.1f %.1f", px(p.X), py(p.Y))
}
fmt.Fprintf(&b, `<path class="ch-line %s" d="%s"/>`, esc(s.Class), d.String())
}
for _, s := range ch.Series {
fmt.Fprintf(&b, `<g class="%s">`, esc(s.Class))
for _, p := range s.Points {
if ch.skip(p) {
continue
}
x, y := px(p.X), py(p.Y)
switch {
case p.Alt:
fmt.Fprintf(&b, `<path class="ch-alt" d="M%.1f %.1fl4.5 4.5-4.5 4.5-4.5-4.5z"><title>%s</title></path>`, x, y-4.5, esc(p.Title))
case p.Mark:
fmt.Fprintf(&b, `<g class="ch-jump"><title>%s</title><circle cx="%.1f" cy="%.1f" r="7"/><circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"/></g>`,
esc(p.Title), x, y, x, y)
case !ch.NoPoints:
fmt.Fprintf(&b, `<circle class="ch-pt" cx="%.1f" cy="%.1f" r="3"><title>%s</title></circle>`, x, y, esc(p.Title))
}
if p.Label != "" {
fmt.Fprintf(&b, `<text class="ch-plab" x="%.1f" y="%.1f">%s</text>`, x+6, y-6, esc(p.Label))
}
}
b.WriteString(`</g>`)
}
// Ticks and strip under the plot.
under := h + 2
for _, t := range ch.Ticks {
x, y := px(t.X), under+float64(t.Row)*8
fmt.Fprintf(&b, `<line class="ch-tick %s" x1="%.1f" x2="%.1f" y1="%.1f" y2="%.1f"><title>%s</title></line>`,
esc(t.Class), x, x, y, y+6, esc(t.Title))
}
under += float64(tickRows) * 8
if tickRows > 0 {
under += 2
}
for _, r := range ch.Strip {
y := under + float64(r.Row)*8
fmt.Fprintf(&b, `<rect class="ch-range %s" x="%.1f" y="%.1f" width="%.1f" height="6"><title>%s</title></rect>`,
esc(r.Class), px(r.X0), y, max(1.5, px(r.X1)-px(r.X0)), esc(r.Title))
}
b.WriteString(`</svg>`)
return template.HTML(b.String())
}
// Legend is the chart's series names with their swatches.
func (ch *Chart) Legend() template.HTML {
var b strings.Builder
b.WriteString(`<span class="ch-legend">`)
for _, s := range ch.Series {
fmt.Fprintf(&b, `<span class="%s"><i class="ch-swatch"></i>%s</span>`, template.HTMLEscapeString(s.Class), template.HTMLEscapeString(s.Name))
}
b.WriteString(`</span>`)
return template.HTML(b.String())
}
func niceCeil(v float64) float64 {
if v <= 0 {
return 1
}
p := math.Pow(10, math.Floor(math.Log10(v)))
for _, m := range []float64{1, 2, 2.5, 5, 10} {
if m*p >= v {
return m * p
}
}
return 10 * p
}
func yTicks(y0, y1 float64, log bool) []float64 {
var out []float64
if log {
decades := math.Log10(y1) - math.Log10(y0)
for e := math.Log10(y0); e <= math.Log10(y1)+1e-9; e++ {
v := math.Pow(10, math.Round(e))
out = append(out, v)
if decades <= 3 && e+1 <= math.Log10(y1)+1e-9 {
out = append(out, 3*v)
}
}
return out
}
step := niceCeil((y1 - y0) / 5)
for v := y0; v <= y1+step*1e-9; v += step {
out = append(out, v)
}
return out
}
func xTicks(x0, x1 float64) []float64 {
step := niceCeil((x1 - x0) / 8)
var out []float64
for v := math.Ceil(x0/step) * step; v <= x1+step*1e-9; v += step {
out = append(out, v)
}
return out
}
+639
View File
@@ -0,0 +1,639 @@
package main
// Claim-all against a real database: TestClaimGolden plays fixed scenarios (one-well and
// multi-well drills on shared wells, wells emptied mid-claim, redeploy on and off, every
// scope, reach 1, 3 and 5) and compares what they leave in the database with
// testdata/claim_golden.txt; TestClaimAllPerf times claim-all and counts its statements.
// Both need MM_TEST_DATABASE, a migrated database of their own (scripts/wt makes one:
// mm_<task>); without it they are skipped. MM_CLAIM_GOLDEN_WRITE=1 rewrites the golden file.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// countTracer counts the statements a pool sends and keeps the slowest.
type countTracer struct {
n atomic.Int64
mu sync.Mutex
slow []string
}
type traceStart struct {
sql string
at time.Time
}
func (c *countTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, d pgx.TraceQueryStartData) context.Context {
c.n.Add(1)
return context.WithValue(ctx, c, traceStart{d.SQL, time.Now()})
}
func (c *countTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryEndData) {
if s, ok := ctx.Value(c).(traceStart); ok {
if d := time.Since(s.at); d > 10*time.Millisecond {
c.mu.Lock()
c.slow = append(c.slow, fmt.Sprintf("%v %s", d.Round(time.Millisecond), strings.Join(strings.Fields(s.sql), " ")))
c.mu.Unlock()
}
}
}
func (c *countTracer) TraceBatchStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceBatchStartData) context.Context {
return ctx
}
func (c *countTracer) TraceBatchQuery(context.Context, *pgx.Conn, pgx.TraceBatchQueryData) {
c.n.Add(1)
}
func (c *countTracer) TraceBatchEnd(context.Context, *pgx.Conn, pgx.TraceBatchEndData) {}
func testDB(t *testing.T) (*pgxpool.Pool, *countTracer) {
t.Helper()
name := os.Getenv("MM_TEST_DATABASE")
if name == "" {
t.Skip("MM_TEST_DATABASE not set")
}
if name == "moonminers" {
t.Fatal("MM_TEST_DATABASE must not be the game's database")
}
if !loadDotEnv(".env") {
loadDotEnv("../.env")
}
os.Setenv("DB_DATABASE", name)
pc, err := pgxpool.ParseConfig(appDSN())
if err != nil {
t.Fatal(err)
}
tr := &countTracer{}
pc.ConnConfig.Tracer = tr
db, err := pgxpool.NewWithConfig(context.Background(), pc)
if err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
return db, tr
}
// fx builds players straight in the database and names every row it makes (labels), so
// what a claim leaves can be compared across runs.
type fx struct {
t *testing.T
ctx context.Context
db *pgxpool.Pool
run uint32 // the first part of the ids the fixture makes (orders them as made)
ids int
label map[string]string // id → label
res map[string]int // resource name → id
resN []string // resource names, sorted
clock int // seconds after base for the next stake
action int
}
var fxBase = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
func newFx(t *testing.T, db *pgxpool.Pool) *fx {
f := &fx{t: t, ctx: context.Background(), db: db, label: map[string]string{}, res: map[string]int{},
run: rand.Uint32()}
rows, err := db.Query(f.ctx, "select id, name from resource order by name")
f.must(err)
var id int
var n string
_, err = pgx.ForEachRow(rows, []any{&id, &n}, func() error {
f.res[n] = id
f.resN = append(f.resN, n)
return nil
})
f.must(err)
if len(f.resN) < 5 {
t.Fatalf("resources: %v", f.resN)
}
return f
}
func (f *fx) must(err error) {
f.t.Helper()
if err != nil {
f.t.Fatal(err)
}
}
func (f *fx) id(q string, args ...any) string {
f.t.Helper()
var id string
f.must(f.db.QueryRow(f.ctx, q, args...).Scan(&id))
return id
}
func (f *fx) user(label string, onboarding []string, advisors ...string) string {
if onboarding == nil {
onboarding = []string{}
}
u := f.id("insert into users(name, onboarding) values ($1, $2) returning id::text", label, onboarding)
f.label[u] = label
f.t.Cleanup(func() {
f.db.Exec(f.ctx, "delete from event_log where user_id = $1", u)
f.db.Exec(f.ctx, "delete from users where id = $1", u)
})
_, err := f.db.Exec(f.ctx, "insert into bank_account(user_id, balance) values ($1, 1000)", u)
f.must(err)
_, err = f.db.Exec(f.ctx, `insert into resource_account(resource_id, user_id, balance)
select id, $1, 0.1 * id from resource`, u)
f.must(err)
for i, a := range advisors {
_, err = f.db.Exec(f.ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", u, a, i+1)
f.must(err)
}
return u
}
// moon makes a moon with wells of the given resources (indexes into resN) and supplies;
// the wells are labelled <moon>.<i>.
func (f *fx) moon(user, label string, res []int, supply []float64) []string {
m := f.id(`insert into staking_source(name, description, user_id, address, grade)
values ($1, '', $2, '', 'C') returning id::text`, label, user)
f.label[m] = label
var wells []string
for i, r := range res {
f.ids++
w := f.id("insert into resource_well(id, resource_id, source_id, supply) values ($1, $2, $3, $4) returning id::text",
fmt.Sprintf("%08x-0000-4000-8000-%012x", f.run, f.ids), f.res[f.resN[r%len(f.resN)]], m, supply[i])
f.label[w] = fmt.Sprintf("%s.%d", label, i)
wells = append(wells, w)
}
return wells
}
func (f *fx) tool(user, label string, item, yl, cl int) string {
id := f.id("insert into inventory_item(user_id, store_item_id, yield_level, cycle_level) values ($1, $2, $3, $4) returning id::text",
user, item, yl, cl)
f.label[id] = label
return id
}
// stake puts a stake in: ready (started at the next tick of the fixture clock, long ago)
// or still running.
func (f *fx) stake(user, label, tool, well string, amount float64, reach int, ready bool) string {
f.clock++
at, secs := fxBase.Add(time.Duration(f.clock)*time.Second), 30.0
if !ready {
at, secs = time.Now().Add(time.Duration(f.clock)*time.Second), 1e8
}
id := f.id(`insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
values ($1, $2, $3, $4, $5, $6, $7) returning id::text`, user, well, tool, secs, amount, reach, at)
f.label[id] = label
return id
}
// finish makes every running stake of the player ready, in the order of their tools' labels
// (deterministic, unlike the random ids that order stakes started together).
func (f *fx) finish(user string) {
rows, err := f.db.Query(f.ctx, `select id::text, inventory_item_id::text from staking_event
where user_id = $1 and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`, user)
f.must(err)
type st struct{ ID, Tool string }
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[st])
f.must(err)
sort.Slice(stakes, func(i, j int) bool { return f.label[stakes[i].Tool] < f.label[stakes[j].Tool] })
for _, s := range stakes {
f.clock++
_, err := f.db.Exec(f.ctx, "update staking_event set created_at = $2, duration_in_secs = least(duration_in_secs, 30) where id = $1",
s.ID, fxBase.Add(time.Duration(f.clock)*time.Second))
f.must(err)
}
}
func fnum(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// claim runs claimReady and writes the result and the player's whole state to out.
func (f *fx) claim(out *strings.Builder, user string, scope ClaimScope, what string) {
f.action++
var lastLog int64
f.must(f.db.QueryRow(f.ctx, "select coalesce(max(id), 0) from event_log where user_id = $1", user).Scan(&lastLog))
res, err := claimReady(f.ctx, f.db, user, scope)
fmt.Fprintf(out, "== %s %s: %s\n", f.label[user], what, f.lbl(scope.MoonID+scope.WellID))
if err != nil {
fmt.Fprintf(out, "error: %v\n", err)
} else {
var fin []string
for _, m := range res.Finished {
fin = append(fin, f.label[m.ID]+"="+m.Name)
}
fmt.Fprintf(out, "claimed %d redeployed %d total %s stopped %d finished %v\n",
res.Claimed, res.Redeployed, fnum(res.Total), res.Stopped, fin)
}
f.dump(out, user, lastLog)
}
func (f *fx) lbl(id string) string {
if l, ok := f.label[id]; ok {
return l
}
return id
}
func (f *fx) dump(out *strings.Builder, user string, lastLog int64) {
q := func(sql string, args ...any) pgx.Rows {
rows, err := f.db.Query(f.ctx, sql, args...)
f.must(err)
return rows
}
// Name the stakes this action made: tool@action.
rows := q("select id::text, inventory_item_id::text from staking_event where user_id = $1", user)
var id, tool string
_, err := pgx.ForEachRow(rows, []any{&id, &tool}, func() error {
if _, ok := f.label[id]; !ok {
f.label[id] = fmt.Sprintf("%s@%d", f.label[tool], f.action)
}
return nil
})
f.must(err)
var lines []string
rows = q(`select resource_well.id::text, supply from resource_well
join staking_source on staking_source.id = source_id where user_id = $1`, user)
var supply float64
_, err = pgx.ForEachRow(rows, []any{&id, &supply}, func() error {
lines = append(lines, fmt.Sprintf("well %s %s", f.lbl(id), fnum(supply)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
lines = nil
rows = q(`select resource.name, balance from resource_account join resource on resource.id = resource_id
where user_id = $1`, user)
var name string
var bal float64
_, err = pgx.ForEachRow(rows, []any{&name, &bal}, func() error {
lines = append(lines, fmt.Sprintf("balance %s %s", name, fnum(bal)))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
var flags []string
f.must(f.db.QueryRow(f.ctx, "select onboarding from users where id = $1", user).Scan(&flags))
slices.Sort(flags)
fmt.Fprintf(out, "onboarding %v\n", flags)
lines = nil
rows = q(`select staking_event.id::text, well_id::text, inventory_item_id::text, duration_in_secs, stake_amount,
staking_event.reach, claim_event.claim_amount,
coalesce((select string_agg(cw.well_id::text || '=' || cw.amount::text, ',') from claim_well cw
where cw.claim_event_id = claim_event.id), '')
from staking_event left join claim_event on claim_event.staking_event_id = staking_event.id
where staking_event.user_id = $1`, user)
var well string
var dur, amount float64
var reach int
var claim *float64
var cw string
_, err = pgx.ForEachRow(rows, []any{&id, &well, &tool, &dur, &amount, &reach, &claim, &cw}, func() error {
c := "running"
if claim != nil {
c = "claimed " + fnum(*claim)
}
var parts []string
for _, p := range strings.Split(cw, ",") {
if w, a, ok := strings.Cut(p, "="); ok {
parts = append(parts, f.lbl(w)+"="+a)
}
}
slices.Sort(parts)
lines = append(lines, fmt.Sprintf("stake %s well %s tool %s secs %s amount %s reach %d %s %v",
f.lbl(id), f.lbl(well), f.lbl(tool), fnum(dur), fnum(amount), reach, c, parts))
return nil
})
f.must(err)
sort.Strings(lines)
out.WriteString(strings.Join(lines, "\n") + "\n")
// The event log in order; runs of stake_stopped (ordered by start time only, ties
// at random) sorted.
lines = nil
rows = q("select kind, data::text from event_log where user_id = $1 and id > $2 order by id", user, lastLog)
var kind, data string
var run []string
flush := func() {
slices.Sort(run)
lines = append(lines, run...)
run = nil
}
_, err = pgx.ForEachRow(rows, []any{&kind, &data}, func() error {
var m map[string]any
if err := json.Unmarshal([]byte(data), &m); err != nil {
return err
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
var b strings.Builder
for _, k := range keys {
v := m[k]
if s, ok := v.(string); ok {
v = f.lbl(s)
} else if x, ok := v.(float64); ok {
v = fnum(x)
}
fmt.Fprintf(&b, " %s=%v", k, v)
}
line := "log " + kind + b.String()
if kind == "stake_stopped" {
run = append(run, line)
return nil
}
flush()
lines = append(lines, line)
return nil
})
f.must(err)
flush()
if len(lines) > 0 {
out.WriteString(strings.Join(lines, "\n") + "\n")
}
}
// withConfig runs fn with the store items' reach replaced (item id → reach).
func withReach(reach map[int]int, fn func()) {
old := cfg()
c := *old
c.StoreItems = slices.Clone(old.StoreItems)
for i := range c.StoreItems {
if r, ok := reach[c.StoreItems[i].ID]; ok {
c.StoreItems[i].Reach = r
}
}
effective.Store(&c)
defer effective.Store(old)
fn()
}
// seqStakeIDs makes new staking_event ids come from a sequence, in the order the stakes
// are made, whether the database makes them (the column default) or the server
// (newStakeID): stakes started together are ordered by id, which is otherwise random.
func seqStakeIDs(t *testing.T, db *pgxpool.Pool) {
ctx := context.Background()
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Fatal(err)
}
defer admin.Close()
const id = `('00000000-0000-4000-8000-' || lpad(to_hex(nextval('claim_test_seq')), 12, '0'))::uuid`
for _, q := range []string{
"create sequence if not exists claim_test_seq",
"grant usage, select on sequence claim_test_seq to " + pgx.Identifier{env("DB_USER", "moonminers")}.Sanitize(),
"alter table staking_event alter column id set default " + id,
} {
if _, err := admin.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
oldID := newStakeID
newStakeID = func() string {
var s string
if err := db.QueryRow(ctx, "select "+id+"::text").Scan(&s); err != nil {
panic(err)
}
return s
}
t.Cleanup(func() {
newStakeID = oldID
admin, err := pgxpool.New(ctx, adminDSN())
if err != nil {
t.Error(err)
return
}
defer admin.Close()
if _, err := admin.Exec(ctx, "alter table staking_event alter column id set default gen_random_uuid()"); err != nil {
t.Error(err)
}
})
}
func TestClaimGolden(t *testing.T) {
db, _ := testDB(t)
seqStakeIDs(t, db)
ctx := context.Background()
// Live events for the redeployed stakes' amounts and durations; removed at the end.
var evs []int
for _, e := range []struct {
kind, res string
mult float64
}{{"cycle_multiplier", "all", 1.5}, {"yield_multiplier", "Sollux", 1.25}, {"yield_multiplier", "Azurium", 0.8}} {
var id int
err := db.QueryRow(ctx, `insert into live_event(name, kind, params, starts_at, ends_at)
values ('claim test', $1, $2, now() - interval '1 day', now() + interval '1 day') returning id`,
e.kind, map[string]any{"resource": e.res, "multiplier": e.mult}).Scan(&id)
if err != nil {
t.Fatal(err)
}
evs = append(evs, id)
}
defer db.Exec(ctx, "delete from live_event where id = any($1)", evs)
var out strings.Builder
run := func(cfgName string) {
f := newFx(t, db)
claimScenarios(f, &out, cfgName)
}
run("reach5")
withReach(map[int]int{1: 1, 2: 1, 3: 3, 4: 2}, func() { run("mixed") })
withReach(map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1}, func() { run("reach1") })
got := out.String()
const path = "testdata/claim_golden.txt"
if os.Getenv("MM_CLAIM_GOLDEN_WRITE") == "1" {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
os.WriteFile(os.TempDir()+"/claim_golden.got", []byte(got), 0o644)
gl, wl := strings.Split(got, "\n"), strings.Split(string(want), "\n")
for i := 0; i < min(len(gl), len(wl)); i++ {
if gl[i] != wl[i] {
t.Fatalf("differs at line %d (full output in %s/claim_golden.got):\n got: %s\nwant: %s",
i+1, os.TempDir(), gl[i], wl[i])
}
}
t.Fatalf("differs in length: got %d lines, want %d", len(gl), len(wl))
}
}
// claimScenarios plays every scenario for one config; p prefixes the labels.
func claimScenarios(f *fx, out *strings.Builder, p string) {
all := ClaimScope{}
redeploy := ClaimScope{Redeploy: true}
// 1. One-well drills overdrilling a well: the third claim empties it and stops the
// fourth (ready, later in the list) and a running one; a multi-well drill on the
// emptied well; claim-all with redeploy, then the redeployed drills again.
u := f.user(p+"-overdrill", nil)
w := f.moon(u, p+"-m1", []int{3, 0, 4}, []float64{10, 50, 5})
for i, item := range []int{1, 2, 1, 3} {
f.stake(u, fmt.Sprintf("%s-s%d", p, i), f.tool(u, fmt.Sprintf("%s-t%d", p, i), item, 0, 0), w[0], 4, 1, true)
}
f.stake(u, p+"-run", f.tool(u, p+"-t4", 1, 0, 0), w[0], 3, 1, false)
f.stake(u, p+"-wide", f.tool(u, p+"-t5", 5, 1, 0), w[2], 7, 5, true)
f.stake(u, p+"-az", f.tool(u, p+"-t6", 2, 0, 1), w[1], 9, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
// 2. Multi-well drills on a 7-well moon (reach 5 covers part of it) and a 4-well moon,
// wells emptied by multi-well claims stopping one-well drills; a moon that finishes.
u = f.user(p+"-wide", []string{"market"}, "Ada Voss", "Rook Halvorsen")
w7 := f.moon(u, p+"-m7", []int{0, 1, 2, 3, 4, 0, 1}, []float64{3, 8, 0, 12, 2.5, 6, 1})
w4 := f.moon(u, p+"-m4", []int{4, 3, 2, 1}, []float64{2, 2, 1, 0.5})
n := 0
tl := func(item, yl, cl int) string {
n++
return f.tool(u, fmt.Sprintf("%s-w%d", p, n), item, yl, cl)
}
f.stake(u, p+"-a1", tl(4, 0, 0), w7[0], 9, 5, true)
f.stake(u, p+"-a2", tl(1, 0, 0), w7[3], 5, 1, true)
f.stake(u, p+"-a3", tl(5, 2, 1), w7[6], 6, 5, true)
f.stake(u, p+"-a4", tl(1, 1, 0), w7[1], 2, 1, true)
f.stake(u, p+"-a5", tl(3, 0, 0), w7[2], 4, 3, true)
f.stake(u, p+"-a6", tl(1, 0, 0), w7[4], 3, 1, false)
f.stake(u, p+"-a7", tl(6, 0, 0), w7[5], 20, 5, false)
f.stake(u, p+"-b1", tl(2, 0, 0), w4[0], 1.5, 1, true)
f.stake(u, p+"-b2", tl(7, 0, 0), w4[1], 3, 5, true)
f.stake(u, p+"-b3", tl(1, 0, 0), w4[3], 1, 1, true)
f.stake(u, p+"-b4", tl(4, 0, 0), w4[2], 2, 3, true)
m7 := f.moonOf(w7[0])
f.claim(out, u, ClaimScope{MoonID: m7}, "claim-moon m7")
f.claim(out, u, ClaimScope{MoonID: m7, Redeploy: true}, "claim-moon m7 redeploy")
f.claim(out, u, ClaimScope{MoonID: f.moonOf(w4[0]), Wide: true, Redeploy: true}, "claim-wide m4 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[0], Redeploy: true}, "claim-well m4.0 redeploy")
f.claim(out, u, ClaimScope{WellID: w4[3]}, "claim-well m4.3")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy")
f.finish(u)
f.claim(out, u, redeploy, "claim-all redeploy 2")
f.finish(u)
f.claim(out, u, all, "claim-all")
f.claim(out, u, all, "claim-all nothing")
// 3. A tool with a second unclaimed stake: redeploying it fails the whole claim.
u = f.user(p+"-busy", []string{"all"})
w = f.moon(u, p+"-mb", []int{0, 1}, []float64{20, 20})
t := f.tool(u, p+"-bt", 1, 0, 0)
f.stake(u, p+"-bs1", t, w[0], 2, 1, true)
f.stake(u, p+"-bs2", t, w[1], 2, 1, false)
f.stake(u, p+"-bs3", f.tool(u, p+"-bt2", 2, 0, 0), w[1], 2, 1, true)
f.claim(out, u, redeploy, "claim-all redeploy busy")
f.claim(out, u, all, "claim-all busy")
// 4. Many moons and drills with tight wells, seeded: three rounds of claim-all with
// redeploy, then one without.
u = f.user(p+"-many", nil, "Juno Park")
rnd := rand.New(rand.NewSource(7))
var moons [][]string
for m := 0; m < 25; m++ {
k := 1 + rnd.Intn(7)
res, sup := make([]int, k), make([]float64, k)
for i := range res {
res[i] = rnd.Intn(5)
sup[i] = float64(rnd.Intn(40)) / 2
if rnd.Intn(6) == 0 {
sup[i] = 0
}
}
moons = append(moons, f.moon(u, fmt.Sprintf("%s-n%d", p, m), res, sup))
}
for i := 0; i < 120; i++ {
ws := moons[rnd.Intn(len(moons))]
item := 1 + rnd.Intn(7)
reach := 1
if rnd.Intn(2) == 0 {
reach = []int{2, 3, 5}[rnd.Intn(3)]
}
tool := f.tool(u, fmt.Sprintf("%s-x%03d", p, i), item, rnd.Intn(3), rnd.Intn(3))
f.stake(u, fmt.Sprintf("%s-y%03d", p, i), tool, ws[rnd.Intn(len(ws))], float64(1+rnd.Intn(16))/2, reach, rnd.Intn(5) != 0)
}
for r := 0; r < 3; r++ {
f.claim(out, u, redeploy, fmt.Sprintf("claim-all redeploy round %d", r))
f.finish(u)
}
f.claim(out, u, all, "claim-all final")
}
func (f *fx) moonOf(well string) string {
return f.id("select source_id::text from resource_well where id = $1", well)
}
// TestClaimAllPerf times claim-all for players with n ready drills, and counts its
// statements: one whole-moon drill per moon of three wells, or (crowded) n drills on 25
// moons, a third of them one-well drills, more than the wells hold.
func TestClaimAllPerf(t *testing.T) {
db, tr := testDB(t)
f := newFx(t, db)
type perfRun struct {
n int
redeploy bool
crowded bool
}
runs := []perfRun{{50, false, false}, {50, true, false}, {200, false, false}, {200, true, false},
{900, false, false}, {900, true, false}, {900, false, true}, {900, true, true}}
for _, run := range runs {
n, moons, supply := run.n, run.n, []float64{40, 30, 3}
if run.crowded {
moons, supply = 25, []float64{300, 200, 8}
}
u := f.user(fmt.Sprintf("perf-%d-%v-%v", n, run.redeploy, run.crowded), nil)
for m := 0; m < moons; m++ {
f.moon(u, fmt.Sprintf("p%d-%d", n, m), []int{m, m + 1, m + 2}, supply)
}
_, err := db.Exec(f.ctx, `
insert into inventory_item(user_id, store_item_id) select $1, 1 + (g % 7) from generate_series(1, $2) g`, u, n)
f.must(err)
_, err = db.Exec(f.ctx, `
with tools as (select id, row_number() over (order by id) r from inventory_item where user_id = $1),
moons as (select id, row_number() over (order by name) r from staking_source where user_id = $1)
insert into staking_event(user_id, well_id, inventory_item_id, duration_in_secs, stake_amount, reach, created_at)
select $1, (select id from resource_well where source_id = moons.id order by id offset tools.r % 3 limit 1),
tools.id, 10, 5 + tools.r % 4, case when $4 and tools.r % 3 = 0 then 1 else 5 end,
$2::timestamptz + tools.r * interval '1 second'
from tools join moons on moons.r = (tools.r - 1) % $3 + 1`, u, fxBase, moons, run.crowded)
f.must(err)
tr.mu.Lock()
tr.slow = nil
tr.mu.Unlock()
start, n0 := time.Now(), tr.n.Load()
res, err := claimReady(f.ctx, db, u, ClaimScope{Redeploy: run.redeploy})
f.must(err)
t.Logf("drills %d redeploy %v crowded %v: claimed %d redeployed %d stopped %d in %v, %d statements",
n, run.redeploy, run.crowded, res.Claimed, res.Redeployed, res.Stopped,
time.Since(start).Round(time.Millisecond), tr.n.Load()-n0)
for _, s := range tr.slow {
t.Logf(" slow: %.200s", s)
}
}
}
+55 -38
View File
@@ -1,6 +1,6 @@
package main
// The game config (constants, store items and their upgrades, moon types) lives in
// The game config (constants, store items and their upgrade tracks, moon types) lives in
// config/game.json, embedded in the binary (`game`). Admins can override its numbers live
// (economy.go); read the effective config with cfg(). Store items are also synced into
// the database (syncStoreItems), because inventory_item references store_item and the
@@ -13,6 +13,8 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"slices"
"strconv"
"strings"
@@ -29,9 +31,11 @@ type GameConfig struct {
StartingTools []int `json:"startingTools"` // store item ids, given free
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
MoonGrades []MoonGrade `json:"moonGrades"`
// Packs of moons bought at once (buyMoons in game.go), smallest first.
MoonPacks []MoonPack `json:"moonPacks,omitempty"`
// Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"`
// Selling tools and attachments back (resale.go).
// Selling tools back (resale.go).
EquipmentResale EquipmentResale `json:"equipmentResale"`
// Credits per unit sold, by resource name.
ResourcePrices map[string]float64 `json:"resourcePrices"`
@@ -45,10 +49,9 @@ type GameConfig struct {
MoonNames MoonNames `json:"moonNames"`
// Crafting refined items from resources (refinery.go).
Refinery RefineryConfig `json:"refinery"`
// Drill attachments and advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"`
Attachments AttachmentConfig `json:"attachments"`
Advisors AdvisorConfig `json:"advisors"`
// Advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"`
Advisors AdvisorConfig `json:"advisors"`
}
// MoonGrade is a moon vendor: its price and the moons it sells. Each well of its moons has
@@ -68,6 +71,30 @@ type MoonGrade struct {
MinValue float64 `json:"minValue,omitempty"`
}
// MoonPack is count moons of one grade bought at once: discount off their price, and each
// moon worth at least minValue × its grade's price (re-rolled like a grade's minValue,
// whichever floor is higher).
type MoonPack struct {
Count int `json:"count"`
Discount float64 `json:"discount"`
MinValue float64 `json:"minValue,omitempty"`
}
// pack is the moon pack of that size (nil: none, or a single moon).
func (cfg *GameConfig) pack(count int) *MoonPack {
for i := range cfg.MoonPacks {
if cfg.MoonPacks[i].Count == count {
return &cfg.MoonPacks[i]
}
}
return nil
}
// PackPrice is what count moons of the grade cost in this pack.
func (p *MoonPack) PackPrice(g *MoonGrade) float64 {
return math.Round(float64(p.Count) * g.Price * (1 - p.Discount))
}
// grade is the moon grade with that name, else the first (lowest) one.
func (cfg *GameConfig) grade(name string) *MoonGrade {
if i := cfg.gradeIndex(name); i >= 0 {
@@ -123,6 +150,10 @@ type MoonType struct {
var game = mustParseGameConfig(gameJSON)
// resourceNames are the rows of the resource table (sql/data.sql). Wells are stored by
// these names, so the config can price no other resource.
var resourceNames = []string{"Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"}
func mustParseGameConfig(data []byte) GameConfig {
var cfg GameConfig
dec := json.NewDecoder(bytes.NewReader(data))
@@ -158,6 +189,14 @@ func (cfg *GameConfig) validate() error {
if err := cfg.MarketPressure.validate(); err != nil {
return err
}
for i, p := range cfg.MoonPacks {
if p.Count < 2 || p.Discount < 0 || p.Discount >= 1 || p.MinValue < 0 || p.MinValue > 3 {
return fmt.Errorf("moon pack %d: count at least 2, discount from 0 to below 1, minValue 0 to 3", i+1)
}
if i > 0 && p.Count <= cfg.MoonPacks[i-1].Count {
return fmt.Errorf("moon packs: smallest first, no two the same size")
}
}
if err := cfg.MoonResale.validate(); err != nil {
return err
}
@@ -165,10 +204,16 @@ func (cfg *GameConfig) validate() error {
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
}
for name, price := range cfg.ResourcePrices {
if !slices.Contains(resourceNames, name) {
return fmt.Errorf("resourcePrices: %q is not a resource (%s)", name, strings.Join(resourceNames, ", "))
}
if price < 0 {
return fmt.Errorf("%s price is negative", name)
}
}
if len(cfg.StoreItems) == 0 {
return fmt.Errorf("no store items")
}
ids := map[int]bool{}
for _, item := range cfg.StoreItems {
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
@@ -177,23 +222,13 @@ func (cfg *GameConfig) validate() error {
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
}
for _, u := range item.Upgrades {
if u.Price < 0 {
return fmt.Errorf("store item %d tier %d: price is negative", item.ID, u.Tier)
}
if u.Reach < 0 || u.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d tier %d: reach must be 0 to %d wells", item.ID, u.Tier, len(cfg.ResourcePrices))
}
if err := cfg.validateTracks(item); err != nil {
return err
}
if ids[item.ID] {
return fmt.Errorf("duplicate store item id %d", item.ID)
}
ids[item.ID] = true
for i, u := range item.Upgrades {
if u.Tier != i+1 {
return fmt.Errorf("store item %d: upgrade tiers must be 1, 2, 3, ... in order", item.ID)
}
}
}
for _, id := range cfg.StartingTools {
if !ids[id] {
@@ -281,7 +316,8 @@ func (cfg *GameConfig) validateWells() error {
return nil
}
// syncStoreItems makes store_item and upgrade_item match the effective config. Store items
// syncStoreItems makes store_item match the effective config (the upgrade tracks are read
// from the config, not stored). Store items
// that were removed from the file are deleted only if no player owns one (deleting
// would cascade to their tools).
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error {
@@ -312,25 +348,6 @@ func syncStoreItemsTx(ctx context.Context, tx pgx.Tx, items []StoreItem) error {
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
tiers := []int{}
for _, u := range item.Upgrades {
tiers = append(tiers, u.Tier)
_, err := tx.Exec(ctx, `
insert into upgrade_item(store_item_id, tier, price, claim_boost)
values ($1, $2, $3, $4)
on conflict (store_item_id, tier) do update set
price = excluded.price, claim_boost = excluded.claim_boost`,
item.ID, u.Tier, u.Price, u.ClaimBoost)
if err != nil {
return fmt.Errorf("store item %d tier %d: %w", item.ID, u.Tier, err)
}
}
_, err = tx.Exec(ctx, `
delete from upgrade_item where store_item_id = $1 and not (tier = any($2))`,
item.ID, tiers)
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
}
_, err := tx.Exec(ctx, `
+149 -98
View File
@@ -3,17 +3,19 @@
"startingResources": 0,
"startingTools": [1],
"moonGrades": [
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 23, "wellSupplyMax": 46, "wellCountWeights": {"1": 40, "2": 60},
{"grade": "D", "price": 300, "minValue": 1.3, "wellSupplyMin": 28, "wellSupplyMax": 56, "wellCountWeights": {"1": 40, "2": 60},
"affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 6000, "minValue": 1.3, "wellSupplyMin": 216, "wellSupplyMax": 430, "wellCountWeights": {"2": 50, "3": 50},
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "wellCountWeights": {"2": 50, "3": 50},
"affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
{"grade": "B", "price": 30000, "wellSupplyMin": 738, "wellSupplyMax": 1968, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
{"grade": "B", "price": 90000, "wellSupplyMin": 2214, "wellSupplyMax": 5904, "wellCountWeights": {"2": 30, "3": 40, "4": 30},
"affinities": {"Sollux": 1, "Shadowstone": 1.5, "Azurium": 2, "Novafor": 2, "Nebulance": 1.2}},
{"grade": "A", "price": 300000, "wellSupplyMin": 4920, "wellSupplyMax": 12915, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
{"grade": "A", "price": 1500000, "wellSupplyMin": 24600, "wellSupplyMax": 64575, "wellCountWeights": {"3": 30, "4": 40, "5": 30},
"affinities": {"Sollux": 0.6, "Shadowstone": 1, "Azurium": 1.5, "Novafor": 2, "Nebulance": 2}},
{"grade": "S", "price": 3000000, "wellSupplyMin": 42230, "wellSupplyMax": 103730, "wellCountWeights": {"4": 50, "5": 50},
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "wellCountWeights": {"4": 50, "5": 50},
"affinities": {"Sollux": 0.4, "Shadowstone": 0.6, "Azurium": 1, "Novafor": 2, "Nebulance": 3.5}}
],
"moonPacks": [{"count": 5, "discount": 0.05, "minValue": 1.4}, {"count": 10, "discount": 0.1, "minValue": 1.5},
{"count": 20, "discount": 0.15, "minValue": 1.6}],
"moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
"equipmentResale": {"factor": 0.5},
"marketVolatility": 0.25,
@@ -32,115 +34,177 @@
"description": "A hovering scout drone that skims ore off the surface.",
"image": "shovel.png",
"price": 1200,
"claimAmount": 4,
"completionTimeInSecs": 3,
"upgrades": [
{"tier": 1, "price": 80, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 100, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 80, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 1}},
{"tier": 4, "price": 110, "claimBoost": 0, "speed": 1.6, "parts": {"Dusk Alloy": 1}},
{"tier": 5, "price": 120, "claimBoost": 0, "speed": 1.8, "parts": {"Dusk Alloy": 2}}
]
"claimAmount": 8,
"completionTimeInSecs": 3, "reach": 5,
"tracks": {
"yield": {"name": "Twin rotors", "levels": [
{"value": 10, "price": 280},
{"value": 12, "price": 280},
{"value": 14, "price": 290},
{"value": 16, "price": 280}
]},
"cycle": {"name": "Flight computer", "levels": [
{"value": 2.6, "price": 180},
{"value": 2.2, "price": 240},
{"value": 1.9, "price": 250},
{"value": 1.7, "price": 210},
{"value": 1.5, "price": 270}
]}
}
},
{
"id": 2,
"name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png",
"price": 5700,
"claimAmount": 20,
"completionTimeInSecs": 6,
"upgrades": [
{"tier": 1, "price": 400, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 470, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 500, "claimBoost": 0, "speed": 1.4, "parts": {"Dusk Alloy": 2}},
{"tier": 4, "price": 610, "claimBoost": 0, "speed": 1.6, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 780, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 1}}
]
"price": 8000,
"claimAmount": 120,
"completionTimeInSecs": 6, "reach": 5,
"tracks": {
"yield": {"name": "Wider chisel", "levels": [
{"value": 144, "price": 1520},
{"value": 168, "price": 1520},
{"value": 192, "price": 1520},
{"value": 216, "price": 1520},
{"value": 240, "price": 1520}
]},
"cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 1160},
{"value": 4.5, "price": 1360},
{"value": 3.9, "price": 1560},
{"value": 3.4, "price": 1720},
{"value": 3, "price": 1780}
]}
}
},
{
"id": 3,
"name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg",
"price": 27000,
"claimAmount": 110,
"completionTimeInSecs": 8,
"upgrades": [
{"tier": 1, "price": 1700, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 4500, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 2900, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 1}},
{"tier": 4, "price": 3400, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 2}},
{"tier": 5, "price": 4300, "claimBoost": 0, "speed": 1.8, "parts": {"Plasma Weave": 3}}
]
"price": 40200,
"claimAmount": 900,
"completionTimeInSecs": 8, "reach": 5,
"tracks": {
"yield": {"name": "Wide-bore bit", "levels": [
{"value": 1060, "price": 6900},
{"value": 1270, "price": 8700},
{"value": 1430, "price": 6900},
{"value": 1640, "price": 8700},
{"value": 1800, "price": 6900}
]},
"cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 5400},
{"value": 6, "price": 7200},
{"value": 5.2, "price": 7800},
{"value": 4.6, "price": 7800},
{"value": 4, "price": 9900}
]}
}
},
{
"id": 4,
"name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png",
"price": 290000,
"claimAmount": 1350,
"price": 645000,
"claimAmount": 20000,
"completionTimeInSecs": 10,
"reach": 2,
"upgrades": [
{"tier": 1, "price": 37000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 23000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 28000, "claimBoost": 0, "speed": 1.4, "parts": {"Cryo Cell": 2}},
{"tier": 4, "price": 34000, "claimBoost": 0, "speed": 1.6, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 42000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 1}}
]
"reach": 5,
"tracks": {
"yield": {"name": "Wider beam", "levels": [
{"value": 23700, "price": 115000},
{"value": 28200, "price": 135000},
{"value": 31800, "price": 115000},
{"value": 36300, "price": 135000},
{"value": 40000, "price": 115000}
]},
"cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 90000},
{"value": 7.5, "price": 115000},
{"value": 6.5, "price": 125000},
{"value": 5.7, "price": 130000},
{"value": 5, "price": 150000}
]}
}
},
{
"id": 5,
"name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg",
"price": 3200000,
"claimAmount": 17000,
"price": 9440000,
"claimAmount": 384000,
"completionTimeInSecs": 12,
"reach": 3,
"upgrades": [
{"tier": 1, "price": 380000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 230000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 280000, "claimBoost": 0, "speed": 1.4, "parts": {"Plasma Weave": 3}},
{"tier": 4, "price": 740000, "claimBoost": 0, "speed": 1.6, "parts": {"Void Core": 1}},
{"tier": 5, "price": 510000, "claimBoost": 0, "speed": 1.8, "parts": {"Stellar Lattice": 1}}
]
"reach": 5,
"tracks": {
"yield": {"name": "Fine sieves", "levels": [
{"value": 452000, "price": 1600000},
{"value": 542000, "price": 2080000},
{"value": 610000, "price": 1600000},
{"value": 700000, "price": 2080000},
{"value": 768000, "price": 1600000}
]},
"cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 1360000},
{"value": 9, "price": 1600000},
{"value": 7.8, "price": 1840000},
{"value": 6.8, "price": 2000000},
{"value": 6, "price": 2080000}
]}
}
},
{
"id": 6,
"name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png",
"price": 7400000,
"claimAmount": 36000,
"price": 17000000,
"claimAmount": 880000,
"completionTimeInSecs": 14,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 470000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1200000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 820000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 1}},
{"tier": 4, "price": 1000000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 1}},
{"tier": 5, "price": 1300000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]
"tracks": {
"yield": {"name": "Larger plates", "levels": [
{"value": 1050000, "price": 3120000},
{"value": 1220000, "price": 3120000},
{"value": 1420000, "price": 3600000},
{"value": 1590000, "price": 3120000},
{"value": 1760000, "price": 3120000}
]},
"cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 3280000},
{"value": 8, "price": 3440000},
{"value": 7, "price": 4000000}
]}
}
},
{
"id": 7,
"name": "Lunar Core Extractor LCE-360",
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
"image": "lunarcoreextractor.png",
"price": 11000000,
"claimAmount": 47000,
"price": 24800000,
"claimAmount": 1600000,
"completionTimeInSecs": 16,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 840000, "claimBoost": 0, "speed": 1.12},
{"tier": 2, "price": 1000000, "claimBoost": 0, "speed": 1.25},
{"tier": 3, "price": 1300000, "claimBoost": 0, "speed": 1.4, "parts": {"Void Core": 2}},
{"tier": 4, "price": 1600000, "claimBoost": 0, "speed": 1.6, "parts": {"Stellar Lattice": 2}},
{"tier": 5, "price": 2200000, "claimBoost": 0, "speed": 1.8, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]
"tracks": {
"yield": {"name": "Core barrel", "levels": [
{"value": 1910000, "price": 4480000},
{"value": 2250000, "price": 5040000},
{"value": 2550000, "price": 4480000},
{"value": 2890000, "price": 5040000},
{"value": 3200000, "price": 4480000}
]},
"cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 3360000},
{"value": 12, "price": 4480000},
{"value": 10.4, "price": 4800000},
{"value": 9.1, "price": 5200000},
{"value": 8, "price": 5680000}
]}
}
}
],
"moons": [
@@ -195,49 +259,36 @@
},
"refinery": {
"slotPrices": [1500, 4000, 10000, 25000, 60000, 150000, 400000, 1000000, 2500000],
"maxBatch": 25,
"maxBatch": 100,
"batchUpgrades": [
{ "maxBatch": 50, "price": 6000 },
{ "maxBatch": 100, "price": 25000 },
{ "maxBatch": 250, "price": 120000 },
{ "maxBatch": 500, "price": 600000 },
{ "maxBatch": 1000, "price": 3000000 },
{ "maxBatch": 2500, "price": 15000000 }
{ "maxBatch": 250, "price": 6000 },
{ "maxBatch": 500, "price": 30000 },
{ "maxBatch": 1000, "price": 150000 },
{ "maxBatch": 2500, "price": 600000 },
{ "maxBatch": 5000, "price": 3000000 },
{ "maxBatch": 10000, "price": 15000000 }
],
"bulkLog": 1.83,
"bulkLog": 1.0,
"unlockGrade": "C",
"items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 40, "seconds": 1,
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 28, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 113, "seconds": 2,
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
"inputs": {"Azurium": 3, "Novafor": 3},
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 157, "seconds": 3,
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
"inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 580, "seconds": 5,
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 460, "seconds": 3,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 630, "seconds": 6,
{"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
]
},
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5},
"attachments": {
"slotsPerTool": 2,
"items": [
{"name": "Turbo Core", "image": "turbo-core.png", "price": 900, "speed": 1.2,
"description": "An overdriven motor core. Runs hot, cycles fast."},
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 950, "yield": 1.15,
"description": "A synthetic-diamond cutting face that keeps more ore in every bite."},
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 85000, "speed": 1.35,
"description": "Wraps the spindle in coolant so the drill never stops to cool down."},
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 90000, "yield": 1.3,
"description": "Ground radar that steers the head into the richest part of the seam."}
]
},
"advisors": {
"seatPrices": [20000, 400000, 8000000],
"items": [
+57 -20
View File
@@ -12,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"math"
"net/http"
@@ -295,8 +296,9 @@ func watchOverride(ctx context.Context, db *pgxpool.Pool) {
}
// saveOverride records a new override (by the given admin), re-syncs the store items and
// puts it in effect.
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) {
// puts it in effect. With since >= 0 it refuses when the newest saved version is not since
// (0: none), so a save from a page loaded before another save doesn't undo that save.
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string, since int64) (int64, error) {
c, err := applyOverride(ov)
if err != nil {
return 0, gameErr("%s", err.Error())
@@ -307,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
}
var id int64
err = inTx(ctx, db, func(tx pgx.Tx) error {
if since >= 0 {
// The lock makes two saves from the same version take turns.
if _, err := tx.Exec(ctx, "lock table config_override in share row exclusive mode"); err != nil {
return err
}
var latest int64
if err := tx.QueryRow(ctx, "select coalesce(max(id), 0) from config_override").Scan(&latest); err != nil {
return err
}
if latest != since {
return gameErr("Version #%d was saved after this page loaded. Reload the page and make your changes again.", latest)
}
}
err := tx.QueryRow(ctx,
`insert into config_override(data, note, created_by)
values ($1, $2, (select id from users where id::text = $3)) returning id`,
@@ -490,10 +505,6 @@ func (s *server) economyRoutes(mux *http.ServeMux) {
}
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "economy", &EconomyPage{})
return
}
ctx := r.Context()
// Pick up a save made by another instance before showing the editor.
if err := loadOverride(ctx, s.db); err != nil {
@@ -505,18 +516,33 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
// The newest saved version (not always the one in effect: an invalid one is skipped);
// a save from this page is refused once there is a newer one.
var latest int64
if len(history) > 0 {
latest = history[0].ID
}
base := baseConfig()
c := cfg()
quotes := marketQuotes(c, time.Now())
data := map[string]any{
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"latest": latest,
"market": quotes,
"colors": resourceColors,
}
// Dev only: ?draft=<JSON merge patch> opens the editor with that draft (screenshots).
if raw := r.FormValue("draft"); s.dev && raw != "" {
var patch any
if err := json.Unmarshal([]byte(raw), &patch); err == nil {
data["draft"] = mergePatch(configDoc(c), patch)
}
}
s.render(w, "economy", &EconomyPage{
Allowed: true,
Data: map[string]any{
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"market": quotes,
"colors": resourceColors,
},
Data: data,
Quotes: quotes,
History: history,
Version: overrideVersion.Load(),
@@ -529,11 +555,6 @@ func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
// {"errors": [...]}.
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
userID := s.currentUser(r)
if !s.isAdmin(userID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
@@ -542,6 +563,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
}
var ov ConfigOverride
var note string
since := int64(-1) // save: the version the page loaded with
switch r.PathValue("action") {
case "analyze":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
@@ -549,7 +571,18 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
return
}
s.render(w, "economy-analysis", analyze(&draft, nil, diffConfig(cfg(), &draft).ChangeCount()))
n := diffConfig(cfg(), &draft).ChangeCount()
a := analyze(&draft, nil, n)
a.Progress = progression(cfg(), &draft, n == 0)
s.render(w, "economy-analysis", a)
return
case "sim":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
if err != nil {
fmt.Fprintf(w, `<p class="eco-bad">%s</p>`, template.HTMLEscapeString(err.Error()))
return
}
s.render(w, "economy-sim", simTimeline(cfg(), &draft, diffConfig(cfg(), &draft).ChangeCount() == 0))
return
case "save":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
@@ -557,6 +590,10 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
return
}
if since, err = strconv.ParseInt(r.PostFormValue("since"), 10, 64); err != nil || since < 0 {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"Reload the page: the save didn't say which version it started from"}})
return
}
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
return
@@ -584,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
id, err := saveOverride(ctx, s.db, ov, note, userID)
id, err := saveOverride(ctx, s.db, ov, note, "", since)
if err != nil {
if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
+894 -323
View File
File diff suppressed because it is too large. Load diff
+109 -84
View File
@@ -4,7 +4,7 @@ package main
// players are active: they claim (claim all with redeploy) as soon as a drill is ready (at
// most every gsClaimGap), stake drills whose well ran dry, sell every half minute and buy, all within a
// click budget (gsClicksPerSec). The "copies" player buys only drill copies (and moons); the
// "boosts" player buys whatever adds the most ₥/minute per ₥ (upgrades, attachments,
// "boosts" player buys whatever adds the most ₥/minute per ₥ (a level on a yield or cycle track,
// advisors or copies). Both buy a moon when their drills are about to run dry. Every report
// row lists the best buy of each kind with what it adds per ₥ spent, in ₥/minute and in
// ₥/click, and the summary compares the boosts with the copies.
@@ -15,7 +15,6 @@ import (
"math/rand/v2"
"slices"
"sort"
"strings"
)
type gsPlayer struct {
@@ -35,13 +34,15 @@ const (
gsClickBurst = 4.0 // clicks a player can bank for a burst
gsSellEvery = 30 // seconds between sell rounds (one click per resource)
gsClaimGap = 3 // seconds a player waits at least between two claims
gsSampleEvery = 30 // seconds between timeline samples
gsIdleShow = 5 // shortest stretch with nothing to click the timeline shows
)
type gsTool struct {
item, tier int
mods []string
doneAt int // busy until then; -1 idle
take []gsTake
item int
lv Levels
doneAt int // busy until then; -1 idle
take []gsTake
}
type gsTake struct {
@@ -61,21 +62,20 @@ type gsMoon struct {
free float64 // ore not yet taken or booked
}
// gsGroup is the drills of one kind (store item, tier, attachments); tool is one of them.
// gsGroup is the drills of one kind (store item, track levels); tool is one of them.
type gsGroup struct {
item, tier int
mods []string
n, tool int
item int
lv Levels
n, tool int
}
func gsKey(item, tier int, mods []string) string { return fmt.Sprint(item, tier, mods) }
// GSBuy is a candidate purchase: what it adds and what it costs.
type GSBuy struct {
Kind, Name string
Cost float64 // ₥
Gain, Click float64 // ₥/hour added, ₥/click added
tool int // upgrade or attachment: which tool; copy: which store item
tool int // upgrade: which tool; copy: which store item
track string // upgrade: yield or cycle
}
func (b GSBuy) Payback() string {
@@ -118,6 +118,23 @@ type GSResult struct {
BuyGapAt SimTime
BuyGapPreS SimTime // the same, before the first moon of the top grade
IdleAt SimTime
// For the timeline chart (progression.go):
Samples []GSSample // every gsSampleEvery seconds
Buys []GSEvent // copies, upgrades and advisors (moons: FirstGrade)
Idles [][2]int // stretches of gsIdleShow seconds or more with nothing to click, [from, to)
}
// GSSample is the player's credits and total earnings at a time (seconds).
type GSSample struct {
At int
Credits, Earned float64
}
// GSEvent is a purchase at a time (seconds).
type GSEvent struct {
At int
Kind, Name string
Cost float64
}
// GradeStat is the value of a grade's moons at base prices.
@@ -162,7 +179,7 @@ func gradeStats(c *GameConfig) []GradeStat {
if st.Drill != "" {
st.Drill += ", "
}
perWell := item.ClaimAmount / float64(item.ToolReach(0))
perWell := item.ClaimAmount / float64(item.ToolReach())
st.Drill += fmt.Sprintf("%s (%.1f claims, %ds)", toolCode(item.Name), g.MeanSupply()/perWell,
item.CompletionTimeInSecs)
}
@@ -180,17 +197,17 @@ func gradeStats(c *GameConfig) []GradeStat {
func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade int, gs []gsGroup, adv []string) (inc, clk float64) {
st := stats[grade]
stakes := 0.0
cycles := map[int]bool{}
cycles := map[float64]bool{}
b := c.bonusesFor(adv)
for _, x := range gs {
if x.n == 0 {
continue
}
it := &c.StoreItems[x.item]
b := c.bonusesFor(adv, x.mods)
secs, y := drillStats(it.ToolSecs(x.tier), it.ToolYield(x.tier), b, 1, 1)
reach := it.ToolReach(x.tier)
secs, y := drillStats(it.ToolSecs(x.lv), it.ToolYield(x.lv), b, 1, 1)
reach := it.ToolReach()
cycles[max(secs, gsClaimGap)] = true
p := float64(max(secs, gsClaimGap)) + 0.5 // waits half a tick on average
p := max(secs, gsClaimGap) + 0.5 // waits half a tick on average
n := float64(x.n)
inc += n * caps(grade).Yield(y, reach) * st.Unit * b.Sell * 3600 / p
per := st.Units / max(1, st.Wells) / y
@@ -201,7 +218,7 @@ func gsEval(c *GameConfig, stats []GradeStat, caps func(int) ReachCaps, grade in
}
claims := 0.0
for secs := range cycles {
claims += 3600 / float64(secs)
claims += 3600 / secs
}
clk = min(claims, 3600/gsClaimGap) + stakes + 3600/gsSellEvery*min(5, math.Round(st.Wells))
if budget := 3600 * gsClicksPerSec; clk > budget {
@@ -224,7 +241,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
var advisors []string
seats := 1
refinery := c.Refinery.UnlockGrade == ""
grade := 0 // of the last moon bought
grade := 0 // the dearest moon bought: purchases are valued on it, and its price is kept
pressure := map[string]float64{}
stock := map[string]float64{}
var resources []string
@@ -250,30 +267,29 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
idx := map[string]int{}
var out []gsGroup
for j, t := range tools {
k := gsKey(t.item, t.tier, t.mods)
k := fmt.Sprint(t.item, t.lv)
if i, ok := idx[k]; ok {
out[i].n++
continue
}
idx[k] = len(out)
out = append(out, gsGroup{t.item, t.tier, t.mods, 1, j})
out = append(out, gsGroup{t.item, t.lv, 1, j})
}
return out
}
// with is gs with one drill of group k (none if k < 0) turned into (item, tier, mods).
with := func(gs []gsGroup, k, item, tier int, mods []string) []gsGroup {
// with is gs with one drill of group k (none if k < 0) turned into (item, lv).
with := func(gs []gsGroup, k, item int, lv Levels) []gsGroup {
out := slices.Clone(gs)
if k >= 0 {
out[k].n--
}
key := gsKey(item, tier, mods)
for i := range out {
if gsKey(out[i].item, out[i].tier, out[i].mods) == key {
if out[i].item == item && out[i].lv == lv {
out[i].n++
return out
}
}
return append(out, gsGroup{item, tier, mods, 1, -1})
return append(out, gsGroup{item, lv, 1, -1})
}
eval := func(gs []gsGroup, adv []string) (float64, float64) { return gsEval(c, stats, caps, grade, gs, adv) }
partsCost := func(parts map[string]float64) (float64, bool) {
@@ -294,25 +310,20 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
var out []GSBuy
if len(tools) < gsMaxTools {
for i, it := range c.StoreItems {
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, 0, nil), advisors))
out = append(out, buy("copy", toolCode(it.Name), it.Price, i, with(gs, -1, i, Levels{}), advisors))
}
}
for k, x := range gs {
it := &c.StoreItems[x.item]
if u := it.NextUpgrade(x.tier); u != nil {
if pc, ok := partsCost(u.Parts); ok {
out = append(out, buy("upgrade", fmt.Sprintf("%s t%d", toolCode(it.Name), u.Tier), u.Price+pc,
x.tool, with(gs, k, x.item, u.Tier, x.mods), advisors))
}
}
if len(x.mods) < c.Attachments.SlotsPerTool {
for _, m := range c.Attachments.Items {
if slices.Contains(x.mods, m.Name) {
continue
for _, kind := range trackKinds {
n := x.lv.Of(kind) + 1
if u := it.TrackLevel(kind, n); u != nil {
if pc, ok := partsCost(u.Parts); ok {
b := buy("upgrade", fmt.Sprintf("%s %s%d", toolCode(it.Name), kind[:1], n), u.Price+pc,
x.tool, with(gs, k, x.item, x.lv.With(kind, n)), advisors)
b.track = kind
out = append(out, b)
}
mods := append(slices.Clone(x.mods), m.Name)
out = append(out, buy("attachment", m.Name+" on "+toolCode(it.Name), m.Price, x.tool,
with(gs, k, x.item, x.tier, mods), advisors))
}
}
}
@@ -347,7 +358,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
sum := 0.0
for _, t := range tools {
it := &c.StoreItems[t.item]
_, y := drillStats(it.ToolSecs(t.tier), it.ToolYield(t.tier), c.bonusesFor(advisors, t.mods), 1, 1)
_, y := drillStats(it.ToolSecs(t.lv), it.ToolYield(t.lv), c.bonusesFor(advisors), 1, 1)
sum += 2 * y
}
return sum
@@ -362,6 +373,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if gap := SimTime(t - lastClick); gap > res.Idle {
res.Idle, res.IdleAt = gap, SimTime(lastClick)
}
if t-lastClick >= gsIdleShow {
res.Idles = append(res.Idles, [2]int{lastClick, t})
}
lastClick = t
return true
}
@@ -380,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
freeOre += m.free
open = append(open, len(moons))
moons = append(moons, m)
grade = g
grade = max(grade, g)
if _, ok := res.FirstGrade[gr.Grade]; !ok {
res.FirstGrade[gr.Grade] = SimTime(t)
res.GradeClicks[gr.Grade] = clicks
@@ -394,7 +408,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
// book stakes a tool on moon mi: on well only (a one-well redeploy), else its fullest wells.
book := func(t int, tl *gsTool, mi, only int) bool {
it := &c.StoreItems[tl.item]
secs, y := drillStats(it.ToolSecs(tl.tier), it.ToolYield(tl.tier), c.bonusesFor(advisors, tl.mods), 1, 1)
secs, y := drillStats(it.ToolSecs(tl.lv), it.ToolYield(tl.lv), c.bonusesFor(advisors), 1, 1)
m := moons[mi]
var order []int
if only >= 0 {
@@ -406,9 +420,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
slices.SortFunc(order, func(a, b int) int {
return cmpDesc(m.wells[a].left-m.wells[a].booked, m.wells[b].left-m.wells[b].booked)
})
order = order[:min(it.ToolReach(tl.tier), len(order))]
order = order[:min(it.ToolReach(), len(order))]
}
share := y / float64(it.ToolReach(tl.tier))
share := y / float64(it.ToolReach())
tl.take = tl.take[:0]
for _, wi := range order {
w := &m.wells[wi]
@@ -423,7 +437,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if len(tl.take) == 0 {
return false
}
tl.doneAt = t + secs
tl.doneAt = t + int(math.Ceil(secs-1e-9))
return true
}
// deploy stakes an idle tool on the moon with the most free ore.
@@ -471,7 +485,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
// Redeploy: the same well for a one-well drill, the same moon for the others.
if len(prev) > 0 {
only := -1
if c.StoreItems[tl.item].ToolReach(tl.tier) == 1 {
if c.StoreItems[tl.item].ToolReach() == 1 {
only = prev[0].well
}
book(t, tl, prev[0].moon, only)
@@ -489,7 +503,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
pressure[name] = c.MarketPressure.Decay(pressure[name], 1)
}
if t-lastSell >= gsSellEvery {
sell := c.bonusesFor(advisors, nil).Sell
sell := c.bonusesFor(advisors).Sell
tick := int64(t / 300)
left := false
for _, name := range resources {
@@ -515,6 +529,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
lastSell = t
}
}
if t%gsSampleEvery == 0 {
res.Samples = append(res.Samples, GSSample{t, credits, earned})
}
report := next < len(gsReportAt) && t >= gsReportAt[next]*60
if !wantBuy && !report && freeOre >= need() {
continue
@@ -556,7 +573,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
row := GSRow{At: SimTime(t), Credits: credits, Tools: len(tools), Grade: c.MoonGrades[grade].Grade,
Income: (earned - lastEarned) * 3600 / dt, PerClick: (earned - lastEarned) / float64(max(1, clicks-lastClicks)),
ClickMin: float64(clicks-lastClicks) * 60 / dt, Best: map[string]GSBuy{}, BestKind: best("", pl.All).Kind}
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
for _, k := range []string{"copy", "upgrade", "advisor"} {
row.Best[k] = best(k, true)
}
res.Rows = append(res.Rows, row)
@@ -566,17 +583,18 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
wantBuy = false
for range 50 {
b := best("", pl.All)
// Keep enough for the next moon, unless the drills have plenty of ore.
// Keep enough for the next moon, unless the drills have plenty of ore: the dearest
// grade bought (a grade picked from credits would fall with every purchase).
reserve := 0.0
if freeOre < 20*need() {
reserve = c.MoonGrades[nextGrade(income)].Price
reserve = c.MoonGrades[grade].Price
}
if b.Kind == "" || credits-b.Cost < reserve {
break
}
cost := 1.0
if b.Kind == "attachment" || (b.Kind == "advisor" && len(advisors) >= seats) {
cost = 2 // buy and equip, buy a seat and hire
if b.Kind == "advisor" && len(advisors) >= seats {
cost = 2 // buy a seat and hire
}
if bank < cost {
wantBuy = true
@@ -587,6 +605,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
}
credits -= b.Cost
res.Bought[b.Kind]++
res.Buys = append(res.Buys, GSEvent{t, b.Kind, b.Name, b.Cost})
res.Spent[b.Kind] += b.Cost
res.GainMin[b.Kind] += b.Gain / 60
res.GainClick[b.Kind] += b.Click
@@ -594,10 +613,8 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
case "copy":
tools = append(tools, gsTool{item: b.tool, doneAt: -1})
case "upgrade":
tools[b.tool].tier++
case "attachment":
name := b.Name[:strings.Index(b.Name, " on ")]
tools[b.tool].mods = append(slices.Clone(tools[b.tool].mods), name)
lv := tools[b.tool].lv
tools[b.tool].lv = lv.With(b.track, lv.Of(b.track)+1)
case "advisor":
if len(advisors) >= seats {
seats++
@@ -626,8 +643,8 @@ func printGradeSim(c *GameConfig) {
for _, pl := range gsPlayers {
r := simulatePlayer(c, pl, stats)
fmt.Printf("\n%s player\n", pl.Name)
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-26s %-34s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
"best copy (/min, /clk)", "best upgrade", "best attachment", "best advisor", "buys")
fmt.Printf("%7s %9s %9s %6s %5s %5s %3s %-26s %-28s %-28s %s\n", "at", "credits", "₥/h", "₥/clk", "clk/m", "tools", "gr",
"best copy (/min, /clk)", "best upgrade", "best advisor", "buys")
cell := func(b GSBuy) string {
if b.Kind == "" {
return "-"
@@ -635,9 +652,9 @@ func printGradeSim(c *GameConfig) {
return fmt.Sprintf("%s %.3g %.3g", b.Name, b.PerMin(), b.PerClick())
}
for _, row := range r.Rows {
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-26s %-34s %-28s %s\n", row.At, row.Credits, row.Income,
fmt.Printf("%7s %9.0f %9.0f %6.0f %5.1f %5d %3s %-26s %-28s %-28s %s\n", row.At, row.Credits, row.Income,
row.PerClick, row.ClickMin, row.Tools, row.Grade, cell(row.Best["copy"]), cell(row.Best["upgrade"]),
cell(row.Best["attachment"]), cell(row.Best["advisor"]), row.BestKind)
cell(row.Best["advisor"]), row.BestKind)
}
fmt.Print("first moon of each grade (clicks by then):")
for _, g := range c.MoonGrades {
@@ -654,7 +671,7 @@ func printGradeSim(c *GameConfig) {
fmt.Printf("\nlongest stretch with nothing to click: %s (from %s); with nothing to buy: %s (from %s), before the top grade %s\nbought: %v\n",
r.Idle, r.IdleAt, r.BuyGap, r.BuyGapAt, r.BuyGapPreS, r.Bought)
fmt.Print("purchases (count, ₥ spent, ₥/min and ₥/click added per 1k₥):")
for _, k := range []string{"copy", "upgrade", "attachment", "advisor"} {
for _, k := range []string{"copy", "upgrade", "advisor"} {
if sp := r.Spent[k]; sp > 0 {
fmt.Printf(" %s %d %s %.3g %.3g;", k, r.Bought[k], formatCompact(sp), r.GainMin[k]/sp*1000, r.GainClick[k]/sp*1000)
}
@@ -664,8 +681,8 @@ func printGradeSim(c *GameConfig) {
}
// printBoostTable compares, for each drill alone on the grade it's meant for, a second copy
// with its boosts: what each adds per 1k₥, in ₥/minute and ₥/click; boosts as a ratio to
// the copy.
// with its upgrades: what each adds per 1k₥, in ₥/minute and ₥/click; upgrades as a ratio
// to the copy: each track's first level, the whole track, and both tracks to the top.
func printBoostTable(c *GameConfig, stats []GradeStat) {
capsBy := map[int]ReachCaps{}
caps := func(g int) ReachCaps {
@@ -674,33 +691,41 @@ func printBoostTable(c *GameConfig, stats []GradeStat) {
}
return capsBy[g]
}
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), boosts ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %13s", "drill", "gr", "copy", "upgrades t1-5")
for _, m := range c.Attachments.Items {
fmt.Printf(" %13s", m.Name)
}
fmt.Println()
fmt.Printf("\na drill alone on its grade: a copy (₥/min, ₥/click per 1k₥), upgrades ÷ copy (₥/min, ₥/click)\n%-9s %2s %13s %15s %15s %15s %15s %15s\n",
"drill", "gr", "copy", "yield 1", "yield all", "cycle 1", "cycle all", "both all")
for i := range c.StoreItems {
it := &c.StoreItems[i]
g := c.gradeIndex(suitedGrade(c, it).Grade)
base := []gsGroup{{i, 0, nil, 1, 0}}
inc0, clk0 := gsEval(c, stats, caps, g, base, nil)
inc0, clk0 := gsEval(c, stats, caps, g, []gsGroup{{i, Levels{}, 1, 0}}, nil)
per := func(gs []gsGroup, cost float64) (float64, float64) {
inc, clk := gsEval(c, stats, caps, g, gs, nil)
return (inc - inc0) / 60 / cost * 1000, (inc/clk - inc0/clk0) / cost * 1000
}
cm, cc := per([]gsGroup{{i, 0, nil, 2, 0}}, it.Price)
cm, cc := per([]gsGroup{{i, Levels{}, 2, 0}}, it.Price)
fmt.Printf("%-9s %2s %6.3g %6.3g", toolCode(it.Name), c.MoonGrades[g].Grade, cm, cc)
cost, top := 0.0, 0
for _, u := range it.Upgrades {
cost += u.Price + partsValue(c, u.Parts)
top = max(top, u.Tier)
cost := func(kind string, upTo int) float64 {
sum := 0.0
for n := 1; n <= upTo; n++ {
if l := it.TrackLevel(kind, n); l != nil {
sum += l.Price + partsValue(c, l.Parts)
}
}
return sum
}
if top > 0 {
m, k := per([]gsGroup{{i, top, nil, 1, 0}}, cost)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
for _, a := range c.Attachments.Items {
m, k := per([]gsGroup{{i, 0, []string{a.Name}, 1, 0}}, a.Price)
top := it.MaxLevels()
for _, v := range []struct {
lv Levels
cost float64
}{
{Levels{Yield: 1}, cost("yield", 1)}, {Levels{Yield: top.Yield}, cost("yield", top.Yield)},
{Levels{Cycle: 1}, cost("cycle", 1)}, {Levels{Cycle: top.Cycle}, cost("cycle", top.Cycle)},
{top, cost("yield", top.Yield) + cost("cycle", top.Cycle)},
} {
if v.cost <= 0 {
fmt.Printf(" %13s", "-")
continue
}
m, k := per([]gsGroup{{i, v.lv, 1, 0}}, v.cost)
fmt.Printf(" ×%5.2f ×%5.2f", m/cm, k/cc)
}
fmt.Println()
+1 -2
View File
@@ -1,8 +1,7 @@
package main
// The Leaderboard section: the richest miners by credit balance, with the player's own
// place below the top when they're not in it. Banned players don't rank. Template:
// leaderboard-section in app.html.
// place below the top when they're not in it. Banned players don't rank.
import (
"context"
+14 -18
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
@@ -27,8 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
return err
}
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
// Credits spent are logged as "price" on spendKinds, earned as "credits" on earnKinds.
// buy_attachment: attachments were sold until migration 020 (kept for older events).
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'upgrade_batch', 'buy_attachment', 'hire_advisor', 'buy_seat'"
const earnKinds = "'sell', 'sell_moon', 'sell_tool'"
// ------------------------------------------------------------------ dashboard
@@ -93,7 +96,7 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
and kind not like 'admin\_%'),
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind = 'sell'),
where created_at >= day and created_at < day + 1 and kind in (`+earnKinds+`)),
(select coalesce(sum((data->>'price')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
from days order by day`)
@@ -124,20 +127,21 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
d.ActiveToday = d.Days[n-1].Active
}
// Dn retention: of the players who joined in the last 30 days at least n days ago,
// the share that did something n days after the day they joined.
// Dn retention: of the players who joined in the last 30 days whose day n is over
// (joined at least n+1 days ago), the share that did something n days after the day
// they joined.
err = q.QueryRow(ctx, `
with cohort as (
select id, created_at::date as day from users
where created_at >= current_date - 30
)
select
count(*) filter (where day <= current_date - 1),
count(*) filter (where day <= current_date - 1 and exists (
count(*) filter (where day <= current_date - 2),
count(*) filter (where day <= current_date - 2 and exists (
select 1 from event_log where user_id = cohort.id
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
count(*) filter (where day <= current_date - 7),
count(*) filter (where day <= current_date - 7 and exists (
count(*) filter (where day <= current_date - 8),
count(*) filter (where day <= current_date - 8 and exists (
select 1 from event_log where user_id = cohort.id
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
@@ -222,10 +226,6 @@ func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
}
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "liveops", &LiveOps{})
return
}
lo, err := s.liveOps(r)
if err != nil {
serverError(w, err)
@@ -256,10 +256,6 @@ func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
// that panel (#lo-events or #lo-announcements).
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
what, action := r.PathValue("what"), r.PathValue("action")
@@ -369,7 +365,7 @@ func parseAnnouncement(r *http.Request) (Announcement, string) {
switch {
case a.Message == "":
msg = "Write a message"
case len(a.Message) > 200:
case utf8.RuneCountInString(a.Message) > 200: // as the form's maxlength counts
msg = "Keep the message under 200 characters"
case !slices.Contains(announcementLevels, a.Level):
msg = "Pick a level"
+2 -2
View File
@@ -1,6 +1,6 @@
// Moon Miners: a server-rendered browser mining game.
// Moon Miners: the game server (JSON API for the Defold client) and its admin pages.
//
// moonminers [serve] run the web server (default)
// moonminers [serve] run the server (default)
// moonminers migrate create roles/schema, apply migrations, load the game config
// moonminers load-config sync the store items in the database with the game config
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
+94 -101
View File
@@ -15,6 +15,8 @@ import (
"math"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5"
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
return q
}
// marketRateAt is the market price of a unit at a tick, before live events.
// marketRateAt is the market price of a unit at a tick, before live events. Ticks in the
// cached 24h window (marketSeries) are read from it; the current tick fills it.
func marketRateAt(name string, tick int64) float64 {
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
c := cfg()
if mc := marketCached.Load(); mc != nil && mc.cfg == c && tick <= mc.tick && tick > mc.tick-marketPoints {
return mc.get(name).series[marketPoints-1-int(mc.tick-tick)]
}
if d := tick - marketTick(time.Now()); d >= -1 && d <= 1 {
return marketSeriesAt(name, tick).rate
}
return rateAt(c, name, tick)
}
func rateAt(c *GameConfig, name string, tick int64) float64 {
return c.unitPrice(name) * (1 + c.MarketVolatility*marketDrift(name, tick))
}
// ------------------------------------------------------------------ shared 24h series
// marketSeries is an item's market rate over the 24h up to a tick (marketPoints, oldest
// first) and what the market shows of it. It's shared by every player: read it, never
// modify it. Live price events aren't in it (they multiply the quote per request), so
// they never make it stale.
type marketSeries struct {
series []float64
rate, open, high, low, change float64
}
// marketTickCache holds the series of one tick under one config snapshot (cfg() is a new
// pointer whenever the economy editor or another instance changes the config).
type marketTickCache struct {
cfg *GameConfig
tick int64
items sync.Map // name → *marketSeries
}
var marketCached atomic.Pointer[marketTickCache]
func (mc *marketTickCache) get(name string) *marketSeries {
if v, ok := mc.items.Load(name); ok {
return v.(*marketSeries)
}
v, _ := mc.items.LoadOrStore(name, newMarketSeries(mc.cfg, name, mc.tick))
return v.(*marketSeries)
}
// marketSeriesAt is an item's series up to tick, cached for the newest tick asked for.
func marketSeriesAt(name string, tick int64) *marketSeries {
c := cfg()
mc := marketCached.Load()
if mc == nil || mc.cfg != c || mc.tick < tick {
next := &marketTickCache{cfg: c, tick: tick}
if marketCached.CompareAndSwap(mc, next) {
mc = next
} else {
mc = marketCached.Load()
}
}
if mc == nil || mc.cfg != c || mc.tick != tick {
return newMarketSeries(c, name, tick) // an older tick, or a racing config change
}
return mc.get(name)
}
func newMarketSeries(c *GameConfig, name string, tick int64) *marketSeries {
m := &marketSeries{series: make([]float64, marketPoints)}
for i := range m.series {
m.series[i] = rateAt(c, name, tick-int64(marketPoints-1-i))
}
m.rate, m.open = m.series[marketPoints-1], m.series[0]
m.high, m.low = m.series[0], m.series[0]
for _, p := range m.series {
m.high, m.low = max(m.high, p), min(m.low, p)
}
if m.open > 0 {
m.change = (m.rate/m.open - 1) * 100
}
return m
}
// marketRate is the market price of a unit at time t, before live events.
@@ -189,13 +266,14 @@ func loadPressure(ctx context.Context, q querier, s *State) error {
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
type PriceBoosts map[string]float64
// For is the combined multiplier for a resource (same rule as eventMultiplier).
// For is the combined multiplier for a resource (same rule as eventMultiplier); For("all")
// is the "all" events alone.
func (b PriceBoosts) For(name string) float64 {
m := 1.0
if v, ok := b["all"]; ok {
m *= v
}
if v, ok := b[name]; ok {
if v, ok := b[name]; ok && name != "all" {
m *= v
}
return m
@@ -249,10 +327,9 @@ type Quote struct {
High float64 // 24h high/low of the market rate
Low float64
Value float64 // Balance × Price (at the quote)
Spark string // SVG path, viewBox 0 0 100 32
URL string
Selected bool
series []float64
series []float64 // the shared 24h market rate (marketSeries): read only
}
func (q Quote) Up() bool { return q.Change >= 0 }
@@ -267,7 +344,7 @@ func (q Quote) PressureText() string {
}
// Unpressed is what one unit pays the player before selling pressure (quote × advisors);
// the sell ticket (market.js) applies the pressure curve to its amount.
// the sell ticket applies the pressure curve to its amount.
func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
// Unit is what one unit pays the player now (a tiny sale).
@@ -278,8 +355,7 @@ func (q Quote) SellAll() float64 {
return sellPayout(q.Balance, q.Price, q.Advisor, cfg().MarketPressure.Average(q.Pressure, q.Balance*q.Base()))
}
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
// AdvisorText is e.g. "+8%".
func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
return strings.ToUpper(name)
}
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
// MarketChart is the selected quote's 24h chart: its points (the quote's series) start at
// Start, unix ms, Step ms apart.
type MarketChart struct {
Line, Area string
Prices string // comma-separated, for the crosshair (market.js)
Start, Step int64 // unix ms of the first point, ms between points
HighY, LowY float64
Grid []GridLine
Times []TimeLabel
Start, Step int64
}
type GridLine struct {
Y float64 // in viewBox units
Top float64 // the same, as a percent of the height
Label string
}
type TimeLabel struct {
X float64 // percent
Label string
}
const (
chartW = 600
chartH = 220
sparkW = 100
sparkH = 32
)
// marketPage builds the market from the rows the market section lists (resources and
// refined items).
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
@@ -347,23 +401,13 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
for _, row := range rows {
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
q.series = make([]float64, marketPoints)
for i := range q.series {
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
}
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
q.High, q.Low = q.series[0], q.series[0]
for _, p := range q.series {
q.High, q.Low = max(q.High, p), min(q.Low, p)
}
if q.Open > 0 {
q.Change = (q.Rate/q.Open - 1) * 100
}
m := marketSeriesAt(row.Resource, now)
q.series = m.series
q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
q.Pressure = s.Pressure[row.Resource]
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
q.Price = q.Rate * q.Boost
q.Value = q.Balance * q.Price
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
mp.Quotes = append(mp.Quotes, q)
}
for i := range mp.Quotes {
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
}
if q := mp.Selected; q != nil {
q.Selected = true
mp.Chart = s.marketChart(q, now)
mp.Chart = marketChart(now)
}
return mp
}
func (s *State) marketChart(q *Quote, now int64) MarketChart {
// Pad the range so the line doesn't touch the edges.
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
lo, hi := q.Low-pad, q.High+pad
c := MarketChart{Step: marketStep.Milliseconds(),
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
c.HighY, c.LowY = y(q.High), y(q.Low)
prices := make([]string, len(q.series))
for i, p := range q.series {
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
}
c.Prices = strings.Join(prices, ",")
for i := 1; i <= 3; i++ {
p := lo + (hi-lo)*float64(i)/4
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
}
for h := 0; h <= 24; h += 6 {
label := fmt.Sprintf("−%dh", 24-h)
if h == 24 {
label = "Now"
}
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
}
return c
}
// chartPaths returns an SVG line path through every nth point of series, scaled to
// w×h between lo and hi, and the area under it.
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
if len(series) < 2 {
return "", ""
}
if hi <= lo {
lo, hi = lo-1, hi+1
}
var b strings.Builder
last := len(series) - 1
for i := 0; i <= last; i += every {
if i+every > last {
i = last
}
x := float64(i) / float64(last) * w
y := h - (series[i]-lo)/(hi-lo)*h
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
}
line = b.String()
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
func marketChart(now int64) MarketChart {
return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
}
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more