Compare commits

..
106 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
183 changed files with 12874 additions and 18731 deletions

No files matched your search

+11 -5
View File
@@ -15,13 +15,14 @@ the whole account. There is also a leaderboard (Leaderboard section: top miners
banned players left out). banned players left out).
## How the parts fit ## How the parts fit
- `server/`: the Go server (game rules, Postgres, admin pages) and the web client, which it - `server/`: the Go server: game rules, Postgres, the JSON API and the admin pages
renders as HTML (htmx, three.js moon). The server is the only authority on game state. (`/utils`, server-rendered with htmx, behind `ADMIN_PASSWORD`). The server is the only
Details: `server/AGENTS.md`. authority on game state. Details: `server/AGENTS.md`.
- `client/`: the Defold client (desktop and a WebAssembly build). It talks to the server's - `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 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`. what it sends. It is the only game client, and its moon generator
Details: `client/AGENTS.md`. (`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, - Repo root: `docker-compose.yml` (local Postgres), `docker-compose.prod.yml` (production,
Dokploy; builds `server/`), `IDEAS.md` (features not built yet), `README.md`. 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>` 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>` 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/`. 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 - `.env` (database credentials) is in the repo root or in `server/`. Never read, print or
commit it. 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. 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). - `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 Needs Go 1.25+ and Podman or Docker. Put a `.env` in the repo root (variables in
`server/README.md`). `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 podman-compose up -d db # or: docker compose up -d db
cd server cd server
make migrate 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 After pulling changes or editing `server/config/game.json`, run `make migrate` again before
migrations and syncs the tools with `config/game.json` (the server alone doesn't). starting the server: it applies new migrations and syncs the tools and prices with the
config (the server alone doesn't).
## Defold client ## Defold client
Needs Java 21+. The Makefile downloads Defold's command-line builder (`bob.jar`). The editor Needs the Defold editor (https://defold.com/download/) or Java 21+. The Makefile downloads
is optional: https://defold.com/download/ 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>`
With the server running: for another place, `JAVA=<path>` for your own Java); without an editor, `java` from PATH.
```bash ```bash
cd client cd client
make run # desktop build, plays against http://localhost:3000 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 ## Deploy
Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`. Dokploy, Docker Compose with `docker-compose.prod.yml`. Details in `server/README.md`.
+16 -10
View File
@@ -1,8 +1,8 @@
# AGENTS.md — Moon Miners client (Defold) # AGENTS.md — Moon Miners client (Defold)
A native client for Moon Miners (rules and state live in the Go server: `../server`, 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 `../server/AGENTS.md`; the repo: `../AGENTS.md`), and the only game client. Layout and HUD
(header, moon list left, the section in the middle, aside with the moon viewport and the 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 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, 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 gamepad later. Details live in each file's header comment; this file has the rules and a
@@ -42,12 +42,16 @@ map.
`gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW; `gui/templates/`, instanced under `protos` in `game.gui`). Template roots are pivot NW;
layout is top-left based (x right, y down). 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; - 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 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 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.note`/`push`/`put`); code that builds outside a build runs through
`blocks.bound(fn)`. `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`, 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`. 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 - 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 - 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. doesn't render (make check catches the warning); stencils and images split batches.
Outline textures use slice9 2. 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 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 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. `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/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. - `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 - 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`, - Sections: `moons.lua`, `market.lua` (+ `ticker.lua`, `chart.lua`, `sale.lua`,
`leaderboard.lua`), `refinery.lua`, `equipment.lua`, `store.lua`, `advisors.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` `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`, Components (`components/`, API in each header; build new UI from these): `button`,
`dialog`, `panel`, `card` (+ `card.grid`), `tabs`, `list` (long lists, Druid Data List), `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/`): Modules (`modules/`):
- `flex.lua` (pure flexbox/grid solver, tests in `scripts/flex_test.lua`), `flex_gui.lua` - `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`. `settings.lua` (the settings file), `images.lua`, `tween_numbers.lua`.
- `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook), - `api.lua` (server API, `X-MM-User`), `predict.lua` (predictors), `test.lua` (test hook),
`hot.lua` (hot reload). `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: Elsewhere:
- `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object). - `main/`: bootstrap (`main.script`, Monarch proxy, window focus, sfx object).
- `moon/`: moon collection, `moon.script`, materials (port of scene.js). - `moon/`: moon collection, `moon.script`, materials (port of scene.js).
`render/mm.render_script`: HDR moon pass with bloom. `render/mm.render_script`: HDR moon pass with bloom.
- `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator - `mmwindow/`: native fullscreen and sticky input. `mmterrain/`: native moon generator, the
(C port of terrain.js; `scripts/moon-check.mjs` compares, in `make check`). 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`), - `gui/templates/`: node templates. `assets/`: fonts (Rajdhani, Oxanium, Inter `sym`),
`ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`). `ui.atlas`, `sfx/` (CREDITS.txt; `scripts/zzfx-render.mjs`).
- `web/index.html`: the web build's page. - `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, # Moon Miners Defold client. Builds with bob.jar, run by the Java the Defold editor ships
# python3 and luajit for shot, play, drive, dev and check. # (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_SHA := 6823d775883a04f4210bf04cd54cbffc468ac0ad
BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar BOB_URL := https://d.defold.com/archive/beta/$(BOB_SHA)/bob/bob.jar
BOB := tools/bob.jar BOB := tools/bob.jar
@@ -18,25 +30,26 @@ $(BOB):
curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@ curl -fL -o $@.tmp $(BOB_URL) && mv $@.tmp $@
resolve: $(BOB) 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). # Dependencies are fetched again only when game.project changes (`make resolve` forces it).
.internal/lib/dependencies.json: game.project | $(BOB) .internal/lib/dependencies.json: game.project | $(BOB)
java -jar $(BOB) --platform $(PLATFORM) resolve $(JAVA) -jar $(BOB) --platform $(PLATFORM) resolve
touch $@ touch $@
build: $(BOB) .internal/lib/dependencies.json 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 --archive --bundle-output dist/$(PLATFORM) build bundle
# Web build (WebAssembly) into dist/web/MoonMiners/ (index.html + engine + archive). Release # 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). # 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 WEB_VARIANT ?= release
UPSTREAM ?= http://localhost:3000 UPSTREAM ?= http://localhost:3000
WEB_PORT ?= 8080 WEB_PORT ?= 8080
web: $(BOB) .internal/lib/dependencies.json 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 --archive --bundle-output dist/web build bundle
serve-web: serve-web:
@@ -50,7 +63,7 @@ dev: build
reload: $(BOB) reload: $(BOB)
@mkdir -p dist/dev @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; } || { grep -E "ERROR|error" dist/dev/bob.log || tail -30 dist/dev/bob.log; exit 1; }
@scripts/dev.py reload @scripts/dev.py reload
@@ -63,7 +76,7 @@ SCENARIO ?= smoke
DRIVE ?= --fixture DRIVE ?= --fixture
drive: $(BOB) .internal/lib/dependencies.json drive: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @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; } || { grep -E "ERROR|error" dist/bob-drive.log || tail -30 dist/bob-drive.log; exit 1; }
scripts/drive.py $(DRIVE) $(SCENARIO) 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). # dist/drive/lint-<size>/. Needs MM_API (a test server).
lint: $(BOB) .internal/lib/dependencies.json lint: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @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; } || { 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 @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. # scenario run (sections) against MM_API (required: a test server) if it answers.
check: $(BOB) .internal/lib/dependencies.json check: $(BOB) .internal/lib/dependencies.json
@mkdir -p dist @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; } || { grep -E "ERROR|error" dist/bob-check.log || tail -30 dist/bob-check.log; exit 1; }
scripts/check.py scripts/check.py
@@ -90,11 +103,13 @@ run: build
# Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too). # Screenshot of the running game at 1280x800 under Xvfb (POPUP=1 opens the modal too).
shot: build 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/. # 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 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: clean:
rm -rf build dist .internal/cache rm -rf build dist .internal/cache
+16 -5
View File
@@ -6,9 +6,12 @@
-- local l = list.new(g, parent, x, y, w, h, { -- local l = list.new(g, parent, x, y, w, h, {
-- key = "moons", row_h = 64, -- the row's slot (gap included) -- key = "moons", row_h = 64, -- the row's slot (gap included)
-- create = function(item, index) ... return root[, druid component] end, -- 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 -- -- id its row registers: Tab walks
-- -- every row, scrolling (modules/focus) -- -- 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) -- 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 -- 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) l.data = g.druid:new_data_list(l.scroll, l.grid, function(_, item, index)
return l.create(item, index) return l.create(item, index)
end) 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) l.scroll.on_scroll:subscribe(function(_, pos)
if g.tearing_down then if g.tearing_down then
return -- (the old list emptying itself during a rebuild: keep the offset) 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 end
function M:set_data(items) 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.items = items
self.data:set_data(items) -- At the saved offset first (Druid clamps it to the rows), so the data list makes the
-- Back to the saved offset (Druid clamps it to the rows; the data list follows the -- rows there, not the top ones only to drop them again.
-- scroll event).
local s = self.scroll local s = self.scroll
s:set_size(self.grid:get_size_for(#items))
s.target_position.y = saved s.target_position.y = saved
s:_set_scroll_position(s.position.x, saved) s:_set_scroll_position(s.position.x, saved)
s.target_position.y = s.position.y 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) local max = math.max(0, #items * gui.get_size(self.prefab).y - self.h)
for _, a in ipairs(self.g.scroll_areas) do for _, a in ipairs(self.g.scroll_areas) do
if a.list == self then 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 }. -- Frame-driven tweens: { t0, dur, step(k, node), node, done }.
local running = {} local running = {}
local shaking = {} -- { node, t, rest = { p } } of the shakes running (M.shake)
local function now() local function now()
return socket.gettime() return socket.gettime()
@@ -208,6 +209,7 @@ end
-- Drop every frame-driven tween (a screen going away). -- Drop every frame-driven tween (a screen going away).
function M.clear() function M.clear()
running = {} running = {}
shaking = {}
end end
---------------------------------------------------------------- fading content ---------------------------------------------------------------- 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/ -- 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. -- 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 } } 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) function M.shake(node, amp, dur, delay)
if M.reduced then if M.reduced then
return return
end end
amp, dur = amp or 6, dur or 0.4 amp, dur = amp or 6, dur or 0.4
local p local rest = {}
return M.tween(node, dur, function(k, n) local keep = {}
p = p or gui.get_position(n) -- where it is when the shake starts (after a delay) 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)) gui.set_position(n, v3(p.x + M.keys(SHAKE, k, M.fn.ease) * amp, p.y, p.z))
end, delay or 0, function() end, delay or 0, function()
if p then if rest.p then
pcall(gui.set_position, node, p) pcall(gui.set_position, node, rest.p)
end end
end) end)
shaking[#shaking + 1] = { node = node, t = t, rest = rest }
return t
end end
-- 1D value noise (smooth), for shakes. -- 1D value noise (smooth), for shakes.
+13 -1
View File
@@ -161,7 +161,9 @@ local function request(method, path, params, cb)
err = "network error" err = "network error"
elseif status < 200 or status >= 300 then elseif status < 200 or status >= 300 then
err = (type(data) == "table" and (data.error or data.message)) or ("HTTP " .. status) 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" err = "bad JSON"
end end
done(err, data, status, recv) done(err, data, status, recv)
@@ -185,6 +187,16 @@ function M.get_save_code(cb)
request("GET", "/api/save-code", nil, cb) request("GET", "/api/save-code", nil, cb)
end 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. -- Run an action (name: e.g. "stake", "claim", "buy-moon"); params are its fields.
function M.act(name, params, cb) function M.act(name, params, cb)
if reject_name ~= "" and name == reject_name then if reject_name ~= "" and name == reject_name then
+97 -4
View File
@@ -36,9 +36,50 @@ end
---------------------------------------------------------------- signatures ---------------------------------------------------------------- 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) local t = type(v)
if t == "table" then if t == "table" then
if not own and frozen[v] then
buf[#buf + 1] = token_of(v)
return
end
buf[#buf + 1] = "{" buf[#buf + 1] = "{"
local n = #v local n = #v
for i = 1, n do for i = 1, n do
@@ -55,7 +96,7 @@ local function ser(v, buf)
end end
if keys then if keys then
if mixed then if mixed then
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) table.sort(keys, key_lt)
else else
table.sort(keys) table.sort(keys)
end end
@@ -68,7 +109,9 @@ local function ser(v, buf)
end end
buf[#buf + 1] = "}" buf[#buf + 1] = "}"
elseif t == "string" then elseif t == "string" then
buf[#buf + 1] = #v .. ":" .. v buf[#buf + 1] = #v
buf[#buf + 1] = ":"
buf[#buf + 1] = v
elseif t == "function" then elseif t == "function" then
buf[#buf + 1] = "f" buf[#buf + 1] = "f"
else else
@@ -76,7 +119,8 @@ local function ser(v, buf)
end end
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(...) function M.sig(...)
local buf = {} local buf = {}
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
@@ -86,6 +130,23 @@ function M.sig(...)
return table.concat(buf) return table.concat(buf)
end 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 ---------------------------------------------------------------- registrations
-- Note a registration made while building (fn(...) makes it again in a later build that -- 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
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 -- 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). -- clip zone again then: the one it was given may have gone with the old parent).
function M.replaying() function M.replaying()
@@ -318,6 +410,7 @@ function M.open(g, key, sig, parent)
reg.used[key] = true reg.used[key] = true
gui.set_parent(old.root, parent, false) gui.set_parent(old.root, parent, false)
rezone(old) rezone(old)
reorder(old)
adopt(reg, old) adopt(reg, old)
return old, true return old, true
end end
+14 -4
View File
@@ -28,6 +28,16 @@ local M = {}
local EPS = 0.01 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) local function differs(a, b)
return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS return math.abs(a.x - b.x) > EPS or math.abs(a.y - b.y) > EPS
end 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) 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) local n, w = ui.rich(parent, str, style, 0, 0)
gui.set_pivot(n, gui.PIVOT_W) gui.set_pivot(n, gui.PIVOT_W)
local s = opts or {} local s = copy(opts)
s.fit, s.height = nil, h s.fit, s.height = nil, h
return { node = n, size = false, style = s, measure = function() return w, h end } return { node = n, size = false, style = s, measure = function() return w, h end }
end end
@@ -124,7 +134,7 @@ function M.label(parent, str, style, h, opts)
w = w - ui.text_width(n) w = w - ui.text_width(n)
gui.set_text(n, t) gui.set_text(n, t)
end end
local s = opts or {} local s = copy(opts)
local fit, shrink = s.fit or s.shrink_text, s.shrink_text local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h 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 } 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 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 local line = (one.max_ascent + one.max_descent) * gui.get_scale(n).y
gui.set_leading(n, lh / line) gui.set_leading(n, lh / line)
local s = opts or {} local s = copy(opts)
local lines = s.lines local lines = s.lines
s.lines = nil s.lines = nil
local measure = M.text_measure(n, lh) 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 -- 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). -- the box's middle, h tall, as wide as its text (set the text first).
function M.node_label(node, h, opts) 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 local fit, shrink = s.fit or s.shrink_text, s.shrink_text
s.fit, s.shrink_text, s.height = nil, nil, h 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 } 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 entries = {} -- { key, node, kind, label, zone, scope }
local scope = "page" 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) local function alive(node)
return pcall(gui.get_id, node) return pcall(gui.get_id, node)
@@ -67,8 +67,30 @@ function M.add(id, node, kind, label, zone)
return return
end end
local base = scope .. ":" .. (id or ("label:" .. tostring(label))) 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 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 } entries[#entries + 1] = { key = key, base = base, node = node, kind = kind, label = label, zone = zone, scope = scope }
end end
@@ -225,12 +247,16 @@ end
-- Rows of a long list exist only while in view, so Tab steps through its items instead: -- 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), -- 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 -- scrolled into view; the rebuild makes its row, which then has the focus. Within the rows
-- when it moved the focus. -- 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 function list_step(g, f, cur, e, dir, scope_)
local a = list_of(g, cur) local a = list_of(g, cur)
local items, i local items, i
if a then 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 items = a.list.items
local id = cur.base:sub(#scope_ + 2) local id = cur.base:sub(#scope_ + 2)
for n, item in ipairs(items) do for n, item in ipairs(items) do
@@ -238,6 +264,11 @@ local function list_step(g, f, cur, e, dir, scope_)
i = n + dir i = n + dir
end end
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 if not i or i < 1 or i > #items then
return false -- (leaving the list: the next element on the page) return false -- (leaving the list: the next element on the page)
end end
+19 -1
View File
@@ -1015,9 +1015,27 @@ function M:batch_ready(g, jobs)
end end
end end
-- (a text node's position is its pivot point and its size not its text's: the box is the
-- text's own, round that point)
local PIVOT = { -- (fraction of the box left of / above the pivot point)
[gui.PIVOT_NW] = { 0, 0 }, [gui.PIVOT_N] = { 0.5, 0 }, [gui.PIVOT_NE] = { 1, 0 },
[gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_E] = { 1, 0.5 },
[gui.PIVOT_SW] = { 0, 1 }, [gui.PIVOT_S] = { 0.5, 1 }, [gui.PIVOT_SE] = { 1, 1 },
}
function M:box_of(key) function M:box_of(key)
local b = self.binds[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 end
function M:play(g, k, data, fxd, gains, credits, at, revealing, newly_ready) 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 -- (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). -- `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. -- 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). -- 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.id = nil
M.rect = nil -- { x, y, w, h } window pixels, origin bottom-left 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.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 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. -- What the render script draws, written by moon.script every frame.
M.frame = { visible = false, has_mesh = false } 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)) gui.set_position(probe, vmath.vector3(size.x, -size.y, 0))
local br = gui.get_screen_position(probe) local br = gui.get_screen_position(probe)
gui.delete_node(probe) gui.delete_node(probe)
local x0, x1 = math.floor(tl.x + 0.5), math.floor(br.x + 0.5) -- The size is rounded once (not as the difference of two rounded corners), so it stays
local y0, y1 = math.floor(br.y + 0.5), math.floor(tl.y + 0.5) -- the same while the node moves by fractions of a pixel: every new size remakes the
return { x = x0, y = y0, w = x1 - x0, h = y1 - y0 }, (x1 - x0) / math.max(1, size.x) -- 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 end
local function set_node(node, quiet) local function set_node(node, quiet)
+53 -21
View File
@@ -582,17 +582,17 @@ end
-- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill -- Without redeploy the claimed tools are idle tools that last mined their well: its Redrill
-- count goes up (the server's LastWell). -- count goes up (the server's LastWell).
M.register("claim", function(st, p, ctx) M.register("claim", function(st, p, ctx)
if p["claim-wide"] then
return false
end
local list = {} local list = {}
for _, s in ipairs(st.stakes) do for _, s in ipairs(st.stakes) do
local wide = (s.reach or 1) > 1 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)) local scope
and (not p["claim-moon"] or s.moonId == p["claim-moon"]) then if p["claim-wide"] then
if wide then scope = wide and s.moonId == p["claim-wide"]
return false -- multi-well drills: the server splits them else
end 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 list[#list + 1] = s
end end
end end
@@ -600,14 +600,33 @@ M.register("claim", function(st, p, ctx)
return false return false
end end
-- Oldest first, each from what the earlier claims left (claimInTx: at most the supply). -- 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) table.sort(list, function(a, b) return a.start < b.start end)
local emptied = {} local emptied = {}
for _, s in ipairs(list) do 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))) local moon = find(st.moons, s.moonId)
M.add_item(st, s.resource, take * M.skew) if (s.reach or 1) > 1 and moon then
M.add_supply(st, s.wellId, -take) local sup = {}
if take > 0 and M.supply(st, s.wellId) <= 0 then for i, w in ipairs(moon.wells or {}) do
emptied[s.wellId] = true 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 end
M.remove_stake(st, s) -- its tool is idle; Redrill there counts it M.remove_stake(st, s) -- its tool is idle; Redrill there counts it
end end
@@ -623,10 +642,17 @@ M.register("claim", function(st, p, ctx)
for _, s in ipairs(list) do for _, s in ipairs(list) do
local tool = M.tool(st, s.toolId) local tool = M.tool(st, s.toolId)
local w = wells(st, s.wellId)[1] 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)) 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). -- 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) M.add_stake(st, tool, s.moonId, w, ctx, group)
local detail = st.moon and find(st.moon.wells, s.wellId) 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 if detail and M.memo.last_well[tool.id] ~= s.wellId and (detail.redrill or 0) > 0 then
@@ -657,7 +683,7 @@ M.register("stake", function(st, p, ctx)
return false -- "Deploy there": the view moves to the other well, wait for it return false -- "Deploy there": the view moves to the other well, wait for it
end end
local tool = M.idle_copy(st, p.tool) local tool = M.idle_copy(st, p.tool)
if not tool or (tool.reach or 1) > 1 then if not tool then
return false return false
end end
local group = nil local group = nil
@@ -670,7 +696,7 @@ M.register("stake", function(st, p, ctx)
end end
-- Refused when the drills on the well take all it has left (fullyCommitted). -- 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) 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 return false
end end
M.add_stake(st, tool, moon.id, w, ctx, group) M.add_stake(st, tool, moon.id, w, ctx, group)
@@ -713,16 +739,22 @@ M.register("redrill", function(st, p, ctx)
if left <= EPS then if left <= EPS then
break break
end 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 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 pick = i
end end
end end
if not pick then if not pick then
pick = 1 pick = 1
for i, t in ipairs(tools) do for i, a in ipairs(amounts) do
if t.yield > tools[pick].yield then if a > amounts[pick] then
pick = i pick = i
end end
end end
+20 -9
View File
@@ -96,7 +96,13 @@ function M.register(id, node, kind, label, zone, no_focus)
if not M.enabled or not id then if not M.enabled or not id then
return return
end 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 local n = 2
while elements[id .. "#" .. n] do while elements[id .. "#" .. n] do
n = n + 1 n = n + 1
@@ -198,10 +204,11 @@ local function visible(node)
return true return true
end end
-- A run of commands spread over frames: { frame_fn, ... }. -- A run of commands spread over frames: { frame_fn, ... }. id: the command's, for the
local function steps(g, list, done) -- 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 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 end
-- mm.test_dir (drive.py passes its output folder): replies go to files there. -- 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() steps(g, list, function()
reply(cmd.id, { ok = true, id = e.id, x = x, y = y }) reply(cmd.id, { ok = true, id = e.id, x = x, y = y })
end) end, cmd.id)
end end
-- Scroll the element's area (components/scroll) so it's in view; the layout rebuilds. -- 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() input(k, { released = true }) end,
}, function() }, function()
reply(cmd.id, { ok = true }) reply(cmd.id, { ok = true })
end) end, cmd.id)
end end
function COMMANDS.type(g, cmd, input) function COMMANDS.type(g, cmd, input)
@@ -605,7 +612,7 @@ function COMMANDS.dump(g, cmd)
steps(g, { function() end, function() steps(g, { function() end, function()
local ok, d = pcall(dump, g) local ok, d = pcall(dump, g)
reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) }) reply(cmd.id, ok and { ok = true, dump = d } or { ok = false, error = tostring(d) })
end }) end }, nil, cmd.id)
end end
function COMMANDS.state(g) function COMMANDS.state(g)
@@ -692,9 +699,13 @@ function M.update(g, input, api)
-- Running multi-frame steps. -- Running multi-frame steps.
if g.test_steps and #g.test_steps > 0 then if g.test_steps and #g.test_steps > 0 then
local s = g.test_steps[1] local s = g.test_steps[1]
s.list[s.i]() local ok, err = pcall(s.list[s.i])
s.i = s.i + 1 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) table.remove(g.test_steps, 1)
if s.done then if s.done then
s.done() s.done()
+1 -1
View File
@@ -1,4 +1,4 @@
-- Theme: the web build's HUD look (server/web/css/app.css, dark and light) as Lua values. -- Theme: the web build's HUD look (the old web client, dark and light) as Lua values.
-- Colors are vector4 (r, g, b, a in 0..1), named tokens that theme.set switches in place; fonts are the names declared in the .gui files; -- 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). -- sizes are in reference pixels (the 1280x800 layout, scaled to the window).
+2 -2
View File
@@ -274,8 +274,8 @@ function M.fit_text(node, max_w)
return return
end end
while #text > 1 do while #text > 1 do
-- (a cut entity like "&am" would show as text) -- (a whole UTF-8 character at a time; a cut entity like "&am" would show as text)
text = text:sub(1, -2):gsub("&[%a#]*$", ""):gsub("%s+$", "") text = text:gsub("[%z\1-\127\192-\255][\128-\191]*$", ""):gsub("&[%a#]*$", ""):gsub("%s+$", "")
gui.set_text(node, text .. "…") gui.set_text(node, text .. "…")
if M.text_width(node) <= max_w then if M.text_width(node) <= max_w then
return return
+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 if html5 and params.resolution and params.resolution > WEB_RESOLUTION then
params.resolution = WEB_RESOLUTION params.resolution = WEB_RESOLUTION
end 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.gen = native.new(terrain.native_params(params))
job.threaded = native.start(job.gen) job.threaded = native.start(job.gen)
self.job = job self.job = job
@@ -252,7 +252,9 @@ end
local function job_done(self, job) local function job_done(self, job)
local res = new_buffer_resource(job.buffer) local res = new_buffer_resource(job.buffer)
cache_put(self, job.key, res) 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 if job.key == self.want_key then
set_mesh(self, job.key, res, job.params) set_mesh(self, job.key, res, job.params)
end end
@@ -450,6 +452,7 @@ function update(self, dt)
if #self.orphans > 0 then if #self.orphans > 0 then
step_orphans(self) step_orphans(self)
end 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 if (not moon.id and not mystery) or not rect or rect.w <= 0 or rect.h <= 0 then
f.visible = false f.visible = false
return return
@@ -602,7 +605,7 @@ function update(self, dt)
end end
function on_input(self, action_id, action) 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 if not rect then
self.dragging = false self.dragging = false
return 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
+106 -15
View File
@@ -12,6 +12,7 @@ local moon_view = require("modules.moon.moon")
local reveal = require("screens.game.reveal") local reveal = require("screens.game.reveal")
local moons = require("screens.game.moons") local moons = require("screens.game.moons")
local moon_sale = require("screens.game.moon_sale") local moon_sale = require("screens.game.moon_sale")
local predict = require("modules.predict")
local panel = require("components.panel") local panel = require("components.panel")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local bar = require("components.bar") local bar = require("components.bar")
@@ -21,6 +22,8 @@ local chip = require("components.chip")
local images = require("modules.images") local images = require("modules.images")
local flex = require("modules.flex") local flex = require("modules.flex")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local anim = require("modules.anim")
local S = theme.size local S = theme.size
@@ -266,9 +269,26 @@ local function well_panel(g, root, pad, top, inner, H)
local wide = moon.wide local wide = moon.wide
local wide_here = {} local wide_here = {}
if wide and #(wrow.wide or {}) > 0 then if wide and #(wrow.wide or {}) > 0 then
-- 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
for _, d in ipairs(wide.drills or {}) do for _, d in ipairs(wide.drills or {}) do
if contains(wrow.wide, d.id) then if contains(wrow.wide, d.id) then
wide_here[#wide_here + 1] = d 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 end
end end
end end
@@ -404,10 +424,83 @@ local function caption(c, x, y, inner, title, text)
}), x, y), c) }), x, y), c)
end 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. -- 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 -- 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. -- 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) function M.build(g, parent, x, y, w, h, H, mod, row)
local st = g.state local st = g.state
local p = panel.new(parent, x, y, w, h) local p = panel.new(parent, x, y, w, h)
@@ -417,7 +510,13 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
local section = g.view.section local section = g.view.section
local moon = section == "moons" and st.moon or nil local moon = section == "moons" and st.moon or nil
local cy 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) local vp = M.row_viewport(w)
viewport(g, root, pad, pad, vp, shown_moon(g)) viewport(g, root, pad, pad, vp, shown_moon(g))
-- The side panel gets the rest of the row as its own box. -- The side panel gets the rest of the row as its own box.
@@ -450,18 +549,10 @@ function M.build(g, parent, x, y, w, h, H, mod, row)
end end
gui.delete_node(box) gui.delete_node(box)
end end
if moon and st.well then if moon then
-- The well panel scrolls under the viewport (many drills). -- The moon's facts are a block of their own (M.facts, core/main.lua): a change the
local top = cy - 8 -- viewport doesn't show keeps the panel and its moon.
local area = scroll.area(g, root, 0, top, w, h - top - 4, "well:" .. st.well.id) return p, { root = root, top = cy - 8, cy = cy, w = w, h = h, pad = pad, inner = inner }
area:finish(well_panel(g, area.content, pad, 8, inner, H) + pad)
elseif moon and moon.finished then
caption(root, pad, cy, inner, moon.name, "Mined out. Nothing left to dig.")
elseif moon then
-- (scrolls like the well panel: Sell moon at its foot)
local top = cy - 8
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)
elseif mod and mod.aside_below and st[section] then elseif mod and mod.aside_below and st[section] then
mod.aside_below(g, root, pad, cy, inner, limit, H) mod.aside_below(g, root, pad, cy, inner, limit, H)
elseif section == "new-moon" then 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.g.view.section == "new-moon" and shot("11-new-moon") end,
function(a) return a:press("buy-moon") end, function(a) return a:press("buy-moon") end,
function(a) return a.g.reveal:card_done() and shot("12-reveal") 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). -- Buy another, straight from the card (its button registers as reveal-buy).
function(a) return a:press("buy-moon") end, 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.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 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, 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 a.g.view.section == "market" and shot("10-market-leaderboard") end,
function(a) return go(a, "help") end, function(a) return go(a, "help") end,
function(a) return a.g.view.section == "help" and shot("11-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. -- Start over: open its confirm, then run new-game (as its OK).
function(a) function(a) return a:press("confirm:new-game") end,
a.g.help_armed = a.g.h.now(a.g) + 30000 function(a) return a.g.dialog.root and shot("12-help-confirm") end,
a.g.dirty = true
return true
end,
function() return shot("12-help-confirm") end,
function(a) return a:press("new-game") end, function(a) return a:press("new-game") end,
function(a) return a.g.state.view.section ~= "help" or true end, function(a) return a.g.state.view.section ~= "help" or true end,
function() return shot("13-new-game") end, function() return shot("13-new-game") end,
+28 -2
View File
@@ -13,6 +13,7 @@ local button = require("components.button")
local chip = require("components.chip") local chip = require("components.chip")
local stat = require("components.stat") local stat = require("components.stat")
local fg = require("modules.flex_gui") local fg = require("modules.flex_gui")
local blocks = require("modules.blocks")
local S = theme.size local S = theme.size
@@ -31,11 +32,12 @@ function M.compact(n)
local function one(f) local function one(f)
return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", "")) return (string.format("%.1f", math.floor(f * 10 + 0.5) / 10):gsub("%.0$", ""))
end 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" return one(n / 1e6) .. "M"
elseif a >= 1e4 then elseif a >= 1e4 then
return string.format("%d", math.floor(n / 1e3 + 0.5)) .. "k" 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" return one(n / 1e3) .. "k"
end end
return ui.money(math.floor(n + 0.5)) return ui.money(math.floor(n + 0.5))
@@ -260,6 +262,30 @@ function M.confirm_server_button(self, b, c, act, params, id)
end end
end }) end })
self.fx:button(id, b) 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 end
---------------------------------------------------------------- inline confirm ---------------------------------------------------------------- inline confirm
+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_edge = ui.outline(tip, 0, 0, 120, 22, "line_hi")
local tip_label = ui.rect(tip, 8, 11, 1, 1, "none") local tip_label = ui.rect(tip, 8, 11, 1, 1, "none")
gui.set_enabled(cross, false) gui.set_enabled(cross, false)
local rect
local shown = -1 local shown = -1
local function move(action) 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 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 and action.screen_y >= rect.y0 and action.screen_y <= rect.y1
if not inside then if not inside then
+5 -3
View File
@@ -113,9 +113,11 @@ end
-- spinner and ignores clicks until the server answers. -- 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 -- 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). -- 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). -- 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) 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 if g.inflight[key] then
button.set_busy(b) button.set_busy(b)
end end
@@ -144,9 +146,9 @@ function M.action(g, b, nodes, name, params, on_done, predictor, id, hit)
if g.autoplay then if g.autoplay then
local p = type(params) == "function" and params() or params local p = type(params) == "function" and params() or params
g.autoplay:register(name, p, fire) 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 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) end)
end end
+50 -10
View File
@@ -45,6 +45,7 @@ local moon_view = require("modules.moon.moon")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
local sfx = require("modules.sfx") local sfx = require("modules.sfx")
local ready = require("screens.game.core.ready") local ready = require("screens.game.core.ready")
local admin = require("screens.game.admin")
local S = theme.size local S = theme.size
local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css) local NARROW_W = 1250 -- below this layout width the aside gets its own row (app.css)
@@ -282,14 +283,25 @@ local function build_main(g, parent, x, y, w, h)
end end
-- The aside's block (narrow: the row under the panels). -- 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 st = g.state
local section = g.view.section 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, -- (on Moons with a moon the panel only shows the viewport: its facts are a block of
pick_keys(st, ALWAYS, SECTION_KEYS[section] or {}, ASIDE_KEYS)) -- their own, below)
return blocks.keep(g, "aside", sig, parent, function(holder) 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) return aside.build(g, holder, x, y, w, h, g.h, M.SECTIONS[section], row)
end) 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 end
-- A full rebuild drops every node and component and builds them all again; otherwise the -- A full rebuild drops every node and component and builds them all again; otherwise the
@@ -359,11 +371,15 @@ function M.build(g)
g.layout_state = true g.layout_state = true
local st = g.state local st = g.state
local common = local_sig(g) 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. -- 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) return banner.build(g, g.h, holder, W)
end) 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) st.unlocked, st.shine, st.admin, st.ready, st.nextEnd, ready.drills(g), ready.batches(g)), g.layout, function(holder)
g.tab_nodes = {} g.tab_nodes = {}
local header_root = ui.rect(holder, 0, banner_h, W, 1, "none") local header_root = ui.rect(holder, 0, banner_h, W, 1, "none")
@@ -374,6 +390,25 @@ function M.build(g)
local pad, gap = S.body_pad, S.gap local pad, gap = S.body_pad, S.gap
local top = header_h + pad local top = header_h + pad
local body_h = H - top - 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) local has_aside = M.aside_shown(g)
-- Narrow windows (the web's @media max-width 1250px): the aside goes to its own row -- 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 -- under the moon list and the main panel, the viewport and the side panel side by
@@ -418,8 +453,8 @@ function M.build(g)
for _, m in ipairs(st.moons) do for _, m in ipairs(st.moons) do
hides[#hides + 1] = g.reveal:hides(m.id) or false hides[#hides + 1] = g.reveal:hides(m.id) or false
end 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, blocks.keep(g, "moonlist", blocks.sig(still, 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, 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) st.moonGrade, st.newMoon and st.newMoon.grades, g.nm_grade), parent, function(holder)
g.moon_nodes = {} g.moon_nodes = {}
moonlist.teardown(g) moonlist.teardown(g)
@@ -442,7 +477,7 @@ function M.build(g)
end end
end end
if page then 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) local s = page:finish(y0 + body_h + gap + row_h + pad)
if g.viewport_node and not g.reveal:active() then if g.viewport_node and not g.reveal:active() then
moon_view.move(g.viewport_node) -- (where the page's offset put it) moon_view.move(g.viewport_node) -- (where the page's offset put it)
@@ -467,7 +502,7 @@ function M.build(g)
end) end)
end end
elseif has_aside then 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 else
g.aside_panel, g.viewport_node, g.viewport_rect, g.view_was_mystery = nil, nil, nil, false 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) if not g.reveal:active() then -- (the reveal's window shows the moon)
@@ -650,6 +685,8 @@ function M.update(g, dt)
g.fx:update(dt) g.fx:update(dt)
g.reveal:update(dt) g.reveal:update(dt)
g.dialog: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) cards.ask_update(g)
check_input(g) check_input(g)
tooltip.update(g.dialog:is_open()) tooltip.update(g.dialog:is_open())
@@ -659,6 +696,9 @@ function M.update(g, dt)
m.update(g, dt) m.update(g, dt)
end end
end) end)
if g.admin then
admin.update(g, dt)
end
g.druid:update(dt) g.druid:update(dt)
if g.rehover then if g.rehover then
-- After a rebuild: what is under the mouse shows its hover at once (the new -- 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 sfx = require("modules.sfx")
local refinery = require("screens.game.refinery") local refinery = require("screens.game.refinery")
local sidepanel = require("screens.game.sidepanel") local sidepanel = require("screens.game.sidepanel")
local blocks = require("modules.blocks")
local M = {} local M = {}
@@ -128,6 +129,9 @@ local function suspend(g)
g.refetch = nil g.refetch = nil
end end
g.base, g.state, g.pending, g.outbox = nil, nil, {}, {} 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 } g.boot = { text = SUSPENDED }
if g.dialog and g.dialog:is_open() then if g.dialog and g.dialog:is_open() then
g.dialog:close("cancel") g.dialog:close("cancel")
@@ -187,7 +191,8 @@ end
-- What we show: the last server state (g.base) with the pending predictions on top. -- What we show: the last server state (g.base) with the pending predictions on top.
local function show(g) 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 g.view = g.state.view
arm_refetch(g) arm_refetch(g)
g.dirty = true g.dirty = true
@@ -215,7 +220,9 @@ end
-- The views shown, like the web's browser history (main.js syncURL: every response whose -- 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 -- 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 VIEW_KEYS = { "section", "moon", "well", "past", "page", "pick", "drill", "adv" }
local function view_key(v) local function view_key(v)
@@ -242,7 +249,14 @@ local function remember(g, from_history)
if from_history then if from_history then
if h.list[from_history] then if h.list[from_history] then
h.list[from_history] = v h.list[from_history] = v
h.i = from_history
end end
if h.want == from_history then
h.want = nil
end
return
end
if h.want then
return return
end end
if h.list[h.i] and view_key(h.list[h.i]) == view_key(v) then 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 elseif g.base then
show(g) -- rolled back show(g) -- rolled back
end 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 if status == 403 then
suspend(g) suspend(g)
g.inflight, g.busy, g.nav_view = {}, false, nil g.inflight, g.busy, g.nav_view = {}, false, nil
@@ -404,6 +422,9 @@ function M.navigate(g, v)
end end
g.nav_view = v g.nav_view = v
g.ask = nil -- (a sell asking in place: cards.ask_button) 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) M.fetch(g, v, v)
end end
@@ -411,12 +432,13 @@ end
-- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go. -- Alt+Left/Right, Backspace; core/keys.lua). Returns true when there was somewhere to go.
function M.go(g, dir) function M.go(g, dir)
local h = history(g) 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 if not v then
return false return false
end end
h.i = h.i + dir h.want = i
local to = { from_history = h.i } local to = { from_history = i }
for k, x in pairs(v) do for k, x in pairs(v) do
to[k] = x to[k] = x
end end
@@ -440,7 +462,7 @@ function M.act(g, name, params, on_done, fn)
if predict.run(st, e) then if predict.run(st, e) then
g.pending[#g.pending + 1] = e g.pending[#g.pending + 1] = e
local prev = g.state local prev = g.state
g.state = st g.state = blocks.freeze(st)
g.dirty = true g.dirty = true
predicted = true predicted = true
-- The action's effects play now, from the predicted change (fx.lua). -- The action's effects play now, from the predicted change (fx.lua).
+88 -32
View File
@@ -12,6 +12,7 @@ local ui = require("modules.ui")
local tooltip = require("modules.tooltip") local tooltip = require("modules.tooltip")
local cards = require("screens.game.cards") local cards = require("screens.game.cards")
local scroll = require("components.scroll") local scroll = require("components.scroll")
local list = require("components.list")
local store = require("screens.game.store") local store = require("screens.game.store")
local row_c = require("components.row") local row_c = require("components.row")
local blocks = require("modules.blocks") local blocks = require("modules.blocks")
@@ -38,6 +39,13 @@ end
local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made local box, row, col, space, made = fg.box, fg.row, fg.col, fg.space, fg.made
-- A tool's track's ladder: state.equipment.ladders holds each once per store item and
-- levels, the row names its own (ladderKey).
local function ladder_of(st, t, tr)
local l = (st.equipment.ladders or {})[t.ladderKey or ""]
return l and l[tr.kind] or {}
end
-- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece. -- Stats: Yield and Cycle (a stake's now), wrapping (rows 20 tall, 16 apart). A tree piece.
local function stats_box(st, parent, t) local function stats_box(st, parent, t)
local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } } local items = { { "Yield", ui.money(t.yield) }, { "Cycle", ui.cycle(t.cycleSecs) } }
@@ -89,7 +97,7 @@ local function track_box(self, H, st, parent, t, tr, w)
-- this width), so the box (and the card) stays the same height from level to level. -- this width), so the box (and the card) stays the same height from level to level.
local parts_h = 0 local parts_h = 0
local probe = ui.rect(parent, 0, 0, 1, 1, "none") local probe = ui.rect(parent, 0, 0, 1, 1, "none")
for _, r in ipairs(tr.ladder or {}) do for _, r in ipairs(ladder_of(st, t, tr)) do
if #(r.parts or {}) > 0 then if #(r.parts or {}) > 0 then
parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w)) parts_h = math.max(parts_h, cards.parts(st, probe, r.parts, 0, 0, w))
end end
@@ -167,8 +175,7 @@ local function geometry(inner)
return { return {
wide = wide, wide = wide,
gap = wide and 12 or 10, gap = wide and 12 or 10,
media_w = wide and 48 or 40, media_h = wide and 40 or 34, status = wide and math.min(200, math.floor(inner * 0.16)) or 0,
status = wide and math.min(230, math.floor(inner * 0.22)) or 0,
track = wide and 124 or 90, track = wide and 124 or 90,
sell = wide and 150 or 60, sell = wide and 150 or 60,
name_px = wide and 14 or 13, name_px = wide and 14 or 13,
@@ -382,14 +389,17 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
name.done = function(n) name.done = function(n)
tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0)) tooltip.add(n, t.name .. ": yield level " .. (t.yieldLevel or 0) .. ", cycle level " .. (t.cycleLevel or 0))
end end
local rh = row_h(open, wide)
-- The picture, square, takes the row's whole height left of every line (narrow: at most 64).
local mh = wide and rh - PADY * 2 or math.min(rh - PADY * 2, 64)
local mw = mh
local media = made({ width = mw, height = mh, shrink = 0 }, function(p, x, y)
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end)
local lines = { local lines = {
row({ height = LINE1, gap = gap, align = "center" }, { row({ height = LINE1, gap = gap, align = "center" }, {
made({ width = geo.media_w, height = geo.media_h, shrink = 0 }, function(p, x, y)
local mw, mh = geo.media_w, geo.media_h
local m = ui.rect(p, x, y, mw, mh, "bg")
ui.outline(m, 0, 0, mw, mh, "line")
cards.image(m, t.image, (mw - mh) / 2, 0, mh)
end),
col({ grow = 1, shrink = 1, basis = 0 }, { col({ grow = 1, shrink = 1, basis = 0 }, {
name, name,
fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }), fg.label(c, tagline, { font = "mono", px = 12, color = "muted" }, 16, { fit = true }),
@@ -443,9 +453,10 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
lines[#lines + 1] = space(0, 6) lines[#lines + 1] = space(0, 6)
lines[#lines + 1] = row({ height = 20, align = "center" }, { left }) lines[#lines + 1] = row({ height = 20, align = "center" }, { left })
end end
local rh = row_h(open, wide)
local selected = t.selected and self.view.drill ~= "" local selected = t.selected and self.view.drill ~= ""
return col({ width = w, height = rh, padding = { PADY, PADX, PADY, PADX } }, lines, { make = function(p, x, y, bw, bh) return row({ width = w, height = rh, gap = gap, padding = { PADY, PADX, PADY, PADX } }, {
media, col({ grow = 1, shrink = 1, basis = 0 }, lines),
}, { make = function(p, x, y, bw, bh)
local slot = ui.rect(p, x, y, bw, bh, "none") local slot = ui.rect(p, x, y, bw, bh, "none")
local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0) local body = ui.rect(slot, 0, 0, bw, bh, "cyan", 0)
row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function() row_c.bind(self, body, bw, bh, { selected = selected, id = "drill:" .. t.id, label = t.name, on_click = function()
@@ -456,12 +467,34 @@ local function tool_row(self, H, c, st, t, copy, geo, w, open, conf)
end }) end })
end end
-- What a row shows besides its tool (t, with its tracks: their affordability is the
-- server's canAfford/shortText, so credits and parts aren't needed): kept while it's the same.
local function row_sig(self, st, t, copy, conf, inner, open)
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
local busy = {} -- (its buttons waiting for the server: H.action's keys)
for k in pairs(self.inflight or {}) do
if k:find(t.id, 1, true) then
busy[#busy + 1] = k
end
end
table.sort(busy)
local sid = "confirm:sell-tool:" .. t.id
local ask = self.ask and self.ask.id == sid and self.ask.t or false
return blocks.sig(inner, open, t, copy, conf, moon and moon.name or false, (t.reach or 1) > 1 and cards.wells(st, t.reach) or false,
st.catalogVersion, self.view.drill ~= "", busy, ask)
end
-- The heading and the column captions stay at the top; the rows are a long list
-- (components/list) under them: only the rows in view exist (a screenful of nodes at any
-- number of tools), each a block kept while its row_sig stays the same, so an action
-- rebuilds only the rows it changed.
function M.build(self, root, px, py, w, h, H) function M.build(self, root, px, py, w, h, H)
local st = self.state local st = self.state
local eq = st.equipment local eq = st.equipment
local open = st.unlocked.store local open = st.unlocked.store
local area, x, inner, y = cards.page(self, root, px, py, w, h, "equipment", TABLE_MAX) local inner = math.min(w - px * 2, TABLE_MAX)
local c = area.content local x, y = math.floor((w - inner) / 2), py - 2
local c = ui.rect(root, 0, 2, w, h - 4, "none") -- (where cards.page's content was)
local geo = geometry(inner) local geo = geometry(inner)
y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge) y = fg.flow(c, x, y, inner, { col({ width = inner }, { -- (full width: Buy tools sits at its right edge)
fg.label(c, "Equipment", "h1", 40), fg.label(c, "Equipment", "h1", 40),
@@ -476,26 +509,44 @@ function M.build(self, root, px, py, w, h, H)
#eq.tools > 0 and header(c, geo) or nil, #eq.tools > 0 and header(c, geo) or nil,
#eq.tools > 0 and space(0, 6) or nil, #eq.tools > 0 and space(0, 6) or nil,
}) }) }) })
if #eq.tools > 0 then if #eq.tools == 0 then
ui.rect(c, x, y, inner, 1, "line_hi") return
y = y + 1
end end
local seen, rh = {}, row_h(open, geo.wide) ui.rect(c, x, y, inner, 1, "line_hi")
y = y + 1
-- Each copy's number among tools of its type (in the table's order).
local seen, copies = {}, {}
for _, t in ipairs(eq.tools) do for _, t in ipairs(eq.tools) do
seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1 seen[t.storeItemId] = (seen[t.storeItemId] or 0) + 1
local copy = seen[t.storeItemId] copies[t.id] = seen[t.storeItemId]
local conf = sell_confirm(st, t)
local moon = t.stake and find(st.moons, "id", t.stake.moonId)
-- (kept while what it shows stays the same: modules/blocks)
local sig = blocks.sig(x, y, inner, t, copy, conf, open, moon and moon.name or false, st.refined, st.credits,
st.resources, st.catalogVersion, self.inflight, self.view, self.ask and self.ask.id or false)
local ry = y
blocks.keep(self, "eq:tool:" .. tostring(t.id), sig, c, function(r)
fg.build(flex.solve(tool_row(self, H, c, st, t, copy, geo, inner, open, conf), x, ry), r)
end)
y = y + rh
end end
area:finish(y + py) local rh = row_h(open, geo.wide)
local rows = {} -- slot node -> its row's block
local l
l = list.new(self, c, x, y, inner, math.max(rh, h - 4 - y - py), {
key = "equipment", row_h = rh,
focus_id = function(t) return "drill:" .. t.id end,
create = function(t)
local slot = ui.rect(l.content, 0, 0, inner, rh, "none")
local copy, conf = copies[t.id], sell_confirm(st, t)
local b, kept = blocks.open(self, "eq:tool:" .. tostring(t.id), row_sig(self, st, t, copy, conf, inner, open), slot)
if not kept then
fg.build(flex.solve(tool_row(self, H, b.root, st, t, copy, geo, inner, open, conf), 0, 0), b.root)
end
blocks.close(self, b)
rows[slot] = b
return slot
end,
-- A row scrolled out goes with its block (its buttons' Druid components too).
on_remove = function(slot)
local b = rows[slot]
rows[slot] = nil
if b then
blocks.drop(self, b)
end
end,
})
l:set_data(eq.tools)
end end
---------------------------------------------------------------- drill panel (aside) ---------------------------------------------------------------- drill panel (aside)
@@ -526,7 +577,7 @@ local TIER_HEAD = { font = "sans_semi", px = 10, color = "muted", tracking = 0.1
-- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's -- A track's ladder: a grid (Level, value, price and parts at 0, 16, 40 % of the row's
-- inner width, ₥/h at the right), the current level and the next lit. -- inner width, ₥/h at the right), the current level and the next lit.
local function ladder(st, c, tr, inner) local function ladder(st, c, t, tr, inner)
local fr = { 0, 0.16, 0.4, 0.84 } local fr = { 0, 0.16, 0.4, 0.84 }
local cols = {} local cols = {}
for i = 1, 3 do for i = 1, 3 do
@@ -541,7 +592,7 @@ local function ladder(st, c, tr, inner)
-- (₥/h: its left edge 22 before the rate column's right edge) -- (₥/h: its left edge 22 before the rate column's right edge)
local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 }) local rate_head = fg.label(c, theme.currency .. "/h", TIER_HEAD, 12, { position = "absolute", top = 0, left = inner - 30 })
local rows = {} local rows = {}
for _, r in ipairs(tr.ladder or {}) do for _, r in ipairs(ladder_of(st, t, tr)) do
local color = r.current and "text" or (r.next and "stat_up" or "text_2") local color = r.current and "text" or (r.next and "stat_up" or "text_2")
local font = r.next and "mono_semi" or "mono" local font = r.next and "mono_semi" or "mono"
local price = "—" local price = "—"
@@ -576,6 +627,11 @@ local function ladder(st, c, tr, inner)
return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) }) return col({}, { box({}, { head, rate_head }), col({ gap = 1 }, rows), space(0, 11) })
end end
-- The drill panel takes the whole aside (core/aside.lua: no moon over it).
function M.aside_full(self)
return self.state.equipment.drill ~= nil
end
function M.aside(self, root, w, h, H) function M.aside(self, root, w, h, H)
local st = self.state local st = self.state
local d = st.equipment and st.equipment.drill local d = st.equipment and st.equipment.drill
@@ -660,7 +716,7 @@ function M.aside(self, root, w, h, H)
elseif open then elseif open then
add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put) add(space(0, 22)) -- (the line's room, kept at the top level: Sell drill stays put)
end end
add(ladder(st, c, tr, inner)) add(ladder(st, c, t, tr, inner))
end end
-- Idle copies while this one mines: deploy one on its best well. -- Idle copies while this one mines: deploy one on its best well.
+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 if not empty then
sell_now = H.action(self, b1, n1, "sell", function() sell_now = H.action(self, b1, n1, "sell", function()
return { resource = q.item, amount = f.text, pick = q.item } 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
end, { width = half }), end, { width = half }),
fg.button(c, "Sell all", { disabled = empty }, function(b2, n2) fg.button(c, "Sell all", { disabled = empty }, function(b2, n2)
+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 return st.moonGrade
end 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) function M.build(self, H, parent, x, y, w, h)
local st = self.state local st = self.state
local v = self.view local v = self.view
+422 -253
View File
@@ -19,6 +19,7 @@ local fg = require("modules.flex_gui")
local images = require("modules.images") local images = require("modules.images")
local test = require("modules.test") local test = require("modules.test")
local predict = require("modules.predict") local predict = require("modules.predict")
local blocks = require("modules.blocks")
local S = theme.size local S = theme.size
@@ -302,42 +303,95 @@ local function fit(p, str, style, opts)
return fg.label(p, str, style, 16, opts) return fg.label(p, str, style, 16, opts)
end end
-- The deploy slot (a dashed ghost button SLOT_H tall): "+ Deploy <drill>" and what a -- Each part of the moon (its heading, a well's card, the multi-well card, the tray) is a
-- cycle gets here, or why it can't (no idle drills, no room). -- kept block (modules/blocks), built at its own origin and put in place: a deploy rebuilds
local function deploy_slot(g, st, well, groups, H) -- 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 grp, here, amount, explicit = deploy_choice(g, st, well, groups)
local ok = grp ~= nil and here > EPS local ok = grp ~= nil and here > EPS
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) local v = { ok = ok, explicit = explicit or false, key = grp and grp.key or false,
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = SLOT_H, disabled = not ok }) name = grp and grp.tool.name or false, tool = grp and grp.tool.id or false }
local title, sub, sub_color if #groups == 0 then
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"
title, sub = "No idle drills", st.unlocked.store and "Buy one in the Store" or "Every drill is busy" elseif not grp then
elseif not grp then v.title, v.sub = "No room here", "The drills on it take all it has"
title, sub = "No room here", "The drills on it take all it has" elseif not ok then
elseif not ok then v.title, v.sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below"
title, sub = "No room for " .. M.short_name(grp.tool), "Pick another drill below" else
else local t = grp.tool
local t = grp.tool local reach = reach_short(st, t.reach)
local reach = reach_short(st, t.reach) v.title = "+ Deploy " .. M.short_name(t)
title = "+ Deploy " .. M.short_name(t) v.sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "")
sub = "+" .. ui.money(here) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or "") if here < amount - EPS then
if here < amount - EPS then v.sub, v.color = "Only +" .. ui.money(here) .. " left here (of " .. ui.money(amount) .. ")", "ready"
sub, sub_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)
local tree = col({ width = w, height = SLOT_H, padding = { 0, 10 }, justify = "center", align = "center", gap = 2 }, {
fit(b, title, { font = "sans_semi", px = 13, color = ok and "cyan" or "muted", tracking = 0.06, upper = true }),
fit(b, sub, { font = "sans", px = 12, color = sub_color or "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
if ok then
local params = { tool = grp.tool.id, target = well.id }
H.action(g, b, bn, "stake", params)
tooltip.add(b, "Deploy " .. (grp.tool.name or "a drill") .. (explicit and "" or " (the smallest idle drill with room here)"))
end
end) end)
end end
-- 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 -- 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 -- 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, -- clock): gold, "+X ready", Claim (scope: claim-well or claim-wide) and, once unlocked,
@@ -345,105 +399,132 @@ end
-- the plain action ids). wait: the group's next drill to finish; where its Claim and -- 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 -- Redeploy will show is registered as "wait:claim?..." (like H.claim_wait's waiting
-- button; not focusable), for the test driver. -- button; not focusable), for the test driver.
local function drill_slot(g, st, d, scope, can_redeploy, n, wait, H, enters) local function drill_slot(g, st, d, dm, scope, can_redeploy, n, wait, mk, H, enters)
local tool = find(st.tools, d.toolId) local due = dm.due
local due = d.ready or (d["end"] or math.huge) <= H.now(g) local reach = dm.reach or nil
local reach = reach_short(st, d.reach) return made({ height = SLOT_H, shrink = 0 }, function(p0, x0, y0, w)
return made({ height = SLOT_H, shrink = 0 }, function(p, x, y, w) -- (a part of its own: a card that changes keeps the slots that didn't)
local slot = ui.rect(p, x, y, w, SLOT_H, "none") part(g, "mo:slot:" .. d.toolId, blocks.sig(w, d, dm, scope, can_redeploy, n, wait, mk), p0, x0, y0, function(p)
local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04) local x, y = 0, 0
ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil) local slot = ui.rect(p, x, y, w, SLOT_H, "none")
local iw = w - 18 local body = ui.rect(slot, 0, 0, w, SLOT_H, due and "ready" or "cyan", due and 0.08 or 0.04)
local right ui.outline(body, 0, 0, w, SLOT_H, due and "ready" or "line_hi", due and 0.6 or nil)
local gets = d.here or d.take or d.amount or 0 local iw = w - 18
if due then local right
-- (one that gets nothing is claimed all the same: it frees the drill) local gets = d.here or d.take or d.amount or 0
right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left", if due then
{ font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }) -- (one that gets nothing is claimed all the same: it frees the drill)
else right = fg.label(body, gets > 0 and ("+" .. ui.money(gets) .. " ready") or "Nothing left",
right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 }) { font = "sans_semi", px = 12, color = gets > 0 and "ready" or "muted", tracking = 0.08, upper = true }, 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 else
local here = ui.money(d.here or d.take or 0) right = fg.label(body, ui.dur(d["end"] - H.now(g)), M12, 16, { shrink = 0 })
gets = fit(body, "Gets " .. here .. (d.state == "short" and (" of " .. ui.money(d.amount)) or "") right.done = function(t)
.. ((d.reach or 1) > 1 and (d.gets and d.gets ~= d.here) and (" · " .. ui.money(d.gets) .. " in all") or ""), H.tick_text(g, t, d["end"], ui.dur, "0:00", "left:" .. d.toolId)
{ 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, M.short_name(tool), 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 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 end
fg.build(flex.solve(row({ width = w - 18, height = SLOT_BTN_H }, kids), 9, 27), body) local second
end if due then
if d.fate and d.fate ~= "" then local suffix = n > 1 and ("#" .. n) or ""
tooltip.add(ui.rect(body, 0, 0, iw, 23, "none"), d.fate) local kids = {
end fg.button(body, "Claim", { primary = true, small = true, h = SLOT_BTN_H }, function(b, bn)
enters[#enters + 1] = function() H.action(g, b, bn, "claim", scope, nil, nil, n > 1 and (act_id("claim", scope) .. suffix) or nil)
row_c.enter(g, "drill:" .. d.toolId, body, w, SLOT_H, { base = gui.get_color(body) }) tooltip.add(b, "Claim every ready drill here")
end 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)
end end
-- The slot of the idle drills that last mined the well (well.redrill): how many, and -- The drill of a list that finishes next (not done yet), or nil.
-- Redrill (sends them out again) once unlocked and the well has supply. local function next_drill(g, drills)
local function idle_slot(g, st, well, H) 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
end
return pick
end
-- 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
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 n = well.redrill or 0
local names = {} local names = {}
for _, t in ipairs(st.tools or {}) do for _, t in ipairs(st.tools or {}) do
@@ -454,21 +535,27 @@ local function idle_slot(g, st, well, H)
local who = #names > 0 and (table.concat(names, ", ") .. (n > #names and (" +" .. (n - #names)) or "")) or ui.plural(n, "drill") 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) -- (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 local full = well.supply > 0 and (well.remain or 1) <= 1e-9
local can = st.unlocked.redrill and well.supply > 0 and not full 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) 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) local body = ui.rect(p, x, y, w, SLOT_H, "cyan", 0.02)
ui.outline(body, 0, 0, w, SLOT_H, "line_hi") 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 }, { local tree = col({ width = w, height = SLOT_H, padding = { 7, 9, 7, 9 }, gap = 4 }, {
row({ height = 16, align = "center" }, { row({ height = 16, align = "center" }, {
fit(body, who, { font = "sans_semi", px = 13, color = "text_2" }), fg.fill(), space(6), 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 }), fg.label(body, "Idle", { font = "sans_semi", px = 12, color = "muted", tracking = 0.08, upper = true }, 16, { shrink = 0 }),
}), }),
can and fg.button(body, "Redrill" .. (n > 1 and (" " .. n) or ""), { small = true, variant = "redrill", h = SLOT_BTN_H }, function(b, bn) 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 }) H.action(g, b, bn, "redrill", { ["redrill-well"] = well.id })
tooltip.add(b, "Send the drills that last mined this well out again") 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" }, { 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" fit(body, well.supply <= 0 and "Last mined here · the well is empty"
or (full and "Last mined here · the drills on it take the rest" or "Last mined here"), S11), 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) fg.build(flex.solve(tree, 0, 0), body)
@@ -488,40 +575,53 @@ local function well_note(c, well)
end end
end end
-- The drill of a list that finishes next (not done yet), or nil. -- What a well's card shows (its block's signature with its rect): the well, its deploy
local function next_drill(g, drills) -- slot, its drills' slots, its idle slot. slots: how many (card_h).
local now, pick = g.h.now(g), nil local function card_model(g, st, well, max, groups, H)
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
end
return pick
end
-- One well's card (a grid cell; its node selects the well when clicked).
local function well_card(g, c, st, moon, well, max, groups, H, enters)
local dry = well.supply <= 0 local dry = well.supply <= 0
local selected = well.id == g.view.well
local scope = { ["claim-well"] = well.id }
local slots = {}
if not dry then
slots[#slots + 1] = deploy_slot(g, st, well, groups, H)
end
local n, next_d = 0, next_drill(g, well.drills)
-- Tapped out once the ready drills are claimed (nothing left, or what is left is the -- 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. -- 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 tapped = well.supply - ready.amount(g, well, well.cap) <= 1e-9 or (well.remain or 1) <= 1e-9
for _, d in ipairs(well.drills or {}) do local m = {
local due = d.ready or (d["end"] or math.huge) <= H.now(g) well = well, max = max, dry = dry, selected = well.id == g.view.well,
n = n + (due and 1 or 0) redeploy = (not dry and not tapped and st.unlocked.redeploy) and true or false,
slots[#slots + 1] = drill_slot(g, st, d, scope, not dry and not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) 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 end
if (well.redrill or 0) > 0 then for i, d in ipairs(well.drills or {}) do
slots[#slots + 1] = idle_slot(g, st, well, H) 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 end
local sup = fit(c, ui.money(well.supply), { font = "mono", px = 13, color = dry and "muted" or "text" }, { shrink = 0 }) 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 sup.done = function(node) H.tick_num(g, node, "supply:" .. well.id, well.supply) end
local list = col({ gap = SLOT_GAP }, slots)
local kids = { local kids = {
row({ height = 18, align = "center" }, { 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), 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),
@@ -529,48 +629,64 @@ local function well_card(g, c, st, moon, well, max, groups, H, enters)
fg.fill(), space(6), sup, fg.fill(), space(6), sup,
}), }),
space(0, 7), space(0, 7),
made({ height = 6, shrink = 0 }, function(q, x, y, w) made({ height = 6, shrink = 0 }, function(q, x, y, bw)
if dry then if dry then
ui.rect(q, x, y, w, 6, "line") ui.rect(q, x, y, bw, 6, "line")
else else
M.supply_bar(q, x, y, w, well, max) M.supply_bar(q, x, y, bw, well, m.max)
end end
end), end),
space(0, 4), space(0, 4),
fg.reserve({ height = 16 }, well_note(c, well)), fg.reserve({ height = 16 }, well_note(c, well)),
space(0, 8), space(0, 8),
col({ gap = SLOT_GAP }, slots), list,
} }
return col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, w, h) local tree = col({ padding = CARD_PAD }, kids, { make = function(parent, x, y, cw, ch)
local slot = ui.rect(parent, x, y, w, h, "none") local slot = ui.rect(parent, x, y, cw, ch, "none")
local card = ui.rect(slot, 0, 0, w, h, "cyan", 0) local card = ui.rect(slot, 0, 0, cw, ch, "cyan", 0)
row_c.bind(g, card, w, h, { selected = selected, id = "well:" .. well.id, label = well.resource, 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 }) on_click = function() H.navigate(g, { section = "moons", moon = moon.id, well = well.id }) end })
ui.outline(card, 0, 0, w, h, selected and "cyan" or "line_hi", selected and 0.9 or nil) ui.outline(card, 0, 0, cw, ch, m.selected and "cyan" or "line_hi", m.selected and 0.9 or nil)
g.well_nodes[well.id] = card blocks.put(g, "well_nodes", well.id, card)
return card return card
end }) 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 end
-- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): a card -- The multi-well drills (moon.wide: reach > 1, claimed together with claim-wide): what its
-- across the grid, its slots in the cards' columns. -- card shows.
local function wide_card(g, c, st, moon, inner, H, enters) local function wide_model(g, st, moon, H)
local wide = moon.wide local wide = moon.wide
local scope = { ["claim-wide"] = moon.id }
local slots, n, next_d = {}, 0, next_drill(g, wide.drills)
-- (the moon tapped out once these are claimed: no Redeploy) -- (the moon tapped out once these are claimed: no Redeploy)
local left = 0 local left = 0
for _, w in ipairs(moon.wells or {}) do for _, w in ipairs(moon.wells or {}) do
left = left + w.supply left = left + w.supply
end end
local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9 local tapped = left - ready.amount(g, wide, wide.cap) <= 1e-9
for _, d in ipairs(wide.drills or {}) do local scope = "claim?claim-wide=" .. moon.id
local due = d.ready or (d["end"] or math.huge) <= H.now(g) return { wide = wide, redeploy = (not tapped and st.unlocked.redeploy) and true or false,
n = n + (due and 1 or 0) drills = drill_models(g, st, wide.drills, H), marks = marks(g, { scope, scope .. "&redeploy=1" }) }
slots[#slots + 1] = drill_slot(g, st, d, scope, not tapped and st.unlocked.redeploy, due and n or 0, d == next_d, H, enters) 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 end
local sw = inner - CARD_PAD * 2 local sw = inner - CARD_PAD * 2
return col({ width = inner, padding = CARD_PAD }, { local tree = col({ width = inner, padding = CARD_PAD }, {
row({ height = 18, align = "center" }, { row({ height = 18, align = "center" }, {
fg.label(c, "Multi-well", { font = "sans_semi", px = 15, color = "text" }, 18), space(10), 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(), fit(c, ui.money(wide.cap or 0) .. " left on the moon", S12), fg.fill(),
@@ -582,28 +698,16 @@ local function wide_card(g, c, st, moon, inner, H, enters)
ui.outline(card, 0, 0, w, h, "line_hi") ui.outline(card, 0, 0, w, h, "line_hi")
return card return card
end }) end })
fg.build(flex.solve(tree, 0, 0), c)
for _, f in ipairs(enters) do
f()
end
return tree.h
end end
-- The tray of idle drills: a tile per group (picture, short name, ×count, what a cycle -- What the tray of idle drills shows: a tile per group (the well it deploys to: the
-- gets); a tile toggles the pick the deploy slots send. -- selected one, else the first it has room on).
local function tray(g, c, st, groups, inner, H) local function tray_model(g, st, groups)
local head = row({ height = 16, align = "center" }, {
fg.label(c, "Idle drills", "h3", 12, { shrink = 0 }), space(10, 0),
#groups > 0 and fit(c, "Click one to deploy it to the selected well", "small") or nil,
})
if #groups == 0 then
return col({ width = inner }, {
head, space(0, 8),
row({ height = 16, align = "center" }, {
fg.label(c, "No idle drills.", "small", 16),
st.unlocked.store and space(5) or nil,
st.unlocked.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 = g.view.moon })
end) or nil,
}),
})
end
-- The well a tile deploys to: the selected one, else the first it has room on.
local moon = st.moon local moon = st.moon
local function target(t) local function target(t)
local wells = moon and moon.wells or {} local wells = moon and moon.wells or {}
@@ -618,40 +722,78 @@ local function tray(g, c, st, groups, inner, H)
end end
end end
end end
local tiles = {} local tiles, keys = {}, {}
for _, grp in ipairs(groups) do for i, grp in ipairs(groups) do
local t = grp.tool local t = grp.tool
local well = target(t) local well = target(t)
local here = well and gets_here(st, well, t) or 0 local here = well and gets_here(st, well, t) or 0
local ok = well ~= nil and well.supply > 0 and here > EPS 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,
local picked = false gets = "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach_short(st, t.reach) and (" · " .. reach_short(st, t.reach)) or ""),
tiles[#tiles + 1] = made({ height = TILE_H }, function(p, x, y, w) well = well and well.id or false, resource = well and well.resource or false,
local b, bn = ui.button(p, "", x, y, { variant = "ghost", w = w, h = TILE_H, disabled = not ok }) ok = (well ~= nil and well.supply > 0 and here > EPS) and true or false }
local img = ui.rect(b, 6, 6, TILE_H - 12, TILE_H - 12, "bg") if well then
if t.image then keys[#keys + 1] = "stake?target=" .. well.id .. "&tool=" .. t.id
images.set(ui.rect(img, 0, 0, TILE_H - 12, TILE_H - 12, "none"), t.image) end
end
local reach = reach_short(st, t.reach)
local tree = col({ width = w, height = TILE_H, padding = { 8, 8, 8, TILE_H }, gap = 4 }, {
row({ height = 16, align = "center" }, {
fit(b, M.short_name(t), NAME13), fg.fill(), space(4),
fg.label(b, "×" .. grp.count, { font = "mono", px = 12, color = picked and "cyan" or "text_2" }, 16, { shrink = 0 }),
}),
fit(b, "+" .. ui.money(t.yield or 0) .. " / " .. ui.cycle(t.cycleSecs) .. (reach and (" · " .. reach) or ""), { font = "mono", px = 11, color = "muted" }),
})
fg.build(flex.solve(tree, 0, 0), b)
if picked then
ui.outline(b, 0, 0, w, TILE_H, "cyan", 0.9)
end
if ok then
tooltip.add(b, "Deploy " .. (t.name or "a drill") .. " to the " .. well.resource .. " well")
H.action(g, b, bn, "stake", { tool = t.id, target = well.id })
else
tooltip.add(b, (t.name or "Drill") .. (well and (" · no room on the " .. well.resource .. " well") or " · no room on this moon"))
end
end)
end end
return col({ width = inner }, { head, space(0, 8), flex.grid(inner, TILE_MIN, 8, tiles) }) return { tiles = tiles, store = st.unlocked.store and true or false, moon = g.view.moon, marks = marks(g, keys) }
end
-- 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 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
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end end
@@ -683,13 +825,10 @@ local function rating_chip(c, r)
end) end)
end end
-- The moon: heading (id, name, class and rating, description; Claim at the right), the
-- well cards, the multi-well card, the tray of idle drills, and selling the moon (once
-- the store is open). 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
-- 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 -- The class line ("<type> class"), but not for moons named after their type (from
-- before moon names), like the web. -- before moon names), like the web.
local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name local has_class = moon.kind and moon.kind ~= "" and moon.kind ~= moon.name
@@ -713,7 +852,6 @@ local function moon_view(g, c, px, py, inner, min_h, H)
head[#head + 1] = space(0, 4) head[#head + 1] = space(0, 4)
end end
-- Claim, at the top right (out of the flow: the heading's text keeps clear of it). -- 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 if moon.ready > 0 then
head[#head + 1] = made({ position = "absolute", top = 26, right = 0 }, function(p, x, y) 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 }) local b, nodes, bw = ui.button(p, "Claim " .. ui.plural(n_ready, "drill"), x, y, { primary = true })
@@ -727,9 +865,31 @@ 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 }) done = n_ready > 0 and ("Claim " .. ui.plural(n_ready, "drill")) or nil })
end) end)
end end
local tree = col({ width = inner, min_height = 80, align = "start" }, head)
fg.build(flex.solve(tree, 0, 0), c)
return tree.h
end
-- The cards: a well each (as many columns as CARD_MIN allows), the multi-well card -- The moon: heading, the well cards, the multi-well card, the tray of idle drills (selling
-- across them, then the idle drills. -- 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 max = M.supply_max(st, moon)
local groups = M.idle_groups(st) local groups = M.idle_groups(st)
local pick_ok = false local pick_ok = false
@@ -739,30 +899,39 @@ local function moon_view(g, c, px, py, inner, min_h, H)
if not pick_ok then if not pick_ok then
g.deploy_pick = nil -- (used up) g.deploy_pick = nil -- (used up)
end end
local enters = {} local cols, cw = flex.columns(inner, CARD_MIN, CARD_GAP)
local cards = {} local wells = moon.wells or {}
for _, well in ipairs(moon.wells) do local models = {}
cards[#cards + 1] = well_card(g, c, st, moon, well, max, groups, H, enters) 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 end
local kids = {
col({ min_height = 80, align = "start" }, head),
space(0, 14),
flex.grid(inner, CARD_MIN, CARD_GAP, cards),
}
if moon.wide and #(moon.wide.drills or {}) > 0 then if moon.wide and #(moon.wide.drills or {}) > 0 then
kids[#kids + 1] = space(0, CARD_GAP) y = y + CARD_GAP
kids[#kids + 1] = wide_card(g, c, st, moon, inner, H, enters) 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 end
kids[#kids + 1] = space(0, 20) y = y + 20
kids[#kids + 1] = tray(g, c, st, groups, inner, H) local tm = tray_model(g, st, groups)
-- (selling the moon, a destructive action, is in the aside: aside.lua) y = y + part(g, "mo:tray", blocks.sig(inner, tm), c, px, y, function(root)
return tray(g, root, tm, inner, H)
local page = col({ width = inner, min_height = min_h }, kids) end)
fg.build(flex.solve(page, px, py), c) return math.max(y, py + min_h)
for _, f in ipairs(enters) do
f()
end
return page.y + page.h
end end
+1 -1
View File
@@ -572,7 +572,7 @@ local function recipe_card(self, H, c, st, rf, r)
refine = H.action(self, b, nodes, "refine", function() refine = H.action(self, b, nodes, "refine", function()
local q = self.refine_qty and self.refine_qty[r.item] or "1" local q = self.refine_qty and self.refine_qty[r.item] or "1"
return { recipe = r.item, qty = (q ~= "" and q or "1") } return { recipe = r.item, qty = (q ~= "" and q or "1") }
end) end, nil, nil, "act:refine?recipe=" .. r.item)
tooltip.add(b, "Refine the number in the box") tooltip.add(b, "Refine the number in the box")
-- Enter in the quantity refines (the web's form). -- Enter in the quantity refines (the web's form).
blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } }) blocks.push(self, "inputs", { input = input(), submit = refine, step = { min = 1, max = rf.maxBatch } })
+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") ui.rect(p, x + 16, y, w - 32, 1, "edge")
return nil return nil
end } or 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 end
local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, { local picker = panel_box({ width = inner, padding = { 14, 0, 6, 0 } }, {
row({ padding = { 0, 16, 0, 16 } }, { fg.label(root, "Moon grades", "cap", 12) }), 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 r.card then
if g.autoplay then if g.autoplay then
g.autoplay:register("reveal-close", {}, function() self:close() end) 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 end
if r.another_btn and not r.another_ok then if r.another_btn and not r.another_ok then
tooltip.add(r.another_btn, r.another_why) 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 -- 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; -- 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 -- 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 -- 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. -- + / 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.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) ui.text(p, "›", { font = "sym", px = 18, color = "nav" }, x + 10, y + 10, gui.PIVOT_CENTER)
end, 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) grant = function(p, x, y)
ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER) ui.text(p, "+", { font = "mono", px = 18, color = "cyan" }, x + 10, y + 10, gui.PIVOT_CENTER)
end, end,
@@ -98,7 +103,9 @@ end
function M.open(g, right, top) function M.open(g, right, top)
local st = g.state local st = g.state
local help = st and st.unlocked and st.unlocked.help 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 local h = PAD * 2 + rows * ROW_H + 8
g.dialog:open({ g.dialog:open({
w = W, w = W,
@@ -171,6 +178,12 @@ function M.open(g, right, top)
item(d, box, y, "Grant (G)", "grant", "grant", close_then(function() item(d, box, y, "Grant (G)", "grant", "grant", close_then(function()
require("screens.game.grant").open(g) require("screens.game.grant").open(g)
end)) 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
end, end,
}) })
-12
View File
@@ -8,8 +8,6 @@
- characters the fonts lack: non-ASCII characters in Lua string literals and in a - 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 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 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 - 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, over their limits, out of nodes or animations, stencil overflow, invalid markup,
script errors script errors
@@ -231,15 +229,6 @@ def check_scenario(api):
check_log(os.path.join(ROOT, "dist/drive/check/game.log")) 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(): def check_flex():
r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE, r = subprocess.run(["luajit", os.path.join(HERE, "flex_test.lua")], cwd=ROOT, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True) stderr=subprocess.STDOUT, text=True)
@@ -257,7 +246,6 @@ def main():
check_lua() check_lua()
check_flags() check_flags()
check_flex() check_flex()
check_moon()
check_fonts(api) check_fonts(api)
if "--static" not in sys.argv: if "--static" not in sys.argv:
check_scenario(api) check_scenario(api)
+5 -2
View File
@@ -63,11 +63,14 @@ def read(name, default=None):
def alive(pid): 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: try:
os.kill(int(pid), 0) with open("/proc/%d/cmdline" % int(pid), "rb") as f:
return True cmd = f.read()
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
return False return False
return any(n in cmd for n in (b"dmengine", b"MoonMiners", b"Xvfb"))
def snapshot(): 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 scenario: a name in scripts/scenarios/ (sections, smoke, ...) or a path to a .py file
with run(game, args). with run(game, args).
Options: 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) --size WxH window and display size (default 1280x800)
--out DIR screenshots and game.log (default dist/drive/<scenario>) --out DIR screenshots and game.log (default dist/drive/<scenario>)
--bundle run the bundle (dist/x86_64-linux/MoonMiners) instead of build/default --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 --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 (`make dev`: $(cat dist/dev/port)), log from --log FILE, screenshots
from its display (dist/dev/display for HEADLESS=1, else $DISPLAY) from its display (dist/dev/display for HEADLESS=1, else $DISPLAY)
--log FILE the attached game's log (default dist/dev/game.log) --log FILE the attached game's log (default dist/dev/game.log)
--config K=V extra engine config (mm.player=..., repeatable) --config K=V extra engine config (mm.player=..., repeatable)
--fixture make a test player first (scripts/fixture.py against --api, which must --fixture make a test player first (scripts/fixture.py against --api) and play as
then be given, or MM_API) and play as it; the scenario gets its ids as game.fixture it; the scenario gets its ids as game.fixture
--fixture-file F use the ids saved in F (a fixture.py output) instead --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 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 = argparse.ArgumentParser(add_help=True)
ap.add_argument("scenario") ap.add_argument("scenario")
ap.add_argument("rest", nargs="*") 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("--size", default="1280x800")
ap.add_argument("--out") ap.add_argument("--out")
ap.add_argument("--bundle", action="store_true") ap.add_argument("--bundle", action="store_true")
@@ -392,6 +393,9 @@ def main():
ap.add_argument("--fixture", action="store_true") ap.add_argument("--fixture", action="store_true")
ap.add_argument("--fixture-file") ap.add_argument("--fixture-file")
args = ap.parse_args() 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: if args.attach:
disp = os.path.join(ROOT, "dist/dev/display") disp = os.path.join(ROOT, "dist/dev/display")
@@ -413,8 +417,12 @@ def main():
try: try:
code = subprocess.call([sys.executable] + sys.argv, env=env) code = subprocess.call([sys.executable] + sys.argv, env=env)
finally: finally:
xvfb.terminate() if args.keep:
xvfb.wait() # 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) sys.exit(code)
path = find_scenario(args.scenario) path = find_scenario(args.scenario)
@@ -428,9 +436,6 @@ def main():
if not args.attach: if not args.attach:
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
elif args.fixture: 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])) fixture = json.loads(subprocess.check_output([sys.executable, os.path.join(HERE, "fixture.py"), args.api]))
args.config.append("mm.player=" + fixture["player"]) args.config.append("mm.player=" + fixture["player"])
print("fixture player", fixture["player"]) print("fixture player", fixture["player"])
-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
@@ -34,7 +34,7 @@ def run(game, args):
clicks = [ clicks = [
("moons", "act:claim?claim-well"), ("moons", "act:claim?claim-well"),
("refinery", "act:collect?job"), ("refinery", "act:collect?job"),
("market", "act:sell"), ("market", "act:sell?pick"),
("store", "act:buy-tool?id"), ("store", "act:buy-tool?id"),
("equipment", "act:upgrade?tool"), ("equipment", "act:upgrade?tool"),
] ]
+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)
+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)
+4
View File
@@ -17,6 +17,10 @@ if [ -z "${MM_IN_XVFB:-}" ]; then
fi fi
export LIBGL_ALWAYS_SOFTWARE=${LIBGL_ALWAYS_SOFTWARE:-1} 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")" cd "$(dirname "$bin")"
log=$(dirname "$out")/shot.log log=$(dirname "$out")/shot.log
TIMEOUT=${TIMEOUT:-120} 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: # 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/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 # 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 set -euo pipefail
bin=$1; out=$2; fx=$3; size=${4:-1280x800} 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'])"; } 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) player=$(get player); moon=$(get moon); well=$(get well); drill=$(get drill_mining)
settle=4; [ "$size" != 1280x800 ] && settle=15 settle=4; [ "$size" != 1280x800 ] && settle=15
+1
View File
@@ -13,6 +13,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
<<: *db-env <<: *db-env
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose: expose:
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers POSTGRES_DB: moonminers
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
+13 -3
View File
@@ -22,6 +22,8 @@ die() { echo "wt: $*" >&2; exit 1; }
task_vars() { task_vars() {
TASK=${1:-} TASK=${1:-}
[[ $TASK =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "task name: lowercase letters, digits and -" [[ $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 WT=$WS/wt-$TASK
DB=mm_${TASK//-/_} DB=mm_${TASK//-/_}
ST=$WT/.wt ST=$WT/.wt
@@ -63,7 +65,8 @@ start_server() {
{ tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; } { tail -5 "$ST/migrate.log"; die "migrate failed (log $ST/migrate.log)"; }
rm -f "$ST/pid" rm -f "$ST/pid"
# Its own session (not killed with the caller's), pid written by the process itself. # 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' \ setsid -f bash -c 'echo $$ >"$PIDFILE"; exec bin/moonminers serve' \
>"$ST/server.log" 2>&1 </dev/null) >"$ST/server.log" 2>&1 </dev/null)
for _ in $(seq 25); do [[ -s $ST/pid ]] && break; sleep 0.1; done 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 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/css/app.css || ! -e $ms/web/static/css/app.css ]] || { mkdir -p "$s/web/static/css"; cp "$ms/web/static/css/app.css" "$s/web/static/css/"; }
[[ -e $s/web/static/app || ! -d $ms/web/static/app ]] || cp -a "$ms/web/static/app" "$s/web/static/app" [[ -e $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" [[ -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" echo "$DB" >"$ST/db"
if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then if [[ -z $(admin_sql "select 1 from pg_database where datname = '$DB'") ]]; then
admin_sql "create database $DB" admin_sql "create database $DB"
@@ -126,7 +136,7 @@ up() {
start_server start_server
fi fi
echo "worktree $WT" 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)" echo "stop: $0 down $TASK (or rm $TASK to also remove the worktree)"
} }
@@ -179,7 +189,7 @@ case ${1:-} in
up) up "${2:-}" ;; up) up "${2:-}" ;;
down) down "${2:-}" ;; down) down "${2:-}" ;;
rm) remove "${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 ;; status) status ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;; *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac esac
-2
View File
@@ -5,6 +5,4 @@ node_modules
.env* .env*
.vscode .vscode
.DS_Store .DS_Store
web/static/css/app.css
art art
web/static/app
-2
View File
@@ -1,8 +1,6 @@
# Build output # Build output
/bin/ /bin/
/moonminers /moonminers
/web/static/css/app.css
/web/static/app/
# Local env files (never commit) # Local env files (never commit)
.env .env
+84 -99
View File
@@ -1,22 +1,17 @@
# AGENTS.md — Moon Miners server # AGENTS.md — Moon Miners server
The Go server and the web client of Moon Miners. What the game is, the repo layout and the The Go server of Moon Miners: game rules, the JSON API the Defold client plays on, and the
shared rules: `../AGENTS.md`. Run every command below from this directory (`server/`). admin pages. What the game is, the repo layout and the shared rules: `../AGENTS.md`. Run
every command below from this directory (`server/`).
## Stack ## Stack
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main` - 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`. 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 - The game's only client is the Defold one (`../client`), on the JSON API (`api.go`,
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their `api.md`). `GET /` redirects to `/utils` (a Defold web build will be served there later).
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine. - The admin pages (`/utils/*`) are server-rendered HTML: Go templates, htmx (vendored in
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals` `web/static/vendor`), plain CSS in `web/static/css/admin.css` (no build step, dark and
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild light themes). No bundler, no TypeScript.
(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.
- Postgres 16, stock image. No functions or extensions in the database. - Postgres 16, stock image. No functions or extensions in the database.
## Layout ## Layout
@@ -30,24 +25,28 @@ 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 `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 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 time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view). `loadView` (state, the past page, the resolved view). `loadState` leaves moon
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as descriptions out (`apiPageFor` loads the viewed moon's, `loadMoonDescription`);
`/?s=&moon=&well=`), the `Page` view model, formatting helpers. `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 - `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 limits with forms parsed up front, a per-IP limit on new players, IPv6 by its /64), `runAction` (every game
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an action).
action and responds the same way. - `admin.go`: the admin login (`ADMIN_PASSWORD`, `/utils/login`, `requireAdmin` around every
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view other `/utils` route, in-memory sessions in the `mm_admin` cookie; none with `MM_DEV=1`).
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same `admin_test.go`: the gate.
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422). - `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). 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, `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 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, sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or 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 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`, - `config/game.json`: the game config (starting credits/resources/tools, `moonGrades`,
resource prices, store items with their two upgrade tracks, moon types: a name, a placeholder 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, description and resource `affinities`, refinery), embedded. `moonGrades` (D, C, B, A, S,
@@ -64,7 +63,7 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item` `config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go; the tracks aren't stored). Admins override its (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 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. `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`, - `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 actions `save-code` and `restore` handled in `handleAPIAction`, not `runAction`; table
@@ -78,15 +77,16 @@ 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 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 `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, 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, 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). watchlist, chart, sell ticket; `pick=` selects a quote).
`web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at - `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 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 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 compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`. drill on it is running; finished drills are claimed in the sale.
Also selling tools for `equipmentResale.factor` (below 1) of their credit price (price + 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, its track levels; refined parts not refunded): only idle tools with nothing to claim,
never the last tool. never the last tool.
@@ -95,96 +95,82 @@ shared rules: `../AGENTS.md`. Run every command below from this directory (`serv
view model. A batch of n takes `seconds` × min(n, 1 + `bulkLog` × ln n) (`batchSecs`): logarithmic. The most per 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` batch is `maxBatch`, raised for the whole refinery (every slot) by `batchUpgrades`
(action `upgrade-batch`, `users.refinery_batch_level`). Tables (action `upgrade-batch`, `users.refinery_batch_level`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`. `refined_account`, `refinery_job`, `users.refinery_slots`.
- `bonus.go`: player bonuses. `bonusesFor` combines the seated advisors (multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on - `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 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`. `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). Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `tracks.go`: drill upgrade tracks. Each store item has two named tracks (`tracks.yield`, - `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 `tracks.cycle` in game.json): levels that set the claim per cycle or the cycle in seconds
flat, with a price and, higher up, refined `parts`. Levels are per tool 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 (`inventory_item.yield_level`, `cycle_level`; copies with the same levels are
interchangeable, `pickTool`); action `upgrade` (`track=yield|cycle`), counted from the next 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 stake. Reach is the item's and never changes. Also the view models (`TrackRow` with its
ladder, on the Equipment card and in the drill panel: `drill=` in the URL, Equipment ladder, on the Equipment card and in the drill panel: `drill=` in the view, Equipment
section, in the aside; the toast text). section, in the aside; the toast text).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy - `advisors.go`: advisors config (`advisors`), hire, buy seat, seat/unseat/swap/dismiss
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing rules (only seated advisors give a bonus; dismissing refunds nothing), Advisors section
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the and the advisor panel (`adv=` in the view, in the aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding` - `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`, 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 `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, reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a moon sale, Redrill), buying a moon of `refinery.unlockGrade` (C) or higher Refinery, buying a
tool Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends tool Advisors. The API sends `unlocked`; `View.resolve` sends locked sections to the moon
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`). view and `runAction` refuses locked actions (`actionGates`). A revealed tab shines until
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New opened (`markSeen` in `handleAPIState`). New sections and actions need a gate there.
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players` - `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 (search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged
`event_log`). In the game, admins (`Page.Admin`, `state.admin`) also get a quick grant as `admin_*` in `event_log` with `"admin": "admin"`). In the game, API players in
dialog (header Grant button or G; action `admin-grant`, 403 for others). `notBanned` gives banned players (`users.banned_at`) a suspended page. `ADMIN_USER_IDS` (`state.admin`) get a quick grant (action `admin-grant`, 403 for others).
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put Banned players (`users.banned_at`) get a 403 from the API.
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`. `web/templates/admin.html`: the page head (`head`, loads `admin.css`), the shared admin nav
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon (`{{template "admin-nav" "<page>"}}`, put it on every `/utils` page; it has the logout),
list, sections, side panel). the confirm dialog, the login page. `/utils` redirects to `/utils/players`.
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's - `web/static/js/`: the admin pages' JS. `tabs.js`: sliding tab highlight (`data-tabs`).
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`) `enter.js`: entrance animations. `ui.js`: the custom confirm dialog (`hx-confirm`) and
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene. toasts. `liveops.js`: wires the dialog on the htmx pages. `economy.js`: the economy editor.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view - `web/static/assets/`: tool and advisor images (the API's catalog image URLs).
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game - `moons.go`: moon settings endpoints (`/api/moon-settings`; saving: admin session, an
and admin pages. `ADMIN_USER_IDS` player or `MM_DEV`), `isAdmin`. `moondesc.go`: moon descriptions
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound generated from the look (mirrors the Defold client's `modules/moon/terrain.lua`, `traits`;
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored `go run . moon-traits` prints Go's side for comparing; nothing checks them automatically
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for any more) and the wells.
outside control (e.g. ads). - `reveal.go`: the new-moon section (`s=new-moon`, a teaser with a shrouded moon, `mystery`
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and in the scene data) and the reveal after `buy-moon` (the act response's `reveal`).
`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).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached `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 sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict `staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header. and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live - `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 ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`). via `eventMultiplier` in game.go, `announcement`; both in the API's `banner`).
- `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in - `scripts/tool-art.mjs`: generated tool art per tier (from the old tier ladder), not in
use; the tools show their original images. use; the tools show their original images.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`. - `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–022):
- `web/app/` (paused experiment: the Preact client, `GET /app`, template `spa.html`): the
Moons slice on the JSON API; other sections link to the classic client. `session.js` is
like the Defold client's session (`base`, `pending`, outbox with `clientSeq`, rollback on
422), `predict.js` a port of the Defold `predict.lua`. Details in each file's header.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check`). Key list items by stable ids
(drills by tool id, not stake id); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–020):
changes for existing ones, applied on every `migrate`, so each must be idempotent. 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. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `art/`: sources of the store and advisor images (Blender scripts, prompts); - `art/`: sources of the store and advisor images (Blender scripts, prompts);
they write into `web/static/assets`. they write into `web/static/assets`.
## Commands ## Commands
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and - `make dev`: server on :3000 (`MM_DEV=1`: no admin login) that reloads templates/static
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every from disk. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every game API request that
game request that long, to try both clients on a slow connection. long, to try the client on a slow connection.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`, - `make build`, `make check` (gofmt, vet, template, API and admin gate tests, JS syntax of
moon traits check, the Preact client's `.value` check and build), `make migrate`, every `web/static/js/*.js`), `make migrate`, `make load-config`. `go run . sim` prints the
`make load-config`. `go run . sim` prints the economy analysis. economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the - 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; admin templates like the server and renders every page against in-memory data; when you
when you add a page, section or view model branch, make sure its states/views reach it. 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). - 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. 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 Other tests: `go test ./...` (game rules on in-memory states, no database);
distribution, value by well count, resale odds, sim seeds). `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 ## Deployment
- Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`; - Production uses `../docker-compose.prod.yml` (at the repo root, build context `server/`;
@@ -200,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 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`, 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 `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` commit `.env`. `ADMIN_PASSWORD`: the admin pages' password (unset: `/utils` is locked).
(unset: nobody); with `MM_DEV=1` everyone is an admin. `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 ## Conventions
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin. - 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 - New section: add it to `sections` and `State.page` (views.go) and map it in the API
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps (`addSection`). New action: a case in `server.runAction` (`/api/act/<action>`) and in
the address bar in sync. `api.md`. The client sends the current view (s, moon, well, drill) with every action, so
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section` action parameters must use other names.
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and - The server is the authority; clients predict only what `api.md` "Prediction" allows.
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically, - New admin page: a route in `adminRoutes` (server.go; it's behind the login), a template
so action parameters must use other names. Give it a sound in fx.js if it needs one. that starts with `{{template "head"}}` and has the admin nav, styles in `admin.css`.
- 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.
- SQL is lowercase with snake_case identifiers. - SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`. - 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 - 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). transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
+2 -1
View File
@@ -39,7 +39,8 @@ game is moon and drill prices with wells scaled alike.
Attachments and the T1–T5 tier ladder are gone. Each drill has two tracks of its own 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 (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 3+ need refined parts. 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 / 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 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 cycle / cycle), × 0.95^(k−1), minus the parts' value, two significant digits, never below
+1 -10
View File
@@ -1,19 +1,10 @@
# Production image: one Go binary with the templates, static files and SQL embedded. # Production image: one Go binary with the templates, static files and SQL embedded.
FROM docker.io/library/golang:1.25-bookworm AS build FROM docker.io/library/golang:1.25-bookworm AS build
WORKDIR /src 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 ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
&& make app ESBUILD=/usr/local/bin/esbuild \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM scratch FROM scratch
COPY --from=build /moonminers /moonminers 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 .PHONY: build dev migrate load-config check
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 css app migrate load-config check build:
build: css app
go build -o bin/moonminers . go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact # Server on :3000 that reloads templates and static files from disk.
# client rebuild on change. dev:
dev: $(TAILWIND) $(ESBUILD) MM_DEV=1 go run . serve
$(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
migrate: migrate:
go run . migrate go run . migrate
@@ -34,17 +15,5 @@ load-config:
check: check:
test -z "$$(gofmt -l .)" && go vet ./... 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 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 $@
+56 -62
View File
@@ -1,19 +1,17 @@
# Moon Miners server # 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 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 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 resources into refined items on a timer; those sell for more and are needed as parts for
the higher levels of the drills' upgrade tracks. 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 It's a Go server: game rules run in Go, one Postgres transaction per action, behind a JSON
a three.js view of the selected moon, generated procedurally from its id. Game rules run in API (`api.md`) that the Defold client (`../client`) plays on. The admin pages (`/utils`) are
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick server-rendered HTML with htmx. `GET /` redirects to `/utils` for now.
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.
Players join anonymously: on the first visit the server creates a player and stores its id in Players join anonymously: on the first request the server creates a player and sends its id
a cookie. No wallet or sign-up needed. 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 This is `server/` in the repo (the Defold client is `../client`). Run the commands below
from this directory. from this directory.
@@ -24,8 +22,6 @@ from this directory.
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it - PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS) - Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI and esbuild into `bin/` on first use.
## Environment ## Environment
The server reads `.env` from its working directory (`server/`), or if there is none, from the 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 ADMIN_DB_PASSWORD=postgres
``` ```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates Optional: `PORT` (default 3000), `HOST` (the address to listen on, e.g. `127.0.0.1`; unset:
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated every interface), `DB_SSLMODE` (default `disable`), `ADMIN_PASSWORD` (the
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new password of the admin pages, `/utils`; unset: they're locked), `ADMIN_USER_IDS`
players by the last `X-Forwarded-For` address instead of the connection's; new players are (comma-separated player ids that are admins in the game API: `state.admin`, `admin-grant`,
limited to 10 a minute per IP). 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 ## Local development
@@ -59,7 +59,9 @@ limited to 10 a minute per IP).
make migrate 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 ```bash
make dev 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 pulling changes (or editing `config/game.json`), run `make migrate` again: new
migrations and the store items' numbers only reach the database through it. 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 Other commands: `make build` (`bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a check in `templates_test.go`, which renders every admin page without a database, the API and
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and admin login tests, JS syntax), `make load-config` (sync the store items in the database with
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`). `config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the 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), 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. 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 ## Database changes
@@ -98,7 +101,8 @@ attachments (dropped in 020) and advisors, 011 market pressure, 012 onboarding,
rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size rating, 016 advisor seats, 017 save codes, 018 moon grade, 019 refinery batch size
(`users.refinery_batch_level`), 020 drill upgrade tracks (`inventory_item.yield_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, `cycle_level`; old tiers became cycle levels, attachments were refunded and dropped,
`upgrade_item` dropped, stake cycles in tenths of a second). `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 ## Game config
@@ -138,17 +142,20 @@ Admins can change these numbers without a deploy on `/utils/economy` (see Admin
## Admin panels ## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1` Server-rendered pages (Go templates, htmx, `web/static/css/admin.css`). Sign in at
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages `/utils/login` with `ADMIN_PASSWORD`: the session is a random token in the `mm_admin` cookie
show "missing permission"). Every admin page (HttpOnly, SameSite=Lax, 7 days), kept in memory, so a restart signs admins out. A wrong
shares one nav: Players · Moons · Economy · Live ops · Game. 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 | | 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` | 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/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with its yield and cycle tracks), resources, refined items, market, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. | | `/utils/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. | | `/utils/liveops` | Dashboard, timed events and announcements. |
@@ -160,22 +167,22 @@ Actions (all `POST`, confirmed in the page's own dialog):
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` | | `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` | | `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement | | `/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` saves it: the admin cookie, an `X-MM-User` in `ADMIN_USER_IDS`, or `MM_DEV=1` |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
Request examples are in `test-endpoints.restclient`. Request examples are in `test-endpoints.restclient`.
### Players ### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the Every admin action is logged in `event_log` (`admin_*` kinds, which the live-ops stats
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a ignore) with `"admin": "admin"` (the admin-grant API action logs the admin player's id).
403. Banned players get a 403 from the API ("This account is suspended").
### Moons ### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab Each moon's look is generated from its id (terrain technique, noise, craters, colors) by the
edits the shared settings that every moon starts from; they're stored as one JSON row in Defold client. The shared settings every moon starts from are stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`. `moon_settings` (`/api/moon-settings`) and merged over the defaults in the client's
`modules/moon/terrain.lua`.
### Economy ### Economy
@@ -193,31 +200,19 @@ and store items are re-synced.
- Timed events: yield, sell price or mining speed multipliers, applied server-side. - Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements. - 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 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
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph editor).
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.
## Security ## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players - Players are identified only by their id (`X-MM-User`, or the `mm_user` cookie: HttpOnly,
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy). 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, - Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413. so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`. - Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
@@ -239,16 +234,15 @@ First deploy:
```dotenv ```dotenv
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers" 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. 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 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. 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 5. Deploy. The admin pages are at `/utils` (sign in with `ADMIN_PASSWORD`).
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations. 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. 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)
}
}
}
+24 -8
View File
@@ -1,7 +1,6 @@
package main package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the // 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
// views build and the same actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md. // milliseconds (null when unset), durations seconds; the client formats. See api.md.
import ( import (
@@ -605,8 +604,8 @@ type APIToast struct {
Kind string `json:"kind"` Kind string `json:"kind"`
} }
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined // APIFX is what the client plays effects from: the action, the moons it mined out and what a
// out and what a sale earned. // sale earned.
type APIFX struct { type APIFX struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Finished []string `json:"finished"` Finished []string `json:"finished"`
@@ -632,7 +631,7 @@ func apiServerError(w http.ResponseWriter, err error) {
} }
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one // 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 "". // writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) { func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context() ctx := r.Context()
@@ -642,7 +641,15 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
userID = c.Value 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) { if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute") apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false return "", false
@@ -653,7 +660,10 @@ func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string,
return "", false return "", false
} }
created = true 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") apiError(w, http.StatusForbidden, "This account is suspended")
return "", false return "", false
} }
@@ -678,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. // 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) { 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) page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil { if err := s.loadRanking(ctx, page); err != nil {
return nil, err return nil, err
@@ -764,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}) 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). // 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) { func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r) get, err := apiParams(r)
+29 -20
View File
@@ -1,9 +1,9 @@
# Moon Miners JSON API # Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the The game's API, for the Defold client. `GET /api/state` is built from the view models
web game: `GET /api/state` is built from the same view models as `GET /`, and (`views.go`), and `POST /api/act/<action>` runs the game rules through `runAction`
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in (server.go), onboarding gates included. Code: `api.go`. "The web" below is the former htmx
server.go), onboarding gates included. Code: `api.go`. client: the API keeps its texts and formats.
Conventions: Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the - 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 | | 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`) | | `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 | | `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) | | `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) |
@@ -115,11 +115,14 @@ Responses:
## GET /api/moon-settings ## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved The procedural-moon look shared by every moon (no player needed): the saved JSON object, or
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in `null` when none is saved. Merge it over the defaults (`DEFAULTS` in the Defold client's
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see `modules/moon/terrain.lua`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the `merge_settings`), then build each moon from its id. Cache it for the session.
session; the web page also reads it once per page load.
`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 ## State
@@ -163,14 +166,15 @@ short code, listed with each field, and the web's text next to it.
``` ```
{tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield, {tools: [{id, storeItemId, name, code, image, yieldLevel, cycleLevel, count, idle, reach, cycleSecs, yield,
selected, stake, tracks, resale, noSale, noSaleText, bonus}], selected, stake, tracks, ladderKey, resale, noSale, noSaleText, bonus}],
ladders: {<ladderKey>: {yield: [steps], cycle: [steps]}},
drill} drill}
``` ```
- Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake` - Every tool is its own row, copies too: `count` is 1, `idle` 1 when it isn't mining, `stake`
its drill. its drill.
- `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value, - `tracks`: the tool's two upgrade tracks, yield then cycle: `{kind, name, level, max, value,
next, ladder}`. `kind` is `yield` (`value`: the claim per cycle) or `cycle` (`value`: seconds 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 per cycle); values are a stake's now, with the player's bonuses and live events. Upgrade
with `tool=<id>&track=<kind>`. with `tool=<id>&track=<kind>`.
- `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name, - `next`: the next level, `null` at the top: `{level, value, label, price, parts: [{name,
@@ -181,8 +185,10 @@ short code, listed with each field, and the web's text next to it.
0). Make it in the Refinery"), `shortText` the line under it when credits are short; 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 `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. sell value, `paybackSecs` null if it never pays back.
- `ladder`: every level from 0 `[{level, value, rate, price, parts: [{name, need, have}], - `ladders`: each track's ladder, sent once for every tool with the same store item and
current, next}]` (the other track as it is; `price` 0 for level 0). 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`); - `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 `noSale`: `only-tool`, `claim-first`, `mining` or `""`, `noSaleText` its text ("Claim its
haul first"). haul first").
@@ -294,7 +300,9 @@ button), `credits` ("Need ₥X more", the button disabled) or `""`.
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId, `{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to 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 `reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value"). ("Highest value").
@@ -449,11 +457,12 @@ From a live server (long ids and arrays shortened).
"tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5, "tracks": [{"kind": "yield", "name": "Twin rotors", "level": 1, "max": 4, "value": 5,
"next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [], "next": {"level": 2, "value": 6, "label": "Twin rotors 2", "price": 320, "parts": [],
"canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "", "canAfford": true, "blocked": "", "short": 0, "blockedText": "", "shortText": "",
"rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}, "rateFrom": 101570.42, "rateTo": 121884.5, "cost": 320, "paybackSecs": 56.7}},
"ladder": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false}, {"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…"}],
{"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"]}, "ladderKey": "1:1:2", "resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}],
{"kind": "cycle", "name": "Flight computer", "level": 2, "max": 5, "value": 2.2, "next": "…", "ladder": ["…"]}], "ladders": {"1:1:2": {"yield": [{"level": 0, "value": 4, "rate": 81256.33, "price": 0, "parts": [], "current": false, "next": false},
"resale": 980, "noSale": "", "noSaleText": "", "bonus": {"speed": 1, "yield": 1}}], {"level": 1, "value": 5, "rate": 101570.42, "price": 290, "parts": [], "current": true, "next": false}, "…"],
"cycle": ["…"]}},
"drill": {"toolId": "13b3…", "mining": false, "resale": 980, "noSale": "", "noSaleText": "", "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}, "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}} "idleSame": 0, "idleBest": "", "deployTo": null}}
+56 -19
View File
@@ -9,6 +9,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"math" "math"
) )
@@ -303,8 +304,11 @@ type APILiveEvent struct {
// ------------------------------------------------------------------ equipment // ------------------------------------------------------------------ equipment
type APIEquipment struct { type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped Tools []APIToolRow `json:"tools"` // copies of a tool (same type and levels) grouped
Drill *APIDrillPanel `json:"drill"` // drill=<tool id> // 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 { type APIToolRow struct {
@@ -321,8 +325,9 @@ type APIToolRow struct {
CycleSecs float64 `json:"cycleSecs"` CycleSecs float64 `json:"cycleSecs"`
Yield float64 `json:"yield"` Yield float64 `json:"yield"`
Selected bool `json:"selected"` Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill Stake *APIStake `json:"stake"` // the shown copy's drill
Tracks []APITrack `json:"tracks"` // yield, then cycle Tracks []APITrack `json:"tracks"` // yield, then cycle
LadderKey string `json:"ladderKey"` // its tracks' ladders in equipment.ladders
Resale float64 `json:"resale"` Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"` NoSaleText string `json:"noSaleText"`
@@ -332,13 +337,12 @@ type APIToolRow struct {
// APITrack is one of a tool's upgrade tracks: values are a stake's now (yield per cycle, // 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. // or the cycle in seconds), with bonuses and live events.
type APITrack struct { type APITrack struct {
Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind> Kind string `json:"kind"` // yield, cycle: upgrade with tool=<id>&track=<kind>
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
Max int `json:"max"` Max int `json:"max"`
Value float64 `json:"value"` Value float64 `json:"value"`
Next *APINextLevel `json:"next"` // null at the top level Next *APINextLevel `json:"next"` // null at the top level
Ladder []APILadderStep `json:"ladder"`
} }
type APINextLevel struct { type APINextLevel struct {
@@ -399,11 +403,7 @@ func apiParts(rows []PartRow) []APIPart {
func apiTracks(s *State, rows []TrackRow) []APITrack { func apiTracks(s *State, rows []TrackRow) []APITrack {
out := []APITrack{} out := []APITrack{}
for _, r := range rows { for _, r := range rows {
t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value, Ladder: []APILadderStep{}} t := APITrack{Kind: r.Kind, Name: r.Name, Level: r.Level, Max: r.Max, Value: r.Value}
for _, st := range r.Ladder {
t.Ladder = append(t.Ladder, APILadderStep{Level: st.Level, Value: st.Value, Rate: st.Rate, Price: st.Price,
Parts: apiParts(st.Parts), Current: st.Current, Next: st.Next})
}
if n := r.Next; n != nil { if n := r.Next; n != nil {
next := &APINextLevel{Level: r.Level + 1, Value: r.NextValue, Label: r.Label(), Price: n.Price, 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), Parts: apiParts(r.Parts), CanAfford: r.CanAfford, Short: max(0, n.Price-s.Balance),
@@ -427,15 +427,38 @@ func apiTracks(s *State, rows []TrackRow) []APITrack {
return out 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 { func apiEquipment(p *Page) *APIEquipment {
s := p.S s := p.S
b := s.Bonus() b := s.Bonus()
e := &APIEquipment{Tools: []APIToolRow{}} e := &APIEquipment{Tools: []APIToolRow{}, Ladders: map[string]map[string][]APILadderStep{}}
for _, row := range p.Equipment { 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), 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, 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, 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), Resale: row.Item.ToolResale(row.Levels()), 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}} NoSale: s.noSale(row), NoSaleText: noSaleText[s.noSale(row)], Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}}
if row.Stake != nil { if row.Stake != nil {
st := apiStake(*row.Stake, false) st := apiStake(*row.Stake, false)
@@ -851,13 +874,27 @@ type APIMoonGrade struct {
SupplyMax float64 `json:"supplyMax"` SupplyMax float64 `json:"supplyMax"`
Default bool `json:"default"` // what buy-moon without a grade buys 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 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 { func apiMoonGrades(gs []GradeOffer) []APIMoonGrade {
out := []APIMoonGrade{} out := []APIMoonGrade{}
for _, g := range gs { for _, g := range gs {
a := APIMoonGrade{Grade: g.Grade, Price: g.Price, CanAfford: g.CanAfford, WellsMin: g.WellsMin, WellsMax: g.WellsMax, 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 { if !g.CanAfford {
a.WhyText = "Not enough credits" a.WhyText = "Not enough credits"
} }
+1 -1
View File
@@ -204,7 +204,7 @@ func checkAPITexts(t *testing.T, v View, a *APIState) {
} }
for _, tr := range r.Tracks { for _, tr := range r.Tracks {
if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") || if n := tr.Next; n != nil && ((n.Blocked != "") != (n.BlockedText != "") || n.Label == "") ||
len(tr.Ladder) != tr.Max+1 { len(e.Ladders[r.LadderKey][tr.Kind]) != tr.Max+1 {
fail("track texts", tr) fail("track texts", tr)
} }
} }
+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)
}
}
}
+46
View File
@@ -13,6 +13,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -29,6 +31,8 @@ type GameConfig struct {
StartingTools []int `json:"startingTools"` // store item ids, given free StartingTools []int `json:"startingTools"` // store item ids, given free
// Moon vendors, cheapest first (rollWells in game.go); the first is the default grade. // Moon vendors, cheapest first (rollWells in game.go); the first is the default grade.
MoonGrades []MoonGrade `json:"moonGrades"` 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). // Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"` MoonResale MoonResale `json:"moonResale"`
// Selling tools back (resale.go). // Selling tools back (resale.go).
@@ -67,6 +71,30 @@ type MoonGrade struct {
MinValue float64 `json:"minValue,omitempty"` 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. // grade is the moon grade with that name, else the first (lowest) one.
func (cfg *GameConfig) grade(name string) *MoonGrade { func (cfg *GameConfig) grade(name string) *MoonGrade {
if i := cfg.gradeIndex(name); i >= 0 { if i := cfg.gradeIndex(name); i >= 0 {
@@ -122,6 +150,10 @@ type MoonType struct {
var game = mustParseGameConfig(gameJSON) 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 { func mustParseGameConfig(data []byte) GameConfig {
var cfg GameConfig var cfg GameConfig
dec := json.NewDecoder(bytes.NewReader(data)) dec := json.NewDecoder(bytes.NewReader(data))
@@ -157,6 +189,14 @@ func (cfg *GameConfig) validate() error {
if err := cfg.MarketPressure.validate(); err != nil { if err := cfg.MarketPressure.validate(); err != nil {
return err 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 { if err := cfg.MoonResale.validate(); err != nil {
return err return err
} }
@@ -164,10 +204,16 @@ func (cfg *GameConfig) validate() error {
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1") return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
} }
for name, price := range cfg.ResourcePrices { 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 { if price < 0 {
return fmt.Errorf("%s price is negative", name) return fmt.Errorf("%s price is negative", name)
} }
} }
if len(cfg.StoreItems) == 0 {
return fmt.Errorf("no store items")
}
ids := map[int]bool{} ids := map[int]bool{}
for _, item := range cfg.StoreItems { for _, item := range cfg.StoreItems {
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 { if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
+51 -49
View File
@@ -3,7 +3,7 @@
"startingResources": 0, "startingResources": 0,
"startingTools": [1], "startingTools": [1],
"moonGrades": [ "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}}, "affinities": {"Sollux": 6, "Shadowstone": 5, "Azurium": 1, "Novafor": 0.4, "Nebulance": 0.15}},
{"grade": "C", "price": 12000, "minValue": 1.3, "wellSupplyMin": 432, "wellSupplyMax": 860, "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}}, "affinities": {"Sollux": 3, "Shadowstone": 3, "Azurium": 2, "Novafor": 1, "Nebulance": 0.4}},
@@ -14,6 +14,8 @@
{"grade": "S", "price": 24000000, "wellSupplyMin": 337840, "wellSupplyMax": 829840, "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}} "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}, "moonResale": {"factor": 0.08, "maxRatio": 0.9, "minHoldSecs": 600},
"equipmentResale": {"factor": 0.5}, "equipmentResale": {"factor": 0.5},
"marketVolatility": 0.25, "marketVolatility": 0.25,
@@ -33,20 +35,20 @@
"image": "shovel.png", "image": "shovel.png",
"price": 1200, "price": 1200,
"claimAmount": 8, "claimAmount": 8,
"completionTimeInSecs": 3, "completionTimeInSecs": 3, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Twin rotors", "levels": [ "yield": {"name": "Twin rotors", "levels": [
{"value": 10, "price": 280}, {"value": 10, "price": 280},
{"value": 12, "price": 280}, {"value": 12, "price": 280},
{"value": 14, "price": 290, "parts": {"Dusk Alloy": 1}}, {"value": 14, "price": 290},
{"value": 16, "price": 280, "parts": {"Dusk Alloy": 2}} {"value": 16, "price": 280}
]}, ]},
"cycle": {"name": "Flight computer", "levels": [ "cycle": {"name": "Flight computer", "levels": [
{"value": 2.6, "price": 180}, {"value": 2.6, "price": 180},
{"value": 2.2, "price": 240}, {"value": 2.2, "price": 240},
{"value": 1.9, "price": 250, "parts": {"Dusk Alloy": 1}}, {"value": 1.9, "price": 250},
{"value": 1.7, "price": 210, "parts": {"Dusk Alloy": 1}}, {"value": 1.7, "price": 210},
{"value": 1.5, "price": 270, "parts": {"Dusk Alloy": 2}} {"value": 1.5, "price": 270}
]} ]}
} }
}, },
@@ -57,21 +59,21 @@
"image": "pickaxe.png", "image": "pickaxe.png",
"price": 8000, "price": 8000,
"claimAmount": 120, "claimAmount": 120,
"completionTimeInSecs": 6, "completionTimeInSecs": 6, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wider chisel", "levels": [ "yield": {"name": "Wider chisel", "levels": [
{"value": 144, "price": 1520}, {"value": 144, "price": 1520},
{"value": 168, "price": 1520}, {"value": 168, "price": 1520},
{"value": 192, "price": 1520, "parts": {"Dusk Alloy": 2}}, {"value": 192, "price": 1520},
{"value": 216, "price": 1520, "parts": {"Cryo Cell": 1}}, {"value": 216, "price": 1520},
{"value": 240, "price": 1520, "parts": {"Plasma Weave": 1}} {"value": 240, "price": 1520}
]}, ]},
"cycle": {"name": "Pneumatic hammer", "levels": [ "cycle": {"name": "Pneumatic hammer", "levels": [
{"value": 5.2, "price": 1160}, {"value": 5.2, "price": 1160},
{"value": 4.5, "price": 1360}, {"value": 4.5, "price": 1360},
{"value": 3.9, "price": 1560, "parts": {"Dusk Alloy": 2}}, {"value": 3.9, "price": 1560},
{"value": 3.4, "price": 1720, "parts": {"Cryo Cell": 1}}, {"value": 3.4, "price": 1720},
{"value": 3, "price": 1780, "parts": {"Plasma Weave": 1}} {"value": 3, "price": 1780}
]} ]}
} }
}, },
@@ -82,21 +84,21 @@
"image": "lunarlite.jpg", "image": "lunarlite.jpg",
"price": 40200, "price": 40200,
"claimAmount": 900, "claimAmount": 900,
"completionTimeInSecs": 8, "completionTimeInSecs": 8, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wide-bore bit", "levels": [ "yield": {"name": "Wide-bore bit", "levels": [
{"value": 1060, "price": 6900}, {"value": 1060, "price": 6900},
{"value": 1270, "price": 8700}, {"value": 1270, "price": 8700},
{"value": 1430, "price": 6900, "parts": {"Cryo Cell": 1}}, {"value": 1430, "price": 6900},
{"value": 1640, "price": 8700, "parts": {"Plasma Weave": 2}}, {"value": 1640, "price": 8700},
{"value": 1800, "price": 6900, "parts": {"Plasma Weave": 3}} {"value": 1800, "price": 6900}
]}, ]},
"cycle": {"name": "Spindle motor", "levels": [ "cycle": {"name": "Spindle motor", "levels": [
{"value": 7, "price": 5400}, {"value": 7, "price": 5400},
{"value": 6, "price": 7200}, {"value": 6, "price": 7200},
{"value": 5.2, "price": 7800, "parts": {"Cryo Cell": 1}}, {"value": 5.2, "price": 7800},
{"value": 4.6, "price": 7800, "parts": {"Plasma Weave": 2}}, {"value": 4.6, "price": 7800},
{"value": 4, "price": 9900, "parts": {"Plasma Weave": 3}} {"value": 4, "price": 9900}
]} ]}
} }
}, },
@@ -108,21 +110,21 @@
"price": 645000, "price": 645000,
"claimAmount": 20000, "claimAmount": 20000,
"completionTimeInSecs": 10, "completionTimeInSecs": 10,
"reach": 2, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Wider beam", "levels": [ "yield": {"name": "Wider beam", "levels": [
{"value": 23700, "price": 115000}, {"value": 23700, "price": 115000},
{"value": 28200, "price": 135000}, {"value": 28200, "price": 135000},
{"value": 31800, "price": 115000, "parts": {"Cryo Cell": 2}}, {"value": 31800, "price": 115000},
{"value": 36300, "price": 135000, "parts": {"Plasma Weave": 3}}, {"value": 36300, "price": 135000},
{"value": 40000, "price": 115000, "parts": {"Void Core": 1}} {"value": 40000, "price": 115000}
]}, ]},
"cycle": {"name": "Laser capacitors", "levels": [ "cycle": {"name": "Laser capacitors", "levels": [
{"value": 8.7, "price": 90000}, {"value": 8.7, "price": 90000},
{"value": 7.5, "price": 115000}, {"value": 7.5, "price": 115000},
{"value": 6.5, "price": 125000, "parts": {"Cryo Cell": 2}}, {"value": 6.5, "price": 125000},
{"value": 5.7, "price": 130000, "parts": {"Plasma Weave": 3}}, {"value": 5.7, "price": 130000},
{"value": 5, "price": 150000, "parts": {"Void Core": 1}} {"value": 5, "price": 150000}
]} ]}
} }
}, },
@@ -134,21 +136,21 @@
"price": 9440000, "price": 9440000,
"claimAmount": 384000, "claimAmount": 384000,
"completionTimeInSecs": 12, "completionTimeInSecs": 12,
"reach": 3, "reach": 5,
"tracks": { "tracks": {
"yield": {"name": "Fine sieves", "levels": [ "yield": {"name": "Fine sieves", "levels": [
{"value": 452000, "price": 1600000}, {"value": 452000, "price": 1600000},
{"value": 542000, "price": 2080000}, {"value": 542000, "price": 2080000},
{"value": 610000, "price": 1600000, "parts": {"Plasma Weave": 3}}, {"value": 610000, "price": 1600000},
{"value": 700000, "price": 2080000, "parts": {"Void Core": 1}}, {"value": 700000, "price": 2080000},
{"value": 768000, "price": 1600000, "parts": {"Stellar Lattice": 1}} {"value": 768000, "price": 1600000}
]}, ]},
"cycle": {"name": "Auger gearbox", "levels": [ "cycle": {"name": "Auger gearbox", "levels": [
{"value": 10.4, "price": 1360000}, {"value": 10.4, "price": 1360000},
{"value": 9, "price": 1600000}, {"value": 9, "price": 1600000},
{"value": 7.8, "price": 1840000, "parts": {"Plasma Weave": 3}}, {"value": 7.8, "price": 1840000},
{"value": 6.8, "price": 2000000, "parts": {"Void Core": 1}}, {"value": 6.8, "price": 2000000},
{"value": 6, "price": 2080000, "parts": {"Stellar Lattice": 1}} {"value": 6, "price": 2080000}
]} ]}
} }
}, },
@@ -165,16 +167,16 @@
"yield": {"name": "Larger plates", "levels": [ "yield": {"name": "Larger plates", "levels": [
{"value": 1050000, "price": 3120000}, {"value": 1050000, "price": 3120000},
{"value": 1220000, "price": 3120000}, {"value": 1220000, "price": 3120000},
{"value": 1420000, "price": 3600000, "parts": {"Void Core": 1}}, {"value": 1420000, "price": 3600000},
{"value": 1590000, "price": 3120000, "parts": {"Stellar Lattice": 1}}, {"value": 1590000, "price": 3120000},
{"value": 1760000, "price": 3120000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 1760000, "price": 3120000}
]}, ]},
"cycle": {"name": "Charge coils", "levels": [ "cycle": {"name": "Charge coils", "levels": [
{"value": 12.2, "price": 2400000}, {"value": 12.2, "price": 2400000},
{"value": 10.5, "price": 2960000}, {"value": 10.5, "price": 2960000},
{"value": 9.1, "price": 3280000, "parts": {"Void Core": 1}}, {"value": 9.1, "price": 3280000},
{"value": 8, "price": 3440000, "parts": {"Stellar Lattice": 1}}, {"value": 8, "price": 3440000},
{"value": 7, "price": 4000000, "parts": {"Void Core": 2, "Stellar Lattice": 2}} {"value": 7, "price": 4000000}
]} ]}
} }
}, },
@@ -191,16 +193,16 @@
"yield": {"name": "Core barrel", "levels": [ "yield": {"name": "Core barrel", "levels": [
{"value": 1910000, "price": 4480000}, {"value": 1910000, "price": 4480000},
{"value": 2250000, "price": 5040000}, {"value": 2250000, "price": 5040000},
{"value": 2550000, "price": 4480000, "parts": {"Void Core": 2}}, {"value": 2550000, "price": 4480000},
{"value": 2890000, "price": 5040000, "parts": {"Stellar Lattice": 2}}, {"value": 2890000, "price": 5040000},
{"value": 3200000, "price": 4480000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 3200000, "price": 4480000}
]}, ]},
"cycle": {"name": "Drill string", "levels": [ "cycle": {"name": "Drill string", "levels": [
{"value": 14, "price": 3360000}, {"value": 14, "price": 3360000},
{"value": 12, "price": 4480000}, {"value": 12, "price": 4480000},
{"value": 10.4, "price": 4800000, "parts": {"Void Core": 2}}, {"value": 10.4, "price": 4800000},
{"value": 9.1, "price": 5200000, "parts": {"Stellar Lattice": 2}}, {"value": 9.1, "price": 5200000},
{"value": 8, "price": 5680000, "parts": {"Void Core": 3, "Stellar Lattice": 3}} {"value": 8, "price": 5680000}
]} ]}
} }
} }
@@ -269,7 +271,7 @@
"bulkLog": 1.0, "bulkLog": 1.0,
"unlockGrade": "C", "unlockGrade": "C",
"items": [ "items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 24, "seconds": 1, {"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 28, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2}, "inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."}, "description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2, {"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 97, "seconds": 2,
@@ -278,7 +280,7 @@
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2, {"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 134, "seconds": 2,
"inputs": {"Nebulance": 4, "Sollux": 2}, "inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."}, "description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 450, "seconds": 3, {"name": "Void Core", "tier": 2, "unlockGrade": "B", "color": "#c4b5fd", "price": 460, "seconds": 3,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5}, "inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."}, "description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3, {"name": "Stellar Lattice", "tier": 2, "unlockGrade": "B", "color": "#fde047", "price": 470, "seconds": 3,
+57 -20
View File
@@ -12,6 +12,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template"
"log" "log"
"math" "math"
"net/http" "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 // saveOverride records a new override (by the given admin), re-syncs the store items and
// puts it in effect. // puts it in effect. With since >= 0 it refuses when the newest saved version is not since
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) { // (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) c, err := applyOverride(ov)
if err != nil { if err != nil {
return 0, gameErr("%s", err.Error()) return 0, gameErr("%s", err.Error())
@@ -307,6 +309,19 @@ func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note
} }
var id int64 var id int64
err = inTx(ctx, db, func(tx pgx.Tx) error { 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, err := tx.QueryRow(ctx,
`insert into config_override(data, note, created_by) `insert into config_override(data, note, created_by)
values ($1, $2, (select id from users where id::text = $3)) returning id`, 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) { 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() ctx := r.Context()
// Pick up a save made by another instance before showing the editor. // Pick up a save made by another instance before showing the editor.
if err := loadOverride(ctx, s.db); err != nil { 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) serverError(w, err)
return 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() base := baseConfig()
c := cfg() c := cfg()
quotes := marketQuotes(c, time.Now()) 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{ s.render(w, "economy", &EconomyPage{
Allowed: true, Allowed: true,
Data: map[string]any{ Data: data,
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"market": quotes,
"colors": resourceColors,
},
Quotes: quotes, Quotes: quotes,
History: history, History: history,
Version: overrideVersion.Load(), 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 // goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
// {"errors": [...]}. // {"errors": [...]}.
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) { 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() ctx := r.Context()
reply := func(status int, v any) { reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json") 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 ov ConfigOverride
var note string var note string
since := int64(-1) // save: the version the page loaded with
switch r.PathValue("action") { switch r.PathValue("action") {
case "analyze": case "analyze":
draft, err := decodeConfig([]byte(r.PostFormValue("raw"))) 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)) s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
return 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 return
case "save": case "save":
draft, err := decodeConfig([]byte(r.PostFormValue("raw"))) 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()}}) reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
return 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 { if diffConfig(cfg(), &draft).ChangeCount() == 0 {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}}) reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
return return
@@ -584,7 +621,7 @@ func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
id, err := saveOverride(ctx, s.db, ov, note, userID) id, err := saveOverride(ctx, s.db, ov, note, "", since)
if err != nil { if err != nil {
if isGameError(err) { if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}}) reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
+786 -110
View File
File diff suppressed because it is too large. Load diff
+31 -4
View File
@@ -34,6 +34,8 @@ const (
gsClickBurst = 4.0 // clicks a player can bank for a burst gsClickBurst = 4.0 // clicks a player can bank for a burst
gsSellEvery = 30 // seconds between sell rounds (one click per resource) gsSellEvery = 30 // seconds between sell rounds (one click per resource)
gsClaimGap = 3 // seconds a player waits at least between two claims 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 { type gsTool struct {
@@ -116,6 +118,23 @@ type GSResult struct {
BuyGapAt SimTime BuyGapAt SimTime
BuyGapPreS SimTime // the same, before the first moon of the top grade BuyGapPreS SimTime // the same, before the first moon of the top grade
IdleAt SimTime 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. // GradeStat is the value of a grade's moons at base prices.
@@ -222,7 +241,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
var advisors []string var advisors []string
seats := 1 seats := 1
refinery := c.Refinery.UnlockGrade == "" 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{} pressure := map[string]float64{}
stock := map[string]float64{} stock := map[string]float64{}
var resources []string var resources []string
@@ -354,6 +373,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
if gap := SimTime(t - lastClick); gap > res.Idle { if gap := SimTime(t - lastClick); gap > res.Idle {
res.Idle, res.IdleAt = gap, SimTime(lastClick) res.Idle, res.IdleAt = gap, SimTime(lastClick)
} }
if t-lastClick >= gsIdleShow {
res.Idles = append(res.Idles, [2]int{lastClick, t})
}
lastClick = t lastClick = t
return true return true
} }
@@ -372,7 +394,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
freeOre += m.free freeOre += m.free
open = append(open, len(moons)) open = append(open, len(moons))
moons = append(moons, m) moons = append(moons, m)
grade = g grade = max(grade, g)
if _, ok := res.FirstGrade[gr.Grade]; !ok { if _, ok := res.FirstGrade[gr.Grade]; !ok {
res.FirstGrade[gr.Grade] = SimTime(t) res.FirstGrade[gr.Grade] = SimTime(t)
res.GradeClicks[gr.Grade] = clicks res.GradeClicks[gr.Grade] = clicks
@@ -507,6 +529,9 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
lastSell = t lastSell = t
} }
} }
if t%gsSampleEvery == 0 {
res.Samples = append(res.Samples, GSSample{t, credits, earned})
}
report := next < len(gsReportAt) && t >= gsReportAt[next]*60 report := next < len(gsReportAt) && t >= gsReportAt[next]*60
if !wantBuy && !report && freeOre >= need() { if !wantBuy && !report && freeOre >= need() {
continue continue
@@ -558,10 +583,11 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
wantBuy = false wantBuy = false
for range 50 { for range 50 {
b := best("", pl.All) 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 reserve := 0.0
if freeOre < 20*need() { if freeOre < 20*need() {
reserve = c.MoonGrades[nextGrade(income)].Price reserve = c.MoonGrades[grade].Price
} }
if b.Kind == "" || credits-b.Cost < reserve { if b.Kind == "" || credits-b.Cost < reserve {
break break
@@ -579,6 +605,7 @@ func simulatePlayer(c *GameConfig, pl gsPlayer, stats []GradeStat) *GSResult {
} }
credits -= b.Cost credits -= b.Cost
res.Bought[b.Kind]++ res.Bought[b.Kind]++
res.Buys = append(res.Buys, GSEvent{t, b.Kind, b.Name, b.Cost})
res.Spent[b.Kind] += b.Cost res.Spent[b.Kind] += b.Cost
res.GainMin[b.Kind] += b.Gain / 60 res.GainMin[b.Kind] += b.Gain / 60
res.GainClick[b.Kind] += b.Click res.GainClick[b.Kind] += b.Click
+1 -2
View File
@@ -1,8 +1,7 @@
package main package main
// The Leaderboard section: the richest miners by credit balance, with the player's own // 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: // place below the top when they're not in it. Banned players don't rank.
// leaderboard-section in app.html.
import ( import (
"context" "context"
+13 -18
View File
@@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@@ -27,9 +28,10 @@ func logEvent(ctx context.Context, q querier, userID, kind string, data map[stri
return err return err
} }
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell. // 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). // buy_attachment: attachments were sold until migration 020 (kept for older events).
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'" 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 // ------------------------------------------------------------------ dashboard
@@ -94,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') where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
and kind not like 'admin\_%'), and kind not like 'admin\_%'),
(select coalesce(sum((data->>'credits')::float8), 0) from event_log (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 (select coalesce(sum((data->>'price')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`)) where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
from days order by day`) from days order by day`)
@@ -125,20 +127,21 @@ func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
d.ActiveToday = d.Days[n-1].Active d.ActiveToday = d.Days[n-1].Active
} }
// Dn retention: of the players who joined in the last 30 days at least n days ago, // Dn retention: of the players who joined in the last 30 days whose day n is over
// the share that did something n days after the day they joined. // (joined at least n+1 days ago), the share that did something n days after the day
// they joined.
err = q.QueryRow(ctx, ` err = q.QueryRow(ctx, `
with cohort as ( with cohort as (
select id, created_at::date as day from users select id, created_at::date as day from users
where created_at >= current_date - 30 where created_at >= current_date - 30
) )
select select
count(*) filter (where day <= current_date - 1), count(*) filter (where day <= current_date - 2),
count(*) filter (where day <= current_date - 1 and exists ( count(*) filter (where day <= current_date - 2 and exists (
select 1 from event_log where user_id = cohort.id 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\_%')), 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 - 8),
count(*) filter (where day <= current_date - 7 and exists ( count(*) filter (where day <= current_date - 8 and exists (
select 1 from event_log where user_id = cohort.id 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\_%')) 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) from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
@@ -223,10 +226,6 @@ func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
} }
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) { 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) lo, err := s.liveOps(r)
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
@@ -257,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 // handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
// that panel (#lo-events or #lo-announcements). // that panel (#lo-events or #lo-announcements).
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) { 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() ctx := r.Context()
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest. // POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
what, action := r.PathValue("what"), r.PathValue("action") what, action := r.PathValue("what"), r.PathValue("action")
@@ -370,7 +365,7 @@ func parseAnnouncement(r *http.Request) (Announcement, string) {
switch { switch {
case a.Message == "": case a.Message == "":
msg = "Write 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" msg = "Keep the message under 200 characters"
case !slices.Contains(announcementLevels, a.Level): case !slices.Contains(announcementLevels, a.Level):
msg = "Pick 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 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 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) // 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" "math"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -73,9 +75,84 @@ func floorDiv(a, b int64) int64 {
return q 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 { 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. // 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". // PriceBoosts are the active price_multiplier events: by resource name, and "all".
type PriceBoosts map[string]float64 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 { func (b PriceBoosts) For(name string) float64 {
m := 1.0 m := 1.0
if v, ok := b["all"]; ok { if v, ok := b["all"]; ok {
m *= v m *= v
} }
if v, ok := b[name]; ok { if v, ok := b[name]; ok && name != "all" {
m *= v m *= v
} }
return m return m
@@ -249,10 +327,9 @@ type Quote struct {
High float64 // 24h high/low of the market rate High float64 // 24h high/low of the market rate
Low float64 Low float64
Value float64 // Balance × Price (at the quote) Value float64 // Balance × Price (at the quote)
Spark string // SVG path, viewBox 0 0 100 32
URL string URL string
Selected bool Selected bool
series []float64 series []float64 // the shared 24h market rate (marketSeries): read only
} }
func (q Quote) Up() bool { return q.Change >= 0 } 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); // 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 } func (q Quote) Unpressed() float64 { return q.Price * q.Advisor }
// Unit is what one unit pays the player now (a tiny sale). // 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())) 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) Base() float64 { return cfg().unitPrice(q.Resource) }
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
// AdvisorText is e.g. "+8%". // AdvisorText is e.g. "+8%".
func (q Quote) AdvisorText() string { return pctText(q.Advisor) } func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
@@ -311,34 +387,12 @@ func marketSymbol(name string) string {
return strings.ToUpper(name) 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 { type MarketChart struct {
Line, Area string Start, Step int64
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
} }
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 // marketPage builds the market from the rows the market section lists (resources and
// refined items). // refined items).
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage { 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 { for _, row := range rows {
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell, 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()} URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
q.series = make([]float64, marketPoints) m := marketSeriesAt(row.Resource, now)
for i := range q.series { q.series = m.series
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i)) q.Rate, q.Open, q.High, q.Low, q.Change = m.rate, m.open, m.high, m.low, m.change
}
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
}
q.Pressure = s.Pressure[row.Resource] q.Pressure = s.Pressure[row.Resource]
q.Factor = cfg().MarketPressure.Factor(q.Pressure) q.Factor = cfg().MarketPressure.Factor(q.Pressure)
q.Price = q.Rate * q.Boost q.Price = q.Rate * q.Boost
q.Value = q.Balance * q.Price q.Value = q.Balance * q.Price
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
mp.Quotes = append(mp.Quotes, q) mp.Quotes = append(mp.Quotes, q)
} }
for i := range mp.Quotes { for i := range mp.Quotes {
@@ -387,62 +431,11 @@ func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
} }
if q := mp.Selected; q != nil { if q := mp.Selected; q != nil {
q.Selected = true q.Selected = true
mp.Chart = s.marketChart(q, now) mp.Chart = marketChart(now)
} }
return mp return mp
} }
func (s *State) marketChart(q *Quote, now int64) MarketChart { func marketChart(now int64) MarketChart {
// Pad the range so the line doesn't touch the edges. return MarketChart{Step: marketStep.Milliseconds(), Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
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)
} }
+17
View File
@@ -0,0 +1,17 @@
package main
import "testing"
// An event on every resource counts once, also when asked for "all" (the cycle speed).
func TestPriceBoostsAllCountsOnce(t *testing.T) {
b := PriceBoosts{"all": 2, "Azurium": 3}
if got := b.For("all"); got != 2 {
t.Errorf(`For("all") = %v, want 2`, got)
}
if got := b.For("Azurium"); got != 6 {
t.Errorf(`For("Azurium") = %v, want 6`, got)
}
if got := b.For("Sollux"); got != 2 {
t.Errorf(`For("Sollux") = %v, want 2`, got)
}
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"testing"
"time"
)
// TestMarketCache: the cached series match the uncached rates, and a new config snapshot
// (the economy editor's override) is picked up at once.
func TestMarketCache(t *testing.T) {
old := cfg()
defer effective.Store(old)
now := marketTick(time.Now())
item := "Sollux"
m := marketSeriesAt(item, now)
for _, tick := range []int64{now, now - 1, now - marketPoints + 1} {
if got, want := marketRateAt(item, tick), rateAt(old, item, tick); got != want {
t.Errorf("tick %d: cached %v, computed %v", tick, got, want)
}
}
if m.rate != rateAt(old, item, now) || m.open != rateAt(old, item, now-marketPoints+1) {
t.Errorf("series ends %v %v", m.open, m.rate)
}
c := baseConfig()
c.ResourcePrices[item] *= 2
effective.Store(&c)
if got, want := marketSeriesAt(item, now).rate, rateAt(&c, item, now); got != want || got == m.rate {
t.Errorf("after a config change: %v, want %v (was %v)", got, want, m.rate)
}
if got := marketRateAt(item, now-5); got != rateAt(&c, item, now-5) {
t.Errorf("after a config change, rate %v", got)
}
}
+21 -7
View File
@@ -1,12 +1,15 @@
package main package main
// Moon descriptions: one sentence generated from how the moon looks (the same seed-derived // Moon descriptions: one sentence generated from how the moon looks (the same seed-derived
// parameters web/static/js/terrain.js renders) and from its wells. // parameters the Defold client's moon generator renders, client/modules/moon/terrain.lua)
// and from its wells.
// //
// resolveMoonLook mirrors the part of terrain.js's resolveMoon that matters for the text // resolveMoonLook mirrors the part of terrain.lua's resolve that matters for the text
// (same hash, same PRNG, same draw order, same color math). terrain.js's moonTraits returns // (same hash, same PRNG, same draw order, same color math). terrain.lua's traits returns
// the same fields; scripts/check-moon-traits.mjs (run by `make check`) compares the two for // the same fields (`moonminers moon-traits` prints Go's side for comparing). The function
// fixed ids so they can't drift. // names below (resolveMoon, moonTraits, hashString, rng, adjustColor, mergeSettings) are
// those of the old terrain.js, which terrain.lua ports (resolve, traits, hash_string, rng,
// adjust_color, merge_settings).
// //
// Settings: like the client, the saved admin settings (moon_settings) are merged over the // Settings: like the client, the saved admin settings (moon_settings) are merged over the
// defaults. The description is computed once, when the moon is bought (or backfilled by // defaults. The description is computed once, when the moon is bought (or backfilled by
@@ -268,7 +271,7 @@ type wellSupply struct {
// classifyMoon turns resolved look parameters (and the defaults they vary from) plus the // classifyMoon turns resolved look parameters (and the defaults they vary from) plus the
// wells into traits. Wealth is judged against the expected supply of new wells // wells into traits. Wealth is judged against the expected supply of new wells
// (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the moon lab (/utils/moons). // (meanWell: the average well of the moon's grade). Thresholds were checked by eye in the old web moon lab.
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, meanWell float64) moonTraits { func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, meanWell float64) moonTraits {
var t moonTraits var t moonTraits
@@ -566,7 +569,7 @@ func backfillMoonDescriptions(ctx context.Context, db *pgxpool.Pool) error {
// ---------------------------------------------------------------- check / samples // ---------------------------------------------------------------- check / samples
// runMoonTraits is the `moon-traits` command, used by scripts/check-moon-traits.mjs. // runMoonTraits is the `moon-traits` command, for comparing with terrain.lua's traits.
// It reads {"settings": {...} | null, "seeds": [...]} on stdin and prints the resolved // It reads {"settings": {...} | null, "seeds": [...]} on stdin and prints the resolved
// look of each seed as JSON. With "samples": true it instead prints each seed's traits and // look of each seed as JSON. With "samples": true it instead prints each seed's traits and
// description, with random wells. // description, with random wells.
@@ -602,3 +605,14 @@ func runMoonTraits() error {
enc.SetIndent("", " ") enc.SetIndent("", " ")
return enc.Encode(out) return enc.Encode(out)
} }
// loadMoonDescription loads the description of one of the player's moons into s.Moons
// (loadState doesn't read them: only the moon view shows one).
func loadMoonDescription(ctx context.Context, q querier, s *State, moonID string) error {
m := s.moon(moonID)
if m == nil || m.Description != "" {
return nil
}
return q.QueryRow(ctx, "select description from staking_source where id = $1 and user_id = $2",
m.ID, s.UserID).Scan(&m.Description)
}
+9 -14
View File
@@ -13,13 +13,15 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// Moon look: the procedural-moon settings tuned on /utils/moons (web/static/js/terrain.js // Moon look: the procedural-moon settings (the Defold client's moon generator,
// documents the fields). Stored as one JSON row; the game merges it over its defaults. // client/modules/moon/terrain.lua, documents the fields). Stored as one JSON row; the client
// merges it over its defaults.
const maxMoonSettingsBytes = 64 << 10 const maxMoonSettingsBytes = 64 << 10
// isAdmin: players listed in ADMIN_USER_IDS (comma separated), or anyone with MM_DEV=1. // isAdmin: players listed in ADMIN_USER_IDS (comma separated), or anyone with MM_DEV=1.
// With neither, nobody is an admin. // With neither, no player is an admin. It's for the API (state.admin, admin-grant,
// saving moon settings); the admin pages use the admin login instead (admin.go).
func (s *server) isAdmin(userID string) bool { func (s *server) isAdmin(userID string) bool {
if s.dev { if s.dev {
return true return true
@@ -53,15 +55,6 @@ func saveMoonSettings(ctx context.Context, db *pgxpool.Pool, settings json.RawMe
return err return err
} }
// handleMoonsAdmin renders the moon editor.
func (s *server) handleMoonsAdmin(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "moons-admin", map[string]any{"Allowed": false})
return
}
s.render(w, "moons-admin", map[string]any{"Allowed": true})
}
// handleGetMoonSettings returns the saved settings, or null. // handleGetMoonSettings returns the saved settings, or null.
func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) { func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
settings, err := loadMoonSettings(r.Context(), s.db) settings, err := loadMoonSettings(r.Context(), s.db)
@@ -77,9 +70,11 @@ func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
w.Write(settings) w.Write(settings)
} }
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only. // handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only: a
// signed-in admin (the mm_admin cookie), an API player in ADMIN_USER_IDS (X-MM-User, as the
// Defold client sends it) or anyone with MM_DEV=1.
func (s *server) handleSaveMoonSettings(w http.ResponseWriter, r *http.Request) { func (s *server) handleSaveMoonSettings(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) { if !s.adminSession(r) && !s.isAdmin(r.Header.Get(userHeader)) {
http.Error(w, "Missing permission", http.StatusForbidden) http.Error(w, "Missing permission", http.StatusForbidden)
return return
} }
+32 -22
View File
@@ -144,39 +144,49 @@ func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string,
if feature == "" { if feature == "" {
return nil return nil
} }
if feature == "new-moon" {
return newMoonAllowed(ctx, db, userID)
}
s := &State{} s := &State{}
err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding) err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
if err != nil { if err != nil {
return err return err
} }
if feature == "new-moon" {
// The first moon is always for sale (and the next once every moon is mined out,
// see moonFinished), more come with the store.
var active int
err := db.QueryRow(ctx, `
select count(*) from staking_source
where user_id = $1
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
or exists (
select 1 from staking_event
join resource_well on resource_well.id = staking_event.well_id
where resource_well.source_id = staking_source.id
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)))`,
userID).Scan(&active)
if err != nil {
return err
}
if active == 0 {
return nil
}
feature = "store"
}
if !s.Unlocked(feature) { if !s.Unlocked(feature) {
return gameErr("Not available yet") return gameErr("Not available yet")
} }
return nil return nil
} }
// newMoonAllowed is the new-moon gate: the first moon is always for sale (and the next once
// every moon is mined out, see moonFinished), more come with the store. buyMoon checks it
// again under the player's row lock, so two buys at once can't both pass it.
func newMoonAllowed(ctx context.Context, q querier, userID string) error {
s := &State{}
err := q.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
if err != nil {
return err
}
var active int
err = q.QueryRow(ctx, `
select count(*) from staking_source
where user_id = $1
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
or exists (
select 1 from staking_event
join resource_well on resource_well.id = staking_event.well_id
where resource_well.source_id = staking_source.id
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)))`,
userID).Scan(&active)
if err != nil {
return err
}
if active > 0 && !s.Unlocked("store") {
return gameErr("Not available yet")
}
return nil
}
// adminOnboarding resets a player's onboarding (reset) or unlocks everything. // adminOnboarding resets a player's onboarding (reset) or unlocks everything.
func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error { func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error {
return inTx(ctx, db, func(tx pgx.Tx) error { return inTx(ctx, db, func(tx pgx.Tx) error {
+16 -57
View File
@@ -2,8 +2,9 @@ package main
// Players admin (/utils/players, admins only): find a player, see everything they own and // Players admin (/utils/players, admins only): find a player, see everything they own and
// their event history, and fix things up (grants, finishing drills, reset, onboarding, ban). Every // their event history, and fix things up (grants, finishing drills, reset, onboarding, ban). Every
// action runs in a transaction and is logged in event_log as admin_* with the admin's id. // action runs in a transaction and is logged in event_log as admin_* with "admin": "admin"
// Banned players get a plain "suspended" page instead of the game (notBanned). // (the game's quick grant, admin-grant, logs the admin player's id instead).
// Banned players get a 403 from the API (apiUser).
import ( import (
"context" "context"
@@ -34,30 +35,14 @@ func (s *server) playerRoutes(mux *http.ServeMux) {
// ------------------------------------------------------------------ bans // ------------------------------------------------------------------ bans
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) bool { // isBanned reports whether the player is banned (no such player: not banned).
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) (bool, error) {
var banned bool var banned bool
err := db.QueryRow(ctx, "select banned_at is not null from users where id = $1", userID).Scan(&banned) err := db.QueryRow(ctx, "select banned_at is not null from users where id = $1", userID).Scan(&banned)
return err == nil && banned if errors.Is(err, pgx.ErrNoRows) {
} return false, nil
// notBanned answers game routes with "This account is suspended" for banned players.
// htmx requests reload the page, which then shows the suspended page.
func (s *server) notBanned(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(userCookie)
if err == nil && uuidPattern.MatchString(c.Value) && isBanned(r.Context(), s.db, c.Value) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Refresh", "true")
http.Error(w, "This account is suspended", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
s.render(w, "suspended", nil)
return
}
h(w, r)
} }
return banned, err
} }
// ------------------------------------------------------------------ list // ------------------------------------------------------------------ list
@@ -160,10 +145,6 @@ type PlayersPage struct {
} }
func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) { func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "players", &PlayersPage{})
return
}
ctx := r.Context() ctx := r.Context()
p := &PlayersPage{Allowed: true, Query: r.FormValue("q"), Sort: r.FormValue("sort")} p := &PlayersPage{Allowed: true, Query: r.FormValue("q"), Sort: r.FormValue("sort")}
if _, ok := playerSorts[p.Sort]; !ok { if _, ok := playerSorts[p.Sort]; !ok {
@@ -174,7 +155,9 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return return
} }
if r.Header.Get("HX-Request") == "true" { // htmx's search swaps just the list; a history restore (Back past htmx's cache) asks
// with HX-Request too but wants the whole page.
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
s.render(w, "pl-list", p) s.render(w, "pl-list", p)
return return
} }
@@ -190,13 +173,6 @@ func (s *server) handlePlayers(w http.ResponseWriter, r *http.Request) {
s.render(w, "players", p) s.render(w, "players", p)
} }
// forbidden renders an admin page's "missing permission" variant with a 403.
func (s *server) forbidden(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
s.render(w, name, data)
}
// ------------------------------------------------------------------ detail // ------------------------------------------------------------------ detail
type PlayerTool struct { type PlayerTool struct {
@@ -239,7 +215,6 @@ func (p EventsPage) Next() int { return p.Page + 1 }
type PlayerPage struct { type PlayerPage struct {
Allowed bool Allowed bool
Self bool // the admin looking at their own player
P PlayerRow P PlayerRow
State *State State *State
Tools []PlayerTool Tools []PlayerTool
@@ -271,7 +246,7 @@ func loadPlayerEvents(ctx context.Context, db *pgxpool.Pool, userID string, page
} }
// loadPlayerPage returns nil if there is no such player. // loadPlayerPage returns nil if there is no such player.
func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*PlayerPage, error) { func (s *server) loadPlayerPage(ctx context.Context, userID string) (*PlayerPage, error) {
rows, err := s.db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+ rows, err := s.db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+
" where users.id = $1", userID) " where users.id = $1", userID)
if err != nil { if err != nil {
@@ -283,7 +258,7 @@ func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*P
} else if err != nil { } else if err != nil {
return nil, err return nil, err
} }
page := &PlayerPage{Allowed: true, P: p, Self: strings.EqualFold(userID, adminID)} page := &PlayerPage{Allowed: true, P: p}
if page.State, err = loadState(ctx, s.db, userID); err != nil { if page.State, err = loadState(ctx, s.db, userID); err != nil {
return nil, err return nil, err
} }
@@ -326,17 +301,12 @@ func (p *PlayerPage) StakesOn(wellID string) int {
} }
func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) { func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) {
adminID := s.currentUser(r)
if !s.isAdmin(adminID) {
s.forbidden(w, "player", &PlayerPage{})
return
}
id := strings.ToLower(r.PathValue("id")) id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) { if !uuidPattern.MatchString(id) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
page, err := s.loadPlayerPage(r.Context(), id, adminID) page, err := s.loadPlayerPage(r.Context(), id)
if err != nil { if err != nil {
serverError(w, err) serverError(w, err)
return return
@@ -349,10 +319,6 @@ func (s *server) handlePlayer(w http.ResponseWriter, r *http.Request) {
} }
func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) { func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
id := strings.ToLower(r.PathValue("id")) id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) { if !uuidPattern.MatchString(id) {
http.NotFound(w, r) http.NotFound(w, r)
@@ -371,11 +337,7 @@ func (s *server) handlePlayerEvents(w http.ResponseWriter, r *http.Request) {
// handlePlayerAction runs an admin action on a player and re-renders the detail (#pl-detail). // handlePlayerAction runs an admin action on a player and re-renders the detail (#pl-detail).
func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) { func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
adminID := s.currentUser(r) adminID := adminLabel
if !s.isAdmin(adminID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context() ctx := r.Context()
id := strings.ToLower(r.PathValue("id")) id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) { if !uuidPattern.MatchString(id) {
@@ -442,7 +404,7 @@ func (s *server) handlePlayerAction(w http.ResponseWriter, r *http.Request) {
serverError(w, err) serverError(w, err)
return return
} }
page, perr := s.loadPlayerPage(ctx, id, adminID) page, perr := s.loadPlayerPage(ctx, id)
if perr != nil { if perr != nil {
serverError(w, perr) serverError(w, perr)
return return
@@ -618,9 +580,6 @@ func adminReset(ctx context.Context, db *pgxpool.Pool, adminID, userID string) e
} }
func adminBan(ctx context.Context, db *pgxpool.Pool, adminID, userID string, ban bool) error { func adminBan(ctx context.Context, db *pgxpool.Pool, adminID, userID string, ban bool) error {
if ban && strings.EqualFold(adminID, userID) {
return gameErr("You can't suspend yourself")
}
return inTx(ctx, db, func(tx pgx.Tx) error { return inTx(ctx, db, func(tx pgx.Tx) error {
banned, err := lockPlayer(ctx, tx, userID) banned, err := lockPlayer(ctx, tx, userID)
if err != nil { if err != nil {
+539
View File
@@ -0,0 +1,539 @@
package main
// Progression charts for the Economy page's Analysis tab: each chart draws the saved config
// and the draft. The payback ladder lists every purchase in the order a player would buy
// it, with the minutes of active play it takes to earn its price back.
import (
"fmt"
"math"
"slices"
"strings"
)
// jumpRatio: a purchase whose payback is more than this many times its predecessor's is a
// jump (a wall) on the ladder.
const jumpRatio = 2.0
// Purchase is one step of the payback ladder.
type Purchase struct {
Order int // 1 on
Kind string // moon, drill, yield, cycle, advisor
Name string // e.g. "Moon C", "PX-4", "PX-4 yield 2", "Ada Voss"
Grade string // the bracket: the moon grade it belongs with
Cost float64
GainPerMin float64 // ₥/min it adds (a moon: what its drill earns on it)
Payback float64 // minutes; +Inf if it adds nothing
Jump float64 // payback ÷ the previous purchase's (advisors left out), when over jumpRatio; else 0
Note string
}
// Ladder is a config's purchases in buying order, and what was left out.
type Ladder struct {
Steps []Purchase
Skipped []string // purchases with no direct income or no seat
Stats []GradeStat
}
// payLadder builds the ladder. Order: for each moon grade (cheapest first), its moon, then
// each drill meant for it (suitedGrade, by price), each followed by its track levels
// (the cheaper next level of the two tracks first), then the advisors the player can afford
// by then (best payback first; the first seat is free, each later one adds its seat price).
// Income is one drill on moons of its grade, net of what the moons cost (gradeStats):
// a drill's own rate; a level, what it adds over the drill's levels bought so far; an
// advisor, what its bonus adds to a full moon (one per well) of the newest drill; a moon, its price over the rate of the
// first drill meant for it (the drill bought so far if none is).
func payLadder(c *GameConfig) Ladder {
var l Ladder
if len(c.MoonGrades) == 0 || len(c.ResourcePrices) == 0 {
return l
}
// Per grade: what claims get (reach caps), the value of a unit and the share of it
// left after paying for the moon (1 - price/value).
stats := gradeStats(c)
l.Stats = stats
caps := map[*MoonGrade]ReachCaps{}
rate := func(it *StoreItem, lv Levels, b Bonus) float64 {
g := suitedGrade(c, it)
if caps[g] == nil {
caps[g] = reachCaps(c, g)
}
st := stats[c.gradeIndex(g.Grade)]
net := 0.0
if st.Mean > 0 {
net = max(0, 1-1/st.Mean)
}
secs, y := drillStats(it.ToolSecs(lv), it.ToolYield(lv), b, 1, 1)
return perHour(caps[g].Yield(y, it.ToolReach()), secs) * st.Unit * net * b.Sell / 60
}
byGrade := map[string][]int{}
order := make([]int, len(c.StoreItems))
for i := range order {
order[i] = i
}
slices.SortStableFunc(order, func(a, b int) int {
return cmpAsc(c.StoreItems[a].Price, c.StoreItems[b].Price)
})
for _, i := range order {
g := suitedGrade(c, &c.StoreItems[i]).Grade
byGrade[g] = append(byGrade[g], i)
}
advisors := map[string]bool{}
for _, a := range c.Advisors.Items {
advisors[a.Name] = true
}
hired := 0
last := -1 // the newest drill
levels := map[int]Levels{}
add := func(p Purchase) {
p.Order = len(l.Steps) + 1
p.Payback = math.Inf(1)
if p.GainPerMin > 0 {
p.Payback = p.Cost / p.GainPerMin
}
// Advisors are account-wide, so they're left out of the jumps.
if p.Kind != "advisor" {
if prev := prevPayback(l.Steps); prev > 0 && !math.IsInf(prev, 0) && p.Payback > jumpRatio*prev {
p.Jump = p.Payback / prev
}
}
l.Steps = append(l.Steps, p)
}
for gi, g := range c.MoonGrades {
// The moon.
moonRate := 0.0
if ds := byGrade[g.Grade]; len(ds) > 0 {
moonRate = rate(&c.StoreItems[ds[0]], Levels{}, noBonus)
} else if last >= 0 {
moonRate = rate(&c.StoreItems[last], levels[last], noBonus)
}
add(Purchase{Kind: "moon", Name: "Moon " + g.Grade, Grade: g.Grade, Cost: g.Price, GainPerMin: moonRate})
// Its drills and their levels.
for _, i := range byGrade[g.Grade] {
it := &c.StoreItems[i]
code := toolCode(it.Name)
add(Purchase{Kind: "drill", Name: code, Grade: g.Grade, Cost: it.Price, GainPerMin: rate(it, Levels{}, noBonus)})
last = i
lv := Levels{}
for {
kind := ""
var cost float64
for _, k := range trackKinds {
if u := it.TrackLevel(k, lv.Of(k)+1); u != nil {
if p := u.Price + partsValue(c, u.Parts); kind == "" || p < cost {
kind, cost = k, p
}
}
}
if kind == "" {
break
}
next := lv.With(kind, lv.Of(kind)+1)
add(Purchase{Kind: kind, Name: fmt.Sprintf("%s %s %d", code, kind, next.Of(kind)), Grade: g.Grade,
Cost: cost, GainPerMin: rate(it, next, noBonus) - rate(it, lv, noBonus)})
lv = next
levels[i] = lv
}
}
// Advisors affordable before the next moon grade (all of them at the last grade).
limit := math.Inf(1)
if gi+1 < len(c.MoonGrades) {
limit = c.MoonGrades[gi+1].Price
}
// Advisors are account-wide: count their bonus on a full moon's worth of the newest
// drill (one per well, the grade's average well count).
fleet := max(1, math.Round(stats[gi].Wells))
for last >= 0 {
seat := 0.0
if hired > 0 {
if hired-1 >= len(c.Advisors.SeatPrices) {
break
}
seat = c.Advisors.SeatPrices[hired-1]
}
it := &c.StoreItems[last]
base := rate(it, levels[last], noBonus)
best, bestGain := -1, 0.0
for ai, a := range c.Advisors.Items {
if !advisors[a.Name] {
continue
}
gain := rate(it, levels[last], c.bonusesFor([]string{a.Name})) - base
if gain > 0 && (best < 0 || (a.Price+seat)/gain < (c.Advisors.Items[best].Price+seat)/bestGain) {
best, bestGain = ai, gain
}
}
if best < 0 || c.Advisors.Items[best].Price+seat >= limit {
break
}
a := c.Advisors.Items[best]
p := Purchase{Kind: "advisor", Name: a.Name, Grade: g.Grade, Cost: a.Price + seat, GainPerMin: bestGain * fleet}
p.Note = fmt.Sprintf("on %.0f drills", fleet)
if seat > 0 {
p.Note += fmt.Sprintf(", with seat %d (%s)", hired+1, formatCredits(seat))
}
add(p)
delete(advisors, a.Name)
hired++
}
}
for _, a := range c.Advisors.Items {
if advisors[a.Name] {
l.Skipped = append(l.Skipped, a.Name+" (advisor: no ₥ income, or no seat left)")
}
}
return l
}
// prevPayback is the payback of the last step that isn't an advisor, 0 if none.
func prevPayback(steps []Purchase) float64 {
for i := len(steps) - 1; i >= 0; i-- {
if steps[i].Kind != "advisor" {
return steps[i].Payback
}
}
return 0
}
func cmpAsc(a, b float64) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// minutesText is a payback for labels: "40s", "12 min", "3.5 h".
func minutesText(m float64) string {
switch {
case math.IsInf(m, 0):
return "never"
case m < 1:
return fmt.Sprintf("%.0fs", m*60)
case m < 120:
return fmt.Sprintf("%.3g min", m)
}
return fmt.Sprintf("%.3g h", m/60)
}
// Progression is the chart view for the Analysis tab.
type Progression struct {
Payback *Chart
Jumps []Purchase // the draft's
SavedJumps int
Skipped []string
Same bool // the draft is the saved config
JumpRatio float64
Tiers []*Chart // per drill tier: price, yield, cycle, units/s
Grades []*Chart // per moon grade: price, value, value ÷ price
Levels []*Chart // per track and drill: payback per level
}
const (
savedClass = "ch-saved"
draftClass = "ch-draft"
)
func progression(saved, draft *GameConfig, same bool) *Progression {
ls, ld := payLadder(saved), payLadder(draft)
p := &Progression{Same: same, Skipped: ld.Skipped, JumpRatio: jumpRatio}
ch := &Chart{Title: "Payback per purchase", XLabel: "buying order →", YLabel: "minutes to earn it back",
LogY: true, YFmt: func(v float64) string { return minutesText(v) }, Height: 300}
series := func(name, class string, l Ladder) ChartSeries {
s := ChartSeries{Name: name, Class: class}
for _, st := range l.Steps {
title := fmt.Sprintf("#%d %s · %s · adds %s/min · pays back in %s", st.Order, st.Name,
formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(st.Payback))
if st.Note != "" {
title += " · " + st.Note
}
pt := ChartPoint{X: float64(st.Order), Y: st.Payback, Title: title, Alt: st.Kind == "advisor"}
if st.Jump > 0 {
pt.Mark = true
pt.Title += fmt.Sprintf(" · ×%.1f the step before", st.Jump)
}
s.Points = append(s.Points, pt)
}
return s
}
draftName := "Draft"
if same {
draftName = "Draft (same as saved)"
}
ch.Series = []ChartSeries{series("Saved", savedClass, ls), series(draftName, draftClass, ld)}
// Bands: the draft's grade brackets.
for i, st := range ld.Steps {
if st.Kind == "moon" {
end := float64(len(ld.Steps)) + 0.5
for _, nx := range ld.Steps[i+1:] {
if nx.Kind == "moon" {
end = float64(nx.Order) - 0.5
break
}
}
ch.Bands = append(ch.Bands, ChartBand{X0: float64(st.Order) - 0.5, X1: end, Label: st.Grade})
}
}
// Label the draft's jumps.
for i, st := range ld.Steps {
if st.Jump > 0 {
ch.Series[1].Points[i].Label = st.Name
p.Jumps = append(p.Jumps, st)
}
}
for _, st := range ls.Steps {
if st.Jump > 0 {
p.SavedJumps++
}
}
p.Payback = ch
p.Tiers, p.Grades, p.Levels = curves(saved, draft, ls, ld, draftName)
return p
}
// JumpText is a jump for the list under the chart.
func (p Purchase) JumpText() string {
return fmt.Sprintf("#%d %s: %s, pays back in %s (×%.1f the step before)", p.Order, p.Name,
formatCredits(p.Cost), minutesText(p.Payback), p.Jump)
}
// Timeline is the clicker sim (the "boosts" player, gradesim.go) for the saved config and
// the draft: credits and income over time, purchases, first moon of each grade, stretches
// with nothing to click.
type Timeline struct {
Credits, Income *Chart
Rows []TimelineRow // saved, draft
Same bool
Grades []string
}
type TimelineRow struct {
Name string
FirstGrade []string // by grade, "" if never
Idle string // longest stretch with nothing to click
IdleCount int // stretches of gsIdleShow s or more
BuyGap string // longest stretch with nothing to buy
Buys int
End string // credits earned by the end
}
func simTimeline(saved, draft *GameConfig, same bool) *Timeline {
pl := gsPlayers[1]
rs := simulatePlayer(saved, pl, gradeStats(saved))
rd := rs
if !same {
rd = simulatePlayer(draft, pl, gradeStats(draft))
}
tl := &Timeline{Same: same}
for _, g := range draft.MoonGrades {
tl.Grades = append(tl.Grades, g.Grade)
}
minFmt := func(v float64) string {
if v >= 60 && math.Mod(v, 60) == 0 {
return fmt.Sprintf("%.0fh", v/60)
}
if v >= 60 {
return fmt.Sprintf("%.1fh", v/60)
}
return fmt.Sprintf("%.0fm", v)
}
ticks := []float64{1, 2, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 360, 480}
tl.Credits = &Chart{Title: "Credits over time", XLabel: "time played (log scale) →", YLabel: "credits on hand", LogY: true,
LogX: true, XTickVals: ticks,
NoPoints: true, YFmt: formatCompact, XFmt: minFmt, StripLabel: "nothing to click"}
tl.Income = &Chart{Title: "Income per minute", XLabel: "time played (log scale) →", LogX: true, XTickVals: ticks, YLabel: "₥ earned per minute (2-minute average)",
LogY: true, NoPoints: true, YFmt: formatCompact, XFmt: minFmt, Height: 220}
names := []string{"Saved", "Draft"}
if same {
names[1] = "Draft (same as saved)"
}
for i, r := range []*GSResult{rs, rd} {
class := []string{savedClass, draftClass}[i]
credits := ChartSeries{Name: names[i], Class: class}
income := ChartSeries{Name: names[i], Class: class}
const window = 120 / gsSampleEvery
for k, s := range r.Samples {
credits.Points = append(credits.Points, ChartPoint{X: float64(s.At) / 60, Y: max(s.Credits, 10)})
if k >= window {
d := s.Earned - r.Samples[k-window].Earned
income.Points = append(income.Points, ChartPoint{X: float64(s.At) / 60, Y: max(d/2, 1)})
}
}
tl.Credits.Series = append(tl.Credits.Series, credits)
tl.Income.Series = append(tl.Income.Series, income)
for _, b := range r.Buys {
tl.Credits.Ticks = append(tl.Credits.Ticks, ChartTick{X: float64(b.At) / 60, Row: i, Class: class,
Title: fmt.Sprintf("%s · %s: %s (%s) %s", names[i], SimTime(b.At), b.Name, b.Kind, formatCredits(b.Cost))})
}
for _, x := range r.Idles {
tl.Credits.Strip = append(tl.Credits.Strip, ChartRange{X0: float64(x[0]) / 60, X1: float64(x[1]) / 60, Row: i, Class: class,
Title: fmt.Sprintf("%s · nothing to click from %s for %ds", names[i], SimTime(x[0]), x[1]-x[0])})
}
row := TimelineRow{Name: names[i], Idle: r.Idle.String(), IdleCount: len(r.Idles), BuyGap: r.BuyGap.String(), Buys: len(r.Buys)}
if n := len(r.Samples); n > 0 {
row.End = formatCompact(r.Samples[n-1].Earned)
}
for _, g := range tl.Grades {
at, ok := r.FirstGrade[g]
if !ok {
row.FirstGrade = append(row.FirstGrade, "")
continue
}
row.FirstGrade = append(row.FirstGrade, at.String())
for _, ch := range []*Chart{tl.Credits, tl.Income} {
ch.VLines = append(ch.VLines, ChartVLine{X: float64(at) / 60, Class: class, Label: g,
Title: fmt.Sprintf("%s: first %s moon at %s", names[i], g, at)})
}
}
tl.Rows = append(tl.Rows, row)
}
return tl
}
// catChart is a small chart of one value per category (drill tiers, grades, levels), saved
// against the draft. Labels name the draft's categories.
func catChart(title, ylabel string, logY bool, labels []string, saved, draft []float64, yf func(float64) string, tip func(k, i int, v float64) string, draftName string) *Chart {
ch := &Chart{Title: title, YLabel: ylabel, LogY: logY, YFmt: yf, Width: 360, Height: 200,
XFmt: func(v float64) string {
if i := int(v) - 1; i >= 0 && i < len(labels) && float64(i+1) == v {
return labels[i]
}
return ""
}}
for i := range labels {
ch.XTickVals = append(ch.XTickVals, float64(i+1))
}
for k, vals := range [][]float64{saved, draft} {
s := ChartSeries{Name: []string{"Saved", draftName}[k], Class: []string{savedClass, draftClass}[k]}
for i, v := range vals {
s.Points = append(s.Points, ChartPoint{X: float64(i + 1), Y: v, Title: s.Name + " · " + tip(k, i, v)})
}
ch.Series = append(ch.Series, s)
}
return ch
}
// curves builds the small multiples: per drill tier (by price), per moon grade, and per drill
// and track the payback of each level (from the ladders).
func curves(saved, draft *GameConfig, ls, ld Ladder, draftName string) (tiers, grades, levels []*Chart) {
type tier struct {
code string
price, claim, secs, perSec float64
}
tiersOf := func(c *GameConfig) []tier {
capsBy := map[*MoonGrade]ReachCaps{}
var out []tier
for _, it := range c.StoreItems {
g := suitedGrade(c, &it)
if capsBy[g] == nil {
capsBy[g] = reachCaps(c, g)
}
caps := capsBy[g]
secs := float64(it.CompletionTimeInSecs)
out = append(out, tier{toolCode(it.Name), it.Price, it.ClaimAmount, secs,
caps.Yield(it.ClaimAmount, it.ToolReach()) / max(secs, 1)})
}
slices.SortStableFunc(out, func(a, b tier) int { return cmpAsc(a.price, b.price) })
return out
}
ts, td := tiersOf(saved), tiersOf(draft)
var labels []string
for _, t := range td {
labels = append(labels, t.code)
}
pick := func(ts []tier, f func(tier) float64) []float64 {
var out []float64
for _, t := range ts {
out = append(out, f(t))
}
return out
}
// Each series' points are named by their own config's tiers (k: 0 saved, 1 draft).
tierChart := func(title, ylabel string, logY bool, f func(tier) float64, yf func(float64) string) *Chart {
return catChart(title, ylabel, logY, labels, pick(ts, f), pick(td, f), yf,
func(k, i int, v float64) string { return fmt.Sprintf("%s: %s", [][]tier{ts, td}[k][i].code, yf(v)) }, draftName)
}
num := func(v float64) string { return formatCompact(v)[len(currency):] }
tiers = []*Chart{
tierChart("Price", "₥", true, func(t tier) float64 { return t.price }, formatCompact),
tierChart("Yield per cycle", "units", true, func(t tier) float64 { return t.claim }, num),
tierChart("Cycle time", "seconds", false, func(t tier) float64 { return t.secs }, func(v float64) string { return fmt.Sprintf("%gs", v) }),
tierChart("Units per second", "on a moon of its grade", true, func(t tier) float64 { return t.perSec }, num),
}
gradesOf := func(c *GameConfig) []string {
var out []string
for _, g := range c.MoonGrades {
out = append(out, g.Grade)
}
return out
}
glabels := gradesOf(draft)
gnames := [][]string{gradesOf(saved), glabels} // per series, as the ladders'
gname := func(k, i int) string {
if i < len(gnames[k]) {
return gnames[k][i]
}
return fmt.Sprint(i + 1)
}
gvals := func(l Ladder, f func(GradeStat) float64) []float64 {
var out []float64
for _, st := range l.Stats {
out = append(out, f(st))
}
return out
}
gtip := func(k, i int, v float64) string {
return fmt.Sprintf("%s: %s", gname(k, i), formatCompact(v))
}
grades = []*Chart{
catChart("Moon price", "₥", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Price }),
gvals(ld, func(g GradeStat) float64 { return g.Price }), formatCompact, gtip, draftName),
catChart("Expected value", "₥ of ore at base prices", true, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean * g.Price }),
gvals(ld, func(g GradeStat) float64 { return g.Mean * g.Price }), formatCompact, gtip, draftName),
catChart("Value ÷ price", "× (p10 to p90 in the tooltip)", false, glabels, gvals(ls, func(g GradeStat) float64 { return g.Mean }),
gvals(ld, func(g GradeStat) float64 { return g.Mean }), func(v float64) string { return fmt.Sprintf("%.2g×", v) },
func(k, i int, v float64) string {
st := []Ladder{ls, ld}[k].Stats
if i < len(st) {
return fmt.Sprintf("%s: %.2f× (p10 %.2f×, p90 %.2f×)", gname(k, i), v, st[i].P10, st[i].P90)
}
return fmt.Sprintf("%.2f×", v)
}, draftName),
}
// Levels: per drill (the draft's, by price) and track, cost ÷ gain of each level.
levelVals := func(l Ladder, code, kind string) ([]float64, []Purchase) {
var out []float64
var steps []Purchase
for _, st := range l.Steps {
if st.Kind == kind && strings.HasPrefix(st.Name, code+" ") {
out = append(out, st.Payback)
steps = append(steps, st)
}
}
return out, steps
}
for _, kind := range trackKinds {
for _, t := range td {
vs, ss := levelVals(ls, t.code, kind)
vd, sd := levelVals(ld, t.code, kind)
var lab []string
for i := range max(len(vs), len(vd)) {
lab = append(lab, fmt.Sprint(i+1))
}
ch := catChart(t.code+" "+kind, "min to pay back", true, lab, vs, vd, minutesText, func(k, i int, v float64) string {
if steps := [][]Purchase{ss, sd}[k]; i < len(steps) {
st := steps[i]
return fmt.Sprintf("level %d: %s for +%s/min, pays back in %s", i+1, formatCredits(st.Cost), formatCompact(st.GainPerMin), minutesText(v))
}
return fmt.Sprintf("level %d: %s", i+1, minutesText(v))
}, draftName)
ch.Width, ch.Height = 280, 170
levels = append(levels, ch)
}
}
return tiers, grades, levels
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"math"
"strconv"
"strings"
"testing"
)
func TestPayLadderOrder(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
if len(l.Steps) == 0 || l.Steps[0].Name != "Moon "+c.MoonGrades[0].Grade {
t.Fatalf("ladder should start with the cheapest moon: %+v", l.Steps[:1])
}
seen := map[string]bool{}
level := map[string]int{} // "PD-1 yield" -> last level
grade := -1
for i, st := range l.Steps {
if st.Order != i+1 {
t.Fatalf("step %d has order %d", i, st.Order)
}
switch st.Kind {
case "moon":
g := c.gradeIndex(st.Grade)
if g != grade+1 {
t.Fatalf("moon %s out of order", st.Grade)
}
grade = g
case "drill":
seen[st.Name] = true
case "yield", "cycle":
f := strings.Fields(st.Name) // code kind n
if !seen[f[0]] {
t.Errorf("%s before its drill", st.Name)
}
key := f[0] + " " + f[1]
level[key]++
if want := f[0] + " " + f[1] + " " + itoa(level[key]); st.Name != want {
t.Errorf("got %s, want %s", st.Name, want)
}
}
if c.gradeIndex(st.Grade) != grade {
t.Errorf("%s in bracket %s after moon %d", st.Name, st.Grade, grade)
}
}
if grade != len(c.MoonGrades)-1 {
t.Errorf("not every moon grade: %d", grade)
}
for _, it := range c.StoreItems {
if !seen[toolCode(it.Name)] {
t.Errorf("drill %s missing", it.Name)
}
top := it.MaxLevels()
if level[toolCode(it.Name)+" yield"] != top.Yield || level[toolCode(it.Name)+" cycle"] != top.Cycle {
t.Errorf("%s: not every level", it.Name)
}
}
}
func itoa(n int) string { return strconv.Itoa(n) }
func TestPayLadderPayback(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
it := &c.StoreItems[0] // PD-1, meant for grade D
g := suitedGrade(&c, it)
st := gradeStats(&c)[c.gradeIndex(g.Grade)]
perMin := reachCaps(&c, g).Yield(it.ClaimAmount, 1) * 3600 / float64(it.CompletionTimeInSecs) / 60 * st.Unit * (1 - 1/st.Mean)
for _, p := range l.Steps {
if p.Name == toolCode(it.Name) {
if want := it.Price / perMin; math.Abs(p.Payback-want) > 1e-6*want {
t.Errorf("PD-1 payback %v min, want %v", p.Payback, want)
}
if p.Payback <= 0 || p.Payback > 60 {
t.Errorf("PD-1 payback %v min out of range", p.Payback)
}
return
}
}
t.Fatal("no PD-1 step")
}
func TestPayLadderJumps(t *testing.T) {
c := baseConfig()
l := payLadder(&c)
for i, st := range l.Steps {
if i == 0 {
if st.Jump != 0 {
t.Error("first step can't jump")
}
continue
}
prev := prevPayback(l.Steps[:i])
want := st.Kind != "advisor" && !math.IsInf(prev, 0) && st.Payback > jumpRatio*prev
if (st.Jump > 0) != want {
t.Errorf("%s: jump %v, payback %v after %v", st.Name, st.Jump, st.Payback, prev)
}
}
// A drill 100× dearer is a wall.
c.StoreItems[1].Price *= 100
for _, st := range payLadder(&c).Steps {
if st.Name == toolCode(c.StoreItems[1].Name) {
if st.Jump < jumpRatio {
t.Errorf("%s at 100× price: jump %v", st.Name, st.Jump)
}
return
}
}
t.Fatal("drill missing")
}
// The tier charts name each series' points from that series' own config: a draft with no
// drills used to index an empty label list, and a new cheapest drill shifted the saved names.
func TestCurvesTierNames(t *testing.T) {
saved := baseConfig()
empty := baseConfig()
empty.StoreItems, empty.StartingTools = nil, nil
progression(&saved, &empty, false) // must not panic
cheap := baseConfig()
extra := cheap.StoreItems[0]
extra.Name, extra.Price = "Test Zzpick", 1
cheap.StoreItems = append([]StoreItem{extra}, cheap.StoreItems...)
tiers, _, _ := curves(&saved, &cheap, payLadder(&saved), payLadder(&cheap), "Draft")
low := saved.StoreItems[0]
for _, it := range saved.StoreItems {
if it.Price < low.Price {
low = it
}
}
first := toolCode(low.Name)
if s := tiers[0].Series[0]; !strings.Contains(s.Points[0].Title, first+":") {
t.Errorf("saved point 1 is %q, want the saved cheapest drill %s", s.Points[0].Title, first)
}
if d := tiers[0].Series[1]; !strings.Contains(d.Points[0].Title, toolCode(extra.Name)+":") {
t.Errorf("draft point 1 is %q, want %s", d.Points[0].Title, toolCode(extra.Name))
}
}
-1
View File
@@ -5,7 +5,6 @@ package main
// bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a // bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a
// replacement moon would cost. It is compared with that replacement price: a new moon of the // replacement moon would cost. It is compared with that replacement price: a new moon of the
// same grade. // same grade.
// Template: "moon-sale" in app.html.
import ( import (
"context" "context"
+25 -6
View File
@@ -1,8 +1,7 @@
package main package main
// The new-moon section (a teaser for an unknown moon) and the reveal after a purchase. // The new-moon section (a teaser for an unknown moon) and the reveal after a purchase
// Templates: "new-moon-section" and "moon-reveal" in app.html; web/static/js/reveal.js // (the act response's reveal; the client plays it from the data the server sends).
// plays the reveal from the data the server sends.
import ( import (
"cmp" "cmp"
@@ -37,6 +36,15 @@ type GradeOffer struct {
SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax SupplyMin float64 // a well holds from SupplyMin to just under SupplyMax
SupplyMax float64 SupplyMax float64
Default bool // what a buy without a grade gets Default bool // what a buy without a grade gets
Packs []PackOffer
}
// PackOffer is a moon pack of a grade (config moonPacks), once the store is open.
type PackOffer struct {
Count int
Price float64
Discount float64
CanAfford bool
} }
// GradeOffers lists every moon grade, cheapest first. // GradeOffers lists every moon grade, cheapest first.
@@ -45,9 +53,16 @@ func (s *State) GradeOffers() []GradeOffer {
var out []GradeOffer var out []GradeOffer
for _, g := range cfg().MoonGrades { for _, g := range cfg().MoonGrades {
lo, hi := g.wellCountRange() lo, hi := g.wellCountRange()
out = append(out, GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price, o := GradeOffer{Grade: g.Grade, Price: g.Price, CanAfford: s.Balance >= g.Price,
WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax, WellsMin: lo, WellsMax: hi, SupplyMin: g.WellSupplyMin, SupplyMax: g.WellSupplyMax,
Default: g.Grade == def}) Default: g.Grade == def}
if s.Unlocked("store") {
for _, p := range cfg().MoonPacks {
price := p.PackPrice(&g)
o.Packs = append(o.Packs, PackOffer{Count: p.Count, Price: price, Discount: p.Discount, CanAfford: s.Balance >= price})
}
}
out = append(out, o)
} }
return out return out
} }
@@ -200,7 +215,11 @@ func (p *Page) moonReveal() *MoonReveal {
rare, rareShare = w, share rare, rareShare = w, share
} }
} }
if rare.Resource != "" && rareShare <= 0.15 { switch {
case rare.Resource == "" || rareShare > 0.15:
case rareShare == 0: // bigger than every sampled well
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · under 1 in %d moons", rare.Resource, len(odds.values)))
default:
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare))) r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
} }
return r return r
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"strings"
"testing"
)
// A well bigger than every sampled one is the rarest, never "1 in 0 moons".
func TestRevealRareWellBeyondSample(t *testing.T) {
s := veteranState()
m := s.Moons[0]
p := s.page(View{Section: "moons", Moon: m.ID}.resolve(s))
if p.Moon == nil {
t.Fatal("no moon on the page")
}
big := p.Moon.GradeConfig().WellSupplyMax * 100
for i := range p.Moon.Wells {
p.Moon.Wells[i].Supply = big
}
found := false
for _, tr := range p.moonReveal().Traits {
if strings.Contains(tr, "1 in 0 ") {
t.Errorf("trait %q", tr)
}
if strings.HasPrefix(tr, "Rare: ") {
found = true
}
}
if !found {
t.Errorf("no rare trait for wells bigger than any sampled")
}
}
+4 -1
View File
@@ -146,7 +146,10 @@ func (s *server) apiRestore(w http.ResponseWriter, r *http.Request, code string)
restoreRefused(w, "That save code doesn't match any miner") restoreRefused(w, "That save code doesn't match any miner")
return "" return ""
} }
if isBanned(r.Context(), s.db, userID) { if banned, err := isBanned(r.Context(), s.db, userID); err != nil {
apiServerError(w, err)
return ""
} else if banned {
// (422, not 403: a client shows 403 as its own player suspended) // (422, not 403: a client shows 403 as its own player suspended)
restoreRefused(w, "That miner is suspended") restoreRefused(w, "That miner is suspended")
return "" return ""
-44
View File
@@ -1,44 +0,0 @@
// Checks that moondesc.go (resolveMoonLook) derives the same moon look as terrain.js
// (moonTraits) for fixed ids, with default and with custom settings. Run by `make check`.
import { execFileSync } from "node:child_process";
import { mergeSettings, moonTraits } from "../web/static/js/terrain.js";
const seeds = [
"00000000-0000-0000-0000-000000000000", "30f76186-83ff-4b26-a429-357bc1ee126f",
"6f1c2a9e-0b7d-4c1e-9a52-3d8e4f7a1b20", "a3b2c1d0-1111-4222-8333-944455556666",
"d9e8f7a6-b5c4-4d3e-8f2a-1b0c9d8e7f6a", "123e4567-e89b-12d3-a456-426614174000",
"f47ac10b-58cc-4372-a567-0e02b2c3d479", "9b2e5c3d-7a1f-4e8b-b6d4-2c0f1e3a5b7d",
"c56a4180-65aa-42ec-a945-5fd21dec0538", "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
"7d444840-9dc0-11d1-b245-5ffdce74fad2", "e902893a-9d22-3c7e-a7b8-d6e313b71d9f",
"b3d4e5f6-0718-4a2b-9c3d-4e5f60718293", "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
"0f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", "8a7b6c5d-4e3f-4a1b-9c8d-7e6f5a4b3c2d",
"2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "fedcba98-7654-4321-8fed-cba987654321",
"moon-lab", "üñí-çødé-🌑",
];
const custom = {
technique: "billow",
shading: { mid: "#3a5f8c" },
shape: { strength: 0.05 },
craters: { count: 90 },
variation: { hue: 40, saturation: 1.3, lightness: 0.3, shape: 0.9, craters: 0.2, technique: false },
};
let failed = 0;
for (const saved of [null, custom]) {
const settings = mergeSettings(saved);
const input = JSON.stringify({ settings: saved, seeds });
const go = JSON.parse(execFileSync("go", ["run", ".", "moon-traits"], { input, encoding: "utf8" }));
seeds.forEach((seed, i) => {
const js = moonTraits(settings, seed);
for (const [key, want] of Object.entries(js)) {
const got = go[i][key];
const same = typeof want === "number" ? Math.abs(got - want) <= 1e-12 * Math.max(1, Math.abs(want)) : got === want;
if (!same) {
failed++;
console.error(`moon traits differ for ${seed} (${saved ? "custom" : "default"} settings): ${key} js=${want} go=${got}`);
}
}
});
}
if (failed) process.exit(1);
console.log(`moon traits match for ${seeds.length} ids x 2 settings`);
-48
View File
@@ -1,48 +0,0 @@
// The Preact client's rule (AGENTS.md, "web/app"): pass signals to JSX as they are and
// read `.value` only in logic, computed() and effects. A `.value` read inside JSX makes the
// whole component re-render on every change instead of Preact updating one text node or
// attribute.
//
// Flags `.value` inside a JSX expression container in web/app/**/*.jsx (`>{x.value}`,
// `attr={x.value}`, a `{x.value …}` child on its own line), up to the first function in
// it: event handlers (`onClick={() => x.value++}`) are fine, `{xs.value.map(…)}` isn't.
// A guard rail, not a parser: it reads the source line by line, so keep JSX in .jsx files
// and Prettier's style (`attr={…}` without spaces; plain JS assignments as `x = {…}`).
//
// Usage: node scripts/check-signals-jsx.mjs <dir>
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const dir = process.argv[2] ?? "web/app";
// A container: opened right after a tag's `>`, an attribute's `=`, or at the start of a
// line (a child), up to its closing brace; nested `${…}` is allowed inside.
const container = /(?:>|=|^\s*)\{((?:[^{}]|\{[^{}]*\})*)\}?/g;
const read = /\.value\b(?!\s*=[^=])/;
function* files(d) {
for (const name of readdirSync(d)) {
const p = join(d, name);
if (name === "vendor") continue;
if (statSync(p).isDirectory()) yield* files(p);
else if (p.endsWith(".jsx")) yield p;
}
}
let bad = 0;
for (const file of files(dir)) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (/^\s*(\/\/|\*)/.test(line)) return;
for (const m of line.matchAll(container)) {
// What runs during render: the part before any function in it.
const body = m[1].split(/=>|\bfunction\b/)[0];
if (read.test(body)) {
console.error(`${file}:${i + 1}: .value read inside JSX; pass the signal, or read it in a computed: ${line.trim()}`);
bad++;
}
}
});
}
process.exit(bad ? 1 : 0);
+68 -188
View File
@@ -35,6 +35,10 @@ type server struct {
newPlayers *rateLimiter newPlayers *rateLimiter
// restores limits save code tries per IP (savecode.go). // restores limits save code tries per IP (savecode.go).
restores *rateLimiter restores *rateLimiter
// The admin login (admin.go): ADMIN_PASSWORD, signed-in sessions, tries per IP.
adminPassword string
admins adminSessions
logins *rateLimiter
} }
func serve(ctx context.Context) error { func serve(ctx context.Context) error {
@@ -48,7 +52,8 @@ func serve(ctx context.Context) error {
} }
go watchOverride(ctx, db) go watchOverride(ctx, db)
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5)} s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10), restores: newRateLimiter(5),
adminPassword: os.Getenv("ADMIN_PASSWORD"), logins: newRateLimiter(10)}
if s.dev { if s.dev {
s.static = os.DirFS("web/static") s.static = os.DirFS("web/static")
} else { } else {
@@ -63,31 +68,36 @@ func serve(ctx context.Context) error {
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.static, "favicon.ico") http.ServeFileFS(w, r, s.static, "favicon.ico")
}) })
mux.HandleFunc("GET /{$}", s.notBanned(s.handleIndex)) // The game's web build (client: make web) at /, from MM_WEB_DIR (default: the client's
mux.HandleFunc("POST /session", s.handleSession) // dist/web/MoonMiners beside this checkout); without one, / goes to the admin.
mux.HandleFunc("POST /act/{action}", s.notBanned(s.withUser(s.handleAction))) webDir := env("MM_WEB_DIR", "../client/dist/web/MoonMiners")
mux.HandleFunc("GET /utils", s.withUser(s.handleUtils)) if st, err := os.Stat(webDir + "/index.html"); err == nil && !st.IsDir() {
mux.HandleFunc("POST /utils/{action}", s.withUser(s.handleUtilsAction)) // "/", not "GET /": that would conflict with the method-less /utils patterns (panic)
mux.HandleFunc("GET /utils/moons", s.handleMoonsAdmin) mux.Handle("/", http.FileServerFS(os.DirFS(webDir)))
s.economyRoutes(mux) } else {
s.playerRoutes(mux) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /utils/liveops", s.handleLiveOps) http.Redirect(w, r, "/utils", http.StatusSeeOther)
mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction) })
mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction) }
mux.HandleFunc("GET /utils/login", s.handleLoginPage)
mux.HandleFunc("POST /utils/login", s.handleLogin)
mux.HandleFunc("POST /utils/logout", s.handleLogout)
// Every other /utils page and action needs an admin session (admin.go).
admin := s.adminRoutes()
mux.Handle("/utils", s.requireAdmin(admin))
mux.Handle("/utils/", s.requireAdmin(admin))
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings) mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings) mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard) mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState) mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction) mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
mux.HandleFunc("GET /api/save-code", s.handleAPISaveCode) mux.HandleFunc("GET /api/save-code", s.handleAPISaveCode)
// The Preact client (web/app, built into web/static/app), next to the htmx one.
mux.HandleFunc("GET /app", func(w http.ResponseWriter, r *http.Request) { s.render(w, "spa", nil) })
var handler http.Handler = mux var handler http.Handler = mux
if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev { if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
handler = delayGame(handler, time.Duration(ms)*time.Millisecond) handler = delayGame(handler, time.Duration(ms)*time.Millisecond)
} }
addr := ":" + env("PORT", "3000") addr := os.Getenv("HOST") + ":" + env("PORT", "3000") // HOST unset: every interface
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: logRequests(secureHeaders(limitBody(handler))), Handler: logRequests(secureHeaders(limitBody(handler))),
@@ -96,7 +106,11 @@ func serve(ctx context.Context) error {
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
// ListenAndServe returns as soon as Shutdown starts: wait for running handlers to
// finish (done) before returning, which closes the pool.
done := make(chan struct{})
go func() { go func() {
defer close(done)
<-ctx.Done() <-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@@ -106,6 +120,7 @@ func serve(ctx context.Context) error {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
<-done
return nil return nil
} }
@@ -132,13 +147,12 @@ func (s *server) render(w http.ResponseWriter, name string, data any) {
} }
} }
// delayGame holds every game request (the htmx page's swaps and actions, the JSON API) for // delayGame holds every game request (the JSON API's state and actions) for d before
// d before answering, to try the clients with a slow connection (MM_API_DELAY_MS, dev only). // answering, to try the client with a slow connection (MM_API_DELAY_MS, dev only).
func delayGame(next http.Handler, d time.Duration) http.Handler { func delayGame(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := r.URL.Path
if strings.HasPrefix(p, "/act/") || strings.HasPrefix(p, "/api/act/") || p == "/api/state" || if strings.HasPrefix(p, "/api/act/") || p == "/api/state" {
(p == "/" && r.Header.Get("HX-Request") == "true") {
time.Sleep(d) time.Sleep(d)
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -255,8 +269,9 @@ func clientIP(r *http.Request) string {
} }
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6 // normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback". // as its IPv4, no zone, every loopback address (127.0.0.0/8, ::1) as "loopback", and an
// Anything unparsable is kept as it is. // IPv6 address as its /64 (one client holds a whole /64). Anything unparsable is kept as
// it is.
func normalIP(s string) string { func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil { if host, _, err := net.SplitHostPort(s); err == nil {
s = host s = host
@@ -269,6 +284,9 @@ func normalIP(s string) string {
if ip.IsLoopback() { if ip.IsLoopback() {
return "loopback" return "loopback"
} }
if ip.Is6() {
return netip.PrefixFrom(ip, 64).Masked().String()
}
return ip.String() return ip.String()
} }
@@ -279,15 +297,6 @@ func serverError(w http.ResponseWriter, err error) {
// ------------------------------------------------------------------ players // ------------------------------------------------------------------ players
// currentUser returns the player id from the cookie, if it names an existing player.
func (s *server) currentUser(r *http.Request) string {
c, err := r.Cookie(userCookie)
if err != nil || !uuidPattern.MatchString(c.Value) || !userExists(r.Context(), s.db, c.Value) {
return ""
}
return c.Value
}
func setUserCookie(w http.ResponseWriter, userID string) { func setUserCookie(w http.ResponseWriter, userID string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: userCookie, Name: userCookie,
@@ -299,84 +308,13 @@ func setUserCookie(w http.ResponseWriter, userID string) {
}) })
} }
func (s *server) withUser(h func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := s.currentUser(r)
if userID == "" {
// The page reloads and the boot script signs the player in again.
w.Header().Set("HX-Refresh", "true")
http.Error(w, "Not signed in", http.StatusUnauthorized)
return
}
h(w, r, userID)
}
}
func newPlayerName() string { func newPlayerName() string {
return "Miner-" + randomHex(3) return "Miner-" + randomHex(3)
} }
// handleSession signs the browser in. Browsers from before the server-rendered UI keep // ------------------------------------------------------------------ actions
// their player id in localStorage and send it here; otherwise a new player is created.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
userID := r.FormValue("userId")
if !uuidPattern.MatchString(userID) || !userExists(r.Context(), s.db, userID) {
if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return
}
var err error
if userID, err = createUser(r.Context(), s.db, newPlayerName()); err != nil {
serverError(w, err)
return
}
}
setUserCookie(w, userID)
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------------------ game page // actionResult is what runAction did, for the API response.
// handleIndex renders the game. Plain requests get the whole page; htmx requests (links
// inside the page) get every region as an out-of-band swap.
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
htmx := r.Header.Get("HX-Request") == "true"
userID := s.currentUser(r)
if userID == "" {
if htmx {
w.Header().Set("HX-Refresh", "true")
return
}
s.render(w, "boot", nil)
return
}
// Opening a newly revealed tab stops its shine (onboarding.go).
if err := markSeen(r.Context(), s.db, userID, r.FormValue("s")); err != nil {
serverError(w, err)
return
}
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
if err != nil {
serverError(w, err)
return
}
page := state.page(v)
if err := s.loadRanking(r.Context(), page); err != nil {
serverError(w, err)
return
}
page.Banner = s.banner(r.Context())
page.Admin = s.isAdmin(userID)
if htmx {
page.OOB = true
s.render(w, "regions", page)
return
}
s.logSession(r.Context(), userID)
s.render(w, "page", page)
}
// actionResult is what runAction did, for the response (HTML regions or JSON).
type actionResult struct { type actionResult struct {
Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller) Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
Err error // a GameError: the action was refused or broke a rule Err error // a GameError: the action was refused or broke a rule
@@ -395,8 +333,8 @@ var errUnknownAction = errors.New("unknown action")
// errForbidden is runAction's error for an admin action run by a player who isn't one (403). // errForbidden is runAction's error for an admin action run by a player who isn't one (403).
var errForbidden = errors.New("missing permission") var errForbidden = errors.New("missing permission")
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads // runAction runs a game action (POST /api/act/<action>): its onboarding gate, then its rule
// the request's parameters, including the current view (s, moon, well, tab), which stays // in game.go. get reads the request's parameters, including the current view (s, moon, well, tab), which stays
// unless the action moves on. new-game only passes the gate; the caller creates the player. // unless the action moves on. new-game only passes the gate; the caller creates the player.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) { func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get) view := viewFromForm(get)
@@ -415,9 +353,18 @@ func (s *server) runAction(ctx context.Context, userID, action string, get func(
switch action { switch action {
case "refused": case "refused":
case "buy-moon": case "buy-moon":
var moonID string count := 1
if moonID, err = buyMoon(ctx, s.db, userID, get("grade")); err == nil { if c := get("count"); c != "" {
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID} if count, err = strconv.Atoi(c); err != nil {
count = 0 // (refused: no pack of that size)
}
}
var ids []string
if ids, err = buyMoons(ctx, s.db, userID, get("grade"), count); err == nil {
message, view = "New moon claimed!", View{Section: "moons", Moon: ids[0]}
if len(ids) > 1 {
message = fmt.Sprintf("%d new moons claimed!", len(ids))
}
} }
case "sell-moon": case "sell-moon":
var name string var name string
@@ -615,88 +562,21 @@ func (res actionResult) toast(state *State) *Toast {
return toast return toast
} }
// handleAction runs a game action and re-renders the screen.
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
if errors.Is(err, errUnknownAction) {
http.NotFound(w, r)
return
}
if errors.Is(err, errForbidden) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
if err != nil {
serverError(w, err)
return
}
if res.Action == "new-game" {
if !s.newPlayers.allow(r) {
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
return
}
newID, err := createUser(ctx, s.db, newPlayerName())
if err != nil {
serverError(w, err)
return
}
setUserCookie(w, newID)
w.Header().Set("HX-Redirect", "/")
return
}
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
serverError(w, err)
return
}
page.Banner = s.banner(ctx)
page.Admin = s.isAdmin(userID)
page.OOB = true
page.Toast = toast
if res.BoughtTool != "" {
page.Modal = state.buySuggestions(res.BoughtTool)
}
if res.Action == "buy-moon" && !toast.Error {
page.Reveal = page.moonReveal()
}
s.render(w, "regions", page)
}
// ------------------------------------------------------------------ admin // ------------------------------------------------------------------ admin
// handleUtils is the admin hub; it opens the Players page (players.go). // adminRoutes are the admin pages and actions, behind requireAdmin.
func (s *server) handleUtils(w http.ResponseWriter, r *http.Request, userID string) { func (s *server) adminRoutes() *http.ServeMux {
http.Redirect(w, r, "/utils/players", http.StatusSeeOther) mux := http.NewServeMux()
} // The admin hub opens the Players page (players.go).
mux.HandleFunc("GET /utils", func(w http.ResponseWriter, r *http.Request) {
func (s *server) handleUtilsAction(w http.ResponseWriter, r *http.Request, userID string) { http.Redirect(w, r, "/utils/players", http.StatusSeeOther)
if !s.isAdmin(userID) { })
http.Error(w, "Missing permission", http.StatusForbidden) s.economyRoutes(mux)
return s.playerRoutes(mux)
} mux.HandleFunc("GET /utils/liveops", s.handleLiveOps)
var err error mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction)
switch r.PathValue("action") { mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction)
case "clear-data": return mux
err = clearUserData(r.Context(), s.db, userID)
case "load-config":
err = syncStoreItems(r.Context(), s.db)
default:
http.NotFound(w, r)
return
}
if err != nil {
serverError(w, err)
return
}
fmt.Fprint(w, "<p>Done.</p>")
} }
// loadRanking fills in the leaderboard section, the one section read from other players. // loadRanking fills in the leaderboard section, the one section read from other players.
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// The new-player limit keys IPv6 clients by their /64, IPv4 by the address.
func TestNormalIP(t *testing.T) {
for in, want := range map[string]string{
"203.0.113.7:4000": "203.0.113.7",
"[::ffff:203.0.113.7]:4000": "203.0.113.7",
"[2001:db8:1:2:aaaa::1]:4000": "2001:db8:1:2::/64",
"2001:db8:1:2:bbbb:cccc::9": "2001:db8:1:2::/64",
"[fe80::1%eth0]:80": "fe80::/64",
"[::1]:80": "loopback",
"127.0.0.2": "loopback",
"not an address": "not an address",
} {
if got := normalIP(in); got != want {
t.Errorf("normalIP(%q) = %q, want %q", in, got, want)
}
}
}
// When the database can't say whether the player exists, apiUser answers 500 and makes no
// new player (the client would save its id over the real one).
func TestAPIUserDatabaseDown(t *testing.T) {
pool, err := pgxpool.New(context.Background(), "postgres://nobody@127.0.0.1:1/none?connect_timeout=1")
if err != nil {
t.Fatal(err)
}
defer pool.Close()
s := &server{db: pool, newPlayers: newRateLimiter(10)}
r := httptest.NewRequest("GET", "/api/state", nil)
r.Header.Set(userHeader, testID("u", 1))
w := httptest.NewRecorder()
if id, created := s.apiUser(w, r); id != "" || created {
t.Fatalf("apiUser = %q, %v; want no player", id, created)
}
if w.Code != http.StatusInternalServerError || w.Header().Get(userHeader) != "" {
t.Fatalf("status %d, %s %q; want 500 and no id", w.Code, userHeader, w.Header().Get(userHeader))
}
}
+12 -3
View File
@@ -139,6 +139,7 @@ type Analysis struct {
Tools []ToolStat Tools []ToolStat
Refining *RefineryStats Refining *RefineryStats
Sim *SimResult Sim *SimResult
Progress *Progression // charts, saved config against this one (the handler sets it)
// Flags are the warnings by row, for the editor grid: "item:<id>" or "refined:<name>". // Flags are the warnings by row, for the editor grid: "item:<id>" or "refined:<name>".
Flags map[string][]string Flags map[string][]string
} }
@@ -485,6 +486,7 @@ func simulate(c *GameConfig) *SimResult {
var wells []*simMoon // unreserved supply by moon var wells []*simMoon // unreserved supply by moon
left := 0.0 // total unreserved supply left := 0.0 // total unreserved supply
moons := 0 moons := 0
top := 0 // the dearest grade bought
for _, id := range c.StartingTools { for _, id := range c.StartingTools {
if i, ok := itemIndex[id]; ok { if i, ok := itemIndex[id]; ok {
tools = append(tools, simTool{item: i}) tools = append(tools, simTool{item: i})
@@ -581,6 +583,7 @@ func simulate(c *GameConfig) *SimResult {
break break
} }
credits -= moonPrice credits -= moonPrice
top = max(top, c.gradeIndex(g.Grade))
// Wells in units at the average price, which is what the sim sells at. // Wells in units at the average price, which is what the sim sells at.
kind := c.Moons[rng.IntN(len(c.Moons))] kind := c.Moons[rng.IntN(len(c.Moons))]
var moon []float64 var moon []float64
@@ -604,7 +607,11 @@ func simulate(c *GameConfig) *SimResult {
// either track for one owned tool of each kind; or an advisor. // either track for one owned tool of each kind; or an advisor.
now := income(advisors) now := income(advisors)
breakEven := func(cost, gain float64) Hours { breakEven := func(cost, gain float64) Hours {
return hours(max(0, cost-credits), now) + hours(cost, gain) wait := Hours(0) // what the player can pay now needs no income
if cost > credits {
wait = hours(cost-credits, now)
}
return wait + hours(cost, gain)
} }
type choice struct { type choice struct {
kind string // tool, upgrade, advisor kind string // tool, upgrade, advisor
@@ -660,8 +667,10 @@ func simulate(c *GameConfig) *SimResult {
} }
consider(ch, income(append(slices.Clone(advisors), a.Name))-now) consider(ch, income(append(slices.Clone(advisors), a.Name))-now)
} }
// Keep enough for the next moon: with dry wells and no credits, nothing earns. // Keep enough for the next moon: with dry wells and no credits, nothing earns. The
if best.kind == "" || credits-best.cost < nextGrade(credits).Price { // reserve is the dearest grade bought, not one picked from credits, which every
// purchase would lower.
if best.kind == "" || credits-best.cost < c.MoonGrades[top].Price {
break break
} }
credits -= best.cost credits -= best.cost
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"maps"
"strings"
"testing"
)
// With no starting tools there is no income, but the player can still buy a drill.
func TestSimBuysWithoutIncome(t *testing.T) {
c := baseConfig()
c.StartingTools = nil
c.StartingCredits = 2000 // a D moon, a PD-1 and the reserve for the next moon
s := simulate(&c)
n := 0
for _, tl := range s.Tools {
n += tl.Count
}
if n == 0 {
t.Fatalf("the sim player bought no drill: %v", s.Events)
}
}
// The sims read the first store item: a config needs one.
func TestValidateNeedsStoreItems(t *testing.T) {
c := baseConfig()
c.StoreItems, c.StartingTools = nil, nil
if err := c.validate(); err == nil {
t.Fatal("a config with no store items passed")
}
}
// The moon reserve doesn't shrink as the player spends: with credits for two B moons the
// player buys a B moon and keeps its price, rather than spending down to a cheaper moon's
// at once.
func TestSimReserveHolds(t *testing.T) {
c := baseConfig()
c.StartingCredits = 2 * c.MoonGrades[c.gradeIndex("B")].Price
s := simulate(&c)
for _, e := range s.Events[1:] {
if strings.HasPrefix(e, SimTime(0).String()) {
t.Fatalf("bought at once after the first moon: %v", s.Events[:5])
}
}
}
// The clicker sim player keeps the dearest moon grade it reached as its reserve, so it
// doesn't spend down and value its drills on a cheaper moon later.
func TestGradeSimGradeHolds(t *testing.T) {
c := baseConfig()
stats := gradeStats(&c)
for _, pl := range gsPlayers {
r := simulatePlayer(&c, pl, stats)
top := 0
for _, row := range r.Rows {
g := c.gradeIndex(row.Grade)
if g < top {
t.Fatalf("%s player: grade %s at %s, after %s", pl.Name, row.Grade, row.At, c.MoonGrades[top].Grade)
}
top = g
}
}
}
// Wells are stored by resource name, so the config can't price a resource the database
// doesn't have.
func TestValidateResourceNames(t *testing.T) {
c := baseConfig()
c.ResourcePrices = maps.Clone(c.ResourcePrices)
c.ResourcePrices["Lunarite"] = 30
if err := c.validate(); err == nil {
t.Fatal("a config with an unknown resource passed")
}
}
@@ -0,0 +1,8 @@
-- Indexes for the per-request state reads (loadState, loadPastPage) and the stake and
-- claim checks: without them each request scanned these tables for every player.
create index if not exists staking_source_user on staking_source (user_id);
create index if not exists resource_well_source on resource_well (source_id);
create index if not exists inventory_item_user on inventory_item (user_id);
create index if not exists staking_event_user on staking_event (user_id, created_at);
create index if not exists staking_event_well on staking_event (well_id);
create index if not exists staking_event_tool on staking_event (inventory_item_id, created_at);
+6
View File
@@ -41,6 +41,7 @@ create table staking_source (
constraint fk_user_staking_source foreign key(user_id) constraint fk_user_staking_source foreign key(user_id)
references users(id) on delete cascade references users(id) on delete cascade
); );
create index staking_source_user on staking_source (user_id);
create table resource_well ( create table resource_well (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -52,6 +53,7 @@ create table resource_well (
constraint fk_rid_resource_well foreign key(resource_id) constraint fk_rid_resource_well foreign key(resource_id)
references resource(id) references resource(id)
); );
create index resource_well_source on resource_well (source_id);
create table inventory_item ( create table inventory_item (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -68,6 +70,7 @@ create table inventory_item (
constraint fk_store_item_inventory_item foreign key(store_item_id) constraint fk_store_item_inventory_item foreign key(store_item_id)
references store_item(id) on delete cascade references store_item(id) on delete cascade
); );
create index inventory_item_user on inventory_item (user_id);
create table upgrade_event ( create table upgrade_event (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
@@ -95,6 +98,9 @@ create table staking_event (
constraint fk_iiid_staking_event foreign key(inventory_item_id) constraint fk_iiid_staking_event foreign key(inventory_item_id)
references inventory_item(id) on delete cascade references inventory_item(id) on delete cascade
); );
create index staking_event_user on staking_event (user_id, created_at);
create index staking_event_well on staking_event (well_id);
create index staking_event_tool on staking_event (inventory_item_id, created_at);
create table claim_event ( create table claim_event (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
+8 -46
View File
@@ -1,9 +1,10 @@
package main package main
// Template check (make check): parses the embedded templates the way the server does, fails // Template check (make check): parses the embedded templates (the admin pages) the way the
// on a {{template "x"}} that names no template, and renders every page and region against // server does, fails on a {{template "x"}} that names no template, and renders every page
// in-memory game states (no database) so missing fields, methods and functions show up // and region against in-memory states (no database) so missing fields, methods and
// here instead of in the live game. Run with `go test -run Templates`. // functions show up here instead of in production. Run with `go test -run Templates`.
// The game states below are also used by api_test.go.
import ( import (
"fmt" "fmt"
@@ -30,51 +31,14 @@ func TestTemplates(t *testing.T) {
} }
} }
unlocked := newPlayerState() // everything open but no moon yet // The admin pages, with and without permission.
unlocked.Onboarding = []string{"all"}
early := veteranState() // Equipment open, the Store not yet (a drill panel without its Store parts)
early.Onboarding = []string{"equipment", "market"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState(), early} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
v = v.resolve(s)
p := s.page(v)
p.Admin = len(s.Tools) > 0 // the grant dialog and button, for some states
if v.Section == "leaderboard" {
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
}
render("page", p)
// An action response: every region out of band, with a toast, the buy modal
// and the moon reveal.
p.OOB = true
p.Banner = []BannerItem{{Level: "event", Text: "Double yield", Left: "3h left"}}
p.Toast = &Toast{Message: "Claimed", Kind: "claim", Finished: "m", Credits: 12}
if len(s.Tools) > 0 {
p.Modal = s.buySuggestions(s.Tools[0].ID)
}
p.Reveal = p.moonReveal()
render("regions", p)
}
// The buy modal with no moons or only empty wells.
s.pagePastInMemory(1)
p := s.page(View{Section: "store"}.resolve(s))
p.OOB = true
p.Modal = &BuyModal{Item: &s.Config.StoreItems[0], NoMoons: true, AllEmpty: true}
render("regions", p)
}
// The Preact client's page (/app).
render("spa", nil)
// Admin pages and the pages outside the game, with and without permission.
s := veteranState() s := veteranState()
now := time.Now() now := time.Now()
player := PlayerRow{ID: s.UserID, Name: "Test Miner", CreatedAt: now, LastSeen: &now, BannedAt: &now} player := PlayerRow{ID: s.UserID, Name: "Test Miner", CreatedAt: now, LastSeen: &now, BannedAt: &now}
events := EventsPage{PlayerID: s.UserID, Events: []PlayerEvent{{ID: 1, Kind: "claim", Data: `{"credits": 1}`, At: now}, events := EventsPage{PlayerID: s.UserID, Events: []PlayerEvent{{ID: 1, Kind: "claim", Data: `{"credits": 1}`, At: now},
{ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true} {ID: 2, Kind: "admin_grant", Data: "{}", At: now}}, Page: 1, More: true}
stake := s.Stakes[0] stake := s.Stakes[0]
playerPage := &PlayerPage{Allowed: true, Self: true, P: player, State: s, Running: 1, Events: events, playerPage := &PlayerPage{Allowed: true, P: player, State: s, Running: 1, Events: events,
Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], Max: Levels{4, 5}, Stake: &stake, Tools: []PlayerTool{{Tool: s.Tools[0], Item: &s.Config.StoreItems[0], Max: Levels{4, 5}, Stake: &stake,
MoonName: "Moon", Remaining: time.Minute}, {Tool: s.Tools[1], Item: &s.Config.StoreItems[1]}}, MoonName: "Moon", Remaining: time.Minute}, {Tool: s.Tools[1], Item: &s.Config.StoreItems[1]}},
StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true} StoreItems: s.Config.StoreItems, Grantable: []string{"Sollux"}, Flash: "Done", FlashError: true}
@@ -94,9 +58,6 @@ func TestTemplates(t *testing.T) {
name string name string
allowed, none any allowed, none any
}{ }{
{"boot", nil, nil},
{"suspended", nil, nil},
{"moons-admin", map[string]any{"Allowed": true}, map[string]any{"Allowed": false}},
{"players", playersPage, &PlayersPage{}}, {"players", playersPage, &PlayersPage{}},
{"pl-list", playersPage, &PlayersPage{}}, {"pl-list", playersPage, &PlayersPage{}},
{"player", playerPage, &PlayerPage{}}, {"player", playerPage, &PlayerPage{}},
@@ -107,6 +68,7 @@ func TestTemplates(t *testing.T) {
{"lo-announcements", liveOps, &LiveOps{}}, {"lo-announcements", liveOps, &LiveOps{}},
{"economy", economy, &EconomyPage{}}, {"economy", economy, &EconomyPage{}},
{"economy-analysis", analyze(cfg(), nil, 2), analyze(cfg(), []string{"bad config"}, 0)}, {"economy-analysis", analyze(cfg(), nil, 2), analyze(cfg(), []string{"bad config"}, 0)},
{"login", &LoginPage{Error: "Wrong password.", Next: "/utils"}, &LoginPage{Locked: true}},
} { } {
render(page.name, page.allowed) render(page.name, page.allowed)
render(page.name, page.none) render(page.name, page.none)
+74 -87
View File
@@ -1,68 +1,55 @@
# Endpoints, in Emacs restclient format. The player is identified by the mm_user cookie; # Endpoints, in Emacs restclient format. The game is the JSON API (/api/, api.md): the player
# set :user_id to a player id. Game endpoints return HTML fragments for htmx. # is identified by X-MM-User (or the mm_user cookie); set :user_id to a player id. The admin
# pages (/utils/) answer HTML (fragments with HX-Request: true).
:user_id = 595ab570-fa74-4c6c-980e-4c80d1064dd1 :user_id = 595ab570-fa74-4c6c-980e-4c80d1064dd1
:host = http://localhost:3000 :host = http://localhost:3000
:api = <<
Content-Type: application/x-www-form-urlencoded
X-MM-User: :user_id
#
:admin_session = <the mm_admin cookie from POST /utils/login>
:headers = << :headers = <<
Content-Type: application/x-www-form-urlencoded Content-Type: application/x-www-form-urlencoded
Cookie: mm_user=:user_id Cookie: mm_admin=:admin_session
HX-Request: true HX-Request: true
# #
# Sign in as an existing player (or create a new one if userId is missing/unknown) # Game actions (JSON API, api.md): the player's view goes along as s, moon, well, drill, pick,
POST :host/session # list, page, adv (s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon).
Content-Type: application/x-www-form-urlencoded # Answers {ok, error?, toast, fx?, state}.
userId=:user_id
# A view: s=moons|equipment|market|store|refinery|advisors|leaderboard|help|new-moon, moon=<id>, well=<id>,
# drill=<tool id> (equipment: the tool's panel, its upgrade tracks),
# pick=<resource or refined item> (market: the selected quote), list=past (moons: the Past tab,
# the only way a mined-out moon shows), page=<n> (with list=past: 10 past moons a page, 1 the
# most recent), adv=<advisor name> (advisors: a hired advisor's panel)
GET :host/?s=store
:headers
# The Preact client (web/app): a page that runs on the JSON API below; same view params
GET :host/app?s=moons
GET :host/?list=past&page=2
:headers
GET :host/?s=advisors&adv=Ada+Voss
:headers
# Buy a moon # Buy a moon
POST :host/act/buy-moon POST :host/api/act/buy-moon
:headers :api
# Buy a moon of a grade (D, C, B, A, S; without one: the newest moon's grade) # Buy a moon of a grade (D, C, B, A, S; without one: the newest moon's grade)
POST :host/act/buy-moon POST :host/api/act/buy-moon
:headers :api
grade=B grade=B
# Sell a moon (not the last one, not right after buying it, no running drills on it) # Sell a moon (not the last one, not right after buying it, no running drills on it)
POST :host/act/sell-moon POST :host/api/act/sell-moon
:headers :api
sell-moon=<moon id> sell-moon=<moon id>
# Sell an idle tool (not the only one) # Sell an idle tool (not the only one)
POST :host/act/sell-tool POST :host/api/act/sell-tool
:headers :api
tool=<tool id> tool=<tool id>
# Buy a tool # Buy a tool
POST :host/act/buy-tool POST :host/api/act/buy-tool
:headers :api
id=1 id=1
# Upgrade a tool: one level on its yield or cycle track (track=yield|cycle) # Upgrade a tool: one level on its yield or cycle track (track=yield|cycle)
POST :host/act/upgrade POST :host/api/act/upgrade
:headers :api
tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield tool=2bd5f846-ce1d-45be-998e-26d2e97b7f68&track=yield
# Deploy a tool on a well # Deploy a tool on a well
POST :host/act/stake POST :host/api/act/stake
:headers :api
tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Claim finished drills on a well (claim-well), a moon (claim-moon) or everywhere (neither). # Claim finished drills on a well (claim-well), a moon (claim-moon) or everywhere (neither).
@@ -70,88 +57,88 @@ tool=1db181dd-320f-4525-a00d-933e5d72d690&target=0a7fd54d-02cf-4c08-b106-6f812ad
# drills that mine several of its wells (reach). # drills that mine several of its wells (reach).
# redeploy=1 puts the tools straight back to work on the same well (drills on a drained well # redeploy=1 puts the tools straight back to work on the same well (drills on a drained well
# stay idle; the toast says how many; a multi-well drill moves to the moon's fullest well). # stay idle; the toast says how many; a multi-well drill moves to the moon's fullest well).
POST :host/act/claim POST :host/api/act/claim
:headers :api
claim-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca&redeploy=1 claim-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca&redeploy=1
# Put the idle tools whose last drill was on a well back to work on it # Put the idle tools whose last drill was on a well back to work on it
POST :host/act/redrill POST :host/api/act/redrill
:headers :api
redrill-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca redrill-well=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Sell a resource at the current market price (all=1 sells the whole balance; pick keeps the # Sell a resource at the current market price (all=1 sells the whole balance; pick keeps the
# market selection) # market selection)
POST :host/act/sell POST :host/api/act/sell
:headers :api
resource=Sollux&amount=10 resource=Sollux&amount=10
# Sell a refined item (same action; all=1 sells the whole balance) # Sell a refined item (same action; all=1 sells the whole balance)
POST :host/act/sell POST :host/api/act/sell
:headers :api
resource=Dusk+Alloy&amount=1 resource=Dusk+Alloy&amount=1
# Refinery: start a batch in the lowest free slot (recipe = refined item name; all=1 makes # Refinery: start a batch in the lowest free slot (recipe = refined item name; all=1 makes
# as many as the inputs allow). Inputs are taken now; collect when it finishes. # as many as the inputs allow). Inputs are taken now; collect when it finishes.
POST :host/act/refine POST :host/api/act/refine
:headers :api
recipe=Dusk+Alloy&qty=3 recipe=Dusk+Alloy&qty=3
# Refinery: collect one finished batch (job id), or every finished one without job # Refinery: collect one finished batch (job id), or every finished one without job
POST :host/act/collect POST :host/api/act/collect
:headers :api
job=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca job=0a7fd54d-02cf-4c08-b106-6f812ad4b1ca
# Refinery: buy the next slot (prices in config refinery.slotPrices) # Refinery: buy the next slot (prices in config refinery.slotPrices)
POST :host/act/buy-slot POST :host/api/act/buy-slot
:headers :api
# Raise the refinery's batch size to the next upgrade (refinery.batchUpgrades) # Raise the refinery's batch size to the next upgrade (refinery.batchUpgrades)
POST :host/act/upgrade-batch POST :host/api/act/upgrade-batch
:headers :api
# Hire an advisor (config name) into a free seat # Hire an advisor (config name) into a free seat
POST :host/act/hire POST :host/api/act/hire
:headers :api
advisor=Mira+Castellanos advisor=Mira+Castellanos
# Buy the next advisor seat (prices in config advisors.seatPrices) # Buy the next advisor seat (prices in config advisors.seatPrices)
POST :host/act/buy-seat POST :host/api/act/buy-seat
:headers :api
# Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere) # Put a hired advisor in an empty seat (seat from 1; moves them if seated elsewhere)
POST :host/act/seat-advisor POST :host/api/act/seat-advisor
:headers :api
advisor=Mira+Castellanos&seat=2 advisor=Mira+Castellanos&seat=2
# Take a seated advisor off their seat (stays hired, no bonus) # Take a seated advisor off their seat (stays hired, no bonus)
POST :host/act/unseat-advisor POST :host/api/act/unseat-advisor
:headers :api
advisor=Mira+Castellanos advisor=Mira+Castellanos
# Swap two hired advisors' seats (one may be unseated) # Swap two hired advisors' seats (one may be unseated)
POST :host/act/swap-advisors POST :host/api/act/swap-advisors
:headers :api
advisor=Mira+Castellanos&with=Ada+Voss advisor=Mira+Castellanos&with=Ada+Voss
# Dismiss a hired advisor (frees their seat, nothing refunded) # Dismiss a hired advisor (frees their seat, nothing refunded)
POST :host/act/dismiss-advisor POST :host/api/act/dismiss-advisor
:headers :api
advisor=Mira+Castellanos advisor=Mira+Castellanos
# Start over as a new player (sets a new cookie) # Start over as a new player (a new id in X-MM-User)
POST :host/act/new-game POST :host/api/act/new-game
:headers :api
# Leaderboard (JSON): [{"name", "balance"}], richest first # Leaderboard (JSON): [{"name", "balance"}], richest first
GET :host/api/leaderboard?limit=10 GET :host/api/leaderboard?limit=10
# JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie); # JSON API for game clients (api.md). The player id goes in X-MM-User (or the mm_user cookie);
# without a known one a player is created and its id comes back in X-MM-User and "userId". # without a known one a player is created and its id comes back in X-MM-User and "userId".
# Game state for a view (same params as GET /): {ok, userId, newPlayer?, state} # Game state for a view (the view params above): {ok, userId, newPlayer?, state}
GET :host/api/state?moon=<moon id>&well=<well id> GET :host/api/state?moon=<moon id>&well=<well id>
X-MM-User: :user_id X-MM-User: :user_id
# Run an action (any /act/<action> above, same params, form or JSON body): # Actions take a form or JSON body:
# {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state # {ok, error?, toast, fx?, state}; a rule violation is a 422 with the state
POST :host/api/act/stake POST :host/api/act/stake
X-MM-User: :user_id X-MM-User: :user_id
@@ -159,7 +146,7 @@ Content-Type: application/json
{"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"} {"tool": "<tool id>", "target": "<well id>", "moon": "<moon id>", "well": "<well id>"}
# Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as for /act/claim) # Claim every finished drill (claim-well, claim-moon, claim-wide, redeploy as above)
POST :host/api/act/claim POST :host/api/act/claim
X-MM-User: :user_id X-MM-User: :user_id
Content-Type: application/x-www-form-urlencoded Content-Type: application/x-www-form-urlencoded
@@ -277,13 +264,16 @@ Content-Type: application/json
{"sell-moon": "<moon id>", "moon": "<moon id>"} {"sell-moon": "<moon id>", "moon": "<moon id>"}
# Admin: sync the store items in the database with the game config (admin players only) # Admin login (ADMIN_PASSWORD): sets the mm_admin cookie and redirects to next (a /utils page).
POST :host/utils/load-config # Without a session, /utils pages redirect here (actions get 401); with MM_DEV=1 no login.
:headers POST :host/utils/login
Content-Type: application/x-www-form-urlencoded
# Admin: reset your own progress password=<ADMIN_PASSWORD>&next=/utils/players
POST :host/utils/clear-data
:headers # Admin logout
POST :host/utils/logout
Cookie: mm_admin=:admin_session
# Admin hub: redirects to the Players page # Admin hub: redirects to the Players page
GET :host/utils GET :host/utils
@@ -339,23 +329,20 @@ POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/ban
POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/unban POST :host/utils/players/30f76186-83ff-4b26-a429-357bc1ee126f/unban
:headers :headers
# Admin: moon lab page, a live editor for the procedural moon settings (admins or MM_DEV=1) # Moon settings (JSON, null if none); the Defold client merges them over its defaults
GET :host/utils/moons
:headers
# Moon settings saved from the moon lab (JSON, null if none); the game merges them over its defaults
GET :host/api/moon-settings GET :host/api/moon-settings
# Admin: save moon settings (a JSON object; see DEFAULTS in web/static/js/terrain.js) # Admin: save moon settings (a JSON object; see DEFAULTS in client/modules/moon/terrain.lua).
# Allowed with the admin cookie, an X-MM-User in ADMIN_USER_IDS, or MM_DEV=1.
POST :host/api/moon-settings POST :host/api/moon-settings
Content-Type: application/json Content-Type: application/json
Cookie: mm_user=:user_id X-MM-User: :user_id
{"craters": {"count": 400}} {"craters": {"count": 400}}
# Admin: live ops page (dashboard, timed events, announcements; HTML) # Admin: live ops page (dashboard, timed events, announcements; HTML)
GET :host/utils/liveops GET :host/utils/liveops
Cookie: mm_user=:user_id Cookie: mm_admin=:admin_session
# Admin: create (or, with id, update) a timed event. kind: yield_multiplier | # Admin: create (or, with id, update) a timed event. kind: yield_multiplier |
# price_multiplier | cycle_multiplier; resource: a resource name or all; times are UTC. # price_multiplier | cycle_multiplier; resource: a resource name or all; times are UTC.
+5187
View File
File diff suppressed because it is too large. Load diff
+23 -22
View File
@@ -256,8 +256,7 @@ type TrackRow struct {
Short string // "Need ₥X more" when credits are short for it Short string // "Need ₥X more" when credits are short for it
RateFrom float64 // credits per hour (creditsPerHour) now and after the next level RateFrom float64 // credits per hour (creditsPerHour) now and after the next level
RateTo float64 RateTo float64
Cost float64 // the next level's price plus its parts at sell value Cost float64 // the next level's price plus its parts at sell value
Ladder []TrackStep // every level, the other track as it is
} }
// TrackStep is one level of a track, for the drill panel's ladder. // TrackStep is one level of a track, for the drill panel's ladder.
@@ -270,20 +269,9 @@ type TrackStep struct {
Current, Next bool Current, Next bool
} }
// Cycle is a cycle track.
func (r TrackRow) Cycle() bool { return r.Kind == "cycle" }
// RateGain is what the next level adds per hour. // RateGain is what the next level adds per hour.
func (r TrackRow) RateGain() float64 { return r.RateTo - r.RateFrom } func (r TrackRow) RateGain() float64 { return r.RateTo - r.RateFrom }
// Payback is how long the next level's extra income takes to repay its cost, "" if never.
func (r TrackRow) Payback() string {
if gain := r.RateGain(); gain > 0 {
return formatSpan(r.Cost / gain * 3600)
}
return ""
}
// Label is the button's text: "Twin rotors 2" (the level it buys). // Label is the button's text: "Twin rotors 2" (the level it buys).
func (r TrackRow) Label() string { return fmt.Sprintf("%s %d", r.Name, r.Level+1) } func (r TrackRow) Label() string { return fmt.Sprintf("%s %d", r.Name, r.Level+1) }
@@ -297,7 +285,7 @@ func (s *State) trackValue(item *StoreItem, lv Levels, kind string) (value, rate
return yield, rate return yield, rate
} }
// trackRows are the tool's two tracks with their next levels and ladders. // trackRows are the tool's two tracks with their next levels (ladders: trackLadder).
func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow { func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
var rows []TrackRow var rows []TrackRow
lv := t.Levels() lv := t.Levels()
@@ -308,14 +296,6 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
} }
r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)} r := TrackRow{Kind: kind, Name: tr.Name, Level: lv.Of(kind), Max: len(tr.Levels)}
r.Value, r.RateFrom = s.trackValue(item, lv, kind) r.Value, r.RateFrom = s.trackValue(item, lv, kind)
for n := 0; n <= r.Max; n++ {
st := TrackStep{Level: n, Current: n == r.Level, Next: n == r.Level+1}
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
if l := item.TrackLevel(kind, n); l != nil {
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
}
r.Ladder = append(r.Ladder, st)
}
if next := item.TrackLevel(kind, r.Level+1); next != nil { if next := item.TrackLevel(kind, r.Level+1); next != nil {
r.Next = next r.Next = next
r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind) r.NextValue, r.RateTo = s.trackValue(item, lv.With(kind, r.Level+1), kind)
@@ -339,6 +319,27 @@ func (s *State) trackRows(t Tool, item *StoreItem) []TrackRow {
return rows return rows
} }
// trackLadder is every level of the item's track (by kind) at the levels lv, the other
// track as it is: the same for every tool of that item at those levels (the API sends it
// once per item and levels, api_sections.go ladderKey).
func (s *State) trackLadder(item *StoreItem, lv Levels, kind string) []TrackStep {
var out []TrackStep
tr := item.Track(kind)
if tr == nil {
return out
}
level := lv.Of(kind)
for n := 0; n <= len(tr.Levels); n++ {
st := TrackStep{Level: n, Current: n == level, Next: n == level+1}
st.Value, st.Rate = s.trackValue(item, lv.With(kind, n), kind)
if l := item.TrackLevel(kind, n); l != nil {
st.Price, st.Parts = l.Price, s.partRows(l.Parts)
}
out = append(out, st)
}
return out
}
// upgradedText names what a tool's latest upgrade on the track gave, for the toast. // upgradedText names what a tool's latest upgrade on the track gave, for the toast.
func (s *State) upgradedText(t Tool, kind string) string { func (s *State) upgradedText(t Tool, kind string) string {
item := s.StoreItem(t.StoreItemID) item := s.StoreItem(t.StoreItemID)
+32 -283
View File
@@ -3,9 +3,7 @@ package main
// View models for the templates (web/templates/*.html). // View models for the templates (web/templates/*.html).
import ( import (
"encoding/json"
"fmt" "fmt"
"hash/fnv"
"html/template" "html/template"
"math" "math"
"net/url" "net/url"
@@ -32,22 +30,11 @@ var resourceColors = map[string]string{
// colorPattern is a CSS hex color, the only form config colors may take. // colorPattern is a CSS hex color, the only form config colors may take.
var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`) var colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{3,8}$`)
// templateFuncs are the admin templates' functions.
var templateFuncs = template.FuncMap{ var templateFuncs = template.FuncMap{
"money": formatMoney, "money": formatMoney,
"cur": formatCredits, "cur": formatCredits,
// short is the tooltip on a button the player can't afford.
"short": func(balance, price float64) string { return shortBy(price - balance) },
"mins": func(m int) string { return formatDuration(time.Duration(m) * time.Minute) },
"secs": secsText, "secs": secsText,
// A track's value (tracks.go): a cycle ("2.6s") or a yield per cycle ("+5").
"trackval": func(cycle bool, v float64) string {
if cycle {
return formatCycle(v)
}
return "+" + formatMoney(v)
},
"curk": formatCompact,
"wells": wellsText,
"dur": formatDuration, "dur": formatDuration,
"color": func(resource string) template.CSS { "color": func(resource string) template.CSS {
c, ok := resourceColors[resource] c, ok := resourceColors[resource]
@@ -60,30 +47,7 @@ var templateFuncs = template.FuncMap{
} }
return template.CSS("background:" + c) return template.CSS("background:" + c)
}, },
// planet is the inline style for a moon thumbnail: a hue derived from its id.
"planet": func(id string) template.CSS {
h := fnv.New32a()
h.Write([]byte(id))
return template.CSS(fmt.Sprintf("--hue:%d", h.Sum32()%360))
},
// pct is value/total as a CSS width, capped at 100%.
"pct": func(value, total float64) template.CSS {
if total <= 0 {
return "width:0%"
}
return template.CSS(fmt.Sprintf("width:%.0f%%", math.Min(100, value/total*100)))
},
"plural": plural, "plural": plural,
// vals builds an hx-vals JSON object from key/value pairs.
"vals": func(pairs ...string) string {
m := map[string]string{}
for i := 0; i+1 < len(pairs); i += 2 {
m[pairs[i]] = pairs[i+1]
}
b, _ := json.Marshal(m)
return string(b)
},
"str": func(v any) string { return fmt.Sprint(v) },
} }
// plural returns e.g. "1 drill" or "3 drills". // plural returns e.g. "1 drill" or "3 drills".
@@ -261,54 +225,13 @@ func (v View) URL() string {
return "/?" + q.Encode() return "/?" + q.Encode()
} }
// Links to related views, for the templates.
func (v View) ToSection(s string) string {
return View{Section: s, Moon: v.Moon}.URL()
}
func (v View) ToMoon(id string) string {
return View{Section: "moons", Moon: id}.URL()
}
// ToPastMoon opens a finished moon from the Past tab (on the current page of it).
func (v View) ToPastMoon(id string) string {
return View{Section: "moons", Moon: id, Past: true, Page: v.Page}.URL()
}
func (v View) ToWell(id string) string {
return View{Section: "moons", Moon: v.Moon, Well: id}.URL()
}
// ToAdvisor opens a hired advisor's panel in the advisors section.
func (v View) ToAdvisor(name string) string {
return View{Section: "advisors", Moon: v.Moon, Adv: name}.URL()
}
// ToDrill opens a tool's panel in the equipment section.
func (v View) ToDrill(id string) string {
return View{Section: "equipment", Moon: v.Moon, Drill: id}.URL()
}
// ------------------------------------------------------------------ page // ------------------------------------------------------------------ page
// Page is the data for the whole screen. Every htmx response re-renders all of it as // Page is the data for one view of the game, mapped to JSON by the API (api.go).
// out-of-band swaps (see web/templates/app.html).
// RefinedNames lists every refined item, for the grant dialog.
func (p *Page) RefinedNames() []string {
var names []string
for _, item := range cfg().Refinery.Items {
names = append(names, item.Name)
}
return names
}
type Page struct { type Page struct {
S *State S *State
V View V View
OOB bool
Toast *Toast
Scene SceneData Scene SceneData
Modal *BuyModal // opens after buying a tool
Banner []BannerItem // active announcements and live events (events.go) Banner []BannerItem // active announcements and live events (events.go)
Admin bool // the player is an admin: the quick grant dialog (admin-grant) Admin bool // the player is an admin: the quick grant dialog (admin-grant)
@@ -332,21 +255,20 @@ type Page struct {
Advisors *AdvisorsPage // advisors section (advisors.go) Advisors *AdvisorsPage // advisors section (advisors.go)
Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server Ranking *LeaderboardPage // leaderboard section (leaderboard.go), loaded by the server
NewMoon *NewMoonTeaser // new-moon section (reveal.go) NewMoon *NewMoonTeaser // new-moon section (reveal.go)
Reveal *MoonReveal // the moon just bought (reveal.go)
} }
type Toast struct { type Toast struct {
Message string Message string
Error bool Error bool
Kind string // the action, or "error"; main.js/fx.js plays effects for it Kind string // the action, or "error"; the client plays effects for it
// Finished lists the moons the action mined out (comma-separated ids), for fx.js. // Finished lists the moons the action mined out (comma-separated ids).
Finished string Finished string
// Credits is what a sale earned, for the sell ticket's payout effect (fx.js). // Credits is what a sale earned, for the sell ticket's payout effect.
Credits float64 Credits float64
} }
// SceneData is handed to the three.js viewport (web/static/js/main.js): the moon to show // SceneData is the moon viewport's selection (the API's view.aside): the moon to show (its
// (its id is the seed) and the selected well. // id is the seed) and the selected well.
type SceneData struct { type SceneData struct {
Select map[string]string `json:"select"` // null hides the viewport Select map[string]string `json:"select"` // null hides the viewport
} }
@@ -387,39 +309,6 @@ func (m Moon) FinishedOn() string {
return m.FinishedAt.Format("Jan 2") return m.FinishedAt.Format("Jan 2")
} }
// ActiveURL and PastURL are the moon list's tabs: the first moon of each list.
func (p *Page) ActiveURL() string {
if len(p.Moons) == 0 {
return p.V.ToSection("new-moon")
}
return p.V.ToMoon(p.Moons[0].ID)
}
func (p *Page) PastURL() string {
if p.S.PastTotal == 0 {
return ""
}
return View{Section: "moons", Past: true}.URL()
}
// PastPaging is the Past tab's paging: the page shown, how many there are, and links to
// the newer and older pages ("" at either end).
type PastPaging struct {
Page, Pages, Total int
Newer, Older string
}
func (p *Page) PastPaging() PastPaging {
pg := PastPaging{Page: p.S.PastPage, Pages: p.S.pastPages(), Total: p.S.PastTotal}
if pg.Page > 1 {
pg.Newer = View{Section: "moons", Past: true, Page: pg.Page - 1}.URL()
}
if pg.Page < pg.Pages {
pg.Older = View{Section: "moons", Past: true, Page: pg.Page + 1}.URL()
}
return pg
}
type MoonDetail struct { type MoonDetail struct {
Moon Moon
Wells []WellRow Wells []WellRow
@@ -443,16 +332,6 @@ type DrillGroup struct {
func (g DrillGroup) Mining() bool { return len(g.Drills) > g.Ready } func (g DrillGroup) Mining() bool { return len(g.Drills) > g.Ready }
// Yields lists each drill as "end:amount" (end in unix ms), so main.js can show the
// amount a Claim button will get as drills finish (display only, capped at the supply).
func (g DrillGroup) Yields() string {
parts := make([]string, len(g.Drills))
for i, d := range g.Drills {
parts[i] = strconv.FormatInt(d.End().UnixMilli(), 10) + ":" + strconv.FormatFloat(d.Take, 'f', -1, 64)
}
return strings.Join(parts, ",")
}
func (g *DrillGroup) add(sr StakeRow) { func (g *DrillGroup) add(sr StakeRow) {
g.Drills = append(g.Drills, sr) g.Drills = append(g.Drills, sr)
if sr.Ready { if sr.Ready {
@@ -476,17 +355,6 @@ type WellRow struct {
DryAt time.Time // when that claim can happen DryAt time.Time // when that claim can happen
} }
// Pending lists when each unfinished drill on the moon finishes (see endsAttr).
func (d *MoonDetail) Pending() string {
var times []time.Time
for _, st := range d.Stakes {
if !st.Ready {
times = append(times, st.End())
}
}
return endsAttr(times)
}
type WellDetail struct { type WellDetail struct {
WellRow WellRow
MoonWide *DrillGroup // the moon's multi-well drills (claimed together) MoonWide *DrillGroup // the moon's multi-well drills (claimed together)
@@ -529,20 +397,6 @@ type ToolGroup struct {
// Short is a deploy card's tool getting less than its yield here (others claim first). // Short is a deploy card's tool getting less than its yield here (others claim first).
func (g ToolGroup) Short() bool { return g.Here < g.Yield-1e-9 } func (g ToolGroup) Short() bool { return g.Here < g.Yield-1e-9 }
// ReachText says how many wells it mines ("" for one).
func (g ToolGroup) ReachText() string { return reachLabel(g.Reach) }
// ReachShort is the reach for a small card: "3 wells", "whole moon" ("" for one).
func (g ToolGroup) ReachShort() string {
switch {
case g.Reach <= 1:
return ""
case g.Reach >= len(cfg().ResourcePrices):
return "whole moon"
}
return fmt.Sprintf("%d wells", g.Reach)
}
// reachLabel is "Mines 3 wells" or "Strips the whole moon" (a reach as big as a moon can // reachLabel is "Mines 3 wells" or "Strips the whole moon" (a reach as big as a moon can
// get); "" for one well. // get); "" for one well.
func reachLabel(reach int) string { func reachLabel(reach int) string {
@@ -571,12 +425,6 @@ type StoreRow struct {
BestRate float64 // the same for the player's best tool; 0 with none BestRate float64 // the same for the player's best tool; 0 with none
} }
// RateGain is how much more per hour it earns than the player's best tool.
func (r StoreRow) RateGain() float64 { return r.Rate - r.BestRate }
// ReachText says how many wells the tool mines ("" for one).
func (r StoreRow) ReachText() string { return reachLabel(r.Item.ToolReach()) }
type MarketRow struct { type MarketRow struct {
ResourceBalance ResourceBalance
Refined bool // a refined item (refinery.go) Refined bool // a refined item (refinery.go)
@@ -778,16 +626,14 @@ func (s *State) page(v View) *Page {
for _, m := range s.Moons { for _, m := range s.Moons {
row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)} row := MoonListRow{Moon: m, Selected: m.ID == v.Moon, Over: s.wellsOver(&m)}
var pending []time.Time var pending []time.Time
for _, st := range s.Stakes { for _, st := range s.stakesOn(m.ID) {
if st.MoonID == m.ID { row.Drills++
row.Drills++ if st.Remaining(s.Now) == 0 {
if st.Remaining(s.Now) == 0 { row.Ready++
row.Ready++ } else {
} else { pending = append(pending, st.End())
pending = append(pending, st.End()) if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) {
if p.NextEnd.IsZero() || st.End().Before(p.NextEnd) { p.NextEnd = st.End()
p.NextEnd = st.End()
}
} }
} }
} }
@@ -948,14 +794,6 @@ func (s *State) page(v View) *Page {
return p return p
} }
// Busy is how many of the row's copies are mining.
func (r ToolRow) Busy() int { return r.Count - r.Idle }
// copyKey is the same for copies of a tool: same type and track levels.
func (s *State) copyKey(t Tool) string {
return fmt.Sprint(t.StoreItemID, t.Levels())
}
// followTool keeps the selected drill on the tool an action used: the action may have // followTool keeps the selected drill on the tool an action used: the action may have
// used an idle copy (pickTool) of the one asked for. // used an idle copy (pickTool) of the one asked for.
func followTool(selected, asked, used string) string { func followTool(selected, asked, used string) string {
@@ -965,11 +803,6 @@ func followTool(selected, asked, used string) string {
return selected return selected
} }
// StakeURL opens the well a stake is on.
func (p *Page) StakeURL(st *StakeRow) string {
return View{Section: "moons", Moon: st.MoonID, Well: st.WellID}.URL()
}
// ------------------------------------------------------------------ after buying a tool // ------------------------------------------------------------------ after buying a tool
// BuyModal opens after a tool purchase and suggests wells to deploy it on. // BuyModal opens after a tool purchase and suggests wells to deploy it on.
@@ -1010,15 +843,21 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
var all []Suggestion var all []Suggestion
for i := range s.Moons { for i := range s.Moons {
moon := &s.Moons[i] moon := &s.Moons[i]
remain := s.project(moon).Remain
for _, w := range moon.Wells { for _, w := range moon.Wells {
if w.Supply <= 0 { if w.Supply <= 0 {
continue continue
} }
// What a cycle deployed here would take from each well (several for a reach). // What a cycle deployed here would take from each well (several for a reach),
// after the drills already on the moon: none and stakeInTx refuses it.
_, yield := s.drill(*t, item, w.Resource) _, yield := s.drill(*t, item, w.Resource)
credits := 0.0 credits, got := 0.0, 0.0
for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, moon.supply()) { for j, take := range moon.split(Stake{WellID: w.ID, Amount: yield, Reach: m.Reach}, remain) {
credits += take * s.sellPrice(moon.Wells[j].Resource) credits += take * s.sellPrice(moon.Wells[j].Resource)
got += take
}
if got <= 1e-9 {
continue
} }
row := s.wellRow(moon, w, "") row := s.wellRow(moon, w, "")
all = append(all, Suggestion{MoonID: moon.ID, MoonName: moon.Name, Well: w, all = append(all, Suggestion{MoonID: moon.ID, MoonName: moon.Name, Well: w,
@@ -1039,13 +878,13 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
} }
return a.Supply > b.Supply return a.Supply > b.Supply
}) })
var bestRate, bestSupply float64 // The labels compare the wells shown; "highest value" only when their values differ.
for _, sg := range all {
bestRate, bestSupply = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply)
}
// Only call a well "highest value" when the values actually differ.
distinct := all[0].PerMin != all[len(all)-1].PerMin
all = all[:min(3, len(all))] all = all[:min(3, len(all))]
bestRate, bestSupply, lowRate := all[0].PerMin, all[0].Supply, all[0].PerMin
for _, sg := range all {
bestRate, bestSupply, lowRate = max(bestRate, sg.PerMin), max(bestSupply, sg.Supply), min(lowRate, sg.PerMin)
}
distinct := bestRate != lowRate
for i := range all { for i := range all {
sg := &all[i] sg := &all[i]
switch { switch {
@@ -1063,9 +902,6 @@ func (s *State) buySuggestions(toolID string) *BuyModal {
return m return m
} }
// ReachText says how many wells the tool mines ("" for one).
func (m *BuyModal) ReachText() string { return reachLabel(m.Reach) }
// bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour). // bestToolRate is what the player's best-earning tool makes per hour (creditsPerHour).
func (s *State) bestToolRate() float64 { func (s *State) bestToolRate() float64 {
best := 0.0 best := 0.0
@@ -1128,19 +964,6 @@ func formatCompact(n float64) string {
return currency + formatMoney(math.Round(n)) return currency + formatMoney(math.Round(n))
} }
// formatSpan formats seconds as "45s", "3m 20s" or "2h 5m".
func formatSpan(secs float64) string {
n := int(math.Ceil(secs))
if n < 3600 {
return formatCycle(float64(n))
}
h, m := n/3600, n%3600/60
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh %dm", h, m)
}
// wellsText is "1 well", "2 wells" or "whole moon". // wellsText is "1 well", "2 wells" or "whole moon".
func wellsText(reach int) string { func wellsText(reach int) string {
switch { switch {
@@ -1173,12 +996,7 @@ func (s *State) project(m *Moon) projection {
n := len(m.Wells) n := len(m.Wells)
p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n), p := projection{Take: map[string][]float64{}, Gets: map[string]float64{}, Demand: make([]float64, n),
Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)} Remain: m.supply(), Claims: make([]int, n), DryAfter: make([]int, n), DryAt: make([]time.Time, n)}
var stakes []Stake stakes := slices.Clone(s.stakesOn(m.ID))
for _, st := range s.Stakes {
if st.MoonID == m.ID {
stakes = append(stakes, st)
}
}
sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) }) sort.SliceStable(stakes, func(i, j int) bool { return stakes[i].Start.Before(stakes[j].Start) })
current := m.supply() current := m.supply()
last := make([]time.Time, n) // latest end among the drills that drew from each well so far last := make([]time.Time, n) // latest end among the drills that drew from each well so far
@@ -1306,38 +1124,8 @@ func (r WellRow) NoteText() string {
return t + " · " + formatMoney(r.Remain) + " free after" return t + " · " + formatMoney(r.Remain) + " free after"
} }
// BarStack is a wells table row's timer bars (template "drill-stack"): the first drills,
// oldest first, and how many it leaves out. Key makes the bars' ids unique on the page
// (the morph matches them per drill).
type BarStack struct {
Key string
Bars []StakeRow
More int
}
// Stack is the well's timer bars; phone for the copy shown under the supply bar on phones.
func (r WellRow) Stack(phone bool) BarStack { return barStack("w"+r.ID, r.Queue(), phone) }
// Stack is the multi-well drills' timer bars.
func (g DrillGroup) Stack(phone bool) BarStack { return barStack("m"+g.MoonID, g.Drills, phone) }
func barStack(key string, q []StakeRow, phone bool) BarStack {
if phone {
key = "p" + key
}
return BarStack{Key: key, Bars: q[:min(len(q), maxBars)], More: max(0, len(q)-maxBars)}
}
const maxBars = 4 const maxBars = 4
// Pct is how far through its cycle the drill is (0-100).
func (r StakeRow) Pct() float64 {
if r.Ready || r.DurationInSecs <= 0 {
return 100
}
return min(100, max(0, 100*(1-r.Remaining.Seconds()/r.DurationInSecs)))
}
// Code is the tool's model code, the last word of its name ("PD-1"). // Code is the tool's model code, the last word of its name ("PD-1").
func (r StakeRow) Code() string { func (r StakeRow) Code() string {
if r.Item == nil { if r.Item == nil {
@@ -1381,23 +1169,9 @@ func (r StakeRow) Fate() string {
return s return s
} }
// DrillsTitle is the tooltip of a row's timer bars: each drill and what it gets. // DrillLabel names the group's drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1").
func (r WellRow) DrillsTitle() string { return drillsTitle(r.Queue()) }
func (g DrillGroup) DrillsTitle() string { return drillsTitle(g.Drills) }
// DrillLabel names a row's own drills compactly ("PD-1 Y1 C2", "PD-1 ×3", "PD-1 ×3 +1");
// a well's multi-well drills show only as diamond-capped bars.
func (r WellRow) DrillLabel() string { return drillLabel(r.Drills) }
func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) } func (g DrillGroup) DrillLabel() string { return drillLabel(g.Drills) }
func drillsTitle(q []StakeRow) string {
lines := make([]string, len(q))
for i, d := range q {
lines[i] = d.Fate()
}
return strings.Join(lines, "\n")
}
func drillLabel(q []StakeRow) string { func drillLabel(q []StakeRow) string {
if len(q) == 0 { if len(q) == 0 {
return "" return ""
@@ -1460,31 +1234,6 @@ func (s *State) betterWell(skip string, free float64) *BetterWell {
return best return best
} }
// URL opens the well.
func (b *BetterWell) URL() string {
return View{Section: "moons", Moon: b.MoonID, Well: b.WellID}.URL()
}
// Queue is every drill on the well, in the order the projection claims them (oldest first).
func (r WellRow) Queue() []StakeRow {
q := append(append([]StakeRow{}, r.Drills...), r.Wide...)
sort.SliceStable(q, func(i, j int) bool { return q[i].Start.Before(q[j].Start) })
return q
}
// SupplyBar is a well's supply bar with its template's settings (template "well-supply").
type SupplyBar struct {
W WellRow
Max float64
Now time.Time
Panel bool
}
// Bar is the well's supply bar for the wells table or (panel) the side panel.
func (r WellRow) Bar(max float64, now time.Time, panel bool) SupplyBar {
return SupplyBar{W: r, Max: max, Now: now, Panel: panel}
}
// secsText is formatCycle for a template value, int or float64 seconds. // secsText is formatCycle for a template value, int or float64 seconds.
func secsText(v any) string { func secsText(v any) string {
switch n := v.(type) { switch n := v.(type) {
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"testing"
"time"
)
type buyWell struct{ supply, busy float64 }
// buyTestState is a new player with one moon of single-resource wells (supplies in units of
// the new tool's yield; busy: what a drill already on the well takes, 0 for none) and a new
// single-well tool.
func buyTestState(t *testing.T, wells []buyWell) (*State, string, float64) {
s := newPlayerState()
var item *StoreItem
for i := range s.Config.StoreItems {
if it := &s.Config.StoreItems[i]; it.ToolReach() == 1 {
item = it
break
}
}
if item == nil {
t.Skip("no single-well tool in the config")
}
tool := Tool{ID: testID("t", 500), StoreItemID: item.ID}
s.Tools = []Tool{tool}
_, yield := s.drill(tool, item, "")
res := s.Config.Resources[0]
moon := Moon{ID: testID("m", 1), Name: "One", CreatedAt: s.Now.Add(-time.Hour)}
for i, w := range wells {
well := Well{ID: testID("w", i), Resource: res, Supply: w.supply * yield}
moon.Wells = append(moon.Wells, well)
if w.busy > 0 {
other := Tool{ID: testID("t", 600+i), StoreItemID: item.ID}
s.Tools = append(s.Tools, other)
s.Stakes = append(s.Stakes, Stake{ID: testID("s", i), MoonID: moon.ID, WellID: well.ID, ToolID: other.ID,
Resource: res, Amount: w.busy * yield, Start: s.Now, DurationInSecs: 60, Reach: 1})
}
}
s.Moons = []Moon{moon}
return s, tool.ID, yield
}
// A well the drills already on it will empty is not suggested (stake would refuse it).
func TestBuySuggestionsSkipCommittedWells(t *testing.T) {
s, id, _ := buyTestState(t, []buyWell{{1, 1}})
m := s.buySuggestions(id)
if len(m.Suggestions) != 0 || !m.AllEmpty {
t.Fatalf("suggestions %+v (allEmpty %v), want none: the well is fully committed", m.Suggestions, m.AllEmpty)
}
// With room for half a cycle, it is suggested, worth half a cycle.
s, id, _ = buyTestState(t, []buyWell{{1.5, 1}})
m = s.buySuggestions(id)
if len(m.Suggestions) != 1 {
t.Fatalf("suggestions %+v, want the well", m.Suggestions)
}
full := s.sellPrice(s.Moons[0].Wells[0].Resource) * s.Moons[0].Wells[0].Supply / 1.5
if got := m.Suggestions[0].Credits; got < full/2-1e-6 || got > full/2+1e-6 {
t.Fatalf("credits %v, want %v (half a cycle left)", got, full/2)
}
}
// "Highest value" goes to the best well shown, even when it is not first or last.
func TestBuySuggestionsHighestValue(t *testing.T) {
// A: idle, 3/4 of a cycle left; B: busy, plenty; C: busy, 3/4 of a cycle in the well,
// half after its drill.
s, id, _ := buyTestState(t, []buyWell{{0.75, 0}, {10, 1}, {0.75, 0.25}})
m := s.buySuggestions(id)
if len(m.Suggestions) != 3 {
t.Fatalf("suggestions %+v, want 3", m.Suggestions)
}
if m.Suggestions[1].ID != s.Moons[0].Wells[1].ID || m.Suggestions[1].Reason != "Highest value" {
t.Fatalf("second suggestion %+v, want well B with Highest value", m.Suggestions[1])
}
}
+1 -23
View File
@@ -1,18 +1,10 @@
package main package main
import ( import "testing"
"bytes"
"strings"
"testing"
)
// A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades, // A tool's panel opens as soon as Equipment is unlocked; its Store parts (upgrades,
// selling) show only once the Store is. // selling) show only once the Store is.
func TestViewDrillPanelOnboarding(t *testing.T) { func TestViewDrillPanelOnboarding(t *testing.T) {
tmpl, err := (&server{}).parseTemplates()
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, c := range []struct { for _, c := range []struct {
flags []string flags []string
open, store bool open, store bool
@@ -37,20 +29,6 @@ func TestViewDrillPanelOnboarding(t *testing.T) {
p := s.page(v) p := s.page(v)
if p.Drill == nil || p.Drill.Store != c.store { if p.Drill == nil || p.Drill.Store != c.store {
t.Errorf("%v: panel %+v, want store %v", c.flags, p.Drill, c.store) t.Errorf("%v: panel %+v, want store %v", c.flags, p.Drill, c.store)
continue
}
var b bytes.Buffer
if err := tmpl.ExecuteTemplate(&b, "drill-panel", p.Drill); err != nil {
t.Fatalf("render: %v", err)
}
html := b.String()
if strings.Count(html, "track-ladder") != 2 {
t.Errorf("%v: no track ladders", c.flags)
}
for _, part := range []string{"/act/sell-tool", "/act/upgrade"} {
if strings.Contains(html, part) != c.store {
t.Errorf("%v: %q shown %v, want %v", c.flags, part, !c.store, c.store)
}
} }
} }
} }
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more