Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85336f109f | ||
|
|
a5385a53a7 | ||
|
|
0846c017ae | ||
|
|
b4e5846dff | ||
|
|
3afd892487 | ||
|
|
cc1005c99e | ||
|
|
c998f8fda0 | ||
|
|
25f315e8dc | ||
|
|
571075c5ff | ||
|
|
fbde1b79ae | ||
|
|
4e7e2d010d | ||
|
|
020d1f7197 | ||
|
|
1fd1059aa7 | ||
|
|
a8bf63ffe4 | ||
|
|
bc34f98e4c | ||
|
|
e6088994ef | ||
|
|
474542abc7 | ||
|
|
c7073e53b9 | ||
|
|
66705d9aba | ||
|
|
0d2a790506 | ||
|
|
960a03a4f6 | ||
|
|
d8a6ae1419 | ||
|
|
85c8219c0d | ||
|
|
1b595a413e | ||
|
|
33f9589caf | ||
|
|
4954cc8be6 | ||
|
|
a52ae701f9 | ||
|
|
d5e592c6b9 | ||
|
|
da3b343bc8 | ||
|
|
93963c0eed | ||
|
|
8c1456d5e6 | ||
|
|
9f7538bdb8 | ||
|
|
56888730e2 | ||
|
|
469e64b596 | ||
|
|
0393ba2cc8 | ||
|
|
7944cd73c7 | ||
|
|
eee2728b1d | ||
|
|
d70f71b33f | ||
|
|
1b2d563b12 | ||
|
|
1f612f28a6 | ||
|
|
46f390126e | ||
|
|
39a1faeb6b | ||
|
|
494d65f837 | ||
|
|
2bc602f278 | ||
|
|
78a076fffc | ||
|
|
42c8b84a7c | ||
|
|
f061317ee8 | ||
|
|
3e4ddfe88d | ||
|
|
7750682e23 | ||
|
|
cd30dcbc95 | ||
|
|
18748f9984 | ||
|
|
65d153a3b8 | ||
|
|
9a5330d637 | ||
|
|
ecf0bdce8d | ||
|
|
bb431e1fe5 | ||
|
|
0468346ac4 | ||
|
|
6f7e3dde56 | ||
|
|
188ced7ffe | ||
|
|
8ccf932b55 | ||
|
|
c2376b2166 | ||
|
|
b7362a53ab | ||
|
|
f8f2260867 | ||
|
|
34cb3076a7 | ||
|
|
d7a62abd1b | ||
|
|
f52064fc71 | ||
|
|
c30c2f83cb | ||
|
|
7abfe21989 | ||
|
|
5801a86fc4 | ||
|
|
3a7f0e9498 | ||
|
|
5c484aa4c4 | ||
|
|
343e8e79ae | ||
|
|
282e8509e6 | ||
|
|
410d773349 | ||
|
|
13e87966ba | ||
|
|
f14c308d62 | ||
|
|
e5af7274c7 | ||
|
|
6482a67fcf | ||
|
|
f57391e16c | ||
|
|
d40ed58a77 | ||
|
|
e4a22b6e6a | ||
|
|
679792e0bf | ||
|
|
0c3a3667a2 | ||
|
|
35f9399bba | ||
|
|
d7afb49035 | ||
|
|
103ad668f1 | ||
|
|
fe782ee636 | ||
|
|
ef6083747d | ||
|
|
dfef6d9866 | ||
|
|
715abd7518 | ||
|
|
1a95f8d26b | ||
|
|
ee6738fafb | ||
|
|
9c7fbf9963 | ||
|
|
b905f3c980 | ||
|
|
171ab7b9ca | ||
|
|
c2f8213cdc | ||
|
|
19ea99add5 | ||
|
|
94d455689e | ||
|
|
a41339e4f1 | ||
|
|
5d2e89efcc | ||
|
|
d169574122 | ||
|
|
76164fc24f | ||
|
|
ede02fa040 | ||
|
|
d1fde538ea | ||
|
|
1970434422 | ||
|
|
cad693a803 | ||
|
|
c6798ec32e | ||
|
|
23bc696203 | ||
|
|
62e3051a17 | ||
|
|
cbf48fb9dc | ||
|
|
0333a5b8ca | ||
|
|
18d346e3a9 | ||
|
|
238fabc7f2 | ||
|
|
993b0e9109 | ||
|
|
40d4054496 | ||
|
|
6e082666a5 | ||
|
|
c954c34172 | ||
|
|
ffc873802f | ||
|
|
de61d40f8c | ||
|
|
f36f0b4b6b | ||
|
|
bdf579d8a7 | ||
|
|
5052401f77 | ||
|
|
5b20ea2be2 | ||
|
|
e336b57ca0 | ||
|
|
0c51dbd749 | ||
|
|
459c7a5ded | ||
|
|
610d26eee9 | ||
|
|
827bcbcd8f | ||
|
|
fddb417095 | ||
|
|
39e364bf10 |
No files matched your search
@@ -1,9 +1,10 @@
|
||||
.git
|
||||
.next
|
||||
bin
|
||||
node_modules
|
||||
.env
|
||||
.env*
|
||||
database.db
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.DS_Store
|
||||
web/static/css/app.css
|
||||
art
|
||||
web/static/app
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -1,46 +1,26 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
# Build output
|
||||
/bin/
|
||||
/moonminers
|
||||
/web/static/css/app.css
|
||||
/web/static/app/
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# Local env files (never commit)
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# vscode
|
||||
# Editors and OS
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
database.db
|
||||
.env
|
||||
# Personal files
|
||||
/sql/create-users.sql
|
||||
/.dir-locals.el
|
||||
sql/queries.sql
|
||||
/project.todo
|
||||
|
||||
# Blender files (generated by art/equipment scripts)
|
||||
*.blend
|
||||
*.blend1
|
||||
/art/equipment/build/
|
||||
/art/advisors/build/
|
||||
/art/attachments/build/
|
||||
@@ -1,74 +1,222 @@
|
||||
# AGENTS.md — Moon Miners
|
||||
|
||||
## What this is
|
||||
Moon Miners is a browser idle/mining game (package name `mooner`). Players join anonymously:
|
||||
the client calls `POST /api/user/anonymous` and stores the user id in `localStorage`. Players
|
||||
own moons ("staking sources") with resource wells, buy and upgrade mining tools (store
|
||||
items) and stake them on wells. When a stake finishes they claim resources (Sollux,
|
||||
Shadowstone, Azurium, Novafor, Nebulance), which they can sell into a bank account
|
||||
balance. There is also a leaderboard.
|
||||
Moon Miners is a browser idle/mining game. Players join anonymously: the server creates a
|
||||
player and keeps its id in the `mm_user` cookie. Players own moons ("staking sources") with
|
||||
resource wells, buy and upgrade mining tools (store items) and stake them on wells. When a
|
||||
stake finishes (drill cycles are seconds: `completionTimeInSecs`, `duration_in_secs`) they
|
||||
claim resources (Sollux, Shadowstone, Azurium, Novafor, Nebulance), which
|
||||
they can sell for credits (₥) or refine into refined items (Refinery section: timed batches
|
||||
in slots; refined items sell for more and are parts for higher tool upgrades). Drill
|
||||
attachments (bought in the Store, equipped on a drill from its panel in Equipment) give it
|
||||
a modifier that makes it faster or bigger; advisors (Advisors section, one per seat) boost drill speed, yield, sell prices or the
|
||||
refinery for the whole account. There is also a leaderboard (Leaderboard section, `leaderboard.go`: top miners by credits, banned players left out; also JSON at `/api/leaderboard`).
|
||||
|
||||
## Stack
|
||||
- Next.js 13.1 (experimental `appDir`) + TypeScript 4.9 (strict) + three.js 0.170.
|
||||
three is pinned at 0.170 because newer `@types/three` needs TypeScript 5.
|
||||
- The game UI is plain three.js + DOM in `src/game`; `src/app/page.tsx` only mounts it.
|
||||
The API lives in the Pages Router (`src/pages/api`).
|
||||
- Postgres via `pg` (`postgresConnection` in `db.ts`). Most game logic is written as
|
||||
PL/pgSQL functions, and the API routes are thin wrappers that run `SELECT * FROM fn(...)`.
|
||||
- SQLite (`dbConnection`, `database.db`) is legacy. Only `api/seed.ts`, `api/drop.ts` and
|
||||
`add-balance` still use it. Don't build anything new on it.
|
||||
- Go (standard library `net/http` + `html/template`) with `pgx` for Postgres. One `main`
|
||||
package at the repo root; templates, static files and SQL are embedded with `go:embed`.
|
||||
- The interface is server-rendered HTML. htmx swaps it in place, morphing the game's regions
|
||||
(idiomorph extension, vendored in `web/static/vendor/idiomorph`) so elements keep their
|
||||
state; Tailwind v4 (standalone CLI) builds the CSS. No bundler, no TypeScript, no Alpine.
|
||||
- Experiment: a second web client at `/app` (`web/app/`), Preact + JSX + `@preact/signals`
|
||||
(vendored in `web/app/vendor`, no other libraries, no preact/compat), bundled by esbuild
|
||||
(standalone binary fetched like Tailwind, `make app`) into `web/static/app/` (built, not
|
||||
committed). See "web/app" below.
|
||||
- three.js is a plain ES module loaded through an import map (vendored in `web/static/vendor`).
|
||||
The viewport in the aside shows the selected moon, generated procedurally from its id
|
||||
(`terrain.js`, run in a worker by `moon-worker.js`; settings tuned on `/utils/moons` and
|
||||
stored in `moon_settings`). The HTML is the interface; the moon is only decoration.
|
||||
- Postgres 16, stock image. No functions or extensions in the database.
|
||||
|
||||
## Layout
|
||||
- `src/game/Game.ts`: controller. It creates/reuses the anonymous player, loads state from
|
||||
the API, and handles HUD actions and 3D picks.
|
||||
- `src/game/MoonScene.ts`: three.js scene (moons, well crystals, drills, progress rings,
|
||||
camera focus, raycast picking). It's rebuilt from state on every `sync()`.
|
||||
- `src/game/Hud.ts`: DOM overlay (top bar, side panels, hints, toasts). It uses event
|
||||
delegation via `data-action` attributes. Styles are in `src/app/globals.css` (plain CSS).
|
||||
- `src/game/api.ts` (typed fetch client) and `src/game/state.ts` (GameState plus helpers).
|
||||
- `src/app/utils/page.tsx`: admin utilities (clear data, import config).
|
||||
- `src/pages/api/**`: REST endpoints. Per-user routes are under `user/[userId]/`.
|
||||
- `src/utils/helpers.ts`: time and formatting helpers. `src/utils/gameLogic.ts` exposes `gameConfig`.
|
||||
- `typings.d.ts` (repo root, imported as `"typings"`): shared interfaces (`IStake`,
|
||||
`IStoreItem`, `IGameConfig`, ...).
|
||||
- `config/game-config.json`: static game config. `next.config.js` injects it as `process.env.gameConfig`.
|
||||
- `sql/tables.sql`: schema. `sql/procedures.sql`: game functions (create_user, stake, claim,
|
||||
purchase_item, upgrade_item, sell_resources, get_leaderboard, ...). `sql/import-config.sql`:
|
||||
`import_config()`, which pulls store/staking config from a Google Sheet into the DB.
|
||||
`sql/data.sql`: seed data.
|
||||
- `scripts/import-game-config.py`: dumps the config Google Sheet as CSV (debug helper).
|
||||
- The repo is `server/` in the `MoonMiners/` workspace; agent worktrees go next to it
|
||||
(`git worktree add ../wt-<task> -b <task>`), a Defold client later in `../client`.
|
||||
- `main.go`: commands (`serve`, `migrate`, `load-config`, `sim [file]`, `moon-traits`), `.env`
|
||||
loading. `balance.go`: the `sim` command (prints the `sim.go` analysis).
|
||||
- `game.go`: game rules. Each action is one transaction that locks (`for update`) the rows it
|
||||
reads; `check (balance >= 0)` in the schema is the safety net. Rule violations return a
|
||||
`GameError` (shown to the player as a toast); other errors are 500s. Past moons (wells
|
||||
empty, nothing unclaimed) are computed (`loadPastMoons`, no column); they still count as
|
||||
owned for the moon price and well scale, and can't be sold. The Past tab loads them 10 at a
|
||||
time (`loadPastPage`, limit/offset; `page=` in the view URL); handlers load state with
|
||||
`loadView` (state, the past page, the resolved view).
|
||||
- `views.go`: the `View` (section, moon, well, tab; carried in the URL as
|
||||
`/?s=&moon=&well=`), the `Page` view model, formatting helpers.
|
||||
- `server.go`: routes and middleware (request log, `nosniff`/`Referrer-Policy` headers, body
|
||||
limits with forms parsed up front, a per-IP limit on new players). `GET /` renders the page
|
||||
(htmx requests get every region as an out-of-band swap); `POST /act/<action>` runs an
|
||||
action and responds the same way.
|
||||
- `api.go`, `api.md`: JSON API for non-web clients (Defold): `GET /api/state` (the view
|
||||
models mapped to JSON DTOs; times in unix ms) and `POST /api/act/<action>` (same
|
||||
`runAction` as `/act/`, gates included; `{ok, error, toast, fx, state}`, rule errors 422).
|
||||
Player id from `X-MM-User` or the cookie, else a new player (id echoed back).
|
||||
`api_sections.go`: every section's block (equipment and drill panel, store, refinery,
|
||||
advisors, leaderboard, help, new-moon teaser, reveal and buy modal in act responses, moon
|
||||
sale, market chart), plus `rules` (numbers for client prediction), `catalog` (static data,
|
||||
image URLs, tool codes), `jobs`, `bonuses`, `liveEvents`; blocks come with `s=` or
|
||||
`include=`; reasons are codes with the web's text beside them (`whyText`, …), confirm dialogs as `confirm`; `catalog=0` or `catalog=<catalogVersion>` skips the catalog; `clientSeq` is echoed. A new section in the API:
|
||||
map its view model in `addSection`. `api_test.go` (in `make check`) builds every block
|
||||
for every test view.
|
||||
- `config/game.json`: the game config (starting credits/resources/tools, moon price, well
|
||||
supply, `wellCountWeights`, resource prices, store items with upgrade tiers, moon types: a
|
||||
name, a placeholder description and resource `affinities`, refinery), embedded.
|
||||
`rollWells` (game.go) rolls a new moon's wells: 2–5 by weight, resources by its type's
|
||||
affinities, each well's supply rolled in [`wellSupplyMin`, `wellSupplyMax`) (fewer wells
|
||||
hold less); a first moon
|
||||
always has every raw input of a recipe. A moon's name comes from `moonNames` (roots and
|
||||
suffix style weights, `rollMoonName`), never repeated among a player's moons; its type is
|
||||
`staking_source.kind` (moons from before migration 013 were named after their type).
|
||||
`config.go` parses it into `game` and syncs store items into `store_item`/`upgrade_item`
|
||||
(needed for the inventory foreign key and the SQL in game.go). Admins override its
|
||||
values live on `/utils/economy` (table editor in `economy.js`; `economy.go`: a patch per
|
||||
save in `config_override`, latest wins; `sim.go`: balance analysis and progression sim). Read config values through
|
||||
`cfg()` (game.json + override), never `game` directly. `migrate.go`: database setup.
|
||||
- `db.go`: connections, `inTx`, `GameError`.
|
||||
- `market.go`: market prices. A unit sells for its config price × a deterministic drift
|
||||
(name + 5-minute tick, within ±`marketVolatility`) × live price events × the player's
|
||||
selling pressure (`marketPressure`: their recent sales of the item, in base-price credits,
|
||||
decaying with a half-life, table `market_pressure`; the factor eases from 1 towards
|
||||
`floor`, and a sale is priced at its average over the pressure it adds). `marketRate` is
|
||||
used by `sell`/`sellRefined` and the market view (24h history recomputed, no table);
|
||||
view model for the Market section (ticker, watchlist, chart, sell ticket; `pick=` in the
|
||||
URL selects a quote). `web/static/js/market.js`: chart crosshair and ticket estimate.
|
||||
- `resale.go`: selling a moon back (`moonResale` in game.json): what's left in its wells at
|
||||
market price (with live events, without selling pressure or advisors) × `factor`, capped
|
||||
at `maxRatio` × the replacement moon's price (`moonPriceFor(owned-1)`), which it's
|
||||
compared with. Not the last moon, not within `minHoldSecs` of buying it, not while a
|
||||
drill on it is running; finished drills are claimed in the sale. Template: `moon-sale`.
|
||||
Also selling tools and spare attachments for `equipmentResale.factor` (below 1) of their
|
||||
credit price (a tool: price + its upgrades; refined parts not refunded): only idle
|
||||
tools with nothing to claim, never the last tool; attachments on it return to the inventory.
|
||||
- `refinery.go`: refinery config (`refinery` in game.json, `parts` on upgrade tiers), rules
|
||||
(start batch, collect, buy slot, sell refined, upgrade parts) and its view model. A batch
|
||||
of n takes `seconds` × n^`bulkExponent` (`batchSecs`). Tables
|
||||
`refined_account`, `refinery_job`, `users.refinery_slots`. Template: `refinery.html`.
|
||||
- `bonus.go`: player bonuses. `bonusesFor` combines advisors and a tool's attachments
|
||||
(multipliers multiply, then each kind is capped by `bonusCaps`); live events multiply on
|
||||
top, uncapped. `drillStats` turns a tool's cycle and yield into a stake's (used by
|
||||
`stakeInTx`, the views and the sim); sells and refinery batches use `Bonus.Sell`/`Refine`.
|
||||
Bonuses are fixed when the action runs (a stake keeps its duration and amount).
|
||||
- `attachments.go`: attachments config (`attachments` in game.json: `slotsPerTool`, items
|
||||
with `speed`/`yield` multipliers), buy/equip/unequip rules (one of each per tool; equip
|
||||
and unequip any time, they count from the next stake), store cards and the drill panel
|
||||
(`drill=` in the URL, Equipment section, in the aside). Table `attachment`
|
||||
(`inventory_item_id` null: unequipped).
|
||||
- `advisors.go`, `web/templates/advisors.html`: advisors config (`advisors`), hire, buy
|
||||
seat, seat/unseat/swap/dismiss rules (only seated advisors give a bonus; dismissing
|
||||
refunds nothing), Advisors section and the advisor panel (`adv=` in the URL, in the
|
||||
aside). Tables `advisor_hire` (`seat`, null: not seated), `users.advisor_seats`.
|
||||
- `onboarding.go`: onboarding by progressive reveal, no tutorial text. `users.onboarding`
|
||||
holds flags that are only ever added (gates passed, `claimed:<resource>`, `seen:<tab>`,
|
||||
`all` for players from before it). New players see only the first moon purchase; deploying
|
||||
reveals Equipment, claiming Market, selling Store (and "+ New moon", Help, upgrades,
|
||||
attachments, moon sale, Redrill), claiming both raw inputs of a recipe Refinery, buying a
|
||||
tool or attachment Advisors. Templates ask `.S.Unlocked "<feature>"`; `View.resolve` sends
|
||||
locked sections to the moon view and `runAction` refuses locked actions (`actionGates`).
|
||||
A revealed tab shines (`data-shine`) until opened (`markSeen` in `handleIndex`). New
|
||||
sections and actions need a gate there.
|
||||
- `players.go`, `web/templates/players.html`: admin Players page at `/utils/players`
|
||||
(search, detail, grants, finish drills, reset, onboarding reset/unlock all, ban; each logged as `admin_*` in
|
||||
`event_log`). `notBanned` gives banned players (`users.banned_at`) a suspended page.
|
||||
`web/templates/admin.html`: the shared admin nav (`{{template "admin-nav" "<page>"}}`, put
|
||||
it on every `/utils` page) and confirm dialog. `/utils` redirects to `/utils/players`.
|
||||
- `web/templates/`: `page.html` (page shell, boot page), `app.html` (header, moon
|
||||
list, sections, side panel).
|
||||
- `web/static/js/main.js`: glue between htmx and the scene (and what a morph keeps of the JS's
|
||||
own changes: classes, styles, inserted elements, typed values), and countdowns (`data-ends`)
|
||||
that enable their Claim button (`data-ready-at`) when due. `scene.js`: the three.js scene.
|
||||
`tabs.js`: sliding tab highlight (`data-tabs`). `enter.js`: entrance animations on view
|
||||
changes. `ui.js`: the custom confirm dialog (`hx-confirm`) and toasts, shared by the game
|
||||
and admin pages.
|
||||
- `web/static/js/fx.js`: effects per action result (toast `data-fx`). `sfx.js`: sound
|
||||
(Web Audio; clips in `web/static/assets/sfx`, see CREDITS.txt; synth sounds via vendored
|
||||
ZzFX). fx.js maps action kinds to sounds; mute is in the header, `window.mmAudio` for
|
||||
outside control (e.g. ads).
|
||||
`terrain.js`: moon generation (no imports, also runs in the worker). `moons-admin.js` and
|
||||
`web/templates/moons.html`: the moon lab. `moons.go`: moon settings endpoints, `isAdmin`.
|
||||
`moondesc.go`: moon descriptions generated from the look (mirrors terrain.js `moonTraits`;
|
||||
`scripts/check-moon-traits.mjs` in `make check` keeps them in sync) and the wells.
|
||||
- `reveal.go`, `web/static/js/reveal.js`: the new-moon section (`s=new-moon`, a teaser with a
|
||||
shrouded moon in the viewport, `mystery` in the scene data) and the reveal after
|
||||
`buy-moon` (the response carries `#reveal` with the moon's card; scene.js plays the
|
||||
burst, fx.js waits for `mm:revealed` before counting the balance down).
|
||||
`rating.go`: a new moon's rating, the percentile of its base-price value among a cached
|
||||
sample of `rollWells` (resampled when the config changes), stored in
|
||||
`staking_source.rating`; tiers Poor/Fair/Good/Great/Exceptional drive the card's verdict
|
||||
and the star chip in the moon list and header.
|
||||
- `liveops.go`, `events.go`, `web/templates/liveops.html`, `web/static/js/liveops.js`: live
|
||||
ops at `/utils/liveops` (dashboard from `event_log`, timed events in `live_event` applied
|
||||
via `eventMultiplier` in game.go, `announcement`; both shown in the game's `#banner`).
|
||||
- `web/css/app.css`: Tailwind input (custom HUD styles); builds to `web/static/css/app.css`.
|
||||
- `web/app/` (the Preact client, `GET /app`, template `spa.html`): the Moons slice (header and
|
||||
wallet, Claim all, moon list with its Active/Past tabs, search and past pages, wells table
|
||||
with multi-well drills and claim-wide, the moon's resale, well panel with the better-well
|
||||
line, a past moon's haul, viewport, toasts) on the JSON API; other sections, buying a moon
|
||||
and the store link to the classic client. `session.js`: like the Defold client's session
|
||||
(`base` server state, `pending` predictions, `shown`/`live` computeds, an outbox sending
|
||||
one request at a time with `clientSeq`, rollback on 422, predicted navigation);
|
||||
`predict.js`: port of the Defold `predict.lua` (claim, stake, redrill; readiness is local,
|
||||
`end <= now`). `clock.js`: server clock, `edge` (bumped at each drill end), the frame loop
|
||||
and eased numbers. `ui.jsx`: `Num`, `Countdown`, `DrillBar` (Web Animation, keyed by tool
|
||||
id; corrections change its playback rate). `fx.js`: effects at the click from the
|
||||
predicted change, using exports of the classic `fx.js`/`sfx.js`. `scene.js` +
|
||||
`scene-worker.js`: the classic `MoonScene` in a worker on an OffscreenCanvas (main thread
|
||||
fallback, `?mainscene`). `dialog.jsx`: the confirm dialog (the classic markup, opened by
|
||||
the classic `ui.js`), for the API's `confirm` texts. Same classes as the classic markup (app.css); `preact.css` only
|
||||
for what differs.
|
||||
Rules: pass signals to JSX as they are; read `.value` only in logic, `computed()` and
|
||||
effects (`scripts/check-signals-jsx.mjs` in `make check` flags `.value` inside JSX).
|
||||
Key list items by stable ids (drills by tool id, not stake id: a predicted stake's id
|
||||
changes when the server answers); never swap an element that animates for another.
|
||||
Animate with transform/opacity (CSS, Web Animations), FLIP (`flip.js`) for rows that move.
|
||||
- `sql/tables.sql`, `sql/data.sql`: schema for fresh databases. `sql/migrations/` (001–016):
|
||||
changes for existing ones, applied on every `migrate`, so each must be idempotent.
|
||||
- `test-endpoints.restclient`: the up-to-date list of endpoints, in Emacs restclient format.
|
||||
- `public/assets/`: moon and item images.
|
||||
|
||||
## Commands
|
||||
- `npm run dev`: dev server on :3000 (the Dockerfile also runs `dev`, on Node 16).
|
||||
- `npm run lint`: next lint (`next/core-web-vitals`).
|
||||
- `npm run build`: type-check and build. There is no test suite, so lint and build are the checks.
|
||||
Don't build while `npm run dev` is running (they share `.next`).
|
||||
- Local DB: `docker compose up -d db` (see README).
|
||||
- `make dev`: server on :3000 that reloads templates/static from disk, with Tailwind and
|
||||
esbuild (the Preact client) watching. `MM_API_DELAY_MS=300` (with `MM_DEV=1`) holds every
|
||||
game request that long, to try both clients on a slow connection.
|
||||
- `make build`, `make check` (gofmt, vet, template check, JS syntax of every `web/static/js/*.js`,
|
||||
moon traits check, the Preact client's `.value` check and build), `make migrate`,
|
||||
`make load-config`. `go run . sim` prints the economy analysis.
|
||||
- Template check: `templates_test.go` (`go test -run Templates`, in `make check`) parses the
|
||||
templates like the server and renders every page and region against in-memory game states;
|
||||
when you add a page, section or view model branch, make sure its states/views reach it.
|
||||
- Local DB: `podman-compose up -d db` then `make migrate` (see README).
|
||||
There is no other test suite, except `wells_test.go` (`go test -run Wells -v`: well roll
|
||||
distribution, value by well count, resale odds, sim seeds).
|
||||
|
||||
## Deployment
|
||||
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app` (root `Dockerfile`),
|
||||
`db` (`docker/db/Dockerfile`, with the SQL baked in for first-time init), and `db-update`
|
||||
(`docker/db/update.sh`, which runs on every deploy before the app starts).
|
||||
- Production uses `docker-compose.prod.yml` (deployed with Dokploy): `app`, `db` (stock
|
||||
Postgres) and `migrate` (same image as `app`, runs on every deploy before the app starts).
|
||||
- Schema changes need both an update to `sql/tables.sql` (fresh DBs) and a new idempotent
|
||||
`sql/migrations/NNN-name.sql` (existing DBs). Function changes in `sql/procedures.sql` are
|
||||
reloaded automatically on deploy.
|
||||
`sql/migrations/NNN-name.sql` (existing DBs). Check that a fresh DB and one built from the
|
||||
pre-Go schema (`git show 610d26e:sql/tables.sql`) plus all migrations give the same
|
||||
`pg_dump --schema-only`.
|
||||
- Keep `docker-compose.prod.yml` services on the explicit `internal` network (Dokploy adds its own network).
|
||||
|
||||
## Environment
|
||||
Postgres credentials come from `.env`: `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`,
|
||||
`DB_DATABASE`. Never read, print or commit `.env`.
|
||||
`DB_DATABASE`, plus `ADMIN_DB_USER`/`ADMIN_DB_PASSWORD` for `migrate`. Never read, print or
|
||||
commit `.env`. `ADMIN_USER_IDS` (comma-separated player ids) grants access to `/utils`
|
||||
(unset: nobody); with `MM_DEV=1` everyone is an admin.
|
||||
|
||||
## Conventions
|
||||
- Put game rules in SQL functions (`sql/procedures.sql`) and keep API routes thin.
|
||||
If you change a function's signature, update the route that calls it and the matching type in `typings.d.ts`.
|
||||
- API route pattern: branch on `req.method`, return 200 with JSON, 400 with
|
||||
`{ error }` for DB/business errors, and 500 for unexpected errors.
|
||||
- Use the `@/*` alias for `src/*`. Import shared types from `"typings"`.
|
||||
- Client components need `"use client"`. Game UI is not React: add HUD markup in `Hud.ts`,
|
||||
handle the action in `Game.handleAction`, and dispose any three.js geometry and materials you create.
|
||||
- SQL is lowercase, with snake_case identifiers and `p_`-prefixed params. Schema changes go in `sql/tables.sql`.
|
||||
There is no migration tool, so mention any manual DB steps you need.
|
||||
- Game rules go in `game.go`, in a transaction, locking what they read. Keep handlers thin.
|
||||
- Layout: header, moon list, main area (one section at a time), and an aside with the 3D
|
||||
viewport and the selected well. Links are `<a href hx-get>` to a view URL; main.js keeps
|
||||
the address bar in sync.
|
||||
- New section: add it to `sections` and `State.page` (views.go) and a `<name>-section`
|
||||
template in `app.html`. New action: a case in `server.runAction` (shared by `/act/` and
|
||||
`/api/act/`) plus a button with `hx-post="/act/<action>"`. The current view (s, moon, well, drill) is sent automatically,
|
||||
so action parameters must use other names. Give it a sound in fx.js if it needs one.
|
||||
- Clicks give instant feedback only (busy state + spinner). No client-side game logic or
|
||||
prediction; the server is the authority. Pages don't reload on timers: countdown labels
|
||||
(`data-ends`) tick client-side and may enable a Claim button, as display only.
|
||||
- SQL is lowercase with snake_case identifiers.
|
||||
- When you add an endpoint, add it to `test-endpoints.restclient`.
|
||||
- New player action: log it with `logEvent(ctx, tx, userID, "<kind>", data)` inside its
|
||||
transaction (spends use the key `price`, earnings `credits`; the dashboard sums them).
|
||||
- Dispose any three.js geometry and materials you create.
|
||||
- Keep changes small. Don't refactor or move files unless asked.
|
||||
@@ -1,33 +1,24 @@
|
||||
# Production image for the Next.js app (game UI + API routes).
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-bookworm-slim AS prod-deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Production image: one Go binary with the templates, static files and SQL embedded.
|
||||
FROM docker.io/library/golang:1.25-bookworm AS build
|
||||
WORKDIR /src
|
||||
ARG TAILWIND_VERSION=v4.1.14
|
||||
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
|
||||
https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 \
|
||||
&& chmod +x /usr/local/bin/tailwindcss
|
||||
ARG ESBUILD_VERSION=0.28.2
|
||||
RUN curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-${ESBUILD_VERSION}.tgz \
|
||||
| tar xz -C /usr/local/bin --strip-components=2 package/bin/esbuild
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN tailwindcss -i web/css/app.css -o web/static/css/app.css --minify \
|
||||
&& make app ESBUILD=/usr/local/bin/esbuild \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /moonminers .
|
||||
|
||||
FROM node:20-bookworm-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY package.json next.config.js ./
|
||||
COPY config ./config
|
||||
COPY public ./public
|
||||
# db.ts opens a (legacy) SQLite file in the working directory, so it must be writable.
|
||||
RUN chown node:node /app
|
||||
USER node
|
||||
FROM scratch
|
||||
COPY --from=build /moonminers /moonminers
|
||||
USER 65532:65532
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node_modules/.bin/next", "start"]
|
||||
ENTRYPOINT ["/moonminers"]
|
||||
CMD ["serve"]
|
||||
@@ -0,0 +1,169 @@
|
||||
# Hand-off: Preact web client experiment (branch `s-preact`)
|
||||
|
||||
## Goal
|
||||
Find out whether a web client can feel as smooth and instant as the Defold client (which
|
||||
predicts click results locally and eases corrections). It's built with Preact + JSX +
|
||||
@preact/signals and compiled by esbuild, on the existing JSON API. It's served at `/app`
|
||||
next to the classic htmx client at `/`. The user will compare them by feel: fast
|
||||
claim/redrill/deploy clicks, and animations that never hitch.
|
||||
|
||||
## What's built
|
||||
- `web/app/` (about 2,400 lines without the vendored code):
|
||||
- `session.js`: a port of the Defold `session.lua`. Holds the `base`/`pending` signals,
|
||||
the `shown`/`live` computeds, and an outbox that sends one request at a time with
|
||||
`clientSeq`. A 422 rolls the prediction back. Navigation is predicted. It refetches at
|
||||
`nextEnd` and keeps the URL in sync.
|
||||
- `predict.js`: a port of `predict.lua` (claim, stake, redrill, derive). One deliberate
|
||||
difference: readiness is local (`s.ready || s.end <= now`).
|
||||
- `clock.js`: server clock offset. An `edge` signal is bumped by a timer at each drill
|
||||
end. Also a single rAF ticker loop and eased numbers (`eased`, `hold`/`release`).
|
||||
- `ui.jsx`:
|
||||
- `Num`: eased number, written by the ticker.
|
||||
- `Countdown`.
|
||||
- `DrillBar`: a WAAPI `scaleX` animation keyed by tool id. A server correction changes
|
||||
`playbackRate` so the bar lands on the true end without jumping.
|
||||
- `SupplyBar`: clip-path.
|
||||
- `buttons.jsx`: `ReadyButton`. It dispatches `mm:ready` when the clock (not a new
|
||||
state) enables it, and the classic fx.js then plays the pop, sparks and sound.
|
||||
- `header.jsx`, `moons.jsx`, `side.jsx`: the same markup and classes as the classic
|
||||
templates, so they use `app.css`. `preact.css` covers only the differences. The whole
|
||||
Moons slice: the moon list's Active/Past tabs, search and past pages, a past moon's
|
||||
haul, the multi-well row and claim-wide (not predicted: the server splits it), the
|
||||
moon sale, and the well panel's better-well line ("Deploy there" moves the view, so it
|
||||
waits for the server).
|
||||
- `dialog.jsx`: the classic confirm dialog (its markup, opened by the classic `ui.js`),
|
||||
for the API's `confirm` texts (the moon sale).
|
||||
- `fx.js`: effects at the click, from the predicted diff. Uses exports added to
|
||||
`web/static/js/fx.js` (`floatText`, `stream`, `mark`, `centerOf`, `formatMoney`) and to
|
||||
`sfx.js`.
|
||||
- `flip.js`: FLIP for keyed rows, with positions relative to the container.
|
||||
- `scene.js` + `scene-worker.js`: the classic `MoonScene` runs in a worker on an
|
||||
OffscreenCanvas. It uses stand-ins for the canvas, its container, `ResizeObserver` and
|
||||
`document`. Pointer and wheel events and the size are forwarded from the page. Drag and
|
||||
zoom were verified. If the worker fails it falls back to the page's thread;
|
||||
`?mainscene` forces that.
|
||||
- `vendor/`: preact 11.0.1 and signals 2.11.3 / signals-core 1.14.4. `jsconfig.json`
|
||||
holds the path aliases, including `three`, used by the worker bundle.
|
||||
- Go: `GET /app` renders `web/templates/spa.html`. `MM_API_DELAY_MS` (dev only) delays
|
||||
`/act/`, `/api/act/`, `/api/state` and htmx `GET /` for both clients.
|
||||
- Build:
|
||||
- `make app`: esbuild is fetched into `bin/` from the npm tarball. It outputs `app.js`,
|
||||
`app.css`, `scene-worker.js` and `moon-worker.js` to `web/static/app/`, which is
|
||||
gitignored and in `.dockerignore`.
|
||||
- `make dev` watches the client.
|
||||
- `make build` depends on `app`.
|
||||
- The Dockerfile fetches esbuild and runs `make app`.
|
||||
- `make check` (passes) also runs `scripts/check-signals-jsx.mjs`, which flags `.value`
|
||||
inside JSX, and builds the client.
|
||||
- AGENTS.md documents `web/app` and its rules. `/app` is in `templates_test.go` and
|
||||
`test-endpoints.restclient`.
|
||||
|
||||
## How to try it
|
||||
```
|
||||
cd wt-s-preact && make app && go build -o bin/moonminers .
|
||||
DB_DATABASE=mm_s_preact PORT=3151 MM_DEV=1 ./bin/moonminers serve
|
||||
# second, slow server on the same DB: MM_API_DELAY_MS=300 PORT=3152 ...
|
||||
```
|
||||
Open `http://localhost:3151/app` and `http://localhost:3151/`. They share the same player
|
||||
cookie. The database `mm_s_preact` is kept. `.env` was copied from `../server/.env` and is
|
||||
gitignored. **The servers are stopped now**; restart them with the commands above.
|
||||
|
||||
## Measurements
|
||||
|
||||
### Second run (Oct 20, after the Moons slice was finished)
|
||||
Headless Chrome 155 on macOS over the DevTools protocol (no puppeteer), Metal GPU
|
||||
(`--use-angle=metal --enable-gpu --ignore-gpu-blocklist`), 1440×900. Same method as below:
|
||||
the median of 3 alternating runs; latency from pointerdown to the first frame where the
|
||||
side panel's drill line count changes. Each test starts from a known state set through the
|
||||
API and admin endpoints (the wells topped up, one drone finished on the well). No other
|
||||
engine was running; the machine's load average was 6 to 9 from other work.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 30 ms | 13 ms | 347 ms | 14 ms |
|
||||
| redrill to visible result | 31 ms | 15 ms | 331 ms | 14 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 6 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 0, 0, 0 | 0, 0, 0 | 0, 0, 1 | 0, 0, 0 |
|
||||
|
||||
Main-thread time for one claim (Chrome's Performance.getMetrics counters, from the click to
|
||||
1.5 s after, 0 ms delay, median of 3):
|
||||
|
||||
| | script | layout | style | all tasks |
|
||||
|---|---|---|---|---|
|
||||
| classic | 25 ms | 5 ms | 9 ms | 69 ms |
|
||||
| app | 11 ms | 8 ms | 10 ms | 62 ms |
|
||||
|
||||
- The classic client's first claim of a page load had 109 ms of script (the AudioContext on
|
||||
the first gesture, as below); the others 24–25 ms.
|
||||
- Most of "all tasks" is the viewport and other frames in the window, the same in both.
|
||||
- With an idle GPU the frame drops are gone for both clients: the earlier noise was the
|
||||
other engine.
|
||||
- Side-by-side screenshots (moon, well panel, multi-well moon, past moons) are identical but
|
||||
for the header's Classic/Admin link.
|
||||
|
||||
### First run
|
||||
Headless Chromium 156 (Playwright's cache) with puppeteer-core, GPU via
|
||||
`--enable-gpu --use-angle=gl-egl`. Swiftshader is far too slow to use. Each figure is the
|
||||
median of 3 alternating runs. The latency is the time from pointerdown to the first frame
|
||||
where the side panel's drill lines change.
|
||||
|
||||
| | classic, 0 ms | app, 0 ms | classic, +300 ms | app, +300 ms |
|
||||
|---|---|---|---|---|
|
||||
| claim to visible result | 52 ms | 14 ms | 342 ms | 12 ms |
|
||||
| redrill to visible result | 41 ms | 11 ms | 329 ms | 8 ms |
|
||||
| 6 deploy clicks 120 ms apart that took effect | 6, 6, 5 | 6, 6, 6 | **2, 2, 2** | 6, 6, 6 |
|
||||
| Redrill clickable 150 ms after Claim | yes | yes | **no** | yes |
|
||||
| dropped frames (>25 ms) per run | 9, 7, 2 | 7, 12, 1 | 9, 5, 1 | 4, 4, 2 |
|
||||
|
||||
- The classic client drops clicks while a request runs: `body.is-busy` sets
|
||||
`pointer-events: none`.
|
||||
- Frame drops were noisy and about the same for both clients. Another agent's Defold engine
|
||||
was using about 460% CPU and the GPU.
|
||||
- Long-animation-frame entries show the frames waiting on the GPU or presentation rather
|
||||
than on scripts.
|
||||
- The worker viewport and the main-thread viewport (`app-main`) showed no clear difference
|
||||
under this load.
|
||||
- The first click in each run has a long task of about 120 ms in both clients. It comes
|
||||
from `sfx.js` creating the AudioContext on the first gesture.
|
||||
- The harness lives in the session scratchpad, which is not kept. If it's needed again,
|
||||
recreate it from this description: `measure.mjs` (in-page rAF sampling, longtask/LoAF
|
||||
observers, a `watch` predicate on the drill-line count, `page.mouse.click`), `setup.mjs`
|
||||
(a player via the admin endpoints: unlock all, 5 drones, credits, a moon) and
|
||||
`runall.sh`.
|
||||
|
||||
## Unfinished / next steps
|
||||
Done since the first hand-off: the well panel and Moons section gaps (better well, multi-well
|
||||
rows and claim-wide, moon sale, past moons tab, search and pages), navigation and sale and
|
||||
mined-out effects, the measurements on a quiet GPU with main-thread time per claim, and the
|
||||
side-by-side screenshots. What's left:
|
||||
1. Other sections (Equipment, Store, Market, Refinery, Advisors, Leaderboard, Help), buying
|
||||
a moon (the new-moon teaser and reveal) and the store link still open the classic client.
|
||||
The API has every section's state and the prediction rules for sells and refinery
|
||||
batches (api.md "Prediction").
|
||||
2. Moon sale, claim-wide and "Deploy there" wait for the server (a spinner); predicting them
|
||||
would need the server's split of multi-well claims and the sale's payout rules.
|
||||
3. The measurement harness lived in a session scratchpad again (CDP: an in-page rAF watch on
|
||||
the drill-line count, clicks through Input.dispatchMouseEvent, Performance.getMetrics
|
||||
around a claim). Worth keeping in `scripts/` if the comparison continues.
|
||||
4. Report: rough line counts are above. Hard parts:
|
||||
- predicted stake ids differ from the server's, so drill rows are keyed by tool id;
|
||||
- easing bar corrections (playbackRate);
|
||||
- OffscreenCanvas shims for OrbitControls (`getRootNode`, pointer capture, readonly
|
||||
`Event.type`);
|
||||
- avoiding ticker writes conflicting with Preact's children (the ticker writes into
|
||||
empty spans).
|
||||
|
||||
## Gotchas
|
||||
- `live` re-derives counts on every `edge`. Components read `live.value` in their body,
|
||||
which is allowed. Never read `.value` inside JSX.
|
||||
- Readiness from the clock: `clockCaused()` compares the time of the last edge with the
|
||||
last `shown` change, because Preact re-renders asynchronously.
|
||||
- The worker bundle includes three.js (about 600 KB). The page itself uses the import map
|
||||
only for the main-thread fallback.
|
||||
- esbuild needs `--tsconfig=web/app/jsconfig.json` so that `three` resolves for
|
||||
`web/static/js/scene.js` too.
|
||||
- Never `pkill -f` a pattern contained in your own command line; it kills your own shell.
|
||||
- `scripts/check-signals-jsx.mjs` flags any `.value` in JSX, including plain objects' fields
|
||||
(the sale lines' `value`): destructure them under another name.
|
||||
- Headless Chrome plays the game's sounds through the speakers; start it with `--mute-audio`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,50 @@
|
||||
TAILWIND_VERSION := v4.1.14
|
||||
TAILWIND := bin/tailwindcss
|
||||
CSS := web/static/css/app.css
|
||||
ESBUILD_VERSION := 0.28.2
|
||||
ESBUILD := bin/esbuild
|
||||
# The Preact client (web/app) bundled into web/static/app: app.js, app.css and the viewport's
|
||||
# worker with three.js (scene-worker.js, which starts moon-worker.js for meshes). The page loads the classic client's modules
|
||||
# (/static/js/...) by URL.
|
||||
APP := $(ESBUILD) app=web/app/main.jsx scene-worker=web/app/scene-worker.js moon-worker=web/static/js/moon-worker.js --bundle --format=esm --target=es2022 '--external:/static/*' --tsconfig=web/app/jsconfig.json --outdir=web/static/app
|
||||
|
||||
.PHONY: build dev css app migrate load-config check
|
||||
|
||||
build: css app
|
||||
go build -o bin/moonminers .
|
||||
|
||||
# Server on :3000 that reloads templates and static files from disk; CSS and the Preact
|
||||
# client rebuild on change.
|
||||
dev: $(TAILWIND) $(ESBUILD)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --watch=always & \
|
||||
$(APP) --sourcemap --watch=forever & \
|
||||
trap 'kill $$(jobs -p)' EXIT; MM_DEV=1 go run . serve
|
||||
|
||||
css: $(TAILWIND)
|
||||
$(TAILWIND) -i web/css/app.css -o $(CSS) --minify
|
||||
|
||||
app: $(ESBUILD)
|
||||
$(APP) --minify
|
||||
|
||||
migrate:
|
||||
go run . migrate
|
||||
|
||||
load-config:
|
||||
go run . load-config
|
||||
|
||||
check:
|
||||
test -z "$$(gofmt -l .)" && go vet ./...
|
||||
go test -run '^(TestTemplates|TestAPI)$$' -count=1 .
|
||||
for f in web/static/js/*.js; do node --check $$f || exit 1; done
|
||||
node --no-warnings scripts/check-moon-traits.mjs
|
||||
node scripts/check-signals-jsx.mjs web/app
|
||||
$(APP) --minify --log-level=warning
|
||||
|
||||
$(ESBUILD):
|
||||
mkdir -p bin
|
||||
curl -fsSL https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-$(ESBUILD_VERSION).tgz | tar xz -C bin --strip-components=2 package/bin/esbuild
|
||||
|
||||
$(TAILWIND):
|
||||
mkdir -p bin
|
||||
curl -fsSL -o $@ https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-linux-x64
|
||||
chmod +x $@
|
||||
@@ -2,146 +2,251 @@
|
||||
|
||||
A browser mining game. Players own moons (staking sources) with resource wells, buy and
|
||||
upgrade mining tools, stake them on wells, claim resources when the stake completes, and sell
|
||||
resources for balance. The game is a three.js scene with a small HTML overlay, served by
|
||||
Next.js 13 (TypeScript), on top of Postgres, where most game logic lives as PL/pgSQL functions.
|
||||
resources for credits (₥). Drill cycles last seconds, so play is active. The refinery turns
|
||||
resources into refined items on a timer; those sell for more and are needed as parts for
|
||||
higher tool upgrades.
|
||||
|
||||
Players join anonymously: on the first visit the browser creates a player
|
||||
(`POST /api/user/anonymous`) and keeps its id in `localStorage`. No wallet or sign-up needed.
|
||||
It's a Go server that renders the interface as HTML (with htmx for in-place updates) next to
|
||||
a three.js view of the selected moon, generated procedurally from its id. Game rules run in
|
||||
Go, one Postgres transaction per action. The page doesn't reload on timers: countdowns tick
|
||||
in the browser and enable each Claim button when its drill should be done, and the server
|
||||
decides what is actually ready when it's clicked.
|
||||
|
||||
Players join anonymously: on the first visit the server creates a player and stores its id in
|
||||
a cookie. No wallet or sign-up needed.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 16+ and npm
|
||||
- PostgreSQL 13+ (uses `gen_random_uuid()`) with the `plpython3u` language available and the
|
||||
Python `requests` package installed for the server's Python (needed by `import_config()`)
|
||||
- A Postgres superuser for the one-time database setup
|
||||
- Go 1.25+
|
||||
- PostgreSQL 16 (stock image is fine), or Podman/Docker to run it
|
||||
- Node.js, only for `make check` (syntax-checks the JS)
|
||||
|
||||
`make` downloads the Tailwind standalone CLI into `bin/` on first use.
|
||||
|
||||
## Environment
|
||||
|
||||
Create a `.env` file in the project root (it is gitignored, never commit it):
|
||||
The server reads `.env` in the project root (gitignored, never commit it). Real environment
|
||||
variables take precedence.
|
||||
|
||||
```dotenv
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_DATABASE=
|
||||
DB_USER=moonminers # app user: read/write access to the tables
|
||||
DB_PASSWORD=moonminers
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=moonminers
|
||||
ADMIN_DB_USER=postgres # superuser, only used by `migrate`
|
||||
ADMIN_DB_PASSWORD=postgres
|
||||
```
|
||||
|
||||
Optional: `PORT` (default 3000), `DB_SSLMODE` (default `disable`), `MM_DEV=1` (reload templates
|
||||
and static files from disk; also makes everyone an admin), `ADMIN_USER_IDS` (comma-separated
|
||||
player ids allowed on the admin pages; unset means no admins), `TRUST_PROXY=1` (behind a reverse proxy: rate-limit new
|
||||
players by the last `X-Forwarded-For` address instead of the connection's; new players are
|
||||
limited to 10 a minute per IP).
|
||||
|
||||
## Local development
|
||||
|
||||
`docker-compose.yml` runs a local Postgres (with `plpython3u` + `requests`) and loads the
|
||||
`sql/` files automatically on first start. The app itself runs on the host.
|
||||
|
||||
1. Create `.env` for the local database:
|
||||
|
||||
```dotenv
|
||||
DB_USER=moonminers
|
||||
DB_PASSWORD=moonminers
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=moonminers
|
||||
```
|
||||
|
||||
2. Start the database and import the game config (needs internet, fetches the Google Sheet):
|
||||
1. Create `.env` as above.
|
||||
2. Start Postgres and set up the database (roles, schema, migrations, store items from
|
||||
`config/game.json`):
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
docker compose exec db psql -U moonminers -d moonminers -c "select import_config();"
|
||||
podman-compose up -d db # or: docker compose up -d db
|
||||
make migrate
|
||||
```
|
||||
|
||||
3. Run the app and open http://localhost:3000. A new anonymous player is created
|
||||
automatically; "Start a new game" in the help panel (`?`) switches to a fresh one.
|
||||
3. Run the server and open http://localhost:3000:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
make dev
|
||||
```
|
||||
|
||||
Other commands: `npm run lint`, `npm run build` (type-check + production build). Don't run
|
||||
`npm run build` while `npm run dev` is running; both use `.next` and the dev server breaks
|
||||
(fix: stop it, `rm -rf .next`, start again).
|
||||
Reset the database (re-runs the SQL files): `docker compose down -v && docker compose up -d db --build`.
|
||||
Apply new migrations to an existing local database:
|
||||
`docker compose exec -T db psql -U postgres -d moonminers -f - < sql/migrations/<file>.sql`.
|
||||
Template, CSS and JS changes show up on reload; Go changes need a restart.
|
||||
|
||||
Other commands: `make build` (CSS + `bin/moonminers`), `make check` (gofmt, vet, the template
|
||||
check in `templates_test.go`, which renders every page against sample game states without a
|
||||
database, JS syntax, and `scripts/check-moon-traits.mjs`, which checks that `moondesc.go` and
|
||||
`terrain.js` agree on moon looks), `make load-config` (sync the store items in the database with `config/game.json`).
|
||||
|
||||
The binary's commands: `serve` (default), `migrate`, `load-config`, `sim [file]` (print the
|
||||
economy analysis and a 4-hour greedy-player sim for the game config, or for a config file),
|
||||
and `moon-traits` (JSON on stdin; used by the moon traits check).
|
||||
Reset the local database: `podman-compose down -v && podman-compose up -d db && make migrate`.
|
||||
|
||||
## Database changes
|
||||
|
||||
`migrate` runs on every deploy and must be safe to repeat. It creates the `devs` role and the
|
||||
app user, creates the schema from `sql/tables.sql` and `sql/data.sql` on an empty database,
|
||||
applies `sql/migrations/*.sql` in name order, grants the app user access, and syncs the
|
||||
store items with the game config.
|
||||
|
||||
For a schema change, update `sql/tables.sql` (fresh databases) and add an idempotent
|
||||
`sql/migrations/NNN-name.sql` (existing databases: `if not exists`, `if exists`). A fresh
|
||||
database and one migrated from the pre-Go schema through every migration must end up with the
|
||||
same schema (compare `pg_dump --schema-only`). The migrations so far: 001 drops the old config
|
||||
tables, 002 moon settings, 003 config overrides, 004 live ops, 005 refinery, 006 player
|
||||
admin, 007 drill cycles in seconds, 008 security fixes (UTC defaults, one claim per stake,
|
||||
non-negative well supply), 009 schema cleanup (old columns, moon images), 010 drill
|
||||
attachments and advisors, 011 market pressure, 012 onboarding, 013 moon type
|
||||
(`staking_source.kind`, now that moons get generated names).
|
||||
|
||||
## Game config
|
||||
|
||||
`config/game.json` holds the balance numbers (starting credits, resources and tools, moon
|
||||
price, well supply range, `wellCountWeights`: how many wells a new moon gets, resource prices
|
||||
in credits per unit), the store items with their
|
||||
upgrade tiers (tiers can also need refined `parts`; a drill cycle is `completionTimeInSecs`),
|
||||
the moon types (a name, a placeholder description: the look is procedural and the
|
||||
description is generated when the moon is bought, see `moondesc.go`, and `affinities`:
|
||||
weights of the resources its wells hold, 1 if unlisted), moon names (`moonNames`: roots, and
|
||||
weights for the suffix styles: Roman numeral, Greek letter, catalogue number, letter and
|
||||
number; a player never gets the same name twice), and the refinery
|
||||
(`refinery`: slot prices, max batch size, `bulkExponent` (a batch of n takes
|
||||
seconds × n^bulkExponent, so bigger batches are faster per unit), and the refined items with
|
||||
their recipe, seconds per unit and sell price). Market prices move: every 5 minutes each resource and refined item
|
||||
drifts within ±`marketVolatility` (default 15%) of its configured price, from a deterministic
|
||||
function of name and time (`market.go`), which also gives the 24h charts; sells use it.
|
||||
A player's own selling lowers their price a little (`marketPressure`): each sale adds its
|
||||
base value to their pressure on the item, which halves every `halfLifeSecs`, and the price
|
||||
factor is `floor + (1 − floor) × e^(−pressure / scale)`, so it flattens out at `floor`.
|
||||
Drill attachments (`attachments`: `slotsPerTool`, and per attachment a `price`, an `image`
|
||||
and `speed`/`yield` multipliers) are bought in the Store and equipped on a drill from its
|
||||
panel in Equipment, where idle drills and spare attachments can also be sold back for
|
||||
`equipmentResale.factor` of their credit price (a drill: its price plus its upgrades; refined
|
||||
parts aren't refunded). A moon sells back (`moonResale`) for what's left in its wells at market
|
||||
price × `factor`, at most `maxRatio` × the price of a replacement moon, no sooner than
|
||||
`minHoldSecs` after buying it, and not while a drill on it is running. Advisors (`advisors`: `seatPrices` for the 2nd, 3rd, ... seat, and per
|
||||
advisor a hire `price` and `speed`/`yield`/`sell`/`refine` multipliers) boost the whole
|
||||
account. A player's multipliers multiply together and each kind is capped by `bonusCaps`;
|
||||
live events multiply on top. They count when an action runs, so a stake keeps the bonuses
|
||||
it started with (`bonus.go`).
|
||||
It's embedded in the binary, so changes need a rebuild.
|
||||
Store items are also kept in the database (`migrate` and `make load-config` sync them;
|
||||
items removed from the file are deleted unless a player owns one).
|
||||
|
||||
Admins can change these numbers without a deploy on `/utils/economy` (see Admin panels).
|
||||
|
||||
## Admin panels
|
||||
|
||||
Admins are the player ids listed in `ADMIN_USER_IDS` (comma-separated). With `MM_DEV=1`
|
||||
everyone is an admin, so never set it in production. Other players get a 403 (the admin pages
|
||||
show "missing permission"). Every admin page
|
||||
shares one nav: Players · Moons · Economy · Live ops · Game.
|
||||
|
||||
| Page | What it's for |
|
||||
|---|---|
|
||||
| `/utils` | Redirects to `/utils/players`. |
|
||||
| `/utils/players` | Find players (id prefix or name), sort by last seen, activity, newest, richest. |
|
||||
| `/utils/players/{id}` | One player: balance, resources, refined items, moons and wells, tools and drills, event history (older pages at `/utils/players/{id}/events?page=n`). Grant/remove credits, grant resources and tools, finish running drills, reset, ban/unban. |
|
||||
| `/utils/moons` | Moon lab: live 3D preview with sliders for terrain, craters, colors, bloom, atmosphere and rotation. "Save for the game" applies the look to every moon. |
|
||||
| `/utils/economy` | Edit the game config without a deploy: tables for equipment (with upgrade tiers), resources, refined items, market, attachments, advisors, moon types and constants, saved together as a draft; live analysis of the draft, version history with revert/reset, raw JSON. |
|
||||
| `/utils/liveops` | Dashboard, timed events and announcements. |
|
||||
|
||||
Actions (all `POST`, confirmed in the page's own dialog):
|
||||
|
||||
| Endpoint | Actions |
|
||||
|---|---|
|
||||
| `/utils/players/{id}/{action}` | `credits`, `resources`, `tool`, `finish-stakes`, `reset`, `ban`, `unban` |
|
||||
| `/utils/economy/{action}` | `analyze`, `save`, `revert`, `reset` |
|
||||
| `/utils/liveops/{what}` | `events/save`, `announcements/save` |
|
||||
| `/utils/liveops/{what}/{id}/{action}` | toggle or delete an event, delete an announcement |
|
||||
| `/utils/{action}` | `clear-data`, `load-config` (older endpoints, no page) |
|
||||
| `/api/moon-settings` | `GET` (public) returns the saved moon look; `POST` (admins) saves it |
|
||||
|
||||
Request examples are in `test-endpoints.restclient`.
|
||||
|
||||
### Players
|
||||
|
||||
Every admin action is logged in `event_log` with the admin's id (`admin_*` kinds, which the
|
||||
live-ops stats ignore). Banned players see "This account is suspended" and their actions get a
|
||||
403.
|
||||
|
||||
### Moons
|
||||
|
||||
Each moon's look is generated from its id (terrain technique, noise, craters, colors). The lab
|
||||
edits the shared settings that every moon starts from; they're stored as one JSON row in
|
||||
`moon_settings` and merged over the defaults in `web/static/js/terrain.js`.
|
||||
|
||||
### Economy
|
||||
|
||||
A form for the constants, resource prices, store items and upgrade tiers (including parts),
|
||||
and the refinery, plus a raw JSON editor for every field. The analysis updates as you type:
|
||||
income and payback per tool and upgrade, refining gain per recipe, red flags, and a 72-hour
|
||||
greedy-player sim. Each save stores the values that differ from `config/game.json` as a new row
|
||||
in `config_override`; the latest row is in effect (merged over the file, reloaded every 30 s)
|
||||
and store items are re-synced.
|
||||
|
||||
### Live ops
|
||||
|
||||
- Dashboard: players, daily actives, D1/D7 retention, actions, credits earned vs spent,
|
||||
resources claimed. Built from `event_log`, which every game action writes to.
|
||||
- Timed events: yield, sell price or mining speed multipliers, applied server-side.
|
||||
- Announcements.
|
||||
|
||||
Live events and announcements show in a banner at the top of the game. Times are UTC.
|
||||
|
||||
## Interface
|
||||
|
||||
Server-rendered regions swapped by htmx, which morphs them into the page with the idiomorph
|
||||
extension (0BSD, `web/static/vendor/idiomorph`) so elements keep their state; the JS only
|
||||
adds display: `main.js` (htmx glue,
|
||||
viewport, countdowns), `fx.js` (effects per action result), `sfx.js` (sound, below),
|
||||
`tabs.js` (sliding tab highlight), `enter.js` (entrance animations when a view changes),
|
||||
`market.js` (chart crosshair), `ui.js` (confirm dialog and toasts, shared with the admin
|
||||
pages), `scene.js`/`terrain.js`/`moon-worker.js` (the moon).
|
||||
|
||||
## Sound
|
||||
|
||||
`web/static/js/sfx.js` plays sound effects with the Web Audio API: short clips from Kenney's
|
||||
CC0 packs (trimmed and loudness-matched, listed in `web/static/assets/sfx/CREDITS.txt`) and
|
||||
a few sounds synthesized with ZzFX (MIT, `web/static/vendor/zzfx`). The speaker button in the
|
||||
header mutes them (remembered per browser); they are also muted while the tab is hidden.
|
||||
`window.mmAudio.mute()` / `unmute()` mute from outside (e.g. during an ad) without touching
|
||||
the player's setting. Set `localStorage["mm-sfx-debug"] = "1"` to log every sound played.
|
||||
|
||||
## Security
|
||||
|
||||
- Players are identified only by the `mm_user` cookie (HttpOnly, SameSite=Lax). New players
|
||||
are rate-limited to 10 a minute per IP (`TRUST_PROXY=1` behind a proxy).
|
||||
- Request bodies are capped at 64 KB (1 MB for `/utils/economy/*`); forms are parsed up front,
|
||||
so oversized ones get a 413.
|
||||
- Every response sets `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`.
|
||||
- Game actions validate ids, lock what they read and keep balances and supplies at 0 or more
|
||||
(schema checks back this up); a stake is claimed at most once (unique index).
|
||||
- Errors that aren't rule violations are logged and answered with a generic 500.
|
||||
|
||||
## Deployment (Dokploy)
|
||||
|
||||
`docker-compose.prod.yml` contains the full stack:
|
||||
`docker-compose.prod.yml` runs `db` (Postgres 16, data in the `db-data` volume), `migrate`
|
||||
(schema and migrations, runs before the app on every deploy) and `app` (port 3000).
|
||||
|
||||
- `app`: the Next.js server (root `Dockerfile`), on port 3000.
|
||||
- `db`: Postgres 16 with plpython3u, storing data in the `db-data` volume. The SQL schema is
|
||||
loaded on first start.
|
||||
- `db-update`: a one-shot job that runs before the app on every deploy. It applies
|
||||
`sql/migrations/*.sql`, reloads the SQL functions, syncs the app user's password, and imports
|
||||
the game config from the Google Sheet.
|
||||
First deploy:
|
||||
|
||||
Steps in Dokploy:
|
||||
|
||||
1. Create a **Docker Compose** service from this Git repository and set the compose path to
|
||||
1. Dokploy: create a **Docker Compose** service from this repository, compose path
|
||||
`docker-compose.prod.yml`.
|
||||
2. In **Environment**, set two strong passwords:
|
||||
2. **Environment**:
|
||||
|
||||
```dotenv
|
||||
POSTGRES_PASSWORD=... # Postgres superuser (used by db-update)
|
||||
DB_PASSWORD=... # app user "moonminers"
|
||||
POSTGRES_PASSWORD=... # strong; Postgres superuser, used by migrate
|
||||
DB_PASSWORD=... # strong; app user "moonminers"
|
||||
ADMIN_USER_IDS= # fill in at step 5
|
||||
```
|
||||
|
||||
3. In **Domains**, add your domain for the service `app` with container port `3000`. Enable
|
||||
HTTPS if you want Let's Encrypt.
|
||||
4. Deploy. The first deploy initializes the database and imports the config. The database
|
||||
server needs outbound internet access to fetch the Google Sheet.
|
||||
Never set `MM_DEV` in production: it makes every player an admin.
|
||||
3. **Domains**: your domain → service `app`, port `3000`, HTTPS on.
|
||||
4. Coming from the old (Next.js) version and want a clean start? Stop the service and delete
|
||||
the `db-data` volume first. Otherwise `migrate` upgrades the old database in place.
|
||||
5. Deploy, open the site once, and copy your player id: the value of the `mm_user` cookie
|
||||
(browser dev tools → Storage/Application → Cookies). Put it in `ADMIN_USER_IDS`
|
||||
(comma-separate several) and redeploy. The admin pages are at `/utils`.
|
||||
|
||||
To redeploy after changes, push and deploy again. Data lives in the `db-data` volume, which
|
||||
survives redeploys. Back it up with `pg_dump` or Dokploy's volume backups.
|
||||
|
||||
Database changes for existing deployments go in a new `sql/migrations/NNN-name.sql`. Migrations
|
||||
run on every deploy, so write them to be idempotent (`if not exists`, `if exists`). Also update
|
||||
`sql/tables.sql` so fresh databases match.
|
||||
Updates: push to the branch Dokploy tracks and deploy; `migrate` applies new migrations.
|
||||
Back up `db-data` with `pg_dump` or Dokploy's volume backups.
|
||||
|
||||
To test the production stack locally:
|
||||
`POSTGRES_PASSWORD=x DB_PASSWORD=y docker compose -p mmprod -f docker-compose.prod.yml up --build`
|
||||
`POSTGRES_PASSWORD=x DB_PASSWORD=y podman-compose -p mmprod -f docker-compose.prod.yml up --build`
|
||||
(add a `ports: ["3100:3000"]` override for `app` to reach it).
|
||||
|
||||
## Manual database setup (without Docker)
|
||||
## Endpoints
|
||||
|
||||
Run once against a fresh database, as a superuser, in this order:
|
||||
|
||||
1. Create the app role(s). The SQL grants permissions to a role named `devs`, so it must exist,
|
||||
and the `DB_USER` used by the app must be a member of it (or be that role):
|
||||
|
||||
```sql
|
||||
create role devs;
|
||||
create user moonminers with password '...' in role devs;
|
||||
```
|
||||
|
||||
2. Load the schema, seed data, and functions:
|
||||
|
||||
```bash
|
||||
psql -d <db> -f sql/tables.sql # tables
|
||||
psql -d <db> -f sql/data.sql # grants to devs + resource types
|
||||
psql -d <db> -f sql/procedures.sql # game functions (stake, claim, purchase_item, ...)
|
||||
psql -d <db> -f sql/import-config.sql # import_config() + plpython3u download_sheets()
|
||||
```
|
||||
|
||||
Note: `sql/import-config.sql` marks `plpython3u` as trusted and `data.sql` grants
|
||||
`pg_read_server_files` to `devs`. Both are broad permissions; review them for production.
|
||||
|
||||
3. Import the game config (store items, upgrades, staking sources, constants) from the Google
|
||||
Sheet. `import_config()` downloads CSVs to `/tmp` on the database server, so the DB host needs
|
||||
outbound internet access. Either run it directly:
|
||||
|
||||
```sql
|
||||
select import_config();
|
||||
```
|
||||
|
||||
or, with the app running, `POST /api/import-config`.
|
||||
|
||||
Re-run this whenever the game config sheet changes.
|
||||
|
||||
Then run the app anywhere with Node 20: set the `DB_*` variables and run `npm ci && npm run build && npm run start`
|
||||
(port 3000). The root `Dockerfile` builds the same thing as an image.
|
||||
|
||||
## API endpoints
|
||||
|
||||
Refer to `test-endpoints.restclient` for an up-to-date list of endpoints. It uses the Emacs
|
||||
restclient format, but the requests are easy to replay with any HTTP client.
|
||||
See `test-endpoints.restclient` (Emacs restclient format).
|
||||
@@ -0,0 +1,438 @@
|
||||
package main
|
||||
|
||||
// Advisors: people a player hires once, for credits, who boost the whole operation (drill
|
||||
// speed, yield, sell prices, refinery speed; bonus.go combines them with drill attachments).
|
||||
// Seats are numbered: players start with one and buy more (advisors.seatPrices), like
|
||||
// refinery slots. Hiring needs a free seat and seats the advisor in the lowest one. Only
|
||||
// seated advisors give their bonus. A hired advisor can be unseated (kept on the payroll,
|
||||
// no bonus), seated again in a free seat, swapped with another (seated or not) or
|
||||
// dismissed. Dismissing refunds nothing (the price is a hiring fee, like seats and slots,
|
||||
// which aren't refunded either); hiring them again costs the full price. Like attachments,
|
||||
// changes count from the next action: running drills keep their bonus.
|
||||
// Tables advisor_hire (seat null: not seated) and users.advisor_seats.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type AdvisorConfig struct {
|
||||
// Price of the 2nd, 3rd, ... seat. Players start with one; the cap is 1 + len.
|
||||
SeatPrices []float64 `json:"seatPrices"`
|
||||
Items []Advisor `json:"items"`
|
||||
}
|
||||
|
||||
type Advisor struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"` // the specialty, e.g. "Commodities Broker"
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"` // portrait, in web/static/assets/advisors
|
||||
Price float64 `json:"price"` // to hire
|
||||
Speed float64 `json:"speed,omitempty"` // multipliers; unset is 1
|
||||
Yield float64 `json:"yield,omitempty"` //
|
||||
Sell float64 `json:"sell,omitempty"` //
|
||||
Refine float64 `json:"refine,omitempty"` //
|
||||
}
|
||||
|
||||
func (c *GameConfig) advisor(name string) *Advisor {
|
||||
for i := range c.Advisors.Items {
|
||||
if c.Advisors.Items[i].Name == name {
|
||||
return &c.Advisors.Items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Effect describes the bonus, e.g. "+8% sell prices".
|
||||
func (a *Advisor) Effect() string {
|
||||
var parts []string
|
||||
for _, e := range []struct {
|
||||
m float64
|
||||
what string
|
||||
}{{a.Speed, "drill speed"}, {a.Yield, "yield"}, {a.Sell, "sell prices"}, {a.Refine, "refinery speed"}} {
|
||||
if e.m > 1 {
|
||||
parts = append(parts, pctText(e.m)+" "+e.what)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// lockAdvisors locks the player's row, which serializes their advisor actions, and returns
|
||||
// how many seats they own.
|
||||
func lockAdvisors(ctx context.Context, tx pgx.Tx, userID string) (int, error) {
|
||||
var seats int
|
||||
err := tx.QueryRow(ctx,
|
||||
"select advisor_seats from users where id = $1 for no key update", userID).Scan(&seats)
|
||||
return seats, err
|
||||
}
|
||||
|
||||
// AdvisorHire is a hired advisor and their seat (0: not seated).
|
||||
type AdvisorHire struct {
|
||||
Name string
|
||||
Seat int
|
||||
}
|
||||
|
||||
// lockHires locks the player's row (see lockAdvisors) and reads their hires.
|
||||
func lockHires(ctx context.Context, tx pgx.Tx, userID string) (int, []AdvisorHire, error) {
|
||||
seats, err := lockAdvisors(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
select advisor, coalesce(seat, 0) from advisor_hire
|
||||
where user_id = $1 order by hired_at, advisor for update`, userID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
hires, err := pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire])
|
||||
return seats, hires, err
|
||||
}
|
||||
|
||||
func findHire(hires []AdvisorHire, name string) *AdvisorHire {
|
||||
for i := range hires {
|
||||
if hires[i].Name == name {
|
||||
return &hires[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freeSeat is the lowest empty seat, 0 if every seat is taken.
|
||||
func freeSeat(seats int, hires []AdvisorHire) int {
|
||||
for n := 1; n <= seats; n++ {
|
||||
taken := false
|
||||
for _, h := range hires {
|
||||
taken = taken || h.Seat == n
|
||||
}
|
||||
if !taken {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// hiredOne finds a hired advisor by name, or the error for one who isn't.
|
||||
func hiredOne(hires []AdvisorHire, name string) (*AdvisorHire, error) {
|
||||
if h := findHire(hires, name); h != nil {
|
||||
return h, nil
|
||||
}
|
||||
if cfg().advisor(name) == nil {
|
||||
return nil, gameErr("Unknown advisor")
|
||||
}
|
||||
return nil, gameErr("%s doesn't work for you", name)
|
||||
}
|
||||
|
||||
// setSeat moves a hired advisor to a seat (0: unseated).
|
||||
func setSeat(ctx context.Context, tx pgx.Tx, userID, name string, seat int) error {
|
||||
_, err := tx.Exec(ctx, "update advisor_hire set seat = nullif($3, 0) where user_id = $1 and advisor = $2",
|
||||
userID, name, seat)
|
||||
return err
|
||||
}
|
||||
|
||||
// hireAdvisor hires an advisor into the lowest free seat.
|
||||
func hireAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
||||
a := cfg().advisor(name)
|
||||
if a == nil {
|
||||
return gameErr("Unknown advisor")
|
||||
}
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
seats, hires, err := lockHires(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if findHire(hires, a.Name) != nil {
|
||||
return gameErr("%s already works for you", a.Name)
|
||||
}
|
||||
seat := freeSeat(seats, hires)
|
||||
if seat == 0 {
|
||||
return gameErr("All advisor seats are taken. Buy another seat first")
|
||||
}
|
||||
if err := debit(ctx, tx, userID, a.Price); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, "insert into advisor_hire(user_id, advisor, seat) values ($1, $2, $3)", userID, a.Name, seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "hire_advisor", map[string]any{"advisor": a.Name, "price": a.Price, "seat": seat})
|
||||
})
|
||||
}
|
||||
|
||||
// seatAdvisor puts a hired advisor in an empty seat (moving them if they sit elsewhere).
|
||||
func seatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string, seat int) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
seats, hires, err := lockHires(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := hiredOne(hires, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seat < 1 || seat > seats {
|
||||
return gameErr("No such seat")
|
||||
}
|
||||
if h.Seat == seat {
|
||||
return gameErr("%s already sits there", name)
|
||||
}
|
||||
for _, o := range hires {
|
||||
if o.Seat == seat {
|
||||
return gameErr("Seat %d is taken by %s", seat, o.Name)
|
||||
}
|
||||
}
|
||||
if err := setSeat(ctx, tx, userID, name, seat); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "seat_advisor", map[string]any{"advisor": name, "seat": seat, "from": h.Seat})
|
||||
})
|
||||
}
|
||||
|
||||
// unseatAdvisor takes a seated advisor off their seat; they stay hired, without a bonus.
|
||||
func unseatAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
_, hires, err := lockHires(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := hiredOne(hires, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.Seat == 0 {
|
||||
return gameErr("%s isn't seated", name)
|
||||
}
|
||||
if err := setSeat(ctx, tx, userID, name, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "unseat_advisor", map[string]any{"advisor": name, "seat": h.Seat})
|
||||
})
|
||||
}
|
||||
|
||||
// swapAdvisors exchanges two hired advisors' seats; one of them may be unseated (the
|
||||
// other then gives up their seat to them).
|
||||
func swapAdvisors(ctx context.Context, db *pgxpool.Pool, userID, name, other string) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
_, hires, err := lockHires(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := hiredOne(hires, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := hiredOne(hires, other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Name == b.Name {
|
||||
return gameErr("Pick two different advisors")
|
||||
}
|
||||
if a.Seat == 0 && b.Seat == 0 {
|
||||
return gameErr("Neither of them is seated")
|
||||
}
|
||||
// One at a time: the seats are unique.
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
seat int
|
||||
}{{a.Name, 0}, {b.Name, a.Seat}, {a.Name, b.Seat}} {
|
||||
if err := setSeat(ctx, tx, userID, step.name, step.seat); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "swap_advisors", map[string]any{"advisor": a.Name, "with": b.Name,
|
||||
"seat": b.Seat, "withSeat": a.Seat})
|
||||
})
|
||||
}
|
||||
|
||||
// dismissAdvisor lets a hired advisor go, freeing their seat. Nothing is refunded.
|
||||
func dismissAdvisor(ctx context.Context, db *pgxpool.Pool, userID, name string) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
_, hires, err := lockHires(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := hiredOne(hires, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "delete from advisor_hire where user_id = $1 and advisor = $2", userID, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "dismiss_advisor", map[string]any{"advisor": name, "seat": h.Seat})
|
||||
})
|
||||
}
|
||||
|
||||
// buyAdvisorSeat adds a seat at the next seat's price.
|
||||
func buyAdvisorSeat(ctx context.Context, db *pgxpool.Pool, userID string) (int, error) {
|
||||
var seats int
|
||||
err := inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
var err error
|
||||
if seats, err = lockAdvisors(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
prices := cfg().Advisors.SeatPrices
|
||||
if seats-1 >= len(prices) {
|
||||
return gameErr("You have every seat already")
|
||||
}
|
||||
price := prices[seats-1]
|
||||
if err := debit(ctx, tx, userID, price); err != nil {
|
||||
return err
|
||||
}
|
||||
seats++
|
||||
_, err = tx.Exec(ctx, "update users set advisor_seats = $2 where id = $1", userID, seats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, "buy_seat", map[string]any{"seats": seats, "price": price})
|
||||
})
|
||||
return seats, err
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ view
|
||||
|
||||
type AdvisorsPage struct {
|
||||
Cards []AdvisorCard
|
||||
Seats int
|
||||
Hired int // seats filled
|
||||
Benched int // hired, not seated
|
||||
SeatList []SeatSlot
|
||||
Bench []AdvisorCard // hired, not seated
|
||||
FreeSeat int // the lowest empty seat, 0 if none
|
||||
MaxSeats int
|
||||
NextSeat int // number of the next seat to buy
|
||||
NextSeatPrice float64 // 0 at the cap
|
||||
CanBuySeat bool
|
||||
Bonuses []BonusRow // the combined advisor bonus, capped
|
||||
Panel *AdvisorPanel // the selected hired advisor (View.Adv)
|
||||
}
|
||||
|
||||
// SeatSlot is a seat and who sits in it (nil: empty).
|
||||
type SeatSlot struct {
|
||||
N int
|
||||
Advisor *AdvisorCard
|
||||
}
|
||||
|
||||
type AdvisorCard struct {
|
||||
*Advisor
|
||||
Hired bool
|
||||
Seat int // 0: not seated (or not hired)
|
||||
CanAfford bool
|
||||
}
|
||||
|
||||
// AdvisorPanel is a hired advisor's details, with what can change: their seat, a swap or
|
||||
// dismissing them.
|
||||
type AdvisorPanel struct {
|
||||
AdvisorCard
|
||||
Moves []SeatSlot // every other seat: move there (empty) or swap with who sits there
|
||||
Swaps []AdvisorCard // seated: the unseated advisors who can take their seat
|
||||
Dismiss string // the dismiss dialog's question
|
||||
}
|
||||
|
||||
func (s *State) advisorsPage(v View) *AdvisorsPage {
|
||||
c := cfg()
|
||||
p := &AdvisorsPage{Seats: s.AdvisorSeats, MaxSeats: 1 + len(c.Advisors.SeatPrices),
|
||||
NextSeat: s.AdvisorSeats + 1,
|
||||
Bonuses: s.BonusRows()}
|
||||
if n := s.AdvisorSeats - 1; n >= 0 && n < len(c.Advisors.SeatPrices) {
|
||||
p.NextSeatPrice = c.Advisors.SeatPrices[n]
|
||||
p.CanBuySeat = s.Balance >= p.NextSeatPrice
|
||||
}
|
||||
card := func(a *Advisor) AdvisorCard {
|
||||
card := AdvisorCard{Advisor: a, CanAfford: s.Balance >= a.Price}
|
||||
for _, h := range s.Hires {
|
||||
if h.Name == a.Name {
|
||||
card.Hired, card.Seat = true, h.Seat
|
||||
}
|
||||
}
|
||||
return card
|
||||
}
|
||||
for i := range c.Advisors.Items {
|
||||
p.Cards = append(p.Cards, card(&c.Advisors.Items[i]))
|
||||
}
|
||||
// Hired advisors, also those since removed from the config (no bonus).
|
||||
hired := func(name string) AdvisorCard {
|
||||
if a := c.advisor(name); a != nil {
|
||||
return card(a)
|
||||
}
|
||||
return card(&Advisor{Name: name, Title: "Advisor"})
|
||||
}
|
||||
p.SeatList = make([]SeatSlot, s.AdvisorSeats)
|
||||
for i := range p.SeatList {
|
||||
p.SeatList[i].N = i + 1
|
||||
}
|
||||
for _, h := range s.Hires {
|
||||
hc := hired(h.Name)
|
||||
if h.Seat >= 1 && h.Seat <= len(p.SeatList) {
|
||||
p.SeatList[h.Seat-1].Advisor = &hc
|
||||
p.Hired++
|
||||
} else {
|
||||
p.Bench = append(p.Bench, hc)
|
||||
p.Benched++
|
||||
}
|
||||
}
|
||||
for _, slot := range p.SeatList {
|
||||
if slot.Advisor == nil {
|
||||
p.FreeSeat = slot.N
|
||||
break
|
||||
}
|
||||
}
|
||||
if v.Adv != "" && s.hire(v.Adv) != nil {
|
||||
pc := hired(v.Adv)
|
||||
panel := &AdvisorPanel{AdvisorCard: pc, Dismiss: dismissConfirm(pc)}
|
||||
for _, slot := range p.SeatList {
|
||||
if slot.N != pc.Seat {
|
||||
panel.Moves = append(panel.Moves, slot)
|
||||
}
|
||||
}
|
||||
if pc.Seat > 0 {
|
||||
panel.Swaps = p.Bench
|
||||
}
|
||||
p.Panel = panel
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// hire finds a hired advisor by name.
|
||||
func (s *State) hire(name string) *AdvisorHire { return findHire(s.Hires, name) }
|
||||
|
||||
// dismissConfirm is the question before dismissing an advisor.
|
||||
func dismissConfirm(c AdvisorCard) string {
|
||||
if c.Price > 0 {
|
||||
return fmt.Sprintf("Dismiss %s? The hiring fee isn't refunded: hiring them again costs %s.",
|
||||
c.Name, formatCredits(c.Price))
|
||||
}
|
||||
return fmt.Sprintf("Dismiss %s?", c.Name)
|
||||
}
|
||||
|
||||
// SeatFree reports whether the player has an empty advisor seat.
|
||||
func (p *AdvisorsPage) SeatFree() bool { return p.FreeSeat > 0 }
|
||||
|
||||
// SeatConfirm is the question before buying the next seat.
|
||||
func (p *AdvisorsPage) SeatConfirm() string {
|
||||
return fmt.Sprintf("Add advisor seat %d for %s?", p.NextSeat, formatCredits(p.NextSeatPrice))
|
||||
}
|
||||
|
||||
// HireWhy is why an advisor can't be hired (the Hire button's tooltip), "" if they can.
|
||||
func (p *AdvisorsPage) HireWhy(c AdvisorCard, balance float64) string {
|
||||
switch {
|
||||
case !p.SeatFree() && p.NextSeatPrice > 0:
|
||||
return "All seats taken. Buy another seat first"
|
||||
case !p.SeatFree():
|
||||
return "All seats taken"
|
||||
case !c.CanAfford:
|
||||
return shortBy(c.Price - balance)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SeatWhy is why an unseated advisor can't take a seat now, "" if they can.
|
||||
func (p *AdvisorsPage) SeatWhy() string {
|
||||
if p.SeatFree() {
|
||||
return ""
|
||||
}
|
||||
return "All seats taken. Unseat someone or buy another seat"
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
package main
|
||||
|
||||
// JSON API for game clients other than the htmx page (a Defold client): the same state the
|
||||
// views build and the same actions, run through runAction (server.go). Times are unix
|
||||
// milliseconds (null when unset), durations seconds; the client formats. See api.md.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// userHeader carries the player id for clients without cookies; responses echo it.
|
||||
const userHeader = "X-MM-User"
|
||||
|
||||
// apiFeatures are the onboarding features the state reports (onboarding.go).
|
||||
var apiFeatures = []string{"moons", "equipment", "market", "store", "refinery", "advisors",
|
||||
"leaderboard", "help", "new-moon", "claim-all", "redrill", "redeploy"}
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
type APIState struct {
|
||||
UserID string `json:"userId"`
|
||||
Now int64 `json:"now"`
|
||||
View APIView `json:"view"` // the resolved view (actions may move it)
|
||||
Credits float64 `json:"credits"`
|
||||
Resources []APIAmount `json:"resources"`
|
||||
Refined []APIAmount `json:"refined"`
|
||||
MoonPrice float64 `json:"moonPrice"`
|
||||
Unlocked map[string]bool `json:"unlocked"`
|
||||
Shine []string `json:"shine"` // revealed tabs not opened yet
|
||||
Ready int `json:"ready"` // finished drills, all moons
|
||||
Idle int `json:"idle"` // tools not mining
|
||||
NextEnd *int64 `json:"nextEnd"`
|
||||
Moons []APIMoonListItem `json:"moons"` // active moons
|
||||
Past []APIMoonListItem `json:"past"` // mined-out moons, last finished first: one page (page=)
|
||||
PastPage APIPastPage `json:"pastPage"`
|
||||
Tools []APITool `json:"tools"`
|
||||
Stakes []APIStake `json:"stakes"` // every running or unclaimed drill
|
||||
Moon *APIMoon `json:"moon"` // s=moons with a moon
|
||||
Well *APIWell `json:"well"` // s=moons with a well selected
|
||||
Market *APIMarket `json:"market"` // once the market is unlocked
|
||||
Banner []APIBanner `json:"banner"`
|
||||
|
||||
// Always present (api_sections.go).
|
||||
ShowNav bool `json:"showNav"` // the section tabs show (not while Moons would be the only one)
|
||||
Jobs []APIJob `json:"jobs"` // refinery batches, once the refinery is open
|
||||
Bonuses []APIBonusRow `json:"bonuses"` // active advisor bonuses (the bonus chips)
|
||||
LiveEvents []APILiveEvent `json:"liveEvents"`
|
||||
Rules *APIRules `json:"rules"`
|
||||
Catalog *APICatalog `json:"catalog,omitempty"` // left out with catalog=0 or catalog=<catalogVersion>
|
||||
// A hash of the catalog: send it back as catalog= to skip the catalog while it's unchanged.
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
|
||||
// Section blocks: present for the view's section (s=) and the sections in include=.
|
||||
Equipment *APIEquipment `json:"equipment,omitempty"`
|
||||
Store *APIStore `json:"store,omitempty"`
|
||||
Refinery *APIRefinery `json:"refinery,omitempty"`
|
||||
Advisors *APIAdvisors `json:"advisors,omitempty"`
|
||||
Leaderboard *APILeaderboard `json:"leaderboard,omitempty"`
|
||||
Help *APIHelp `json:"help,omitempty"`
|
||||
NewMoon *APINewMoon `json:"newMoon,omitempty"`
|
||||
}
|
||||
|
||||
type APIView struct {
|
||||
Section string `json:"section"`
|
||||
Moon string `json:"moon"`
|
||||
Well string `json:"well"`
|
||||
Past bool `json:"past"`
|
||||
Pick string `json:"pick"`
|
||||
Drill string `json:"drill"`
|
||||
Page int `json:"page"` // the page of past moons (from 1)
|
||||
Adv string `json:"adv"` // the selected hired advisor (advisors section)
|
||||
Aside bool `json:"aside"` // the web shows the aside (viewport and side panel)
|
||||
}
|
||||
|
||||
// APIPastPage is the page of past moons in past: request others with page=<n>.
|
||||
type APIPastPage struct {
|
||||
Page int `json:"page"` // from 1, most recently finished first
|
||||
Pages int `json:"pages"` // at least 1
|
||||
Total int `json:"total"` // past moons in all
|
||||
PerPage int `json:"perPage"`
|
||||
}
|
||||
|
||||
type APIAmount struct {
|
||||
Name string `json:"name"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// APIRating is a moon's rating as the web shows it: the tier and its label, the stars,
|
||||
// the percentile and "top N%" (the share of moons at least this good).
|
||||
type APIRating struct {
|
||||
Tier string `json:"tier"`
|
||||
Label string `json:"label"`
|
||||
Stars int `json:"stars"`
|
||||
Pct int `json:"pct"`
|
||||
Top int `json:"top"`
|
||||
}
|
||||
|
||||
type APIWellSupply struct {
|
||||
ID string `json:"id"`
|
||||
Resource string `json:"resource"`
|
||||
Supply float64 `json:"supply"`
|
||||
}
|
||||
|
||||
type APIMoonListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Rating *APIRating `json:"rating"`
|
||||
Wells []APIWellSupply `json:"wells"`
|
||||
Drills int `json:"drills"`
|
||||
Ready int `json:"ready"`
|
||||
Over bool `json:"over"` // a well has more drills than supply
|
||||
Selected bool `json:"selected"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
Finished bool `json:"finished"`
|
||||
FinishedAt *int64 `json:"finishedAt"`
|
||||
Mined []APIAmount `json:"mined,omitempty"` // past moons: claimed from it
|
||||
FinishedOn string `json:"finishedOn,omitempty"` // past moons: the web's date, e.g. "Oct 18"
|
||||
}
|
||||
|
||||
type APITool struct {
|
||||
ID string `json:"id"`
|
||||
StoreItemID int `json:"storeItemId"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"` // model code, e.g. "PD-1"
|
||||
Image string `json:"image"` // static URL
|
||||
Tier int `json:"tier"`
|
||||
MaxTier int `json:"maxTier"`
|
||||
Reach int `json:"reach"` // wells one cycle mines
|
||||
CycleSecs int `json:"cycleSecs"` // a stake now, with bonuses and live events
|
||||
Yield float64 `json:"yield"`
|
||||
Attachments []string `json:"attachments"`
|
||||
StakeID string `json:"stakeId,omitempty"` // mining, if set
|
||||
}
|
||||
|
||||
type APIStake struct {
|
||||
ID string `json:"id"`
|
||||
MoonID string `json:"moonId"`
|
||||
WellID string `json:"wellId"` // the first well it mines
|
||||
ToolID string `json:"toolId"`
|
||||
Resource string `json:"resource"`
|
||||
Amount float64 `json:"amount"`
|
||||
Reach int `json:"reach"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Secs int `json:"durationSecs"`
|
||||
Ready bool `json:"ready"`
|
||||
Take float64 `json:"take"` // what claiming it would get now
|
||||
// The web's words for its reach: "Mines 3 wells", "Strips the whole moon", "" for one.
|
||||
ReachText string `json:"reachText"`
|
||||
Covers []string `json:"covers,omitempty"` // multi-well drills: the wells it mines
|
||||
// Moon detail only (projection, claims oldest first): what it gets in all and from
|
||||
// this well, and whether that is its full amount (ok, short, none).
|
||||
Gets *float64 `json:"gets,omitempty"`
|
||||
Here *float64 `json:"here,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Fate string `json:"fate,omitempty"` // the web's tooltip line, e.g. "Prospector Drone PD-1 T0: +2"
|
||||
}
|
||||
|
||||
type APIDrillGroup struct {
|
||||
Drills []APIStake `json:"drills"`
|
||||
Ready int `json:"ready"`
|
||||
ReadyAmount float64 `json:"readyAmount"` // what claiming the ready ones gets
|
||||
NextEnd *int64 `json:"nextEnd"`
|
||||
Cap float64 `json:"cap"`
|
||||
Label string `json:"label"` // the web's compact label of its drills, e.g. "PD-1 ×3 +1"
|
||||
}
|
||||
|
||||
type APIMoon struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
Rating *APIRating `json:"rating"`
|
||||
Finished bool `json:"finished"`
|
||||
FinishedOn string `json:"finishedOn,omitempty"` // past moon: the web's date, e.g. "Oct 18"
|
||||
Ready int `json:"ready"`
|
||||
NextEnd *int64 `json:"nextEnd"`
|
||||
Wells []APIWellRow `json:"wells"`
|
||||
Wide *APIDrillGroup `json:"wide"` // drills that mine several wells (claim-wide)
|
||||
Sale *APIMoonSale `json:"sale"` // the resale offer, once unlocked.store (null for a past moon)
|
||||
}
|
||||
|
||||
type APIWellRow struct {
|
||||
APIWellSupply
|
||||
APIDrillGroup // drills that mine only this well (claim-well)
|
||||
Wide []string `json:"wide"` // ids of the moon's multi-well drills that mine it too
|
||||
Selected bool `json:"selected"`
|
||||
Redrill int `json:"redrill"` // idle tools that last mined it
|
||||
Demand float64 `json:"demand"`
|
||||
Remain float64 `json:"remain"`
|
||||
DryAfter int `json:"dryAfter"` // the claim that empties it (0: none)
|
||||
DryAt *int64 `json:"dryAt"`
|
||||
// Shared supply, as the web's well note: over (drills want more than it holds), full
|
||||
// (they'll drain it), statusText the note's lead ("Over-drilled — next claims will come
|
||||
// up short", "Fully committed" or ""), noteText the rest ("47 left · 2 committed · 45
|
||||
// free after", "" with no demand).
|
||||
Over bool `json:"over"`
|
||||
Full bool `json:"full"`
|
||||
StatusText string `json:"statusText"`
|
||||
NoteText string `json:"noteText"`
|
||||
}
|
||||
|
||||
type APIWell struct {
|
||||
ID string `json:"id"`
|
||||
Resource string `json:"resource"`
|
||||
Rate float64 `json:"rate"` // credits per unit now
|
||||
Idle []APIToolGroup `json:"idle"` // tools to deploy here
|
||||
Better *APIBetterWell `json:"better"`
|
||||
}
|
||||
|
||||
type APIToolGroup struct {
|
||||
ToolID string `json:"toolId"` // send as tool= to stake (an idle copy is picked)
|
||||
StoreItemID int `json:"storeItemId"`
|
||||
Name string `json:"name"`
|
||||
Tier int `json:"tier"`
|
||||
Count int `json:"count"`
|
||||
CycleSecs int `json:"cycleSecs"`
|
||||
Yield float64 `json:"yield"`
|
||||
Reach int `json:"reach"`
|
||||
Here float64 `json:"here"` // what a cycle here gets after the drills already on the moon
|
||||
Free float64 `json:"free"`
|
||||
Short bool `json:"short"`
|
||||
Attachments []string `json:"attachments"`
|
||||
}
|
||||
|
||||
type APIBetterWell struct {
|
||||
MoonID string `json:"moonId"`
|
||||
WellID string `json:"wellId"`
|
||||
Resource string `json:"resource"`
|
||||
Free float64 `json:"free"`
|
||||
ToolID string `json:"toolId"`
|
||||
}
|
||||
|
||||
type APIMarket struct {
|
||||
Quotes []APIQuote `json:"quotes"`
|
||||
Selected string `json:"selected"`
|
||||
Total float64 `json:"total"` // value of everything held
|
||||
Held int `json:"held"` // kinds held
|
||||
Best string `json:"best"` // biggest 24h gain
|
||||
Worst string `json:"worst"` // biggest 24h loss
|
||||
Chart *APIChart `json:"chart"` // s=market (or include=market): the selected quote's 24h history
|
||||
}
|
||||
|
||||
type APIQuote struct {
|
||||
Item string `json:"item"`
|
||||
Refined bool `json:"refined"`
|
||||
Balance float64 `json:"balance"`
|
||||
Price float64 `json:"price"` // one unit sells for now: rate × boost × advisor × factor
|
||||
Rate float64 `json:"rate"`
|
||||
Boost float64 `json:"boost"`
|
||||
Advisor float64 `json:"advisor"`
|
||||
Pressure float64 `json:"pressure"`
|
||||
Factor float64 `json:"factor"`
|
||||
Open float64 `json:"open"` // market rate 24h ago
|
||||
Change float64 `json:"change"` // percent over 24h
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Value float64 `json:"value"`
|
||||
// For the sell ticket: the price before selling pressure, the base price (pressure is
|
||||
// counted in base-price credits) and what selling everything held pays now.
|
||||
Symbol string `json:"symbol"`
|
||||
Unpressed float64 `json:"unpressed"`
|
||||
Base float64 `json:"base"`
|
||||
SellAll float64 `json:"sellAll"`
|
||||
Spark []float64 `json:"spark,omitempty"` // s=market: 24h, every 3rd point (15 minutes) and now
|
||||
// The web's texts: up (change >= 0, green ▲), changeText "+1.25%"/"−0.50%"; pressed
|
||||
// (selling pressure lowers the price noticeably), pressureText "−4%"; boosted (a live
|
||||
// event), boostText "×2"; advised (an advisor sell bonus), advisorText "+8%". Each
|
||||
// text is "" when its flag is false.
|
||||
Up bool `json:"up"`
|
||||
ChangeText string `json:"changeText"`
|
||||
Pressed bool `json:"pressed"`
|
||||
PressureText string `json:"pressureText"`
|
||||
Boosted bool `json:"boosted"`
|
||||
BoostText string `json:"boostText"`
|
||||
Advised bool `json:"advised"`
|
||||
AdvisorText string `json:"advisorText"`
|
||||
}
|
||||
|
||||
// APIBanner is a banner line: an announcement or a live event (id matches liveEvents[].id).
|
||||
type APIBanner struct {
|
||||
Level string `json:"level"`
|
||||
Text string `json:"text"`
|
||||
Left string `json:"left"` // the web's "3h 20m left" (events only, else "")
|
||||
Kind string `json:"kind"` // announcement, event
|
||||
ID int `json:"id"`
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
func unixMS(t time.Time) *int64 {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
ms := t.UnixMilli()
|
||||
return &ms
|
||||
}
|
||||
|
||||
func apiRating(r *Rating) *APIRating {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &APIRating{Tier: r.Tier, Label: r.Label, Stars: r.Stars, Pct: r.Pct, Top: r.Top()}
|
||||
}
|
||||
|
||||
func apiAmounts(rows []ResourceBalance) []APIAmount {
|
||||
out := []APIAmount{}
|
||||
for _, r := range rows {
|
||||
out = append(out, APIAmount{Name: r.Resource, Amount: r.Balance})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiGear(gear []*Attachment) []string {
|
||||
out := []string{}
|
||||
for _, a := range gear {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiStake(st StakeRow, projected bool) APIStake {
|
||||
a := APIStake{ID: st.ID, MoonID: st.MoonID, WellID: st.WellID, ToolID: st.ToolID, Resource: st.Resource,
|
||||
Amount: st.Amount, Reach: max(1, st.Reach), Start: st.Start.UnixMilli(), End: st.End().UnixMilli(),
|
||||
Secs: st.DurationInSecs, Ready: st.Ready, Take: st.Take, Covers: st.Covers, ReachText: st.ReachText()}
|
||||
if projected {
|
||||
gets, here := st.Gets, st.Here
|
||||
a.Gets, a.Here, a.State, a.Fate = &gets, &here, st.State, st.Fate()
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func apiGroup(g DrillGroup) APIDrillGroup {
|
||||
a := APIDrillGroup{Drills: []APIStake{}, Ready: g.Ready, ReadyAmount: g.ReadyAmount, NextEnd: unixMS(g.NextEnd), Cap: g.Cap,
|
||||
Label: g.DrillLabel()}
|
||||
for _, d := range g.Drills {
|
||||
a.Drills = append(a.Drills, apiStake(d, true))
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func apiMoonList(rows []MoonListRow) []APIMoonListItem {
|
||||
out := []APIMoonListItem{}
|
||||
for _, r := range rows {
|
||||
m := APIMoonListItem{ID: r.ID, Name: r.Name, Kind: r.Kind, Rating: apiRating(r.Rating), Wells: []APIWellSupply{},
|
||||
Drills: r.Drills, Ready: r.Ready, Over: r.Over, Selected: r.Selected, CreatedAt: r.CreatedAt.UnixMilli(),
|
||||
Finished: r.Finished, FinishedAt: unixMS(r.FinishedAt)}
|
||||
for _, w := range r.Wells {
|
||||
m.Wells = append(m.Wells, APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply})
|
||||
}
|
||||
if r.Finished {
|
||||
m.Mined, m.FinishedOn = apiAmounts(r.Mined), r.FinishedOn()
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// apiState is the JSON state for a page the views built (State.page). extra are pages of
|
||||
// other sections (include=) whose blocks it adds.
|
||||
func apiState(p *Page, extra ...*Page) *APIState {
|
||||
s, v := p.S, p.V
|
||||
a := &APIState{UserID: s.UserID, Now: s.Now.UnixMilli(),
|
||||
View: APIView{Section: v.Section, Moon: v.Moon, Well: v.Well, Past: v.Past, Pick: v.Pick, Drill: v.Drill,
|
||||
Page: v.Page, Adv: v.Adv, Aside: p.Scene.Select != nil},
|
||||
PastPage: APIPastPage{Page: s.PastPage, Pages: s.pastPages(), Total: s.PastTotal, PerPage: pastPerPage},
|
||||
Credits: s.Balance, Resources: apiAmounts(s.Resources), Refined: []APIAmount{}, MoonPrice: s.MoonPrice(),
|
||||
Unlocked: map[string]bool{}, Shine: []string{}, Ready: p.Ready, Idle: p.Idle, NextEnd: unixMS(p.NextEnd),
|
||||
Moons: apiMoonList(p.Moons), Past: apiMoonList(p.Past), Tools: []APITool{}, Stakes: []APIStake{},
|
||||
Banner: []APIBanner{}}
|
||||
for _, r := range s.Refined {
|
||||
a.Refined = append(a.Refined, APIAmount{Name: r.Item, Amount: r.Balance})
|
||||
}
|
||||
for _, f := range apiFeatures {
|
||||
switch f {
|
||||
case "redrill":
|
||||
a.Unlocked[f] = s.Unlocked("market")
|
||||
case "redeploy":
|
||||
a.Unlocked[f] = s.Unlocked("store")
|
||||
default:
|
||||
a.Unlocked[f] = s.Unlocked(f)
|
||||
}
|
||||
}
|
||||
for _, tab := range onboardingTabs {
|
||||
if s.Shine(tab) {
|
||||
a.Shine = append(a.Shine, tab)
|
||||
}
|
||||
}
|
||||
for _, t := range s.Tools {
|
||||
item := s.StoreItem(t.StoreItemID)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
tool := APITool{ID: t.ID, StoreItemID: t.StoreItemID, Name: item.Name, Code: toolCode(item.Name),
|
||||
Image: itemImages + item.Image, Tier: t.Tier, MaxTier: len(item.Upgrades),
|
||||
Reach: max(1, item.ToolReach(t.Tier)), Attachments: apiGear(s.gear(t.ID))}
|
||||
tool.CycleSecs, tool.Yield = s.drill(t, item, "")
|
||||
if st := s.StakeForTool(t.ID); st != nil {
|
||||
tool.StakeID = st.ID
|
||||
}
|
||||
a.Tools = append(a.Tools, tool)
|
||||
}
|
||||
for _, st := range s.Stakes {
|
||||
a.Stakes = append(a.Stakes, apiStake(s.stakeRow(st), false))
|
||||
}
|
||||
|
||||
if d := p.Moon; d != nil {
|
||||
m := &APIMoon{ID: d.ID, Name: d.Name, Kind: d.Kind, Description: d.Description, Rating: apiRating(d.Rating),
|
||||
Finished: d.Finished, FinishedOn: d.FinishedOn(), Ready: d.Ready, NextEnd: unixMS(d.NextEnd), Wells: []APIWellRow{}}
|
||||
if d.Wide != nil {
|
||||
g := apiGroup(*d.Wide)
|
||||
m.Wide = &g
|
||||
}
|
||||
for _, w := range d.Wells {
|
||||
row := APIWellRow{APIWellSupply: APIWellSupply{ID: w.ID, Resource: w.Resource, Supply: w.Supply},
|
||||
APIDrillGroup: apiGroup(w.DrillGroup), Wide: []string{}, Selected: w.Selected, Redrill: w.Redrill,
|
||||
Demand: w.Demand, Remain: w.Remain, DryAfter: w.DryAfter, DryAt: unixMS(w.DryAt),
|
||||
Over: w.Over(), Full: w.Full(), StatusText: w.StatusText(), NoteText: w.NoteText()}
|
||||
for _, st := range w.Wide {
|
||||
row.Wide = append(row.Wide, st.ID)
|
||||
}
|
||||
m.Wells = append(m.Wells, row)
|
||||
}
|
||||
if s.Unlocked("store") {
|
||||
m.Sale = apiMoonSale(d.Sale)
|
||||
}
|
||||
a.Moon = m
|
||||
}
|
||||
if w := p.Well; w != nil {
|
||||
well := &APIWell{ID: w.ID, Resource: w.Resource, Rate: w.Rate, Idle: []APIToolGroup{}}
|
||||
for _, g := range w.Idle {
|
||||
well.Idle = append(well.Idle, APIToolGroup{ToolID: g.ID, StoreItemID: g.StoreItemID, Name: g.Item.Name,
|
||||
Tier: g.Tier, Count: g.Count, CycleSecs: g.Secs, Yield: g.Yield, Reach: max(1, g.Reach), Here: g.Here,
|
||||
Free: g.Free, Short: g.Short(), Attachments: apiGear(g.Gear)})
|
||||
}
|
||||
if b := w.Better; b != nil {
|
||||
well.Better = &APIBetterWell{MoonID: b.MoonID, WellID: b.WellID, Resource: b.Resource, Free: b.Free}
|
||||
if b.Tool != nil {
|
||||
well.Better.ToolID = b.Tool.ID
|
||||
}
|
||||
}
|
||||
a.Well = well
|
||||
}
|
||||
|
||||
// The market is part of the core loop, so it comes with every state once unlocked.
|
||||
if trade := p.Trade; trade != nil || s.Unlocked("market") {
|
||||
if trade == nil {
|
||||
var rows []MarketRow
|
||||
for _, r := range s.Resources {
|
||||
rows = append(rows, MarketRow{ResourceBalance: r})
|
||||
}
|
||||
if s.Unlocked("refinery") {
|
||||
rows = append(rows, s.refinedMarketRows()...)
|
||||
}
|
||||
trade = s.marketPage(rows, v)
|
||||
}
|
||||
a.Market = apiMarket(trade, false)
|
||||
}
|
||||
for _, b := range p.Banner {
|
||||
a.Banner = append(a.Banner, APIBanner{Level: b.Level, Text: b.Text, Left: b.Left, Kind: b.Kind, ID: b.ID,
|
||||
End: b.EndsAt.UnixMilli()})
|
||||
}
|
||||
|
||||
a.ShowNav, a.Jobs, a.Bonuses = s.ShowNav(), []APIJob{}, apiBonusRows(s.Bonus())
|
||||
a.LiveEvents, a.Rules, a.Catalog = []APILiveEvent{}, apiRules(p), apiCatalog(s)
|
||||
a.CatalogVersion = a.Catalog.version()
|
||||
if s.Unlocked("refinery") {
|
||||
for _, j := range s.Jobs {
|
||||
a.Jobs = append(a.Jobs, apiJob(j, a.Now))
|
||||
}
|
||||
}
|
||||
for _, page := range append([]*Page{p}, extra...) {
|
||||
a.addSection(page)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// addSection adds the block of a page's section, unless an earlier page added it.
|
||||
func (a *APIState) addSection(p *Page) {
|
||||
switch p.V.Section {
|
||||
case "equipment":
|
||||
if a.Equipment == nil {
|
||||
a.Equipment = apiEquipment(p)
|
||||
}
|
||||
case "store":
|
||||
if a.Store == nil {
|
||||
a.Store = apiStore(p)
|
||||
}
|
||||
case "refinery":
|
||||
if a.Refinery == nil && p.Refinery != nil {
|
||||
a.Refinery = apiRefinery(p)
|
||||
}
|
||||
case "advisors":
|
||||
if a.Advisors == nil && p.Advisors != nil {
|
||||
a.Advisors = apiAdvisors(p)
|
||||
}
|
||||
case "leaderboard":
|
||||
if a.Leaderboard == nil && p.Ranking != nil {
|
||||
a.Leaderboard = apiLeaderboard(p.Ranking)
|
||||
}
|
||||
case "help":
|
||||
if a.Help == nil {
|
||||
a.Help = apiHelp(p.S)
|
||||
}
|
||||
case "new-moon":
|
||||
if a.NewMoon == nil && p.NewMoon != nil {
|
||||
a.NewMoon = apiNewMoon(p.NewMoon)
|
||||
}
|
||||
case "market":
|
||||
if p.Trade != nil && (a.Market == nil || a.Market.Chart == nil) {
|
||||
a.Market = apiMarket(p.Trade, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apiMarket maps the market; full adds the chart and the sparklines.
|
||||
func apiMarket(trade *MarketPage, full bool) *APIMarket {
|
||||
c := cfg()
|
||||
mk := &APIMarket{Quotes: []APIQuote{}, Total: trade.Total, Held: trade.Held}
|
||||
if trade.Selected != nil {
|
||||
mk.Selected = trade.Selected.Resource
|
||||
}
|
||||
if trade.Best != nil {
|
||||
mk.Best = trade.Best.Resource
|
||||
}
|
||||
if trade.Worst != nil {
|
||||
mk.Worst = trade.Worst.Resource
|
||||
}
|
||||
for _, q := range trade.Quotes {
|
||||
aq := APIQuote{Item: q.Resource, Refined: q.Refined, Balance: q.Balance,
|
||||
Price: q.Price, Rate: q.Rate, Boost: q.Boost, Advisor: q.Advisor, Pressure: q.Pressure, Factor: q.Factor,
|
||||
Open: q.Open, Change: q.Change, High: q.High, Low: q.Low, Value: q.Value,
|
||||
Symbol: q.Symbol(), Unpressed: q.Unpressed(), Base: q.Base(),
|
||||
Up: q.Up(), ChangeText: q.ChangeText(), Pressed: q.Pressed(), Boosted: q.Boosted(), Advised: q.Advised()}
|
||||
if aq.Pressed {
|
||||
aq.PressureText = q.PressureText()
|
||||
}
|
||||
if aq.Boosted {
|
||||
aq.BoostText = q.BoostText()
|
||||
}
|
||||
if aq.Advised {
|
||||
aq.AdvisorText = q.AdvisorText()
|
||||
}
|
||||
// A sale is priced at the pressure factor averaged over what it adds (sell in game.go).
|
||||
aq.SellAll = q.Balance * aq.Unpressed * c.MarketPressure.Average(q.Pressure, q.Balance*aq.Base)
|
||||
if full {
|
||||
aq.Spark = spark(q.series)
|
||||
}
|
||||
mk.Quotes = append(mk.Quotes, aq)
|
||||
}
|
||||
if full && trade.Selected != nil {
|
||||
mk.Chart = &APIChart{Item: trade.Selected.Resource, Start: trade.Chart.Start, Step: trade.Chart.Step,
|
||||
Prices: append([]float64{}, trade.Selected.series...)}
|
||||
}
|
||||
return mk
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ handlers
|
||||
|
||||
type APIResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
New bool `json:"newPlayer,omitempty"` // the request had no known player: one was created
|
||||
Toast *APIToast `json:"toast,omitempty"`
|
||||
FX *APIFX `json:"fx,omitempty"`
|
||||
// One-shot results of an action, as the web shows them: the reveal card after buy-moon,
|
||||
// the deploy suggestions after buy-tool.
|
||||
Reveal *APIReveal `json:"reveal,omitempty"`
|
||||
BuyModal *APIBuyModal `json:"buyModal,omitempty"`
|
||||
ClientSeq any `json:"clientSeq,omitempty"` // the request's clientSeq, echoed (a number if it is an integer)
|
||||
State *APIState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type APIToast struct {
|
||||
Message string `json:"message"`
|
||||
Error bool `json:"error"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// APIFX is what the web client plays effects from (fx.js): the action, the moons it mined
|
||||
// out and what a sale earned.
|
||||
type APIFX struct {
|
||||
Kind string `json:"kind"`
|
||||
Finished []string `json:"finished"`
|
||||
Credits float64 `json:"credits"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("api: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func apiError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, APIResponse{Error: message})
|
||||
}
|
||||
|
||||
func apiServerError(w http.ResponseWriter, err error) {
|
||||
log.Printf("error: %v", err)
|
||||
apiError(w, http.StatusInternalServerError, "Something went wrong")
|
||||
}
|
||||
|
||||
// apiUser finds the player from the X-MM-User header or the mm_user cookie, or creates one
|
||||
// (as POST /session does). It echoes the id in the header and cookie; on a refusal it
|
||||
// writes the error response and returns "".
|
||||
func (s *server) apiUser(w http.ResponseWriter, r *http.Request) (userID string, created bool) {
|
||||
ctx := r.Context()
|
||||
userID = r.Header.Get(userHeader)
|
||||
if userID == "" {
|
||||
if c, err := r.Cookie(userCookie); err == nil {
|
||||
userID = c.Value
|
||||
}
|
||||
}
|
||||
if !uuidPattern.MatchString(userID) || !userExists(ctx, s.db, userID) {
|
||||
if !s.newPlayers.allow(r) {
|
||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||
return "", false
|
||||
}
|
||||
var err error
|
||||
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
|
||||
apiServerError(w, err)
|
||||
return "", false
|
||||
}
|
||||
created = true
|
||||
} else if isBanned(ctx, s.db, userID) {
|
||||
apiError(w, http.StatusForbidden, "This account is suspended")
|
||||
return "", false
|
||||
}
|
||||
w.Header().Set(userHeader, userID)
|
||||
setUserCookie(w, userID)
|
||||
return userID, created
|
||||
}
|
||||
|
||||
// apiPage loads the player's state and builds the state for a view. get reads the
|
||||
// request's include= and catalog= (see apiStateWith).
|
||||
func (s *server) apiPage(ctx context.Context, userID string, v View, get func(string) string) (*APIState, error) {
|
||||
state, v, err := loadView(ctx, s.db, userID, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.apiPageFor(ctx, state, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.apiStateWith(ctx, page, get)
|
||||
}
|
||||
|
||||
// apiPageFor builds the page for a resolved view, with what the server loads for it.
|
||||
func (s *server) apiPageFor(ctx context.Context, state *State, v View) (*Page, error) {
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// apiStateWith maps a page and the include= sections, the banner and live events. include
|
||||
// lists other sections (comma-separated, or "all") whose blocks to add; catalog=0 leaves the
|
||||
// catalog out, catalog=<catalogVersion> leaves it out while it's unchanged.
|
||||
func (s *server) apiStateWith(ctx context.Context, page *Page, get func(string) string) (*APIState, error) {
|
||||
page.Banner = s.banner(ctx)
|
||||
var extra []*Page
|
||||
for _, section := range apiIncludes(get("include")) {
|
||||
if section == page.V.Section {
|
||||
continue
|
||||
}
|
||||
v := page.V
|
||||
v.Section = section
|
||||
if v = v.resolve(page.S); v.Section != section {
|
||||
continue // locked
|
||||
}
|
||||
p, err := s.apiPageFor(ctx, page.S, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extra = append(extra, p)
|
||||
}
|
||||
a := apiState(page, extra...)
|
||||
a.skipCatalog(get("catalog"))
|
||||
events, err := listLiveEvents(ctx, s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Active(page.S.Now) {
|
||||
a.LiveEvents = append(a.LiveEvents, APILiveEvent{ID: e.ID, Name: e.Name, Kind: e.Kind, Resource: e.Resource,
|
||||
Multiplier: e.Multiplier, Start: e.StartsAt.UnixMilli(), End: e.EndsAt.UnixMilli(), Headline: e.Headline()})
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// skipCatalog leaves the catalog out for catalog=0, or catalog=<version> while it's that one.
|
||||
func (a *APIState) skipCatalog(param string) {
|
||||
if param != "" && (param == "0" || param == a.CatalogVersion) {
|
||||
a.Catalog = nil
|
||||
}
|
||||
}
|
||||
|
||||
// apiIncludes reads include=: section names, comma-separated, or "all".
|
||||
func apiIncludes(include string) []string {
|
||||
if include == "all" {
|
||||
return []string{"equipment", "market", "store", "refinery", "advisors", "leaderboard", "help", "new-moon"}
|
||||
}
|
||||
var out []string
|
||||
for _, name := range strings.Split(include, ",") {
|
||||
if name = strings.TrimSpace(name); sections[name] && name != "moons" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleAPIState is GET /api/state: the state for a view (s, moon, well, list, pick, drill).
|
||||
func (s *server) handleAPIState(w http.ResponseWriter, r *http.Request) {
|
||||
userID, created := s.apiUser(w, r)
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
// Opening a newly revealed tab stops its shine, as on the page.
|
||||
if err := markSeen(ctx, s.db, userID, r.FormValue("s")); err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
state, err := s.apiPage(ctx, userID, viewFromForm(r.FormValue), r.FormValue)
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
s.logSession(ctx, userID)
|
||||
writeJSON(w, http.StatusOK, APIResponse{OK: true, UserID: userID, New: created, State: state})
|
||||
}
|
||||
|
||||
// handleAPIAction is POST /api/act/{action}: runs an action like POST /act/{action} and
|
||||
// answers with the toast and the state after it. A rule violation is a 422 (with the state).
|
||||
func (s *server) handleAPIAction(w http.ResponseWriter, r *http.Request) {
|
||||
get, err := apiParams(r)
|
||||
if err != nil {
|
||||
apiError(w, http.StatusBadRequest, "Bad request")
|
||||
return
|
||||
}
|
||||
userID, created := s.apiUser(w, r)
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
res, err := s.runAction(ctx, userID, r.PathValue("action"), get)
|
||||
if errors.Is(err, errUnknownAction) {
|
||||
apiError(w, http.StatusNotFound, "Unknown action")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
if res.Action == "new-game" {
|
||||
// A fresh player; the client stores the new id.
|
||||
if !s.newPlayers.allow(r) {
|
||||
apiError(w, http.StatusTooManyRequests, "Too many new players, try again in a minute")
|
||||
return
|
||||
}
|
||||
if userID, err = createUser(ctx, s.db, newPlayerName()); err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set(userHeader, userID)
|
||||
w.Header().Del("Set-Cookie") // the old player's
|
||||
setUserCookie(w, userID)
|
||||
created, res.View, res.Message = true, View{}, "New game started"
|
||||
}
|
||||
|
||||
state, v, err := loadView(ctx, s.db, userID, res.View)
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
toast := res.toast(state)
|
||||
page, err := s.apiPageFor(ctx, state, v)
|
||||
if err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
resp := APIResponse{OK: !toast.Error, UserID: userID, New: created, ClientSeq: apiSeq(get("clientSeq")),
|
||||
Toast: &APIToast{Message: toast.Message, Error: toast.Error, Kind: toast.Kind}}
|
||||
if !toast.Error {
|
||||
if res.BoughtTool != "" {
|
||||
resp.BuyModal = apiBuyModal(state.buySuggestions(res.BoughtTool))
|
||||
}
|
||||
if res.Action == "buy-moon" {
|
||||
resp.Reveal = apiReveal(page.moonReveal())
|
||||
}
|
||||
}
|
||||
if resp.State, err = s.apiStateWith(ctx, page, get); err != nil {
|
||||
apiServerError(w, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if toast.Error {
|
||||
resp.Error, status = toast.Message, http.StatusUnprocessableEntity
|
||||
} else {
|
||||
resp.FX = &APIFX{Kind: toast.Kind, Finished: res.Finished, Credits: toast.Credits}
|
||||
if resp.FX.Finished == nil {
|
||||
resp.FX.Finished = []string{}
|
||||
}
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
|
||||
// apiParams reads an action's parameters from the query and form (parsed by limitBody) or
|
||||
// a JSON object body; JSON numbers and booleans become strings (true is "1").
|
||||
func apiParams(r *http.Request) (func(string) string, error) {
|
||||
ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if ct != "application/json" {
|
||||
return r.FormValue, nil
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
vals := map[string]string{}
|
||||
for k, v := range body {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
vals[k] = v
|
||||
case float64:
|
||||
vals[k] = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
if v {
|
||||
vals[k] = "1"
|
||||
}
|
||||
case nil:
|
||||
default:
|
||||
return nil, errors.New("api: parameters must be strings, numbers or booleans")
|
||||
}
|
||||
}
|
||||
return func(k string) string {
|
||||
if v, ok := vals[k]; ok {
|
||||
return v
|
||||
}
|
||||
return r.FormValue(k)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// apiSeq is a client's clientSeq as it echoes back: an integer, else the string, else nil.
|
||||
func apiSeq(seq string) any {
|
||||
if seq == "" {
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.ParseInt(seq, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
return seq
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
# Moon Miners JSON API
|
||||
|
||||
For game clients other than the htmx page (the Defold client). Same state and rules as the
|
||||
web game: `GET /api/state` is built from the same view models as `GET /`, and
|
||||
`POST /api/act/<action>` runs the same code as `POST /act/<action>` (`runAction` in
|
||||
server.go), onboarding gates included. Code: `api.go`.
|
||||
|
||||
Conventions:
|
||||
- Times are unix milliseconds (`null` when unset), durations seconds. `state.now` is the
|
||||
server's clock: compute time left as `end - now` (plus the time since the response).
|
||||
- Amounts and prices are raw numbers; the client formats them (currency symbol ₥, see
|
||||
[Texts](#texts) for the web's formats).
|
||||
- Reasons are codes, each with the web's player-facing text next to it (`why` and
|
||||
`whyText`, `noSale` and `noSaleText`, …), and actions the web confirms carry the dialog's
|
||||
words (`confirm`, see [Texts](#texts)). Other display text: names, descriptions, toast
|
||||
messages, banner text and the few labels listed with their fields.
|
||||
|
||||
## Auth
|
||||
|
||||
The player id is read from the `X-MM-User` header, else the `mm_user` cookie. If neither
|
||||
names an existing player, a new one is created (at most 10 per minute per IP, else
|
||||
`429`), with `newPlayer: true` in the body. Every successful response returns the id in
|
||||
the `X-MM-User` header, the `mm_user` cookie and `userId`; store it and send it back.
|
||||
Banned players get `403`.
|
||||
|
||||
## GET /api/state
|
||||
|
||||
Query params (all optional, same as the web URL): `s` (section: `moons`, `equipment`,
|
||||
`market`, `store`, `refinery`, `advisors`, `leaderboard`, `help`, `new-moon`), `moon`,
|
||||
`well`, `list=past` (show a mined-out moon), `page` (the page of `past`, from 1: 10 past
|
||||
moons a page, most recently finished first; out-of-range pages are clamped), `pick` (market:
|
||||
selected quote), `drill`, `adv` (advisors: the hired advisor whose panel the web shows).
|
||||
`include` (comma-separated sections, or `all`): section blocks to add besides the one for
|
||||
`s` (see [Sections](#sections)); locked ones are skipped. Invalid or locked values fall back
|
||||
like the web (`state.view` is the resolved view). Opening a revealed section with `s=`
|
||||
clears its shine.
|
||||
`catalog`: `0` leaves `state.catalog` out; the `catalogVersion` of the catalog the client
|
||||
has leaves it out while it's unchanged (it changes when admins change the config). Without
|
||||
it the catalog comes as before. `state.catalogVersion` is always there.
|
||||
|
||||
Response `200`: `{ok: true, userId, newPlayer?, state}`.
|
||||
|
||||
## POST /api/act/<action>
|
||||
|
||||
Body: form (`application/x-www-form-urlencoded`) or a JSON object (strings, numbers,
|
||||
booleans; `true` is sent as `"1"`, `false` as unset). Query params work too. Include the
|
||||
current view (`s`, `moon`, `well`, …, `include` and `catalog`) to get the state for it back.
|
||||
`clientSeq` (optional, any string or integer) comes back as `clientSeq` in the response
|
||||
(an integer comes back as a number, so `"007"` becomes `7`), on 200 and 422, so a client can match responses to the
|
||||
predictions it queued.
|
||||
|
||||
Core-loop actions and their params:
|
||||
|
||||
| action | params | notes |
|
||||
|---|---|---|
|
||||
| `buy-moon` | – | view moves to the new moon |
|
||||
| `stake` | `tool` (tool id), `target` (well id) | deploy; any idle copy of the tool may be used |
|
||||
| `claim` | `claim-well` or `claim-moon` or `claim-wide` (moon id), `redeploy=1` | no scope: claim all (needs `unlocked["claim-all"]`); `redeploy` needs `unlocked.redeploy` |
|
||||
| `redrill` | `redrill-well` (well id) | redeploy idle tools that last mined the well |
|
||||
| `sell` | `resource`, `amount` or `all=1` | resources and refined items |
|
||||
|
||||
The other sections' actions:
|
||||
|
||||
| action | params | notes |
|
||||
|---|---|---|
|
||||
| `buy-tool` | `id` (store item id) | response has `buyModal` |
|
||||
| `upgrade` | `tool` (tool id) | `drill=` follows the copy upgraded |
|
||||
| `sell-tool` | `tool` | idle, nothing to claim, not the last tool (`noSale`) |
|
||||
| `buy-attachment` | `item` (attachment name) | |
|
||||
| `equip`, `unequip` | `item` (attachment name), `to` (tool id) | `drill=` follows the copy used |
|
||||
| `sell-attachment` | `item` | a spare one |
|
||||
| `refine` | `recipe` (refined item name), `qty` or `all=1` | `all` makes as many as possible (`maxQty`) |
|
||||
| `collect` | `job` (job id), or none for every finished batch | |
|
||||
| `buy-slot` | – | refinery slot |
|
||||
| `hire` | `advisor` (name) | needs a free seat; takes the lowest one (`freeSeat`) |
|
||||
| `buy-seat` | – | advisor seat |
|
||||
| `seat-advisor` | `advisor`, `seat` (from 1) | a hired advisor into an empty seat (moves them if seated) |
|
||||
| `unseat-advisor` | `advisor` | off their seat: stays hired, no bonus |
|
||||
| `swap-advisors` | `advisor`, `with` (names) | exchange seats; one of them may be unseated |
|
||||
| `dismiss-advisor` | `advisor` | frees their seat; nothing is refunded (confirm with `dismiss`) |
|
||||
| `sell-moon` | `sell-moon` (moon id) | `moon.sale.canSell`; view moves to another moon |
|
||||
| `new-game` | – | creates a new player and returns its id and state |
|
||||
|
||||
Responses:
|
||||
- `200` `{ok: true, userId, clientSeq?, toast: {message, error: false, kind}, fx: {kind, finished, credits}, reveal?, buyModal?, state}`.
|
||||
`kind` is the action (for sounds and effects); `finished` lists moons the action mined
|
||||
out; `credits` is what a market sale earned. `reveal` (after `buy-moon`) and `buyModal`
|
||||
(after `buy-tool`) are one-shot results, see below.
|
||||
- `422` rule violation or locked action: `{ok: false, error, toast: {message, error: true, kind: "error"}, state}`.
|
||||
A locked action (its part of the game not revealed yet) says "Not available yet".
|
||||
- `400` bad body, `404` unknown action, `429` too many new players, `403` banned,
|
||||
`500` `{ok: false, error: "Something went wrong"}`.
|
||||
|
||||
## GET /api/moon-settings
|
||||
|
||||
The procedural-moon look tuned by admins on `/utils/moons` (no player needed): the saved
|
||||
JSON object, or `null` when none is saved. Merge it over the defaults (`DEFAULTS` in
|
||||
`web/static/js/terrain.js`; unknown keys and wrong types are ignored, see
|
||||
`mergeSettings`), then build each moon from its id like `terrain.js` does. Cache it for the
|
||||
session; the web page also reads it once per page load.
|
||||
|
||||
## State
|
||||
|
||||
| key | |
|
||||
|---|---|
|
||||
| `userId`, `now` | |
|
||||
| `view` | `{section, moon, well, past, page, pick, drill, adv, aside}`, resolved (`page` clamped, `adv` only a hired advisor in `s=advisors`); `aside`: the web shows the aside (viewport and side panel): moon view, new-moon teaser, an open drill or advisor panel |
|
||||
| `credits` | balance |
|
||||
| `resources`, `refined` | `[{name, amount}]` |
|
||||
| `moonPrice` | price of the next moon |
|
||||
| `unlocked` | `{moons, equipment, market, store, refinery, advisors, leaderboard, help, new-moon, claim-all, redrill, redeploy}`: booleans; hide what is false |
|
||||
| `shine` | revealed sections not opened yet |
|
||||
| `ready`, `idle`, `nextEnd` | finished drills (all moons), idle tools, soonest drill end |
|
||||
| `pastPage` | `{page, pages, total, perPage}`: the page of past moons in `past` (`total` in all); ask for others with `page=` |
|
||||
| `moons`, `past` | `past` is one page (`pastPage`). `[{id, name, kind, rating?, wells: [{id, resource, supply}], drills, ready, over, selected, createdAt, finished, finishedAt, mined?, finishedOn?}]`; `rating` is `{tier, label, stars, pct, top}` as on the reveal card (`label` e.g. "Great", `top` the web's "top N%"); `finishedOn` the web's date of a past moon ("Oct 18") |
|
||||
| `tools` | every tool: `{id, storeItemId, name, code, image, tier, maxTier, reach, cycleSecs, yield, attachments, stakeId?}` (`stakeId` set while mining; `code` e.g. `"PD-1"`; `image` a static URL; `cycleSecs`/`yield` with advisors, its attachments and "all" live events) |
|
||||
| `stakes` | every running or unclaimed drill: `{id, moonId, wellId, toolId, resource, amount, reach, reachText, start, end, durationSecs, ready, take, covers?}`; `reachText` the web's "Mines 3 wells" / "Strips the whole moon" (`""` for one well) |
|
||||
| `moon` | selected moon (`s=moons`): `{id, name, kind, description, rating, finished, finishedOn?, ready, nextEnd, wells, wide}`; each well `{id, resource, supply, drills, ready, readyAmount, nextEnd, cap, label, wide, selected, redrill, demand, remain, dryAfter, dryAt}`; `drills` are stakes with the claim projection `gets`, `here`, `state` (`ok`, `short`, `none`) and `fate`, the web's tooltip line ("Prospector Drone PD-1 T0: +2 of 3, ready"); `label` is the web's compact name of the well's drills ("PD-1 T1", "PD-1 ×3 +1"); `over` (drills want more than it holds), `full` (they'll drain it), `statusText` the well note's lead ("Over-drilled — next claims will come up short", "Fully committed", `""`) and `noteText` the rest ("47 left · 2 committed · 45 free after", "… · runs dry after 3 claims", `""` without demand); `wide` (moon) is the group of multi-well drills (with its own `label`), claimed with `claim-wide`; `sale` is the resale offer (below), once `unlocked.store` |
|
||||
| `well` | selected well: `{id, resource, rate, idle: [{toolId, storeItemId, name, tier, count, cycleSecs, yield, reach, here, free, short, attachments}], better: {moonId, wellId, resource, free, toolId}?}` |
|
||||
| `market` | once unlocked: `{quotes: [{item, refined, balance, price, rate, boost, advisor, pressure, factor, open, change, high, low, value, symbol, unpressed, base, sellAll, spark?, up, changeText, pressed, pressureText, boosted, boostText, advised, advisorText}], selected, total, held, best, worst, chart}`; `price` is what one unit sells for now; the web's texts: `changeText` "+1.25%"/"−0.50%" (`up`: change ≥ 0), `pressureText` "−4%" shown when `pressed` (recent selling lowers the price), `boostText` "×2" when `boosted` (live event), `advisorText` "+8%" when `advised` (each `""` otherwise); `chart` and `spark` only for `s=market` (or `include=market`) |
|
||||
| `banner` | `[{level, text, left, kind, id, end}]` announcements, then live events: `kind` `announcement` or `event`, `id` the announcement's or live event's id (an event's matches `liveEvents[].id`), `end` when it ends; `left` the web's "3h 20m left" at `now` (events only, else `""`) |
|
||||
| `liveEvents` | active live events: `[{id, name, kind, resource, multiplier, start, end, headline}]`, `kind` `price_multiplier`, `cycle_multiplier` or `yield_multiplier` |
|
||||
| `showNav` | the section tabs show (the web hides them while Moons would be the only one) |
|
||||
| `jobs` | once the refinery is open: every batch `[{id, slot, item, qty, start, end, ready}]` (the Refinery tab's badge) |
|
||||
| `bonuses` | active advisor bonuses (the web's bonus chips): `[{kind, mult, capped, label, text}]`, `kind` `speed`, `yield`, `sell`, `refine`; `label` and `text` the chip ("Drill speed", "+10%") |
|
||||
| `rules` | numbers for prediction: `{wellSupplyMax, maxSupply, slotsPerTool, wellsMin, wellsMax, pressure: {floor, scale, halfLifeSecs, cap}, refinery: {bulkExponent, maxBatch, speed}, moonResale: {factor, maxRatio, minHoldSecs}, equipmentResale, bonusCaps: {speed, yield, sell, refine}, bonus: {speed, yield, sell, refine}, boosts: {price, cycle, yield}}`; `maxSupply` is the scale of the web's supply bars (the config max, or the biggest owned well if bigger); `boosts` are live event multipliers by resource or `"all"` |
|
||||
| `catalog` | static data: `{tools: [{storeItemId, name, code, image, description, price, yield, cycleSecs, reach, tiers: [{tier, price, claimBoost, reach, parts: [{name, amount}]}]}], attachments: [{name, image, description, price, resale, effect: {speed, yield}, effectText}], advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText}], resources: [{name, color, price, symbol}], refined: [{name, tier, color, price, seconds, inputs, description, symbol}], reach: [{reach, label, short, wells}]}`; images are URLs on this server (`/static/assets/...`); `effectText` e.g. "+20% speed"; `reach` the web's words for a reach from 1 to the whole moon (`label` "Mines 3 wells"/"Strips the whole moon" on cards, `short` "3 wells"/"whole moon" on deploy cards, `wells` "1 well"/"whole moon" in the tier ladder; `""` where the web shows nothing; a bigger reach reads as the last entry). Left out with `catalog=` (see GET /api/state) |
|
||||
| `catalogVersion` | a hash of the catalog, for `catalog=` |
|
||||
|
||||
Section blocks (`equipment`, `store`, `refinery`, `advisors`, `leaderboard`, `help`,
|
||||
`newMoon`) are left out unless their section is the view's (`s=`) or in `include=`.
|
||||
|
||||
Codes and texts: where the web shows a reason (why a button is disabled), the API gives a
|
||||
short code, listed with each field, and the web's text next to it.
|
||||
|
||||
## Sections
|
||||
|
||||
### equipment (`s=equipment`)
|
||||
|
||||
```
|
||||
{tools: [{id, storeItemId, name, code, image, tier, maxTier, count, idle, reach, cycleSecs, yield,
|
||||
attachments, slots, freeSlots, selected, stake, next, preview, ladder, resale, noSale, bonus, gear}],
|
||||
drill}
|
||||
```
|
||||
|
||||
- Copies of a tool (same type, tier and attachments) are one row: `count` copies, `idle`
|
||||
of them idle; `id` is the copy shown (the selected one, else an idle one), `stake` its drill.
|
||||
- `next`: the next tier, `null` at the max: `{tier, price, parts: [{name, need, have}],
|
||||
canAfford, blocked, short, blockedText, shortText}`; `blocked` is `credits` or `parts`
|
||||
(`""` when it can be bought), `short` the credits missing; `blockedText` the button's
|
||||
tooltip ("Need ₥750 more", "Need 2 Dusk Alloy (you have 0). Make it in the Refinery"),
|
||||
`shortText` the line under it when credits are short.
|
||||
- `preview`: what the next tier changes, with the player's bonuses now: `{tier, yieldFrom,
|
||||
yieldTo, secsFrom, secsTo, reachFrom, reachTo, rateFrom, rateTo, cost, paybackSecs}`
|
||||
(`rate` is credits per hour; `paybackSecs` null if it never pays back).
|
||||
- `ladder`: every tier `[{tier, cycleSecs, yield, reach, rate, current, next}]`.
|
||||
- `resale`: what it sells for; `noSale`: `only-tool`, `claim-first`, `mining` or `""`,
|
||||
`noSaleText` its text ("Claim its haul first").
|
||||
- `bonus`: `{speed, yield}` from advisors and its attachments (capped); `gear` the
|
||||
attachments `[{name, image, effect}]`.
|
||||
- `drill` (with `drill=<tool id>`, else `null`): the drill panel `{toolId, slots: [{n,
|
||||
attachment}], spares: [{name, count, cycleSecs, yield, canEquip, why, whyText, price, resale,
|
||||
confirm}], mining, resale, noSale, noSaleText, confirm, idleSame, idleBest, deployTo, ladder,
|
||||
gear}`. `attachment` is `""` for an empty slot (unequip with `item=<name>&to=<toolId>`); a
|
||||
spare's `cycleSecs`/`yield` are the drill's with it equipped, `why` is `fitted` (one is on
|
||||
already) or `full`; `confirm` (panel) is the sell-tool dialog, a spare's the
|
||||
sell-attachment one. `gear[].effectText` e.g. "+20% speed". While it mines, `idleSame` idle tools of its type, `idleBest` the one to deploy
|
||||
and `deployTo` its best well (a suggestion, as in `buyModal`).
|
||||
|
||||
### store (`s=store`)
|
||||
|
||||
```
|
||||
{tools: [{storeItemId, name, code, image, description, price, canAfford, owned, yield, cycleSecs, reach, rate, bestRate, whyText}],
|
||||
attachments: [{name, image, description, effect: {speed, yield}, effectText, price, canAfford, spare, equipped, resale, whyText}]}
|
||||
```
|
||||
|
||||
`whyText`: the disabled Buy button's tooltip ("Need ₥1,400 more"), `""` when affordable.
|
||||
|
||||
`yield` and `cycleSecs` are the base stats the card shows; `rate` is credits per hour it
|
||||
would earn with the player's advisors, `bestRate` the same for their best tool (0: none).
|
||||
|
||||
### refinery (`s=refinery`)
|
||||
|
||||
```
|
||||
{slots: [{n, job: {id, slot, item, qty, start, end, ready, progress} | null}], slotCount, maxSlots,
|
||||
nextSlot, nextSlotPrice, canBuySlot, free, ready, maxBatch,
|
||||
recipes: [{item, tier, seconds, price, sellPrice, inputs: [{name, need, have}], inputValue, gain,
|
||||
maxQty, bulkQty, bulkSecs, bulkBonus, owned, why, whyText}],
|
||||
stock: [{name, amount}], buySlot, buySlotWhyText}
|
||||
```
|
||||
|
||||
`nextSlotPrice` is 0 at the cap. Per recipe: `inputs` per unit, `maxQty` what one batch
|
||||
can make now, `bulkSecs` how long a batch of `bulkQty` takes, `bulkBonus` the percent
|
||||
saved per unit, `sellPrice` one unit's price now (as the market quote), `why` `busy` (no
|
||||
free slot), `inputs` or `""`, `whyText` its text ("All slots are busy", "Not enough
|
||||
inputs"). `buySlot` is the buy-slot dialog (`null` at the cap), `buySlotWhyText` why
|
||||
"+ Slot" is disabled. Refined items sell through `sell` like resources.
|
||||
|
||||
### advisors (`s=advisors`)
|
||||
|
||||
```
|
||||
{seats, hired, benched, maxSeats, nextSeat, nextSeatPrice, canBuySeat, seatFree, freeSeat, seatList: [{n, advisor}],
|
||||
bench, bonuses, buySeat, buySeatWhyText, selected,
|
||||
advisors: [{name, title, image, description, price, effect: {speed, yield, sell, refine}, effectText, hired, seat,
|
||||
canAfford, canHire, why, whyText, canSeat, seatWhy, seatWhyText, dismiss, removed}]}
|
||||
```
|
||||
|
||||
Seats are numbered from 1. Only seated advisors give their bonus (`bonuses`); a hired
|
||||
advisor can be unseated and stays hired. Changes count from the next stake, sale or batch.
|
||||
`hired` is the seats filled, `benched` the advisors hired but not seated, `seatList` every
|
||||
seat with the name of who sits there (`""` empty), `bench` the names of the hired advisors
|
||||
not seated in the web's order (hire order), `freeSeat` the lowest empty seat (0 if
|
||||
none: where `hire` seats a new one). `selected` is the web's open panel (`view.adv`).
|
||||
`buySeat` is the buy-seat dialog (`null` at the cap), `buySeatWhyText` why "+ Seat" is
|
||||
disabled. An advisor's `why` (hiring): `hired` ("On your board"), `seats` ("All seats
|
||||
taken. Buy another seat first"), `credits` ("Need ₥X more") or `""`. For a hired advisor:
|
||||
`seat` (0: not seated), `dismiss` the dismiss-advisor dialog (`null` if not hired; nothing
|
||||
is refunded, hiring them again costs `price`); unseated ones also `canSeat` (a seat is
|
||||
free: `seat-advisor` with `seat=<freeSeat>`) and `seatWhy` `seats` ("All seats taken.
|
||||
Unseat someone or buy another seat") or `""`. A hired advisor no longer in the config comes
|
||||
last with `removed` (no bonus, no card on the web; it shows in its seat or the bench and can be
|
||||
dismissed).
|
||||
|
||||
The web's panel for a hired advisor (any client may do the same): for every other seat,
|
||||
`seat-advisor` to an empty one or `swap-advisors` with who sits there; when seated, also
|
||||
`swap-advisors` with each unseated advisor and `unseat-advisor`; and `dismiss-advisor`.
|
||||
|
||||
### leaderboard (`s=leaderboard`)
|
||||
|
||||
`{players, top: [{rank, name, credits, me}], you, me: {rank, credits}}`: `you` is the
|
||||
player's row when they're not in `top` (else `null`); `me` their place either way.
|
||||
|
||||
### help (`s=help`)
|
||||
|
||||
`{steps: [string], note, newGame}`: the "How to play" steps (the web's text; `note` says
|
||||
progress is saved on this device). Its button is `new-game`, `newGame` its dialog.
|
||||
|
||||
### newMoon (`s=new-moon`)
|
||||
|
||||
`{price, canAfford, after, need, types, resources, wellsMin, wellsMax, whyText}`: `after` the
|
||||
balance after buying, `need` the credits missing, `whyText` why the button is disabled, `types` the moon types it can roll,
|
||||
`resources` what its wells can hold. Buy with `buy-moon`.
|
||||
|
||||
### reveal (in the `buy-moon` response)
|
||||
|
||||
```
|
||||
{moonId, name, kind, description, rating, headline, sub,
|
||||
worth, multiple, traits: [string], maxSupply, fullAt, wells: [{id, resource, supply, full}],
|
||||
next: {price, canBuy, why, whyText}}
|
||||
```
|
||||
|
||||
`headline`, `sub` and `traits` are the card's verdict text; `worth` its wells at market
|
||||
prices, `multiple` worth / its price; a well bar is `supply / maxSupply`, `full` from
|
||||
`fullAt`. `next` is the card's "Buy another" button (`buy-moon` again): the next moon's
|
||||
price, `canBuy`, and `why` `locked` (more moons come with the store: the web hides the
|
||||
button), `credits` ("Need ₥X more", the button disabled) or `""`.
|
||||
|
||||
### buyModal (in the `buy-tool` response)
|
||||
|
||||
`{toolId, storeItemId, cycleSecs, yield, reach, noMoons, allEmpty, suggestions: [{moonId,
|
||||
moonName, wellId, resource, supply, credits, perMin, idle, reason}]}`: up to 3 wells to
|
||||
deploy the new tool on (`stake` with `tool=<toolId>&target=<wellId>`), best first;
|
||||
`reason` `best-value`, `most-supply`, `no-drill` or `shared`, `reasonText` its text
|
||||
("Highest value").
|
||||
|
||||
### moon.sale (with `s=moons`, once `unlocked.store`)
|
||||
|
||||
```
|
||||
{total, market, replace, delta, capped, factor, lines: [{resource, supply, rate, value}],
|
||||
last, running, ready, holdEnd, canSell, why, whyText, confirm}
|
||||
```
|
||||
|
||||
`total` is the offer (`market` × `factor`, capped), `replace` a new moon's price after the
|
||||
sale, `delta` = total − replace; `ready` finished drills are claimed in the sale.
|
||||
`why`: `last` (only moon), `running` (drills on it), `hold` (sellable at `holdEnd`) or `""`;
|
||||
`whyText` the web's text ("Wait for 2 drills to finish", "Just bought: you can sell it in
|
||||
4:27", at `now`); `confirm` the sell-moon dialog.
|
||||
|
||||
### market chart (`s=market`)
|
||||
|
||||
`market.chart`: `{item, start, step, prices}`, the selected quote's 24h market rate (before
|
||||
events, advisors and pressure), one point every `step` ms from `start` (289 points, the last
|
||||
is now). Each quote's `spark` is every 3rd point plus the last (the web's sparkline).
|
||||
|
||||
## Texts
|
||||
|
||||
Confirm dialogs (`confirm`, `buySlot`, `buySeat`, `dismiss`, `newGame`): `{title, text, ok, danger}`,
|
||||
the web's title, question, OK button and whether it's destructive, e.g.
|
||||
`{"title": "Sell drill", "text": "Sell this Prospector Drone PD-1 (T0) for ₥750? Its
|
||||
attachments go back to your inventory.", "ok": "Sell for ₥750", "danger": true}`. Texts with
|
||||
a time in them (`whyText` of a held moon, a banner's `left`) are as of `state.now`.
|
||||
|
||||
Not sent (the client words them from the numbers, like the web's templates):
|
||||
- moon list status: "N drills", "Idle", "Tapped out"; "A well here has more drills than
|
||||
supply" (`over`).
|
||||
- deploy card: "Only X left after current drills", "Well spoken for: nothing left after
|
||||
current drills" (`well.idle[].short`, `here`, `free`).
|
||||
- upgrade payback ("pays back in 2h 5m"), "Max tier", empty states ("No idle tools.").
|
||||
|
||||
The web's formats:
|
||||
- credits: `₥` + the amount with thousands commas and up to 2 decimals (`₥1,234.5`);
|
||||
amounts the same without `₥`. Compact (per hour): `₥1.2M`, `₥45k`, `₥1.5k`, else whole.
|
||||
- cycles: `3s`, `2m`, `2m 30s`; longer spans `2h 5m`. Countdowns `m:ss`, `h:mm:ss`.
|
||||
- banner time left: `2d 4h left`, `3h 20m left`, `45m left`.
|
||||
- bonuses: `+` and the percent with one decimal at most (`+10%`, `+12.5%`).
|
||||
|
||||
## Prediction
|
||||
|
||||
The client may show results before the server answers; the next state replaces them.
|
||||
- Stake: a drill takes `tool.cycleSecs` and gets `tool.yield` (× `rules.boosts.yield` of
|
||||
the well's resource, if any). Each stake carries `start`, `end` and `take` (what claiming
|
||||
it would get now).
|
||||
- Sell: `amount` units pay `amount × unpressed × avg`, with
|
||||
`avg = floor + (1 − floor) × scale × (e^(−p/scale) − e^(−(p+d)/scale)) / d`,
|
||||
`p` the quote's `pressure`, `d = amount × base` and `floor`, `scale` from
|
||||
`rules.pressure` (`avg` is `factor` when `d` is 0). `sellAll` is that for the whole
|
||||
balance. Pressure then becomes `min(p + d, rules.pressure.cap)` and halves every
|
||||
`halfLifeSecs`.
|
||||
- Refine: a batch of `qty` takes `round(seconds × qty^bulkExponent / speed)` seconds
|
||||
(`seconds` per recipe, the rest in `rules.refinery`).
|
||||
- Every response's `state.now` is the server clock; `clientSeq` matches a response to its
|
||||
request.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
POST /api/act/stake
|
||||
X-MM-User: 29dea5d5-709d-4c55-a269-cd90e4379fe9
|
||||
Content-Type: application/json
|
||||
|
||||
{"tool": "0fbfcb4f-…", "target": "b9d643c4-…", "moon": "54daa782-…", "well": "b9d643c4-…", "clientSeq": 12}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
|
||||
"clientSeq": 12,
|
||||
"toast": {"message": "Drill deployed", "error": false, "kind": "stake"},
|
||||
"fx": {"kind": "stake", "finished": [], "credits": 0},
|
||||
"state": {
|
||||
"userId": "29dea5d5-709d-4c55-a269-cd90e4379fe9",
|
||||
"now": 1792391815265,
|
||||
"view": {"section": "moons", "moon": "54daa782-…", "well": "b9d643c4-…", "past": false, "page": 1, "pick": "", "drill": "", "adv": "", "aside": true},
|
||||
"credits": 1644.63,
|
||||
"resources": [{"name": "Azurium", "amount": 0}, {"name": "Nebulance", "amount": 0}],
|
||||
"refined": [],
|
||||
"moonPrice": 400,
|
||||
"unlocked": {"moons": true, "equipment": true, "market": true, "store": true, "refinery": false, "advisors": false,
|
||||
"leaderboard": true, "help": true, "new-moon": true, "claim-all": false, "redrill": true, "redeploy": true},
|
||||
"shine": ["equipment", "store"],
|
||||
"ready": 0, "idle": 0, "nextEnd": 1792391819259,
|
||||
"moons": [{"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70},
|
||||
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47}],
|
||||
"drills": 1, "ready": 0, "over": false, "selected": true, "createdAt": 1792391738240,
|
||||
"finished": false, "finishedAt": null}],
|
||||
"past": [],
|
||||
"pastPage": {"page": 1, "pages": 1, "total": 0, "perPage": 10},
|
||||
"tools": [{"id": "0fbfcb4f-…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
||||
"image": "/static/assets/items/shovel.png", "tier": 0, "maxTier": 5,
|
||||
"reach": 1, "cycleSecs": 4, "yield": 2, "attachments": [], "stakeId": "efc5071d-…"}],
|
||||
"stakes": [{"id": "efc5071d-…", "moonId": "54daa782-…", "wellId": "b9d643c4-…", "toolId": "0fbfcb4f-…",
|
||||
"resource": "Nebulance", "amount": 2, "reach": 1, "start": 1792391815259, "end": 1792391819259,
|
||||
"durationSecs": 4, "ready": false, "take": 2}],
|
||||
"moon": {"id": "54daa782-…", "name": "Acheris XII", "kind": "Plutargos", "description": "A bleached mauve satellite …",
|
||||
"rating": {"tier": "fair", "label": "Fair", "stars": 2, "pct": 30, "top": 70}, "finished": false, "ready": 0, "nextEnd": 1792391819259,
|
||||
"wells": [{"id": "b9d643c4-…", "resource": "Nebulance", "supply": 47,
|
||||
"drills": [{"id": "efc5071d-…", "…": "…", "gets": 2, "here": 2, "state": "ok", "fate": "Prospector Drone PD-1 T0: +2"}],
|
||||
"ready": 0, "readyAmount": 0, "nextEnd": 1792391819259, "cap": 47, "label": "PD-1 T0", "wide": [],
|
||||
"selected": true, "redrill": 0, "demand": 2, "remain": 45, "dryAfter": 0, "dryAt": null}],
|
||||
"wide": null},
|
||||
"well": {"id": "b9d643c4-…", "resource": "Nebulance", "rate": 11.157, "idle": [], "better": null},
|
||||
"market": {"quotes": [{"item": "Azurium", "refined": false, "balance": 0, "price": 6.864, "rate": 6.864, "boost": 1,
|
||||
"advisor": 1, "pressure": 0, "factor": 1, "open": 6.899, "change": -0.501,
|
||||
"high": 7.151, "low": 5.786, "value": 0}],
|
||||
"selected": "Azurium", "total": 0},
|
||||
"banner": [],
|
||||
"showNav": true, "jobs": [], "bonuses": [], "liveEvents": [],
|
||||
"rules": {"wellSupplyMax": 90, "maxSupply": 90, "slotsPerTool": 2, "…": "…"},
|
||||
"catalog": {"tools": ["…"], "attachments": ["…"], "advisors": ["…"], "resources": ["…"], "refined": ["…"], "reach": ["…"]},
|
||||
"catalogVersion": "9adb4eabab929f5d"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Section examples
|
||||
|
||||
From a live server (long ids and arrays shortened).
|
||||
|
||||
`GET /api/state?s=equipment&drill=7e40…`, `state.equipment`:
|
||||
|
||||
```json
|
||||
{"tools": [{"id": "7e40…", "storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1",
|
||||
"image": "/static/assets/items/shovel.png", "tier": 1, "maxTier": 5, "count": 1, "idle": 1, "reach": 1,
|
||||
"cycleSecs": 3, "yield": 3, "attachments": ["Turbo Core"], "slots": 2, "freeSlots": 1, "selected": true,
|
||||
"stake": null, "next": {"tier": 2, "price": 900, "parts": [], "canAfford": true, "blocked": "", "short": 0},
|
||||
"preview": {"tier": 2, "yieldFrom": 3, "yieldTo": 4, "secsFrom": 3, "secsTo": 3, "reachFrom": 1, "reachTo": 1,
|
||||
"rateFrom": 24015.57, "rateTo": 32020.76, "cost": 900, "paybackSecs": 404.7},
|
||||
"ladder": [{"tier": 0, "cycleSecs": 3, "yield": 2, "reach": 1, "rate": 16010.38, "current": false, "next": false}, "…"],
|
||||
"resale": 1175, "noSale": "", "bonus": {"speed": 1.2, "yield": 1},
|
||||
"gear": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png", "effect": {"speed": 1.2, "yield": 1}}]}],
|
||||
"drill": {"toolId": "7e40…", "slots": [{"n": 1, "attachment": "Turbo Core"}, {"n": 2, "attachment": ""}],
|
||||
"spares": [{"name": "Diamond Bit", "count": 1, "cycleSecs": 3, "yield": 2.3, "canEquip": true, "why": "",
|
||||
"price": 4000, "resale": 2000}],
|
||||
"mining": false, "resale": 750, "noSale": "", "idleSame": 0, "idleBest": "", "deployTo": null,
|
||||
"ladder": ["…"], "gear": ["…"]}}
|
||||
```
|
||||
|
||||
`s=store`, `state.store`:
|
||||
|
||||
```json
|
||||
{"tools": [{"storeItemId": 1, "name": "Prospector Drone PD-1", "code": "PD-1", "image": "/static/assets/items/shovel.png",
|
||||
"description": "A hovering scout drone that skims ore off the surface.", "price": 1500, "canAfford": true,
|
||||
"owned": 1, "yield": 2, "cycleSecs": 4, "reach": 1, "rate": 12007.78, "bestRate": 12007.78}, "…"],
|
||||
"attachments": [{"name": "Turbo Core", "image": "/static/assets/attachments/turbo-core.png",
|
||||
"description": "An overdriven motor core. Runs hot, cycles fast.", "effect": {"speed": 1.2, "yield": 1},
|
||||
"price": 3000, "canAfford": true, "spare": 0, "equipped": 0, "resale": 1500}, "…"]}
|
||||
```
|
||||
|
||||
`s=refinery`, `state.refinery`:
|
||||
|
||||
```json
|
||||
{"slots": [{"n": 1, "job": {"id": "1155…", "slot": 1, "item": "Dusk Alloy", "qty": 3, "start": 1792402355103,
|
||||
"end": 1792402357103, "ready": false, "progress": 0.004}}],
|
||||
"slotCount": 1, "maxSlots": 4, "nextSlot": 2, "nextSlotPrice": 1500, "canBuySlot": true, "free": 0, "ready": 0, "maxBatch": 25,
|
||||
"recipes": [{"item": "Dusk Alloy", "tier": 1, "seconds": 1, "price": 30, "sellPrice": 29.52,
|
||||
"inputs": [{"name": "Shadowstone", "need": 2, "have": 494}, {"name": "Sollux", "need": 2, "have": 494}],
|
||||
"inputValue": 15, "gain": 100, "maxQty": 25, "bulkQty": 25, "bulkSecs": 7, "bulkBonus": 72.4,
|
||||
"owned": 0, "why": "busy"}, "…"],
|
||||
"stock": []}
|
||||
```
|
||||
|
||||
`s=advisors`, `state.advisors`:
|
||||
|
||||
```json
|
||||
{"seats": 2, "hired": 1, "benched": 1, "maxSeats": 4, "nextSeat": 3, "nextSeatPrice": 40000, "canBuySeat": true,
|
||||
"seatFree": true, "freeSeat": 2, "seatList": [{"n": 1, "advisor": "Ada Voss"}, {"n": 2, "advisor": ""}], "bench": ["Mira Castellanos"],
|
||||
"bonuses": [{"kind": "speed", "mult": 1.1, "capped": false, "label": "Drill speed", "text": "+10%"}], "selected": "",
|
||||
"buySeat": {"title": "New seat", "text": "Add advisor seat 3 for ₥40,000?", "ok": "Buy seat", "danger": false},
|
||||
"buySeatWhyText": "",
|
||||
"advisors": [{"name": "Ada Voss", "title": "Shift Foreman", "image": "/static/assets/advisors/ada-voss.png",
|
||||
"description": "Runs the drill crews in tight shifts. No rig sits idle on her watch.", "price": 8000,
|
||||
"effect": {"speed": 1.1, "yield": 1, "sell": 1, "refine": 1}, "effectText": "+10% drill speed",
|
||||
"hired": true, "seat": 1, "canAfford": true, "canHire": false, "why": "hired", "whyText": "On your board",
|
||||
"canSeat": false, "seatWhy": "", "seatWhyText": "",
|
||||
"dismiss": {"title": "Dismiss advisor", "text": "Dismiss Ada Voss? The hiring fee isn't refunded: hiring them again costs ₥8,000.",
|
||||
"ok": "Dismiss", "danger": true}, "removed": false},
|
||||
{"name": "Mira Castellanos", "…": "…", "hired": true, "seat": 0, "canSeat": true, "seatWhy": ""}, "…"]}
|
||||
```
|
||||
|
||||
`s=leaderboard`: `{"players": 2, "top": [{"rank": 1, "name": "…", "credits": 73579.5, "me": true}, "…"], "you": null, "me": {"rank": 1, "credits": 73579.5}}`
|
||||
|
||||
`s=new-moon`, `state.newMoon`:
|
||||
|
||||
```json
|
||||
{"price": 400, "canAfford": true, "after": 73179.5, "need": 0, "types": ["Caelusium", "Argentis", "…"],
|
||||
"resources": ["Azurium", "Nebulance", "Novafor", "Shadowstone", "Sollux"], "wellsMin": 2, "wellsMax": 5}
|
||||
```
|
||||
|
||||
`POST /api/act/buy-moon`, `reveal`:
|
||||
|
||||
```json
|
||||
{"moonId": "9b79…", "name": "Lithara-η", "kind": "Lumineer", "description": "…",
|
||||
"rating": {"tier": "poor", "label": "Poor", "stars": 1, "pct": 12, "top": 88},
|
||||
"headline": "Slim pickings", "sub": "Not much down there. Better luck next time.", "worth": 717, "multiple": 1.79,
|
||||
"traits": [], "maxSupply": 90, "fullAt": 84,
|
||||
"wells": [{"id": "92cf…", "resource": "Azurium", "supply": 37, "full": false}, "…"],
|
||||
"next": {"price": 550, "canBuy": true, "why": "", "whyText": ""}}
|
||||
```
|
||||
|
||||
`POST /api/act/buy-tool`, `buyModal`:
|
||||
|
||||
```json
|
||||
{"toolId": "e79d…", "storeItemId": 1, "cycleSecs": 4, "yield": 2, "reach": 1, "noMoons": false, "allEmpty": false,
|
||||
"suggestions": [{"moonId": "9b79…", "moonName": "Lithara-η", "wellId": "92cf…", "resource": "Azurium", "supply": 37,
|
||||
"credits": 13.6, "perMin": 204.0, "idle": true, "reason": "best-value"}, "…"]}
|
||||
```
|
||||
|
||||
`s=moons&moon=…`, `state.moon.sale`:
|
||||
|
||||
```json
|
||||
{"total": 91, "market": 919.54, "replace": 400, "delta": -309, "capped": false, "factor": 0.1,
|
||||
"lines": [{"resource": "Nebulance", "supply": 64, "rate": 10.55, "value": 675.48}, "…"],
|
||||
"last": false, "running": 0, "ready": 0, "holdEnd": 1792402983084, "canSell": false, "why": "hold"}
|
||||
```
|
||||
|
||||
`s=market&pick=Azurium`, `state.market` (one quote):
|
||||
|
||||
```json
|
||||
{"selected": "Azurium", "held": 6, "best": "Cryo Cell", "worst": "Dusk Alloy", "total": 17038.73,
|
||||
"chart": {"item": "Azurium", "start": 1792315800000, "step": 300000, "prices": [7.03, "… 289 points"]},
|
||||
"quotes": [{"item": "Azurium", "refined": false, "balance": 500, "price": 6.80, "rate": 6.80, "boost": 1, "advisor": 1,
|
||||
"pressure": 0, "factor": 1, "open": 7.03, "change": -3.33, "high": 7.15, "low": 5.79, "value": 3400.28,
|
||||
"symbol": "AZU", "unpressed": 6.80, "base": 6.5, "sellAll": 3384.06, "spark": [7.03, "… 97 points"]}, "…"]}
|
||||
```
|
||||
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
// API check (make check): builds the JSON state (api.go) for every view of the template
|
||||
// check's in-memory states and checks a few fields. Run with `go test -run API`.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
unlocked := newPlayerState()
|
||||
unlocked.Onboarding = []string{"all"}
|
||||
asides := map[bool]bool{}
|
||||
for _, s := range []*State{veteranState(), newPlayerState(), unlocked, fullBoardState()} {
|
||||
for _, v := range testViews(s) {
|
||||
s.pagePastInMemory(v.Page)
|
||||
p := s.page(v.resolve(s))
|
||||
testRanking(p)
|
||||
a := apiState(p)
|
||||
if _, err := json.Marshal(a); err != nil {
|
||||
t.Fatalf("marshal %+v: %v", v, err)
|
||||
}
|
||||
checkAPISections(t, s, p.V, a)
|
||||
checkAPITexts(t, p.V, a)
|
||||
for section, present := range map[string]bool{"equipment": a.Equipment != nil, "store": a.Store != nil,
|
||||
"refinery": a.Refinery != nil, "advisors": a.Advisors != nil, "leaderboard": a.Leaderboard != nil,
|
||||
"help": a.Help != nil, "new-moon": a.NewMoon != nil} {
|
||||
if present != (p.V.Section == section) {
|
||||
t.Errorf("view %+v: %s block present %v", v, section, present)
|
||||
}
|
||||
}
|
||||
if a.Rules == nil || a.Catalog == nil || len(a.Catalog.Tools) != len(s.Config.StoreItems) {
|
||||
t.Errorf("view %+v: rules or catalog missing", v)
|
||||
}
|
||||
if (len(a.Jobs) > 0) && !s.Unlocked("refinery") {
|
||||
t.Errorf("view %+v: refinery jobs before the refinery is open", v)
|
||||
}
|
||||
if a.Market != nil {
|
||||
for _, q := range a.Market.Quotes {
|
||||
if q.SellAll > q.Value+1e-6 || (q.Balance > 0 && q.SellAll <= 0) {
|
||||
t.Errorf("quote %s: sell all %v, value %v", q.Item, q.SellAll, q.Value)
|
||||
}
|
||||
}
|
||||
if (a.Market.Chart != nil) != (p.V.Section == "market") {
|
||||
t.Errorf("view %+v: market chart %v", v, a.Market.Chart != nil)
|
||||
}
|
||||
if c := a.Market.Chart; c != nil && (len(c.Prices) != marketPoints || c.Item != a.Market.Selected) {
|
||||
t.Errorf("view %+v: chart of %s with %d points", v, c.Item, len(c.Prices))
|
||||
}
|
||||
}
|
||||
if a.Moon != nil && (a.Moon.Sale != nil) != (s.Unlocked("store") && p.Moon.Sale != nil) {
|
||||
t.Errorf("view %+v: moon sale %v", v, a.Moon.Sale != nil)
|
||||
}
|
||||
if p.Moon != nil {
|
||||
// The reveal card and the buy modal, as an action response maps them.
|
||||
if r := apiReveal(p.moonReveal()); r == nil || len(r.Wells) != len(p.Moon.Wells) || r.Headline == "" ||
|
||||
r.Next.Price != s.MoonPrice() || r.Next.CanBuy != (r.Next.Why == "") || (r.Next.Why == "") != (r.Next.WhyText == "") {
|
||||
t.Errorf("view %+v: reveal %+v", v, r)
|
||||
}
|
||||
}
|
||||
for _, tool := range s.Tools {
|
||||
if m := apiBuyModal(s.buySuggestions(tool.ID)); m == nil || m.ToolID != tool.ID {
|
||||
t.Errorf("buy modal for %s: %+v", tool.ID, m)
|
||||
}
|
||||
}
|
||||
// Every section block at once (include=all).
|
||||
var extra []*Page
|
||||
for _, section := range apiIncludes("all") {
|
||||
ev := p.V
|
||||
ev.Section = section
|
||||
if ev = ev.resolve(s); ev.Section == section {
|
||||
ep := s.page(ev)
|
||||
testRanking(ep)
|
||||
extra = append(extra, ep)
|
||||
}
|
||||
}
|
||||
all := apiState(p, extra...)
|
||||
for _, ep := range extra {
|
||||
checkAPISections(t, s, ep.V, all)
|
||||
}
|
||||
checkAPITexts(t, p.V, all)
|
||||
if _, err := json.Marshal(all); err != nil {
|
||||
t.Fatalf("marshal %+v with every section: %v", v, err)
|
||||
}
|
||||
// The aside: the moon view, the new-moon teaser, an open drill or advisor panel.
|
||||
aside := p.Moon != nil || p.V.Section == "new-moon" || p.Drill != nil ||
|
||||
p.Advisors != nil && p.Advisors.Panel != nil
|
||||
if a.View.Aside != aside || aside != (p.Scene.Select != nil) {
|
||||
t.Errorf("view %+v: aside %v, want %v", p.V, a.View.Aside, aside)
|
||||
}
|
||||
asides[a.View.Aside] = true
|
||||
if a.View.Section != p.V.Section || a.View.Moon != p.V.Moon || a.View.Well != p.V.Well {
|
||||
t.Errorf("view %+v: state has %+v", p.V, a.View)
|
||||
}
|
||||
if len(a.Stakes) != len(s.Stakes) || len(a.Tools) != len(s.Tools) {
|
||||
t.Errorf("view %+v: %d stakes, %d tools", v, len(a.Stakes), len(a.Tools))
|
||||
}
|
||||
for _, st := range a.Stakes {
|
||||
if st.End-st.Start != int64(st.Secs)*1000 {
|
||||
t.Errorf("stake %s: end - start is not its duration", st.ID)
|
||||
}
|
||||
}
|
||||
if (a.Moon != nil) != (p.Moon != nil) || (a.Well != nil) != (p.Well != nil) {
|
||||
t.Errorf("view %+v: moon or well missing", v)
|
||||
}
|
||||
if pp := a.PastPage; len(a.Past) > pp.PerPage || pp.Page < 1 || pp.Page > pp.Pages || pp.Total != s.PastTotal ||
|
||||
a.View.Page != pp.Page || pp.Page < pp.Pages && len(a.Past) != pp.PerPage {
|
||||
t.Errorf("view %+v: past page %+v with %d moons", v, pp, len(a.Past))
|
||||
}
|
||||
if (a.Market != nil) != s.Unlocked("market") {
|
||||
t.Errorf("view %+v: market is %v, unlocked %v", v, a.Market != nil, s.Unlocked("market"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !asides[true] || !asides[false] {
|
||||
t.Errorf("views with and without the aside: %v", asides)
|
||||
}
|
||||
a := apiState(newPlayerState().page(View{}.resolve(newPlayerState())))
|
||||
if a.Unlocked["market"] || !a.Unlocked["new-moon"] || a.View.Section != "new-moon" {
|
||||
t.Errorf("new player: unlocked %v, section %q", a.Unlocked, a.View.Section)
|
||||
}
|
||||
|
||||
// The catalog: a stable version, skipped with catalog=0 or catalog=<version> only.
|
||||
s := veteranState()
|
||||
a, b := apiState(s.page(View{}.resolve(s))), apiState(s.page(View{}.resolve(s)))
|
||||
if a.CatalogVersion == "" || a.CatalogVersion != b.CatalogVersion {
|
||||
t.Errorf("catalog versions %q, %q", a.CatalogVersion, b.CatalogVersion)
|
||||
}
|
||||
for param, kept := range map[string]bool{"": true, "1": true, "stale": true, "0": false, a.CatalogVersion: false} {
|
||||
c := apiState(s.page(View{}.resolve(s)))
|
||||
if c.skipCatalog(param); (c.Catalog != nil) != kept {
|
||||
t.Errorf("catalog=%q: catalog present %v", param, c.Catalog != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAPITexts checks that ratings carry their label and "top N%", every reason code its
|
||||
// text and every confirm its question, as the web shows them.
|
||||
func checkAPITexts(t *testing.T, v View, a *APIState) {
|
||||
t.Helper()
|
||||
fail := func(what string, x any) { t.Errorf("view %+v: %s: %+v", v, what, x) }
|
||||
rating := func(r *APIRating) {
|
||||
if r != nil && (r.Label == "" || r.Top < 1) {
|
||||
fail("rating", r)
|
||||
}
|
||||
}
|
||||
for _, m := range append(append([]APIMoonListItem{}, a.Moons...), a.Past...) {
|
||||
rating(m.Rating)
|
||||
if m.Finished && m.FinishedOn == "" {
|
||||
fail("past moon date", m)
|
||||
}
|
||||
}
|
||||
if m := a.Moon; m != nil {
|
||||
rating(m.Rating)
|
||||
for _, w := range m.Wells {
|
||||
if len(w.Drills) > 0 && w.Label == "" {
|
||||
fail("well label", w)
|
||||
}
|
||||
if (w.Over || w.Full) != (w.StatusText != "") || (w.Demand > 0) != (w.NoteText != "") {
|
||||
fail("well note", w)
|
||||
}
|
||||
for _, d := range w.Drills {
|
||||
if d.Fate == "" || (d.Reach > 1) != (d.ReachText != "") {
|
||||
fail("drill fate", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sale := m.Sale; sale != nil && ((sale.Why != "") != (sale.WhyText != "") || sale.Confirm.Text == "") {
|
||||
fail("moon sale", sale)
|
||||
}
|
||||
}
|
||||
if a.Market != nil {
|
||||
for _, q := range a.Market.Quotes {
|
||||
if q.ChangeText == "" || q.Pressed != (q.PressureText != "") || q.Boosted != (q.BoostText != "") || q.Advised != (q.AdvisorText != "") {
|
||||
fail("quote texts", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range a.Bonuses {
|
||||
if b.Label == "" || b.Text == "" {
|
||||
fail("bonus", b)
|
||||
}
|
||||
}
|
||||
if c := a.Catalog; c != nil {
|
||||
if len(c.Reach) != len(c.Resources) {
|
||||
fail("reach texts", c.Reach)
|
||||
}
|
||||
for _, at := range c.Attachments {
|
||||
if at.EffectText == "" {
|
||||
fail("attachment effect", at)
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := a.Equipment; e != nil {
|
||||
for _, r := range e.Tools {
|
||||
if (r.NoSale != "") != (r.NoSaleText != "") || r.Next != nil && (r.Next.Blocked != "") != (r.Next.BlockedText != "") {
|
||||
fail("tool row texts", r)
|
||||
}
|
||||
}
|
||||
if d := e.Drill; d != nil {
|
||||
if (d.NoSale != "") != (d.NoSaleText != "") || d.Confirm.Text == "" {
|
||||
fail("drill panel texts", d)
|
||||
}
|
||||
for _, sp := range d.Spares {
|
||||
if (sp.Why != "") != (sp.WhyText != "") || sp.Confirm.Text == "" {
|
||||
fail("spare texts", sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if st := a.Store; st != nil {
|
||||
for _, tool := range st.Tools {
|
||||
if tool.CanAfford == (tool.WhyText != "") {
|
||||
fail("store tool text", tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
if r := a.Refinery; r != nil {
|
||||
if (r.NextSlotPrice > 0) != (r.BuySlot != nil) {
|
||||
fail("buy slot confirm", r.BuySlot)
|
||||
}
|
||||
for _, rc := range r.Recipes {
|
||||
if (rc.Why != "") != (rc.WhyText != "") {
|
||||
fail("recipe texts", rc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ad := a.Advisors; ad != nil {
|
||||
if (ad.NextSeatPrice > 0) != (ad.BuySeat != nil) {
|
||||
fail("buy seat confirm", ad.BuySeat)
|
||||
}
|
||||
for _, up := range ad.Advisors {
|
||||
if (up.Why != "") != (up.WhyText != "") || up.Why == "" && !up.CanHire {
|
||||
fail("advisor texts", up)
|
||||
}
|
||||
if up.Hired != (up.Dismiss != nil) || up.Dismiss != nil && up.Dismiss.Text == "" ||
|
||||
(up.SeatWhy != "") != (up.SeatWhyText != "") || up.CanSeat && (up.Seat != 0 || ad.FreeSeat == 0) {
|
||||
fail("advisor seat texts", up)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h := a.Help; h != nil && h.NewGame.Text != newGameConfirm {
|
||||
fail("new game confirm", h.NewGame)
|
||||
}
|
||||
}
|
||||
|
||||
// testRanking stands in for the leaderboard the server loads (loadRanking).
|
||||
func testRanking(p *Page) {
|
||||
if p.V.Section == "leaderboard" {
|
||||
p.Ranking = &LeaderboardPage{Players: 12, Top: []LeaderboardRow{{Rank: 1, Name: "Top Miner", Balance: 9000}},
|
||||
You: &LeaderboardRow{Rank: 12, Name: "Test Miner", Balance: 10, Me: true}}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAPISections checks that the block of a view's section is in the state, as the
|
||||
// section's view model has it.
|
||||
func checkAPISections(t *testing.T, s *State, v View, a *APIState) {
|
||||
t.Helper()
|
||||
switch v.Section {
|
||||
case "equipment":
|
||||
if a.Equipment == nil || len(a.Equipment.Tools) == 0 && len(s.Tools) > 0 {
|
||||
t.Errorf("view %+v: equipment %+v", v, a.Equipment)
|
||||
} else if (a.Equipment.Drill != nil) != (v.Drill != "") {
|
||||
t.Errorf("view %+v: drill panel %v", v, a.Equipment.Drill != nil)
|
||||
} else if d := a.Equipment.Drill; d != nil && len(d.Slots) != cfg().Attachments.SlotsPerTool {
|
||||
t.Errorf("view %+v: %d slots", v, len(d.Slots))
|
||||
}
|
||||
case "store":
|
||||
if a.Store == nil || len(a.Store.Tools) != len(s.Config.StoreItems) || len(a.Store.Attachments) != len(cfg().Attachments.Items) {
|
||||
t.Errorf("view %+v: store %+v", v, a.Store)
|
||||
}
|
||||
case "refinery":
|
||||
if a.Refinery == nil || len(a.Refinery.Recipes) != len(cfg().Refinery.Items) || len(a.Refinery.Slots) < s.RefinerySlots {
|
||||
t.Errorf("view %+v: refinery %+v", v, a.Refinery)
|
||||
}
|
||||
case "advisors":
|
||||
if a.Advisors == nil || len(a.Advisors.Advisors) < len(cfg().Advisors.Items) || len(a.Advisors.SeatList) != s.AdvisorSeats ||
|
||||
a.Advisors.Hired+a.Advisors.Benched != len(s.Hires) || a.Advisors.Selected != v.Adv || len(a.Advisors.Bench) != a.Advisors.Benched {
|
||||
t.Errorf("view %+v: advisors %+v", v, a.Advisors)
|
||||
}
|
||||
for _, h := range s.Hires {
|
||||
if h.Seat > 0 && a.Advisors.SeatList[h.Seat-1].Advisor != h.Name {
|
||||
t.Errorf("view %+v: seat %d has %q, not %s", v, h.Seat, a.Advisors.SeatList[h.Seat-1].Advisor, h.Name)
|
||||
}
|
||||
}
|
||||
case "leaderboard":
|
||||
if a.Leaderboard == nil || a.Leaderboard.Me == nil || a.Leaderboard.Me.Rank != 12 {
|
||||
t.Errorf("view %+v: leaderboard %+v", v, a.Leaderboard)
|
||||
}
|
||||
case "help":
|
||||
if a.Help == nil || len(a.Help.Steps) == 0 {
|
||||
t.Errorf("view %+v: help %+v", v, a.Help)
|
||||
}
|
||||
case "new-moon":
|
||||
if a.NewMoon == nil || a.NewMoon.Price != s.MoonPrice() || a.NewMoon.WellsMin < 1 {
|
||||
t.Errorf("view %+v: new moon %+v", v, a.NewMoon)
|
||||
}
|
||||
case "market":
|
||||
if a.Market == nil || a.Market.Chart == nil {
|
||||
t.Errorf("view %+v: market chart missing", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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()
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
// Player bonuses: drill attachments (attachments.go, equipped on one tool; their bonus is
|
||||
// the modifier) and advisors (advisors.go, for the whole operation). Each bonus is a
|
||||
// multiplier (1.1 = +10%). Within a player's attachments and advisors they multiply, then each kind is capped by bonusCaps
|
||||
// in the config; live events (events.go) multiply on top, uncapped. Bonuses are applied
|
||||
// when an action runs: a stake fixes its duration and amount when it starts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BonusCaps are the highest combined multipliers a player's attachments and advisors give.
|
||||
type BonusCaps struct {
|
||||
Speed float64 `json:"speed"` // drill speed: cycle duration is divided by it
|
||||
Yield float64 `json:"yield"` // resources per stake
|
||||
Sell float64 `json:"sell"` // credits per unit sold
|
||||
Refine float64 `json:"refine"` // refinery speed: batch duration is divided by it
|
||||
}
|
||||
|
||||
// Bonus is a set of multipliers (1 = none).
|
||||
type Bonus struct {
|
||||
Speed, Yield, Sell, Refine float64
|
||||
}
|
||||
|
||||
var noBonus = Bonus{1, 1, 1, 1}
|
||||
|
||||
// mult reads an optional multiplier from the config: unset (0) is 1.
|
||||
func mult(v float64) float64 {
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// bonusesFor combines the named advisors and attachments, capped.
|
||||
func (c *GameConfig) bonusesFor(advisors, attachments []string) Bonus {
|
||||
b := noBonus
|
||||
for _, name := range advisors {
|
||||
if a := c.advisor(name); a != nil {
|
||||
b.Speed *= mult(a.Speed)
|
||||
b.Yield *= mult(a.Yield)
|
||||
b.Sell *= mult(a.Sell)
|
||||
b.Refine *= mult(a.Refine)
|
||||
}
|
||||
}
|
||||
for _, name := range attachments {
|
||||
if m := c.attachment(name); m != nil {
|
||||
b.Speed *= mult(m.Speed)
|
||||
b.Yield *= mult(m.Yield)
|
||||
}
|
||||
}
|
||||
b.Speed = min(b.Speed, c.BonusCaps.Speed)
|
||||
b.Yield = min(b.Yield, c.BonusCaps.Yield)
|
||||
b.Sell = min(b.Sell, c.BonusCaps.Sell)
|
||||
b.Refine = min(b.Refine, c.BonusCaps.Refine)
|
||||
return b
|
||||
}
|
||||
|
||||
// drillStats is a tool's cycle (seconds) and yield per stake with the player's bonus and
|
||||
// the live event multipliers. stakeInTx and the views both use it.
|
||||
func drillStats(secs int, yield float64, b Bonus, eventSpeed, eventYield float64) (int, float64) {
|
||||
return max(1, int(math.Round(float64(secs)/(b.Speed*eventSpeed)))), yield * b.Yield * eventYield
|
||||
}
|
||||
|
||||
// pctText formats a multiplier as a bonus, e.g. 1.1 → "+10%".
|
||||
func pctText(m float64) string {
|
||||
return "+" + strconv.FormatFloat(math.Round((m-1)*1000)/10, 'f', -1, 64) + "%"
|
||||
}
|
||||
|
||||
func (cfg *GameConfig) validateBonuses() error {
|
||||
caps := cfg.BonusCaps
|
||||
for name, v := range map[string]float64{"speed": caps.Speed, "yield": caps.Yield, "sell": caps.Sell, "refine": caps.Refine} {
|
||||
if v < 1 {
|
||||
return fmt.Errorf("bonusCaps.%s must be at least 1", name)
|
||||
}
|
||||
}
|
||||
okMult := func(v float64) bool { return v == 0 || v >= 1 }
|
||||
if len(cfg.Attachments.Items) > 0 && cfg.Attachments.SlotsPerTool < 1 {
|
||||
return fmt.Errorf("attachments: slotsPerTool must be at least 1")
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, m := range cfg.Attachments.Items {
|
||||
if m.Name == "" || names[m.Name] {
|
||||
return fmt.Errorf("attachments: names must be set and unique (%q)", m.Name)
|
||||
}
|
||||
names[m.Name] = true
|
||||
if m.Price < 0 || !okMult(m.Speed) || !okMult(m.Yield) {
|
||||
return fmt.Errorf("attachment %q: price can't be negative, speed and yield must be at least 1", m.Name)
|
||||
}
|
||||
}
|
||||
for i, p := range cfg.Advisors.SeatPrices {
|
||||
if p < 0 {
|
||||
return fmt.Errorf("advisors: seat %d price is negative", i+2)
|
||||
}
|
||||
}
|
||||
names = map[string]bool{}
|
||||
for _, a := range cfg.Advisors.Items {
|
||||
if a.Name == "" || names[a.Name] {
|
||||
return fmt.Errorf("advisors: names must be set and unique (%q)", a.Name)
|
||||
}
|
||||
names[a.Name] = true
|
||||
if a.Price < 0 || !okMult(a.Speed) || !okMult(a.Yield) || !okMult(a.Sell) || !okMult(a.Refine) {
|
||||
return fmt.Errorf("advisor %q: price can't be negative, bonuses must be at least 1", a.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ in a transaction
|
||||
|
||||
// loadBonus reads the player's seated advisors and, with a tool id, the tool's attachments, and
|
||||
// combines them. Callers that pass a tool have locked its row; the advisor rows are
|
||||
// share-locked so a hire can't change them halfway through the action.
|
||||
func loadBonus(ctx context.Context, tx pgx.Tx, userID, toolID string) (Bonus, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
select advisor from advisor_hire where user_id = $1 and seat is not null order by advisor for share`, userID)
|
||||
if err != nil {
|
||||
return noBonus, err
|
||||
}
|
||||
advisors, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
if err != nil {
|
||||
return noBonus, err
|
||||
}
|
||||
var mods []string
|
||||
if toolID != "" {
|
||||
rows, err = tx.Query(ctx, `
|
||||
select item from attachment where inventory_item_id = $1 order by item`, toolID)
|
||||
if err != nil {
|
||||
return noBonus, err
|
||||
}
|
||||
if mods, err = pgx.CollectRows(rows, pgx.RowTo[string]); err != nil {
|
||||
return noBonus, err
|
||||
}
|
||||
}
|
||||
return cfg().bonusesFor(advisors, mods), nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
// loadBonusState loads the player's attachments, advisors (hired, and the seated ones) and
|
||||
// advisor seats into the state.
|
||||
func loadBonusState(ctx context.Context, db querier, s *State) error {
|
||||
err := db.QueryRow(ctx, "select advisor_seats from users where id = $1", s.UserID).Scan(&s.AdvisorSeats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := db.Query(ctx, `
|
||||
select advisor, coalesce(seat, 0) from advisor_hire where user_id = $1
|
||||
order by seat nulls last, hired_at, advisor`, s.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Hires, err = pgx.CollectRows(rows, pgx.RowToStructByPos[AdvisorHire]); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Advisors = nil
|
||||
for _, h := range s.Hires {
|
||||
if h.Seat > 0 {
|
||||
s.Advisors = append(s.Advisors, h.Name)
|
||||
}
|
||||
}
|
||||
rows, err = db.Query(ctx, `
|
||||
select coalesce(inventory_item_id::text, ''), item from attachment
|
||||
where user_id = $1 order by item`, s.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Fitted, s.Spare = map[string][]string{}, map[string]int{}
|
||||
var toolID, item string
|
||||
_, err = pgx.ForEachRow(rows, []any{&toolID, &item}, func() error {
|
||||
if toolID == "" {
|
||||
s.Spare[item]++
|
||||
} else {
|
||||
s.Fitted[toolID] = append(s.Fitted[toolID], item)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Bonus is the player's advisor bonus (no attachments).
|
||||
func (s *State) Bonus() Bonus { return cfg().bonusesFor(s.Advisors, nil) }
|
||||
|
||||
// toolBonus is the bonus for stakes of one tool: advisors and its attachments.
|
||||
func (s *State) toolBonus(toolID string) Bonus {
|
||||
return cfg().bonusesFor(s.Advisors, s.Fitted[toolID])
|
||||
}
|
||||
|
||||
// drill is what a stake of the tool on a well of the resource would take and return now.
|
||||
func (s *State) drill(t Tool, item *StoreItem, resource string) (int, float64) {
|
||||
return drillStats(item.CompletionTimeInSecs, item.ToolYield(t.Tier), s.toolBonus(t.ID),
|
||||
s.CycleBoosts.For("all"), s.YieldBoosts.For(resource))
|
||||
}
|
||||
|
||||
// sellPrice is what one unit sells for now: market rate × live events × advisors.
|
||||
func (s *State) sellPrice(name string) float64 {
|
||||
return marketRate(name, s.Now) * s.PriceBoosts.For(name) * s.Bonus().Sell
|
||||
}
|
||||
|
||||
// BonusRow is one active bonus, for display.
|
||||
type BonusRow struct {
|
||||
Label string
|
||||
Text string // "+10%"
|
||||
Capped bool
|
||||
}
|
||||
|
||||
// BonusRows lists the player's active advisor bonuses.
|
||||
func (s *State) BonusRows() []BonusRow {
|
||||
return bonusRows(s.Bonus())
|
||||
}
|
||||
|
||||
// bonusRows lists the multipliers of b above 1; Capped marks the ones at their cap.
|
||||
func bonusRows(b Bonus) []BonusRow {
|
||||
caps := cfg().BonusCaps
|
||||
var rows []BonusRow
|
||||
add := func(label string, v, cap float64) {
|
||||
if v > 1 {
|
||||
rows = append(rows, BonusRow{Label: label, Text: pctText(v), Capped: v >= cap})
|
||||
}
|
||||
}
|
||||
add("Drill speed", b.Speed, caps.Speed)
|
||||
add("Yield", b.Yield, caps.Yield)
|
||||
add("Sell prices", b.Sell, caps.Sell)
|
||||
add("Refinery speed", b.Refine, caps.Refine)
|
||||
return rows
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,40 +1,40 @@
|
||||
# Production stack (used by Dokploy). Set POSTGRES_PASSWORD and DB_PASSWORD in the
|
||||
# environment, and add a domain for the `app` service on port 3000 in Dokploy.
|
||||
x-db-env: &db-env
|
||||
DB_HOST: db
|
||||
DB_PORT: "5432"
|
||||
DB_USER: moonminers
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
DB_DATABASE: moonminers
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: "5432"
|
||||
DB_USER: moonminers
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
DB_DATABASE: moonminers
|
||||
<<: *db-env
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
TRUST_PROXY: "1" # behind Dokploy's proxy: rate limits use X-Forwarded-For
|
||||
expose:
|
||||
- "3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db-update:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/db/Dockerfile
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
POSTGRES_DB: moonminers
|
||||
APP_DB_USER: moonminers
|
||||
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# TCP check: the temporary server used during first-time init only listens on a socket.
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d moonminers"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
@@ -42,20 +42,16 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# One-shot: migrations, function reload and config import, before the app starts.
|
||||
db-update:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/db/Dockerfile
|
||||
command: ["/moonminers/update.sh"]
|
||||
# One-shot, before the app starts on every deploy: roles, schema, migrations and the
|
||||
# store item sync from config/game.json (see migrate.go).
|
||||
migrate:
|
||||
build: .
|
||||
command: ["migrate"]
|
||||
restart: "no"
|
||||
environment:
|
||||
PGHOST: db
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
PGDATABASE: moonminers
|
||||
APP_DB_USER: moonminers
|
||||
APP_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
<<: *db-env
|
||||
ADMIN_DB_USER: postgres
|
||||
ADMIN_DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Local development database. Run the app itself with `npm run dev`.
|
||||
name: moonminers
|
||||
|
||||
# Local development database. Run the app itself with `make dev`.
|
||||
services:
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/db/Dockerfile
|
||||
image: docker.io/library/postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: moonminers
|
||||
APP_DB_USER: moonminers
|
||||
APP_DB_PASSWORD: moonminers
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
# The SQL in sql/ is loaded once, when this volume is empty.
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, '\''))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
// Onboarding by progressive reveal: a new player sees only what the next step needs, and
|
||||
// each section (and the controls that belong to it) shows up once the step before it is
|
||||
// done. No tutorial text: hidden things are simply not rendered, and a newly revealed tab
|
||||
// shines until it's first opened.
|
||||
//
|
||||
// users.onboarding holds flags, which are only ever added:
|
||||
// - a gate passed: equipment (first deploy), market (first claim), store (first sale),
|
||||
// refinery (claimed every raw input of a recipe), advisors (bought a tool or attachment);
|
||||
// - claimed:<resource>, for the refinery gate;
|
||||
// - seen:<tab>, the revealed tab was opened (no more shine);
|
||||
// - all: everything unlocked (players from before onboarding, admin "unlock all").
|
||||
// Owning a moon is read from the game state. Redrill opens with the first claim. The store gate also reveals "+ New moon",
|
||||
// Help, Leaderboard, moon sale, "Claim and redeploy", upgrades and the
|
||||
// drill panel (attachments); Claim all also needs 2 tools.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// onboardingTabs are the gated sections that shine when revealed, in walk-through order.
|
||||
var onboardingTabs = []string{"equipment", "market", "store", "refinery", "advisors"}
|
||||
|
||||
// Unlocked says whether a section or feature is available to the player.
|
||||
func (s *State) Unlocked(feature string) bool {
|
||||
if slices.Contains(s.Onboarding, "all") {
|
||||
return true
|
||||
}
|
||||
switch feature {
|
||||
case "moons":
|
||||
return true
|
||||
case "new-moon":
|
||||
// The first moon is always for sale, and so is the next one once every moon is
|
||||
// mined out.
|
||||
active := slices.ContainsFunc(s.Moons, func(m Moon) bool { return !m.Finished })
|
||||
return !active || slices.Contains(s.Onboarding, "store")
|
||||
case "help", "leaderboard":
|
||||
feature = "store"
|
||||
case "claim-all":
|
||||
return slices.Contains(s.Onboarding, "store") && len(s.Tools) >= 2
|
||||
}
|
||||
return slices.Contains(s.Onboarding, feature)
|
||||
}
|
||||
|
||||
// Shine says whether a section tab was just revealed and hasn't been opened yet.
|
||||
func (s *State) Shine(tab string) bool {
|
||||
return !slices.Contains(s.Onboarding, "all") && slices.Contains(s.Onboarding, tab) &&
|
||||
!slices.Contains(s.Onboarding, "seen:"+tab)
|
||||
}
|
||||
|
||||
// ShowNav says whether the section tabs show at all: not while Moons would be the only one.
|
||||
func (s *State) ShowNav() bool { return s.Unlocked("equipment") }
|
||||
|
||||
// OnboardingStage describes how far a player got, for the admin Players page.
|
||||
func (s *State) OnboardingStage() string {
|
||||
if slices.Contains(s.Onboarding, "all") {
|
||||
return "Done (everything unlocked)"
|
||||
}
|
||||
var open []string
|
||||
for _, tab := range onboardingTabs {
|
||||
if slices.Contains(s.Onboarding, tab) {
|
||||
open = append(open, tab)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(open) == len(onboardingTabs):
|
||||
return "Done (all tabs revealed)"
|
||||
case len(open) > 0:
|
||||
return "Revealed: " + strings.Join(open, ", ")
|
||||
case len(s.Moons) > 0:
|
||||
return "Has a moon, no deploy yet"
|
||||
}
|
||||
return "New: no moon yet"
|
||||
}
|
||||
|
||||
// unlock adds onboarding flags. It reads first without a lock, so once the flags are there
|
||||
// (nearly always) the action doesn't touch the users row.
|
||||
func unlock(ctx context.Context, tx pgx.Tx, userID string, flags ...string) error {
|
||||
var have []string
|
||||
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&have); err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(have, "all") || !slices.ContainsFunc(flags, func(f string) bool { return !slices.Contains(have, f) }) {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
update users set onboarding = array(select distinct unnest(onboarding || $2::text[]))
|
||||
where id = $1`, userID, flags)
|
||||
return err
|
||||
}
|
||||
|
||||
// unlockClaim records a claim: the market opens, and the refinery once the player has
|
||||
// claimed every raw input of some recipe.
|
||||
func unlockClaim(ctx context.Context, tx pgx.Tx, userID, resource string) error {
|
||||
if err := unlock(ctx, tx, userID, "market", "claimed:"+resource); err != nil {
|
||||
return err
|
||||
}
|
||||
var have []string
|
||||
if err := tx.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&have); err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(have, "all") || slices.Contains(have, "refinery") {
|
||||
return nil
|
||||
}
|
||||
for _, item := range cfg().Refinery.Items {
|
||||
paired := len(item.Inputs) > 0
|
||||
for input := range item.Inputs {
|
||||
if _, raw := cfg().ResourcePrices[input]; !raw || !slices.Contains(have, "claimed:"+input) {
|
||||
paired = false
|
||||
}
|
||||
}
|
||||
if paired {
|
||||
return unlock(ctx, tx, userID, "refinery")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSeen clears the shine on a revealed tab once the player opens it.
|
||||
func markSeen(ctx context.Context, db *pgxpool.Pool, userID, section string) error {
|
||||
if !slices.Contains(onboardingTabs, section) {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(ctx, `
|
||||
update users set onboarding = onboarding || array['seen:' || $2]
|
||||
where id = $1 and $2 = any(onboarding) and not ('seen:' || $2) = any(onboarding)`,
|
||||
userID, section)
|
||||
return err
|
||||
}
|
||||
|
||||
// actionGates is the feature each action needs; runAction refuses the rest.
|
||||
var actionGates = map[string]string{
|
||||
"sell-moon": "store", "sell-tool": "store", "sell-attachment": "store",
|
||||
"buy-tool": "store", "upgrade": "store", "buy-attachment": "store",
|
||||
"equip": "store", "unequip": "store", "redrill": "market", "new-game": "help",
|
||||
"sell": "market",
|
||||
"refine": "refinery", "collect": "refinery", "buy-slot": "refinery",
|
||||
"hire": "advisors", "buy-seat": "advisors", "seat-advisor": "advisors", "unseat-advisor": "advisors",
|
||||
"swap-advisors": "advisors", "dismiss-advisor": "advisors",
|
||||
"buy-moon": "new-moon",
|
||||
}
|
||||
|
||||
// actionAllowed refuses an action whose part of the game isn't revealed yet.
|
||||
func actionAllowed(ctx context.Context, db *pgxpool.Pool, userID, action string, redeploy bool) error {
|
||||
feature := actionGates[action]
|
||||
if action == "claim" && redeploy {
|
||||
feature = "store"
|
||||
}
|
||||
if feature == "" {
|
||||
return nil
|
||||
}
|
||||
s := &State{}
|
||||
err := db.QueryRow(ctx, "select onboarding from users where id = $1", userID).Scan(&s.Onboarding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feature == "new-moon" {
|
||||
// The first moon is always for sale (and the next once every moon is mined out,
|
||||
// see moonFinished), more come with the store.
|
||||
var active int
|
||||
err := db.QueryRow(ctx, `
|
||||
select count(*) from staking_source
|
||||
where user_id = $1
|
||||
and (exists (select 1 from resource_well where source_id = staking_source.id and supply > 0)
|
||||
or exists (
|
||||
select 1 from staking_event
|
||||
join resource_well on resource_well.id = staking_event.well_id
|
||||
where resource_well.source_id = staking_source.id
|
||||
and not exists (select 1 from claim_event where staking_event_id = staking_event.id)))`,
|
||||
userID).Scan(&active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active == 0 {
|
||||
return nil
|
||||
}
|
||||
feature = "store"
|
||||
}
|
||||
if !s.Unlocked(feature) {
|
||||
return gameErr("Not available yet")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminOnboarding resets a player's onboarding (reset) or unlocks everything.
|
||||
func adminOnboarding(ctx context.Context, db *pgxpool.Pool, adminID, userID string, reset bool) error {
|
||||
return inTx(ctx, db, func(tx pgx.Tx) error {
|
||||
if _, err := lockPlayer(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, set := "admin_onboarding_unlock", []string{"all"}
|
||||
if reset {
|
||||
kind, set = "admin_onboarding_reset", []string{}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "update users set onboarding = $2 where id = $1", userID, set); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, userID, kind, map[string]any{"admin": adminID})
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 667 KiB |
|
Before Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 460 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 1016 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 883 KiB |
|
Before Width: | Height: | Size: 852 KiB |
|
Before Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 1008 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 772 KiB |
|
Before Width: | Height: | Size: 955 KiB |
|
Before Width: | Height: | Size: 741 KiB |
|
Before Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 872 KiB |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
// The new-moon section (a teaser for an unknown moon) and the reveal after a purchase.
|
||||
// Templates: "new-moon-section" and "moon-reveal" in app.html; web/static/js/reveal.js
|
||||
// plays the reveal from the data the server sends.
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// NewMoonTeaser is what the player can know before buying: the price and the odds, not
|
||||
// the moon.
|
||||
type NewMoonTeaser struct {
|
||||
Price float64
|
||||
CanAfford bool
|
||||
After float64 // balance after buying
|
||||
Need float64 // credits missing, if it can't afford it
|
||||
Types []string // the moon types a purchase can roll
|
||||
Resources []string // what its wells can hold
|
||||
Wells string // how many wells it can have, e.g. "2–5"
|
||||
}
|
||||
|
||||
func (s *State) newMoonTeaser() *NewMoonTeaser {
|
||||
t := &NewMoonTeaser{
|
||||
Price: s.MoonPrice(),
|
||||
Resources: s.Config.Resources,
|
||||
Wells: s.WellRange(),
|
||||
}
|
||||
t.CanAfford = s.Balance >= t.Price
|
||||
t.After = s.Balance - t.Price
|
||||
t.Need = max(0, -t.After)
|
||||
for _, m := range cfg().Moons {
|
||||
t.Types = append(t.Types, m.Name)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// MoonReveal is the moon just bought, for the reveal card: its verdict (rating.go), what
|
||||
// it's worth now and what's rare about it.
|
||||
type MoonReveal struct {
|
||||
*MoonDetail
|
||||
Rating Rating
|
||||
Headline string // e.g. "Jackpot! A five-well giant"
|
||||
Sub string // the line under it
|
||||
Worth float64 // its wells at current market prices
|
||||
Multiple float64 // Worth / what it cost
|
||||
Traits []string // notable traits and their rarity
|
||||
MaxSupply float64 // the biggest well a moon can have: a full bar
|
||||
FullAt float64 // wells from here count as full
|
||||
Next NextMoon // buying another one right away
|
||||
}
|
||||
|
||||
// NextMoon is the card's "Buy another": the next moon's price and whether the player can
|
||||
// buy it now. Why: "locked" (more moons come with the store, onboarding.go), "credits" or
|
||||
// "".
|
||||
type NextMoon struct {
|
||||
Price float64
|
||||
CanBuy bool
|
||||
Why string
|
||||
WhyText string
|
||||
}
|
||||
|
||||
var countWords = []string{"", "one", "two", "three", "four", "five", "six", "seven", "eight"}
|
||||
|
||||
// moonReveal describes the player's newest moon, already on the page as p.Moon.
|
||||
func (p *Page) moonReveal() *MoonReveal {
|
||||
if p.Moon == nil {
|
||||
return nil
|
||||
}
|
||||
c := cfg()
|
||||
owned := len(p.S.Moons) - 1
|
||||
scale := c.wellScale(owned)
|
||||
wells := make([]wellSupply, len(p.Moon.Wells))
|
||||
for i, w := range p.Moon.Wells {
|
||||
wells[i] = wellSupply{Resource: w.Resource, Supply: w.Supply}
|
||||
}
|
||||
r := &MoonReveal{MoonDetail: p.Moon, Next: NextMoon{Price: p.S.MoonPrice()}}
|
||||
switch {
|
||||
case !p.S.Unlocked("store"):
|
||||
r.Next.Why, r.Next.WhyText = "locked", "Not available yet"
|
||||
case p.S.Balance < r.Next.Price:
|
||||
r.Next.Why, r.Next.WhyText = "credits", shortBy(r.Next.Price-p.S.Balance)
|
||||
}
|
||||
r.Next.CanBuy = r.Next.Why == ""
|
||||
if p.Moon.Rating != nil {
|
||||
r.Rating = *p.Moon.Rating
|
||||
} else {
|
||||
r.Rating = ratingOf(moonRating(wells, scale))
|
||||
}
|
||||
// Bars are out of the biggest possible well; the top tenth of the range counts as full.
|
||||
r.MaxSupply = c.WellSupplyMax * scale
|
||||
r.FullAt = math.Floor((c.WellSupplyMax - (c.WellSupplyMax-c.WellSupplyMin)*0.1) * scale)
|
||||
for _, w := range wells {
|
||||
r.Worth += w.Supply * marketRate(w.Resource, p.S.Now)
|
||||
}
|
||||
r.Worth = math.Round(r.Worth)
|
||||
if price := c.moonPriceFor(owned); price > 0 {
|
||||
r.Multiple = r.Worth / price
|
||||
}
|
||||
|
||||
n := len(wells)
|
||||
count := strconv.Itoa(n)
|
||||
if n < len(countWords) {
|
||||
count = countWords[n]
|
||||
}
|
||||
switch r.Rating.Tier {
|
||||
case "exceptional":
|
||||
r.Headline = "Jackpot! A motherlode"
|
||||
if n >= 5 {
|
||||
r.Headline = "Jackpot! A " + count + "-well giant"
|
||||
}
|
||||
r.Sub = fmt.Sprintf("Richer than %d%% of moons", r.Rating.Pct)
|
||||
case "great":
|
||||
r.Headline = "Rich find"
|
||||
r.Sub = fmt.Sprintf("Top %d%% of moons", r.Rating.Top())
|
||||
case "good":
|
||||
r.Headline = "Solid haul"
|
||||
r.Sub = fmt.Sprintf("Better than %d%% of moons", r.Rating.Pct)
|
||||
case "fair":
|
||||
r.Headline = "Fair claim"
|
||||
r.Sub = "About what a moon usually holds"
|
||||
default:
|
||||
r.Headline = "Slim pickings"
|
||||
r.Sub = "Not much down there. Better luck next time."
|
||||
}
|
||||
|
||||
odds := currentOdds()
|
||||
// More wells than usual, if that's uncommon.
|
||||
avg := 0.0
|
||||
for k, share := range odds.count {
|
||||
avg += float64(k) * share
|
||||
}
|
||||
if share := odds.count[n]; share > 0 && share <= 0.3 && float64(n) > avg {
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("%d wells · 1 in %d moons", n, oneIn(share)))
|
||||
}
|
||||
full, best := 0, wellSupply{}
|
||||
for _, w := range wells {
|
||||
if w.Supply >= r.FullAt {
|
||||
full++
|
||||
}
|
||||
if w.Supply > best.Supply {
|
||||
best = w
|
||||
}
|
||||
}
|
||||
// Supply is rolled below wellSupplyMax, so the fullest a well gets is one under it.
|
||||
word := "Near-full"
|
||||
if best.Supply >= math.Ceil(r.MaxSupply)-1 {
|
||||
word = "Full"
|
||||
}
|
||||
switch {
|
||||
case full == 1:
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("%s well · %s %s/%s", word, best.Resource, formatMoney(best.Supply), formatMoney(r.MaxSupply)))
|
||||
case full > 1:
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("%d near-full wells", full))
|
||||
}
|
||||
// A big well of one of the two priciest resources, if few moons have one that big.
|
||||
prices := slices.SortedFunc(maps.Keys(c.ResourcePrices), func(a, b string) int {
|
||||
return cmp.Compare(c.ResourcePrices[b], c.ResourcePrices[a])
|
||||
})
|
||||
rare, rareShare := wellSupply{}, 1.0
|
||||
for _, w := range wells {
|
||||
if i := slices.Index(prices, w.Resource); i < 0 || i > 1 {
|
||||
continue
|
||||
}
|
||||
if share := odds.richShare(w.Resource, w.Supply/scale); share < rareShare {
|
||||
rare, rareShare = w, share
|
||||
}
|
||||
}
|
||||
if rare.Resource != "" && rareShare <= 0.15 {
|
||||
r.Traits = append(r.Traits, fmt.Sprintf("Rare: %s-rich · 1 in %d moons", rare.Resource, oneIn(rareShare)))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// WellRange is how many wells a new moon can have, e.g. "2–5".
|
||||
func (s *State) WellRange() string {
|
||||
lo, hi := wellCountRange()
|
||||
if lo == hi {
|
||||
return strconv.Itoa(lo)
|
||||
}
|
||||
return strconv.Itoa(lo) + "–" + strconv.Itoa(hi)
|
||||
}
|
||||
|
||||
// wellCountRange is the fewest and most wells a new moon can have.
|
||||
func wellCountRange() (lo, hi int) {
|
||||
for n, w := range cfg().WellCountWeights {
|
||||
if w > 0 {
|
||||
if lo == 0 || n < lo {
|
||||
lo = n
|
||||
}
|
||||
hi = max(hi, n)
|
||||
}
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,48 @@
|
||||
// The Preact client's rule (AGENTS.md, "web/app"): pass signals to JSX as they are and
|
||||
// read `.value` only in logic, computed() and effects. A `.value` read inside JSX makes the
|
||||
// whole component re-render on every change instead of Preact updating one text node or
|
||||
// attribute.
|
||||
//
|
||||
// Flags `.value` inside a JSX expression container in web/app/**/*.jsx (`>{x.value}`,
|
||||
// `attr={x.value}`, a `{x.value …}` child on its own line), up to the first function in
|
||||
// it: event handlers (`onClick={() => x.value++}`) are fine, `{xs.value.map(…)}` isn't.
|
||||
// A guard rail, not a parser: it reads the source line by line, so keep JSX in .jsx files
|
||||
// and Prettier's style (`attr={…}` without spaces; plain JS assignments as `x = {…}`).
|
||||
//
|
||||
// Usage: node scripts/check-signals-jsx.mjs <dir>
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dir = process.argv[2] ?? "web/app";
|
||||
// A container: opened right after a tag's `>`, an attribute's `=`, or at the start of a
|
||||
// line (a child), up to its closing brace; nested `${…}` is allowed inside.
|
||||
const container = /(?:>|=|^\s*)\{((?:[^{}]|\{[^{}]*\})*)\}?/g;
|
||||
const read = /\.value\b(?!\s*=[^=])/;
|
||||
|
||||
function* files(d) {
|
||||
for (const name of readdirSync(d)) {
|
||||
const p = join(d, name);
|
||||
if (name === "vendor") continue;
|
||||
if (statSync(p).isDirectory()) yield* files(p);
|
||||
else if (p.endsWith(".jsx")) yield p;
|
||||
}
|
||||
}
|
||||
|
||||
let bad = 0;
|
||||
for (const file of files(dir)) {
|
||||
readFileSync(file, "utf8")
|
||||
.split("\n")
|
||||
.forEach((line, i) => {
|
||||
if (/^\s*(\/\/|\*)/.test(line)) return;
|
||||
for (const m of line.matchAll(container)) {
|
||||
// What runs during render: the part before any function in it.
|
||||
const body = m[1].split(/=>|\bfunction\b/)[0];
|
||||
if (read.test(body)) {
|
||||
console.error(`${file}:${i + 1}: .value read inside JSX; pass the signal, or read it in a computed: ${line.trim()}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -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)
|
||||
@@ -0,0 +1,714 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const userCookie = "mm_user"
|
||||
|
||||
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
type server struct {
|
||||
db *pgxpool.Pool
|
||||
// dev reloads templates and static files from disk (MM_DEV=1).
|
||||
dev bool
|
||||
templates *template.Template
|
||||
static fs.FS
|
||||
// newPlayers limits how fast one IP can create players.
|
||||
newPlayers *rateLimiter
|
||||
}
|
||||
|
||||
func serve(ctx context.Context) error {
|
||||
db, err := connect(ctx, appDSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := loadOverride(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
go watchOverride(ctx, db)
|
||||
|
||||
s := &server{db: db, dev: os.Getenv("MM_DEV") == "1", newPlayers: newRateLimiter(10)}
|
||||
if s.dev {
|
||||
s.static = os.DirFS("web/static")
|
||||
} else {
|
||||
s.static, _ = fs.Sub(embedded, "web/static")
|
||||
}
|
||||
if s.templates, err = s.parseTemplates(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.static)))
|
||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, s.static, "favicon.ico")
|
||||
})
|
||||
mux.HandleFunc("GET /{$}", s.notBanned(s.handleIndex))
|
||||
mux.HandleFunc("POST /session", s.handleSession)
|
||||
mux.HandleFunc("POST /act/{action}", s.notBanned(s.withUser(s.handleAction)))
|
||||
mux.HandleFunc("GET /utils", s.withUser(s.handleUtils))
|
||||
mux.HandleFunc("POST /utils/{action}", s.withUser(s.handleUtilsAction))
|
||||
mux.HandleFunc("GET /utils/moons", s.handleMoonsAdmin)
|
||||
s.economyRoutes(mux)
|
||||
s.playerRoutes(mux)
|
||||
mux.HandleFunc("GET /utils/liveops", s.handleLiveOps)
|
||||
mux.HandleFunc("POST /utils/liveops/{what}", s.handleLiveOpsAction)
|
||||
mux.HandleFunc("POST /utils/liveops/{what}/{id}/{action}", s.handleLiveOpsAction)
|
||||
mux.HandleFunc("GET /api/moon-settings", s.handleGetMoonSettings)
|
||||
mux.HandleFunc("POST /api/moon-settings", s.handleSaveMoonSettings)
|
||||
mux.HandleFunc("GET /api/leaderboard", s.handleLeaderboard)
|
||||
mux.HandleFunc("GET /api/state", s.handleAPIState)
|
||||
mux.HandleFunc("POST /api/act/{action}", s.handleAPIAction)
|
||||
// The Preact client (web/app, built into web/static/app), next to the htmx one.
|
||||
mux.HandleFunc("GET /app", func(w http.ResponseWriter, r *http.Request) { s.render(w, "spa", nil) })
|
||||
|
||||
var handler http.Handler = mux
|
||||
if ms, _ := strconv.Atoi(os.Getenv("MM_API_DELAY_MS")); ms > 0 && s.dev {
|
||||
handler = delayGame(handler, time.Duration(ms)*time.Millisecond)
|
||||
}
|
||||
addr := ":" + env("PORT", "3000")
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: logRequests(secureHeaders(limitBody(handler))),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(shutdown)
|
||||
}()
|
||||
log.Printf("listening on %s", addr)
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) parseTemplates() (*template.Template, error) {
|
||||
var files fs.FS = embedded
|
||||
if s.dev {
|
||||
files = os.DirFS(".")
|
||||
}
|
||||
return template.New("").Funcs(templateFuncs).ParseFS(files, "web/templates/*.html")
|
||||
}
|
||||
|
||||
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
||||
t := s.templates
|
||||
if s.dev {
|
||||
var err error
|
||||
if t, err = s.parseTemplates(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// delayGame holds every game request (the htmx page's swaps and actions, the JSON API) for
|
||||
// d before answering, to try the clients with a slow connection (MM_API_DELAY_MS, dev only).
|
||||
func delayGame(next http.Handler, d time.Duration) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if strings.HasPrefix(p, "/act/") || strings.HasPrefix(p, "/api/act/") || p == "/api/state" ||
|
||||
(p == "/" && r.Header.Get("HX-Request") == "true") {
|
||||
time.Sleep(d)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.RequestURI(), time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
// Request body limits. The economy editor, which saves whole configs, gets more room.
|
||||
const (
|
||||
maxBodyBytes = 64 << 10
|
||||
maxAdminBodyBytes = 1 << 20
|
||||
)
|
||||
|
||||
// limitBody caps request bodies and parses forms up front, so an oversized or malformed
|
||||
// form fails with 413/400 instead of reaching a handler as an empty form.
|
||||
func limitBody(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(maxBodyBytes)
|
||||
if strings.HasPrefix(r.URL.Path, "/utils/economy/") {
|
||||
limit = maxAdminBodyBytes
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
|
||||
} else {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// secureHeaders sets headers that are safe everywhere. TODO (IDEAS.md): CSP, frame-ancestors.
|
||||
func secureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// rateLimiter is a per-IP token bucket for endpoints that create players.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
perMin float64
|
||||
buckets map[string]*bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(perMin float64) *rateLimiter {
|
||||
return &rateLimiter{perMin: perMin, buckets: map[string]*bucket{}}
|
||||
}
|
||||
|
||||
// allow takes a token for the request's IP, or reports false if it has none left.
|
||||
func (l *rateLimiter) allow(r *http.Request) bool {
|
||||
ip := clientIP(r)
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if len(l.buckets) > 10000 {
|
||||
// Forget idle IPs (a full bucket is the same as no bucket).
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
b, ok := l.buckets[ip]
|
||||
if !ok {
|
||||
b = &bucket{tokens: l.perMin, last: now}
|
||||
l.buckets[ip] = b
|
||||
}
|
||||
b.tokens = min(l.perMin, b.tokens+now.Sub(b.last).Minutes()*l.perMin)
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// clientIP is the connection's address, or with TRUST_PROXY=1 the address the proxy saw
|
||||
// (the last X-Forwarded-For entry; earlier ones are client-supplied), normalised.
|
||||
func clientIP(r *http.Request) string {
|
||||
if os.Getenv("TRUST_PROXY") == "1" {
|
||||
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
|
||||
parts := strings.Split(xff[len(xff)-1], ",")
|
||||
if ip := strings.TrimSpace(parts[len(parts)-1]); ip != "" {
|
||||
return normalIP(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalIP(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// normalIP is an address (with or without a port) as one key per client: IPv4-mapped IPv6
|
||||
// as its IPv4, no zone, and every loopback address (127.0.0.0/8, ::1) as "loopback".
|
||||
// Anything unparsable is kept as it is.
|
||||
func normalIP(s string) string {
|
||||
if host, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = host
|
||||
}
|
||||
ip, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
ip = ip.Unmap().WithZone("")
|
||||
if ip.IsLoopback() {
|
||||
return "loopback"
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func serverError(w http.ResponseWriter, err error) {
|
||||
log.Printf("error: %v", err)
|
||||
http.Error(w, "Something went wrong", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ players
|
||||
|
||||
// currentUser returns the player id from the cookie, if it names an existing player.
|
||||
func (s *server) currentUser(r *http.Request) string {
|
||||
c, err := r.Cookie(userCookie)
|
||||
if err != nil || !uuidPattern.MatchString(c.Value) || !userExists(r.Context(), s.db, c.Value) {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func setUserCookie(w http.ResponseWriter, userID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: userCookie,
|
||||
Value: userID,
|
||||
Path: "/",
|
||||
MaxAge: 10 * 365 * 24 * 3600,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) withUser(h func(w http.ResponseWriter, r *http.Request, userID string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := s.currentUser(r)
|
||||
if userID == "" {
|
||||
// The page reloads and the boot script signs the player in again.
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
http.Error(w, "Not signed in", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(w, r, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func newPlayerName() string {
|
||||
return "Miner-" + randomHex(3)
|
||||
}
|
||||
|
||||
// handleSession signs the browser in. Browsers from before the server-rendered UI keep
|
||||
// their player id in localStorage and send it here; otherwise a new player is created.
|
||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.FormValue("userId")
|
||||
if !uuidPattern.MatchString(userID) || !userExists(r.Context(), s.db, userID) {
|
||||
if !s.newPlayers.allow(r) {
|
||||
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if userID, err = createUser(r.Context(), s.db, newPlayerName()); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
setUserCookie(w, userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ game page
|
||||
|
||||
// handleIndex renders the game. Plain requests get the whole page; htmx requests (links
|
||||
// inside the page) get every region as an out-of-band swap.
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
htmx := r.Header.Get("HX-Request") == "true"
|
||||
userID := s.currentUser(r)
|
||||
if userID == "" {
|
||||
if htmx {
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
return
|
||||
}
|
||||
s.render(w, "boot", nil)
|
||||
return
|
||||
}
|
||||
// Opening a newly revealed tab stops its shine (onboarding.go).
|
||||
if err := markSeen(r.Context(), s.db, userID, r.FormValue("s")); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
state, v, err := loadView(r.Context(), s.db, userID, viewFromForm(r.FormValue))
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(r.Context(), page); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
page.Banner = s.banner(r.Context())
|
||||
if htmx {
|
||||
page.OOB = true
|
||||
s.render(w, "regions", page)
|
||||
return
|
||||
}
|
||||
s.logSession(r.Context(), userID)
|
||||
s.render(w, "page", page)
|
||||
}
|
||||
|
||||
// actionResult is what runAction did, for the response (HTML regions or JSON).
|
||||
type actionResult struct {
|
||||
Action string // the action, "refused" (onboarding gate) or "new-game" (left to the caller)
|
||||
Err error // a GameError: the action was refused or broke a rule
|
||||
Message string
|
||||
View View // the view to show next (the request's, unless the action moves on)
|
||||
BoughtTool string // a tool just bought (the buy modal)
|
||||
Upgraded string // a tool just upgraded (its toast)
|
||||
Finished []string // moons a claim mined out
|
||||
SaleCredits float64 // what a market sale earned (the sell ticket's payout effect)
|
||||
}
|
||||
|
||||
// errUnknownAction is runAction's error for an action that doesn't exist.
|
||||
var errUnknownAction = errors.New("unknown action")
|
||||
|
||||
// runAction runs a game action: its onboarding gate, then its rule in game.go. get reads
|
||||
// the request's parameters, including the current view (s, moon, well, tab), which stays
|
||||
// unless the action moves on. new-game only passes the gate; the caller creates the player.
|
||||
func (s *server) runAction(ctx context.Context, userID, action string, get func(string) string) (actionResult, error) {
|
||||
view := viewFromForm(get)
|
||||
var message, boughtTool, upgraded string
|
||||
var finished []string
|
||||
var saleCredits float64
|
||||
var err error
|
||||
|
||||
// Actions of parts of the game not revealed yet are refused (onboarding.go).
|
||||
if err = actionAllowed(ctx, s.db, userID, action, get("redeploy") == "1"); err != nil && !isGameError(err) {
|
||||
return actionResult{}, err
|
||||
}
|
||||
if err != nil {
|
||||
action = "refused"
|
||||
}
|
||||
switch action {
|
||||
case "refused":
|
||||
case "buy-moon":
|
||||
var moonID string
|
||||
if moonID, err = buyMoon(ctx, s.db, userID); err == nil {
|
||||
message, view = "New moon claimed!", View{Section: "moons", Moon: moonID}
|
||||
}
|
||||
case "sell-moon":
|
||||
var name string
|
||||
var earned float64
|
||||
if name, earned, err = sellMoon(ctx, s.db, userID, get("sell-moon")); err == nil {
|
||||
message, view = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned)), View{Section: "moons"}
|
||||
}
|
||||
case "sell-tool":
|
||||
var name string
|
||||
var earned float64
|
||||
if name, earned, err = sellTool(ctx, s.db, userID, get("tool")); err == nil {
|
||||
message = fmt.Sprintf("Sold %s for %s", name, formatCredits(earned))
|
||||
}
|
||||
case "sell-attachment":
|
||||
item := get("item")
|
||||
var earned float64
|
||||
if earned, err = sellAttachment(ctx, s.db, userID, item); err == nil {
|
||||
message = fmt.Sprintf("Sold %s for %s", item, formatCredits(earned))
|
||||
}
|
||||
case "buy-tool":
|
||||
id, _ := strconv.Atoi(get("id"))
|
||||
if boughtTool, err = buyTool(ctx, s.db, userID, id); err == nil {
|
||||
message = "Tool purchased"
|
||||
}
|
||||
case "upgrade":
|
||||
var tool string
|
||||
if tool, err = upgradeTool(ctx, s.db, userID, get("tool")); err == nil {
|
||||
message, upgraded = "Tool upgraded", tool
|
||||
view.Drill = followTool(view.Drill, get("tool"), tool)
|
||||
}
|
||||
case "stake":
|
||||
if err = stake(ctx, s.db, userID, get("tool"), get("target")); err == nil {
|
||||
message = "Drill deployed"
|
||||
}
|
||||
case "claim":
|
||||
// Claims every finished drill on the given well or moon (or everywhere); claim-wide
|
||||
// claims a moon's drills that mine several of its wells.
|
||||
scope := ClaimScope{
|
||||
MoonID: get("claim-moon"),
|
||||
WellID: get("claim-well"),
|
||||
Redeploy: get("redeploy") == "1",
|
||||
}
|
||||
if id := get("claim-wide"); id != "" {
|
||||
scope.MoonID, scope.Wide = id, true
|
||||
}
|
||||
var res ClaimResult
|
||||
if res, err = claimReady(ctx, s.db, userID, scope); err == nil {
|
||||
claimed, idle := formatMoney(res.Total), res.Claimed-res.Redeployed
|
||||
switch {
|
||||
case res.Claimed == 0:
|
||||
err = gameErr("Nothing is ready to claim")
|
||||
case !scope.Redeploy:
|
||||
message = fmt.Sprintf("Claimed %s from %s", claimed, plural(res.Claimed, "drill"))
|
||||
case idle == 0:
|
||||
message = fmt.Sprintf("Claimed %s and redeployed", claimed)
|
||||
default:
|
||||
// Redeploying skips drained wells.
|
||||
why := "its well is empty"
|
||||
if idle > 1 {
|
||||
why = "their wells are empty"
|
||||
}
|
||||
if res.Redeployed > 0 {
|
||||
claimed += fmt.Sprintf(" and redeployed %d", res.Redeployed)
|
||||
}
|
||||
message = fmt.Sprintf("Claimed %s. %s left idle (%s)", claimed, plural(idle, "drill"), why)
|
||||
}
|
||||
if res.Stopped > 0 {
|
||||
verb := "drill"
|
||||
if res.Stopped > 1 {
|
||||
verb = "drills"
|
||||
}
|
||||
message += fmt.Sprintf(". Well tapped out — %d %s returned idle", res.Stopped, verb)
|
||||
}
|
||||
if len(res.Finished) > 0 {
|
||||
names := make([]string, len(res.Finished))
|
||||
for i, m := range res.Finished {
|
||||
names[i] = m.Name
|
||||
finished = append(finished, m.ID)
|
||||
// Move on to an active moon (resolve picks one, if any is left).
|
||||
if m.ID == view.Moon {
|
||||
view.Moon, view.Well = "", ""
|
||||
}
|
||||
}
|
||||
verb := "is"
|
||||
if len(names) > 1 {
|
||||
verb = "are"
|
||||
}
|
||||
message = fmt.Sprintf("%s %s mined out — moved to Past moons", strings.Join(names, " and "), verb)
|
||||
}
|
||||
}
|
||||
case "redrill":
|
||||
var n int
|
||||
if n, err = redrill(ctx, s.db, userID, get("redrill-well")); err == nil {
|
||||
message = fmt.Sprintf("Redrilled %s", plural(n, "drill"))
|
||||
}
|
||||
case "sell":
|
||||
resource := get("resource")
|
||||
all := get("all") == "1"
|
||||
amount, _ := strconv.ParseFloat(get("amount"), 64)
|
||||
var earned float64
|
||||
if amount, earned, err = sell(ctx, s.db, userID, resource, amount, all); err == nil {
|
||||
message = fmt.Sprintf("Sold %s %s for %s", formatMoney(amount), resource, formatCredits(earned))
|
||||
saleCredits = earned
|
||||
}
|
||||
case "refine":
|
||||
// Starts a batch: recipe (a refined item name), qty, or all=1 for as many as possible.
|
||||
recipe := get("recipe")
|
||||
qty, _ := strconv.Atoi(get("qty"))
|
||||
if qty, err = startRefine(ctx, s.db, userID, recipe, qty, get("all") == "1"); err == nil {
|
||||
message = fmt.Sprintf("Refining %d %s", qty, recipe)
|
||||
}
|
||||
case "collect":
|
||||
// Collects one finished batch (job), or all of them.
|
||||
var done []RefineryJob
|
||||
if done, err = collectRefinery(ctx, s.db, userID, get("job")); err == nil {
|
||||
if len(done) == 1 {
|
||||
message = fmt.Sprintf("Collected %d %s", done[0].Qty, done[0].Item)
|
||||
} else {
|
||||
message = fmt.Sprintf("Collected %d batches", len(done))
|
||||
}
|
||||
}
|
||||
case "buy-slot":
|
||||
var n int
|
||||
if n, err = buyRefinerySlot(ctx, s.db, userID); err == nil {
|
||||
message = fmt.Sprintf("Refinery slot %d online", n)
|
||||
}
|
||||
case "buy-attachment":
|
||||
item := get("item")
|
||||
if err = buyAttachment(ctx, s.db, userID, item); err == nil {
|
||||
message = item + " added to your inventory"
|
||||
}
|
||||
case "equip", "unequip":
|
||||
// Puts an attachment (item, by name) on a tool (to), or takes it off.
|
||||
// A busy tool's idle copy may get it instead; the panel follows that one.
|
||||
item, tool := get("item"), get("to")
|
||||
var used string
|
||||
if action == "equip" {
|
||||
used, err = equipAttachment(ctx, s.db, userID, item, tool)
|
||||
message = item + " equipped"
|
||||
} else {
|
||||
used, err = unequipAttachment(ctx, s.db, userID, item, tool)
|
||||
message = item + " unequipped"
|
||||
}
|
||||
if err == nil {
|
||||
view.Drill = followTool(view.Drill, tool, used)
|
||||
}
|
||||
case "hire":
|
||||
name := get("advisor")
|
||||
if err = hireAdvisor(ctx, s.db, userID, name); err == nil {
|
||||
message = name + " hired"
|
||||
}
|
||||
case "seat-advisor":
|
||||
// Puts a hired advisor (advisor) in an empty seat (seat, from 1).
|
||||
name := get("advisor")
|
||||
seat, _ := strconv.Atoi(get("seat"))
|
||||
if err = seatAdvisor(ctx, s.db, userID, name, seat); err == nil {
|
||||
message = fmt.Sprintf("%s takes seat %d", name, seat)
|
||||
}
|
||||
case "unseat-advisor":
|
||||
name := get("advisor")
|
||||
if err = unseatAdvisor(ctx, s.db, userID, name); err == nil {
|
||||
message = name + " left their seat"
|
||||
}
|
||||
case "swap-advisors":
|
||||
// Exchanges the seats of two hired advisors (advisor, with).
|
||||
name, other := get("advisor"), get("with")
|
||||
if err = swapAdvisors(ctx, s.db, userID, name, other); err == nil {
|
||||
message = fmt.Sprintf("%s and %s swapped seats", name, other)
|
||||
}
|
||||
case "dismiss-advisor":
|
||||
name := get("advisor")
|
||||
if err = dismissAdvisor(ctx, s.db, userID, name); err == nil {
|
||||
message = name + " dismissed"
|
||||
}
|
||||
case "buy-seat":
|
||||
var n int
|
||||
if n, err = buyAdvisorSeat(ctx, s.db, userID); err == nil {
|
||||
message = fmt.Sprintf("Advisor seat %d added", n)
|
||||
}
|
||||
case "new-game":
|
||||
default:
|
||||
return actionResult{}, errUnknownAction
|
||||
}
|
||||
if err != nil && !isGameError(err) {
|
||||
return actionResult{}, err
|
||||
}
|
||||
return actionResult{Action: action, Err: err, Message: message, View: view, BoughtTool: boughtTool,
|
||||
Upgraded: upgraded, Finished: finished, SaleCredits: saleCredits}, nil
|
||||
}
|
||||
|
||||
// toast is the action's toast, for the state after it.
|
||||
func (res actionResult) toast(state *State) *Toast {
|
||||
if res.Err != nil {
|
||||
return &Toast{Message: res.Err.Error(), Error: true, Kind: "error"}
|
||||
}
|
||||
toast := &Toast{Message: res.Message, Kind: res.Action, Finished: strings.Join(res.Finished, ","), Credits: res.SaleCredits}
|
||||
if t := state.tool(res.Upgraded); t != nil {
|
||||
toast.Message = state.upgradedText(*t)
|
||||
}
|
||||
return toast
|
||||
}
|
||||
|
||||
// handleAction runs a game action and re-renders the screen.
|
||||
func (s *server) handleAction(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
ctx := r.Context()
|
||||
res, err := s.runAction(ctx, userID, r.PathValue("action"), r.FormValue)
|
||||
if errors.Is(err, errUnknownAction) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
if res.Action == "new-game" {
|
||||
if !s.newPlayers.allow(r) {
|
||||
http.Error(w, "Too many new players, try again in a minute", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
newID, err := createUser(ctx, s.db, newPlayerName())
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
setUserCookie(w, newID)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
return
|
||||
}
|
||||
|
||||
state, v, err := loadView(ctx, s.db, userID, res.View)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
toast := res.toast(state)
|
||||
page := state.page(v)
|
||||
if err := s.loadRanking(ctx, page); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
page.Banner = s.banner(ctx)
|
||||
page.OOB = true
|
||||
page.Toast = toast
|
||||
if res.BoughtTool != "" {
|
||||
page.Modal = state.buySuggestions(res.BoughtTool)
|
||||
}
|
||||
if res.Action == "buy-moon" && !toast.Error {
|
||||
page.Reveal = page.moonReveal()
|
||||
}
|
||||
s.render(w, "regions", page)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ admin
|
||||
|
||||
// handleUtils is the admin hub; it opens the Players page (players.go).
|
||||
func (s *server) handleUtils(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
http.Redirect(w, r, "/utils/players", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *server) handleUtilsAction(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
if !s.isAdmin(userID) {
|
||||
http.Error(w, "Missing permission", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
switch r.PathValue("action") {
|
||||
case "clear-data":
|
||||
err = clearUserData(r.Context(), s.db, userID)
|
||||
case "load-config":
|
||||
err = syncStoreItems(r.Context(), s.db)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, "<p>Done.</p>")
|
||||
}
|
||||
|
||||
// loadRanking fills in the leaderboard section, the one section read from other players.
|
||||
func (s *server) loadRanking(ctx context.Context, page *Page) (err error) {
|
||||
if page.V.Section == "leaderboard" {
|
||||
page.Ranking, err = loadLeaderboard(ctx, s.db, page.S.UserID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
|
||||
limit, err := strconv.Atoi(r.FormValue("limit"))
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
entries, err := leaderboard(r.Context(), s.db, limit)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +1,2 @@
|
||||
grant usage on schema public to devs;
|
||||
grant usage on all sequences in schema public to devs;
|
||||
grant select, insert, update, delete on all tables in schema public to devs;
|
||||
grant pg_read_server_files to devs;
|
||||
|
||||
insert into resource(name)
|
||||
values ('Sollux'), ('Shadowstone'), ('Azurium'), ('Novafor'), ('Nebulance');
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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;
|
||||