Compare commits

...
127 Commits
Author SHA1 Message Date
EmilNielsenandClaude Opus 5.5 85336f109f Hand-off: second measurements, what's done and what's left
Re-measured on a quiet GPU (Chrome 155, Metal): claim and redrill show in
13–15 ms in the Preact client at 0 and +300 ms (classic 30 ms and 330–347
ms), every deploy click counts, no dropped frames for either; main-thread
time per claim: 11 ms of script against 25 ms. Side-by-side screenshots
match. The open list is now the other sections, the actions that still
wait for the server, and keeping the harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:42:16 +07:00
EmilNielsenandClaude Opus 5.5 a5385a53a7 Preact client: past moons, search, multi-well drills, moon sale, better well
The rest of the Moons slice from the hand-off's list:
- Moon list: Active/Past tabs, the search box, past moons (what each gave,
  when it finished) a page at a time; a past moon's page with its haul.
- Wells table: the multi-well row (what's left on the moon, its drills,
  claim-wide), and each well's drill bars include the multi-well drills
  that mine it. Their readiness is now local like the others' (derive).
- Moon resale under the table: the offer against a new moon, the wells'
  values, the hold countdown (the session wakes at holdEnd), and the API's
  confirm text in the classic dialog (dialog.jsx, the classic ui.js).
- Well panel: "Better: … well, N free · Deploy there" (it moves the view,
  so it waits for the server), multi-well drill lines (reach, here / in
  all) with their claim and claim-and-redeploy, and the store link.
- Effects: the relay click when following a link, the sale's gold ring and
  cash count, and the mined-out sound and Past tab flash (onAnswer, from
  the server's fx.finished, also after a predicted claim).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:19:07 +07:00
JosephFerano 0846c017ae Hand-off notes for the Preact client experiment 2026-10-20 08:42:29 +07:00
JosephFerano b4e5846dff Keep the built Preact client out of the Docker context 2026-10-20 08:34:14 +07:00
JosephFerano 3afd892487 Preact client: .value-in-JSX check, docs, Docker build, template test
scripts/check-signals-jsx.mjs (in make check) flags signal reads inside JSX; make check
also builds the client. The Dockerfile fetches esbuild and runs make app. AGENTS.md
describes web/app and its rules; /app is in the template check and the endpoint list.
2026-10-20 08:33:46 +07:00
JosephFerano cc1005c99e Preact client: moon viewport in a worker on an OffscreenCanvas
The classic MoonScene runs in scene-worker.js against stand-ins for the canvas, its
container, ResizeObserver and the document; the page forwards pointer and wheel events
and the size. Falls back to the classic scene on the page's thread (?mainscene forces it).
Also: FLIP positions relative to their container, so scrolling isn't a move.
2026-10-20 08:31:35 +07:00
JosephFerano c998f8fda0 Preact client at /app: session with prediction, Moons section, well panel, viewport
Signals over /api/state, an outbox sending one request at a time with clientSeq, the
Defold client's predictors (claim, stake, redrill) applied at the click and replaced by
the server's state. Bundled by esbuild (make app) into web/static/app; the classic
client's scene, effects and sounds are loaded by URL. MM_API_DELAY_MS (dev) delays game
requests for both clients.
2026-10-20 08:19:58 +07:00
JosephFerano 25f315e8dc API: advisors bench (hired, not seated, in the web's order) and removed (hired but no longer in the config, no card) 2026-10-20 01:39:53 +07:00
JosephFerano 571075c5ff Rate limit: normalise client IPs (loopback as one, IPv4-mapped as IPv4) 2026-10-20 00:20:23 +07:00
JosephFerano fbde1b79ae API: view.aside, whether the web shows the aside 2026-10-20 00:19:42 +07:00
JosephFerano 4e7e2d010d API: well note, stake reach and market quote texts
Well rows get over, full, statusText and noteText (the web's well note);
stakes get reachText; market quotes get up/changeText and the pressure,
event boost and advisor texts the web shows (empty when not shown), so
clients don't word them.
2026-10-19 22:43:55 +07:00
JosephFerano 020d1f7197 Past moons: 10 a page
The Past tab loads the 10 most recently finished moons at a time
(loadPastPage: limit/offset in the query, plus a selected past moon from
another page); older pages via page= in the view URL (with list=past).
The web shows Newer/Older controls and the total on the tab; the API has
view.page, pastPage {page, pages, total, perPage} and takes page=.
2026-10-19 22:37:08 +07:00
JosephFerano 1fd1059aa7 New-moon reveal: Buy another
The reveal (web card and the API's reveal) carries the next moon's price
and whether it can be bought now (why: locked or credits). The web card
gets a Buy another button next to Start mining: it closes the reveal and
buys again (buy-moon), disabled when unaffordable, hidden while more
moons are locked.
2026-10-19 22:36:57 +07:00
JosephFerano a8bf63ffe4 Advisors: numbered seats, seat, unseat, swap and dismiss
Hired advisors sit in numbered seats (advisor_hire.seat, migration 016:
existing hires take seats in hiring order, once); only seated advisors
give their bonus. New actions seat-advisor, unseat-advisor, swap-advisors
and dismiss-advisor (nothing refunded), gated on advisors and logged.
The Advisors section shows the seats and the advisors without one; a
hired advisor opens a panel in the aside (adv= in the view URL). The API
advisors block carries seatList, freeSeat, each advisor's seat, seat
reasons and the dismiss dialog.
2026-10-19 22:36:11 +07:00
JosephFerano bc34f98e4c api.md: Texts section after the market chart 2026-10-19 18:09:22 +07:00
JosephFerano e6088994ef API: web texts beside reason codes, confirm dialogs, rating label/top, banner ids and end, skippable catalog
- Ratings everywhere carry label and top (as the reveal card).
- Banner items: kind, id, end, left; live events: id (matches the banner).
- Reason codes get the web's text (noSaleText, whyText, blockedText, reasonText, ...);
  confirm dialogs (sell moon/tool/attachment, buy seat/slot, new game) as {title, text, ok, danger}.
  The dynamic confirm and tooltip texts move from the templates into view-model methods
  (same output).
- Other labels: bonus chips, effect texts, reach wording (catalog.reach), well drill labels,
  drill fate lines, past moon dates.
- catalog=0 or catalog=<catalogVersion> leaves the catalog out.
2026-10-19 18:07:53 +07:00
JosephFerano 474542abc7 API: help note and clientSeq docs; test that section blocks come only with their section 2026-10-19 16:36:42 +07:00
JosephFerano c7073e53b9 JSON API: every section's state, reveal and buy modal, prediction rules, catalog, clientSeq
GET /api/state adds section blocks for s= (and include=): equipment with the
drill panel, store, refinery, advisors, leaderboard, help, the new-moon teaser
and the market chart; the moon sale offer in moon. Always: rules (pressure
curve, refinery timing, bonuses, live boosts, supply scale), catalog (image
URLs, tool codes), refinery jobs, advisor bonuses, live events, showNav.
Act responses carry the reveal card after buy-moon, the deploy suggestions
after buy-tool, and echo clientSeq. Reasons are codes. Mapped from the
existing view models; HTML unchanged.
2026-10-19 16:34:58 +07:00
JosephFerano 66705d9aba api.md: document GET /api/moon-settings for the Defold client 2026-10-19 14:43:01 +07:00
JosephFerano 0d2a790506 API new-game: one Set-Cookie, for the new player 2026-10-19 13:39:54 +07:00
JosephFerano 960a03a4f6 JSON API for game clients: GET /api/state, POST /api/act/<action>
The action switch moves from handleAction into runAction, shared by the htmx
handler and the API. The state maps the view models to JSON (unix ms times).
Player id from X-MM-User or the cookie, else a new player. api.md documents it;
api_test.go builds it for every test view and runs in make check.
2026-10-19 13:38:23 +07:00
JosephFerano d8a6ae1419 Workspace layout: repo in server/, worktrees beside it; pin compose project name 2026-10-19 13:26:28 +07:00
JosephFerano 85c8219c0d Refinery: shorter batch times (1/2/3/5/6s) and a stronger bulk bonus (n^0.6) 2026-10-18 22:55:44 +07:00
EmilNielsenandClaude Opus 5.5 1b595a413e Leaderboard section: top miners by credits, with the player's own place
Revealed with the store gate (like Help). Banned players no longer rank,
in the section or in /api/leaderboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:32:16 +07:00
JosephFerano 33f9589caf Wells table: a timer bar per drill instead of the note row; no dry countdown 2026-10-18 13:47:01 +07:00
JosephFerano 4954cc8be6 Merge idiomorph swaps, the template check and the reveal fix 2026-10-18 13:34:45 +07:00
JosephFerano a52ae701f9 Merge branch 'worktree-agent-af46c79678d692771' 2026-10-18 13:29:39 +07:00
JosephFerano d5e592c6b9 Show well panel drills as compact 50px rows 2026-10-18 13:29:20 +07:00
JosephFerano da3b343bc8 Merge branch 'worktree-agent-a03c8b90fd218ee5c' 2026-10-18 13:26:20 +07:00
JosephFerano 93963c0eed Show tool upgrades as stat rows (yield, reach, cycle: now ▸ next) in a stat-up accent, not money green 2026-10-18 13:25:37 +07:00
JosephFerano 8c1456d5e6 Hide the new moon's page until its reveal ends 2026-10-18 13:25:06 +07:00
JosephFerano 9f7538bdb8 Morph the page regions with idiomorph instead of replacing them 2026-10-18 13:25:06 +07:00
JosephFerano 56888730e2 Show shared well supply: drill pips, committed vs left, over-drilled warning, per-drill projection, effective deploy yields; deploy cards above the drill list 2026-10-18 13:14:14 +07:00
JosephFerano 469e64b596 Stop the drills on a well when a claim taps it out 2026-10-18 13:12:44 +07:00
JosephFerano 0393ba2cc8 Add a template check to make check 2026-10-18 12:57:26 +07:00
JosephFerano 7944cd73c7 Merge branch 'worktree-agent-ae88760616889d09c' 2026-10-18 12:56:49 +07:00
JosephFerano eee2728b1d Show past moons only from the Past tab (list=past); fall back to an active moon or New moon 2026-10-18 12:55:04 +07:00
JosephFerano d70f71b33f Show what each tool upgrade gives: before/after yield, reach, credits/hour and payback, tier ladder, store comparison 2026-10-18 12:52:07 +07:00
JosephFerano 1b2d563b12 Merge branch 'worktree-agent-ad509b9fdba374129' 2026-10-18 12:40:26 +07:00
JosephFerano 1f612f28a6 Give the well panel's deploy cards a big image and a compact info block 2026-10-18 12:39:48 +07:00
JosephFerano 46f390126e Speed up Void Core and Stellar Lattice 2026-10-18 12:37:00 +07:00
JosephFerano 39a1faeb6b Merge the moon rating; renumber its migration to 015 2026-10-18 12:36:46 +07:00
JosephFerano 494d65f837 Call a well full only at the top roll; document rating.go 2026-10-18 12:35:52 +07:00
JosephFerano 2bc602f278 Rate each new moon against all possible moons and give the reveal a verdict 2026-10-18 12:31:12 +07:00
JosephFerano 78a076fffc Update the deployment steps in the README 2026-10-18 12:28:16 +07:00
JosephFerano 42c8b84a7c Fix the wells table's Empty label and show reach on equipment cards 2026-10-18 12:22:39 +07:00
JosephFerano f061317ee8 Give the L300 its second well at tier 2, so tier 1 mines one well as before 2026-10-18 12:15:01 +07:00
JosephFerano 3e4ddfe88d Claim a moon's multi-well drills from the wells table and well panel 2026-10-18 12:12:26 +07:00
JosephFerano 7750682e23 Drop the moon's Drills tab: the wells table and well panel show the drills 2026-10-18 12:12:26 +07:00
JosephFerano cd30dcbc95 Drills with a reach mine several wells of a moon per cycle; retune the top tools for it 2026-10-18 12:12:26 +07:00
JosephFerano 18748f9984 Give tool cards an edge-to-edge image and a compact info block 2026-10-18 12:04:50 +07:00
JosephFerano 65d153a3b8 Raise Dusk Alloy to 30 2026-10-18 11:53:19 +07:00
JosephFerano 9a5330d637 Roll each well's supply on its own in 30-90, so no well holds more than 90 2026-10-18 11:36:09 +07:00
JosephFerano ecf0bdce8d Merge branch 'main' into worktree-agent-aba4a2f074db0707a 2026-10-18 11:33:40 +07:00
JosephFerano bb431e1fe5 Let the sim go through thousands of moons now that they're a flat-price consumable 2026-10-18 11:30:07 +07:00
JosephFerano 0468346ac4 Check that the richest fresh moon never resells for its price 2026-10-18 11:30:07 +07:00
JosephFerano 6f7e3dde56 Flat moon price and well supply; resale at 0.1 of market value, capped at 0.9x the price 2026-10-18 11:30:07 +07:00
JosephFerano 188ced7ffe Merge branch 'worktree-agent-ac9d939117186a616' 2026-10-18 11:26:34 +07:00
JosephFerano 8ccf932b55 Theme the effect particles and reveal glows from the color tokens 2026-10-18 11:24:26 +07:00
JosephFerano c2376b2166 Show word lists in the economy editor's game.json column 2026-10-18 11:16:13 +07:00
JosephFerano b7362a53ab Generated moon names, unique per player; moon type in its own column 2026-10-18 11:14:11 +07:00
JosephFerano f8f2260867 Add a light theme: color tokens in app.css, toggle in the header and admin nav 2026-10-18 11:10:05 +07:00
JosephFerano 34cb3076a7 Variable wells per moon: 2-5 by weight, resources by moon type, same value per moon 2026-10-18 11:08:43 +07:00
JosephFerano d7a62abd1b Merge branch 'worktree-agent-a07ace2b9256e399c' 2026-10-18 10:47:47 +07:00
JosephFerano f52064fc71 Draw the new moon's scan sweep inside its list entry so it stays on it while the entry animates 2026-10-18 10:47:24 +07:00
JosephFerano c30c2f83cb Replace the new-tab shine with a targeting reticle that locks on 2026-10-18 10:45:40 +07:00
JosephFerano 7abfe21989 Show Redrill from the first claim during onboarding 2026-10-18 10:32:51 +07:00
JosephFerano 5801a86fc4 Start new players with no resources 2026-10-18 10:32:24 +07:00
JosephFerano 3a7f0e9498 Show + New moon to fully unlocked players with no moon 2026-10-18 10:24:55 +07:00
JosephFerano 5c484aa4c4 Onboarding by progressive reveal: sections and controls unlock as the player plays, new tabs shine until opened 2026-10-18 10:20:04 +07:00
JosephFerano 343e8e79ae Merge branch 'worktree-agent-ae5b379d907169822' 2026-10-18 09:54:51 +07:00
JosephFerano 282e8509e6 Themed thin scrollbars everywhere; fix stray overflow on the reveal card, lab panel and narrow tool cards 2026-10-18 09:54:18 +07:00
JosephFerano 410d773349 Merge branch 'worktree-agent-acc89c09c237841b5' 2026-10-18 09:53:30 +07:00
JosephFerano 13e87966ba Tool copies: actions and selection use an idle copy; busy drill panel offers an idle one 2026-10-18 09:53:14 +07:00
JosephFerano f14c308d62 Flatten moon price growth (1.24 to 1.15), well supply growth 1.21 to 1.13 2026-10-18 09:52:47 +07:00
JosephFerano e5af7274c7 Merge branch 'worktree-agent-a465dc2239b8312f0' 2026-10-18 09:45:11 +07:00
JosephFerano 6482a67fcf Toasts: compact stack in the bottom-left corner, max 3, slide in from the left 2026-10-18 09:44:52 +07:00
JosephFerano f57391e16c Merge branch 'worktree-agent-add85bf1547309157' 2026-10-18 09:44:31 +07:00
JosephFerano d40ed58a77 Replace advisor portraits with Codex-generated art 2026-10-18 09:44:12 +07:00
JosephFerano e4a22b6e6a Sell ticket: bigger payout number; keep the count out of the live region 2026-10-18 09:38:25 +07:00
JosephFerano 679792e0bf Sell ticket: payout swell, drain and power-down effect on a sale 2026-10-18 09:34:36 +07:00
JosephFerano 0c3a3667a2 Sell ticket: big payout number with per-unit price; Sell all preview 2026-10-18 09:34:36 +07:00
JosephFerano 35f9399bba Show the claim amount on Claim buttons as soon as drills finish 2026-10-18 09:20:56 +07:00
JosephFerano d7afb49035 Merge branch 'worktree-agent-a876271d1fae758dd' 2026-10-18 09:09:23 +07:00
JosephFerano 103ad668f1 Past moons: mined-out moons leave the list for a Past tab 2026-10-18 09:07:34 +07:00
JosephFerano fe782ee636 Lock a moon's wells before checking its drills on sale; document resale 2026-10-18 09:00:16 +07:00
JosephFerano ef6083747d Hide well supply on the mystery moon 2026-10-18 08:59:09 +07:00
JosephFerano dfef6d9866 Add advisor portraits and attachment art
Blender scripts for the five advisor busts and the four drill attachments,
rendered with the equipment pipeline.
2026-10-18 08:57:55 +07:00
JosephFerano 715abd7518 Sell tools and spare attachments back for part of their price 2026-10-18 08:56:21 +07:00
JosephFerano 1a95f8d26b Well panel: one-click tool cards instead of the deploy dropdown 2026-10-18 08:52:22 +07:00
JosephFerano ee6738fafb Sell moons back for what's left in their wells at market prices 2026-10-18 08:52:07 +07:00
JosephFerano 9c7fbf9963 Add a mystery moon panel and a reveal animation for new moons 2026-10-18 08:50:56 +07:00
JosephFerano b905f3c980 Copy pass on Equipment, Advisors and attachments; show the shortfall on unaffordable buttons 2026-10-18 08:44:01 +07:00
JosephFerano 171ab7b9ca Trim player-facing text; say how many credits or inputs are missing 2026-10-18 08:42:24 +07:00
JosephFerano c2f8213cdc Make the sound effects more sci-fi 2026-10-18 08:42:07 +07:00
JosephFerano 19ea99add5 Rebalance tool and upgrade prices
Upgrades now cost about what their extra income is worth, at the tool's own
payback rate (a little more per tier), so no tier costs more than the next tool
for less income. The PX-4 drops to 2400. The economy analysis flags upgrades
that cost more than the next tool and earn less, or pay back over twice as
slowly as their tool.
2026-10-18 08:40:26 +07:00
JosephFerano 94d455689e Market: card grid for assets, chart and sell ticket side by side 2026-10-18 08:37:04 +07:00
JosephFerano a41339e4f1 Cheaper moons and a stronger starter drill
Moons start at 400 (growth 1.24), wells hold 30-90, and the PD-1 mines 2 per
4s cycle. Attachments cost more so they don't come before the first hour. The
sim keeps about 2 moons per 10 minutes for the first hour.
2026-10-18 08:35:37 +07:00
JosephFerano 5d2e89efcc Add drill attachments and advisors
Attachments are bought in the Store and equipped on a drill from its panel in
the Equipment section (now cards with tool art). Advisors are hired into seats
in a new Advisors section. Their bonuses combine in bonus.go, capped, and apply
when a stake, sale or refinery batch starts.
2026-10-18 08:31:42 +07:00
JosephFerano d169574122 Lower sell prices a little under a player's own heavy selling 2026-10-18 08:27:15 +07:00
JosephFerano 76164fc24f Gray out tapped-out wells and moons 2026-10-18 08:21:12 +07:00
JosephFerano ede02fa040 Keep a Manage button on every well row, next to Redrill 2026-10-18 08:10:03 +07:00
JosephFerano d1fde538ea Speed up refining to seconds per unit and add a bulk bonus 2026-10-18 08:09:22 +07:00
JosephFerano 1970434422 Give Redrill button its own violet color 2026-10-18 08:05:32 +07:00
JosephFerano cad693a803 Fix wallet count-up flicker after claims 2026-10-18 07:45:10 +07:00
JosephFerano c6798ec32e Stop claims from redeploying drills; add Redrill 2026-10-18 07:41:29 +07:00
JosephFerano 23bc696203 Move Claim all next to the Help tab and rename ? to Help 2026-10-18 07:37:24 +07:00
JosephFerano 62e3051a17 Clean up the frontend
Share the confirm dialog and toasts in ui.js, tick badges and Claim
buttons on the server's clock, merge duplicate CSS, add focus styles
and drop the moon image column from the economy editor.
2026-10-17 22:30:51 +07:00
JosephFerano cbf48fb9dc Fix server-side correctness issues and update docs
Accurate claim/redeploy messages, no default admin id, 403 on every
admin page, data for live badges, and migration 009 so migrated
databases match a fresh schema (drops the unused wallet and moon image
columns).
2026-10-17 22:30:51 +07:00
JosephFerano 0333a5b8ca Harden the server and game actions
Body limits, timeouts and safe headers; new players rate-limited per IP; no ids on the
leaderboard. Claims skip rows a concurrent claim holds and lock in a fixed order; yield
events apply at stake time. Migration 008 fixes timestamp defaults and adds constraints.
2026-10-17 21:57:37 +07:00
JosephFerano 18d346e3a9 Run drills in seconds for faster, active play
Cycles are now seconds (migration 007) and the economy is rebalanced around them.
Claim buttons count down and enable themselves; a Claim all button redeploys every
finished drill. The sim plays an active player.
2026-10-17 21:57:24 +07:00
JosephFerano 238fabc7f2 Add sound effects and header controls
sfx.js plays Kenney CC0 clips and ZzFX synth sounds for action results, dialogs and
errors. The header gets a mute button and an Admin link.
2026-10-17 21:56:40 +07:00
JosephFerano 993b0e9109 Add sliding tab ink and section enter animations
tabs.js slides a highlight between the active tabs of any data-tabs strip; enter.js
animates frames in when a section, moon, tab or well is entered, and on admin page loads.
2026-10-17 21:56:05 +07:00
JosephFerano 40d4054496 Add ideas list 2026-10-17 21:35:38 +07:00
JosephFerano 6e082666a5 Add game feel effects
fx.js plays effects for action results, keyed by the toast's kind: wallet
count-ups, floating numbers, particles and scan sweeps.
2026-10-17 21:35:38 +07:00
JosephFerano c954c34172 Generate moon descriptions from their look
moondesc.go mirrors terrain.js to describe each moon from its look and
wells; scripts/check-moon-traits.mjs (in make check) keeps the two in sync.
2026-10-17 21:35:38 +07:00
JosephFerano ffc873802f Add the players admin page
Search and inspect players, grant credits, resources and tools, finish
drills, reset and ban. Every admin page shares one nav.
2026-10-17 21:35:38 +07:00
JosephFerano de61d40f8c Add the economy editor and rebalance the game
Admins edit the game config live on /utils/economy; each save is an
override merged over game.json. sim.go analyzes a config and simulates
progression. The numbers are rebalanced with it, and moon price and well
supply now grow with the moons a player owns.
2026-10-17 21:35:38 +07:00
JosephFerano f36f0b4b6b Redesign the market
Prices drift within marketVolatility of the config price on a deterministic
5-minute tick. The market shows a ticker, a watchlist, 24h charts and a
sell ticket.
2026-10-17 21:35:38 +07:00
JosephFerano bdf579d8a7 Add live ops
Player actions go to an event log that feeds a dashboard on /utils/liveops,
which also schedules timed yield, price and speed events and announcements
shown in a banner in the game.
2026-10-17 21:35:38 +07:00
JosephFerano 5052401f77 Add the refinery
Resources are refined in timed batches, in slots players buy. Refined items
sell for more and are needed as parts for higher tool upgrades.
2026-10-17 21:35:38 +07:00
JosephFerano 5b20ea2be2 Suggest wells after buying a tool and tick countdowns
After a purchase a dialog ranks the player's wells for the new tool.
Countdown labels tick once a second as display only; the server still
decides what is ready.
2026-10-17 21:35:38 +07:00
JosephFerano e336b57ca0 Add equipment art
Blender scripts that model and render the seven tools, their .glb models
and the store images. The store shows the tools as cards.
2026-10-17 21:35:38 +07:00
JosephFerano 0c51dbd749 Add the moon lab
Admins tune the procedural moon look live on /utils/moons. The settings are
saved in moon_settings and every moon in the game starts from them.
2026-10-17 21:35:38 +07:00
JosephFerano 459c7a5ded Port the game to Go
A Go server renders the interface as HTML (htmx for in-place updates) over
a three.js viewport of procedurally generated moons. Game rules run in Go,
one Postgres transaction per action; the game config lives in game.json.
2026-10-17 21:35:37 +07:00
JosephFerano 610d26eee9 Remove the Next.js/TypeScript app
The game is being ported to a Go server with server-rendered HTML.
This removes the old frontend, API routes, PL/pgSQL procedures,
Google Sheet import script and custom Postgres image.
2026-10-17 20:43:21 +07:00
JosephFerano 827bcbcd8f Clean up unused code 2026-10-17 17:02:21 +07:00
252 changed files with 36851 additions and 12579 deletions

No files matched your search

+5 -4
View File
@@ -1,9 +1,10 @@
.git
.next
bin
node_modules
.env
.env*
database.db
npm-debug.log*
.DS_Store
.vscode
.DS_Store
web/static/css/app.css
art
web/static/app
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+17 -37
View File
@@ -1,46 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Build output
/bin/
/moonminers
/web/static/css/app.css
/web/static/app/
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# Local env files (never commit)
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vscode
# Editors and OS
.vscode
.DS_Store
database.db
.env
# Personal files
/sql/create-users.sql
/.dir-locals.el
sql/queries.sql
/project.todo
# Blender files (generated by art/equipment scripts)
*.blend
*.blend1
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+201 -53
View File
@@ -1,74 +1,222 @@
# AGENTS.md — Moon Miners
## What this is
Moon Miners is a browser idle/mining game (package name `mooner`). Players join anonymously:
the client calls `POST /api/user/anonymous` and stores the user id in `localStorage`. Players
own moons ("staking sources") with resource wells, buy and upgrade mining tools (store
items) and stake them on wells. When a stake finishes they claim resources (Sollux,
Shadowstone, Azurium, Novafor, Nebulance), which they can sell into a bank account
balance. There is also a leaderboard.
Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a
player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the
refinery for the whole account. There is also a leaderboard (Leaderboard section, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`).
## Stack
- Next.js 13.1 (experimental `appDir`) + TypeScript 4.9 (strict) + three.js 0.170.
three is pinned at 0.170 because newer `@types/three` needs TypeScript 5.
- The game UI is plain three.js + DOM in `src/game`; `src/app/page.tsx` only mounts it.
The API lives in the Pages Router (`src/pages/api`).
- Postgres via `pg` (`postgresConnection` in `db.ts`). Most game logic is written as
PL/pgSQL functions, and the API routes are thin wrappers that run `SELECT * FROM fn(...)`.
- SQLite (`dbConnection`, `database.db`) is legacy. Only `api/seed.ts`, `api/drop.ts` and
`add-balance` still use it. Don't build anything new on it.
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
committed). See "web/app" below.
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
The viewport in the aside shows the selected moon, generated procedurally from its id
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
- Postgres 16, stock image. No functions or extensions in the database.
## Layout
- `src/game/Game.ts`: controller. It creates/reuses the anonymous player, loads state from
the API, and handles HUD actions and 3D picks.
- `src/game/MoonScene.ts`: three.js scene (moons, well crystals, drills, progress rings,
camera focus, raycast picking). It's rebuilt from state on every `sync()`.
- `src/game/Hud.ts`: DOM overlay (top bar, side panels, hints, toasts). It uses event
delegation via `data-action` attributes. Styles are in `src/app/globals.css` (plain CSS).
- `src/game/api.ts` (typed fetch client) and `src/game/state.ts` (GameState plus helpers).
- `src/app/utils/page.tsx`: admin utilities (clear data, import config).
- `src/pages/api/**`: REST endpoints. Per-user routes are under `user/[userId]/`.
- `src/utils/helpers.ts`: time and formatting helpers. `src/utils/gameLogic.ts` exposes `gameConfig`.
- `typings.d.ts` (repo root, imported as `"typings"`): shared interfaces (`IStake`,
`IStoreItem`, `IGameConfig`, ...).
- `config/game-config.json`: static game config. `next.config.js` injects it as `process.env.gameConfig`.
- `sql/tables.sql`: schema. `sql/procedures.sql`: game functions (create_user, stake, claim,
purchase_item, upgrade_item, sell_resources, get_leaderboard, ...). `sql/import-config.sql`:
`import_config()`, which pulls store/staking config from a Google Sheet into the DB.
`sql/data.sql`: seed data.
- `scripts/import-game-config.py`: dumps the config Google Sheet as CSV (debug helper).
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
`loadView` (state, the past page, the resolved view).
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
action and responds the same way.
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
for every test view.
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
name, a placeholder description and resource `affinities`, refinery), embedded.
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
hold less); a first moon
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
`staking_source.kind` (moons from before migration 013 were named after their type).
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
(needed for the inventory foreign key and the SQL in game.go). Admins override its
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
- `db.go`: connections, `inTx`, `GameError`.
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
market price (with live events, without selling pressure or advisors) × `factor`, capped
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
and unequip any time, they count from the next stake), store cards and the drill panel
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
(`inventory_item_id` null: unequipped).
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
`all` for players from before it). New players see only the first moon purchase; deploying
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
sections and actions need a gate there.
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
list, sections, side panel).
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
and admin pages.
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
outside control (e.g. ads).
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
burst, fx.js waits for `mm:revealed` before counting the balance down).
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
sample of `rollWells` (resampled when the config changes), stored in
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
and the star chip in the moon list and header.
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
and the store link to the classic client. `session.js`: like the Defold client's session
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
id; corrections change its playback rate). `fx.js`: effects at the click from the
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
for what differs.
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
effects (`scripts/check-signals-jsx.mjs` in `make check` flags `.value` inside JSX).
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
changes when the server answers); never swap an element that animates for another.
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
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.
- `public/assets/`: moon and item images.
## Commands
- `npm run dev`: dev server on :3000 (the Dockerfile also runs `dev`, on Node 16).
- `npm run lint`: next lint (`next/core-web-vitals`).
- `npm run build`: type-check and build. There is no test suite, so lint and build are the checks.
Don't build while `npm run dev` is running (they share `.next`).
- Local DB: `docker compose up -d db` (see README).
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
game request that long, to try both clients on a slow connection.
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
moon traits check, the Preact client's `.value` check and build), `make migrate`,
`make load-config`. `go run . sim` prints the economy analysis.
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
templates like the server and renders every page and region against in-memory game states;
when you add a page, section or view model branch, make sure its states/views reach it.
- Local DB: `podman-compose up -d db` then `make migrate` (see README).
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
distribution, value by well count, resale odds, sim seeds).
## Deployment
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app` (root `Dockerfile`),
`db` (`docker/db/Dockerfile`, with the SQL baked in for first-time init), and `db-update`
(`docker/db/update.sh`, which runs on every deploy before the app starts).
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
`sql/migrations/NNN-name.sql` (existing DBs). Function changes in `sql/procedures.sql` are
reloaded automatically on deploy.
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
`pg_dump --schema-only`.
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
`DB_DATABASE`. Never read, print or commit `.env`.
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
(unset: nobody); with `MM_DEV=1` everyone is an admin.
## Conventions
- Put game rules in SQL functions (`sql/procedures.sql`) and keep API routes thin.
If you change a function's signature, update the route that calls it and the matching type in `typings.d.ts`.
- API route pattern: branch on `req.method`, return 200 with JSON, 400 with
`{ error }` for DB/business errors, and 500 for unexpected errors.
- Use the `@/*` alias for `src/*`. Import shared types from `"typings"`.
- Client components need `"use client"`. Game UI is not React: add HUD markup in `Hud.ts`,
handle the action in `Game.handleAction`, and dispose any three.js geometry and materials you create.
- SQL is lowercase, with snake_case identifiers and `p_`-prefixed params. Schema changes go in `sql/tables.sql`.
There is no migration tool, so mention any manual DB steps you need.
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
the address bar in sync.
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
so action parameters must use other names. Give it a sound in fx.js if it needs one.
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
prediction; the server is the authority. Pages don't reload on timers: countdown labels
(`data-ends`) tick client-side and may enable a Claim button, as display only.
- SQL is lowercase with snake_case identifiers.
- When you add an endpoint, add it to `test-endpoints.restclient`.
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
- Dispose any three.js geometry and materials you create.
- Keep changes small. Don't refactor or move files unless asked.
+21 -30
View File
@@ -1,33 +1,24 @@
# Production image for the Next.js app (game UI + API routes).
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-bookworm-slim AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
# Production image: one Go binary with the templates, static files and SQL embedded.
FROM docker.io/library/golang:1.25-bookworm AS build
WORKDIR /src
ARG TAILWIND_VERSION=v4.1.14
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
&& chmod +x /usr/local/bin/tailwindcss
ARG ESBUILD_VERSION=0.28.2
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN npm run build
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
&& make app ESBUILD=/usr/local/bin/esbuild \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/.next ./.next
COPY package.json next.config.js ./
COPY config ./config
COPY public ./public
# db.ts opens a (legacy) SQLite file in the working directory, so it must be writable.
RUN chown node:node /app
USER node
FROM scratch
COPY --from=build /moonminers /moonminers
USER 65532:65532
ENV PORT=3000
EXPOSE 3000
CMD ["node_modules/.bin/next", "start"]
ENTRYPOINT ["/moonminers"]
CMD ["serve"]
+169
View File
@@ -0,0 +1,169 @@
# 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`.
+65
View File
@@ -0,0 +1,65 @@
# Ideas
Not built yet. Rough priority within each section.
## Player interactions
1. **Contracts.** A player posts "need 40 Cryo Cells, paying ₥3,000"; anyone can fill it. Works with
few players and fits the idle pace. NPC buyers fill expired contracts so they never sit dead.
2. **Megaprojects.** Server-wide builds (space elevator, orbital refinery) everyone feeds resources
into; finishing unlocks a bonus for all. Runs as a live-ops event and burns surplus resources.
3. **Mining rights.** Lease idle wells to other players for a cut of what they mine.
4. **Corporations.** Groups of 5–20 with a shared weekly goal, a corporation leaderboard and a message
board.
5. **Player market.** Buy/sell orders between players with a small fee (a credit sink). Needs many
active players, so later.
6. **Banks and loans.** Borrow credits to buy a moon or tool early, repaid with interest from
sales; deposits earn interest. Start with an NPC bank (interest as a credit sink, loan limits by
net worth, default = repossess a tool). Later, player-to-player loans or player-run banks.
7. **Light rivalry (optional).** Scouting, contested rare moons, seasonal claim races. Keep it out of
the core loop: idle players hate losing progress while offline.
Needed first: display names (with a filter for offensive names); limits against alt accounts
feeding a main one (fees, trade limits for new accounts, gift caps). Trades stay in credits only.
## Story events (live ops)
Wrap the live-ops multipliers in news-style events. Needs moons to belong to regions/sectors first.
- **New cluster discovered.** A limited-time region opens with new moon types, a rare resource or
richer wells; first players in get the best ones.
- **War in a region.** Demand spikes (higher sell prices for alloys/fuel), but drills there
risk slowdowns or losses; players choose risk vs reward.
- **Solar storm.** Drills slow down everywhere except shielded or upgraded ones.
- **Trade embargo.** One resource can't be sold for a few days; prices jump when it lifts.
- **Comet pass.** A short window where a moon type yields a one-off resource.
- **Corporate rush / tender.** An NPC corporation orders a huge amount of something by a
deadline; contributors split the reward (ties into contracts and megaprojects).
Shown in the banner and a "news feed" with flavour text; the effect is still a multiplier,
restriction or new moon type under the hood.
## Economy
- Bigger tools barely earn more per hour than the free drone; tune so each tier earns ~2× per hour
and pays back within a session or two (see /utils/economy).
- A moon costs ₥700 but holds ~₥4,167 of ore; the sim hits the 300-moon cap in ~13.5 h.
- All resources sell for the same price, so which well you mine doesn't matter yet.
- Wells (50–200 supply) are smaller than what big tools claim per cycle.
- Sinks: moons (flat price, used up by mining), refinery slots, upgrade parts.
- Rare moons (planets, stars) with a resource only they have, needed for top recipes.
## Platform and money
- Web first (CrazyGames, Poki), then installable with push notifications, then Capacitor apps
for the stores once players come back on their own. Idle games fit mobile best.
- Money: opt-in rewarded ads ("finish this drill now"), cosmetics, a supporter pack.
## Security later
- Content-Security-Policy with nonces: needs the inline import map and boot script
(page.html) and the inline `onerror` handlers (app.html, economy.js) moved to nonce'd or
external scripts first.
- `frame-ancestors` allowing the game portals, with the cookie switched to `SameSite=None; Secure`
so it works inside their iframes.
- Real accounts and session tokens instead of the player id in the cookie.
+50
View File
@@ -0,0 +1,50 @@
TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss
CSS := web/static/css/app.css
ESBUILD_VERSION := 0.28.2
ESBUILD := bin/esbuild
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
# (/static/js/...) by URL.
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
.PHONY: build dev css app migrate load-config check
build: css app
go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
# client rebuild on change.
dev: $(TAILWIND) $(ESBUILD)
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
$(APP) --sourcemap --watch=forever & \
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
css: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
app: $(ESBUILD)
$(APP) --minify
migrate:
go run . migrate
load-config:
go run . load-config
check:
test -z "$$(gofmt -l .)" && go vet ./...
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
for f in web/static/js/*.js; do node --check $$f || exit 1; done
node --no-warnings scripts/check-moon-traits.mjs
node scripts/check-signals-jsx.mjs web/app
$(APP) --minify --log-level=warning
$(ESBUILD):
mkdir -p bin
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
$(TAILWIND):
mkdir -p bin
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x $@
+211 -106
View File
@@ -2,146 +2,251 @@
A browser mining game. Players own moons (staking sources) with resource wells, buy and
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
resources for balance. The game is a three.js scene with a small HTML overlay, served by
Next.js 13 (TypeScript), on top of Postgres, where most game logic lives as PL/pgSQL functions.
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
resources into refined items on a timer; those sell for more and are needed as parts for
higher tool upgrades.
Players join anonymously: on the first visit the browser creates a player
(`POST /api/user/anonymous`) and keeps its id in `localStorage`. No wallet or sign-up needed.
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
in the browser and enable each Claim button when its drill should be done, and the server
decides what is actually ready when it's clicked.
Players join anonymously: on the first visit the server creates a player and stores its id in
a cookie. No wallet or sign-up needed.
## Requirements
- Node.js 16+ and npm
- PostgreSQL 13+ (uses `gen_random_uuid()`) with the `plpython3u` language available and the
Python `requests` package installed for the server's Python (needed by `import_config()`)
- A Postgres superuser for the one-time database setup
- Go 1.25+
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
- Node.js, only for `make check` (syntax-checks the JS)
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
## Environment
Create a `.env` file in the project root (it is gitignored, never commit it):
The server reads `.env` in the project root (gitignored, never commit it). Real environment
variables take precedence.
```dotenv
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
DB_DATABASE=
DB_USER=moonminers # app user: read/write access to the tables
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
```
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
players by the last `X-Forwarded-For` address instead of the connection's; new players are
limited to 10 a minute per IP).
## Local development
`docker-compose.yml` runs a local Postgres (with `plpython3u` + `requests`) and loads the
`sql/` files automatically on first start. The app itself runs on the host.
1. Create `.env` for the local database:
```dotenv
DB_USER=moonminers
DB_PASSWORD=moonminers
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=moonminers
```
2. Start the database and import the game config (needs internet, fetches the Google Sheet):
1. Create `.env` as above.
2. Start Postgres and set up the database (roles, schema, migrations, store items from
`config/game.json`):
```bash
docker compose up -d db
docker compose exec db psql -U moonminers -d moonminers -c "select import_config();"
podman-compose up -d db # or: docker compose up -d db
make migrate
```
3. Run the app and open http://localhost:3000. A new anonymous player is created
automatically; "Start a new game" in the help panel (`?`) switches to a fresh one.
3. Run the server and open http://localhost:3000:
```bash
npm install
npm run dev
make dev
```
Other commands: `npm run lint`, `npm run build` (type-check + production build). Don't run
`npm run build` while `npm run dev` is running; both use `.next` and the dev server breaks
(fix: stop it, `rm -rf .next`, start again).
Reset the database (re-runs the SQL files): `docker compose down -v && docker compose up -d db --build`.
Apply new migrations to an existing local database:
`docker compose exec -T db psql -U postgres -d moonminers -f - < sql/migrations/<file>.sql`.
Template, CSS and JS changes show up on reload; Go changes need a restart.
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
check in `templates_test.go`, which renders every page against sample game states without a
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
and `moon-traits` (JSON on stdin; used by the moon traits check).
Reset the local database: `podman-compose down -v && podman-compose up -d db && make migrate`.
## Database changes
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
store items with the game config.
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
database and one migrated from the pre-Go schema through every migration must end up with the
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
(`staking_source.kind`, now that moons get generated names).
## Game config
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
in credits per unit), the store items with their
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
the moon types (a name, a placeholder description: the look is procedural and the
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
number; a player never gets the same name twice), and the refinery
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
panel in Equipment, where idle drills and spare attachments can also be sold back for
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
it started with (`bonus.go`).
It's embedded in the binary, so changes need a rebuild.
Store items are also kept in the database (`migrate` and `make load-config` sync them;
items removed from the file are deleted unless a player owns one).
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
## Admin panels
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
show "missing permission"). Every admin page
shares one nav: Players · Moons · Economy · Live ops · Game.
| Page | What it's for |
|---|---|
| `/utils` | Redirects to `/utils/players`. |
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
| `/utils/liveops` | Dashboard, timed events and announcements. |
Actions (all `POST`, confirmed in the page's own dialog):
| Endpoint | Actions |
|---|---|
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
Request examples are in `test-endpoints.restclient`.
### Players
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
403.
### Moons
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
edits the shared settings that every moon starts from; they're stored as one JSON row in
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
### Economy
A form for the constants, resource prices, store items and upgrade tiers (including parts),
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
and store items are re-synced.
### Live ops
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
resources claimed. Built from `event_log`, which every game action writes to.
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
- Announcements.
Live events and announcements show in a banner at the top of the game. Times are UTC.
## Interface
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
adds display: `main.js` (htmx glue,
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
## Sound
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
header mutes them (remembered per browser); they are also muted while the tab is hidden.
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
## Security
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
so oversized ones get a 413.
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
(schema checks back this up); a stake is claimed at most once (unique index).
- Errors that aren't rule violations are logged and answered with a generic 500.
## Deployment (Dokploy)
`docker-compose.prod.yml` contains the full stack:
`docker-compose.prod.yml` runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
- `app`: the Next.js server (root `Dockerfile`), on port 3000.
- `db`: Postgres 16 with plpython3u, storing data in the `db-data` volume. The SQL schema is
loaded on first start.
- `db-update`: a one-shot job that runs before the app on every deploy. It applies
`sql/migrations/*.sql`, reloads the SQL functions, syncs the app user's password, and imports
the game config from the Google Sheet.
First deploy:
Steps in Dokploy:
1. Create a **Docker Compose** service from this Git repository and set the compose path to
1. Dokploy: create a **Docker Compose** service from this repository, compose path
`docker-compose.prod.yml`.
2. In **Environment**, set two strong passwords:
2. **Environment**:
```dotenv
POSTGRES_PASSWORD=... # Postgres superuser (used by db-update)
DB_PASSWORD=... # app user "moonminers"
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # strong; app user "moonminers"
ADMIN_USER_IDS= # fill in at step 5
```
3. In **Domains**, add your domain for the service `app` with container port `3000`. Enable
HTTPS if you want Let's Encrypt.
4. Deploy. The first deploy initializes the database and imports the config. The database
server needs outbound internet access to fetch the Google Sheet.
Never set `MM_DEV` in production: it makes every player an admin.
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
(comma-separate several) and redeploy. The admin pages are at `/utils`.
To redeploy after changes, push and deploy again. Data lives in the `db-data` volume, which
survives redeploys. Back it up with `pg_dump` or Dokploy's volume backups.
Database changes for existing deployments go in a new `sql/migrations/NNN-name.sql`. Migrations
run on every deploy, so write them to be idempotent (`if not exists`, `if exists`). Also update
`sql/tables.sql` so fresh databases match.
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.
To test the production stack locally:
`POSTGRES_PASSWORD=x DB_PASSWORD=y docker compose -p mmprod -f docker-compose.prod.yml up --build`
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
(add a `ports: ["3100:3000"]` override for `app` to reach it).
## Manual database setup (without Docker)
## Endpoints
Run once against a fresh database, as a superuser, in this order:
1. Create the app role(s). The SQL grants permissions to a role named `devs`, so it must exist,
and the `DB_USER` used by the app must be a member of it (or be that role):
```sql
create role devs;
create user moonminers with password '...' in role devs;
```
2. Load the schema, seed data, and functions:
```bash
psql -d <db> -f sql/tables.sql # tables
psql -d <db> -f sql/data.sql # grants to devs + resource types
psql -d <db> -f sql/procedures.sql # game functions (stake, claim, purchase_item, ...)
psql -d <db> -f sql/import-config.sql # import_config() + plpython3u download_sheets()
```
Note: `sql/import-config.sql` marks `plpython3u` as trusted and `data.sql` grants
`pg_read_server_files` to `devs`. Both are broad permissions; review them for production.
3. Import the game config (store items, upgrades, staking sources, constants) from the Google
Sheet. `import_config()` downloads CSVs to `/tmp` on the database server, so the DB host needs
outbound internet access. Either run it directly:
```sql
select import_config();
```
or, with the app running, `POST /api/import-config`.
Re-run this whenever the game config sheet changes.
Then run the app anywhere with Node 20: set the `DB_*` variables and run `npm ci && npm run build && npm run start`
(port 3000). The root `Dockerfile` builds the same thing as an image.
## API endpoints
Refer to `test-endpoints.restclient` for an up-to-date list of endpoints. It uses the Emacs
restclient format, but the requests are easy to replay with any HTTP client.
See `test-endpoints.restclient` (Emacs restclient format).
+438
View File
@@ -0,0 +1,438 @@
package main
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
// no bonus), seated again in a free seat, swapped with another (seated or not) or
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
// which aren't refunded either); hiring them again costs the full price. Like attachments,
// changes count from the next action: running drills keep their bonus.
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AdvisorConfig struct {
// Price of the 2nd, 3rd, ... seat. Players start with one; the cap is 1 + len.
SeatPrices []float64 `json:"seatPrices"`
Items []Advisor `json:"items"`
}
type Advisor struct {
Name string `json:"name"`
Title string `json:"title"` // the specialty, e.g. "Commodities Broker"
Description string `json:"description"`
Image string `json:"image"` // portrait, in web/static/assets/advisors
Price float64 `json:"price"` // to hire
Speed float64 `json:"speed,omitempty"` // multipliers; unset is 1
Yield float64 `json:"yield,omitempty"` //
Sell float64 `json:"sell,omitempty"` //
Refine float64 `json:"refine,omitempty"` //
}
func (c *GameConfig) advisor(name string) *Advisor {
for i := range c.Advisors.Items {
if c.Advisors.Items[i].Name == name {
return &c.Advisors.Items[i]
}
}
return nil
}
// Effect describes the bonus, e.g. "+8% sell prices".
func (a *Advisor) Effect() string {
var parts []string
for _, e := range []struct {
m float64
what string
}{{a.Speed, "drill speed"}, {a.Yield, "yield"}, {a.Sell, "sell prices"}, {a.Refine, "refinery speed"}} {
if e.m > 1 {
parts = append(parts, pctText(e.m)+" "+e.what)
}
}
return strings.Join(parts, ", ")
}
// lockAdvisors locks the player's row, which serializes their advisor actions, and returns
// how many seats they own.
func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
var seats int
err := tx.QueryRow(ctx,
"select advisor_seats from users where id = $1 for no key update", userID).Scan(&seats)
return seats, err
}
// AdvisorHire is a hired advisor and their seat (0: not seated).
type AdvisorHire struct {
Name string
Seat int
}
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
seats, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return 0, nil, err
}
rows, err := tx.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire
where user_id = $1 order by hired_at, advisor for update`, userID)
if err != nil {
return 0, nil, err
}
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
return seats, hires, err
}
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
for i := range hires {
if hires[i].Name == name {
return &hires[i]
}
}
return nil
}
// freeSeat is the lowest empty seat, 0 if every seat is taken.
func freeSeat(seats int, hires []AdvisorHire) int {
for n := 1; n <= seats; n++ {
taken := false
for _, h := range hires {
taken = taken || h.Seat == n
}
if !taken {
return n
}
}
return 0
}
// hiredOne finds a hired advisor by name, or the error for one who isn't.
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
if h := findHire(hires, name); h != nil {
return h, nil
}
if cfg().advisor(name) == nil {
return nil, gameErr("Unknown advisor")
}
return nil, gameErr("%s doesn't work for you", name)
}
// setSeat moves a hired advisor to a seat (0: unseated).
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
userID, name, seat)
return err
}
// hireAdvisor hires an advisor into the lowest free seat.
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().advisor(name)
if a == nil {
return gameErr("Unknown advisor")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
if findHire(hires, a.Name) != nil {
return gameErr("%s already works for you", a.Name)
}
seat := freeSeat(seats, hires)
if seat == 0 {
return gameErr("All advisor seats are taken. Buy another seat first")
}
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
})
}
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
seats, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if seat < 1 || seat > seats {
return gameErr("No such seat")
}
if h.Seat == seat {
return gameErr("%s already sits there", name)
}
for _, o := range hires {
if o.Seat == seat {
return gameErr("Seat %d is taken by %s", seat, o.Name)
}
}
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
return err
}
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
})
}
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if h.Seat == 0 {
return gameErr("%s isn't seated", name)
}
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
return err
}
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
// other then gives up their seat to them).
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
a, err := hiredOne(hires, name)
if err != nil {
return err
}
b, err := hiredOne(hires, other)
if err != nil {
return err
}
if a.Name == b.Name {
return gameErr("Pick two different advisors")
}
if a.Seat == 0 && b.Seat == 0 {
return gameErr("Neither of them is seated")
}
// One at a time: the seats are unique.
for _, step := range []struct {
name string
seat int
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
return err
}
}
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
"seat": b.Seat, "withSeat": a.Seat})
})
}
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
_, hires, err := lockHires(ctx, tx, userID)
if err != nil {
return err
}
h, err := hiredOne(hires, name)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
return err
}
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
})
}
// buyAdvisorSeat adds a seat at the next seat's price.
func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int, error) {
var seats int
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if seats, err = lockAdvisors(ctx, tx, userID); err != nil {
return err
}
prices := cfg().Advisors.SeatPrices
if seats-1 >= len(prices) {
return gameErr("You have every seat already")
}
price := prices[seats-1]
if err := debit(ctx, tx, userID, price); err != nil {
return err
}
seats++
_, err = tx.Exec(ctx, "update users set advisor_seats = $2 where id = $1", userID, seats)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_seat", map[string]any{"seats": seats, "price": price})
})
return seats, err
}
// ------------------------------------------------------------------ view
type AdvisorsPage struct {
Cards []AdvisorCard
Seats int
Hired int // seats filled
Benched int // hired, not seated
SeatList []SeatSlot
Bench []AdvisorCard // hired, not seated
FreeSeat int // the lowest empty seat, 0 if none
MaxSeats int
NextSeat int // number of the next seat to buy
NextSeatPrice float64 // 0 at the cap
CanBuySeat bool
Bonuses []BonusRow // the combined advisor bonus, capped
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
}
// SeatSlot is a seat and who sits in it (nil: empty).
type SeatSlot struct {
N int
Advisor *AdvisorCard
}
type AdvisorCard struct {
*Advisor
Hired bool
Seat int // 0: not seated (or not hired)
CanAfford bool
}
// AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
// dismissing them.
type AdvisorPanel struct {
AdvisorCard
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
Dismiss string // the dismiss dialog's question
}
func (s *State) advisorsPage(v View) *AdvisorsPage {
c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
NextSeat: s.AdvisorSeats + 1,
Bonuses: s.BonusRows()}
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
p.NextSeatPrice = c.Advisors.SeatPrices[n]
p.CanBuySeat = s.Balance >= p.NextSeatPrice
}
card := func(a *Advisor) AdvisorCard {
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Hires {
if h.Name == a.Name {
card.Hired, card.Seat = true, h.Seat
}
}
return card
}
for i := range c.Advisors.Items {
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
}
// Hired advisors, also those since removed from the config (no bonus).
hired := func(name string) AdvisorCard {
if a := c.advisor(name); a != nil {
return card(a)
}
return card(&Advisor{Name: name, Title: "Advisor"})
}
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
for i := range p.SeatList {
p.SeatList[i].N = i + 1
}
for _, h := range s.Hires {
hc := hired(h.Name)
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
p.SeatList[h.Seat-1].Advisor = &hc
p.Hired++
} else {
p.Bench = append(p.Bench, hc)
p.Benched++
}
}
for _, slot := range p.SeatList {
if slot.Advisor == nil {
p.FreeSeat = slot.N
break
}
}
if v.Adv != "" && s.hire(v.Adv) != nil {
pc := hired(v.Adv)
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
for _, slot := range p.SeatList {
if slot.N != pc.Seat {
panel.Moves = append(panel.Moves, slot)
}
}
if pc.Seat > 0 {
panel.Swaps = p.Bench
}
p.Panel = panel
}
return p
}
// hire finds a hired advisor by name.
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
// dismissConfirm is the question before dismissing an advisor.
func dismissConfirm(c AdvisorCard) string {
if c.Price > 0 {
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
c.Name, formatCredits(c.Price))
}
return fmt.Sprintf("Dismiss %s?", c.Name)
}
// SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
// SeatConfirm is the question before buying the next seat.
func (p *AdvisorsPage) SeatConfirm() string {
return fmt.Sprintf("Add advisor seat %d for %s?", p.NextSeat, formatCredits(p.NextSeatPrice))
}
// HireWhy is why an advisor can't be hired (the Hire button's tooltip), "" if they can.
func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
switch {
case !p.SeatFree() && p.NextSeatPrice > 0:
return "All seats taken. Buy another seat first"
case !p.SeatFree():
return "All seats taken"
case !c.CanAfford:
return shortBy(c.Price - balance)
}
return ""
}
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
func (p *AdvisorsPage) SeatWhy() string {
if p.SeatFree() {
return ""
}
return "All seats taken. Unseat someone or buy another seat"
}
+867
View File
@@ -0,0 +1,867 @@
package main
// JSON API for game clients other than the htmx page (a Defold client): the same state the
// views build and the same actions, run through runAction (server.go). Times are unix
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"mime"
"net/http"
"strconv"
"strings"
"time"
)
// userHeader carries the player id for clients without cookies; responses echo it.
const userHeader = "X-MM-User"
// apiFeatures are the onboarding features the state reports (onboarding.go).
var apiFeatures = []string{"moons", "equipment", "market", "store", "refinery", "advisors",
"leaderboard", "help", "new-moon", "claim-all", "redrill", "redeploy"}
// ------------------------------------------------------------------ state
type APIState struct {
UserID string `json:"userId"`
Now int64 `json:"now"`
View APIView `json:"view"` // the resolved view (actions may move it)
Credits float64 `json:"credits"`
Resources []APIAmount `json:"resources"`
Refined []APIAmount `json:"refined"`
MoonPrice float64 `json:"moonPrice"`
Unlocked map[string]bool `json:"unlocked"`
Shine []string `json:"shine"` // revealed tabs not opened yet
Ready int `json:"ready"` // finished drills, all moons
Idle int `json:"idle"` // tools not mining
NextEnd *int64 `json:"nextEnd"`
Moons []APIMoonListItem `json:"moons"` // active moons
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
PastPage APIPastPage `json:"pastPage"`
Tools []APITool `json:"tools"`
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
Moon *APIMoon `json:"moon"` // s=moons with a moon
Well *APIWell `json:"well"` // s=moons with a well selected
Market *APIMarket `json:"market"` // once the market is unlocked
Banner []APIBanner `json:"banner"`
// Always present (api_sections.go).
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
Jobs []APIJob `json:"jobs"` // refinery batches, once the refinery is open
Bonuses []APIBonusRow `json:"bonuses"` // active advisor bonuses (the bonus chips)
LiveEvents []APILiveEvent `json:"liveEvents"`
Rules *APIRules `json:"rules"`
Catalog *APICatalog `json:"catalog,omitempty"` // left out with catalog=0 or catalog=<catalogVersion>
// A hash of the catalog: send it back as catalog= to skip the catalog while it's unchanged.
CatalogVersion string `json:"catalogVersion"`
// Section blocks: present for the view's section (s=) and the sections in include=.
Equipment *APIEquipment `json:"equipment,omitempty"`
Store *APIStore `json:"store,omitempty"`
Refinery *APIRefinery `json:"refinery,omitempty"`
Advisors *APIAdvisors `json:"advisors,omitempty"`
Leaderboard *APILeaderboard `json:"leaderboard,omitempty"`
Help *APIHelp `json:"help,omitempty"`
NewMoon *APINewMoon `json:"newMoon,omitempty"`
}
type APIView struct {
Section string `json:"section"`
Moon string `json:"moon"`
Well string `json:"well"`
Past bool `json:"past"`
Pick string `json:"pick"`
Drill string `json:"drill"`
Page int `json:"page"` // the page of past moons (from 1)
Adv string `json:"adv"` // the selected hired advisor (advisors section)
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
}
// APIPastPage is the page of past moons in past: request others with page=<n>.
type APIPastPage struct {
Page int `json:"page"` // from 1, most recently finished first
Pages int `json:"pages"` // at least 1
Total int `json:"total"` // past moons in all
PerPage int `json:"perPage"`
}
type APIAmount struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
}
// APIRating is a moon's rating as the web shows it: the tier and its label, the stars,
// the percentile and "top N%" (the share of moons at least this good).
type APIRating struct {
Tier string `json:"tier"`
Label string `json:"label"`
Stars int `json:"stars"`
Pct int `json:"pct"`
Top int `json:"top"`
}
type APIWellSupply struct {
ID string `json:"id"`
Resource string `json:"resource"`
Supply float64 `json:"supply"`
}
type APIMoonListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Rating *APIRating `json:"rating"`
Wells []APIWellSupply `json:"wells"`
Drills int `json:"drills"`
Ready int `json:"ready"`
Over bool `json:"over"` // a well has more drills than supply
Selected bool `json:"selected"`
CreatedAt int64 `json:"createdAt"`
Finished bool `json:"finished"`
FinishedAt *int64 `json:"finishedAt"`
Mined []APIAmount `json:"mined,omitempty"` // past moons: claimed from it
FinishedOn string `json:"finishedOn,omitempty"` // past moons: the web's date, e.g. "Oct 18"
}
type APITool struct {
ID string `json:"id"`
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // model code, e.g. "PD-1"
Image string `json:"image"` // static URL
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Reach int `json:"reach"` // wells one cycle mines
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
StakeID string `json:"stakeId,omitempty"` // mining, if set
}
type APIStake struct {
ID string `json:"id"`
MoonID string `json:"moonId"`
WellID string `json:"wellId"` // the first well it mines
ToolID string `json:"toolId"`
Resource string `json:"resource"`
Amount float64 `json:"amount"`
Reach int `json:"reach"`
Start int64 `json:"start"`
End int64 `json:"end"`
Secs int `json:"durationSecs"`
Ready bool `json:"ready"`
Take float64 `json:"take"` // what claiming it would get now
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
ReachText string `json:"reachText"`
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
// Moon detail only (projection, claims oldest first): what it gets in all and from
// this well, and whether that is its full amount (ok, short, none).
Gets *float64 `json:"gets,omitempty"`
Here *float64 `json:"here,omitempty"`
State string `json:"state,omitempty"`
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
}
type APIDrillGroup struct {
Drills []APIStake `json:"drills"`
Ready int `json:"ready"`
ReadyAmount float64 `json:"readyAmount"` // what claiming the ready ones gets
NextEnd *int64 `json:"nextEnd"`
Cap float64 `json:"cap"`
Label string `json:"label"` // the web's compact label of its drills, e.g. "PD-1 ×3 +1"
}
type APIMoon struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Rating *APIRating `json:"rating"`
Finished bool `json:"finished"`
FinishedOn string `json:"finishedOn,omitempty"` // past moon: the web's date, e.g. "Oct 18"
Ready int `json:"ready"`
NextEnd *int64 `json:"nextEnd"`
Wells []APIWellRow `json:"wells"`
Wide *APIDrillGroup `json:"wide"` // drills that mine several wells (claim-wide)
Sale *APIMoonSale `json:"sale"` // the resale offer, once unlocked.store (null for a past moon)
}
type APIWellRow struct {
APIWellSupply
APIDrillGroup // drills that mine only this well (claim-well)
Wide []string `json:"wide"` // ids of the moon's multi-well drills that mine it too
Selected bool `json:"selected"`
Redrill int `json:"redrill"` // idle tools that last mined it
Demand float64 `json:"demand"`
Remain float64 `json:"remain"`
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
DryAt *int64 `json:"dryAt"`
// Shared supply, as the web's well note: over (drills want more than it holds), full
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
// free after", "" with no demand).
Over bool `json:"over"`
Full bool `json:"full"`
StatusText string `json:"statusText"`
NoteText string `json:"noteText"`
}
type APIWell struct {
ID string `json:"id"`
Resource string `json:"resource"`
Rate float64 `json:"rate"` // credits per unit now
Idle []APIToolGroup `json:"idle"` // tools to deploy here
Better *APIBetterWell `json:"better"`
}
type APIToolGroup struct {
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Tier int `json:"tier"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
Free float64 `json:"free"`
Short bool `json:"short"`
Attachments []string `json:"attachments"`
}
type APIBetterWell struct {
MoonID string `json:"moonId"`
WellID string `json:"wellId"`
Resource string `json:"resource"`
Free float64 `json:"free"`
ToolID string `json:"toolId"`
}
type APIMarket struct {
Quotes []APIQuote `json:"quotes"`
Selected string `json:"selected"`
Total float64 `json:"total"` // value of everything held
Held int `json:"held"` // kinds held
Best string `json:"best"` // biggest 24h gain
Worst string `json:"worst"` // biggest 24h loss
Chart *APIChart `json:"chart"` // s=market (or include=market): the selected quote's 24h history
}
type APIQuote struct {
Item string `json:"item"`
Refined bool `json:"refined"`
Balance float64 `json:"balance"`
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
Rate float64 `json:"rate"`
Boost float64 `json:"boost"`
Advisor float64 `json:"advisor"`
Pressure float64 `json:"pressure"`
Factor float64 `json:"factor"`
Open float64 `json:"open"` // market rate 24h ago
Change float64 `json:"change"` // percent over 24h
High float64 `json:"high"`
Low float64 `json:"low"`
Value float64 `json:"value"`
// For the sell ticket: the price before selling pressure, the base price (pressure is
// counted in base-price credits) and what selling everything held pays now.
Symbol string `json:"symbol"`
Unpressed float64 `json:"unpressed"`
Base float64 `json:"base"`
SellAll float64 `json:"sellAll"`
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
// text is "" when its flag is false.
Up bool `json:"up"`
ChangeText string `json:"changeText"`
Pressed bool `json:"pressed"`
PressureText string `json:"pressureText"`
Boosted bool `json:"boosted"`
BoostText string `json:"boostText"`
Advised bool `json:"advised"`
AdvisorText string `json:"advisorText"`
}
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
type APIBanner struct {
Level string `json:"level"`
Text string `json:"text"`
Left string `json:"left"` // the web's "3h 20m left" (events only, else "")
Kind string `json:"kind"` // announcement, event
ID int `json:"id"`
End int64 `json:"end"`
}
func unixMS(t time.Time) *int64 {
if t.IsZero() {
return nil
}
ms := t.UnixMilli()
return &ms
}
func apiRating(r *Rating) *APIRating {
if r == nil {
return nil
}
return &APIRating{Tier: r.Tier, Label: r.Label, Stars: r.Stars, Pct: r.Pct, Top: r.Top()}
}
func apiAmounts(rows []ResourceBalance) []APIAmount {
out := []APIAmount{}
for _, r := range rows {
out = append(out, APIAmount{Name: r.Resource, Amount: r.Balance})
}
return out
}
func apiGear(gear []*Attachment) []string {
out := []string{}
for _, a := range gear {
out = append(out, a.Name)
}
return out
}
func apiStake(st StakeRow, projected bool) APIStake {
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
if projected {
gets, here := st.Gets, st.Here
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
}
return a
}
func apiGroup(g DrillGroup) APIDrillGroup {
a := APIDrillGroup{Drills: []APIStake{}, Ready: g.Ready, ReadyAmount: g.ReadyAmount, NextEnd: unixMS(g.NextEnd), Cap: g.Cap,
Label: g.DrillLabel()}
for _, d := range g.Drills {
a.Drills = append(a.Drills, apiStake(d, true))
}
return a
}
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
out := []APIMoonListItem{}
for _, r := range rows {
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
for _, w := range r.Wells {
m.Wells = append(m.Wells, APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply})
}
if r.Finished {
m.Mined, m.FinishedOn = apiAmounts(r.Mined), r.FinishedOn()
}
out = append(out, m)
}
return out
}
// apiState is the JSON state for a page the views built (State.page). extra are pages of
// other sections (include=) whose blocks it adds.
func apiState(p *Page, extra ...*Page) *APIState {
s, v := p.S, p.V
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
Banner: []APIBanner{}}
for _, r := range s.Refined {
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
}
for _, f := range apiFeatures {
switch f {
case "redrill":
a.Unlocked[f] = s.Unlocked("market")
case "redeploy":
a.Unlocked[f] = s.Unlocked("store")
default:
a.Unlocked[f] = s.Unlocked(f)
}
}
for _, tab := range onboardingTabs {
if s.Shine(tab) {
a.Shine = append(a.Shine, tab)
}
}
for _, t := range s.Tools {
item := s.StoreItem(t.StoreItemID)
if item == nil {
continue
}
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
tool.StakeID = st.ID
}
a.Tools = append(a.Tools, tool)
}
for _, st := range s.Stakes {
a.Stakes = append(a.Stakes, apiStake(s.stakeRow(st), false))
}
if d := p.Moon; d != nil {
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Description: d.Description, Rating: apiRating(d.Rating),
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
if d.Wide != nil {
g := apiGroup(*d.Wide)
m.Wide = &g
}
for _, w := range d.Wells {
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
for _, st := range w.Wide {
row.Wide = append(row.Wide, st.ID)
}
m.Wells = append(m.Wells, row)
}
if s.Unlocked("store") {
m.Sale = apiMoonSale(d.Sale)
}
a.Moon = m
}
if w := p.Well; w != nil {
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
for _, g := range w.Idle {
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
}
if b := w.Better; b != nil {
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
if b.Tool != nil {
well.Better.ToolID = b.Tool.ID
}
}
a.Well = well
}
// The market is part of the core loop, so it comes with every state once unlocked.
if trade := p.Trade; trade != nil || s.Unlocked("market") {
if trade == nil {
var rows []MarketRow
for _, r := range s.Resources {
rows = append(rows, MarketRow{ResourceBalance: r})
}
if s.Unlocked("refinery") {
rows = append(rows, s.refinedMarketRows()...)
}
trade = s.marketPage(rows, v)
}
a.Market = apiMarket(trade, false)
}
for _, b := range p.Banner {
a.Banner = append(a.Banner, APIBanner{Level: b.Level, Text: b.Text, Left: b.Left, Kind: b.Kind, ID: b.ID,
End: b.EndsAt.UnixMilli()})
}
a.ShowNav, a.Jobs, a.Bonuses = s.ShowNav(), []APIJob{}, apiBonusRows(s.Bonus())
a.LiveEvents, a.Rules, a.Catalog = []APILiveEvent{}, apiRules(p), apiCatalog(s)
a.CatalogVersion = a.Catalog.version()
if s.Unlocked("refinery") {
for _, j := range s.Jobs {
a.Jobs = append(a.Jobs, apiJob(j, a.Now))
}
}
for _, page := range append([]*Page{p}, extra...) {
a.addSection(page)
}
return a
}
// addSection adds the block of a page's section, unless an earlier page added it.
func (a *APIState) addSection(p *Page) {
switch p.V.Section {
case "equipment":
if a.Equipment == nil {
a.Equipment = apiEquipment(p)
}
case "store":
if a.Store == nil {
a.Store = apiStore(p)
}
case "refinery":
if a.Refinery == nil && p.Refinery != nil {
a.Refinery = apiRefinery(p)
}
case "advisors":
if a.Advisors == nil && p.Advisors != nil {
a.Advisors = apiAdvisors(p)
}
case "leaderboard":
if a.Leaderboard == nil && p.Ranking != nil {
a.Leaderboard = apiLeaderboard(p.Ranking)
}
case "help":
if a.Help == nil {
a.Help = apiHelp(p.S)
}
case "new-moon":
if a.NewMoon == nil && p.NewMoon != nil {
a.NewMoon = apiNewMoon(p.NewMoon)
}
case "market":
if p.Trade != nil && (a.Market == nil || a.Market.Chart == nil) {
a.Market = apiMarket(p.Trade, true)
}
}
}
// apiMarket maps the market; full adds the chart and the sparklines.
func apiMarket(trade *MarketPage, full bool) *APIMarket {
c := cfg()
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
if trade.Selected != nil {
mk.Selected = trade.Selected.Resource
}
if trade.Best != nil {
mk.Best = trade.Best.Resource
}
if trade.Worst != nil {
mk.Worst = trade.Worst.Resource
}
for _, q := range trade.Quotes {
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
if aq.Pressed {
aq.PressureText = q.PressureText()
}
if aq.Boosted {
aq.BoostText = q.BoostText()
}
if aq.Advised {
aq.AdvisorText = q.AdvisorText()
}
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
if full {
aq.Spark = spark(q.series)
}
mk.Quotes = append(mk.Quotes, aq)
}
if full && trade.Selected != nil {
mk.Chart = &APIChart{Item: trade.Selected.Resource, Start: trade.Chart.Start, Step: trade.Chart.Step,
Prices: append([]float64{}, trade.Selected.series...)}
}
return mk
}
// ------------------------------------------------------------------ handlers
type APIResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
UserID string `json:"userId,omitempty"`
New bool `json:"newPlayer,omitempty"` // the request had no known player: one was created
Toast *APIToast `json:"toast,omitempty"`
FX *APIFX `json:"fx,omitempty"`
// One-shot results of an action, as the web shows them: the reveal card after buy-moon,
// the deploy suggestions after buy-tool.
Reveal *APIReveal `json:"reveal,omitempty"`
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
State *APIState `json:"state,omitempty"`
}
type APIToast struct {
Message string `json:"message"`
Error bool `json:"error"`
Kind string `json:"kind"`
}
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
// out and what a sale earned.
type APIFX struct {
Kind string `json:"kind"`
Finished []string `json:"finished"`
Credits float64 `json:"credits"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: %v", err)
}
}
func apiError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, APIResponse{Error: message})
}
func apiServerError(w http.ResponseWriter, err error) {
log.Printf("error: %v", err)
apiError(w, http.StatusInternalServerError, "Something went wrong")
}
// 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
// writes the error response and returns "".
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
ctx := r.Context()
userID = r.Header.Get(userHeader)
if userID == "" {
if c, err := r.Cookie(userCookie); err == nil {
userID = c.Value
}
}
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return "", false
}
var err error
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return "", false
}
created = true
} else if isBanned(ctx, s.db, userID) {
apiError(w, http.StatusForbidden, "This account is suspended")
return "", false
}
w.Header().Set(userHeader, userID)
setUserCookie(w, userID)
return userID, created
}
// apiPage loads the player's state and builds the state for a view. get reads the
// request's include= and catalog= (see apiStateWith).
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
state, v, err := loadView(ctx, s.db, userID, v)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
return nil, err
}
return s.apiStateWith(ctx, page, get)
}
// 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) {
page := state.page(v)
if err := s.loadRanking(ctx, page); err != nil {
return nil, err
}
return page, nil
}
// apiStateWith maps a page and the include= sections, the banner and live events. include
// lists other sections (comma-separated, or "all") whose blocks to add; catalog=0 leaves the
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
page.Banner = s.banner(ctx)
var extra []*Page
for _, section := range apiIncludes(get("include")) {
if section == page.V.Section {
continue
}
v := page.V
v.Section = section
if v = v.resolve(page.S); v.Section != section {
continue // locked
}
p, err := s.apiPageFor(ctx, page.S, v)
if err != nil {
return nil, err
}
extra = append(extra, p)
}
a := apiState(page, extra...)
a.skipCatalog(get("catalog"))
events, err := listLiveEvents(ctx, s.db)
if err != nil {
return nil, err
}
for _, e := range events {
if e.Active(page.S.Now) {
a.LiveEvents = append(a.LiveEvents, APILiveEvent{ID: e.ID, Name: e.Name, Kind: e.Kind, Resource: e.Resource,
Multiplier: e.Multiplier, Start: e.StartsAt.UnixMilli(), End: e.EndsAt.UnixMilli(), Headline: e.Headline()})
}
}
return a, nil
}
// skipCatalog leaves the catalog out for catalog=0, or catalog=<version> while it's that one.
func (a *APIState) skipCatalog(param string) {
if param != "" && (param == "0" || param == a.CatalogVersion) {
a.Catalog = nil
}
}
// apiIncludes reads include=: section names, comma-separated, or "all".
func apiIncludes(include string) []string {
if include == "all" {
return []string{"equipment", "market", "store", "refinery", "advisors", "leaderboard", "help", "new-moon"}
}
var out []string
for _, name := range strings.Split(include, ",") {
if name = strings.TrimSpace(name); sections[name] && name != "moons" {
out = append(out, name)
}
}
return out
}
// handleAPIState is GET /api/state: the state for a view (s, moon, well, list, pick, drill).
func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
// Opening a newly revealed tab stops its shine, as on the page.
if err := markSeen(ctx, s.db, userID, r.FormValue("s")); err != nil {
apiServerError(w, err)
return
}
state, err := s.apiPage(ctx, userID, viewFromForm(r.FormValue), r.FormValue)
if err != nil {
apiServerError(w, err)
return
}
s.logSession(ctx, userID)
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
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
get, err := apiParams(r)
if err != nil {
apiError(w, http.StatusBadRequest, "Bad request")
return
}
userID, created := s.apiUser(w, r)
if userID == "" {
return
}
ctx := r.Context()
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
if errors.Is(err, errUnknownAction) {
apiError(w, http.StatusNotFound, "Unknown action")
return
}
if err != nil {
apiServerError(w, err)
return
}
if res.Action == "new-game" {
// A fresh player; the client stores the new id.
if !s.newPlayers.allow(r) {
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
return
}
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
apiServerError(w, err)
return
}
w.Header().Set(userHeader, userID)
w.Header().Del("Set-Cookie") // the old player's
setUserCookie(w, userID)
created, res.View, res.Message = true, View{}, "New game started"
}
state, v, err := loadView(ctx, s.db, userID, res.View)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, v)
if err != nil {
apiServerError(w, err)
return
}
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
if !toast.Error {
if res.BoughtTool != "" {
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
}
if res.Action == "buy-moon" {
resp.Reveal = apiReveal(page.moonReveal())
}
}
if resp.State, err = s.apiStateWith(ctx, page, get); err != nil {
apiServerError(w, err)
return
}
status := http.StatusOK
if toast.Error {
resp.Error, status = toast.Message, http.StatusUnprocessableEntity
} else {
resp.FX = &APIFX{Kind: toast.Kind, Finished: res.Finished, Credits: toast.Credits}
if resp.FX.Finished == nil {
resp.FX.Finished = []string{}
}
}
writeJSON(w, status, resp)
}
// apiParams reads an action's parameters from the query and form (parsed by limitBody) or
// a JSON object body; JSON numbers and booleans become strings (true is "1").
func apiParams(r *http.Request) (func(string) string, error) {
ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if ct != "application/json" {
return r.FormValue, nil
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
vals := map[string]string{}
for k, v := range body {
switch v := v.(type) {
case string:
vals[k] = v
case float64:
vals[k] = strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
vals[k] = "1"
}
case nil:
default:
return nil, errors.New("api: parameters must be strings, numbers or booleans")
}
}
return func(k string) string {
if v, ok := vals[k]; ok {
return v
}
return r.FormValue(k)
}, nil
}
// apiSeq is a client's clientSeq as it echoes back: an integer, else the string, else nil.
func apiSeq(seq string) any {
if seq == "" {
return nil
}
if n, err := strconv.ParseInt(seq, 10, 64); err == nil {
return n
}
return seq
}
+495
View File
@@ -0,0 +1,495 @@
# Moon Miners JSON API
For game clients other than the htmx page (the Defold client). Same state and rules as the
web game: `GET /api/state` is built from the same view models as `GET /`, and
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
server.go), onboarding gates included. Code: `api.go`.
Conventions:
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
server's clock: compute time left as `end - now` (plus the time since the response).
- Amounts and prices are raw numbers; the client formats them (currency symbol ₥, see
[Texts](#texts) for the web's formats).
- Reasons are codes, each with the web's player-facing text next to it (`why` and
`whyText`, `noSale` and `noSaleText`, …), and actions the web confirms carry the dialog's
words (`confirm`, see [Texts](#texts)). Other display text: names, descriptions, toast
messages, banner text and the few labels listed with their fields.
## Auth
The player id is read from the `X-MM-User` header, else the `mm_user` cookie. If neither
names an existing player, a new one is created (at most 10 per minute per IP, else
`429`), with `newPlayer: true` in the body. Every successful response returns the id in
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
Banned players get `403`.
## GET /api/state
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
clears its shine.
`catalog`: `0` leaves `state.catalog` out; the `catalogVersion` of the catalog the client
has leaves it out while it's unchanged (it changes when admins change the config). Without
it the catalog comes as before. `state.catalogVersion` is always there.
Response `200`: `{ok: true, userId, newPlayer?, state}`.
## POST /api/act/&lt;action&gt;
Body: form (`application/x-www-form-urlencoded`) or a JSON object (strings, numbers,
booleans; `true` is sent as `"1"`, `false` as unset). Query params work too. Include the
current view (`s`, `moon`, `well`, …, `include` and `catalog`) to get the state for it back.
`clientSeq` (optional, any string or integer) comes back as `clientSeq` in the response
(an integer comes back as a number, so `"007"` becomes `7`), on 200 and 422, so a client can match responses to the
predictions it queued.
Core-loop actions and their params:
| action | params | notes |
|---|---|---|
| `buy-moon` | – | view moves to the new moon |
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used |
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy` |
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well |
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
The other sections' actions:
| action | params | notes |
|---|---|---|
| `buy-tool` | `id` (store item id) | response has `buyModal` |
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
| `buy-attachment` | `item` (attachment name) | |
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
| `sell-attachment` | `item` | a spare one |
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
| `collect` | `job` (job id), or none for every finished batch | |
| `buy-slot` | – | refinery slot |
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
| `buy-seat` | – | advisor seat |
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
| `new-game` | – | creates a new player and returns its id and state |
Responses:
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
`kind` is the action (for sounds and effects); `finished` lists moons the action mined
out; `credits` is what a market sale earned. `reveal` (after `buy-moon`) and `buyModal`
(after `buy-tool`) are one-shot results, see below.
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
A locked action (its part of the game not revealed yet) says "Not available yet".
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
`500` `{ok: false, error: "Something went wrong"}`.
## GET /api/moon-settings
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
session; the web page also reads it once per page load.
## State
| key | |
|---|---|
| `userId`, `now` | |
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
| `credits` | balance |
| `resources`, `refined` | `[{name, amount}]` |
| `moonPrice` | price of the next moon |
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
| `shine` | revealed sections not opened yet |
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
| `catalogVersion` | a hash of the catalog, for `catalog=` |
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
`newMoon`) are left out unless their section is the view's (`s=`) or in `include=`.
Codes and texts: where the web shows a reason (why a button is disabled), the API gives a
short code, listed with each field, and the web's text next to it.
## Sections
### equipment (`s=equipment`)
```
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
drill}
```
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
`shortText` the line under it when credits are short.
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
`noSaleText` its text ("Claim its haul first").
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
attachments `[{name, image, effect}]`.
- `drill` (with `drill=<tool id>`, else `null`): the drill panel `{toolId, slots: [{n,
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
and `deployTo` its best well (a suggestion, as in `buyModal`).
### store (`s=store`)
```
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
```
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
`yield` and `cycleSecs` are the base stats the card shows; `rate` is credits per hour it
would earn with the player's advisors, `bestRate` the same for their best tool (0: none).
### refinery (`s=refinery`)
```
{slots: [{n, job: {id, slot, item, qty, start, end, ready, progress} | null}], slotCount, maxSlots,
nextSlot, nextSlotPrice, canBuySlot, free, ready, maxBatch,
recipes: [{item, tier, seconds, price, sellPrice, inputs: [{name, need, have}], inputValue, gain,
maxQty, bulkQty, bulkSecs, bulkBonus, owned, why, whyText}],
stock: [{name, amount}], buySlot, buySlotWhyText}
```
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
"+ Slot" is disabled. Refined items sell through `sell` like resources.
### advisors (`s=advisors`)
```
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
bench, bonuses, buySeat, buySeatWhyText, selected,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
```
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
dismissed).
The web's panel for a hired advisor (any client may do the same): for every other seat,
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
### leaderboard (`s=leaderboard`)
`{players, top: [{rank, name, credits, me}], you, me: {rank, credits}}`: `you` is the
player's row when they're not in `top` (else `null`); `me` their place either way.
### help (`s=help`)
`{steps: [string], note, newGame}`: the "How to play" steps (the web's text; `note` says
progress is saved on this device). Its button is `new-game`, `newGame` its dialog.
### newMoon (`s=new-moon`)
`{price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: `after` the
balance after buying, `need` the credits missing, `whyText` why the button is disabled, `types` the moon types it can roll,
`resources` what its wells can hold. Buy with `buy-moon`.
### reveal (in the `buy-moon` response)
```
{moonId, name, kind, description, rating, headline, sub,
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
next: {price, canBuy, why, whyText}}
```
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
button), `credits` ("Need ₥X more", the button disabled) or `""`.
### buyModal (in the `buy-tool` response)
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
("Highest value").
### moon.sale (with `s=moons`, once `unlocked.store`)
```
{total, market, replace, delta, capped, factor, lines: [{resource, supply, rate, value}],
last, running, ready, holdEnd, canSell, why, whyText, confirm}
```
`total` is the offer (`market` × `factor`, capped), `replace` a new moon's price after the
sale, `delta` = total − replace; `ready` finished drills are claimed in the sale.
`why`: `last` (only moon), `running` (drills on it), `hold` (sellable at `holdEnd`) or `""`;
`whyText` the web's text ("Wait for 2 drills to finish", "Just bought: you can sell it in
4:27", at `now`); `confirm` the sell-moon dialog.
### market chart (`s=market`)
`market.chart`: `{item, start, step, prices}`, the selected quote's 24h market rate (before
events, advisors and pressure), one point every `step` ms from `start` (289 points, the last
is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkline).
## Texts
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
the web's title, question, OK button and whether it's destructive, e.g.
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
Not sent (the client words them from the numbers, like the web's templates):
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
supply" (`over`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
The web's formats:
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
## Prediction
The client may show results before the server answers; the next state replaces them.
- Stake: a drill takes `tool.cycleSecs` and gets `tool.yield` (× `rules.boosts.yield` of
the well's resource, if any). Each stake carries `start`, `end` and `take` (what claiming
it would get now).
- Sell: `amount` units pay `amount × unpressed × avg`, with
`avg = floor + (1 − floor) × scale × (e^(−p/scale) − e^(−(p+d)/scale)) / d`,
`p` the quote's `pressure`, `d = amount × base` and `floor`, `scale` from
`rules.pressure` (`avg` is `factor` when `d` is 0). `sellAll` is that for the whole
balance. Pressure then becomes `min(p + d, rules.pressure.cap)` and halves every
`halfLifeSecs`.
- Refine: a batch of `qty` takes `round(seconds × qty^bulkExponent / speed)` seconds
(`seconds` per recipe, the rest in `rules.refinery`).
- Every response's `state.now` is the server clock; `clientSeq` matches a response to its
request.
## Example
```
POST /api/act/stake
X-MM-User: 29dea5d5-709d-4c55-a269-cd90e4379fe9
Content-Type: application/json
{"tool": "0fbfcb4f-…", "target": "b9d643c4-…", "moon": "54daa782-…", "well": "b9d643c4-…", "clientSeq": 12}
```
```json
{
"ok": true,
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"clientSeq": 12,
"toast": {"message": "Drill deployed", "error": false, "kind": "stake"},
"fx": {"kind": "stake", "finished": [], "credits": 0},
"state": {
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
"now": 1792391815265,
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
"credits": 1644.63,
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
"refined": [],
"moonPrice": 400,
"unlocked": {"moons": true, "equipment": true, "market": true, "store": true, "refinery": false, "advisors": false,
"leaderboard": true, "help": true, "new-moon": true, "claim-all": false, "redrill": true, "redeploy": true},
"shine": ["equipment", "store"],
"ready": 0, "idle": 0, "nextEnd": 1792391819259,
"moons": [{"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70},
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47}],
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
"finished": false, "finishedAt": null}],
"past": [],
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
"durationSecs": 4, "ready": false, "take": 2}],
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
"wide": null},
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
"market": {"quotes": [{"item": "Azurium", "refined": false, "balance": 0, "price": 6.864, "rate": 6.864, "boost": 1,
"advisor": 1, "pressure": 0, "factor": 1, "open": 6.899, "change": -0.501,
"high": 7.151, "low": 5.786, "value": 0}],
"selected": "Azurium", "total": 0},
"banner": [],
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
"catalogVersion": "9adb4eabab929f5d"
}
}
```
## Section examples
From a live server (long ids and arrays shortened).
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
```json
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
"image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
```
`s=store`, `state.store`:
```json
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
```
`s=refinery`, `state.refinery`:
```json
{"slots": [{"n": 1, "job": {"id": "1155…", "slot": 1, "item": "Dusk Alloy", "qty": 3, "start": 1792402355103,
"end": 1792402357103, "ready": false, "progress": 0.004}}],
"slotCount": 1, "maxSlots": 4, "nextSlot": 2, "nextSlotPrice": 1500, "canBuySlot": true, "free": 0, "ready": 0, "maxBatch": 25,
"recipes": [{"item": "Dusk Alloy", "tier": 1, "seconds": 1, "price": 30, "sellPrice": 29.52,
"inputs": [{"name": "Shadowstone", "need": 2, "have": 494}, {"name": "Sollux", "need": 2, "have": 494}],
"inputValue": 15, "gain": 100, "maxQty": 25, "bulkQty": 25, "bulkSecs": 7, "bulkBonus": 72.4,
"owned": 0, "why": "busy"}, "…"],
"stock": []}
```
`s=advisors`, `state.advisors`:
```json
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
"buySeatWhyText": "",
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
"canSeat": false, "seatWhy": "", "seatWhyText": "",
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
"ok": "Dismiss", "danger": true}, "removed": false},
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
```
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
`s=new-moon`, `state.newMoon`:
```json
{"price": 400, "canAfford": true, "after": 73179.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 5}
```
`POST /api/act/buy-moon`, `reveal`:
```json
{"moonId": "9b79…", "name": "Lithara-η", "kind": "Lumineer", "description": "…",
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
"traits": [], "maxSupply": 90, "fullAt": 84,
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
```
`POST /api/act/buy-tool`, `buyModal`:
```json
{"toolId": "e79d…", "storeItemId": 1, "cycleSecs": 4, "yield": 2, "reach": 1, "noMoons": false, "allEmpty": false,
"suggestions": [{"moonId": "9b79…", "moonName": "Lithara-η", "wellId": "92cf…", "resource": "Azurium", "supply": 37,
"credits": 13.6, "perMin": 204.0, "idle": true, "reason": "best-value"}, "…"]}
```
`s=moons&moon=…`, `state.moon.sale`:
```json
{"total": 91, "market": 919.54, "replace": 400, "delta": -309, "capped": false, "factor": 0.1,
"lines": [{"resource": "Nebulance", "supply": 64, "rate": 10.55, "value": 675.48}, "…"],
"last": false, "running": 0, "ready": 0, "holdEnd": 1792402983084, "canSell": false, "why": "hold"}
```
`s=market&pick=Azurium`, `state.market` (one quote):
```json
{"selected": "Azurium", "held": 6, "best": "Cryo Cell", "worst": "Dusk Alloy", "total": 17038.73,
"chart": {"item": "Azurium", "start": 1792315800000, "step": 300000, "prices": [7.03, "… 289 points"]},
"quotes": [{"item": "Azurium", "refined": false, "balance": 500, "price": 6.80, "rate": 6.80, "boost": 1, "advisor": 1,
"pressure": 0, "factor": 1, "open": 7.03, "change": -3.33, "high": 7.15, "low": 5.79, "value": 3400.28,
"symbol": "AZU", "unpressed": 6.80, "base": 6.5, "sellAll": 3384.06, "spark": [7.03, "… 97 points"]}, "…"]}
```
+1049
View File
File diff suppressed because it is too large. Load diff
+304
View File
@@ -0,0 +1,304 @@
package main
// API check (make check): builds the JSON state (api.go) for every view of the template
// check's in-memory states and checks a few fields. Run with `go test -run API`.
import (
"encoding/json"
"testing"
)
func TestAPI(t *testing.T) {
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
asides := map[bool]bool{}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
for _, v := range testViews(s) {
s.pagePastInMemory(v.Page)
p := s.page(v.resolve(s))
testRanking(p)
a := apiState(p)
if _, err := json.Marshal(a); err != nil {
t.Fatalf("marshal %+v: %v", v, err)
}
checkAPISections(t, s, p.V, a)
checkAPITexts(t, p.V, a)
for section, present := range map[string]bool{"equipment": a.Equipment != nil, "store": a.Store != nil,
"refinery": a.Refinery != nil, "advisors": a.Advisors != nil, "leaderboard": a.Leaderboard != nil,
"help": a.Help != nil, "new-moon": a.NewMoon != nil} {
if present != (p.V.Section == section) {
t.Errorf("view %+v: %s block present %v", v, section, present)
}
}
if a.Rules == nil || a.Catalog == nil || len(a.Catalog.Tools) != len(s.Config.StoreItems) {
t.Errorf("view %+v: rules or catalog missing", v)
}
if (len(a.Jobs) > 0) && !s.Unlocked("refinery") {
t.Errorf("view %+v: refinery jobs before the refinery is open", v)
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
}
}
if (a.Market.Chart != nil) != (p.V.Section == "market") {
t.Errorf("view %+v: market chart %v", v, a.Market.Chart != nil)
}
if c := a.Market.Chart; c != nil && (len(c.Prices) != marketPoints || c.Item != a.Market.Selected) {
t.Errorf("view %+v: chart of %s with %d points", v, c.Item, len(c.Prices))
}
}
if a.Moon != nil && (a.Moon.Sale != nil) != (s.Unlocked("store") && p.Moon.Sale != nil) {
t.Errorf("view %+v: moon sale %v", v, a.Moon.Sale != nil)
}
if p.Moon != nil {
// The reveal card and the buy modal, as an action response maps them.
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
t.Errorf("view %+v: reveal %+v", v, r)
}
}
for _, tool := range s.Tools {
if m := apiBuyModal(s.buySuggestions(tool.ID)); m == nil || m.ToolID != tool.ID {
t.Errorf("buy modal for %s: %+v", tool.ID, m)
}
}
// Every section block at once (include=all).
var extra []*Page
for _, section := range apiIncludes("all") {
ev := p.V
ev.Section = section
if ev = ev.resolve(s); ev.Section == section {
ep := s.page(ev)
testRanking(ep)
extra = append(extra, ep)
}
}
all := apiState(p, extra...)
for _, ep := range extra {
checkAPISections(t, s, ep.V, all)
}
checkAPITexts(t, p.V, all)
if _, err := json.Marshal(all); err != nil {
t.Fatalf("marshal %+v with every section: %v", v, err)
}
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
p.Advisors != nil && p.Advisors.Panel != nil
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
}
asides[a.View.Aside] = true
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
t.Errorf("view %+v: state has %+v", p.V, a.View)
}
if len(a.Stakes) != len(s.Stakes) || len(a.Tools) != len(s.Tools) {
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
}
for _, st := range a.Stakes {
if st.End-st.Start != int64(st.Secs)*1000 {
t.Errorf("stake %s: end - start is not its duration", st.ID)
}
}
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
t.Errorf("view %+v: moon or well missing", v)
}
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
}
if (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
}
}
}
if !asides[true] || !asides[false] {
t.Errorf("views with and without the aside: %v", asides)
}
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
}
// The catalog: a stable version, skipped with catalog=0 or catalog=<version> only.
s := veteranState()
a, b := apiState(s.page(View{}.resolve(s))), apiState(s.page(View{}.resolve(s)))
if a.CatalogVersion == "" || a.CatalogVersion != b.CatalogVersion {
t.Errorf("catalog versions %q, %q", a.CatalogVersion, b.CatalogVersion)
}
for param, kept := range map[string]bool{"": true, "1": true, "stale": true, "0": false, a.CatalogVersion: false} {
c := apiState(s.page(View{}.resolve(s)))
if c.skipCatalog(param); (c.Catalog != nil) != kept {
t.Errorf("catalog=%q: catalog present %v", param, c.Catalog != nil)
}
}
}
// checkAPITexts checks that ratings carry their label and "top N%", every reason code its
// text and every confirm its question, as the web shows them.
func checkAPITexts(t *testing.T, v View, a *APIState) {
t.Helper()
fail := func(what string, x any) { t.Errorf("view %+v: %s: %+v", v, what, x) }
rating := func(r *APIRating) {
if r != nil && (r.Label == "" || r.Top < 1) {
fail("rating", r)
}
}
for _, m := range append(append([]APIMoonListItem{}, a.Moons...), a.Past...) {
rating(m.Rating)
if m.Finished && m.FinishedOn == "" {
fail("past moon date", m)
}
}
if m := a.Moon; m != nil {
rating(m.Rating)
for _, w := range m.Wells {
if len(w.Drills) > 0 && w.Label == "" {
fail("well label", w)
}
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
fail("well note", w)
}
for _, d := range w.Drills {
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
fail("drill fate", d)
}
}
}
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
fail("moon sale", sale)
}
}
if a.Market != nil {
for _, q := range a.Market.Quotes {
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
fail("quote texts", q)
}
}
}
for _, b := range a.Bonuses {
if b.Label == "" || b.Text == "" {
fail("bonus", b)
}
}
if c := a.Catalog; c != nil {
if len(c.Reach) != len(c.Resources) {
fail("reach texts", c.Reach)
}
for _, at := range c.Attachments {
if at.EffectText == "" {
fail("attachment effect", at)
}
}
}
if e := a.Equipment; e != nil {
for _, r := range e.Tools {
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
fail("tool row texts", r)
}
}
if d := e.Drill; d != nil {
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
fail("drill panel texts", d)
}
for _, sp := range d.Spares {
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
fail("spare texts", sp)
}
}
}
}
if st := a.Store; st != nil {
for _, tool := range st.Tools {
if tool.CanAfford == (tool.WhyText != "") {
fail("store tool text", tool)
}
}
}
if r := a.Refinery; r != nil {
if (r.NextSlotPrice > 0) != (r.BuySlot != nil) {
fail("buy slot confirm", r.BuySlot)
}
for _, rc := range r.Recipes {
if (rc.Why != "") != (rc.WhyText != "") {
fail("recipe texts", rc)
}
}
}
if ad := a.Advisors; ad != nil {
if (ad.NextSeatPrice > 0) != (ad.BuySeat != nil) {
fail("buy seat confirm", ad.BuySeat)
}
for _, up := range ad.Advisors {
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
fail("advisor texts", up)
}
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
fail("advisor seat texts", up)
}
}
}
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
fail("new game confirm", h.NewGame)
}
}
// testRanking stands in for the leaderboard the server loads (loadRanking).
func testRanking(p *Page) {
if p.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}}
}
}
// checkAPISections checks that the block of a view's section is in the state, as the
// section's view model has it.
func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
t.Helper()
switch v.Section {
case "equipment":
if a.Equipment == nil || len(a.Equipment.Tools) == 0 && len(s.Tools) > 0 {
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
t.Errorf("view %+v: %d slots", v, len(d.Slots))
}
case "store":
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
t.Errorf("view %+v: store %+v", v, a.Store)
}
case "refinery":
if a.Refinery == nil || len(a.Refinery.Recipes) != len(cfg().Refinery.Items) || len(a.Refinery.Slots) < s.RefinerySlots {
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
}
case "advisors":
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
}
for _, h := range s.Hires {
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
}
}
case "leaderboard":
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
}
case "help":
if a.Help == nil || len(a.Help.Steps) == 0 {
t.Errorf("view %+v: help %+v", v, a.Help)
}
case "new-moon":
if a.NewMoon == nil || a.NewMoon.Price != s.MoonPrice() || a.NewMoon.WellsMin < 1 {
t.Errorf("view %+v: new moon %+v", v, a.NewMoon)
}
case "market":
if a.Market == nil || a.Market.Chart == nil {
t.Errorf("view %+v: market chart missing", v)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
# Advisor portraits
`web/static/assets/advisors/<slug>.png` (`image` in `config/game.json` `advisors`) are
generated with Codex CLI image generation (codex-cli 0.162.1, signed in with ChatGPT),
then cropped to a square, resized to 512×512 and quantized with ImageMagick.
## Prompt
Each prompt is the style prompt followed by the advisor's subject line.
Style (the same for all):
> Stylized painted sci-fi character portrait, head-and-shoulders bust, 3/4 view, centered
> with some headroom, space mining corporation, moody rim lighting, plain dark teal-navy
> background fading to near-black (#040a16) at the edges with a faint subtle starfield, no
> scenery, cohesive limited palette, clean readable silhouette, game card art, no text, no
> logo, no border.
Subjects:
- `ada-voss`: Subject: Ada Voss, operations foreman, a woman in her 30s with a determined
look, orange hard hat with a glowing headlamp, headset microphone, orange-and-yellow
hi-vis safety vest over a dark work suit.
- `rook-halvorsen`: Subject: Rook Halvorsen, veteran geologist, a man in his 50s with a
thick red-brown beard, headlamp strap across his forehead, olive field jacket, holding up
a glowing cyan ore sample that lights his face from below.
- `mira-castellanos`: Subject: Mira Castellanos, market broker, a woman in her 40s with
dark hair pulled back, a brass holographic monocle over one eye glowing cyan, sharp
tailored dark suit, small gold lapel pin, confident knowing expression.
- `tobias-kel`: Subject: Tobias Kel, refinery chief, a burly man in his 40s with a shaved
head and a big bushy moustache, welding goggles pushed up on his forehead, rust-orange
heat-proof refinery jacket, warm furnace glow on one side.
- `juno-park`: Subject: Juno Park, company executive, a woman in her 30s with a sleek black
bob haircut, navy command uniform with violet epaulettes, a thin transparent HUD glass
strip over one eye glowing violet, calm commanding expression.
## Generate
One run per image (two candidates per advisor were made and the best kept):
```sh
codex exec --skip-git-repo-check -s workspace-write --enable image_generation -C "$OUT" \
"Use your built-in image generation tool (not code, not an API script) to generate ONE square image at the largest square size available, and save it as PNG to $OUT/$SLUG.png. Use exactly this prompt: '$STYLE $SUBJECT' Then print the saved path."
```
Codex doesn't always return a square (1145×1374 and 1024×1536 also came back), so crop
a square near the top (offset a tenth of the extra height), then:
```sh
magick in.png -crop ${W}x${W}+0+$Y +repage -filter Lanczos -resize 512x512 -strip full.png
magick full.png -dither FloydSteinberg -colors 256 -define png:compression-level=9 PNG8:$SLUG.png
```
+18
View File
@@ -0,0 +1,18 @@
# Drill attachment art
The attachment images (`web/static/assets/attachments/<image>`, `image` in
`config/game.json` `attachments.items`) are rendered in Blender (4.5 LTS, Cycles on
CPU) with the equipment pipeline: `art/equipment/lib.py` for materials, the regolith
ground, world, lights, camera and render settings.
```sh
art/attachments/render_all.sh # all four
art/attachments/render_all.sh diamond-bit # just one
```
`render.py` builds and renders at 1024px, `finish.py` (system `python3` with Pillow)
writes the 512px PNGs and contact sheets in `build/`. `--res N` and `--samples N`
give quick previews.
`parts.py`: one build function per attachment and `PARTS` (key = image name, detail
scale `S`, ground seed).
+48
View File
@@ -0,0 +1,48 @@
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
python3 art/attachments/finish.py [RAW_DIR] [--sheet DIR]
Needs Pillow. Writes web/static/assets/attachments/<name>.png for every <name>.png in RAW_DIR
(the names are the `image` slugs in config/game.json).
"""
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'attachments'))
def main():
a = sys.argv[1:]
sheet_dir = None
if '--sheet' in a:
i = a.index('--sheet')
sheet_dir = a[i + 1]
del a[i:i + 2]
raw = a[0] if a else os.path.join(HERE, 'build')
os.makedirs(OUT, exist_ok=True)
imgs = []
for fname in sorted(f for f in os.listdir(raw) if f.endswith('.png') and not f.startswith('contact_sheet')):
im = Image.open(os.path.join(raw, fname)).convert('RGB').resize((512, 512), Image.LANCZOS)
out = os.path.join(OUT, fname)
im.save(out, 'PNG', optimize=True)
if os.path.getsize(out) > 200_000:
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
q.save(out, 'PNG', optimize=True)
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
imgs.append(Image.open(out).convert('RGB'))
if sheet_dir and imgs:
os.makedirs(sheet_dir, exist_ok=True)
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
pad = 8
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
for i, im in enumerate(imgs):
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
sheet.save(os.path.join(sheet_dir, fn))
print('contact sheets in', sheet_dir)
main()
+174
View File
@@ -0,0 +1,174 @@
"""The four drill attachments. Each build function draws into a lib.Builder (art/equipment).
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
Keys are the image names (`image` in config/game.json `attachments.items`, minus .png).
"""
import math
from lib import CYAN, AMBER
PI = math.pi
# Turbo Core: an overdriven motor core. Finned housing, glowing copper windings, flange.
def turbo_core(b):
z0 = 0.05
# mounting flange and feet
b.cyl(0.17, 0.035, 'graphite', (0, 0, z0 + 0.0175))
for k in range(8):
a = k / 8 * 2 * PI + PI / 8
b.cyl(0.011, 0.012, 'chrome', (math.cos(a) * 0.145, math.sin(a) * 0.145, z0 + 0.04), seg=8)
for sx in (-1, 1):
b.box((0.06, 0.32, 0.05), 'dark', (sx * 0.14, 0, 0.025), bevel=0.008)
# housing: lower stator drum with radial fins
zc = z0 + 0.035
b.cyl(0.12, 0.22, 'steel', (0, 0, zc + 0.11))
for k in range(20):
a = k / 20 * 2 * PI
with b.at((0, 0, zc + 0.11), (0, 0, a)):
b.box((0.05, 0.008, 0.2), 'graphite', (0.135, 0, 0), bevel=0.002)
for z in (zc + 0.015, zc + 0.205):
b.torus(0.155, 0.012, 'graphite', (0, 0, z))
# exposed windings: glowing core behind a cage
zw = zc + 0.22
b.cyl(0.085, 0.13, 'core_hot', (0, 0, zw + 0.065))
b.helix((0, 0), 0.092, zw + 0.008, zw + 0.122, 7, 0.008, 'brass')
for k in range(8):
a = k / 8 * 2 * PI
b.strut((math.cos(a) * 0.105, math.sin(a) * 0.105, zw), (math.cos(a) * 0.105, math.sin(a) * 0.105, zw + 0.13),
0.006, 'chrome', seg=8)
b.light((0, -0.16, zw + 0.07), AMBER, 6, 0.03)
b.light((0.16, 0.0, zw + 0.07), AMBER, 3, 0.03)
# cap with turbine fan and shaft coupling
zt = zw + 0.13
b.cyl(0.115, 0.03, 'white', (0, 0, zt + 0.015))
b.cyl(0.08, 0.02, 'dark', (0, 0, zt + 0.04))
for k in range(12):
a = k / 12 * 2 * PI
with b.at((0, 0, zt + 0.045), (0, 0, a)):
b.box((0.065, 0.004, 0.012), 'metal', (0.04, 0, 0), (0.6, 0, 0), bevel=0.001)
b.cyl(0.03, 0.08, 'chrome', (0, 0, zt + 0.08))
b.cyl(0.04, 0.02, 'graphite', (0, 0, zt + 0.125))
for k in range(4):
a = k / 4 * 2 * PI
b.box((0.012, 0.012, 0.02), 'chrome', (math.cos(a) * 0.04, math.sin(a) * 0.04, zt + 0.14), (0, 0, a), bevel=0.002)
# status lamp, label plate and cables
b.box((0.08, 0.006, 0.04), 'white', (0.0, -0.157, zc + 0.12), bevel=0.002)
b.box((0.05, 0.004, 0.006), 'amber', (0.0, -0.161, zc + 0.12), bevel=0)
b.lamp((0.1, -0.1, zt + 0.035), 0.012, 'red', housing=False)
b.tube([(-0.12, 0.08, zw + 0.03), (-0.2, 0.12, zw - 0.05), (-0.24, 0.05, 0.02)], 0.012, 'rubber')
b.tube([(-0.12, 0.03, zw + 0.06), (-0.22, 0.02, zw - 0.02), (-0.26, -0.06, 0.02)], 0.009, 'orange')
# Diamond Bit: a fluted drill bit with a diamond-studded cutting crown, upright on its shank.
def diamond_bit(b):
# shank in a coupling block on the ground
b.box((0.2, 0.2, 0.05), 'graphite', (0, 0, 0.025), bevel=0.01)
b.hazard_band(0.21, 0.21, 0.015, (0, 0, 0.055))
b.cyl(0.065, 0.08, 'steel', (0, 0, 0.1))
for k in range(6):
a = k / 6 * 2 * PI
b.box((0.018, 0.035, 0.07), 'metal', (math.cos(a) * 0.066, math.sin(a) * 0.066, 0.1), (0, 0, a), bevel=0.004)
# fluted body
z0, z1 = 0.14, 0.44
b.cyl(0.045, z1 - z0, 'graphite', (0, 0, (z0 + z1) / 2))
for k in range(2):
pts = []
n = 48
for i in range(n + 1):
t = i / n
a = k * PI + t * 2.6 * PI
pts.append((math.cos(a) * 0.055, math.sin(a) * 0.055, z0 + (z1 - z0) * t))
b.tube(pts, 0.024, 'metal', seg=12, steps=2)
# crown: carbide ring with diamond studs, cone tip
zc = z1 + 0.01
b.lathe([(0.07, zc - 0.03), (0.09, zc), (0.09, zc + 0.035), (0.08, zc + 0.045), (0.0, zc + 0.045)], 'graphite')
b.lathe([(0.078, zc + 0.045), (0.05, zc + 0.09), (0.015, zc + 0.13), (0.0, zc + 0.135)], 'steel')
for k in range(12):
a = k / 12 * 2 * PI
b.sphere(0.014, 'diamond', (math.cos(a) * 0.089, math.sin(a) * 0.089, zc + 0.02), seg=6)
for k in range(7):
a = k / 7 * 2 * PI + 0.3
b.sphere(0.011, 'diamond', (math.cos(a) * 0.05, math.sin(a) * 0.05, zc + 0.088), seg=6)
b.sphere(0.01, 'diamond', (0, 0, zc + 0.136), seg=6)
b.light((0.0, -0.15, zc + 0.06), CYAN, 3, 0.02)
# a couple of loose diamonds on the ground
for x, y in ((0.16, -0.1), (-0.13, -0.15), (0.07, -0.17)):
b.sphere(0.013, 'diamond', (x, y, 0.008), seg=6)
# Cryo Cooler: a coolant jacket that wraps the spindle. Frosted canister, glowing coolant
# windows, coiled lines and a gauge.
def cryo_cooler(b):
b.cyl(0.16, 0.03, 'graphite', (0, 0, 0.015))
b.cyl(0.15, 0.015, 'cyan_soft', (0, 0, 0.035))
# jacket
z0, z1 = 0.045, 0.34
b.cyl(0.11, z1 - z0, 'frost', (0, 0, (z0 + z1) / 2))
for z in (z0 + 0.02, (z0 + z1) / 2, z1 - 0.02):
b.torus(0.112, 0.012, 'steel', (0, 0, z))
for k in range(4):
a = k / 4 * 2 * PI + PI / 4
with b.at((0, 0, 0), (0, 0, a)):
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z0 + 0.075), bevel=0.003)
b.box((0.012, 0.04, 0.1), 'coolant', (0.0, -0.104, z1 - 0.075), bevel=0.003)
b.light((0, -0.25, 0.19), CYAN, 8, 0.05)
# spindle through the middle
b.cyl(0.035, 0.12, 'chrome', (0, 0, z1 + 0.06))
b.cyl(0.075, 0.03, 'white', (0, 0, z1 + 0.015))
b.cyl(0.045, 0.02, 'graphite', (0, 0, z1 + 0.12))
# coolant coil around the jacket and feed lines
b.helix((0, 0), 0.128, z0 + 0.04, z1 - 0.04, 4, 0.007, 'chrome')
b.tube([(0.12, 0.05, z1 - 0.04), (0.2, 0.08, z1 + 0.02), (0.24, 0.12, 0.1), (0.26, 0.2, 0.02)], 0.014, 'white')
b.tube([(-0.12, 0.05, z0 + 0.05), (-0.2, 0.1, 0.05), (-0.22, 0.2, 0.02)], 0.012, 'rubber')
# pressure gauge
with b.at((-0.08, -0.1, z1 - 0.05), (0, 0, -0.6)):
b.cyl(0.03, 0.02, 'steel', (0, -0.02, 0), axis='y')
b.cyl(0.024, 0.005, 'white', (0, -0.031, 0), axis='y')
b.box((0.003, 0.002, 0.02), 'red', (0.005, -0.034, 0.004), (0, 0.6, 0), bevel=0)
# frost chips on the ground
for x, y, r in ((0.18, -0.06, 0.016), (-0.17, -0.1, 0.012), (0.08, -0.18, 0.01)):
b.sphere(r, 'ice', (x, y, r * 0.4), seg=10, scale=(1.2, 1.0, 0.6))
# Seam Scanner: ground-penetrating radar on a sled. Antenna panel facing the ground,
# rotating dish, a scan display.
def seam_scanner(b):
# sled runners
for sx in (-1, 1):
b.box((0.03, 0.42, 0.025), 'dark', (sx * 0.15, 0, 0.0125), bevel=0.008)
b.strut((sx * 0.15, -0.21, 0.012), (sx * 0.15, -0.25, 0.05), 0.013, 'dark')
# radar body with glowing underside emitter
b.box((0.36, 0.34, 0.08), 'white', (0, 0, 0.075), bevel=0.02)
b.box((0.3, 0.28, 0.02), 'cyan', (0, 0, 0.032), bevel=0.004)
b.light((0, 0, 0.01), CYAN, 5, 0.1)
b.hazard_band(0.37, 0.02, 0.02, (0, -0.17, 0.085))
# electronics box
b.box((0.22, 0.2, 0.1), 'graphite', (0.02, 0.04, 0.165), bevel=0.012)
b.vent(0.12, 0.05, (0.131, 0.06, 0.165), (0, 0, PI / 2))
# display with a scan grid
with b.at((-0.02, -0.08, 0.23), (-0.5, 0, 0)):
b.box((0.16, 0.012, 0.11), 'dark', (0, 0, 0), bevel=0.006)
b.box((0.14, 0.004, 0.09), 'glass', (0, -0.007, 0), bevel=0)
# mast with a dish
b.cyl(0.018, 0.16, 'steel', (0.08, 0.1, 0.29))
b.cyl(0.03, 0.02, 'graphite', (0.08, 0.1, 0.37))
b.lathe([(0.0, 0.0), (0.05, 0.012), (0.09, 0.04)], 'white', (0.08, 0.1, 0.39), (0.5, 0.0, -0.4))
b.strut((0.08, 0.1, 0.39), (0.08 - 0.02, 0.1 - 0.035, 0.45), 0.004, 'graphite')
b.lamp((0.06, 0.065, 0.455), 0.01, 'cyan', housing=False)
# antenna whips and lamp
b.strut((-0.13, 0.12, 0.215), (-0.15, 0.14, 0.42), 0.004, 'graphite')
b.lamp((-0.15, 0.14, 0.425), 0.009, 'amber', housing=False)
b.lamp((0.14, -0.1, 0.13), 0.01, 'amber')
# cable reel at the back
b.cyl(0.05, 0.05, 'orange', (-0.1, 0.13, 0.17), axis='x')
b.cyl(0.035, 0.055, 'rubber', (-0.1, 0.13, 0.17), axis='x')
PARTS = {
'turbo-core': {'build': turbo_core, 'S': 0.3, 'seed': 11},
'diamond-bit': {'build': diamond_bit, 'S': 0.25, 'seed': 12, 'fill': 0.8},
'cryo-cooler': {'build': cryo_cooler, 'S': 0.3, 'seed': 13},
'seam-scanner': {'build': seam_scanner, 'S': 0.3, 'seed': 14},
}
+87
View File
@@ -0,0 +1,87 @@
"""Build and render the drill attachment icons.
blender -b --factory-startup --python art/attachments/render.py -- [names...|all] [--out DIR]
[--res N] [--samples N]
Raw renders go to --out (default art/attachments/build); finish.py turns them into the
game images. Uses art/equipment/lib.py, so the parts match the store equipment.
"""
import os
import sys
import time
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, '..', 'equipment'))
import lib # noqa: E402
import parts # noqa: E402
from lib import srgb # noqa: E402
def args():
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
opts = {'out': os.path.join(HERE, 'build'), 'res': 1024, 'samples': 96}
names = []
i = 0
while i < len(a):
k = a[i]
if k == '--out':
opts['out'] = a[i + 1]; i += 2
elif k in ('--res', '--samples'):
opts[k[2:]] = int(a[i + 1]); i += 2
else:
names.append(k); i += 1
if not names or names == ['all']:
names = list(parts.PARTS)
return names, opts
def materials(S):
M = lib.make_materials(S)
M['core_hot'] = lib.emissive('core_hot', srgb('#ff8a2b'), 9.0, base=srgb('#5a2a0a'))
M['diamond'] = lib.simple('diamond', srgb('#e8f8ff'), metal=0.3, rough=0.03, coat=1.0,
emission=srgb('#9fe8ff'), estrength=0.35)
M['frost'] = lib.paint('frost', srgb('#e4f1f8'), S, rough=0.55, panels=False, wear=0.2, dirt=0.25)
M['coolant'] = lib.emissive('coolant', srgb('#5fd4ff'), 5.0, base=srgb('#10303c'))
M['ice'] = lib.simple('ice', srgb('#cfeefc'), rough=0.15, coat=1.0, emission=srgb('#5fd4ff'), estrength=0.3)
return M
def build_scene(name):
spec = parts.PARTS[name]
lib.reset()
coll = bpy.context.scene.collection
S = spec['S']
b = lib.Builder(name, materials(S), S)
spec['build'](b)
objs = b.finish(coll)
return spec, objs, coll
def main():
names, o = args()
for name in names:
t0 = time.time()
spec, objs, coll = build_scene(name)
pts = lib.machine_points(objs)
lo = [min(p[i] for p in pts) for i in range(3)]
hi = [max(p[i] for p in pts) for i in range(3)]
span = max(hi[0] - lo[0], hi[1] - lo[1])
lib.build_ground(coll, spec['S'], span * spec.get('ground', 0.9), ((lo[0], lo[1]), (hi[0], hi[1])),
seed=spec.get('seed', 3))
lib.build_world()
lib.add_lights(coll)
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.78), aim=spec.get('aim', (0.5, 0.5)))
lib.setup_render(o['res'], o['samples'])
os.makedirs(o['out'], exist_ok=True)
sc = bpy.context.scene
sc.render.filepath = os.path.join(o['out'], name + '.png')
bpy.ops.render.render(write_still=True)
print(f'[attachments] {name}: {time.time() - t0:.1f}s', flush=True)
main()
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Render every attachments image (or the named ones) and write the game images.
# art/attachments/render_all.sh [name ...]
set -e
here=$(cd "$(dirname "$0")" && pwd)
blender=${BLENDER:-blender}
[ $# -eq 0 ] && set -- all
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" --out "$here/build"
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
+57
View File
@@ -0,0 +1,57 @@
# Store equipment art
The seven store item images (`web/static/assets/items/`) are rendered in Blender
(4.5 LTS, Cycles on CPU) from these scripts. The scripts are the source: every
mesh, material, light and the camera are generated, nothing is loaded from a
`.blend` or an image texture, and all randomness is seeded, so a re-run gives the
same images.
## Re-render
```sh
art/equipment/render_all.sh # all seven
art/equipment/render_all.sh regolith # just one (names below)
```
This runs `render.py` in Blender (build, render 1024px, export `.glb`), then
`finish.py` with the system `python3` (needs Pillow) to downscale to 512px, write
the game images and contact sheets (`build/contact_sheet*.png`). `BLENDER=...`
overrides the Blender binary. Each image takes about a minute on a 16-thread CPU.
| name | file | machine |
|----------------------|--------------------------|------------------------------------------|
| `shovel` | `shovel.png` | Prospector Drone PD-1 |
| `pickaxe` | `pickaxe.png` | Percussion Drill PX-4 |
| `lunarlite` | `lunarlite.jpg` | LunarLite L100 |
| `lunarlite300` | `lunarlite300.png` | LunarLite L300 |
| `regolith` | `regolith.jpg` | Lunar Regolith Extractor LRE-3000 |
| `dustexcavator` | `dustexcavator.png` | Electrostatic Dust Excavator EDE-700 |
| `lunarcoreextractor` | `lunarcoreextractor.png` | Lunar Core Extractor LCE-360 |
Other options (`blender -b --python art/equipment/render.py -- <names|all> ...`):
`--res N`, `--samples N` for quick previews, `--no-render`, `--glb DIR`, and
`--blend DIR` to save the generated scene for poking around in the Blender UI.
`.blend` files are generated output and are not committed (`*.blend` is in
`.gitignore`).
## Files
- `lib.py`: materials (procedural painted hull with panel seams, worn edges and
crevice dirt; chrome, rubber, hazard stripes, emissive cyan/amber), the mesh
`Builder` (bevelled boxes, cylinders, struts, pistons, lattices, trusses, crawler
tracks, vents, bolts, railings), the regolith ground patch and rocks, world,
three-point lighting, auto-framing camera, render and bloom settings.
- `machines.py`: one build function per machine, plus `MACHINES` (detail scale
`S` and ground seed per machine).
- `render.py`: Blender entry point. `finish.py`: image post-processing.
- `glb/`: low-poly exports of each machine (no ground, lights or camera), for
possible use in the three.js viewport; Y-up, metres. Procedural materials are
replaced by flat PBR values on export, so they have no seams, wear or stripes.
## Look
Camera azimuth 38° right of front, elevation 24°, 70 mm, auto-fit to the machine
so the set reads as a family. Sun key (warm, upper left), cool fill (right), cyan
rim (behind), dim navy sky. The background is a screen-space navy gradient
(`#0c1c33` centre to `#060d1a` edge) seen only by camera rays, with the Standard
view transform so it matches the UI. Slight bloom in the compositor.
+57
View File
@@ -0,0 +1,57 @@
"""Turn raw 1024px renders into the 512px game images, plus contact sheets.
python3 art/equipment/finish.py [RAW_DIR] [--sheet DIR]
Needs Pillow. Writes web/static/assets/items/<name>.(png|jpg).
"""
import os
import sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
ITEMS = os.path.normpath(os.path.join(HERE, '..', '..', 'web', 'static', 'assets', 'items'))
ORDER = [('shovel', 'shovel.png'), ('pickaxe', 'pickaxe.png'), ('lunarlite', 'lunarlite.jpg'),
('lunarlite300', 'lunarlite300.png'), ('regolith', 'regolith.jpg'),
('dustexcavator', 'dustexcavator.png'), ('lunarcoreextractor', 'lunarcoreextractor.png')]
BG = (6, 13, 26)
def main():
a = sys.argv[1:]
sheet_dir = None
if '--sheet' in a:
i = a.index('--sheet')
sheet_dir = a[i + 1]
del a[i:i + 2]
raw = a[0] if a else os.path.join(HERE, 'build')
imgs = []
for name, fname in ORDER:
src = os.path.join(raw, name + '.png')
if not os.path.exists(src):
print('missing', src)
continue
im = Image.open(src).convert('RGB').resize((512, 512), Image.LANCZOS)
out = os.path.join(ITEMS, fname)
if fname.endswith('.jpg'):
im.save(out, 'JPEG', quality=90, optimize=True, progressive=True)
else:
im.save(out, 'PNG', optimize=True)
if os.path.getsize(out) > 200_000:
q = im.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.FLOYDSTEINBERG)
q.save(out, 'PNG', optimize=True)
print(f'{fname}: {os.path.getsize(out) // 1024} KB')
imgs.append(Image.open(out).convert('RGB'))
if sheet_dir and imgs:
os.makedirs(sheet_dir, exist_ok=True)
for size, fn in ((512, 'contact_sheet.png'), (200, 'contact_sheet_200px.png')):
pad = 8
sheet = Image.new('RGB', (len(imgs) * (size + pad) + pad, size + 2 * pad), (4, 10, 22))
for i, im in enumerate(imgs):
sheet.paste(im.resize((size, size), Image.LANCZOS), (pad + i * (size + pad), pad))
sheet.save(os.path.join(sheet_dir, fn))
print('contact sheets in', sheet_dir)
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1094
View File
File diff suppressed because it is too large. Load diff
+518
View File
@@ -0,0 +1,518 @@
"""The seven store machines. Each build function draws into a lib.Builder.
Coordinates: metres, Z up, front faces -Y (the camera sits front-right, +X/-Y).
"""
import math
from lib import CYAN, AMBER
PI = math.pi
# 1. shovel.png: Prospector Drone PD-1, a small four-legged sampling drone
def drone(b):
bz = 0.56
# hull
b.box((0.74, 0.52, 0.22), 'white', (0, 0, bz), bevel=0.045)
b.box((0.62, 0.44, 0.09), 'graphite', (0, 0.0, bz - 0.14), bevel=0.025)
b.box((0.66, 0.46, 0.05), 'graphite', (0, 0.0, bz + 0.13), bevel=0.015)
b.box((0.44, 0.28, 0.012), 'solar', (-0.03, 0.03, bz + 0.165), (0.06, 0, 0), bevel=0.003)
b.hazard_band(0.6, 0.02, 0.04, (0, -0.262, bz - 0.075))
b.hazard_band(0.02, 0.4, 0.04, (0.372, 0, bz - 0.075))
b.bolts((-0.33, -0.262, bz + 0.07), (0.33, -0.262, bz + 0.07), 8, 0.008, '-y')
b.bolts((0.372, -0.22, bz + 0.07), (0.372, 0.22, bz + 0.07), 6, 0.008, 'x')
b.vent(0.22, 0.09, (0.372, 0.06, bz - 0.005), (0, 0, PI / 2))
b.box((0.012, 0.36, 0.012), 'cyan', (0.373, 0.0, bz + 0.06), bevel=0)
# sensor head
b.box((0.36, 0.13, 0.17), 'graphite', (0, -0.3, bz + 0.0), bevel=0.03)
b.box((0.38, 0.05, 0.035), 'white', (0, -0.33, bz + 0.1), bevel=0.012)
b.cyl(0.062, 0.03, 'dark', (-0.08, -0.368, bz + 0.0), axis='y')
b.cyl(0.046, 0.034, 'cyan', (-0.08, -0.37, bz + 0.0), axis='y')
b.cyl(0.03, 0.03, 'dark', (0.07, -0.368, bz + 0.02), axis='y')
b.cyl(0.02, 0.034, 'cyan', (0.07, -0.37, bz + 0.02), axis='y')
b.cyl(0.012, 0.034, 'amber', (0.13, -0.37, bz + 0.05), axis='y')
b.box((0.26, 0.01, 0.012), 'cyan', (0, -0.367, bz - 0.06), bevel=0)
b.light((0, -0.5, bz - 0.05), CYAN, 30, 0.05)
# legs
for sx in (-1, 1):
for sy in (-1, 1):
hip = (sx * 0.33, sy * 0.17, bz - 0.04)
knee = (sx * 0.56, sy * 0.36, bz + 0.18)
foot = (sx * 0.68, sy * 0.48, 0.035)
b.cyl(0.05, 0.07, 'graphite', hip, axis='x')
b.beam(hip, knee, 0.055, 0.045, 'white')
b.cyl(0.04, 0.07, 'dark', knee, (0, 0, math.atan2(sy * 0.19, sx * 0.23)), axis='x')
b.strut(knee, foot, 0.024, 'graphite', r2=0.018)
b.strut((foot[0], foot[1], 0.17), foot, 0.02, 'chrome')
b.cyl(0.07, 0.03, 'dark', (foot[0], foot[1], 0.018))
b.cyl(0.045, 0.03, 'graphite', (foot[0], foot[1], 0.045))
mid = ((knee[0] + foot[0]) / 2, (knee[1] + foot[1]) / 2, (knee[2] + foot[2]) / 2 + 0.04)
b.piston((sx * 0.36, sy * 0.19, bz + 0.07), mid, 0.014)
# scraper blade under the front
b.arc_plate(0.13, -PI * 0.92, -PI * 0.28, 0.5, 0.014, 'steel', (0, -0.2, 0.18))
b.box((0.5, 0.05, 0.014), 'chrome', (0, -0.29, 0.06), (-0.5, 0, 0), bevel=0.004)
for sx in (-1, 1):
b.box((0.02, 0.16, 0.1), 'graphite', (sx * 0.24, -0.2, 0.2), bevel=0.006)
b.piston((sx * 0.16, -0.12, bz - 0.17), (sx * 0.16, -0.24, 0.22), 0.015)
# rear auger
ax, ay = 0.14, 0.2
b.box((0.12, 0.12, 0.08), 'graphite', (ax, ay, bz - 0.2), bevel=0.015)
b.strut((ax, ay, bz - 0.22), (ax, ay, 0.07), 0.013, 'chrome')
b.helix((ax, ay), 0.024, 0.08, bz - 0.26, 5, 0.007, 'metal')
b.cyl(0.026, 0.05, 'metal', (ax, ay, 0.045), r2=0.004)
# sample canisters
for i in range(3):
x = -0.24 + i * 0.075
b.cyl(0.03, 0.09, 'steel', (x, 0.2, bz + 0.2))
b.cyl(0.031, 0.014, 'cyan', (x, 0.2, bz + 0.25))
# antenna + dish
b.strut((0.27, 0.17, bz + 0.15), (0.29, 0.2, bz + 0.55), 0.006, 'graphite')
b.lamp((0.29, 0.2, bz + 0.56), 0.016, 'amber', housing=False)
b.lathe([(0.0, 0.0), (0.04, 0.008), (0.075, 0.03)], 'white', (0.2, 0.2, bz + 0.22), (0.5, -0.4, 0))
b.cyl(0.012, 0.06, 'graphite', (0.2, 0.2, bz + 0.18))
b.tube([(-0.3, -0.2, bz - 0.12), (-0.26, -0.27, bz - 0.25), (-0.16, -0.26, 0.24)], 0.008, 'rubber')
b.tube([(0.3, -0.2, bz - 0.12), (0.26, -0.27, bz - 0.25), (0.16, -0.26, 0.24)], 0.008, 'rubber')
def drill_head(b, x, y, z, s):
"""Rotary drill head riding on a mast; front faces -Y."""
b.box((0.62 * s, 0.56 * s, 0.5 * s), 'white', (x, y, z), bevel=0.05 * s)
b.box((0.66 * s, 0.6 * s, 0.08 * s), 'graphite', (x, y, z + 0.2 * s), bevel=0.02 * s)
b.cyl(0.25 * s, 0.1 * s, 'graphite', (x, y, z - 0.3 * s))
b.cyl(0.21 * s, 0.11 * s, 'cyan', (x, y, z - 0.3 * s))
b.cyl(0.18 * s, 0.22 * s, 'steel', (x, y, z + 0.36 * s))
b.box((0.42 * s, 0.012, 0.03 * s), 'cyan', (x, y - 0.285 * s, z + 0.08 * s), bevel=0)
b.box((0.2 * s, 0.32 * s, 0.28 * s), 'graphite', (x + 0.4 * s, y, z), bevel=0.03 * s)
b.vent(0.36 * s, 0.14 * s, (x, y - 0.282 * s, z - 0.1 * s))
b.bolts((x - 0.27 * s, y - 0.282 * s, z + 0.16 * s), (x + 0.27 * s, y - 0.282 * s, z + 0.16 * s), 5, 0.012 * s, '-y')
b.light((x, y - 0.1 * s, z - 0.5 * s), CYAN, 25 * s * s, 0.05)
# 2. pickaxe.png: Percussion Drill PX-4, a small tracked hammer drill
def percussion(b):
for sx in (-1, 1):
b.track(sx * 0.52, 0.0, 1.7, 0.42, 0.3)
b.box((0.76, 1.46, 0.22), 'graphite', (0, 0.0, 0.44), bevel=0.04)
b.box((1.04, 1.34, 0.16), 'white', (0, 0.12, 0.63), bevel=0.04)
b.hazard_band(1.05, 0.03, 0.06, (0, -0.545, 0.6))
b.bolts((-0.48, -0.56, 0.68), (0.48, -0.56, 0.68), 9, 0.012, '-y')
# power pack
b.box((0.9, 0.6, 0.44), 'steel', (0, 0.44, 0.93), bevel=0.05)
b.vent(0.56, 0.22, (0.08, 0.135, 0.93))
b.vent(0.4, 0.24, (0.455, 0.44, 0.93), (0, 0, PI / 2))
b.fins(0.62, 0.4, 0.07, 10, 'graphite', (0, 0.44, 1.15))
b.box((0.012, 0.012, 0.32), 'cyan', (0.457, 0.18, 0.93), bevel=0)
b.box((0.32, 0.012, 0.02), 'cyan', (0.2, 0.135, 1.07), bevel=0)
b.cyl(0.05, 0.3, 'graphite', (-0.33, 0.62, 1.28))
b.cyl(0.055, 0.04, 'dark', (-0.33, 0.62, 1.44))
b.lamp((0.36, 0.66, 1.2), 0.035, 'amber')
# operator console
b.box((0.32, 0.26, 0.3), 'graphite', (-0.28, -0.12, 0.86), bevel=0.03)
b.box((0.26, 0.012, 0.13), 'glass', (-0.28, -0.255, 0.9), (0.2, 0, 0), bevel=0)
b.box((0.36, 0.3, 0.03), 'white', (-0.28, -0.12, 1.03), bevel=0.01)
# boom + mast at the front
mx, my = 0.12, -0.92
b.box((0.24, 0.3, 0.22), 'dark', (mx, -0.3, 0.82), bevel=0.03)
b.beam((mx, -0.36, 0.82), (mx, my + 0.08, 1.0), 0.14, 0.14, 'white')
b.piston((mx, -0.3, 0.7), (mx, my + 0.1, 0.86), 0.035)
b.box((0.12, 0.12, 1.84), 'steel', (mx, my, 1.02), bevel=0.02)
b.box((0.05, 0.04, 1.78), 'dark', (mx, my - 0.07, 1.02), bevel=0.01)
b.hazard_band(0.13, 0.13, 0.12, (mx, my, 1.82))
b.hazard_band(0.13, 0.13, 0.08, (mx, my, 0.22))
b.cyl(0.07, 0.1, 'graphite', (mx, my, 1.98))
b.lamp((mx, my, 2.06), 0.03, 'amber', housing=False)
# hammer
hy = my - 0.13
b.box((0.26, 0.24, 0.38), 'white', (mx, hy, 1.36), bevel=0.04)
b.box((0.28, 0.26, 0.05), 'graphite', (mx, hy, 1.57), bevel=0.015)
b.cyl(0.11, 0.05, 'cyan', (mx, hy, 1.15))
b.box((0.18, 0.012, 0.02), 'cyan', (mx, hy - 0.122, 1.46), bevel=0)
b.bolts((mx - 0.1, hy - 0.122, 1.26), (mx + 0.1, hy - 0.122, 1.26), 3, 0.012, '-y')
b.cyl(0.028, 1.0, 'chrome', (mx, hy, 0.62))
b.cyl(0.06, 0.1, 'graphite', (mx, hy, 0.17))
b.cyl(0.05, 0.12, 'metal', (mx, hy, 0.06), r2=0.01)
b.cyl(0.1, 0.07, 'dark', (mx, hy, 0.26))
b.light((mx, hy - 0.1, 1.05), CYAN, 30, 0.05)
b.tube([(0.3, 0.2, 1.0), (0.42, -0.1, 1.35), (0.3, -0.6, 1.6), (mx + 0.1, hy + 0.05, 1.5)], 0.02, 'rubber')
b.tube([(0.22, 0.2, 0.95), (0.36, -0.2, 1.2), (mx + 0.12, hy + 0.08, 1.3)], 0.015, 'rubber')
# 3. lunarlite.jpg: LunarLite L100, light skid-mounted rig with a lattice mast
def l100(b):
py = 0.75
b.box((1.6, 2.6, 0.25), 'graphite', (0, 0, py), bevel=0.04)
b.box((1.72, 2.72, 0.09), 'white', (0, 0, py + 0.16), bevel=0.03)
b.hazard_band(1.62, 0.03, 0.08, (0, -1.31, py + 0.0))
for sx in (-1, 1):
for sy in (-1, 1):
x, y = sx * 1.12, sy * 1.0
b.beam((sx * 0.75, y, py), (x, y, py), 0.17, 0.17, 'steel')
b.box((0.22, 0.22, 0.5), 'graphite', (x, y, py - 0.08), bevel=0.03)
b.cyl(0.055, 0.6, 'chrome', (x, y, 0.33))
b.cyl(0.24, 0.07, 'dark', (x, y, 0.035), r2=0.2)
b.hazard_band(0.23, 0.23, 0.08, (x, y, py + 0.2))
# mast
my, mh = -0.95, 3.6
b.lattice((0, my, py + 0.2), 0.52, 0.38, mh, 8, 0.045, 0.022)
b.box((0.62, 0.58, 0.2), 'graphite', (0, my, py + 0.2 + mh + 0.08), bevel=0.03)
b.torus(0.17, 0.04, 'chrome', (0, my, py + mh + 0.48), axis='x')
b.lamp((0.24, my - 0.24, py + mh + 0.32), 0.05, 'amber')
b.hazard_band(0.56, 0.56, 0.14, (0, my, py + 0.32))
b.cyl(0.045, mh, 'chrome', (0, my - 0.38, py + mh / 2))
drill_head(b, 0, my - 0.38, py + 2.3, 0.8)
b.cyl(0.2, 0.25, 'dark', (0, my - 0.38, 0.12), r2=0.18)
b.torus(0.26, 0.045, 'cyan', (0, my - 0.38, 0.03))
b.light((0, my - 0.38, 0.25), CYAN, 40, 0.1)
for sx in (-1, 1):
b.piston((sx * 0.36, 0.1, py + 0.22), (sx * 0.2, my + 0.2, py + 1.8), 0.05)
# power unit
b.box((1.3, 1.0, 0.82), 'steel', (0, 0.66, py + 0.62), bevel=0.06)
b.vent(0.6, 0.4, (0.25, 0.155, py + 0.6))
b.vent(0.6, 0.4, (0.655, 0.75, py + 0.6), (0, 0, PI / 2))
b.fins(1.0, 0.7, 0.09, 14, 'graphite', (0, 0.7, py + 1.03))
b.box((0.014, 0.014, 0.6), 'cyan', (-0.2, 0.155, py + 0.6), bevel=0)
b.box((0.9, 0.014, 0.02), 'cyan', (0, 0.155, py + 0.95), bevel=0)
b.cyl(0.07, 0.55, 'graphite', (-0.45, 0.95, py + 1.3))
b.cyl(0.08, 0.05, 'dark', (-0.45, 0.95, py + 1.58))
b.bolts((-0.6, 0.155, py + 0.27), (0.6, 0.155, py + 0.27), 10, 0.014, '-y')
# pipe rack on the right side
for i in range(6):
b.cyl(0.045, 1.8, 'chrome', (0.75 + (i % 3) * 0.1, -0.1, py + 0.3 + (i // 3) * 0.1), axis='y')
for y in (-0.75, 0.55):
b.box((0.34, 0.08, 0.26), 'graphite', (0.85, y, py + 0.32), bevel=0.01)
# solar wing
b.strut((-0.62, 0.6, py + 1.0), (-0.95, 0.6, py + 1.5), 0.03, 'graphite')
b.box((0.9, 0.72, 0.03), 'solar', (-1.12, 0.6, py + 1.56), (0, 0.35, 0), bevel=0.006)
b.box((0.94, 0.76, 0.02), 'graphite', (-1.12, 0.6, py + 1.535), (0, 0.35, 0), bevel=0.005)
b.railing([(-0.82, -0.3), (-0.82, 1.3), (0.82, 1.3)], py + 0.2, 0.38, 0.015)
b.tube([(0.3, 0.2, py + 0.9), (0.3, -0.3, py + 1.6), (0.1, my - 0.2, py + 2.3)], 0.03, 'rubber')
# 4. lunarlite300.png: LunarLite L300, heavy twin-mast rig on crawlers
def l300(b):
for sx in (-1, 1):
b.track(sx * 1.35, 0.0, 3.6, 0.8, 0.6)
py = 1.05
b.box((2.4, 3.4, 0.35), 'graphite', (0, 0, py), bevel=0.05)
b.box((3.4, 3.8, 0.14), 'white', (0, 0, py + 0.24), bevel=0.04)
b.hazard_band(3.42, 0.04, 0.1, (0, -1.9, py + 0.24))
for sx in (-1, 1):
for sy in (-1, 1):
x, y = sx * 2.0, sy * 1.6
b.beam((sx * 1.6, y, py + 0.15), (x, y, py + 0.15), 0.22, 0.22, 'steel')
b.box((0.3, 0.3, 0.6), 'graphite', (x, y, py), bevel=0.04)
b.cyl(0.075, 0.8, 'chrome', (x, y, 0.45))
b.cyl(0.32, 0.08, 'dark', (x, y, 0.04), r2=0.28)
b.hazard_band(0.31, 0.31, 0.1, (x, y, py + 0.33))
# twin masts + crossbeam
my, mh = -1.45, 4.8
top = py + 0.3 + mh
for sx in (-1, 1):
x = sx * 1.05
b.lattice((x, my, py + 0.3), 0.62, 0.46, mh, 10, 0.055, 0.026)
b.hazard_band(0.66, 0.66, 0.16, (x, my, py + 0.46))
b.cyl(0.05, mh, 'chrome', (x, my - 0.45, py + 0.3 + mh / 2))
drill_head(b, x, my - 0.45, py + 2.2 + (0.9 if sx > 0 else 0), 0.95)
b.cyl(0.25, 0.3, 'dark', (x, my - 0.45, 0.15), r2=0.22)
b.torus(0.28, 0.04, 'cyan', (x, my - 0.45, 0.04))
b.light((x, my - 0.45, 0.3), CYAN, 60, 0.1)
b.piston((x * 0.8, my + 1.5, py + 0.3), (x, my + 0.25, py + 2.6), 0.065)
b.lamp((x, my - 0.3, top + 0.42), 0.06, 'amber')
b.box((2.9, 0.62, 0.32), 'steel', (0, my, top + 0.15), bevel=0.05)
b.box((2.0, 0.014, 0.03), 'cyan', (0, my - 0.315, top + 0.15), bevel=0)
b.bolts((-1.35, my - 0.315, top + 0.05), (1.35, my - 0.315, top + 0.05), 12, 0.018, '-y')
b.box((0.4, 0.7, 0.3), 'graphite', (-0.3, my, top - 0.12), bevel=0.03)
b.strut((-0.3, my, top - 0.25), (-0.3, my, top - 1.4), 0.015, 'dark')
b.hazard_band(0.2, 0.2, 0.15, (-0.3, my, top - 1.5))
# control cabin
b.box((1.2, 1.0, 0.9), 'white', (0, -0.3, py + 0.8), bevel=0.06)
b.box((1.0, 0.014, 0.35), 'glass', (0, -0.805, py + 0.95), bevel=0)
b.box((0.014, 0.8, 0.35), 'glass', (0.605, -0.3, py + 0.95), bevel=0)
b.box((1.3, 1.1, 0.1), 'graphite', (0, -0.3, py + 1.3), bevel=0.03)
b.strut((0.4, -0.1, py + 1.35), (0.4, -0.1, py + 1.9), 0.012, 'graphite')
b.lamp((0.4, -0.1, py + 1.92), 0.03, 'cyan', housing=False)
# generators
for sx in (-1, 1):
b.box((1.3, 1.1, 0.95), 'steel', (sx * 0.85, 1.15, py + 0.78), bevel=0.06)
b.fins(1.0, 0.8, 0.1, 14, 'graphite', (sx * 0.85, 1.15, py + 1.255))
b.vent(0.7, 0.45, (sx * 0.85, 0.595, py + 0.78))
b.box((0.016, 0.016, 0.7), 'cyan', (sx * 0.85 + 0.45, 0.594, py + 0.78), bevel=0)
b.vent(0.8, 0.45, (1.505, 1.15, py + 0.78), (0, 0, PI / 2))
for x in (1.0, 1.45):
b.cyl(0.22, 1.1, 'white', (x, -0.05, py + 0.55), axis='y')
for y in (-0.4, 0.3):
b.torus(0.225, 0.022, 'graphite', (x, y, py + 0.55), axis='y')
for i in range(6):
b.cyl(0.05, 2.4, 'chrome', (-1.6 - (i % 2) * 0.1, 0.2, py + 0.4 + (i // 2) * 0.1), axis='y')
b.tube([(0.85, 0.6, py + 1.2), (0.95, -0.6, py + 2.3), (1.05, my, py + 2.8)], 0.045, 'rubber')
b.tube([(-0.85, 0.6, py + 1.2), (-0.95, -0.6, py + 2.0), (-1.05, my, py + 2.0)], 0.045, 'rubber')
b.railing([(-1.7, -1.2), (-1.7, 1.85), (1.7, 1.85), (1.7, -1.0)], py + 0.31, 0.42, 0.018)
# 5. regolith.jpg: Lunar Regolith Extractor LRE-3000, bucket-wheel excavator
def bucket_wheel(b):
for sx in (-1, 1):
b.track(sx * 1.9, 0.0, 5.2, 1.2, 1.1)
b.box((3.0, 3.6, 0.5), 'graphite', (0, 0, 1.25), bevel=0.06)
b.cyl(1.8, 0.4, 'dark', (0, 0, 1.7), r2=1.7)
b.torus(1.8, 0.035, 'cyan', (0, 0, 1.72))
for i in range(36):
a = i / 36 * 2 * PI
b.cyl(0.05, 0.06, 'chrome', (math.cos(a) * 1.74, math.sin(a) * 1.74, 1.92), seg=8)
with b.at((0, 0, 1.9), (0, 0, -0.75)):
b.box((3.4, 4.4, 1.4), 'white', (0, 0.4, 0.7), bevel=0.1)
b.hazard_band(3.42, 4.42, 0.14, (0, 0.4, 0.08))
b.vent(2.6, 0.6, (1.705, 1.0, 0.8), (0, 0, PI / 2))
b.box((0.016, 3.6, 0.035), 'cyan', (1.71, 0.4, 1.25), bevel=0)
b.fins(2.4, 1.6, 0.14, 18, 'graphite', (0, 1.7, 1.4))
b.box((1.1, 1.0, 0.9), 'steel', (1.2, -1.2, 1.85), bevel=0.06)
b.box((0.9, 0.014, 0.4), 'glass', (1.2, -1.705, 1.95), bevel=0)
b.box((0.014, 0.8, 0.4), 'glass', (1.755, -1.2, 1.95), bevel=0)
b.lamp((1.6, -1.0, 2.36), 0.07, 'amber')
# A-frame pylon
pt = (0, 0.6, 4.6)
for sx in (-1, 1):
for y in (-0.4, 1.6):
b.strut((sx * 1.2, y, 1.4), pt, 0.11, 'steel')
b.cyl(0.2, 0.55, 'graphite', pt, axis='x')
b.lamp((0, 0.6, 4.92), 0.08, 'amber')
# boom with conveyor
b.truss((0, -1.4, 1.2), (0, -6.0, 2.9), 1.0, 0.8, 7, 0.08, 0.035)
b.beam((0, -1.6, 1.68), (0, -6.0, 3.36), 0.72, 0.08, 'rubber')
for sx in (-1, 1):
b.strut(pt, (sx * 0.4, -5.7, 3.3), 0.03, 'chrome')
b.strut(pt, (sx * 0.4, -3.6, 2.3), 0.03, 'chrome')
# counterweight
b.truss((0, 2.4, 1.6), (0, 5.4, 3.2), 0.9, 0.7, 4, 0.08, 0.035)
b.box((1.9, 1.2, 1.2), 'graphite', (0, 5.6, 3.0), bevel=0.08)
b.hazard_band(1.92, 1.22, 0.26, (0, 5.6, 2.55))
b.strut(pt, (0, 5.6, 3.6), 0.035, 'chrome')
# bucket wheel (axis along X)
with b.at((0, -6.9, 2.6)):
R = 2.2
for x in (-0.32, 0.32):
b.torus(R, 0.13, 'steel', (x, 0, 0), axis='x')
b.cyl(0.48, 1.0, 'graphite', (0, 0, 0), axis='x')
b.cyl(0.33, 1.06, 'cyan', (0, 0, 0), axis='x')
b.light((0.8, 0, 0), CYAN, 60, 0.2)
n = 10
for i in range(n):
a = i / n * 2 * PI
ca, sa = math.cos(a), math.sin(a)
b.strut((0.32, 0, 0), (0.32, ca * R, sa * R), 0.07, 'graphite')
a2 = a + PI / n
b.strut((-0.32, 0, 0), (-0.32, math.cos(a2) * R, math.sin(a2) * R), 0.07, 'graphite')
with b.at((0, ca * (R + 0.2), sa * (R + 0.2)), (a, 0, 0)):
b.box((0.95, 0.6, 0.62), 'white', (0, 0.05, 0), bevel=0.06)
b.box((0.97, 0.56, 0.12), 'hazard', (0, 0.06, 0.32), bevel=0.02)
for t in range(3):
b.cyl(0.055, 0.22, 'chrome', (-0.3 + t * 0.3, 0.38, 0.32), (-PI / 2 + 0.6, 0, 0), r2=0.0, seg=10)
# discharge conveyor and hopper (back right)
hx, hy = 3.6, 4.6
b.beam((1.3, 1.3, 2.6), (hx - 0.6, hy - 0.4, 4.4), 0.55, 0.1, 'rubber')
b.truss((1.3, 1.3, 2.4), (hx - 0.6, hy - 0.4, 4.2), 0.65, 0.4, 5, 0.06, 0.025)
b.lathe([(0.5, 2.6), (1.7, 4.4)], 'steel', (hx, hy, 0), sides=4)
b.torus(1.72, 0.06, 'hazard', (hx, hy, 4.4), sides=4)
b.lathe([(0.0, 4.75), (0.6, 4.55), (1.15, 4.3)], 'regolith', (hx, hy, 0))
for dx in (-1, 1):
for dy in (-1, 1):
b.strut((hx + dx * 0.95, hy + dy * 0.95, 0), (hx + dx * 0.95, hy + dy * 0.95, 3.4), 0.1, 'graphite')
b.cyl(0.26, 0.1, 'dark', (hx + dx * 0.95, hy + dy * 0.95, 0.05))
b.strut((hx + dx * 0.95, hy - 0.95, 1.4), (hx + dx * 0.95, hy + 0.95, 1.4), 0.05, 'graphite')
b.cyl(0.36, 0.6, 'graphite', (hx, hy, 2.2), r2=0.3)
b.torus(0.37, 0.04, 'cyan', (hx, hy, 2.0))
b.lamp((hx + 1.2, hy - 1.2, 4.55), 0.07, 'amber')
# 6. dustexcavator.png: Electrostatic Dust Excavator EDE-700
def electrostatic(b):
import random
rng = random.Random(7)
py = 1.2
b.cyl(3.4, 0.5, 'graphite', (0, 0, py), r2=3.2, seg=6)
b.cyl(3.26, 0.12, 'white', (0, 0, py + 0.3), seg=6, bevel=0.03)
for i in range(6):
a = i / 6 * 2 * PI + PI / 6
# strip on each hex face (faces sit between vertices)
b.box((0.7, 0.05, 0.08), 'cyan', (math.cos(a) * 2.86, math.sin(a) * 2.86, py), (0, 0, a + PI / 2), bevel=0)
x, y = math.cos(a) * 3.3, math.sin(a) * 3.3
b.strut((math.cos(a) * 2.6, math.sin(a) * 2.6, py - 0.2), (x, y, 0.4), 0.13, 'steel')
b.cyl(0.6, 0.26, 'dark', (x, y, 0.25), r2=0.5)
b.torus(0.47, 0.045, 'cyan', (x, y, 0.08))
b.light((x, y, 0.15), CYAN, 15, 0.2)
b.lathe([(0.2, 0.15), (1.6, 0.3), (1.8, py - 0.2), (0.6, py - 0.2)], 'steel')
b.torus(1.66, 0.05, 'cyan', (0, 0, 0.32))
# column and coils
b.cyl(0.72, 6.0, 'steel', (0, 0, py + 3.2), r2=0.56)
for k in range(4):
z = py + 1.4 + k * 1.15
R = 1.6 - k * 0.22
b.torus(R, 0.22, 'graphite', (0, 0, z))
b.torus(R + 0.02, 0.17, 'brass', (0, 0, z + 0.1))
b.torus(R - 0.2, 0.06, 'cyan', (0, 0, z - 0.02))
for j in range(8):
a = j / 8 * 2 * PI
b.strut((math.cos(a) * 0.62, math.sin(a) * 0.62, z), (math.cos(a) * (R - 0.15), math.sin(a) * (R - 0.15), z), 0.045, 'metal')
b.light((0, 0, py + 3.0), CYAN, 30, 1.0)
# electrode plates
for i in range(8):
a = i / 8 * 2 * PI
with b.at((math.cos(a) * 2.5, math.sin(a) * 2.5, py + 0.35), (0, 0, a)):
with b.at((0, 0, 0), (0, -0.08, 0)):
b.box((0.12, 0.9, 3.6), 'white', (0, 0, 1.8), bevel=0.04)
for sy in (-1, 1):
b.box((0.14, 0.05, 3.3), 'cyan', (-0.0, sy * 0.44, 1.8), bevel=0)
b.sphere(0.13, 'chrome', (-0.12, 0, 3.66))
b.box((0.5, 0.6, 0.3), 'graphite', (0, 0, 0.15), bevel=0.03)
b.hazard_band(0.52, 0.62, 0.08, (0, 0, 0.27))
# field shell
b.cyl(2.3, 4.2, 'field', (0, 0, py + 0.4 + 2.1), r2=1.95, cap=False, seg=48)
# arcs between plates and coils
for i in range(8):
a = i / 8 * 2 * PI + 0.2
z0 = py + 1.2 + (i % 4) * 1.0
r0, r1 = 2.35, 1.6 - (i % 4) * 0.22 + 0.2
pts = []
for k in range(7):
t = k / 6
r = r0 + (r1 - r0) * t
aa = a + (rng.random() - 0.5) * 0.15
pts.append((math.cos(aa) * r, math.sin(aa) * r, z0 + 0.3 * t + (rng.random() - 0.5) * 0.25))
b.tube(pts, 0.025, 'core', seg=6, steps=3)
# top terminal
ty = py + 6.4
b.torus(1.0, 0.36, 'chrome', (0, 0, ty))
b.sphere(0.48, 'core', (0, 0, ty + 0.3))
b.light((0, 0, ty + 1.0), CYAN, 30, 0.5)
for j in range(24):
a = j / 24 * 2 * PI
b.cyl(0.05, 0.06, 'graphite', (math.cos(a) * 1.0, math.sin(a) * 1.0, ty + 0.36), seg=8)
# dust motes drawn into the intake
for i in range(140):
a = rng.random() * 2 * PI
r = 0.8 + rng.random() * 3.4
z = 0.1 + rng.random() * 2.4 * (r / 4)
b.sphere(0.02 + rng.random() * 0.03, 'cyan_soft', (math.cos(a) * r, math.sin(a) * r, z), seg=6)
# collection tanks
for a in (-0.9, -2.2):
x, y = math.cos(a) * 3.9, math.sin(a) * 3.9
b.cyl(0.44, 1.4, 'white', (x, y, 0.7))
b.lathe([(0.44, 0.0), (0.3, 0.18), (0.0, 0.26)], 'white', (x, y, 1.4))
b.box((0.08, 0.08, 1.0), 'cyan', (x + math.cos(a) * 0.45, y + math.sin(a) * 0.45, 0.8), (0, 0, a), bevel=0)
b.torus(0.45, 0.04, 'graphite', (x, y, 0.3))
b.tube([(x, y, 1.6), (x * 0.85, y * 0.85, 2.0), (x * 0.7, y * 0.7, py + 0.4)], 0.08, 'graphite')
# 7. lunarcoreextractor.png: Lunar Core Extractor LCE-360, deep-core platform
def core_extractor(b):
dy = 7.0
for sx, sy in ((-1, 0), (1, 0), (0, 1), (0, -1)):
x, y = sx * 6.6, sy * 6.6
b.lattice((x, y, 0.6), 1.7, 1.35, dy - 0.6, 3, 0.2, 0.09)
b.cyl(1.5, 0.7, 'dark', (x, y, 0.35), r2=1.3, seg=24)
b.hazard_band(1.8, 1.8, 0.32, (x, y, 1.0))
oct_rot = (0, 0, PI / 8)
b.cyl(8.6, 1.2, 'graphite', (0, 0, dy), oct_rot, seg=8, bevel=0.12)
b.cyl(8.8, 0.3, 'white', (0, 0, dy + 0.75), oct_rot, seg=8, bevel=0.06)
b.cyl(8.64, 0.14, 'cyan', (0, 0, dy + 0.2), oct_rot, seg=8, cap=False)
ap = 8.6 * math.cos(PI / 8)
for i in range(16):
a = i / 16 * 2 * PI
off = (a + PI / 8) % (PI / 4) - PI / 8
r = ap / math.cos(off) + 0.05
b.lamp((math.cos(a) * r, math.sin(a) * r, dy - 0.25), 0.13, 'cyan' if i % 2 else 'amber', housing=False)
top = dy + 0.9
# glowing core shaft
b.cyl(0.9, dy, 'core', (0, 0, dy / 2))
b.cyl(1.6, dy - 0.4, 'field', (0, 0, dy / 2 - 0.2), cap=False)
for k in range(5):
z = 0.9 + k * 1.3
b.torus(1.3, 0.14, 'steel', (0, 0, z))
for j in range(4):
a = j * PI / 2 + k * 0.4
b.strut((math.cos(a) * 1.3, math.sin(a) * 1.3, z), (math.cos(a) * 1.3, math.sin(a) * 1.3, z + 1.3), 0.07, 'graphite')
b.lathe([(2.0, 0.0), (2.6, 0.35), (3.4, 0.25), (4.2, 0.0)], 'regolith')
b.torus(2.4, 0.15, 'cyan', (0, 0, 0.06))
b.torus(3.4, 0.06, 'amber', (0, 0, 0.04))
b.light((0, 0, 1.2), CYAN, 120, 0.6)
b.light((0, 0, 5.0), CYAN, 60, 0.6)
# derrick
H = 14
b.lattice((0, 0, top), 4.4, 1.3, H, 12, 0.17, 0.075)
b.hazard_band(4.5, 4.5, 0.4, (0, 0, top + 0.6))
b.box((2.2, 2.2, 0.8), 'white', (0, 0, top + H + 0.4), bevel=0.1)
b.torus(0.6, 0.12, 'chrome', (0, 0, top + H + 1.1), axis='x')
for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
b.lamp((sx * 1.0, sy * 1.0, top + H + 0.95), 0.14, 'amber')
b.strut((0, 0, top + H + 1.2), (0, 0, top + H + 3.0), 0.07, 'graphite')
b.lamp((0, 0, top + H + 3.05), 0.17, 'amber', housing=False)
# travelling block + drill string
b.box((1.2, 1.0, 1.4), 'white', (0, 0, top + 8.5), bevel=0.1)
b.box((0.9, 0.03, 0.07), 'cyan', (0, -0.51, top + 8.6), bevel=0)
for sx in (-1, 1):
b.strut((sx * 0.3, 0, top + 9.2), (sx * 0.3, 0, top + H), 0.045, 'chrome')
b.cyl(0.22, 7.8, 'chrome', (0, 0, top + 3.9))
b.box((1.6, 1.6, 1.0), 'graphite', (0, 0, top + 4.0), bevel=0.08)
b.cyl(0.72, 0.16, 'cyan', (0, 0, top + 3.45))
b.cyl(1.7, 0.6, 'dark', (0, 0, top + 0.3), r2=1.5)
b.torus(1.6, 0.08, 'cyan', (0, 0, top + 0.6))
def module(x, y, w, d, h):
b.box((w, d, h), 'white', (x, y, top + h / 2), bevel=0.1)
b.box((w * 0.85, 0.03, 0.4), 'glass', (x, y - d / 2 - 0.01, top + h * 0.7), bevel=0)
b.box((0.03, d * 0.8, 0.4), 'glass', (x + w / 2 + 0.01, y, top + h * 0.7), bevel=0)
b.box((w + 0.12, d + 0.12, 0.18), 'graphite', (x, y, top + h + 0.09), bevel=0.04)
b.fins(w * 0.7, d * 0.6, 0.3, 12, 'graphite', (x, y, top + h + 0.18))
b.box((0.05, 0.05, h * 0.6), 'cyan', (x - w / 2 - 0.01, y - d / 2 - 0.01, top + h * 0.45), bevel=0)
b.hazard_band(w + 0.04, d + 0.04, 0.16, (x, y, top + 0.1))
module(4.8, -3.2, 3.0, 2.6, 2.4)
module(-4.6, -3.8, 3.2, 2.2, 3.2)
module(3.6, 5.0, 3.6, 2.0, 2.0)
# control tower
cx, cy = -5.2, 2.2
module(cx, cy, 2.2, 2.2, 5.0)
b.box((2.8, 2.8, 1.0), 'steel', (cx, cy, top + 5.7), bevel=0.1)
b.box((2.6, 0.03, 0.5), 'glass', (cx, cy - 1.41, top + 5.75), bevel=0)
b.box((0.03, 2.6, 0.5), 'glass', (cx + 1.41, cy, top + 5.75), bevel=0)
b.cyl(0.16, 1.1, 'graphite', (cx, cy, top + 6.6), r2=0.12)
b.lathe([(0.0, 0.0), (0.5, 0.08), (1.0, 0.3), (1.3, 0.55)], 'white', (cx, cy, top + 7.2), (0.7, 0.0, 0.6))
b.lamp((cx, cy, top + 8.1), 0.12, 'cyan', housing=False)
# tanks
for x, y in ((5.2, 1.2), (6.4, 2.6), (4.0, 2.4)):
b.cyl(0.85, 3.2, 'steel', (x, y, top + 1.6))
b.lathe([(0.85, 0.0), (0.6, 0.35), (0.0, 0.5)], 'steel', (x, y, top + 3.2))
for z in (0.9, 2.4):
b.torus(0.86, 0.06, 'graphite', (x, y, top + z))
b.box((0.07, 0.07, 1.8), 'cyan', (x - 0.6, y - 0.6, top + 1.6), (0, 0, PI / 4), bevel=0)
# crane
kx, ky = -2.8, -6.4
b.cyl(0.8, 1.6, 'graphite', (kx, ky, top + 0.8), r2=0.6)
b.box((1.6, 1.4, 0.9), 'steel', (kx, ky, top + 2.0), bevel=0.08)
b.truss((kx, ky, top + 2.4), (kx - 3.2, ky - 1.0, top + 7.5), 0.8, 0.8, 7, 0.08, 0.035)
b.lamp((kx - 3.2, ky - 1.0, top + 7.85), 0.12, 'amber')
b.strut((kx - 3.1, ky - 1.0, top + 7.3), (kx - 3.1, ky - 1.0, top + 4.0), 0.02, 'dark')
b.hazard_band(0.4, 0.4, 0.3, (kx - 3.1, ky - 1.0, top + 3.9))
# pipe racks
for i in range(8):
b.cyl(0.16, 5.0, 'chrome', (1.7, -5.0 - (i % 4) * 0.36 + 0.5, top + 0.35 + (i // 4) * 0.36), axis='x')
for x in (0.0, 3.4):
b.box((0.3, 1.6, 1.1), 'graphite', (x, -5.0, top + 0.55), bevel=0.04)
b.tube([(4.0, 2.4, top + 2.5), (2.5, 1.6, top + 3.4), (1.0, 0.6, top + 3.6), (0.5, 0, top + 3.8)], 0.18, 'graphite')
oct_pts = []
for i in range(9):
a = i / 8 * 2 * PI + PI / 8
oct_pts.append((math.cos(a) * 8.45, math.sin(a) * 8.45))
b.railing(oct_pts, top, 1.0, 0.045, post_every=1.4)
MACHINES = {
'shovel': {'build': drone, 'S': 0.45, 'seed': 1},
'pickaxe': {'build': percussion, 'S': 0.8, 'seed': 2},
'lunarlite': {'build': l100, 'S': 1.2, 'seed': 3},
'lunarlite300': {'build': l300, 'S': 1.8, 'seed': 4},
'regolith': {'build': bucket_wheel, 'S': 3.0, 'seed': 5, 'fill': 0.92},
'dustexcavator': {'build': electrostatic, 'S': 2.6, 'seed': 6},
'lunarcoreextractor': {'build': core_extractor, 'S': 4.5, 'seed': 7, 'fill': 0.88},
}
+96
View File
@@ -0,0 +1,96 @@
"""Build, render and export the store equipment.
blender -b --python art/equipment/render.py -- [names...|all] [--out DIR] [--glb DIR]
[--blend DIR] [--res N] [--samples N] [--no-render]
Raw renders go to --out (default art/equipment/build); finish.py turns them into
the 512px game images. --glb writes low-poly .glb exports; --blend saves the
generated scene (for inspection only; the scripts are the source).
"""
import os
import sys
import time
import bpy
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import lib # noqa: E402
import machines # noqa: E402
def args():
a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
opts = {'out': os.path.join(HERE, 'build'), 'glb': None, 'blend': None, 'res': 1024,
'samples': 96, 'render': True}
names = []
i = 0
while i < len(a):
k = a[i]
if k in ('--out', '--glb', '--blend'):
opts[k[2:]] = a[i + 1]; i += 2
elif k in ('--res', '--samples'):
opts[k[2:]] = int(a[i + 1]); i += 2
elif k == '--no-render':
opts['render'] = False; i += 1
else:
names.append(k); i += 1
if not names or names == ['all']:
names = list(machines.MACHINES)
return names, opts
def build_scene(name, lod):
spec = machines.MACHINES[name]
lib.reset()
sc = bpy.context.scene
coll = sc.collection
S = spec['S']
M = lib.make_materials(S)
b = lib.Builder(name, M, S, lod)
spec['build'](b)
objs = b.finish(coll)
return spec, objs, coll
def main():
names, o = args()
for name in names:
t0 = time.time()
if o['glb']:
spec, objs, coll = build_scene(name, 'export')
lib.flatten_materials(objs)
for ob in bpy.data.objects:
ob.select_set(ob in objs)
os.makedirs(o['glb'], exist_ok=True)
bpy.ops.export_scene.gltf(filepath=os.path.join(o['glb'], name + '.glb'), export_format='GLB',
use_selection=True, export_apply=True, export_lights=False,
export_cameras=False, export_yup=True)
spec, objs, coll = build_scene(name, 'render')
pts = lib.machine_points(objs)
lo = [min(p[i] for p in pts) for i in range(3)]
hi = [max(p[i] for p in pts) for i in range(3)]
span = max(hi[0] - lo[0], hi[1] - lo[1])
radius = span * spec.get('ground', 0.8)
lib.build_ground(coll, spec['S'], radius, ((lo[0], lo[1]), (hi[0], hi[1])), seed=spec.get('seed', 3))
lib.build_world()
lib.add_lights(coll)
lib.setup_camera(coll, pts, fill=spec.get('fill', 0.84), aim=spec.get('aim', (0.5, 0.5)))
lib.setup_render(o['res'], o['samples'])
tb = time.time() - t0
if o['blend']:
os.makedirs(o['blend'], exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(o['blend'], name + '.blend'), compress=True,
relative_remap=True)
if o['render']:
os.makedirs(o['out'], exist_ok=True)
sc = bpy.context.scene
sc.render.filepath = os.path.join(o['out'], name + '.png')
t1 = time.time()
bpy.ops.render.render(write_still=True)
print(f'[equipment] {name}: build {tb:.1f}s render {time.time() - t1:.1f}s', flush=True)
main()
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Rebuild every machine, render it, export .glb files and write the game images.
# art/equipment/render_all.sh [machine ...] (default: all seven)
set -e
here=$(cd "$(dirname "$0")" && pwd)
blender=${BLENDER:-blender}
[ $# -eq 0 ] && set -- all
"$blender" -b --factory-startup --python "$here/render.py" -- "$@" \
--out "$here/build" --glb "$here/glb"
python3 "$here/finish.py" "$here/build" --sheet "$here/build"
+318
View File
@@ -0,0 +1,318 @@
package main
// Drill attachments: equipment bought in the Store that goes into the player's inventory,
// then is equipped on one drill in the Equipment section (the drill's side panel). An
// attachment's modifier makes the drill's stakes faster (speed) or bigger (yield). A drill
// has attachments.slotsPerTool slots and takes at most one of each kind. Equipping and
// unequipping work any time, also while the drill mines: like every bonus (bonus.go), it
// counts from the drill's next stake. Table attachment (inventory_item_id null: unequipped).
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type AttachmentConfig struct {
SlotsPerTool int `json:"slotsPerTool"`
Items []Attachment `json:"items"`
}
type Attachment struct {
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"` // in web/static/assets/attachments
Price float64 `json:"price"`
Speed float64 `json:"speed,omitempty"` // modifiers (multipliers); unset is 1
Yield float64 `json:"yield,omitempty"`
}
func (c *GameConfig) attachment(name string) *Attachment {
for i := range c.Attachments.Items {
if c.Attachments.Items[i].Name == name {
return &c.Attachments.Items[i]
}
}
return nil
}
// Effect describes the modifier, e.g. "+20% speed".
func (a *Attachment) Effect() string {
var parts []string
if a.Speed > 1 {
parts = append(parts, pctText(a.Speed)+" speed")
}
if a.Yield > 1 {
parts = append(parts, pctText(a.Yield)+" yield")
}
return strings.Join(parts, ", ")
}
// buyAttachment buys an attachment into the player's inventory (unequipped).
func buyAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
a := cfg().attachment(name)
if a == nil {
return gameErr("That attachment isn't for sale")
}
return inTx(ctx, db, func(tx pgx.Tx) error {
if err := debit(ctx, tx, userID, a.Price); err != nil {
return err
}
_, err := tx.Exec(ctx, "insert into attachment(user_id, item) values ($1, $2)", userID, a.Name)
if err != nil {
return err
}
if err := unlock(ctx, tx, userID, "advisors"); err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_attachment", map[string]any{"item": a.Name, "price": a.Price})
})
}
// lockTool locks one of the player's tools.
func lockTool(ctx context.Context, tx pgx.Tx, userID, toolID string) error {
if !uuidPattern.MatchString(toolID) {
return gameErr("Tool not found")
}
var id string
err := tx.QueryRow(ctx, `
select id from inventory_item where id = $1 and user_id = $2 for update`, toolID, userID).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Tool not found")
}
return err
}
// equipAttachment puts one of the player's spare attachments of a kind on a tool (an idle
// copy of it, if there is one: see pickTool) and returns the tool it went on.
func equipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
a := cfg().attachment(name)
if a == nil {
return "", gameErr("Unknown attachment")
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The tool row first, then its attachments.
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
rows, err := tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 for update`, toolID)
if err != nil {
return err
}
fitted, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return err
}
for _, f := range fitted {
if f == a.Name {
return gameErr("This drill already has a %s", a.Name)
}
}
if len(fitted) >= cfg().Attachments.SlotsPerTool {
return gameErr("This drill's slots are full. Unequip one first")
}
var id string
err = tx.QueryRow(ctx, `
select id from attachment
where user_id = $1 and item = $2 and inventory_item_id is null
limit 1 for update skip locked`, userID, a.Name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("You have no spare %s. Buy one in the store", a.Name)
} else if err != nil {
return err
}
_, err = tx.Exec(ctx, "update attachment set inventory_item_id = $2 where id = $1", id, toolID)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "equip", map[string]any{"item": a.Name, "tool": toolID})
})
return toolID, err
}
// unequipAttachment takes an attachment of a kind off a tool (an idle copy of it, if there
// is one), back into the inventory, and returns the tool it came off.
func unequipAttachment(ctx context.Context, db *pgxpool.Pool, userID, name, toolID string) (string, error) {
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if toolID, err = pickTool(ctx, tx, userID, toolID, false); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
update attachment set inventory_item_id = null
where inventory_item_id = $1 and item = $2`, toolID, name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return gameErr("That attachment isn't on this drill")
}
return logEvent(ctx, tx, userID, "unequip", map[string]any{"item": name, "tool": toolID})
})
return toolID, err
}
// ------------------------------------------------------------------ view
// gear is the attachments equipped on a tool, in config order.
func (s *State) gear(toolID string) []*Attachment {
var out []*Attachment
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
for _, name := range s.Fitted[toolID] {
if name == a.Name {
out = append(out, a)
}
}
}
return out
}
// AttachmentCard is an attachment in the store.
type AttachmentCard struct {
*Attachment
Spare, Equipped int
CanAfford bool
}
func (s *State) attachmentCards() []AttachmentCard {
var cards []AttachmentCard
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
card := AttachmentCard{Attachment: a, Spare: s.Spare[a.Name], CanAfford: s.Balance >= a.Price}
for _, names := range s.Fitted {
for _, n := range names {
if n == a.Name {
card.Equipped++
}
}
}
cards = append(cards, card)
}
return cards
}
// DrillPanel is the side panel for a drill selected in the Equipment section: its slots
// and the spare attachments that fit, with the stats they'd give.
type DrillPanel struct {
ToolRow
Slots []SlotGear
Spares []SpareGear
Mining bool
Resale float64 // what it sells for (resale.go)
NoSale string // why it can't be sold, if it can't
// While it mines: the idle tools of its type, the best one and its best well.
IdleSame int
IdleBest *Tool
DeployTo *Suggestion
IdleURL string // opens IdleBest's panel
}
// SlotGear is one attachment slot; Gear is nil when it's empty.
type SlotGear struct {
N int
Gear *Attachment
}
// SpareGear is a kind of spare attachment and the drill's stats with it equipped.
type SpareGear struct {
*Attachment
Count int
Secs int
Yield float64
CanEquip bool
Why string // why it can't be equipped
}
// noSale is why a tool can't be sold (only-tool, claim-first, mining), "" if it can.
func (s *State) noSale(row ToolRow) string {
switch {
case len(s.Tools) <= 1:
return "only-tool"
case row.Stake != nil && row.Stake.Ready:
return "claim-first"
case row.Stake != nil:
return "mining"
}
return ""
}
// Confirm is the question before selling one (the Sell button's confirm dialog).
func (sg SpareGear) Confirm() string {
return fmt.Sprintf("Sell a spare %s for %s? You paid %s.", sg.Name, formatCredits(sg.Resale()), formatCredits(sg.Price))
}
// SellConfirm is the question before selling the drill (its Sell button's confirm dialog).
func (d *DrillPanel) SellConfirm() string {
q := fmt.Sprintf("Sell this %s (T%d) for %s?", d.Item.Name, d.Tier, formatCredits(d.Resale))
if len(d.Gear) > 0 {
q += " Its attachments go back to your inventory."
}
return q
}
var noSaleText = map[string]string{
"only-tool": "You can't sell your only tool",
"claim-first": "Claim its haul first",
"mining": "Still mining. Sell it once it's idle",
}
func (s *State) drillPanel(row ToolRow) *DrillPanel {
d := &DrillPanel{ToolRow: row, Mining: row.Stake != nil, Resale: row.Item.ToolResale(row.Tier)}
d.NoSale = noSaleText[s.noSale(row)]
for i := range cfg().Attachments.SlotsPerTool {
slot := SlotGear{N: i + 1}
if i < len(row.Gear) {
slot.Gear = row.Gear[i]
}
d.Slots = append(d.Slots, slot)
}
for i := range cfg().Attachments.Items {
a := &cfg().Attachments.Items[i]
n := s.Spare[a.Name]
if n == 0 {
continue
}
sg := SpareGear{Attachment: a, Count: n, CanEquip: true}
names := append([]string{a.Name}, s.Fitted[row.ID]...)
sg.Secs, sg.Yield = drillStats(row.Item.CompletionTimeInSecs, row.Item.ToolYield(row.Tier),
cfg().bonusesFor(s.Advisors, names), s.CycleBoosts.For("all"), s.YieldBoosts.For(""))
for _, g := range row.Gear {
if g.Name == a.Name {
sg.CanEquip, sg.Why = false, "Already on this drill"
}
}
if sg.CanEquip && row.FreeSlots == 0 {
sg.CanEquip, sg.Why = false, "Slots full. Unequip one first"
}
d.Spares = append(d.Spares, sg)
}
if d.Mining {
// Highest tier, then most attachments: the one pickTool would deploy.
for i := range s.Tools {
t := &s.Tools[i]
if t.StoreItemID != row.StoreItemID || s.StakeForTool(t.ID) != nil {
continue
}
d.IdleSame++
if b := d.IdleBest; b == nil || t.Tier > b.Tier ||
(t.Tier == b.Tier && len(s.Fitted[t.ID]) > len(s.Fitted[b.ID])) {
d.IdleBest = t
}
}
if d.IdleBest != nil {
d.IdleURL = View{Section: "equipment", Drill: d.IdleBest.ID}.URL()
if m := s.buySuggestions(d.IdleBest.ID); m != nil && len(m.Suggestions) > 0 {
d.DeployTo = &m.Suggestions[0]
}
}
}
return d
}
+74
View File
@@ -0,0 +1,74 @@
package main
// `moonminers sim [config.json]` prints the economy analysis (sim.go) for the embedded game
// config, or for the given file, so balance changes can be checked without the admin page.
import (
"fmt"
"os"
)
func runSim(args []string) error {
c := game
if len(args) > 0 {
data, err := os.ReadFile(args[0])
if err != nil {
return err
}
if c, err = decodeConfig(data); err != nil {
return fmt.Errorf("%s: %w", args[0], err)
}
}
a := analyze(&c, nil, 0)
fmt.Printf("avg price %.2f avg well %.0f units (%.2f wells) moon: price %s, ore %.0f units worth %s (%.2f×)\n",
a.AvgPrice, a.AvgSupply, a.AvgWells, formatCredits(c.MoonPrice), a.MoonUnits, formatCredits(a.MoonValue),
a.MoonValue/max(1, c.MoonPrice))
fmt.Println("(on moon: what a claim gets on average from a fresh moon, given what the wells in its reach hold; ₥/h at that)")
fmt.Printf("\n%-38s %8s %5s %6s %5s %7s %8s %8s %7s\n", "tool", "price", "secs", "claim", "wells", "on moon", "₥/h", "payback", "ratio")
for _, t := range a.Tools {
fmt.Printf("%-38s %8.0f %5d %6.0f %5d %7.0f %8.0f %8s %7s\n", t.Name, t.Price, t.Secs, t.Claim,
t.Reach, t.Eff, t.CreditsPerHour, t.Payback, t.Ratio)
for _, u := range t.Tiers {
fmt.Printf(" tier %d %6.0f + parts %5.0f yield %5.0f wells %d on moon %5.0f %8.0f ₥/h payback %s\n",
u.Tier, u.Price, u.PartsValue, u.Yield, u.Reach, u.Eff, u.CreditsPerHour, u.Payback)
}
}
fmt.Println("\nrefinery")
for _, r := range a.Refining.Recipes {
fmt.Printf(" %-16s %-40s in %6.1f price %6.1f gain %s %5.0f ₥/slot-h\n",
r.Name, r.Inputs, r.InputValue, r.Price, r.GainPct(), r.PerSlotHour)
}
for _, s := range a.Refining.Slots {
fmt.Printf(" slot %d %s payback %s\n", s.Slot, formatCredits(s.Price), s.Payback)
}
s := a.Sim
fmt.Println("\nsim")
for _, t := range s.Tools {
fmt.Printf(" %-38s afford %8s bought %8s ×%d\n", t.Name, t.AffordAt, t.BoughtAt, t.Count)
}
for _, m := range s.Attachments {
fmt.Printf(" attachment %-27s bought %8s ×%d\n", m.Name, m.BoughtAt, m.Count)
}
for _, a := range s.Advisors {
fmt.Printf(" advisor %-29s hired %8s\n", a.Name, a.BoughtAt)
}
fmt.Printf(" advisor seats %d\n", s.Seats)
for _, m := range s.Moons {
fmt.Printf(" moon #%d at %s\n", m.Count, m.At)
}
for _, sn := range s.Snapshots {
fmt.Printf(" %s credits %10.0f earned %10.0f rate %9.0f/h tools %3d moons %3d (+%d) pressure %s\n",
sn.At, sn.Credits, sn.Earned, sn.RatePerHour, sn.Tools, sn.Moons, sn.MoonsBought, sn.PressureText())
}
if os.Getenv("EVENTS") != "" {
for _, e := range s.Events {
fmt.Println(" " + e)
}
fmt.Printf(" (+%d more)\n", s.MoreEvents)
}
fmt.Println("\nwarnings")
for _, w := range a.Warnings {
fmt.Println(" " + w)
}
return nil
}
+232
View File
@@ -0,0 +1,232 @@
package main
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
// when an action runs: a stake fixes its duration and amount when it starts.
import (
"context"
"fmt"
"math"
"strconv"
"github.com/jackc/pgx/v5"
)
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
type BonusCaps struct {
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
Yield float64 `json:"yield"` // resources per stake
Sell float64 `json:"sell"` // credits per unit sold
Refine float64 `json:"refine"` // refinery speed: batch duration is divided by it
}
// Bonus is a set of multipliers (1 = none).
type Bonus struct {
Speed, Yield, Sell, Refine float64
}
var noBonus = Bonus{1, 1, 1, 1}
// mult reads an optional multiplier from the config: unset (0) is 1.
func mult(v float64) float64 {
if v == 0 {
return 1
}
return v
}
// bonusesFor combines the named advisors and attachments, capped.
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
b := noBonus
for _, name := range advisors {
if a := c.advisor(name); a != nil {
b.Speed *= mult(a.Speed)
b.Yield *= mult(a.Yield)
b.Sell *= mult(a.Sell)
b.Refine *= mult(a.Refine)
}
}
for _, name := range attachments {
if m := c.attachment(name); m != nil {
b.Speed *= mult(m.Speed)
b.Yield *= mult(m.Yield)
}
}
b.Speed = min(b.Speed, c.BonusCaps.Speed)
b.Yield = min(b.Yield, c.BonusCaps.Yield)
b.Sell = min(b.Sell, c.BonusCaps.Sell)
b.Refine = min(b.Refine, c.BonusCaps.Refine)
return b
}
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
// the live event multipliers. stakeInTx and the views both use it.
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
}
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
func pctText(m float64) string {
return "+" + strconv.FormatFloat(math.Round((m-1)*1000)/10, 'f', -1, 64) + "%"
}
func (cfg *GameConfig) validateBonuses() error {
caps := cfg.BonusCaps
for name, v := range map[string]float64{"speed": caps.Speed, "yield": caps.Yield, "sell": caps.Sell, "refine": caps.Refine} {
if v < 1 {
return fmt.Errorf("bonusCaps.%s must be at least 1", name)
}
}
okMult := func(v float64) bool { return v == 0 || v >= 1 }
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
}
names := map[string]bool{}
for _, m := range cfg.Attachments.Items {
if m.Name == "" || names[m.Name] {
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
}
names[m.Name] = true
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
}
}
for i, p := range cfg.Advisors.SeatPrices {
if p < 0 {
return fmt.Errorf("advisors: seat %d price is negative", i+2)
}
}
names = map[string]bool{}
for _, a := range cfg.Advisors.Items {
if a.Name == "" || names[a.Name] {
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
}
names[a.Name] = true
if a.Price < 0 || !okMult(a.Speed) || !okMult(a.Yield) || !okMult(a.Sell) || !okMult(a.Refine) {
return fmt.Errorf("advisor %q: price can't be negative, bonuses must be at least 1", a.Name)
}
}
return nil
}
// ------------------------------------------------------------------ in a transaction
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
// combines them. Callers that pass a tool have locked its row; the advisor rows are
// share-locked so a hire can't change them halfway through the action.
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
rows, err := tx.Query(ctx, `
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
if err != nil {
return noBonus, err
}
advisors, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return noBonus, err
}
var mods []string
if toolID != "" {
rows, err = tx.Query(ctx, `
select item from attachment where inventory_item_id = $1 order by item`, toolID)
if err != nil {
return noBonus, err
}
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return noBonus, err
}
}
return cfg().bonusesFor(advisors, mods), nil
}
// ------------------------------------------------------------------ state
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
// advisor seats into the state.
func loadBonusState(ctx context.Context, db querier, s *State) error {
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
if err != nil {
return err
}
rows, err := db.Query(ctx, `
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
order by seat nulls last, hired_at, advisor`, s.UserID)
if err != nil {
return err
}
if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
return err
}
s.Advisors = nil
for _, h := range s.Hires {
if h.Seat > 0 {
s.Advisors = append(s.Advisors, h.Name)
}
}
rows, err = db.Query(ctx, `
select coalesce(inventory_item_id::text, ''), item from attachment
where user_id = $1 order by item`, s.UserID)
if err != nil {
return err
}
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
var toolID, item string
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
if toolID == "" {
s.Spare[item]++
} else {
s.Fitted[toolID] = append(s.Fitted[toolID], item)
}
return nil
})
return err
}
// Bonus is the player's advisor bonus (no attachments).
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
func (s *State) toolBonus(toolID string) Bonus {
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
}
// drill is what a stake of the tool on a well of the resource would take and return now.
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
return drillStats(item.CompletionTimeInSecs, item.ToolYield(t.Tier), s.toolBonus(t.ID),
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
}
// sellPrice is what one unit sells for now: market rate × live events × advisors.
func (s *State) sellPrice(name string) float64 {
return marketRate(name, s.Now) * s.PriceBoosts.For(name) * s.Bonus().Sell
}
// BonusRow is one active bonus, for display.
type BonusRow struct {
Label string
Text string // "+10%"
Capped bool
}
// BonusRows lists the player's active advisor bonuses.
func (s *State) BonusRows() []BonusRow {
return bonusRows(s.Bonus())
}
// bonusRows lists the multipliers of b above 1; Capped marks the ones at their cap.
func bonusRows(b Bonus) []BonusRow {
caps := cfg().BonusCaps
var rows []BonusRow
add := func(label string, v, cap float64) {
if v > 1 {
rows = append(rows, BonusRow{Label: label, Text: pctText(v), Capped: v >= cap})
}
}
add("Drill speed", b.Speed, caps.Speed)
add("Yield", b.Yield, caps.Yield)
add("Sell prices", b.Sell, caps.Sell)
add("Refinery speed", b.Refine, caps.Refine)
return rows
}
+311
View File
@@ -0,0 +1,311 @@
package main
// The game config (constants, store items and their upgrades, moon types) lives in
// config/game.json, embedded in the binary (`game`). Admins can override its numbers live
// (economy.go); read the effective config with cfg(). Store items are also synced into
// the database (syncStoreItems), because inventory_item references store_item and the
// game rules join on those tables.
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"log"
"math"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed config/game.json
var gameJSON []byte
type GameConfig struct {
StartingCredits float64 `json:"startingCredits"`
StartingResources float64 `json:"startingResources"` // of each resource
StartingTools []int `json:"startingTools"` // store item ids, given free
MoonPrice float64 `json:"moonPrice"`
// Each moon a player owns multiplies the price of their next one (1 or unset: flat).
MoonPriceGrowth float64 `json:"moonPriceGrowth,omitempty"`
// Likewise for the supply of its wells: later moons are bigger, so big drills fit.
WellSupplyGrowth float64 `json:"wellSupplyGrowth,omitempty"`
// Each well of a new moon has a random supply in [wellSupplyMin, wellSupplyMax)
// (rollWells in game.go).
WellSupplyMin float64 `json:"wellSupplyMin"`
WellSupplyMax float64 `json:"wellSupplyMax"`
// How many wells a new moon has: weight by count, e.g. {"2": 30, "5": 8}.
WellCountWeights map[int]float64 `json:"wellCountWeights"`
// Selling a moon back (resale.go).
MoonResale MoonResale `json:"moonResale"`
// Selling tools and attachments back (resale.go).
EquipmentResale EquipmentResale `json:"equipmentResale"`
// Credits per unit sold, by resource name.
ResourcePrices map[string]float64 `json:"resourcePrices"`
// Market prices drift within ±marketVolatility of the base price (market.go).
MarketVolatility float64 `json:"marketVolatility"`
// A player's own recent sales lower their price a little (market.go).
MarketPressure MarketPressure `json:"marketPressure"`
StoreItems []StoreItem `json:"storeItems"`
Moons []MoonType `json:"moons"`
// A bought moon's name: a root and a suffix (rollMoonName in game.go).
MoonNames MoonNames `json:"moonNames"`
// Crafting refined items from resources (refinery.go).
Refinery RefineryConfig `json:"refinery"`
// Drill attachments and advisors (bonus.go): their combined bonuses are capped here.
BonusCaps BonusCaps `json:"bonusCaps"`
Attachments AttachmentConfig `json:"attachments"`
Advisors AdvisorConfig `json:"advisors"`
}
// moonPriceFor is the price of a player's next moon when they own `owned` moons.
func (cfg *GameConfig) moonPriceFor(owned int) float64 {
if cfg.MoonPriceGrowth <= 1 {
return cfg.MoonPrice
}
return math.Round(cfg.MoonPrice * math.Pow(cfg.MoonPriceGrowth, float64(owned)))
}
// wellScale multiplies the well supply of a player's next moon when they own `owned` moons.
func (cfg *GameConfig) wellScale(owned int) float64 {
if cfg.WellSupplyGrowth <= 1 {
return 1
}
return math.Pow(cfg.WellSupplyGrowth, float64(owned))
}
// MoonType is a kind of moon players can buy; a bought moon gets its name. Its look is
// procedural (seeded by the moon's id) and its description generated (moondesc.go); this
// description is only the placeholder, and lets migrate recognize moons that still need one.
type MoonType struct {
Name string `json:"name"`
Description string `json:"description"`
// Weights of the resources its wells hold, by name; unlisted resources weigh 1.
Affinities map[string]float64 `json:"affinities,omitempty"`
}
var game = mustParseGameConfig(gameJSON)
func mustParseGameConfig(data []byte) GameConfig {
var cfg GameConfig
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
panic(fmt.Sprintf("config/game.json: %v", err))
}
if err := cfg.validate(); err != nil {
panic(fmt.Sprintf("config/game.json: %v", err))
}
return cfg
}
func (cfg *GameConfig) validate() error {
if len(cfg.Moons) == 0 {
return fmt.Errorf("no moons")
}
if cfg.WellSupplyMin > cfg.WellSupplyMax {
return fmt.Errorf("wellSupplyMin is above wellSupplyMax")
}
for name, v := range map[string]float64{"startingCredits": cfg.StartingCredits,
"startingResources": cfg.StartingResources, "moonPrice": cfg.MoonPrice,
"wellSupplyMin": cfg.WellSupplyMin} {
if v < 0 {
return fmt.Errorf("%s is negative", name)
}
}
if err := cfg.validateWells(); err != nil {
return err
}
if err := cfg.MoonNames.validate(); err != nil {
return err
}
if cfg.MoonPriceGrowth != 0 && cfg.MoonPriceGrowth < 1 {
return fmt.Errorf("moonPriceGrowth must be at least 1")
}
if cfg.WellSupplyGrowth != 0 && cfg.WellSupplyGrowth < 1 {
return fmt.Errorf("wellSupplyGrowth must be at least 1")
}
if cfg.MarketVolatility < 0 || cfg.MarketVolatility >= 1 {
return fmt.Errorf("marketVolatility must be at least 0 and below 1")
}
if err := cfg.MarketPressure.validate(); err != nil {
return err
}
if err := cfg.MoonResale.validate(); err != nil {
return err
}
if r := cfg.EquipmentResale.Factor; r < 0 || r >= 1 {
return fmt.Errorf("equipmentResale.factor must be at least 0 and below 1")
}
for name, price := range cfg.ResourcePrices {
if price < 0 {
return fmt.Errorf("%s price is negative", name)
}
}
ids := map[int]bool{}
for _, item := range cfg.StoreItems {
if item.Price < 0 || item.ClaimAmount < 0 || item.CompletionTimeInSecs < 1 {
return fmt.Errorf("store item %d: price and claim amount can't be negative, cycle must be at least 1 second", item.ID)
}
if item.Reach < 0 || item.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d: reach must be 0 to %d wells", item.ID, len(cfg.ResourcePrices))
}
for _, u := range item.Upgrades {
if u.Price < 0 {
return fmt.Errorf("store item %d tier %d: price is negative", item.ID, u.Tier)
}
if u.Reach < 0 || u.Reach > len(cfg.ResourcePrices) {
return fmt.Errorf("store item %d tier %d: reach must be 0 to %d wells", item.ID, u.Tier, len(cfg.ResourcePrices))
}
}
if ids[item.ID] {
return fmt.Errorf("duplicate store item id %d", item.ID)
}
ids[item.ID] = true
for i, u := range item.Upgrades {
if u.Tier != i+1 {
return fmt.Errorf("store item %d: upgrade tiers must be 1, 2, 3, ... in order", item.ID)
}
}
}
for _, id := range cfg.StartingTools {
if !ids[id] {
return fmt.Errorf("startingTools: no store item %d", id)
}
}
if err := cfg.validateRefinery(); err != nil {
return err
}
return cfg.validateBonuses()
}
// MoonNames are the parts of moon names. A name is a root and a suffix, whose style is
// picked by weight: a Roman numeral ("Vesperis VII"), a Greek letter ("Vesperis-β"), a
// catalogue number ("Vesperis 142") or a letter and number ("Vesperis K-7").
type MoonNames struct {
Roots []string `json:"roots"`
Roman float64 `json:"roman"`
Greek float64 `json:"greek"`
Number float64 `json:"number"`
Code float64 `json:"code"`
}
func (n MoonNames) validate() error {
if len(n.Roots) == 0 {
return fmt.Errorf("moonNames: no roots")
}
for _, r := range n.Roots {
if strings.TrimSpace(r) == "" {
return fmt.Errorf("moonNames: a root is empty")
}
}
if n.Roman < 0 || n.Greek < 0 || n.Number < 0 || n.Code < 0 {
return fmt.Errorf("moonNames: style weights can't be negative")
}
if n.Roman+n.Greek+n.Number+n.Code <= 0 {
return fmt.Errorf("moonNames: give at least one suffix style a weight")
}
return nil
}
func (cfg *GameConfig) validateWells() error {
total := 0.0
for n, w := range cfg.WellCountWeights {
if n < 1 || n > len(cfg.ResourcePrices) {
return fmt.Errorf("wellCountWeights: a moon can have 1 to %d wells, not %d", len(cfg.ResourcePrices), n)
}
if w < 0 {
return fmt.Errorf("wellCountWeights: the weight for %d wells is negative", n)
}
total += w
}
if total <= 0 {
return fmt.Errorf("wellCountWeights: give at least one well count a weight")
}
for _, m := range cfg.Moons {
for name, w := range m.Affinities {
if _, ok := cfg.ResourcePrices[name]; !ok {
return fmt.Errorf("moon %s: affinity for %q, which is not a resource", m.Name, name)
}
if w <= 0 {
return fmt.Errorf("moon %s: the affinity for %s must be positive", m.Name, name)
}
}
}
return nil
}
// syncStoreItems makes store_item and upgrade_item match the effective config. Store items
// that were removed from the file are deleted only if no player owns one (deleting
// would cascade to their tools).
func syncStoreItems(ctx context.Context, db *pgxpool.Pool) error {
items := cfg().StoreItems
err := inTx(ctx, db, func(tx pgx.Tx) error { return syncStoreItemsTx(ctx, tx, items) })
if err != nil {
return fmt.Errorf("syncing store items: %w", err)
}
log.Printf("synced %d store items from the game config", len(items))
return nil
}
func syncStoreItemsTx(ctx context.Context, tx pgx.Tx, items []StoreItem) error {
ids := []int{}
for _, item := range items {
ids = append(ids, item.ID)
_, err := tx.Exec(ctx, `
insert into store_item(id, name, description, image_name, claim_amount,
completion_time_in_secs, price)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (id) do update set
name = excluded.name, description = excluded.description,
image_name = excluded.image_name, claim_amount = excluded.claim_amount,
completion_time_in_secs = excluded.completion_time_in_secs,
price = excluded.price`,
item.ID, item.Name, item.Description, item.Image, item.ClaimAmount,
item.CompletionTimeInSecs, item.Price)
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
tiers := []int{}
for _, u := range item.Upgrades {
tiers = append(tiers, u.Tier)
_, err := tx.Exec(ctx, `
insert into upgrade_item(store_item_id, tier, price, claim_boost)
values ($1, $2, $3, $4)
on conflict (store_item_id, tier) do update set
price = excluded.price, claim_boost = excluded.claim_boost`,
item.ID, u.Tier, u.Price, u.ClaimBoost)
if err != nil {
return fmt.Errorf("store item %d tier %d: %w", item.ID, u.Tier, err)
}
}
_, err = tx.Exec(ctx, `
delete from upgrade_item where store_item_id = $1 and not (tier = any($2))`,
item.ID, tiers)
if err != nil {
return fmt.Errorf("store item %d: %w", item.ID, err)
}
}
_, err := tx.Exec(ctx, `
delete from store_item where not (id = any($1))
and not exists (select 1 from inventory_item where store_item_id = store_item.id)`,
ids)
if err != nil {
return err
}
rows, err := tx.Query(ctx, "select id from store_item where not (id = any($1))", ids)
if err != nil {
return err
}
kept, err := pgx.CollectRows(rows, pgx.RowTo[int])
if err != nil {
return err
}
if len(kept) > 0 {
log.Printf("WARNING: store items %v are no longer in the config but players own them; kept", kept)
}
return nil
}
-81
View File
@@ -1,81 +0,0 @@
{
"resources": ["Sollux", "Shadowstone", "Azurium", "Novafor", "Nebulance"],
"moons": {
"name": "Caelusium",
"description" : "Caelusium is a medium-sized moon with a diverse landscape. Its craters contain a wealth of common resources, making it an ideal starting location for beginners. The moon's relatively mild weather conditions and stable surface offer a friendly environment for establishing mining operations.",
"image": "moon_1.jpg",
"price": 100,
"resourceChance": 1.0,
"resourceMinStartAmount": 50,
"resourceMaxStartAmount": 200
},
"items": {
"maxItemUpgrade": 5
},
"store": [
{
"id": 2,
"name": "LunarLite L100",
"description": "LunarLite L100 is a lightweight, entry-level drill designed for beginners. With its easy-to-use interface and moderate drilling speed, it's perfect for getting started with lunar resource extraction.",
"image": "/assets/drill_1.jpg",
"price": 250,
"claimAmount": 10,
"completionTimeInMins": 1,
"upgrades": [
{ "tier": 1, "price": 50, "claimBoost": 10 },
{ "tier": 2, "price": 75, "claimBoost": 20 },
{ "tier": 3, "price": 100, "claimBoost": 30 },
{ "tier": 4, "price": 125, "claimBoost": 40 },
{ "tier": 5, "price": 150, "claimBoost": 50 }
]
},
{
"id": 1,
"name": "Lunar Regolith Extractor LRE-3000",
"description": "The LRE-3000 is a high-efficiency drill designed for extracting regolith, the lunar soil composed of small rocks, dust, and other materials. This drill utilizes a unique auger system to bore into the moon's surface and collect regolith for further processing, ensuring minimal waste and maximum resource extraction.",
"image": "/assets/drill_2.jpg",
"price": 100,
"claimAmount": 75,
"completionTimeInMins": 5,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 10 },
{ "tier": 2, "price": 300, "claimBoost": 30 },
{ "tier": 3, "price": 400, "claimBoost": 40 },
{ "tier": 4, "price": 500, "claimBoost": 50 },
{ "tier": 5, "price": 600, "claimBoost": 60 }
]
},
{
"id": 3,
"name": "Electrostatic Dust Excavator EDE-700",
"description": "The EDE-700 is a specialized excavator designed to collect fine lunar dust, which is rich in valuable elements such as helium-3, a potential fuel for future fusion reactors. The machine uses an electrostatic system to attract and capture dust particles, allowing for efficient collection and reduced risk of hazardous dust exposure for operators.",
"image": "/assets/drill_3.jpg",
"price": 500,
"claimAmount": 100,
"completionTimeInMins": 30,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 25 },
{ "tier": 2, "price": 300, "claimBoost": 50 },
{ "tier": 3, "price": 400, "claimBoost": 75 },
{ "tier": 4, "price": 500, "claimBoost": 100 },
{ "tier": 5, "price": 600, "claimBoost": 150 }
]
},
{
"id": 4,
"name": "Lunar Core Extractor LCE-360",
"description": "The LCE-360 is a sophisticated drill designed to extract core samples from the moon's surface and subsurface layers. With its adjustable coring system, this machine can retrieve samples of various depths and diameters, providing valuable information about the moon's geological history and enabling efficient extraction of embedded resources.",
"image": "/assets/drill_4.jpg",
"price": 5000,
"claimAmount": 500,
"completionTimeInMins": 120,
"upgrades": [
{ "tier": 1, "price": 200, "claimBoost": 10 },
{ "tier": 2, "price": 300, "claimBoost": 20 },
{ "tier": 3, "price": 400, "claimBoost": 30 },
{ "tier": 4, "price": 500, "claimBoost": 40 },
{ "tier": 5, "price": 600, "claimBoost": 50 }
]
}
]
}
+240
View File
@@ -0,0 +1,240 @@
{
"startingCredits": 2000,
"startingResources": 0,
"startingTools": [1],
"moonPrice": 400,
"moonPriceGrowth": 1,
"wellSupplyMin": 30,
"wellSupplyMax": 90,
"wellSupplyGrowth": 1,
"wellCountWeights": {"2": 30, "3": 35, "4": 27, "5": 8},
"moonResale": {"factor": 0.1, "maxRatio": 0.9, "minHoldSecs": 600},
"equipmentResale": {"factor": 0.5},
"marketVolatility": 0.15,
"marketPressure": {"floor": 0.85, "scale": 50000, "halfLifeSecs": 300},
"resourcePrices": {
"Azurium": 6.5,
"Nebulance": 11,
"Novafor": 8.5,
"Shadowstone": 4.5,
"Sollux": 3
},
"storeItems": [
{
"id": 1,
"name": "Prospector Drone PD-1",
"description": "A hovering scout drone that skims ore off the surface.",
"image": "shovel.png",
"price": 1500,
"claimAmount": 2,
"completionTimeInSecs": 4,
"upgrades": [
{"tier": 1, "price": 850, "claimBoost": 1},
{"tier": 2, "price": 900, "claimBoost": 2},
{"tier": 3, "price": 950, "claimBoost": 3, "parts": {"Dusk Alloy": 1}},
{"tier": 4, "price": 1000, "claimBoost": 4, "parts": {"Dusk Alloy": 2}},
{"tier": 5, "price": 1050, "claimBoost": 5, "parts": {"Cryo Cell": 1}}
]
},
{
"id": 2,
"name": "Percussion Drill PX-4",
"description": "Hammers the crust a thousand times a second to crack open ore seams.",
"image": "pickaxe.png",
"price": 2400,
"claimAmount": 4,
"completionTimeInSecs": 5,
"upgrades": [
{"tier": 1, "price": 650, "claimBoost": 1},
{"tier": 2, "price": 700, "claimBoost": 2},
{"tier": 3, "price": 750, "claimBoost": 3, "parts": {"Dusk Alloy": 2}},
{"tier": 4, "price": 800, "claimBoost": 4, "parts": {"Cryo Cell": 1}},
{"tier": 5, "price": 850, "claimBoost": 5, "parts": {"Plasma Weave": 1}}
]
},
{
"id": 3,
"name": "LunarLite L100",
"description": "An entry-level rotary drill: point it down and the ore rolls in.",
"image": "lunarlite.jpg",
"price": 4800,
"claimAmount": 16,
"completionTimeInSecs": 10,
"upgrades": [
{"tier": 1, "price": 650, "claimBoost": 2},
{"tier": 2, "price": 1100, "claimBoost": 5},
{"tier": 3, "price": 1100, "claimBoost": 8, "parts": {"Cryo Cell": 1}},
{"tier": 4, "price": 1550, "claimBoost": 12, "parts": {"Plasma Weave": 2}},
{"tier": 5, "price": 1600, "claimBoost": 16, "parts": {"Plasma Weave": 3}}
]
},
{
"id": 4,
"name": "LunarLite L300",
"description": "The L100's laser-cutting big brother, built to burn through bedrock.",
"image": "lunarlite300.png",
"price": 9100,
"claimAmount": 70,
"completionTimeInSecs": 20,
"upgrades": [
{"tier": 1, "price": 1450, "claimBoost": 10},
{"tier": 2, "price": 1700, "claimBoost": 21, "reach": 2},
{"tier": 3, "price": 2200, "claimBoost": 35, "parts": {"Cryo Cell": 2}},
{"tier": 4, "price": 2900, "claimBoost": 52, "parts": {"Plasma Weave": 3}},
{"tier": 5, "price": 3200, "claimBoost": 70, "parts": {"Void Core": 1}, "reach": 3}
]
},
{
"id": 5,
"name": "Lunar Regolith Extractor LRE-3000",
"description": "An auger rig that chews through regolith by the ton and keeps every grain worth selling.",
"image": "regolith.jpg",
"price": 18000,
"claimAmount": 110,
"completionTimeInSecs": 16,
"reach": 3,
"upgrades": [
{"tier": 1, "price": 2050, "claimBoost": 12},
{"tier": 2, "price": 2250, "claimBoost": 27},
{"tier": 3, "price": 2250, "claimBoost": 44, "parts": {"Plasma Weave": 3}},
{"tier": 4, "price": 2300, "claimBoost": 64, "parts": {"Void Core": 1}},
{"tier": 5, "price": 2500, "claimBoost": 90, "parts": {"Stellar Lattice": 1}}
]
},
{
"id": 6,
"name": "Electrostatic Dust Excavator EDE-700",
"description": "Charged plates pull ore-rich dust straight out of the ground, no digging needed.",
"image": "dustexcavator.png",
"price": 36000,
"claimAmount": 120,
"completionTimeInSecs": 9,
"reach": 4,
"upgrades": [
{"tier": 1, "price": 3900, "claimBoost": 14},
{"tier": 2, "price": 4200, "claimBoost": 30},
{"tier": 3, "price": 4300, "claimBoost": 47, "parts": {"Void Core": 1}},
{"tier": 4, "price": 4600, "claimBoost": 70, "parts": {"Stellar Lattice": 1}},
{"tier": 5, "price": 4600, "claimBoost": 100, "parts": {"Void Core": 2, "Stellar Lattice": 2}}
]
},
{
"id": 7,
"name": "Lunar Core Extractor LCE-360",
"description": "A deep-core rig that bores to the mantle and hauls up what's been buried for eons.",
"image": "lunarcoreextractor.png",
"price": 61000,
"claimAmount": 130,
"completionTimeInSecs": 5,
"reach": 5,
"upgrades": [
{"tier": 1, "price": 5950, "claimBoost": 15},
{"tier": 2, "price": 6450, "claimBoost": 31},
{"tier": 3, "price": 6450, "claimBoost": 50, "parts": {"Void Core": 2}},
{"tier": 4, "price": 6900, "claimBoost": 77, "parts": {"Stellar Lattice": 2}},
{"tier": 5, "price": 6900, "claimBoost": 110, "parts": {"Void Core": 3, "Stellar Lattice": 3}}
]
}
],
"moons": [
{"name": "Caelusium", "description": "A mild, cratered moon full of common ore, ideal for a first claim.",
"affinities": {"Sollux": 3, "Shadowstone": 2.5}},
{"name": "Argentis", "description": "A small ice moon hiding rare minerals under its frozen crust.",
"affinities": {"Azurium": 3, "Novafor": 2}},
{"name": "Ferrunia", "description": "A rugged rock moon laced with iron and heavy metals.",
"affinities": {"Shadowstone": 3, "Novafor": 2}},
{"name": "Heliosia", "description": "A volcanic moon whose molten depths hold exotic minerals.",
"affinities": {"Sollux": 3, "Shadowstone": 2}},
{"name": "Celestite", "description": "A crystal-crusted moon with its riches buried in deep caves.",
"affinities": {"Azurium": 2, "Shadowstone": 2}},
{"name": "Terraphor", "description": "A large, Earth-like moon with fertile soil and plenty of ore."},
{"name": "Plutargos", "description": "A distant, frozen moon with exotic elements locked in its ice.",
"affinities": {"Nebulance": 3, "Azurium": 2}},
{"name": "Nebulor", "description": "A gas-wrapped moon with ore scattered across floating rock.",
"affinities": {"Nebulance": 3, "Novafor": 2}},
{"name": "Titanus", "description": "A massive metal-core moon whose heavy gravity strains every rig.",
"affinities": {"Novafor": 3, "Shadowstone": 2}},
{"name": "Lumineer", "description": "A glowing crystal moon with rare ore beneath its calm surface.",
"affinities": {"Sollux": 2, "Nebulance": 2}}
],
"moonNames": {
"roman": 4, "greek": 3, "number": 2, "code": 2,
"roots": [
"Aberon", "Acheris", "Aegirion", "Aethra", "Agathor", "Alcyra", "Aldaris", "Alethe", "Amarion", "Ambrael",
"Amethra", "Anchora", "Andrastis", "Anemos", "Antheia", "Apheron", "Aquilon", "Arcturis", "Ardentia", "Argyros",
"Ariadna", "Arkhalon", "Asteron", "Astrael", "Atheris", "Aurelion", "Auriga", "Austera", "Avalith", "Axiora",
"Azoria", "Barathos", "Basaltis", "Belisar", "Beryllon", "Boreas", "Brevanis", "Brightholm", "Brimora", "Cadmeia",
"Caladrel", "Calyxis", "Candora", "Carbonis", "Carnelia", "Cassara", "Castellum", "Cendris", "Ceratos", "Cerulis",
"Chalcedon", "Chimara", "Cinderra", "Cirrhos", "Clarion", "Cobaltine", "Coronis", "Crysolis", "Cyanthe", "Cygnara",
"Cymora", "Dalmora", "Danthis", "Dawnrest", "Deimara", "Delphion", "Demetra", "Dioscura", "Dravenna", "Drusilon",
"Duskara", "Dymaris", "Eidolon", "Elanthis", "Electra", "Elysara", "Emberon", "Empyra", "Enceris", "Eosphor",
"Erebon", "Eridanis", "Erythra", "Esperon", "Ethralis", "Evandra", "Faelis", "Farholm", "Ferrolux", "Feyrana",
"Flintor", "Fornaxis", "Fulgora", "Galenthe", "Garnis", "Gelida", "Gemmora", "Glaciera", "Glimmerhold", "Gloamis",
"Gorgonis", "Granitas", "Gravellon", "Halcyra", "Haloris", "Harrowmere", "Hearthis", "Hespera", "Hyalith", "Hydraxis",
"Hyperis", "Icarion", "Ignara", "Ilmaris", "Iolanthe", "Iridia", "Jadeon", "Janthis", "Jovarra", "Kaelis",
"Kethra", "Korvanth", "Kryos", "Kyanos", "Labradis", "Lacuna", "Lamberis", "Lapidis", "Larkspire", "Lazurna",
"Leucos", "Liriel", "Lithara", "Lodestar", "Lucerna", "Lumara", "Lunesse", "Lyrandis", "Magnetar", "Malachis",
"Marrowind", "Melanthe", "Meridia", "Mesmera", "Mirandel", "Morrowen", "Murkhaven", "Myrmidon", "Naiadra", "Nacrelle",
"Nephara", "Nerissa", "Nimbara", "Noctaris", "Norvalis", "Nyxara", "Obsidara", "Oceanis", "Ocrellis", "Opalith",
"Orichal", "Orinthe", "Orphyx", "Ossidan", "Pallara", "Pandoris", "Parhelia", "Peridon", "Perseia", "Petralis",
"Phaedra", "Phosphor", "Pyrrhon", "Quartzel", "Quasara", "Quillon", "Radiantis", "Ravenmoor", "Regalith", "Rhodara",
"Rimefall", "Rubellis", "Runara", "Saberis", "Saffira", "Salvora", "Samaris", "Sardonyx", "Scorith", "Selenara",
"Seraphis", "Shalecrest", "Silvaris", "Skarnis", "Solenne", "Sombrel", "Spinelle", "Stellaris", "Stormhold", "Strathis",
"Sulvaine", "Talvora", "Tamsyra", "Tempestra", "Tenebris", "Theronis", "Thulestar", "Topazia", "Tourmal", "Tranquis",
"Tyrrhen", "Umbrael", "Undara", "Uralis", "Valdris", "Vaskaris", "Velloria", "Verdantis", "Vesperis", "Vexalis",
"Virelith", "Vitrion", "Volantis", "Vorthane", "Wraithmere", "Wyrmstone", "Xanthera", "Xenolith", "Xyrallis", "Yarrowen",
"Ysolde", "Zanthros", "Zephyria", "Zirconis", "Zoraleth", "Zyphera"
]
},
"refinery": {
"slotPrices": [1500, 4000, 10000],
"maxBatch": 25,
"bulkExponent": 0.6,
"items": [
{"name": "Dusk Alloy", "tier": 1, "color": "#818cf8", "price": 30, "seconds": 1,
"inputs": {"Sollux": 2, "Shadowstone": 2},
"description": "Sunlit Sollux fused into Shadowstone: light, hard plating for drill housings."},
{"name": "Cryo Cell", "tier": 1, "color": "#67e8f9", "price": 63, "seconds": 2,
"inputs": {"Azurium": 3, "Novafor": 3},
"description": "Azurium coolant sealed around a Novafor core keeps bits from melting at depth."},
{"name": "Plasma Weave", "tier": 1, "color": "#f472b6", "price": 70, "seconds": 3,
"inputs": {"Nebulance": 4, "Sollux": 2},
"description": "Charged Nebulance spun on a Sollux lattice, the conductive mesh in every cutting head."},
{"name": "Void Core", "tier": 2, "color": "#c4b5fd", "price": 320, "seconds": 5,
"inputs": {"Dusk Alloy": 2, "Cryo Cell": 2, "Nebulance": 5},
"description": "A cold, dense power core that drives the heaviest rigs for weeks."},
{"name": "Stellar Lattice", "tier": 2, "color": "#fde047", "price": 310, "seconds": 6,
"inputs": {"Plasma Weave": 2, "Cryo Cell": 1, "Shadowstone": 4},
"description": "A self-aligning crystal frame that lets deep-core extractors bore without drift."}
]
},
"bonusCaps": {"speed": 1.6, "yield": 1.6, "sell": 1.15, "refine": 1.5},
"attachments": {
"slotsPerTool": 2,
"items": [
{"name": "Turbo Core", "image": "turbo-core.png", "price": 3000, "speed": 1.2,
"description": "An overdriven motor core. Runs hot, cycles fast."},
{"name": "Diamond Bit", "image": "diamond-bit.png", "price": 4000, "yield": 1.15,
"description": "A synthetic-diamond cutting face that keeps more ore in every bite."},
{"name": "Cryo Cooler", "image": "cryo-cooler.png", "price": 25000, "speed": 1.35,
"description": "Wraps the spindle in coolant so the drill never stops to cool down."},
{"name": "Seam Scanner", "image": "seam-scanner.png", "price": 30000, "yield": 1.3,
"description": "Ground radar that steers the head into the richest part of the seam."}
]
},
"advisors": {
"seatPrices": [10000, 40000, 120000],
"items": [
{"name": "Ada Voss", "image": "ada-voss.png", "title": "Shift Foreman", "price": 8000, "speed": 1.1,
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch."},
{"name": "Rook Halvorsen", "image": "rook-halvorsen.png", "title": "Field Geologist", "price": 9000, "yield": 1.1,
"description": "Reads core samples like a book and knows where the ore runs thick."},
{"name": "Mira Castellanos", "image": "mira-castellanos.png", "title": "Commodities Broker", "price": 12000, "sell": 1.08,
"description": "Has a buyer for everything and never takes the first offer."},
{"name": "Tobias Kel", "image": "tobias-kel.png", "title": "Refinery Chief", "price": 5000, "refine": 1.25,
"description": "Keeps the furnaces at temperature and the batches moving."},
{"name": "Juno Park", "image": "juno-park.png", "title": "Operations Director", "price": 30000, "speed": 1.05, "yield": 1.05,
"description": "Sees the whole operation at once and trims waste wherever she looks."}
]
}
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// appDSN connects as the app user (DB_USER), which only has read/write access to the tables.
func appDSN() string {
return dsn(env("DB_USER", "moonminers"), env("DB_PASSWORD", ""))
}
// adminDSN connects as a superuser (ADMIN_DB_USER), needed to create roles and change the schema.
func adminDSN() string {
return dsn(env("ADMIN_DB_USER", "postgres"), env("ADMIN_DB_PASSWORD", ""))
}
func dsn(user, password string) string {
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(user, password),
Host: env("DB_HOST", "localhost") + ":" + env("DB_PORT", "5432"),
Path: env("DB_DATABASE", "moonminers"),
}
q := url.Values{"sslmode": {env("DB_SSLMODE", "disable")}}
u.RawQuery = q.Encode()
return u.String()
}
func connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
db, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, err
}
if err := db.Ping(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("connecting to postgres: %w", err)
}
return db, nil
}
// inTx runs fn in a transaction, committing if it returns nil.
func inTx(ctx context.Context, db *pgxpool.Pool, fn func(tx pgx.Tx) error) error {
tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
// GameError is a rule violation the player should see (not enough money, tool busy...).
type GameError struct{ Message string }
func (e *GameError) Error() string { return e.Message }
func gameErr(format string, args ...any) error {
return &GameError{Message: fmt.Sprintf(format, args...)}
}
func isGameError(err error) bool {
var ge *GameError
return errors.As(err, &ge)
}
-16
View File
@@ -1,16 +0,0 @@
import sqlite3 from "sqlite3";
import { open } from "sqlite";
import { Pool } from "pg";
export const dbConnection = open({
filename: "database.db",
driver: sqlite3.Database,
});
export const postgresConnection = new Pool({
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: +process.env.DB_PORT!,
database: process.env.DB_DATABASE,
});
+20 -24
View File
@@ -1,40 +1,40 @@
# Production stack (used by Dokploy). Set POSTGRES_PASSWORD and DB_PASSWORD in the
# environment, and add a domain for the `app` service on port 3000 in Dokploy.
x-db-env: &db-env
DB_HOST: db
DB_PORT: "5432"
DB_USER: moonminers
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
DB_DATABASE: moonminers
services:
app:
build: .
restart: unless-stopped
environment:
DB_HOST: db
DB_PORT: "5432"
DB_USER: moonminers
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
DB_DATABASE: moonminers
<<: *db-env
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose:
- "3000"
depends_on:
db:
condition: service_healthy
db-update:
migrate:
condition: service_completed_successfully
networks:
- internal
db:
build:
context: .
dockerfile: docker/db/Dockerfile
image: docker.io/library/postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
# TCP check: the temporary server used during first-time init only listens on a socket.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d moonminers"]
interval: 5s
timeout: 5s
@@ -42,20 +42,16 @@ services:
networks:
- internal
# One-shot: migrations, function reload and config import, before the app starts.
db-update:
build:
context: .
dockerfile: docker/db/Dockerfile
command: ["/moonminers/update.sh"]
# One-shot, before the app starts on every deploy: roles, schema, migrations and the
# store item sync from config/game.json (see migrate.go).
migrate:
build: .
command: ["migrate"]
restart: "no"
environment:
PGHOST: db
PGUSER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
PGDATABASE: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
<<: *db-env
ADMIN_DB_USER: postgres
ADMIN_DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
depends_on:
db:
condition: service_healthy
+4 -7
View File
@@ -1,19 +1,16 @@
# Local development database. Run the app itself with `npm run dev`.
name: moonminers
# Local development database. Run the app itself with `make dev`.
services:
db:
build:
context: .
dockerfile: docker/db/Dockerfile
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: moonminers
ports:
- "5432:5432"
volumes:
# The SQL in sql/ is loaded once, when this volume is empty.
- db-data:/var/lib/postgresql/data
volumes:
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
# Creates the `devs` role the SQL files grant to, plus the app login user.
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
-v app_user="$APP_DB_USER" -v app_password="$APP_DB_PASSWORD" <<'EOSQL'
create role devs;
create user :"app_user" with password :'app_password' in role devs;
EOSQL
-19
View File
@@ -1,19 +0,0 @@
# Postgres with plpython3u + requests (needed by import_config()) and the game's SQL baked in.
# Build context is the repository root.
FROM postgres:16
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-plpython3-16 python3-requests \
&& rm -rf /var/lib/apt/lists/*
# Runs once, in name order, when the data volume is empty.
COPY docker/db/00-roles.sh /docker-entrypoint-initdb.d/00-roles.sh
COPY sql/tables.sql /docker-entrypoint-initdb.d/01-tables.sql
COPY sql/data.sql /docker-entrypoint-initdb.d/02-data.sql
COPY sql/procedures.sql /docker-entrypoint-initdb.d/03-procedures.sql
COPY sql/import-config.sql /docker-entrypoint-initdb.d/04-import-config.sql
# Used by the db-update service on every deploy.
COPY sql /moonminers/sql
COPY docker/db/update.sh /moonminers/update.sh
RUN chmod +x /docker-entrypoint-initdb.d/00-roles.sh /moonminers/update.sh
-34
View File
@@ -1,34 +0,0 @@
#!/bin/bash
# Brings an existing database up to date. Runs on every deploy (db-update service),
# connecting as the superuser via PGHOST/PGUSER/PGPASSWORD/PGDATABASE.
# Everything here must be safe to run repeatedly.
set -euo pipefail
cd /moonminers/sql
psql_run() { psql -v ON_ERROR_STOP=1 -q "$@"; }
# Keep the app user's password in sync with the deployment settings.
psql_run -v app_user="$APP_DB_USER" -v app_password="$APP_DB_PASSWORD" <<'EOSQL'
alter role :"app_user" with password :'app_password';
EOSQL
for migration in migrations/*.sql; do
[ -e "$migration" ] || continue
echo "Applying $migration"
psql_run -f "$migration"
done
echo "Reloading functions"
psql_run -f procedures.sql
psql_run -f import-config.sql
# Refresh store items/upgrades/moons/constants from the Google Sheet. A failed download
# only matters if the game has no config at all yet.
echo "Importing game config"
if ! psql_run -c "select import_config();"; then
if [ "$(psql -tAc 'select count(*) from store_item')" = "0" ]; then
echo "Config import failed and the database has no game config." >&2
exit 1
fi
echo "WARNING: config import failed; keeping the existing game config." >&2
fi
echo "Database is up to date"
+598
View File
@@ -0,0 +1,598 @@
package main
// Live economy overrides, tuned on /utils/economy. config/game.json is the base; an admin
// can override any of its values without a deploy. Every save appends a row to
// config_override (the history); the latest row is the one in effect. A row holds only
// the values that differ from game.json, so later changes to game.json still show
// through for everything else. Read the effective config with cfg().
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ConfigOverride is what a config_override row stores: a patch over game.json in the
// shape of the config document. Objects merge key by key; arrays of objects with an "id"
// (store items) merge element by element by id ({"id": n, "$delete": true} removes one);
// any other value replaces the game.json
// one. Being generic, it carries any config field, including ones added later.
type ConfigOverride map[string]any
var (
effective atomic.Pointer[GameConfig]
overrideVersion atomic.Int64 // id of the config_override row in effect, 0 for none
)
func init() { effective.Store(&game) }
// cfg returns the effective game config: game.json merged with the latest override.
// It's a shared snapshot: read it, never modify it.
func cfg() *GameConfig { return effective.Load() }
// baseConfig returns a fresh, unshared copy of config/game.json.
func baseConfig() GameConfig { return mustParseGameConfig(gameJSON) }
// configDoc is a config as a generic JSON document.
func configDoc(c *GameConfig) map[string]any {
data, err := json.Marshal(c)
if err != nil {
panic(err)
}
var doc map[string]any
json.Unmarshal(data, &doc)
return doc
}
// decodeConfig parses a config document strictly (unknown fields are errors) and
// validates it.
func decodeConfig(data []byte) (GameConfig, error) {
var c GameConfig
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return c, err
}
return c, c.validate()
}
// applyOverride returns game.json with the override merged in.
func applyOverride(ov ConfigOverride) (GameConfig, error) {
base := baseConfig()
data, err := json.Marshal(mergePatch(configDoc(&base), map[string]any(ov)))
if err != nil {
return GameConfig{}, err
}
return decodeConfig(data)
}
// diffConfig returns the override that turns `from` into `to`.
func diffConfig(from, to *GameConfig) ConfigOverride {
d, changed := diffDoc(configDoc(from), configDoc(to))
if !changed {
return ConfigOverride{}
}
return ConfigOverride(d.(map[string]any))
}
// idKeyed reports whether v is a non-empty array of objects that all have an "id".
func idKeyed(v any) bool {
arr, ok := v.([]any)
if !ok || len(arr) == 0 {
return false
}
for _, e := range arr {
m, ok := e.(map[string]any)
if !ok || m["id"] == nil {
return false
}
}
return true
}
func findID(arr []any, id any) map[string]any {
for _, e := range arr {
if m := e.(map[string]any); m["id"] == id {
return m
}
}
return nil
}
// deleteKey marks an element of an id-keyed array as removed: {"id": 3, "$delete": true}.
const deleteKey = "$delete"
func mergePatch(base, patch any) any {
if idKeyed(patch) && idKeyed(base) {
out := []any{}
for _, e := range base.([]any) {
if p := findID(patch.([]any), e.(map[string]any)["id"]); p != nil {
if p[deleteKey] == true {
continue
}
e = mergePatch(e, p)
}
out = append(out, e)
}
for _, p := range patch.([]any) {
if findID(base.([]any), p.(map[string]any)["id"]) == nil && p.(map[string]any)[deleteKey] != true {
out = append(out, p)
}
}
return out
}
pm, ok := patch.(map[string]any)
if !ok {
return patch
}
out := map[string]any{}
bm, _ := base.(map[string]any)
for k, v := range bm {
out[k] = v
}
for k, v := range pm {
if v == nil {
delete(out, k)
} else {
out[k] = mergePatch(bm[k], v)
}
}
return out
}
// diffDoc returns the patch that turns from into to, and whether there is a difference.
func diffDoc(from, to any) (any, bool) {
if fm, ok := from.(map[string]any); ok {
if tm, ok := to.(map[string]any); ok {
out := map[string]any{}
for k, tv := range tm {
if d, changed := diffDoc(fm[k], tv); changed {
out[k] = d
}
}
for k := range fm {
if _, ok := tm[k]; !ok {
out[k] = nil
}
}
return out, len(out) > 0
}
}
if idKeyed(from) && idKeyed(to) {
out := []any{}
for _, e := range to.([]any) {
id := e.(map[string]any)["id"]
f := findID(from.([]any), id)
if f == nil {
out = append(out, e)
} else if d, changed := diffDoc(f, e); changed {
d.(map[string]any)["id"] = id
out = append(out, d)
}
}
for _, e := range from.([]any) {
if id := e.(map[string]any)["id"]; findID(to.([]any), id) == nil {
out = append(out, map[string]any{"id": id, deleteKey: true})
}
}
return out, len(out) > 0
}
if from == nil && to == nil {
return nil, false
}
return to, !reflect.DeepEqual(from, to)
}
// ChangeCount counts the values an override sets (a replaced array counts once).
func (ov ConfigOverride) ChangeCount() int { return countLeaves(map[string]any(ov)) }
func countLeaves(v any) int {
switch v := v.(type) {
case map[string]any:
n := 0
for k, e := range v {
if k != "id" {
n += countLeaves(e)
}
}
return n
case []any:
if idKeyed(v) {
n := 0
for _, e := range v {
n += countLeaves(e)
}
return n
}
}
return 1
}
// Keys lists the top-level fields an override sets.
func (ov ConfigOverride) Keys() string {
keys := make([]string, 0, len(ov))
for k := range ov {
keys = append(keys, k)
}
slices.Sort(keys)
return strings.Join(keys, ", ")
}
// ------------------------------------------------------------------ database
func isUndefinedTable(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "42P01"
}
// loadOverride makes cfg() return game.json merged with the latest saved override. It
// doesn't touch store_item (see saveOverride and migrate). A missing table (database not
// migrated yet) or an invalid override leaves game.json in effect.
func loadOverride(ctx context.Context, db *pgxpool.Pool) error {
var id int64
var raw []byte
err := db.QueryRow(ctx,
"select id, data from config_override order by id desc limit 1").Scan(&id, &raw)
switch {
case errors.Is(err, pgx.ErrNoRows):
effective.Store(&game)
overrideVersion.Store(0)
return nil
case isUndefinedTable(err):
log.Print("WARNING: no config_override table (run migrate); using config/game.json")
return nil
case err != nil:
return fmt.Errorf("loading config override: %w", err)
}
if id == overrideVersion.Load() {
return nil
}
var ov ConfigOverride
if err := json.Unmarshal(raw, &ov); err != nil {
log.Printf("WARNING: config override #%d is unreadable (%v); using config/game.json", id, err)
return nil
}
c, err := applyOverride(ov)
if err != nil {
log.Printf("WARNING: config override #%d is invalid (%v); using config/game.json", id, err)
return nil
}
effective.Store(&c)
overrideVersion.Store(id)
log.Printf("game config: config/game.json + override #%d", id)
return nil
}
// watchOverride picks up overrides saved by other server instances.
func watchOverride(ctx context.Context, db *pgxpool.Pool) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := loadOverride(ctx, db); err != nil {
log.Print(err)
}
}
}
}
// saveOverride records a new override (by the given admin), re-syncs the store items and
// puts it in effect.
func saveOverride(ctx context.Context, db *pgxpool.Pool, ov ConfigOverride, note, userID string) (int64, error) {
c, err := applyOverride(ov)
if err != nil {
return 0, gameErr("%s", err.Error())
}
data, err := json.Marshal(ov)
if err != nil {
return 0, err
}
var id int64
err = inTx(ctx, db, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx,
`insert into config_override(data, note, created_by)
values ($1, $2, (select id from users where id::text = $3)) returning id`,
string(data), note, userID).Scan(&id)
if err != nil {
return err
}
return syncStoreItemsTx(ctx, tx, c.StoreItems)
})
if err != nil {
return 0, err
}
effective.Store(&c)
overrideVersion.Store(id)
log.Printf("game config: saved override #%d (%s)", id, note)
return id, nil
}
type OverrideRow struct {
ID int64
Note string
Created time.Time
Admin string
Override ConfigOverride
}
func loadOverrideVersion(ctx context.Context, db *pgxpool.Pool, id int64) (ConfigOverride, error) {
var ov ConfigOverride
var raw []byte
err := db.QueryRow(ctx, "select data from config_override where id = $1", id).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return ov, gameErr("No version #%d", id)
} else if err != nil {
return ov, err
}
return ov, json.Unmarshal(raw, &ov)
}
func overrideHistory(ctx context.Context, db *pgxpool.Pool, limit int) ([]OverrideRow, error) {
rows, err := db.Query(ctx, `
select config_override.id, note, config_override.created_at, coalesce(users.name, ''), data
from config_override left join users on users.id = config_override.created_by
order by config_override.id desc limit $1`, limit)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (OverrideRow, error) {
var r OverrideRow
var raw []byte
if err := row.Scan(&r.ID, &r.Note, &r.Created, &r.Admin, &raw); err != nil {
return r, err
}
json.Unmarshal(raw, &r.Override)
return r, nil
})
}
// ------------------------------------------------------------------ editor
// formatParts formats a name → quantity map as "Name: qty, Name: qty" (sorted by name).
func formatParts(parts map[string]float64) string {
var out []string
for _, name := range sortedKeys(parts) {
out = append(out, name+": "+strconv.FormatFloat(parts[name], 'f', -1, 64))
}
return strings.Join(out, ", ")
}
// leafPaths lists the values an override sets, e.g. "moonPrice", "storeItems#3.price".
func leafPaths(v any, prefix string, out *[]string) {
join := func(k string) string {
if prefix == "" {
return k
}
return prefix + "." + k
}
switch v := v.(type) {
case map[string]any:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
if k != "id" {
leafPaths(v[k], join(k), out)
}
}
return
case []any:
if idKeyed(v) {
for _, e := range v {
leafPaths(e, fmt.Sprintf("%s#%v", prefix, e.(map[string]any)["id"]), out)
}
return
}
}
*out = append(*out, prefix)
}
// Summary describes an override's changes in a few words.
func (ov ConfigOverride) Summary() string {
var paths []string
leafPaths(map[string]any(ov), "", &paths)
if len(paths) == 0 {
return "game.json as is"
}
if len(paths) > 4 {
return strings.Join(paths[:4], ", ") + fmt.Sprintf(" +%d more", len(paths)-4)
}
return strings.Join(paths, ", ")
}
// QuoteRow is the market price of a resource or refined item, for the editor.
type QuoteRow struct {
Name string `json:"name"`
Kind string `json:"kind"`
Color string `json:"color"`
Base float64 `json:"base"`
Now float64 `json:"now"`
Change float64 `json:"change"` // percent over 24h
Spark string `json:"spark"` // SVG path, viewBox 0 0 100 24
}
func (q QuoteRow) ChangeText() string { return fmt.Sprintf("%+.2f%%", q.Change) }
func marketQuotes(c *GameConfig, now time.Time) []QuoteRow {
var rows []QuoteRow
add := func(name, kind, color string, base float64) {
tick := marketTick(now)
const span, step = 24 * 12, 4 // 24h of 5-minute ticks
var series []float64
lo, hi := math.Inf(1), math.Inf(-1)
for t := tick - span; t <= tick; t += step {
v := marketRateAt(name, t)
series = append(series, v)
lo, hi = min(lo, v), max(hi, v)
}
var path strings.Builder
for i, v := range series {
y := 12.0
if hi > lo {
y = 22 - (v-lo)/(hi-lo)*20
}
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&path, "%s%.1f %.1f", cmd, float64(i)*100/float64(len(series)-1), y)
}
q := QuoteRow{Name: name, Kind: kind, Color: color, Base: base,
Now: series[len(series)-1], Spark: path.String()}
if series[0] > 0 {
q.Change = (q.Now/series[0] - 1) * 100
}
rows = append(rows, q)
}
for _, name := range sortedKeys(c.ResourcePrices) {
add(name, "Resource", resourceColors[name], c.ResourcePrices[name])
}
for _, item := range c.Refinery.Items {
add(item.Name, "Refined", item.Color, item.Price)
}
return rows
}
// ------------------------------------------------------------------ handlers
type EconomyPage struct {
Allowed bool
Data map[string]any // for economy.js: saved and base config, market prices
Quotes []QuoteRow
History []OverrideRow
Version int64 // override in effect, 0 for plain game.json
Message string
}
func (s *server) economyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /utils/economy", s.handleEconomy)
mux.HandleFunc("POST /utils/economy/{action}", s.handleEconomyAction)
}
func (s *server) handleEconomy(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "economy", &EconomyPage{})
return
}
ctx := r.Context()
// Pick up a save made by another instance before showing the editor.
if err := loadOverride(ctx, s.db); err != nil {
serverError(w, err)
return
}
history, err := overrideHistory(ctx, s.db, 50)
if err != nil && !isUndefinedTable(err) {
serverError(w, err)
return
}
base := baseConfig()
c := cfg()
quotes := marketQuotes(c, time.Now())
s.render(w, "economy", &EconomyPage{
Allowed: true,
Data: map[string]any{
"saved": configDoc(c),
"base": configDoc(&base),
"version": overrideVersion.Load(),
"market": quotes,
"colors": resourceColors,
},
Quotes: quotes,
History: history,
Version: overrideVersion.Load(),
Message: r.FormValue("msg"),
})
}
// handleEconomyAction: analyze renders the analysis of a draft (raw = the whole config
// JSON); save stores a draft as a new override; revert puts a saved version back; reset
// goes back to game.json. Save, revert and reset answer JSON: {"version", "message"} or
// {"errors": [...]}.
func (s *server) handleEconomyAction(w http.ResponseWriter, r *http.Request) {
userID := s.currentUser(r)
if !s.isAdmin(userID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
var ov ConfigOverride
var note string
switch r.PathValue("action") {
case "analyze":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
if err != nil {
s.render(w, "economy-analysis", analyze(&draft, []string{err.Error()}, 0))
return
}
s.render(w, "economy-analysis", analyze(&draft, nil, diffConfig(cfg(), &draft).ChangeCount()))
return
case "save":
draft, err := decodeConfig([]byte(r.PostFormValue("raw")))
if err != nil {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
return
}
if diffConfig(cfg(), &draft).ChangeCount() == 0 {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{"No changes to save"}})
return
}
base := baseConfig()
ov = diffConfig(&base, &draft)
if note = strings.TrimSpace(r.PostFormValue("note")); note == "" {
note = fmt.Sprintf("%s from game.json", plural(ov.ChangeCount(), "change"))
}
case "revert":
version, _ := strconv.ParseInt(r.PostFormValue("version"), 10, 64)
var err error
if ov, err = loadOverrideVersion(ctx, s.db, version); err != nil {
if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
} else {
serverError(w, err)
}
return
}
note = fmt.Sprintf("Revert to #%d", version)
case "reset":
ov, note = ConfigOverride{}, "Reset to game.json"
default:
http.NotFound(w, r)
return
}
id, err := saveOverride(ctx, s.db, ov, note, userID)
if err != nil {
if isGameError(err) {
reply(http.StatusUnprocessableEntity, map[string]any{"errors": []string{err.Error()}})
} else {
serverError(w, err)
}
return
}
reply(http.StatusOK, map[string]any{"version": id,
"message": fmt.Sprintf("Saved version #%d (%s). The game uses it now.", id, note)})
}
+230
View File
@@ -0,0 +1,230 @@
package main
// Live events: timed boosts set on /utils/liveops (table live_event), and announcements.
// The game transactions call eventMultiplier; the game page shows active ones in a banner.
import (
"context"
"fmt"
"math"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// querier is a transaction or the pool.
type querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// Event kinds. A multiplier above 1 helps the player: more resources per claim, more
// credits per unit sold, or faster stakes (duration divided by the multiplier, for
// stakes started during the event).
var eventKinds = []string{"yield_multiplier", "price_multiplier", "cycle_multiplier"}
type LiveEvent struct {
ID int
Name string
Kind string
Resource string // a resource name, or "all"
Multiplier float64
StartsAt time.Time
EndsAt time.Time
Enabled bool
}
func (e LiveEvent) Active(now time.Time) bool {
return e.Enabled && !now.Before(e.StartsAt) && now.Before(e.EndsAt)
}
// Status for the admin list.
func (e LiveEvent) Status(now time.Time) string {
switch {
case !e.Enabled:
return "Off"
case now.Before(e.StartsAt):
return "Scheduled"
case now.Before(e.EndsAt):
return "Live"
}
return "Ended"
}
// Headline describes the effect, e.g. "Double Azurium yield".
func (e LiveEvent) Headline() string {
var factor string
switch e.Multiplier {
case 2:
factor = "Double"
case 3:
factor = "Triple"
default:
factor = strconv.FormatFloat(e.Multiplier, 'f', -1, 64) + "×"
}
what := ""
if e.Resource != "all" && e.Resource != "" {
what = e.Resource + " "
}
switch e.Kind {
case "yield_multiplier":
return factor + " " + what + "yield"
case "price_multiplier":
if what == "" {
return factor + " sell prices"
}
return factor + " " + what + "prices"
case "cycle_multiplier":
return factor + " mining speed"
}
return e.Name
}
// eventMultiplier is the combined multiplier of the active events of a kind that cover
// the resource (overlapping events multiply). Call it inside the action's transaction.
func eventMultiplier(ctx context.Context, q querier, kind, resource string) (float64, error) {
var m float64
err := q.QueryRow(ctx, `
select coalesce(exp(sum(ln((params->>'multiplier')::float8))), 1)
from live_event
where enabled and kind = $1 and starts_at <= now() and ends_at > now()
and (params->>'multiplier')::float8 > 0
and coalesce(params->>'resource', 'all') in ('all', $2)`, kind, resource).Scan(&m)
return m, err
}
const liveEventColumns = `id, name, kind, coalesce(params->>'resource', 'all'),
coalesce((params->>'multiplier')::float8, 1), starts_at, ends_at, enabled`
func scanLiveEvent(row pgx.CollectableRow) (LiveEvent, error) {
var e LiveEvent
err := row.Scan(&e.ID, &e.Name, &e.Kind, &e.Resource, &e.Multiplier, &e.StartsAt, &e.EndsAt, &e.Enabled)
return e, err
}
func listLiveEvents(ctx context.Context, q querier) ([]LiveEvent, error) {
rows, err := q.Query(ctx, "select "+liveEventColumns+" from live_event order by starts_at desc, id desc")
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, scanLiveEvent)
}
func saveLiveEvent(ctx context.Context, q querier, e LiveEvent) error {
params := map[string]any{"resource": e.Resource, "multiplier": e.Multiplier}
if e.ID == 0 {
_, err := q.Exec(ctx, `
insert into live_event(name, kind, params, starts_at, ends_at, enabled)
values ($1, $2, $3, $4, $5, $6)`, e.Name, e.Kind, params, e.StartsAt, e.EndsAt, e.Enabled)
return err
}
_, err := q.Exec(ctx, `
update live_event set name = $2, kind = $3, params = $4, starts_at = $5, ends_at = $6, enabled = $7
where id = $1`, e.ID, e.Name, e.Kind, params, e.StartsAt, e.EndsAt, e.Enabled)
return err
}
type Announcement struct {
ID int
Message string
Level string // info, warning, success
StartsAt time.Time
EndsAt time.Time
}
func (a Announcement) Status(now time.Time) string {
switch {
case now.Before(a.StartsAt):
return "Scheduled"
case now.Before(a.EndsAt):
return "Live"
}
return "Ended"
}
var announcementLevels = []string{"info", "success", "warning"}
func listAnnouncements(ctx context.Context, q querier) ([]Announcement, error) {
rows, err := q.Query(ctx, `
select id, message, level, starts_at, ends_at from announcement
order by starts_at desc, id desc`)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByPos[Announcement])
}
func saveAnnouncement(ctx context.Context, q querier, a Announcement) error {
if a.ID == 0 {
_, err := q.Exec(ctx, `
insert into announcement(message, level, starts_at, ends_at) values ($1, $2, $3, $4)`,
a.Message, a.Level, a.StartsAt, a.EndsAt)
return err
}
_, err := q.Exec(ctx, `
update announcement set message = $2, level = $3, starts_at = $4, ends_at = $5 where id = $1`,
a.ID, a.Message, a.Level, a.StartsAt, a.EndsAt)
return err
}
// ------------------------------------------------------------------ banner
// BannerItem is one line of the banner at the top of the game page.
type BannerItem struct {
Level string // info, warning, success, event
Text string
Left string // "3h left"
// For API clients: the announcement or live event it shows and when it ends.
Kind string // announcement, event
ID int
EndsAt time.Time
}
// loadBanner returns the active announcements, then the active events.
func loadBanner(ctx context.Context, q querier) ([]BannerItem, error) {
now := time.Now()
rows, err := q.Query(ctx, `
select id, message, level, starts_at, ends_at from announcement
where starts_at <= now() and ends_at > now() order by starts_at, id`)
if err != nil {
return nil, err
}
anns, err := pgx.CollectRows(rows, pgx.RowToStructByPos[Announcement])
if err != nil {
return nil, err
}
var items []BannerItem
for _, a := range anns {
items = append(items, BannerItem{Level: a.Level, Text: a.Message, Kind: "announcement", ID: a.ID, EndsAt: a.EndsAt})
}
rows, err = q.Query(ctx, "select "+liveEventColumns+` from live_event
where enabled and starts_at <= now() and ends_at > now() order by ends_at, id`)
if err != nil {
return nil, err
}
events, err := pgx.CollectRows(rows, scanLiveEvent)
if err != nil {
return nil, err
}
for _, e := range events {
items = append(items, BannerItem{Level: "event", Text: e.Headline(), Left: timeLeft(e.EndsAt.Sub(now)),
Kind: "event", ID: e.ID, EndsAt: e.EndsAt})
}
return items, nil
}
// timeLeft formats e.g. "2d 4h left", "3h 20m left", "45m left".
func timeLeft(d time.Duration) string {
mins := int(math.Ceil(d.Minutes()))
switch {
case mins >= 24*60:
return fmt.Sprintf("%dd %dh left", mins/(24*60), mins%(24*60)/60)
case mins >= 60:
return fmt.Sprintf("%dh %dm left", mins/60, mins%60)
}
return fmt.Sprintf("%dm left", max(mins, 1))
}
+1539
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
module moonminers
go 1.25.10
require github.com/jackc/pgx/v5 v5.11.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+26
View File
@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+60
View File
@@ -0,0 +1,60 @@
package main
// The Leaderboard section: the richest miners by credit balance, with the player's own
// place below the top when they're not in it. Banned players don't rank. Template:
// leaderboard-section in app.html.
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const leaderboardSize = 10
type LeaderboardPage struct {
Top []LeaderboardRow
You *LeaderboardRow // the player, when they're not in Top
Players int // miners ranked
}
type LeaderboardRow struct {
Rank int
Name string
Balance float64
Me bool
}
// loadLeaderboard reads the top of the ranking and the player's own row. Ties go to whoever
// joined first, so every miner has one place.
func loadLeaderboard(ctx context.Context, db *pgxpool.Pool, userID string) (*LeaderboardPage, error) {
rows, err := db.Query(ctx, `
select rank, name, balance, id = $1, players from (
select users.id, users.name, bank_account.balance,
row_number() over (order by bank_account.balance desc, users.created_at, users.id) as rank,
count(*) over () as players
from users join bank_account on bank_account.user_id = users.id
where users.banned_at is null
) ranked
where rank <= $2 or id = $1
order by rank`, userID, leaderboardSize)
if err != nil {
return nil, err
}
p := &LeaderboardPage{}
var r LeaderboardRow
_, err = pgx.ForEachRow(rows, []any{&r.Rank, &r.Name, &r.Balance, &r.Me, &p.Players}, func() error {
if r.Rank <= leaderboardSize {
p.Top = append(p.Top, r)
} else {
you := r
p.You = &you
}
return nil
})
if err != nil {
return nil, err
}
return p, nil
}
+402
View File
@@ -0,0 +1,402 @@
package main
// Live ops (/utils/liveops, admins only): the event log behind the metrics, the
// dashboard, and forms for timed events (events.go) and announcements.
import (
"context"
"fmt"
"log"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// logEvent records a player action in event_log. Call it inside the action's
// transaction, so the log matches what happened. data must marshal to a JSON object.
func logEvent(ctx context.Context, q querier, userID, kind string, data map[string]any) error {
if data == nil {
data = map[string]any{}
}
_, err := q.Exec(ctx,
"insert into event_log(user_id, kind, data) values ($1, $2, $3)", userID, kind, data)
return err
}
// Credits spent are logged as "price" on these kinds, earned as "credits" on sell.
const spendKinds = "'buy_moon', 'buy_tool', 'upgrade', 'buy_slot', 'buy_attachment', 'hire_advisor', 'buy_seat'"
// ------------------------------------------------------------------ dashboard
type DayStat struct {
Day time.Time
Active int
Actions int
Earned float64
Spent float64
// Bar heights, percent of the column's maximum.
ActivePct, ActionsPct, EarnedPct, SpentPct float64
}
type Retention struct{ Cohort, Returned int }
func (r Retention) Rate() string {
if r.Cohort == 0 {
return "—"
}
return fmt.Sprintf("%.0f%%", 100*float64(r.Returned)/float64(r.Cohort))
}
type ResourceStat struct {
Resource string
Amount float64
Pct float64
}
type Dashboard struct {
Players, NewToday, ActiveToday int
Days []DayStat
MaxActive, MaxActions int
MaxCredits float64
D1, D7 Retention
Claimed []ResourceStat
}
func percentOf(v, of float64) float64 {
if of <= 0 {
return 0
}
return 100 * v / of
}
func loadDashboard(ctx context.Context, q querier) (*Dashboard, error) {
d := &Dashboard{}
err := q.QueryRow(ctx, `
select count(*), count(*) filter (where created_at >= current_date) from users`).
Scan(&d.Players, &d.NewToday)
if err != nil {
return nil, err
}
rows, err := q.Query(ctx, `
with days as (
select day::date as day from generate_series(current_date - 13, current_date, interval '1 day') day
)
select day,
(select count(distinct user_id) from event_log
where created_at >= day and created_at < day + 1 and kind not like 'admin\_%'),
(select count(*) from event_log
where created_at >= day and created_at < day + 1 and kind not in ('session', 'new_player')
and kind not like 'admin\_%'),
(select coalesce(sum((data->>'credits')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind = 'sell'),
(select coalesce(sum((data->>'price')::float8), 0) from event_log
where created_at >= day and created_at < day + 1 and kind in (`+spendKinds+`))
from days order by day`)
if err != nil {
return nil, err
}
d.Days, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (DayStat, error) {
var s DayStat
err := row.Scan(&s.Day, &s.Active, &s.Actions, &s.Earned, &s.Spent)
return s, err
})
if err != nil {
return nil, err
}
for _, s := range d.Days {
d.MaxActive = max(d.MaxActive, s.Active)
d.MaxActions = max(d.MaxActions, s.Actions)
d.MaxCredits = max(d.MaxCredits, s.Earned, s.Spent)
}
for i := range d.Days {
s := &d.Days[i]
s.ActivePct = percentOf(float64(s.Active), float64(d.MaxActive))
s.ActionsPct = percentOf(float64(s.Actions), float64(d.MaxActions))
s.EarnedPct = percentOf(s.Earned, d.MaxCredits)
s.SpentPct = percentOf(s.Spent, d.MaxCredits)
}
if n := len(d.Days); n > 0 {
d.ActiveToday = d.Days[n-1].Active
}
// Dn retention: of the players who joined in the last 30 days at least n days ago,
// the share that did something n days after the day they joined.
err = q.QueryRow(ctx, `
with cohort as (
select id, created_at::date as day from users
where created_at >= current_date - 30
)
select
count(*) filter (where day <= current_date - 1),
count(*) filter (where day <= current_date - 1 and exists (
select 1 from event_log where user_id = cohort.id
and created_at >= day + 1 and created_at < day + 2 and kind not like 'admin\_%')),
count(*) filter (where day <= current_date - 7),
count(*) filter (where day <= current_date - 7 and exists (
select 1 from event_log where user_id = cohort.id
and created_at >= day + 7 and created_at < day + 8 and kind not like 'admin\_%'))
from cohort`).Scan(&d.D1.Cohort, &d.D1.Returned, &d.D7.Cohort, &d.D7.Returned)
if err != nil {
return nil, err
}
rows, err = q.Query(ctx, `
select data->>'resource', sum((data->>'amount')::float8) from event_log
where kind = 'claim' and created_at >= current_date - 13
group by 1 order by 1`)
if err != nil {
return nil, err
}
d.Claimed, err = pgx.CollectRows(rows, func(row pgx.CollectableRow) (ResourceStat, error) {
var s ResourceStat
err := row.Scan(&s.Resource, &s.Amount)
return s, err
})
if err != nil {
return nil, err
}
var most float64
for _, s := range d.Claimed {
most = max(most, s.Amount)
}
for i := range d.Claimed {
d.Claimed[i].Pct = percentOf(d.Claimed[i].Amount, most)
}
return d, nil
}
// ------------------------------------------------------------------ handlers
// LiveOps is the view model for web/templates/liveops.html.
type LiveOps struct {
Allowed bool
Now time.Time
Dash *Dashboard
Events []LiveEvent
Announcements []Announcement
Resources []string
Kinds []string
Levels []string
// The forms: a new item, or the one being edited.
Event LiveEvent
Announcement Announcement
EventError string
AnnError string
}
const formTime = "2006-01-02T15:04"
// Input formats a time for <input type="datetime-local"> (UTC).
func (LiveOps) Input(t time.Time) string { return t.UTC().Format(formTime) }
// When formats a time for the lists (UTC).
func (LiveOps) When(t time.Time) string { return t.UTC().Format("Jan 2 15:04") }
func (s *server) liveOps(r *http.Request) (*LiveOps, error) {
ctx := r.Context()
now := time.Now()
lo := &LiveOps{Allowed: true, Now: now, Kinds: eventKinds, Levels: announcementLevels}
lo.Event = LiveEvent{Kind: "yield_multiplier", Resource: "all", Multiplier: 2, Enabled: true,
StartsAt: now, EndsAt: now.Add(24 * time.Hour)}
lo.Announcement = Announcement{Level: "info", StartsAt: now, EndsAt: now.Add(24 * time.Hour)}
var err error
if lo.Events, err = listLiveEvents(ctx, s.db); err != nil {
return nil, err
}
if lo.Announcements, err = listAnnouncements(ctx, s.db); err != nil {
return nil, err
}
rows, err := s.db.Query(ctx, "select name from resource order by name")
if err != nil {
return nil, err
}
if lo.Resources, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return nil, err
}
return lo, nil
}
func (s *server) handleLiveOps(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
s.forbidden(w, "liveops", &LiveOps{})
return
}
lo, err := s.liveOps(r)
if err != nil {
serverError(w, err)
return
}
if lo.Dash, err = loadDashboard(r.Context(), s.db); err != nil {
serverError(w, err)
return
}
// ?event=<id> / ?announcement=<id> opens that item in its form.
if id, _ := strconv.Atoi(r.FormValue("event")); id != 0 {
for _, e := range lo.Events {
if e.ID == id {
lo.Event = e
}
}
}
if id, _ := strconv.Atoi(r.FormValue("announcement")); id != 0 {
for _, a := range lo.Announcements {
if a.ID == id {
lo.Announcement = a
}
}
}
s.render(w, "liveops", lo)
}
// handleLiveOpsAction saves, toggles or deletes an event or announcement and re-renders
// that panel (#lo-events or #lo-announcements).
func (s *server) handleLiveOpsAction(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
// POST /utils/liveops/{what} saves; /utils/liveops/{what}/{id}/{action} does the rest.
what, action := r.PathValue("what"), r.PathValue("action")
if action == "" {
action = "save"
}
id, _ := strconv.Atoi(r.PathValue("id"))
var formErr string
var form any
var err error
switch what + "/" + action {
case "events/save":
var e LiveEvent
if e, formErr = parseLiveEvent(r); formErr == "" {
err = saveLiveEvent(ctx, s.db, e)
} else {
form = e
}
case "events/toggle":
_, err = s.db.Exec(ctx, "update live_event set enabled = not enabled where id = $1", id)
case "events/delete":
_, err = s.db.Exec(ctx, "delete from live_event where id = $1", id)
case "announcements/save":
var a Announcement
if a, formErr = parseAnnouncement(r); formErr == "" {
err = saveAnnouncement(ctx, s.db, a)
} else {
form = a
}
case "announcements/delete":
_, err = s.db.Exec(ctx, "delete from announcement where id = $1", id)
default:
http.NotFound(w, r)
return
}
if err != nil {
serverError(w, err)
return
}
lo, err := s.liveOps(r)
if err != nil {
serverError(w, err)
return
}
// Leave a ?event=... edit link behind once it's saved.
w.Header().Set("HX-Replace-Url", "/utils/liveops")
if what == "events" {
if e, ok := form.(LiveEvent); ok {
lo.Event, lo.EventError = e, formErr
}
s.render(w, "lo-events", lo)
return
}
if a, ok := form.(Announcement); ok {
lo.Announcement, lo.AnnError = a, formErr
}
s.render(w, "lo-announcements", lo)
}
// parseTimes reads the starts/ends inputs (UTC).
func parseTimes(r *http.Request) (time.Time, time.Time, string) {
start, err1 := time.ParseInLocation(formTime, r.FormValue("starts"), time.UTC)
end, err2 := time.ParseInLocation(formTime, r.FormValue("ends"), time.UTC)
switch {
case err1 != nil || err2 != nil:
return start, end, "Enter a start and an end time"
case !end.After(start):
return start, end, "The end must be after the start"
}
return start, end, ""
}
func parseLiveEvent(r *http.Request) (LiveEvent, string) {
id, _ := strconv.Atoi(r.FormValue("id"))
mult, err := strconv.ParseFloat(r.FormValue("multiplier"), 64)
e := LiveEvent{
ID: id,
Name: strings.TrimSpace(r.FormValue("name")),
Kind: r.FormValue("kind"),
Resource: r.FormValue("resource"),
Multiplier: mult,
Enabled: r.FormValue("enabled") == "1",
}
var msg string
e.StartsAt, e.EndsAt, msg = parseTimes(r)
if e.Resource == "" || e.Kind == "cycle_multiplier" {
e.Resource = "all"
}
switch {
case e.Name == "":
msg = "Give the event a name"
case !slices.Contains(eventKinds, e.Kind):
msg = "Pick a kind"
case err != nil || !(mult > 0 && mult <= 100):
msg = "The multiplier must be above 0 and at most 100"
}
return e, msg
}
func parseAnnouncement(r *http.Request) (Announcement, string) {
id, _ := strconv.Atoi(r.FormValue("id"))
a := Announcement{ID: id, Message: strings.TrimSpace(r.FormValue("message")), Level: r.FormValue("level")}
var msg string
a.StartsAt, a.EndsAt, msg = parseTimes(r)
switch {
case a.Message == "":
msg = "Write a message"
case len(a.Message) > 200:
msg = "Keep the message under 200 characters"
case !slices.Contains(announcementLevels, a.Level):
msg = "Pick a level"
}
return a, msg
}
// banner loads the game page's banner; a failure only hides it.
func (s *server) banner(ctx context.Context) []BannerItem {
items, err := loadBanner(ctx, s.db)
if err != nil {
log.Printf("banner: %v", err)
}
return items
}
// logSession records a full page load of the game (a session start), at most once per
// 10 minutes per player.
func (s *server) logSession(ctx context.Context, userID string) {
_, err := s.db.Exec(ctx, `
insert into event_log(user_id, kind)
select $1::uuid, 'session'
where not exists (
select 1 from event_log
where user_id = $1 and kind = 'session' and created_at > now() - interval '10 minutes')`,
userID)
if err != nil {
log.Printf("event log: %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
// Moon Miners: a server-rendered browser mining game.
//
// moonminers [serve] run the web server (default)
// moonminers migrate create roles/schema, apply migrations, load the game config
// moonminers load-config sync the store items in the database with the game config
// moonminers sim [file] print the economy analysis for the game config (or a draft file)
// moonminers moon-traits print moon look traits/descriptions (stdin JSON; see moondesc.go)
package main
import (
"bufio"
"context"
"embed"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
)
//go:embed web/templates web/static sql/tables.sql sql/data.sql sql/migrations
var embedded embed.FS
func main() {
loadDotEnv(".env")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
var err error
switch cmd {
case "serve":
err = serve(ctx)
case "migrate":
err = migrate(ctx)
case "load-config":
err = runLoadConfig(ctx)
case "moon-traits":
err = runMoonTraits()
case "sim":
err = runSim(os.Args[2:])
default:
err = fmt.Errorf("unknown command %q (want serve, migrate, load-config, sim or moon-traits)", cmd)
}
if err != nil {
log.Fatal(err)
}
}
func runLoadConfig(ctx context.Context) error {
db, err := connect(ctx, appDSN())
if err != nil {
return err
}
defer db.Close()
if err := loadOverride(ctx, db); err != nil {
return err
}
return syncStoreItems(ctx, db)
}
// loadDotEnv sets variables from a KEY=VALUE file, without overriding the real environment.
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
key, value, ok := strings.Cut(line, "=")
if !ok || strings.HasPrefix(line, "#") {
continue
}
key = strings.TrimSpace(key)
if _, set := os.LookupEnv(key); !set {
os.Setenv(key, strings.Trim(strings.TrimSpace(value), `"'`))
}
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+426
View File
@@ -0,0 +1,426 @@
package main
// Market prices. A unit sells for its base price (config: resourcePrices, or a refined
// item's price) times a drift that moves every 5 minutes, times any live price events.
// The drift is a deterministic function of the item name and time, so the 24h history
// is recomputed instead of stored. It stays within ±marketVolatility of the base. A
// player's own recent sales of an item lower its price for them a little (selling pressure).
import (
"context"
"fmt"
"hash/fnv"
"math"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
marketStep = 5 * time.Minute
marketPoints = 24*12 + 1 // 24h of 5-minute ticks, including now
)
func marketTick(t time.Time) int64 { return t.Unix() / int64(marketStep/time.Second) }
func marketSeed(name string) uint64 {
h := fnv.New64a()
h.Write([]byte(name))
return h.Sum64()
}
// hashUnit maps (seed, i) to [-1, 1].
func hashUnit(seed uint64, i int64) float64 {
x := seed ^ uint64(i)*0x9e3779b97f4a7c15
x ^= x >> 30
x *= 0xbf58476d1ce4e5b9
x ^= x >> 27
x *= 0x94d049bb133111eb
x ^= x >> 31
return float64(x>>11)/float64(1<<53)*2 - 1
}
// marketDrift is the price drift of a tick, in [-1, 1]: three slow waves (5-29h), smooth value
// noise on an hourly lattice and a little per-tick jitter. The weights sum to 1.
func marketDrift(name string, tick int64) float64 {
seed := marketSeed(name)
hours := float64(tick) / 12
d := 0.0
for i, w := range []float64{0.26, 0.24, 0.2} {
period := []float64{5, 11, 29}[i] * (0.8 + 0.4*(hashUnit(seed, int64(-1-i))+1)/2)
phase := math.Pi * hashUnit(seed, int64(-10-i))
d += w * math.Sin(2*math.Pi*hours/period+phase)
}
const lattice = 12 // ticks
k := floorDiv(tick, lattice)
f := float64(tick-k*lattice) / lattice
f = (1 - math.Cos(math.Pi*f)) / 2
a, b := hashUnit(seed^0xa5a5, k), hashUnit(seed^0xa5a5, k+1)
d += 0.25 * (a + (b-a)*f)
d += 0.05 * hashUnit(seed^0x5a5a, tick)
return d
}
func floorDiv(a, b int64) int64 {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
// marketRateAt is the market price of a unit at a tick, before live events.
func marketRateAt(name string, tick int64) float64 {
return cfg().unitPrice(name) * (1 + cfg().MarketVolatility*marketDrift(name, tick))
}
// marketRate is the market price of a unit at time t, before live events. sell and
// sellRefined use it with time.Now().
func marketRate(name string, t time.Time) float64 { return marketRateAt(name, marketTick(t)) }
// ------------------------------------------------------------------ selling pressure
// A player's sales of an item push its price down for them, a little. Each sale adds its
// base value (amount × base price) to the player's pressure on the item, which halves every
// halfLifeSecs. The price factor at pressure p is floor + (1−floor)·e^(−p/scale): small sales
// barely move it, heavy selling flattens out towards the floor, never below. A sale is priced
// at the factor averaged over the pressure it adds, so splitting a sale doesn't pay.
// Per player, so one player's dumping doesn't lower everyone's prices. Stored pressure is
// capped at pressureCap scales (the price is within 2% of the floor's gap by then), so a
// huge dump recovers as fast as a merely heavy one.
const pressureCap = 4
// MarketPressure is the selling pressure config (marketPressure in game.json).
type MarketPressure struct {
Floor float64 `json:"floor"` // lowest price factor, in (0, 1]; 1 turns it off
Scale float64 `json:"scale"` // base value sold that closes ~63% of the gap to the floor
HalfLifeSecs float64 `json:"halfLifeSecs"` // how fast pressure wears off
}
func (m MarketPressure) validate() error {
if !(m.Floor > 0 && m.Floor <= 1) || !(m.Scale > 0) || !(m.HalfLifeSecs > 0) {
return fmt.Errorf("marketPressure: floor must be in (0, 1], scale and halfLifeSecs above 0")
}
return nil
}
// Factor is the price factor at pressure p.
func (m MarketPressure) Factor(p float64) float64 {
return m.Floor + (1-m.Floor)*math.Exp(-max(0, p)/m.Scale)
}
// Average is the factor averaged over a sale that adds d to pressure p.
func (m MarketPressure) Average(p, d float64) float64 {
if d <= 0 {
return m.Factor(p)
}
p = max(0, p)
return m.Floor + (1-m.Floor)*m.Scale*(math.Exp(-p/m.Scale)-math.Exp(-(p+d)/m.Scale))/d
}
// Decay is pressure p after secs seconds.
func (m MarketPressure) Decay(p, secs float64) float64 {
return p * math.Exp2(-max(0, secs)/m.HalfLifeSecs)
}
// lockPressure locks the player's pressure on an item and returns it as of now.
func lockPressure(ctx context.Context, tx pgx.Tx, userID, item string, now time.Time) (float64, error) {
_, err := tx.Exec(ctx, `
insert into market_pressure (user_id, item) values ($1, $2) on conflict do nothing`, userID, item)
if err != nil {
return 0, err
}
var p float64
var at time.Time
err = tx.QueryRow(ctx, `
select pressure, updated_at from market_pressure where user_id = $1 and item = $2 for update`,
userID, item).Scan(&p, &at)
return cfg().MarketPressure.Decay(p, now.Sub(at).Seconds()), err
}
// sellPressure locks the player's pressure on an item, adds a sale of amount units and
// returns the price factor of the sale.
func sellPressure(ctx context.Context, tx pgx.Tx, userID, item string, amount float64, now time.Time) (float64, error) {
p, err := lockPressure(ctx, tx, userID, item, now)
if err != nil {
return 0, err
}
m := cfg().MarketPressure
d := amount * cfg().unitPrice(item)
_, err = tx.Exec(ctx, `
update market_pressure set pressure = $3, updated_at = $4 where user_id = $1 and item = $2`,
userID, item, min(p+d, pressureCap*m.Scale), now)
return m.Average(p, d), err
}
// loadPressure loads the player's selling pressure by item, as of s.Now.
func loadPressure(ctx context.Context, q querier, s *State) error {
rows, err := q.Query(ctx, "select item, pressure, updated_at from market_pressure where user_id = $1", s.UserID)
if err != nil {
return err
}
s.Pressure = map[string]float64{}
var item string
var p float64
var at time.Time
_, err = pgx.ForEachRow(rows, []any{&item, &p, &at}, func() error {
s.Pressure[item] = cfg().MarketPressure.Decay(p, s.Now.Sub(at).Seconds())
return nil
})
return err
}
// ------------------------------------------------------------------ live price events
// PriceBoosts are the active price_multiplier events: by resource name, and "all".
type PriceBoosts map[string]float64
// For is the combined multiplier for a resource (same rule as eventMultiplier).
func (b PriceBoosts) For(name string) float64 {
m := 1.0
if v, ok := b["all"]; ok {
m *= v
}
if v, ok := b[name]; ok {
m *= v
}
return m
}
// loadBoosts returns the active live events of a kind (price_multiplier, ...), combined.
func loadBoosts(ctx context.Context, q querier, now time.Time, kind string) (PriceBoosts, error) {
events, err := listLiveEvents(ctx, q)
if err != nil {
return nil, err
}
b := PriceBoosts{}
for _, e := range events {
if e.Kind != kind || !e.Active(now) || !(e.Multiplier > 0) {
continue
}
key := e.Resource
if key == "" {
key = "all"
}
if _, ok := b[key]; !ok {
b[key] = 1
}
b[key] *= e.Multiplier
}
return b, nil
}
// ------------------------------------------------------------------ view
type MarketPage struct {
Quotes []Quote
Selected *Quote
Chart MarketChart
Total float64 // value of everything held, at current prices
Held int // how many kinds are held
Best *Quote // biggest 24h gain
Worst *Quote // biggest 24h loss
}
type Quote struct {
MarketRow
Price float64 // what one unit sells for now (rate × boost × advisor × factor)
Rate float64 // market rate now, before events
Boost float64
Advisor float64 // the player's advisor sell bonus (bonus.go)
Pressure float64 // the player's selling pressure on it, now
Factor float64 // price factor from that pressure
Open float64 // market rate 24h ago
Change float64 // percent, over 24h, market rate
High float64 // 24h high/low of the market rate
Low float64
Value float64 // Balance × Price
Spark string // SVG path, viewBox 0 0 100 32
URL string
Selected bool
series []float64
}
func (q Quote) Up() bool { return q.Change >= 0 }
func (q Quote) Boosted() bool { return q.Boost != 1 }
func (q Quote) Advised() bool { return q.Advisor > 1 }
// Pressed is whether recent selling lowers the price noticeably; PressureText is e.g. "−4%".
func (q Quote) Pressed() bool { return q.Factor < 0.995 }
func (q Quote) PressureText() string {
return "−" + strconv.Itoa(int(math.Round((1-q.Factor)*100))) + "%"
}
// Unpressed is the price of one unit before selling pressure; the sell ticket (market.js)
// applies the pressure curve to its amount.
func (q Quote) Unpressed() float64 { return q.Rate * q.Boost * q.Advisor }
func (q Quote) Base() float64 { return cfg().unitPrice(q.Resource) }
func (q Quote) Curve() MarketPressure { return cfg().MarketPressure }
// AdvisorText is e.g. "+8%".
func (q Quote) AdvisorText() string { return pctText(q.Advisor) }
func (q Quote) Symbol() string { return marketSymbol(q.Resource) }
func (q Quote) ChangeText() string {
sign := "+"
if q.Change < 0 {
sign = "−"
}
return sign + strconv.FormatFloat(math.Abs(q.Change), 'f', 2, 64) + "%"
}
// BoostText is e.g. "×2".
func (q Quote) BoostText() string { return "×" + strconv.FormatFloat(q.Boost, 'f', -1, 64) }
// marketSymbol is a ticker symbol: the first three letters, or initials for two words.
func marketSymbol(name string) string {
words := strings.Fields(strings.ToUpper(name))
if len(words) > 1 {
s := ""
for _, w := range words {
s += w[:1]
}
return s + strings.Repeat("X", max(0, 3-len(s)))
}
if len(name) >= 3 {
return strings.ToUpper(name[:3])
}
return strings.ToUpper(name)
}
// MarketChart is the selected quote's 24h chart, viewBox 0 0 ChartW ChartH.
type MarketChart struct {
Line, Area string
Prices string // comma-separated, for the crosshair (market.js)
Start, Step int64 // unix ms of the first point, ms between points
HighY, LowY float64
Grid []GridLine
Times []TimeLabel
}
type GridLine struct {
Y float64 // in viewBox units
Top float64 // the same, as a percent of the height
Label string
}
type TimeLabel struct {
X float64 // percent
Label string
}
const (
chartW = 600
chartH = 220
sparkW = 100
sparkH = 32
)
// marketPage builds the market from the rows the market section lists (resources and
// refined items).
func (s *State) marketPage(rows []MarketRow, v View) *MarketPage {
now := marketTick(s.Now)
mp := &MarketPage{}
for _, row := range rows {
q := Quote{MarketRow: row, Boost: s.PriceBoosts.For(row.Resource), Advisor: s.Bonus().Sell,
URL: View{Section: "market", Moon: v.Moon, Pick: row.Resource}.URL()}
q.series = make([]float64, marketPoints)
for i := range q.series {
q.series[i] = marketRateAt(row.Resource, now-int64(marketPoints-1-i))
}
q.Rate, q.Open = q.series[marketPoints-1], q.series[0]
q.High, q.Low = q.series[0], q.series[0]
for _, p := range q.series {
q.High, q.Low = max(q.High, p), min(q.Low, p)
}
if q.Open > 0 {
q.Change = (q.Rate/q.Open - 1) * 100
}
q.Pressure = s.Pressure[row.Resource]
q.Factor = cfg().MarketPressure.Factor(q.Pressure)
q.Price = q.Rate * q.Boost * q.Advisor * q.Factor
q.Value = q.Balance * q.Price
q.Spark, _ = chartPaths(q.series, 3, sparkW, sparkH, q.Low, q.High)
mp.Quotes = append(mp.Quotes, q)
}
for i := range mp.Quotes {
q := &mp.Quotes[i]
if q.Resource == v.Pick {
mp.Selected = q
}
mp.Total += q.Value
if q.Balance > 0 {
mp.Held++
}
if mp.Best == nil || q.Change > mp.Best.Change {
mp.Best = q
}
if mp.Worst == nil || q.Change < mp.Worst.Change {
mp.Worst = q
}
}
if mp.Selected == nil && len(mp.Quotes) > 0 {
mp.Selected = &mp.Quotes[0]
}
if q := mp.Selected; q != nil {
q.Selected = true
mp.Chart = s.marketChart(q, now)
}
return mp
}
func (s *State) marketChart(q *Quote, now int64) MarketChart {
// Pad the range so the line doesn't touch the edges.
pad := max((q.High-q.Low)*0.12, q.Rate*0.005)
lo, hi := q.Low-pad, q.High+pad
c := MarketChart{Step: marketStep.Milliseconds(),
Start: (now - marketPoints + 1) * marketStep.Milliseconds()}
c.Line, c.Area = chartPaths(q.series, 1, chartW, chartH, lo, hi)
y := func(p float64) float64 { return chartH - (p-lo)/(hi-lo)*chartH }
c.HighY, c.LowY = y(q.High), y(q.Low)
prices := make([]string, len(q.series))
for i, p := range q.series {
prices[i] = strconv.FormatFloat(p, 'f', 4, 64)
}
c.Prices = strings.Join(prices, ",")
for i := 1; i <= 3; i++ {
p := lo + (hi-lo)*float64(i)/4
c.Grid = append(c.Grid, GridLine{Y: y(p), Top: y(p) / chartH * 100, Label: formatCredits(p)})
}
for h := 0; h <= 24; h += 6 {
label := fmt.Sprintf("−%dh", 24-h)
if h == 24 {
label = "Now"
}
c.Times = append(c.Times, TimeLabel{X: float64(h) / 24 * 100, Label: label})
}
return c
}
// chartPaths returns an SVG line path through every nth point of series, scaled to
// w×h between lo and hi, and the area under it.
func chartPaths(series []float64, every int, w, h, lo, hi float64) (line, area string) {
if len(series) < 2 {
return "", ""
}
if hi <= lo {
lo, hi = lo-1, hi+1
}
var b strings.Builder
last := len(series) - 1
for i := 0; i <= last; i += every {
if i+every > last {
i = last
}
x := float64(i) / float64(last) * w
y := h - (series[i]-lo)/(hi-lo)*h
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&b, "%s%.1f %.1f", cmd, x, y)
}
line = b.String()
return line, fmt.Sprintf("%sL%.1f %.1fL0 %.1fZ", line, w, h, h)
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"io/fs"
"log"
"sort"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// migrate brings the database up to date. It connects as a superuser and is safe to run
// repeatedly (it runs on every deploy):
// 1. creates the `devs` role and the app user (DB_USER), and syncs the app user's password;
// 2. creates the schema (sql/tables.sql, sql/data.sql) if the database is empty;
// 3. applies sql/migrations/*.sql in name order (each must be idempotent);
// 4. grants the app user access to the tables;
// 5. syncs the store items with the game config (config/game.json plus the live override);
// 6. gives moons still carrying a config moon type's description a generated one.
func migrate(ctx context.Context) error {
db, err := connect(ctx, adminDSN())
if err != nil {
return err
}
defer db.Close()
appUser := env("DB_USER", "moonminers")
appPassword := env("DB_PASSWORD", "")
if appPassword == "" {
return fmt.Errorf("DB_PASSWORD is required")
}
user := pgx.Identifier{appUser}.Sanitize()
roles := fmt.Sprintf(`
do $$ begin
if not exists (select from pg_roles where rolname = 'devs') then create role devs; end if;
if not exists (select from pg_roles where rolname = %[1]s) then
create user %[2]s in role devs;
end if;
end $$;
alter role %[2]s with login password %[3]s;
grant devs to %[2]s;`,
quoteLiteral(appUser), user, quoteLiteral(appPassword))
if _, err := db.Exec(ctx, roles); err != nil {
return fmt.Errorf("creating roles: %w", err)
}
var exists bool
if err := db.QueryRow(ctx, "select to_regclass('public.users') is not null").Scan(&exists); err != nil {
return err
}
if !exists {
log.Print("creating schema")
for _, file := range []string{"sql/tables.sql", "sql/data.sql"} {
if err := execFile(ctx, db, file); err != nil {
return err
}
}
}
migrations, err := fs.Glob(embedded, "sql/migrations/*.sql")
if err != nil {
return err
}
sort.Strings(migrations)
for _, file := range migrations {
log.Printf("applying %s", file)
if err := execFile(ctx, db, file); err != nil {
return err
}
}
_, err = db.Exec(ctx, `
grant usage on schema public to devs;
grant usage on all sequences in schema public to devs;
grant select, insert, update, delete on all tables in schema public to devs;`)
if err != nil {
return fmt.Errorf("granting access: %w", err)
}
if err := loadOverride(ctx, db); err != nil {
return err
}
if err := syncStoreItems(ctx, db); err != nil {
return err
}
if err := backfillMoonDescriptions(ctx, db); err != nil {
return fmt.Errorf("moon descriptions: %w", err)
}
log.Print("database is up to date")
return nil
}
func execFile(ctx context.Context, db *pgxpool.Pool, file string) error {
sql, err := embedded.ReadFile(file)
if err != nil {
return err
}
if _, err := db.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("%s: %w", file, err)
}
return nil
}
func quoteLiteral(s string) string {
out := []byte{'\''}
for i := 0; i < len(s); i++ {
if s[i] == '\'' {
out = append(out, '\'')
}
out = append(out, s[i])
}
return string(append(out, '\''))
}
+602
View File
@@ -0,0 +1,602 @@
package main
// 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.
//
// resolveMoonLook mirrors the part of terrain.js's resolveMoon that matters for the text
// (same hash, same PRNG, same draw order, same color math). terrain.js's moonTraits returns
// the same fields; scripts/check-moon-traits.mjs (run by `make check`) compares the two for
// fixed ids so they can't drift.
//
// 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
// migrate), from the settings saved at that time; later changes in the moon lab re-render
// the moon but don't rewrite its description.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"os"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var moonTechniques = []string{"fbm", "ridged", "warped", "billow", "craterField"}
// moonLook holds the settings fields the description depends on (defaults from terrain.js).
type moonLook struct {
Technique string
ShapeStrength float64
ShapeFreq float64
RidgeStrength float64
RidgeFreq float64
CraterCount float64
MaxRadius float64
CraterDepth float64
Mid string // shading.mid
VarHue float64
VarSat float64
VarLight float64
VarShape float64
VarFreq float64
VarCraters float64
VarTechnique bool
}
func defaultMoonLook() moonLook {
return moonLook{
Technique: "fbm", ShapeStrength: 0.035, ShapeFreq: 1.1, RidgeStrength: 0.025, RidgeFreq: 1.6,
CraterCount: 320, MaxRadius: 0.3, CraterDepth: 0.42, Mid: "#76695c",
VarHue: 180, VarSat: 0.6, VarLight: 0.12, VarShape: 0.5, VarFreq: 0.3, VarCraters: 0.6,
VarTechnique: true,
}
}
// parseMoonLook merges saved settings JSON (may be nil) over the defaults, keeping only
// values of the right type, like terrain.js's mergeSettings.
func parseMoonLook(raw []byte) moonLook {
l := defaultMoonLook()
var m map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &m) != nil {
return l
}
sub := func(key string) map[string]any { v, _ := m[key].(map[string]any); return v }
num := func(o map[string]any, key string, dst *float64) {
if v, ok := o[key].(float64); ok {
*dst = v
}
}
shape, ridge, craters, shading, variation := sub("shape"), sub("ridge"), sub("craters"), sub("shading"), sub("variation")
if v, ok := m["technique"].(string); ok {
l.Technique = v
}
num(shape, "strength", &l.ShapeStrength)
num(shape, "frequency", &l.ShapeFreq)
num(ridge, "strength", &l.RidgeStrength)
num(ridge, "frequency", &l.RidgeFreq)
num(craters, "count", &l.CraterCount)
num(craters, "maxRadius", &l.MaxRadius)
num(craters, "depth", &l.CraterDepth)
if v, ok := shading["mid"].(string); ok {
l.Mid = v
}
num(variation, "hue", &l.VarHue)
num(variation, "saturation", &l.VarSat)
num(variation, "lightness", &l.VarLight)
num(variation, "shape", &l.VarShape)
num(variation, "frequency", &l.VarFreq)
num(variation, "craters", &l.VarCraters)
if v, ok := variation["technique"].(bool); ok {
l.VarTechnique = v
}
return l
}
// resolvedLook is what one moon gets; the JSON names match terrain.js's moonTraits.
type resolvedLook struct {
Technique string `json:"technique"`
Mid string `json:"mid"`
ShapeStrength float64 `json:"shapeStrength"`
RidgeStrength float64 `json:"ridgeStrength"`
CraterCount float64 `json:"craterCount"`
MaxRadius float64 `json:"maxRadius"`
}
// resolveMoonLook mirrors terrain.js resolveMoon for the fields in resolvedLook.
func resolveMoonLook(l moonLook, seed string) resolvedLook {
r := moonRng(moonHash(seed))
jitter := func(amount float64) float64 { return 1 + (r()*2-1)*amount }
r()
r()
r() // offsets
techniqueRoll := r()
dh := (r()*2 - 1) * l.VarHue
sScale := jitter(l.VarSat)
dl := (r()*2 - 1) * l.VarLight
shapeJ := jitter(l.VarShape)
ridgeJ := jitter(l.VarShape)
jitter(l.VarFreq) // shape frequency
jitter(l.VarFreq) // ridge frequency
countJ := jitter(l.VarCraters)
sizeJ := jitter(l.VarCraters * 0.5)
out := resolvedLook{
Technique: l.Technique,
Mid: adjustColor(l.Mid, dh, sScale, dl),
ShapeStrength: l.ShapeStrength * shapeJ,
RidgeStrength: l.RidgeStrength * ridgeJ,
CraterCount: math.Max(0, jsRound(l.CraterCount*countJ)),
MaxRadius: l.MaxRadius * sizeJ,
}
if l.VarTechnique {
out.Technique = moonTechniques[int(math.Floor(techniqueRoll*float64(len(moonTechniques))))]
}
return out
}
// moonHash is terrain.js hashString (FNV-1a over UTF-16 code units).
func moonHash(s string) uint32 {
h := uint32(2166136261)
for _, c := range []rune(s) {
var units []uint32
if c >= 0x10000 {
c -= 0x10000
units = []uint32{0xd800 + uint32(c>>10), 0xdc00 + uint32(c&0x3ff)}
} else {
units = []uint32{uint32(c)}
}
for _, u := range units {
h ^= u
h *= 16777619
}
}
return h
}
// moonRng is terrain.js rng (mulberry32), uniform in [0, 1).
func moonRng(seed uint32) func() float64 {
a := seed
return func() float64 {
a += 0x6d2b79f5
t := (a ^ a>>15) * (1 | a)
t = (t + (t^t>>7)*(61|t)) ^ t
return float64(t^t>>14) / 4294967296
}
}
func jsRound(x float64) float64 { return math.Floor(x + 0.5) }
func hexToRGB(hex string) [3]float64 {
s := strings.TrimPrefix(hex, "#")
end := 0
for end < len(s) && strings.ContainsRune("0123456789abcdefABCDEF", rune(s[end])) {
end++
}
n, _ := strconv.ParseUint(s[:end], 16, 64) // parseInt: leading hex digits, else 0
v := int64(int32(uint32(n))) // JS bit ops work on int32
return [3]float64{float64((v>>16)&255) / 255, float64((v>>8)&255) / 255, float64(v&255) / 255}
}
func rgbToHSL(c [3]float64) [3]float64 {
r, g, b := c[0], c[1], c[2]
mx, mn := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
l := (mx + mn) / 2
if mx == mn {
return [3]float64{0, 0, l}
}
d := mx - mn
s := d / (mx + mn)
if l > 0.5 {
s = d / (2 - mx - mn)
}
var h float64
switch {
case mx == r:
h = (g - b) / d
if g < b {
h += 6
}
case mx == g:
h = (b-r)/d + 2
default:
h = (r-g)/d + 4
}
return [3]float64{h * 60, s, l}
}
func hslToRGB(h, s, l float64) [3]float64 {
h = math.Mod(math.Mod(h, 360)+360, 360) / 360
if s == 0 {
return [3]float64{l, l, l}
}
q := l + s - l*s
if l < 0.5 {
q = l * (1 + s)
}
p := 2*l - q
hue := func(t float64) float64 {
t = math.Mod(t+1, 1)
switch {
case t < 1.0/6:
return p + (q-p)*6*t
case t < 1.0/2:
return q
case t < 2.0/3:
return p + (q-p)*(2.0/3-t)*6
}
return p
}
return [3]float64{hue(h + 1.0/3), hue(h), hue(h - 1.0/3)}
}
// adjustColor is terrain.js adjustColor: shift hue, scale saturation, offset lightness.
func adjustColor(hex string, dh, sScale, dl float64) string {
c := rgbToHSL(hexToRGB(hex))
rgb := hslToRGB(c[0]+dh, math.Min(1, c[1]*sScale), math.Min(1, math.Max(0, c[2]+dl)))
out := "#"
for _, v := range rgb {
out += fmt.Sprintf("%02x", int(jsRound(math.Min(1, math.Max(0, v))*255)))
}
return out
}
// ---------------------------------------------------------------- traits
// moonTraits are the word-level facts a description is built from.
type moonTraits struct {
Tone string // "pale", "dark" or ""
Hue string // hue family: grey, red, orange, yellow, olive, green, teal, blue, violet, magenta
Muted bool // only a faint tint of Hue over grey
Craters string // light, medium, heavy, field
Relief string // ridged, mountainous, flat, rolling, warped, uplands, craters
Wealth string // rich, modest, sparse
Richest string // resource with the most supply
}
type wellSupply struct {
Resource string
Supply float64
}
// classifyMoon turns resolved look parameters (and the defaults they vary from) plus the
// wells into traits. Wealth is judged against the expected supply of new wells
// (times wellScale, the growth for the buyer's moon count). Thresholds were checked by eye in the moon lab (/utils/moons).
func classifyMoon(base moonLook, r resolvedLook, wells []wellSupply, wellScale float64) moonTraits {
var t moonTraits
hsl := rgbToHSL(hexToRGB(r.Mid))
baseL := rgbToHSL(hexToRGB(base.Mid))[2]
switch dl := hsl[2] - baseL; {
case dl > 0.07:
t.Tone = "pale"
case dl < -0.07:
t.Tone = "dark"
}
h := hsl[0]
switch {
case hsl[1] < 0.07:
t.Hue = "grey"
case h < 18 || h >= 340:
t.Hue = "red"
case h < 42:
t.Hue = "orange"
case h < 72:
t.Hue = "yellow"
case h < 108:
t.Hue = "olive"
case h < 160:
t.Hue = "green"
case h < 195:
t.Hue = "teal"
case h < 255:
t.Hue = "blue"
case h < 295:
t.Hue = "violet"
default:
t.Hue = "magenta"
}
t.Muted = t.Hue != "grey" && hsl[1] < 0.105
countJ := 1.0
if base.CraterCount > 0 {
countJ = r.CraterCount / base.CraterCount
}
switch {
case r.Technique == "craterField":
t.Craters = "field"
case r.CraterCount == 0 || base.CraterDepth == 0 || countJ < 0.75:
t.Craters = "light"
case countJ > 1.3:
t.Craters = "heavy"
default:
t.Craters = "medium"
}
relief := 1.0
if s := base.ShapeStrength + base.RidgeStrength; s > 0 {
relief = (r.ShapeStrength + r.RidgeStrength) / s
}
switch {
case r.Technique == "craterField":
t.Relief = "craters"
case r.Technique == "ridged":
t.Relief = "ridged"
case relief > 1.25:
t.Relief = "mountainous"
case relief < 0.75:
t.Relief = "flat"
case r.Technique == "billow":
t.Relief = "rolling"
case r.Technique == "warped":
t.Relief = "warped"
default:
t.Relief = "uplands"
}
best := wellSupply{Supply: -1}
for _, w := range wells {
if w.Supply > best.Supply || (w.Supply == best.Supply && w.Resource < best.Resource) {
best = w
}
}
t.Richest = best.Resource
t.Wealth = moonWealth(wells, wellScale)
return t
}
// moonWealth is rich, modest or sparse: the wells' supply against the expected supply of
// as many new wells (times wellScale).
func moonWealth(wells []wellSupply, wellScale float64) string {
c := cfg()
total := 0.0
for _, w := range wells {
total += w.Supply
}
expected := float64(len(wells)) * (c.WellSupplyMin + c.WellSupplyMax) / 2 * wellScale
switch {
case expected <= 0 || total > expected*1.12:
return "rich"
case total < expected*0.88:
return "sparse"
default:
return "modest"
}
}
// ---------------------------------------------------------------- grammar
var (
hueWords = map[string][]string{
"grey": {"ashen grey", "slate-grey", "dust-grey"},
"red": {"rust-red", "oxide-red", "crimson"},
"orange": {"ochre", "umber", "copper-brown"},
"yellow": {"sulfur-yellow", "sandy", "brass-toned"},
"olive": {"olive", "lichen-green", "khaki"},
"green": {"moss-green", "jade", "verdigris"},
"teal": {"teal", "sea-green", "aqua-tinged"},
"blue": {"ice-blue", "cobalt", "steel-blue"},
"violet": {"violet", "indigo", "amethyst"},
"magenta": {"plum", "magenta", "rose-tinted"},
}
mutedWords = map[string][]string{ // a faint tint over grey
"red": {"rose-grey", "rusty grey", "rose-grey"},
"orange": {"taupe", "brownish grey", "taupe"},
"yellow": {"sand-grey", "fawn", "sand-grey"},
"olive": {"olive-grey", "lichen-grey", "olive-grey"},
"green": {"sage-grey", "sage", "sage-grey"},
"teal": {"teal-grey", "grey-green", "teal-grey"},
"blue": {"blue-grey", "slate-blue", "blue-grey"},
"violet": {"lavender-grey", "mauve-grey", "lavender-grey"},
"magenta": {"mauve", "heather-grey", "mauve"},
}
toneWords = map[string][]string{"pale": {"pale", "bleached", "light"}, "dark": {"dark", "shadowed", "dusky"}}
craterWords = map[string][]string{
"light": {"smooth-faced", "lightly cratered", "unscarred"},
"medium": {"pitted", "cratered", "pockmarked"},
"heavy": {"crater-scarred", "heavily cratered", "battered"},
"field": {"crater-scarred", "impact-riddled", "battered"},
}
reliefWords = map[string][]string{
"ridged": {"jagged ridges", "knife-edged ridges", "sawtooth ridges"},
"mountainous": {"towering highlands", "broken peaks", "high massifs"},
"flat": {"worn lowlands", "low, eroded hills", "softened lowlands"},
"rolling": {"rolling domes", "swollen hills", "soft, rounded hills"},
"warped": {"twisted canyons", "swirling valleys", "folded ravines"},
"uplands": {"rolling uplands", "weathered hills", "rumpled highlands"},
"craters": {"overlapping craters", "endless crater fields", "crowded impact basins"},
}
nounWords = []string{"moon", "world", "moon", "rock", "satellite"}
// Resource phrases: {R} is the richest resource.
resWith = map[string][]string{ // after "with"
"rich": {"rich {R} seams", "deep {R} veins", "abundant {R}"},
"modest": {"modest {R} deposits", "steady {R} veins", "fair {R} lodes"},
"sparse": {"only thin {R} traces", "scant wells, mostly {R}", "meager {R} pockets"},
}
resTail = map[string][]string{ // after a comma, ending the sentence
"rich": {"rich in {R}", "its wells heavy with {R}", "laced with {R}"},
"modest": {"holding modest {R} deposits", "its best wells yielding {R}", "with fair {R} lodes"},
"sparse": {"thin on resources but rich in {R}", "poor in ore save for {R}", "its few wells mostly {R}"},
}
resHide = map[string][]string{ // object of "hide"/"shelter"
"rich": {"rich {R} seams", "deep {R} veins", "a wealth of {R}"},
"modest": {"modest {R} deposits", "steady {R} veins", "fair {R} lodes"},
"sparse": {"only thin {R} traces", "meager {R} pockets", "a little {R}"},
}
)
// describeMoon builds one sentence (at most ~14 words) from the traits; word choices are
// seeded by the moon id, so the same moon always gets the same text.
func describeMoon(seed string, t moonTraits) string {
r := moonRng(moonHash(seed) ^ 0x5eed)
pick := func(list []string) string { return list[int(r()*float64(len(list)))] }
color := pick(hueWords[t.Hue])
if t.Muted {
color = pick(mutedWords[t.Hue])
}
if t.Tone != "" {
color = pick(toneWords[t.Tone]) + " " + color
}
crater := pick(craterWords[t.Craters])
relief := pick(reliefWords[t.Relief])
noun := pick(nounWords)
res := func(m map[string][]string) string {
name := t.Richest
if name == "" {
name = "ore"
}
return strings.ReplaceAll(pick(m[t.Wealth]), "{R}", name)
}
verb := pick([]string{"hide", "shelter", "hold"})
material := pick([]string{"stone", "dust", "regolith"})
patterns := []func() string{
// A crater-scarred, pale ochre moon with rich Azurium seams.
func() string {
return article(crater) + " " + crater + ", " + color + " " + noun + " with " + res(resWith)
},
// A rust-red world of jagged ridges, thin on resources but rich in Nebulance.
func() string {
return article(color) + " " + color + " " + noun + " of " + relief + ", " + res(resTail)
},
// Jagged ridges cross this cobalt moon, its wells heavy with Sollux.
func() string {
return capitalize(relief) + " " + pick([]string{"cross", "cover", "mark"}) + " this " + color + " " + noun + ", " + res(resTail)
},
// A violet moon whose rolling uplands hide deep Novafor veins.
func() string {
return article(color) + " " + color + " " + noun + " whose " + relief + " " + verb + " " + res(resHide)
},
// A pitted moon of dark umber stone, rich in Sollux.
func() string {
return article(crater) + " " + crater + " " + noun + " of " + color + " " + material + ", " + res(resTail)
},
}
// Patterns are tried from a seeded start until one fits in 14 words.
start := int(r() * float64(len(patterns)))
var s string
for i := range patterns {
s = patterns[(start+i)%len(patterns)]()
if len(strings.Fields(s)) <= 14 {
break
}
}
return capitalize(s) + "."
}
// article returns "an" before a vowel sound, else "a".
func article(word string) string {
if word != "" && strings.ContainsRune("aeiouAEIOU", rune(word[0])) {
return "an"
}
return "a"
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// ---------------------------------------------------------------- database
// moonDescription generates the description of a moon whose wells exist; wellScale is the
// supply growth its wells were created with (1 if unknown).
func moonDescription(ctx context.Context, q querier, moonID string, wellScale float64) (string, error) {
var raw []byte
err := q.QueryRow(ctx, "select settings from moon_settings where id = 1").Scan(&raw)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
rows, err := q.Query(ctx, `
select r.name, w.supply from resource_well w join resource r on r.id = w.resource_id
where w.source_id = $1`, moonID)
if err != nil {
return "", err
}
wells, err := pgx.CollectRows(rows, pgx.RowToStructByPos[wellSupply])
if err != nil {
return "", err
}
look := parseMoonLook(raw)
return describeMoon(moonID, classifyMoon(look, resolveMoonLook(look, moonID), wells, wellScale)), nil
}
// backfillMoonDescriptions gives trait-based descriptions to moons that still have a
// config moon type's description (or none). Idempotent: generated text never matches.
func backfillMoonDescriptions(ctx context.Context, db *pgxpool.Pool) error {
var old []string
for _, c := range []GameConfig{baseConfig(), *cfg()} {
for _, m := range c.Moons {
old = append(old, m.Description)
}
}
rows, err := db.Query(ctx, `
select id::text from staking_source where description = '' or description = any($1)`, old)
if err != nil {
return err
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[string])
if err != nil {
return err
}
for _, id := range ids {
desc, err := moonDescription(ctx, db, id, 1)
if err != nil {
return err
}
if _, err := db.Exec(ctx, "update staking_source set description = $1 where id = $2", desc, id); err != nil {
return err
}
}
if len(ids) > 0 {
log.Printf("generated descriptions for %d moons", len(ids))
}
return nil
}
// ---------------------------------------------------------------- check / samples
// runMoonTraits is the `moon-traits` command, used by scripts/check-moon-traits.mjs.
// 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
// description, with random wells.
func runMoonTraits() error {
var in struct {
Settings json.RawMessage `json:"settings"`
Seeds []string `json:"seeds"`
Samples bool `json:"samples"`
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
if err := json.Unmarshal(data, &in); err != nil {
return err
}
look := parseMoonLook(in.Settings)
out := []any{}
for _, seed := range in.Seeds {
res := resolveMoonLook(look, seed)
if !in.Samples {
out = append(out, res)
continue
}
wr := moonRng(moonHash(seed) ^ 0xbeef)
kind := cfg().Moons[int(wr()*float64(len(cfg().Moons)))]
wells := rollWells(cfg(), kind, 1, false, wr)
t := classifyMoon(look, res, wells, 1)
out = append(out, map[string]any{"seed": seed, "look": res, "traits": t, "text": describeMoon(seed, t)})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Moon look: the procedural-moon settings tuned on /utils/moons (web/static/js/terrain.js
// documents the fields). Stored as one JSON row; the game merges it over its defaults.
const maxMoonSettingsBytes = 64 << 10
// isAdmin: players listed in ADMIN_USER_IDS (comma separated), or anyone with MM_DEV=1.
// With neither, nobody is an admin.
func (s *server) isAdmin(userID string) bool {
if s.dev {
return true
}
if userID == "" {
return false
}
for _, id := range strings.Split(os.Getenv("ADMIN_USER_IDS"), ",") {
if strings.EqualFold(strings.TrimSpace(id), userID) {
return true
}
}
return false
}
// loadMoonSettings returns the saved settings JSON, or nil if none are saved.
func loadMoonSettings(ctx context.Context, db *pgxpool.Pool) (json.RawMessage, error) {
var raw []byte
err := db.QueryRow(ctx, "select settings from moon_settings where id = 1").Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return raw, err
}
func saveMoonSettings(ctx context.Context, db *pgxpool.Pool, settings json.RawMessage) error {
_, err := db.Exec(ctx, `
insert into moon_settings (id, settings, updated_at) values (1, $1::jsonb, now())
on conflict (id) do update set settings = excluded.settings, updated_at = excluded.updated_at`,
string(settings))
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.
func (s *server) handleGetMoonSettings(w http.ResponseWriter, r *http.Request) {
settings, err := loadMoonSettings(r.Context(), s.db)
if err != nil {
serverError(w, err)
return
}
if settings == nil {
settings = json.RawMessage("null")
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Write(settings)
}
// handleSaveMoonSettings stores the settings (a JSON object) for everyone. Admins only.
func (s *server) handleSaveMoonSettings(w http.ResponseWriter, r *http.Request) {
if !s.isAdmin(s.currentUser(r)) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxMoonSettingsBytes))
if err != nil {
http.Error(w, "Settings too large", http.StatusRequestEntityTooLarge)
return
}
var obj map[string]any
if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
http.Error(w, "Settings must be a JSON object", http.StatusBadRequest)
return
}
if err := saveMoonSettings(r.Context(), s.db, body); err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
-16
View File
@@ -1,16 +0,0 @@
/** @type {import('next').NextConfig} */
const fs = require("fs");
const gameConfigContent = fs.readFileSync("config/game-config.json", "utf-8");
const gameConfig = JSON.parse(gameConfigContent);
const nextConfig = {
experimental: {
appDir: true,
},
env: {
gameConfig: gameConfig,
},
};
module.exports = nextConfig;
+206
View File
@@ -0,0 +1,206 @@
package main
// Onboarding by progressive reveal: a new player sees only what the next step needs, and
// each section (and the controls that belong to it) shows up once the step before it is
// done. No tutorial text: hidden things are simply not rendered, and a newly revealed tab
// shines until it's first opened.
//
// users.onboarding holds flags, which are only ever added:
// - a gate passed: equipment (first deploy), market (first claim), store (first sale),
// refinery (claimed every raw input of a recipe), advisors (bought a tool or attachment);
// - claimed:<resource>, for the refinery gate;
// - seen:<tab>, the revealed tab was opened (no more shine);
// - all: everything unlocked (players from before onboarding, admin "unlock all").
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades and the
// drill panel (attachments); Claim all also needs 2 tools.
import (
"context"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// onboardingTabs are the gated sections that shine when revealed, in walk-through order.
var onboardingTabs = []string{"equipment", "market", "store", "refinery", "advisors"}
// Unlocked says whether a section or feature is available to the player.
func (s *State) Unlocked(feature string) bool {
if slices.Contains(s.Onboarding, "all") {
return true
}
switch feature {
case "moons":
return true
case "new-moon":
// The first moon is always for sale, and so is the next one once every moon is
// mined out.
active := slices.ContainsFunc(s.Moons, func(m Moon) bool { return !m.Finished })
return !active || slices.Contains(s.Onboarding, "store")
case "help", "leaderboard":
feature = "store"
case "claim-all":
return slices.Contains(s.Onboarding, "store") && len(s.Tools) >= 2
}
return slices.Contains(s.Onboarding, feature)
}
// Shine says whether a section tab was just revealed and hasn't been opened yet.
func (s *State) Shine(tab string) bool {
return !slices.Contains(s.Onboarding, "all") && slices.Contains(s.Onboarding, tab) &&
!slices.Contains(s.Onboarding, "seen:"+tab)
}
// ShowNav says whether the section tabs show at all: not while Moons would be the only one.
func (s *State) ShowNav() bool { return s.Unlocked("equipment") }
// OnboardingStage describes how far a player got, for the admin Players page.
func (s *State) OnboardingStage() string {
if slices.Contains(s.Onboarding, "all") {
return "Done (everything unlocked)"
}
var open []string
for _, tab := range onboardingTabs {
if slices.Contains(s.Onboarding, tab) {
open = append(open, tab)
}
}
switch {
case len(open) == len(onboardingTabs):
return "Done (all tabs revealed)"
case len(open) > 0:
return "Revealed: " + strings.Join(open, ", ")
case len(s.Moons) > 0:
return "Has a moon, no deploy yet"
}
return "New: no moon yet"
}
// unlock adds onboarding flags. It reads first without a lock, so once the flags are there
// (nearly always) the action doesn't touch the users row.
func unlock(ctx context.Context, tx pgx.Tx, userID string, flags ...string) error {
var have []string
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&have); err != nil {
return err
}
if slices.Contains(have, "all") || !slices.ContainsFunc(flags, func(f string) bool { return !slices.Contains(have, f) }) {
return nil
}
_, err := tx.Exec(ctx, `
update users set onboarding = array(select distinct unnest(onboarding || $2::text[]))
where id = $1`, userID, flags)
return err
}
// unlockClaim records a claim: the market opens, and the refinery once the player has
// claimed every raw input of some recipe.
func unlockClaim(ctx context.Context, tx pgx.Tx, userID, resource string) error {
if err := unlock(ctx, tx, userID, "market", "claimed:"+resource); err != nil {
return err
}
var have []string
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&have); err != nil {
return err
}
if slices.Contains(have, "all") || slices.Contains(have, "refinery") {
return nil
}
for _, item := range cfg().Refinery.Items {
paired := len(item.Inputs) > 0
for input := range item.Inputs {
if _, raw := cfg().ResourcePrices[input]; !raw || !slices.Contains(have, "claimed:"+input) {
paired = false
}
}
if paired {
return unlock(ctx, tx, userID, "refinery")
}
}
return nil
}
// markSeen clears the shine on a revealed tab once the player opens it.
func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) error {
if !slices.Contains(onboardingTabs, section) {
return nil
}
_, err := db.Exec(ctx, `
update users set onboarding = onboarding || array['seen:' || $2]
where id = $1 and $2 = any(onboarding) and not ('seen:' || $2) = any(onboarding)`,
userID, section)
return err
}
// actionGates is the feature each action needs; runAction refuses the rest.
var actionGates = map[string]string{
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
"sell": "market",
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
"buy-moon": "new-moon",
}
// actionAllowed refuses an action whose part of the game isn't revealed yet.
func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string, redeploy bool) error {
feature := actionGates[action]
if action == "claim" && redeploy {
feature = "store"
}
if feature == "" {
return nil
}
s := &State{}
err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
if err != nil {
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) {
return gameErr("Not available yet")
}
return nil
}
// adminOnboarding resets a player's onboarding (reset) or unlocks everything.
func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error {
return inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
kind, set := "admin_onboarding_unlock", []string{"all"}
if reset {
kind, set = "admin_onboarding_reset", []string{}
}
if _, err := tx.Exec(ctx, "update users set onboarding = $2 where id = $1", userID, set); err != nil {
return err
}
return logEvent(ctx, tx, userID, kind, map[string]any{"admin": adminID})
})
}
-8462
View File
File diff suppressed because it is too large. Load diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "mooner",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@next/font": "13.1.6",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"next": "13.1.6",
"pg": "^8.10.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.8.0",
"sqlite": "^4.1.2",
"sqlite3": "^5.1.4",
"three": "^0.170.0",
"typescript": "4.9.5"
},
"devDependencies": {
"@types/pg": "^8.6.6",
"@types/three": "^0.170.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.6"
}
}
+642
View File
@@ -0,0 +1,642 @@
package main
// 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
// action runs in a transaction and is logged in event_log as admin_* with the admin's id.
// Banned players get a plain "suspended" page instead of the game (notBanned).
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const eventsPerPage = 25
// idPrefix is what a search must look like to be treated as a player id prefix.
var idPrefix = regexp.MustCompile(`^[0-9a-f-]{1,36}$`)
func (s *server) playerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /utils/players", s.handlePlayers)
mux.HandleFunc("GET /utils/players/{id}", s.handlePlayer)
mux.HandleFunc("GET /utils/players/{id}/events", s.handlePlayerEvents)
mux.HandleFunc("POST /utils/players/{id}/{action}", s.handlePlayerAction)
}
// ------------------------------------------------------------------ bans
func isBanned(ctx context.Context, db *pgxpool.Pool, userID string) bool {
var banned bool
err := db.QueryRow(ctx, "select banned_at is not null from users where id = $1", userID).Scan(&banned)
return err == nil && banned
}
// 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)
}
}
// ------------------------------------------------------------------ list
type PlayerRow struct {
ID string
Name string
CreatedAt time.Time
BannedAt *time.Time
LastSeen *time.Time
Actions int // in the last 7 days
Balance float64
Moons int
Tools int
}
func (p PlayerRow) Short() string { return p.ID[:8] }
func (p PlayerRow) Created() string { return p.CreatedAt.UTC().Format("2006-01-02 15:04") }
func (p PlayerRow) Seen() string {
if p.LastSeen == nil {
return "never"
}
return ago(time.Since(*p.LastSeen))
}
func (p PlayerRow) Banned() string {
if p.BannedAt == nil {
return ""
}
return p.BannedAt.UTC().Format("2006-01-02 15:04")
}
// ago formats e.g. "just now", "5m ago", "3h ago", "2d ago".
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
var playerSorts = map[string]string{
"seen": "last_seen desc nulls last",
"active": "actions desc, last_seen desc nulls last",
"new": "users.created_at desc",
"rich": "balance desc",
}
// Player activity, leaving out what admins did to them.
const playerActivity = `
left join lateral (
select max(created_at) as last_seen,
count(*) filter (where created_at > now() - interval '7 days'
and kind not in ('session', 'new_player')) as actions
from event_log where user_id = users.id and kind not like 'admin\_%'
) activity on true`
const playerColumns = `users.id, users.name, users.created_at, users.banned_at,
activity.last_seen, coalesce(activity.actions, 0) as actions,
coalesce((select balance from bank_account where user_id = users.id), 0) as balance,
(select count(*) from staking_source where user_id = users.id),
(select count(*) from inventory_item where user_id = users.id)`
// findPlayers lists up to 50 players, matching q as an id prefix or a name part.
func findPlayers(ctx context.Context, db *pgxpool.Pool, q, sort string) ([]PlayerRow, error) {
order, ok := playerSorts[sort]
if !ok {
order = playerSorts["seen"]
}
q = strings.ToLower(strings.TrimSpace(q))
where, args := "", []any{}
if q != "" {
name := strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q)
where = "where users.name ilike '%' || $1 || '%'"
args = append(args, name)
if idPrefix.MatchString(q) {
where += " or users.id::text like $2 || '%'"
args = append(args, q)
}
}
rows, err := db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+" "+where+
" order by "+order+", users.id limit 50", args...)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByPos[PlayerRow])
}
type PlayersPage struct {
Allowed bool
Query, Sort string
Players []PlayerRow
Total, Active, Suspended int
}
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()
p := &PlayersPage{Allowed: true, Query: r.FormValue("q"), Sort: r.FormValue("sort")}
if _, ok := playerSorts[p.Sort]; !ok {
p.Sort = "seen"
}
var err error
if p.Players, err = findPlayers(ctx, s.db, p.Query, p.Sort); err != nil {
serverError(w, err)
return
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, "pl-list", p)
return
}
err = s.db.QueryRow(ctx, `
select count(*), count(banned_at),
(select count(distinct user_id) from event_log
where created_at >= current_date and kind not like 'admin\_%')
from users`).Scan(&p.Total, &p.Suspended, &p.Active)
if err != nil {
serverError(w, err)
return
}
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
type PlayerTool struct {
Tool
Item *StoreItem
MaxTier int
Stake *Stake
MoonName string
Remaining time.Duration
}
type PlayerEvent struct {
ID int64
Kind string
Data string
At time.Time
}
func (e PlayerEvent) When() string { return e.At.UTC().Format("2006-01-02 15:04:05") }
func (e PlayerEvent) Admin() bool { return strings.HasPrefix(e.Kind, "admin_") }
func (e PlayerEvent) Empty() bool { return e.Data == "{}" }
func (e PlayerEvent) Short() string { return truncate(e.Data, 140) }
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
type EventsPage struct {
PlayerID string
Events []PlayerEvent
Page int
More bool
}
func (p EventsPage) Prev() int { return p.Page - 1 }
func (p EventsPage) Next() int { return p.Page + 1 }
type PlayerPage struct {
Allowed bool
Self bool // the admin looking at their own player
P PlayerRow
State *State
Tools []PlayerTool
Running int
Events EventsPage
// Choices for the grant forms.
StoreItems []StoreItem
Grantable []string
Flash string
FlashError bool
}
func loadPlayerEvents(ctx context.Context, db *pgxpool.Pool, userID string, page int) (EventsPage, error) {
ep := EventsPage{PlayerID: userID, Page: max(page, 0)}
rows, err := db.Query(ctx, `
select id, kind, data::text, created_at from event_log where user_id = $1
order by created_at desc, id desc limit $2 offset $3`,
userID, eventsPerPage+1, ep.Page*eventsPerPage)
if err != nil {
return ep, err
}
if ep.Events, err = pgx.CollectRows(rows, pgx.RowToStructByPos[PlayerEvent]); err != nil {
return ep, err
}
if len(ep.Events) > eventsPerPage {
ep.Events, ep.More = ep.Events[:eventsPerPage], true
}
return ep, nil
}
// loadPlayerPage returns nil if there is no such player.
func (s *server) loadPlayerPage(ctx context.Context, userID, adminID string) (*PlayerPage, error) {
rows, err := s.db.Query(ctx, "select "+playerColumns+" from users"+playerActivity+
" where users.id = $1", userID)
if err != nil {
return nil, err
}
p, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[PlayerRow])
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, err
}
page := &PlayerPage{Allowed: true, P: p, Self: strings.EqualFold(userID, adminID)}
if page.State, err = loadState(ctx, s.db, userID); err != nil {
return nil, err
}
st := page.State
for _, t := range st.Tools {
pt := PlayerTool{Tool: t, Item: st.StoreItem(t.StoreItemID), Stake: st.StakeForTool(t.ID)}
if pt.Item != nil {
pt.MaxTier = len(pt.Item.Upgrades)
}
if pt.Stake != nil {
pt.MoonName = st.MoonName(pt.Stake.MoonID)
pt.Remaining = pt.Stake.Remaining(st.Now)
if pt.Remaining > 0 {
page.Running++
}
}
page.Tools = append(page.Tools, pt)
}
if page.Events, err = loadPlayerEvents(ctx, s.db, userID, 0); err != nil {
return nil, err
}
c := cfg()
page.StoreItems = c.StoreItems
page.Grantable = append(page.Grantable, st.Config.Resources...)
for _, item := range c.Refinery.Items {
page.Grantable = append(page.Grantable, item.Name)
}
return page, nil
}
// StakesOn counts the drills on a well.
func (p *PlayerPage) StakesOn(wellID string) int {
n := 0
for _, st := range p.State.Stakes {
if st.WellID == wellID {
n++
}
}
return n
}
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"))
if !uuidPattern.MatchString(id) {
http.NotFound(w, r)
return
}
page, err := s.loadPlayerPage(r.Context(), id, adminID)
if err != nil {
serverError(w, err)
return
}
if page == nil {
http.NotFound(w, r)
return
}
s.render(w, "player", page)
}
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"))
if !uuidPattern.MatchString(id) {
http.NotFound(w, r)
return
}
page, _ := strconv.Atoi(r.FormValue("page"))
ep, err := loadPlayerEvents(r.Context(), s.db, id, page)
if err != nil {
serverError(w, err)
return
}
s.render(w, "pl-events", ep)
}
// ------------------------------------------------------------------ actions
// 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) {
adminID := s.currentUser(r)
if !s.isAdmin(adminID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
ctx := r.Context()
id := strings.ToLower(r.PathValue("id"))
if !uuidPattern.MatchString(id) {
http.NotFound(w, r)
return
}
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
var message string
var err error
switch r.PathValue("action") {
case "credits":
remove := r.FormValue("op") == "remove"
if err = adminCredits(ctx, s.db, adminID, id, amount, remove); err == nil {
verb := "Granted"
if remove {
verb = "Removed"
}
message = fmt.Sprintf("%s %s", verb, formatCredits(amount))
}
case "resources":
name := r.FormValue("resource")
if err = adminResources(ctx, s.db, adminID, id, name, amount); err == nil {
message = fmt.Sprintf("Granted %s %s", formatMoney(amount), name)
}
case "tool":
item, _ := strconv.Atoi(r.FormValue("item"))
tier, _ := strconv.Atoi(r.FormValue("tier"))
var name string
if name, err = adminTool(ctx, s.db, adminID, id, item, tier); err == nil {
message = fmt.Sprintf("Granted %s (tier %d)", name, tier)
}
case "finish-stakes":
var n int
if n, err = adminFinishStakes(ctx, s.db, adminID, id); err == nil {
message = fmt.Sprintf("Finished %s; the player can claim now", plural(n, "drill"))
}
case "reset":
if err = adminReset(ctx, s.db, adminID, id); err == nil {
message = "Player reset to a fresh start"
}
case "onboarding-reset", "onboarding-unlock":
reset := r.PathValue("action") == "onboarding-reset"
if err = adminOnboarding(ctx, s.db, adminID, id, reset); err == nil {
message = "Everything unlocked"
if reset {
message = "Onboarding restarted"
}
}
case "ban":
if err = adminBan(ctx, s.db, adminID, id, true); err == nil {
message = "Account suspended"
}
case "unban":
if err = adminBan(ctx, s.db, adminID, id, false); err == nil {
message = "Suspension lifted"
}
default:
http.NotFound(w, r)
return
}
if err != nil && !isGameError(err) {
serverError(w, err)
return
}
page, perr := s.loadPlayerPage(ctx, id, adminID)
if perr != nil {
serverError(w, perr)
return
}
if page == nil {
http.NotFound(w, r)
return
}
page.Flash = message
if err != nil {
page.Flash, page.FlashError = err.Error(), true
}
s.render(w, "pl-detail", page)
}
// lockPlayer locks the player's row for the rest of the transaction.
func lockPlayer(ctx context.Context, tx pgx.Tx, userID string) (banned bool, err error) {
err = tx.QueryRow(ctx, "select banned_at is not null from users where id = $1 for no key update", userID).
Scan(&banned)
if errors.Is(err, pgx.ErrNoRows) {
return false, gameErr("Player not found")
}
return banned, err
}
func validAmount(amount float64) error {
if !(amount > 0) || math.IsInf(amount, 0) || amount > 1e12 {
return gameErr("Enter an amount above 0")
}
return nil
}
// adminCredits adds credits, or with remove takes them away (the balance can't go below 0).
func adminCredits(ctx context.Context, db *pgxpool.Pool, adminID, userID string, amount float64, remove bool) error {
if err := validAmount(amount); err != nil {
return err
}
return inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
var balance float64
err := tx.QueryRow(ctx,
"select balance from bank_account where user_id = $1 for update", userID).Scan(&balance)
if err != nil {
return err
}
kind, delta := "admin_grant", amount
if remove {
if amount > balance {
return gameErr("The player only has %s", formatCredits(balance))
}
kind, delta = "admin_remove", -amount
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, delta)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, kind, map[string]any{"admin": adminID, "credits": delta})
})
}
// adminResources adds a resource or a refined item to the player's balance.
func adminResources(ctx context.Context, db *pgxpool.Pool, adminID, userID, name string, amount float64) error {
if err := validAmount(amount); err != nil {
return err
}
return inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
if cfg().refinedItem(name) != nil {
_, err := tx.Exec(ctx, `
insert into refined_account(user_id, item, balance) values ($1, $2, $3)
on conflict (user_id, item) do update set balance = refined_account.balance + excluded.balance`,
userID, name, amount)
if err != nil {
return err
}
} else {
var resourceID int
err := tx.QueryRow(ctx, "select id from resource where name = $1", name).Scan(&resourceID)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Unknown resource %q", name)
} else if err != nil {
return err
}
// The player's row is locked, so no one else adds this account meanwhile.
tag, err := tx.Exec(ctx, `
update resource_account set balance = balance + $3
where user_id = $1 and resource_id = $2`, userID, resourceID, amount)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
_, err = tx.Exec(ctx, `
insert into resource_account(user_id, resource_id, balance) values ($1, $2, $3)`,
userID, resourceID, amount)
if err != nil {
return err
}
}
}
return logEvent(ctx, tx, userID, "admin_grant",
map[string]any{"admin": adminID, "resource": name, "amount": amount})
})
}
// adminTool gives the player a store item at a tier (0 is the base tool).
func adminTool(ctx context.Context, db *pgxpool.Pool, adminID, userID string, itemID, tier int) (string, error) {
var item *StoreItem
items := cfg().StoreItems
for i := range items {
if items[i].ID == itemID {
item = &items[i]
}
}
if item == nil {
return "", gameErr("Pick a tool")
}
if tier < 0 || tier > len(item.Upgrades) {
return "", gameErr("%s has tiers 0 to %d", item.Name, len(item.Upgrades))
}
err := inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
_, err := tx.Exec(ctx,
"insert into inventory_item(user_id, store_item_id, tier) values ($1, $2, $3)",
userID, itemID, tier)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "admin_grant",
map[string]any{"admin": adminID, "item": itemID, "name": item.Name, "tier": tier})
})
return item.Name, err
}
// adminFinishStakes makes every running drill of the player finished, for testing. The
// player then claims them as usual.
func adminFinishStakes(ctx context.Context, db *pgxpool.Pool, adminID, userID string) (int, error) {
var n int
err := inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
tag, err := tx.Exec(ctx, `
update staking_event set created_at = now() - duration_in_secs * interval '1 second'
where user_id = $1
and created_at + duration_in_secs * interval '1 second' > now()
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`,
userID)
if err != nil {
return err
}
if n = int(tag.RowsAffected()); n == 0 {
return gameErr("No drills are running")
}
return logEvent(ctx, tx, userID, "admin_finish_stakes", map[string]any{"admin": adminID, "count": n})
})
return n, err
}
// adminReset resets the player to a fresh start. clearUserData has its own transaction,
// so the log entry is written right after it.
func adminReset(ctx context.Context, db *pgxpool.Pool, adminID, userID string) error {
if err := clearUserData(ctx, db, userID); err != nil {
return err
}
return logEvent(ctx, db, userID, "admin_reset", map[string]any{"admin": adminID})
}
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 {
banned, err := lockPlayer(ctx, tx, userID)
if err != nil {
return err
}
switch {
case ban && banned:
return gameErr("Already suspended")
case !ban && !banned:
return gameErr("Not suspended")
}
kind, set := "admin_unban", "null"
if ban {
kind, set = "admin_ban", "now()"
}
if _, err := tx.Exec(ctx, "update users set banned_at = "+set+" where id = $1", userID); err != nil {
return err
}
return logEvent(ctx, tx, userID, kind, map[string]any{"admin": adminID})
})
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 KiB

+140
View File
@@ -0,0 +1,140 @@
package main
// Moon ratings: how a new moon compares with every moon a purchase can roll. A moon's
// value is its wells at base prices (unscaled by wellScale); its rating is the share of
// possible moons worth less, from a fixed sample of rollWells drawn once per config (an
// economy override gives a new config, and a new sample). buyMoon stores it on the moon
// (staking_source.rating, 0–100); the reveal card and the moon list show its tier.
import (
"math"
mrand "math/rand/v2"
"slices"
"sort"
"sync"
)
const ratingSamples = 20000
// moonOdds is the sampled distribution of new moons for one config.
type moonOdds struct {
values []float64 // sorted
wells map[string][]float64 // supplies of every sampled well of each resource, sorted
count map[int]float64 // share of moons with n wells
}
var oddsCache struct {
sync.Mutex
cfg *GameConfig
odds *moonOdds
}
// currentOdds samples the moons the current config can roll (cached until it changes).
func currentOdds() *moonOdds {
c := cfg()
oddsCache.Lock()
defer oddsCache.Unlock()
if oddsCache.cfg == c && oddsCache.odds != nil {
return oddsCache.odds
}
o := &moonOdds{wells: map[string][]float64{}, count: map[int]float64{}}
rnd := mrand.New(mrand.NewPCG(1, 2))
if len(c.Moons) > 0 {
for i := range ratingSamples {
wells := rollWells(c, c.Moons[i%len(c.Moons)], 1, false, rnd.Float64)
o.values = append(o.values, wellsValue(c, wells, 1))
o.count[len(wells)] += 1.0 / ratingSamples
for _, w := range wells {
o.wells[w.Resource] = append(o.wells[w.Resource], w.Supply)
}
}
}
slices.Sort(o.values)
for _, s := range o.wells {
slices.Sort(s)
}
oddsCache.cfg, oddsCache.odds = c, o
return o
}
// richShare is the share of moons with a well of the resource holding at least supply
// (unscaled).
func (o *moonOdds) richShare(resource string, supply float64) float64 {
s := o.wells[resource]
if len(o.values) == 0 {
return 1
}
return float64(len(s)-sort.SearchFloat64s(s, supply)) / float64(len(o.values))
}
// wellsValue is what a moon's wells are worth at base prices, per unit of well scale.
func wellsValue(c *GameConfig, wells []wellSupply, scale float64) float64 {
v := 0.0
for _, w := range wells {
v += w.Supply * c.ResourcePrices[w.Resource]
}
if scale > 0 {
v /= scale
}
return v
}
// moonRating is the percentile (0–100) of a moon's value among possible moons.
func moonRating(wells []wellSupply, scale float64) int {
o := currentOdds()
if len(o.values) == 0 {
return 50
}
v := wellsValue(cfg(), wells, scale)
below := sort.SearchFloat64s(o.values, v)
return min(100, int(math.Floor(float64(below)*100/float64(len(o.values)))))
}
// Rating is a moon's tier, from its stored percentile.
type Rating struct {
Tier string // poor, fair, good, great, exceptional
Label string // Poor, Fair, …
Stars int // 1–5
Pct int // percentile
}
// ratingTiers: the lowest percentile of each tier, best first.
var ratingTiers = []struct {
min int
tier, label string
}{
{95, "exceptional", "Exceptional"},
{80, "great", "Great"},
{50, "good", "Good"},
{20, "fair", "Fair"},
{0, "poor", "Poor"},
}
func ratingOf(pct int) Rating {
for i, t := range ratingTiers {
if pct >= t.min {
return Rating{Tier: t.tier, Label: t.label, Stars: len(ratingTiers) - i, Pct: pct}
}
}
return Rating{}
}
// Top is the share of moons at least this good, e.g. "Top 4%".
func (r Rating) Top() int { return max(1, 100-r.Pct) }
// StarList is for ranging over the five stars: true for a filled one.
func (r Rating) StarList() []bool {
s := make([]bool, 5)
for i := range r.Stars {
s[i] = true
}
return s
}
// oneIn says how rare a share is, e.g. 0.08 → "1 in 12".
func oneIn(share float64) int {
if share <= 0 {
return 0
}
return int(math.Ceil(1/share - 0.5)) // ties round down: 12.5 → 12
}
+708
View File
@@ -0,0 +1,708 @@
package main
// The refinery turns raw resources into refined items on a timer. A batch (recipe ×
// quantity) takes its inputs when it starts and fills one slot until the player collects
// it. Refined items sell in the market and are parts for higher tool upgrades.
// Recipes, prices, slot prices and upgrade parts come from config/game.json ("refinery",
// and "parts" on upgrade tiers).
//
// Lock order, shared with game.go: users row (refinery actions) → bank_account →
// resource_account → refined_account.
import (
"context"
"errors"
"fmt"
"math"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type RefineryConfig struct {
// Price of the 2nd, 3rd, ... slot. Players start with one; the cap is 1 + len.
SlotPrices []float64 `json:"slotPrices"`
// Most units one batch can make.
MaxBatch int `json:"maxBatch"`
// Bulk bonus: a batch of n takes seconds × n^bulkExponent (1 = no bonus), so big
// batches take less time per unit.
BulkExponent float64 `json:"bulkExponent"`
Items []RefinedItem `json:"items"`
}
// batchSecs is how long a batch of qty units of item takes, before live events.
func (r *RefineryConfig) batchSecs(item *RefinedItem, qty int) float64 {
return float64(item.Seconds) * math.Pow(float64(qty), r.BulkExponent)
}
// bulkBonus is the time saved per unit in a batch of qty, in percent.
func (r *RefineryConfig) bulkBonus(qty int) float64 {
return (1 - math.Pow(float64(max(1, qty)), r.BulkExponent-1)) * 100
}
// RefinedItem is a refined item and its recipe: one unit takes Inputs (resources or
// lower-tier refined items, by name) and Seconds.
type RefinedItem struct {
Name string `json:"name"`
Tier int `json:"tier"`
Color string `json:"color"`
Price float64 `json:"price"` // credits per unit sold
Seconds int `json:"seconds"`
Inputs map[string]float64 `json:"inputs"`
Description string `json:"description"`
}
func (c *GameConfig) refinedItem(name string) *RefinedItem {
for i := range c.Refinery.Items {
if c.Refinery.Items[i].Name == name {
return &c.Refinery.Items[i]
}
}
return nil
}
// MaxSlots is the most refinery slots a player can own.
func (r *RefineryConfig) MaxSlots() int { return 1 + len(r.SlotPrices) }
// unitPrice is what one unit of a resource or refined item sells for.
func (c *GameConfig) unitPrice(name string) float64 {
if item := c.refinedItem(name); item != nil {
return item.Price
}
return c.ResourcePrices[name]
}
func (cfg *GameConfig) validateRefinery() error {
r := &cfg.Refinery
for i, p := range r.SlotPrices {
if p < 0 {
return fmt.Errorf("refinery slot %d: price is negative", i+2)
}
}
if len(r.Items) > 0 && r.MaxBatch < 1 {
return fmt.Errorf("refinery: maxBatch must be at least 1")
}
if !(r.BulkExponent > 0 && r.BulkExponent <= 1) {
return fmt.Errorf("refinery: bulkExponent must be in (0, 1]")
}
tiers := map[string]int{}
for _, item := range r.Items {
if item.Name == "" {
return fmt.Errorf("refinery: an item has no name")
}
if _, ok := tiers[item.Name]; ok {
return fmt.Errorf("refinery: duplicate item %q", item.Name)
}
if _, ok := cfg.ResourcePrices[item.Name]; ok {
return fmt.Errorf("refinery: %q is already a resource", item.Name)
}
if !colorPattern.MatchString(item.Color) {
return fmt.Errorf("refinery %q: color must be a hex color like #a1b2c3", item.Name)
}
tiers[item.Name] = item.Tier
}
for _, item := range r.Items {
if item.Tier < 1 || item.Seconds < 1 || item.Price < 0 || len(item.Inputs) == 0 {
return fmt.Errorf("refinery %q: needs tier ≥ 1, seconds ≥ 1, a price ≥ 0 and inputs", item.Name)
}
for name, qty := range item.Inputs {
if !(qty > 0) {
return fmt.Errorf("refinery %q: input %q needs a positive amount", item.Name, name)
}
_, raw := cfg.ResourcePrices[name]
// Refined inputs must be a lower tier, so recipes can't loop.
if tier, ok := tiers[name]; !raw && (!ok || tier >= item.Tier) {
return fmt.Errorf("refinery %q: input %q must be a resource or a lower-tier refined item", item.Name, name)
}
}
}
for _, si := range cfg.StoreItems {
for _, u := range si.Upgrades {
for name, qty := range u.Parts {
if _, ok := tiers[name]; !ok || !(qty > 0) {
return fmt.Errorf("store item %d tier %d: part %q must be a refined item with a positive amount", si.ID, u.Tier, name)
}
}
}
}
return nil
}
// upgradeParts is what upgrading a tool to the given tier uses up, from the config.
func upgradeParts(storeItemID, tier int) map[string]float64 {
for _, item := range cfg().StoreItems {
if item.ID == storeItemID {
for _, u := range item.Upgrades {
if u.Tier == tier {
return u.Parts
}
}
}
}
return nil
}
// ------------------------------------------------------------------ state
type RefinedBalance struct {
Item string
Balance float64
}
// RefineryJob is an uncollected batch.
type RefineryJob struct {
ID string
Slot int
Item string
Qty int
StartedAt time.Time
EndsAt time.Time
}
func loadRefinery(ctx context.Context, db *pgxpool.Pool, s *State) error {
err := db.QueryRow(ctx, "select refinery_slots from users where id = $1", s.UserID).
Scan(&s.RefinerySlots)
if err != nil {
return err
}
rows, err := db.Query(ctx, `
select item, balance from refined_account where user_id = $1 order by item`, s.UserID)
if err != nil {
return err
}
if s.Refined, err = pgx.CollectRows(rows, pgx.RowToStructByPos[RefinedBalance]); err != nil {
return err
}
rows, err = db.Query(ctx, `
select id, slot, item, qty, started_at, ends_at from refinery_job
where user_id = $1 and collected_at is null order by slot`, s.UserID)
if err != nil {
return err
}
s.Jobs, err = pgx.CollectRows(rows, pgx.RowToStructByPos[RefineryJob])
return err
}
// have is how much of a resource or refined item the player owns.
func (s *State) have(name string) float64 {
for _, r := range s.Resources {
if r.Resource == name {
return r.Balance
}
}
for _, r := range s.Refined {
if r.Item == name {
return r.Balance
}
}
return 0
}
// ------------------------------------------------------------------ helpers
// lockRefinery locks the player's row, which serializes their refinery actions, and
// returns how many slots they own.
func lockRefinery(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
var slots int
err := tx.QueryRow(ctx,
"select refinery_slots from users where id = $1 for no key update", userID).Scan(&slots)
return slots, err
}
// lockItems locks the player's balances of the named resources and refined items and
// returns them (missing accounts count as 0).
func lockItems(ctx context.Context, tx pgx.Tx, userID string, names []string) (map[string]float64, error) {
var raw, refined []string
for _, name := range names {
if cfg().refinedItem(name) != nil {
refined = append(refined, name)
} else {
raw = append(raw, name)
}
}
have := map[string]float64{}
scan := func(sql string, names []string) error {
if len(names) == 0 {
return nil
}
rows, err := tx.Query(ctx, sql, userID, names)
if err != nil {
return err
}
var name string
var balance float64
_, err = pgx.ForEachRow(rows, []any{&name, &balance}, func() error {
have[name] = balance
return nil
})
return err
}
err := scan(`
select resource.name, resource_account.balance
from resource_account join resource on resource.id = resource_account.resource_id
where resource_account.user_id = $1 and resource.name = any($2)
order by resource.id for update of resource_account`, raw)
if err != nil {
return nil, err
}
err = scan(`
select item, balance from refined_account
where user_id = $1 and item = any($2) order by item for update`, refined)
return have, err
}
func sortedKeys(m map[string]float64) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// takeItems uses up resources and refined items, or fails naming the first one short.
func takeItems(ctx context.Context, tx pgx.Tx, userID string, need map[string]float64) error {
if len(need) == 0 {
return nil
}
names := sortedKeys(need)
have, err := lockItems(ctx, tx, userID, names)
if err != nil {
return err
}
for _, name := range names {
if have[name] < need[name] {
return gameErr("Need %s %s, you have %s", formatMoney(need[name]), name, formatMoney(have[name]))
}
}
for _, name := range names {
if cfg().refinedItem(name) != nil {
_, err = tx.Exec(ctx, `
update refined_account set balance = balance - $3
where user_id = $1 and item = $2`, userID, name, need[name])
} else {
_, err = tx.Exec(ctx, `
update resource_account set balance = balance - $3
where user_id = $1 and resource_id = (select id from resource where name = $2)`,
userID, name, need[name])
}
if err != nil {
return err
}
}
return nil
}
func addRefined(ctx context.Context, tx pgx.Tx, userID, item string, amount float64) error {
_, err := tx.Exec(ctx, `
insert into refined_account(user_id, item, balance) values ($1, $2, $3)
on conflict (user_id, item) do update set balance = refined_account.balance + excluded.balance`,
userID, item, amount)
return err
}
// ------------------------------------------------------------------ actions
// startRefine starts a batch of qty units in the lowest free slot; with all set it makes
// as many as the player's inputs allow (up to maxBatch).
func startRefine(ctx context.Context, db *pgxpool.Pool, userID, name string, qty int, all bool) (int, error) {
item := cfg().refinedItem(name)
if item == nil {
return 0, gameErr("Unknown recipe")
}
maxBatch := cfg().Refinery.MaxBatch
err := inTx(ctx, db, func(tx pgx.Tx) error {
slots, err := lockRefinery(ctx, tx, userID)
if err != nil {
return err
}
rows, err := tx.Query(ctx, `
select slot from refinery_job where user_id = $1 and collected_at is null`, userID)
if err != nil {
return err
}
busy, err := pgx.CollectRows(rows, pgx.RowTo[int])
if err != nil {
return err
}
slot := 0
for n := 1; n <= slots && slot == 0; n++ {
slot = n
for _, b := range busy {
if b == n {
slot = 0
}
}
}
if slot == 0 {
return gameErr("All refinery slots are busy. Collect a batch or buy another slot")
}
if all {
have, err := lockItems(ctx, tx, userID, sortedKeys(item.Inputs))
if err != nil {
return err
}
qty = maxBatch
for name, need := range item.Inputs {
qty = min(qty, int(math.Floor(have[name]/need)))
}
if qty < 1 {
for _, name := range sortedKeys(item.Inputs) {
if have[name] < item.Inputs[name] {
return gameErr("Need %s %s for one %s, you have %s", formatMoney(item.Inputs[name]),
name, item.Name, formatMoney(have[name]))
}
}
}
}
if qty < 1 {
return gameErr("Enter how many to refine")
}
if qty > maxBatch {
return gameErr("A batch can make at most %d", maxBatch)
}
need := map[string]float64{}
for name, n := range item.Inputs {
need[name] = n * float64(qty)
}
if err := takeItems(ctx, tx, userID, need); err != nil {
return err
}
// Live events: a cycle multiplier speeds up batches like stakes.
speed, err := eventMultiplier(ctx, tx, "cycle_multiplier", "all")
if err != nil {
return err
}
// Advisors with a refinery bonus too (bonus.go).
bonus, err := loadBonus(ctx, tx, userID, "")
if err != nil {
return err
}
secs := math.Round(cfg().Refinery.batchSecs(item, qty) / (speed * bonus.Refine))
_, err = tx.Exec(ctx, `
insert into refinery_job(user_id, slot, item, qty, ends_at)
values ($1, $2, $3, $4, now() + $5 * interval '1 second')`,
userID, slot, item.Name, qty, secs)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "refine",
map[string]any{"item": item.Name, "qty": qty, "slot": slot, "seconds": secs})
})
return qty, err
}
// collectRefinery collects the player's finished batches (or one, by id) and returns
// what it collected.
func collectRefinery(ctx context.Context, db *pgxpool.Pool, userID, jobID string) ([]RefineryJob, error) {
if jobID != "" && !uuidPattern.MatchString(jobID) {
return nil, gameErr("Batch not found")
}
var done []RefineryJob
err := inTx(ctx, db, func(tx pgx.Tx) error {
if _, err := lockRefinery(ctx, tx, userID); err != nil {
return err
}
rows, err := tx.Query(ctx, `
select id, slot, item, qty, started_at, ends_at from refinery_job
where user_id = $1 and collected_at is null and ends_at <= now()
and ($2 = '' or id::text = $2)
order by slot for update`, userID, jobID)
if err != nil {
return err
}
if done, err = pgx.CollectRows(rows, pgx.RowToStructByPos[RefineryJob]); err != nil {
return err
}
if len(done) == 0 {
var running bool
err := tx.QueryRow(ctx, `
select exists(select 1 from refinery_job
where user_id = $1 and collected_at is null and ($2 = '' or id::text = $2))`,
userID, jobID).Scan(&running)
if err != nil {
return err
}
if running {
return gameErr("That batch isn't finished yet")
}
return gameErr("Nothing to collect")
}
for _, j := range done {
if err := addRefined(ctx, tx, userID, j.Item, float64(j.Qty)); err != nil {
return err
}
_, err := tx.Exec(ctx, "update refinery_job set collected_at = now() where id = $1", j.ID)
if err != nil {
return err
}
err = logEvent(ctx, tx, userID, "collect_refined", map[string]any{"item": j.Item, "qty": j.Qty})
if err != nil {
return err
}
}
return nil
})
return done, err
}
// buyRefinerySlot adds a slot at the next slot's price.
func buyRefinerySlot(ctx context.Context, db *pgxpool.Pool, userID string) (int, error) {
var slots int
err := inTx(ctx, db, func(tx pgx.Tx) error {
var err error
if slots, err = lockRefinery(ctx, tx, userID); err != nil {
return err
}
prices := cfg().Refinery.SlotPrices
if slots-1 >= len(prices) {
return gameErr("Your refinery has every slot already")
}
price := prices[slots-1]
if err := debit(ctx, tx, userID, price); err != nil {
return err
}
slots++
_, err = tx.Exec(ctx, "update users set refinery_slots = $2 where id = $1", userID, slots)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "buy_slot", map[string]any{"slots": slots, "price": price})
})
return slots, err
}
// sellRefined is sell (game.go) for refined items.
func sellRefined(ctx context.Context, db *pgxpool.Pool, userID, name string, amount float64, all bool) (float64, float64, error) {
var earned float64
err := inTx(ctx, db, func(tx pgx.Tx) error {
now := time.Now()
price := marketRate(name, now)
if price <= 0 {
return gameErr("%s can't be sold right now", name)
}
_, err := tx.Exec(ctx, "select 1 from bank_account where user_id = $1 for update", userID)
if err != nil {
return err
}
var balance float64
err = tx.QueryRow(ctx, `
select balance from refined_account where user_id = $1 and item = $2 for update`,
userID, name).Scan(&balance)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if all {
amount = balance
}
if !(amount > 0) {
return gameErr("Enter an amount to sell")
}
if amount > balance {
return gameErr("You only have %s %s", formatMoney(balance), name)
}
boost, err := eventMultiplier(ctx, tx, "price_multiplier", name)
if err != nil {
return err
}
bonus, err := loadBonus(ctx, tx, userID, "")
if err != nil {
return err
}
factor, err := sellPressure(ctx, tx, userID, name, amount, now)
if err != nil {
return err
}
earned = amount * price * boost * bonus.Sell * factor
_, err = tx.Exec(ctx, `
update refined_account set balance = balance - $3 where user_id = $1 and item = $2`,
userID, name, amount)
if err != nil {
return err
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, earned)
if err != nil {
return err
}
if err := unlock(ctx, tx, userID, "store"); err != nil {
return err
}
return logEvent(ctx, tx, userID, "sell", map[string]any{"resource": name, "amount": amount, "credits": earned})
})
return amount, earned, err
}
// ------------------------------------------------------------------ view
type RefineryPage struct {
Slots []SlotRow
SlotCount int
MaxSlots int
NextSlot int // number of the next slot to buy
NextSlotPrice float64 // 0 at the cap
CanBuySlot bool
Free int // empty slots
Ready int // finished batches
MaxBatch int
Recipes []RecipeCard
Stock []RefinedBalance // refined items owned, in config order
}
// SlotConfirm is the question before buying the next slot.
func (p *RefineryPage) SlotConfirm() string {
return fmt.Sprintf("Add refinery slot %d for %s? Each slot runs one batch at a time.", p.NextSlot, formatCredits(p.NextSlotPrice))
}
// Why is why a recipe can't be started (its buttons' tooltip), "" if it can.
func (p *RefineryPage) Why(card RecipeCard) string {
switch {
case p.Free == 0:
return "All slots are busy"
case card.MaxQty == 0:
return "Not enough inputs"
}
return ""
}
type SlotRow struct {
N int
Job *JobRow
}
type JobRow struct {
RefineryJob
Recipe *RefinedItem
Ready bool
Remaining time.Duration
Progress float64 // 0..1
}
// PartRow is a needed resource or refined item and how much the player has.
type PartRow struct {
Name string
Need, Have float64
}
func (p PartRow) OK() bool { return p.Have >= p.Need }
type RecipeCard struct {
Item *RefinedItem
Inputs []PartRow
InputValue float64 // credits the inputs of one unit sell for
Gain float64 // percent over InputValue
MaxQty int // units the player can make in one batch now
BulkQty int // batch size the estimate is for: MaxQty, or maxBatch below 2
BulkSecs int // how long a batch of BulkQty takes
BulkBonus float64 // percent saved per unit in that batch
Owned float64
}
func (s *State) partRows(need map[string]float64) []PartRow {
var rows []PartRow
for _, name := range sortedKeys(need) {
rows = append(rows, PartRow{Name: name, Need: need[name], Have: s.have(name)})
}
return rows
}
// upgradeParts is the parts an upgrade to the given tier needs, with what the player has.
func (s *State) upgradeParts(storeItemID, tier int) []PartRow {
return s.partRows(upgradeParts(storeItemID, tier))
}
func (s *State) refineryPage() *RefineryPage {
c := cfg()
rp := &RefineryPage{SlotCount: s.RefinerySlots, NextSlot: s.RefinerySlots + 1, MaxSlots: c.Refinery.MaxSlots(), MaxBatch: c.Refinery.MaxBatch}
if n := s.RefinerySlots - 1; n >= 0 && n < len(c.Refinery.SlotPrices) {
rp.NextSlotPrice = c.Refinery.SlotPrices[n]
rp.CanBuySlot = s.Balance >= rp.NextSlotPrice
}
for n := 1; n <= max(s.RefinerySlots, len(s.Jobs)); n++ {
row := SlotRow{N: n}
for _, j := range s.Jobs {
if j.Slot == n {
jr := &JobRow{RefineryJob: j, Recipe: c.refinedItem(j.Item), Remaining: max(0, j.EndsAt.Sub(s.Now))}
jr.Ready = jr.Remaining == 0
if total := j.EndsAt.Sub(j.StartedAt); total > 0 {
jr.Progress = 1 - float64(jr.Remaining)/float64(total)
}
if jr.Ready {
rp.Ready++
}
row.Job = jr
}
}
if row.Job == nil {
rp.Free++
}
rp.Slots = append(rp.Slots, row)
}
for i := range c.Refinery.Items {
item := &c.Refinery.Items[i]
card := RecipeCard{Item: item, Inputs: s.partRows(item.Inputs), MaxQty: c.Refinery.MaxBatch, Owned: s.have(item.Name)}
for _, in := range card.Inputs {
card.InputValue += in.Need * c.unitPrice(in.Name)
card.MaxQty = min(card.MaxQty, int(math.Floor(in.Have/in.Need)))
}
card.BulkQty = card.MaxQty
if card.BulkQty < 2 {
card.BulkQty = c.Refinery.MaxBatch
}
card.BulkSecs = int(math.Round(c.Refinery.batchSecs(item, card.BulkQty) / s.Bonus().Refine))
card.BulkBonus = c.Refinery.bulkBonus(card.BulkQty)
if card.InputValue > 0 {
card.Gain = (item.Price/card.InputValue - 1) * 100
}
rp.Recipes = append(rp.Recipes, card)
if card.Owned > 0 {
rp.Stock = append(rp.Stock, RefinedBalance{Item: item.Name, Balance: card.Owned})
}
}
return rp
}
// refinedMarketRows are the market rows for refined items, in config order.
func (s *State) refinedMarketRows() []MarketRow {
var rows []MarketRow
for _, item := range cfg().Refinery.Items {
rows = append(rows, MarketRow{ResourceBalance: ResourceBalance{Resource: item.Name, Balance: s.have(item.Name)},
Refined: true})
}
return rows
}
// refinedColor is a refined item's color from the config, for the "color" template func.
func refinedColor(name string) string {
if item := cfg().refinedItem(name); item != nil {
return item.Color
}
return ""
}
// RefineryPending lists when each running batch finishes (see endsAttr), so the nav
// badge can count batches that finish between server responses.
func (s *State) RefineryPending() string {
var ends []time.Time
for _, j := range s.Jobs {
if j.EndsAt.After(s.Now) {
ends = append(ends, j.EndsAt)
}
}
return endsAttr(ends)
}
// RefineryReady is how many batches are ready to collect (for the nav badge).
func (s *State) RefineryReady() int {
n := 0
for _, j := range s.Jobs {
if !j.EndsAt.After(s.Now) {
n++
}
}
return n
}
+413
View File
@@ -0,0 +1,413 @@
package main
// Selling a moon back. A moon resells for what is left in its wells at today's market
// prices (with live price events, without the player's own selling pressure or advisor
// bonuses) times moonResale.factor, capped at moonResale.maxRatio times what a
// replacement moon would cost. It is compared with that replacement price: after a sale
// the player owns one moon fewer, so their next moon costs moonPriceFor(owned-1).
// Template: "moon-sale" in app.html.
import (
"context"
"errors"
"fmt"
"math"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type MoonResale struct {
// Share of the wells' market value paid for a moon.
Factor float64 `json:"factor"`
// The offer is at most this many times the price of a replacement moon.
MaxRatio float64 `json:"maxRatio"`
// A moon can be sold this long after it was bought.
MinHoldSecs int `json:"minHoldSecs"`
}
func (r MoonResale) validate() error {
if r.Factor < 0 {
return fmt.Errorf("moonResale.factor is negative")
}
if r.MaxRatio < 0 {
return fmt.Errorf("moonResale.maxRatio is negative")
}
if r.MinHoldSecs < 0 {
return fmt.Errorf("moonResale.minHoldSecs is negative")
}
return nil
}
// ResaleLine is one well in an offer.
type ResaleLine struct {
Resource string
Supply float64 // left in the well
Rate float64 // credits per unit (market price × live events)
Value float64 // Supply × Rate
}
type MoonOffer struct {
Lines []ResaleLine
Market float64 // what the wells are worth at market
Total float64 // the offer
Replace float64 // what a replacement moon would cost after the sale
Capped bool // the offer hit maxRatio
}
func (o *MoonOffer) Delta() float64 { return o.Total - o.Replace }
func (o *MoonOffer) Profit() bool { return o.Delta() > 0 }
// moonOffer prices wells (supply left by resource). owned is the moons the player owns.
func moonOffer(wells []wellSupply, owned int, now time.Time, boost func(string) float64) MoonOffer {
r := cfg().MoonResale
o := MoonOffer{Replace: cfg().moonPriceFor(owned - 1)}
for _, w := range wells {
rate := marketRate(w.Resource, now) * boost(w.Resource)
line := ResaleLine{Resource: w.Resource, Supply: w.Supply, Rate: rate, Value: w.Supply * rate}
o.Lines = append(o.Lines, line)
o.Market += line.Value
}
o.Total = math.Floor(o.Market * r.Factor)
if limit := math.Floor(o.Replace * r.MaxRatio); r.MaxRatio > 0 && o.Total > limit {
o.Total, o.Capped = limit, true
}
return o
}
// MoonSale is the Sell moon panel: the offer, or why the moon can't be sold yet.
type MoonSale struct {
MoonOffer
Name string
Last bool // the player's only moon
Running int // unfinished drills on it
Ready int // finished drills, claimed in the sale
HoldEnd time.Time // when it can be sold, if not yet
Now time.Time
}
func (m *MoonSale) Loss() float64 { return -m.Delta() }
func (m *MoonSale) Factor() float64 { return cfg().MoonResale.Factor }
func (m *MoonSale) HoldLeft() string { return formatDuration(m.HoldEnd.Sub(m.Now)) }
func (m *MoonSale) Blocked() bool { return m.Last || m.Running > 0 || !m.HoldEnd.IsZero() }
// Why is the reason the moon can't be sold, for the button's tooltip.
func (m *MoonSale) Why() string {
switch {
case m.Last:
return "You can't sell your only moon"
case m.Running > 0:
return fmt.Sprintf("Wait for %s to finish", plural(m.Running, "drill"))
case !m.HoldEnd.IsZero():
return "Just bought: you can sell it in " + m.HoldLeft()
}
return ""
}
// Confirm is the confirm dialog's question.
func (m *MoonSale) Confirm() string {
q := fmt.Sprintf("Sell %s for %s?", m.Name, formatCredits(m.Total))
if d := m.Delta(); d > 0 {
q += fmt.Sprintf(" That's %s more than a new moon costs (%s).", formatCredits(d), formatCredits(m.Replace))
} else {
q += fmt.Sprintf(" That's %s less than a new moon costs (%s).", formatCredits(-d), formatCredits(m.Replace))
}
if m.Ready > 0 {
verb := "are"
if m.Ready == 1 {
verb = "is"
}
q += fmt.Sprintf(" %s %s claimed first.", plural(m.Ready, "finished drill"), verb)
}
return q + " The moon and its wells are gone for good."
}
// moonSale builds the panel for one of the player's moons. Finished drills are claimed
// in the sale, so their haul is taken out of the wells first, as the sale will.
func (s *State) moonSale(moon *Moon) *MoonSale {
m := &MoonSale{Name: moon.Name, Last: len(s.Moons) <= 1, Now: s.Now}
wells := make([]wellSupply, len(moon.Wells))
supply := make([]float64, len(moon.Wells))
for i, w := range moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
supply[i] = w.Supply
}
for _, st := range s.Stakes {
if st.MoonID != moon.ID {
continue
}
if st.Remaining(s.Now) > 0 {
m.Running++
continue
}
m.Ready++
for i, take := range moon.split(st, supply) {
supply[i] -= take
wells[i].Supply -= take
}
}
if end := moon.CreatedAt.Add(time.Duration(cfg().MoonResale.MinHoldSecs) * time.Second); s.Now.Before(end) {
m.HoldEnd = end
}
m.MoonOffer = moonOffer(wells, len(s.Moons), s.Now, s.PriceBoosts.For)
return m
}
// sellMoon sells one of the player's moons for its offer. Finished drills on it are
// claimed first; unfinished ones block the sale. Its wells and stakes go with it
// (cascade); the tools stay with the player, idle.
func sellMoon(ctx context.Context, db *pgxpool.Pool, userID, moonID string) (string, float64, error) {
if !uuidPattern.MatchString(moonID) {
return "", 0, gameErr("Moon not found")
}
var name string
var earned float64
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The player's row first, as buyMoon does: the moon count can't change under us.
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
var owned int
err := tx.QueryRow(ctx, "select count(*) from staking_source where user_id = $1", userID).Scan(&owned)
if err != nil {
return err
}
var holdLeft float64
err = tx.QueryRow(ctx, `
select name, extract(epoch from coalesce(created_at, 'epoch') + $3 * interval '1 second' - now())
from staking_source where id = $1 and user_id = $2
for update`, moonID, userID, cfg().MoonResale.MinHoldSecs).Scan(&name, &holdLeft)
if errors.Is(err, pgx.ErrNoRows) {
return gameErr("Moon not found")
} else if err != nil {
return err
}
// A past moon is worth nothing and keeps counting toward the moon price.
if done, err := moonFinished(ctx, tx, moonID); err != nil {
return err
} else if done {
return gameErr("%s is mined out", name)
}
if owned <= 1 {
return gameErr("You can't sell your only moon")
}
if holdLeft > 0 {
return gameErr("You just bought %s: you can sell it in %s", name,
formatDuration(time.Duration(holdLeft*float64(time.Second))))
}
// Lock its wells: a drill being deployed on one now waits for the sale (and then
// fails), instead of being started and deleted with the moon.
if _, err := tx.Exec(ctx, "select 1 from resource_well where source_id = $1 order by id for update", moonID); err != nil {
return err
}
// Its unclaimed drills: running ones block the sale, finished ones are claimed.
rows, err := tx.Query(ctx, `
select staking_event.id,
staking_event.created_at + duration_in_secs * interval '1 second' <= now()
from staking_event
join resource_well on resource_well.id = staking_event.well_id
where resource_well.source_id = $1
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)
order by staking_event.created_at
for update of staking_event`, moonID)
if err != nil {
return err
}
type unclaimed struct {
ID string
Finished bool
}
stakes, err := pgx.CollectRows(rows, pgx.RowToStructByPos[unclaimed])
if err != nil {
return err
}
running := 0
for _, st := range stakes {
if !st.Finished {
running++
}
}
if running > 0 {
return gameErr("Wait for %s on %s to finish", plural(running, "drill"), name)
}
// Lock order as in sell and claimReady: bank account, then resources.
if _, err := tx.Exec(ctx, "select 1 from bank_account where user_id = $1 for update", userID); err != nil {
return err
}
_, err = tx.Exec(ctx, `
select 1 from resource_account where user_id = $1 order by resource_id for update`, userID)
if err != nil {
return err
}
for _, st := range stakes {
if _, _, err := claimInTx(ctx, tx, userID, st.ID); err != nil && err != errAlreadyClaimed {
return err
}
}
rows, err = tx.Query(ctx, `
select resource.name, resource_well.supply
from resource_well join resource on resource.id = resource_well.resource_id
where resource_well.source_id = $1
order by resource.name
for update of resource_well`, moonID)
if err != nil {
return err
}
wells, err := pgx.CollectRows(rows, pgx.RowToStructByPos[wellSupply])
if err != nil {
return err
}
boosts := map[string]float64{}
for _, w := range wells {
if boosts[w.Resource], err = eventMultiplier(ctx, tx, "price_multiplier", w.Resource); err != nil {
return err
}
}
offer := moonOffer(wells, owned, time.Now(), func(r string) float64 { return boosts[r] })
earned = offer.Total
tag, err := tx.Exec(ctx, "delete from staking_source where id = $1 and user_id = $2", moonID, userID)
if err != nil {
return err
}
if tag.RowsAffected() != 1 {
return gameErr("Moon not found")
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, earned)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "sell_moon", map[string]any{
"moon": moonID, "name": name, "credits": earned, "market": offer.Market,
"replace": offer.Replace, "capped": offer.Capped})
})
return name, earned, err
}
// ------------------------------------------------------------------ equipment
// Tools and attachments sell back for equipmentResale.factor (below 1) of their price in
// credits: a tool for its store price plus the upgrades it has had. Refined parts used by
// upgrades are not refunded.
type EquipmentResale struct {
Factor float64 `json:"factor"`
}
func resaleOf(price float64) float64 { return math.Floor(price * cfg().EquipmentResale.Factor) }
// ToolResale is what a tool sells for: its price and its upgrades' prices, × the factor.
func (item *StoreItem) ToolResale(tier int) float64 {
paid := item.Price
for _, u := range item.Upgrades {
if u.Tier <= tier {
paid += u.Price
}
}
return resaleOf(paid)
}
func (a *Attachment) Resale() float64 { return resaleOf(a.Price) }
// sellTool sells one of the player's idle tools. Its attachments go back to the inventory.
func sellTool(ctx context.Context, db *pgxpool.Pool, userID, toolID string) (string, float64, error) {
var name string
var earned float64
err := inTx(ctx, db, func(tx pgx.Tx) error {
// The player's row first, so two sales can't both pass the last-tool check.
if _, err := lockPlayer(ctx, tx, userID); err != nil {
return err
}
// A busy tool's idle copy is sold instead (see pickTool).
toolID, err := pickTool(ctx, tx, userID, toolID, false)
if err != nil {
return err
}
var storeItemID, tier int
var price, upgrades float64
err = tx.QueryRow(ctx, `
select inventory_item.store_item_id, inventory_item.tier, store_item.name, store_item.price,
(select coalesce(sum(price), 0) from upgrade_item
where store_item_id = inventory_item.store_item_id and tier <= inventory_item.tier)
from inventory_item join store_item on store_item.id = inventory_item.store_item_id
where inventory_item.id = $1`, toolID).Scan(&storeItemID, &tier, &name, &price, &upgrades)
if err != nil {
return err
}
var tools int
err = tx.QueryRow(ctx, "select count(*) from inventory_item where user_id = $1", userID).Scan(&tools)
if err != nil {
return err
}
if tools <= 1 {
return gameErr("You can't sell your only tool")
}
var running, ready int
err = tx.QueryRow(ctx, `
select count(*) filter (where created_at + duration_in_secs * interval '1 second' > now()),
count(*) filter (where created_at + duration_in_secs * interval '1 second' <= now())
from staking_event
where inventory_item_id = $1
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)`,
toolID).Scan(&running, &ready)
if err != nil {
return err
}
if running > 0 {
return gameErr("This drill is still mining. Sell it once it's idle")
}
if ready > 0 {
return gameErr("Claim this drill's haul first")
}
earned = resaleOf(price + upgrades)
tag, err := tx.Exec(ctx, "update attachment set inventory_item_id = null where inventory_item_id = $1", toolID)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, "delete from inventory_item where id = $1", toolID); err != nil {
return err
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, earned)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "sell_tool", map[string]any{"tool": toolID, "item": storeItemID,
"tier": tier, "credits": earned, "attachments": tag.RowsAffected()})
})
return name, earned, err
}
// sellAttachment sells one of the player's spare (unequipped) attachments of a kind.
func sellAttachment(ctx context.Context, db *pgxpool.Pool, userID, name string) (float64, error) {
a := cfg().attachment(name)
if a == nil {
return 0, gameErr("Unknown attachment")
}
earned := a.Resale()
err := inTx(ctx, db, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
delete from attachment where id = (
select id from attachment
where user_id = $1 and item = $2 and inventory_item_id is null
limit 1 for update skip locked)`, userID, a.Name)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return gameErr("You have no spare %s", a.Name)
}
_, err = tx.Exec(ctx,
"update bank_account set balance = balance + $2 where user_id = $1", userID, earned)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "sell_attachment", map[string]any{"item": a.Name, "credits": earned})
})
return earned, err
}
+200
View File
@@ -0,0 +1,200 @@
package main
// 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
// plays the reveal from the data the server sends.
import (
"cmp"
"fmt"
"maps"
"math"
"slices"
"strconv"
)
// NewMoonTeaser is what the player can know before buying: the price and the odds, not
// the moon.
type NewMoonTeaser struct {
Price float64
CanAfford bool
After float64 // balance after buying
Need float64 // credits missing, if it can't afford it
Types []string // the moon types a purchase can roll
Resources []string // what its wells can hold
Wells string // how many wells it can have, e.g. "2–5"
}
func (s *State) newMoonTeaser() *NewMoonTeaser {
t := &NewMoonTeaser{
Price: s.MoonPrice(),
Resources: s.Config.Resources,
Wells: s.WellRange(),
}
t.CanAfford = s.Balance >= t.Price
t.After = s.Balance - t.Price
t.Need = max(0, -t.After)
for _, m := range cfg().Moons {
t.Types = append(t.Types, m.Name)
}
return t
}
// MoonReveal is the moon just bought, for the reveal card: its verdict (rating.go), what
// it's worth now and what's rare about it.
type MoonReveal struct {
*MoonDetail
Rating Rating
Headline string // e.g. "Jackpot! A five-well giant"
Sub string // the line under it
Worth float64 // its wells at current market prices
Multiple float64 // Worth / what it cost
Traits []string // notable traits and their rarity
MaxSupply float64 // the biggest well a moon can have: a full bar
FullAt float64 // wells from here count as full
Next NextMoon // buying another one right away
}
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
// "".
type NextMoon struct {
Price float64
CanBuy bool
Why string
WhyText string
}
var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
// moonReveal describes the player's newest moon, already on the page as p.Moon.
func (p *Page) moonReveal() *MoonReveal {
if p.Moon == nil {
return nil
}
c := cfg()
owned := len(p.S.Moons) - 1
scale := c.wellScale(owned)
wells := make([]wellSupply, len(p.Moon.Wells))
for i, w := range p.Moon.Wells {
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
}
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
switch {
case !p.S.Unlocked("store"):
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
case p.S.Balance < r.Next.Price:
r.Next.Why, r.Next.WhyText = "credits", shortBy(r.Next.Price-p.S.Balance)
}
r.Next.CanBuy = r.Next.Why == ""
if p.Moon.Rating != nil {
r.Rating = *p.Moon.Rating
} else {
r.Rating = ratingOf(moonRating(wells, scale))
}
// Bars are out of the biggest possible well; the top tenth of the range counts as full.
r.MaxSupply = c.WellSupplyMax * scale
r.FullAt = math.Floor((c.WellSupplyMax - (c.WellSupplyMax-c.WellSupplyMin)*0.1) * scale)
for _, w := range wells {
r.Worth += w.Supply * marketRate(w.Resource, p.S.Now)
}
r.Worth = math.Round(r.Worth)
if price := c.moonPriceFor(owned); price > 0 {
r.Multiple = r.Worth / price
}
n := len(wells)
count := strconv.Itoa(n)
if n < len(countWords) {
count = countWords[n]
}
switch r.Rating.Tier {
case "exceptional":
r.Headline = "Jackpot! A motherlode"
if n >= 5 {
r.Headline = "Jackpot! A " + count + "-well giant"
}
r.Sub = fmt.Sprintf("Richer than %d%% of moons", r.Rating.Pct)
case "great":
r.Headline = "Rich find"
r.Sub = fmt.Sprintf("Top %d%% of moons", r.Rating.Top())
case "good":
r.Headline = "Solid haul"
r.Sub = fmt.Sprintf("Better than %d%% of moons", r.Rating.Pct)
case "fair":
r.Headline = "Fair claim"
r.Sub = "About what a moon usually holds"
default:
r.Headline = "Slim pickings"
r.Sub = "Not much down there. Better luck next time."
}
odds := currentOdds()
// More wells than usual, if that's uncommon.
avg := 0.0
for k, share := range odds.count {
avg += float64(k) * share
}
if share := odds.count[n]; share > 0 && share <= 0.3 && float64(n) > avg {
r.Traits = append(r.Traits, fmt.Sprintf("%d wells · 1 in %d moons", n, oneIn(share)))
}
full, best := 0, wellSupply{}
for _, w := range wells {
if w.Supply >= r.FullAt {
full++
}
if w.Supply > best.Supply {
best = w
}
}
// Supply is rolled below wellSupplyMax, so the fullest a well gets is one under it.
word := "Near-full"
if best.Supply >= math.Ceil(r.MaxSupply)-1 {
word = "Full"
}
switch {
case full == 1:
r.Traits = append(r.Traits, fmt.Sprintf("%s well · %s %s/%s", word, best.Resource, formatMoney(best.Supply), formatMoney(r.MaxSupply)))
case full > 1:
r.Traits = append(r.Traits, fmt.Sprintf("%d near-full wells", full))
}
// A big well of one of the two priciest resources, if few moons have one that big.
prices := slices.SortedFunc(maps.Keys(c.ResourcePrices), func(a, b string) int {
return cmp.Compare(c.ResourcePrices[b], c.ResourcePrices[a])
})
rare, rareShare := wellSupply{}, 1.0
for _, w := range wells {
if i := slices.Index(prices, w.Resource); i < 0 || i > 1 {
continue
}
if share := odds.richShare(w.Resource, w.Supply/scale); share < rareShare {
rare, rareShare = w, share
}
}
if rare.Resource != "" && rareShare <= 0.15 {
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
}
return r
}
// WellRange is how many wells a new moon can have, e.g. "2–5".
func (s *State) WellRange() string {
lo, hi := wellCountRange()
if lo == hi {
return strconv.Itoa(lo)
}
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
}
// wellCountRange is the fewest and most wells a new moon can have.
func wellCountRange() (lo, hi int) {
for n, w := range cfg().WellCountWeights {
if w > 0 {
if lo == 0 || n < lo {
lo = n
}
hi = max(hi, n)
}
}
return lo, hi
}
+44
View File
@@ -0,0 +1,44 @@
// 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
@@ -0,0 +1,48 @@
// 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);
-20
View File
@@ -1,20 +0,0 @@
import requests
import csv
sheet_names = [ "storeitems", "stakingSources" ]
spread_sheet_id = "1TitHE6rHFgmaRlBBLtMLarfNtp1UMzvRur4ZJ-PKhr0"
for sheet_name in sheet_names:
url = ("https://docs.google.com/spreadsheets/d/" +
spread_sheet_id +
"/gviz/tq?tqx=out:csv&sheet=" +
sheet_name)
response = requests.get(url)
csv_data = response.content.decode('utf-8')
csv_reader = csv.DictReader(csv_data.splitlines(), delimiter=',')
for row in csv_reader:
print(row)
+714
View File
@@ -0,0 +1,714 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/netip"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const userCookie = "mm_user"
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
type server struct {
db *pgxpool.Pool
// dev reloads templates and static files from disk (MM_DEV=1).
dev bool
templates *template.Template
static fs.FS
// newPlayers limits how fast one IP can create players.
newPlayers *rateLimiter
}
func serve(ctx context.Context) error {
db, err := connect(ctx, appDSN())
if err != nil {
return err
}
defer db.Close()
if err := loadOverride(ctx, db); err != nil {
return err
}
go watchOverride(ctx, db)
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10)}
if s.dev {
s.static = os.DirFS("web/static")
} else {
s.static, _ = fs.Sub(embedded, "web/static")
}
if s.templates, err = s.parseTemplates(); err != nil {
return err
}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.static)))
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.static, "favicon.ico")
})
mux.HandleFunc("GET /{$}", s.notBanned(s.handleIndex))
mux.HandleFunc("POST /session", s.handleSession)
mux.HandleFunc("POST /act/{action}", s.notBanned(s.withUser(s.handleAction)))
mux.HandleFunc("GET /utils", s.withUser(s.handleUtils))
mux.HandleFunc("POST /utils/{action}", s.withUser(s.handleUtilsAction))
mux.HandleFunc("GET /utils/moons", s.handleMoonsAdmin)
s.economyRoutes(mux)
s.playerRoutes(mux)
mux.HandleFunc("GET /utils/liveops", s.handleLiveOps)
mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction)
mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction)
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
mux.HandleFunc("GET /api/state", s.handleAPIState)
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
// 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
if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
handler = delayGame(handler, time.Duration(ms)*time.Millisecond)
}
addr := ":" + env("PORT", "3000")
srv := &http.Server{
Addr: addr,
Handler: logRequests(secureHeaders(limitBody(handler))),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdown)
}()
log.Printf("listening on %s", addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func (s *server) parseTemplates() (*template.Template, error) {
var files fs.FS = embedded
if s.dev {
files = os.DirFS(".")
}
return template.New("").Funcs(templateFuncs).ParseFS(files, "web/templates/*.html")
}
func (s *server) render(w http.ResponseWriter, name string, data any) {
t := s.templates
if s.dev {
var err error
if t, err = s.parseTemplates(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err)
}
}
// delayGame holds every game request (the htmx page's swaps and actions, the JSON API) for
// d before answering, to try the clients with a slow connection (MM_API_DELAY_MS, dev only).
func delayGame(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if strings.HasPrefix(p, "/act/") || strings.HasPrefix(p, "/api/act/") || p == "/api/state" ||
(p == "/" && r.Header.Get("HX-Request") == "true") {
time.Sleep(d)
}
next.ServeHTTP(w, r)
})
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.RequestURI(), time.Since(start).Round(time.Millisecond))
})
}
// Request body limits. The economy editor, which saves whole configs, gets more room.
const (
maxBodyBytes = 64 << 10
maxAdminBodyBytes = 1 << 20
)
// limitBody caps request bodies and parses forms up front, so an oversized or malformed
// form fails with 413/400 instead of reaching a handler as an empty form.
func limitBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit := int64(maxBodyBytes)
if strings.HasPrefix(r.URL.Path, "/utils/economy/") {
limit = maxAdminBodyBytes
}
if r.ContentLength > limit {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
} else {
http.Error(w, "Bad request", http.StatusBadRequest)
}
return
}
}
next.ServeHTTP(w, r)
})
}
// secureHeaders sets headers that are safe everywhere. TODO (IDEAS.md): CSP, frame-ancestors.
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
// rateLimiter is a per-IP token bucket for endpoints that create players.
type rateLimiter struct {
mu sync.Mutex
perMin float64
buckets map[string]*bucket
}
type bucket struct {
tokens float64
last time.Time
}
func newRateLimiter(perMin float64) *rateLimiter {
return &rateLimiter{perMin: perMin, buckets: map[string]*bucket{}}
}
// allow takes a token for the request's IP, or reports false if it has none left.
func (l *rateLimiter) allow(r *http.Request) bool {
ip := clientIP(r)
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if len(l.buckets) > 10000 {
// Forget idle IPs (a full bucket is the same as no bucket).
for k, b := range l.buckets {
if now.Sub(b.last) > time.Minute {
delete(l.buckets, k)
}
}
}
b, ok := l.buckets[ip]
if !ok {
b = &bucket{tokens: l.perMin, last: now}
l.buckets[ip] = b
}
b.tokens = min(l.perMin, b.tokens+now.Sub(b.last).Minutes()*l.perMin)
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw
// (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
func clientIP(r *http.Request) string {
if os.Getenv("TRUST_PROXY") == "1" {
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
parts := strings.Split(xff[len(xff)-1], ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
return normalIP(ip)
}
}
}
return normalIP(r.RemoteAddr)
}
// 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".
// Anything unparsable is kept as it is.
func normalIP(s string) string {
if host, _, err := net.SplitHostPort(s); err == nil {
s = host
}
ip, err := netip.ParseAddr(s)
if err != nil {
return s
}
ip = ip.Unmap().WithZone("")
if ip.IsLoopback() {
return "loopback"
}
return ip.String()
}
func serverError(w http.ResponseWriter, err error) {
log.Printf("error: %v", err)
http.Error(w, "Something went wrong", http.StatusInternalServerError)
}
// ------------------------------------------------------------------ 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) {
http.SetCookie(w, &http.Cookie{
Name: userCookie,
Value: userID,
Path: "/",
MaxAge: 10 * 365 * 24 * 3600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
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 {
return "Miner-" + randomHex(3)
}
// handleSession signs the browser in. Browsers from before the server-rendered UI keep
// 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
// 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())
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 {
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
Message string
View View // the view to show next (the request's, unless the action moves on)
BoughtTool string // a tool just bought (the buy modal)
Upgraded string // a tool just upgraded (its toast)
Finished []string // moons a claim mined out
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect)
}
// errUnknownAction is runAction's error for an action that doesn't exist.
var errUnknownAction = errors.New("unknown action")
// runAction runs a game action: its onboarding gate, then its rule 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.
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
view := viewFromForm(get)
var message, boughtTool, upgraded string
var finished []string
var saleCredits float64
var err error
// Actions of parts of the game not revealed yet are refused (onboarding.go).
if err = actionAllowed(ctx, s.db, userID, action, get("redeploy") == "1"); err != nil && !isGameError(err) {
return actionResult{}, err
}
if err != nil {
action = "refused"
}
switch action {
case "refused":
case "buy-moon":
var moonID string
if moonID, err = buyMoon(ctx, s.db, userID); err == nil {
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
}
case "sell-moon":
var name string
var earned float64
if name, earned, err = sellMoon(ctx, s.db, userID, get("sell-moon")); err == nil {
message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"}
}
case "sell-tool":
var name string
var earned float64
if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned))
}
case "sell-attachment":
item := get("item")
var earned float64
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
}
case "buy-tool":
id, _ := strconv.Atoi(get("id"))
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil {
message = "Tool purchased"
}
case "upgrade":
var tool string
if tool, err = upgradeTool(ctx, s.db, userID, get("tool")); err == nil {
message, upgraded = "Tool upgraded", tool
view.Drill = followTool(view.Drill, get("tool"), tool)
}
case "stake":
if err = stake(ctx, s.db, userID, get("tool"), get("target")); err == nil {
message = "Drill deployed"
}
case "claim":
// Claims every finished drill on the given well or moon (or everywhere); claim-wide
// claims a moon's drills that mine several of its wells.
scope := ClaimScope{
MoonID: get("claim-moon"),
WellID: get("claim-well"),
Redeploy: get("redeploy") == "1",
}
if id := get("claim-wide"); id != "" {
scope.MoonID, scope.Wide = id, true
}
var res ClaimResult
if res, err = claimReady(ctx, s.db, userID, scope); err == nil {
claimed, idle := formatMoney(res.Total), res.Claimed-res.Redeployed
switch {
case res.Claimed == 0:
err = gameErr("Nothing is ready to claim")
case !scope.Redeploy:
message = fmt.Sprintf("Claimed %s from %s", claimed, plural(res.Claimed, "drill"))
case idle == 0:
message = fmt.Sprintf("Claimed %s and redeployed", claimed)
default:
// Redeploying skips drained wells.
why := "its well is empty"
if idle > 1 {
why = "their wells are empty"
}
if res.Redeployed > 0 {
claimed += fmt.Sprintf(" and redeployed %d", res.Redeployed)
}
message = fmt.Sprintf("Claimed %s. %s left idle (%s)", claimed, plural(idle, "drill"), why)
}
if res.Stopped > 0 {
verb := "drill"
if res.Stopped > 1 {
verb = "drills"
}
message += fmt.Sprintf(". Well tapped out — %d %s returned idle", res.Stopped, verb)
}
if len(res.Finished) > 0 {
names := make([]string, len(res.Finished))
for i, m := range res.Finished {
names[i] = m.Name
finished = append(finished, m.ID)
// Move on to an active moon (resolve picks one, if any is left).
if m.ID == view.Moon {
view.Moon, view.Well = "", ""
}
}
verb := "is"
if len(names) > 1 {
verb = "are"
}
message = fmt.Sprintf("%s %s mined out — moved to Past moons", strings.Join(names, " and "), verb)
}
}
case "redrill":
var n int
if n, err = redrill(ctx, s.db, userID, get("redrill-well")); err == nil {
message = fmt.Sprintf("Redrilled %s", plural(n, "drill"))
}
case "sell":
resource := get("resource")
all := get("all") == "1"
amount, _ := strconv.ParseFloat(get("amount"), 64)
var earned float64
if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil {
message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned))
saleCredits = earned
}
case "refine":
// Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible.
recipe := get("recipe")
qty, _ := strconv.Atoi(get("qty"))
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, get("all") == "1"); err == nil {
message = fmt.Sprintf("Refining %d %s", qty, recipe)
}
case "collect":
// Collects one finished batch (job), or all of them.
var done []RefineryJob
if done, err = collectRefinery(ctx, s.db, userID, get("job")); err == nil {
if len(done) == 1 {
message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item)
} else {
message = fmt.Sprintf("Collected %d batches", len(done))
}
}
case "buy-slot":
var n int
if n, err = buyRefinerySlot(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Refinery slot %d online", n)
}
case "buy-attachment":
item := get("item")
if err = buyAttachment(ctx, s.db, userID, item); err == nil {
message = item + " added to your inventory"
}
case "equip", "unequip":
// Puts an attachment (item, by name) on a tool (to), or takes it off.
// A busy tool's idle copy may get it instead; the panel follows that one.
item, tool := get("item"), get("to")
var used string
if action == "equip" {
used, err = equipAttachment(ctx, s.db, userID, item, tool)
message = item + " equipped"
} else {
used, err = unequipAttachment(ctx, s.db, userID, item, tool)
message = item + " unequipped"
}
if err == nil {
view.Drill = followTool(view.Drill, tool, used)
}
case "hire":
name := get("advisor")
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " hired"
}
case "seat-advisor":
// Puts a hired advisor (advisor) in an empty seat (seat, from 1).
name := get("advisor")
seat, _ := strconv.Atoi(get("seat"))
if err = seatAdvisor(ctx, s.db, userID, name, seat); err == nil {
message = fmt.Sprintf("%s takes seat %d", name, seat)
}
case "unseat-advisor":
name := get("advisor")
if err = unseatAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " left their seat"
}
case "swap-advisors":
// Exchanges the seats of two hired advisors (advisor, with).
name, other := get("advisor"), get("with")
if err = swapAdvisors(ctx, s.db, userID, name, other); err == nil {
message = fmt.Sprintf("%s and %s swapped seats", name, other)
}
case "dismiss-advisor":
name := get("advisor")
if err = dismissAdvisor(ctx, s.db, userID, name); err == nil {
message = name + " dismissed"
}
case "buy-seat":
var n int
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
message = fmt.Sprintf("Advisor seat %d added", n)
}
case "new-game":
default:
return actionResult{}, errUnknownAction
}
if err != nil && !isGameError(err) {
return actionResult{}, err
}
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool,
Upgraded: upgraded, Finished: finished, SaleCredits: saleCredits}, nil
}
// toast is the action's toast, for the state after it.
func (res actionResult) toast(state *State) *Toast {
if res.Err != nil {
return &Toast{Message: res.Err.Error(), Error: true, Kind: "error"}
}
toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
if t := state.tool(res.Upgraded); t != nil {
toast.Message = state.upgradedText(*t)
}
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 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.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
// handleUtils is the admin hub; it opens the Players page (players.go).
func (s *server) handleUtils(w http.ResponseWriter, r *http.Request, userID string) {
http.Redirect(w, r, "/utils/players", http.StatusSeeOther)
}
func (s *server) handleUtilsAction(w http.ResponseWriter, r *http.Request, userID string) {
if !s.isAdmin(userID) {
http.Error(w, "Missing permission", http.StatusForbidden)
return
}
var err error
switch r.PathValue("action") {
case "clear-data":
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.
func (s *server) loadRanking(ctx context.Context, page *Page) (err error) {
if page.V.Section == "leaderboard" {
page.Ranking, err = loadLeaderboard(ctx, s.db, page.S.UserID)
}
return err
}
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
limit, err := strconv.Atoi(r.FormValue("limit"))
if err != nil || limit <= 0 || limit > 100 {
limit = 10
}
entries, err := leaderboard(r.Context(), s.db, limit)
if err != nil {
serverError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
+854
View File
@@ -0,0 +1,854 @@
package main
// Economy analysis for /utils/economy: per-tool numbers and a simple progression sim,
// computed from a (draft) config. Everything uses average prices; the sim's moons are
// rolled like real ones (rollWells) from a fixed seed, so the results are deterministic.
import (
"fmt"
"math"
"math/rand/v2"
"slices"
)
const (
// A bigger tool should earn at least this many times the previous one per hour.
minRateStep = 1.2
// An upgrade shouldn't take more than this many times as long to pay back as its tool.
maxUpgradePayback = 2.0
// Payback time shouldn't jump by more than this between consecutive tools.
maxPaybackJump = 4.0
simHours = 4
simReaction = 3 // seconds between the sim player's actions
simMaxTools = 300
// Moons are a flat-price consumable, so a busy player goes through thousands.
simMaxMoons = 10000
)
// simSeed seeds the sim's moon rolls.
var simSeed = [2]uint64{1, 2}
// capRolls fresh moons give the spread of what a tool's reach can hold (reachCaps).
const capRolls = 4000
// ReachCaps is, for each reach (index reach-1), what the reach's richest wells of a
// freshly rolled moon hold in all, in units, sorted, with running sums (Yield).
type ReachCaps []reachCap
type reachCap struct{ caps, sums []float64 } // sums[i]: caps[:i] added up
// reachCaps rolls capRolls moons from a fixed seed (no well growth).
func reachCaps(c *GameConfig) ReachCaps {
n := max(1, len(c.ResourcePrices))
caps := make([][]float64, n)
rc := make(ReachCaps, n)
if len(c.Moons) == 0 {
return rc
}
rng := rand.New(rand.NewPCG(simSeed[1], simSeed[0]))
for range capRolls {
kind := c.Moons[rng.IntN(len(c.Moons))]
var supply []float64
for _, w := range rollWells(c, kind, 1, false, rng.Float64) {
supply = append(supply, w.Supply)
}
slices.SortFunc(supply, cmpDesc)
sum := 0.0
for r := range n {
if r < len(supply) {
sum += supply[r]
}
caps[r] = append(caps[r], sum)
}
}
for r := range caps {
slices.Sort(caps[r])
sums := make([]float64, len(caps[r])+1)
for i, cp := range caps[r] {
sums[i+1] = sums[i] + cp
}
rc[r] = reachCap{caps[r], sums}
}
return rc
}
// Yield is what a claim of amount with the given reach gets on average from a fresh moon,
// deployed on its richest well: all of it, unless the wells it covers hold less.
func (rc ReachCaps) Yield(amount float64, reach int) float64 {
if len(rc) == 0 {
return amount
}
r := rc[min(max(1, reach), len(rc))-1]
n := len(r.caps)
if n == 0 {
return amount
}
// The moons that hold less than amount give what they hold, the others amount.
i, _ := slices.BinarySearch(r.caps, amount)
return (r.sums[i] + amount*float64(n-i)) / float64(n)
}
type TierStat struct {
Tier int
Price float64
PartsValue float64 // sell value of the refined parts it uses up
Yield float64
Reach int
Eff float64 // what a claim gets on average from a fresh moon (ReachCaps)
CreditsPerHour float64 // at Eff
Payback Hours // for the upgrade's extra income to repay its price plus parts
TooBig bool // yield above what its reach can hold (Capacity)
Capacity float64
}
type ToolStat struct {
ID int
Name string
Price float64
Claim float64
Reach int
Eff float64 // what a claim gets on average from a fresh moon (ReachCaps)
Capacity float64 // the most its reach can hold: reach × the biggest well
Secs int
UnitsPerHour float64 // at Eff
CreditsPerHour float64
Payback Hours
Ratio Times // credits/hour vs the previous (cheaper) tool; 0 for the first
PaybackJump Times // payback vs the previous tool
TooBig bool
RatioLow bool
JumpHigh bool
Tiers []TierStat
}
type Analysis struct {
Errors []string
Warnings []string
Unsaved int // fields that differ from the config in effect
AvgPrice float64
AvgSupply float64 // per well
AvgWells float64
MoonUnits float64 // in units at the average price
MoonValue float64
MinRateStep float64
MaxJump float64
Tools []ToolStat
Refining *RefineryStats
Sim *SimResult
// Flags are the warnings by row, for the editor grid: "item:<id>" or "refined:<name>".
Flags map[string][]string
}
func (a *Analysis) warn(key, msg string) {
a.Warnings = append(a.Warnings, msg)
if a.Flags == nil {
a.Flags = map[string][]string{}
}
a.Flags[key] = append(a.Flags[key], msg)
}
func avgPrice(c *GameConfig) float64 {
if len(c.ResourcePrices) == 0 {
return 0
}
sum := 0.0
for _, p := range c.ResourcePrices {
sum += p
}
return sum / float64(len(c.ResourcePrices))
}
// avgWells is the expected number of wells on a moon (wellCountWeights).
func avgWells(c *GameConfig) float64 {
sum, n := 0.0, 0.0
for count, w := range c.WellCountWeights {
sum += w
n += w * float64(min(count, len(c.ResourcePrices)))
}
if sum <= 0 {
return 1
}
return n / sum
}
func perHour(units float64, secs int) float64 {
if secs <= 0 {
return 0
}
return units * 3600 / float64(secs)
}
// Hours is a duration in hours; Inf for never.
type Hours float64
func (h Hours) String() string {
switch {
case math.IsInf(float64(h), 0) || math.IsNaN(float64(h)):
return "never"
case h < 1:
return fmt.Sprintf("%.0fm", float64(h)*60)
case h < 100:
return fmt.Sprintf("%.1fh", float64(h))
}
return fmt.Sprintf("%.0fh", float64(h))
}
// Times is a ratio; 0 for none.
type Times float64
func (t Times) String() string {
if t == 0 || math.IsInf(float64(t), 0) || math.IsNaN(float64(t)) {
return "—"
}
return fmt.Sprintf("%.2f×", float64(t))
}
func hours(price, rate float64) Hours {
if rate <= 0 {
return Hours(math.Inf(1))
}
return Hours(price / rate)
}
func analyze(c *GameConfig, errs []string, unsaved int) *Analysis {
a := &Analysis{Errors: errs, Unsaved: unsaved, MinRateStep: minRateStep, MaxJump: maxPaybackJump}
if len(errs) > 0 {
return a
}
a.AvgPrice = avgPrice(c)
a.AvgWells = avgWells(c)
a.AvgSupply = (c.WellSupplyMin + c.WellSupplyMax) / 2
a.MoonUnits = a.AvgSupply * a.AvgWells
a.MoonValue = a.MoonUnits * a.AvgPrice
a.Sim = simulate(c)
caps := reachCaps(c)
// Wells grow with moons owned (wellSupplyGrowth), so a tool is too big if it outclaims
// the biggest wells its reach covers on the next moon at the point the sim player buys it.
wellMax := func(id int) float64 {
moons := 0
if n := len(a.Sim.Snapshots); n > 0 {
moons = a.Sim.Snapshots[n-1].Moons
}
for i, item := range c.StoreItems {
if item.ID == id && a.Sim.Tools[i].BoughtAt >= 0 {
moons = a.Sim.Tools[i].Moons
}
}
return c.WellSupplyMax * c.wellScale(moons)
}
items := slices.Clone(c.StoreItems)
slices.SortStableFunc(items, func(x, y StoreItem) int {
switch {
case x.Price < y.Price:
return -1
case x.Price > y.Price:
return 1
}
return 0
})
for i, item := range items {
t := ToolStat{ID: item.ID, Name: item.Name, Price: item.Price, Claim: item.ClaimAmount,
Secs: item.CompletionTimeInSecs, Reach: item.ToolReach(0)}
t.Eff = caps.Yield(item.ClaimAmount, t.Reach)
t.UnitsPerHour = perHour(t.Eff, item.CompletionTimeInSecs)
t.CreditsPerHour = t.UnitsPerHour * a.AvgPrice
t.Payback = hours(item.Price, t.CreditsPerHour)
maxWell := wellMax(item.ID)
capacity := func(reach int) float64 { return float64(min(reach, len(c.ResourcePrices))) * maxWell }
t.Capacity = capacity(t.Reach)
t.TooBig = item.ClaimAmount > t.Capacity
if i > 0 {
prev := a.Tools[i-1]
if prev.CreditsPerHour > 0 {
t.Ratio = Times(t.CreditsPerHour / prev.CreditsPerHour)
t.RatioLow = t.Ratio < minRateStep
}
if prev.Payback > 0 && !math.IsInf(float64(prev.Payback), 0) {
t.PaybackJump = Times(t.Payback / prev.Payback)
t.JumpHigh = t.PaybackJump > maxPaybackJump
}
}
for _, u := range item.Upgrades {
yield, reach := item.ClaimAmount+u.ClaimBoost, item.ToolReach(u.Tier)
eff := caps.Yield(yield, reach)
rate := perHour(eff, item.CompletionTimeInSecs) * a.AvgPrice
prevRate := t.CreditsPerHour
if len(t.Tiers) > 0 {
prevRate = t.Tiers[len(t.Tiers)-1].CreditsPerHour
}
parts := partsValue(c, u.Parts)
t.Tiers = append(t.Tiers, TierStat{Tier: u.Tier, Price: u.Price, PartsValue: parts, Yield: yield,
Reach: reach, Eff: eff, CreditsPerHour: rate, Payback: hours(u.Price+parts, rate-prevRate),
Capacity: capacity(reach), TooBig: yield > capacity(reach)})
}
// An upgrade should be worth its price next to buying the next tool, and pay back
// about as fast as the tool itself.
for _, u := range t.Tiers {
if i+1 < len(items) {
next := &items[i+1]
nextRate := perHour(caps.Yield(next.ClaimAmount, next.ToolReach(0)), next.CompletionTimeInSecs) * a.AvgPrice
if u.Price+u.PartsValue > next.Price && u.CreditsPerHour < nextRate {
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s tier %d costs %s, more than %s (%s), and earns less.",
item.Name, u.Tier, formatCredits(u.Price+u.PartsValue), next.Name, formatCredits(next.Price)))
}
}
if u.Payback > maxUpgradePayback*t.Payback {
a.warn(fmt.Sprintf("item:%d", item.ID), fmt.Sprintf("%s tier %d pays back in %s, over %.0f× the tool itself (%s).",
item.Name, u.Tier, u.Payback, maxUpgradePayback, t.Payback))
}
}
key := fmt.Sprintf("item:%d", item.ID)
if t.TooBig {
a.warn(key, fmt.Sprintf("%s claims %s, more than %s can hold (%s).",
item.Name, formatMoney(item.ClaimAmount), reachText(t.Reach), formatMoney(t.Capacity)))
} else {
for _, u := range t.Tiers {
if u.TooBig {
a.warn(key, fmt.Sprintf("%s at tier %d claims %s, more than %s can hold (%s).",
item.Name, u.Tier, formatMoney(u.Yield), reachText(u.Reach), formatMoney(u.Capacity)))
break
}
}
}
if t.RatioLow {
a.warn(key, fmt.Sprintf("%s earns only %.2f× %s per hour.",
item.Name, t.Ratio, a.Tools[i-1].Name))
}
if t.JumpHigh {
a.warn(key, fmt.Sprintf("%s takes %.1f× longer to pay back than %s.",
item.Name, t.PaybackJump, a.Tools[i-1].Name))
}
a.Tools = append(a.Tools, t)
}
a.Refining = analyzeRefinery(c)
for _, w := range a.Refining.Warnings {
a.warn("refined:"+w.Item, w.Message)
}
if a.Sim.Capped {
a.Warnings = append(a.Warnings, fmt.Sprintf(
"Runaway growth: the greedy player hits the %d-tool or %d-moon cap at %s.", simMaxTools, simMaxMoons, a.Sim.CappedAt))
}
return a
}
// reachText is "a well" or "3 wells", for warnings.
func reachText(reach int) string {
if reach <= 1 {
return "a well"
}
return fmt.Sprintf("%d wells", reach)
}
// ------------------------------------------------------------------ progression sim
// SimTime is a point in the sim, in seconds from the start; -1 for never.
type SimTime int
func (s SimTime) String() string {
if s < 0 {
return "—"
}
return fmt.Sprintf("%dh %02dm %02ds", s/3600, s%3600/60, s%60)
}
type SimTool struct {
Name string
Price float64
AffordAt SimTime // when the balance first reached the price; -1 for never
BoughtAt SimTime // first purchase; -1 for never
Moons int // moons owned at the first purchase
Count int
}
type SimMoon struct {
Count int
At SimTime
}
// PressureText is the selling pressure discount, e.g. "−4%".
func (s SimSnapshot) PressureText() string { return fmt.Sprintf("−%.0f%%", (1-s.Pressure)*100) }
type SimSnapshot struct {
At SimTime
Credits float64
Earned float64
RatePerHour float64 // credits earned per hour since the previous snapshot
MoonsBought int // since the previous snapshot
Tools, Moons int
SupplyLeft float64
Pressure float64 // selling pressure price factor at the time (market.go)
}
type SimResult struct {
Hours int
Reaction int // seconds between the player's actions
Tools []SimTool
Attachments []SimBuy // equipped, by kind
Advisors []SimBuy // hired
Seats int // advisor seats at the end
Moons []SimMoon
Snapshots []SimSnapshot
Events []string
MoreEvents int
Capped bool
CappedAt SimTime
}
// SimBuy is an attachment or advisor the sim player bought: when first, and how many.
type SimBuy struct {
Name string
BoughtAt SimTime // -1 for never
Count int
}
type simTool struct {
item int // index into the config's store items
tier int
mods []string // attachments equipped
busy bool
doneAt int
amount float64
}
// simulate plays a greedy, active player: starting config, sells everything at the average
// price, and every simReaction seconds claims and redeploys every finished drill, buys a
// moon when its wells can't take two more rounds from every drill, and otherwise buys
// what breaks even soonest (time to save up plus payback): a tool, an upgrade, an
// attachment for one tool or an advisor (with a seat if needed), keeping enough for the next moon.
func simulate(c *GameConfig) *SimResult {
price := avgPrice(c)
rng := rand.New(rand.NewPCG(simSeed[0], simSeed[1]))
res := &SimResult{Hours: simHours, Reaction: simReaction, Seats: 1}
itemIndex := map[int]int{}
for i, item := range c.StoreItems {
itemIndex[item.ID] = i
res.Tools = append(res.Tools, SimTool{Name: item.Name, Price: item.Price, AffordAt: -1, BoughtAt: -1})
}
for _, m := range c.Attachments.Items {
res.Attachments = append(res.Attachments, SimBuy{Name: m.Name, BoughtAt: -1})
}
for _, a := range c.Advisors.Items {
res.Advisors = append(res.Advisors, SimBuy{Name: a.Name, BoughtAt: -1})
}
var advisors []string // hired
// A tool's cycle and yield with its attachments and the given advisors (bonus.go). A drill
// that finishes between the player's actions waits for the next one.
stats := func(item, tier int, mods, adv []string) (int, float64) {
it := &c.StoreItems[item]
secs, y := drillStats(it.CompletionTimeInSecs, it.ToolYield(tier), c.bonusesFor(adv, mods), 1, 1)
return (secs + simReaction - 1) / simReaction * simReaction, y
}
// rate is a tool's credits per hour, with what its reach gets from a fresh moon.
caps := reachCaps(c)
rate := func(item, tier int, mods, adv []string) float64 {
secs, y := stats(item, tier, mods, adv)
return perHour(caps.Yield(y, c.StoreItems[item].ToolReach(tier)), secs) * price * c.bonusesFor(adv, nil).Sell
}
reach := func(t *simTool) int { return c.StoreItems[t.item].ToolReach(t.tier) }
yield := func(t *simTool) float64 { _, y := stats(t.item, t.tier, t.mods, advisors); return y }
cycle := func(t *simTool) int { secs, _ := stats(t.item, t.tier, t.mods, advisors); return secs }
credits := c.StartingCredits + c.StartingResources*price*float64(len(c.ResourcePrices))
earned, earnedAtSnap, snapAt, moonsAtSnap := 0.0, 0.0, 0, 0
var tools []simTool
var wells []*simMoon // unreserved supply by moon
left := 0.0 // total unreserved supply
moons := 0
for _, id := range c.StartingTools {
if i, ok := itemIndex[id]; ok {
tools = append(tools, simTool{item: i})
res.Tools[i].Count++
}
}
income := func(adv []string) float64 {
sum := 0.0
for i := range tools {
sum += rate(tools[i].item, tools[i].tier, tools[i].mods, adv)
}
return sum
}
capped := func(t int) {
if !res.Capped {
res.Capped, res.CappedAt = true, SimTime(t)
}
}
event := func(t int, format string, args ...any) {
if len(res.Events) < 40 {
res.Events = append(res.Events, SimTime(t).String()+" "+fmt.Sprintf(format, args...))
} else {
res.MoreEvents++
}
}
bought := func(b *SimBuy, t int) {
if b.BoughtAt < 0 {
b.BoughtAt = SimTime(t)
}
b.Count++
}
pressure := 0.0 // per resource
end := simHours * 3600
for t := 0; t <= end; t++ {
snap := t > 0 && (t <= 3600 && t%600 == 0 || t%1800 == 0)
if t%simReaction != 0 && !snap {
continue
}
act := t%simReaction == 0
// Claim finished drills (and sell at once, with the advisors' sell bonus and selling
// pressure, spread evenly over the resources).
sell := c.bonusesFor(advisors, nil).Sell
if act {
pressure = c.MarketPressure.Decay(pressure, simReaction)
units := 0.0
for i := range tools {
if tools[i].busy && tools[i].doneAt <= t {
units += tools[i].amount
tools[i].busy = false
}
}
d := units * price / float64(max(1, len(c.ResourcePrices)))
sold := units * price * sell * c.MarketPressure.Average(pressure, d)
pressure = min(pressure+d, pressureCap*c.MarketPressure.Scale)
credits += sold
earned += sold
}
for i := range res.Tools {
if res.Tools[i].AffordAt < 0 && credits >= res.Tools[i].Price {
res.Tools[i].AffordAt = SimTime(t)
}
}
if snap {
res.Snapshots = append(res.Snapshots, SimSnapshot{At: SimTime(t), Credits: credits,
Earned: earned, RatePerHour: (earned - earnedAtSnap) * 3600 / float64(t-snapAt),
MoonsBought: moons - moonsAtSnap, Tools: len(tools), Moons: moons, SupplyLeft: left,
Pressure: c.MarketPressure.Factor(pressure)})
earnedAtSnap, snapAt, moonsAtSnap = earned, t, moons
}
// Past a cap the sim says nothing more (and runs slow with that many tools and moons).
if res.Capped && snap {
break
}
if t == end || !act {
continue
}
// Buy what the greedy player wants, as long as they can afford it.
for {
round := 0.0 // what every drill claims in one cycle
for i := range tools {
round += yield(&tools[i])
}
// Buy ahead, so the drills never run dry while the player is short of a moon.
if moons == 0 || left < 2*round {
if moons >= simMaxMoons {
capped(t)
break
}
moonPrice := c.moonPriceFor(moons)
if credits < moonPrice {
break
}
credits -= moonPrice
// Wells in units at the average price, which is what the sim sells at.
kind := c.Moons[rng.IntN(len(c.Moons))]
var moon []float64
for _, w := range rollWells(c, kind, c.wellScale(moons), moons == 0, rng.Float64) {
units := w.Supply
if price > 0 {
units *= c.ResourcePrices[w.Resource] / price
}
moon = append(moon, units)
left += units
}
wells = append(wells, newSimMoon(moon))
moons++
if moons <= 5 || moons <= 100 && moons%10 == 0 || moons%500 == 0 || moons == simMaxMoons {
res.Moons = append(res.Moons, SimMoon{Count: moons, At: SimTime(t)})
}
event(t, "moon #%d (%s)", moons, formatCredits(moonPrice))
continue
}
// Soonest break-even (time to save up plus payback): a new tool; the next tier or
// an attachment for one owned tool of each kind; or an advisor.
now := income(advisors)
breakEven := func(cost, gain float64) Hours {
return hours(max(0, cost-credits), now) + hours(cost, gain)
}
type choice struct {
kind string // tool, upgrade, attachment, advisor
item int // store item index (tool)
tool int // index into tools (upgrade, attachment)
name string // attachment or advisor
seat float64
cost float64
payback Hours
}
best := choice{payback: Hours(math.Inf(1))}
consider := func(ch choice, gain float64) {
if ch.payback = breakEven(ch.cost, gain); ch.payback < best.payback {
best = ch
}
}
if len(tools) < simMaxTools {
for i, item := range c.StoreItems {
consider(choice{kind: "tool", item: i, cost: item.Price}, rate(i, 0, nil, advisors))
}
} else {
capped(t)
}
seen := map[string]bool{}
for i := range tools {
tl := &tools[i]
key := fmt.Sprint(tl.item, tl.tier, tl.mods)
if seen[key] {
continue
}
seen[key] = true
item := &c.StoreItems[tl.item]
cur := rate(tl.item, tl.tier, tl.mods, advisors)
if up := item.NextUpgrade(tl.tier); up != nil {
consider(choice{kind: "upgrade", item: tl.item, tool: i, cost: up.Price + partsValue(c, up.Parts)},
rate(tl.item, tl.tier+1, tl.mods, advisors)-cur)
}
if len(tl.mods) >= c.Attachments.SlotsPerTool {
continue
}
for _, m := range c.Attachments.Items {
if slices.Contains(tl.mods, m.Name) {
continue
}
mods := append(slices.Clone(tl.mods), m.Name)
consider(choice{kind: "attachment", item: tl.item, tool: i, name: m.Name, cost: m.Price},
rate(tl.item, tl.tier, mods, advisors)-cur)
}
}
for _, a := range c.Advisors.Items {
if slices.Contains(advisors, a.Name) {
continue
}
ch := choice{kind: "advisor", name: a.Name, cost: a.Price}
if len(advisors) >= res.Seats {
if res.Seats-1 >= len(c.Advisors.SeatPrices) {
continue
}
ch.seat = c.Advisors.SeatPrices[res.Seats-1]
ch.cost += ch.seat
}
consider(ch, income(append(slices.Clone(advisors), a.Name))-now)
}
// Keep enough for the next moon: with dry wells and no credits, nothing earns.
if best.kind == "" || credits-best.cost < c.moonPriceFor(moons) {
break
}
credits -= best.cost
switch best.kind {
case "upgrade":
tools[best.tool].tier++
event(t, "upgrade %s to tier %d (%s)", c.StoreItems[best.item].Name, tools[best.tool].tier, formatCredits(best.cost))
case "tool":
tools = append(tools, simTool{item: best.item})
if res.Tools[best.item].BoughtAt < 0 {
res.Tools[best.item].BoughtAt, res.Tools[best.item].Moons = SimTime(t), moons
}
res.Tools[best.item].Count++
event(t, "buy %s (%s)", c.StoreItems[best.item].Name, formatCredits(best.cost))
case "attachment":
tools[best.tool].mods = append(slices.Clone(tools[best.tool].mods), best.name)
for i := range res.Attachments {
if res.Attachments[i].Name == best.name {
bought(&res.Attachments[i], t)
}
}
event(t, "equip %s on %s (%s)", best.name, c.StoreItems[best.item].Name, formatCredits(best.cost))
case "advisor":
if best.seat > 0 {
res.Seats++
}
advisors = append(advisors, best.name)
for i := range res.Advisors {
if res.Advisors[i].Name == best.name {
bought(&res.Advisors[i], t)
}
}
event(t, "hire %s (%s)", best.name, formatCredits(best.cost))
}
}
// Deploy idle drills, biggest first, each on the moon whose wells in its reach hold
// the most, from its fullest well (splitClaim, as in the game), reserving what it will
// claim. Mined-out moons are dropped first.
var idle []int
for i := range tools {
if !tools[i].busy {
idle = append(idle, i)
}
}
if len(idle) == 0 {
continue
}
wells = slices.DeleteFunc(wells, func(m *simMoon) bool { return m.richest < 0 })
yields := map[int]float64{}
for _, i := range idle {
yields[i] = yield(&tools[i])
}
slices.SortFunc(idle, func(a, b int) int { return cmpDesc(yields[a], yields[b]) })
for _, i := range idle {
r := reach(&tools[i])
cur, best, anchor := -1, 0.0, -1
for m, moon := range wells {
if top := moon.top[min(r, len(moon.top))-1]; top > best {
cur, best, anchor = m, top, moon.richest
}
}
if cur < 0 {
break
}
amount := 0.0
moon := wells[cur]
for w, take := range splitClaim(yields[i], anchor, moon.wells, r) {
moon.wells[w] -= take
amount += take
}
moon.update()
left -= amount
tools[i].busy, tools[i].doneAt, tools[i].amount = true, t+cycle(&tools[i]), amount
}
}
return res
}
// simMoon is a moon in the sim: its wells' unreserved supply, and (kept up to date by
// update) what its richest wells hold by reach (top[reach-1]) and its richest well (-1
// once it's mined out).
type simMoon struct {
wells []float64
top []float64
richest int
}
func newSimMoon(wells []float64) *simMoon {
m := &simMoon{wells: wells, top: make([]float64, max(1, len(wells)))}
m.update()
return m
}
func (m *simMoon) update() {
sorted := slices.Clone(m.wells)
slices.SortFunc(sorted, cmpDesc)
m.richest = -1
sum := 0.0
for i := range m.top {
if i < len(sorted) {
sum += max(0, sorted[i])
}
m.top[i] = sum
}
for i, w := range m.wells {
if w > 0 && (m.richest < 0 || w > m.wells[m.richest]) {
m.richest = i
}
}
}
func cmpDesc(a, b float64) int {
switch {
case a > b:
return -1
case a < b:
return 1
}
return 0
}
// ------------------------------------------------------------------ refining
const minRefineGain = 0.15
// partsValue is what refined parts would sell for.
func partsValue(c *GameConfig, parts map[string]float64) float64 {
v := 0.0
for name, qty := range parts {
v += qty * c.unitPrice(name)
}
return v
}
// rawValue is what the resources behind one unit of a resource or refined item sell for.
func rawValue(c *GameConfig, name string) float64 {
item := c.refinedItem(name)
if item == nil {
return c.ResourcePrices[name]
}
v := 0.0
for in, qty := range item.Inputs {
v += qty * rawValue(c, in)
}
return v
}
type RecipeStat struct {
Name, Color string
Tier, Secs int
Inputs string
InputValue float64 // the inputs sold as they are
RawValue float64 // the resources behind the inputs, sold raw
Price float64
Gain float64 // over InputValue, as a fraction
PerSlotHour float64 // profit per hour for one busy slot
LowGain bool
BelowInputs bool // a tier-2+ item worth less than its inputs
}
func (r RecipeStat) GainPct() string { return fmt.Sprintf("%+.0f%%", r.Gain*100) }
type SlotStat struct {
Slot int
Price float64
Payback Hours // with the best recipe running nonstop
}
type RefineryStats struct {
Recipes []RecipeStat
Slots []SlotStat
MaxBatch int
BulkExponent float64
MinGain string
Warnings []RecipeWarning
}
type RecipeWarning struct{ Item, Message string }
func analyzeRefinery(c *GameConfig) *RefineryStats {
r := &RefineryStats{MaxBatch: c.Refinery.MaxBatch, BulkExponent: c.Refinery.BulkExponent, MinGain: fmt.Sprintf("%.0f%%", minRefineGain*100)}
best := 0.0
for _, item := range c.Refinery.Items {
st := RecipeStat{Name: item.Name, Color: item.Color, Tier: item.Tier, Secs: item.Seconds,
Inputs: formatParts(item.Inputs), Price: item.Price}
for in, qty := range item.Inputs {
st.InputValue += qty * c.unitPrice(in)
st.RawValue += qty * rawValue(c, in)
}
if st.InputValue > 0 {
st.Gain = (item.Price - st.InputValue) / st.InputValue
}
st.PerSlotHour = (item.Price - st.InputValue) * 3600 / float64(max(1, item.Seconds))
best = max(best, st.PerSlotHour)
st.BelowInputs = item.Tier >= 2 && item.Price < st.InputValue
st.LowGain = !st.BelowInputs && st.Gain < minRefineGain
switch {
case st.BelowInputs:
r.Warnings = append(r.Warnings, RecipeWarning{item.Name, fmt.Sprintf("%s sells for %s, less than its inputs (%s).",
item.Name, formatCredits(item.Price), formatCredits(st.InputValue))})
case st.LowGain:
r.Warnings = append(r.Warnings, RecipeWarning{item.Name, fmt.Sprintf("Refining %s gains only %s over selling its inputs.",
item.Name, st.GainPct())})
}
r.Recipes = append(r.Recipes, st)
}
for i, price := range c.Refinery.SlotPrices {
r.Slots = append(r.Slots, SlotStat{Slot: i + 2, Price: price, Payback: hours(price, best)})
}
return r
}
-6
View File
@@ -1,8 +1,2 @@
grant usage on schema public to devs;
grant usage on all sequences in schema public to devs;
grant select, insert, update, delete on all tables in schema public to devs;
grant pg_read_server_files to devs;
insert into resource(name)
values ('Sollux'), ('Shadowstone'), ('Azurium'), ('Novafor'), ('Nebulance');
-163
View File
@@ -1,163 +0,0 @@
create or replace function import_config()
returns void as $$
begin
create temporary table if not exists storeItems(
id integer primary key,
name text not null,
description text not null,
image text not null,
claimAmount float8 not null,
completionTimeInMins integer not null,
basePrice float8 not null,
tier_1_price float8 not null,
tier_1_claimboost float8 not null,
tier_2_price float8 not null,
tier_2_claimboost float8 not null,
tier_3_price float8 not null,
tier_3_claimboost float8 not null,
tier_4_price float8 not null,
tier_4_claimboost float8 not null,
tier_5_price float8 not null,
tier_5_claimboost float8 not null
) on commit drop;
create temporary table if not exists stakingSources(
id integer primary key,
name text not null,
description text not null,
image text not null
) on commit drop;
create temporary table if not exists gameConstants(
key text primary key,
value float4 not null
) on commit drop;
perform download_sheets();
--- For some reason if you don't explicitly set it, it just copies it in a weird order
copy storeItems(
id, name, description, image, claimAmount, completionTimeInMins, basePrice,
tier_1_price, tier_1_claimboost, tier_2_price, tier_2_claimboost,
tier_3_price, tier_3_claimboost, tier_4_price, tier_4_claimboost,
tier_5_price, tier_5_claimboost
) from '/tmp/storeItems.csv' with (delimiter ',', format csv, header true);
copy stakingSources from '/tmp/stakingSources.csv' (delimiter ',', format csv, header true);
copy gameConstants from '/tmp/gameConstants.csv' with (delimiter ',', format csv, header true);
perform copy_store_items();
perform copy_upgrade_items(1);
perform copy_upgrade_items(2);
perform copy_upgrade_items(3);
perform copy_upgrade_items(4);
perform copy_upgrade_items(5);
perform copy_staking_source_items();
perform copy_game_constants();
end;
$$ language plpgsql;
create or replace function copy_store_items()
returns void as $$
begin
insert into store_item(
id,
name,
description,
price,
image_name,
claim_amount,
completion_time_in_mins
)
select
id,
name,
description,
basePrice,
image,
claimAmount,
completionTimeInMins
from storeItems
on conflict(id)
do update set
name = excluded.name,
description = excluded.description,
price = excluded.price,
image_name = excluded.image_name,
claim_amount = excluded.claim_amount,
completion_time_in_mins = excluded.completion_time_in_mins;
end;
$$ language plpgsql;
create or replace function copy_upgrade_items(p_tier integer)
returns void as $$
begin
execute format('
insert into upgrade_item(tier, store_item_id, price, claim_boost)
select %1$s, id, tier_%1$s_price, tier_%1$s_claimboost
from storeItems
on conflict(store_item_id, tier) do update set
price = excluded.price,
claim_boost = excluded.claim_boost;
', p_tier);
end;
$$ language plpgsql;
create or replace function copy_staking_source_items()
returns void as $$
begin
insert into staking_source_item(id, name, description, image_name)
select id, name, description, image
from stakingSources
on conflict(id)
do update set
name = excluded.name,
description = excluded.description,
image_name = excluded.image_name;
end;
$$ language plpgsql;
create or replace function copy_game_constants()
returns void as $$
begin
insert into game_constants(key, value)
select key, value from gameConstants
on conflict(key) do update set
value = excluded.value;
end;
$$ language plpgsql;
-- Needed to load the python3 extension
create or replace trusted language plpython3u;
-- If you don't grant this permission then devs will not be able to update this function
-- It's dangerous though, I guess
update pg_language set lanpltrusted = true where lanname = 'plpython3u';
create or replace function download_sheets()
returns void as $$
import requests
import tempfile
import os
# Download CSV file
sheet_names = [("storeItems", "0"),("stakingSources","1898988196"), ("gameConstants","583419882") ]
spread_sheet_id = "1TitHE6rHFgmaRlBBLtMLarfNtp1UMzvRur4ZJ-PKhr0"
temp_files = []
for (sheet_name,sheet_id) in sheet_names:
url = ("https://docs.google.com/spreadsheets/d/" +
spread_sheet_id +
"/export?format=csv&gid=" +
sheet_id)
response = requests.get(url)
response.raise_for_status()
# Save CSV to tmp directory file
with open(f'/tmp/{sheet_name}.csv', 'w') as f:
f.write(response.content.decode('utf-8'))
$$ language plpython3u;
@@ -0,0 +1,3 @@
-- Game constants and moon types are now read from config/game.json at runtime.
drop table if exists game_constants;
drop table if exists staking_source_item;
-4
View File
@@ -1,4 +0,0 @@
-- Allow players to own several copies of the same tool.
-- Run once against existing databases (fresh setups already get this from tables.sql),
-- then reload sql/procedures.sql for the updated purchase_item().
alter table inventory_item drop constraint if exists uk_user_store_item;
+6
View File
@@ -0,0 +1,6 @@
-- Procedural moon settings tuned on /utils/moons (one row).
create table if not exists moon_settings (
id integer primary key default 1 check (id = 1),
settings jsonb not null,
updated_at timestamp with time zone not null default now()
);
+11
View File
@@ -0,0 +1,11 @@
-- Live game-config overrides edited on /utils/economy. Each save adds a row; the latest
-- one is in effect, merged over config/game.json.
create table if not exists config_override (
id serial primary key,
data jsonb not null,
note text not null default '',
created_at timestamp with time zone not null default now()
);
-- The admin who saved the version (added later; null for older rows).
alter table config_override add column if not exists created_by uuid references users(id) on delete set null;
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more