Compare commits

...
119 Commits
Author SHA1 Message Date
JosephFerano e267992615 Tool art: keep the tier ring and tops inside the deploy card crop 2026-10-19 22:37:26 +07:00
JosephFerano fa5fc46cb8 AGENTS.md: tool art script 2026-10-19 22:34:16 +07:00
JosephFerano 7af1c5a821 Show each tool's tier art in Equipment, deploy cards, drill lines and the API
StoreItem.ImageAt picks <image>-tN, falling back to the base image when a tier has
no file. API: state.tools and equipment rows use it; catalog tiers get an image.
TestAPI checks every tool has an image at every tier.
2026-10-19 22:33:44 +07:00
JosephFerano fcec292660 Tool art: generated drill images per tool and tier
scripts/tool-art.mjs draws each store item as an isometric SVG (own silhouette and
color per tool; tiers grow the rig, add parts, light a ring of tier segments and
strengthen the glow) and rasterizes it to web/static/assets/items. Tier 0 keeps the
existing file names; tier N adds -tN before the extension.
2026-10-19 22:33:44 +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
JosephFerano fddb417095 Merge remote-tracking branch 'origin/main' 2026-10-17 16:39:53 +07:00
JosephFerano 39e364bf10 Prepare for merge 2026-10-17 16:39:42 +07:00
257 changed files with 33648 additions and 12583 deletions

No files matched your search

+4 -4
View File
@@ -1,9 +1,9 @@
.git .git
.next bin
node_modules node_modules
.env .env
.env* .env*
database.db
npm-debug.log*
.DS_Store
.vscode .vscode
.DS_Store
web/static/css/app.css
art
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+16 -37
View File
@@ -1,46 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Build output
/bin/
/moonminers
/web/static/css/app.css
# dependencies # Local env files (never commit)
/node_modules .env
/.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
.env*.local .env*.local
# vercel # Editors and OS
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vscode
.vscode .vscode
.DS_Store
database.db # Personal files
.env
/sql/create-users.sql /sql/create-users.sql
/.dir-locals.el /.dir-locals.el
sql/queries.sql sql/queries.sql
/project.todo /project.todo
# Blender files (generated by art/equipment scripts)
*.blend
*.blend1
/art/equipment/build/
/art/advisors/build/
/art/attachments/build/
+172 -53
View File
@@ -1,74 +1,193 @@
# AGENTS.md — Moon Miners # AGENTS.md — Moon Miners
## What this is ## What this is
Moon Miners is a browser idle/mining game (package name `mooner`). Players join anonymously: Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a
the client calls `POST /api/user/anonymous` and stores the user id in `localStorage`. Players player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with
own moons ("staking sources") with resource wells, buy and upgrade mining tools (store resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
items) and stake them on wells. When a stake finishes they claim resources (Sollux, stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
Shadowstone, Azurium, Novafor, Nebulance), which they can sell into a bank account claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
balance. There is also a leaderboard. 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 ## Stack
- Next.js 13.1 (experimental `appDir`) + TypeScript 4.9 (strict) + three.js 0.170. - Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
three is pinned at 0.170 because newer `@types/three` needs TypeScript 5. package at the repo root; templates, static files and SQL are embedded with `go:embed`.
- The game UI is plain three.js + DOM in `src/game`; `src/app/page.tsx` only mounts it. - The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
The API lives in the Pages Router (`src/pages/api`). (idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
- Postgres via `pg` (`postgresConnection` in `db.ts`). Most game logic is written as state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
PL/pgSQL functions, and the API routes are thin wrappers that run `SELECT * FROM fn(...)`. - three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
- SQLite (`dbConnection`, `database.db`) is legacy. Only `api/seed.ts`, `api/drop.ts` and The viewport in the aside shows the selected moon, generated procedurally from its id
`add-balance` still use it. Don't build anything new on it. (`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 ## Layout
- `src/game/Game.ts`: controller. It creates/reuses the anonymous player, loads state from - The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
the API, and handles HUD actions and 3D picks. (`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
- `src/game/MoonScene.ts`: three.js scene (moons, well crystals, drills, progress rings, - `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
camera focus, raycast picking). It's rebuilt from state on every `sync()`. loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
- `src/game/Hud.ts`: DOM overlay (top bar, side panels, hints, toasts). It uses event - `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
delegation via `data-action` attributes. Styles are in `src/app/globals.css` (plain CSS). reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
- `src/game/api.ts` (typed fetch client) and `src/game/state.ts` (GameState plus helpers). `GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
- `src/app/utils/page.tsx`: admin utilities (clear data, import config). empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
- `src/pages/api/**`: REST endpoints. Per-user routes are under `user/[userId]/`. owned for the moon price and well scale, and can't be sold.
- `src/utils/helpers.ts`: time and formatting helpers. `src/utils/gameLogic.ts` exposes `gameConfig`. - `views.go`: the `View` (section, moon, well, tab; carried in the URL as
- `typings.d.ts` (repo root, imported as `"typings"`): shared interfaces (`IStake`, `/?s=&moon=&well=`), the `Page` view model, formatting helpers.
`IStoreItem`, `IGameConfig`, ...). - `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
- `config/game-config.json`: static game config. `next.config.js` injects it as `process.env.gameConfig`. limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
- `sql/tables.sql`: schema. `sql/procedures.sql`: game functions (create_user, stake, claim, (htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
purchase_item, upgrade_item, sell_resources, get_leaderboard, ...). `sql/import-config.sql`: action and responds the same way.
`import_config()`, which pulls store/staking config from a Google Sheet into the DB. - `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
`sql/data.sql`: seed data. models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
- `scripts/import-game-config.py`: dumps the config Google Sheet as CSV (debug helper). `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 and buy
seat rules, Advisors section. Tables `advisor_hire`, `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`).
- `scripts/tool-art.mjs`: draws the tool images (`web/static/assets/items`, one per tool and
tier: `<image>-tN`, `StoreItem.ImageAt`); rerun it after adding a tool or tier (needs
rsvg-convert and ImageMagick). A new tool needs a drawing there.
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–015):
changes for existing ones, applied on every `migrate`, so each must be idempotent.
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format. - `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
- `public/assets/`: moon and item images.
## Commands ## Commands
- `npm run dev`: dev server on :3000 (the Dockerfile also runs `dev`, on Node 16). - `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind watching.
- `npm run lint`: next lint (`next/core-web-vitals`). - `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
- `npm run build`: type-check and build. There is no test suite, so lint and build are the checks. moon traits check), `make migrate`, `make load-config`. `go run . sim` prints the economy analysis.
Don't build while `npm run dev` is running (they share `.next`). - Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
- Local DB: `docker compose up -d db` (see README). 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 ## Deployment
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app` (root `Dockerfile`), - Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
`db` (`docker/db/Dockerfile`, with the SQL baked in for first-time init), and `db-update` Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
(`docker/db/update.sh`, which runs on every deploy before the app starts).
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent - 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 `sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
reloaded automatically on deploy. 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). - Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
## Environment ## Environment
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, 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 ## Conventions
- Put game rules in SQL functions (`sql/procedures.sql`) and keep API routes thin. - Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
If you change a function's signature, update the route that calls it and the matching type in `typings.d.ts`. - Layout: header, moon list, main area (one section at a time), and an aside with the 3D
- API route pattern: branch on `req.method`, return 200 with JSON, 400 with viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
`{ error }` for DB/business errors, and 500 for unexpected errors. the address bar in sync.
- Use the `@/*` alias for `src/*`. Import shared types from `"typings"`. - New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
- Client components need `"use client"`. Game UI is not React: add HUD markup in `Hud.ts`, template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
handle the action in `Game.handleAction`, and dispose any three.js geometry and materials you create. `/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
- SQL is lowercase, with snake_case identifiers and `p_`-prefixed params. Schema changes go in `sql/tables.sql`. so action parameters must use other names. Give it a sound in fx.js if it needs one.
There is no migration tool, so mention any manual DB steps you need. - 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`. - 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. - Keep changes small. Don't refactor or move files unless asked.
+17 -30
View File
@@ -1,33 +1,20 @@
# Production image for the Next.js app (game UI + API routes). # Production image: one Go binary with the templates, static files and SQL embedded.
FROM node:20-bookworm-slim AS deps FROM docker.io/library/golang:1.25-bookworm AS build
WORKDIR /app WORKDIR /src
COPY package.json package-lock.json ./ ARG TAILWIND_VERSION=v4.1.14
RUN npm ci RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
FROM node:20-bookworm-slim AS prod-deps && chmod +x /usr/local/bin/tailwindcss
WORKDIR /app COPY go.mod go.sum ./
COPY package.json package-lock.json ./ RUN go mod download
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
COPY . . COPY . .
RUN npm run build RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
FROM node:20-bookworm-slim FROM scratch
WORKDIR /app COPY --from=build /moonminers /moonminers
ENV NODE_ENV=production \ USER 65532:65532
NEXT_TELEMETRY_DISABLED=1 \ ENV PORT=3000
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
EXPOSE 3000 EXPOSE 3000
CMD ["node_modules/.bin/next", "start"] ENTRYPOINT ["/moonminers"]
CMD ["serve"]
+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.
+33
View File
@@ -0,0 +1,33 @@
TAILWIND_VERSION := v4.1.14
TAILWIND := bin/tailwindcss
CSS := web/static/css/app.css
.PHONY: build dev css migrate load-config check
build: css
go build -o bin/moonminers .
# Server on :3000 that reloads templates and static files from disk; CSS rebuilds on change.
dev: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
trap 'kill $$!' EXIT; MM_DEV=1 go run . serve
css: $(TAILWIND)
$(TAILWIND) -i web/css/app.css -o $(CSS) --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
$(TAILWIND):
mkdir -p bin
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
chmod +x $@
+209 -104
View File
@@ -2,146 +2,251 @@
A browser mining game. Players own moons (staking sources) with resource wells, buy and 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 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 resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
Next.js 13 (TypeScript), on top of Postgres, where most game logic lives as PL/pgSQL functions. 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 It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
(`POST /api/user/anonymous`) and keeps its id in `localStorage`. No wallet or sign-up needed. 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 ## Requirements
- Node.js 16+ and npm - Go 1.25+
- PostgreSQL 13+ (uses `gen_random_uuid()`) with the `plpython3u` language available and the - PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
Python `requests` package installed for the server's Python (needed by `import_config()`) - Node.js, only for `make check` (syntax-checks the JS)
- A Postgres superuser for the one-time database setup
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
## Environment ## 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 ```dotenv
DB_USER= DB_USER=moonminers # app user: read/write access to the tables
DB_PASSWORD=
DB_HOST=
DB_PORT=
DB_DATABASE=
```
## 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_PASSWORD=moonminers
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=moonminers DB_DATABASE=moonminers
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
ADMIN_DB_PASSWORD=postgres
``` ```
2. Start the database and import the game config (needs internet, fetches the Google Sheet): 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
1. Create `.env` as above.
2. Start Postgres and set up the database (roles, schema, migrations, store items from
`config/game.json`):
```bash ```bash
docker compose up -d db podman-compose up -d db # or: docker compose up -d db
docker compose exec db psql -U moonminers -d moonminers -c "select import_config();" make migrate
``` ```
3. Run the app and open http://localhost:3000. A new anonymous player is created 3. Run the server and open http://localhost:3000:
automatically; "Start a new game" in the help panel (`?`) switches to a fresh one.
```bash ```bash
npm install make dev
npm run dev
``` ```
Other commands: `npm run lint`, `npm run build` (type-check + production build). Don't run Template, CSS and JS changes show up on reload; Go changes need a restart.
`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). Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
Reset the database (re-runs the SQL files): `docker compose down -v && docker compose up -d db --build`. check in `templates_test.go`, which renders every page against sample game states without a
Apply new migrations to an existing local database: database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
`docker compose exec -T db psql -U postgres -d moonminers -f - < sql/migrations/<file>.sql`. `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) ## 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. First deploy:
- `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.
Steps in Dokploy: 1. Dokploy: create a **Docker Compose** service from this repository, compose path
1. Create a **Docker Compose** service from this Git repository and set the compose path to
`docker-compose.prod.yml`. `docker-compose.prod.yml`.
2. In **Environment**, set two strong passwords: 2. **Environment**:
```dotenv ```dotenv
POSTGRES_PASSWORD=... # Postgres superuser (used by db-update) POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
DB_PASSWORD=... # app user "moonminers" 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 Never set `MM_DEV` in production: it makes every player an admin.
HTTPS if you want Let's Encrypt. 3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
4. Deploy. The first deploy initializes the database and imports the config. The database 4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
server needs outbound internet access to fetch the Google Sheet. 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 Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
survives redeploys. Back it up with `pg_dump` or Dokploy's volume backups. Back up `db-data` 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.
To test the production stack locally: 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). (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: See `test-endpoints.restclient` (Emacs restclient format).
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.
+186
View File
@@ -0,0 +1,186 @@
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). Each
// advisor takes a seat: players start with one and buy more (advisors.seatPrices), like
// refinery slots. Tables advisor_hire 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
}
// hireAdvisor hires an advisor into a 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, err := lockAdvisors(ctx, tx, userID)
if err != nil {
return err
}
var hired int
var already bool
err = tx.QueryRow(ctx, `
select count(*), coalesce(bool_or(advisor = $2), false) from advisor_hire where user_id = $1`,
userID, a.Name).Scan(&hired, &already)
if err != nil {
return err
}
if already {
return gameErr("%s already works for you", a.Name)
}
if hired >= seats {
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) values ($1, $2)", userID, a.Name)
if err != nil {
return err
}
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price})
})
}
// 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, Hired int
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
}
type AdvisorCard struct {
*Advisor
Hired bool
CanAfford bool
}
func (s *State) advisorsPage() *AdvisorsPage {
c := cfg()
p := &AdvisorsPage{Seats: s.AdvisorSeats, Hired: len(s.Advisors), 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
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
for _, h := range s.Advisors {
card.Hired = card.Hired || h == a.Name
}
p.Cards = append(p.Cards, card)
}
return p
}
// SeatFree reports whether the player has an empty advisor seat.
func (p *AdvisorsPage) SeatFree() bool { return p.Hired < p.Seats }
// 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 ""
}
+820
View File
@@ -0,0 +1,820 @@
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
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"`
}
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
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"`
}
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
}
// 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}
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},
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.ImageAt(t.Tier), Tier: t.Tier, MaxTier: len(item.Upgrades),
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
if st := s.StakeForTool(t.ID); st != nil {
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)}
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()}
// 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, err := loadState(ctx, s.db, userID)
if err != nil {
return nil, err
}
page, err := s.apiPageFor(ctx, state, v.resolve(state))
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, err := loadState(ctx, s.db, userID)
if err != nil {
apiServerError(w, err)
return
}
toast := res.toast(state)
page, err := s.apiPageFor(ctx, state, res.View.resolve(state))
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
}
+463
View File
@@ -0,0 +1,463 @@
# 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), `pick` (market: selected quote), `drill`.
`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 |
| `buy-seat` | – | advisor seat |
| `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, pick, drill}`, resolved |
| `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 |
| `moons`, `past` | `[{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, the tool's art at its tier (`shovel-t2.png` at T2; tier 0 and tiers without art use the base image); `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, start, end, durationSecs, ready, take, covers?}` |
| `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"); `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?}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; `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}], image}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`; a tool's `image` is its tier 0 art, each tier's `image` its art from that tier on); `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.
- `image`: the tool's art at its tier, like `state.tools`.
- `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, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, bonuses, buySeat, buySeatWhyText,
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, canAfford,
canHire, why, whyText}]}
```
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
disabled. An advisor's `why`: `hired` ("On your board"), `seats` ("All seats taken. Buy
another seat first"), `credits` ("Need ₥X more") or `""`.
### 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}]}
```
`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`.
### 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`, `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`).
- well note: "Over-drilled — next claims will come up short", "Fully committed", "X left · Y
committed · runs dry after N claims" / "Z free after" (`demand`, `remain`, `dryAfter`).
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
current drills" (`well.idle[].short`, `here`, `free`).
- market: change ("+1.25%"), pressure ("−12%"), boost ("×2"), advisor ("+10%").
- 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, "pick": "", "drill": ""},
"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": [],
"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": "7d983412e77a3dde"
}
}
```
## 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-t1.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
"price": 4000, "resale": 2000}],
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
"ladder": ["…"], "gear": ["…"]}}
```
`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": 1, "hired": 0, "maxSeats": 4, "nextSeat": 2, "nextSeatPrice": 10000, "canBuySeat": true, "seatFree": true,
"bonuses": [],
"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}, "hired": false, "canAfford": true,
"canHire": true}, "…"]}
```
`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}, "…"]}
```
`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"]}, "…"]}
```
+977
View File
@@ -0,0 +1,977 @@
package main
// JSON API, the sections beyond the core loop (see api.go, api.md): equipment and the drill
// panel, store, refinery, advisors, leaderboard, help, the new-moon teaser and reveal, the
// market chart, the moon sale, bonuses, the catalog of assets and the rules a client needs
// to predict results. Every block maps a view model the templates use; no game rules here.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"strings"
)
// Image URLs, as the templates build them.
const (
itemImages = "/static/assets/items/"
attachmentImages = "/static/assets/attachments/"
advisorImages = "/static/assets/advisors/"
)
// ------------------------------------------------------------------ always present
// APICatalog is the static game data: every tool, attachment, advisor, resource and
// refined item with its image and color.
type APICatalog struct {
Tools []APICatalogTool `json:"tools"`
Attachments []APICatalogAttachment `json:"attachments"`
Advisors []APICatalogAdvisor `json:"advisors"`
Resources []APICatalogResource `json:"resources"`
Refined []APICatalogRefined `json:"refined"`
Reach []APIReachText `json:"reach"` // the web's wording of a reach, 1 up to the whole moon
}
// APIReachText is how the web words a tool's reach (wells one cycle mines): label on cards
// ("Mines 3 wells", "Strips the whole moon"; "" for one), short on deploy cards ("3 wells",
// "whole moon"; "" for one), wells in the tier ladder ("1 well", "whole moon"). A reach at or
// above the last entry's is the whole moon.
type APIReachText struct {
Reach int `json:"reach"`
Label string `json:"label"`
Short string `json:"short"`
Wells string `json:"wells"`
}
type APICatalogTool struct {
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"` // e.g. "PD-1"
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Yield float64 `json:"yield"` // base, tier 0
CycleSecs int `json:"cycleSecs"` // base
Reach int `json:"reach"` // tier 0
Tiers []APITierUp `json:"tiers"` // upgrades, tier 1 on
}
type APITierUp struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
ClaimBoost float64 `json:"claimBoost"`
Reach int `json:"reach"` // wells per cycle from this tier on
Parts []APIAmount `json:"parts"` // refined items it uses up
Image string `json:"image"` // the tool's image at this tier
}
type APIEffect struct {
Speed float64 `json:"speed"` // multipliers, 1 = none
Yield float64 `json:"yield"`
Sell float64 `json:"sell,omitempty"`
Refine float64 `json:"refine,omitempty"`
}
type APICatalogAttachment struct {
Name string `json:"name"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"` // e.g. "+20% speed"
}
type APICatalogAdvisor struct {
Name string `json:"name"`
Title string `json:"title"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"` // e.g. "+8% sell prices"
}
type APICatalogResource struct {
Name string `json:"name"`
Color string `json:"color"`
Price float64 `json:"price"` // base price
Symbol string `json:"symbol"`
}
type APICatalogRefined struct {
Name string `json:"name"`
Tier int `json:"tier"`
Color string `json:"color"`
Price float64 `json:"price"` // base price
Seconds int `json:"seconds"` // per unit, batch of one
Inputs []APIAmount `json:"inputs"` // per unit
Description string `json:"description"`
Symbol string `json:"symbol"`
}
func colorOf(name string) string {
if c, ok := resourceColors[name]; ok {
return c
}
return refinedColor(name)
}
func effectOf(speed, yield, sell, refine float64) APIEffect {
e := APIEffect{Speed: mult(speed), Yield: mult(yield)}
if sell != 0 {
e.Sell = sell
}
if refine != 0 {
e.Refine = refine
}
return e
}
func apiNeed(need map[string]float64) []APIAmount {
out := []APIAmount{}
for _, name := range sortedKeys(need) {
out = append(out, APIAmount{Name: name, Amount: need[name]})
}
return out
}
func apiCatalog(s *State) *APICatalog {
c := cfg()
cat := &APICatalog{Tools: []APICatalogTool{}, Attachments: []APICatalogAttachment{}, Advisors: []APICatalogAdvisor{},
Resources: []APICatalogResource{}, Refined: []APICatalogRefined{}, Reach: []APIReachText{}}
for i := range s.Config.StoreItems {
item := &s.Config.StoreItems[i]
t := APICatalogTool{StoreItemID: item.ID, Name: item.Name, Code: toolCode(item.Name), Image: itemImages + item.Image,
Description: item.Description, Price: item.Price, Yield: item.ClaimAmount, CycleSecs: item.CompletionTimeInSecs,
Reach: max(1, item.ToolReach(0)), Tiers: []APITierUp{}}
for _, u := range item.Upgrades {
t.Tiers = append(t.Tiers, APITierUp{Tier: u.Tier, Price: u.Price, ClaimBoost: u.ClaimBoost,
Reach: max(1, item.ToolReach(u.Tier)), Parts: apiNeed(upgradeParts(item.ID, u.Tier)),
Image: itemImages + item.ImageAt(u.Tier)})
}
cat.Tools = append(cat.Tools, t)
}
for i := range c.Attachments.Items {
a := &c.Attachments.Items[i]
cat.Attachments = append(cat.Attachments, APICatalogAttachment{Name: a.Name, Image: attachmentImages + a.Image,
Description: a.Description, Price: a.Price, Resale: a.Resale(), Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
for i := range c.Advisors.Items {
a := &c.Advisors.Items[i]
cat.Advisors = append(cat.Advisors, APICatalogAdvisor{Name: a.Name, Title: a.Title, Image: advisorImages + a.Image,
Description: a.Description, Price: a.Price, Effect: effectOf(a.Speed, a.Yield, mult(a.Sell), mult(a.Refine)),
EffectText: a.Effect()})
}
for _, name := range sortedKeys(c.ResourcePrices) {
cat.Resources = append(cat.Resources, APICatalogResource{Name: name, Color: colorOf(name),
Price: c.ResourcePrices[name], Symbol: marketSymbol(name)})
}
for i := range c.Refinery.Items {
r := &c.Refinery.Items[i]
cat.Refined = append(cat.Refined, APICatalogRefined{Name: r.Name, Tier: r.Tier, Color: r.Color, Price: r.Price,
Seconds: r.Seconds, Inputs: apiNeed(r.Inputs), Description: r.Description, Symbol: marketSymbol(r.Name)})
}
for reach := 1; reach <= max(1, len(c.ResourcePrices)); reach++ {
short := ""
if reach > 1 {
short = wellsText(reach)
}
cat.Reach = append(cat.Reach, APIReachText{Reach: reach, Label: reachLabel(reach), Short: short, Wells: wellsText(reach)})
}
return cat
}
// version is a short hash of the catalog (catalogVersion): it changes when the config does.
func (c *APICatalog) version() string {
b, err := json.Marshal(c)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
// APIConfirm is a confirm dialog the web shows before an action (hx-confirm): its title,
// question and OK button; danger marks a destructive one.
type APIConfirm struct {
Title string `json:"title"`
Text string `json:"text"`
OK string `json:"ok"`
Danger bool `json:"danger"`
}
// APIRules are the numbers a client needs to predict results (api.md, Prediction).
type APIRules struct {
WellSupplyMax float64 `json:"wellSupplyMax"` // config max: a full reveal bar is this × the well scale
MaxSupply float64 `json:"maxSupply"` // the scale of the web's supply bars
SlotsPerTool int `json:"slotsPerTool"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
Pressure APIPressure `json:"pressure"`
Refinery APIRefineryRules `json:"refinery"`
MoonResale MoonResale `json:"moonResale"`
EquipmentResale float64 `json:"equipmentResale"` // factor of the credit price
BonusCaps BonusCaps `json:"bonusCaps"`
Bonus APIEffect `json:"bonus"` // the advisors' bonus now, capped (tools add attachments)
Boosts map[string]APIMap `json:"boosts"` // live event multipliers: price, cycle, yield, by resource or "all"
}
type APIMap = map[string]float64
type APIPressure struct {
Floor float64 `json:"floor"`
Scale float64 `json:"scale"`
HalfLifeSecs float64 `json:"halfLifeSecs"`
Cap float64 `json:"cap"` // stored pressure is capped at this
}
type APIRefineryRules struct {
BulkExponent float64 `json:"bulkExponent"`
MaxBatch int `json:"maxBatch"`
Speed float64 `json:"speed"` // advisors' refine bonus × the cycle event for "all"
}
func apiRules(p *Page) *APIRules {
s, c := p.S, cfg()
b := s.Bonus()
lo, hi := wellCountRange()
r := &APIRules{WellSupplyMax: c.WellSupplyMax, MaxSupply: p.MaxSupply, SlotsPerTool: c.Attachments.SlotsPerTool,
WellsMin: lo, WellsMax: hi,
Pressure: APIPressure{Floor: c.MarketPressure.Floor, Scale: c.MarketPressure.Scale,
HalfLifeSecs: c.MarketPressure.HalfLifeSecs, Cap: pressureCap * c.MarketPressure.Scale},
Refinery: APIRefineryRules{BulkExponent: c.Refinery.BulkExponent, MaxBatch: c.Refinery.MaxBatch,
Speed: b.Refine * s.CycleBoosts.For("all")},
MoonResale: c.MoonResale, EquipmentResale: c.EquipmentResale.Factor, BonusCaps: c.BonusCaps,
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield, Sell: b.Sell, Refine: b.Refine},
Boosts: map[string]APIMap{"price": boostMap(s.PriceBoosts), "cycle": boostMap(s.CycleBoosts), "yield": boostMap(s.YieldBoosts)}}
return r
}
func boostMap(b PriceBoosts) APIMap {
m := APIMap{}
for k, v := range b {
m[k] = v
}
return m
}
// APIBonusRow is an active advisor bonus (the web's bonus chips).
type APIBonusRow struct {
Kind string `json:"kind"` // speed, yield, sell, refine
Mult float64 `json:"mult"`
Capped bool `json:"capped"`
Label string `json:"label"` // the chip's text: "Drill speed", "+10%"
Text string `json:"text"`
}
func apiBonusRows(b Bonus) []APIBonusRow {
caps := cfg().BonusCaps
out := []APIBonusRow{}
for _, r := range []struct {
kind, label string
v, cap float64
}{{"speed", "Drill speed", b.Speed, caps.Speed}, {"yield", "Yield", b.Yield, caps.Yield},
{"sell", "Sell prices", b.Sell, caps.Sell}, {"refine", "Refinery speed", b.Refine, caps.Refine}} {
if r.v > 1 {
out = append(out, APIBonusRow{Kind: r.kind, Mult: r.v, Capped: r.v >= r.cap, Label: r.label, Text: pctText(r.v)})
}
}
return out
}
// APIJob is a refinery batch (always in the state once the refinery is open: the nav badge).
type APIJob struct {
ID string `json:"id"`
Slot int `json:"slot"`
Item string `json:"item"`
Qty int `json:"qty"`
Start int64 `json:"start"`
End int64 `json:"end"`
Ready bool `json:"ready"`
Progress float64 `json:"progress,omitempty"` // 0..1, refinery section only
}
func apiJob(j RefineryJob, now int64) APIJob {
return APIJob{ID: j.ID, Slot: j.Slot, Item: j.Item, Qty: j.Qty, Start: j.StartedAt.UnixMilli(),
End: j.EndsAt.UnixMilli(), Ready: j.EndsAt.UnixMilli() <= now}
}
// APILiveEvent is an active live event (the banner shows its headline).
type APILiveEvent struct {
ID int `json:"id"` // banner items of kind event carry it
Name string `json:"name"`
Kind string `json:"kind"` // price_multiplier, cycle_multiplier, yield_multiplier
Resource string `json:"resource"`
Multiplier float64 `json:"multiplier"`
Start int64 `json:"start"`
End int64 `json:"end"`
Headline string `json:"headline"`
}
// ------------------------------------------------------------------ equipment
type APIEquipment struct {
Tools []APIToolRow `json:"tools"` // copies of a tool (same type, tier, attachments) grouped
Drill *APIDrillPanel `json:"drill"` // drill=<tool id>
}
type APIToolRow struct {
ID string `json:"id"` // the copy shown (selected, else an idle one, else the first to finish)
StoreItemID int `json:"storeItemId"`
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
Tier int `json:"tier"`
MaxTier int `json:"maxTier"`
Count int `json:"count"`
Idle int `json:"idle"`
Reach int `json:"reach"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Attachments []string `json:"attachments"`
Slots int `json:"slots"`
FreeSlots int `json:"freeSlots"`
Selected bool `json:"selected"`
Stake *APIStake `json:"stake"` // the shown copy's drill
Next *APINextTier `json:"next"` // null at the max tier
Preview *APIPreview `json:"preview"`
Ladder []APITierRow `json:"ladder"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"` // only-tool, claim-first, mining; "" can sell
NoSaleText string `json:"noSaleText"`
Bonus APIEffect `json:"bonus"` // advisors and its attachments, capped
Gear []APIGearDetail `json:"gear"`
}
type APIGearDetail struct {
Name string `json:"name"`
Image string `json:"image"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
}
type APINextTier struct {
Tier int `json:"tier"`
Price float64 `json:"price"`
Parts []APIPart `json:"parts"`
CanAfford bool `json:"canAfford"` // credits and parts
Blocked string `json:"blocked"` // credits, parts; "" when it can be bought
Short float64 `json:"short"` // credits missing
// The web's texts: why the upgrade button is disabled (its tooltip) and the line under it
// when credits are short ("Need ₥X more").
BlockedText string `json:"blockedText"`
ShortText string `json:"shortText"`
}
type APIPart struct {
Name string `json:"name"`
Need float64 `json:"need"`
Have float64 `json:"have"`
}
type APIPreview struct {
Tier int `json:"tier"`
YieldFrom float64 `json:"yieldFrom"`
YieldTo float64 `json:"yieldTo"`
SecsFrom int `json:"secsFrom"`
SecsTo int `json:"secsTo"`
ReachFrom int `json:"reachFrom"`
ReachTo int `json:"reachTo"`
RateFrom float64 `json:"rateFrom"` // credits per hour
RateTo float64 `json:"rateTo"`
Cost float64 `json:"cost"` // price plus the parts at sell value
PaybackSecs *float64 `json:"paybackSecs"` // null if it never pays back
}
type APITierRow struct {
Tier int `json:"tier"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour
Current bool `json:"current"`
Next bool `json:"next"`
}
type APIDrillPanel struct {
ToolID string `json:"toolId"`
Slots []APISlot `json:"slots"`
Spares []APISpare `json:"spares"`
Mining bool `json:"mining"`
Resale float64 `json:"resale"`
NoSale string `json:"noSale"`
NoSaleText string `json:"noSaleText"`
Confirm APIConfirm `json:"confirm"` // before sell-tool
IdleSame int `json:"idleSame"` // while it mines: idle tools of its type
IdleBest string `json:"idleBest"` // the one pickTool would deploy
DeployTo *APISuggestion `json:"deployTo"` // its best well
Ladder []APITierRow `json:"ladder"`
Gear []APIGearDetail `json:"gear"`
}
type APISlot struct {
N int `json:"n"`
Attachment string `json:"attachment"` // "" empty; unequip with item=<name>&to=<toolId>
}
type APISpare struct {
Name string `json:"name"`
Count int `json:"count"`
CycleSecs int `json:"cycleSecs"` // the drill's stats with it equipped
Yield float64 `json:"yield"`
CanEquip bool `json:"canEquip"`
Why string `json:"why"` // fitted, full
WhyText string `json:"whyText"`
Price float64 `json:"price"`
Resale float64 `json:"resale"`
Confirm APIConfirm `json:"confirm"` // before sell-attachment
}
func apiTierRows(rows []TierRow) []APITierRow {
out := []APITierRow{}
for _, r := range rows {
out = append(out, APITierRow{Tier: r.Tier, CycleSecs: r.Secs, Yield: r.Yield, Reach: r.Reach, Rate: r.Rate,
Current: r.Current, Next: r.Next})
}
return out
}
func apiGearDetail(gear []*Attachment) []APIGearDetail {
out := []APIGearDetail{}
for _, a := range gear {
out = append(out, APIGearDetail{Name: a.Name, Image: attachmentImages + a.Image, Effect: effectOf(a.Speed, a.Yield, 0, 0),
EffectText: a.Effect()})
}
return out
}
func apiEquipment(p *Page) *APIEquipment {
s := p.S
e := &APIEquipment{Tools: []APIToolRow{}}
for _, row := range p.Equipment {
b := s.toolBonus(row.ID)
t := APIToolRow{ID: row.ID, StoreItemID: row.StoreItemID, Name: row.Item.Name, Code: toolCode(row.Item.Name),
Image: itemImages + row.Item.ImageAt(row.Tier), Tier: row.Tier, MaxTier: row.MaxTier, Count: row.Count, Idle: row.Idle,
Reach: max(1, row.Reach), CycleSecs: row.Secs, Yield: row.Yield, Attachments: apiGear(row.Gear),
Slots: len(row.Gear) + row.FreeSlots, FreeSlots: row.FreeSlots, Selected: row.Selected,
Ladder: apiTierRows(row.Ladder), Resale: row.Item.ToolResale(row.Tier), NoSale: s.noSale(row),
NoSaleText: noSaleText[s.noSale(row)],
Bonus: APIEffect{Speed: b.Speed, Yield: b.Yield}, Gear: apiGearDetail(row.Gear)}
if row.Stake != nil {
st := apiStake(*row.Stake, false)
t.Stake = &st
}
if n := row.Next; n != nil {
next := &APINextTier{Tier: n.Tier, Price: n.Price, Parts: []APIPart{}, CanAfford: row.CanAfford,
Short: max(0, n.Price-s.Balance), BlockedText: row.Missing, ShortText: row.Short}
for _, part := range row.Parts {
next.Parts = append(next.Parts, APIPart{Name: part.Name, Need: part.Need, Have: part.Have})
if !part.OK() && next.Blocked == "" && next.Short == 0 {
next.Blocked = "parts"
}
}
if next.Short > 0 {
next.Blocked = "credits"
}
t.Next = next
}
if u := row.Preview; u != nil {
pv := &APIPreview{Tier: u.Tier, YieldFrom: u.YieldFrom, YieldTo: u.YieldTo, SecsFrom: u.SecsFrom, SecsTo: u.SecsTo,
ReachFrom: max(1, u.ReachFrom), ReachTo: max(1, u.ReachTo), RateFrom: u.RateFrom, RateTo: u.RateTo, Cost: u.Cost}
if gain := u.RateGain(); gain > 0 {
secs := u.Cost / gain * 3600
pv.PaybackSecs = &secs
}
t.Preview = pv
}
e.Tools = append(e.Tools, t)
}
if d := p.Drill; d != nil {
dp := &APIDrillPanel{ToolID: d.ID, Slots: []APISlot{}, Spares: []APISpare{}, Mining: d.Mining, Resale: d.Resale,
NoSale: s.noSale(d.ToolRow), NoSaleText: d.NoSale, IdleSame: d.IdleSame, Ladder: apiTierRows(d.Ladder),
Gear: apiGearDetail(d.Gear),
Confirm: APIConfirm{Title: "Sell drill", Text: d.SellConfirm(), OK: "Sell for " + formatCredits(d.Resale), Danger: true}}
for _, slot := range d.Slots {
a := APISlot{N: slot.N}
if slot.Gear != nil {
a.Attachment = slot.Gear.Name
}
dp.Slots = append(dp.Slots, a)
}
for _, sg := range d.Spares {
sp := APISpare{Name: sg.Name, Count: sg.Count, CycleSecs: sg.Secs, Yield: sg.Yield, CanEquip: sg.CanEquip,
WhyText: sg.Why, Price: sg.Price, Resale: sg.Resale(),
Confirm: APIConfirm{Title: "Sell " + sg.Name, Text: sg.Confirm(), OK: "Sell for " + formatCredits(sg.Resale()), Danger: true}}
switch {
case sg.CanEquip:
case strings.HasPrefix(sg.Why, "Already"):
sp.Why = "fitted"
default:
sp.Why = "full"
}
dp.Spares = append(dp.Spares, sp)
}
if d.IdleBest != nil {
dp.IdleBest = d.IdleBest.ID
}
if d.DeployTo != nil {
sg := apiSuggestion(*d.DeployTo)
dp.DeployTo = &sg
}
e.Drill = dp
}
return e
}
// ------------------------------------------------------------------ buy-tool suggestions
// APIBuyModal is what the web's buy modal shows after buy-tool: wells to deploy it on.
type APIBuyModal struct {
ToolID string `json:"toolId"`
StoreItemID int `json:"storeItemId"`
CycleSecs int `json:"cycleSecs"`
Yield float64 `json:"yield"`
Reach int `json:"reach"`
NoMoons bool `json:"noMoons"`
AllEmpty bool `json:"allEmpty"`
Suggestions []APISuggestion `json:"suggestions"` // best first, at most 3
}
type APISuggestion struct {
MoonID string `json:"moonId"`
MoonName string `json:"moonName"`
WellID string `json:"wellId"` // stake with tool=<id>&target=<wellId>
Resource string `json:"resource"`
Supply float64 `json:"supply"`
Credits float64 `json:"credits"` // per cycle at the market price now
PerMin float64 `json:"perMin"`
Idle bool `json:"idle"` // no drill on it yet
Reason string `json:"reason"` // best-value, most-supply, no-drill, shared
ReasonText string `json:"reasonText"`
}
var suggestionReasons = map[string]string{"Highest value": "best-value", "Most supply left": "most-supply",
"No drill here yet": "no-drill", "Shares a well": "shared"}
func apiSuggestion(sg Suggestion) APISuggestion {
return APISuggestion{MoonID: sg.MoonID, MoonName: sg.MoonName, WellID: sg.ID, Resource: sg.Resource, Supply: sg.Supply,
Credits: sg.Credits, PerMin: sg.PerMin, Idle: sg.Idle, Reason: suggestionReasons[sg.Reason], ReasonText: sg.Reason}
}
func apiBuyModal(m *BuyModal) *APIBuyModal {
if m == nil {
return nil
}
a := &APIBuyModal{ToolID: m.ToolID, StoreItemID: m.Item.ID, CycleSecs: m.Secs, Yield: m.Yield, Reach: max(1, m.Reach),
NoMoons: m.NoMoons, AllEmpty: m.AllEmpty, Suggestions: []APISuggestion{}}
for _, sg := range m.Suggestions {
a.Suggestions = append(a.Suggestions, apiSuggestion(sg))
}
return a
}
// ------------------------------------------------------------------ store
type APIStore struct {
Tools []APIStoreTool `json:"tools"`
Attachments []APIStoreGear `json:"attachments"`
}
type APIStoreTool struct {
StoreItemID int `json:"storeItemId"` // buy-tool id=<storeItemId>
Name string `json:"name"`
Code string `json:"code"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
Owned int `json:"owned"`
Yield float64 `json:"yield"` // base, as the card shows
CycleSecs int `json:"cycleSecs"` // base
Reach int `json:"reach"`
Rate float64 `json:"rate"` // credits per hour it would earn, with advisors
BestRate float64 `json:"bestRate"` // the same for the player's best tool
WhyText string `json:"whyText"` // why Buy is disabled ("Need ₥X more"), "" if it isn't
}
type APIStoreGear struct {
Name string `json:"name"` // buy-attachment item=<name>
Image string `json:"image"`
Description string `json:"description"`
Effect APIEffect `json:"effect"`
EffectText string `json:"effectText"`
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
Spare int `json:"spare"`
Equipped int `json:"equipped"`
Resale float64 `json:"resale"`
WhyText string `json:"whyText"` // why Buy is disabled, "" if it isn't
}
// whyShort is the web's tooltip on a Buy button the player can't afford, "" when they can.
func whyShort(canAfford bool, balance, price float64) string {
if canAfford {
return ""
}
return shortBy(price - balance)
}
func apiStore(p *Page) *APIStore {
bal := p.S.Balance
st := &APIStore{Tools: []APIStoreTool{}, Attachments: []APIStoreGear{}}
for _, r := range p.Store {
st.Tools = append(st.Tools, APIStoreTool{StoreItemID: r.Item.ID, Name: r.Item.Name, Code: toolCode(r.Item.Name),
Image: itemImages + r.Item.Image, Description: r.Item.Description, Price: r.Item.Price, CanAfford: r.CanAfford,
Owned: r.Owned, Yield: r.Item.ClaimAmount, CycleSecs: r.Item.CompletionTimeInSecs,
Reach: max(1, r.Item.ToolReach(0)), Rate: r.Rate, BestRate: r.BestRate, WhyText: whyShort(r.CanAfford, bal, r.Item.Price)})
}
for _, g := range p.Gear {
st.Attachments = append(st.Attachments, APIStoreGear{Name: g.Name, Image: attachmentImages + g.Image,
Description: g.Description, Effect: effectOf(g.Speed, g.Yield, 0, 0), EffectText: g.Attachment.Effect(), Price: g.Price,
CanAfford: g.CanAfford, Spare: g.Spare, Equipped: g.Equipped, Resale: g.Resale(), WhyText: whyShort(g.CanAfford, bal, g.Price)})
}
return st
}
// ------------------------------------------------------------------ refinery
type APIRefinery struct {
Slots []APIRefinerySlot `json:"slots"`
SlotCount int `json:"slotCount"`
MaxSlots int `json:"maxSlots"`
NextSlot int `json:"nextSlot"`
NextSlotPrice float64 `json:"nextSlotPrice"` // 0 at the cap
CanBuySlot bool `json:"canBuySlot"`
Free int `json:"free"`
Ready int `json:"ready"`
MaxBatch int `json:"maxBatch"`
Recipes []APIRecipe `json:"recipes"`
Stock []APIAmount `json:"stock"` // refined items owned
BuySlot *APIConfirm `json:"buySlot"` // before buy-slot; null at the cap
BuySlotWhy string `json:"buySlotWhyText"` // why "+ Slot" is disabled, "" if it isn't
}
type APIRefinerySlot struct {
N int `json:"n"`
Job *APIJob `json:"job"` // null: idle
}
type APIRecipe struct {
Item string `json:"item"` // refine recipe=<item>&qty=<n> (or all=1)
Tier int `json:"tier"`
Seconds int `json:"seconds"` // one unit
Price float64 `json:"price"` // base sell price
SellPrice float64 `json:"sellPrice"`
Inputs []APIPart `json:"inputs"` // per unit, with what the player has
InputValue float64 `json:"inputValue"`
Gain float64 `json:"gain"` // percent over inputValue
MaxQty int `json:"maxQty"` // units one batch can make now
BulkQty int `json:"bulkQty"`
BulkSecs int `json:"bulkSecs"` // a batch of bulkQty, with every bonus (see rules.refinery)
BulkBonus float64 `json:"bulkBonus"`
Owned float64 `json:"owned"`
Why string `json:"why"` // busy (no free slot), inputs; "" can refine
WhyText string `json:"whyText"`
}
func apiRefinery(p *Page) *APIRefinery {
s, rp := p.S, p.Refinery
now := s.Now.UnixMilli()
speed := s.Bonus().Refine * s.CycleBoosts.For("all")
r := &APIRefinery{Slots: []APIRefinerySlot{}, SlotCount: rp.SlotCount, MaxSlots: rp.MaxSlots, NextSlot: rp.NextSlot,
NextSlotPrice: rp.NextSlotPrice, CanBuySlot: rp.CanBuySlot, Free: rp.Free, Ready: rp.Ready, MaxBatch: rp.MaxBatch,
Recipes: []APIRecipe{}, Stock: []APIAmount{}}
if rp.NextSlotPrice > 0 {
r.BuySlot = &APIConfirm{Title: "New refinery slot", Text: rp.SlotConfirm(), OK: "Buy slot"}
r.BuySlotWhy = whyShort(rp.CanBuySlot, s.Balance, rp.NextSlotPrice)
}
for _, slot := range rp.Slots {
row := APIRefinerySlot{N: slot.N}
if j := slot.Job; j != nil {
job := apiJob(j.RefineryJob, now)
job.Progress = j.Progress
if j.Ready {
job.Progress = 1
}
row.Job = &job
}
r.Slots = append(r.Slots, row)
}
for _, card := range rp.Recipes {
rc := APIRecipe{Item: card.Item.Name, Tier: card.Item.Tier, Seconds: card.Item.Seconds, Price: card.Item.Price,
SellPrice: s.sellPrice(card.Item.Name) * cfg().MarketPressure.Factor(s.Pressure[card.Item.Name]),
Inputs: []APIPart{}, InputValue: card.InputValue, Gain: card.Gain, MaxQty: card.MaxQty, BulkQty: card.BulkQty,
BulkSecs: int(math.Round(cfg().Refinery.batchSecs(card.Item, card.BulkQty) / speed)),
BulkBonus: card.BulkBonus, Owned: card.Owned}
for _, in := range card.Inputs {
rc.Inputs = append(rc.Inputs, APIPart{Name: in.Name, Need: in.Need, Have: in.Have})
}
switch {
case rp.Free == 0:
rc.Why = "busy"
case card.MaxQty == 0:
rc.Why = "inputs"
}
rc.WhyText = rp.Why(card)
r.Recipes = append(r.Recipes, rc)
}
for _, st := range rp.Stock {
r.Stock = append(r.Stock, APIAmount{Name: st.Item, Amount: st.Balance})
}
return r
}
// ------------------------------------------------------------------ advisors
type APIAdvisors struct {
Seats int `json:"seats"`
Hired int `json:"hired"`
MaxSeats int `json:"maxSeats"`
NextSeat int `json:"nextSeat"`
NextSeatPrice float64 `json:"nextSeatPrice"` // 0 at the cap
CanBuySeat bool `json:"canBuySeat"`
SeatFree bool `json:"seatFree"`
Bonuses []APIBonusRow `json:"bonuses"`
Advisors []APIAdvisorUp `json:"advisors"`
BuySeat *APIConfirm `json:"buySeat"` // before buy-seat; null at the cap
BuySeatWhy string `json:"buySeatWhyText"` // why "+ Seat" is disabled, "" if it isn't
}
type APIAdvisorUp struct {
Name string `json:"name"` // hire advisor=<name>
Title string `json:"title"`
Image string `json:"image"`
Description string `json:"description"`
Price float64 `json:"price"`
Effect APIEffect `json:"effect"`
Hired bool `json:"hired"`
CanAfford bool `json:"canAfford"`
CanHire bool `json:"canHire"` // not hired, a seat free, affordable
EffectText string `json:"effectText"`
Why string `json:"why"` // hired, seats (none free), credits; "" can hire
WhyText string `json:"whyText"`
}
func apiAdvisors(p *Page) *APIAdvisors {
ap := p.Advisors
a := &APIAdvisors{Seats: ap.Seats, Hired: ap.Hired, MaxSeats: ap.MaxSeats, NextSeat: ap.NextSeat,
NextSeatPrice: ap.NextSeatPrice, CanBuySeat: ap.CanBuySeat, SeatFree: ap.SeatFree(),
Bonuses: apiBonusRows(p.S.Bonus()), Advisors: []APIAdvisorUp{}}
if ap.NextSeatPrice > 0 {
a.BuySeat = &APIConfirm{Title: "New seat", Text: ap.SeatConfirm(), OK: "Buy seat"}
a.BuySeatWhy = whyShort(ap.CanBuySeat, p.S.Balance, ap.NextSeatPrice)
}
for _, c := range ap.Cards {
up := APIAdvisorUp{Name: c.Name, Title: c.Title, Image: advisorImages + c.Image,
Description: c.Description, Price: c.Price, Effect: effectOf(c.Speed, c.Yield, mult(c.Sell), mult(c.Refine)),
Hired: c.Hired, CanAfford: c.CanAfford, CanHire: !c.Hired && c.CanAfford && ap.SeatFree(), EffectText: c.Advisor.Effect()}
switch {
case c.Hired:
up.Why, up.WhyText = "hired", "On your board"
case !ap.SeatFree():
up.Why, up.WhyText = "seats", ap.HireWhy(c, p.S.Balance)
case !c.CanAfford:
up.Why, up.WhyText = "credits", ap.HireWhy(c, p.S.Balance)
}
a.Advisors = append(a.Advisors, up)
}
return a
}
// ------------------------------------------------------------------ leaderboard, help, new moon
type APILeaderboard struct {
Players int `json:"players"`
Top []APIRankRow `json:"top"`
You *APIRankRow `json:"you"` // null when the player is in top
Me *APIRankRowShort `json:"me"` // the player's place either way
}
type APIRankRow struct {
Rank int `json:"rank"`
Name string `json:"name"`
Credits float64 `json:"credits"`
Me bool `json:"me"`
}
type APIRankRowShort struct {
Rank int `json:"rank"`
Credits float64 `json:"credits"`
}
func apiLeaderboard(lb *LeaderboardPage) *APILeaderboard {
a := &APILeaderboard{Players: lb.Players, Top: []APIRankRow{}}
for _, r := range lb.Top {
a.Top = append(a.Top, APIRankRow{Rank: r.Rank, Name: r.Name, Credits: r.Balance, Me: r.Me})
if r.Me {
a.Me = &APIRankRowShort{Rank: r.Rank, Credits: r.Balance}
}
}
if r := lb.You; r != nil {
a.You = &APIRankRow{Rank: r.Rank, Name: r.Name, Credits: r.Balance, Me: true}
a.Me = &APIRankRowShort{Rank: r.Rank, Credits: r.Balance}
}
return a
}
// APIHelp is the Help section: the steps of "How to play" (the same text as the template's
// help-section; keep them in step). The note is reworded for clients outside a browser.
type APIHelp struct {
Steps []string `json:"steps"`
Note string `json:"note"`
NewGame APIConfirm `json:"newGame"` // before new-game
}
// newGameConfirm is the help section's "Start a new game" question (the template's
// hx-confirm; keep them in step).
const newGameConfirm = "Start over with a new anonymous miner? Your current progress stays on the server but you won't be able to get back to it."
func apiHelp(s *State) *APIHelp {
return &APIHelp{Steps: []string{
"Claim a moon. It comes with " + s.WellRange() + " resource wells; which resources depends on the kind of moon.",
"Buy mining tools in the Store.",
"Pick a well on your moon and deploy a tool on it.",
`When the drill finishes, claim what it dug up. "Claim and redeploy" puts the tool straight back to work; after a claim, "Redrill" does the same.`,
"Sell resources in the Market, upgrade your equipment, claim more moons.",
"Buy attachments in the Store and equip them on a drill (click it in Equipment) to make it faster or bigger. Hire advisors to boost your whole operation.",
}, Note: "Your progress is saved on this device.",
NewGame: APIConfirm{Title: "Start over", Text: newGameConfirm, OK: "Start over", Danger: true}}
}
type APINewMoon struct {
Price float64 `json:"price"`
CanAfford bool `json:"canAfford"`
After float64 `json:"after"` // balance after buying
Need float64 `json:"need"` // credits missing
Types []string `json:"types"`
Resources []string `json:"resources"`
WellsMin int `json:"wellsMin"`
WellsMax int `json:"wellsMax"`
WhyText string `json:"whyText"` // why "Claim and reveal" is disabled, "" if it isn't
}
func apiNewMoon(t *NewMoonTeaser) *APINewMoon {
lo, hi := wellCountRange()
a := &APINewMoon{Price: t.Price, CanAfford: t.CanAfford, After: t.After, Need: t.Need, Types: []string{},
Resources: []string{}, WellsMin: lo, WellsMax: hi}
if !t.CanAfford {
a.WhyText = "Not enough credits"
}
a.Types = append(a.Types, t.Types...)
a.Resources = append(a.Resources, t.Resources...)
return a
}
// APIReveal is the reveal card after buy-moon (the act response's reveal).
type APIReveal struct {
MoonID string `json:"moonId"`
Name string `json:"name"`
Kind string `json:"kind"`
Description string `json:"description"`
Rating APIRating `json:"rating"`
Headline string `json:"headline"`
Sub string `json:"sub"`
Worth float64 `json:"worth"` // its wells at market prices now
Multiple float64 `json:"multiple"` // worth / its price
Traits []string `json:"traits"`
MaxSupply float64 `json:"maxSupply"` // a full bar
FullAt float64 `json:"fullAt"` // wells from here count as full
Wells []APIRevealWell `json:"wells"`
}
type APIRevealWell struct {
ID string `json:"id"`
Resource string `json:"resource"`
Supply float64 `json:"supply"`
Full bool `json:"full"`
}
func apiReveal(r *MoonReveal) *APIReveal {
if r == nil {
return nil
}
a := &APIReveal{MoonID: r.ID, Name: r.Name, Kind: r.Kind, Description: r.Description,
Rating: *apiRating(&r.Rating),
Headline: r.Headline, Sub: r.Sub, Worth: r.Worth, Multiple: r.Multiple, Traits: []string{}, MaxSupply: r.MaxSupply,
FullAt: r.FullAt, Wells: []APIRevealWell{}}
a.Traits = append(a.Traits, r.Traits...)
for _, w := range r.Wells {
a.Wells = append(a.Wells, APIRevealWell{ID: w.ID, Resource: w.Resource, Supply: w.Supply, Full: w.Supply >= r.FullAt})
}
return a
}
// ------------------------------------------------------------------ moon sale
type APIMoonSale struct {
Total float64 `json:"total"` // the offer
Market float64 `json:"market"` // the wells at market
Replace float64 `json:"replace"` // a new moon's price after the sale
Delta float64 `json:"delta"` // total - replace
Capped bool `json:"capped"`
Factor float64 `json:"factor"`
Lines []APISaleLine `json:"lines"`
Last bool `json:"last"`
Running int `json:"running"`
Ready int `json:"ready"` // finished drills, claimed in the sale
HoldEnd *int64 `json:"holdEnd"` // sellable from then, null if already
CanSell bool `json:"canSell"`
Why string `json:"why"` // last, running, hold; "" can sell
WhyText string `json:"whyText"`
Confirm APIConfirm `json:"confirm"` // before sell-moon
}
type APISaleLine struct {
Resource string `json:"resource"`
Supply float64 `json:"supply"`
Rate float64 `json:"rate"`
Value float64 `json:"value"`
}
func apiMoonSale(m *MoonSale) *APIMoonSale {
if m == nil {
return nil
}
a := &APIMoonSale{Total: m.Total, Market: m.Market, Replace: m.Replace, Delta: m.Delta(), Capped: m.Capped,
Factor: m.Factor(), Lines: []APISaleLine{}, Last: m.Last, Running: m.Running, Ready: m.Ready,
HoldEnd: unixMS(m.HoldEnd), CanSell: !m.Blocked(), WhyText: m.Why(),
Confirm: APIConfirm{Title: "Sell " + m.Name, Text: m.Confirm(), OK: "Sell for " + formatCredits(m.Total), Danger: true}}
switch {
case m.Last:
a.Why = "last"
case m.Running > 0:
a.Why = "running"
case !m.HoldEnd.IsZero():
a.Why = "hold"
}
for _, l := range m.Lines {
a.Lines = append(a.Lines, APISaleLine{Resource: l.Resource, Supply: l.Supply, Rate: l.Rate, Value: l.Value})
}
return a
}
// ------------------------------------------------------------------ market chart
// APIChart is the selected quote's 24h market rate: one point per step from start.
type APIChart struct {
Item string `json:"item"`
Start int64 `json:"start"`
Step int64 `json:"step"` // ms
Prices []float64 `json:"prices"`
}
// spark is every 3rd point of a series and the last one, as the web's sparklines draw it.
func spark(series []float64) []float64 {
out := []float64{}
last := len(series) - 1
for i := 0; i <= last; i += 3 {
if i+3 > last {
i = last
}
out = append(out, series[i])
}
return out
}
+277
View File
@@ -0,0 +1,277 @@
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"
"io/fs"
"testing"
)
func TestAPI(t *testing.T) {
// Every tool has art at every tier (scripts/tool-art.mjs), else its URLs fall back.
for _, item := range game.StoreItems {
for tier := 0; tier <= len(item.Upgrades); tier++ {
name := item.ImageAt(tier)
if _, err := fs.Stat(embedded, "web/static/assets/items/"+name); err != nil || (tier > 0 && name == item.Image) {
t.Errorf("%s: no image for tier %d (run node scripts/tool-art.mjs)", item.Name, tier)
}
}
}
unlocked := newPlayerState()
unlocked.Onboarding = []string{"all"}
for _, s := range []*State{veteranState(), newPlayerState(), unlocked} {
for _, v := range testViews(s) {
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 == "" {
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)
}
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 (a.Market != nil) != s.Unlocked("market") {
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
}
}
}
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)
}
for _, d := range w.Drills {
if d.Fate == "" {
fail("drill fate", d)
}
}
}
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
fail("moon sale", sale)
}
}
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 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) {
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
}
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
}
+224
View File
@@ -0,0 +1,224 @@
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 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 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 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 from advisor_hire where user_id = $1 order by hired_at, advisor`, s.UserID)
if err != nil {
return err
}
if s.Advisors, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
return err
}
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 # 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. # environment, and add a domain for the `app` service on port 3000 in Dokploy.
services: x-db-env: &db-env
app:
build: .
restart: unless-stopped
environment:
DB_HOST: db DB_HOST: db
DB_PORT: "5432" DB_PORT: "5432"
DB_USER: moonminers DB_USER: moonminers
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
DB_DATABASE: moonminers DB_DATABASE: moonminers
services:
app:
build: .
restart: unless-stopped
environment:
<<: *db-env
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
expose: expose:
- "3000" - "3000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db-update: migrate:
condition: service_completed_successfully condition: service_completed_successfully
networks: networks:
- internal - internal
db: db:
build: image: docker.io/library/postgres:16
context: .
dockerfile: docker/db/Dockerfile
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: moonminers POSTGRES_DB: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
healthcheck: 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"] test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d moonminers"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
@@ -42,20 +42,16 @@ services:
networks: networks:
- internal - internal
# One-shot: migrations, function reload and config import, before the app starts. # One-shot, before the app starts on every deploy: roles, schema, migrations and the
db-update: # store item sync from config/game.json (see migrate.go).
build: migrate:
context: . build: .
dockerfile: docker/db/Dockerfile command: ["migrate"]
command: ["/moonminers/update.sh"]
restart: "no" restart: "no"
environment: environment:
PGHOST: db <<: *db-env
PGUSER: postgres ADMIN_DB_USER: postgres
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} ADMIN_DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
PGDATABASE: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
depends_on: depends_on:
db: db:
condition: service_healthy 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: services:
db: db:
build: image: docker.io/library/postgres:16
context: .
dockerfile: docker/db/Dockerfile
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: moonminers POSTGRES_DB: moonminers
APP_DB_USER: moonminers
APP_DB_PASSWORD: moonminers
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
# The SQL in sql/ is loaded once, when this volume is empty.
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
volumes: 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))
}
+1472
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;
+205
View File
@@ -0,0 +1,205 @@
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",
"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
}
+182
View File
@@ -0,0 +1,182 @@
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
}
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}
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`);
-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)
+545
View File
@@ -0,0 +1,545 @@
// Tool art: draws every store item (drill) at every tier as an SVG and rasterizes it into
// web/static/assets/items (rsvg-convert + ImageMagick). Tier 0 keeps the item's `image` name
// from config/game.json; tier N is the same name with "-tN" before the extension (toolImage
// in game.go). Run from the repo root: node scripts/tool-art.mjs [--svg dir]
//
// Each tool has its own silhouette and color family; tiers grow the rig, add parts, light
// up a ring of tier segments around its base and strengthen the glow.
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
const SIZE = 512;
const OUT = "web/static/assets/items";
// ------------------------------------------------------------------ helpers
const C = Math.cos(Math.PI / 6);
const R2 = Math.SQRT2;
const f = (n) => +n.toFixed(1);
// Isometric projection: X runs down-right, Y down-left, Z up.
// While BB is set, every projected point grows it (the rig's bounding box, for fitting).
let BB = null;
function grow(x, y, r = 0) {
if (!BB) return;
BB.x0 = Math.min(BB.x0, x - r); BB.x1 = Math.max(BB.x1, x + r);
BB.y0 = Math.min(BB.y0, y - r); BB.y1 = Math.max(BB.y1, y + r);
}
const P = (x, y, z) => {
const p = [f((x - y) * C), f((x + y) * 0.5 - z)];
grow(...p);
return p;
};
const pts = (list) => list.map((p) => p.join(",")).join(" ");
function hex(c) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
const [x, y] = [hex(a), hex(b)];
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("");
}
const shade = (c, k) => (k >= 0 ? mix(c, "#ffffff", k) : mix(c, "#000000", -k));
// A seeded random, so the rocks stay put between runs.
function rng(seed) {
let s = seed * 2654435761 % 4294967296;
return () => ((s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296);
}
// Materials: hull plating, dark machinery, and the tool's own color.
const HULL = "#b9c8db";
const IRON = "#3a4a63";
class Art {
constructor(color) {
this.color = color;
this.out = [];
}
add(s) {
this.out.push(s);
}
poly(list, fill, extra = "") {
this.add(`<polygon points="${pts(list)}" fill="${fill}" ${extra}/>`);
}
line(a, b, stroke, w = 2, extra = "") {
this.add(`<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}" stroke="${stroke}" stroke-width="${w}" stroke-linecap="round" ${extra}/>`);
}
// Box from its min corner (x, y, z), size w (X), d (Y), h (Z): top, right (+X), left (+Y).
box(x, y, z, w, d, h, col, edge = true) {
const st = edge ? `stroke="${shade(col, -0.55)}" stroke-width="1" stroke-linejoin="round"` : "";
this.poly([P(x, y + d, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, -0.18), st);
this.poly([P(x + w, y, z), P(x + w, y + d, z), P(x + w, y + d, z + h), P(x + w, y, z + h)], shade(col, -0.45), st);
this.poly([P(x, y, z + h), P(x + w, y, z + h), P(x + w, y + d, z + h), P(x, y + d, z + h)], shade(col, 0.22), st);
}
cbox(cx, cy, z, w, d, h, col, edge) {
this.box(cx - w / 2, cy - d / 2, z, w, d, h, col, edge);
}
// Vertical cylinder centered on (cx, cy), from z up h.
cyl(cx, cy, z, r, h, col) {
const [bx, by] = P(cx, cy, z);
const [, ty] = P(cx, cy, z + h);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
const g = this.grad(col);
this.add(`<path d="M${bx - rx},${ty} L${bx - rx},${by} A${rx},${ry} 0 0 0 ${bx + rx},${by} L${bx + rx},${ty} Z" fill="url(#${g})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
this.add(`<ellipse cx="${bx}" cy="${ty}" rx="${rx}" ry="${ry}" fill="${shade(col, 0.25)}" stroke="${shade(col, -0.5)}" stroke-width="1"/>`);
}
// A flat disc (ellipse) lying at height z.
disc(cx, cy, z, r, fill, extra = "") {
const [x, y] = P(cx, cy, z);
this.add(`<ellipse cx="${x}" cy="${y}" rx="${f(r * C * R2)}" ry="${f(r * R2 * 0.5)}" fill="${fill}" ${extra}/>`);
}
// Cone (drill bit) from a disc of radius r at z1 down to a point at z2.
cone(cx, cy, z1, z2, r, col) {
const [x, y1] = P(cx, cy, z1);
const [, y2] = P(cx, cy, z2);
const rx = f(r * C * R2), ry = f(r * R2 * 0.5);
this.add(`<path d="M${x - rx},${y1} A${rx},${ry} 0 0 0 ${x + rx},${y1} L${x},${y2} Z" fill="url(#${this.grad(col)})" stroke="${shade(col, -0.55)}" stroke-width="1"/>`);
}
// Horizontal shading gradient for round parts (lit from the left).
grad(col) {
const id = "g" + col.slice(1);
this.defs ??= new Map();
if (!this.defs.has(id)) {
this.defs.set(id, `<linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stop-color="${shade(col, -0.25)}"/><stop offset="0.3" stop-color="${shade(col, 0.3)}"/>
<stop offset="0.65" stop-color="${shade(col, -0.2)}"/><stop offset="1" stop-color="${shade(col, -0.6)}"/></linearGradient>`);
}
return id;
}
// A light: a bright core with a soft halo in the tool's color.
light(p, r, k = 1, col = this.color) {
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${f(r * 2.6)}" fill="${col}" opacity="${f(0.35 * k * 10) / 10}" filter="url(#blur)"/>`);
this.add(`<circle cx="${p[0]}" cy="${p[1]}" r="${r}" fill="${mix(col, "#ffffff", 0.6)}"/>`);
}
// Lattice between two square frames (a tower section): legs plus X bracing.
lattice(cx, cy, z1, z2, half1, half2, col, w = 3, bays = 4) {
const corner = (h, z) => [[cx - h, cy - h, z], [cx + h, cy - h, z], [cx + h, cy + h, z], [cx - h, cy + h, z]];
const at = (t) => corner(half1 + (half2 - half1) * t, z1 + (z2 - z1) * t);
for (let i = 0; i < 4; i++) {
const j = (i + 1) % 4;
if (i === 0) continue; // the back face would only add clutter
for (let b = 0; b < bays; b++) {
const lo = at(b / bays), hi = at((b + 1) / bays);
this.line(P(...lo[i]), P(...hi[j]), shade(col, -0.25), w * 0.55);
this.line(P(...lo[j]), P(...hi[i]), shade(col, -0.25), w * 0.55);
this.line(P(...hi[i]), P(...hi[j]), shade(col, -0.1), w * 0.6);
}
}
const lo = at(0), hi = at(1);
for (let i = 0; i < 4; i++) this.line(P(...lo[i]), P(...hi[i]), i === 2 ? shade(col, 0.2) : col, w);
}
// Diagonal warning stripes on the +Y face of a box.
stripes(x, y, z, w, d, h, n, col) {
for (let i = 0; i < n; i++) {
const a = x + (w / n) * i, b = a + (w / n) * 0.5;
this.poly([P(a, y + d, z), P(b, y + d, z), P(b + h * 0.4, y + d, z + h), P(a + h * 0.4, y + d, z + h)], col, 'opacity="0.85"');
}
}
}
// ------------------------------------------------------------------ the tools
// Each draw(a, t) adds the rig's shapes around the ground origin (0, 0, 0); t is the tier.
const TOOLS = {
// Prospector Drone PD-1: a hovering quad-rotor that pulls ore up with a tractor beam.
1: {
color: "#7cf06a",
size: 0.8,
draw(a, t) {
const c = a.color, z = 92;
// tractor beam and its pool on the ground
a.disc(0, 0, 0, 34, c, `opacity="${0.18 + t * 0.05}" filter="url(#blur)"`);
const [bx, by] = P(0, 0, z), [, gy] = P(0, 0, 0);
a.add(`<polygon points="${bx - 14},${by} ${bx + 14},${by} ${bx + 34},${gy} ${bx - 34},${gy}" fill="url(#beam)" opacity="${0.45 + t * 0.08}"/>`);
const arms = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
const rotor = (sx, sy) => {
const x = sx * 58, y = sy * 58;
a.cyl(x, y, z + 6, 7, 10, IRON);
a.disc(x, y, z + 17, 30, shade(c, 0.4), `opacity="0.18"`);
a.disc(x, y, z + 17, 30, "none", `stroke="${c}" stroke-width="${t >= 4 ? 3 : 1.5}" opacity="0.9"`);
a.light(P(x, y, z + 17), 2.5, 0.6);
};
// back rotors and arms first
for (const [sx, sy] of arms.slice(0, 1)) rotor(sx, sy);
for (const [sx, sy] of arms) a.line(P(0, 0, z + 10), P(sx * 58, sy * 58, z + 10), shade(HULL, -0.3), 7);
for (const [sx, sy] of arms.slice(1, 3)) rotor(sx, sy);
// body: a squat saucer with a dome
a.cyl(0, 0, z - 6, 30, 16, HULL);
a.cyl(0, 0, z + 10, 22, 6, IRON);
const [dx, dy] = P(0, 0, z + 16);
a.add(`<path d="M${dx - 22},${dy} A22,14 0 0 1 ${dx + 22},${dy} A22,9 0 0 1 ${dx - 22},${dy}" fill="${mix(c, "#0a2020", 0.55)}" stroke="${c}" stroke-width="1.5"/>`);
a.light([dx, dy - 4], 4, 0.8 + t * 0.1);
// side lamps grow in number with the tier
for (let i = 0; i < 2 + Math.min(t, 3); i++) a.light(P(26, -18 + i * 10, z + 2), 2.2, 0.7);
if (t >= 2) {
a.line(P(-8, -8, z + 18), P(-14, -14, z + 50), shade(HULL, -0.2), 2);
a.light(P(-14, -14, z + 50), 2.5, 1);
}
if (t >= 3) {
a.cyl(-6, 34, z - 14, 9, 22, c);
a.cyl(34, -6, z - 14, 9, 22, c);
}
rotor(1, 1);
},
},
// Percussion Drill PX-4: a tracked crawler with a hammer mast at its nose.
2: {
color: "#ff9a3d",
size: 0.86,
draw(a, t) {
const c = a.color;
// shock rings where the hammer bites
for (let i = 0; i < 1 + Math.ceil(t / 2); i++) {
a.disc(70, 0, 0, 22 + i * 14, "none", `stroke="${c}" stroke-width="2" opacity="${0.7 - i * 0.18}"`);
}
// tracks
a.box(-70, -48, 0, 120, 22, 26, IRON);
a.box(-70, 26, 0, 120, 22, 26, IRON);
for (let i = 0; i < 5; i++) {
const [x, y] = P(-60 + i * 25, 48, 13);
a.add(`<ellipse cx="${x}" cy="${y}" rx="8" ry="10" fill="${shade(IRON, -0.4)}" stroke="${shade(IRON, 0.3)}" stroke-width="1.5"/>`);
}
// hull, cab and the stack
a.box(-64, -40, 26, 108, 80, 26, HULL);
a.stripes(-64, -40, 26, 108, 80, 26, 7, c);
a.box(-60, -34, 52, 48, 56, 34 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-12, -26, 60), P(-12, 16, 60), P(-12, 16, 80), P(-12, -26, 80)], mix(c, "#04121a", 0.5));
a.cyl(-50, -24, 86 + (t >= 3 ? 10 : 0), 6, 26, IRON);
if (t >= 2) a.light(P(-50, -24, 118 + (t >= 3 ? 10 : 0)), 3, 0.8);
// the mast: a tall column with a piston block
const top = 150 + t * 10;
a.box(44, -12, 26, 16, 24, top - 26, shade(HULL, -0.1));
a.stripes(44, -12, 30, 16, 24, top - 34, 6, c);
a.cyl(70, 0, 30, 9, top - 40, shade(HULL, 0.1));
a.cbox(70, 0, 46 + (top - 80) * 0.45, 30, 30, 34, c);
a.cbox(70, 0, top - 10, 26, 26, 14, IRON);
a.cone(70, 0, 30, -6, 10, shade(HULL, -0.2));
a.light(P(70, 0, 2), 4, 0.6 + t * 0.1);
if (t >= 4) {
a.cyl(0, -8, 52, 12, 22, c);
a.light(P(0, -8, 74), 3, 1);
}
a.light(P(70, 0, top + 4), 3, 0.9);
},
},
// LunarLite L100: a light tripod with a spiral rotary bit.
3: {
color: "#5fd4ff",
size: 0.84,
draw(a, t) {
const c = a.color, hub = 130;
a.disc(0, 0, 0, 22, c, `opacity="${0.3 + t * 0.07}" filter="url(#blur)"`);
const legs = [[-70, -10], [20, -66], [40, 60]];
a.line(P(...legs[0], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
a.line(P(...legs[1], 0), P(0, 0, hub), shade(HULL, -0.35), 6);
// the bit: a spiral auger into the ground
a.cone(0, 0, 76, -14, 14, shade(HULL, 0.05));
for (let i = 0; i < 6; i++) {
const z = 70 - i * 13, r = 14 * (z + 14) / 90;
const [x, y] = P(0, 0, z);
a.add(`<path d="M${f(x - r * 1.2)},${y} q${f(r * 1.2)},${f(r * 0.7)} ${f(r * 2.4)},-5" fill="none" stroke="${c}" stroke-width="2" opacity="0.9"/>`);
}
// motor housing
a.cyl(0, 0, 76, 16, 14, IRON);
a.cyl(0, 0, 90, 24, 40 + (t >= 3 ? 8 : 0), HULL);
a.disc(0, 0, 108, 24.5, "none", `stroke="${c}" stroke-width="3"`);
if (t >= 1) a.disc(0, 0, 98, 24.5, "none", `stroke="${c}" stroke-width="2" opacity="0.7"`);
a.cyl(0, 0, 130 + (t >= 3 ? 8 : 0), 10, 10, IRON);
a.light(P(0, 0, 146 + (t >= 3 ? 8 : 0)), 3, 0.8 + t * 0.1);
if (t >= 2) {
a.cbox(-34, 18, 0, 22, 22, 26, c);
a.line(P(-34, 18, 26), P(0, 0, 100), IRON, 2);
}
if (t >= 4) a.cyl(30, -30, 0, 9, 34, c);
a.line(P(...legs[2], 0), P(0, 0, hub), shade(HULL, -0.1), 6);
for (const l of legs) a.disc(l[0], l[1], 0, 7, IRON);
},
},
// LunarLite L300: a tall derrick with a laser cutter firing straight down.
4: {
color: "#ff5fd2",
size: 0.94,
draw(a, t) {
const c = a.color, top = 230 + t * 12;
// laser impact
a.disc(0, 0, 0, 30 + t * 4, c, `opacity="0.55" filter="url(#blur)"`);
a.disc(0, 0, 0, 12, "#ffffff", `opacity="0.8" filter="url(#blur)"`);
// power unit at the back
a.box(-96, -70, 0, 40, 36, 34, HULL);
a.stripes(-96, -70, 0, 40, 36, 34, 4, c);
a.box(-46, -50, 0, 92, 100, 10, IRON);
a.lattice(0, 0, 10, top, 42, 13, shade(HULL, -0.15), 4, 5);
// emitter and beam
const ez = 96;
const [bx, by] = P(0, 0, ez), [, gy] = P(0, 0, 0);
a.add(`<rect x="${bx - 8}" y="${by}" width="16" height="${gy - by}" fill="${c}" opacity="0.9" filter="url(#blur)"/>`);
a.add(`<rect x="${bx - 1.8}" y="${by}" width="4" height="${gy - by}" fill="#ffe6f8"/>`);
a.cbox(0, 0, ez, 26, 26, 30, HULL);
a.disc(0, 0, ez + 12, 18.5, "none", `stroke="${c}" stroke-width="3"`);
a.cone(0, 0, ez, ez - 16, 9, IRON);
if (t >= 2) a.cbox(0, 0, ez + 30, 16, 16, 24, c);
// crown block
a.cbox(0, 0, top, 34, 34, 14, c);
a.light(P(0, 0, top + 22), 3.5, 0.9 + t * 0.1);
if (t >= 3) {
a.cyl(56, 30, 0, 12, 44, c);
a.light(P(56, 30, 48), 3, 0.8);
}
if (t >= 4) {
for (const z of [ez + 70, ez + 110]) a.disc(0, 0, z, 30, "none", `stroke="${c}" stroke-width="2.5" opacity="0.85"`);
}
},
},
// Lunar Regolith Extractor LRE-3000: a bucket-wheel excavator on tracks.
5: {
color: "#ffd24a",
size: 0.95,
draw(a, t) {
const c = a.color;
// counterweight boom (behind)
a.line(P(-10, 0, 80), P(-120, 0, 106), shade(HULL, -0.35), 12);
a.line(P(-30, 0, 120), P(-120, 0, 122), shade(HULL, 0.05), 3);
a.cbox(-120, 0, 92, 30, 34, 30, IRON);
// tracks and turntable
a.box(-60, -56, 0, 100, 26, 24, IRON);
a.box(-60, 30, 0, 100, 26, 24, IRON);
a.cyl(-10, 0, 24, 40, 10, IRON);
// superstructure
a.box(-50, -34, 34, 74, 68, 46, HULL);
a.stripes(-50, -34, 34, 74, 68, 16, 6, c);
a.box(-36, -24, 80, 34, 40, 26 + (t >= 3 ? 10 : 0), HULL);
a.poly([P(-2, -18, 88), P(-2, 10, 88), P(-2, 10, 100), P(-2, -18, 100)], mix(c, "#141004", 0.4));
// boom out to the wheel
const wx = 130, wz = 74;
a.line(P(10, 0, 70), P(wx, 0, wz + 6), shade(HULL, -0.1), 12);
a.line(P(10, 0, 116), P(wx - 6, 0, wz + 26), shade(HULL, 0.1), 3);
a.line(P(-20, 0, 110), P(10, 0, 116), shade(HULL, 0.1), 3);
// the bucket wheel, facing the viewer
const [x, y] = P(wx, 0, wz);
const R = 52 + t * 3, n = 8 + t * 2;
grow(x, y, R + 9);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${shade(HULL, -0.2)}" stroke-width="9"/>`);
a.add(`<circle cx="${x}" cy="${y}" r="${R}" fill="none" stroke="${c}" stroke-width="2"/>`);
for (let i = 0; i < n; i++) {
const ang = (i / n) * Math.PI * 2;
const ex = x + Math.cos(ang) * R, ey = y + Math.sin(ang) * R;
a.line([x, y], [f(ex), f(ey)], shade(HULL, -0.3), 2.5);
a.add(`<rect x="${f(ex - 8)}" y="${f(ey - 7)}" width="16" height="14" rx="3" fill="${c}" stroke="${shade(c, -0.6)}" stroke-width="1" transform="rotate(${f((ang * 180) / Math.PI + 90)} ${f(ex)} ${f(ey)})"/>`);
}
a.add(`<circle cx="${x}" cy="${y}" r="12" fill="${IRON}" stroke="${c}" stroke-width="2"/>`);
a.light([x, y], 4, 0.7 + t * 0.1);
if (t >= 2) a.light(P(-36, -24, 108 + (t >= 3 ? 10 : 0)), 3, 0.9);
if (t >= 4) {
a.cyl(22, 40, 34, 10, 30, c);
a.light(P(22, 40, 66), 2.5, 1);
}
// dust thrown up by the wheel
const r = rng(5);
for (let i = 0; i < 6 + t * 3; i++) {
a.add(`<circle cx="${f(x - 20 + r() * 70)}" cy="${f(y + R * 0.4 + r() * 50)}" r="${f(1 + r() * 2.5)}" fill="${mix(c, "#ffffff", 0.3)}" opacity="${f(0.3 + r() * 0.5)}"/>`);
}
},
},
// Electrostatic Dust Excavator EDE-700: a coil tower of charged plates with arcs.
6: {
color: "#a98bff",
size: 0.97,
draw(a, t) {
const c = a.color, plates = 3 + Math.min(t, 3);
a.disc(0, 0, 0, 80, c, `opacity="${0.15 + t * 0.05}" filter="url(#blur)"`);
// base platform with feet
a.cyl(0, 0, 0, 62, 16, IRON);
for (const [x, y] of [[-60, 0], [0, -60], [60, 0], [0, 60]]) a.cbox(x, y, 0, 18, 18, 22, HULL);
a.cyl(0, 0, 16, 30, 26, HULL);
// the column and its plates, widest at the bottom
const h = 60 + plates * 30;
a.cyl(0, 0, 40, 13, h, shade(HULL, -0.05));
const r = rng(6);
for (let i = 0; i < plates; i++) {
const z = 58 + i * 30, rad = 54 - i * 5;
a.cyl(0, 0, z, rad, 6, HULL);
a.disc(0, 0, z + 6, rad + 0.5, "none", `stroke="${c}" stroke-width="2.5"`);
a.disc(0, 0, z + 6, rad, c, `opacity="0.25" filter="url(#blur)"`);
a.cyl(0, 0, z + 6, 14, 24, shade(HULL, -0.05));
}
// arcs between the plates
for (let i = 0; i < Math.min(plates - 1, 1 + t); i++) {
const z = 64 + i * 30, side = i % 2 ? 1 : -1;
let [x, y] = P(side * 36, side * -10, z);
let d = `M${x},${y}`;
for (let k = 0; k < 4; k++) d += ` L${f(x + side * (4 + r() * 10))},${f(y - 7 - k * 6)}`;
a.add(`<path d="${d}" fill="none" stroke="${c}" stroke-width="5" opacity="0.5" filter="url(#blur)"/>`);
a.add(`<path d="${d}" fill="none" stroke="#f2ecff" stroke-width="1.5"/>`);
}
// the top sphere
const [tx, ty] = P(0, 0, 40 + h + 14);
grow(tx, ty, 16 + t);
a.add(`<circle cx="${tx}" cy="${ty}" r="${16 + t}" fill="url(#orb)" stroke="${c}" stroke-width="1.5"/>`);
a.light([tx, ty], 5, 0.8 + t * 0.12);
// dust rising into the field
for (let i = 0; i < 8 + t * 4; i++) {
const ang = r() * Math.PI * 2, rr = 50 + r() * 50;
const [px, py] = P(Math.cos(ang) * rr, Math.sin(ang) * rr, r() * h);
a.add(`<circle cx="${px}" cy="${py}" r="${f(1 + r() * 2)}" fill="${mix(c, "#ffffff", 0.4)}" opacity="${f(0.3 + r() * 0.6)}"/>`);
}
},
},
// Lunar Core Extractor LCE-360: a heavy rig on legs over a glowing bore, with a tall tower.
7: {
color: "#ff5a3c",
size: 1,
draw(a, t) {
const c = a.color;
// the bore under the rig
a.disc(0, 0, 0, 52, c, `opacity="0.75" filter="url(#blur)"`);
a.disc(0, 0, 0, 30, "#ffd0a0", `opacity="0.85" filter="url(#blur)"`);
a.disc(0, 0, 0, 44, "none", `stroke="${shade(c, 0.2)}" stroke-width="3"`);
// legs
for (const [x, y] of [[-80, -80], [80, -80], [-80, 80]]) a.cbox(x, y, 0, 14, 14, 56, IRON);
// drill string into the bore
a.cyl(0, 0, -4, 8, 60, shade(HULL, -0.2));
// deck
a.box(-92, -92, 56, 184, 184, 16, HULL);
a.stripes(-92, -92, 56, 184, 184, 16, 12, c);
// tanks and a crane
a.cyl(-60, 60, 72, 18, 40, HULL);
a.disc(-60, 60, 96, 18.5, "none", `stroke="${c}" stroke-width="2.5"`);
if (t >= 2) {
a.cyl(60, -60, 72, 16, 34, HULL);
a.disc(60, -60, 92, 16.5, "none", `stroke="${c}" stroke-width="2.5"`);
}
a.box(30, 30, 72, 52, 44, 40 + (t >= 3 ? 14 : 0), HULL);
a.poly([P(82, 38, 92), P(82, 66, 92), P(82, 66, 104), P(82, 38, 104)], mix(c, "#1a0604", 0.4));
const top = 260 + t * 12;
a.lattice(0, 0, 72, top, 36, 12, shade(HULL, -0.1), 4, 6);
a.cbox(0, 0, top, 30, 30, 12, c);
a.light(P(0, 0, top + 20), 4, 0.9 + t * 0.1);
a.line(P(-70, -40, 72), P(-110, 10, 170 + t * 6), shade(HULL, -0.2), 5);
a.line(P(-110, 10, 170 + t * 6), P(-110, 10, 120), IRON, 1.5);
a.cbox(-110, 10, 108, 10, 10, 12, c);
if (t >= 4) for (const z of [140, 200]) a.disc(0, 0, z, 26, "none", `stroke="${c}" stroke-width="2.5" opacity="0.9"`);
// the front leg last
a.cbox(80, 80, 0, 14, 14, 56, IRON);
a.light(P(92, 92, 72), 3, 0.9);
},
},
};
// ------------------------------------------------------------------ scene
function svg(id, tier, maxTier) {
const tool = TOOLS[id];
const c = tool.color;
const gx = 256, gy = 338; // ground origin on the image; deploy cards crop to about y 51..392
// Fit the top tier into the frame (up to y 72, 440 wide); lower tiers are smaller, and
// smaller tools stay smaller (tool.size).
BB = { x0: 0, x1: 0, y0: 0, y1: 0 };
tool.draw(new Art(c), maxTier);
const box = BB;
BB = null;
const fit = Math.min((gy - 72) / -box.y0, 440 / (box.x1 - box.x0), 1.8);
const a = new Art(c);
tool.draw(a, tier);
const k = maxTier ? tier / maxTier : 0;
const scale = fit * tool.size * (0.8 + 0.2 * k);
const shift = f(-((box.x0 + box.x1) / 2) * scale);
// The ground disc, rocks, and a ring of tier segments around the base.
const r = rng(id * 31 + 7);
let rocks = "";
for (let i = 0; i < 26; i++) {
const ang = r() * Math.PI * 2, d = 0.25 + r() * 0.75;
const x = f(Math.cos(ang) * 190 * d), y = f(Math.sin(ang) * 50 * d);
const s = f(2 + r() * 6);
rocks += `<ellipse cx="${x}" cy="${y}" rx="${s}" ry="${f(s * 0.6)}" fill="#2a3446" opacity="${f(0.5 + r() * 0.5)}"/>`;
}
const rx = 168, ry = 44;
const circ = Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
const seg = circ / Math.max(maxTier, 1);
const lit = Array.from({ length: maxTier }, (_, i) => (i < tier ? `${f(seg * 0.82)} ${f(seg * 0.18)}` : `0 ${f(seg)}`)).join(" ");
const ring = `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-opacity="0.18" stroke-width="3" stroke-dasharray="${f(seg * 0.82)} ${f(seg * 0.18)}" transform="rotate(180)"/>`
+ (tier ? `<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="7" opacity="0.6" filter="url(#blur)" stroke-dasharray="${lit}" transform="rotate(180)"/>
<ellipse rx="${rx}" ry="${ry}" fill="none" stroke="${mix(c, "#ffffff", 0.35)}" stroke-width="3" stroke-dasharray="${lit}" transform="rotate(180)"/>` : "");
// Sparkles at the top tier.
let sparkles = "";
if (tier === maxTier && maxTier > 0) {
const s = rng(id * 13);
for (let i = 0; i < 14; i++) {
const x = f(80 + s() * 352), y = f(70 + s() * 240), q = f(2 + s() * 4);
sparkles += `<path d="M${x},${y - q * 2} L${x + q * 0.4},${y} L${x},${y + q * 2} L${x - q * 0.4},${y} Z M${x - q * 2},${y} L${x},${y + q * 0.4} L${x + q * 2},${y} L${x},${y - q * 0.4} Z" fill="${mix(c, "#ffffff", 0.6)}" opacity="${f(0.4 + s() * 0.6)}"/>`;
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
<defs>
<radialGradient id="bg" cx="0.5" cy="0.45" r="0.75"><stop offset="0" stop-color="#0e2446"/><stop offset="1" stop-color="#040a16"/></radialGradient>
<radialGradient id="aura" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${c}" stop-opacity="${f((0.16 + 0.3 * k) * 100) / 100}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>
<radialGradient id="ground" cx="0.5" cy="0.4" r="0.6"><stop offset="0" stop-color="#1c2638"/><stop offset="0.75" stop-color="#121a28"/><stop offset="1" stop-color="#0a101c" stop-opacity="0"/></radialGradient>
<linearGradient id="beam" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c}" stop-opacity="0.9"/><stop offset="1" stop-color="${c}" stop-opacity="0.15"/></linearGradient>
<radialGradient id="orb" cx="0.35" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffffff"/><stop offset="0.4" stop-color="${mix(c, "#ffffff", 0.4)}"/><stop offset="1" stop-color="${shade(c, -0.5)}"/></radialGradient>
<filter id="blur" x="-1" y="-1" width="3" height="3"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="halo" x="-0.2" y="-0.2" width="1.4" height="1.4"><feGaussianBlur stdDeviation="${f(2 + 4 * k)}" result="b"/><feFlood flood-color="${c}" flood-opacity="${f((0.25 + 0.5 * k) * 100) / 100}"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>
${[...(a.defs?.values() ?? [])].join("\n ")}
</defs>
<rect width="${SIZE}" height="${SIZE}" fill="url(#bg)"/>
<g stroke="#5fd4ff" stroke-opacity="0.06" stroke-width="1">${Array.from({ length: 15 }, (_, i) => `<line x1="${i * 36 + 4}" y1="0" x2="${i * 36 + 4}" y2="${SIZE}"/><line x1="0" y1="${i * 36 + 4}" x2="${SIZE}" y2="${i * 36 + 4}"/>`).join("")}</g>
<ellipse cx="${gx}" cy="${gy - 110}" rx="${f(150 + 60 * k)}" ry="${f(150 + 60 * k)}" fill="url(#aura)"/>
<g transform="translate(${gx} ${gy})">
<ellipse rx="230" ry="70" fill="url(#ground)"/>
${rocks}
${ring}
<ellipse rx="${f(110 * scale)}" ry="${f(38 * scale)}" fill="#000" opacity="0.45" filter="url(#blur)"/>
<g transform="translate(${shift} 0) scale(${f(scale * 1000) / 1000})" filter="url(#halo)">
${a.out.join("\n ")}
</g>
</g>
${sparkles}
</svg>`;
}
// ------------------------------------------------------------------ main
export function tierImage(image, tier) {
if (!tier) return image;
const dot = image.lastIndexOf(".");
return `${image.slice(0, dot)}-t${tier}${image.slice(dot)}`;
}
const game = JSON.parse(readFileSync("config/game.json", "utf8"));
const svgDir = process.argv.indexOf("--svg") > 0 ? process.argv[process.argv.indexOf("--svg") + 1] : null;
const tmp = mkdtempSync(join(tmpdir(), "tool-art-"));
if (svgDir) mkdirSync(svgDir, { recursive: true });
try {
for (const item of game.storeItems) {
if (!TOOLS[item.id]) {
console.warn(`no art for store item ${item.id} (${item.name}), skipped`);
continue;
}
const maxTier = item.upgrades?.length ?? 0;
for (let tier = 0; tier <= maxTier; tier++) {
const name = tierImage(item.image, tier);
const src = join(svgDir ?? tmp, name.replace(/\.\w+$/, ".svg"));
writeFileSync(src, svg(item.id, tier, maxTier));
const png = join(tmp, "out.png");
execFileSync("rsvg-convert", ["-w", String(SIZE), "-h", String(SIZE), "-o", png, src]);
const dst = join(OUT, name);
const opts = name.endsWith(".jpg") ? ["-strip", "-quality", "88", "-interlace", "Plane"] : ["-strip", "-colors", "256", "PNG8:" + dst];
execFileSync("magick", [png, ...opts, ...(name.endsWith(".jpg") ? [dst] : [])]);
console.log(dst);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
+657
View File
@@ -0,0 +1,657 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"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)
addr := ":" + env("PORT", "3000")
srv := &http.Server{
Addr: addr,
Handler: logRequests(secureHeaders(limitBody(mux))),
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)
}
}
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).
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 ip
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
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, err := loadState(r.Context(), s.db, userID)
if err != nil {
serverError(w, err)
return
}
page := state.page(viewFromForm(r.FormValue).resolve(state))
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 "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, err := loadState(ctx, s.db, userID)
if err != nil {
serverError(w, err)
return
}
toast := res.toast(state)
page := state.page(res.View.resolve(state))
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) insert into resource(name)
values ('Sollux'), ('Shadowstone'), ('Azurium'), ('Novafor'), ('Nebulance'); 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;
+41
View File
@@ -0,0 +1,41 @@
-- Live ops (/utils/liveops): event log for metrics, timed events, announcements.
-- When each player joined. Existing players get their first tool's time (the starting
-- tools are given on sign-up), or now.
alter table users add column if not exists created_at timestamp with time zone;
update users set created_at = coalesce(
(select min(created_at) from inventory_item where user_id = users.id), now())
where created_at is null;
alter table users alter column created_at set default now();
alter table users alter column created_at set not null;
-- One row per player action. No foreign key: metrics outlive deleted players.
create table if not exists event_log (
id bigserial primary key,
user_id uuid,
kind text not null,
data jsonb not null default '{}',
created_at timestamp with time zone not null default now()
);
create index if not exists event_log_created_at on event_log (created_at);
create index if not exists event_log_kind on event_log (kind, created_at);
-- Timed boosts. params: {"resource": "<name>" | "all", "multiplier": n}.
create table if not exists live_event (
id serial primary key,
name text not null,
kind text not null check (kind in ('yield_multiplier', 'price_multiplier', 'cycle_multiplier')),
params jsonb not null default '{}',
starts_at timestamp with time zone not null,
ends_at timestamp with time zone not null,
enabled boolean not null default true
);
-- Messages shown in the banner at the top of the game.
create table if not exists announcement (
id serial primary key,
message text not null,
level text not null default 'info' check (level in ('info', 'warning', 'success')),
starts_at timestamp with time zone not null,
ends_at timestamp with time zone not null
);
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more